Compare commits

..

20 Commits

Author SHA1 Message Date
Piyush Gupta 1a8e4f562c Merge branch 'main' of https://github.com/formbricks/formbricks into fix/Changing-project-name-doesn't-update-in-the-sidebar-and-project-selector 2025-07-07 10:07:49 +05:30
Dhruwang Jariwala 4fdea3221b feat: Personal links (#6138)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-07-04 14:17:40 +00:00
Jakob Schott fef30c54b2 feat: replace deprecated modals with new one (5824) (#5903)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com>
2025-07-04 11:44:36 +00:00
Johannes 75362eac7a chore: updating contribution docs (#6157) 2025-07-04 04:56:14 -07:00
Dhruwang Jariwala 6e3b224944 chore: sunset card shadow color (#6152) 2025-07-04 10:44:32 +00:00
Aditya ef1be219b4 fix: Show Specific Error for Duplicate Tag Names (#6057)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-07-04 08:47:49 +00:00
Piyush Gupta ba9b01a969 fix: survey list refresh (#6104)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-07-04 08:16:27 +00:00
Harsh Bhat e810e38333 chore: change pricing (#5850)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-07-03 13:40:19 +00:00
victorvhs017 dab8ad00d5 feat: Add Sentry source maps (#6047) 2025-07-03 13:03:59 +00:00
Anshuman Pandey 2c34f43c83 fix: adds build step to the database package for optimizing docker build (#5970)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-07-02 03:42:01 +00:00
Kunal Garg 979fd71a11 feat: reset password in accounts page (#5219)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2025-07-01 15:41:14 +00:00
Harsh Bhat 1be23eebbb docs: Add audit logs, domain split in the license details (#6139) 2025-07-01 04:57:42 -07:00
Dhruwang Jariwala d10cff917d fix: recall parsing for headlines with empty strings (#6131) 2025-07-01 08:16:14 +00:00
suraj 82f7029958 fix:Changing project name doesn't update in the sidebar and project selector 2025-06-30 15:06:31 +05:30
Suraj a56b42e7ce Merge branch 'formbricks:main' into main 2025-06-25 17:43:23 +05:30
Suraj b6223d1b0a Merge branch 'formbricks:main' into main 2025-01-24 19:59:35 +05:30
Dhruwang Jariwala 9a4dd4a60d Merge branch 'main' into main 2024-12-17 09:23:32 +05:30
Suraj 8eabe4aa88 Merge branch 'formbricks:main' into main 2024-12-13 20:16:50 +05:30
Dhruwang Jariwala 43e07b846f Merge branch 'main' into main 2024-12-13 14:55:01 +05:30
suraj ac324e5e9a resolve BUG #4447 2024-12-12 23:20:22 +05:30
208 changed files with 8430 additions and 4890 deletions
+2
View File
@@ -210,6 +210,8 @@ UNKEY_ROOT_KEY=
# The SENTRY_AUTH_TOKEN variable is picked up by the Sentry Build Plugin. # The SENTRY_AUTH_TOKEN variable is picked up by the Sentry Build Plugin.
# It's used automatically by Sentry during the build for authentication when uploading source maps. # It's used automatically by Sentry during the build for authentication when uploading source maps.
# SENTRY_AUTH_TOKEN= # SENTRY_AUTH_TOKEN=
# The SENTRY_ENVIRONMENT is the environment which the error will belong to in the Sentry dashboard
# SENTRY_ENVIRONMENT=
# Configure the minimum role for user management from UI(owner, manager, disabled) # Configure the minimum role for user management from UI(owner, manager, disabled)
# USER_MANAGEMENT_MINIMUM_ROLE="manager" # USER_MANAGEMENT_MINIMUM_ROLE="manager"
@@ -0,0 +1,121 @@
name: 'Upload Sentry Sourcemaps'
description: 'Extract sourcemaps from Docker image and upload to Sentry'
inputs:
docker_image:
description: 'Docker image to extract sourcemaps from'
required: true
release_version:
description: 'Sentry release version (e.g., v1.2.3)'
required: true
sentry_auth_token:
description: 'Sentry authentication token'
required: true
runs:
using: 'composite'
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Validate Sentry auth token
shell: bash
run: |
set -euo pipefail
echo "🔐 Validating Sentry authentication token..."
# Assign token to local variable for secure handling
SENTRY_TOKEN="${{ inputs.sentry_auth_token }}"
# Test the token by making a simple API call to Sentry
response=$(curl -s -w "%{http_code}" -o /tmp/sentry_response.json \
-H "Authorization: Bearer $SENTRY_TOKEN" \
"https://sentry.io/api/0/organizations/formbricks/")
http_code=$(echo "$response" | tail -n1)
if [ "$http_code" != "200" ]; then
echo "❌ Error: Invalid Sentry auth token (HTTP $http_code)"
echo "Please check your SENTRY_AUTH_TOKEN is correct and has the necessary permissions."
if [ -f /tmp/sentry_response.json ]; then
echo "Response body:"
cat /tmp/sentry_response.json
fi
exit 1
fi
echo "✅ Sentry auth token validated successfully"
# Clean up temp file
rm -f /tmp/sentry_response.json
- name: Extract sourcemaps from Docker image
shell: bash
run: |
set -euo pipefail
echo "📦 Extracting sourcemaps from Docker image: ${{ inputs.docker_image }}"
# Create temporary container from the image and capture its ID
echo "Creating temporary container..."
CONTAINER_ID=$(docker create "${{ inputs.docker_image }}")
echo "Container created with ID: $CONTAINER_ID"
# Set up cleanup function to ensure container is removed on script exit
cleanup_container() {
# Capture the current exit code to preserve it
local original_exit_code=$?
echo "🧹 Cleaning up Docker container..."
# Remove the container if it exists (ignore errors if already removed)
if [ -n "$CONTAINER_ID" ]; then
docker rm -f "$CONTAINER_ID" 2>/dev/null || true
echo "Container $CONTAINER_ID removed"
fi
# Exit with the original exit code to preserve script success/failure status
exit $original_exit_code
}
# Register cleanup function to run on script exit (success or failure)
trap cleanup_container EXIT
# Extract .next directory containing sourcemaps
docker cp "$CONTAINER_ID:/home/nextjs/apps/web/.next" ./extracted-next
# Verify sourcemaps exist
if [ ! -d "./extracted-next/static/chunks" ]; then
echo "❌ Error: .next/static/chunks directory not found in Docker image"
echo "Expected structure: /home/nextjs/apps/web/.next/static/chunks/"
exit 1
fi
sourcemap_count=$(find ./extracted-next/static/chunks -name "*.map" | wc -l)
echo "✅ Found $sourcemap_count sourcemap files"
if [ "$sourcemap_count" -eq 0 ]; then
echo "❌ Error: No sourcemap files found. Check that productionBrowserSourceMaps is enabled."
exit 1
fi
- name: Create Sentry release and upload sourcemaps
uses: getsentry/action-release@v3
env:
SENTRY_AUTH_TOKEN: ${{ inputs.sentry_auth_token }}
SENTRY_ORG: formbricks
SENTRY_PROJECT: formbricks-cloud
with:
environment: production
version: ${{ inputs.release_version }}
sourcemaps: './extracted-next/'
- name: Clean up extracted files
shell: bash
if: always()
run: |
set -euo pipefail
# Clean up extracted files
rm -rf ./extracted-next
echo "🧹 Cleaned up extracted files"
+22
View File
@@ -32,3 +32,25 @@ jobs:
with: with:
VERSION: v${{ needs.docker-build.outputs.VERSION }} VERSION: v${{ needs.docker-build.outputs.VERSION }}
ENVIRONMENT: "prod" ENVIRONMENT: "prod"
upload-sentry-sourcemaps:
name: Upload Sentry Sourcemaps
runs-on: ubuntu-latest
permissions:
contents: read
needs:
- docker-build
- deploy-formbricks-cloud
steps:
- name: Checkout
uses: actions/checkout@v4.2.2
with:
fetch-depth: 0
- name: Upload Sentry Sourcemaps
uses: ./.github/actions/upload-sentry-sourcemaps
continue-on-error: true
with:
docker_image: ghcr.io/formbricks/formbricks:v${{ needs.docker-build.outputs.VERSION }}
release_version: v${{ needs.docker-build.outputs.VERSION }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
@@ -0,0 +1,46 @@
name: Upload Sentry Sourcemaps (Manual)
on:
workflow_dispatch:
inputs:
docker_image:
description: "Docker image to extract sourcemaps from"
required: true
type: string
release_version:
description: "Release version (e.g., v1.2.3)"
required: true
type: string
tag_version:
description: "Docker image tag (leave empty to use release_version)"
required: false
type: string
permissions:
contents: read
jobs:
upload-sourcemaps:
name: Upload Sourcemaps to Sentry
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4.2.2
with:
fetch-depth: 0
- name: Set Docker Image
run: |
if [ -n "${{ inputs.tag_version }}" ]; then
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.tag_version }}" >> $GITHUB_ENV
else
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.release_version }}" >> $GITHUB_ENV
fi
- name: Upload Sourcemaps to Sentry
uses: ./.github/actions/upload-sentry-sourcemaps
with:
docker_image: ${{ env.DOCKER_IMAGE }}
release_version: ${{ inputs.release_version }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
+1 -11
View File
@@ -14,17 +14,7 @@ Are you brimming with brilliant ideas? For new features that can elevate Formbri
## 🛠 Crafting Pull Requests ## 🛠 Crafting Pull Requests
Ready to dive into the code and make a real impact? Here's your path: For the time being, we don't have the capacity to properly facilitate community contributions. It's a lot of engineering attention often spent on issues which don't follow our prioritization, so we've decided to only facilitate community code contributions in rare exceptions in the coming months.
1. **Read our Best Practices**: [It takes 5 minutes](https://formbricks.com/docs/developer-docs/contributing/get-started) but will help you save hours 🤓
1. **Fork the Repository:** Fork our repository or use [Gitpod](https://gitpod.io) or use [Github Codespaces](https://github.com/features/codespaces) to get started instantly.
1. **Tweak and Transform:** Work your coding magic and apply your changes.
1. **Pull Request Act:** If you're ready to go, craft a new pull request closely following our PR template 🙏
Would you prefer a chat before you dive into a lot of work? [Github Discussions](https://github.com/formbricks/formbricks/discussions) is your harbor. Share your thoughts, and we'll meet you there with open arms. We're responsive and friendly, promise!
## 🚀 Aspiring Features ## 🚀 Aspiring Features
+1 -1
View File
@@ -192,7 +192,7 @@ Here are a few options:
- Upvote issues with 👍 reaction so we know what the demand for a particular issue is to prioritize it within the roadmap. - Upvote issues with 👍 reaction so we know what the demand for a particular issue is to prioritize it within the roadmap.
Please check out [our contribution guide](https://formbricks.com/docs/developer-docs/contributing/get-started) and our [list of open issues](https://github.com/formbricks/formbricks/issues) for more information. - Note: For the time being, we can only facilitate code contributions as an exception.
## All Thanks To Our Contributors ## All Thanks To Our Contributors
+14 -41
View File
@@ -25,21 +25,9 @@ RUN corepack prepare pnpm@9.15.9 --activate
# Install necessary build tools and compilers # Install necessary build tools and compilers
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3 RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
# BuildKit secret handling without hardcoded fallback values # Copy the secrets handling script
# This approach relies entirely on secrets passed from GitHub Actions COPY apps/web/scripts/docker/read-secrets.sh /tmp/read-secrets.sh
RUN echo '#!/bin/sh' > /tmp/read-secrets.sh && \ RUN chmod +x /tmp/read-secrets.sh
echo 'if [ -f "/run/secrets/database_url" ]; then' >> /tmp/read-secrets.sh && \
echo ' export DATABASE_URL=$(cat /run/secrets/database_url)' >> /tmp/read-secrets.sh && \
echo 'else' >> /tmp/read-secrets.sh && \
echo ' echo "DATABASE_URL secret not found. Build may fail if this is required."' >> /tmp/read-secrets.sh && \
echo 'fi' >> /tmp/read-secrets.sh && \
echo 'if [ -f "/run/secrets/encryption_key" ]; then' >> /tmp/read-secrets.sh && \
echo ' export ENCRYPTION_KEY=$(cat /run/secrets/encryption_key)' >> /tmp/read-secrets.sh && \
echo 'else' >> /tmp/read-secrets.sh && \
echo ' echo "ENCRYPTION_KEY secret not found. Build may fail if this is required."' >> /tmp/read-secrets.sh && \
echo 'fi' >> /tmp/read-secrets.sh && \
echo 'exec "$@"' >> /tmp/read-secrets.sh && \
chmod +x /tmp/read-secrets.sh
# Increase Node.js memory limit as a regular build argument # Increase Node.js memory limit as a regular build argument
ARG NODE_OPTIONS="--max_old_space_size=4096" ARG NODE_OPTIONS="--max_old_space_size=4096"
@@ -62,6 +50,9 @@ RUN touch apps/web/.env
# Install the dependencies # Install the dependencies
RUN pnpm install --ignore-scripts RUN pnpm install --ignore-scripts
# Build the database package first
RUN pnpm build --filter=@formbricks/database
# Build the project using our secret reader script # Build the project using our secret reader script
# This mounts the secrets only during this build step without storing them in layers # This mounts the secrets only during this build step without storing them in layers
RUN --mount=type=secret,id=database_url \ RUN --mount=type=secret,id=database_url \
@@ -106,20 +97,8 @@ RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma 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 RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
COPY --from=installer /app/packages/database/package.json ./packages/database/package.json COPY --from=installer /app/packages/database/dist ./packages/database/dist
RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json RUN chown -R nextjs:nextjs ./packages/database/dist && chmod -R 755 ./packages/database/dist
COPY --from=installer /app/packages/database/migration ./packages/database/migration
RUN chown -R nextjs:nextjs ./packages/database/migration && chmod -R 755 ./packages/database/migration
COPY --from=installer /app/packages/database/src ./packages/database/src
RUN chown -R nextjs:nextjs ./packages/database/src && chmod -R 755 ./packages/database/src
COPY --from=installer /app/packages/database/node_modules ./packages/database/node_modules
RUN chown -R nextjs:nextjs ./packages/database/node_modules && chmod -R 755 ./packages/database/node_modules
COPY --from=installer /app/packages/logger/dist ./packages/database/node_modules/@formbricks/logger/dist
RUN chown -R nextjs:nextjs ./packages/database/node_modules/@formbricks/logger/dist && chmod -R 755 ./packages/database/node_modules/@formbricks/logger/dist
COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client
RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client
@@ -142,12 +121,14 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
COPY --from=installer /app/node_modules/zod ./node_modules/zod COPY --from=installer /app/node_modules/zod ./node_modules/zod
RUN chmod -R 755 ./node_modules/zod RUN chmod -R 755 ./node_modules/zod
RUN npm install --ignore-scripts -g tsx typescript pino-pretty
RUN npm install -g prisma RUN npm install -g prisma
# Create a startup script to handle the conditional logic
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
RUN chown nextjs:nextjs /home/nextjs/start.sh && chmod +x /home/nextjs/start.sh
EXPOSE 3000 EXPOSE 3000
ENV HOSTNAME "0.0.0.0" ENV HOSTNAME="0.0.0.0"
ENV NODE_ENV="production"
USER nextjs USER nextjs
# Prepare volume for uploads # Prepare volume for uploads
@@ -158,12 +139,4 @@ VOLUME /home/nextjs/apps/web/uploads/
RUN mkdir -p /home/nextjs/apps/web/saml-connection RUN mkdir -p /home/nextjs/apps/web/saml-connection
VOLUME /home/nextjs/apps/web/saml-connection VOLUME /home/nextjs/apps/web/saml-connection
CMD if [ "${DOCKER_CRON_ENABLED:-1}" = "1" ]; then \ CMD ["/home/nextjs/start.sh"]
echo "Starting cron jobs..."; \
supercronic -quiet /app/docker/cronjobs & \
else \
echo "Docker cron jobs are disabled via DOCKER_CRON_ENABLED=0"; \
fi; \
(cd packages/database && npm run db:migrate:deploy) && \
(cd packages/database && npm run db:create-saml-database:deploy) && \
exec node apps/web/server.js
@@ -1,5 +1,5 @@
import { ModalWithTabs } from "@/modules/ui/components/modal-with-tabs"; import { cleanup, render, screen } from "@testing-library/react";
import { cleanup, render } from "@testing-library/react"; import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { TActionClass } from "@formbricks/types/action-classes"; import { TActionClass } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
@@ -8,23 +8,40 @@ import { ActionDetailModal } from "./ActionDetailModal";
// Import mocked components // Import mocked components
import { ActionSettingsTab } from "./ActionSettingsTab"; import { ActionSettingsTab } from "./ActionSettingsTab";
// Mock child components // Mock the Dialog components
vi.mock("@/modules/ui/components/modal-with-tabs", () => ({ vi.mock("@/modules/ui/components/dialog", () => ({
ModalWithTabs: vi.fn(({ tabs, icon, label, description, open, setOpen }) => ( Dialog: ({
<div data-testid="modal-with-tabs"> open,
<span data-testid="modal-label">{label}</span> onOpenChange,
<span data-testid="modal-description">{description}</span> children,
<span data-testid="modal-open">{open.toString()}</span> }: {
<button onClick={() => setOpen(false)}>Close</button> open: boolean;
{icon} onOpenChange: (open: boolean) => void;
{tabs.map((tab) => ( children: React.ReactNode;
<div key={tab.title}> }) =>
<h2>{tab.title}</h2> open ? (
{tab.children} <div data-testid="dialog">
</div> {children}
))} <button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
</div> Close
)), </button>
</div>
) : null,
DialogContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-content">{children}</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<p data-testid="dialog-description">{children}</p>
),
DialogBody: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-body">{children}</div>
),
})); }));
vi.mock("./ActionActivityTab", () => ({ vi.mock("./ActionActivityTab", () => ({
@@ -44,6 +61,22 @@ vi.mock("@/app/(app)/environments/[environmentId]/actions/utils", () => ({
}, },
})); }));
// Mock useTranslate
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => {
const translations = {
"common.activity": "Activity",
"common.settings": "Settings",
"common.no_code": "No Code",
"common.action": "Action",
"common.code": "Code",
};
return translations[key] || key;
},
}),
}));
const mockEnvironmentId = "test-env-id"; const mockEnvironmentId = "test-env-id";
const mockSetOpen = vi.fn(); const mockSetOpen = vi.fn();
@@ -89,58 +122,68 @@ describe("ActionDetailModal", () => {
vi.clearAllMocks(); // Clear mocks after each test vi.clearAllMocks(); // Clear mocks after each test
}); });
test("renders ModalWithTabs with correct props", () => { test("renders correctly when open", () => {
render(<ActionDetailModal {...defaultProps} />); render(<ActionDetailModal {...defaultProps} />);
const mockedModalWithTabs = vi.mocked(ModalWithTabs); expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Action");
expect(screen.getByTestId("dialog-description")).toHaveTextContent("This is a test action");
expect(screen.getByTestId("code-icon")).toBeInTheDocument();
expect(screen.getByText("Activity")).toBeInTheDocument();
expect(screen.getByText("Settings")).toBeInTheDocument();
// Only the first tab (Activity) should be active initially
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
});
expect(mockedModalWithTabs).toHaveBeenCalled(); test("does not render when open is false", () => {
const props = mockedModalWithTabs.mock.calls[0][0]; render(<ActionDetailModal {...defaultProps} open={false} />);
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
});
// Check basic props test("switches tabs correctly", async () => {
expect(props.open).toBe(true); const user = userEvent.setup();
expect(props.setOpen).toBe(mockSetOpen); render(<ActionDetailModal {...defaultProps} />);
expect(props.label).toBe(mockActionClass.name);
expect(props.description).toBe(mockActionClass.description);
// Check icon data-testid based on the mock for the default 'code' type // Initially shows activity tab (first tab is active)
expect(props.icon).toBeDefined(); expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
if (!props.icon) { expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
throw new Error("Icon prop is not defined");
}
expect((props.icon as any).props["data-testid"]).toBe("code-icon");
// Check tabs structure // Click settings tab
expect(props.tabs).toHaveLength(2); const settingsTab = screen.getByText("Settings");
expect(props.tabs[0].title).toBe("common.activity"); await user.click(settingsTab);
expect(props.tabs[1].title).toBe("common.settings");
// Check if the correct mocked components are used as children // Now shows settings tab content
// Access the mocked functions directly expect(screen.queryByTestId("action-activity-tab")).not.toBeInTheDocument();
const mockedActionActivityTab = vi.mocked(ActionActivityTab); expect(screen.getByTestId("action-settings-tab")).toBeInTheDocument();
const mockedActionSettingsTab = vi.mocked(ActionSettingsTab);
if (!props.tabs[0].children || !props.tabs[1].children) { // Click activity tab again
throw new Error("Tabs children are not defined"); const activityTab = screen.getByText("Activity");
} await user.click(activityTab);
expect((props.tabs[0].children as any).type).toBe(mockedActionActivityTab); // Back to activity tab content
expect((props.tabs[1].children as any).type).toBe(mockedActionSettingsTab); expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
});
// Check props passed to child components test("resets to first tab when modal is reopened", async () => {
const activityTabProps = (props.tabs[0].children as any).props; const user = userEvent.setup();
expect(activityTabProps.otherEnvActionClasses).toBe(mockOtherEnvActionClasses); const { rerender } = render(<ActionDetailModal {...defaultProps} />);
expect(activityTabProps.otherEnvironment).toBe(mockOtherEnvironment);
expect(activityTabProps.isReadOnly).toBe(false);
expect(activityTabProps.environment).toBe(mockEnvironment);
expect(activityTabProps.actionClass).toBe(mockActionClass);
expect(activityTabProps.environmentId).toBe(mockEnvironmentId);
const settingsTabProps = (props.tabs[1].children as any).props; // Switch to settings tab
expect(settingsTabProps.actionClass).toBe(mockActionClass); const settingsTab = screen.getByText("Settings");
expect(settingsTabProps.actionClasses).toBe(mockActionClasses); await user.click(settingsTab);
expect(settingsTabProps.setOpen).toBe(mockSetOpen); expect(screen.getByTestId("action-settings-tab")).toBeInTheDocument();
expect(settingsTabProps.isReadOnly).toBe(false);
// Close modal
rerender(<ActionDetailModal {...defaultProps} open={false} />);
// Reopen modal
rerender(<ActionDetailModal {...defaultProps} open={true} />);
// Should be back to activity tab (first tab)
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
}); });
test("renders correct icon based on action type", () => { test("renders correct icon based on action type", () => {
@@ -148,33 +191,68 @@ describe("ActionDetailModal", () => {
const noCodeAction: TActionClass = { ...mockActionClass, type: "noCode" } as TActionClass; const noCodeAction: TActionClass = { ...mockActionClass, type: "noCode" } as TActionClass;
render(<ActionDetailModal {...defaultProps} actionClass={noCodeAction} />); render(<ActionDetailModal {...defaultProps} actionClass={noCodeAction} />);
const mockedModalWithTabs = vi.mocked(ModalWithTabs); expect(screen.getByTestId("nocode-icon")).toBeInTheDocument();
const props = mockedModalWithTabs.mock.calls[0][0]; expect(screen.queryByTestId("code-icon")).not.toBeInTheDocument();
});
// Expect the 'nocode-icon' based on the updated mock and action type test("handles action without description", () => {
expect(props.icon).toBeDefined(); const actionWithoutDescription = { ...mockActionClass, description: "" };
render(<ActionDetailModal {...defaultProps} actionClass={actionWithoutDescription} />);
if (!props.icon) { expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Action");
throw new Error("Icon prop is not defined"); expect(screen.getByTestId("dialog-description")).toHaveTextContent("Code action");
} });
expect((props.icon as any).props["data-testid"]).toBe("nocode-icon"); test("passes correct props to ActionActivityTab", () => {
render(<ActionDetailModal {...defaultProps} />);
const mockedActionActivityTab = vi.mocked(ActionActivityTab);
expect(mockedActionActivityTab).toHaveBeenCalledWith(
{
otherEnvActionClasses: mockOtherEnvActionClasses,
otherEnvironment: mockOtherEnvironment,
isReadOnly: false,
environment: mockEnvironment,
actionClass: mockActionClass,
environmentId: mockEnvironmentId,
},
undefined
);
});
test("passes correct props to ActionSettingsTab when tab is active", async () => {
const user = userEvent.setup();
render(<ActionDetailModal {...defaultProps} />);
// ActionSettingsTab should not be called initially since first tab is active
const mockedActionSettingsTab = vi.mocked(ActionSettingsTab);
expect(mockedActionSettingsTab).not.toHaveBeenCalled();
// Click the settings tab to activate ActionSettingsTab
const settingsTab = screen.getByText("Settings");
await user.click(settingsTab);
// Now ActionSettingsTab should be called with correct props
expect(mockedActionSettingsTab).toHaveBeenCalledWith(
{
actionClass: mockActionClass,
actionClasses: mockActionClasses,
setOpen: mockSetOpen,
isReadOnly: false,
},
undefined
);
}); });
test("passes isReadOnly prop correctly", () => { test("passes isReadOnly prop correctly", () => {
render(<ActionDetailModal {...defaultProps} isReadOnly={true} />); render(<ActionDetailModal {...defaultProps} isReadOnly={true} />);
// Access the mocked component directly
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
const props = mockedModalWithTabs.mock.calls[0][0];
if (!props.tabs[0].children || !props.tabs[1].children) { const mockedActionActivityTab = vi.mocked(ActionActivityTab);
throw new Error("Tabs children are not defined"); expect(mockedActionActivityTab).toHaveBeenCalledWith(
} expect.objectContaining({
isReadOnly: true,
const activityTabProps = (props.tabs[0].children as any).props; }),
expect(activityTabProps.isReadOnly).toBe(true); undefined
);
const settingsTabProps = (props.tabs[1].children as any).props;
expect(settingsTabProps.isReadOnly).toBe(true);
}); });
}); });
@@ -59,6 +59,16 @@ export const ActionDetailModal = ({
}, },
]; ];
const typeDescription = () => {
if (actionClass.description) return actionClass.description;
else
return (
(actionClass.type && actionClass.type === "noCode" ? t("common.no_code") : t("common.code")) +
" " +
t("common.action").toLowerCase()
);
};
return ( return (
<> <>
<ModalWithTabs <ModalWithTabs
@@ -67,7 +77,7 @@ export const ActionDetailModal = ({
tabs={tabs} tabs={tabs}
icon={ACTION_TYPE_ICON_LOOKUP[actionClass.type]} icon={ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
label={actionClass.name} label={actionClass.name}
description={actionClass.description || ""} description={typeDescription()}
/> />
</> </>
); );
@@ -210,14 +210,13 @@ export const ActionSettingsTab = ({
)} )}
</div> </div>
<div className="flex justify-between border-t border-slate-200 py-6"> <div className="flex justify-between gap-x-2 border-slate-200 pt-4">
<div> <div className="flex items-center gap-x-2">
{!isReadOnly ? ( {!isReadOnly ? (
<Button <Button
type="button" type="button"
variant="destructive" variant="destructive"
onClick={() => setOpenDeleteDialog(true)} onClick={() => setOpenDeleteDialog(true)}
className="mr-3"
id="deleteActionModalTrigger"> id="deleteActionModalTrigger">
<TrashIcon /> <TrashIcon />
{t("common.delete")} {t("common.delete")}
@@ -22,14 +22,29 @@ vi.mock("@/modules/ui/components/button", () => ({
), ),
})); }));
vi.mock("@/modules/ui/components/modal", () => ({ vi.mock("@/modules/ui/components/dialog", () => ({
Modal: ({ children, open, setOpen, ...props }: any) => Dialog: ({ children, open, onOpenChange }: any) =>
open ? ( open ? (
<div data-testid="modal" {...props}> <div data-testid="dialog" role="dialog">
{children} {children}
<button onClick={() => setOpen(false)}>Close Modal</button> <button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div> </div>
) : null, ) : null,
DialogContent: ({ children, ...props }: any) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
),
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
DialogTitle: ({ children, className }: any) => (
<h2 data-testid="dialog-title" className={className}>
{children}
</h2>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-description">{children}</div>
),
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
})); }));
vi.mock("@tolgee/react", () => ({ vi.mock("@tolgee/react", () => ({
@@ -70,17 +85,21 @@ describe("AddActionModal", () => {
); );
expect(screen.getByRole("button", { name: "common.add_action" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "common.add_action" })).toBeInTheDocument();
expect(screen.getByTestId("plus-icon")).toBeInTheDocument(); expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
}); });
test("opens the modal when the 'Add Action' button is clicked", async () => { test("opens the dialog when the 'Add Action' button is clicked", async () => {
render( render(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} /> <AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
); );
const addButton = screen.getByRole("button", { name: "common.add_action" }); const addButton = screen.getByRole("button", { name: "common.add_action" });
await userEvent.click(addButton); await userEvent.click(addButton);
expect(screen.getByTestId("modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-content")).toBeInTheDocument();
expect(screen.getByTestId("dialog-header")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toBeInTheDocument();
expect(screen.getByTestId("dialog-body")).toBeInTheDocument();
expect(screen.getByTestId("mouse-pointer-icon")).toBeInTheDocument(); expect(screen.getByTestId("mouse-pointer-icon")).toBeInTheDocument();
expect(screen.getByText("environments.actions.track_new_user_action")).toBeInTheDocument(); expect(screen.getByText("environments.actions.track_new_user_action")).toBeInTheDocument();
expect( expect(
@@ -108,35 +127,35 @@ describe("AddActionModal", () => {
expect(props.setActionClasses).toBeInstanceOf(Function); expect(props.setActionClasses).toBeInstanceOf(Function);
}); });
test("closes the modal when the close button (simulated) is clicked", async () => { test("closes the dialog when the close button (simulated) is clicked", async () => {
render( render(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} /> <AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
); );
const addButton = screen.getByRole("button", { name: "common.add_action" }); const addButton = screen.getByRole("button", { name: "common.add_action" });
await userEvent.click(addButton); await userEvent.click(addButton);
expect(screen.getByTestId("modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
// Simulate closing via the mocked Modal's close button // Simulate closing via the mocked Dialog's close button
const closeModalButton = screen.getByText("Close Modal"); const closeDialogButton = screen.getByText("Close Dialog");
await userEvent.click(closeModalButton); await userEvent.click(closeDialogButton);
expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
}); });
test("closes the modal when setOpen is called from CreateNewActionTab", async () => { test("closes the dialog when setOpen is called from CreateNewActionTab", async () => {
render( render(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} /> <AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
); );
const addButton = screen.getByRole("button", { name: "common.add_action" }); const addButton = screen.getByRole("button", { name: "common.add_action" });
await userEvent.click(addButton); await userEvent.click(addButton);
expect(screen.getByTestId("modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
// Simulate closing via the mocked CreateNewActionTab's button // Simulate closing via the mocked CreateNewActionTab's button
const closeFromTabButton = screen.getByText("Close from Tab"); const closeFromTabButton = screen.getByText("Close from Tab");
await userEvent.click(closeFromTabButton); await userEvent.click(closeFromTabButton);
expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
}); });
}); });
@@ -2,7 +2,14 @@
import { CreateNewActionTab } from "@/modules/survey/editor/components/create-new-action-tab"; import { CreateNewActionTab } from "@/modules/survey/editor/components/create-new-action-tab";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Modal } from "@/modules/ui/components/modal"; import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { MousePointerClickIcon, PlusIcon } from "lucide-react"; import { MousePointerClickIcon, PlusIcon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
@@ -26,36 +33,26 @@ export const AddActionModal = ({ environmentId, actionClasses, isReadOnly }: Add
{t("common.add_action")} {t("common.add_action")}
<PlusIcon /> <PlusIcon />
</Button> </Button>
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false} restrictOverflow> <Dialog open={open} onOpenChange={setOpen}>
<div className="flex h-full flex-col rounded-lg"> <DialogContent disableCloseOnOutsideClick>
<div className="rounded-t-lg bg-slate-100"> <DialogHeader>
<div className="flex w-full items-center justify-between p-6"> <MousePointerClickIcon />
<div className="flex items-center space-x-2"> <DialogTitle>{t("environments.actions.track_new_user_action")}</DialogTitle>
<div className="mr-1.5 h-6 w-6 text-slate-500"> <DialogDescription>
<MousePointerClickIcon className="h-5 w-5" /> {t("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")}
</div> </DialogDescription>
<div> </DialogHeader>
<div className="text-xl font-medium text-slate-700"> <DialogBody>
{t("environments.actions.track_new_user_action")} <CreateNewActionTab
</div> actionClasses={newActionClasses}
<div className="text-sm text-slate-500"> environmentId={environmentId}
{t("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")} isReadOnly={isReadOnly}
</div> setActionClasses={setNewActionClasses}
</div> setOpen={setOpen}
</div> />
</div> </DialogBody>
</div> </DialogContent>
</div> </Dialog>
<div className="px-6 py-4">
<CreateNewActionTab
actionClasses={newActionClasses}
environmentId={environmentId}
isReadOnly={isReadOnly}
setActionClasses={setNewActionClasses}
setOpen={setOpen}
/>
</div>
</Modal>
</> </>
); );
}; };
@@ -92,14 +92,24 @@ vi.mock("@/modules/ui/components/additional-integration-settings", () => ({
</div> </div>
), ),
})); }));
vi.mock("@/modules/ui/components/modal", () => ({ vi.mock("@/modules/ui/components/dialog", () => ({
Modal: ({ children, open, setOpen }) => Dialog: ({ children, open, onOpenChange }: any) =>
open ? ( open ? (
<div data-testid="modal"> <div data-testid="dialog" role="dialog">
{children} {children}
<button onClick={() => setOpen(false)}>Close Modal</button> <button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div> </div>
) : null, ) : null,
DialogContent: ({ children, ...props }: any) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
),
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
DialogTitle: ({ children }: any) => <h2 data-testid="dialog-title">{children}</h2>,
DialogDescription: ({ children }: any) => <p data-testid="dialog-description">{children}</p>,
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
DialogFooter: ({ children }: any) => <div data-testid="dialog-footer">{children}</div>,
})); }));
vi.mock("@/modules/ui/components/alert", () => ({ vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children }) => <div data-testid="alert">{children}</div>, Alert: ({ children }) => <div data-testid="alert">{children}</div>,
@@ -10,8 +10,16 @@ import { AdditionalIntegrationSettings } from "@/modules/ui/components/additiona
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert"; import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox"; import { Checkbox } from "@/modules/ui/components/checkbox";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Label } from "@/modules/ui/components/label"; import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -19,11 +27,11 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/modules/ui/components/select"; } from "@/modules/ui/components/select";
import { useTranslate } from "@tolgee/react"; import { TFnType, useTranslate } from "@tolgee/react";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form"; import { Control, Controller, useForm } from "react-hook-form";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { TIntegrationItem } from "@formbricks/types/integration"; import { TIntegrationItem } from "@formbricks/types/integration";
import { import {
@@ -68,6 +76,80 @@ const NoBaseFoundError = () => {
); );
}; };
const renderQuestionSelection = ({
t,
selectedSurvey,
control,
includeVariables,
setIncludeVariables,
includeHiddenFields,
includeMetadata,
setIncludeHiddenFields,
setIncludeMetadata,
includeCreatedAt,
setIncludeCreatedAt,
}: {
t: TFnType;
selectedSurvey: TSurvey;
control: Control<IntegrationModalInputs>;
includeVariables: boolean;
setIncludeVariables: (value: boolean) => void;
includeHiddenFields: boolean;
includeMetadata: boolean;
setIncludeHiddenFields: (value: boolean) => void;
setIncludeMetadata: (value: boolean) => void;
includeCreatedAt: boolean;
setIncludeCreatedAt: (value: boolean) => void;
}) => {
return (
<div className="space-y-4">
<div>
<Label htmlFor="Surveys">{t("common.questions")}</Label>
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
<Controller
key={question.id}
control={control}
name={"questions"}
render={({ field }) => (
<div className="my-1 flex items-center space-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
id={question.id}
value={question.id}
className="bg-white"
checked={field.value?.includes(question.id)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, question.id])
: field.onChange(field.value?.filter((value) => value !== question.id));
}}
/>
<span className="ml-2">{getLocalizedValue(question.headline, "default")}</span>
</label>
</div>
)}
/>
))}
</div>
</div>
</div>
<AdditionalIntegrationSettings
includeVariables={includeVariables}
setIncludeVariables={setIncludeVariables}
includeHiddenFields={includeHiddenFields}
includeMetadata={includeMetadata}
setIncludeHiddenFields={setIncludeHiddenFields}
setIncludeMetadata={setIncludeMetadata}
includeCreatedAt={includeCreatedAt}
setIncludeCreatedAt={setIncludeCreatedAt}
/>
</div>
);
};
export const AddIntegrationModal = ({ export const AddIntegrationModal = ({
open, open,
setOpenWithStates, setOpenWithStates,
@@ -210,182 +292,148 @@ export const AddIntegrationModal = ({
}; };
return ( return (
<Modal open={open} setOpen={handleClose} noPadding> <Dialog open={open} onOpenChange={setOpenWithStates}>
<div className="rounded-t-lg bg-slate-100"> <DialogContent className="overflow-visible md:overflow-visible">
<div className="flex w-full items-center justify-between p-6"> <DialogHeader>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500"> <div className="relative size-8">
<Image className="w-12" src={AirtableLogo} alt="Airtable logo" /> <Image
fill
className="object-contain object-center"
src={AirtableLogo}
alt={t("environments.integrations.airtable.airtable_logo")}
/>
</div> </div>
<div> <div className="space-y-0.5">
<div className="text-xl font-medium text-slate-700"> <DialogTitle>{t("environments.integrations.airtable.link_airtable_table")}</DialogTitle>
{t("environments.integrations.airtable.link_airtable_table")} <DialogDescription>
</div>
<div className="text-sm text-slate-500">
{t("environments.integrations.airtable.sync_responses_with_airtable")} {t("environments.integrations.airtable.sync_responses_with_airtable")}
</div> </DialogDescription>
</div> </div>
</div> </div>
</div> </DialogHeader>
</div> <form className="space-y-4" onSubmit={handleSubmit(submitHandler)}>
<form onSubmit={handleSubmit(submitHandler)}> <DialogBody className="overflow-visible">
<div className="flex rounded-lg p-6"> <div className="flex w-full flex-col gap-y-4">
<div className="flex w-full flex-col gap-y-4 pt-5"> {airtableArray.length ? (
{airtableArray.length ? ( <BaseSelectDropdown
<BaseSelectDropdown
control={control}
isLoading={isLoading}
fetchTable={fetchTable}
airtableArray={airtableArray}
setValue={setValue}
defaultValue={defaultData?.base}
/>
) : (
<NoBaseFoundError />
)}
<div className="flex w-full flex-col">
<Label htmlFor="table">{t("environments.integrations.airtable.table_name")}</Label>
<div className="mt-1 flex">
<Controller
control={control} control={control}
name="table" isLoading={isLoading}
render={({ field }) => ( fetchTable={fetchTable}
<Select airtableArray={airtableArray}
required setValue={setValue}
disabled={!tables.length} defaultValue={defaultData?.base}
onValueChange={(val) => {
field.onChange(val);
}}
defaultValue={defaultData?.table}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
{tables.length ? (
<SelectContent>
{tables.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
</SelectItem>
))}
</SelectContent>
) : null}
</Select>
)}
/> />
</div> ) : (
</div> <NoBaseFoundError />
)}
{surveys.length ? (
<div className="flex w-full flex-col"> <div className="flex w-full flex-col">
<Label htmlFor="survey">{t("common.select_survey")}</Label> <Label htmlFor="table">{t("environments.integrations.airtable.table_name")}</Label>
<div className="mt-1 flex"> <div className="mt-1 flex">
<Controller <Controller
control={control} control={control}
name="survey" name="table"
render={({ field }) => ( render={({ field }) => (
<Select <Select
required required
disabled={!tables.length}
onValueChange={(val) => { onValueChange={(val) => {
field.onChange(val); field.onChange(val);
setValue("questions", []);
}} }}
defaultValue={defaultData?.survey}> defaultValue={defaultData?.table}>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> {tables.length ? (
{surveys.map((item) => ( <SelectContent>
<SelectItem key={item.id} value={item.id}> {tables.map((item) => (
{item.name} <SelectItem key={item.id} value={item.id}>
</SelectItem> {item.name}
))} </SelectItem>
</SelectContent> ))}
</SelectContent>
) : null}
</Select> </Select>
)} )}
/> />
</div> </div>
</div> </div>
) : null}
{!surveys.length ? ( {surveys.length ? (
<p className="m-1 text-xs text-slate-500"> <div className="flex w-full flex-col">
{t("environments.integrations.create_survey_warning")} <Label htmlFor="survey">{t("common.select_survey")}</Label>
</p> <div className="mt-1 flex">
) : null} <Controller
control={control}
{survey && selectedSurvey && ( name="survey"
<div className="space-y-4"> render={({ field }) => (
<div> <Select
<Label htmlFor="Surveys">{t("common.questions")}</Label> required
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200"> onValueChange={(val) => {
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900"> field.onChange(val);
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => ( setValue("questions", []);
<Controller }}
key={question.id} defaultValue={defaultData?.survey}>
control={control} <SelectTrigger>
name={"questions"} <SelectValue />
render={({ field }) => ( </SelectTrigger>
<div className="my-1 flex items-center space-x-2"> <SelectContent>
<label htmlFor={question.id} className="flex cursor-pointer items-center"> {surveys.map((item) => (
<Checkbox <SelectItem key={item.id} value={item.id}>
type="button" {item.name}
id={question.id} </SelectItem>
value={question.id} ))}
className="bg-white" </SelectContent>
checked={field.value?.includes(question.id)} </Select>
onCheckedChange={(checked) => { )}
return checked />
? field.onChange([...field.value, question.id])
: field.onChange(field.value?.filter((value) => value !== question.id));
}}
/>
<span className="ml-2">
{getLocalizedValue(question.headline, "default")}
</span>
</label>
</div>
)}
/>
))}
</div>
</div> </div>
</div> </div>
<AdditionalIntegrationSettings
includeVariables={includeVariables}
setIncludeVariables={setIncludeVariables}
includeHiddenFields={includeHiddenFields}
includeMetadata={includeMetadata}
setIncludeHiddenFields={setIncludeHiddenFields}
setIncludeMetadata={setIncludeMetadata}
includeCreatedAt={includeCreatedAt}
setIncludeCreatedAt={setIncludeCreatedAt}
/>
</div>
)}
<div className="flex justify-end gap-x-2">
{isEditMode ? (
<Button
onClick={async () => {
await handleDelete(defaultData.index);
}}
type="button"
loading={isLoading}
variant="destructive">
{t("common.delete")}
</Button>
) : ( ) : (
<Button type="button" loading={isLoading} variant="ghost" onClick={handleClose}> <p className="m-1 text-xs text-slate-500">
{t("common.cancel")} {t("environments.integrations.create_survey_warning")}
</Button> </p>
)} )}
<Button type="submit">{t("common.save")}</Button> {survey &&
selectedSurvey &&
renderQuestionSelection({
t,
selectedSurvey,
control,
includeVariables,
setIncludeVariables,
includeHiddenFields,
includeMetadata,
setIncludeHiddenFields,
setIncludeMetadata,
includeCreatedAt,
setIncludeCreatedAt,
})}
</div> </div>
</div> </DialogBody>
</div> <DialogFooter>
</form> {isEditMode ? (
</Modal> <Button
onClick={async () => {
await handleDelete(defaultData.index);
}}
type="button"
loading={isLoading}
variant="destructive">
{t("common.delete")}
</Button>
) : (
<Button type="button" loading={isLoading} variant="ghost" onClick={handleClose}>
{t("common.cancel")}
</Button>
)}
<Button type="submit">{t("common.save")}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
); );
}; };
@@ -88,9 +88,24 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({
</div> </div>
), ),
})); }));
vi.mock("@/modules/ui/components/modal", () => ({ vi.mock("@/modules/ui/components/dialog", () => ({
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) => Dialog: ({ children, open, onOpenChange }: any) =>
open ? <div data-testid="modal">{children}</div> : null, open ? (
<div data-testid="dialog" role="dialog">
{children}
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div>
) : null,
DialogContent: ({ children, ...props }: any) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
),
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
DialogTitle: ({ children }: any) => <h2 data-testid="dialog-title">{children}</h2>,
DialogDescription: ({ children }: any) => <p data-testid="dialog-description">{children}</p>,
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
DialogFooter: ({ children }: any) => <div data-testid="dialog-footer">{children}</div>,
})); }));
vi.mock("next/image", () => ({ vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
@@ -304,10 +319,9 @@ describe("AddIntegrationModal", () => {
/> />
); );
expect(screen.getByTestId("modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect( expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Google Sheet");
screen.getByText("Link Google Sheet", { selector: "div.text-xl.font-medium" }) expect(screen.getByTestId("dialog-description")).toHaveTextContent("Sync responses with Google Sheets.");
).toBeInTheDocument();
// Use getByPlaceholderText for the input // Use getByPlaceholderText for the input
expect( expect(
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>") screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
@@ -332,10 +346,9 @@ describe("AddIntegrationModal", () => {
/> />
); );
expect(screen.getByTestId("modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect( expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Google Sheet");
screen.getByText("Link Google Sheet", { selector: "div.text-xl.font-medium" }) expect(screen.getByTestId("dialog-description")).toHaveTextContent("Sync responses with Google Sheets.");
).toBeInTheDocument();
// Use getByPlaceholderText for the input // Use getByPlaceholderText for the input
expect( expect(
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>") screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
@@ -14,10 +14,18 @@ import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings"; import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox"; import { Checkbox } from "@/modules/ui/components/checkbox";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector"; import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
import { Input } from "@/modules/ui/components/input"; import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label"; import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import Image from "next/image"; import Image from "next/image";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -202,31 +210,28 @@ export const AddIntegrationModal = ({
}; };
return ( return (
<Modal open={open} setOpen={setOpenWithStates} noPadding closeOnOutsideClick={true}> <Dialog open={open} onOpenChange={setOpenWithStates}>
<div className="flex h-full flex-col rounded-lg"> <DialogContent>
<div className="rounded-t-lg bg-slate-100"> <DialogHeader>
<div className="flex w-full items-center justify-between p-6"> <div className="flex items-center space-x-2">
<div className="flex items-center space-x-2"> <div className="relative size-8">
<div className="mr-1.5 h-6 w-6 text-slate-500"> <Image
<Image fill
className="w-12" className="object-contain object-center"
src={GoogleSheetLogo} src={GoogleSheetLogo}
alt={t("environments.integrations.google_sheets.google_sheet_logo")} alt={t("environments.integrations.google_sheets.google_sheet_logo")}
/> />
</div> </div>
<div> <div className="space-y-0.5">
<div className="text-xl font-medium text-slate-700"> <DialogTitle>{t("environments.integrations.google_sheets.link_google_sheet")}</DialogTitle>
{t("environments.integrations.google_sheets.link_google_sheet")} <DialogDescription>
</div> {t("environments.integrations.google_sheets.google_sheets_integration_description")}
<div className="text-sm text-slate-500"> </DialogDescription>
{t("environments.integrations.google_sheets.google_sheets_integration_description")}
</div>
</div>
</div> </div>
</div> </div>
</div> </DialogHeader>
<form onSubmit={handleSubmit(linkSheet)}> <form className="space-y-4" onSubmit={handleSubmit(linkSheet)}>
<div className="flex justify-between rounded-lg p-6"> <DialogBody>
<div className="w-full space-y-4"> <div className="w-full space-y-4">
<div> <div>
<div className="mb-4"> <div className="mb-4">
@@ -292,39 +297,37 @@ export const AddIntegrationModal = ({
</div> </div>
)} )}
</div> </div>
</div> </DialogBody>
<div className="flex justify-end border-t border-slate-200 p-6"> <DialogFooter>
<div className="flex space-x-2"> {selectedIntegration ? (
{selectedIntegration ? ( <Button
<Button type="button"
type="button" variant="destructive"
variant="destructive" loading={isDeleting}
loading={isDeleting} onClick={() => {
onClick={() => { deleteLink();
deleteLink(); }}>
}}> {t("common.delete")}
{t("common.delete")}
</Button>
) : (
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
resetForm();
}}>
{t("common.cancel")}
</Button>
)}
<Button type="submit" loading={isLinkingSheet}>
{selectedIntegration
? t("common.update")
: t("environments.integrations.google_sheets.link_google_sheet")}
</Button> </Button>
</div> ) : (
</div> <Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
resetForm();
}}>
{t("common.cancel")}
</Button>
)}
<Button type="submit" loading={isLinkingSheet}>
{selectedIntegration
? t("common.update")
: t("environments.integrations.google_sheets.link_google_sheet")}
</Button>
</DialogFooter>
</form> </form>
</div> </DialogContent>
</Modal> </Dialog>
); );
}; };
@@ -74,13 +74,41 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({
vi.mock("@/modules/ui/components/label", () => ({ vi.mock("@/modules/ui/components/label", () => ({
Label: ({ children }: { children: React.ReactNode }) => <label>{children}</label>, Label: ({ children }: { children: React.ReactNode }) => <label>{children}</label>,
})); }));
vi.mock("@/modules/ui/components/modal", () => ({ vi.mock("@/modules/ui/components/dialog", () => ({
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) => Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div data-testid="modal">{children}</div> : null, open ? <div data-testid="dialog">{children}</div> : null,
DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-content" className={className}>
{children}
</div>
),
DialogHeader: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-header" className={className}>
{children}
</div>
),
DialogDescription: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<p data-testid="dialog-description" className={className}>
{children}
</p>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogBody: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-body" className={className}>
{children}
</div>
),
DialogFooter: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-footer" className={className}>
{children}
</div>
),
})); }));
vi.mock("lucide-react", () => ({ vi.mock("lucide-react", () => ({
PlusIcon: () => <span data-testid="plus-icon">+</span>, PlusIcon: () => <span data-testid="plus-icon">+</span>,
XIcon: () => <span data-testid="x-icon">x</span>, TrashIcon: () => <span data-testid="trash-icon">🗑</span>,
})); }));
vi.mock("next/image", () => ({ vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
@@ -334,7 +362,7 @@ describe("AddIntegrationModal (Notion)", () => {
/> />
); );
expect(screen.getByTestId("modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByText("environments.integrations.notion.link_database")).toBeInTheDocument(); expect(screen.getByText("environments.integrations.notion.link_database")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-a-database")).toBeInTheDocument(); expect(screen.getByTestId("dropdown-select-a-database")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-survey")).toBeInTheDocument(); expect(screen.getByTestId("dropdown-select-survey")).toBeInTheDocument();
@@ -359,7 +387,7 @@ describe("AddIntegrationModal (Notion)", () => {
/> />
); );
expect(screen.getByTestId("modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-a-database")).toHaveValue(databases[0].id); expect(screen.getByTestId("dropdown-select-a-database")).toHaveValue(databases[0].id);
expect(screen.getByTestId("dropdown-select-survey")).toHaveValue(surveys[0].id); expect(screen.getByTestId("dropdown-select-survey")).toHaveValue(surveys[0].id);
expect(screen.getByText("Map Formbricks fields to Notion property")).toBeInTheDocument(); expect(screen.getByText("Map Formbricks fields to Notion property")).toBeInTheDocument();
@@ -381,7 +409,7 @@ describe("AddIntegrationModal (Notion)", () => {
expect(columnDropdowns[1]).toHaveValue("p2"); expect(columnDropdowns[1]).toHaveValue("p2");
expect(screen.getAllByTestId("plus-icon").length).toBeGreaterThan(0); expect(screen.getAllByTestId("plus-icon").length).toBeGreaterThan(0);
expect(screen.getAllByTestId("x-icon").length).toBeGreaterThan(0); expect(screen.getAllByTestId("trash-icon").length).toBeGreaterThan(0);
}); });
expect(screen.getByText("Delete")).toBeInTheDocument(); expect(screen.getByText("Delete")).toBeInTheDocument();
@@ -445,8 +473,8 @@ describe("AddIntegrationModal (Notion)", () => {
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(2); expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(2);
const xButton = screen.getAllByTestId("x-icon")[0]; // Get the first X button const trashButton = screen.getAllByTestId("trash-icon")[0]; // Get the first trash button
await userEvent.click(xButton); await userEvent.click(trashButton);
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(1); expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(1);
}); });
@@ -12,11 +12,19 @@ import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { replaceHeadlineRecall } from "@/lib/utils/recall"; import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { getQuestionTypes } from "@/modules/survey/lib/questions"; import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector"; import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
import { Label } from "@/modules/ui/components/label"; import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { PlusIcon, XIcon } from "lucide-react"; import { PlusIcon, TrashIcon } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -336,9 +344,9 @@ export const AddIntegrationModal = ({
col={mapping[idx].column} col={mapping[idx].column}
ques={mapping[idx].question} ques={mapping[idx].question}
/> />
<div className="flex w-full items-center"> <div className="flex w-full items-center space-x-2">
<div className="flex w-full items-center"> <div className="flex w-full items-center">
<div className="w-[340px] max-w-full"> <div className="max-w-full flex-1">
<DropdownSelector <DropdownSelector
placeholder={t("environments.integrations.notion.select_a_survey_question")} placeholder={t("environments.integrations.notion.select_a_survey_question")}
items={filteredQuestionItems} items={filteredQuestionItems}
@@ -384,7 +392,7 @@ export const AddIntegrationModal = ({
/> />
</div> </div>
<div className="h-px w-4 border-t border-t-slate-300" /> <div className="h-px w-4 border-t border-t-slate-300" />
<div className="w-[340px] max-w-full"> <div className="max-w-full flex-1">
<DropdownSelector <DropdownSelector
placeholder={t("environments.integrations.notion.select_a_field_to_map")} placeholder={t("environments.integrations.notion.select_a_field_to_map")}
items={getFilteredDbItems()} items={getFilteredDbItems()}
@@ -430,53 +438,45 @@ export const AddIntegrationModal = ({
/> />
</div> </div>
</div> </div>
<button <div className="flex space-x-2">
type="button" {mapping.length > 1 && (
className={`rounded-md p-1 hover:bg-slate-300 ${ <Button variant="secondary" size="icon" className="size-10" onClick={deleteRow}>
idx === mapping.length - 1 ? "visible" : "invisible" <TrashIcon />
}`} </Button>
onClick={addRow}> )}
<PlusIcon className="h-5 w-5 font-bold text-slate-500" /> <Button variant="secondary" size="icon" className="size-10" onClick={addRow}>
</button> <PlusIcon />
<button </Button>
type="button" </div>
className={`flex-1 rounded-md p-1 hover:bg-red-100 ${
mapping.length > 1 ? "visible" : "invisible"
}`}
onClick={deleteRow}>
<XIcon className="h-5 w-5 text-red-500" />
</button>
</div> </div>
</div> </div>
); );
}; };
return ( return (
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false} size="lg"> <Dialog open={open} onOpenChange={setOpen}>
<div className="flex h-full flex-col rounded-lg"> <DialogContent>
<div className="rounded-t-lg bg-slate-100"> <DialogHeader>
<div className="flex w-full items-center justify-between p-6"> <div className="mb-4 flex items-start space-x-2">
<div className="flex items-center space-x-2"> <div className="relative size-8">
<div className="mr-1.5 h-6 w-6 text-slate-500"> <Image
<Image fill
className="w-12" className="object-contain object-center"
src={NotionLogo} src={NotionLogo}
alt={t("environments.integrations.notion.notion_logo")} alt={t("environments.integrations.notion.notion_logo")}
/> />
</div> </div>
<div> <div className="space-y-0.5">
<div className="text-xl font-medium text-slate-700"> <DialogTitle>{t("environments.integrations.notion.link_notion_database")}</DialogTitle>
{t("environments.integrations.notion.link_notion_database")} <DialogDescription>
</div> {t("environments.integrations.notion.notion_integration_description")}
<div className="text-sm text-slate-500"> </DialogDescription>
{t("environments.integrations.notion.sync_responses_with_a_notion_database")}
</div>
</div>
</div> </div>
</div> </div>
</div> </DialogHeader>
<form onSubmit={handleSubmit(linkDatabase)} className="w-full">
<div className="flex justify-between rounded-lg p-6"> <form onSubmit={handleSubmit(linkDatabase)} className="contents space-y-4">
<DialogBody>
<div className="w-full space-y-4"> <div className="w-full space-y-4">
<div> <div>
<div className="mb-4"> <div className="mb-4">
@@ -521,7 +521,7 @@ export const AddIntegrationModal = ({
<Label> <Label>
{t("environments.integrations.notion.map_formbricks_fields_to_notion_property")} {t("environments.integrations.notion.map_formbricks_fields_to_notion_property")}
</Label> </Label>
<div className="mt-4 max-h-[20vh] w-full overflow-y-auto"> <div className="mt-1 space-y-2 overflow-y-auto">
{mapping.map((_, idx) => ( {mapping.map((_, idx) => (
<MappingRow idx={idx} key={idx} /> <MappingRow idx={idx} key={idx} />
))} ))}
@@ -530,43 +530,40 @@ export const AddIntegrationModal = ({
)} )}
</div> </div>
</div> </div>
</div> </DialogBody>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2"> <DialogFooter>
{selectedIntegration ? ( {selectedIntegration ? (
<Button
type="button"
variant="destructive"
loading={isDeleting}
onClick={() => {
deleteLink();
}}>
{t("common.delete")}
</Button>
) : (
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
resetForm();
setMapping([]);
}}>
{t("common.cancel")}
</Button>
)}
<Button <Button
type="submit" type="button"
loading={isLinkingDatabase} variant="destructive"
disabled={mapping.filter((m) => m.error).length > 0}> loading={isDeleting}
{selectedIntegration onClick={() => {
? t("common.update") deleteLink();
: t("environments.integrations.notion.link_database")} }}>
{t("common.delete")}
</Button> </Button>
</div> ) : (
</div> <Button
type="button"
variant="secondary"
onClick={() => {
setOpen(false);
resetForm();
setMapping([]);
}}>
{t("common.cancel")}
</Button>
)}
<Button
type="submit"
loading={isLinkingDatabase}
disabled={mapping.filter((m) => m.error).length > 0}>
{selectedIntegration ? t("common.update") : t("environments.integrations.notion.link_database")}
</Button>
</DialogFooter>
</form> </form>
</div> </DialogContent>
</Modal> </Dialog>
); );
}; };
@@ -83,9 +83,24 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({
</div> </div>
), ),
})); }));
vi.mock("@/modules/ui/components/modal", () => ({ vi.mock("@/modules/ui/components/dialog", () => ({
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) => Dialog: ({ children, open, onOpenChange }: any) =>
open ? <div data-testid="modal">{children}</div> : null, open ? (
<div data-testid="dialog" role="dialog">
{children}
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div>
) : null,
DialogContent: ({ children, ...props }: any) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
),
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
DialogTitle: ({ children }: any) => <h2 data-testid="dialog-title">{children}</h2>,
DialogDescription: ({ children }: any) => <p data-testid="dialog-description">{children}</p>,
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
DialogFooter: ({ children }: any) => <div data-testid="dialog-footer">{children}</div>,
})); }));
vi.mock("next/image", () => ({ vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
@@ -121,6 +136,8 @@ vi.mock("@tolgee/react", async () => {
if (key === "common.all_questions") return "All questions"; if (key === "common.all_questions") return "All questions";
if (key === "common.selected_questions") return "Selected questions"; if (key === "common.selected_questions") return "Selected questions";
if (key === "environments.integrations.slack.link_slack_channel") return "Link Slack Channel"; if (key === "environments.integrations.slack.link_slack_channel") return "Link Slack Channel";
if (key === "environments.integrations.slack.slack_integration_description")
return "Send responses directly to Slack.";
if (key === "common.update") return "Update"; if (key === "common.update") return "Update";
if (key === "common.delete") return "Delete"; if (key === "common.delete") return "Delete";
if (key === "common.cancel") return "Cancel"; if (key === "common.cancel") return "Cancel";
@@ -312,10 +329,9 @@ describe("AddChannelMappingModal", () => {
/> />
); );
expect(screen.getByTestId("modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect( expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Slack Channel");
screen.getByText("Link Slack Channel", { selector: "div.text-xl.font-medium" }) expect(screen.getByTestId("dialog-description")).toHaveTextContent("Send responses directly to Slack.");
).toBeInTheDocument();
expect(screen.getByTestId("channel-dropdown")).toBeInTheDocument(); expect(screen.getByTestId("channel-dropdown")).toBeInTheDocument();
expect(screen.getByTestId("survey-dropdown")).toBeInTheDocument(); expect(screen.getByTestId("survey-dropdown")).toBeInTheDocument();
expect(screen.getByText("Cancel")).toBeInTheDocument(); expect(screen.getByText("Cancel")).toBeInTheDocument();
@@ -339,10 +355,9 @@ describe("AddChannelMappingModal", () => {
/> />
); );
expect(screen.getByTestId("modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect( expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Slack Channel");
screen.getByText("Link Slack Channel", { selector: "div.text-xl.font-medium" }) expect(screen.getByTestId("dialog-description")).toHaveTextContent("Send responses directly to Slack.");
).toBeInTheDocument();
expect(screen.getByTestId("channel-dropdown")).toHaveValue(channels[0].id); expect(screen.getByTestId("channel-dropdown")).toHaveValue(channels[0].id);
expect(screen.getByTestId("survey-dropdown")).toHaveValue(surveys[0].id); expect(screen.getByTestId("survey-dropdown")).toHaveValue(surveys[0].id);
expect(screen.getByText("Questions")).toBeInTheDocument(); expect(screen.getByText("Questions")).toBeInTheDocument();
@@ -7,9 +7,17 @@ import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings"; import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox"; import { Checkbox } from "@/modules/ui/components/checkbox";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector"; import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
import { Label } from "@/modules/ui/components/label"; import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { CircleHelpIcon } from "lucide-react"; import { CircleHelpIcon } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
@@ -189,24 +197,28 @@ export const AddChannelMappingModal = ({
); );
return ( return (
<Modal open={open} setOpen={setOpenWithStates} noPadding closeOnOutsideClick={true}> <Dialog open={open} onOpenChange={setOpenWithStates}>
<div className="flex h-full flex-col rounded-lg"> <DialogContent>
<div className="rounded-t-lg bg-slate-100"> <DialogHeader>
<div className="flex w-full items-center justify-between p-6"> <div className="flex items-center space-x-2">
<div className="flex items-center space-x-2"> <div className="relative size-8">
<div className="mr-1.5 h-6 w-6 text-slate-500"> <Image
<Image className="w-12" src={SlackLogo} alt="Slack logo" /> fill
</div> className="object-contain object-center"
<div> src={SlackLogo}
<div className="text-xl font-medium text-slate-700"> alt={t("environments.integrations.slack.slack_logo")}
{t("environments.integrations.slack.link_slack_channel")} />
</div> </div>
</div> <div className="space-y-0.5">
<DialogTitle>{t("environments.integrations.slack.link_slack_channel")}</DialogTitle>
<DialogDescription>
{t("environments.integrations.slack.slack_integration_description")}
</DialogDescription>
</div> </div>
</div> </div>
</div> </DialogHeader>
<form onSubmit={handleSubmit(linkChannel)}> <form className="space-y-4" onSubmit={handleSubmit(linkChannel)}>
<div className="flex justify-between rounded-lg p-6"> <DialogBody>
<div className="w-full space-y-4"> <div className="w-full space-y-4">
<div> <div>
<div className="mb-4"> <div className="mb-4">
@@ -289,31 +301,29 @@ export const AddChannelMappingModal = ({
</div> </div>
)} )}
</div> </div>
</div> </DialogBody>
<div className="flex justify-end border-t border-slate-200 p-6"> <DialogFooter>
<div className="flex space-x-2"> {selectedIntegration ? (
{selectedIntegration ? ( <Button type="button" variant="destructive" loading={isDeleting} onClick={deleteLink}>
<Button type="button" variant="destructive" loading={isDeleting} onClick={deleteLink}> {t("common.delete")}
{t("common.delete")}
</Button>
) : (
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
resetForm();
}}>
{t("common.cancel")}
</Button>
)}
<Button type="submit" loading={isLinkingChannel}>
{selectedIntegration ? t("common.update") : t("environments.integrations.slack.link_channel")}
</Button> </Button>
</div> ) : (
</div> <Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
resetForm();
}}>
{t("common.cancel")}
</Button>
)}
<Button type="submit" loading={isLinkingChannel}>
{selectedIntegration ? t("common.update") : t("environments.integrations.slack.link_channel")}
</Button>
</DialogFooter>
</form> </form>
</div> </DialogContent>
</Modal> </Dialog>
); );
}; };
@@ -4,18 +4,27 @@ import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { PasswordConfirmationModal } from "./password-confirmation-modal"; import { PasswordConfirmationModal } from "./password-confirmation-modal";
// Mock the Modal component // Mock the Dialog component
vi.mock("@/modules/ui/components/modal", () => ({ vi.mock("@/modules/ui/components/dialog", () => ({
Modal: ({ children, open, setOpen, title }: any) => Dialog: ({ children, open, onOpenChange }: any) =>
open ? ( open ? (
<div data-testid="modal"> <div data-testid="dialog" role="dialog">
<div data-testid="modal-title">{title}</div>
{children} {children}
<button data-testid="modal-close" onClick={() => setOpen(false)}> <button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
Close Close
</button> </button>
</div> </div>
) : null, ) : null,
DialogContent: ({ children, ...props }: any) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
),
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
DialogTitle: ({ children }: any) => <h2 data-testid="dialog-title">{children}</h2>,
DialogDescription: ({ children }: any) => <p data-testid="dialog-description">{children}</p>,
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
DialogFooter: ({ children }: any) => <div data-testid="dialog-footer">{children}</div>,
})); }));
// Mock the PasswordInput component // Mock the PasswordInput component
@@ -54,13 +63,13 @@ describe("PasswordConfirmationModal", () => {
test("renders nothing when open is false", () => { test("renders nothing when open is false", () => {
render(<PasswordConfirmationModal {...defaultProps} open={false} />); render(<PasswordConfirmationModal {...defaultProps} open={false} />);
expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
}); });
test("renders modal content when open is true", () => { test("renders dialog content when open is true", () => {
render(<PasswordConfirmationModal {...defaultProps} />); render(<PasswordConfirmationModal {...defaultProps} />);
expect(screen.getByTestId("modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("modal-title")).toBeInTheDocument(); expect(screen.getByTestId("dialog-title")).toBeInTheDocument();
}); });
test("displays old and new email addresses", () => { test("displays old and new email addresses", () => {
@@ -1,8 +1,16 @@
"use client"; "use client";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form"; import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
import { Modal } from "@/modules/ui/components/modal";
import { PasswordInput } from "@/modules/ui/components/password-input"; import { PasswordInput } from "@/modules/ui/components/password-input";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
@@ -54,64 +62,69 @@ export const PasswordConfirmationModal = ({
}; };
return ( return (
<Modal open={open} setOpen={setOpen} title={t("auth.forgot-password.reset.confirm_password")}> <Dialog open={open} onOpenChange={setOpen}>
<FormProvider {...form}> <DialogContent>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <DialogHeader>
<p className="text-muted-foreground text-sm"> <DialogTitle>{t("auth.forgot-password.reset.confirm_password")}</DialogTitle>
{t("auth.email-change.confirm_password_description")} <DialogDescription>{t("auth.email-change.confirm_password_description")}</DialogDescription>
</p> </DialogHeader>
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<DialogBody>
<div className="space-y-4">
<div className="flex flex-col gap-2 text-sm sm:flex-row sm:justify-between sm:gap-4">
<p>
<strong>{t("auth.email-change.old_email")}:</strong>
<br /> {oldEmail.toLowerCase()}
</p>
<p>
<strong>{t("auth.email-change.new_email")}:</strong>
<br /> {newEmail.toLowerCase()}
</p>
</div>
<div className="flex flex-col gap-2 text-sm sm:flex-row sm:justify-between sm:gap-4"> <FormField
<p> control={form.control}
<strong>{t("auth.email-change.old_email")}:</strong> name="password"
<br /> {oldEmail.toLowerCase()} render={({ field, fieldState: { error } }) => (
</p> <FormItem className="w-full">
<p> <FormControl>
<strong>{t("auth.email-change.new_email")}:</strong> <div>
<br /> {newEmail.toLowerCase()} <PasswordInput
</p> id="password"
</div> autoComplete="current-password"
placeholder="*******"
<FormField aria-placeholder="password"
control={form.control} aria-label="password"
name="password" aria-required="true"
render={({ field, fieldState: { error } }) => ( required
<FormItem className="w-full"> className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
<FormControl> value={field.value}
<div> onChange={(password) => field.onChange(password)}
<PasswordInput />
id="password" {error?.message && <FormError className="text-left">{error.message}</FormError>}
autoComplete="current-password" </div>
placeholder="*******" </FormControl>
aria-placeholder="password" </FormItem>
aria-label="password" )}
aria-required="true" />
required </div>
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm" </DialogBody>
value={field.value} <DialogFooter>
onChange={(password) => field.onChange(password)} <Button type="button" variant="secondary" onClick={handleCancel}>
/> {t("common.cancel")}
{error?.message && <FormError className="text-left">{error.message}</FormError>} </Button>
</div> <Button
</FormControl> type="submit"
</FormItem> variant="default"
)} loading={isSubmitting}
/> disabled={isSubmitting || !isDirty || oldEmail.toLowerCase() === newEmail.toLowerCase()}>
{t("common.confirm")}
<div className="mt-4 space-x-2 text-right"> </Button>
<Button type="button" variant="secondary" onClick={handleCancel}> </DialogFooter>
{t("common.cancel")} </form>
</Button> </FormProvider>
<Button </DialogContent>
type="submit" </Dialog>
variant="default"
loading={isSubmitting}
disabled={isSubmitting || !isDirty || oldEmail.toLowerCase() === newEmail.toLowerCase()}>
{t("common.confirm")}
</Button>
</div>
</form>
</FormProvider>
</Modal>
); );
}; };
@@ -26,8 +26,26 @@ vi.mock("@/modules/ui/components/button", () => ({
)), )),
})); }));
vi.mock("@/modules/ui/components/modal", () => ({ vi.mock("@/modules/ui/components/dialog", () => ({
Modal: vi.fn(({ children, open }) => (open ? <div data-testid="modal">{children}</div> : null)), Dialog: vi.fn(({ children, open, onOpenChange }) =>
open ? (
<div data-testid="dialog" role="dialog">
{children}
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div>
) : null
),
DialogContent: vi.fn(({ children, hideCloseButton, width, className }) => (
<div
data-testid="dialog-content"
data-hide-close-button={hideCloseButton}
data-width={width}
className={className}>
{children}
</div>
)),
DialogBody: vi.fn(({ children }) => <div data-testid="dialog-body">{children}</div>),
DialogFooter: vi.fn(({ children }) => <div data-testid="dialog-footer">{children}</div>),
})); }));
const mockResponses = [ const mockResponses = [
@@ -163,12 +181,12 @@ describe("ResponseCardModal", () => {
test("should not render if selectedResponseId is null", () => { test("should not render if selectedResponseId is null", () => {
const { container } = render(<ResponseCardModal {...defaultProps} selectedResponseId={null} />); const { container } = render(<ResponseCardModal {...defaultProps} selectedResponseId={null} />);
expect(container.firstChild).toBeNull(); expect(container.firstChild).toBeNull();
expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
}); });
test("should render the modal when a response is selected", () => { test("should render the dialog when a response is selected", () => {
render(<ResponseCardModal {...defaultProps} />); render(<ResponseCardModal {...defaultProps} />);
expect(screen.getByTestId("modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("single-response-card")).toBeInTheDocument(); expect(screen.getByTestId("single-response-card")).toBeInTheDocument();
}); });
@@ -204,14 +222,6 @@ describe("ResponseCardModal", () => {
expect(nextButton).toBeDisabled(); expect(nextButton).toBeDisabled();
}); });
test("should call setSelectedResponseId with null when close button is clicked", async () => {
render(<ResponseCardModal {...defaultProps} />);
const buttons = screen.getAllByTestId("mock-button");
const closeButton = buttons.find((button) => button.querySelector("svg.lucide-x"));
if (closeButton) await userEvent.click(closeButton);
expect(mockSetSelectedResponseId).toHaveBeenCalledWith(null);
});
test("useEffect should set open to true and currentIndex when selectedResponseId is provided", () => { test("useEffect should set open to true and currentIndex when selectedResponseId is provided", () => {
render(<ResponseCardModal {...defaultProps} selectedResponseId={mockResponses[1].id} />); render(<ResponseCardModal {...defaultProps} selectedResponseId={mockResponses[1].id} />);
expect(mockSetOpen).toHaveBeenCalledWith(true); expect(mockSetOpen).toHaveBeenCalledWith(true);
@@ -229,11 +239,10 @@ describe("ResponseCardModal", () => {
expect(mockSetOpen).toHaveBeenCalledWith(false); expect(mockSetOpen).toHaveBeenCalledWith(false);
}); });
test("should render ChevronLeft, ChevronRight, and XIcon", () => { test("should render ChevronLeft and ChevronRight icons", () => {
render(<ResponseCardModal {...defaultProps} />); render(<ResponseCardModal {...defaultProps} />);
expect(document.querySelector(".lucide-chevron-left")).toBeInTheDocument(); expect(document.querySelector(".lucide-chevron-left")).toBeInTheDocument();
expect(document.querySelector(".lucide-chevron-right")).toBeInTheDocument(); expect(document.querySelector(".lucide-chevron-right")).toBeInTheDocument();
expect(document.querySelector(".lucide-x")).toBeInTheDocument();
}); });
}); });
@@ -1,7 +1,7 @@
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard"; import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Modal } from "@/modules/ui/components/modal"; import { Dialog, DialogBody, DialogContent, DialogFooter } from "@/modules/ui/components/dialog";
import { ChevronLeft, ChevronRight, XIcon } from "lucide-react"; import { ChevronLeft, ChevronRight } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses"; import { TResponse } from "@formbricks/types/responses";
@@ -64,42 +64,20 @@ export const ResponseCardModal = ({
} }
}; };
const handleClose = () => { const handleClose = (open: boolean) => {
setSelectedResponseId(null); setOpen(open);
if (!open) {
setSelectedResponseId(null);
}
}; };
// If no response is selected or currentIndex is null, do not render the modal // If no response is selected or currentIndex is null, do not render the modal
if (selectedResponseId === null || currentIndex === null) return null; if (selectedResponseId === null || currentIndex === null) return null;
return ( return (
<Modal <Dialog open={open} onOpenChange={handleClose}>
hideCloseButton <DialogContent width="wide">
open={open} <DialogBody>
setOpen={setOpen}
size="xxl"
className="max-h-[80vh] overflow-auto"
noPadding>
<div className="h-full rounded-lg">
<div className="relative h-full w-full overflow-auto p-4">
<div className="mb-4 flex items-center justify-end space-x-2">
<Button
onClick={handleBack}
disabled={currentIndex === 0}
variant="ghost"
className="border bg-white p-2">
<ChevronLeft className="h-5 w-5" />
</Button>
<Button
onClick={handleNext}
disabled={currentIndex === responses.length - 1}
variant="ghost"
className="border bg-white p-2">
<ChevronRight className="h-5 w-5" />
</Button>
<Button className="border bg-white p-2" onClick={handleClose} variant="ghost">
<XIcon className="h-5 w-5" />
</Button>
</div>
<SingleResponseCard <SingleResponseCard
survey={survey} survey={survey}
response={responses[currentIndex]} response={responses[currentIndex]}
@@ -113,8 +91,20 @@ export const ResponseCardModal = ({
setSelectedResponseId={setSelectedResponseId} setSelectedResponseId={setSelectedResponseId}
locale={locale} locale={locale}
/> />
</div> </DialogBody>
</div> <DialogFooter>
</Modal> <Button onClick={handleBack} disabled={currentIndex === 0} variant="outline" size="icon">
<ChevronLeft />
</Button>
<Button
onClick={handleNext}
disabled={currentIndex === responses.length - 1}
variant="outline"
size="icon">
<ChevronRight />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
); );
}; };
@@ -1,13 +1,15 @@
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation"; import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage"; import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA"; import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { RESPONSES_PER_PAGE } from "@/lib/constants"; import { IS_FORMBRICKS_CLOUD, RESPONSES_PER_PAGE } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl"; import { getPublicDomain } from "@/lib/getPublicUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service"; import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service"; import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale"; import { findMatchingLocale } from "@/lib/utils/locale";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
@@ -33,6 +35,9 @@ const Page = async (props) => {
const tags = await getTagsByEnvironmentId(params.environmentId); const tags = await getTagsByEnvironmentId(params.environmentId);
const isContactsEnabled = await getIsContactsEnabled();
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
// Get response count for the CTA component // Get response count for the CTA component
const responseCount = await getResponseCountBySurveyId(params.surveyId); const responseCount = await getResponseCountBySurveyId(params.surveyId);
@@ -51,6 +56,9 @@ const Page = async (props) => {
user={user} user={user}
publicDomain={publicDomain} publicDomain={publicDomain}
responseCount={responseCount} responseCount={responseCount}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
/> />
}> }>
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="responses" /> <SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="responses" />
@@ -1,18 +1,23 @@
"use server"; "use server";
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate"; import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { WEBAPP_URL } from "@/lib/constants";
import { putFile } from "@/lib/storage/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service"; import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { convertToCsv } from "@/lib/utils/file-conversion";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper"; import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { generatePersonalLinks } from "@/modules/ee/contacts/lib/contacts";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customization/lib/organization"; import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customization/lib/organization";
import { sendEmbedSurveyPreviewEmail } from "@/modules/email"; import { sendEmbedSurveyPreviewEmail } from "@/modules/email";
import { customAlphabet } from "nanoid"; import { customAlphabet } from "nanoid";
import { z } from "zod"; import { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { ResourceNotFoundError } from "@formbricks/types/errors"; import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
const ZSendEmbedSurveyPreviewEmailAction = z.object({ const ZSendEmbedSurveyPreviewEmailAction = z.object({
surveyId: ZId, surveyId: ZId,
@@ -222,3 +227,89 @@ export const getEmailHtmlAction = authenticatedActionClient
return await getEmailTemplateHtml(parsedInput.surveyId, ctx.user.locale); return await getEmailTemplateHtml(parsedInput.surveyId, ctx.user.locale);
}); });
const ZGeneratePersonalLinksAction = z.object({
surveyId: ZId,
segmentId: ZId,
environmentId: ZId,
expirationDays: z.number().optional(),
});
export const generatePersonalLinksAction = authenticatedActionClient
.schema(ZGeneratePersonalLinksAction)
.action(async ({ ctx, parsedInput }) => {
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
throw new OperationNotAllowedError("Contacts are not enabled for this environment");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite",
},
],
});
// Get contacts and generate personal links
const contactsResult = await generatePersonalLinks(
parsedInput.surveyId,
parsedInput.segmentId,
parsedInput.expirationDays
);
if (!contactsResult || contactsResult.length === 0) {
throw new UnknownError("No contacts found for the selected segment");
}
// Prepare CSV data with the specified headers and order
const csvHeaders = [
"Formbricks Contact ID",
"User ID",
"First Name",
"Last Name",
"Email",
"Personal Link",
];
const csvData = contactsResult
.map((contact) => {
if (!contact) {
return null;
}
const attributes = contact.attributes ?? {};
return {
"Formbricks Contact ID": contact.contactId,
"User ID": attributes.userId ?? "",
"First Name": attributes.firstName ?? "",
"Last Name": attributes.lastName ?? "",
Email: attributes.email ?? "",
"Personal Link": contact.surveyUrl,
};
})
.filter((contact) => contact !== null);
// Convert to CSV using the file conversion utility
const csvContent = await convertToCsv(csvHeaders, csvData);
const fileName = `personal-links-${parsedInput.surveyId}-${Date.now()}.csv`;
// Store file temporarily and return download URL
const fileBuffer = Buffer.from(csvContent);
await putFile(fileName, fileBuffer, "private", parsedInput.environmentId);
const downloadUrl = `${WEBAPP_URL}/storage/${parsedInput.environmentId}/private/${fileName}`;
return {
downloadUrl,
fileName,
count: csvData.length,
};
});
@@ -117,9 +117,9 @@ vi.mock("./shareEmbedModal/EmbedView", () => ({
EmbedView: (props: any) => mockEmbedViewComponent(props), EmbedView: (props: any) => mockEmbedViewComponent(props),
})); }));
const mockPanelInfoViewComponent = vi.fn(); // Mock getSurveyUrl to return a predictable URL
vi.mock("./shareEmbedModal/PanelInfoView", () => ({ vi.mock("@/modules/analysis/utils", () => ({
PanelInfoView: (props: any) => mockPanelInfoViewComponent(props), getSurveyUrl: vi.fn().mockResolvedValue("https://public-domain.com/s/survey1"),
})); }));
let capturedDialogOnOpenChange: ((open: boolean) => void) | undefined; let capturedDialogOnOpenChange: ((open: boolean) => void) | undefined;
@@ -133,8 +133,6 @@ vi.mock("@/modules/ui/components/dialog", async () => {
capturedDialogOnOpenChange = props.onOpenChange; capturedDialogOnOpenChange = props.onOpenChange;
return <actual.Dialog {...props} />; return <actual.Dialog {...props} />;
}, },
// DialogTitle, DialogContent, DialogDescription will be the actual components
// due to ...actual spread and no specific mock for them here.
}; };
}); });
@@ -154,13 +152,15 @@ describe("ShareEmbedSurvey", () => {
modalView: "start" as "start" | "embed" | "panel", modalView: "start" as "start" | "embed" | "panel",
setOpen: mockSetOpen, setOpen: mockSetOpen,
user: mockUser, user: mockUser,
segments: [],
isContactsEnabled: true,
isFormbricksCloud: true,
}; };
beforeEach(() => { beforeEach(() => {
mockEmbedViewComponent.mockImplementation( mockEmbedViewComponent.mockImplementation(
({ handleInitialPageButton, tabs, activeId, survey, email, surveyUrl, publicDomain, locale }) => ( ({ tabs, activeId, survey, email, surveyUrl, publicDomain, locale }) => (
<div> <div>
<button onClick={() => handleInitialPageButton()}>EmbedViewMockContent</button>
<div data-testid="embedview-tabs">{JSON.stringify(tabs)}</div> <div data-testid="embedview-tabs">{JSON.stringify(tabs)}</div>
<div data-testid="embedview-activeid">{activeId}</div> <div data-testid="embedview-activeid">{activeId}</div>
<div data-testid="embedview-survey-id">{survey.id}</div> <div data-testid="embedview-survey-id">{survey.id}</div>
@@ -171,9 +171,6 @@ describe("ShareEmbedSurvey", () => {
</div> </div>
) )
); );
mockPanelInfoViewComponent.mockImplementation(({ handleInitialPageButton }) => (
<button onClick={() => handleInitialPageButton()}>PanelInfoViewMockContent</button>
));
}); });
test("renders initial 'start' view correctly when open and modalView is 'start' for link survey", () => { test("renders initial 'start' view correctly when open and modalView is 'start' for link survey", () => {
@@ -205,43 +202,15 @@ describe("ShareEmbedSurvey", () => {
const embedButton = screen.getByText("environments.surveys.summary.embed_survey"); const embedButton = screen.getByText("environments.surveys.summary.embed_survey");
await userEvent.click(embedButton); await userEvent.click(embedButton);
expect(mockEmbedViewComponent).toHaveBeenCalled(); expect(mockEmbedViewComponent).toHaveBeenCalled();
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument(); expect(screen.getByTestId("embedview-tabs")).toBeInTheDocument();
}); });
test("switches to 'panel' view when 'Send to panel' button is clicked", async () => { test("switches to 'panel' view when 'Send to panel' button is clicked", async () => {
render(<ShareEmbedSurvey {...defaultProps} />); render(<ShareEmbedSurvey {...defaultProps} />);
const panelButton = screen.getByText("environments.surveys.summary.send_to_panel"); const panelButton = screen.getByText("environments.surveys.summary.send_to_panel");
await userEvent.click(panelButton); await userEvent.click(panelButton);
expect(mockPanelInfoViewComponent).toHaveBeenCalled(); // Panel view currently just shows a title, no component is rendered
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument(); expect(screen.getByText("environments.surveys.summary.send_to_panel")).toBeInTheDocument();
});
test("returns to 'start' view when handleInitialPageButton is triggered from EmbedView", async () => {
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="embed" />);
expect(mockEmbedViewComponent).toHaveBeenCalled();
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument();
const embedViewButton = screen.getByText("EmbedViewMockContent");
await userEvent.click(embedViewButton);
// Should go back to start view, not close the modal
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
expect(screen.queryByText("EmbedViewMockContent")).not.toBeInTheDocument();
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("returns to 'start' view when handleInitialPageButton is triggered from PanelInfoView", async () => {
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="panel" />);
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
const panelInfoViewButton = screen.getByText("PanelInfoViewMockContent");
await userEvent.click(panelInfoViewButton);
// Should go back to start view, not close the modal
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
expect(screen.queryByText("PanelInfoViewMockContent")).not.toBeInTheDocument();
expect(mockSetOpen).not.toHaveBeenCalled();
}); });
test("handleOpenChange (when Dialog calls its onOpenChange prop)", () => { test("handleOpenChange (when Dialog calls its onOpenChange prop)", () => {
@@ -267,7 +236,7 @@ describe("ShareEmbedSurvey", () => {
tabs: { id: string; label: string; icon: LucideIcon }[]; tabs: { id: string; label: string; icon: LucideIcon }[];
activeId: string; activeId: string;
}; };
expect(embedViewProps.tabs.length).toBe(3); expect(embedViewProps.tabs.length).toBe(4);
expect(embedViewProps.tabs.find((tab) => tab.id === "app")).toBeUndefined(); expect(embedViewProps.tabs.find((tab) => tab.id === "app")).toBeUndefined();
expect(embedViewProps.tabs[0].id).toBe("link"); expect(embedViewProps.tabs[0].id).toBe("link");
expect(embedViewProps.activeId).toBe("link"); expect(embedViewProps.activeId).toBe("link");
@@ -297,24 +266,21 @@ describe("ShareEmbedSurvey", () => {
test("initial showView is set by modalView prop when open is true", () => { test("initial showView is set by modalView prop when open is true", () => {
render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="embed" />); render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="embed" />);
expect(mockEmbedViewComponent).toHaveBeenCalled(); expect(mockEmbedViewComponent).toHaveBeenCalled();
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument(); expect(screen.getByTestId("embedview-tabs")).toBeInTheDocument();
cleanup(); cleanup();
render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="panel" />); render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="panel" />);
expect(mockPanelInfoViewComponent).toHaveBeenCalled(); // Panel view currently just shows a title
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument(); expect(screen.getByText("environments.surveys.summary.send_to_panel")).toBeInTheDocument();
}); });
test("useEffect sets showView to 'start' when open becomes false", () => { test("useEffect sets showView to 'start' when open becomes false", () => {
const { rerender } = render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="embed" />); const { rerender } = render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="embed" />);
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument(); // Starts in embed expect(screen.getByTestId("embedview-tabs")).toBeInTheDocument(); // Starts in embed
rerender(<ShareEmbedSurvey {...defaultProps} open={false} modalView="embed" />); rerender(<ShareEmbedSurvey {...defaultProps} open={false} modalView="embed" />);
// Dialog mock returns null when open is false, so EmbedViewMockContent is not found // Dialog mock returns null when open is false, so EmbedViewMockContent is not found
expect(screen.queryByText("EmbedViewMockContent")).not.toBeInTheDocument(); expect(screen.queryByTestId("embedview-tabs")).not.toBeInTheDocument();
// To verify showView is 'start', we'd need to inspect internal state or render start view elements
// For now, we trust the useEffect sets showView, and if it were to re-open in 'start' mode, it would show.
// The main check is that the previous view ('embed') is gone.
}); });
test("renders correct label for link tab based on singleUse survey property", () => { test("renders correct label for link tab based on singleUse survey property", () => {
@@ -12,15 +12,16 @@ import {
LinkIcon, LinkIcon,
MailIcon, MailIcon,
SmartphoneIcon, SmartphoneIcon,
UserIcon,
UsersRound, UsersRound,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import { EmbedView } from "./shareEmbedModal/EmbedView"; import { EmbedView } from "./shareEmbedModal/EmbedView";
import { PanelInfoView } from "./shareEmbedModal/PanelInfoView";
interface ShareEmbedSurveyProps { interface ShareEmbedSurveyProps {
survey: TSurvey; survey: TSurvey;
@@ -29,6 +30,9 @@ interface ShareEmbedSurveyProps {
modalView: "start" | "embed" | "panel"; modalView: "start" | "embed" | "panel";
setOpen: React.Dispatch<React.SetStateAction<boolean>>; setOpen: React.Dispatch<React.SetStateAction<boolean>>;
user: TUser; user: TUser;
segments: TSegment[];
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
} }
export const ShareEmbedSurvey = ({ export const ShareEmbedSurvey = ({
@@ -38,6 +42,9 @@ export const ShareEmbedSurvey = ({
modalView, modalView,
setOpen, setOpen,
user, user,
segments,
isContactsEnabled,
isFormbricksCloud,
}: ShareEmbedSurveyProps) => { }: ShareEmbedSurveyProps) => {
const router = useRouter(); const router = useRouter();
const environmentId = survey.environmentId; const environmentId = survey.environmentId;
@@ -52,6 +59,7 @@ export const ShareEmbedSurvey = ({
label: `${isSingleUseLinkSurvey ? t("environments.surveys.summary.single_use_links") : t("environments.surveys.summary.share_the_link")}`, label: `${isSingleUseLinkSurvey ? t("environments.surveys.summary.single_use_links") : t("environments.surveys.summary.share_the_link")}`,
icon: LinkIcon, icon: LinkIcon,
}, },
{ id: "personal-links", label: t("environments.surveys.summary.personal_links"), icon: UserIcon },
{ id: "email", label: t("environments.surveys.summary.embed_in_an_email"), icon: MailIcon }, { id: "email", label: t("environments.surveys.summary.embed_in_an_email"), icon: MailIcon },
{ id: "webpage", label: t("environments.surveys.summary.embed_on_website"), icon: Code2Icon }, { id: "webpage", label: t("environments.surveys.summary.embed_on_website"), icon: Code2Icon },
@@ -60,8 +68,8 @@ export const ShareEmbedSurvey = ({
[t, isSingleUseLinkSurvey, survey.type] [t, isSingleUseLinkSurvey, survey.type]
); );
const [activeId, setActiveId] = useState(survey.type === "link" ? tabs[0].id : tabs[3].id); const [activeId, setActiveId] = useState(survey.type === "link" ? tabs[0].id : tabs[4].id);
const [showView, setShowView] = useState<"start" | "embed" | "panel">("start"); const [showView, setShowView] = useState<"start" | "embed" | "panel" | "personal-links">("start");
const [surveyUrl, setSurveyUrl] = useState(""); const [surveyUrl, setSurveyUrl] = useState("");
useEffect(() => { useEffect(() => {
@@ -80,7 +88,7 @@ export const ShareEmbedSurvey = ({
useEffect(() => { useEffect(() => {
if (survey.type !== "link") { if (survey.type !== "link") {
setActiveId(tabs[3].id); setActiveId(tabs[4].id);
} }
}, [survey.type, tabs]); }, [survey.type, tabs]);
@@ -93,7 +101,7 @@ export const ShareEmbedSurvey = ({
}, [open, modalView]); }, [open, modalView]);
const handleOpenChange = (open: boolean) => { const handleOpenChange = (open: boolean) => {
setActiveId(survey.type === "link" ? tabs[0].id : tabs[3].id); setActiveId(survey.type === "link" ? tabs[0].id : tabs[4].id);
setOpen(open); setOpen(open);
if (!open) { if (!open) {
setShowView("start"); setShowView("start");
@@ -101,10 +109,6 @@ export const ShareEmbedSurvey = ({
router.refresh(); router.refresh();
}; };
const handleInitialPageButton = () => {
setShowView("start");
};
return ( return (
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="w-full bg-white p-0 lg:h-[700px]" width="wide"> <DialogContent className="w-full bg-white p-0 lg:h-[700px]" width="wide">
@@ -166,22 +170,28 @@ export const ShareEmbedSurvey = ({
</div> </div>
</div> </div>
) : showView === "embed" ? ( ) : showView === "embed" ? (
<EmbedView <>
handleInitialPageButton={handleInitialPageButton} <DialogTitle className="sr-only">{t("environments.surveys.summary.embed_survey")}</DialogTitle>
tabs={survey.type === "link" ? tabs : [tabs[3]]} <EmbedView
disableBack={false} tabs={survey.type === "link" ? tabs : [tabs[4]]}
activeId={activeId} activeId={activeId}
environmentId={environmentId} environmentId={environmentId}
setActiveId={setActiveId} setActiveId={setActiveId}
survey={survey} survey={survey}
email={email} email={email}
surveyUrl={surveyUrl} surveyUrl={surveyUrl}
publicDomain={publicDomain} publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl} setSurveyUrl={setSurveyUrl}
locale={user.locale} locale={user.locale}
/> segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={isFormbricksCloud}
/>
</>
) : showView === "panel" ? ( ) : showView === "panel" ? (
<PanelInfoView handleInitialPageButton={handleInitialPageButton} disableBack={false} /> <>
<DialogTitle className="sr-only">{t("environments.surveys.summary.send_to_panel")}</DialogTitle>
</>
) : null} ) : null}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -20,9 +20,22 @@ vi.mock("@/modules/ui/components/button", () => ({
}), }),
})); }));
// Mock Modal // Mock Dialog
vi.mock("@/modules/ui/components/modal", () => ({ vi.mock("@/modules/ui/components/dialog", () => ({
Modal: vi.fn(({ children, open }) => (open ? <div data-testid="modal">{children}</div> : null)), Dialog: vi.fn(({ children, open, onOpenChange }) =>
open ? (
<div data-testid="dialog" role="dialog">
{children}
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div>
) : null
),
DialogContent: vi.fn(({ children, ...props }) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
)),
DialogBody: vi.fn(({ children }) => <div data-testid="dialog-body">{children}</div>),
})); }));
// Mock useTranslate // Mock useTranslate
@@ -120,7 +133,7 @@ describe("ShareSurveyResults", () => {
test("does not render content when modal is closed (open is false)", () => { test("does not render content when modal is closed (open is false)", () => {
render(<ShareSurveyResults {...defaultProps} open={false} />); render(<ShareSurveyResults {...defaultProps} open={false} />);
expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
expect(screen.queryByText("environments.surveys.summary.publish_to_web_warning")).not.toBeInTheDocument(); expect(screen.queryByText("environments.surveys.summary.publish_to_web_warning")).not.toBeInTheDocument();
expect( expect(
screen.queryByText("environments.surveys.summary.survey_results_are_public") screen.queryByText("environments.surveys.summary.survey_results_are_public")
@@ -1,7 +1,7 @@
"use client"; "use client";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Modal } from "@/modules/ui/components/modal"; import { Dialog, DialogBody, DialogContent } from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { AlertCircleIcon, CheckCircle2Icon } from "lucide-react"; import { AlertCircleIcon, CheckCircle2Icon } from "lucide-react";
import { Clipboard } from "lucide-react"; import { Clipboard } from "lucide-react";
@@ -26,70 +26,72 @@ export const ShareSurveyResults = ({
}: ShareEmbedSurveyProps) => { }: ShareEmbedSurveyProps) => {
const { t } = useTranslate(); const { t } = useTranslate();
return ( return (
<Modal open={open} setOpen={setOpen} size="lg"> <Dialog open={open} onOpenChange={setOpen}>
{showPublishModal && surveyUrl ? ( <DialogContent>
<div className="flex flex-col rounded-2xl bg-white px-12 py-6"> <DialogBody>
<div className="flex flex-col items-center gap-y-6 text-center"> {showPublishModal && surveyUrl ? (
<CheckCircle2Icon className="h-20 w-20 text-slate-300" /> <div className="flex flex-col items-center gap-y-6 text-center">
<div> <CheckCircle2Icon className="text-primary h-20 w-20" />
<p className="text-lg font-medium text-slate-600"> <div>
{t("environments.surveys.summary.survey_results_are_public")} <p className="text-primary text-lg font-medium">
</p> {t("environments.surveys.summary.survey_results_are_public")}
<p className="text-balanced mt-2 text-sm text-slate-500"> </p>
{t("environments.surveys.summary.survey_results_are_shared_with_anyone_who_has_the_link")} <p className="text-balanced mt-2 text-sm text-slate-500">
</p> {t("environments.surveys.summary.survey_results_are_shared_with_anyone_who_has_the_link")}
</div> </p>
<div className="flex gap-2"> </div>
<div className="whitespace-nowrap rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-800"> <div className="flex gap-2">
<span>{surveyUrl}</span> <div className="whitespace-nowrap rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-800">
<span>{surveyUrl}</span>
</div>
<Button
variant="secondary"
size="sm"
title="Copy survey link to clipboard"
aria-label="Copy survey link to clipboard"
className="hover:cursor-pointer"
onClick={() => {
navigator.clipboard.writeText(surveyUrl);
toast.success(t("common.link_copied"));
}}>
<Clipboard />
</Button>
</div>
<div className="flex gap-2">
<Button
type="submit"
variant="secondary"
className="text-center"
onClick={() => handleUnpublish()}>
{t("environments.surveys.summary.unpublish_from_web")}
</Button>
<Button className="text-center" asChild>
<Link href={surveyUrl} target="_blank" rel="noopener noreferrer">
{t("environments.surveys.summary.view_site")}
</Link>
</Button>
</div> </div>
<Button
variant="secondary"
size="sm"
title="Copy survey link to clipboard"
aria-label="Copy survey link to clipboard"
className="hover:cursor-pointer"
onClick={() => {
navigator.clipboard.writeText(surveyUrl);
toast.success(t("common.link_copied"));
}}>
<Clipboard />
</Button>
</div> </div>
<div className="flex gap-2"> ) : (
<Button <div className="flex flex-col rounded-2xl bg-white p-8">
type="submit" <div className="flex flex-col items-center gap-y-6 text-center">
variant="secondary" <AlertCircleIcon className="h-20 w-20 text-slate-300" />
className="text-center" <div>
onClick={() => handleUnpublish()}> <p className="text-lg font-medium text-slate-600">
{t("environments.surveys.summary.unpublish_from_web")} {t("environments.surveys.summary.publish_to_web_warning")}
</Button> </p>
<Button className="text-center" asChild> <p className="text-balanced mt-2 text-sm text-slate-500">
<Link href={surveyUrl} target="_blank" rel="noopener noreferrer"> {t("environments.surveys.summary.publish_to_web_warning_description")}
{t("environments.surveys.summary.view_site")} </p>
</Link> </div>
</Button> <Button type="submit" className="h-full text-center" onClick={() => handlePublish()}>
{t("environments.surveys.summary.publish_to_web")}
</Button>
</div>
</div> </div>
</div> )}
</div> </DialogBody>
) : ( </DialogContent>
<div className="flex flex-col rounded-2xl bg-white p-8"> </Dialog>
<div className="flex flex-col items-center gap-y-6 text-center">
<AlertCircleIcon className="h-20 w-20 text-slate-300" />
<div>
<p className="text-lg font-medium text-slate-600">
{t("environments.surveys.summary.publish_to_web_warning")}
</p>
<p className="text-balanced mt-2 text-sm text-slate-500">
{t("environments.surveys.summary.publish_to_web_warning_description")}
</p>
</div>
<Button type="submit" className="h-full text-center" onClick={() => handlePublish()}>
{t("environments.surveys.summary.publish_to_web")}
</Button>
</div>
</div>
)}
</Modal>
); );
}; };
@@ -15,6 +15,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
@@ -25,6 +26,9 @@ interface SurveyAnalysisCTAProps {
user: TUser; user: TUser;
publicDomain: string; publicDomain: string;
responseCount: number; responseCount: number;
segments: TSegment[];
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
} }
interface ModalState { interface ModalState {
@@ -41,6 +45,9 @@ export const SurveyAnalysisCTA = ({
user, user,
publicDomain, publicDomain,
responseCount, responseCount,
segments,
isContactsEnabled,
isFormbricksCloud,
}: SurveyAnalysisCTAProps) => { }: SurveyAnalysisCTAProps) => {
const { t } = useTranslate(); const { t } = useTranslate();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -175,6 +182,9 @@ export const SurveyAnalysisCTA = ({
setOpen={setOpen} setOpen={setOpen}
user={user} user={user}
modalView={modalView} modalView={modalView}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={isFormbricksCloud}
/> />
))} ))}
<SuccessMessage environment={environment} survey={survey} /> <SuccessMessage environment={environment} survey={survey} />
@@ -29,6 +29,22 @@ vi.mock("./WebsiteTab", () => ({
), ),
})); }));
vi.mock("./personal-links-tab", () => ({
PersonalLinksTab: (props: { segments: any[]; surveyId: string; environmentId: string }) => (
<div data-testid="personal-links-tab">
PersonalLinksTab Content for {props.surveyId} in {props.environmentId}
</div>
),
}));
vi.mock("@/modules/ui/components/upgrade-prompt", () => ({
UpgradePrompt: (props: { title: string; description: string; buttons: any[] }) => (
<div data-testid="upgrade-prompt">
{props.title} - {props.description}
</div>
),
}));
// Mock @tolgee/react // Mock @tolgee/react
vi.mock("@tolgee/react", () => ({ vi.mock("@tolgee/react", () => ({
useTranslate: () => ({ useTranslate: () => ({
@@ -43,6 +59,21 @@ vi.mock("lucide-react", () => ({
LinkIcon: () => <div data-testid="link-icon">LinkIcon</div>, LinkIcon: () => <div data-testid="link-icon">LinkIcon</div>,
GlobeIcon: () => <div data-testid="globe-icon">GlobeIcon</div>, GlobeIcon: () => <div data-testid="globe-icon">GlobeIcon</div>,
SmartphoneIcon: () => <div data-testid="smartphone-icon">SmartphoneIcon</div>, SmartphoneIcon: () => <div data-testid="smartphone-icon">SmartphoneIcon</div>,
AlertCircle: ({ className }: { className?: string }) => (
<div className={className} data-testid="alert-circle">
AlertCircle
</div>
),
AlertTriangle: ({ className }: { className?: string }) => (
<div className={className} data-testid="alert-triangle">
AlertTriangle
</div>
),
Info: ({ className }: { className?: string }) => (
<div className={className} data-testid="info">
Info
</div>
),
})); }));
const mockTabs = [ const mockTabs = [
@@ -56,7 +87,6 @@ const mockSurveyLink = { id: "survey1", type: "link" };
const mockSurveyWeb = { id: "survey2", type: "web" }; const mockSurveyWeb = { id: "survey2", type: "web" };
const defaultProps = { const defaultProps = {
handleInitialPageButton: vi.fn(),
tabs: mockTabs, tabs: mockTabs,
activeId: "email", activeId: "email",
setActiveId: vi.fn(), setActiveId: vi.fn(),
@@ -67,7 +97,9 @@ const defaultProps = {
publicDomain: "http://example.com", publicDomain: "http://example.com",
setSurveyUrl: vi.fn(), setSurveyUrl: vi.fn(),
locale: "en" as any, locale: "en" as any,
disableBack: false, segments: [],
isContactsEnabled: true,
isFormbricksCloud: false,
}; };
describe("EmbedView", () => { describe("EmbedView", () => {
@@ -76,11 +108,6 @@ describe("EmbedView", () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
test("does not render back button when disableBack is true", () => {
render(<EmbedView {...defaultProps} disableBack={true} />);
expect(screen.queryByRole("button", { name: "common.back" })).not.toBeInTheDocument();
});
test("does not render desktop tabs for non-link survey type", () => { test("does not render desktop tabs for non-link survey type", () => {
render(<EmbedView {...defaultProps} survey={mockSurveyWeb} />); render(<EmbedView {...defaultProps} survey={mockSurveyWeb} />);
// Desktop tabs container should not be present or not have lg:flex if it's a common parent // Desktop tabs container should not be present or not have lg:flex if it's a common parent
@@ -2,33 +2,32 @@
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react"; import { TSegment } from "@formbricks/types/segment";
import { ArrowLeftIcon } from "lucide-react";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { AppTab } from "./AppTab"; import { AppTab } from "./AppTab";
import { EmailTab } from "./EmailTab"; import { EmailTab } from "./EmailTab";
import { LinkTab } from "./LinkTab"; import { LinkTab } from "./LinkTab";
import { WebsiteTab } from "./WebsiteTab"; import { WebsiteTab } from "./WebsiteTab";
import { PersonalLinksTab } from "./personal-links-tab";
interface EmbedViewProps { interface EmbedViewProps {
handleInitialPageButton: () => void;
tabs: Array<{ id: string; label: string; icon: any }>; tabs: Array<{ id: string; label: string; icon: any }>;
activeId: string; activeId: string;
setActiveId: React.Dispatch<React.SetStateAction<string>>; setActiveId: React.Dispatch<React.SetStateAction<string>>;
environmentId: string; environmentId: string;
disableBack: boolean;
survey: any; survey: any;
email: string; email: string;
surveyUrl: string; surveyUrl: string;
publicDomain: string; publicDomain: string;
setSurveyUrl: React.Dispatch<React.SetStateAction<string>>; setSurveyUrl: React.Dispatch<React.SetStateAction<string>>;
locale: TUserLocale; locale: TUserLocale;
segments: TSegment[];
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
} }
export const EmbedView = ({ export const EmbedView = ({
handleInitialPageButton,
tabs, tabs,
disableBack,
activeId, activeId,
setActiveId, setActiveId,
environmentId, environmentId,
@@ -38,18 +37,45 @@ export const EmbedView = ({
publicDomain, publicDomain,
setSurveyUrl, setSurveyUrl,
locale, locale,
segments,
isContactsEnabled,
isFormbricksCloud,
}: EmbedViewProps) => { }: EmbedViewProps) => {
const { t } = useTranslate(); const renderActiveTab = () => {
switch (activeId) {
case "email":
return <EmailTab surveyId={survey.id} email={email} />;
case "webpage":
return <WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />;
case "link":
return (
<LinkTab
survey={survey}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={locale}
/>
);
case "app":
return <AppTab />;
case "personal-links":
return (
<PersonalLinksTab
segments={segments}
surveyId={survey.id}
environmentId={environmentId}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={isFormbricksCloud}
/>
);
default:
return null;
}
};
return ( return (
<div className="h-full overflow-hidden"> <div className="h-full overflow-hidden">
{!disableBack && (
<div className="border-b border-slate-200 py-2 pl-2">
<Button variant="ghost" className="focus:ring-0" onClick={handleInitialPageButton}>
<ArrowLeftIcon />
{t("common.back")}
</Button>
</div>
)}
<div className="grid h-full grid-cols-4"> <div className="grid h-full grid-cols-4">
{survey.type === "link" && ( {survey.type === "link" && (
<div className={cn("col-span-1 hidden flex-col gap-3 border-r border-slate-200 p-4 lg:flex")}> <div className={cn("col-span-1 hidden flex-col gap-3 border-r border-slate-200 p-4 lg:flex")}>
@@ -75,21 +101,7 @@ export const EmbedView = ({
)} )}
<div <div
className={`col-span-4 h-full overflow-y-auto bg-slate-50 px-4 py-6 ${survey.type === "link" ? "lg:col-span-3" : ""} lg:p-6`}> className={`col-span-4 h-full overflow-y-auto bg-slate-50 px-4 py-6 ${survey.type === "link" ? "lg:col-span-3" : ""} lg:p-6`}>
{activeId === "email" ? ( {renderActiveTab()}
<EmailTab surveyId={survey.id} email={email} />
) : activeId === "webpage" ? (
<WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />
) : activeId === "link" ? (
<LinkTab
survey={survey}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={locale}
/>
) : activeId === "app" ? (
<AppTab />
) : null}
<div className="mt-2 rounded-md p-3 text-center lg:hidden"> <div className="mt-2 rounded-md p-3 text-center lg:hidden">
{tabs.slice(0, 2).map((tab) => ( {tabs.slice(0, 2).map((tab) => (
<Button <Button
@@ -1,108 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { PanelInfoView } from "./PanelInfoView";
// Mock next/image
vi.mock("next/image", () => ({
default: ({ src, alt, className }: { src: any; alt: string; className?: string }) => (
// eslint-disable-next-line @next/next/no-img-element
<img src={src.src} alt={alt} className={className} />
),
}));
// Mock next/link
vi.mock("next/link", () => ({
default: ({ children, href, target }: { children: React.ReactNode; href: string; target?: string }) => (
<a href={href} target={target}>
{children}
</a>
),
}));
// Mock Button component
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, variant, asChild }: any) => {
if (asChild) {
return <div onClick={onClick}>{children}</div>; // NOSONAR
}
return (
<button onClick={onClick} data-variant={variant}>
{children}
</button>
);
},
}));
// Mock lucide-react
vi.mock("lucide-react", () => ({
ArrowLeftIcon: vi.fn(() => <div data-testid="arrow-left-icon">ArrowLeftIcon</div>),
}));
const mockHandleInitialPageButton = vi.fn();
describe("PanelInfoView", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders correctly with back button and all sections", async () => {
render(<PanelInfoView disableBack={false} handleInitialPageButton={mockHandleInitialPageButton} />);
// Check for back button
const backButton = screen.getByText("common.back");
expect(backButton).toBeInTheDocument();
expect(screen.getByTestId("arrow-left-icon")).toBeInTheDocument();
// Check images
expect(screen.getAllByAltText("Prolific panel selection UI")[0]).toBeInTheDocument();
expect(screen.getAllByAltText("Prolific panel selection UI")[1]).toBeInTheDocument();
// Check text content (Tolgee keys)
expect(screen.getByText("environments.surveys.summary.what_is_a_panel")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.what_is_a_panel_answer")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.when_do_i_need_it")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.when_do_i_need_it_answer")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.what_is_prolific")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.what_is_prolific_answer")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_1")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_1_description")
).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_2")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_2_description")
).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_3")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_3_description")
).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_4")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_4_description")
).toBeInTheDocument();
// Check "Learn more" link
const learnMoreLink = screen.getByRole("link", { name: "common.learn_more" });
expect(learnMoreLink).toBeInTheDocument();
expect(learnMoreLink).toHaveAttribute(
"href",
"https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/market-research-panel"
);
expect(learnMoreLink).toHaveAttribute("target", "_blank");
// Click back button
await userEvent.click(backButton);
expect(mockHandleInitialPageButton).toHaveBeenCalledTimes(1);
});
test("renders correctly without back button when disableBack is true", () => {
render(<PanelInfoView disableBack={true} handleInitialPageButton={mockHandleInitialPageButton} />);
expect(screen.queryByRole("button", { name: "common.back" })).not.toBeInTheDocument();
expect(screen.queryByTestId("arrow-left-icon")).not.toBeInTheDocument();
});
});
@@ -1,98 +0,0 @@
"use client";
import ProlificLogo from "@/images/prolific-logo.webp";
import ProlificUI from "@/images/prolific-screenshot.webp";
import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react";
import { ArrowLeftIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
interface PanelInfoViewProps {
disableBack: boolean;
handleInitialPageButton: () => void;
}
export const PanelInfoView = ({ disableBack, handleInitialPageButton }: PanelInfoViewProps) => {
const { t } = useTranslate();
return (
<div className="h-full overflow-hidden text-slate-900">
{!disableBack && (
<div className="border-b border-slate-200 py-2">
<Button variant="ghost" onClick={handleInitialPageButton}>
<ArrowLeftIcon />
{t("common.back")}
</Button>
</div>
)}
<div className="grid h-full grid-cols-2">
<div className="flex flex-col gap-y-6 border-r border-slate-200 p-8">
<Image src={ProlificUI} alt="Prolific panel selection UI" className="rounded-lg shadow-lg" />
<div>
<p className="text-md font-semibold">{t("environments.surveys.summary.what_is_a_panel")}</p>
<p className="text-slate-600">{t("environments.surveys.summary.what_is_a_panel_answer")}</p>
</div>
<div>
<p className="text-md font-semibold">{t("environments.surveys.summary.when_do_i_need_it")}</p>
<p className="text-slate-600">{t("environments.surveys.summary.when_do_i_need_it_answer")}</p>
</div>
<div>
<p className="text-md font-semibold">{t("environments.surveys.summary.what_is_prolific")}</p>
<p className="text-slate-600">{t("environments.surveys.summary.what_is_prolific_answer")}</p>
</div>
</div>
<div className="relative flex flex-col gap-y-6 bg-slate-50 p-8">
<Image
src={ProlificLogo}
alt="Prolific panel selection UI"
className="absolute right-8 top-8 w-32"
/>
<div>
<h3 className="text-xl font-semibold">
{t("environments.surveys.summary.how_to_create_a_panel")}
</h3>
</div>
<div>
<p className="text-md font-semibold">
{t("environments.surveys.summary.how_to_create_a_panel_step_1")}
</p>
<p className="text-slate-600">
{t("environments.surveys.summary.how_to_create_a_panel_step_1_description")}
</p>
</div>
<div>
<p className="text-md font-semibold">
{t("environments.surveys.summary.how_to_create_a_panel_step_2")}
</p>
<p className="text-slate-600">
{t("environments.surveys.summary.how_to_create_a_panel_step_2_description")}
</p>
</div>
<div>
<p className="text-md font-semibold">
{t("environments.surveys.summary.how_to_create_a_panel_step_3")}
</p>
<p className="text-slate-600">
{t("environments.surveys.summary.how_to_create_a_panel_step_3_description")}
</p>
</div>
<div>
<p className="text-md font-semibold">
{t("environments.surveys.summary.how_to_create_a_panel_step_4")}
</p>
<p className="text-slate-600">
{t("environments.surveys.summary.how_to_create_a_panel_step_4_description")}
</p>
</div>
<Button className="justify-center" asChild>
<Link
href="https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/market-research-panel"
target="_blank">
{t("common.learn_more")}
</Link>
</Button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,519 @@
import { generatePersonalLinksAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { PersonalLinksTab } from "./personal-links-tab";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions", () => ({
generatePersonalLinksAction: vi.fn(),
}));
vi.mock("react-hot-toast", () => ({
default: {
loading: vi.fn(),
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn(),
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
// Mock UI components
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children, variant }: any) => (
<div data-testid="alert" data-variant={variant}>
{children}
</div>
),
AlertButton: ({ children }: any) => <div data-testid="alert-button">{children}</div>,
AlertDescription: ({ children }: any) => <div data-testid="alert-description">{children}</div>,
AlertTitle: ({ children }: any) => <div data-testid="alert-title">{children}</div>,
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, disabled, loading, className, ...props }: any) => (
<button
data-testid="button"
onClick={onClick}
disabled={disabled}
data-loading={loading}
className={className}
{...props}>
{children}
</button>
),
}));
vi.mock("@/modules/ui/components/date-picker", () => ({
DatePicker: ({ date, updateSurveyDate, minDate, onClearDate }: any) => (
<div data-testid="date-picker">
<input
data-testid="date-input"
type="date"
value={date ? date.toISOString().split("T")[0] : ""}
onChange={(e) => {
const newDate = e.target.value ? new Date(e.target.value) : null;
updateSurveyDate(newDate);
}}
/>
<button data-testid="clear-date" onClick={() => onClearDate()}>
Clear
</button>
<div data-testid="min-date">{minDate ? minDate.toISOString() : ""}</div>
</div>
),
}));
vi.mock("@/modules/ui/components/select", () => {
let globalOnValueChange: ((value: string) => void) | null = null;
return {
Select: ({ children, value, onValueChange, disabled }: any) => {
globalOnValueChange = onValueChange;
return (
<div data-testid="select" data-disabled={disabled} data-value={value}>
<div data-testid="select-current-value">{value || "Select option"}</div>
{children}
</div>
);
},
SelectContent: ({ children }: any) => <div data-testid="select-content">{children}</div>,
SelectItem: ({ children, value }: any) => (
<div
data-testid="select-item"
data-value={value}
onClick={() => {
if (globalOnValueChange) {
globalOnValueChange(value);
}
}}>
{children}
</div>
),
SelectTrigger: ({ children, className }: any) => (
<div data-testid="select-trigger" className={className}>
{children}
</div>
),
SelectValue: ({ placeholder }: any) => <div data-testid="select-value">{placeholder}</div>,
};
});
// Mock icons
vi.mock("lucide-react", () => ({
DownloadIcon: () => <div data-testid="download-icon" />,
KeyIcon: () => <div data-testid="key-icon" />,
}));
// Mock Next.js Link
vi.mock("next/link", () => ({
default: ({ children, href, target, rel }: any) => (
<a data-testid="link" href={href} target={target} rel={rel}>
{children}
</a>
),
}));
const mockGeneratePersonalLinksAction = vi.mocked(generatePersonalLinksAction);
const mockToast = vi.mocked(toast);
const mockGetFormattedErrorMessage = vi.mocked(getFormattedErrorMessage);
// Mock segments data
const mockSegments = [
{
id: "segment1",
title: "Public Segment 1",
isPrivate: false,
description: "Test segment 1",
filters: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
surveys: [],
},
{
id: "segment2",
title: "Public Segment 2",
isPrivate: false,
description: "Test segment 2",
filters: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
surveys: [],
},
{
id: "segment3",
title: "Private Segment",
isPrivate: true,
description: "Test private segment",
filters: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
surveys: [],
},
];
const defaultProps = {
environmentId: "test-env-id",
surveyId: "test-survey-id",
segments: mockSegments,
isContactsEnabled: true,
isFormbricksCloud: false,
};
// Helper function to trigger select change
const selectOption = (value: string) => {
const selectItems = screen.getAllByTestId("select-item");
const targetItem = selectItems.find((item) => item.getAttribute("data-value") === value);
if (targetItem) {
fireEvent.click(targetItem);
}
};
describe("PersonalLinksTab", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("renders the component with correct title and description", () => {
render(<PersonalLinksTab {...defaultProps} />);
expect(
screen.getByText("environments.surveys.summary.generate_personal_links_title")
).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.generate_personal_links_description")
).toBeInTheDocument();
});
test("renders recipients section with segment selection", () => {
render(<PersonalLinksTab {...defaultProps} />);
expect(screen.getByText("common.recipients")).toBeInTheDocument();
expect(screen.getByTestId("select")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.create_and_manage_segments")).toBeInTheDocument();
});
test("renders expiry date section with date picker", () => {
render(<PersonalLinksTab {...defaultProps} />);
expect(screen.getByText("environments.surveys.summary.expiry_date_optional")).toBeInTheDocument();
expect(screen.getByTestId("date-picker")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.expiry_date_description")).toBeInTheDocument();
});
test("renders generate button with correct initial state", () => {
render(<PersonalLinksTab {...defaultProps} />);
const button = screen.getByTestId("button");
expect(button).toBeInTheDocument();
expect(button).toBeDisabled();
expect(screen.getByText("environments.surveys.summary.generate_and_download_links")).toBeInTheDocument();
expect(screen.getByTestId("download-icon")).toBeInTheDocument();
});
test("renders info alert with correct content", () => {
render(<PersonalLinksTab {...defaultProps} />);
expect(screen.getByTestId("alert")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.personal_links_work_with_segments")
).toBeInTheDocument();
expect(screen.getByTestId("link")).toHaveAttribute(
"href",
"https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting#segment-configuration"
);
});
test("filters out private segments and shows only public segments", () => {
render(<PersonalLinksTab {...defaultProps} />);
const selectItems = screen.getAllByTestId("select-item");
expect(selectItems).toHaveLength(2); // Only public segments
expect(selectItems[0]).toHaveTextContent("Public Segment 1");
expect(selectItems[1]).toHaveTextContent("Public Segment 2");
});
test("shows no segments message when no public segments available", () => {
const propsWithPrivateSegments = {
...defaultProps,
segments: [mockSegments[2]], // Only private segment
};
render(<PersonalLinksTab {...propsWithPrivateSegments} />);
expect(screen.getByText("environments.surveys.summary.no_segments_available")).toBeInTheDocument();
expect(screen.getByTestId("select")).toHaveAttribute("data-disabled", "true");
expect(screen.getByTestId("button")).toBeDisabled();
});
test("enables button when segment is selected", () => {
render(<PersonalLinksTab {...defaultProps} />);
// Initially disabled
expect(screen.getByTestId("button")).toBeDisabled();
// Select a segment
selectOption("segment1");
// Should now be enabled
expect(screen.getByTestId("button")).not.toBeDisabled();
});
test("handles date selection correctly", () => {
render(<PersonalLinksTab {...defaultProps} />);
const dateInput = screen.getByTestId("date-input");
const testDate = "2024-12-31";
fireEvent.change(dateInput, { target: { value: testDate } });
expect(dateInput).toHaveValue(testDate);
});
test("clears date when clear button is clicked", () => {
render(<PersonalLinksTab {...defaultProps} />);
const dateInput = screen.getByTestId("date-input");
const clearButton = screen.getByTestId("clear-date");
// Set a date first
fireEvent.change(dateInput, { target: { value: "2024-12-31" } });
// Clear the date
fireEvent.click(clearButton);
expect(dateInput).toHaveValue("");
});
test("sets minimum date to tomorrow", () => {
render(<PersonalLinksTab {...defaultProps} />);
const minDateElement = screen.getByTestId("min-date");
// Should have some ISO date string for a future date
expect(minDateElement.textContent).toMatch(/\d{4}-\d{2}-\d{2}T/);
});
test("successfully generates and downloads links", async () => {
const mockResult = {
data: {
downloadUrl: "https://example.com/download/file.csv",
fileName: "personal-links.csv",
count: 5,
},
};
mockGeneratePersonalLinksAction.mockResolvedValue(mockResult);
render(<PersonalLinksTab {...defaultProps} />);
// Select a segment
selectOption("segment1");
// Click generate button
const generateButton = screen.getByTestId("button");
fireEvent.click(generateButton);
// Verify action was called
await waitFor(() => {
expect(mockGeneratePersonalLinksAction).toHaveBeenCalledWith({
surveyId: "test-survey-id",
segmentId: "segment1",
environmentId: "test-env-id",
expirationDays: undefined,
});
});
// Verify loading toast
expect(mockToast.loading).toHaveBeenCalledWith("environments.surveys.summary.generating_links_toast", {
duration: 5000,
id: "generating-links",
});
});
test("generates links with expiry date when date is selected", async () => {
const mockResult = {
data: {
downloadUrl: "https://example.com/download/file.csv",
fileName: "personal-links.csv",
count: 3,
},
};
mockGeneratePersonalLinksAction.mockResolvedValue(mockResult);
render(<PersonalLinksTab {...defaultProps} />);
// Select a segment
selectOption("segment1");
// Set expiry date (10 days from now)
const dateInput = screen.getByTestId("date-input");
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 10);
const expiryDate = futureDate.toISOString().split("T")[0];
fireEvent.change(dateInput, { target: { value: expiryDate } });
// Click generate button
const generateButton = screen.getByTestId("button");
fireEvent.click(generateButton);
await waitFor(() => {
expect(mockGeneratePersonalLinksAction).toHaveBeenCalledWith({
surveyId: "test-survey-id",
segmentId: "segment1",
environmentId: "test-env-id",
expirationDays: expect.any(Number),
});
});
// Verify that expirationDays is a reasonable value (between 9-10 days)
const callArgs = mockGeneratePersonalLinksAction.mock.calls[0][0];
expect(callArgs.expirationDays).toBeGreaterThanOrEqual(9);
expect(callArgs.expirationDays).toBeLessThanOrEqual(10);
});
test("handles error response from generatePersonalLinksAction", async () => {
const mockErrorResult = {
serverError: "Test error message",
};
mockGeneratePersonalLinksAction.mockResolvedValue(mockErrorResult);
mockGetFormattedErrorMessage.mockReturnValue("Formatted error message");
render(<PersonalLinksTab {...defaultProps} />);
// Select a segment
selectOption("segment1");
// Click generate button
const generateButton = screen.getByTestId("button");
fireEvent.click(generateButton);
// Wait for the action to be called
await waitFor(() => {
expect(mockGeneratePersonalLinksAction).toHaveBeenCalledWith({
surveyId: "test-survey-id",
segmentId: "segment1",
environmentId: "test-env-id",
expirationDays: undefined,
});
});
// Wait for error handling
await waitFor(() => {
expect(mockGetFormattedErrorMessage).toHaveBeenCalledWith(mockErrorResult);
expect(mockToast.error).toHaveBeenCalledWith("Formatted error message", {
duration: 5000,
id: "generating-links",
});
});
});
test("shows generating state when triggered", async () => {
// Mock a promise that resolves quickly
const mockResult = { data: { downloadUrl: "test", fileName: "test.csv", count: 1 } };
mockGeneratePersonalLinksAction.mockResolvedValue(mockResult);
render(<PersonalLinksTab {...defaultProps} />);
// Select a segment
selectOption("segment1");
// Click generate button
const generateButton = screen.getByTestId("button");
fireEvent.click(generateButton);
// Verify loading toast is called
expect(mockToast.loading).toHaveBeenCalledWith("environments.surveys.summary.generating_links_toast", {
duration: 5000,
id: "generating-links",
});
});
test("button is disabled when no segment is selected", () => {
render(<PersonalLinksTab {...defaultProps} />);
const generateButton = screen.getByTestId("button");
expect(generateButton).toBeDisabled();
});
test("button is disabled when no public segments are available", () => {
const propsWithNoPublicSegments = {
...defaultProps,
segments: [mockSegments[2]], // Only private segments
};
render(<PersonalLinksTab {...propsWithNoPublicSegments} />);
const generateButton = screen.getByTestId("button");
expect(generateButton).toBeDisabled();
});
test("handles empty segments array", () => {
const propsWithEmptySegments = {
...defaultProps,
segments: [],
};
render(<PersonalLinksTab {...propsWithEmptySegments} />);
expect(screen.getByText("environments.surveys.summary.no_segments_available")).toBeInTheDocument();
expect(screen.getByTestId("button")).toBeDisabled();
});
test("calculates expiration days correctly for different dates", async () => {
const mockResult = {
data: {
downloadUrl: "https://example.com/download/file.csv",
fileName: "test.csv",
count: 1,
},
};
mockGeneratePersonalLinksAction.mockResolvedValue(mockResult);
render(<PersonalLinksTab {...defaultProps} />);
// Select a segment
selectOption("segment1");
// Set expiry date to 5 days from now
const dateInput = screen.getByTestId("date-input");
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 5);
const expiryDate = futureDate.toISOString().split("T")[0];
fireEvent.change(dateInput, { target: { value: expiryDate } });
// Click generate button
const generateButton = screen.getByTestId("button");
fireEvent.click(generateButton);
await waitFor(() => {
expect(mockGeneratePersonalLinksAction).toHaveBeenCalledWith({
surveyId: "test-survey-id",
segmentId: "segment1",
environmentId: "test-env-id",
expirationDays: expect.any(Number),
});
});
// Verify that expirationDays is a reasonable value (between 4-5 days)
const callArgs = mockGeneratePersonalLinksAction.mock.calls[0][0];
expect(callArgs.expirationDays).toBeGreaterThanOrEqual(4);
expect(callArgs.expirationDays).toBeLessThanOrEqual(5);
});
});
@@ -0,0 +1,231 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { DatePicker } from "@/modules/ui/components/date-picker";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { useTranslate } from "@tolgee/react";
import { DownloadIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import toast from "react-hot-toast";
import { TSegment } from "@formbricks/types/segment";
import { generatePersonalLinksAction } from "../../actions";
interface PersonalLinksTabProps {
environmentId: string;
surveyId: string;
segments: TSegment[];
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
}
// Custom DatePicker component with date restrictions
const RestrictedDatePicker = ({
date,
updateSurveyDate,
}: {
date: Date | null;
updateSurveyDate: (date: Date | null) => void;
}) => {
// Get tomorrow's date
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
const handleDateUpdate = (date: Date) => {
updateSurveyDate(date);
};
return (
<DatePicker
date={date}
updateSurveyDate={handleDateUpdate}
minDate={tomorrow}
onClearDate={() => updateSurveyDate(null)}
/>
);
};
export const PersonalLinksTab = ({
environmentId,
segments,
surveyId,
isContactsEnabled,
isFormbricksCloud,
}: PersonalLinksTabProps) => {
const { t } = useTranslate();
const [selectedSegment, setSelectedSegment] = useState<string>("");
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const publicSegments = segments.filter((segment) => !segment.isPrivate);
// Utility function for file downloads
const downloadFile = (url: string, filename: string) => {
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const handleGenerateLinks = async () => {
if (!selectedSegment || isGenerating) return;
setIsGenerating(true);
// Show initial toast
toast.loading(t("environments.surveys.summary.generating_links_toast"), {
duration: 5000,
id: "generating-links",
});
const result = await generatePersonalLinksAction({
surveyId: surveyId,
segmentId: selectedSegment,
environmentId: environmentId,
expirationDays: expiryDate
? Math.max(1, Math.floor((expiryDate.getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24)))
: undefined,
});
if (result?.data) {
downloadFile(result.data.downloadUrl, result.data.fileName || "personal-links.csv");
toast.success(t("environments.surveys.summary.links_generated_success_toast"), {
duration: 5000,
id: "generating-links",
});
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage, {
duration: 5000,
id: "generating-links",
});
}
setIsGenerating(false);
};
// Button state logic
const isButtonDisabled = !selectedSegment || isGenerating || publicSegments.length === 0;
const buttonText = isGenerating
? t("environments.surveys.summary.generating_links")
: t("environments.surveys.summary.generate_and_download_links");
if (!isContactsEnabled) {
return (
<UpgradePrompt
title={t("environments.surveys.summary.personal_links_upgrade_prompt_title")}
description={t("environments.surveys.summary.personal_links_upgrade_prompt_description")}
buttons={[
{
text: isFormbricksCloud ? t("common.start_free_trial") : t("common.request_trial_license"),
href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license",
},
{
text: t("common.learn_more"),
href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/learn-more-self-hosting-license",
},
]}
/>
);
}
return (
<div className="flex h-full grow flex-col gap-6">
<div>
<h2 className="mb-2 text-lg font-semibold text-slate-800">
{t("environments.surveys.summary.generate_personal_links_title")}
</h2>
<p className="text-sm text-slate-600">
{t("environments.surveys.summary.generate_personal_links_description")}
</p>
</div>
<div className="space-y-6">
{/* Recipients Section */}
<div>
<label htmlFor="segment-select" className="mb-2 block text-sm font-medium text-slate-700">
{t("common.recipients")}
</label>
<Select
value={selectedSegment}
onValueChange={setSelectedSegment}
disabled={publicSegments.length === 0}>
<SelectTrigger id="segment-select" className="w-full bg-white">
<SelectValue
placeholder={
publicSegments.length === 0
? t("environments.surveys.summary.no_segments_available")
: t("environments.surveys.summary.select_segment")
}
/>
</SelectTrigger>
<SelectContent>
{publicSegments.map((segment) => (
<SelectItem key={segment.id} value={segment.id}>
{segment.title}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-slate-500">
{t("environments.surveys.summary.create_and_manage_segments")}
</p>
</div>
{/* Expiry Date Section */}
<div>
<label htmlFor="expiry-date-picker" className="mb-2 block text-sm font-medium text-slate-700">
{t("environments.surveys.summary.expiry_date_optional")}
</label>
<div id="expiry-date-picker">
<RestrictedDatePicker
date={expiryDate}
updateSurveyDate={(date: Date | null) => setExpiryDate(date)}
/>
</div>
<p className="mt-1 text-xs text-slate-500">
{t("environments.surveys.summary.expiry_date_description")}
</p>
</div>
{/* Generate Button */}
<Button
onClick={handleGenerateLinks}
disabled={isButtonDisabled}
loading={isGenerating}
className="w-fit">
<DownloadIcon className="mr-2 h-4 w-4" />
{buttonText}
</Button>
</div>
<hr />
{/* Info Box */}
<Alert variant="info" size="small">
<AlertTitle>{t("environments.surveys.summary.personal_links_work_with_segments")}</AlertTitle>
<AlertButton>
<Link
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting#segment-configuration"
target="_blank"
rel="noopener noreferrer">
{t("common.learn_more")}
</Link>
</AlertButton>
</Alert>
</div>
);
};
@@ -101,7 +101,7 @@ const mockProject = {
highlightBorderColor: null, highlightBorderColor: null,
cardBackgroundColor: { light: "#FFFFFF", dark: "#000000" }, cardBackgroundColor: { light: "#FFFFFF", dark: "#000000" },
cardBorderColor: { light: "#FFFFFF", dark: "#000000" }, cardBorderColor: { light: "#FFFFFF", dark: "#000000" },
cardShadowColor: { light: "#FFFFFF", dark: "#000000" },
questionColor: { light: "#FFFFFF", dark: "#000000" }, questionColor: { light: "#FFFFFF", dark: "#000000" },
inputColor: { light: "#FFFFFF", dark: "#000000" }, inputColor: { light: "#FFFFFF", dark: "#000000" },
inputBorderColor: { light: "#FFFFFF", dark: "#000000" }, inputBorderColor: { light: "#FFFFFF", dark: "#000000" },
@@ -123,7 +123,7 @@ const mockComputedStyling = {
inputBorderColor: "#000000", inputBorderColor: "#000000",
cardBackgroundColor: "#FFFFFF", cardBackgroundColor: "#FFFFFF",
cardBorderColor: "#EEEEEE", cardBorderColor: "#EEEEEE",
cardShadowColor: "#AAAAAA",
highlightBorderColor: null, highlightBorderColor: null,
thankYouCardIconColor: "#007BFF", thankYouCardIconColor: "#007BFF",
thankYouCardIconBgColor: "#DDDDDD", thankYouCardIconBgColor: "#DDDDDD",
@@ -2,10 +2,12 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage"; import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA"; import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary"; import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
import { DEFAULT_LOCALE } from "@/lib/constants"; import { DEFAULT_LOCALE, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl"; import { getPublicDomain } from "@/lib/getPublicUrl";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
@@ -36,6 +38,8 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
if (!user) { if (!user) {
throw new Error(t("common.user_not_found")); throw new Error(t("common.user_not_found"));
} }
const isContactsEnabled = await getIsContactsEnabled();
const segments = isContactsEnabled ? await getSegments(environment.id) : [];
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration // Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
const initialSurveySummary = await getSurveySummary(surveyId); const initialSurveySummary = await getSurveySummary(surveyId);
@@ -54,6 +58,9 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
user={user} user={user}
publicDomain={publicDomain} publicDomain={publicDomain}
responseCount={initialSurveySummary?.meta.totalResponses ?? 0} responseCount={initialSurveySummary?.meta.totalResponses ?? 0}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
/> />
}> }>
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="summary" /> <SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="summary" />
+12 -1
View File
@@ -31,6 +31,8 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url", WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false, IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
SENTRY_RELEASE: "mock-sentry-release",
SENTRY_ENVIRONMENT: "mock-sentry-environment",
})); }));
vi.mock("@/tolgee/language", () => ({ vi.mock("@/tolgee/language", () => ({
@@ -59,9 +61,18 @@ vi.mock("@/tolgee/client", () => ({
})); }));
vi.mock("@/app/sentry/SentryProvider", () => ({ vi.mock("@/app/sentry/SentryProvider", () => ({
SentryProvider: ({ children, sentryDsn }: { children: React.ReactNode; sentryDsn?: string }) => ( SentryProvider: ({
children,
sentryDsn,
sentryRelease,
}: {
children: React.ReactNode;
sentryDsn?: string;
sentryRelease?: string;
}) => (
<div data-testid="sentry-provider"> <div data-testid="sentry-provider">
SentryProvider: {sentryDsn} SentryProvider: {sentryDsn}
{sentryRelease && ` - Release: ${sentryRelease}`}
{children} {children}
</div> </div>
), ),
+6 -2
View File
@@ -1,5 +1,5 @@
import { SentryProvider } from "@/app/sentry/SentryProvider"; import { SentryProvider } from "@/app/sentry/SentryProvider";
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants"; import { IS_PRODUCTION, SENTRY_DSN, SENTRY_ENVIRONMENT, SENTRY_RELEASE } from "@/lib/constants";
import { TolgeeNextProvider } from "@/tolgee/client"; import { TolgeeNextProvider } from "@/tolgee/client";
import { getLocale } from "@/tolgee/language"; import { getLocale } from "@/tolgee/language";
import { getTolgee } from "@/tolgee/server"; import { getTolgee } from "@/tolgee/server";
@@ -25,7 +25,11 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => {
return ( return (
<html lang={locale} translate="no"> <html lang={locale} translate="no">
<body className="flex h-dvh flex-col transition-all ease-in-out"> <body className="flex h-dvh flex-col transition-all ease-in-out">
<SentryProvider sentryDsn={SENTRY_DSN} isEnabled={IS_PRODUCTION}> <SentryProvider
sentryDsn={SENTRY_DSN}
sentryRelease={SENTRY_RELEASE}
sentryEnvironment={SENTRY_ENVIRONMENT}
isEnabled={IS_PRODUCTION}>
<TolgeeNextProvider language={locale} staticData={staticData as unknown as TolgeeStaticData}> <TolgeeNextProvider language={locale} staticData={staticData as unknown as TolgeeStaticData}>
{children} {children}
</TolgeeNextProvider> </TolgeeNextProvider>
@@ -48,6 +48,24 @@ describe("SentryProvider", () => {
); );
}); });
test("calls Sentry.init with sentryRelease when provided", () => {
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
const testRelease = "v1.2.3";
render(
<SentryProvider sentryDsn={sentryDsn} sentryRelease={testRelease} isEnabled>
<div data-testid="child">Test Content</div>
</SentryProvider>
);
expect(initSpy).toHaveBeenCalledWith(
expect.objectContaining({
dsn: sentryDsn,
release: testRelease,
})
);
});
test("does not call Sentry.init when sentryDsn is not provided", () => { test("does not call Sentry.init when sentryDsn is not provided", () => {
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined); const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
+11 -1
View File
@@ -6,14 +6,24 @@ import { useEffect } from "react";
interface SentryProviderProps { interface SentryProviderProps {
children: React.ReactNode; children: React.ReactNode;
sentryDsn?: string; sentryDsn?: string;
sentryRelease?: string;
sentryEnvironment?: string;
isEnabled?: boolean; isEnabled?: boolean;
} }
export const SentryProvider = ({ children, sentryDsn, isEnabled }: SentryProviderProps) => { export const SentryProvider = ({
children,
sentryDsn,
sentryRelease,
sentryEnvironment,
isEnabled,
}: SentryProviderProps) => {
useEffect(() => { useEffect(() => {
if (sentryDsn && isEnabled) { if (sentryDsn && isEnabled) {
Sentry.init({ Sentry.init({
dsn: sentryDsn, dsn: sentryDsn,
release: sentryRelease,
environment: sentryEnvironment,
// No tracing while Sentry doesn't update to telemetry 2.0.0 - https://github.com/getsentry/sentry-javascript/issues/15737 // No tracing while Sentry doesn't update to telemetry 2.0.0 - https://github.com/getsentry/sentry-javascript/issues/15737
tracesSampleRate: 0, tracesSampleRate: 0,
+20 -2
View File
@@ -233,8 +233,8 @@ export enum STRIPE_PROJECT_NAMES {
} }
export enum STRIPE_PRICE_LOOKUP_KEYS { export enum STRIPE_PRICE_LOOKUP_KEYS {
STARTUP_MONTHLY = "formbricks_startup_monthly", STARTUP_MAY25_MONTHLY = "STARTUP_MAY25_MONTHLY",
STARTUP_YEARLY = "formbricks_startup_yearly", STARTUP_MAY25_YEARLY = "STARTUP_MAY25_YEARLY",
SCALE_MONTHLY = "formbricks_scale_monthly", SCALE_MONTHLY = "formbricks_scale_monthly",
SCALE_YEARLY = "formbricks_scale_yearly", SCALE_YEARLY = "formbricks_scale_yearly",
} }
@@ -273,6 +273,24 @@ export const RECAPTCHA_SITE_KEY = env.RECAPTCHA_SITE_KEY;
export const RECAPTCHA_SECRET_KEY = env.RECAPTCHA_SECRET_KEY; export const RECAPTCHA_SECRET_KEY = env.RECAPTCHA_SECRET_KEY;
export const IS_RECAPTCHA_CONFIGURED = Boolean(RECAPTCHA_SITE_KEY && RECAPTCHA_SECRET_KEY); export const IS_RECAPTCHA_CONFIGURED = Boolean(RECAPTCHA_SITE_KEY && RECAPTCHA_SECRET_KEY);
// Use the app version for Sentry release (updated during build in production)
// Fallback to environment variable if package.json is not accessible
export const SENTRY_RELEASE = (() => {
if (process.env.NODE_ENV !== "production") {
return undefined;
}
// Try to read from package.json with proper error handling
try {
const pkg = require("../package.json");
return pkg.version === "0.0.0" ? undefined : `v${pkg.version}`;
} catch {
// If package.json can't be read (e.g., in some deployment scenarios),
// return undefined and let Sentry work without release tracking
return undefined;
}
})();
export const SENTRY_ENVIRONMENT = env.SENTRY_ENVIRONMENT;
export const SENTRY_DSN = env.SENTRY_DSN; export const SENTRY_DSN = env.SENTRY_DSN;
export const PROMETHEUS_ENABLED = env.PROMETHEUS_ENABLED === "1"; export const PROMETHEUS_ENABLED = env.PROMETHEUS_ENABLED === "1";
+2
View File
@@ -127,6 +127,7 @@ export const env = createEnv({
.string() .string()
.transform((val) => parseInt(val)) .transform((val) => parseInt(val))
.optional(), .optional(),
SENTRY_ENVIRONMENT: z.string().optional(),
}, },
/* /*
@@ -225,5 +226,6 @@ export const env = createEnv({
AUDIT_LOG_ENABLED: process.env.AUDIT_LOG_ENABLED, AUDIT_LOG_ENABLED: process.env.AUDIT_LOG_ENABLED,
AUDIT_LOG_GET_USER_IP: process.env.AUDIT_LOG_GET_USER_IP, AUDIT_LOG_GET_USER_IP: process.env.AUDIT_LOG_GET_USER_IP,
SESSION_MAX_AGE: process.env.SESSION_MAX_AGE, SESSION_MAX_AGE: process.env.SESSION_MAX_AGE,
SENTRY_ENVIRONMENT: process.env.SENTRY_ENVIRONMENT,
}, },
}); });
-4
View File
@@ -8,7 +8,6 @@ export const COLOR_DEFAULTS = {
inputBorderColor: "#cbd5e1", inputBorderColor: "#cbd5e1",
cardBackgroundColor: "#ffffff", cardBackgroundColor: "#ffffff",
cardBorderColor: "#f8fafc", cardBorderColor: "#f8fafc",
cardShadowColor: "#000000",
highlightBorderColor: "#64748b", highlightBorderColor: "#64748b",
} as const; } as const;
@@ -32,9 +31,6 @@ export const defaultStyling: TProjectStyling = {
cardBorderColor: { cardBorderColor: {
light: COLOR_DEFAULTS.cardBorderColor, light: COLOR_DEFAULTS.cardBorderColor,
}, },
cardShadowColor: {
light: COLOR_DEFAULTS.cardShadowColor,
},
isLogoHidden: false, isLogoHidden: false,
highlightBorderColor: undefined, highlightBorderColor: undefined,
isDarkModeEnabled: false, isDarkModeEnabled: false,
+29 -1
View File
@@ -1,5 +1,8 @@
import { TagError } from "@/modules/projects/settings/types/tag";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest"; import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TTag } from "@formbricks/types/tags"; import { TTag } from "@formbricks/types/tags";
import { createTag, getTag, getTagsByEnvironmentId } from "./service"; import { createTag, getTag, getTagsByEnvironmentId } from "./service";
@@ -110,7 +113,7 @@ describe("Tag Service", () => {
vi.mocked(prisma.tag.create).mockResolvedValue(mockTag); vi.mocked(prisma.tag.create).mockResolvedValue(mockTag);
const result = await createTag("env1", "New Tag"); const result = await createTag("env1", "New Tag");
expect(result).toEqual(mockTag); expect(result).toEqual({ ok: true, data: mockTag });
expect(prisma.tag.create).toHaveBeenCalledWith({ expect(prisma.tag.create).toHaveBeenCalledWith({
data: { data: {
name: "New Tag", name: "New Tag",
@@ -118,5 +121,30 @@ describe("Tag Service", () => {
}, },
}); });
}); });
test("should handle duplicate tag name error", async () => {
// const duplicateError = new Error("Unique constraint failed");
// (duplicateError as any).code = "P2002";
const duplicateError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "4.0.0",
});
vi.mocked(prisma.tag.create).mockRejectedValue(duplicateError);
const result = await createTag("env1", "Duplicate Tag");
expect(result).toEqual({
ok: false,
error: { message: "Tag with this name already exists", code: TagError.TAG_NAME_ALREADY_EXISTS },
});
});
test("should handle general database errors", async () => {
const generalError = new Error("Database connection failed");
vi.mocked(prisma.tag.create).mockRejectedValue(generalError);
const result = await createTag("env1", "New Tag");
expect(result).toStrictEqual({
ok: false,
error: { message: "Database connection failed", code: TagError.UNEXPECTED_ERROR },
});
});
}); });
}); });
+21 -3
View File
@@ -1,7 +1,11 @@
import "server-only"; import "server-only";
import { TagError } from "@/modules/projects/settings/types/tag";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react"; import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common"; import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { TTag } from "@formbricks/types/tags"; import { TTag } from "@formbricks/types/tags";
import { ITEMS_PER_PAGE } from "../constants"; import { ITEMS_PER_PAGE } from "../constants";
import { validateInputs } from "../utils/validate"; import { validateInputs } from "../utils/validate";
@@ -42,7 +46,10 @@ export const getTag = reactCache(async (id: string): Promise<TTag | null> => {
} }
}); });
export const createTag = async (environmentId: string, name: string): Promise<TTag> => { export const createTag = async (
environmentId: string,
name: string
): Promise<Result<TTag, { code: TagError; message: string; meta?: Record<string, string> }>> => {
validateInputs([environmentId, ZId], [name, ZString]); validateInputs([environmentId, ZId], [name, ZString]);
try { try {
@@ -53,8 +60,19 @@ export const createTag = async (environmentId: string, name: string): Promise<TT
}, },
}); });
return tag; return ok(tag);
} catch (error) { } catch (error) {
throw error; if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
return err({
code: TagError.TAG_NAME_ALREADY_EXISTS,
message: "Tag with this name already exists",
});
}
}
return err({
code: TagError.UNEXPECTED_ERROR,
message: error.message,
});
} }
}; };
+41 -27
View File
@@ -108,6 +108,10 @@
"thanks_for_upgrading": "Vielen Dank, dass Du dein Formbricks-Abonnement aktualisiert hast.", "thanks_for_upgrading": "Vielen Dank, dass Du dein Formbricks-Abonnement aktualisiert hast.",
"upgrade_successful": "Upgrade erfolgreich" "upgrade_successful": "Upgrade erfolgreich"
}, },
"c": {
"link_expired": "Dein Link ist abgelaufen.",
"link_expired_description": "Der von dir verwendete Link ist nicht mehr gültig."
},
"common": { "common": {
"accepted": "Akzeptiert", "accepted": "Akzeptiert",
"account": "Konto", "account": "Konto",
@@ -313,9 +317,11 @@
"question_id": "Frage-ID", "question_id": "Frage-ID",
"questions": "Fragen", "questions": "Fragen",
"read_docs": "Dokumentation lesen", "read_docs": "Dokumentation lesen",
"recipients": "Empfänger",
"remove": "Entfernen", "remove": "Entfernen",
"reorder_and_hide_columns": "Spalten neu anordnen und ausblenden", "reorder_and_hide_columns": "Spalten neu anordnen und ausblenden",
"report_survey": "Umfrage melden", "report_survey": "Umfrage melden",
"request_pricing": "Preise anfragen",
"request_trial_license": "Testlizenz anfordern", "request_trial_license": "Testlizenz anfordern",
"reset_to_default": "Auf Standard zurücksetzen", "reset_to_default": "Auf Standard zurücksetzen",
"response": "Antwort", "response": "Antwort",
@@ -596,6 +602,7 @@
"contact_not_found": "Kein solcher Kontakt gefunden", "contact_not_found": "Kein solcher Kontakt gefunden",
"contacts_table_refresh": "Kontakte aktualisieren", "contacts_table_refresh": "Kontakte aktualisieren",
"contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert", "contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert",
"delete_contact_confirmation": "Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesem Kontakt verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren.",
"first_name": "Vorname", "first_name": "Vorname",
"last_name": "Nachname", "last_name": "Nachname",
"no_responses_found": "Keine Antworten gefunden", "no_responses_found": "Keine Antworten gefunden",
@@ -632,6 +639,7 @@
"airtable_integration": "Airtable Integration", "airtable_integration": "Airtable Integration",
"airtable_integration_description": "Synchronisiere Antworten direkt mit Airtable.", "airtable_integration_description": "Synchronisiere Antworten direkt mit Airtable.",
"airtable_integration_is_not_configured": "Airtable Integration ist nicht konfiguriert", "airtable_integration_is_not_configured": "Airtable Integration ist nicht konfiguriert",
"airtable_logo": "Airtable-Logo",
"connect_with_airtable": "Mit Airtable verbinden", "connect_with_airtable": "Mit Airtable verbinden",
"link_airtable_table": "Airtable Tabelle verknüpfen", "link_airtable_table": "Airtable Tabelle verknüpfen",
"link_new_table": "Neue Tabelle verknüpfen", "link_new_table": "Neue Tabelle verknüpfen",
@@ -721,6 +729,7 @@
"slack_integration": "Slack Integration", "slack_integration": "Slack Integration",
"slack_integration_description": "Sende Antworten direkt an Slack.", "slack_integration_description": "Sende Antworten direkt an Slack.",
"slack_integration_is_not_configured": "Slack Integration ist in deiner Instanz von Formbricks nicht konfiguriert.", "slack_integration_is_not_configured": "Slack Integration ist in deiner Instanz von Formbricks nicht konfiguriert.",
"slack_logo": "Slack-Logo",
"slack_reconnect_button": "Erneut verbinden", "slack_reconnect_button": "Erneut verbinden",
"slack_reconnect_button_description": "<b>Hinweis:</b> Wir haben kürzlich unsere Slack-Integration geändert, um auch private Kanäle zu unterstützen. Bitte verbinden Sie Ihren Slack-Workspace erneut." "slack_reconnect_button_description": "<b>Hinweis:</b> Wir haben kürzlich unsere Slack-Integration geändert, um auch private Kanäle zu unterstützen. Bitte verbinden Sie Ihren Slack-Workspace erneut."
}, },
@@ -905,8 +914,7 @@
"tag_already_exists": "Tag existiert bereits", "tag_already_exists": "Tag existiert bereits",
"tag_deleted": "Tag gelöscht", "tag_deleted": "Tag gelöscht",
"tag_updated": "Tag aktualisiert", "tag_updated": "Tag aktualisiert",
"tags_merged": "Tags zusammengeführt", "tags_merged": "Tags zusammengeführt"
"unique_constraint_failed_on_the_fields": "Eindeutige Einschränkung für die Felder fehlgeschlagen"
}, },
"teams": { "teams": {
"manage_teams": "Teams verwalten", "manage_teams": "Teams verwalten",
@@ -979,63 +987,53 @@
"api_keys_description": "Verwalte API-Schlüssel, um auf die Formbricks-Management-APIs zuzugreifen" "api_keys_description": "Verwalte API-Schlüssel, um auf die Formbricks-Management-APIs zuzugreifen"
}, },
"billing": { "billing": {
"10000_monthly_responses": "10,000 monatliche Antworten", "1000_monthly_responses": "1,000 monatliche Antworten",
"1500_monthly_responses": "1,500 monatliche Antworten", "1_project": "1 Projekt",
"2000_monthly_identified_users": "2,000 monatlich identifizierte Nutzer", "2000_contacts": "2,000 Kontakte",
"30000_monthly_identified_users": "30,000 monatlich identifizierte Nutzer",
"3_projects": "3 Projekte", "3_projects": "3 Projekte",
"5000_monthly_responses": "5,000 monatliche Antworten", "5000_monthly_responses": "5,000 monatliche Antworten",
"5_projects": "5 Projekte", "7500_contacts": "7,500 Kontakte",
"7500_monthly_identified_users": "7,500 monatlich identifizierte Nutzer",
"advanced_targeting": "Erweitertes Targeting",
"all_integrations": "Alle Integrationen", "all_integrations": "Alle Integrationen",
"all_surveying_features": "Alle Umfragefunktionen",
"annually": "Jährlich", "annually": "Jährlich",
"api_webhooks": "API & Webhooks", "api_webhooks": "API & Webhooks",
"app_surveys": "In-app Umfragen", "app_surveys": "In-app Umfragen",
"contact_us": "Kontaktiere uns", "attribute_based_targeting": "Attributbasiertes Targeting",
"current": "aktuell", "current": "aktuell",
"current_plan": "Aktueller Plan", "current_plan": "Aktueller Plan",
"current_tier_limit": "Aktuelles Limit", "current_tier_limit": "Aktuelles Limit",
"custom_miu_limit": "Benutzerdefiniertes MIU-Limit", "custom": "Benutzerdefiniert & Skalierung",
"custom_contacts_limit": "Benutzerdefiniertes Kontaktlimit",
"custom_project_limit": "Benutzerdefiniertes Projektlimit", "custom_project_limit": "Benutzerdefiniertes Projektlimit",
"customer_success_manager": "Customer Success Manager", "custom_response_limit": "Benutzerdefiniertes Antwortlimit",
"email_embedded_surveys": "Eingebettete Umfragen in E-Mails", "email_embedded_surveys": "Eingebettete Umfragen in E-Mails",
"email_support": "E-Mail-Support", "email_follow_ups": "E-Mail Follow-ups",
"enterprise": "Enterprise",
"enterprise_description": "Premium-Support und benutzerdefinierte Limits.", "enterprise_description": "Premium-Support und benutzerdefinierte Limits.",
"everybody_has_the_free_plan_by_default": "Jeder hat standardmäßig den kostenlosen Plan!", "everybody_has_the_free_plan_by_default": "Jeder hat standardmäßig den kostenlosen Plan!",
"everything_in_free": "Alles in 'Free''", "everything_in_free": "Alles in 'Free''",
"everything_in_scale": "Alles in 'Scale''",
"everything_in_startup": "Alles in 'Startup''", "everything_in_startup": "Alles in 'Startup''",
"free": "Kostenlos", "free": "Kostenlos",
"free_description": "Unbegrenzte Umfragen, Teammitglieder und mehr.", "free_description": "Unbegrenzte Umfragen, Teammitglieder und mehr.",
"get_2_months_free": "2 Monate gratis", "get_2_months_free": "2 Monate gratis",
"get_in_touch": "Kontaktiere uns", "get_in_touch": "Kontaktiere uns",
"hosted_in_frankfurt": "Gehostet in Frankfurt",
"ios_android_sdks": "iOS & Android SDK für mobile Umfragen",
"link_surveys": "Umfragen verlinken (teilbar)", "link_surveys": "Umfragen verlinken (teilbar)",
"logic_jumps_hidden_fields_recurring_surveys": "Logik, versteckte Felder, wiederkehrende Umfragen, usw.", "logic_jumps_hidden_fields_recurring_surveys": "Logik, versteckte Felder, wiederkehrende Umfragen, usw.",
"manage_card_details": "Karteninformationen verwalten", "manage_card_details": "Karteninformationen verwalten",
"manage_subscription": "Abonnement verwalten", "manage_subscription": "Abonnement verwalten",
"monthly": "Monatlich", "monthly": "Monatlich",
"monthly_identified_users": "Monatlich identifizierte Nutzer", "monthly_identified_users": "Monatlich identifizierte Nutzer",
"multi_language_surveys": "Mehrsprachige Umfragen",
"per_month": "pro Monat", "per_month": "pro Monat",
"per_year": "pro Jahr", "per_year": "pro Jahr",
"plan_upgraded_successfully": "Plan erfolgreich aktualisiert", "plan_upgraded_successfully": "Plan erfolgreich aktualisiert",
"premium_support_with_slas": "Premium-Support mit SLAs", "premium_support_with_slas": "Premium-Support mit SLAs",
"priority_support": "Priorisierter Support",
"remove_branding": "Branding entfernen", "remove_branding": "Branding entfernen",
"say_hi": "Sag Hi!",
"scale": "Scale",
"scale_description": "Erweiterte Funktionen für größere Unternehmen.",
"startup": "Start-up", "startup": "Start-up",
"startup_description": "Alles in 'Free' mit zusätzlichen Funktionen.", "startup_description": "Alles in 'Free' mit zusätzlichen Funktionen.",
"switch_plan": "Plan wechseln", "switch_plan": "Plan wechseln",
"switch_plan_confirmation_text": "Bist du sicher, dass du zum {plan}-Plan wechseln möchtest? Dir werden {price} {period} berechnet.", "switch_plan_confirmation_text": "Bist du sicher, dass du zum {plan}-Plan wechseln möchtest? Dir werden {price} {period} berechnet.",
"team_access_roles": "Rollen für Teammitglieder", "team_access_roles": "Rollen für Teammitglieder",
"technical_onboarding": "Technische Einführung",
"unable_to_upgrade_plan": "Plan kann nicht aktualisiert werden", "unable_to_upgrade_plan": "Plan kann nicht aktualisiert werden",
"unlimited_apps_websites": "Unbegrenzte Apps & Websites",
"unlimited_miu": "Unbegrenzte MIU", "unlimited_miu": "Unbegrenzte MIU",
"unlimited_projects": "Unbegrenzte Projekte", "unlimited_projects": "Unbegrenzte Projekte",
"unlimited_responses": "Unbegrenzte Antworten", "unlimited_responses": "Unbegrenzte Antworten",
@@ -1074,6 +1072,7 @@
"create_new_organization": "Neue Organisation erstellen", "create_new_organization": "Neue Organisation erstellen",
"create_new_organization_description": "Erstelle eine neue Organisation, um weitere Projekte zu verwalten.", "create_new_organization_description": "Erstelle eine neue Organisation, um weitere Projekte zu verwalten.",
"customize_email_with_a_higher_plan": "E-Mail-Anpassung mit einem höheren Plan", "customize_email_with_a_higher_plan": "E-Mail-Anpassung mit einem höheren Plan",
"delete_member_confirmation": "Gelöschte Mitglieder verlieren den Zugriff auf alle Projekte und Umfragen deiner Organisation.",
"delete_organization": "Organisation löschen", "delete_organization": "Organisation löschen",
"delete_organization_description": "Organisation mit allen Projekten einschließlich aller Umfragen, Antworten, Personen, Aktionen und Attribute löschen", "delete_organization_description": "Organisation mit allen Projekten einschließlich aller Umfragen, Antworten, Personen, Aktionen und Attribute löschen",
"delete_organization_warning": "Bevor Du mit dem Löschen dieser Organisation fortfährst, sei dir bitte der folgenden Konsequenzen bewusst:", "delete_organization_warning": "Bevor Du mit dem Löschen dieser Organisation fortfährst, sei dir bitte der folgenden Konsequenzen bewusst:",
@@ -1230,8 +1229,9 @@
"copy_survey_description": "Kopiere diese Umfrage in eine andere Umgebung", "copy_survey_description": "Kopiere diese Umfrage in eine andere Umgebung",
"copy_survey_error": "Kopieren der Umfrage fehlgeschlagen", "copy_survey_error": "Kopieren der Umfrage fehlgeschlagen",
"copy_survey_link_to_clipboard": "Umfragelink in die Zwischenablage kopieren", "copy_survey_link_to_clipboard": "Umfragelink in die Zwischenablage kopieren",
"copy_survey_partially_success": "{success} Umfragen erfolgreich kopiert, {error} fehlgeschlagen.",
"copy_survey_success": "Umfrage erfolgreich kopiert!", "copy_survey_success": "Umfrage erfolgreich kopiert!",
"delete_survey_and_responses_warning": "Bist Du sicher, dass Du diese Umfrage und alle ihre Antworten löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.", "delete_survey_and_responses_warning": "Bist Du sicher, dass Du diese Umfrage und alle ihre Antworten löschen möchtest?",
"edit": { "edit": {
"1_choose_the_default_language_for_this_survey": "1. Wähle die Standardsprache für diese Umfrage:", "1_choose_the_default_language_for_this_survey": "1. Wähle die Standardsprache für diese Umfrage:",
"2_activate_translation_for_specific_languages": "2. Übersetzung für bestimmte Sprachen aktivieren:", "2_activate_translation_for_specific_languages": "2. Übersetzung für bestimmte Sprachen aktivieren:",
@@ -1304,7 +1304,6 @@
"card_arrangement_for_survey_type_derived": "Kartenanordnung für {surveyTypeDerived} Umfragen", "card_arrangement_for_survey_type_derived": "Kartenanordnung für {surveyTypeDerived} Umfragen",
"card_background_color": "Hintergrundfarbe der Karte", "card_background_color": "Hintergrundfarbe der Karte",
"card_border_color": "Farbe des Kartenrandes", "card_border_color": "Farbe des Kartenrandes",
"card_shadow_color": "Farbton des Kartenschattens",
"card_styling": "Kartenstil", "card_styling": "Kartenstil",
"casual": "Lässig", "casual": "Lässig",
"caution_edit_duplicate": "Duplizieren & bearbeiten", "caution_edit_duplicate": "Duplizieren & bearbeiten",
@@ -1329,7 +1328,6 @@
"change_the_brand_color_of_the_survey": "Markenfarbe der Umfrage ändern.", "change_the_brand_color_of_the_survey": "Markenfarbe der Umfrage ändern.",
"change_the_placement_of_this_survey": "Platzierung dieser Umfrage ändern.", "change_the_placement_of_this_survey": "Platzierung dieser Umfrage ändern.",
"change_the_question_color_of_the_survey": "Fragefarbe der Umfrage ändern.", "change_the_question_color_of_the_survey": "Fragefarbe der Umfrage ändern.",
"change_the_shadow_color_of_the_card": "Schattenfarbe der Karte ändern.",
"changes_saved": "Änderungen gespeichert.", "changes_saved": "Änderungen gespeichert.",
"character_limit_toggle_description": "Begrenzen Sie, wie kurz oder lang eine Antwort sein kann.", "character_limit_toggle_description": "Begrenzen Sie, wie kurz oder lang eine Antwort sein kann.",
"character_limit_toggle_title": "Fügen Sie Zeichenbeschränkungen hinzu", "character_limit_toggle_title": "Fügen Sie Zeichenbeschränkungen hinzu",
@@ -1716,6 +1714,7 @@
"congrats": "Glückwunsch! Deine Umfrage ist jetzt live.", "congrats": "Glückwunsch! Deine Umfrage ist jetzt live.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Verbinde deine Website oder App mit Formbricks, um loszulegen.", "connect_your_website_or_app_with_formbricks_to_get_started": "Verbinde deine Website oder App mit Formbricks, um loszulegen.",
"copy_link_to_public_results": "Link zu öffentlichen Ergebnissen kopieren", "copy_link_to_public_results": "Link zu öffentlichen Ergebnissen kopieren",
"create_and_manage_segments": "Erstellen und verwalten Sie Ihre Segmente unter Kontakte > Segmente",
"create_single_use_links": "Single-Use Links erstellen", "create_single_use_links": "Single-Use Links erstellen",
"create_single_use_links_description": "Akzeptiere nur eine Antwort pro Link. So geht's.", "create_single_use_links_description": "Akzeptiere nur eine Antwort pro Link. So geht's.",
"custom_range": "Benutzerdefinierter Bereich...", "custom_range": "Benutzerdefinierter Bereich...",
@@ -1734,12 +1733,19 @@
"embed_on_website": "Auf Website einbetten", "embed_on_website": "Auf Website einbetten",
"embed_pop_up_survey_title": "Wie man eine Pop-up-Umfrage auf seiner Website einbindet", "embed_pop_up_survey_title": "Wie man eine Pop-up-Umfrage auf seiner Website einbindet",
"embed_survey": "Umfrage einbetten", "embed_survey": "Umfrage einbetten",
"expiry_date_description": "Sobald der Link abläuft, kann der Empfänger nicht mehr auf die Umfrage antworten.",
"expiry_date_optional": "Ablaufdatum (optional)",
"failed_to_copy_link": "Kopieren des Links fehlgeschlagen", "failed_to_copy_link": "Kopieren des Links fehlgeschlagen",
"filter_added_successfully": "Filter erfolgreich hinzugefügt", "filter_added_successfully": "Filter erfolgreich hinzugefügt",
"filter_updated_successfully": "Filter erfolgreich aktualisiert", "filter_updated_successfully": "Filter erfolgreich aktualisiert",
"filtered_responses_csv": "Gefilterte Antworten (CSV)", "filtered_responses_csv": "Gefilterte Antworten (CSV)",
"filtered_responses_excel": "Gefilterte Antworten (Excel)", "filtered_responses_excel": "Gefilterte Antworten (Excel)",
"formbricks_email_survey_preview": "Formbricks E-Mail-Umfrage Vorschau", "formbricks_email_survey_preview": "Formbricks E-Mail-Umfrage Vorschau",
"generate_and_download_links": "Links generieren und herunterladen",
"generate_personal_links_description": "Erstellen Sie persönliche Links für ein Segment und ordnen Sie Umfrageantworten jedem Kontakt zu. Eine CSV-Datei Ihrer persönlichen Links inklusive relevanter Kontaktinformationen wird automatisch heruntergeladen.",
"generate_personal_links_title": "Maximieren Sie Erkenntnisse mit persönlichen Umfragelinks",
"generating_links": "Links werden generiert",
"generating_links_toast": "Links werden generiert, der Download startet in Kürze…",
"go_to_setup_checklist": "Gehe zur Einrichtungs-Checkliste \uD83D\uDC49", "go_to_setup_checklist": "Gehe zur Einrichtungs-Checkliste \uD83D\uDC49",
"hide_embed_code": "Einbettungscode ausblenden", "hide_embed_code": "Einbettungscode ausblenden",
"how_to_create_a_panel": "Wie man ein Panel erstellt", "how_to_create_a_panel": "Wie man ein Panel erstellt",
@@ -1765,12 +1771,18 @@
"last_quarter": "Letztes Quartal", "last_quarter": "Letztes Quartal",
"last_year": "Letztes Jahr", "last_year": "Letztes Jahr",
"link_to_public_results_copied": "Link zu öffentlichen Ergebnissen kopiert", "link_to_public_results_copied": "Link zu öffentlichen Ergebnissen kopiert",
"links_generated_success_toast": "Links erfolgreich generiert, Ihr Download beginnt in Kürze.",
"make_sure_the_survey_type_is_set_to": "Stelle sicher, dass der Umfragetyp richtig eingestellt ist", "make_sure_the_survey_type_is_set_to": "Stelle sicher, dass der Umfragetyp richtig eingestellt ist",
"mobile_app": "Mobile App", "mobile_app": "Mobile App",
"no_responses_found": "Keine Antworten gefunden", "no_responses_found": "Keine Antworten gefunden",
"no_segments_available": "Keine Segmente verfügbar",
"only_completed": "Nur vollständige Antworten", "only_completed": "Nur vollständige Antworten",
"other_values_found": "Andere Werte gefunden", "other_values_found": "Andere Werte gefunden",
"overall": "Insgesamt", "overall": "Insgesamt",
"personal_links": "Persönliche Links",
"personal_links_upgrade_prompt_description": "Erstellen Sie persönliche Links für ein Segment und verknüpfen Sie Umfrageantworten mit jedem Kontakt.",
"personal_links_upgrade_prompt_title": "Verwende persönliche Links mit einem höheren Plan",
"personal_links_work_with_segments": "Persönliche Links funktionieren mit Segmenten.",
"publish_to_web": "Im Web veröffentlichen", "publish_to_web": "Im Web veröffentlichen",
"publish_to_web_warning": "Du bist dabei, diese Umfrageergebnisse öffentlich zugänglich zu machen.", "publish_to_web_warning": "Du bist dabei, diese Umfrageergebnisse öffentlich zugänglich zu machen.",
"publish_to_web_warning_description": "Deine Umfrageergebnisse werden öffentlich sein. Jeder außerhalb deiner Organisation kann darauf zugreifen, wenn er den Link hat.", "publish_to_web_warning_description": "Deine Umfrageergebnisse werden öffentlich sein. Jeder außerhalb deiner Organisation kann darauf zugreifen, wenn er den Link hat.",
@@ -1779,6 +1791,7 @@
"quickstart_web_apps": "Schnellstart: Web-Apps", "quickstart_web_apps": "Schnellstart: Web-Apps",
"quickstart_web_apps_description": "Bitte folge der Schnellstartanleitung, um loszulegen:", "quickstart_web_apps_description": "Bitte folge der Schnellstartanleitung, um loszulegen:",
"results_are_public": "Ergebnisse sind öffentlich", "results_are_public": "Ergebnisse sind öffentlich",
"select_segment": "Segment auswählen",
"selected_responses_csv": "Ausgewählte Antworten (CSV)", "selected_responses_csv": "Ausgewählte Antworten (CSV)",
"selected_responses_excel": "Ausgewählte Antworten (Excel)", "selected_responses_excel": "Ausgewählte Antworten (Excel)",
"send_preview": "Vorschau senden", "send_preview": "Vorschau senden",
@@ -1804,6 +1817,7 @@
"this_year": "Dieses Jahr", "this_year": "Dieses Jahr",
"time_to_complete": "Zeit zur Fertigstellung", "time_to_complete": "Zeit zur Fertigstellung",
"to_connect_your_website_with_formbricks": "deine Website mit Formbricks zu verbinden", "to_connect_your_website_with_formbricks": "deine Website mit Formbricks zu verbinden",
"to_create_personal_links_segment_required": "Um persönliche Links für Ihre Umfrage zu erstellen, müssen Sie zuerst ein Segment einrichten.",
"ttc_tooltip": "Durchschnittliche Zeit bis zum Abschluss der Umfrage.", "ttc_tooltip": "Durchschnittliche Zeit bis zum Abschluss der Umfrage.",
"unknown_question_type": "Unbekannter Fragetyp", "unknown_question_type": "Unbekannter Fragetyp",
"unpublish_from_web": "Aus dem Web entfernen", "unpublish_from_web": "Aus dem Web entfernen",
@@ -2827,4 +2841,4 @@
"understand_purchase_intention_question_3_headline": "Was, wenn überhaupt, hält Dich heute davon ab, einen Kauf zu tätigen?", "understand_purchase_intention_question_3_headline": "Was, wenn überhaupt, hält Dich heute davon ab, einen Kauf zu tätigen?",
"understand_purchase_intention_question_3_placeholder": "Tippe deine Antwort hier..." "understand_purchase_intention_question_3_placeholder": "Tippe deine Antwort hier..."
} }
} }
+41 -27
View File
@@ -108,6 +108,10 @@
"thanks_for_upgrading": "Thanks a lot for upgrading your Formbricks subscription.", "thanks_for_upgrading": "Thanks a lot for upgrading your Formbricks subscription.",
"upgrade_successful": "Upgrade successful" "upgrade_successful": "Upgrade successful"
}, },
"c": {
"link_expired": "Your link is expired.",
"link_expired_description": "The link you used is no longer valid."
},
"common": { "common": {
"accepted": "Accepted", "accepted": "Accepted",
"account": "Account", "account": "Account",
@@ -313,9 +317,11 @@
"question_id": "Question ID", "question_id": "Question ID",
"questions": "Questions", "questions": "Questions",
"read_docs": "Read Docs", "read_docs": "Read Docs",
"recipients": "Recipients",
"remove": "Remove", "remove": "Remove",
"reorder_and_hide_columns": "Reorder and hide columns", "reorder_and_hide_columns": "Reorder and hide columns",
"report_survey": "Report Survey", "report_survey": "Report Survey",
"request_pricing": "Request Pricing",
"request_trial_license": "Request trial license", "request_trial_license": "Request trial license",
"reset_to_default": "Reset to default", "reset_to_default": "Reset to default",
"response": "Response", "response": "Response",
@@ -596,6 +602,7 @@
"contact_not_found": "No such contact found", "contact_not_found": "No such contact found",
"contacts_table_refresh": "Refresh contacts", "contacts_table_refresh": "Refresh contacts",
"contacts_table_refresh_success": "Contacts refreshed successfully", "contacts_table_refresh_success": "Contacts refreshed successfully",
"delete_contact_confirmation": "This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact's data will be lost.",
"first_name": "First Name", "first_name": "First Name",
"last_name": "Last Name", "last_name": "Last Name",
"no_responses_found": "No responses found", "no_responses_found": "No responses found",
@@ -632,6 +639,7 @@
"airtable_integration": "Airtable Integration", "airtable_integration": "Airtable Integration",
"airtable_integration_description": "Sync responses directly with Airtable.", "airtable_integration_description": "Sync responses directly with Airtable.",
"airtable_integration_is_not_configured": "Airtable Integration is not configured", "airtable_integration_is_not_configured": "Airtable Integration is not configured",
"airtable_logo": "Airtable logo",
"connect_with_airtable": "Connect with Airtable", "connect_with_airtable": "Connect with Airtable",
"link_airtable_table": "Link Airtable Table", "link_airtable_table": "Link Airtable Table",
"link_new_table": "Link new table", "link_new_table": "Link new table",
@@ -721,6 +729,7 @@
"slack_integration": "Slack Integration", "slack_integration": "Slack Integration",
"slack_integration_description": "Send responses directly to Slack.", "slack_integration_description": "Send responses directly to Slack.",
"slack_integration_is_not_configured": "Slack Integration is not configured in your instance of Formbricks.", "slack_integration_is_not_configured": "Slack Integration is not configured in your instance of Formbricks.",
"slack_logo": "Slack logo",
"slack_reconnect_button": "Reconnect", "slack_reconnect_button": "Reconnect",
"slack_reconnect_button_description": "<b>Note:</b> We recently changed our Slack integration to also support private channels. Please reconnect your Slack workspace." "slack_reconnect_button_description": "<b>Note:</b> We recently changed our Slack integration to also support private channels. Please reconnect your Slack workspace."
}, },
@@ -905,8 +914,7 @@
"tag_already_exists": "Tag already exists", "tag_already_exists": "Tag already exists",
"tag_deleted": "Tag deleted", "tag_deleted": "Tag deleted",
"tag_updated": "Tag updated", "tag_updated": "Tag updated",
"tags_merged": "Tags merged", "tags_merged": "Tags merged"
"unique_constraint_failed_on_the_fields": "Unique constraint failed on the fields"
}, },
"teams": { "teams": {
"manage_teams": "Manage teams", "manage_teams": "Manage teams",
@@ -979,63 +987,53 @@
"api_keys_description": "Manage API keys to access Formbricks management APIs" "api_keys_description": "Manage API keys to access Formbricks management APIs"
}, },
"billing": { "billing": {
"10000_monthly_responses": "10000 Monthly Responses", "1000_monthly_responses": "Monthly 1,000 Responses",
"1500_monthly_responses": "1500 Monthly Responses", "1_project": "1 Project",
"2000_monthly_identified_users": "2000 Monthly Identified Users", "2000_contacts": "2,000 Contacts",
"30000_monthly_identified_users": "30000 Monthly Identified Users",
"3_projects": "3 Projects", "3_projects": "3 Projects",
"5000_monthly_responses": "5,000 Monthly Responses", "5000_monthly_responses": "5,000 Monthly Responses",
"5_projects": "5 Projects", "7500_contacts": "7,500 Contacts",
"7500_monthly_identified_users": "7500 Monthly Identified Users",
"advanced_targeting": "Advanced Targeting",
"all_integrations": "All Integrations", "all_integrations": "All Integrations",
"all_surveying_features": "All surveying features",
"annually": "Annually", "annually": "Annually",
"api_webhooks": "API & Webhooks", "api_webhooks": "API & Webhooks",
"app_surveys": "App Surveys", "app_surveys": "App Surveys",
"contact_us": "Contact Us", "attribute_based_targeting": "Attribute-based Targeting",
"current": "Current", "current": "Current",
"current_plan": "Current Plan", "current_plan": "Current Plan",
"current_tier_limit": "Current Tier Limit", "current_tier_limit": "Current Tier Limit",
"custom_miu_limit": "Custom MIU limit", "custom": "Custom & Scale",
"custom_contacts_limit": "Custom Contacts Limit",
"custom_project_limit": "Custom Project Limit", "custom_project_limit": "Custom Project Limit",
"customer_success_manager": "Customer Success Manager", "custom_response_limit": "Custom Response Limit",
"email_embedded_surveys": "Email Embedded Surveys", "email_embedded_surveys": "Email Embedded Surveys",
"email_support": "Email Support", "email_follow_ups": "Email Follow-ups",
"enterprise": "Enterprise",
"enterprise_description": "Premium support and custom limits.", "enterprise_description": "Premium support and custom limits.",
"everybody_has_the_free_plan_by_default": "Everybody has the free plan by default!", "everybody_has_the_free_plan_by_default": "Everybody has the free plan by default!",
"everything_in_free": "Everything in Free", "everything_in_free": "Everything in Free",
"everything_in_scale": "Everything in Scale",
"everything_in_startup": "Everything in Startup", "everything_in_startup": "Everything in Startup",
"free": "Free", "free": "Free",
"free_description": "Unlimited Surveys, Team Members, and more.", "free_description": "Unlimited Surveys, Team Members, and more.",
"get_2_months_free": "Get 2 months free", "get_2_months_free": "Get 2 months free",
"get_in_touch": "Get in touch", "get_in_touch": "Get in touch",
"hosted_in_frankfurt": "Hosted in Frankfurt",
"ios_android_sdks": "iOS & Android SDK for mobile surveys",
"link_surveys": "Link Surveys (Shareable)", "link_surveys": "Link Surveys (Shareable)",
"logic_jumps_hidden_fields_recurring_surveys": "Logic Jumps, Hidden Fields, Recurring Surveys, etc.", "logic_jumps_hidden_fields_recurring_surveys": "Logic Jumps, Hidden Fields, Recurring Surveys, etc.",
"manage_card_details": "Manage Card Details", "manage_card_details": "Manage Card Details",
"manage_subscription": "Manage Subscription", "manage_subscription": "Manage Subscription",
"monthly": "Monthly", "monthly": "Monthly",
"monthly_identified_users": "Monthly Identified Users", "monthly_identified_users": "Monthly Identified Users",
"multi_language_surveys": "Multi-Language Surveys",
"per_month": "per month", "per_month": "per month",
"per_year": "per year", "per_year": "per year",
"plan_upgraded_successfully": "Plan upgraded successfully", "plan_upgraded_successfully": "Plan upgraded successfully",
"premium_support_with_slas": "Premium support with SLAs", "premium_support_with_slas": "Premium support with SLAs",
"priority_support": "Priority Support",
"remove_branding": "Remove Branding", "remove_branding": "Remove Branding",
"say_hi": "Say Hi!",
"scale": "Scale",
"scale_description": "Advanced features for scaling your business.",
"startup": "Startup", "startup": "Startup",
"startup_description": "Everything in Free with additional features.", "startup_description": "Everything in Free with additional features.",
"switch_plan": "Switch Plan", "switch_plan": "Switch Plan",
"switch_plan_confirmation_text": "Are you sure you want to switch to the {plan} plan? You will be charged {price} {period}.", "switch_plan_confirmation_text": "Are you sure you want to switch to the {plan} plan? You will be charged {price} {period}.",
"team_access_roles": "Team Access Roles", "team_access_roles": "Team Access Roles",
"technical_onboarding": "Technical Onboarding",
"unable_to_upgrade_plan": "Unable to upgrade plan", "unable_to_upgrade_plan": "Unable to upgrade plan",
"unlimited_apps_websites": "Unlimited Apps & Websites",
"unlimited_miu": "Unlimited MIU", "unlimited_miu": "Unlimited MIU",
"unlimited_projects": "Unlimited Projects", "unlimited_projects": "Unlimited Projects",
"unlimited_responses": "Unlimited Responses", "unlimited_responses": "Unlimited Responses",
@@ -1074,6 +1072,7 @@
"create_new_organization": "Create new organization", "create_new_organization": "Create new organization",
"create_new_organization_description": "Create a new organization to handle a different set of projects.", "create_new_organization_description": "Create a new organization to handle a different set of projects.",
"customize_email_with_a_higher_plan": "Customize email with a higher plan", "customize_email_with_a_higher_plan": "Customize email with a higher plan",
"delete_member_confirmation": "Deleted members will lose access to all projects and surveys of your organization.",
"delete_organization": "Delete Organization", "delete_organization": "Delete Organization",
"delete_organization_description": "Delete organization with all its projects including all surveys, responses, people, actions and attributes", "delete_organization_description": "Delete organization with all its projects including all surveys, responses, people, actions and attributes",
"delete_organization_warning": "Before you proceed with deleting this organization, please be aware of the following consequences:", "delete_organization_warning": "Before you proceed with deleting this organization, please be aware of the following consequences:",
@@ -1230,8 +1229,9 @@
"copy_survey_description": "Copy this survey to another environment", "copy_survey_description": "Copy this survey to another environment",
"copy_survey_error": "Failed to copy survey", "copy_survey_error": "Failed to copy survey",
"copy_survey_link_to_clipboard": "Copy survey link to clipboard", "copy_survey_link_to_clipboard": "Copy survey link to clipboard",
"copy_survey_partially_success": "{success} surveys copied successfully, {error} failed.",
"copy_survey_success": "Survey copied successfully!", "copy_survey_success": "Survey copied successfully!",
"delete_survey_and_responses_warning": "Are you sure you want to delete this survey and all of its responses? This action cannot be undone.", "delete_survey_and_responses_warning": "Are you sure you want to delete this survey and all of its responses?",
"edit": { "edit": {
"1_choose_the_default_language_for_this_survey": "1. Choose the default language for this survey:", "1_choose_the_default_language_for_this_survey": "1. Choose the default language for this survey:",
"2_activate_translation_for_specific_languages": "2. Activate translation for specific languages:", "2_activate_translation_for_specific_languages": "2. Activate translation for specific languages:",
@@ -1304,7 +1304,6 @@
"card_arrangement_for_survey_type_derived": "Card Arrangement for {surveyTypeDerived} Surveys", "card_arrangement_for_survey_type_derived": "Card Arrangement for {surveyTypeDerived} Surveys",
"card_background_color": "Card background color", "card_background_color": "Card background color",
"card_border_color": "Card border color", "card_border_color": "Card border color",
"card_shadow_color": "Card shadow color",
"card_styling": "Card Styling", "card_styling": "Card Styling",
"casual": "Casual", "casual": "Casual",
"caution_edit_duplicate": "Duplicate & edit", "caution_edit_duplicate": "Duplicate & edit",
@@ -1329,7 +1328,6 @@
"change_the_brand_color_of_the_survey": "Change the brand color of the survey.", "change_the_brand_color_of_the_survey": "Change the brand color of the survey.",
"change_the_placement_of_this_survey": "Change the placement of this survey.", "change_the_placement_of_this_survey": "Change the placement of this survey.",
"change_the_question_color_of_the_survey": "Change the question color of the survey.", "change_the_question_color_of_the_survey": "Change the question color of the survey.",
"change_the_shadow_color_of_the_card": "Change the shadow color of the card.",
"changes_saved": "Changes saved.", "changes_saved": "Changes saved.",
"character_limit_toggle_description": "Limit how short or long an answer can be.", "character_limit_toggle_description": "Limit how short or long an answer can be.",
"character_limit_toggle_title": "Add character limits", "character_limit_toggle_title": "Add character limits",
@@ -1716,6 +1714,7 @@
"congrats": "Congrats! Your survey is live.", "congrats": "Congrats! Your survey is live.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Connect your website or app with Formbricks to get started.", "connect_your_website_or_app_with_formbricks_to_get_started": "Connect your website or app with Formbricks to get started.",
"copy_link_to_public_results": "Copy link to public results", "copy_link_to_public_results": "Copy link to public results",
"create_and_manage_segments": "Create and manage your Segments under Contacts > Segments",
"create_single_use_links": "Create single-use links", "create_single_use_links": "Create single-use links",
"create_single_use_links_description": "Accept only one submission per link. Here is how.", "create_single_use_links_description": "Accept only one submission per link. Here is how.",
"custom_range": "Custom range...", "custom_range": "Custom range...",
@@ -1734,12 +1733,19 @@
"embed_on_website": "Embed on website", "embed_on_website": "Embed on website",
"embed_pop_up_survey_title": "How to embed a pop-up survey on your website", "embed_pop_up_survey_title": "How to embed a pop-up survey on your website",
"embed_survey": "Embed survey", "embed_survey": "Embed survey",
"expiry_date_description": "Once the link expires, the recipient cannot respond to survey any longer.",
"expiry_date_optional": "Expiry date (optional)",
"failed_to_copy_link": "Failed to copy link", "failed_to_copy_link": "Failed to copy link",
"filter_added_successfully": "Filter added successfully", "filter_added_successfully": "Filter added successfully",
"filter_updated_successfully": "Filter updated successfully", "filter_updated_successfully": "Filter updated successfully",
"filtered_responses_csv": "Filtered responses (CSV)", "filtered_responses_csv": "Filtered responses (CSV)",
"filtered_responses_excel": "Filtered responses (Excel)", "filtered_responses_excel": "Filtered responses (Excel)",
"formbricks_email_survey_preview": "Formbricks Email Survey Preview", "formbricks_email_survey_preview": "Formbricks Email Survey Preview",
"generate_and_download_links": "Generate & download links",
"generate_personal_links_description": "Generate personal links for a segment and match survey responses to each contact. A CSV of you personal links incl. relevant contact information will be downloaded automatically.",
"generate_personal_links_title": "Maximize insights with personal survey links",
"generating_links": "Generating links",
"generating_links_toast": "Generating links, download will start soon…",
"go_to_setup_checklist": "Go to Setup Checklist \uD83D\uDC49", "go_to_setup_checklist": "Go to Setup Checklist \uD83D\uDC49",
"hide_embed_code": "Hide embed code", "hide_embed_code": "Hide embed code",
"how_to_create_a_panel": "How to create a panel", "how_to_create_a_panel": "How to create a panel",
@@ -1765,12 +1771,18 @@
"last_quarter": "Last quarter", "last_quarter": "Last quarter",
"last_year": "Last year", "last_year": "Last year",
"link_to_public_results_copied": "Link to public results copied", "link_to_public_results_copied": "Link to public results copied",
"links_generated_success_toast": "Links generated successfully, your download will start soon.",
"make_sure_the_survey_type_is_set_to": "Make sure the survey type is set to", "make_sure_the_survey_type_is_set_to": "Make sure the survey type is set to",
"mobile_app": "Mobile app", "mobile_app": "Mobile app",
"no_responses_found": "No responses found", "no_responses_found": "No responses found",
"no_segments_available": "No segments available",
"only_completed": "Only completed", "only_completed": "Only completed",
"other_values_found": "Other values found", "other_values_found": "Other values found",
"overall": "Overall", "overall": "Overall",
"personal_links": "Personal links",
"personal_links_upgrade_prompt_description": "Generate personal links for a segment and link survey responses to each contact.",
"personal_links_upgrade_prompt_title": "Use personal links with a higher plan",
"personal_links_work_with_segments": "Personal links work with segments.",
"publish_to_web": "Publish to web", "publish_to_web": "Publish to web",
"publish_to_web_warning": "You are about to release these survey results to the public.", "publish_to_web_warning": "You are about to release these survey results to the public.",
"publish_to_web_warning_description": "Your survey results will be public. Anyone outside your organization can access them if they have the link.", "publish_to_web_warning_description": "Your survey results will be public. Anyone outside your organization can access them if they have the link.",
@@ -1779,6 +1791,7 @@
"quickstart_web_apps": "Quickstart: Web apps", "quickstart_web_apps": "Quickstart: Web apps",
"quickstart_web_apps_description": "Please follow the Quickstart guide to get started:", "quickstart_web_apps_description": "Please follow the Quickstart guide to get started:",
"results_are_public": "Results are public", "results_are_public": "Results are public",
"select_segment": "Select segment",
"selected_responses_csv": "Selected responses (CSV)", "selected_responses_csv": "Selected responses (CSV)",
"selected_responses_excel": "Selected responses (Excel)", "selected_responses_excel": "Selected responses (Excel)",
"send_preview": "Send preview", "send_preview": "Send preview",
@@ -1804,6 +1817,7 @@
"this_year": "This year", "this_year": "This year",
"time_to_complete": "Time to Complete", "time_to_complete": "Time to Complete",
"to_connect_your_website_with_formbricks": "to connect your website with Formbricks", "to_connect_your_website_with_formbricks": "to connect your website with Formbricks",
"to_create_personal_links_segment_required": "To create personal links for your survey, you need to set up a segment first.",
"ttc_tooltip": "Average time to complete the survey.", "ttc_tooltip": "Average time to complete the survey.",
"unknown_question_type": "Unknown Question Type", "unknown_question_type": "Unknown Question Type",
"unpublish_from_web": "Unpublish from web", "unpublish_from_web": "Unpublish from web",
@@ -2827,4 +2841,4 @@
"understand_purchase_intention_question_3_headline": "What, if anything, is holding you back from making a purchase today?", "understand_purchase_intention_question_3_headline": "What, if anything, is holding you back from making a purchase today?",
"understand_purchase_intention_question_3_placeholder": "Type your answer here..." "understand_purchase_intention_question_3_placeholder": "Type your answer here..."
} }
} }
+41 -27
View File
@@ -108,6 +108,10 @@
"thanks_for_upgrading": "Merci beaucoup d'avoir mis à niveau votre abonnement Formbricks.", "thanks_for_upgrading": "Merci beaucoup d'avoir mis à niveau votre abonnement Formbricks.",
"upgrade_successful": "Mise à niveau réussie" "upgrade_successful": "Mise à niveau réussie"
}, },
"c": {
"link_expired": "Votre lien est expiré.",
"link_expired_description": "Le lien que vous avez utilisé n'est plus valide."
},
"common": { "common": {
"accepted": "Accepté", "accepted": "Accepté",
"account": "Compte", "account": "Compte",
@@ -313,9 +317,11 @@
"question_id": "ID de la question", "question_id": "ID de la question",
"questions": "Questions", "questions": "Questions",
"read_docs": "Lire les documents", "read_docs": "Lire les documents",
"recipients": "Destinataires",
"remove": "Retirer", "remove": "Retirer",
"reorder_and_hide_columns": "Réorganiser et masquer des colonnes", "reorder_and_hide_columns": "Réorganiser et masquer des colonnes",
"report_survey": "Rapport d'enquête", "report_survey": "Rapport d'enquête",
"request_pricing": "Demander la tarification",
"request_trial_license": "Demander une licence d'essai", "request_trial_license": "Demander une licence d'essai",
"reset_to_default": "Réinitialiser par défaut", "reset_to_default": "Réinitialiser par défaut",
"response": "Réponse", "response": "Réponse",
@@ -596,6 +602,7 @@
"contact_not_found": "Aucun contact trouvé", "contact_not_found": "Aucun contact trouvé",
"contacts_table_refresh": "Rafraîchir les contacts", "contacts_table_refresh": "Rafraîchir les contacts",
"contacts_table_refresh_success": "Contacts rafraîchis avec succès", "contacts_table_refresh_success": "Contacts rafraîchis avec succès",
"delete_contact_confirmation": "Cela supprimera toutes les réponses aux enquêtes et les attributs de contact associés à ce contact. Toute la personnalisation et le ciblage basés sur les données de ce contact seront perdus.",
"first_name": "Prénom", "first_name": "Prénom",
"last_name": "Nom de famille", "last_name": "Nom de famille",
"no_responses_found": "Aucune réponse trouvée", "no_responses_found": "Aucune réponse trouvée",
@@ -632,6 +639,7 @@
"airtable_integration": "Intégration Airtable", "airtable_integration": "Intégration Airtable",
"airtable_integration_description": "Synchronisez les réponses directement avec Airtable.", "airtable_integration_description": "Synchronisez les réponses directement avec Airtable.",
"airtable_integration_is_not_configured": "L'intégration Airtable n'est pas configurée", "airtable_integration_is_not_configured": "L'intégration Airtable n'est pas configurée",
"airtable_logo": "Logo Airtable",
"connect_with_airtable": "Se connecter à Airtable", "connect_with_airtable": "Se connecter à Airtable",
"link_airtable_table": "Lier la table Airtable", "link_airtable_table": "Lier la table Airtable",
"link_new_table": "Lier nouvelle table", "link_new_table": "Lier nouvelle table",
@@ -721,6 +729,7 @@
"slack_integration": "Intégration Slack", "slack_integration": "Intégration Slack",
"slack_integration_description": "Envoyez les réponses directement sur Slack.", "slack_integration_description": "Envoyez les réponses directement sur Slack.",
"slack_integration_is_not_configured": "L'intégration Slack n'est pas configurée dans votre instance de Formbricks.", "slack_integration_is_not_configured": "L'intégration Slack n'est pas configurée dans votre instance de Formbricks.",
"slack_logo": "logo Slack",
"slack_reconnect_button": "Reconnecter", "slack_reconnect_button": "Reconnecter",
"slack_reconnect_button_description": "<b>Remarque :</b> Nous avons récemment modifié notre intégration Slack pour prendre en charge les canaux privés. Veuillez reconnecter votre espace de travail Slack." "slack_reconnect_button_description": "<b>Remarque :</b> Nous avons récemment modifié notre intégration Slack pour prendre en charge les canaux privés. Veuillez reconnecter votre espace de travail Slack."
}, },
@@ -905,8 +914,7 @@
"tag_already_exists": "Le tag existe déjà", "tag_already_exists": "Le tag existe déjà",
"tag_deleted": "Tag supprimé", "tag_deleted": "Tag supprimé",
"tag_updated": "Étiquette mise à jour", "tag_updated": "Étiquette mise à jour",
"tags_merged": "Étiquettes fusionnées", "tags_merged": "Étiquettes fusionnées"
"unique_constraint_failed_on_the_fields": "Échec de la contrainte unique sur les champs"
}, },
"teams": { "teams": {
"manage_teams": "Gérer les équipes", "manage_teams": "Gérer les équipes",
@@ -979,63 +987,53 @@
"api_keys_description": "Gérer les clés API pour accéder aux API de gestion de Formbricks" "api_keys_description": "Gérer les clés API pour accéder aux API de gestion de Formbricks"
}, },
"billing": { "billing": {
"10000_monthly_responses": "10000 Réponses Mensuelles", "1000_monthly_responses": "1000 Réponses Mensuelles",
"1500_monthly_responses": "1500 Réponses Mensuelles", "1_project": "1 Projet",
"2000_monthly_identified_users": "2000 Utilisateurs Identifiés Mensuels", "2000_contacts": "2 000 Contacts",
"30000_monthly_identified_users": "30000 Utilisateurs Identifiés Mensuels",
"3_projects": "3 Projets", "3_projects": "3 Projets",
"5000_monthly_responses": "5,000 Réponses Mensuelles", "5000_monthly_responses": "5,000 Réponses Mensuelles",
"5_projects": "5 Projets", "7500_contacts": "7 500 Contacts",
"7500_monthly_identified_users": "7500 Utilisateurs Identifiés Mensuels",
"advanced_targeting": "Ciblage Avancé",
"all_integrations": "Toutes les intégrations", "all_integrations": "Toutes les intégrations",
"all_surveying_features": "Tous les outils d'arpentage",
"annually": "Annuellement", "annually": "Annuellement",
"api_webhooks": "API et Webhooks", "api_webhooks": "API et Webhooks",
"app_surveys": "Sondages d'application", "app_surveys": "Sondages d'application",
"contact_us": "Contactez-nous", "attribute_based_targeting": "Ciblage basé sur les attributs",
"current": "Actuel", "current": "Actuel",
"current_plan": "Plan actuel", "current_plan": "Plan actuel",
"current_tier_limit": "Limite de niveau actuel", "current_tier_limit": "Limite de niveau actuel",
"custom_miu_limit": "Limite MIU personnalisé", "custom": "Personnalisé et Échelle",
"custom_contacts_limit": "Limite de contacts personnalisé",
"custom_project_limit": "Limite de projet personnalisé", "custom_project_limit": "Limite de projet personnalisé",
"customer_success_manager": "Responsable de la réussite client", "custom_response_limit": "Limite de réponse personnalisé",
"email_embedded_surveys": "Sondages intégrés par e-mail", "email_embedded_surveys": "Sondages intégrés par e-mail",
"email_support": "Support par e-mail", "email_follow_ups": "Relances par e-mail",
"enterprise": "Entreprise",
"enterprise_description": "Soutien premium et limites personnalisées.", "enterprise_description": "Soutien premium et limites personnalisées.",
"everybody_has_the_free_plan_by_default": "Tout le monde a le plan gratuit par défaut !", "everybody_has_the_free_plan_by_default": "Tout le monde a le plan gratuit par défaut !",
"everything_in_free": "Tout est gratuit", "everything_in_free": "Tout est gratuit",
"everything_in_scale": "Tout à l'échelle",
"everything_in_startup": "Tout dans le Startup", "everything_in_startup": "Tout dans le Startup",
"free": "Gratuit", "free": "Gratuit",
"free_description": "Sondages illimités, membres d'équipe, et plus encore.", "free_description": "Sondages illimités, membres d'équipe, et plus encore.",
"get_2_months_free": "Obtenez 2 mois gratuits", "get_2_months_free": "Obtenez 2 mois gratuits",
"get_in_touch": "Prenez contact", "get_in_touch": "Prenez contact",
"hosted_in_frankfurt": "Hébergé à Francfort",
"ios_android_sdks": "SDK iOS et Android pour les sondages mobiles",
"link_surveys": "Sondages par lien (partageables)", "link_surveys": "Sondages par lien (partageables)",
"logic_jumps_hidden_fields_recurring_surveys": "Sauts logiques, champs cachés, enquêtes récurrentes, etc.", "logic_jumps_hidden_fields_recurring_surveys": "Sauts logiques, champs cachés, enquêtes récurrentes, etc.",
"manage_card_details": "Gérer les détails de la carte", "manage_card_details": "Gérer les détails de la carte",
"manage_subscription": "Gérer l'abonnement", "manage_subscription": "Gérer l'abonnement",
"monthly": "Mensuel", "monthly": "Mensuel",
"monthly_identified_users": "Utilisateurs Identifiés Mensuels", "monthly_identified_users": "Utilisateurs Identifiés Mensuels",
"multi_language_surveys": "Sondages multilingues",
"per_month": "par mois", "per_month": "par mois",
"per_year": "par an", "per_year": "par an",
"plan_upgraded_successfully": "Plan mis à jour avec succès", "plan_upgraded_successfully": "Plan mis à jour avec succès",
"premium_support_with_slas": "Soutien premium avec SLA", "premium_support_with_slas": "Soutien premium avec SLA",
"priority_support": "Soutien Prioritaire",
"remove_branding": "Supprimer la marque", "remove_branding": "Supprimer la marque",
"say_hi": "Dis bonjour !",
"scale": "Échelle",
"scale_description": "Fonctionnalités avancées pour développer votre entreprise.",
"startup": "Startup", "startup": "Startup",
"startup_description": "Tout est gratuit avec des fonctionnalités supplémentaires.", "startup_description": "Tout est gratuit avec des fonctionnalités supplémentaires.",
"switch_plan": "Changer de plan", "switch_plan": "Changer de plan",
"switch_plan_confirmation_text": "Êtes-vous sûr de vouloir passer au plan {plan} ? Vous serez facturé {price} {period}.", "switch_plan_confirmation_text": "Êtes-vous sûr de vouloir passer au plan {plan} ? Vous serez facturé {price} {period}.",
"team_access_roles": "Rôles d'accès d'équipe", "team_access_roles": "Rôles d'accès d'équipe",
"technical_onboarding": "Intégration technique",
"unable_to_upgrade_plan": "Impossible de mettre à niveau le plan", "unable_to_upgrade_plan": "Impossible de mettre à niveau le plan",
"unlimited_apps_websites": "Applications et sites Web illimités",
"unlimited_miu": "MIU Illimité", "unlimited_miu": "MIU Illimité",
"unlimited_projects": "Projets illimités", "unlimited_projects": "Projets illimités",
"unlimited_responses": "Réponses illimitées", "unlimited_responses": "Réponses illimitées",
@@ -1074,6 +1072,7 @@
"create_new_organization": "Créer une nouvelle organisation", "create_new_organization": "Créer une nouvelle organisation",
"create_new_organization_description": "Créer une nouvelle organisation pour gérer un ensemble différent de projets.", "create_new_organization_description": "Créer une nouvelle organisation pour gérer un ensemble différent de projets.",
"customize_email_with_a_higher_plan": "Personnalisez l'e-mail avec un plan supérieur", "customize_email_with_a_higher_plan": "Personnalisez l'e-mail avec un plan supérieur",
"delete_member_confirmation": "Les membres supprimés perdront l'accès à tous les projets et enquêtes de votre organisation.",
"delete_organization": "Supprimer l'organisation", "delete_organization": "Supprimer l'organisation",
"delete_organization_description": "Supprimer l'organisation avec tous ses projets, y compris toutes les enquêtes, réponses, personnes, actions et attributs.", "delete_organization_description": "Supprimer l'organisation avec tous ses projets, y compris toutes les enquêtes, réponses, personnes, actions et attributs.",
"delete_organization_warning": "Avant de procéder à la suppression de cette organisation, veuillez prendre connaissance des conséquences suivantes :", "delete_organization_warning": "Avant de procéder à la suppression de cette organisation, veuillez prendre connaissance des conséquences suivantes :",
@@ -1230,8 +1229,9 @@
"copy_survey_description": "Copier cette enquête dans un autre environnement", "copy_survey_description": "Copier cette enquête dans un autre environnement",
"copy_survey_error": "Échec de la copie du sondage", "copy_survey_error": "Échec de la copie du sondage",
"copy_survey_link_to_clipboard": "Copier le lien du sondage dans le presse-papiers", "copy_survey_link_to_clipboard": "Copier le lien du sondage dans le presse-papiers",
"copy_survey_partially_success": "{success} enquêtes copiées avec succès, {error} échouées.",
"copy_survey_success": "Enquête copiée avec succès !", "copy_survey_success": "Enquête copiée avec succès !",
"delete_survey_and_responses_warning": "Êtes-vous sûr de vouloir supprimer cette enquête et toutes ses réponses ? Cette action ne peut pas être annulée.", "delete_survey_and_responses_warning": "Êtes-vous sûr de vouloir supprimer cette enquête et toutes ses réponses?",
"edit": { "edit": {
"1_choose_the_default_language_for_this_survey": "1. Choisissez la langue par défaut pour ce sondage :", "1_choose_the_default_language_for_this_survey": "1. Choisissez la langue par défaut pour ce sondage :",
"2_activate_translation_for_specific_languages": "2. Activer la traduction pour des langues spécifiques :", "2_activate_translation_for_specific_languages": "2. Activer la traduction pour des langues spécifiques :",
@@ -1304,7 +1304,6 @@
"card_arrangement_for_survey_type_derived": "Disposition des cartes pour les enquêtes {surveyTypeDerived}", "card_arrangement_for_survey_type_derived": "Disposition des cartes pour les enquêtes {surveyTypeDerived}",
"card_background_color": "Couleur de fond de la carte", "card_background_color": "Couleur de fond de la carte",
"card_border_color": "Couleur de la bordure de la carte", "card_border_color": "Couleur de la bordure de la carte",
"card_shadow_color": "Couleur de l'ombre de la carte",
"card_styling": "Style de carte", "card_styling": "Style de carte",
"casual": "Décontracté", "casual": "Décontracté",
"caution_edit_duplicate": "Dupliquer et modifier", "caution_edit_duplicate": "Dupliquer et modifier",
@@ -1329,7 +1328,6 @@
"change_the_brand_color_of_the_survey": "Changez la couleur de la marque du sondage.", "change_the_brand_color_of_the_survey": "Changez la couleur de la marque du sondage.",
"change_the_placement_of_this_survey": "Changez le placement de cette enquête.", "change_the_placement_of_this_survey": "Changez le placement de cette enquête.",
"change_the_question_color_of_the_survey": "Changez la couleur des questions du sondage.", "change_the_question_color_of_the_survey": "Changez la couleur des questions du sondage.",
"change_the_shadow_color_of_the_card": "Changez la couleur de l'ombre de la carte.",
"changes_saved": "Modifications enregistrées.", "changes_saved": "Modifications enregistrées.",
"character_limit_toggle_description": "Limitez la longueur des réponses.", "character_limit_toggle_description": "Limitez la longueur des réponses.",
"character_limit_toggle_title": "Ajouter des limites de caractères", "character_limit_toggle_title": "Ajouter des limites de caractères",
@@ -1716,6 +1714,7 @@
"congrats": "Félicitations ! Votre enquête est en ligne.", "congrats": "Félicitations ! Votre enquête est en ligne.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Connectez votre site web ou votre application à Formbricks pour commencer.", "connect_your_website_or_app_with_formbricks_to_get_started": "Connectez votre site web ou votre application à Formbricks pour commencer.",
"copy_link_to_public_results": "Copier le lien vers les résultats publics", "copy_link_to_public_results": "Copier le lien vers les résultats publics",
"create_and_manage_segments": "Créez et gérez vos Segments sous Contacts > Segments",
"create_single_use_links": "Créer des liens à usage unique", "create_single_use_links": "Créer des liens à usage unique",
"create_single_use_links_description": "Acceptez uniquement une soumission par lien. Voici comment.", "create_single_use_links_description": "Acceptez uniquement une soumission par lien. Voici comment.",
"custom_range": "Plage personnalisée...", "custom_range": "Plage personnalisée...",
@@ -1734,12 +1733,19 @@
"embed_on_website": "Incorporer sur le site web", "embed_on_website": "Incorporer sur le site web",
"embed_pop_up_survey_title": "Comment intégrer une enquête pop-up sur votre site web", "embed_pop_up_survey_title": "Comment intégrer une enquête pop-up sur votre site web",
"embed_survey": "Intégrer l'enquête", "embed_survey": "Intégrer l'enquête",
"expiry_date_description": "Une fois le lien expiré, le destinataire ne peut plus répondre au sondage.",
"expiry_date_optional": "Date d'expiration (facultatif)",
"failed_to_copy_link": "Échec de la copie du lien", "failed_to_copy_link": "Échec de la copie du lien",
"filter_added_successfully": "Filtre ajouté avec succès", "filter_added_successfully": "Filtre ajouté avec succès",
"filter_updated_successfully": "Filtre mis à jour avec succès", "filter_updated_successfully": "Filtre mis à jour avec succès",
"filtered_responses_csv": "Réponses filtrées (CSV)", "filtered_responses_csv": "Réponses filtrées (CSV)",
"filtered_responses_excel": "Réponses filtrées (Excel)", "filtered_responses_excel": "Réponses filtrées (Excel)",
"formbricks_email_survey_preview": "Aperçu de l'enquête par e-mail Formbricks", "formbricks_email_survey_preview": "Aperçu de l'enquête par e-mail Formbricks",
"generate_and_download_links": "Générer et télécharger les liens",
"generate_personal_links_description": "Générez des liens personnels pour un segment et associez les réponses du sondage à chaque contact. Un fichier CSV de vos liens personnels incluant les informations de contact pertinentes sera téléchargé automatiquement.",
"generate_personal_links_title": "Maximisez les insights avec des liens d'enquête personnels",
"generating_links": "Génération de liens",
"generating_links_toast": "Génération des liens, le téléchargement commencera bientôt…",
"go_to_setup_checklist": "Allez à la liste de contrôle de configuration \uD83D\uDC49", "go_to_setup_checklist": "Allez à la liste de contrôle de configuration \uD83D\uDC49",
"hide_embed_code": "Cacher le code d'intégration", "hide_embed_code": "Cacher le code d'intégration",
"how_to_create_a_panel": "Comment créer un panneau", "how_to_create_a_panel": "Comment créer un panneau",
@@ -1765,12 +1771,18 @@
"last_quarter": "dernier trimestre", "last_quarter": "dernier trimestre",
"last_year": "l'année dernière", "last_year": "l'année dernière",
"link_to_public_results_copied": "Lien vers les résultats publics copié", "link_to_public_results_copied": "Lien vers les résultats publics copié",
"links_generated_success_toast": "Liens générés avec succès, votre téléchargement commencera bientôt.",
"make_sure_the_survey_type_is_set_to": "Assurez-vous que le type d'enquête est défini sur", "make_sure_the_survey_type_is_set_to": "Assurez-vous que le type d'enquête est défini sur",
"mobile_app": "Application mobile", "mobile_app": "Application mobile",
"no_responses_found": "Aucune réponse trouvée", "no_responses_found": "Aucune réponse trouvée",
"no_segments_available": "Aucun segment disponible",
"only_completed": "Uniquement terminé", "only_completed": "Uniquement terminé",
"other_values_found": "D'autres valeurs trouvées", "other_values_found": "D'autres valeurs trouvées",
"overall": "Globalement", "overall": "Globalement",
"personal_links": "Liens personnels",
"personal_links_upgrade_prompt_description": "Générez des liens personnels pour un segment et associez les réponses du sondage à chaque contact.",
"personal_links_upgrade_prompt_title": "Utilisez des liens personnels avec un plan supérieur",
"personal_links_work_with_segments": "Les liens personnels fonctionnent avec les segments.",
"publish_to_web": "Publier sur le web", "publish_to_web": "Publier sur le web",
"publish_to_web_warning": "Vous êtes sur le point de rendre ces résultats d'enquête publics.", "publish_to_web_warning": "Vous êtes sur le point de rendre ces résultats d'enquête publics.",
"publish_to_web_warning_description": "Les résultats de votre enquête seront publics. Toute personne en dehors de votre organisation pourra y accéder si elle a le lien.", "publish_to_web_warning_description": "Les résultats de votre enquête seront publics. Toute personne en dehors de votre organisation pourra y accéder si elle a le lien.",
@@ -1779,6 +1791,7 @@
"quickstart_web_apps": "Démarrage rapide : Applications web", "quickstart_web_apps": "Démarrage rapide : Applications web",
"quickstart_web_apps_description": "Veuillez suivre le guide de démarrage rapide pour commencer :", "quickstart_web_apps_description": "Veuillez suivre le guide de démarrage rapide pour commencer :",
"results_are_public": "Les résultats sont publics.", "results_are_public": "Les résultats sont publics.",
"select_segment": "Sélectionner le segment",
"selected_responses_csv": "Réponses sélectionnées (CSV)", "selected_responses_csv": "Réponses sélectionnées (CSV)",
"selected_responses_excel": "Réponses sélectionnées (Excel)", "selected_responses_excel": "Réponses sélectionnées (Excel)",
"send_preview": "Envoyer un aperçu", "send_preview": "Envoyer un aperçu",
@@ -1804,6 +1817,7 @@
"this_year": "Cette année", "this_year": "Cette année",
"time_to_complete": "Temps à compléter", "time_to_complete": "Temps à compléter",
"to_connect_your_website_with_formbricks": "connecter votre site web à Formbricks", "to_connect_your_website_with_formbricks": "connecter votre site web à Formbricks",
"to_create_personal_links_segment_required": "Pour créer des liens personnels pour votre enquête, vous devez d'abord définir un segment.",
"ttc_tooltip": "Temps moyen pour compléter l'enquête.", "ttc_tooltip": "Temps moyen pour compléter l'enquête.",
"unknown_question_type": "Type de question inconnu", "unknown_question_type": "Type de question inconnu",
"unpublish_from_web": "Désactiver la publication sur le web", "unpublish_from_web": "Désactiver la publication sur le web",
@@ -2827,4 +2841,4 @@
"understand_purchase_intention_question_3_headline": "Qu'est-ce qui vous empêche de faire un achat aujourd'hui, s'il y a quelque chose ?", "understand_purchase_intention_question_3_headline": "Qu'est-ce qui vous empêche de faire un achat aujourd'hui, s'il y a quelque chose ?",
"understand_purchase_intention_question_3_placeholder": "Entrez votre réponse ici..." "understand_purchase_intention_question_3_placeholder": "Entrez votre réponse ici..."
} }
} }
+41 -27
View File
@@ -108,6 +108,10 @@
"thanks_for_upgrading": "Valeu demais por atualizar sua assinatura do Formbricks.", "thanks_for_upgrading": "Valeu demais por atualizar sua assinatura do Formbricks.",
"upgrade_successful": "Atualização bem-sucedida" "upgrade_successful": "Atualização bem-sucedida"
}, },
"c": {
"link_expired": "Seu link está expirado.",
"link_expired_description": "O link que você usou não é mais válido."
},
"common": { "common": {
"accepted": "Aceito", "accepted": "Aceito",
"account": "conta", "account": "conta",
@@ -313,9 +317,11 @@
"question_id": "ID da Pergunta", "question_id": "ID da Pergunta",
"questions": "Perguntas", "questions": "Perguntas",
"read_docs": "Ler Documentos", "read_docs": "Ler Documentos",
"recipients": "Destinatários",
"remove": "remover", "remove": "remover",
"reorder_and_hide_columns": "Reordenar e ocultar colunas", "reorder_and_hide_columns": "Reordenar e ocultar colunas",
"report_survey": "Relatório de Pesquisa", "report_survey": "Relatório de Pesquisa",
"request_pricing": "Solicitar Preços",
"request_trial_license": "Pedir licença de teste", "request_trial_license": "Pedir licença de teste",
"reset_to_default": "Restaurar para o padrão", "reset_to_default": "Restaurar para o padrão",
"response": "Resposta", "response": "Resposta",
@@ -596,6 +602,7 @@
"contact_not_found": "Nenhum contato encontrado", "contact_not_found": "Nenhum contato encontrado",
"contacts_table_refresh": "Atualizar contatos", "contacts_table_refresh": "Atualizar contatos",
"contacts_table_refresh_success": "Contatos atualizados com sucesso", "contacts_table_refresh_success": "Contatos atualizados com sucesso",
"delete_contact_confirmation": "Isso irá apagar todas as respostas da pesquisa e atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.",
"first_name": "Primeiro Nome", "first_name": "Primeiro Nome",
"last_name": "Sobrenome", "last_name": "Sobrenome",
"no_responses_found": "Nenhuma resposta encontrada", "no_responses_found": "Nenhuma resposta encontrada",
@@ -632,6 +639,7 @@
"airtable_integration": "Integração com Airtable", "airtable_integration": "Integração com Airtable",
"airtable_integration_description": "Sincronize respostas diretamente com o Airtable.", "airtable_integration_description": "Sincronize respostas diretamente com o Airtable.",
"airtable_integration_is_not_configured": "A integração com o Airtable não está configurada", "airtable_integration_is_not_configured": "A integração com o Airtable não está configurada",
"airtable_logo": "Logo do Airtable",
"connect_with_airtable": "Conectar com o Airtable", "connect_with_airtable": "Conectar com o Airtable",
"link_airtable_table": "Vincular Tabela do Airtable", "link_airtable_table": "Vincular Tabela do Airtable",
"link_new_table": "Vincular nova tabela", "link_new_table": "Vincular nova tabela",
@@ -721,6 +729,7 @@
"slack_integration": "Integração com o Slack", "slack_integration": "Integração com o Slack",
"slack_integration_description": "Manda as respostas direto pro Slack.", "slack_integration_description": "Manda as respostas direto pro Slack.",
"slack_integration_is_not_configured": "A integração do Slack não está configurada na sua instância do Formbricks.", "slack_integration_is_not_configured": "A integração do Slack não está configurada na sua instância do Formbricks.",
"slack_logo": "Logotipo do Slack",
"slack_reconnect_button": "Reconectar", "slack_reconnect_button": "Reconectar",
"slack_reconnect_button_description": "<b>Observação:</b> Recentemente, alteramos nossa integração com o Slack para também suportar canais privados. Por favor, reconecte seu workspace do Slack." "slack_reconnect_button_description": "<b>Observação:</b> Recentemente, alteramos nossa integração com o Slack para também suportar canais privados. Por favor, reconecte seu workspace do Slack."
}, },
@@ -905,8 +914,7 @@
"tag_already_exists": "Tag já existe", "tag_already_exists": "Tag já existe",
"tag_deleted": "Tag apagada", "tag_deleted": "Tag apagada",
"tag_updated": "Tag atualizada", "tag_updated": "Tag atualizada",
"tags_merged": "Tags mescladas", "tags_merged": "Tags mescladas"
"unique_constraint_failed_on_the_fields": "Falha na restrição única nos campos"
}, },
"teams": { "teams": {
"manage_teams": "Gerenciar Equipes", "manage_teams": "Gerenciar Equipes",
@@ -979,63 +987,53 @@
"api_keys_description": "Gerencie chaves de API para acessar as APIs de gerenciamento do Formbricks" "api_keys_description": "Gerencie chaves de API para acessar as APIs de gerenciamento do Formbricks"
}, },
"billing": { "billing": {
"10000_monthly_responses": "10000 Respostas Mensais", "1000_monthly_responses": "1000 Respostas Mensais",
"1500_monthly_responses": "1500 Respostas Mensais", "1_project": "1 Projeto",
"2000_monthly_identified_users": "2000 Usuários Identificados Mensalmente", "2000_contacts": "2.000 Contatos",
"30000_monthly_identified_users": "30000 Usuários Identificados Mensalmente",
"3_projects": "3 Projetos", "3_projects": "3 Projetos",
"5000_monthly_responses": "5,000 Respostas Mensais", "5000_monthly_responses": "5,000 Respostas Mensais",
"5_projects": "5 Projetos", "7500_contacts": "7.500 Contatos",
"7500_monthly_identified_users": "7500 Usuários Identificados Mensalmente",
"advanced_targeting": "Mira Avançada",
"all_integrations": "Todas as Integrações", "all_integrations": "Todas as Integrações",
"all_surveying_features": "Todos os recursos de levantamento",
"annually": "anualmente", "annually": "anualmente",
"api_webhooks": "API e Webhooks", "api_webhooks": "API e Webhooks",
"app_surveys": "Pesquisas de App", "app_surveys": "Pesquisas de App",
"contact_us": "Fale Conosco", "attribute_based_targeting": "Segmentação Baseada em Atributos",
"current": "atual", "current": "atual",
"current_plan": "Plano Atual", "current_plan": "Plano Atual",
"current_tier_limit": "Limite Atual de Nível", "current_tier_limit": "Limite Atual de Nível",
"custom_miu_limit": "Limite MIU personalizado", "custom": "Personalizado e Escala",
"custom_contacts_limit": "Limite de Contatos Personalizado",
"custom_project_limit": "Limite de Projeto Personalizado", "custom_project_limit": "Limite de Projeto Personalizado",
"customer_success_manager": "Gerente de Sucesso do Cliente", "custom_response_limit": "Limite de Resposta Personalizado",
"email_embedded_surveys": "Pesquisas Incorporadas no Email", "email_embedded_surveys": "Pesquisas Incorporadas no Email",
"email_support": "Suporte por Email", "email_follow_ups": "Acompanhamentos por Email",
"enterprise": "Empresa",
"enterprise_description": "Suporte premium e limites personalizados.", "enterprise_description": "Suporte premium e limites personalizados.",
"everybody_has_the_free_plan_by_default": "Todo mundo tem o plano gratuito por padrão!", "everybody_has_the_free_plan_by_default": "Todo mundo tem o plano gratuito por padrão!",
"everything_in_free": "Tudo de graça", "everything_in_free": "Tudo de graça",
"everything_in_scale": "Tudo em Escala",
"everything_in_startup": "Tudo em Startup", "everything_in_startup": "Tudo em Startup",
"free": "grátis", "free": "grátis",
"free_description": "Pesquisas ilimitadas, membros da equipe e mais.", "free_description": "Pesquisas ilimitadas, membros da equipe e mais.",
"get_2_months_free": "Ganhe 2 meses grátis", "get_2_months_free": "Ganhe 2 meses grátis",
"get_in_touch": "Entre em contato", "get_in_touch": "Entre em contato",
"hosted_in_frankfurt": "Hospedado em Frankfurt",
"ios_android_sdks": "SDK para iOS e Android para pesquisas móveis",
"link_surveys": "Link de Pesquisas (Compartilhável)", "link_surveys": "Link de Pesquisas (Compartilhável)",
"logic_jumps_hidden_fields_recurring_surveys": "Pulos Lógicos, Campos Ocultos, Pesquisas Recorrentes, etc.", "logic_jumps_hidden_fields_recurring_surveys": "Pulos Lógicos, Campos Ocultos, Pesquisas Recorrentes, etc.",
"manage_card_details": "Gerenciar Detalhes do Cartão", "manage_card_details": "Gerenciar Detalhes do Cartão",
"manage_subscription": "Gerenciar Assinatura", "manage_subscription": "Gerenciar Assinatura",
"monthly": "mensal", "monthly": "mensal",
"monthly_identified_users": "Usuários Identificados Mensalmente", "monthly_identified_users": "Usuários Identificados Mensalmente",
"multi_language_surveys": "Pesquisas Multilíngues",
"per_month": "por mês", "per_month": "por mês",
"per_year": "por ano", "per_year": "por ano",
"plan_upgraded_successfully": "Plano atualizado com sucesso", "plan_upgraded_successfully": "Plano atualizado com sucesso",
"premium_support_with_slas": "Suporte premium com SLAs", "premium_support_with_slas": "Suporte premium com SLAs",
"priority_support": "Suporte Prioritário",
"remove_branding": "Remover Marca", "remove_branding": "Remover Marca",
"say_hi": "Diz oi!",
"scale": "escala",
"scale_description": "Recursos avançados pra escalar seu negócio.",
"startup": "startup", "startup": "startup",
"startup_description": "Tudo no Grátis com recursos adicionais.", "startup_description": "Tudo no Grátis com recursos adicionais.",
"switch_plan": "Mudar Plano", "switch_plan": "Mudar Plano",
"switch_plan_confirmation_text": "Tem certeza de que deseja mudar para o plano {plan}? Você será cobrado {price} {period}.", "switch_plan_confirmation_text": "Tem certeza de que deseja mudar para o plano {plan}? Você será cobrado {price} {period}.",
"team_access_roles": "Funções de Acesso da Equipe", "team_access_roles": "Funções de Acesso da Equipe",
"technical_onboarding": "Integração Técnica",
"unable_to_upgrade_plan": "Não foi possível atualizar o plano", "unable_to_upgrade_plan": "Não foi possível atualizar o plano",
"unlimited_apps_websites": "Apps e Sites Ilimitados",
"unlimited_miu": "MIU Ilimitado", "unlimited_miu": "MIU Ilimitado",
"unlimited_projects": "Projetos Ilimitados", "unlimited_projects": "Projetos Ilimitados",
"unlimited_responses": "Respostas Ilimitadas", "unlimited_responses": "Respostas Ilimitadas",
@@ -1074,6 +1072,7 @@
"create_new_organization": "Criar nova organização", "create_new_organization": "Criar nova organização",
"create_new_organization_description": "Criar uma nova organização para lidar com um conjunto diferente de projetos.", "create_new_organization_description": "Criar uma nova organização para lidar com um conjunto diferente de projetos.",
"customize_email_with_a_higher_plan": "Personalize o email com um plano superior", "customize_email_with_a_higher_plan": "Personalize o email com um plano superior",
"delete_member_confirmation": "Membros apagados perderão acesso a todos os projetos e pesquisas da sua organização.",
"delete_organization": "Excluir Organização", "delete_organization": "Excluir Organização",
"delete_organization_description": "Excluir organização com todos os seus projetos, incluindo todas as pesquisas, respostas, pessoas, ações e atributos", "delete_organization_description": "Excluir organização com todos os seus projetos, incluindo todas as pesquisas, respostas, pessoas, ações e atributos",
"delete_organization_warning": "Antes de continuar com a exclusão desta organização, esteja ciente das seguintes consequências:", "delete_organization_warning": "Antes de continuar com a exclusão desta organização, esteja ciente das seguintes consequências:",
@@ -1230,8 +1229,9 @@
"copy_survey_description": "Copiar essa pesquisa para outro ambiente", "copy_survey_description": "Copiar essa pesquisa para outro ambiente",
"copy_survey_error": "Falha ao copiar pesquisa", "copy_survey_error": "Falha ao copiar pesquisa",
"copy_survey_link_to_clipboard": "Copiar link da pesquisa para a área de transferência", "copy_survey_link_to_clipboard": "Copiar link da pesquisa para a área de transferência",
"copy_survey_partially_success": "{success} pesquisas copiadas com sucesso, {error} falharam.",
"copy_survey_success": "Pesquisa copiada com sucesso!", "copy_survey_success": "Pesquisa copiada com sucesso!",
"delete_survey_and_responses_warning": "Você tem certeza de que quer deletar essa pesquisa e todas as suas respostas? Essa ação não pode ser desfeita.", "delete_survey_and_responses_warning": "Você tem certeza de que quer deletar essa pesquisa e todas as suas respostas?",
"edit": { "edit": {
"1_choose_the_default_language_for_this_survey": "1. Escolha o idioma padrão para essa pesquisa:", "1_choose_the_default_language_for_this_survey": "1. Escolha o idioma padrão para essa pesquisa:",
"2_activate_translation_for_specific_languages": "2. Ativar tradução para idiomas específicos:", "2_activate_translation_for_specific_languages": "2. Ativar tradução para idiomas específicos:",
@@ -1304,7 +1304,6 @@
"card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Pesquisas {surveyTypeDerived}", "card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Pesquisas {surveyTypeDerived}",
"card_background_color": "Cor de fundo do cartão", "card_background_color": "Cor de fundo do cartão",
"card_border_color": "Cor da borda do cartão", "card_border_color": "Cor da borda do cartão",
"card_shadow_color": "cor da sombra do cartão",
"card_styling": "Estilização de Cartão", "card_styling": "Estilização de Cartão",
"casual": "Casual", "casual": "Casual",
"caution_edit_duplicate": "Duplicar e editar", "caution_edit_duplicate": "Duplicar e editar",
@@ -1329,7 +1328,6 @@
"change_the_brand_color_of_the_survey": "Muda a cor da marca da pesquisa.", "change_the_brand_color_of_the_survey": "Muda a cor da marca da pesquisa.",
"change_the_placement_of_this_survey": "Muda a posição dessa pesquisa.", "change_the_placement_of_this_survey": "Muda a posição dessa pesquisa.",
"change_the_question_color_of_the_survey": "Muda a cor da pergunta da pesquisa.", "change_the_question_color_of_the_survey": "Muda a cor da pergunta da pesquisa.",
"change_the_shadow_color_of_the_card": "Muda a cor da sombra do cartão.",
"changes_saved": "Mudanças salvas.", "changes_saved": "Mudanças salvas.",
"character_limit_toggle_description": "Limite o quão curta ou longa uma resposta pode ser.", "character_limit_toggle_description": "Limite o quão curta ou longa uma resposta pode ser.",
"character_limit_toggle_title": "Adicionar limites de caracteres", "character_limit_toggle_title": "Adicionar limites de caracteres",
@@ -1716,6 +1714,7 @@
"congrats": "Parabéns! Sua pesquisa está no ar.", "congrats": "Parabéns! Sua pesquisa está no ar.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Conecte seu site ou app com o Formbricks para começar.", "connect_your_website_or_app_with_formbricks_to_get_started": "Conecte seu site ou app com o Formbricks para começar.",
"copy_link_to_public_results": "Copiar link para resultados públicos", "copy_link_to_public_results": "Copiar link para resultados públicos",
"create_and_manage_segments": "Crie e gerencie seus Segmentos em Contatos > Segmentos",
"create_single_use_links": "Crie links de uso único", "create_single_use_links": "Crie links de uso único",
"create_single_use_links_description": "Aceite apenas uma submissão por link. Aqui está como.", "create_single_use_links_description": "Aceite apenas uma submissão por link. Aqui está como.",
"custom_range": "Intervalo personalizado...", "custom_range": "Intervalo personalizado...",
@@ -1734,12 +1733,19 @@
"embed_on_website": "Incorporar no site", "embed_on_website": "Incorporar no site",
"embed_pop_up_survey_title": "Como incorporar uma pesquisa pop-up no seu site", "embed_pop_up_survey_title": "Como incorporar uma pesquisa pop-up no seu site",
"embed_survey": "Incorporar pesquisa", "embed_survey": "Incorporar pesquisa",
"expiry_date_description": "Quando o link expirar, o destinatário não poderá mais responder à pesquisa.",
"expiry_date_optional": "Data de expiração (opcional)",
"failed_to_copy_link": "Falha ao copiar link", "failed_to_copy_link": "Falha ao copiar link",
"filter_added_successfully": "Filtro adicionado com sucesso", "filter_added_successfully": "Filtro adicionado com sucesso",
"filter_updated_successfully": "Filtro atualizado com sucesso", "filter_updated_successfully": "Filtro atualizado com sucesso",
"filtered_responses_csv": "Respostas filtradas (CSV)", "filtered_responses_csv": "Respostas filtradas (CSV)",
"filtered_responses_excel": "Respostas filtradas (Excel)", "filtered_responses_excel": "Respostas filtradas (Excel)",
"formbricks_email_survey_preview": "Prévia da Pesquisa por E-mail do Formbricks", "formbricks_email_survey_preview": "Prévia da Pesquisa por E-mail do Formbricks",
"generate_and_download_links": "Gerar & baixar links",
"generate_personal_links_description": "Gerar links pessoais para um segmento e associar respostas de pesquisa a cada contato. Um CSV dos seus links pessoais com as informações de contato relevantes será baixado automaticamente.",
"generate_personal_links_title": "Maximize insights com links de pesquisa personalizados",
"generating_links": "Gerando links",
"generating_links_toast": "Gerando links, o download começará em breve…",
"go_to_setup_checklist": "Vai para a Lista de Configuração \uD83D\uDC49", "go_to_setup_checklist": "Vai para a Lista de Configuração \uD83D\uDC49",
"hide_embed_code": "Esconder código de incorporação", "hide_embed_code": "Esconder código de incorporação",
"how_to_create_a_panel": "Como criar um painel", "how_to_create_a_panel": "Como criar um painel",
@@ -1765,12 +1771,18 @@
"last_quarter": "Último trimestre", "last_quarter": "Último trimestre",
"last_year": "Último ano", "last_year": "Último ano",
"link_to_public_results_copied": "Link pros resultados públicos copiado", "link_to_public_results_copied": "Link pros resultados públicos copiado",
"links_generated_success_toast": "Links gerados com sucesso, o download começará em breve.",
"make_sure_the_survey_type_is_set_to": "Certifique-se de que o tipo de pesquisa esteja definido como", "make_sure_the_survey_type_is_set_to": "Certifique-se de que o tipo de pesquisa esteja definido como",
"mobile_app": "app de celular", "mobile_app": "app de celular",
"no_responses_found": "Nenhuma resposta encontrada", "no_responses_found": "Nenhuma resposta encontrada",
"no_segments_available": "Nenhum segmento disponível",
"only_completed": "Somente concluído", "only_completed": "Somente concluído",
"other_values_found": "Outros valores encontrados", "other_values_found": "Outros valores encontrados",
"overall": "No geral", "overall": "No geral",
"personal_links": "Links pessoais",
"personal_links_upgrade_prompt_description": "Gerar links pessoais para um segmento e vincular respostas de pesquisa a cada contato.",
"personal_links_upgrade_prompt_title": "Use links pessoais com um plano superior",
"personal_links_work_with_segments": "Links pessoais funcionam com segmentos.",
"publish_to_web": "Publicar na web", "publish_to_web": "Publicar na web",
"publish_to_web_warning": "Você está prestes a divulgar esses resultados da pesquisa para o público.", "publish_to_web_warning": "Você está prestes a divulgar esses resultados da pesquisa para o público.",
"publish_to_web_warning_description": "Os resultados da sua pesquisa serão públicos. Qualquer pessoa fora da sua organização pode acessá-los se tiver o link.", "publish_to_web_warning_description": "Os resultados da sua pesquisa serão públicos. Qualquer pessoa fora da sua organização pode acessá-los se tiver o link.",
@@ -1779,6 +1791,7 @@
"quickstart_web_apps": "Início rápido: Aplicativos web", "quickstart_web_apps": "Início rápido: Aplicativos web",
"quickstart_web_apps_description": "Por favor, siga o guia de início rápido para começar:", "quickstart_web_apps_description": "Por favor, siga o guia de início rápido para começar:",
"results_are_public": "Os resultados são públicos", "results_are_public": "Os resultados são públicos",
"select_segment": "Selecionar segmento",
"selected_responses_csv": "Respostas selecionadas (CSV)", "selected_responses_csv": "Respostas selecionadas (CSV)",
"selected_responses_excel": "Respostas selecionadas (Excel)", "selected_responses_excel": "Respostas selecionadas (Excel)",
"send_preview": "Enviar prévia", "send_preview": "Enviar prévia",
@@ -1804,6 +1817,7 @@
"this_year": "Este ano", "this_year": "Este ano",
"time_to_complete": "Tempo para Concluir", "time_to_complete": "Tempo para Concluir",
"to_connect_your_website_with_formbricks": "conectar seu site com o Formbricks", "to_connect_your_website_with_formbricks": "conectar seu site com o Formbricks",
"to_create_personal_links_segment_required": "Para criar links pessoais para sua pesquisa, você precisa configurar um segmento primeiro.",
"ttc_tooltip": "Tempo médio para completar a pesquisa.", "ttc_tooltip": "Tempo médio para completar a pesquisa.",
"unknown_question_type": "Tipo de pergunta desconhecido", "unknown_question_type": "Tipo de pergunta desconhecido",
"unpublish_from_web": "Despublicar da web", "unpublish_from_web": "Despublicar da web",
@@ -2827,4 +2841,4 @@
"understand_purchase_intention_question_3_headline": "O que, se é que tem algo, está te impedindo de fazer a compra hoje?", "understand_purchase_intention_question_3_headline": "O que, se é que tem algo, está te impedindo de fazer a compra hoje?",
"understand_purchase_intention_question_3_placeholder": "Digite sua resposta aqui..." "understand_purchase_intention_question_3_placeholder": "Digite sua resposta aqui..."
} }
} }
+41 -27
View File
@@ -108,6 +108,10 @@
"thanks_for_upgrading": "Muito obrigado por atualizar a sua subscrição do Formbricks.", "thanks_for_upgrading": "Muito obrigado por atualizar a sua subscrição do Formbricks.",
"upgrade_successful": "Atualização bem-sucedida" "upgrade_successful": "Atualização bem-sucedida"
}, },
"c": {
"link_expired": "O seu link expirou.",
"link_expired_description": "O link que utilizou já não é válido."
},
"common": { "common": {
"accepted": "Aceite", "accepted": "Aceite",
"account": "Conta", "account": "Conta",
@@ -313,9 +317,11 @@
"question_id": "ID da pergunta", "question_id": "ID da pergunta",
"questions": "Perguntas", "questions": "Perguntas",
"read_docs": "Ler Documentos", "read_docs": "Ler Documentos",
"recipients": "Destinatários",
"remove": "Remover", "remove": "Remover",
"reorder_and_hide_columns": "Reordenar e ocultar colunas", "reorder_and_hide_columns": "Reordenar e ocultar colunas",
"report_survey": "Relatório de Inquérito", "report_survey": "Relatório de Inquérito",
"request_pricing": "Pedido de Preços",
"request_trial_license": "Solicitar licença de teste", "request_trial_license": "Solicitar licença de teste",
"reset_to_default": "Repor para o padrão", "reset_to_default": "Repor para o padrão",
"response": "Resposta", "response": "Resposta",
@@ -596,6 +602,7 @@
"contact_not_found": "Nenhum contacto encontrado", "contact_not_found": "Nenhum contacto encontrado",
"contacts_table_refresh": "Atualizar contactos", "contacts_table_refresh": "Atualizar contactos",
"contacts_table_refresh_success": "Contactos atualizados com sucesso", "contacts_table_refresh_success": "Contactos atualizados com sucesso",
"delete_contact_confirmation": "Isto irá eliminar todas as respostas das pesquisas e os atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.",
"first_name": "Primeiro Nome", "first_name": "Primeiro Nome",
"last_name": "Apelido", "last_name": "Apelido",
"no_responses_found": "Nenhuma resposta encontrada", "no_responses_found": "Nenhuma resposta encontrada",
@@ -632,6 +639,7 @@
"airtable_integration": "Integração com o Airtable", "airtable_integration": "Integração com o Airtable",
"airtable_integration_description": "Sincronize respostas diretamente com o Airtable.", "airtable_integration_description": "Sincronize respostas diretamente com o Airtable.",
"airtable_integration_is_not_configured": "A integração com o Airtable não está configurada", "airtable_integration_is_not_configured": "A integração com o Airtable não está configurada",
"airtable_logo": "logotipo Airtable",
"connect_with_airtable": "Ligar ao Airtable", "connect_with_airtable": "Ligar ao Airtable",
"link_airtable_table": "Ligar Tabela Airtable", "link_airtable_table": "Ligar Tabela Airtable",
"link_new_table": "Ligar nova tabela", "link_new_table": "Ligar nova tabela",
@@ -721,6 +729,7 @@
"slack_integration": "Integração com Slack", "slack_integration": "Integração com Slack",
"slack_integration_description": "Enviar respostas diretamente para o Slack.", "slack_integration_description": "Enviar respostas diretamente para o Slack.",
"slack_integration_is_not_configured": "A integração com o Slack não está configurada na sua instância do Formbricks.", "slack_integration_is_not_configured": "A integração com o Slack não está configurada na sua instância do Formbricks.",
"slack_logo": "Logótipo Slack",
"slack_reconnect_button": "Reconectar", "slack_reconnect_button": "Reconectar",
"slack_reconnect_button_description": "<b>Nota:</b> Recentemente alterámos a nossa integração com o Slack para também suportar canais privados. Por favor, reconecte o seu espaço de trabalho do Slack." "slack_reconnect_button_description": "<b>Nota:</b> Recentemente alterámos a nossa integração com o Slack para também suportar canais privados. Por favor, reconecte o seu espaço de trabalho do Slack."
}, },
@@ -905,8 +914,7 @@
"tag_already_exists": "A etiqueta já existe", "tag_already_exists": "A etiqueta já existe",
"tag_deleted": "Etiqueta eliminada", "tag_deleted": "Etiqueta eliminada",
"tag_updated": "Etiqueta atualizada", "tag_updated": "Etiqueta atualizada",
"tags_merged": "Etiquetas fundidas", "tags_merged": "Etiquetas fundidas"
"unique_constraint_failed_on_the_fields": "A restrição de unicidade falhou nos campos"
}, },
"teams": { "teams": {
"manage_teams": "Gerir equipas", "manage_teams": "Gerir equipas",
@@ -979,63 +987,53 @@
"api_keys_description": "Gerir chaves API para aceder às APIs de gestão do Formbricks" "api_keys_description": "Gerir chaves API para aceder às APIs de gestão do Formbricks"
}, },
"billing": { "billing": {
"10000_monthly_responses": "10000 Respostas Mensais", "1000_monthly_responses": "1000 Respostas Mensais",
"1500_monthly_responses": "1500 Respostas Mensais", "1_project": "1 Projeto",
"2000_monthly_identified_users": "2000 Utilizadores Identificados Mensalmente", "2000_contacts": "2,000 Contactos",
"30000_monthly_identified_users": "30000 Utilizadores Identificados Mensalmente",
"3_projects": "3 Projetos", "3_projects": "3 Projetos",
"5000_monthly_responses": "5,000 Respostas Mensais", "5000_monthly_responses": "5,000 Respostas Mensais",
"5_projects": "5 Projetos", "7500_contacts": "7,500 Contactos",
"7500_monthly_identified_users": "7500 Utilizadores Identificados Mensalmente",
"advanced_targeting": "Segmentação Avançada",
"all_integrations": "Todas as Integrações", "all_integrations": "Todas as Integrações",
"all_surveying_features": "Todas as funcionalidades de inquérito",
"annually": "Anualmente", "annually": "Anualmente",
"api_webhooks": "API e Webhooks", "api_webhooks": "API e Webhooks",
"app_surveys": "Inquéritos da Aplicação", "app_surveys": "Inquéritos da Aplicação",
"contact_us": "Contacte-nos", "attribute_based_targeting": "Segmentação Baseada em Atributos",
"current": "Atual", "current": "Atual",
"current_plan": "Plano Atual", "current_plan": "Plano Atual",
"current_tier_limit": "Limite Atual do Nível", "current_tier_limit": "Limite Atual do Nível",
"custom_miu_limit": "Limite MIU Personalizado", "custom": "Personalizado e Escala",
"custom_contacts_limit": "Limite de Contactos Personalizado",
"custom_project_limit": "Limite de Projeto Personalizado", "custom_project_limit": "Limite de Projeto Personalizado",
"customer_success_manager": "Gestor de Sucesso do Cliente", "custom_response_limit": "Limite de Resposta Personalizado",
"email_embedded_surveys": "Inquéritos Incorporados no Email", "email_embedded_surveys": "Inquéritos Incorporados no Email",
"email_support": "Suporte por Email", "email_follow_ups": "Acompanhamentos por Email",
"enterprise": "Empresa",
"enterprise_description": "Suporte premium e limites personalizados.", "enterprise_description": "Suporte premium e limites personalizados.",
"everybody_has_the_free_plan_by_default": "Todos têm o plano gratuito por defeito!", "everybody_has_the_free_plan_by_default": "Todos têm o plano gratuito por defeito!",
"everything_in_free": "Tudo em Gratuito", "everything_in_free": "Tudo em Gratuito",
"everything_in_scale": "Tudo em Escala",
"everything_in_startup": "Tudo em Startup", "everything_in_startup": "Tudo em Startup",
"free": "Grátis", "free": "Grátis",
"free_description": "Inquéritos ilimitados, membros da equipa e mais.", "free_description": "Inquéritos ilimitados, membros da equipa e mais.",
"get_2_months_free": "Obtenha 2 meses grátis", "get_2_months_free": "Obtenha 2 meses grátis",
"get_in_touch": "Entre em contacto", "get_in_touch": "Entre em contacto",
"hosted_in_frankfurt": "Hospedado em Frankfurt",
"ios_android_sdks": "SDK iOS e Android para inquéritos móveis",
"link_surveys": "Ligar Inquéritos (Partilhável)", "link_surveys": "Ligar Inquéritos (Partilhável)",
"logic_jumps_hidden_fields_recurring_surveys": "Saltos Lógicos, Campos Ocultos, Inquéritos Recorrentes, etc.", "logic_jumps_hidden_fields_recurring_surveys": "Saltos Lógicos, Campos Ocultos, Inquéritos Recorrentes, etc.",
"manage_card_details": "Gerir Detalhes do Cartão", "manage_card_details": "Gerir Detalhes do Cartão",
"manage_subscription": "Gerir Subscrição", "manage_subscription": "Gerir Subscrição",
"monthly": "Mensal", "monthly": "Mensal",
"monthly_identified_users": "Utilizadores Identificados Mensalmente", "monthly_identified_users": "Utilizadores Identificados Mensalmente",
"multi_language_surveys": "Inquéritos Multilingues",
"per_month": "por mês", "per_month": "por mês",
"per_year": "por ano", "per_year": "por ano",
"plan_upgraded_successfully": "Plano atualizado com sucesso", "plan_upgraded_successfully": "Plano atualizado com sucesso",
"premium_support_with_slas": "Suporte premium com SLAs", "premium_support_with_slas": "Suporte premium com SLAs",
"priority_support": "Suporte Prioritário",
"remove_branding": "Remover Marca", "remove_branding": "Remover Marca",
"say_hi": "Diga Olá!",
"scale": "Escala",
"scale_description": "Funcionalidades avançadas para escalar o seu negócio.",
"startup": "Inicialização", "startup": "Inicialização",
"startup_description": "Tudo no plano Gratuito com funcionalidades adicionais.", "startup_description": "Tudo no plano Gratuito com funcionalidades adicionais.",
"switch_plan": "Mudar Plano", "switch_plan": "Mudar Plano",
"switch_plan_confirmation_text": "Tem a certeza de que deseja mudar para o plano {plan}? Ser-lhe-á cobrado {price} {period}.", "switch_plan_confirmation_text": "Tem a certeza de que deseja mudar para o plano {plan}? Ser-lhe-á cobrado {price} {period}.",
"team_access_roles": "Funções de Acesso da Equipa", "team_access_roles": "Funções de Acesso da Equipa",
"technical_onboarding": "Integração Técnica",
"unable_to_upgrade_plan": "Não é possível atualizar o plano", "unable_to_upgrade_plan": "Não é possível atualizar o plano",
"unlimited_apps_websites": "Aplicações e Websites Ilimitados",
"unlimited_miu": "MIU Ilimitado", "unlimited_miu": "MIU Ilimitado",
"unlimited_projects": "Projetos Ilimitados", "unlimited_projects": "Projetos Ilimitados",
"unlimited_responses": "Respostas Ilimitadas", "unlimited_responses": "Respostas Ilimitadas",
@@ -1074,6 +1072,7 @@
"create_new_organization": "Criar nova organização", "create_new_organization": "Criar nova organização",
"create_new_organization_description": "Crie uma nova organização para gerir um conjunto diferente de projetos.", "create_new_organization_description": "Crie uma nova organização para gerir um conjunto diferente de projetos.",
"customize_email_with_a_higher_plan": "Personalize o e-mail com um plano superior", "customize_email_with_a_higher_plan": "Personalize o e-mail com um plano superior",
"delete_member_confirmation": "Membros eliminados perderão acesso a todos os projetos e inquéritos da sua organização.",
"delete_organization": "Eliminar Organização", "delete_organization": "Eliminar Organização",
"delete_organization_description": "Eliminar organização com todos os seus projetos, incluindo todos os inquéritos, respostas, pessoas, ações e atributos", "delete_organization_description": "Eliminar organização com todos os seus projetos, incluindo todos os inquéritos, respostas, pessoas, ações e atributos",
"delete_organization_warning": "Antes de prosseguir com a eliminação desta organização, esteja ciente das seguintes consequências:", "delete_organization_warning": "Antes de prosseguir com a eliminação desta organização, esteja ciente das seguintes consequências:",
@@ -1230,8 +1229,9 @@
"copy_survey_description": "Copiar este questionário para outro ambiente", "copy_survey_description": "Copiar este questionário para outro ambiente",
"copy_survey_error": "Falha ao copiar inquérito", "copy_survey_error": "Falha ao copiar inquérito",
"copy_survey_link_to_clipboard": "Copiar link do inquérito para a área de transferência", "copy_survey_link_to_clipboard": "Copiar link do inquérito para a área de transferência",
"copy_survey_partially_success": "{success} inquéritos copiados com sucesso, {error} falharam.",
"copy_survey_success": "Inquérito copiado com sucesso!", "copy_survey_success": "Inquérito copiado com sucesso!",
"delete_survey_and_responses_warning": "Tem a certeza de que deseja eliminar este inquérito e todas as suas respostas? Esta ação não pode ser desfeita.", "delete_survey_and_responses_warning": "Tem a certeza de que deseja eliminar este inquérito e todas as suas respostas?",
"edit": { "edit": {
"1_choose_the_default_language_for_this_survey": "1. Escolha o idioma padrão para este inquérito:", "1_choose_the_default_language_for_this_survey": "1. Escolha o idioma padrão para este inquérito:",
"2_activate_translation_for_specific_languages": "2. Ativar tradução para idiomas específicos:", "2_activate_translation_for_specific_languages": "2. Ativar tradução para idiomas específicos:",
@@ -1304,7 +1304,6 @@
"card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Inquéritos {surveyTypeDerived}", "card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Inquéritos {surveyTypeDerived}",
"card_background_color": "Cor de fundo do cartão", "card_background_color": "Cor de fundo do cartão",
"card_border_color": "Cor da borda do cartão", "card_border_color": "Cor da borda do cartão",
"card_shadow_color": "Cor da sombra do cartão",
"card_styling": "Estilo do cartão", "card_styling": "Estilo do cartão",
"casual": "Casual", "casual": "Casual",
"caution_edit_duplicate": "Duplicar e editar", "caution_edit_duplicate": "Duplicar e editar",
@@ -1329,7 +1328,6 @@
"change_the_brand_color_of_the_survey": "Alterar a cor da marca do inquérito", "change_the_brand_color_of_the_survey": "Alterar a cor da marca do inquérito",
"change_the_placement_of_this_survey": "Alterar a colocação deste inquérito.", "change_the_placement_of_this_survey": "Alterar a colocação deste inquérito.",
"change_the_question_color_of_the_survey": "Alterar a cor da pergunta do inquérito", "change_the_question_color_of_the_survey": "Alterar a cor da pergunta do inquérito",
"change_the_shadow_color_of_the_card": "Alterar a cor da sombra do cartão.",
"changes_saved": "Alterações guardadas.", "changes_saved": "Alterações guardadas.",
"character_limit_toggle_description": "Limitar o quão curta ou longa uma resposta pode ser.", "character_limit_toggle_description": "Limitar o quão curta ou longa uma resposta pode ser.",
"character_limit_toggle_title": "Adicionar limites de caracteres", "character_limit_toggle_title": "Adicionar limites de caracteres",
@@ -1716,6 +1714,7 @@
"congrats": "Parabéns! O seu inquérito está ativo.", "congrats": "Parabéns! O seu inquérito está ativo.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Ligue o seu website ou aplicação ao Formbricks para começar.", "connect_your_website_or_app_with_formbricks_to_get_started": "Ligue o seu website ou aplicação ao Formbricks para começar.",
"copy_link_to_public_results": "Copiar link para resultados públicos", "copy_link_to_public_results": "Copiar link para resultados públicos",
"create_and_manage_segments": "Crie e gere os seus Segmentos em Contactos > Segmentos",
"create_single_use_links": "Criar links de uso único", "create_single_use_links": "Criar links de uso único",
"create_single_use_links_description": "Aceitar apenas uma submissão por link. Aqui está como.", "create_single_use_links_description": "Aceitar apenas uma submissão por link. Aqui está como.",
"custom_range": "Intervalo personalizado...", "custom_range": "Intervalo personalizado...",
@@ -1734,12 +1733,19 @@
"embed_on_website": "Incorporar no site", "embed_on_website": "Incorporar no site",
"embed_pop_up_survey_title": "Como incorporar um questionário pop-up no seu site", "embed_pop_up_survey_title": "Como incorporar um questionário pop-up no seu site",
"embed_survey": "Incorporar inquérito", "embed_survey": "Incorporar inquérito",
"expiry_date_description": "Uma vez que o link expira, o destinatário não pode mais responder ao questionário.",
"expiry_date_optional": "Data de expiração (opcional)",
"failed_to_copy_link": "Falha ao copiar link", "failed_to_copy_link": "Falha ao copiar link",
"filter_added_successfully": "Filtro adicionado com sucesso", "filter_added_successfully": "Filtro adicionado com sucesso",
"filter_updated_successfully": "Filtro atualizado com sucesso", "filter_updated_successfully": "Filtro atualizado com sucesso",
"filtered_responses_csv": "Respostas filtradas (CSV)", "filtered_responses_csv": "Respostas filtradas (CSV)",
"filtered_responses_excel": "Respostas filtradas (Excel)", "filtered_responses_excel": "Respostas filtradas (Excel)",
"formbricks_email_survey_preview": "Pré-visualização da Pesquisa de E-mail do Formbricks", "formbricks_email_survey_preview": "Pré-visualização da Pesquisa de E-mail do Formbricks",
"generate_and_download_links": "Gerar & descarregar links",
"generate_personal_links_description": "Gerar links pessoais para um segmento e associar as respostas do inquérito a cada contacto. Um ficheiro CSV dos seus links pessoais, incluindo a informação relevante de contacto, será descarregado automaticamente.",
"generate_personal_links_title": "Maximize os insights com links pessoais de inquérito",
"generating_links": "Gerando links",
"generating_links_toast": "A gerar links, o download começará em breve…",
"go_to_setup_checklist": "Ir para a Lista de Verificação de Configuração \uD83D\uDC49", "go_to_setup_checklist": "Ir para a Lista de Verificação de Configuração \uD83D\uDC49",
"hide_embed_code": "Ocultar código de incorporação", "hide_embed_code": "Ocultar código de incorporação",
"how_to_create_a_panel": "Como criar um painel", "how_to_create_a_panel": "Como criar um painel",
@@ -1765,12 +1771,18 @@
"last_quarter": "Último trimestre", "last_quarter": "Último trimestre",
"last_year": "Ano passado", "last_year": "Ano passado",
"link_to_public_results_copied": "Link para resultados públicos copiado", "link_to_public_results_copied": "Link para resultados públicos copiado",
"links_generated_success_toast": "Links gerados com sucesso, o seu download começará em breve.",
"make_sure_the_survey_type_is_set_to": "Certifique-se de que o tipo de inquérito está definido para", "make_sure_the_survey_type_is_set_to": "Certifique-se de que o tipo de inquérito está definido para",
"mobile_app": "Aplicação móvel", "mobile_app": "Aplicação móvel",
"no_responses_found": "Nenhuma resposta encontrada", "no_responses_found": "Nenhuma resposta encontrada",
"no_segments_available": "Sem segmentos disponíveis",
"only_completed": "Apenas concluído", "only_completed": "Apenas concluído",
"other_values_found": "Outros valores encontrados", "other_values_found": "Outros valores encontrados",
"overall": "Geral", "overall": "Geral",
"personal_links": "Links pessoais",
"personal_links_upgrade_prompt_description": "Gerar links pessoais para um segmento e associar as respostas do inquérito a cada contacto.",
"personal_links_upgrade_prompt_title": "Utilize links pessoais com um plano superior",
"personal_links_work_with_segments": "Os links pessoais funcionam com segmentos.",
"publish_to_web": "Publicar na web", "publish_to_web": "Publicar na web",
"publish_to_web_warning": "Está prestes a divulgar estes resultados do inquérito ao público.", "publish_to_web_warning": "Está prestes a divulgar estes resultados do inquérito ao público.",
"publish_to_web_warning_description": "Os resultados do seu inquérito serão públicos. Qualquer pessoa fora da sua organização pode aceder a eles se tiver o link.", "publish_to_web_warning_description": "Os resultados do seu inquérito serão públicos. Qualquer pessoa fora da sua organização pode aceder a eles se tiver o link.",
@@ -1779,6 +1791,7 @@
"quickstart_web_apps": "Início rápido: Aplicações web", "quickstart_web_apps": "Início rápido: Aplicações web",
"quickstart_web_apps_description": "Por favor, siga o guia de início rápido para começar:", "quickstart_web_apps_description": "Por favor, siga o guia de início rápido para começar:",
"results_are_public": "Os resultados são públicos", "results_are_public": "Os resultados são públicos",
"select_segment": "Selecionar segmento",
"selected_responses_csv": "Respostas selecionadas (CSV)", "selected_responses_csv": "Respostas selecionadas (CSV)",
"selected_responses_excel": "Respostas selecionadas (Excel)", "selected_responses_excel": "Respostas selecionadas (Excel)",
"send_preview": "Enviar pré-visualização", "send_preview": "Enviar pré-visualização",
@@ -1804,6 +1817,7 @@
"this_year": "Este ano", "this_year": "Este ano",
"time_to_complete": "Tempo para Concluir", "time_to_complete": "Tempo para Concluir",
"to_connect_your_website_with_formbricks": "para ligar o seu website ao Formbricks", "to_connect_your_website_with_formbricks": "para ligar o seu website ao Formbricks",
"to_create_personal_links_segment_required": "Para criar links pessoais para o seu inquérito, é necessário configurar primeiro um segmento.",
"ttc_tooltip": "Tempo médio para concluir o inquérito.", "ttc_tooltip": "Tempo médio para concluir o inquérito.",
"unknown_question_type": "Tipo de Pergunta Desconhecido", "unknown_question_type": "Tipo de Pergunta Desconhecido",
"unpublish_from_web": "Despublicar da web", "unpublish_from_web": "Despublicar da web",
@@ -2827,4 +2841,4 @@
"understand_purchase_intention_question_3_headline": "O que, se alguma coisa, o está a impedir de fazer uma compra hoje?", "understand_purchase_intention_question_3_headline": "O que, se alguma coisa, o está a impedir de fazer uma compra hoje?",
"understand_purchase_intention_question_3_placeholder": "Escreva a sua resposta aqui..." "understand_purchase_intention_question_3_placeholder": "Escreva a sua resposta aqui..."
} }
} }
+41 -27
View File
@@ -108,6 +108,10 @@
"thanks_for_upgrading": "非常感謝您升級您的 Formbricks 訂閱。", "thanks_for_upgrading": "非常感謝您升級您的 Formbricks 訂閱。",
"upgrade_successful": "升級成功" "upgrade_successful": "升級成功"
}, },
"c": {
"link_expired": "您 的 連結 已過期。",
"link_expired_description": "您 使用 的 連結 已無效。"
},
"common": { "common": {
"accepted": "已接受", "accepted": "已接受",
"account": "帳戶", "account": "帳戶",
@@ -313,9 +317,11 @@
"question_id": "問題 ID", "question_id": "問題 ID",
"questions": "問題", "questions": "問題",
"read_docs": "閱讀文件", "read_docs": "閱讀文件",
"recipients": "收件者",
"remove": "移除", "remove": "移除",
"reorder_and_hide_columns": "重新排序和隱藏欄位", "reorder_and_hide_columns": "重新排序和隱藏欄位",
"report_survey": "報告問卷", "report_survey": "報告問卷",
"request_pricing": "請求定價",
"request_trial_license": "請求試用授權", "request_trial_license": "請求試用授權",
"reset_to_default": "重設為預設值", "reset_to_default": "重設為預設值",
"response": "回應", "response": "回應",
@@ -596,6 +602,7 @@
"contact_not_found": "找不到此聯絡人", "contact_not_found": "找不到此聯絡人",
"contacts_table_refresh": "重新整理聯絡人", "contacts_table_refresh": "重新整理聯絡人",
"contacts_table_refresh_success": "聯絡人已成功重新整理", "contacts_table_refresh_success": "聯絡人已成功重新整理",
"delete_contact_confirmation": "這將刪除與此聯繫人相關的所有調查回應和聯繫屬性。任何基於此聯繫人數據的定位和個性化將會丟失。",
"first_name": "名字", "first_name": "名字",
"last_name": "姓氏", "last_name": "姓氏",
"no_responses_found": "找不到回應", "no_responses_found": "找不到回應",
@@ -632,6 +639,7 @@
"airtable_integration": "Airtable 整合", "airtable_integration": "Airtable 整合",
"airtable_integration_description": "直接與 Airtable 同步回應。", "airtable_integration_description": "直接與 Airtable 同步回應。",
"airtable_integration_is_not_configured": "尚未設定 Airtable 整合", "airtable_integration_is_not_configured": "尚未設定 Airtable 整合",
"airtable_logo": "Airtable 標誌",
"connect_with_airtable": "連線 Airtable", "connect_with_airtable": "連線 Airtable",
"link_airtable_table": "連結 Airtable 表格", "link_airtable_table": "連結 Airtable 表格",
"link_new_table": "連結新表格", "link_new_table": "連結新表格",
@@ -721,6 +729,7 @@
"slack_integration": "Slack 整合", "slack_integration": "Slack 整合",
"slack_integration_description": "直接將回應傳送至 Slack。", "slack_integration_description": "直接將回應傳送至 Slack。",
"slack_integration_is_not_configured": "您的 Formbricks 執行個體中尚未設定 Slack 整合。", "slack_integration_is_not_configured": "您的 Formbricks 執行個體中尚未設定 Slack 整合。",
"slack_logo": "Slack 標誌",
"slack_reconnect_button": "重新連線", "slack_reconnect_button": "重新連線",
"slack_reconnect_button_description": "<b>注意:</b>我們最近變更了我們的 Slack 整合以支援私人頻道。請重新連線您的 Slack 工作區。" "slack_reconnect_button_description": "<b>注意:</b>我們最近變更了我們的 Slack 整合以支援私人頻道。請重新連線您的 Slack 工作區。"
}, },
@@ -905,8 +914,7 @@
"tag_already_exists": "標籤已存在", "tag_already_exists": "標籤已存在",
"tag_deleted": "標籤已刪除", "tag_deleted": "標籤已刪除",
"tag_updated": "標籤已更新", "tag_updated": "標籤已更新",
"tags_merged": "標籤已合併", "tags_merged": "標籤已合併"
"unique_constraint_failed_on_the_fields": "欄位上唯一性限制失敗"
}, },
"teams": { "teams": {
"manage_teams": "管理團隊", "manage_teams": "管理團隊",
@@ -979,63 +987,53 @@
"api_keys_description": "管理 API 金鑰以存取 Formbricks 管理 API" "api_keys_description": "管理 API 金鑰以存取 Formbricks 管理 API"
}, },
"billing": { "billing": {
"10000_monthly_responses": "10000 個每月回應", "1000_monthly_responses": "1000 個每月回應",
"1500_monthly_responses": "1500 個每月回應", "1_project": "1 個專案",
"2000_monthly_identified_users": "2000 個每月識別使用者", "2000_contacts": "2000 個聯絡人",
"30000_monthly_identified_users": "30000 個每月識別使用者",
"3_projects": "3 個專案", "3_projects": "3 個專案",
"5000_monthly_responses": "5000 個每月回應", "5000_monthly_responses": "5000 個每月回應",
"5_projects": "5 個專案", "7500_contacts": "7500 個聯絡人",
"7500_monthly_identified_users": "7500 個每月識別使用者",
"advanced_targeting": "進階目標設定",
"all_integrations": "所有整合", "all_integrations": "所有整合",
"all_surveying_features": "所有調查功能",
"annually": "每年", "annually": "每年",
"api_webhooks": "API 和 Webhook", "api_webhooks": "API 和 Webhook",
"app_surveys": "應用程式問卷", "app_surveys": "應用程式問卷",
"contact_us": "聯絡我們", "attribute_based_targeting": "基於屬性的定位",
"current": "目前", "current": "目前",
"current_plan": "目前方案", "current_plan": "目前方案",
"current_tier_limit": "目前層級限制", "current_tier_limit": "目前層級限制",
"custom_miu_limit": "自訂 MIU 上限", "custom": "自訂 & 規模",
"custom_contacts_limit": "自訂聯絡人上限",
"custom_project_limit": "自訂專案上限", "custom_project_limit": "自訂專案上限",
"customer_success_manager": "客戶成功經理", "custom_response_limit": "自訂回應上限",
"email_embedded_surveys": "電子郵件嵌入式問卷", "email_embedded_surveys": "電子郵件嵌入式問卷",
"email_support": "電子郵件支援", "email_follow_ups": "電子郵件後續追蹤",
"enterprise": "企業版",
"enterprise_description": "頂級支援和自訂限制。", "enterprise_description": "頂級支援和自訂限制。",
"everybody_has_the_free_plan_by_default": "每個人預設都有免費方案!", "everybody_has_the_free_plan_by_default": "每個人預設都有免費方案!",
"everything_in_free": "免費方案中的所有功能", "everything_in_free": "免費方案中的所有功能",
"everything_in_scale": "進階方案中的所有功能",
"everything_in_startup": "啟動方案中的所有功能", "everything_in_startup": "啟動方案中的所有功能",
"free": "免費", "free": "免費",
"free_description": "無限問卷、團隊成員等。", "free_description": "無限問卷、團隊成員等。",
"get_2_months_free": "免費獲得 2 個月", "get_2_months_free": "免費獲得 2 個月",
"get_in_touch": "取得聯繫", "get_in_touch": "取得聯繫",
"hosted_in_frankfurt": "託管在 Frankfurt",
"ios_android_sdks": "iOS 和 Android SDK 用於行動問卷",
"link_surveys": "連結問卷(可分享)", "link_surveys": "連結問卷(可分享)",
"logic_jumps_hidden_fields_recurring_surveys": "邏輯跳躍、隱藏欄位、定期問卷等。", "logic_jumps_hidden_fields_recurring_surveys": "邏輯跳躍、隱藏欄位、定期問卷等。",
"manage_card_details": "管理卡片詳細資料", "manage_card_details": "管理卡片詳細資料",
"manage_subscription": "管理訂閱", "manage_subscription": "管理訂閱",
"monthly": "每月", "monthly": "每月",
"monthly_identified_users": "每月識別使用者", "monthly_identified_users": "每月識別使用者",
"multi_language_surveys": "多語言問卷",
"per_month": "每月", "per_month": "每月",
"per_year": "每年", "per_year": "每年",
"plan_upgraded_successfully": "方案已成功升級", "plan_upgraded_successfully": "方案已成功升級",
"premium_support_with_slas": "具有 SLA 的頂級支援", "premium_support_with_slas": "具有 SLA 的頂級支援",
"priority_support": "優先支援",
"remove_branding": "移除品牌", "remove_branding": "移除品牌",
"say_hi": "打個招呼!",
"scale": "進階版",
"scale_description": "用於擴展業務的進階功能。",
"startup": "啟動版", "startup": "啟動版",
"startup_description": "免費方案中的所有功能以及其他功能。", "startup_description": "免費方案中的所有功能以及其他功能。",
"switch_plan": "切換方案", "switch_plan": "切換方案",
"switch_plan_confirmation_text": "您確定要切換到 {plan} 計劃嗎?您將被收取 {price} {period}。", "switch_plan_confirmation_text": "您確定要切換到 {plan} 計劃嗎?您將被收取 {price} {period}。",
"team_access_roles": "團隊存取角色", "team_access_roles": "團隊存取角色",
"technical_onboarding": "技術新手上路",
"unable_to_upgrade_plan": "無法升級方案", "unable_to_upgrade_plan": "無法升級方案",
"unlimited_apps_websites": "無限應用程式和網站",
"unlimited_miu": "無限 MIU", "unlimited_miu": "無限 MIU",
"unlimited_projects": "無限專案", "unlimited_projects": "無限專案",
"unlimited_responses": "無限回應", "unlimited_responses": "無限回應",
@@ -1074,6 +1072,7 @@
"create_new_organization": "建立新組織", "create_new_organization": "建立新組織",
"create_new_organization_description": "建立新組織以處理一組不同的專案。", "create_new_organization_description": "建立新組織以處理一組不同的專案。",
"customize_email_with_a_higher_plan": "使用更高等級的方案自訂電子郵件", "customize_email_with_a_higher_plan": "使用更高等級的方案自訂電子郵件",
"delete_member_confirmation": "刪除的成員將失去存取您組織的所有專案和問卷的權限。",
"delete_organization": "刪除組織", "delete_organization": "刪除組織",
"delete_organization_description": "刪除包含所有專案的組織,包括所有問卷、回應、人員、操作和屬性", "delete_organization_description": "刪除包含所有專案的組織,包括所有問卷、回應、人員、操作和屬性",
"delete_organization_warning": "在您繼續刪除此組織之前,請注意以下後果:", "delete_organization_warning": "在您繼續刪除此組織之前,請注意以下後果:",
@@ -1230,8 +1229,9 @@
"copy_survey_description": "將此問卷複製到另一個環境", "copy_survey_description": "將此問卷複製到另一個環境",
"copy_survey_error": "無法複製問卷", "copy_survey_error": "無法複製問卷",
"copy_survey_link_to_clipboard": "將問卷連結複製到剪貼簿", "copy_survey_link_to_clipboard": "將問卷連結複製到剪貼簿",
"copy_survey_partially_success": "{success} 個問卷已成功複製,{error} 個失敗。",
"copy_survey_success": "問卷已成功複製!", "copy_survey_success": "問卷已成功複製!",
"delete_survey_and_responses_warning": "您確定要刪除此問卷及其所有回應嗎?此操作無法復原。", "delete_survey_and_responses_warning": "您確定要刪除此問卷及其所有回應嗎?",
"edit": { "edit": {
"1_choose_the_default_language_for_this_survey": "1. 選擇此問卷的預設語言:", "1_choose_the_default_language_for_this_survey": "1. 選擇此問卷的預設語言:",
"2_activate_translation_for_specific_languages": "2. 啟用特定語言的翻譯:", "2_activate_translation_for_specific_languages": "2. 啟用特定語言的翻譯:",
@@ -1304,7 +1304,6 @@
"card_arrangement_for_survey_type_derived": "'{'surveyTypeDerived'}' 問卷的卡片排列", "card_arrangement_for_survey_type_derived": "'{'surveyTypeDerived'}' 問卷的卡片排列",
"card_background_color": "卡片背景顏色", "card_background_color": "卡片背景顏色",
"card_border_color": "卡片邊框顏色", "card_border_color": "卡片邊框顏色",
"card_shadow_color": "卡片陰影顏色",
"card_styling": "卡片樣式設定", "card_styling": "卡片樣式設定",
"casual": "隨意", "casual": "隨意",
"caution_edit_duplicate": "複製 & 編輯", "caution_edit_duplicate": "複製 & 編輯",
@@ -1329,7 +1328,6 @@
"change_the_brand_color_of_the_survey": "變更問卷的品牌顏色。", "change_the_brand_color_of_the_survey": "變更問卷的品牌顏色。",
"change_the_placement_of_this_survey": "變更此問卷的位置。", "change_the_placement_of_this_survey": "變更此問卷的位置。",
"change_the_question_color_of_the_survey": "變更問卷的問題顏色。", "change_the_question_color_of_the_survey": "變更問卷的問題顏色。",
"change_the_shadow_color_of_the_card": "變更卡片的陰影顏色。",
"changes_saved": "已儲存變更。", "changes_saved": "已儲存變更。",
"character_limit_toggle_description": "限制答案的長度或短度。", "character_limit_toggle_description": "限制答案的長度或短度。",
"character_limit_toggle_title": "新增字元限制", "character_limit_toggle_title": "新增字元限制",
@@ -1716,6 +1714,7 @@
"congrats": "恭喜!您的問卷已上線。", "congrats": "恭喜!您的問卷已上線。",
"connect_your_website_or_app_with_formbricks_to_get_started": "將您的網站或應用程式與 Formbricks 連線以開始使用。", "connect_your_website_or_app_with_formbricks_to_get_started": "將您的網站或應用程式與 Formbricks 連線以開始使用。",
"copy_link_to_public_results": "複製公開結果的連結", "copy_link_to_public_results": "複製公開結果的連結",
"create_and_manage_segments": "在 聯絡人 > 分段 中建立和管理您的分段",
"create_single_use_links": "建立單次使用連結", "create_single_use_links": "建立單次使用連結",
"create_single_use_links_description": "每個連結只接受一次提交。以下是如何操作。", "create_single_use_links_description": "每個連結只接受一次提交。以下是如何操作。",
"custom_range": "自訂範圍...", "custom_range": "自訂範圍...",
@@ -1734,12 +1733,19 @@
"embed_on_website": "嵌入網站", "embed_on_website": "嵌入網站",
"embed_pop_up_survey_title": "如何在您的網站上嵌入彈出式問卷", "embed_pop_up_survey_title": "如何在您的網站上嵌入彈出式問卷",
"embed_survey": "嵌入問卷", "embed_survey": "嵌入問卷",
"expiry_date_description": "一旦連結過期,收件者將無法再回應 survey。",
"expiry_date_optional": "到期日 (可選)",
"failed_to_copy_link": "無法複製連結", "failed_to_copy_link": "無法複製連結",
"filter_added_successfully": "篩選器已成功新增", "filter_added_successfully": "篩選器已成功新增",
"filter_updated_successfully": "篩選器已成功更新", "filter_updated_successfully": "篩選器已成功更新",
"filtered_responses_csv": "篩選回應 (CSV)", "filtered_responses_csv": "篩選回應 (CSV)",
"filtered_responses_excel": "篩選回應 (Excel)", "filtered_responses_excel": "篩選回應 (Excel)",
"formbricks_email_survey_preview": "Formbricks 電子郵件問卷預覽", "formbricks_email_survey_preview": "Formbricks 電子郵件問卷預覽",
"generate_and_download_links": "生成 & 下載 連結",
"generate_personal_links_description": "為 一個 群組 生成 個人 連結,並 將 調查 回應 對應 到 每個 聯絡人。含 有 相關 聯絡信息 的 個人 連結 CSV 會 自動 下載。",
"generate_personal_links_title": "透過個人化調查連結最大化洞察",
"generating_links": "生成 連結",
"generating_links_toast": "生成 連結,下載 將 會 很快 開始…",
"go_to_setup_checklist": "前往設定檢查清單 \uD83D\uDC49", "go_to_setup_checklist": "前往設定檢查清單 \uD83D\uDC49",
"hide_embed_code": "隱藏嵌入程式碼", "hide_embed_code": "隱藏嵌入程式碼",
"how_to_create_a_panel": "如何建立小組", "how_to_create_a_panel": "如何建立小組",
@@ -1765,12 +1771,18 @@
"last_quarter": "上一季", "last_quarter": "上一季",
"last_year": "去年", "last_year": "去年",
"link_to_public_results_copied": "已複製公開結果的連結", "link_to_public_results_copied": "已複製公開結果的連結",
"links_generated_success_toast": "連結 成功 生成,您的 下載 將 會 很快 開始。",
"make_sure_the_survey_type_is_set_to": "請確保問卷類型設定為", "make_sure_the_survey_type_is_set_to": "請確保問卷類型設定為",
"mobile_app": "行動應用程式", "mobile_app": "行動應用程式",
"no_responses_found": "找不到回應", "no_responses_found": "找不到回應",
"no_segments_available": "沒有可用的區段",
"only_completed": "僅已完成", "only_completed": "僅已完成",
"other_values_found": "找到其他值", "other_values_found": "找到其他值",
"overall": "整體", "overall": "整體",
"personal_links": "個人 連結",
"personal_links_upgrade_prompt_description": "為一個群組生成個人連結,並將調查回應連結到每個聯絡人。",
"personal_links_upgrade_prompt_title": "使用 個人 連結 與 更高 的 計劃",
"personal_links_work_with_segments": "個人 連結 可 與 分段 一起 使用",
"publish_to_web": "發布至網站", "publish_to_web": "發布至網站",
"publish_to_web_warning": "您即將將這些問卷結果發布到公共領域。", "publish_to_web_warning": "您即將將這些問卷結果發布到公共領域。",
"publish_to_web_warning_description": "您的問卷結果將會是公開的。任何組織外的人員都可以存取這些結果(如果他們有連結)。", "publish_to_web_warning_description": "您的問卷結果將會是公開的。任何組織外的人員都可以存取這些結果(如果他們有連結)。",
@@ -1779,6 +1791,7 @@
"quickstart_web_apps": "快速入門:Web apps", "quickstart_web_apps": "快速入門:Web apps",
"quickstart_web_apps_description": "請按照 Quickstart 指南開始:", "quickstart_web_apps_description": "請按照 Quickstart 指南開始:",
"results_are_public": "結果是公開的", "results_are_public": "結果是公開的",
"select_segment": "選擇 區隔",
"selected_responses_csv": "選擇的回應 (CSV)", "selected_responses_csv": "選擇的回應 (CSV)",
"selected_responses_excel": "選擇的回應 (Excel)", "selected_responses_excel": "選擇的回應 (Excel)",
"send_preview": "發送預覽", "send_preview": "發送預覽",
@@ -1804,6 +1817,7 @@
"this_year": "今年", "this_year": "今年",
"time_to_complete": "完成時間", "time_to_complete": "完成時間",
"to_connect_your_website_with_formbricks": "以將您的網站與 Formbricks 連線", "to_connect_your_website_with_formbricks": "以將您的網站與 Formbricks 連線",
"to_create_personal_links_segment_required": "要為 問卷 創建 個人連結,您 必須先 設置 一個 分段。",
"ttc_tooltip": "完成問卷的平均時間。", "ttc_tooltip": "完成問卷的平均時間。",
"unknown_question_type": "未知的問題類型", "unknown_question_type": "未知的問題類型",
"unpublish_from_web": "從網站取消發布", "unpublish_from_web": "從網站取消發布",
@@ -2827,4 +2841,4 @@
"understand_purchase_intention_question_3_headline": "有什麼阻礙您今天進行購買嗎?", "understand_purchase_intention_question_3_headline": "有什麼阻礙您今天進行購買嗎?",
"understand_purchase_intention_question_3_placeholder": "在此輸入您的答案..." "understand_purchase_intention_question_3_placeholder": "在此輸入您的答案..."
} }
} }
@@ -13,6 +13,7 @@ export const SurveyLinkDisplay = ({ surveyUrl }: SurveyLinkDisplayProps) => {
autoFocus={true} autoFocus={true}
className="mt-2 w-full min-w-96 text-ellipsis rounded-lg border bg-white px-4 py-2 text-slate-800 caret-transparent" className="mt-2 w-full min-w-96 text-ellipsis rounded-lg border bg-white px-4 py-2 text-slate-800 caret-transparent"
value={surveyUrl} value={surveyUrl}
readOnly
/> />
) : ( ) : (
//loading state //loading state
@@ -50,8 +50,14 @@ export const createTagAction = authenticatedActionClient.schema(ZCreateTagAction
}); });
ctx.auditLoggingCtx.organizationId = organizationId; ctx.auditLoggingCtx.organizationId = organizationId;
const result = await createTag(parsedInput.environmentId, parsedInput.tagName); const result = await createTag(parsedInput.environmentId, parsedInput.tagName);
ctx.auditLoggingCtx.tagId = result.id;
ctx.auditLoggingCtx.newObject = result; if (result.ok) {
ctx.auditLoggingCtx.tagId = result.data.id;
ctx.auditLoggingCtx.newObject = result.data;
} else {
ctx.auditLoggingCtx.newObject = null;
}
return result; return result;
} }
) )
@@ -1,3 +1,5 @@
import { TagError } from "@/modules/projects/settings/types/tag";
import "@testing-library/jest-dom/vitest";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
@@ -150,7 +152,9 @@ describe("ResponseTagsWrapper", () => {
}); });
test("creates a new tag via TagsCombobox and calls updateFetchedResponses on success", async () => { test("creates a new tag via TagsCombobox and calls updateFetchedResponses on success", async () => {
vi.mocked(createTagAction).mockResolvedValueOnce({ data: { id: "newTagId", name: "NewTag" } } as any); vi.mocked(createTagAction).mockResolvedValueOnce({
data: { ok: true, data: { id: "newTagId", name: "NewTag" } },
} as any);
vi.mocked(createTagToResponseAction).mockResolvedValueOnce({ data: "tagAdded" } as any); vi.mocked(createTagToResponseAction).mockResolvedValueOnce({ data: "tagAdded" } as any);
render( render(
<ResponseTagsWrapper <ResponseTagsWrapper
@@ -176,7 +180,10 @@ describe("ResponseTagsWrapper", () => {
test("handles createTagAction failure and shows toast error", async () => { test("handles createTagAction failure and shows toast error", async () => {
vi.mocked(createTagAction).mockResolvedValueOnce({ vi.mocked(createTagAction).mockResolvedValueOnce({
error: { details: [{ issue: "Unique constraint failed on the fields" }] }, data: {
ok: false,
error: { message: "Unique constraint failed on the fields", code: TagError.TAG_NAME_ALREADY_EXISTS },
},
} as any); } as any);
render( render(
<ResponseTagsWrapper <ResponseTagsWrapper
@@ -1,6 +1,7 @@
"use client"; "use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { TagError } from "@/modules/projects/settings/types/tag";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Tag } from "@/modules/ui/components/tag"; import { Tag } from "@/modules/ui/components/tag";
import { TagsCombobox } from "@/modules/ui/components/tags-combobox"; import { TagsCombobox } from "@/modules/ui/components/tags-combobox";
@@ -58,6 +59,57 @@ export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
}, [tagIdToHighlight]); }, [tagIdToHighlight]);
const handleCreateTag = async (tagName: string) => {
setOpen(false);
const createTagResponse = await createTagAction({
environmentId,
tagName: tagName?.trim() ?? "",
});
if (createTagResponse?.data?.ok) {
const tag = createTagResponse.data.data;
setTagsState((prevTags) => [
...prevTags,
{
tagId: tag.id,
tagName: tag.name,
},
]);
const createTagToResponseActionResponse = await createTagToResponseAction({
responseId,
tagId: tag.id,
});
if (createTagToResponseActionResponse?.data) {
updateFetchedResponses();
setSearchValue("");
} else {
const errorMessage = getFormattedErrorMessage(createTagToResponseActionResponse);
toast.error(errorMessage);
}
return;
}
if (createTagResponse?.data?.error?.code === TagError.TAG_NAME_ALREADY_EXISTS) {
toast.error(t("environments.surveys.responses.tag_already_exists"), {
duration: 2000,
icon: <AlertCircleIcon className="h-5 w-5 text-orange-500" />,
});
setSearchValue("");
return;
}
const errorMessage = getFormattedErrorMessage(createTagResponse);
toast.error(errorMessage ?? t("common.something_went_wrong_please_try_again"), {
duration: 2000,
});
setSearchValue("");
};
return ( return (
<div className="flex items-center gap-3 border-t border-slate-200 px-6 py-4"> <div className="flex items-center gap-3 border-t border-slate-200 px-6 py-4">
{!isReadOnly && ( {!isReadOnly && (
@@ -93,46 +145,7 @@ export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
setSearchValue={setSearchValue} setSearchValue={setSearchValue}
tags={environmentTags?.map((tag) => ({ value: tag.id, label: tag.name })) ?? []} tags={environmentTags?.map((tag) => ({ value: tag.id, label: tag.name })) ?? []}
currentTags={tagsState.map((tag) => ({ value: tag.tagId, label: tag.tagName }))} currentTags={tagsState.map((tag) => ({ value: tag.tagId, label: tag.tagName }))}
createTag={async (tagName) => { createTag={handleCreateTag}
setOpen(false);
const createTagResponse = await createTagAction({
environmentId,
tagName: tagName?.trim() ?? "",
});
if (createTagResponse?.data) {
setTagsState((prevTags) => [
...prevTags,
{
tagId: createTagResponse.data?.id ?? "",
tagName: createTagResponse.data?.name ?? "",
},
]);
const createTagToResponseActionResponse = await createTagToResponseAction({
responseId,
tagId: createTagResponse.data.id,
});
if (createTagToResponseActionResponse?.data) {
updateFetchedResponses();
setSearchValue("");
}
} else {
const errorMessage = getFormattedErrorMessage(createTagResponse);
if (errorMessage.includes("Unique constraint failed on the fields")) {
toast.error(t("environments.surveys.responses.tag_already_exists"), {
duration: 2000,
icon: <AlertCircleIcon className="h-5 w-5 text-orange-500" />,
});
} else {
toast.error(errorMessage ?? t("common.something_went_wrong_please_try_again"), {
duration: 2000,
});
}
setSearchValue("");
}
}}
addTag={(tagId) => { addTag={(tagId) => {
setTagsState((prevTags) => [ setTagsState((prevTags) => [
...prevTags, ...prevTags,
@@ -150,9 +150,10 @@ export const SingleResponseCard = ({
<DeleteDialog <DeleteDialog
open={deleteDialogOpen} open={deleteDialogOpen}
setOpen={setDeleteDialogOpen} setOpen={setDeleteDialogOpen}
deleteWhat="response" deleteWhat={t("common.response")}
onDelete={handleDeleteResponse} onDelete={handleDeleteResponse}
isDeleting={isDeleting} isDeleting={isDeleting}
text={t("environments.surveys.responses.delete_response_confirmation")}
/> />
</div> </div>
{user && pageType === "response" && ( {user && pageType === "response" && (
@@ -1,87 +1,87 @@
import { TFnType } from "@tolgee/react"; import { TFnType } from "@tolgee/react";
export const getCloudPricingData = (t: TFnType) => { export type TPricingPlan = {
return { id: string;
plans: [ name: string;
{ featured: boolean;
name: t("environments.settings.billing.free"), CTA?: string;
id: "free", description: string;
featured: false, price: {
description: t("environments.settings.billing.free_description"), monthly: string;
price: { monthly: "$0", yearly: "$0" }, yearly: string;
mainFeatures: [ };
t("environments.settings.billing.unlimited_surveys"), mainFeatures: string[];
t("environments.settings.billing.unlimited_team_members"), href?: string;
t("environments.settings.billing.3_projects"), };
t("environments.settings.billing.1500_monthly_responses"),
t("environments.settings.billing.2000_monthly_identified_users"), export const getCloudPricingData = (t: TFnType): { plans: TPricingPlan[] } => {
t("environments.settings.billing.website_surveys"), const freePlan: TPricingPlan = {
t("environments.settings.billing.app_surveys"), id: "free",
t("environments.settings.billing.unlimited_apps_websites"), name: t("environments.settings.billing.free"),
t("environments.settings.billing.link_surveys"), featured: false,
t("environments.settings.billing.email_embedded_surveys"), description: t("environments.settings.billing.free_description"),
t("environments.settings.billing.logic_jumps_hidden_fields_recurring_surveys"), price: { monthly: "$0", yearly: "$0" },
t("environments.settings.billing.api_webhooks"), mainFeatures: [
t("environments.settings.billing.all_integrations"), t("environments.settings.billing.unlimited_surveys"),
t("environments.settings.billing.all_surveying_features"), t("environments.settings.billing.1000_monthly_responses"),
], t("environments.settings.billing.2000_contacts"),
href: "https://app.formbricks.com/auth/signup?plan=free", t("environments.settings.billing.1_project"),
}, t("environments.settings.billing.unlimited_team_members"),
{ t("environments.settings.billing.link_surveys"),
name: t("environments.settings.billing.startup"), t("environments.settings.billing.website_surveys"),
id: "startup", t("environments.settings.billing.app_surveys"),
featured: false, t("environments.settings.billing.ios_android_sdks"),
description: t("environments.settings.billing.startup_description"), t("environments.settings.billing.email_embedded_surveys"),
price: { monthly: "$39", yearly: "$390 " }, t("environments.settings.billing.logic_jumps_hidden_fields_recurring_surveys"),
mainFeatures: [ t("environments.settings.billing.api_webhooks"),
t("environments.settings.billing.everything_in_free"), t("environments.settings.billing.all_integrations"),
t("environments.settings.billing.unlimited_surveys"), t("environments.settings.billing.hosted_in_frankfurt") + " 🇪🇺",
t("environments.settings.billing.remove_branding"),
t("environments.settings.billing.email_support"),
t("environments.settings.billing.3_projects"),
t("environments.settings.billing.5000_monthly_responses"),
t("environments.settings.billing.7500_monthly_identified_users"),
],
href: "https://app.formbricks.com/auth/signup?plan=startup",
},
{
name: t("environments.settings.billing.scale"),
id: "scale",
featured: true,
description: t("environments.settings.billing.scale_description"),
price: { monthly: "$149", yearly: "$1,490" },
mainFeatures: [
t("environments.settings.billing.everything_in_startup"),
t("environments.settings.billing.team_access_roles"),
t("environments.settings.billing.multi_language_surveys"),
t("environments.settings.billing.advanced_targeting"),
t("environments.settings.billing.priority_support"),
t("environments.settings.billing.5_projects"),
t("environments.settings.billing.10000_monthly_responses"),
t("environments.settings.billing.30000_monthly_identified_users"),
],
href: "https://app.formbricks.com/auth/signup?plan=scale",
},
{
name: t("environments.settings.billing.enterprise"),
id: "enterprise",
featured: false,
description: t("environments.settings.billing.enterprise_description"),
price: {
monthly: t("environments.settings.billing.say_hi"),
yearly: t("environments.settings.billing.say_hi"),
},
mainFeatures: [
t("environments.settings.billing.everything_in_scale"),
t("environments.settings.billing.custom_project_limit"),
t("environments.settings.billing.custom_miu_limit"),
t("environments.settings.billing.premium_support_with_slas"),
t("environments.settings.billing.uptime_sla_99"),
t("environments.settings.billing.customer_success_manager"),
t("environments.settings.billing.technical_onboarding"),
],
href: "https://cal.com/johannes/enterprise-cloud",
},
], ],
}; };
const startupPlan: TPricingPlan = {
id: "startup",
name: t("environments.settings.billing.startup"),
featured: true,
CTA: t("common.start_free_trial"),
description: t("environments.settings.billing.startup_description"),
price: { monthly: "$49", yearly: "$490" },
mainFeatures: [
t("environments.settings.billing.everything_in_free"),
t("environments.settings.billing.5000_monthly_responses"),
t("environments.settings.billing.7500_contacts"),
t("environments.settings.billing.3_projects"),
t("environments.settings.billing.remove_branding"),
t("environments.settings.billing.email_follow_ups"),
t("environments.settings.billing.attribute_based_targeting"),
],
};
const customPlan: TPricingPlan = {
id: "enterprise",
name: t("environments.settings.billing.custom"),
featured: false,
CTA: t("common.request_pricing"),
description: t("environments.settings.billing.enterprise_description"),
price: {
monthly: t("environments.settings.billing.custom"),
yearly: t("environments.settings.billing.custom"),
},
mainFeatures: [
t("environments.settings.billing.everything_in_startup"),
t("environments.settings.billing.custom_response_limit"),
t("environments.settings.billing.custom_contacts_limit"),
t("environments.settings.billing.custom_project_limit"),
t("environments.settings.billing.team_access_roles"),
t("environments.project.languages.multi_language_surveys"),
t("environments.settings.enterprise.saml_sso"),
t("environments.settings.billing.uptime_sla_99"),
t("environments.settings.billing.premium_support_with_slas"),
],
href: "https://app.formbricks.com/s/cm7k8esy20001jp030fh8a9o5?source=billingView&delivery=cloud",
};
return {
plans: [freePlan, startupPlan, customPlan],
};
}; };
@@ -8,19 +8,10 @@ import { useTranslate } from "@tolgee/react";
import { CheckIcon } from "lucide-react"; import { CheckIcon } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { TOrganization, TOrganizationBillingPeriod } from "@formbricks/types/organizations"; import { TOrganization, TOrganizationBillingPeriod } from "@formbricks/types/organizations";
import { TPricingPlan } from "../api/lib/constants";
interface PricingCardProps { interface PricingCardProps {
plan: { plan: TPricingPlan;
id: string;
name: string;
featured: boolean;
price: {
monthly: string;
yearly: string;
};
mainFeatures: string[];
href: string;
};
planPeriod: TOrganizationBillingPeriod; planPeriod: TOrganizationBillingPeriod;
organization: TOrganization; organization: TOrganization;
onUpgrade: () => Promise<void>; onUpgrade: () => Promise<void>;
@@ -28,7 +19,6 @@ interface PricingCardProps {
projectFeatureKeys: { projectFeatureKeys: {
FREE: string; FREE: string;
STARTUP: string; STARTUP: string;
SCALE: string;
ENTERPRISE: string; ENTERPRISE: string;
}; };
} }
@@ -72,18 +62,33 @@ export const PricingCard = ({
return null; return null;
} }
if (plan.id !== projectFeatureKeys.ENTERPRISE && plan.id !== projectFeatureKeys.FREE) { if (plan.id === projectFeatureKeys.ENTERPRISE) {
return (
<Button
variant="outline"
loading={loading}
onClick={() => {
window.open(plan.href, "_blank", "noopener,noreferrer");
}}
className="flex justify-center bg-white">
{t(plan.CTA ?? "common.request_pricing")}
</Button>
);
}
if (plan.id === projectFeatureKeys.STARTUP) {
if (organization.billing.plan === projectFeatureKeys.FREE) { if (organization.billing.plan === projectFeatureKeys.FREE) {
return ( return (
<Button <Button
loading={loading} loading={loading}
variant="default"
onClick={async () => { onClick={async () => {
setLoading(true); setLoading(true);
await onUpgrade(); await onUpgrade();
setLoading(false); setLoading(false);
}} }}
className="flex justify-center"> className="flex justify-center">
{t("common.start_free_trial")} {t(plan.CTA ?? "common.start_free_trial")}
</Button> </Button>
); );
} }
@@ -100,15 +105,20 @@ export const PricingCard = ({
); );
} }
return <></>; return null;
}, [ }, [
isCurrentPlan, isCurrentPlan,
loading, loading,
onUpgrade, onUpgrade,
organization.billing.plan, organization.billing.plan,
plan.CTA,
plan.featured,
plan.href,
plan.id, plan.id,
projectFeatureKeys.ENTERPRISE, projectFeatureKeys.ENTERPRISE,
projectFeatureKeys.FREE, projectFeatureKeys.FREE,
projectFeatureKeys.STARTUP,
t,
]); ]);
return ( return (
@@ -147,7 +157,7 @@ export const PricingCard = ({
: plan.price.yearly : plan.price.yearly
: t(plan.price.monthly)} : t(plan.price.monthly)}
</p> </p>
{plan.name !== "Enterprise" && ( {plan.id !== projectFeatureKeys.ENTERPRISE && (
<div className="text-sm leading-5"> <div className="text-sm leading-5">
<p className={plan.featured ? "text-slate-700" : "text-slate-600"}> <p className={plan.featured ? "text-slate-700" : "text-slate-600"}>
/ {planPeriod === "monthly" ? "Month" : "Year"} / {planPeriod === "monthly" ? "Month" : "Year"}
@@ -171,16 +181,9 @@ export const PricingCard = ({
{t("environments.settings.billing.manage_subscription")} {t("environments.settings.billing.manage_subscription")}
</Button> </Button>
)} )}
{organization.billing.plan !== plan.id && plan.id === projectFeatureKeys.ENTERPRISE && (
<Button loading={loading} onClick={() => onUpgrade()} className="flex justify-center">
{t("environments.settings.billing.contact_us")}
</Button>
)}
</div> </div>
<div className="mt-8 flow-root sm:mt-10"> <div className="mt-8 flow-root sm:mt-10">
<ul <ul
role="list"
className={cn( className={cn(
plan.featured plan.featured
? "divide-slate-900/5 border-slate-900/5 text-slate-600" ? "divide-slate-900/5 border-slate-900/5 text-slate-600"
@@ -193,7 +196,6 @@ export const PricingCard = ({
className={cn(plan.featured ? "text-brand-dark" : "text-slate-500", "h-6 w-5 flex-none")} className={cn(plan.featured ? "text-brand-dark" : "text-slate-500", "h-6 w-5 flex-none")}
aria-hidden="true" aria-hidden="true"
/> />
{t(mainFeature)} {t(mainFeature)}
</li> </li>
))} ))}
@@ -21,15 +21,12 @@ interface PricingTableProps {
responseCount: number; responseCount: number;
projectCount: number; projectCount: number;
stripePriceLookupKeys: { stripePriceLookupKeys: {
STARTUP_MONTHLY: string; STARTUP_MAY25_MONTHLY: string;
STARTUP_YEARLY: string; STARTUP_MAY25_YEARLY: string;
SCALE_MONTHLY: string;
SCALE_YEARLY: string;
}; };
projectFeatureKeys: { projectFeatureKeys: {
FREE: string; FREE: string;
STARTUP: string; STARTUP: string;
SCALE: string;
ENTERPRISE: string; ENTERPRISE: string;
}; };
hasBillingRights: boolean; hasBillingRights: boolean;
@@ -102,35 +99,32 @@ export const PricingTable = ({
throw new Error(t("common.something_went_wrong_please_try_again")); throw new Error(t("common.something_went_wrong_please_try_again"));
} }
} catch (err) { } catch (err) {
toast.error(t("environments.settings.billing.unable_to_upgrade_plan")); if (err instanceof Error) {
toast.error(err.message);
} else {
toast.error(t("environments.settings.billing.unable_to_upgrade_plan"));
}
} }
}; };
const onUpgrade = async (planId: string) => { const onUpgrade = async (planId: string) => {
if (planId === "scale") {
await upgradePlan(
planPeriod === "monthly" ? stripePriceLookupKeys.SCALE_MONTHLY : stripePriceLookupKeys.SCALE_YEARLY
);
return;
}
if (planId === "startup") { if (planId === "startup") {
await upgradePlan( await upgradePlan(
planPeriod === "monthly" planPeriod === "monthly"
? stripePriceLookupKeys.STARTUP_MONTHLY ? stripePriceLookupKeys.STARTUP_MAY25_MONTHLY
: stripePriceLookupKeys.STARTUP_YEARLY : stripePriceLookupKeys.STARTUP_MAY25_YEARLY
); );
return; return;
} }
if (planId === "enterprise") { if (planId === "custom") {
window.location.href = "https://cal.com/johannes/license"; window.location.href =
"https://app.formbricks.com/s/cm7k8esy20001jp030fh8a9o5?source=billingView&delivery=cloud";
return; return;
} }
if (planId === "free") { if (planId === "free") {
toast.error(t("environments.settings.billing.everybody_has_the_free_plan_by_default")); toast.error(t("environments.settings.billing.everybody_has_the_free_plan_by_default"));
return;
} }
}; };
@@ -233,7 +227,7 @@ export const PricingTable = ({
<div <div
className={cn( className={cn(
"relative mx-8 flex flex-col gap-4 pb-12", "relative mx-8 flex flex-col gap-4 pb-6",
projectsUnlimitedCheck && "mb-0 mt-4 flex-row pb-0" projectsUnlimitedCheck && "mb-0 mt-4 flex-row pb-0"
)}> )}>
<p className="text-md font-semibold text-slate-700">{t("common.projects")}</p> <p className="text-md font-semibold text-slate-700">{t("common.projects")}</p>
@@ -282,7 +276,7 @@ export const PricingTable = ({
</span> </span>
</button> </button>
</div> </div>
<div className="relative mx-auto grid max-w-md grid-cols-1 gap-y-8 lg:mx-0 lg:-mb-14 lg:max-w-none lg:grid-cols-4"> <div className="relative mx-auto grid max-w-md grid-cols-1 gap-y-8 lg:mx-0 lg:-mb-14 lg:max-w-none lg:grid-cols-3">
<div <div
className="hidden lg:absolute lg:inset-x-px lg:bottom-0 lg:top-4 lg:block lg:rounded-xl lg:rounded-t-2xl lg:border lg:border-slate-200 lg:bg-slate-100 lg:pb-8 lg:ring-1 lg:ring-white/10" className="hidden lg:absolute lg:inset-x-px lg:bottom-0 lg:top-4 lg:block lg:rounded-xl lg:rounded-t-2xl lg:border lg:border-slate-200 lg:bg-slate-100 lg:pb-8 lg:ring-1 lg:ring-white/10"
aria-hidden="true" aria-hidden="true"
@@ -2,6 +2,7 @@
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { deleteContactAction } from "@/modules/ee/contacts/actions"; import { deleteContactAction } from "@/modules/ee/contacts/actions";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog"; import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { TrashIcon } from "lucide-react"; import { TrashIcon } from "lucide-react";
@@ -48,18 +49,21 @@ export const DeleteContactButton = ({ environmentId, contactId, isReadOnly }: De
return ( return (
<> <>
<button <Button
variant="destructive"
size="icon"
onClick={() => { onClick={() => {
setDeleteDialogOpen(true); setDeleteDialogOpen(true);
}}> }}>
<TrashIcon className="h-5 w-5 text-slate-500 hover:text-red-700" /> <TrashIcon />
</button> </Button>
<DeleteDialog <DeleteDialog
open={deleteDialogOpen} open={deleteDialogOpen}
setOpen={setDeleteDialogOpen} setOpen={setDeleteDialogOpen}
deleteWhat="person" deleteWhat="person"
onDelete={handleDeletePerson} onDelete={handleDeletePerson}
isDeleting={isDeletingPerson} isDeleting={isDeletingPerson}
text={t("environments.contacts.delete_contact_confirmation")}
/> />
</> </>
); );
@@ -6,12 +6,21 @@ import { createContactsFromCSVAction } from "@/modules/ee/contacts/actions";
import { CsvTable } from "@/modules/ee/contacts/components/csv-table"; import { CsvTable } from "@/modules/ee/contacts/components/csv-table";
import { UploadContactsAttributes } from "@/modules/ee/contacts/components/upload-contacts-attribute"; import { UploadContactsAttributes } from "@/modules/ee/contacts/components/upload-contacts-attribute";
import { TContactCSVUploadResponse, ZContactCSVUploadResponse } from "@/modules/ee/contacts/types/contact"; import { TContactCSVUploadResponse, ZContactCSVUploadResponse } from "@/modules/ee/contacts/types/contact";
import { Alert } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Modal } from "@/modules/ui/components/modal"; import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { StylingTabs } from "@/modules/ui/components/styling-tabs"; import { StylingTabs } from "@/modules/ui/components/styling-tabs";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { parse } from "csv-parse/sync"; import { parse } from "csv-parse/sync";
import { ArrowUpFromLineIcon, CircleAlertIcon, FileUpIcon, PlusIcon, XIcon } from "lucide-react"; import { ArrowUpFromLineIcon, FileUpIcon, PlusIcon } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key"; import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
@@ -286,190 +295,155 @@ export const UploadContactsCSVButton = ({
{t("common.upload")} CSV {t("common.upload")} CSV
<PlusIcon /> <PlusIcon />
</Button> </Button>
<Modal <Dialog open={open} onOpenChange={setOpen}>
open={open} <DialogContent disableCloseOnOutsideClick={true} className="overflow-auto">
setOpen={setOpen} <DialogHeader>
noPadding <FileUpIcon />
closeOnOutsideClick={false} <DialogTitle>{t("common.upload")} CSV</DialogTitle>
className="overflow-auto" <DialogDescription>
size="xl" {t("environments.contacts.upload_contacts_modal_description")}
hideCloseButton> </DialogDescription>
<div className="sticky top-0 flex h-full flex-col rounded-lg"> </DialogHeader>
<button
className={cn(
"absolute right-0 top-0 hidden pr-4 pt-4 text-slate-400 hover:text-slate-500 focus:outline-none focus:ring-0 sm:block"
)}
onClick={() => {
resetState(true);
}}>
<XIcon className="h-6 w-6 rounded-md bg-white" />
<span className="sr-only">Close</span>
</button>
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<FileUpIcon className="h-5 w-5" />
</div>
<div>
<div className="text-xl font-medium text-slate-700">{t("common.upload")} CSV</div>
<div className="text-sm text-slate-500">
{t("environments.contacts.upload_contacts_modal_description")}
</div>
</div>
</div>
</div>
</div>
</div>
{error ? (
<div
className="mx-6 my-4 flex items-center gap-2 rounded-md border-2 border-red-200 bg-red-50 p-4"
ref={errorContainerRef}>
<CircleAlertIcon className="text-red-600" />
<p className="text-red-600">{error}</p>
</div>
) : null}
<div className="flex flex-col gap-8 px-6 py-4">
<div className="flex flex-col gap-2">
<div className="no-scrollbar max-h-[400px] overflow-auto rounded-md border-2 border-dashed border-slate-300 bg-slate-50 p-4">
{!csvResponse.length ? (
<div>
<label
htmlFor="file"
className={cn(
"relative flex cursor-pointer flex-col items-center justify-center rounded-lg hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-700 dark:hover:border-slate-500 dark:hover:bg-slate-800"
)}
onDragOver={(e) => handleDragOver(e)}
onDrop={(e) => handleDrop(e)}>
<div className="flex flex-col items-center justify-center pb-6 pt-5">
<ArrowUpFromLineIcon className="h-6 text-slate-500" />
<p className={cn("mt-2 text-center text-sm text-slate-500")}>
<span className="font-semibold">{t("common.upload_input_description")}</span>
</p>
<input
type="file"
id={"file"}
name={"file"}
accept=".csv"
className="hidden"
onChange={handleFileUpload}
/>
</div>
</label>
</div>
) : (
<div className="flex flex-col items-center gap-8">
<h3 className="font-medium text-slate-500">
{t("environments.contacts.upload_contacts_modal_preview")}
</h3>
<div className="h-[300px] w-full overflow-auto rounded-md border border-slate-300">
<CsvTable data={[...csvResponse.slice(0, 11)]} />
</div>
</div>
)}
</div>
{!csvResponse.length && (
<div className="flex justify-start">
<Button onClick={handleDownloadExampleCSV} variant="secondary">
{t("environments.contacts.upload_contacts_modal_download_example_csv")}
</Button>
</div>
)}
</div>
{csvResponse.length > 0 ? (
<div className="flex flex-col">
<h3 className="font-medium text-slate-900">
{t("environments.contacts.upload_contacts_modal_attributes_title")}
</h3>
<p className="mb-2 text-slate-500">
{t("environments.contacts.upload_contacts_modal_attributes_description")}
</p>
<DialogBody>
<div className="flex flex-col gap-6">
{error ? (
<Alert variant="error" size="small">
{error}
</Alert>
) : null}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{csvColumns.map((column, index) => { <div className="no-scrollbar rounded-md border-2 border-dashed border-slate-300 bg-slate-50 p-4">
return ( {!csvResponse.length ? (
<UploadContactsAttributes <div>
key={index} <label
csvColumn={column} htmlFor="file"
attributeMap={attributeMap} className={cn(
setAttributeMap={setAttributeMap} "relative flex cursor-pointer flex-col items-center justify-center rounded-lg hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-700 dark:hover:border-slate-500 dark:hover:bg-slate-800"
contactAttributeKeys={contactAttributeKeys} )}
/> onDragOver={(e) => handleDragOver(e)}
); onDrop={(e) => handleDrop(e)}>
})} <div className="flex flex-col items-center justify-center pb-6 pt-5">
<ArrowUpFromLineIcon className="h-6 text-slate-500" />
<p className={cn("mt-2 text-center text-sm text-slate-500")}>
<span className="font-semibold">{t("common.upload_input_description")}</span>
</p>
<input
type="file"
id={"file"}
name={"file"}
accept=".csv"
className="hidden"
onChange={handleFileUpload}
/>
</div>
</label>
</div>
) : (
<div className="flex flex-col items-center gap-8">
<h3 className="font-medium text-slate-500">
{t("environments.contacts.upload_contacts_modal_preview")}
</h3>
<div className="h-[300px] w-full overflow-auto rounded-md border border-slate-300">
<CsvTable data={[...csvResponse.slice(0, 11)]} />
</div>
</div>
)}
</div>
{!csvResponse.length && (
<div className="flex justify-start">
<Button onClick={handleDownloadExampleCSV} variant="secondary">
{t("environments.contacts.upload_contacts_modal_download_example_csv")}
</Button>
</div>
)}
</div>
{csvResponse.length > 0 ? (
<div className="flex flex-col">
<h3 className="font-medium text-slate-900">
{t("environments.contacts.upload_contacts_modal_attributes_title")}
</h3>
<p className="mb-2 text-slate-500">
{t("environments.contacts.upload_contacts_modal_attributes_description")}
</p>
<div className="flex flex-col gap-2">
{csvColumns.map((column, index) => {
return (
<UploadContactsAttributes
key={index}
csvColumn={column}
attributeMap={attributeMap}
setAttributeMap={setAttributeMap}
contactAttributeKeys={contactAttributeKeys}
/>
);
})}
</div>
</div>
) : null}
<div className="flex flex-col">
<h3 className="font-medium text-slate-900">
{t("environments.contacts.upload_contacts_modal_duplicates_title")}
</h3>
<p className="mb-2 text-slate-500">
{t("environments.contacts.upload_contacts_modal_duplicates_description")}
</p>
<StylingTabs
id="duplicate-contacts"
options={[
{
value: "skip",
label: t("environments.contacts.upload_contacts_modal_duplicates_skip_title"),
},
{
value: "update",
label: t("environments.contacts.upload_contacts_modal_duplicates_update_title"),
},
{
value: "overwrite",
label: t("environments.contacts.upload_contacts_modal_duplicates_overwrite_title"),
},
]}
defaultSelected={duplicateContactsAction}
onChange={(value) => setDuplicateContactsAction(value)}
className="max-w-[400px]"
tabsContainerClassName="p-1 rounded-lg"
/>
<div className="mt-1">
<p className="text-sm font-medium text-slate-500">
{duplicateContactsAction === "skip" &&
t("environments.contacts.upload_contacts_modal_duplicates_skip_description")}
{duplicateContactsAction === "update" &&
t("environments.contacts.upload_contacts_modal_duplicates_update_description")}
{duplicateContactsAction === "overwrite" &&
t("environments.contacts.upload_contacts_modal_duplicates_overwrite_description")}
</p>
</div>
</div> </div>
</div> </div>
) : null} </DialogBody>
<div className="flex flex-col"> <DialogFooter>
<h3 className="font-medium text-slate-900">
{t("environments.contacts.upload_contacts_modal_duplicates_title")}
</h3>
<p className="mb-2 text-slate-500">
{t("environments.contacts.upload_contacts_modal_duplicates_description")}
</p>
<StylingTabs
id="duplicate-contacts"
options={[
{
value: "skip",
label: t("environments.contacts.upload_contacts_modal_duplicates_skip_title"),
},
{
value: "update",
label: t("environments.contacts.upload_contacts_modal_duplicates_update_title"),
},
{
value: "overwrite",
label: t("environments.contacts.upload_contacts_modal_duplicates_overwrite_title"),
},
]}
defaultSelected={duplicateContactsAction}
onChange={(value) => setDuplicateContactsAction(value)}
className="max-w-[400px]"
tabsContainerClassName="p-1 rounded-lg"
/>
<div className="mt-1">
<p className="text-sm font-medium text-slate-500">
{duplicateContactsAction === "skip" &&
t("environments.contacts.upload_contacts_modal_duplicates_skip_description")}
{duplicateContactsAction === "update" &&
t("environments.contacts.upload_contacts_modal_duplicates_update_description")}
{duplicateContactsAction === "overwrite" &&
t("environments.contacts.upload_contacts_modal_duplicates_overwrite_description")}
</p>
</div>
</div>
</div>
<div className="sticky bottom-0 w-full bg-white">
<div className="flex justify-end rounded-b-lg p-4">
{csvResponse.length > 0 ? ( {csvResponse.length > 0 ? (
<Button <Button
size="sm"
variant="secondary" variant="secondary"
onClick={() => { onClick={() => {
resetState(); resetState();
}} }}>
className="mr-2">
{t("environments.contacts.upload_contacts_modal_pick_different_file")} {t("environments.contacts.upload_contacts_modal_pick_different_file")}
</Button> </Button>
) : null} ) : null}
<Button <Button onClick={handleUpload} loading={loading} disabled={loading || !csvResponse.length}>
size="sm"
onClick={handleUpload}
loading={loading}
disabled={loading || !csvResponse.length}>
{t("environments.contacts.upload_contacts_modal_upload_btn")} {t("environments.contacts.upload_contacts_modal_upload_btn")}
</Button> </Button>
</div> </DialogFooter>
</div> </DialogContent>
</Modal> </Dialog>
</> </>
); );
}; };
@@ -10,6 +10,12 @@ vi.mock("jsonwebtoken", () => ({
default: { default: {
sign: vi.fn(), sign: vi.fn(),
verify: vi.fn(), verify: vi.fn(),
TokenExpiredError: class TokenExpiredError extends Error {
constructor(message: string) {
super(message);
this.name = "TokenExpiredError";
}
},
}, },
})); }));
@@ -145,8 +151,8 @@ describe("Contact Survey Link", () => {
if (!result.ok) { if (!result.ok) {
expect(result.error).toEqual({ expect(result.error).toEqual({
type: "bad_request", type: "bad_request",
message: "Invalid or expired survey token", message: "Invalid survey token",
details: [{ field: "token", issue: "Invalid or expired survey token" }], details: [{ field: "token", issue: "invalid_token" }],
}); });
} }
}); });
@@ -166,8 +172,8 @@ describe("Contact Survey Link", () => {
if (!result.ok) { if (!result.ok) {
expect(result.error).toEqual({ expect(result.error).toEqual({
type: "bad_request", type: "bad_request",
message: "Invalid or expired survey token", message: "Invalid survey token",
details: [{ field: "token", issue: "Invalid or expired survey token" }], details: [{ field: "token", issue: "invalid_token" }],
}); });
} }
}); });
@@ -3,6 +3,7 @@ import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
import { getPublicDomain } from "@/lib/getPublicUrl"; import { getPublicDomain } from "@/lib/getPublicUrl";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import { logger } from "@formbricks/logger";
import { Result, err, ok } from "@formbricks/types/error-handlers"; import { Result, err, ok } from "@formbricks/types/error-handlers";
// Creates an encrypted personalized survey link for a contact // Creates an encrypted personalized survey link for a contact
@@ -73,11 +74,22 @@ export const verifyContactSurveyToken = (
surveyId, surveyId,
}); });
} catch (error) { } catch (error) {
console.error("Error verifying contact survey token:", error); logger.error("Error verifying contact survey token:", error);
// Check if the error is specifically a JWT expiration error
if (error instanceof jwt.TokenExpiredError) {
return err({
type: "bad_request",
message: "Survey link has expired",
details: [{ field: "token", issue: "token_expired" }],
});
}
// Handle other JWT errors or general validation errors
return err({ return err({
type: "bad_request", type: "bad_request",
message: "Invalid or expired survey token", message: "Invalid survey token",
details: [{ field: "token", issue: "Invalid or expired survey token" }], details: [{ field: "token", issue: "invalid_token" }],
}); });
} }
}; };
+365 -12
View File
@@ -6,10 +6,31 @@ import {
buildContactWhereClause, buildContactWhereClause,
createContactsFromCSV, createContactsFromCSV,
deleteContact, deleteContact,
generatePersonalLinks,
getContact, getContact,
getContacts, getContacts,
getContactsInSegment,
} from "./contacts"; } from "./contacts";
// Mock additional dependencies for the new functions
vi.mock("@/modules/ee/contacts/segments/lib/segments", () => ({
getSegment: vi.fn(),
}));
vi.mock("@/modules/ee/contacts/segments/lib/filter/prisma-query", () => ({
segmentFilterToPrismaQuery: vi.fn(),
}));
vi.mock("@/modules/ee/contacts/lib/contact-survey-link", () => ({
getContactSurveyLink: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
vi.mock("@formbricks/database", () => ({ vi.mock("@formbricks/database", () => ({
prisma: { prisma: {
contact: { contact: {
@@ -31,11 +52,18 @@ vi.mock("@formbricks/database", () => ({
}, },
}, },
})); }));
vi.mock("@/lib/constants", () => ({ ITEMS_PER_PAGE: 2 })); vi.mock("@/lib/constants", () => ({
ITEMS_PER_PAGE: 2,
ENCRYPTION_KEY: "test-encryption-key-32-chars-long!",
IS_PRODUCTION: false,
IS_POSTHOG_CONFIGURED: false,
POSTHOG_API_HOST: "test-posthog-host",
POSTHOG_API_KEY: "test-posthog-key",
}));
const environmentId = "env1"; const environmentId = "cm123456789012345678901237";
const contactId = "contact1"; const contactId = "cm123456789012345678901238";
const userId = "user1"; const userId = "cm123456789012345678901239";
const mockContact: Contact & { const mockContact: Contact & {
attributes: { value: string; attributeKey: { key: string; name: string } }[]; attributes: { value: string; attributeKey: { key: string; name: string } }[];
} = { } = {
@@ -159,7 +187,7 @@ describe("createContactsFromCSV", () => {
.mockResolvedValueOnce([ .mockResolvedValueOnce([
{ key: "email", id: "id-email" }, { key: "email", id: "id-email" },
{ key: "name", id: "id-name" }, { key: "name", id: "id-name" },
]); ] as any);
vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 2 }); vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 2 });
vi.mocked(prisma.contact.create).mockResolvedValue({ vi.mocked(prisma.contact.create).mockResolvedValue({
id: "c1", id: "c1",
@@ -183,12 +211,12 @@ describe("createContactsFromCSV", () => {
test("skips duplicate contact with 'skip' action", async () => { test("skips duplicate contact with 'skip' action", async () => {
vi.mocked(prisma.contact.findMany).mockResolvedValue([ vi.mocked(prisma.contact.findMany).mockResolvedValue([
{ id: "c1", attributes: [{ attributeKey: { key: "email" }, value: "john@example.com" }] }, { id: "c1", attributes: [{ attributeKey: { key: "email" }, value: "john@example.com" }] },
]); ] as any);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]); vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([ vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([
{ key: "email", id: "id-email" }, { key: "email", id: "id-email" },
{ key: "name", id: "id-name" }, { key: "name", id: "id-name" },
]); ] as any);
const csvData = [{ email: "john@example.com", name: "John" }]; const csvData = [{ email: "john@example.com", name: "John" }];
const result = await createContactsFromCSV(csvData, environmentId, "skip", { const result = await createContactsFromCSV(csvData, environmentId, "skip", {
email: "email", email: "email",
@@ -206,12 +234,12 @@ describe("createContactsFromCSV", () => {
{ attributeKey: { key: "name" }, value: "Old" }, { attributeKey: { key: "name" }, value: "Old" },
], ],
}, },
]); ] as any);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]); vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([ vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([
{ key: "email", id: "id-email" }, { key: "email", id: "id-email" },
{ key: "name", id: "id-name" }, { key: "name", id: "id-name" },
]); ] as any);
vi.mocked(prisma.contact.update).mockResolvedValue({ vi.mocked(prisma.contact.update).mockResolvedValue({
id: "c1", id: "c1",
environmentId, environmentId,
@@ -239,12 +267,12 @@ describe("createContactsFromCSV", () => {
{ attributeKey: { key: "name" }, value: "Old" }, { attributeKey: { key: "name" }, value: "Old" },
], ],
}, },
]); ] as any);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]); vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([ vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([
{ key: "email", id: "id-email" }, { key: "email", id: "id-email" },
{ key: "name", id: "id-name" }, { key: "name", id: "id-name" },
]); ] as any);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 2 }); vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 2 });
vi.mocked(prisma.contact.update).mockResolvedValue({ vi.mocked(prisma.contact.update).mockResolvedValue({
id: "c1", id: "c1",
@@ -326,8 +354,333 @@ describe("buildContactWhereClause", () => {
}); });
test("returns where clause without search", () => { test("returns where clause without search", () => {
const environmentId = "env-1"; const environmentId = "cm123456789012345678901240";
const result = buildContactWhereClause(environmentId); const result = buildContactWhereClause(environmentId);
expect(result).toEqual({ environmentId }); expect(result).toEqual({ environmentId });
}); });
}); });
describe("getContactsInSegment", () => {
const mockSegmentId = "cm123456789012345678901235";
const mockEnvironmentId = "cm123456789012345678901236";
beforeEach(() => {
vi.clearAllMocks();
});
test("returns contacts when segment and filters are valid", async () => {
const mockSegment = {
id: mockSegmentId,
createdAt: new Date(),
updatedAt: new Date(),
environmentId: mockEnvironmentId,
description: "Test segment",
title: "Test Segment",
isPrivate: false,
surveys: [],
filters: [],
};
const mockContacts = [
{
id: "contact-1",
attributes: [
{ attributeKey: { key: "email" }, value: "test@example.com" },
{ attributeKey: { key: "name" }, value: "Test User" },
],
},
{
id: "contact-2",
attributes: [
{ attributeKey: { key: "email" }, value: "another@example.com" },
{ attributeKey: { key: "name" }, value: "Another User" },
],
},
] as any;
const mockWhereClause = {
environmentId: mockEnvironmentId,
attributes: {
some: {
attributeKey: { key: "email" },
value: "test@example.com",
},
},
};
// Mock the dependencies
const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
const { segmentFilterToPrismaQuery } = await import(
"@/modules/ee/contacts/segments/lib/filter/prisma-query"
);
vi.mocked(getSegment).mockResolvedValue(mockSegment);
vi.mocked(segmentFilterToPrismaQuery).mockResolvedValue({
ok: true,
data: { whereClause: mockWhereClause },
} as any);
vi.mocked(prisma.contact.findMany).mockResolvedValue(mockContacts);
const result = await getContactsInSegment(mockSegmentId);
expect(result).toEqual([
{
contactId: "contact-1",
attributes: {
email: "test@example.com",
name: "Test User",
},
},
{
contactId: "contact-2",
attributes: {
email: "another@example.com",
name: "Another User",
},
},
]);
expect(prisma.contact.findMany).toHaveBeenCalledWith({
where: mockWhereClause,
select: {
id: true,
attributes: {
where: {
attributeKey: {
key: {
in: ["userId", "firstName", "lastName", "email"],
},
},
},
select: {
attributeKey: {
select: {
key: true,
},
},
value: true,
},
},
},
orderBy: {
createdAt: "desc",
},
});
});
test("returns null when segment is not found", async () => {
const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
vi.mocked(getSegment).mockRejectedValue(new Error("Segment not found"));
const result = await getContactsInSegment(mockSegmentId);
expect(result).toBeNull();
});
test("returns null when segment filter to prisma query fails", async () => {
const mockSegment = {
id: mockSegmentId,
createdAt: new Date(),
updatedAt: new Date(),
environmentId: mockEnvironmentId,
description: "Test segment",
title: "Test Segment",
isPrivate: false,
surveys: [],
filters: [],
};
const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
const { segmentFilterToPrismaQuery } = await import(
"@/modules/ee/contacts/segments/lib/filter/prisma-query"
);
vi.mocked(getSegment).mockResolvedValue(mockSegment);
vi.mocked(segmentFilterToPrismaQuery).mockResolvedValue({
ok: false,
error: { type: "bad_request" },
} as any);
const result = await getContactsInSegment(mockSegmentId);
expect(result).toBeNull();
});
test("returns null when prisma query fails", async () => {
const mockSegment = {
id: mockSegmentId,
createdAt: new Date(),
updatedAt: new Date(),
environmentId: mockEnvironmentId,
description: "Test segment",
title: "Test Segment",
isPrivate: false,
surveys: [],
filters: [],
};
const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
const { segmentFilterToPrismaQuery } = await import(
"@/modules/ee/contacts/segments/lib/filter/prisma-query"
);
vi.mocked(getSegment).mockResolvedValue(mockSegment);
vi.mocked(segmentFilterToPrismaQuery).mockResolvedValue({
ok: true,
data: { whereClause: {} },
} as any);
vi.mocked(prisma.contact.findMany).mockRejectedValue(new Error("Database error"));
const result = await getContactsInSegment(mockSegmentId);
expect(result).toBeNull();
});
test("handles errors gracefully", async () => {
const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
vi.mocked(getSegment).mockRejectedValue(new Error("Database error"));
const result = await getContactsInSegment(mockSegmentId);
expect(result).toBeNull(); // The function catches errors and returns null
});
});
describe("generatePersonalLinks", () => {
const mockSurveyId = "cm123456789012345678901234"; // Valid CUID2 format
const mockSegmentId = "cm123456789012345678901235"; // Valid CUID2 format
const mockExpirationDays = 7;
beforeEach(() => {
vi.clearAllMocks();
});
test("returns null when getContactsInSegment fails", async () => {
// Mock getSegment to fail which will cause getContactsInSegment to return null
const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
vi.mocked(getSegment).mockRejectedValue(new Error("Segment not found"));
const result = await generatePersonalLinks(mockSurveyId, mockSegmentId);
expect(result).toBeNull();
});
test("returns empty array when no contacts in segment", async () => {
// Mock successful segment retrieval but no contacts
const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
const { segmentFilterToPrismaQuery } = await import(
"@/modules/ee/contacts/segments/lib/filter/prisma-query"
);
vi.mocked(getSegment).mockResolvedValue({
id: mockSegmentId,
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env-123",
description: "Test segment",
title: "Test Segment",
isPrivate: false,
surveys: [],
filters: [],
});
vi.mocked(segmentFilterToPrismaQuery).mockResolvedValue({
ok: true,
data: { whereClause: {} },
} as any);
vi.mocked(prisma.contact.findMany).mockResolvedValue([]);
const result = await generatePersonalLinks(mockSurveyId, mockSegmentId);
expect(result).toEqual([]);
});
test("generates personal links for contacts successfully", async () => {
// Mock all the dependencies that getContactsInSegment needs
const { getSegment } = await import("@/modules/ee/contacts/segments/lib/segments");
const { segmentFilterToPrismaQuery } = await import(
"@/modules/ee/contacts/segments/lib/filter/prisma-query"
);
const { getContactSurveyLink } = await import("@/modules/ee/contacts/lib/contact-survey-link");
vi.mocked(getSegment).mockResolvedValue({
id: mockSegmentId,
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env-123",
description: "Test segment",
title: "Test Segment",
isPrivate: false,
surveys: [],
filters: [],
});
vi.mocked(segmentFilterToPrismaQuery).mockResolvedValue({
ok: true,
data: { whereClause: {} },
} as any);
vi.mocked(prisma.contact.findMany).mockResolvedValue([
{
id: "contact-1",
attributes: [
{ attributeKey: { key: "email" }, value: "test@example.com" },
{ attributeKey: { key: "name" }, value: "Test User" },
],
},
{
id: "contact-2",
attributes: [
{ attributeKey: { key: "email" }, value: "another@example.com" },
{ attributeKey: { key: "name" }, value: "Another User" },
],
},
] as any);
// Mock getContactSurveyLink to return successful results
vi.mocked(getContactSurveyLink)
.mockReturnValueOnce({
ok: true,
data: "https://example.com/survey/link1",
})
.mockReturnValueOnce({
ok: true,
data: "https://example.com/survey/link2",
});
const result = await generatePersonalLinks(mockSurveyId, mockSegmentId, mockExpirationDays);
expect(result).toEqual([
{
contactId: "contact-1",
attributes: {
email: "test@example.com",
name: "Test User",
},
surveyUrl: "https://example.com/survey/link1",
expirationDays: mockExpirationDays,
},
{
contactId: "contact-2",
attributes: {
email: "another@example.com",
name: "Another User",
},
surveyUrl: "https://example.com/survey/link2",
expirationDays: mockExpirationDays,
},
]);
expect(getContactSurveyLink).toHaveBeenCalledWith("contact-1", mockSurveyId, mockExpirationDays);
expect(getContactSurveyLink).toHaveBeenCalledWith("contact-2", mockSurveyId, mockExpirationDays);
});
});
@@ -1,9 +1,13 @@
import "server-only"; import "server-only";
import { ITEMS_PER_PAGE } from "@/lib/constants"; import { ITEMS_PER_PAGE } from "@/lib/constants";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
import { segmentFilterToPrismaQuery } from "@/modules/ee/contacts/segments/lib/filter/prisma-query";
import { getSegment } from "@/modules/ee/contacts/segments/lib/segments";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react"; import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber, ZOptionalString } from "@formbricks/types/common"; import { ZId, ZOptionalNumber, ZOptionalString } from "@formbricks/types/common";
import { DatabaseError, ValidationError } from "@formbricks/types/errors"; import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import { import {
@@ -15,6 +19,76 @@ import {
} from "../types/contact"; } from "../types/contact";
import { transformPrismaContact } from "./utils"; import { transformPrismaContact } from "./utils";
export const getContactsInSegment = reactCache(async (segmentId: string) => {
try {
const segment = await getSegment(segmentId);
if (!segment) {
return null;
}
const segmentFilterToPrismaQueryResult = await segmentFilterToPrismaQuery(
segment.id,
segment.filters,
segment.environmentId
);
if (!segmentFilterToPrismaQueryResult.ok) {
return null;
}
const { whereClause } = segmentFilterToPrismaQueryResult.data;
const requiredAttributes = ["userId", "firstName", "lastName", "email"];
const contacts = await prisma.contact.findMany({
where: whereClause,
select: {
id: true,
attributes: {
where: {
attributeKey: {
key: {
in: requiredAttributes,
},
},
},
select: {
attributeKey: {
select: {
key: true,
},
},
value: true,
},
},
},
orderBy: {
createdAt: "desc",
},
});
const contactsWithAttributes = contacts.map((contact) => {
const attributes = contact.attributes.reduce(
(acc, attr) => {
acc[attr.attributeKey.key] = attr.value;
return acc;
},
{} as Record<string, string>
);
return {
contactId: contact.id,
attributes,
};
});
return contactsWithAttributes;
} catch (error) {
logger.error(error, "Failed to get contacts in segment");
return null;
}
});
const selectContact = { const selectContact = {
id: true, id: true,
createdAt: true, createdAt: true,
@@ -418,3 +492,37 @@ export const createContactsFromCSV = async (
throw error; throw error;
} }
}; };
export const generatePersonalLinks = async (surveyId: string, segmentId: string, expirationDays?: number) => {
const contactsResult = await getContactsInSegment(segmentId);
if (!contactsResult) {
return null;
}
// Generate survey links for each contact
const contactLinks = contactsResult
.map((contact) => {
const { contactId, attributes } = contact;
const surveyUrlResult = getContactSurveyLink(contactId, surveyId, expirationDays);
if (!surveyUrlResult.ok) {
logger.error(
{ error: surveyUrlResult.error, contactId: contactId, surveyId: surveyId },
"Failed to generate survey URL for contact"
);
return null;
}
return {
contactId,
attributes,
surveyUrl: surveyUrlResult.data,
expirationDays,
};
})
.filter(Boolean);
return contactLinks;
};
@@ -6,47 +6,66 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key"; import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TSegment } from "@formbricks/types/segment"; import { TSegment } from "@formbricks/types/segment";
// Mock the Modal component // Mock the Dialog components
vi.mock("@/modules/ui/components/modal", () => ({ vi.mock("@/modules/ui/components/dialog", () => ({
Modal: ({ Dialog: ({
children, children,
open, open,
closeOnOutsideClick, onOpenChange,
setOpen,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
open: boolean; open: boolean;
closeOnOutsideClick?: boolean; onOpenChange: (open: boolean) => void;
setOpen?: (open: boolean) => void; }) =>
}) => { open ? (
return open ? ( // NOSONAR // This is a mock <div data-testid="dialog">
<button {children}
data-testid="modal-overlay" <button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
onClick={(e) => { Close
if (closeOnOutsideClick && e.target === e.currentTarget && setOpen) { </button>
setOpen(false); </div>
} ) : null,
}}> DialogContent: ({
<div data-testid="modal-content">{children}</div> children,
</button> className,
) : null; // NOSONAR // This is a mock hideCloseButton,
}, }: {
children: React.ReactNode;
className?: string;
hideCloseButton?: boolean;
}) => (
<div data-testid="dialog-content" className={className} data-hide-close-button={hideCloseButton}>
{children}
</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogBody: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-body">{children}</div>
),
}));
// Mock the Input component
vi.mock("@/modules/ui/components/input", () => ({
Input: ({ placeholder, onChange, autoFocus }: any) => (
<input data-testid="search-input" placeholder={placeholder} onChange={onChange} autoFocus={autoFocus} />
),
})); }));
// Mock the TabBar component // Mock the TabBar component
vi.mock("@/modules/ui/components/tab-bar", () => ({ vi.mock("@/modules/ui/components/tab-bar", () => ({
TabBar: ({ TabBar: ({ tabs, activeId, setActiveId }: any) => (
tabs, <div data-testid="tab-bar">
activeId, {tabs.map((tab: any) => (
setActiveId, <button
}: { key={tab.id}
tabs: any[]; data-testid={`tab-${tab.id}`}
activeId: string; onClick={() => setActiveId(tab.id)}
setActiveId: (id: string) => void; className={activeId === tab.id ? "active" : ""}>
}) => (
<div>
{tabs.map((tab) => (
<button key={tab.id} data-testid={`tab-${tab.id}`} onClick={() => setActiveId(tab.id)}>
{tab.label} {activeId === tab.id ? "(Active)" : ""} {tab.label} {activeId === tab.id ? "(Active)" : ""}
</button> </button>
))} ))}
@@ -54,11 +73,94 @@ vi.mock("@/modules/ui/components/tab-bar", () => ({
), ),
})); }));
// Mock the useTranslate hook
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => {
const translations = {
"common.add_filter": "Add Filter",
"common.all": "All",
"environments.segments.person_and_attributes": "Person & Attributes",
"common.segments": "Segments",
"environments.segments.devices": "Devices",
"environments.segments.phone": "Phone",
"environments.segments.desktop": "Desktop",
"environments.segments.no_filters_yet": "No filters yet",
"environments.segments.no_segments_yet": "No segments yet",
"environments.segments.no_attributes_yet": "No attributes yet",
"common.user_id": "userId",
"common.person": "Person",
"common.attributes": "Attributes",
};
return translations[key] || key;
},
}),
}));
// Mock createId // Mock createId
vi.mock("@paralleldrive/cuid2", () => ({ vi.mock("@paralleldrive/cuid2", () => ({
createId: vi.fn(() => "mockCuid"), createId: vi.fn(() => "mockCuid"),
})); }));
// Mock the AttributeTabContent component
vi.mock("./attribute-tab-content", () => ({
default: ({ contactAttributeKeys, onAddFilter, setOpen, handleAddFilter }: any) => (
<div data-testid="attribute-tab-content">
<h2>Person</h2>
<button
data-testid="filter-btn-person-userId"
onClick={() => handleAddFilter({ type: "person", onAddFilter, setOpen })}
onKeyDown={(e: any) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleAddFilter({ type: "person", onAddFilter, setOpen });
}
}}
tabIndex={0}>
userId
</button>
<hr />
<h2>Attributes</h2>
{contactAttributeKeys.length === 0 ? (
<p>No attributes yet</p>
) : (
contactAttributeKeys.map((attr: any) => (
<button
key={attr.id}
data-testid={`filter-btn-attribute-${attr.key}`}
onClick={() =>
handleAddFilter({ type: "attribute", onAddFilter, setOpen, contactAttributeKey: attr.key })
}
onKeyDown={(e: any) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleAddFilter({ type: "attribute", onAddFilter, setOpen, contactAttributeKey: attr.key });
}
}}
tabIndex={0}>
{attr.name ?? attr.key}
</button>
))
)}
</div>
),
}));
// Mock the FilterButton component
vi.mock("./filter-button", () => ({
default: ({ icon, label, onClick, onKeyDown, tabIndex = 0, ...props }: any) => (
<button
className="flex w-full cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50"
tabIndex={tabIndex}
onClick={onClick}
onKeyDown={onKeyDown}
{...props}>
{icon}
<span>{label}</span>
</button>
),
}));
const mockContactAttributeKeys: TContactAttributeKey[] = [ const mockContactAttributeKeys: TContactAttributeKey[] = [
{ {
id: "attr1", id: "attr1",
@@ -154,16 +256,20 @@ describe("AddFilterModal", () => {
/> />
); );
// ... assertions ... // ... assertions ...
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-content")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Add Filter");
expect(screen.getByTestId("search-input")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Browse filters...")).toBeInTheDocument(); expect(screen.getByPlaceholderText("Browse filters...")).toBeInTheDocument();
expect(screen.getByTestId("tab-all")).toHaveTextContent("common.all (Active)"); expect(screen.getByTestId("tab-all")).toHaveTextContent("All (Active)");
expect(screen.getByText("Email Address")).toBeInTheDocument(); expect(screen.getByText("Email Address")).toBeInTheDocument();
expect(screen.getByText("Plan Type")).toBeInTheDocument(); expect(screen.getByText("Plan Type")).toBeInTheDocument();
expect(screen.getByText("userId")).toBeInTheDocument(); expect(screen.getByText("userId")).toBeInTheDocument();
expect(screen.getByText("Active Users")).toBeInTheDocument(); expect(screen.getByText("Active Users")).toBeInTheDocument();
expect(screen.getByText("Paying Customers")).toBeInTheDocument(); expect(screen.getByText("Paying Customers")).toBeInTheDocument();
expect(screen.queryByText("Private Segment")).not.toBeInTheDocument(); expect(screen.queryByText("Private Segment")).not.toBeInTheDocument();
expect(screen.getByText("environments.segments.phone")).toBeInTheDocument(); expect(screen.getByText("Phone")).toBeInTheDocument();
expect(screen.getByText("environments.segments.desktop")).toBeInTheDocument(); expect(screen.getByText("Desktop")).toBeInTheDocument();
}); });
test("does not render when closed", () => { test("does not render when closed", () => {
@@ -176,6 +282,7 @@ describe("AddFilterModal", () => {
segments={mockSegments} segments={mockSegments}
/> />
); );
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText("Browse filters...")).not.toBeInTheDocument(); expect(screen.queryByPlaceholderText("Browse filters...")).not.toBeInTheDocument();
}); });
@@ -210,22 +317,22 @@ describe("AddFilterModal", () => {
const attributesTabButton = screen.getByTestId("tab-attributes"); const attributesTabButton = screen.getByTestId("tab-attributes");
await user.click(attributesTabButton); await user.click(attributesTabButton);
// ... assertions ... // ... assertions ...
expect(attributesTabButton).toHaveTextContent("environments.segments.person_and_attributes (Active)"); expect(attributesTabButton).toHaveTextContent("Person & Attributes (Active)");
expect(screen.getByText("common.user_id")).toBeInTheDocument(); expect(screen.getByText("userId")).toBeInTheDocument();
// Switch to Segments tab // Switch to Segments tab
const segmentsTabButton = screen.getByTestId("tab-segments"); const segmentsTabButton = screen.getByTestId("tab-segments");
await user.click(segmentsTabButton); await user.click(segmentsTabButton);
// ... assertions ... // ... assertions ...
expect(segmentsTabButton).toHaveTextContent("common.segments (Active)"); expect(segmentsTabButton).toHaveTextContent("Segments (Active)");
expect(screen.getByText("Active Users")).toBeInTheDocument(); expect(screen.getByText("Active Users")).toBeInTheDocument();
// Switch to Devices tab // Switch to Devices tab
const devicesTabButton = screen.getByTestId("tab-devices"); const devicesTabButton = screen.getByTestId("tab-devices");
await user.click(devicesTabButton); await user.click(devicesTabButton);
// ... assertions ... // ... assertions ...
expect(devicesTabButton).toHaveTextContent("environments.segments.devices (Active)"); expect(devicesTabButton).toHaveTextContent("Devices (Active)");
expect(screen.getByText("environments.segments.phone")).toBeInTheDocument(); expect(screen.getByText("Phone")).toBeInTheDocument();
}); });
// --- Click and Keydown Tests --- // --- Click and Keydown Tests ---
@@ -499,7 +606,7 @@ describe("AddFilterModal", () => {
/> />
); );
await user.click(screen.getByTestId("tab-attributes")); await user.click(screen.getByTestId("tab-attributes"));
expect(await screen.findByText("environments.segments.no_attributes_yet")).toBeInTheDocument(); expect(await screen.findByText("No attributes yet")).toBeInTheDocument();
}); });
test("displays 'no segments yet' message", async () => { test("displays 'no segments yet' message", async () => {
@@ -513,7 +620,7 @@ describe("AddFilterModal", () => {
/> />
); );
await user.click(screen.getByTestId("tab-segments")); await user.click(screen.getByTestId("tab-segments"));
expect(await screen.findByText("environments.segments.no_segments_yet")).toBeInTheDocument(); expect(await screen.findByText("No segments yet")).toBeInTheDocument();
}); });
test("displays 'no filters match' message when search yields no results", async () => { test("displays 'no filters match' message when search yields no results", async () => {
@@ -528,7 +635,7 @@ describe("AddFilterModal", () => {
); );
const searchInput = screen.getByPlaceholderText("Browse filters..."); const searchInput = screen.getByPlaceholderText("Browse filters...");
await user.type(searchInput, "nonexistentfilter"); await user.type(searchInput, "nonexistentfilter");
expect(await screen.findByText("environments.segments.no_filters_yet")).toBeInTheDocument(); expect(await screen.findByText("No filters yet")).toBeInTheDocument();
}); });
test("verifies keyboard navigation through filter buttons", async () => { test("verifies keyboard navigation through filter buttons", async () => {
@@ -548,19 +655,19 @@ describe("AddFilterModal", () => {
// Tab to the first tab button ("all") // Tab to the first tab button ("all")
await user.tab(); await user.tab();
expect(document.activeElement).toHaveTextContent(/common\.all/); expect(document.activeElement).toHaveTextContent(/All/);
// Tab to the second tab button ("attributes") // Tab to the second tab button ("attributes")
await user.tab(); await user.tab();
expect(document.activeElement).toHaveTextContent(/person_and_attributes/); expect(document.activeElement).toHaveTextContent(/Person & Attributes/);
// Tab to the third tab button ("segments") // Tab to the third tab button ("segments")
await user.tab(); await user.tab();
expect(document.activeElement).toHaveTextContent(/common\.segments/); expect(document.activeElement).toHaveTextContent(/Segments/);
// Tab to the fourth tab button ("devices") // Tab to the fourth tab button ("devices")
await user.tab(); await user.tab();
expect(document.activeElement).toHaveTextContent(/environments\.segments\.devices/); expect(document.activeElement).toHaveTextContent(/Devices/);
// Tab to the first filter button ("Email Address") // Tab to the first filter button ("Email Address")
await user.tab(); await user.tab();
@@ -595,21 +702,4 @@ describe("AddFilterModal", () => {
expect(button).not.toHaveAttribute("tabIndex", "-1"); // Should not be unfocusable expect(button).not.toHaveAttribute("tabIndex", "-1"); // Should not be unfocusable
}); });
}); });
test("closes the modal when clicking outside the content area", async () => {
render(
<AddFilterModal
open={true}
setOpen={setOpen}
onAddFilter={onAddFilter}
contactAttributeKeys={mockContactAttributeKeys}
segments={mockSegments}
/>
);
const modalOverlay = screen.getByTestId("modal-overlay");
await user.click(modalOverlay);
expect(setOpen).toHaveBeenCalledWith(false);
});
}); });
@@ -1,8 +1,8 @@
"use client"; "use client";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { Dialog, DialogBody, DialogContent, DialogHeader, DialogTitle } from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input"; import { Input } from "@/modules/ui/components/input";
import { Modal } from "@/modules/ui/components/modal";
import { TabBar } from "@/modules/ui/components/tab-bar"; import { TabBar } from "@/modules/ui/components/tab-bar";
import { createId } from "@paralleldrive/cuid2"; import { createId } from "@paralleldrive/cuid2";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
@@ -457,26 +457,31 @@ export function AddFilterModal({
}; };
return ( return (
<Modal <Dialog open={open} onOpenChange={setOpen}>
className="sm:w-[650px] sm:max-w-full" <DialogContent width="narrow" disableCloseOnOutsideClick>
closeOnOutsideClick <DialogHeader>
hideCloseButton <DialogTitle>{t("common.add_filter")}</DialogTitle>
open={open} </DialogHeader>
setOpen={setOpen}>
<div className="flex w-auto flex-col">
<Input
autoFocus
onChange={(e) => {
setSearchValue(e.target.value);
}}
placeholder="Browse filters..."
/>
<TabBar activeId={activeTabId} className="bg-white" setActiveId={setActiveTabId} tabs={tabs} />
</div>
<div className={cn("mt-2 flex max-h-80 flex-col gap-1 overflow-y-auto")}> <DialogBody>
<TabContent /> <div className="flex flex-col">
</div> <div className="flex w-auto flex-col">
</Modal> <Input
autoFocus
onChange={(e) => {
setSearchValue(e.target.value);
}}
placeholder="Browse filters..."
/>
<TabBar activeId={activeTabId} className="bg-white" setActiveId={setActiveTabId} tabs={tabs} />
</div>
<div className={cn("mt-2 flex flex-col gap-1 overflow-y-auto")}>
<TabContent />
</div>
</div>
</DialogBody>
</DialogContent>
</Dialog>
); );
} }
@@ -27,16 +27,55 @@ vi.mock("@/modules/ee/contacts/segments/actions", () => ({
})); }));
// Mock child components that are complex or have their own tests // Mock child components that are complex or have their own tests
vi.mock("@/modules/ui/components/modal", () => ({ vi.mock("@/modules/ui/components/dialog", () => ({
Modal: ({ open, setOpen, children, noPadding, closeOnOutsideClick, size, className }) => Dialog: ({
open,
onOpenChange,
children,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
}) =>
open ? ( open ? (
<div data-testid="modal" className={className} data-size={size} data-nopadding={noPadding}> <div data-testid="dialog">
{children} {children}
<button data-testid="modal-close-outside" onClick={() => closeOnOutsideClick && setOpen(false)}> <button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
Close Outside Close
</button> </button>
</div> </div>
) : null, ) : null,
DialogContent: ({
children,
className,
disableCloseOnOutsideClick,
}: {
children: React.ReactNode;
className?: string;
disableCloseOnOutsideClick?: boolean;
}) => (
<div
data-testid="dialog-content"
className={className}
data-disable-close-on-outside-click={disableCloseOnOutsideClick}>
{children}
</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<p data-testid="dialog-description">{children}</p>
),
DialogBody: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-body">{children}</div>
),
DialogFooter: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-footer">{children}</div>
),
})); }));
vi.mock("./add-filter-modal", () => ({ vi.mock("./add-filter-modal", () => ({
@@ -84,12 +123,12 @@ describe("CreateSegmentModal", () => {
render(<CreateSegmentModal {...defaultProps} />); render(<CreateSegmentModal {...defaultProps} />);
const createButton = screen.getByText("common.create_segment"); const createButton = screen.getByText("common.create_segment");
expect(createButton).toBeInTheDocument(); expect(createButton).toBeInTheDocument();
expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
await userEvent.click(createButton); await userEvent.click(createButton);
expect(screen.getByTestId("modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByText("common.create_segment", { selector: "h3" })).toBeInTheDocument(); // Modal title expect(screen.getByText("common.create_segment", { selector: "h2" })).toBeInTheDocument(); // Modal title
}); });
test("closes modal on cancel button click", async () => { test("closes modal on cancel button click", async () => {
@@ -97,11 +136,11 @@ describe("CreateSegmentModal", () => {
const createButton = screen.getByText("common.create_segment"); const createButton = screen.getByText("common.create_segment");
await userEvent.click(createButton); await userEvent.click(createButton);
expect(screen.getByTestId("modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
const cancelButton = screen.getByText("common.cancel"); const cancelButton = screen.getByText("common.cancel");
await userEvent.click(cancelButton); await userEvent.click(cancelButton);
expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
}); });
test("updates title and description state on input change", async () => { test("updates title and description state on input change", async () => {
@@ -144,7 +183,7 @@ describe("CreateSegmentModal", () => {
await userEvent.click(openModalButton); await userEvent.click(openModalButton);
// Get modal and scope queries // Get modal and scope queries
const modal = await screen.findByTestId("modal"); const modal = await screen.findByTestId("dialog");
// Find the save button using getByText with a specific selector within the modal // Find the save button using getByText with a specific selector within the modal
const saveButton = within(modal).getByText("common.create_segment", { const saveButton = within(modal).getByText("common.create_segment", {
@@ -168,7 +207,7 @@ describe("CreateSegmentModal", () => {
await userEvent.click(createButton); await userEvent.click(createButton);
// Get modal and scope queries // Get modal and scope queries
const modal = await screen.findByTestId("modal"); const modal = await screen.findByTestId("dialog");
const titleInput = within(modal).getByPlaceholderText("environments.segments.ex_power_users"); const titleInput = within(modal).getByPlaceholderText("environments.segments.ex_power_users");
const descriptionInput = within(modal).getByPlaceholderText( const descriptionInput = within(modal).getByPlaceholderText(
@@ -196,7 +235,7 @@ describe("CreateSegmentModal", () => {
}); });
}); });
expect(toast.success).toHaveBeenCalledWith("environments.segments.segment_saved_successfully"); expect(toast.success).toHaveBeenCalledWith("environments.segments.segment_saved_successfully");
expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); // Modal should close on success expect(screen.queryByTestId("dialog")).not.toBeInTheDocument(); // Modal should close on success
}); });
test("shows error toast if createSegmentAction fails", async () => { test("shows error toast if createSegmentAction fails", async () => {
@@ -219,7 +258,7 @@ describe("CreateSegmentModal", () => {
}); });
expect(getFormattedErrorMessage).toHaveBeenCalledWith(errorResponse); expect(getFormattedErrorMessage).toHaveBeenCalledWith(errorResponse);
expect(toast.error).toHaveBeenCalledWith("Formatted API Error"); expect(toast.error).toHaveBeenCalledWith("Formatted API Error");
expect(screen.getByTestId("modal")).toBeInTheDocument(); // Modal should stay open on error expect(screen.getByTestId("dialog")).toBeInTheDocument(); // Modal should stay open on error
}); });
test("shows generic error toast if Zod parsing succeeds during save error handling", async () => { test("shows generic error toast if Zod parsing succeeds during save error handling", async () => {
@@ -230,7 +269,7 @@ describe("CreateSegmentModal", () => {
await userEvent.click(openModalButton); await userEvent.click(openModalButton);
// Get the modal element // Get the modal element
const modal = await screen.findByTestId("modal"); const modal = await screen.findByTestId("dialog");
const titleInput = within(modal).getByPlaceholderText("environments.segments.ex_power_users"); const titleInput = within(modal).getByPlaceholderText("environments.segments.ex_power_users");
await userEvent.type(titleInput, "Generic Error Segment"); await userEvent.type(titleInput, "Generic Error Segment");
@@ -253,7 +292,7 @@ describe("CreateSegmentModal", () => {
// Now that we know the catch block ran, verify the action was called // Now that we know the catch block ran, verify the action was called
expect(createSegmentAction).toHaveBeenCalled(); expect(createSegmentAction).toHaveBeenCalled();
expect(screen.getByTestId("modal")).toBeInTheDocument(); // Modal should stay open expect(screen.getByTestId("dialog")).toBeInTheDocument(); // Modal should stay open
}); });
test("opens AddFilterModal when 'Add Filter' button is clicked", async () => { test("opens AddFilterModal when 'Add Filter' button is clicked", async () => {
@@ -4,8 +4,16 @@ import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createSegmentAction } from "@/modules/ee/contacts/segments/actions"; import { createSegmentAction } from "@/modules/ee/contacts/segments/actions";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input"; import { Input } from "@/modules/ui/components/input";
import { Modal } from "@/modules/ui/components/modal";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { FilterIcon, PlusIcon, UsersIcon } from "lucide-react"; import { FilterIcon, PlusIcon, UsersIcon } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -132,41 +140,30 @@ export function CreateSegmentModal({
<PlusIcon /> <PlusIcon />
</Button> </Button>
<Modal <Dialog
className="md:w-full"
closeOnOutsideClick={false}
noPadding
open={open} open={open}
setOpen={() => { onOpenChange={(open) => {
handleResetState(); if (!open) {
}} handleResetState();
size="lg"> }
<div className="rounded-lg bg-slate-50"> }}>
<div className="rounded-t-lg bg-slate-100"> <DialogContent className="sm:max-w-4xl" disableCloseOnOutsideClick>
<div className="flex w-full items-center gap-4 p-6"> <DialogHeader>
<div className="flex items-center space-x-2"> <UsersIcon />
<div className="mr-1.5 h-6 w-6 text-slate-500"> <DialogTitle>{t("common.create_segment")}</DialogTitle>
<UsersIcon className="h-5 w-5" /> <DialogDescription>
</div> {t("environments.segments.segments_help_you_target_users_with_same_characteristics_easily")}
<div> </DialogDescription>
<h3 className="text-base font-medium">{t("common.create_segment")}</h3> </DialogHeader>
<p className="text-sm text-slate-600">
{t(
"environments.segments.segments_help_you_target_users_with_same_characteristics_easily"
)}
</p>
</div>
</div>
</div>
</div>
<div className="flex flex-col overflow-auto rounded-lg bg-white p-6"> <DialogBody>
<div className="flex w-full items-center gap-4"> <div className="flex w-full items-center gap-4">
<div className="flex w-1/2 flex-col gap-2"> <div className="flex w-1/2 flex-col gap-2">
<label className="text-sm font-medium text-slate-900">{t("common.title")}</label> <label className="text-sm font-medium text-slate-900">{t("common.title")}</label>
<div className="relative flex flex-col gap-1"> <div className="relative flex flex-col gap-1">
<Input <Input
className="w-auto" className="w-auto"
value={segment.title}
onChange={(e) => { onChange={(e) => {
setSegment((prev) => ({ setSegment((prev) => ({
...prev, ...prev,
@@ -181,6 +178,7 @@ export function CreateSegmentModal({
<div className="flex w-1/2 flex-col gap-2"> <div className="flex w-1/2 flex-col gap-2">
<label className="text-sm font-medium text-slate-900">{t("common.description")}</label> <label className="text-sm font-medium text-slate-900">{t("common.description")}</label>
<Input <Input
value={segment.description ?? ""}
onChange={(e) => { onChange={(e) => {
setSegment((prev) => ({ setSegment((prev) => ({
...prev, ...prev,
@@ -191,72 +189,71 @@ export function CreateSegmentModal({
/> />
</div> </div>
</div> </div>
<div className="flex flex-col gap-y-2 pt-4">
<label className="text-sm font-medium text-slate-900">{t("common.targeting")}</label>
<div className="filter-scrollbar flex w-full flex-col gap-4 overflow-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
{segment.filters.length === 0 && (
<div className="-mb-2 flex items-center gap-1">
<FilterIcon className="h-5 w-5 text-slate-700" />
<h3 className="text-sm font-medium text-slate-700">
{t("environments.segments.add_your_first_filter_to_get_started")}
</h3>
</div>
)}
<label className="my-4 text-sm font-medium text-slate-900">{t("common.targeting")}</label> <SegmentEditor
<div className="filter-scrollbar flex w-full flex-col gap-4 overflow-auto rounded-lg border border-slate-200 bg-slate-50 p-4"> contactAttributeKeys={contactAttributeKeys}
{segment.filters.length === 0 && ( environmentId={environmentId}
<div className="-mb-2 flex items-center gap-1"> group={segment.filters}
<FilterIcon className="h-5 w-5 text-slate-700" /> segment={segment}
<h3 className="text-sm font-medium text-slate-700"> segments={segments}
{t("environments.segments.add_your_first_filter_to_get_started")} setSegment={setSegment}
</h3> />
</div>
)}
<SegmentEditor
contactAttributeKeys={contactAttributeKeys}
environmentId={environmentId}
group={segment.filters}
segment={segment}
segments={segments}
setSegment={setSegment}
/>
<Button
className="w-fit"
onClick={() => {
setAddFilterModalOpen(true);
}}
size="sm"
variant="secondary">
{t("common.add_filter")}
</Button>
<AddFilterModal
contactAttributeKeys={contactAttributeKeys}
onAddFilter={(filter) => {
handleAddFilterInGroup(filter);
}}
open={addFilterModalOpen}
segments={segments}
setOpen={setAddFilterModalOpen}
/>
</div>
<div className="flex justify-end pt-4">
<div className="flex space-x-2">
<Button <Button
className="w-fit"
onClick={() => { onClick={() => {
handleResetState(); setAddFilterModalOpen(true);
}} }}
type="button" size="sm"
variant="ghost"> variant="secondary">
{t("common.cancel")} {t("common.add_filter")}
</Button> </Button>
<Button
disabled={isSaveDisabled} <AddFilterModal
loading={isCreatingSegment} contactAttributeKeys={contactAttributeKeys}
onClick={() => { onAddFilter={(filter) => {
handleCreateSegment(); handleAddFilterInGroup(filter);
}} }}
type="submit"> open={addFilterModalOpen}
{t("common.create_segment")} segments={segments}
</Button> setOpen={setAddFilterModalOpen}
/>
</div> </div>
</div> </div>
</div> </DialogBody>
</div>
</Modal> <DialogFooter>
<Button
onClick={() => {
handleResetState();
}}
type="button"
variant="secondary">
{t("common.cancel")}
</Button>
<Button
disabled={isSaveDisabled}
loading={isCreatingSegment}
onClick={() => {
handleCreateSegment();
}}
type="submit">
{t("common.create_segment")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</> </>
); );
} }
@@ -1,32 +1,80 @@
import { EditSegmentModal } from "@/modules/ee/contacts/segments/components/edit-segment-modal"; import { EditSegmentModal } from "@/modules/ee/contacts/segments/components/edit-segment-modal";
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TSegmentWithSurveyNames } from "@formbricks/types/segment"; import { TSegmentWithSurveyNames } from "@formbricks/types/segment";
// Mock child components // Mock child components
vi.mock("@/modules/ee/contacts/segments/components/segment-settings", () => ({ vi.mock("@/modules/ee/contacts/segments/components/segment-settings", () => ({
SegmentSettings: vi.fn(() => <div>SegmentSettingsMock</div>), SegmentSettings: vi.fn(() => <div data-testid="segment-settings">SegmentSettingsMock</div>),
})); }));
vi.mock("@/modules/ee/contacts/segments/components/segment-activity-tab", () => ({ vi.mock("@/modules/ee/contacts/segments/components/segment-activity-tab", () => ({
SegmentActivityTab: vi.fn(() => <div>SegmentActivityTabMock</div>), SegmentActivityTab: vi.fn(() => <div data-testid="segment-activity-tab">SegmentActivityTabMock</div>),
})); }));
vi.mock("@/modules/ui/components/modal-with-tabs", () => ({
ModalWithTabs: vi.fn(({ open, label, description, tabs, icon }) => // Mock the Dialog components
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({
open,
onOpenChange,
children,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
}) =>
open ? ( open ? (
<div> <div data-testid="dialog">
<h1>{label}</h1> {children}
<p>{description}</p> <button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
<div>{icon}</div> Close
<ul> </button>
{tabs.map((tab) => (
<li key={tab.title}>
<h2>{tab.title}</h2>
<div>{tab.children}</div>
</li>
))}
</ul>
</div> </div>
) : null ) : null,
DialogContent: ({
children,
disableCloseOnOutsideClick,
}: {
children: React.ReactNode;
disableCloseOnOutsideClick?: boolean;
}) => (
<div data-testid="dialog-content" data-disable-close-on-outside-click={disableCloseOnOutsideClick}>
{children}
</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<p data-testid="dialog-description">{children}</p>
),
DialogBody: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-body">{children}</div>
),
}));
// Mock useTranslate
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => {
const translations = {
"common.activity": "Activity",
"common.settings": "Settings",
};
return translations[key] || key;
},
}),
}));
// Mock lucide-react
vi.mock("lucide-react", () => ({
UsersIcon: ({ className }: { className?: string }) => (
<span data-testid="users-icon" className={className}>
👥
</span>
), ),
})); }));
@@ -62,77 +110,92 @@ describe("EditSegmentModal", () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
test("renders correctly when open and contacts enabled", async () => { test("renders correctly when open and contacts enabled", () => {
render(<EditSegmentModal {...defaultProps} />); render(<EditSegmentModal {...defaultProps} />);
expect(screen.getByText("Test Segment")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByText("This is a test segment")).toBeInTheDocument(); expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Segment");
expect(screen.getByText("common.activity")).toBeInTheDocument(); expect(screen.getByTestId("dialog-description")).toHaveTextContent("This is a test segment");
expect(screen.getByText("common.settings")).toBeInTheDocument(); expect(screen.getByTestId("users-icon")).toBeInTheDocument();
expect(screen.getByText("SegmentActivityTabMock")).toBeInTheDocument(); expect(screen.getByText("Activity")).toBeInTheDocument();
expect(screen.getByText("SegmentSettingsMock")).toBeInTheDocument(); expect(screen.getByText("Settings")).toBeInTheDocument();
// Only the first tab (Activity) should be active initially
const ModalWithTabsMock = vi.mocked( expect(screen.getByTestId("segment-activity-tab")).toBeInTheDocument();
await import("@/modules/ui/components/modal-with-tabs") expect(screen.queryByTestId("segment-settings")).not.toBeInTheDocument();
).ModalWithTabs;
// Check that the mock was called
expect(ModalWithTabsMock).toHaveBeenCalled();
// Get the arguments of the first call
const callArgs = ModalWithTabsMock.mock.calls[0];
expect(callArgs).toBeDefined(); // Ensure the mock was called
const propsPassed = callArgs[0]; // The first argument is the props object
// Assert individual properties
expect(propsPassed.open).toBe(true);
expect(propsPassed.setOpen).toBe(defaultProps.setOpen);
expect(propsPassed.label).toBe("Test Segment");
expect(propsPassed.description).toBe("This is a test segment");
expect(propsPassed.closeOnOutsideClick).toBe(false);
expect(propsPassed.icon).toBeDefined(); // Check if icon exists
expect(propsPassed.tabs).toHaveLength(2); // Check number of tabs
// Check properties of the first tab
expect(propsPassed.tabs[0].title).toBe("common.activity");
expect(propsPassed.tabs[0].children).toBeDefined();
// Check properties of the second tab
expect(propsPassed.tabs[1].title).toBe("common.settings");
expect(propsPassed.tabs[1].children).toBeDefined();
}); });
test("renders correctly when open and contacts disabled", async () => { test("renders correctly when open and contacts disabled", () => {
render(<EditSegmentModal {...defaultProps} isContactsEnabled={false} />); render(<EditSegmentModal {...defaultProps} isContactsEnabled={false} />);
expect(screen.getByText("Test Segment")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByText("This is a test segment")).toBeInTheDocument(); expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Segment");
expect(screen.getByText("common.activity")).toBeInTheDocument(); expect(screen.getByTestId("dialog-description")).toHaveTextContent("This is a test segment");
expect(screen.getByText("common.settings")).toBeInTheDocument(); // Tab title still exists expect(screen.getByText("Activity")).toBeInTheDocument();
expect(screen.getByText("SegmentActivityTabMock")).toBeInTheDocument(); expect(screen.getByText("Settings")).toBeInTheDocument();
// Check that the settings content is not rendered, which is the key behavior expect(screen.getByTestId("segment-activity-tab")).toBeInTheDocument();
expect(screen.queryByText("SegmentSettingsMock")).not.toBeInTheDocument(); // Settings tab content should not render when contacts are disabled
expect(screen.queryByTestId("segment-settings")).not.toBeInTheDocument();
const ModalWithTabsMock = vi.mocked(
await import("@/modules/ui/components/modal-with-tabs")
).ModalWithTabs;
const calls = ModalWithTabsMock.mock.calls;
const lastCallArgs = calls[calls.length - 1][0]; // Get the props of the last call
// Check that the Settings tab was passed in props
const settingsTab = lastCallArgs.tabs.find((tab) => tab.title === "common.settings");
expect(settingsTab).toBeDefined();
// The children prop will be <SettingsTab />, but its rendered output is null/empty.
// The check above (queryByText("SegmentSettingsMock")) already confirms this.
// No need to check settingsTab.children === null here.
}); });
test("does not render when open is false", () => { test("does not render when open is false", () => {
render(<EditSegmentModal {...defaultProps} open={false} />); render(<EditSegmentModal {...defaultProps} open={false} />);
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
expect(screen.queryByText("Test Segment")).not.toBeInTheDocument(); expect(screen.queryByText("Test Segment")).not.toBeInTheDocument();
expect(screen.queryByText("common.activity")).not.toBeInTheDocument(); expect(screen.queryByText("Activity")).not.toBeInTheDocument();
expect(screen.queryByText("common.settings")).not.toBeInTheDocument(); expect(screen.queryByText("Settings")).not.toBeInTheDocument();
});
test("switches tabs correctly", async () => {
const user = userEvent.setup();
render(<EditSegmentModal {...defaultProps} />);
// Initially shows activity tab (first tab is active)
expect(screen.getByTestId("segment-activity-tab")).toBeInTheDocument();
expect(screen.queryByTestId("segment-settings")).not.toBeInTheDocument();
// Click settings tab
const settingsTab = screen.getByText("Settings");
await user.click(settingsTab);
// Now shows settings tab content
expect(screen.queryByTestId("segment-activity-tab")).not.toBeInTheDocument();
expect(screen.getByTestId("segment-settings")).toBeInTheDocument();
// Click activity tab again
const activityTab = screen.getByText("Activity");
await user.click(activityTab);
// Back to activity tab content
expect(screen.getByTestId("segment-activity-tab")).toBeInTheDocument();
expect(screen.queryByTestId("segment-settings")).not.toBeInTheDocument();
});
test("resets to first tab when modal is reopened", async () => {
const user = userEvent.setup();
const { rerender } = render(<EditSegmentModal {...defaultProps} />);
// Switch to settings tab
const settingsTab = screen.getByText("Settings");
await user.click(settingsTab);
expect(screen.getByTestId("segment-settings")).toBeInTheDocument();
// Close modal
rerender(<EditSegmentModal {...defaultProps} open={false} />);
// Reopen modal
rerender(<EditSegmentModal {...defaultProps} open={true} />);
// Should be back to activity tab (first tab)
expect(screen.getByTestId("segment-activity-tab")).toBeInTheDocument();
expect(screen.queryByTestId("segment-settings")).not.toBeInTheDocument();
});
test("handles segment without description", () => {
const segmentWithoutDescription = { ...mockSegment, description: "" };
render(<EditSegmentModal {...defaultProps} currentSegment={segmentWithoutDescription} />);
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Segment");
expect(screen.getByTestId("dialog-description")).toHaveTextContent("");
}); });
}); });
@@ -1,9 +1,17 @@
"use client"; "use client";
import { SegmentSettings } from "@/modules/ee/contacts/segments/components/segment-settings"; import { SegmentSettings } from "@/modules/ee/contacts/segments/components/segment-settings";
import { ModalWithTabs } from "@/modules/ui/components/modal-with-tabs"; import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { UsersIcon } from "lucide-react"; import { UsersIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key"; import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment"; import { TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
import { SegmentActivityTab } from "./segment-activity-tab"; import { SegmentActivityTab } from "./segment-activity-tab";
@@ -30,6 +38,8 @@ export const EditSegmentModal = ({
isReadOnly, isReadOnly,
}: EditSegmentModalProps) => { }: EditSegmentModalProps) => {
const { t } = useTranslate(); const { t } = useTranslate();
const [activeTab, setActiveTab] = useState(0);
const SettingsTab = () => { const SettingsTab = () => {
if (isContactsEnabled) { if (isContactsEnabled) {
return ( return (
@@ -58,17 +68,42 @@ export const EditSegmentModal = ({
}, },
]; ];
const handleTabClick = (index: number) => {
setActiveTab(index);
};
useEffect(() => {
if (!open) {
setActiveTab(0);
}
}, [open]);
return ( return (
<> <Dialog open={open} onOpenChange={setOpen}>
<ModalWithTabs <DialogContent disableCloseOnOutsideClick>
open={open} <DialogHeader>
setOpen={setOpen} <UsersIcon />
tabs={tabs} <DialogTitle>{currentSegment.title}</DialogTitle>
icon={<UsersIcon className="h-5 w-5" />} <DialogDescription>{currentSegment.description ?? ""}</DialogDescription>
label={currentSegment.title} </DialogHeader>
description={currentSegment.description || ""} <DialogBody>
closeOnOutsideClick={false} <div className="flex h-full w-full items-center justify-center space-x-2 border-b border-slate-200 px-6">
/> {tabs.map((tab, index) => (
</> <button
key={tab.title}
className={`mr-4 px-1 pb-3 focus:outline-none ${
activeTab === index
? "border-brand-dark border-b-2 font-semibold text-slate-900"
: "text-slate-500 hover:text-slate-700"
}`}
onClick={() => handleTabClick(index)}>
{tab.title}
</button>
))}
</div>
<div className="flex-1 pt-4">{tabs[activeTab].children}</div>
</DialogBody>
</DialogContent>
</Dialog>
); );
}; };
@@ -138,7 +138,7 @@ export function SegmentSettings({
}, [segment]); }, [segment]);
return ( return (
<div className="mb-4"> <div>
<div className="rounded-lg bg-slate-50"> <div className="rounded-lg bg-slate-50">
<div className="flex flex-col overflow-auto rounded-lg bg-white"> <div className="flex flex-col overflow-auto rounded-lg bg-white">
<div className="flex w-full items-center gap-4"> <div className="flex w-full items-center gap-4">
@@ -179,50 +179,51 @@ export function SegmentSettings({
</div> </div>
</div> </div>
<label className="my-4 text-sm font-medium text-slate-900">{t("common.targeting")}</label> <div className="flex flex-col gap-y-2 pt-4">
<div className="filter-scrollbar flex max-h-96 w-full flex-col gap-4 overflow-auto rounded-lg border border-slate-200 bg-slate-50 p-4"> <label className="text-sm font-medium text-slate-900">{t("common.targeting")}</label>
{segment.filters.length === 0 && ( <div className="filter-scrollbar flex max-h-96 w-full flex-col gap-4 overflow-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
<div className="-mb-2 flex items-center gap-1"> {segment.filters.length === 0 && (
<FilterIcon className="h-5 w-5 text-slate-700" /> <div className="-mb-2 flex items-center gap-1">
<h3 className="text-sm font-medium text-slate-700"> <FilterIcon className="h-5 w-5 text-slate-700" />
{t("environments.segments.add_your_first_filter_to_get_started")} <h3 className="text-sm font-medium text-slate-700">
</h3> {t("environments.segments.add_your_first_filter_to_get_started")}
</h3>
</div>
)}
<SegmentEditor
contactAttributeKeys={contactAttributeKeys}
environmentId={environmentId}
group={segment.filters}
segment={segment}
segments={segments}
setSegment={setSegment}
viewOnly={isReadOnly}
/>
<div>
<Button
onClick={() => {
setAddFilterModalOpen(true);
}}
size="sm"
disabled={isReadOnly}
variant="secondary">
{t("common.add_filter")}
</Button>
</div> </div>
)}
<SegmentEditor <AddFilterModal
contactAttributeKeys={contactAttributeKeys} contactAttributeKeys={contactAttributeKeys}
environmentId={environmentId} onAddFilter={(filter) => {
group={segment.filters} handleAddFilterInGroup(filter);
segment={segment}
segments={segments}
setSegment={setSegment}
viewOnly={isReadOnly}
/>
<div>
<Button
onClick={() => {
setAddFilterModalOpen(true);
}} }}
size="sm" open={addFilterModalOpen}
disabled={isReadOnly} segments={segments}
variant="secondary"> setOpen={setAddFilterModalOpen}
{t("common.add_filter")} />
</Button>
</div> </div>
<AddFilterModal
contactAttributeKeys={contactAttributeKeys}
onAddFilter={(filter) => {
handleAddFilterInGroup(filter);
}}
open={addFilterModalOpen}
segments={segments}
setOpen={setAddFilterModalOpen}
/>
</div> </div>
<div className="flex w-full items-center justify-between pt-4"> <div className="flex w-full items-center justify-between pt-4">
{!isReadOnly && ( {!isReadOnly && (
<> <>
@@ -6,8 +6,24 @@ import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { CreateTeamModal } from "./create-team-modal"; import { CreateTeamModal } from "./create-team-modal";
vi.mock("@/modules/ui/components/modal", () => ({ vi.mock("@/modules/ui/components/dialog", () => ({
Modal: ({ children }: any) => <div data-testid="Modal">{children}</div>, Dialog: ({ children, open }: { children: React.ReactNode; open: boolean }) =>
open ? <div data-testid="dialog">{children}</div> : null,
DialogContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-content">{children}</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-title">{children}</div>
),
DialogBody: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-body">{children}</div>
),
DialogFooter: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-footer">{children}</div>
),
})); }));
vi.mock("@/modules/ee/teams/team-list/actions", () => ({ vi.mock("@/modules/ee/teams/team-list/actions", () => ({
@@ -24,9 +40,13 @@ describe("CreateTeamModal", () => {
const setOpen = vi.fn(); const setOpen = vi.fn();
test("renders modal, form, and tolgee strings", () => { test("renders dialog, form, and tolgee strings", () => {
render(<CreateTeamModal open={true} setOpen={setOpen} organizationId="org-1" />); render(<CreateTeamModal open={true} setOpen={setOpen} organizationId="org-1" />);
expect(screen.getByTestId("Modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-header")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toBeInTheDocument();
expect(screen.getByTestId("dialog-body")).toBeInTheDocument();
expect(screen.getByTestId("dialog-footer")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.create_new_team")).toBeInTheDocument(); expect(screen.getByText("environments.settings.teams.create_new_team")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.team_name")).toBeInTheDocument(); expect(screen.getByText("environments.settings.teams.team_name")).toBeInTheDocument();
expect(screen.getByText("common.cancel")).toBeInTheDocument(); expect(screen.getByText("common.cancel")).toBeInTheDocument();
@@ -47,7 +67,7 @@ describe("CreateTeamModal", () => {
expect(screen.getByText("environments.settings.teams.create")).toBeDisabled(); expect(screen.getByText("environments.settings.teams.create")).toBeDisabled();
}); });
test("calls createTeamAction, shows success toast, calls onCreate, refreshes and closes modal on success", async () => { test("calls createTeamAction, shows success toast, calls onCreate, refreshes and closes dialog on success", async () => {
vi.mocked(createTeamAction).mockResolvedValue({ data: "team-123" }); vi.mocked(createTeamAction).mockResolvedValue({ data: "team-123" });
const onCreate = vi.fn(); const onCreate = vi.fn();
render(<CreateTeamModal open={true} setOpen={setOpen} organizationId="org-1" onCreate={onCreate} />); render(<CreateTeamModal open={true} setOpen={setOpen} organizationId="org-1" onCreate={onCreate} />);
@@ -3,10 +3,16 @@
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createTeamAction } from "@/modules/ee/teams/team-list/actions"; import { createTeamAction } from "@/modules/ee/teams/team-list/actions";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input"; import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label"; import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { H4 } from "@/modules/ui/components/typography";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { UsersIcon } from "lucide-react"; import { UsersIcon } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -48,45 +54,45 @@ export const CreateTeamModal = ({ open, setOpen, organizationId, onCreate }: Cre
}; };
return ( return (
<Modal noPadding closeOnOutsideClick={true} size="md" open={open} setOpen={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<div className="rounded-t-lg bg-slate-100"> <DialogContent>
<div className="flex w-full items-center gap-4 p-6"> <DialogHeader>
<div className="flex items-center space-x-2"> <UsersIcon />
<UsersIcon className="h-5 w-5" /> <DialogTitle>{t("environments.settings.teams.create_new_team")}</DialogTitle>
<H4>{t("environments.settings.teams.create_new_team")}</H4> </DialogHeader>
</div>
</div> <form onSubmit={handleTeamCreation} className="gap-y-4 pt-4">
</div> <DialogBody>
<form onSubmit={handleTeamCreation}> <div className="grid w-full gap-y-2 pb-4">
<div className="flex flex-col overflow-auto rounded-lg bg-white p-6"> <Label htmlFor="team-name">{t("environments.settings.teams.team_name")}</Label>
<Label htmlFor="team-name" className="mb-1 text-sm font-medium text-slate-900"> <Input
{t("environments.settings.teams.team_name")} id="team-name"
</Label> name="team-name"
<Input value={teamName}
id="team-name" onChange={(e) => {
name="team-name" setTeamName(e.target.value);
value={teamName} }}
onChange={(e) => { placeholder={t("environments.settings.teams.enter_team_name")}
setTeamName(e.target.value); />
}} </div>
placeholder={t("environments.settings.teams.enter_team_name")} </DialogBody>
/>
</div> <DialogFooter>
<div className="flex items-end justify-end gap-2 p-6 pt-0"> <Button
<Button variant="secondary"
variant="secondary" type="button"
type="button" onClick={() => {
onClick={() => { setOpen(false);
setOpen(false); setTeamName("");
setTeamName(""); }}>
}}> {t("common.cancel")}
{t("common.cancel")} </Button>
</Button> <Button disabled={!teamName || isLoading} loading={isLoading} type="submit">
<Button disabled={!teamName || isLoading} loading={isLoading} type="submit"> {t("environments.settings.teams.create")}
{t("environments.settings.teams.create")} </Button>
</Button> </DialogFooter>
</div> </form>
</form> </DialogContent>
</Modal> </Dialog>
); );
}; };
@@ -42,7 +42,7 @@ export const DeleteTeam = ({ teamId, onDelete, isOwnerOrManager }: DeleteTeamPro
return ( return (
<> <>
<div className="flex flex-col space-y-2"> <div className="flex flex-row items-baseline space-x-2">
<Label htmlFor="deleteTeamButton">{t("common.danger_zone")}</Label> <Label htmlFor="deleteTeamButton">{t("common.danger_zone")}</Label>
<TooltipRenderer <TooltipRenderer
shouldRender={!isOwnerOrManager} shouldRender={!isOwnerOrManager}
@@ -50,7 +50,6 @@ export const DeleteTeam = ({ teamId, onDelete, isOwnerOrManager }: DeleteTeamPro
className="w-auto"> className="w-auto">
<Button <Button
variant="destructive" variant="destructive"
size="sm"
type="button" type="button"
id="deleteTeamButton" id="deleteTeamButton"
className="w-auto" className="w-auto"
@@ -7,8 +7,49 @@ import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { TeamSettingsModal } from "./team-settings-modal"; import { TeamSettingsModal } from "./team-settings-modal";
vi.mock("@/modules/ui/components/modal", () => ({ // Mock the Dialog components
Modal: ({ children, ...props }: any) => <div data-testid="Modal">{children}</div>, vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({
open,
onOpenChange,
children,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
}) =>
open ? (
<div data-testid="dialog">
{children}
<button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
Close
</button>
</div>
) : null,
DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-content" className={className}>
{children}
</div>
),
DialogHeader: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-header" className={className}>
{children}
</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<p data-testid="dialog-description">{children}</p>
),
DialogBody: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-body" className={className}>
{children}
</div>
),
DialogFooter: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-footer">{children}</div>
),
})); }));
vi.mock("@/modules/ee/teams/team-list/components/team-settings/delete-team", () => ({ vi.mock("@/modules/ee/teams/team-list/components/team-settings/delete-team", () => ({
@@ -60,15 +101,15 @@ describe("TeamSettingsModal", () => {
currentUserId="1" currentUserId="1"
/> />
); );
expect(screen.getByTestId("Modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.team_name_settings_title")).toBeInTheDocument(); expect(screen.getByText("environments.settings.teams.team_name_settings_title")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.team_settings_description")).toBeInTheDocument(); expect(screen.getByText("environments.settings.teams.team_settings_description")).toBeInTheDocument();
expect(screen.getByText("common.team_name")).toBeInTheDocument(); expect(screen.getByText("common.team_name")).toBeInTheDocument();
expect(screen.getByText("common.members")).toBeInTheDocument(); expect(screen.getByText("common.members")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.add_members_description")).toBeInTheDocument(); expect(screen.getByText("environments.settings.teams.add_members_description")).toBeInTheDocument();
expect(screen.getByText("Add member")).toBeInTheDocument(); expect(screen.getByText("Add member")).toBeInTheDocument();
expect(screen.getByText("Projects")).toBeInTheDocument(); expect(screen.getByText("common.projects")).toBeInTheDocument();
expect(screen.getByText("Add project")).toBeInTheDocument(); expect(screen.getByText("common.add_project")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.add_projects_description")).toBeInTheDocument(); expect(screen.getByText("environments.settings.teams.add_projects_description")).toBeInTheDocument();
expect(screen.getByText("common.cancel")).toBeInTheDocument(); expect(screen.getByText("common.cancel")).toBeInTheDocument();
expect(screen.getByText("common.save")).toBeInTheDocument(); expect(screen.getByText("common.save")).toBeInTheDocument();
@@ -1,6 +1,5 @@
"use client"; "use client";
import { cn } from "@/lib/cn";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { ZTeamPermission } from "@/modules/ee/teams/project-teams/types/team"; import { ZTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
@@ -17,9 +16,17 @@ import {
} from "@/modules/ee/teams/team-list/types/team"; } from "@/modules/ee/teams/team-list/types/team";
import { getTeamAccessFlags } from "@/modules/ee/teams/utils/teams"; import { getTeamAccessFlags } from "@/modules/ee/teams/utils/teams";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form"; import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input"; import { Input } from "@/modules/ui/components/input";
import { Modal } from "@/modules/ui/components/modal";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -28,10 +35,10 @@ import {
SelectValue, SelectValue,
} from "@/modules/ui/components/select"; } from "@/modules/ui/components/select";
import { TooltipRenderer } from "@/modules/ui/components/tooltip"; import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { H4, Muted } from "@/modules/ui/components/typography"; import { Muted } from "@/modules/ui/components/typography";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { PlusIcon, Trash2Icon, XIcon } from "lucide-react"; import { PlusIcon, Trash2Icon } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useMemo } from "react"; import { useMemo } from "react";
import { FormProvider, SubmitHandler, useForm, useWatch } from "react-hook-form"; import { FormProvider, SubmitHandler, useForm, useWatch } from "react-hook-form";
@@ -196,44 +203,19 @@ export const TeamSettingsModal = ({
const hasEmptyProject = watchProjects.some((p) => !p.projectId); const hasEmptyProject = watchProjects.some((p) => !p.projectId);
return ( return (
<Modal <Dialog open={open} onOpenChange={setOpen}>
open={open} <DialogContent>
setOpen={setOpen} <DialogHeader className="pb-4">
noPadding <DialogTitle>
className="flex max-h-[90dvh] flex-col overflow-visible" {t("environments.settings.teams.team_name_settings_title", {
size="md" teamName: team.name,
hideCloseButton })}
closeOnOutsideClick={true}> </DialogTitle>
<div className="sticky top-0 z-10 rounded-t-lg bg-slate-100"> <DialogDescription>{t("environments.settings.teams.team_settings_description")}</DialogDescription>
<button </DialogHeader>
className={cn( <FormProvider {...form}>
"absolute right-0 top-0 hidden pr-4 pt-4 text-slate-400 hover:text-slate-500 focus:outline-none focus:ring-0 sm:block" <form className="contents space-y-4" onSubmit={handleSubmit(handleUpdateTeam)}>
)} <DialogBody className="flex-grow space-y-6 overflow-y-auto">
onClick={closeSettingsModal}>
<XIcon className="h-6 w-6 rounded-md bg-white" />
<span className="sr-only">Close</span>
</button>
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div>
<H4>
{t("environments.settings.teams.team_name_settings_title", {
teamName: team.name,
})}
</H4>
<Muted className="text-slate-500">
{t("environments.settings.teams.team_settings_description")}
</Muted>
</div>
</div>
</div>
</div>
<FormProvider {...form}>
<form
className="flex w-full flex-grow flex-col overflow-hidden"
onSubmit={handleSubmit(handleUpdateTeam)}>
<div className="flex-grow space-y-6 overflow-y-auto p-6">
<div className="space-y-6">
<FormField <FormField
control={control} control={control}
name="name" name="name"
@@ -255,13 +237,18 @@ export const TeamSettingsModal = ({
{/* Members Section */} {/* Members Section */}
<div className="space-y-2"> <div className="space-y-2">
<FormLabel>{t("common.members")}</FormLabel> <div className="flex flex-col space-y-1">
<FormLabel>{t("common.members")}</FormLabel>
<Muted className="block text-slate-500">
{t("environments.settings.teams.add_members_description")}
</Muted>
</div>
<FormField <FormField
control={control} control={control}
name={`members`} name={`members`}
render={({ fieldState: { error } }) => ( render={({ fieldState: { error } }) => (
<FormItem className="flex-1"> <FormItem className="flex-1">
<div className="max-h-40 space-y-2 overflow-y-auto p-1"> <div className="space-y-2 overflow-y-auto">
{watchMembers.map((member, index) => { {watchMembers.map((member, index) => {
const memberOpts = getMemberOptionsForIndex(index); const memberOpts = getMemberOptionsForIndex(index);
return ( return (
@@ -382,20 +369,22 @@ export const TeamSettingsModal = ({
<span>Add member</span> <span>Add member</span>
</Button> </Button>
</TooltipRenderer> </TooltipRenderer>
<Muted className="block text-slate-500">
{t("environments.settings.teams.add_members_description")}
</Muted>
</div> </div>
{/* Projects Section */} {/* Projects Section */}
<div className="space-y-2"> <div className="space-y-2">
<FormLabel>Projects</FormLabel> <div className="flex flex-col space-y-1">
<FormLabel>{t("common.projects")}</FormLabel>
<Muted className="block text-slate-500">
{t("environments.settings.teams.add_projects_description")}
</Muted>
</div>
<FormField <FormField
control={control} control={control}
name={`projects`} name={`projects`}
render={({ fieldState: { error } }) => ( render={({ fieldState: { error } }) => (
<FormItem className="flex-1"> <FormItem className="flex-1">
<div className="max-h-40 space-y-2 overflow-y-auto p-1"> <div className="space-y-2">
{watchProjects.map((project, index) => { {watchProjects.map((project, index) => {
const projectOpts = getProjectOptionsForIndex(index); const projectOpts = getProjectOptionsForIndex(index);
return ( return (
@@ -495,26 +484,19 @@ export const TeamSettingsModal = ({
!isOwnerOrManager || selectedProjectIds.length === orgProjects.length || hasEmptyProject !isOwnerOrManager || selectedProjectIds.length === orgProjects.length || hasEmptyProject
}> }>
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
<span>Add project</span> {t("common.add_project")}
</Button> </Button>
</TooltipRenderer> </TooltipRenderer>
<Muted className="block text-slate-500">
{t("environments.settings.teams.add_projects_description")}
</Muted>
</div> </div>
</DialogBody>
<div className="w-max"> <DialogFooter>
<div className="w-full">
<DeleteTeam <DeleteTeam
teamId={team.id} teamId={team.id}
onDelete={closeSettingsModal} onDelete={closeSettingsModal}
isOwnerOrManager={isOwnerOrManager} isOwnerOrManager={isOwnerOrManager}
/> />
</div> </div>
</div>
</div>
<div className="sticky bottom-0 z-10 border-slate-200 p-6">
<div className="flex justify-between">
<Button size="default" type="button" variant="outline" onClick={closeSettingsModal}> <Button size="default" type="button" variant="outline" onClick={closeSettingsModal}>
{t("common.cancel")} {t("common.cancel")}
</Button> </Button>
@@ -525,10 +507,10 @@ export const TeamSettingsModal = ({
disabled={!isOwnerOrManager && !isTeamAdminMember}> disabled={!isOwnerOrManager && !isTeamAdminMember}>
{t("common.save")} {t("common.save")}
</Button> </Button>
</div> </DialogFooter>
</div> </form>
</form> </FormProvider>
</FormProvider> </DialogContent>
</Modal> </Dialog>
); );
}; };
@@ -14,6 +14,23 @@ vi.mock("@/modules/ee/two-factor-auth/actions", () => ({
setupTwoFactorAuthAction: vi.fn(), setupTwoFactorAuthAction: vi.fn(),
})); }));
// Mock the translation function
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
"environments.settings.profile.two_factor_authentication": "Two-Factor Authentication",
"environments.settings.profile.confirm_your_current_password_to_get_started":
"Confirm your current password to get started",
"common.password": "Password",
"common.confirm": "Confirm",
"common.cancel": "Cancel",
};
return translations[key] || key;
},
}),
}));
describe("ConfirmPasswordForm", () => { describe("ConfirmPasswordForm", () => {
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
@@ -31,13 +48,9 @@ describe("ConfirmPasswordForm", () => {
test("renders the form with password input", () => { test("renders the form with password input", () => {
render(<ConfirmPasswordForm {...mockProps} />); render(<ConfirmPasswordForm {...mockProps} />);
expect(screen.getByText("environments.settings.profile.two_factor_authentication")).toBeInTheDocument(); expect(screen.getByLabelText("Password")).toBeInTheDocument();
expect( expect(screen.getByRole("button", { name: "Confirm" })).toBeInTheDocument();
screen.getByText("environments.settings.profile.confirm_your_current_password_to_get_started") expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
).toBeInTheDocument();
expect(screen.getByLabelText("common.password")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "common.confirm" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "common.cancel" })).toBeInTheDocument();
}); });
test("handles form submission successfully", async () => { test("handles form submission successfully", async () => {
@@ -56,9 +69,9 @@ describe("ConfirmPasswordForm", () => {
render(<ConfirmPasswordForm {...mockProps} />); render(<ConfirmPasswordForm {...mockProps} />);
const passwordInput = screen.getByLabelText("common.password"); const passwordInput = screen.getByLabelText("Password");
await user.type(passwordInput, "testPassword123!"); await user.type(passwordInput, "testPassword123!");
const submitButton = screen.getByRole("button", { name: "common.confirm" }); const submitButton = screen.getByRole("button", { name: "Confirm" });
await user.click(submitButton); await user.click(submitButton);
await waitFor(() => { await waitFor(() => {
@@ -74,7 +87,7 @@ describe("ConfirmPasswordForm", () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<ConfirmPasswordForm {...mockProps} />); render(<ConfirmPasswordForm {...mockProps} />);
await user.click(screen.getByRole("button", { name: "common.cancel" })); await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(mockProps.setOpen).toHaveBeenCalledWith(false); expect(mockProps.setOpen).toHaveBeenCalledWith(false);
}); });
}); });
@@ -57,16 +57,8 @@ export const ConfirmPasswordForm = ({
return ( return (
<FormProvider {...form}> <FormProvider {...form}>
<div className="p-6"> <form className="flex flex-col space-y-4" onSubmit={handleSubmit(onSubmit)}>
<h1 className="text-lg font-semibold"> <div className="flex flex-col gap-2">
{t("environments.settings.profile.two_factor_authentication")}
</h1>
<h3 className="text-sm text-slate-700">
{t("environments.settings.profile.confirm_your_current_password_to_get_started")}
</h3>
</div>
<form className="flex flex-col space-y-10" onSubmit={handleSubmit(onSubmit)}>
<div className="flex flex-col gap-2 px-6">
<label htmlFor="password" className="text-sm font-medium text-slate-700"> <label htmlFor="password" className="text-sm font-medium text-slate-700">
{t("common.password")} {t("common.password")}
</label> </label>
@@ -95,7 +87,7 @@ export const ConfirmPasswordForm = ({
/> />
</div> </div>
<div className="flex w-full items-center justify-end space-x-4 border-t border-slate-300 p-4"> <div className="flex w-full items-center justify-end space-x-2">
<Button variant="secondary" size="sm" type="button" onClick={() => setOpen(false)}> <Button variant="secondary" size="sm" type="button" onClick={() => setOpen(false)}>
{t("common.cancel")} {t("common.cancel")}
</Button> </Button>
@@ -5,6 +5,49 @@ import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { DisableTwoFactorModal } from "./disable-two-factor-modal"; import { DisableTwoFactorModal } from "./disable-two-factor-modal";
// Mock the Dialog components
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({
children,
open,
onOpenChange,
}: {
children: React.ReactNode;
open: boolean;
onOpenChange: () => void;
}) =>
open ? (
<div data-testid="dialog">
{children}
<button data-testid="dialog-close" onClick={onOpenChange}>
Close
</button>
</div>
) : null,
DialogContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-content">{children}</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<p data-testid="dialog-description">{children}</p>
),
DialogBody: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-body" className={className}>
{children}
</div>
),
DialogFooter: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-footer" className={className}>
{children}
</div>
),
}));
vi.mock("@/modules/ee/two-factor-auth/actions", () => ({ vi.mock("@/modules/ee/two-factor-auth/actions", () => ({
disableTwoFactorAuthAction: vi.fn(), disableTwoFactorAuthAction: vi.fn(),
})); }));
@@ -15,15 +58,23 @@ vi.mock("next/navigation", () => ({
}), }),
})); }));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
describe("DisableTwoFactorModal", () => { describe("DisableTwoFactorModal", () => {
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.clearAllMocks(); vi.clearAllMocks();
}); });
test("renders modal with correct title and description", () => { test("renders dialog with correct title and description", () => {
render(<DisableTwoFactorModal open={true} setOpen={() => {}} />); render(<DisableTwoFactorModal open={true} setOpen={() => {}} />);
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-content")).toBeInTheDocument();
expect( expect(
screen.getByText("environments.settings.profile.disable_two_factor_authentication") screen.getByText("environments.settings.profile.disable_two_factor_authentication")
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -3,9 +3,17 @@
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { disableTwoFactorAuthAction } from "@/modules/ee/two-factor-auth/actions"; import { disableTwoFactorAuthAction } from "@/modules/ee/two-factor-auth/actions";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form"; import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input"; import { Input } from "@/modules/ui/components/input";
import { Modal } from "@/modules/ui/components/modal";
import { OTPInput } from "@/modules/ui/components/otp-input"; import { OTPInput } from "@/modules/ui/components/otp-input";
import { PasswordInput } from "@/modules/ui/components/password-input"; import { PasswordInput } from "@/modules/ui/components/password-input";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
@@ -63,101 +71,45 @@ export const DisableTwoFactorModal = ({ open, setOpen }: DisableTwoFactorModalPr
}; };
return ( return (
<Modal <Dialog
open={open} open={open}
setOpen={() => { onOpenChange={(open) => {
form.reset(); if (!open) {
setOpen(false); form.reset();
}} }
noPadding> setOpen(open);
<FormProvider {...form}> }}>
<div> <DialogContent>
<div className="p-6"> <DialogHeader>
<h1 className="text-lg font-semibold"> <DialogTitle>{t("environments.settings.profile.disable_two_factor_authentication")}</DialogTitle>
{t("environments.settings.profile.disable_two_factor_authentication")} <DialogDescription>
</h1> {t("environments.settings.profile.disable_two_factor_authentication_description")}
<p className="text-sm text-slate-700"> </DialogDescription>
{t("environments.settings.profile.disable_two_factor_authentication_description")} </DialogHeader>
</p>
</div>
<FormProvider {...form}>
<form className="flex flex-col space-y-6" onSubmit={form.handleSubmit(onSubmit)}> <form className="flex flex-col space-y-6" onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex flex-col gap-2 px-6"> <DialogBody className="space-y-6">
<label htmlFor="password" className="text-sm font-medium text-slate-700">
{t("common.password")}
</label>
<FormField
control={form.control}
name="password"
render={({ field, fieldState: { error } }) => (
<FormItem className="w-full">
<FormControl>
<FormItem>
<PasswordInput
id="password"
autoComplete="current-password"
placeholder="*******"
aria-placeholder="password"
required
onChange={(password) => field.onChange(password)}
value={field.value}
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
/>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</FormItem>
</FormControl>
</FormItem>
)}
/>
</div>
<div className="px-6">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="code" className="text-sm font-medium text-slate-700"> <label htmlFor="password" className="text-sm font-medium text-slate-700">
{backupCodeInputVisible {t("common.password")}
? t("environments.settings.profile.backup_code")
: t("environments.settings.profile.two_factor_code")}
</label> </label>
<p className="text-sm text-slate-700">
{backupCodeInputVisible
? t(
"environments.settings.profile.each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator"
)
: t(
"environments.settings.profile.two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app"
)}
</p>
</div>
{backupCodeInputVisible ? (
<FormField <FormField
control={form.control} control={form.control}
name="backupCode" name="password"
render={({ field, fieldState: { error } }) => ( render={({ field, fieldState: { error } }) => (
<FormItem className="w-full"> <FormItem className="w-full">
<FormControl> <FormControl>
<FormItem> <FormItem>
<Input {...field} placeholder="XXXXX-XXXXX" className="mt-2" /> <PasswordInput
{error?.message && <FormError className="text-left">{error.message}</FormError>} id="password"
</FormItem> autoComplete="current-password"
</FormControl> placeholder="*******"
</FormItem> aria-placeholder="password"
)} required
/> onChange={(password) => field.onChange(password)}
) : ( value={field.value}
<FormField className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
control={form.control}
name="code"
render={({ field, fieldState: { error } }) => (
<FormItem className="w-full">
<FormControl>
<FormItem>
<OTPInput
value={field.value || ""}
valueLength={6}
onChange={field.onChange}
containerClassName="justify-start mt-4"
/> />
{error?.message && <FormError className="text-left">{error.message}</FormError>} {error?.message && <FormError className="text-left">{error.message}</FormError>}
</FormItem> </FormItem>
@@ -165,14 +117,70 @@ export const DisableTwoFactorModal = ({ open, setOpen }: DisableTwoFactorModalPr
</FormItem> </FormItem>
)} )}
/> />
)} </div>
</div>
<div className="flex w-full items-center justify-between border-t border-slate-300 p-4"> <div>
<div className="flex flex-col gap-2">
<label htmlFor="code" className="text-sm font-medium text-slate-700">
{backupCodeInputVisible
? t("environments.settings.profile.backup_code")
: t("environments.settings.profile.two_factor_code")}
</label>
<p className="text-sm text-slate-700">
{backupCodeInputVisible
? t(
"environments.settings.profile.each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator"
)
: t(
"environments.settings.profile.two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app"
)}
</p>
</div>
{backupCodeInputVisible ? (
<FormField
control={form.control}
name="backupCode"
render={({ field, fieldState: { error } }) => (
<FormItem className="w-full">
<FormControl>
<FormItem>
<Input {...field} placeholder="XXXXX-XXXXX" className="mt-2" />
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</FormItem>
</FormControl>
</FormItem>
)}
/>
) : (
<FormField
control={form.control}
name="code"
render={({ field, fieldState: { error } }) => (
<FormItem className="w-full">
<FormControl>
<FormItem>
<OTPInput
value={field.value || ""}
valueLength={6}
onChange={field.onChange}
containerClassName="justify-start mt-4"
/>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</FormItem>
</FormControl>
</FormItem>
)}
/>
)}
</div>
</DialogBody>
<DialogFooter className="flex w-full items-center justify-between">
<div> <div>
<Button <Button
variant="ghost" variant="ghost"
size="sm"
type="button" type="button"
onClick={() => setBackupCodeInputVisible((prev) => !prev)}> onClick={() => setBackupCodeInputVisible((prev) => !prev)}>
{backupCodeInputVisible {backupCodeInputVisible
@@ -183,7 +191,6 @@ export const DisableTwoFactorModal = ({ open, setOpen }: DisableTwoFactorModalPr
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<Button <Button
variant="secondary" variant="secondary"
size="sm"
type="button" type="button"
onClick={() => { onClick={() => {
form.reset(); form.reset();
@@ -192,14 +199,12 @@ export const DisableTwoFactorModal = ({ open, setOpen }: DisableTwoFactorModalPr
{t("common.cancel")} {t("common.cancel")}
</Button> </Button>
<Button size="sm" loading={form.formState.isSubmitting}> <Button loading={form.formState.isSubmitting}>{t("common.disable")}</Button>
{t("common.disable")}
</Button>
</div> </div>
</div> </DialogFooter>
</form> </form>
</div> </FormProvider>
</FormProvider> </DialogContent>
</Modal> </Dialog>
); );
}; };
@@ -4,17 +4,40 @@ import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { EnableTwoFactorModal } from "./enable-two-factor-modal"; import { EnableTwoFactorModal } from "./enable-two-factor-modal";
// Mock the Modal component to expose the close functionality // Mock the Dialog component to expose the close functionality
vi.mock("@/modules/ui/components/modal", () => ({ vi.mock("@/modules/ui/components/dialog", () => ({
Modal: ({ children, open, setOpen }: { children: React.ReactNode; open: boolean; setOpen: () => void }) => Dialog: ({
children,
open,
onOpenChange,
}: {
children: React.ReactNode;
open: boolean;
onOpenChange: () => void;
}) =>
open ? ( open ? (
<div data-testid="modal"> <div data-testid="dialog">
{children} {children}
<button data-testid="modal-close" onClick={setOpen}> <button data-testid="dialog-close" onClick={onOpenChange}>
Close Close
</button> </button>
</div> </div>
) : null, ) : null,
DialogContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-content">{children}</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<p data-testid="dialog-description">{children}</p>
),
DialogBody: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-body">{children}</div>
),
})); }));
// Mock the child components // Mock the child components
@@ -94,6 +117,8 @@ describe("EnableTwoFactorModal", () => {
const setOpen = vi.fn(); const setOpen = vi.fn();
render(<EnableTwoFactorModal open={true} setOpen={setOpen} />); render(<EnableTwoFactorModal open={true} setOpen={setOpen} />);
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-content")).toBeInTheDocument();
expect(screen.getByTestId("confirm-password-form")).toBeInTheDocument(); expect(screen.getByTestId("confirm-password-form")).toBeInTheDocument();
}); });
@@ -101,6 +126,7 @@ describe("EnableTwoFactorModal", () => {
const setOpen = vi.fn(); const setOpen = vi.fn();
render(<EnableTwoFactorModal open={false} setOpen={setOpen} />); render(<EnableTwoFactorModal open={false} setOpen={setOpen} />);
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
expect(screen.queryByTestId("confirm-password-form")).not.toBeInTheDocument(); expect(screen.queryByTestId("confirm-password-form")).not.toBeInTheDocument();
}); });
@@ -127,7 +153,7 @@ describe("EnableTwoFactorModal", () => {
expect(screen.getByTestId("display-backup-codes")).toBeInTheDocument(); expect(screen.getByTestId("display-backup-codes")).toBeInTheDocument();
}); });
test("resets state when modal is closed", async () => { test("resets state when dialog is closed", async () => {
const setOpen = vi.fn(); const setOpen = vi.fn();
const user = userEvent.setup(); const user = userEvent.setup();
const { rerender } = render(<EnableTwoFactorModal open={true} setOpen={setOpen} />); const { rerender } = render(<EnableTwoFactorModal open={true} setOpen={setOpen} />);
@@ -136,13 +162,13 @@ describe("EnableTwoFactorModal", () => {
await user.click(screen.getByText("Next")); await user.click(screen.getByText("Next"));
expect(screen.getByTestId("scan-qr-code")).toBeInTheDocument(); expect(screen.getByTestId("scan-qr-code")).toBeInTheDocument();
// Close modal using the close button // Close dialog using the close button
await user.click(screen.getByTestId("modal-close")); await user.click(screen.getByTestId("dialog-close"));
// Verify setOpen was called with false // Verify setOpen was called with false
expect(setOpen).toHaveBeenCalledWith(false); expect(setOpen).toHaveBeenCalledWith(false);
// Reopen modal // Reopen dialog
rerender(<EnableTwoFactorModal open={true} setOpen={setOpen} />); rerender(<EnableTwoFactorModal open={true} setOpen={setOpen} />);
// Should be back at the first step // Should be back at the first step
@@ -4,7 +4,15 @@ import { ConfirmPasswordForm } from "@/modules/ee/two-factor-auth/components/con
import { DisplayBackupCodes } from "@/modules/ee/two-factor-auth/components/display-backup-codes"; import { DisplayBackupCodes } from "@/modules/ee/two-factor-auth/components/display-backup-codes";
import { EnterCode } from "@/modules/ee/two-factor-auth/components/enter-code"; import { EnterCode } from "@/modules/ee/two-factor-auth/components/enter-code";
import { ScanQRCode } from "@/modules/ee/two-factor-auth/components/scan-qr-code"; import { ScanQRCode } from "@/modules/ee/two-factor-auth/components/scan-qr-code";
import { Modal } from "@/modules/ui/components/modal"; import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
@@ -26,6 +34,8 @@ export const EnableTwoFactorModal = ({ open, setOpen }: EnableTwoFactorModalProp
router.refresh(); router.refresh();
}; };
const { t } = useTranslate();
const resetState = () => { const resetState = () => {
setCurrentStep("confirmPassword"); setCurrentStep("confirmPassword");
setBackupCodes([]); setBackupCodes([]);
@@ -35,26 +45,38 @@ export const EnableTwoFactorModal = ({ open, setOpen }: EnableTwoFactorModalProp
}; };
return ( return (
<Modal open={open} setOpen={() => resetState()} noPadding> <Dialog open={open} onOpenChange={() => resetState()}>
{currentStep === "confirmPassword" && ( <DialogContent>
<ConfirmPasswordForm <DialogHeader>
setBackupCodes={setBackupCodes} <DialogTitle>{t("environments.settings.profile.two_factor_authentication")}</DialogTitle>
setCurrentStep={setCurrentStep} <DialogDescription>
setDataUri={setDataUri} {t("environments.settings.profile.confirm_your_current_password_to_get_started")}
setSecret={setSecret} </DialogDescription>
setOpen={setOpen} </DialogHeader>
/> <DialogBody>
)} {currentStep === "confirmPassword" && (
<ConfirmPasswordForm
setBackupCodes={setBackupCodes}
setCurrentStep={setCurrentStep}
setDataUri={setDataUri}
setSecret={setSecret}
setOpen={setOpen}
/>
)}
{currentStep === "scanQRCode" && ( {currentStep === "scanQRCode" && (
<ScanQRCode setCurrentStep={setCurrentStep} dataUri={dataUri} secret={secret} setOpen={setOpen} /> <ScanQRCode setCurrentStep={setCurrentStep} dataUri={dataUri} secret={secret} setOpen={setOpen} />
)} )}
{currentStep === "enterCode" && ( {currentStep === "enterCode" && (
<EnterCode setCurrentStep={setCurrentStep} setOpen={setOpen} refreshData={refreshData} /> <EnterCode setCurrentStep={setCurrentStep} setOpen={setOpen} refreshData={refreshData} />
)} )}
{currentStep === "backupCodes" && <DisplayBackupCodes backupCodes={backupCodes} setOpen={resetState} />} {currentStep === "backupCodes" && (
</Modal> <DisplayBackupCodes backupCodes={backupCodes} setOpen={resetState} />
)}
</DialogBody>
</DialogContent>
</Dialog>
); );
}; };
@@ -86,7 +86,7 @@ export const EnterCode = ({ setCurrentStep, setOpen, refreshData }: EnterCodePro
/> />
</div> </div>
<div className="flex w-full items-center justify-end space-x-4 border-t border-slate-300 p-4"> <div className="flex w-full items-center justify-end space-x-4">
<Button variant="secondary" size="sm" type="button" onClick={() => setOpen(false)}> <Button variant="secondary" size="sm" type="button" onClick={() => setOpen(false)}>
{t("common.cancel")} {t("common.cancel")}
</Button> </Button>
@@ -47,7 +47,7 @@ export const ScanQRCode = ({ dataUri, secret, setCurrentStep, setOpen }: ScanQRC
</div> </div>
</div> </div>
<div className="flex w-full items-center justify-end space-x-4 border-t border-slate-300 p-4"> <div className="flex w-full items-center justify-end space-x-4">
<Button variant="secondary" size="sm" type="button" onClick={() => setOpen(false)}> <Button variant="secondary" size="sm" type="button" onClick={() => setOpen(false)}>
{t("common.cancel")} {t("common.cancel")}
</Button> </Button>
@@ -39,7 +39,7 @@ describe("updateProjectBranding", () => {
inputBorderColor: { light: "#cbd5e1" }, inputBorderColor: { light: "#cbd5e1" },
cardBackgroundColor: { light: "#ffffff" }, cardBackgroundColor: { light: "#ffffff" },
cardBorderColor: { light: "#f8fafc" }, cardBorderColor: { light: "#f8fafc" },
cardShadowColor: { light: "#000000" },
isLogoHidden: false, isLogoHidden: false,
isDarkModeEnabled: false, isDarkModeEnabled: false,
background: { bg: "#fff", bgType: "color" as const }, background: { bg: "#fff", bgType: "color" as const },
@@ -0,0 +1,181 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { AddWebhookModal } from "./add-webhook-modal";
// Mock the Dialog components
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({
children,
open,
onOpenChange,
}: {
children: React.ReactNode;
open: boolean;
onOpenChange: (open: boolean) => void;
}) =>
open ? (
<div data-testid="dialog">
{children}
<button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
Close
</button>
</div>
) : null,
DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-content" className={className}>
{children}
</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
),
DialogTitle: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<h2 data-testid="dialog-title" className={className}>
{children}
</h2>
),
DialogDescription: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<p data-testid="dialog-description" className={className}>
{children}
</p>
),
DialogBody: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-body" className={className}>
{children}
</div>
),
DialogFooter: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-footer">{children}</div>
),
}));
// Mock the child components
vi.mock("./survey-checkbox-group", () => ({
SurveyCheckboxGroup: ({
surveys,
selectedSurveys,
selectedAllSurveys,
onSelectAllSurveys,
onSelectedSurveyChange,
allowChanges,
}: any) => (
<div data-testid="survey-checkbox-group">
<button onClick={onSelectAllSurveys}>Select All Surveys</button>
{surveys.map((survey: any) => (
<button key={survey.id} onClick={() => onSelectedSurveyChange(survey.id)}>
{survey.name}
</button>
))}
</div>
),
}));
vi.mock("./trigger-checkbox-group", () => ({
TriggerCheckboxGroup: ({ selectedTriggers, onCheckboxChange, allowChanges }: any) => (
<div data-testid="trigger-checkbox-group">
<button onClick={() => onCheckboxChange("responseCreated")}>Response Created</button>
<button onClick={() => onCheckboxChange("responseUpdated")}>Response Updated</button>
</div>
),
}));
// Mock actions
vi.mock("../actions", () => ({
createWebhookAction: vi.fn(),
testEndpointAction: vi.fn(),
}));
// Mock other dependencies
vi.mock("next/navigation", () => ({
useRouter: () => ({
refresh: vi.fn(),
}),
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
describe("AddWebhookModal", () => {
const mockProps = {
environmentId: "env-123",
open: true,
surveys: [
{ id: "survey-1", name: "Test Survey 1" },
{ id: "survey-2", name: "Test Survey 2" },
],
setOpen: vi.fn(),
};
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders dialog with correct title and description", () => {
render(<AddWebhookModal {...mockProps} />);
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-content")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent(
"environments.integrations.webhooks.add_webhook"
);
expect(
screen.getByText("environments.integrations.webhooks.add_webhook_description")
).toBeInTheDocument();
});
test("does not render when closed", () => {
render(<AddWebhookModal {...mockProps} open={false} />);
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
});
test("renders form fields", () => {
render(<AddWebhookModal {...mockProps} />);
expect(screen.getByLabelText("common.name")).toBeInTheDocument();
expect(screen.getByLabelText("common.url")).toBeInTheDocument();
expect(screen.getByTestId("trigger-checkbox-group")).toBeInTheDocument();
expect(screen.getByTestId("survey-checkbox-group")).toBeInTheDocument();
});
test("renders footer buttons", () => {
render(<AddWebhookModal {...mockProps} />);
expect(screen.getByTestId("dialog-footer")).toBeInTheDocument();
expect(screen.getByText("common.cancel")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "environments.integrations.webhooks.add_webhook" })
).toBeInTheDocument();
});
test("calls setOpen when cancel button is clicked", async () => {
const user = userEvent.setup();
render(<AddWebhookModal {...mockProps} />);
await user.click(screen.getByText("common.cancel"));
expect(mockProps.setOpen).toHaveBeenCalledWith(false);
});
test("renders webhook icon in header", () => {
render(<AddWebhookModal {...mockProps} />);
expect(screen.getByTestId("dialog-header")).toBeInTheDocument();
// The Webhook icon should be rendered within the header
const header = screen.getByTestId("dialog-header");
expect(header).toBeInTheDocument();
});
});
@@ -5,9 +5,17 @@ import { SurveyCheckboxGroup } from "@/modules/integrations/webhooks/components/
import { TriggerCheckboxGroup } from "@/modules/integrations/webhooks/components/trigger-checkbox-group"; import { TriggerCheckboxGroup } from "@/modules/integrations/webhooks/components/trigger-checkbox-group";
import { isDiscordWebhook, validWebHookURL } from "@/modules/integrations/webhooks/lib/utils"; import { isDiscordWebhook, validWebHookURL } from "@/modules/integrations/webhooks/lib/utils";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input"; import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label"; import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { PipelineTriggers } from "@prisma/client"; import { PipelineTriggers } from "@prisma/client";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import clsx from "clsx"; import clsx from "clsx";
@@ -159,114 +167,102 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
}; };
return ( return (
<Modal open={open} setOpen={setOpenWithStates} noPadding closeOnOutsideClick={true}> <Dialog open={open} onOpenChange={setOpenWithStates}>
<div className="flex h-full flex-col rounded-lg"> <DialogContent>
<div className="rounded-t-lg bg-slate-100"> <DialogHeader>
<div className="flex w-full items-center justify-between p-6"> <Webhook />
<div className="flex items-center space-x-2"> <DialogTitle>{t("environments.integrations.webhooks.add_webhook")}</DialogTitle>
<div className="mr-1.5 h-6 w-6 text-slate-500"> <DialogDescription>
<Webhook /> {t("environments.integrations.webhooks.add_webhook_description")}
</div> </DialogDescription>
<div> </DialogHeader>
<div className="text-xl font-medium text-slate-700">
{t("environments.integrations.webhooks.add_webhook")}
</div>
<div className="text-sm text-slate-500">
{t("environments.integrations.webhooks.add_webhook_description")}
</div>
</div>
</div>
</div>
</div>
<form onSubmit={handleSubmit(submitWebhook)}> <form onSubmit={handleSubmit(submitWebhook)}>
<div className="flex justify-between rounded-lg p-6"> <DialogBody className="space-y-4 pb-4">
<div className="w-full space-y-4"> <div className="col-span-1">
<div className="col-span-1"> <Label htmlFor="name">{t("common.name")}</Label>
<Label htmlFor="name">{t("common.name")}</Label> <div className="mt-1 flex">
<div className="mt-1 flex"> <Input
<Input type="text"
type="text" id="name"
id="name" {...register("name")}
{...register("name")} placeholder={t("environments.integrations.webhooks.webhook_name_placeholder")}
placeholder={t("environments.integrations.webhooks.webhook_name_placeholder")}
/>
</div>
</div>
<div className="col-span-1">
<Label htmlFor="URL">{t("common.url")}</Label>
<div className="mt-1 flex">
<Input
type="url"
id="URL"
value={testEndpointInput}
onChange={(e) => {
setTestEndpointInput(e.target.value);
}}
className={clsx(
endpointAccessible === true
? "border-green-500 bg-green-50"
: endpointAccessible === false
? "border-red-200 bg-red-50"
: endpointAccessible === undefined
? "border-slate-200 bg-white"
: null
)}
placeholder={t("environments.integrations.webhooks.webhook_url_placeholder")}
/>
<Button
type="button"
variant="secondary"
loading={hittingEndpoint}
className="ml-2 whitespace-nowrap"
disabled={testEndpointInput.trim() === ""}
onClick={() => {
handleTestEndpoint(true);
}}>
{t("environments.integrations.webhooks.test_endpoint")}
</Button>
</div>
</div>
<div>
<Label htmlFor="Triggers">{t("environments.integrations.webhooks.triggers")}</Label>
<TriggerCheckboxGroup
selectedTriggers={selectedTriggers}
onCheckboxChange={handleCheckboxChange}
allowChanges={true}
/>
</div>
<div>
<Label htmlFor="Surveys">{t("common.surveys")}</Label>
<SurveyCheckboxGroup
surveys={surveys}
selectedSurveys={selectedSurveys}
selectedAllSurveys={selectedAllSurveys}
onSelectAllSurveys={handleSelectAllSurveys}
onSelectedSurveyChange={handleSelectedSurveyChange}
allowChanges={true}
/> />
</div> </div>
</div> </div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6"> <div className="col-span-1">
<div className="flex space-x-2"> <Label htmlFor="URL">{t("common.url")}</Label>
<Button <div className="mt-1 flex">
type="button" <Input
variant="ghost" type="url"
onClick={() => { id="URL"
setOpenWithStates(false); value={testEndpointInput}
}}> onChange={(e) => {
{t("common.cancel")} setTestEndpointInput(e.target.value);
</Button> }}
<Button type="submit" loading={creatingWebhook}> className={clsx(
{t("environments.integrations.webhooks.add_webhook")} endpointAccessible === true
</Button> ? "border-green-500 bg-green-50"
: endpointAccessible === false
? "border-red-200 bg-red-50"
: endpointAccessible === undefined
? "border-slate-200 bg-white"
: null
)}
placeholder={t("environments.integrations.webhooks.webhook_url_placeholder")}
/>
<Button
type="button"
variant="secondary"
loading={hittingEndpoint}
className="ml-2 whitespace-nowrap"
disabled={testEndpointInput.trim() === ""}
onClick={() => {
handleTestEndpoint(true);
}}>
{t("environments.integrations.webhooks.test_endpoint")}
</Button>
</div>
</div> </div>
</div>
<div>
<Label htmlFor="Triggers">{t("environments.integrations.webhooks.triggers")}</Label>
<TriggerCheckboxGroup
selectedTriggers={selectedTriggers}
onCheckboxChange={handleCheckboxChange}
allowChanges={true}
/>
</div>
<div>
<Label htmlFor="Surveys">{t("common.surveys")}</Label>
<SurveyCheckboxGroup
surveys={surveys}
selectedSurveys={selectedSurveys}
selectedAllSurveys={selectedAllSurveys}
onSelectAllSurveys={handleSelectAllSurveys}
onSelectedSurveyChange={handleSelectedSurveyChange}
allowChanges={true}
/>
</div>
</DialogBody>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => {
setOpenWithStates(false);
}}>
{t("common.cancel")}
</Button>
<Button type="submit" loading={creatingWebhook}>
{t("environments.integrations.webhooks.add_webhook")}
</Button>
</DialogFooter>
</form> </form>
</div> </DialogContent>
</Modal> </Dialog>
); );
}; };
@@ -0,0 +1,239 @@
import { WebhookModal } from "@/modules/integrations/webhooks/components/webhook-detail-modal";
import { Webhook } from "@prisma/client";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey } from "@formbricks/types/surveys/types";
// Mock the Dialog components
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({
open,
onOpenChange,
children,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
}) =>
open ? (
<div data-testid="dialog">
{children}
<button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
Close
</button>
</div>
) : null,
DialogContent: ({
children,
disableCloseOnOutsideClick,
}: {
children: React.ReactNode;
disableCloseOnOutsideClick?: boolean;
}) => (
<div data-testid="dialog-content" data-disable-close-on-outside-click={disableCloseOnOutsideClick}>
{children}
</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<p data-testid="dialog-description">{children}</p>
),
DialogBody: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-body">{children}</div>
),
}));
// Mock the tab components
vi.mock("@/modules/integrations/webhooks/components/webhook-overview-tab", () => ({
WebhookOverviewTab: ({ webhook }: { webhook: Webhook }) => (
<div data-testid="webhook-overview-tab">Overview for {webhook.name}</div>
),
}));
vi.mock("@/modules/integrations/webhooks/components/webhook-settings-tab", () => ({
WebhookSettingsTab: ({ webhook, setOpen }: { webhook: Webhook; setOpen: (v: boolean) => void }) => (
<div data-testid="webhook-settings-tab">
Settings for {webhook.name}
<button onClick={() => setOpen(false)}>Close from settings</button>
</div>
),
}));
// Mock useTranslate
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => {
const translations = {
"common.overview": "Overview",
"common.settings": "Settings",
"common.webhook": "Webhook",
};
return translations[key] || key;
},
}),
}));
// Mock lucide-react
vi.mock("lucide-react", () => ({
WebhookIcon: () => <span data-testid="webhook-icon">🪝</span>,
}));
const mockWebhook: Webhook = {
id: "webhook-1",
name: "Test Webhook",
url: "https://example.com/webhook",
source: "user",
triggers: [],
surveyIds: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env-1",
};
const mockSurveys: TSurvey[] = [];
describe("WebhookModal", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders correctly when open", () => {
const setOpen = vi.fn();
render(
<WebhookModal
open={true}
setOpen={setOpen}
webhook={mockWebhook}
surveys={mockSurveys}
isReadOnly={false}
/>
);
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Webhook");
expect(screen.getByTestId("webhook-icon")).toBeInTheDocument();
expect(screen.getByText("Overview")).toBeInTheDocument();
expect(screen.getByText("Settings")).toBeInTheDocument();
expect(screen.getByTestId("webhook-overview-tab")).toBeInTheDocument();
});
test("does not render when closed", () => {
const setOpen = vi.fn();
render(
<WebhookModal
open={false}
setOpen={setOpen}
webhook={mockWebhook}
surveys={mockSurveys}
isReadOnly={false}
/>
);
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
});
test("switches tabs correctly", async () => {
const setOpen = vi.fn();
const user = userEvent.setup();
render(
<WebhookModal
open={true}
setOpen={setOpen}
webhook={mockWebhook}
surveys={mockSurveys}
isReadOnly={false}
/>
);
// Initially shows overview tab
expect(screen.getByTestId("webhook-overview-tab")).toBeInTheDocument();
expect(screen.queryByTestId("webhook-settings-tab")).not.toBeInTheDocument();
// Click settings tab
const settingsTab = screen.getByText("Settings");
await user.click(settingsTab);
// Now shows settings tab
expect(screen.queryByTestId("webhook-overview-tab")).not.toBeInTheDocument();
expect(screen.getByTestId("webhook-settings-tab")).toBeInTheDocument();
// Click overview tab again
const overviewTab = screen.getByText("Overview");
await user.click(overviewTab);
// Back to overview tab
expect(screen.getByTestId("webhook-overview-tab")).toBeInTheDocument();
expect(screen.queryByTestId("webhook-settings-tab")).not.toBeInTheDocument();
});
test("uses webhook as title when name is not provided", () => {
const setOpen = vi.fn();
const webhookWithoutName = { ...mockWebhook, name: "" };
render(
<WebhookModal
open={true}
setOpen={setOpen}
webhook={webhookWithoutName}
surveys={mockSurveys}
isReadOnly={false}
/>
);
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Webhook");
});
test("resets to first tab when modal is reopened", async () => {
const setOpen = vi.fn();
const user = userEvent.setup();
const { rerender } = render(
<WebhookModal
open={true}
setOpen={setOpen}
webhook={mockWebhook}
surveys={mockSurveys}
isReadOnly={false}
/>
);
// Switch to settings tab
const settingsTab = screen.getByText("Settings");
await user.click(settingsTab);
expect(screen.getByTestId("webhook-settings-tab")).toBeInTheDocument();
// Close modal
rerender(
<WebhookModal
open={false}
setOpen={setOpen}
webhook={mockWebhook}
surveys={mockSurveys}
isReadOnly={false}
/>
);
// Reopen modal
rerender(
<WebhookModal
open={true}
setOpen={setOpen}
webhook={mockWebhook}
surveys={mockSurveys}
isReadOnly={false}
/>
);
// Should be back to overview tab
expect(screen.getByTestId("webhook-overview-tab")).toBeInTheDocument();
expect(screen.queryByTestId("webhook-settings-tab")).not.toBeInTheDocument();
});
});
@@ -2,10 +2,18 @@
import { WebhookOverviewTab } from "@/modules/integrations/webhooks/components/webhook-overview-tab"; import { WebhookOverviewTab } from "@/modules/integrations/webhooks/components/webhook-overview-tab";
import { WebhookSettingsTab } from "@/modules/integrations/webhooks/components/webhook-settings-tab"; import { WebhookSettingsTab } from "@/modules/integrations/webhooks/components/webhook-settings-tab";
import { ModalWithTabs } from "@/modules/ui/components/modal-with-tabs"; import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Webhook } from "@prisma/client"; import { Webhook } from "@prisma/client";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { WebhookIcon } from "lucide-react"; import { WebhookIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
interface WebhookModalProps { interface WebhookModalProps {
@@ -18,6 +26,8 @@ interface WebhookModalProps {
export const WebhookModal = ({ open, setOpen, webhook, surveys, isReadOnly }: WebhookModalProps) => { export const WebhookModal = ({ open, setOpen, webhook, surveys, isReadOnly }: WebhookModalProps) => {
const { t } = useTranslate(); const { t } = useTranslate();
const [activeTab, setActiveTab] = useState(0);
const tabs = [ const tabs = [
{ {
title: t("common.overview"), title: t("common.overview"),
@@ -31,16 +41,45 @@ export const WebhookModal = ({ open, setOpen, webhook, surveys, isReadOnly }: We
}, },
]; ];
const handleTabClick = (index: number) => {
setActiveTab(index);
};
useEffect(() => {
if (!open) {
setActiveTab(0);
}
}, [open]);
return ( return (
<> <Dialog open={open} onOpenChange={setOpen}>
<ModalWithTabs <DialogContent disableCloseOnOutsideClick>
open={open} <DialogHeader>
setOpen={setOpen} <WebhookIcon />
tabs={tabs} <DialogTitle>{webhook.name || t("common.webhook")}</DialogTitle>{" "} {/* NOSONAR // We want to check for empty strings */}
icon={<WebhookIcon />} <DialogDescription>{webhook.url}</DialogDescription>
label={webhook.name ? webhook.name : webhook.url} </DialogHeader>
description={""} <DialogBody>
/> <div className="flex h-full w-full flex-col">
</> <div className="flex w-full items-center justify-center space-x-2 border-b border-slate-200 px-6">
{tabs.map((tab, index) => (
<button
key={tab.title}
type="button"
className={`mr-4 px-1 pb-3 focus:outline-none ${
activeTab === index
? "border-brand-dark border-b-2 font-semibold text-slate-900"
: "text-slate-500 hover:text-slate-700"
}`}
onClick={() => handleTabClick(index)}>
{tab.title}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto pt-4">{tabs[activeTab].children}</div>
</div>
</DialogBody>
</DialogContent>
</Dialog>
); );
}; };
@@ -217,14 +217,10 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: We
/> />
</div> </div>
<div className="flex justify-between border-t border-slate-200 py-6"> <div className="flex justify-between space-x-2">
<div> <div className="flex space-x-2">
{!isReadOnly && ( {!isReadOnly && (
<Button <Button type="button" variant="destructive" onClick={() => setOpenDeleteDialog(true)}>
type="button"
variant="destructive"
onClick={() => setOpenDeleteDialog(true)}
className="mr-3">
<TrashIcon /> <TrashIcon />
{t("common.delete")} {t("common.delete")}
</Button> </Button>
@@ -6,8 +6,27 @@ import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { CreateOrganizationModal } from "./index"; import { CreateOrganizationModal } from "./index";
vi.mock("@/modules/ui/components/modal", () => ({ vi.mock("@/modules/ui/components/dialog", () => ({
Modal: ({ open, children }) => (open ? <div data-testid="modal">{children}</div> : null), Dialog: ({ children, open }: { children: React.ReactNode; open: boolean }) =>
open ? <div data-testid="dialog">{children}</div> : null,
DialogContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-content">{children}</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-title">{children}</div>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-description">{children}</div>
),
DialogBody: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-body">{children}</div>
),
DialogFooter: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-footer">{children}</div>
),
})); }));
vi.mock("lucide-react", () => ({ vi.mock("lucide-react", () => ({
@@ -34,9 +53,14 @@ describe("CreateOrganizationModal", () => {
cleanup(); cleanup();
}); });
test("renders modal and form fields", () => { test("renders dialog and form fields", () => {
render(<CreateOrganizationModal open={true} setOpen={vi.fn()} />); render(<CreateOrganizationModal open={true} setOpen={vi.fn()} />);
expect(screen.getByTestId("modal")).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-header")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toBeInTheDocument();
expect(screen.getByTestId("dialog-description")).toBeInTheDocument();
expect(screen.getByTestId("dialog-body")).toBeInTheDocument();
expect(screen.getByTestId("dialog-footer")).toBeInTheDocument();
expect( expect(
screen.getByPlaceholderText("environments.settings.general.organization_name_placeholder") screen.getByPlaceholderText("environments.settings.general.organization_name_placeholder")
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -61,7 +85,7 @@ describe("CreateOrganizationModal", () => {
expect(submitBtn).not.toBeDisabled(); expect(submitBtn).not.toBeDisabled();
}); });
test("calls createOrganizationAction and closes modal on success", async () => { test("calls createOrganizationAction and closes dialog on success", async () => {
const setOpen = vi.fn(); const setOpen = vi.fn();
vi.mocked(createOrganizationAction).mockResolvedValue({ data: { id: "org-1" } } as any); vi.mocked(createOrganizationAction).mockResolvedValue({ data: { id: "org-1" } } as any);
render(<CreateOrganizationModal open={true} setOpen={setOpen} />); render(<CreateOrganizationModal open={true} setOpen={setOpen} />);
@@ -3,9 +3,17 @@
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createOrganizationAction } from "@/modules/organization/actions"; import { createOrganizationAction } from "@/modules/organization/actions";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input"; import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label"; import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { PlusCircleIcon } from "lucide-react"; import { PlusCircleIcon } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -50,57 +58,44 @@ export const CreateOrganizationModal = ({ open, setOpen }: CreateOrganizationMod
}; };
return ( return (
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false}> <Dialog open={open} onOpenChange={setOpen}>
<div className="flex h-full flex-col rounded-lg"> <DialogContent disableCloseOnOutsideClick={true}>
<div className="rounded-t-lg bg-slate-100"> <DialogHeader>
<div className="flex items-center justify-between p-6"> <PlusCircleIcon />
<div className="flex items-center space-x-2"> <DialogTitle>{t("environments.settings.general.create_new_organization")}</DialogTitle>
<div className="mr-1.5 h-10 w-10 text-slate-500"> <DialogDescription>
<PlusCircleIcon className="h-5 w-5" /> {t("environments.settings.general.create_new_organization_description")}
</div> </DialogDescription>
<div> </DialogHeader>
<div className="text-xl font-medium text-slate-700">
{t("environments.settings.general.create_new_organization")} <form onSubmit={handleSubmit(submitOrganization)} className="space-y-4">
</div> <DialogBody>
<div className="text-sm text-slate-500"> <div className="grid w-full space-y-2">
{t("environments.settings.general.create_new_organization_description")} <Label>{t("environments.settings.general.organization_name")}</Label>
</div> <Input
</div> autoFocus
placeholder={t("environments.settings.general.organization_name_placeholder")}
{...register("name", { required: true })}
value={organizationName}
onChange={(e) => setOrganizationName(e.target.value)}
/>
</div> </div>
</div> </DialogBody>
</div> <DialogFooter>
<form onSubmit={handleSubmit(submitOrganization)}> <Button
<div className="flex w-full justify-between space-y-4 rounded-lg p-6"> type="button"
<div className="grid w-full gap-x-2"> variant="secondary"
<div> onClick={() => {
<Label>{t("environments.settings.general.organization_name")}</Label> setOpen(false);
<Input }}>
autoFocus {t("common.cancel")}
placeholder={t("environments.settings.general.organization_name_placeholder")} </Button>
{...register("name", { required: true })} <Button type="submit" loading={loading} disabled={!isOrganizationNameValid}>
value={organizationName} {t("environments.settings.general.create_new_organization")}
onChange={(e) => setOrganizationName(e.target.value)} </Button>
/> </DialogFooter>
</div>
</div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
}}>
{t("common.cancel")}
</Button>
<Button type="submit" loading={loading} disabled={!isOrganizationNameValid}>
{t("environments.settings.general.create_new_organization")}
</Button>
</div>
</div>
</form> </form>
</div> </DialogContent>
</Modal> </Dialog>
); );
}; };
@@ -5,6 +5,44 @@ import { afterEach, describe, expect, test, vi } from "vitest";
import { TProject } from "@formbricks/types/project"; import { TProject } from "@formbricks/types/project";
import { AddApiKeyModal } from "./add-api-key-modal"; import { AddApiKeyModal } from "./add-api-key-modal";
// Mock the Dialog components
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({
open,
onOpenChange,
children,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
}) =>
open ? (
<div data-testid="dialog">
{children}
<button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
Close
</button>
</div>
) : null,
DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-content" className={className}>
{children}
</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogBody: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-body">{children}</div>
),
DialogFooter: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-footer">{children}</div>
),
}));
// Mock the translate hook // Mock the translate hook
vi.mock("@tolgee/react", () => ({ vi.mock("@tolgee/react", () => ({
useTranslate: () => ({ useTranslate: () => ({
@@ -102,11 +140,9 @@ describe("AddApiKeyModal", () => {
test("renders the modal with initial state", () => { test("renders the modal with initial state", () => {
render(<AddApiKeyModal {...defaultProps} />); render(<AddApiKeyModal {...defaultProps} />);
const modalTitle = screen.getByText("environments.project.api_keys.add_api_key", {
selector: "div.text-xl",
});
expect(modalTitle).toBeInTheDocument(); expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("environments.project.api_keys.add_api_key");
expect(screen.getByPlaceholderText("e.g. GitHub, PostHog, Slack")).toBeInTheDocument(); expect(screen.getByPlaceholderText("e.g. GitHub, PostHog, Slack")).toBeInTheDocument();
expect(screen.getByText("environments.project.api_keys.project_access")).toBeInTheDocument(); expect(screen.getByText("environments.project.api_keys.project_access")).toBeInTheDocument();
}); });
@@ -4,6 +4,14 @@ import { getOrganizationAccessKeyDisplayName } from "@/modules/organization/sett
import { TOrganizationProject } from "@/modules/organization/settings/api-keys/types/api-keys"; import { TOrganizationProject } from "@/modules/organization/settings/api-keys/types/api-keys";
import { Alert, AlertTitle } from "@/modules/ui/components/alert"; import { Alert, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -12,7 +20,6 @@ import {
} from "@/modules/ui/components/dropdown-menu"; } from "@/modules/ui/components/dropdown-menu";
import { Input } from "@/modules/ui/components/input"; import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label"; import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { Switch } from "@/modules/ui/components/switch"; import { Switch } from "@/modules/ui/components/switch";
import { ApiKeyPermission } from "@prisma/client"; import { ApiKeyPermission } from "@prisma/client";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
@@ -210,212 +217,199 @@ export const AddApiKeyModal = ({
}; };
return ( return (
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={true}> <Dialog open={open} onOpenChange={setOpen}>
<div className="flex h-full flex-col rounded-lg"> <DialogContent>
<div className="rounded-t-lg bg-slate-100"> <DialogHeader>
<div className="flex items-center justify-between p-6"> <DialogTitle>{t("environments.project.api_keys.add_api_key")}</DialogTitle>
<div className="flex items-center space-x-2"> </DialogHeader>
<div className="text-xl font-medium text-slate-700"> <form onSubmit={handleSubmit(submitAPIKey)} className="contents">
{t("environments.project.api_keys.add_api_key")} <DialogBody className="space-y-4 overflow-y-auto py-4">
</div> <div className="space-y-2">
<Label>{t("environments.project.api_keys.api_key_label")}</Label>
<Input
placeholder="e.g. GitHub, PostHog, Slack"
{...register("label", { required: true, validate: (value) => value.trim() !== "" })}
/>
</div> </div>
</div>
</div> <div className="space-y-2">
<form onSubmit={handleSubmit(submitAPIKey)}> <Label>{t("environments.project.api_keys.project_access")}</Label>
<div className="flex flex-col justify-between rounded-lg p-6">
<div className="w-full space-y-6">
<div className="space-y-2"> <div className="space-y-2">
<Label>{t("environments.project.api_keys.api_key_label")}</Label> {/* Permission rows */}
<Input {Object.keys(selectedPermissions).map((key) => {
placeholder="e.g. GitHub, PostHog, Slack" const permissionIndex = parseInt(key.split("-")[1]);
{...register("label", { required: true, validate: (value) => value.trim() !== "" })} const permission = selectedPermissions[key];
/> return (
</div> <div key={key} className="flex items-center gap-2">
{/* Project dropdown */}
<div className="space-y-2"> <div className="w-1/3">
<Label>{t("environments.project.api_keys.project_access")}</Label> <DropdownMenu>
<div className="space-y-2"> <DropdownMenuTrigger asChild>
{/* Permission rows */} <button
{Object.keys(selectedPermissions).map((key) => { type="button"
const permissionIndex = parseInt(key.split("-")[1]); className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
const permission = selectedPermissions[key]; <span className="flex w-4/5 flex-1">
return ( <span className="w-full truncate text-left">{permission.projectName}</span>
<div key={key} className="flex items-center gap-2"> </span>
{/* Project dropdown */} <span className="flex h-full items-center border-l pl-3">
<div className="w-1/3"> <ChevronDownIcon className="h-4 w-4 text-slate-500" />
<DropdownMenu> </span>
<DropdownMenuTrigger asChild> </button>
<button </DropdownMenuTrigger>
type="button" <DropdownMenuContent className="min-w-[8rem]">
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none"> {projectOptions.map((option) => (
<span className="flex w-4/5 flex-1"> <DropdownMenuItem
<span className="w-full truncate text-left">{permission.projectName}</span> key={option.id}
</span> onClick={() => {
<span className="flex h-full items-center border-l pl-3"> updateProjectAndEnvironment(key, option.id);
<ChevronDownIcon className="h-4 w-4 text-slate-500" /> }}>
</span> {option.name}
</button> </DropdownMenuItem>
</DropdownMenuTrigger> ))}
<DropdownMenuContent className="min-w-[8rem]"> </DropdownMenuContent>
{projectOptions.map((option) => ( </DropdownMenu>
<DropdownMenuItem
key={option.id}
onClick={() => {
updateProjectAndEnvironment(key, option.id);
}}>
{option.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Environment dropdown */}
<div className="w-1/3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
<span className="flex w-4/5 flex-1">
<span className="w-full truncate text-left capitalize">
{permission.environmentType}
</span>
</span>
<span className="flex h-full items-center border-l pl-3">
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[8rem] capitalize">
{getEnvironmentOptionsForProject(permission.projectId).map((env) => (
<DropdownMenuItem
key={env.id}
onClick={() => {
updatePermission(key, "environmentId", env.id);
}}>
{env.type}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Permission level dropdown */}
<div className="w-1/3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
<span className="flex w-4/5 flex-1">
<span className="w-full truncate text-left capitalize">
{permission.permission}
</span>
</span>
<span className="flex h-full items-center border-l pl-3">
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[8rem] capitalize">
{permissionOptions.map((option) => (
<DropdownMenuItem
key={option}
onClick={() => {
updatePermission(key, "permission", option);
}}>
{option}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Delete button */}
<button
type="button"
className="p-2"
onClick={() => removePermission(permissionIndex)}>
<Trash2Icon className={"h-5 w-5 text-slate-500 hover:text-red-500"} />
</button>
</div> </div>
);
})}
{/* Add permission button */} {/* Environment dropdown */}
<Button type="button" variant="outline" onClick={addPermission}> <div className="w-1/3">
<span className="mr-2">+</span> {t("environments.settings.api_keys.add_permission")} <DropdownMenu>
</Button> <DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
<span className="flex w-4/5 flex-1">
<span className="w-full truncate text-left capitalize">
{permission.environmentType}
</span>
</span>
<span className="flex h-full items-center border-l pl-3">
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[8rem] capitalize">
{getEnvironmentOptionsForProject(permission.projectId).map((env) => (
<DropdownMenuItem
key={env.id}
onClick={() => {
updatePermission(key, "environmentId", env.id);
}}>
{env.type}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Permission level dropdown */}
<div className="w-1/3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
<span className="flex w-4/5 flex-1">
<span className="w-full truncate text-left capitalize">
{permission.permission}
</span>
</span>
<span className="flex h-full items-center border-l pl-3">
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[8rem] capitalize">
{permissionOptions.map((option) => (
<DropdownMenuItem
key={option}
onClick={() => {
updatePermission(key, "permission", option);
}}>
{option}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Delete button */}
<button type="button" className="p-2" onClick={() => removePermission(permissionIndex)}>
<Trash2Icon className={"h-5 w-5 text-slate-500 hover:text-red-500"} />
</button>
</div>
);
})}
{/* Add permission button */}
<Button type="button" variant="outline" onClick={addPermission}>
<span className="mr-2">+</span> {t("environments.settings.api_keys.add_permission")}
</Button>
</div>
</div>
<div className="space-y-4">
<div>
<Label>{t("environments.project.api_keys.organization_access")}</Label>
<p className="text-sm text-slate-500">
{t("environments.project.api_keys.organization_access_description")}
</p>
</div>
<div className="space-y-2">
<div className="grid grid-cols-[auto_100px_100px] gap-4">
<div></div>
<span className="flex items-center justify-center text-sm font-medium">Read</span>
<span className="flex items-center justify-center text-sm font-medium">Write</span>
{Object.keys(selectedOrganizationAccess).map((key) => (
<Fragment key={key}>
<div className="py-1 text-sm">{getOrganizationAccessKeyDisplayName(key, t)}</div>
<div className="flex items-center justify-center py-1">
<Switch
data-testid={`organization-access-${key}-read`}
checked={selectedOrganizationAccess[key].read}
onCheckedChange={(newVal) =>
setSelectedOrganizationAccessValue(key, "read", newVal)
}
/>
</div>
<div className="flex items-center justify-center py-1">
<Switch
data-testid={`organization-access-${key}-write`}
checked={selectedOrganizationAccess[key].write}
onCheckedChange={(newVal) =>
setSelectedOrganizationAccessValue(key, "write", newVal)
}
/>
</div>
</Fragment>
))}
</div> </div>
</div> </div>
<div className="space-y-4">
<div>
<Label>{t("environments.project.api_keys.organization_access")}</Label>
<p className="text-sm text-slate-500">
{t("environments.project.api_keys.organization_access_description")}
</p>
</div>
<div className="space-y-2">
<div className="grid grid-cols-[auto_100px_100px] gap-4">
<div></div>
<span className="flex items-center justify-center text-sm font-medium">Read</span>
<span className="flex items-center justify-center text-sm font-medium">Write</span>
{Object.keys(selectedOrganizationAccess).map((key) => (
<Fragment key={key}>
<div className="py-1 text-sm">{getOrganizationAccessKeyDisplayName(key, t)}</div>
<div className="flex items-center justify-center py-1">
<Switch
data-testid={`organization-access-${key}-read`}
checked={selectedOrganizationAccess[key].read}
onCheckedChange={(newVal) =>
setSelectedOrganizationAccessValue(key, "read", newVal)
}
/>
</div>
<div className="flex items-center justify-center py-1">
<Switch
data-testid={`organization-access-${key}-write`}
checked={selectedOrganizationAccess[key].write}
onCheckedChange={(newVal) =>
setSelectedOrganizationAccessValue(key, "write", newVal)
}
/>
</div>
</Fragment>
))}
</div>
</div>
</div>
<Alert variant="warning">
<AlertTitle>{t("environments.project.api_keys.api_key_security_warning")}</AlertTitle>
</Alert>
</div> </div>
</div> <Alert variant="warning">
<div className="flex justify-end border-t border-slate-200 p-6"> <AlertTitle>{t("environments.project.api_keys.api_key_security_warning")}</AlertTitle>
<div className="flex space-x-2"> </Alert>
<Button </DialogBody>
type="button" <DialogFooter>
variant="ghost" <Button
onClick={() => { type="button"
setOpen(false); variant="secondary"
reset(); onClick={() => {
setSelectedPermissions({}); setOpen(false);
}}> reset();
{t("common.cancel")} setSelectedPermissions({});
</Button> }}>
<Button {t("common.cancel")}
type="submit" </Button>
disabled={isSubmitDisabled() || isCreatingAPIKey} <Button
loading={isCreatingAPIKey}> type="submit"
{t("environments.project.api_keys.add_api_key")} disabled={isSubmitDisabled() || isCreatingAPIKey}
</Button> loading={isCreatingAPIKey}>
</div> {t("environments.project.api_keys.add_api_key")}
</div> </Button>
</DialogFooter>
</form> </form>
</div> </DialogContent>
</Modal> </Dialog>
); );
}; };
@@ -31,6 +31,31 @@ vi.mock("@tolgee/react", () => ({
}), }),
})); }));
// Mock the Dialog components
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ children, open, onOpenChange }: any) =>
open ? (
<div data-testid="dialog" role="dialog">
{children}
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div>
) : null,
DialogContent: ({ children, ...props }: any) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
),
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
DialogTitle: ({ children, className }: any) => (
<h2 data-testid="dialog-title" className={className}>
{children}
</h2>
),
DialogDescription: ({ children }: any) => <p data-testid="dialog-description">{children}</p>,
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
DialogFooter: ({ children }: any) => <div data-testid="dialog-footer">{children}</div>,
}));
// Base project setup // Base project setup
const baseProject = {}; const baseProject = {};
@@ -156,11 +181,10 @@ describe("EditAPIKeys", () => {
const addButton = screen.getByRole("button", { name: "environments.settings.api_keys.add_api_key" }); const addButton = screen.getByRole("button", { name: "environments.settings.api_keys.add_api_key" });
await userEvent.click(addButton); await userEvent.click(addButton);
// Look for the modal title specifically // Look for the modal title using the correct test id
const modalTitle = screen.getByText("environments.project.api_keys.add_api_key", { const modalTitle = screen.getByTestId("dialog-title");
selector: "div.text-xl",
});
expect(modalTitle).toBeInTheDocument(); expect(modalTitle).toBeInTheDocument();
expect(modalTitle).toHaveTextContent("environments.project.api_keys.add_api_key");
}); });
test("handles API key deletion", async () => { test("handles API key deletion", async () => {
@@ -244,6 +244,7 @@ export const EditAPIKeys = ({ organizationId, apiKeys, locale, isReadOnly, proje
deleteWhat={t("environments.project.api_keys.api_key")} deleteWhat={t("environments.project.api_keys.api_key")}
onDelete={handleDeleteKey} onDelete={handleDeleteKey}
isDeleting={isLoading} isDeleting={isLoading}
text={t("environments.project.api_keys.delete_api_key_confirmation")}
/> />
</div> </div>
); );

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