mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-24 07:00:34 -06:00
Compare commits
20 Commits
fix/valida
...
feat-reset
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93c72df4d9 | ||
|
|
49560ccba8 | ||
|
|
3f98283d4d | ||
|
|
7b64422a3f | ||
|
|
a7ee1f189f | ||
|
|
46a590311b | ||
|
|
0faeffb624 | ||
|
|
d9727a336a | ||
|
|
330e0db668 | ||
|
|
f5b7f73199 | ||
|
|
c02f070307 | ||
|
|
bc489e050a | ||
|
|
3062059ed5 | ||
|
|
f27ede6b2c | ||
|
|
e460ff5100 | ||
|
|
4699c0014b | ||
|
|
52f69be05d | ||
|
|
619c0983a4 | ||
|
|
964fb8d4f4 | ||
|
|
5391c60bba |
@@ -210,8 +210,6 @@ UNKEY_ROOT_KEY=
|
||||
# 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.
|
||||
# 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)
|
||||
# USER_MANAGEMENT_MINIMUM_ROLE="manager"
|
||||
|
||||
121
.github/actions/upload-sentry-sourcemaps/action.yml
vendored
121
.github/actions/upload-sentry-sourcemaps/action.yml
vendored
@@ -1,121 +0,0 @@
|
||||
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
.github/workflows/formbricks-release.yml
vendored
22
.github/workflows/formbricks-release.yml
vendored
@@ -32,25 +32,3 @@ jobs:
|
||||
with:
|
||||
VERSION: v${{ needs.docker-build.outputs.VERSION }}
|
||||
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 }}
|
||||
|
||||
46
.github/workflows/upload-sentry-sourcemaps.yml
vendored
46
.github/workflows/upload-sentry-sourcemaps.yml
vendored
@@ -1,46 +0,0 @@
|
||||
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 }}
|
||||
@@ -14,7 +14,17 @@ Are you brimming with brilliant ideas? For new features that can elevate Formbri
|
||||
|
||||
## 🛠 Crafting Pull Requests
|
||||
|
||||
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.
|
||||
Ready to dive into the code and make a real impact? Here's your path:
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
- Note: For the time being, we can only facilitate code contributions as an exception.
|
||||
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.
|
||||
|
||||
## All Thanks To Our Contributors
|
||||
|
||||
|
||||
@@ -25,9 +25,21 @@ RUN corepack prepare pnpm@9.15.9 --activate
|
||||
# Install necessary build tools and compilers
|
||||
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
|
||||
|
||||
# Copy the secrets handling script
|
||||
COPY apps/web/scripts/docker/read-secrets.sh /tmp/read-secrets.sh
|
||||
RUN chmod +x /tmp/read-secrets.sh
|
||||
# BuildKit secret handling without hardcoded fallback values
|
||||
# This approach relies entirely on secrets passed from GitHub Actions
|
||||
RUN echo '#!/bin/sh' > /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
|
||||
ARG NODE_OPTIONS="--max_old_space_size=4096"
|
||||
@@ -50,9 +62,6 @@ RUN touch apps/web/.env
|
||||
# Install the dependencies
|
||||
RUN pnpm install --ignore-scripts
|
||||
|
||||
# Build the database package first
|
||||
RUN pnpm build --filter=@formbricks/database
|
||||
|
||||
# Build the project using our secret reader script
|
||||
# This mounts the secrets only during this build step without storing them in layers
|
||||
RUN --mount=type=secret,id=database_url \
|
||||
@@ -97,8 +106,20 @@ 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
|
||||
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
|
||||
|
||||
COPY --from=installer /app/packages/database/dist ./packages/database/dist
|
||||
RUN chown -R nextjs:nextjs ./packages/database/dist && chmod -R 755 ./packages/database/dist
|
||||
COPY --from=installer /app/packages/database/package.json ./packages/database/package.json
|
||||
RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json
|
||||
|
||||
COPY --from=installer /app/packages/database/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
|
||||
RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client
|
||||
@@ -121,14 +142,12 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
|
||||
COPY --from=installer /app/node_modules/zod ./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
|
||||
|
||||
# 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
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
ENV NODE_ENV="production"
|
||||
USER nextjs
|
||||
|
||||
# Prepare volume for uploads
|
||||
@@ -139,4 +158,12 @@ VOLUME /home/nextjs/apps/web/uploads/
|
||||
RUN mkdir -p /home/nextjs/apps/web/saml-connection
|
||||
VOLUME /home/nextjs/apps/web/saml-connection
|
||||
|
||||
CMD ["/home/nextjs/start.sh"]
|
||||
CMD if [ "${DOCKER_CRON_ENABLED:-1}" = "1" ]; then \
|
||||
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
|
||||
@@ -94,7 +94,6 @@ describe("LandingSidebar component", () => {
|
||||
organizationId: "o1",
|
||||
redirect: true,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,7 +130,6 @@ export const LandingSidebar = ({
|
||||
organizationId: organization.id,
|
||||
redirect: true,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
}}
|
||||
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ModalWithTabs } from "@/modules/ui/components/modal-with-tabs";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
@@ -8,40 +8,23 @@ import { ActionDetailModal } from "./ActionDetailModal";
|
||||
// Import mocked components
|
||||
import { ActionSettingsTab } from "./ActionSettingsTab";
|
||||
|
||||
// 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 }: { 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 child components
|
||||
vi.mock("@/modules/ui/components/modal-with-tabs", () => ({
|
||||
ModalWithTabs: vi.fn(({ tabs, icon, label, description, open, setOpen }) => (
|
||||
<div data-testid="modal-with-tabs">
|
||||
<span data-testid="modal-label">{label}</span>
|
||||
<span data-testid="modal-description">{description}</span>
|
||||
<span data-testid="modal-open">{open.toString()}</span>
|
||||
<button onClick={() => setOpen(false)}>Close</button>
|
||||
{icon}
|
||||
{tabs.map((tab) => (
|
||||
<div key={tab.title}>
|
||||
<h2>{tab.title}</h2>
|
||||
{tab.children}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
vi.mock("./ActionActivityTab", () => ({
|
||||
@@ -61,22 +44,6 @@ 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 mockSetOpen = vi.fn();
|
||||
|
||||
@@ -122,68 +89,58 @@ describe("ActionDetailModal", () => {
|
||||
vi.clearAllMocks(); // Clear mocks after each test
|
||||
});
|
||||
|
||||
test("renders correctly when open", () => {
|
||||
test("renders ModalWithTabs with correct props", () => {
|
||||
render(<ActionDetailModal {...defaultProps} />);
|
||||
|
||||
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();
|
||||
});
|
||||
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
|
||||
|
||||
test("does not render when open is false", () => {
|
||||
render(<ActionDetailModal {...defaultProps} open={false} />);
|
||||
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
expect(mockedModalWithTabs).toHaveBeenCalled();
|
||||
const props = mockedModalWithTabs.mock.calls[0][0];
|
||||
|
||||
test("switches tabs correctly", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ActionDetailModal {...defaultProps} />);
|
||||
// Check basic props
|
||||
expect(props.open).toBe(true);
|
||||
expect(props.setOpen).toBe(mockSetOpen);
|
||||
expect(props.label).toBe(mockActionClass.name);
|
||||
expect(props.description).toBe(mockActionClass.description);
|
||||
|
||||
// Initially shows activity tab (first tab is active)
|
||||
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
|
||||
// Check icon data-testid based on the mock for the default 'code' type
|
||||
expect(props.icon).toBeDefined();
|
||||
if (!props.icon) {
|
||||
throw new Error("Icon prop is not defined");
|
||||
}
|
||||
expect((props.icon as any).props["data-testid"]).toBe("code-icon");
|
||||
|
||||
// Click settings tab
|
||||
const settingsTab = screen.getByText("Settings");
|
||||
await user.click(settingsTab);
|
||||
// Check tabs structure
|
||||
expect(props.tabs).toHaveLength(2);
|
||||
expect(props.tabs[0].title).toBe("common.activity");
|
||||
expect(props.tabs[1].title).toBe("common.settings");
|
||||
|
||||
// Now shows settings tab content
|
||||
expect(screen.queryByTestId("action-activity-tab")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("action-settings-tab")).toBeInTheDocument();
|
||||
// Check if the correct mocked components are used as children
|
||||
// Access the mocked functions directly
|
||||
const mockedActionActivityTab = vi.mocked(ActionActivityTab);
|
||||
const mockedActionSettingsTab = vi.mocked(ActionSettingsTab);
|
||||
|
||||
// Click activity tab again
|
||||
const activityTab = screen.getByText("Activity");
|
||||
await user.click(activityTab);
|
||||
if (!props.tabs[0].children || !props.tabs[1].children) {
|
||||
throw new Error("Tabs children are not defined");
|
||||
}
|
||||
|
||||
// Back to activity tab content
|
||||
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
|
||||
});
|
||||
expect((props.tabs[0].children as any).type).toBe(mockedActionActivityTab);
|
||||
expect((props.tabs[1].children as any).type).toBe(mockedActionSettingsTab);
|
||||
|
||||
test("resets to first tab when modal is reopened", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<ActionDetailModal {...defaultProps} />);
|
||||
// Check props passed to child components
|
||||
const activityTabProps = (props.tabs[0].children as any).props;
|
||||
expect(activityTabProps.otherEnvActionClasses).toBe(mockOtherEnvActionClasses);
|
||||
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);
|
||||
|
||||
// Switch to settings tab
|
||||
const settingsTab = screen.getByText("Settings");
|
||||
await user.click(settingsTab);
|
||||
expect(screen.getByTestId("action-settings-tab")).toBeInTheDocument();
|
||||
|
||||
// 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();
|
||||
const settingsTabProps = (props.tabs[1].children as any).props;
|
||||
expect(settingsTabProps.actionClass).toBe(mockActionClass);
|
||||
expect(settingsTabProps.actionClasses).toBe(mockActionClasses);
|
||||
expect(settingsTabProps.setOpen).toBe(mockSetOpen);
|
||||
expect(settingsTabProps.isReadOnly).toBe(false);
|
||||
});
|
||||
|
||||
test("renders correct icon based on action type", () => {
|
||||
@@ -191,68 +148,33 @@ describe("ActionDetailModal", () => {
|
||||
const noCodeAction: TActionClass = { ...mockActionClass, type: "noCode" } as TActionClass;
|
||||
render(<ActionDetailModal {...defaultProps} actionClass={noCodeAction} />);
|
||||
|
||||
expect(screen.getByTestId("nocode-icon")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("code-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
|
||||
const props = mockedModalWithTabs.mock.calls[0][0];
|
||||
|
||||
test("handles action without description", () => {
|
||||
const actionWithoutDescription = { ...mockActionClass, description: "" };
|
||||
render(<ActionDetailModal {...defaultProps} actionClass={actionWithoutDescription} />);
|
||||
// Expect the 'nocode-icon' based on the updated mock and action type
|
||||
expect(props.icon).toBeDefined();
|
||||
|
||||
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Action");
|
||||
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Code action");
|
||||
});
|
||||
if (!props.icon) {
|
||||
throw new Error("Icon prop is not defined");
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
expect((props.icon as any).props["data-testid"]).toBe("nocode-icon");
|
||||
});
|
||||
|
||||
test("passes isReadOnly prop correctly", () => {
|
||||
render(<ActionDetailModal {...defaultProps} isReadOnly={true} />);
|
||||
// Access the mocked component directly
|
||||
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
|
||||
const props = mockedModalWithTabs.mock.calls[0][0];
|
||||
|
||||
const mockedActionActivityTab = vi.mocked(ActionActivityTab);
|
||||
expect(mockedActionActivityTab).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isReadOnly: true,
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
if (!props.tabs[0].children || !props.tabs[1].children) {
|
||||
throw new Error("Tabs children are not defined");
|
||||
}
|
||||
|
||||
const activityTabProps = (props.tabs[0].children as any).props;
|
||||
expect(activityTabProps.isReadOnly).toBe(true);
|
||||
|
||||
const settingsTabProps = (props.tabs[1].children as any).props;
|
||||
expect(settingsTabProps.isReadOnly).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,16 +59,6 @@ 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 (
|
||||
<>
|
||||
<ModalWithTabs
|
||||
@@ -77,7 +67,7 @@ export const ActionDetailModal = ({
|
||||
tabs={tabs}
|
||||
icon={ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
|
||||
label={actionClass.name}
|
||||
description={typeDescription()}
|
||||
description={actionClass.description || ""}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -210,13 +210,14 @@ export const ActionSettingsTab = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-x-2 border-slate-200 pt-4">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="flex justify-between border-t border-slate-200 py-6">
|
||||
<div>
|
||||
{!isReadOnly ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => setOpenDeleteDialog(true)}
|
||||
className="mr-3"
|
||||
id="deleteActionModalTrigger">
|
||||
<TrashIcon />
|
||||
{t("common.delete")}
|
||||
|
||||
@@ -22,29 +22,14 @@ vi.mock("@/modules/ui/components/button", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: ({ children, open, onOpenChange }: any) =>
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ children, open, setOpen, ...props }: any) =>
|
||||
open ? (
|
||||
<div data-testid="dialog" role="dialog">
|
||||
<div data-testid="modal" {...props}>
|
||||
{children}
|
||||
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
|
||||
<button onClick={() => setOpen(false)}>Close Modal</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 }: { children: React.ReactNode }) => (
|
||||
<div data-testid="dialog-description">{children}</div>
|
||||
),
|
||||
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
@@ -85,21 +70,17 @@ describe("AddActionModal", () => {
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "common.add_action" })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens the dialog when the 'Add Action' button is clicked", async () => {
|
||||
test("opens the modal when the 'Add Action' button is clicked", async () => {
|
||||
render(
|
||||
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
|
||||
);
|
||||
const addButton = screen.getByRole("button", { name: "common.add_action" });
|
||||
await userEvent.click(addButton);
|
||||
|
||||
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("modal")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mouse-pointer-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.actions.track_new_user_action")).toBeInTheDocument();
|
||||
expect(
|
||||
@@ -127,35 +108,35 @@ describe("AddActionModal", () => {
|
||||
expect(props.setActionClasses).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
test("closes the dialog when the close button (simulated) is clicked", async () => {
|
||||
test("closes the modal when the close button (simulated) is clicked", async () => {
|
||||
render(
|
||||
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
|
||||
);
|
||||
const addButton = screen.getByRole("button", { name: "common.add_action" });
|
||||
await userEvent.click(addButton);
|
||||
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
|
||||
// Simulate closing via the mocked Dialog's close button
|
||||
const closeDialogButton = screen.getByText("Close Dialog");
|
||||
await userEvent.click(closeDialogButton);
|
||||
// Simulate closing via the mocked Modal's close button
|
||||
const closeModalButton = screen.getByText("Close Modal");
|
||||
await userEvent.click(closeModalButton);
|
||||
|
||||
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("closes the dialog when setOpen is called from CreateNewActionTab", async () => {
|
||||
test("closes the modal when setOpen is called from CreateNewActionTab", async () => {
|
||||
render(
|
||||
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
|
||||
);
|
||||
const addButton = screen.getByRole("button", { name: "common.add_action" });
|
||||
await userEvent.click(addButton);
|
||||
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
|
||||
// Simulate closing via the mocked CreateNewActionTab's button
|
||||
const closeFromTabButton = screen.getByText("Close from Tab");
|
||||
await userEvent.click(closeFromTabButton);
|
||||
|
||||
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,14 +2,7 @@
|
||||
|
||||
import { CreateNewActionTab } from "@/modules/survey/editor/components/create-new-action-tab";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Modal } from "@/modules/ui/components/modal";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { MousePointerClickIcon, PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
@@ -33,26 +26,36 @@ export const AddActionModal = ({ environmentId, actionClasses, isReadOnly }: Add
|
||||
{t("common.add_action")}
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent disableCloseOnOutsideClick>
|
||||
<DialogHeader>
|
||||
<MousePointerClickIcon />
|
||||
<DialogTitle>{t("environments.actions.track_new_user_action")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<CreateNewActionTab
|
||||
actionClasses={newActionClasses}
|
||||
environmentId={environmentId}
|
||||
isReadOnly={isReadOnly}
|
||||
setActionClasses={setNewActionClasses}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false} restrictOverflow>
|
||||
<div className="flex h-full flex-col rounded-lg">
|
||||
<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">
|
||||
<MousePointerClickIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-medium text-slate-700">
|
||||
{t("environments.actions.track_new_user_action")}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{t("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
<CreateNewActionTab
|
||||
actionClasses={newActionClasses}
|
||||
environmentId={environmentId}
|
||||
isReadOnly={isReadOnly}
|
||||
setActionClasses={setNewActionClasses}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -221,6 +221,7 @@ describe("MainNavigation", () => {
|
||||
vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut });
|
||||
|
||||
// Set up localStorage spy on the mocked localStorage
|
||||
const removeItemSpy = vi.spyOn(window.localStorage, "removeItem");
|
||||
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
|
||||
@@ -242,18 +243,23 @@ describe("MainNavigation", () => {
|
||||
const logoutButton = screen.getByText("common.logout");
|
||||
await userEvent.click(logoutButton);
|
||||
|
||||
// Verify localStorage.removeItem is called with the correct key
|
||||
expect(removeItemSpy).toHaveBeenCalledWith("formbricks-environment-id");
|
||||
|
||||
expect(mockSignOut).toHaveBeenCalledWith({
|
||||
reason: "user_initiated",
|
||||
redirectUrl: "/auth/login",
|
||||
organizationId: "org1",
|
||||
redirect: false,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRouterPush).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
// Clean up spy
|
||||
removeItemSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("handles organization switching", async () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[enviro
|
||||
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
|
||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { capitalizeFirstLetter } from "@/lib/utils/strings";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
@@ -390,13 +391,14 @@ export const MainNavigation = ({
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
|
||||
const route = await signOutWithAudit({
|
||||
reason: "user_initiated",
|
||||
redirectUrl: "/auth/login",
|
||||
organizationId: organization.id,
|
||||
redirect: false,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings
|
||||
}}
|
||||
|
||||
@@ -92,24 +92,14 @@ vi.mock("@/modules/ui/components/additional-integration-settings", () => ({
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: ({ children, open, onOpenChange }: any) =>
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ children, open, setOpen }) =>
|
||||
open ? (
|
||||
<div data-testid="dialog" role="dialog">
|
||||
<div data-testid="modal">
|
||||
{children}
|
||||
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
|
||||
<button onClick={() => setOpen(false)}>Close Modal</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("@/modules/ui/components/alert", () => ({
|
||||
Alert: ({ children }) => <div data-testid="alert">{children}</div>,
|
||||
|
||||
@@ -10,16 +10,8 @@ import { AdditionalIntegrationSettings } from "@/modules/ui/components/additiona
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
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 { Modal } from "@/modules/ui/components/modal";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -27,11 +19,11 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { TFnType, useTranslate } from "@tolgee/react";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Control, Controller, useForm } from "react-hook-form";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import {
|
||||
@@ -76,80 +68,6 @@ 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 = ({
|
||||
open,
|
||||
setOpenWithStates,
|
||||
@@ -292,148 +210,182 @@ export const AddIntegrationModal = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpenWithStates}>
|
||||
<DialogContent className="overflow-visible md:overflow-visible">
|
||||
<DialogHeader>
|
||||
<Modal open={open} setOpen={handleClose} noPadding>
|
||||
<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="relative size-8">
|
||||
<Image
|
||||
fill
|
||||
className="object-contain object-center"
|
||||
src={AirtableLogo}
|
||||
alt={t("environments.integrations.airtable.airtable_logo")}
|
||||
/>
|
||||
<div className="mr-1.5 h-6 w-6 text-slate-500">
|
||||
<Image className="w-12" src={AirtableLogo} alt="Airtable logo" />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<DialogTitle>{t("environments.integrations.airtable.link_airtable_table")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<div>
|
||||
<div className="text-xl font-medium text-slate-700">
|
||||
{t("environments.integrations.airtable.link_airtable_table")}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{t("environments.integrations.airtable.sync_responses_with_airtable")}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" onSubmit={handleSubmit(submitHandler)}>
|
||||
<DialogBody className="overflow-visible">
|
||||
<div className="flex w-full flex-col gap-y-4">
|
||||
{airtableArray.length ? (
|
||||
<BaseSelectDropdown
|
||||
control={control}
|
||||
isLoading={isLoading}
|
||||
fetchTable={fetchTable}
|
||||
airtableArray={airtableArray}
|
||||
setValue={setValue}
|
||||
defaultValue={defaultData?.base}
|
||||
/>
|
||||
) : (
|
||||
<NoBaseFoundError />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(submitHandler)}>
|
||||
<div className="flex rounded-lg p-6">
|
||||
<div className="flex w-full flex-col gap-y-4 pt-5">
|
||||
{airtableArray.length ? (
|
||||
<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}
|
||||
name="table"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
required
|
||||
disabled={!tables.length}
|
||||
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>
|
||||
|
||||
{surveys.length ? (
|
||||
<div className="flex w-full flex-col">
|
||||
<Label htmlFor="table">{t("environments.integrations.airtable.table_name")}</Label>
|
||||
<Label htmlFor="survey">{t("common.select_survey")}</Label>
|
||||
<div className="mt-1 flex">
|
||||
<Controller
|
||||
control={control}
|
||||
name="table"
|
||||
name="survey"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
required
|
||||
disabled={!tables.length}
|
||||
onValueChange={(val) => {
|
||||
field.onChange(val);
|
||||
setValue("questions", []);
|
||||
}}
|
||||
defaultValue={defaultData?.table}>
|
||||
defaultValue={defaultData?.survey}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
{tables.length ? (
|
||||
<SelectContent>
|
||||
{tables.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
) : null}
|
||||
<SelectContent>
|
||||
{surveys.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{surveys.length ? (
|
||||
<div className="flex w-full flex-col">
|
||||
<Label htmlFor="survey">{t("common.select_survey")}</Label>
|
||||
<div className="mt-1 flex">
|
||||
<Controller
|
||||
control={control}
|
||||
name="survey"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
required
|
||||
onValueChange={(val) => {
|
||||
field.onChange(val);
|
||||
setValue("questions", []);
|
||||
}}
|
||||
defaultValue={defaultData?.survey}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{surveys.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
{!surveys.length ? (
|
||||
<p className="m-1 text-xs text-slate-500">
|
||||
{t("environments.integrations.create_survey_warning")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{survey && selectedSurvey && (
|
||||
<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>
|
||||
) : (
|
||||
<p className="m-1 text-xs text-slate-500">
|
||||
{t("environments.integrations.create_survey_warning")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{survey &&
|
||||
selectedSurvey &&
|
||||
renderQuestionSelection({
|
||||
t,
|
||||
selectedSurvey,
|
||||
control,
|
||||
includeVariables,
|
||||
setIncludeVariables,
|
||||
includeHiddenFields,
|
||||
includeMetadata,
|
||||
setIncludeHiddenFields,
|
||||
setIncludeMetadata,
|
||||
includeCreatedAt,
|
||||
setIncludeCreatedAt,
|
||||
})}
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
{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}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<AdditionalIntegrationSettings
|
||||
includeVariables={includeVariables}
|
||||
setIncludeVariables={setIncludeVariables}
|
||||
includeHiddenFields={includeHiddenFields}
|
||||
includeMetadata={includeMetadata}
|
||||
setIncludeHiddenFields={setIncludeHiddenFields}
|
||||
setIncludeMetadata={setIncludeMetadata}
|
||||
includeCreatedAt={includeCreatedAt}
|
||||
setIncludeCreatedAt={setIncludeCreatedAt}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit">{t("common.save")}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<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}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button type="submit">{t("common.save")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -88,24 +88,9 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
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 }: 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/modal", () => ({
|
||||
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
||||
open ? <div data-testid="modal">{children}</div> : null,
|
||||
}));
|
||||
vi.mock("next/image", () => ({
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
@@ -319,9 +304,10 @@ describe("AddIntegrationModal", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Google Sheet");
|
||||
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Sync responses with Google Sheets.");
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Link Google Sheet", { selector: "div.text-xl.font-medium" })
|
||||
).toBeInTheDocument();
|
||||
// Use getByPlaceholderText for the input
|
||||
expect(
|
||||
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
|
||||
@@ -346,9 +332,10 @@ describe("AddIntegrationModal", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Google Sheet");
|
||||
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Sync responses with Google Sheets.");
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Link Google Sheet", { selector: "div.text-xl.font-medium" })
|
||||
).toBeInTheDocument();
|
||||
// Use getByPlaceholderText for the input
|
||||
expect(
|
||||
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
|
||||
|
||||
@@ -14,18 +14,10 @@ import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
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 { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { Modal } from "@/modules/ui/components/modal";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -210,28 +202,31 @@ export const AddIntegrationModal = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpenWithStates}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative size-8">
|
||||
<Image
|
||||
fill
|
||||
className="object-contain object-center"
|
||||
src={GoogleSheetLogo}
|
||||
alt={t("environments.integrations.google_sheets.google_sheet_logo")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<DialogTitle>{t("environments.integrations.google_sheets.link_google_sheet")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.integrations.google_sheets.google_sheets_integration_description")}
|
||||
</DialogDescription>
|
||||
<Modal open={open} setOpen={setOpenWithStates} noPadding closeOnOutsideClick={true}>
|
||||
<div className="flex h-full flex-col rounded-lg">
|
||||
<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">
|
||||
<Image
|
||||
className="w-12"
|
||||
src={GoogleSheetLogo}
|
||||
alt={t("environments.integrations.google_sheets.google_sheet_logo")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-medium text-slate-700">
|
||||
{t("environments.integrations.google_sheets.link_google_sheet")}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{t("environments.integrations.google_sheets.google_sheets_integration_description")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" onSubmit={handleSubmit(linkSheet)}>
|
||||
<DialogBody>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(linkSheet)}>
|
||||
<div className="flex justify-between rounded-lg p-6">
|
||||
<div className="w-full space-y-4">
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
@@ -297,37 +292,39 @@ export const AddIntegrationModal = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
{selectedIntegration ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loading={isDeleting}
|
||||
onClick={() => {
|
||||
deleteLink();
|
||||
}}>
|
||||
{t("common.delete")}
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-slate-200 p-6">
|
||||
<div className="flex space-x-2">
|
||||
{selectedIntegration ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loading={isDeleting}
|
||||
onClick={() => {
|
||||
deleteLink();
|
||||
}}>
|
||||
{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
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -74,41 +74,13 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({
|
||||
vi.mock("@/modules/ui/components/label", () => ({
|
||||
Label: ({ children }: { children: React.ReactNode }) => <label>{children}</label>,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
||||
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("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
||||
open ? <div data-testid="modal">{children}</div> : null,
|
||||
}));
|
||||
vi.mock("lucide-react", () => ({
|
||||
PlusIcon: () => <span data-testid="plus-icon">+</span>,
|
||||
TrashIcon: () => <span data-testid="trash-icon">🗑️</span>,
|
||||
XIcon: () => <span data-testid="x-icon">x</span>,
|
||||
}));
|
||||
vi.mock("next/image", () => ({
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
@@ -362,7 +334,7 @@ describe("AddIntegrationModal (Notion)", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.integrations.notion.link_database")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-select-a-database")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-select-survey")).toBeInTheDocument();
|
||||
@@ -387,7 +359,7 @@ describe("AddIntegrationModal (Notion)", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-select-a-database")).toHaveValue(databases[0].id);
|
||||
expect(screen.getByTestId("dropdown-select-survey")).toHaveValue(surveys[0].id);
|
||||
expect(screen.getByText("Map Formbricks fields to Notion property")).toBeInTheDocument();
|
||||
@@ -409,7 +381,7 @@ describe("AddIntegrationModal (Notion)", () => {
|
||||
expect(columnDropdowns[1]).toHaveValue("p2");
|
||||
|
||||
expect(screen.getAllByTestId("plus-icon").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByTestId("trash-icon").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByTestId("x-icon").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
expect(screen.getByText("Delete")).toBeInTheDocument();
|
||||
@@ -473,8 +445,8 @@ describe("AddIntegrationModal (Notion)", () => {
|
||||
|
||||
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(2);
|
||||
|
||||
const trashButton = screen.getAllByTestId("trash-icon")[0]; // Get the first trash button
|
||||
await userEvent.click(trashButton);
|
||||
const xButton = screen.getAllByTestId("x-icon")[0]; // Get the first X button
|
||||
await userEvent.click(xButton);
|
||||
|
||||
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -12,19 +12,11 @@ import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { getQuestionTypes } from "@/modules/survey/lib/questions";
|
||||
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 { Label } from "@/modules/ui/components/label";
|
||||
import { Modal } from "@/modules/ui/components/modal";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { PlusIcon, XIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -344,9 +336,9 @@ export const AddIntegrationModal = ({
|
||||
col={mapping[idx].column}
|
||||
ques={mapping[idx].question}
|
||||
/>
|
||||
<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="max-w-full flex-1">
|
||||
<div className="w-[340px] max-w-full">
|
||||
<DropdownSelector
|
||||
placeholder={t("environments.integrations.notion.select_a_survey_question")}
|
||||
items={filteredQuestionItems}
|
||||
@@ -392,7 +384,7 @@ export const AddIntegrationModal = ({
|
||||
/>
|
||||
</div>
|
||||
<div className="h-px w-4 border-t border-t-slate-300" />
|
||||
<div className="max-w-full flex-1">
|
||||
<div className="w-[340px] max-w-full">
|
||||
<DropdownSelector
|
||||
placeholder={t("environments.integrations.notion.select_a_field_to_map")}
|
||||
items={getFilteredDbItems()}
|
||||
@@ -438,45 +430,53 @@ export const AddIntegrationModal = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
{mapping.length > 1 && (
|
||||
<Button variant="secondary" size="icon" className="size-10" onClick={deleteRow}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" size="icon" className="size-10" onClick={addRow}>
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md p-1 hover:bg-slate-300 ${
|
||||
idx === mapping.length - 1 ? "visible" : "invisible"
|
||||
}`}
|
||||
onClick={addRow}>
|
||||
<PlusIcon className="h-5 w-5 font-bold text-slate-500" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<div className="mb-4 flex items-start space-x-2">
|
||||
<div className="relative size-8">
|
||||
<Image
|
||||
fill
|
||||
className="object-contain object-center"
|
||||
src={NotionLogo}
|
||||
alt={t("environments.integrations.notion.notion_logo")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<DialogTitle>{t("environments.integrations.notion.link_notion_database")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.integrations.notion.notion_integration_description")}
|
||||
</DialogDescription>
|
||||
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false} size="lg">
|
||||
<div className="flex h-full flex-col rounded-lg">
|
||||
<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">
|
||||
<Image
|
||||
className="w-12"
|
||||
src={NotionLogo}
|
||||
alt={t("environments.integrations.notion.notion_logo")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-medium text-slate-700">
|
||||
{t("environments.integrations.notion.link_notion_database")}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{t("environments.integrations.notion.sync_responses_with_a_notion_database")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(linkDatabase)} className="contents space-y-4">
|
||||
<DialogBody>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(linkDatabase)} className="w-full">
|
||||
<div className="flex justify-between rounded-lg p-6">
|
||||
<div className="w-full space-y-4">
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
@@ -521,7 +521,7 @@ export const AddIntegrationModal = ({
|
||||
<Label>
|
||||
{t("environments.integrations.notion.map_formbricks_fields_to_notion_property")}
|
||||
</Label>
|
||||
<div className="mt-1 space-y-2 overflow-y-auto">
|
||||
<div className="mt-4 max-h-[20vh] w-full overflow-y-auto">
|
||||
{mapping.map((_, idx) => (
|
||||
<MappingRow idx={idx} key={idx} />
|
||||
))}
|
||||
@@ -530,40 +530,43 @@ export const AddIntegrationModal = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
{selectedIntegration ? (
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-slate-200 p-6">
|
||||
<div className="flex space-x-2">
|
||||
{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
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loading={isDeleting}
|
||||
onClick={() => {
|
||||
deleteLink();
|
||||
}}>
|
||||
{t("common.delete")}
|
||||
type="submit"
|
||||
loading={isLinkingDatabase}
|
||||
disabled={mapping.filter((m) => m.error).length > 0}>
|
||||
{selectedIntegration
|
||||
? t("common.update")
|
||||
: t("environments.integrations.notion.link_database")}
|
||||
</Button>
|
||||
) : (
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -83,24 +83,9 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
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 }: 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/modal", () => ({
|
||||
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
||||
open ? <div data-testid="modal">{children}</div> : null,
|
||||
}));
|
||||
vi.mock("next/image", () => ({
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
@@ -136,8 +121,6 @@ vi.mock("@tolgee/react", async () => {
|
||||
if (key === "common.all_questions") return "All 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.slack_integration_description")
|
||||
return "Send responses directly to Slack.";
|
||||
if (key === "common.update") return "Update";
|
||||
if (key === "common.delete") return "Delete";
|
||||
if (key === "common.cancel") return "Cancel";
|
||||
@@ -329,9 +312,10 @@ describe("AddChannelMappingModal", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Slack Channel");
|
||||
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Send responses directly to Slack.");
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Link Slack Channel", { selector: "div.text-xl.font-medium" })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("channel-dropdown")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("survey-dropdown")).toBeInTheDocument();
|
||||
expect(screen.getByText("Cancel")).toBeInTheDocument();
|
||||
@@ -355,9 +339,10 @@ describe("AddChannelMappingModal", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Slack Channel");
|
||||
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Send responses directly to Slack.");
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Link Slack Channel", { selector: "div.text-xl.font-medium" })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("channel-dropdown")).toHaveValue(channels[0].id);
|
||||
expect(screen.getByTestId("survey-dropdown")).toHaveValue(surveys[0].id);
|
||||
expect(screen.getByText("Questions")).toBeInTheDocument();
|
||||
|
||||
@@ -7,17 +7,9 @@ import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
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 { Label } from "@/modules/ui/components/label";
|
||||
import { Modal } from "@/modules/ui/components/modal";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { CircleHelpIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
@@ -197,28 +189,24 @@ export const AddChannelMappingModal = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpenWithStates}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative size-8">
|
||||
<Image
|
||||
fill
|
||||
className="object-contain object-center"
|
||||
src={SlackLogo}
|
||||
alt={t("environments.integrations.slack.slack_logo")}
|
||||
/>
|
||||
</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>
|
||||
<Modal open={open} setOpen={setOpenWithStates} noPadding closeOnOutsideClick={true}>
|
||||
<div className="flex h-full flex-col rounded-lg">
|
||||
<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">
|
||||
<Image className="w-12" src={SlackLogo} alt="Slack logo" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-medium text-slate-700">
|
||||
{t("environments.integrations.slack.link_slack_channel")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" onSubmit={handleSubmit(linkChannel)}>
|
||||
<DialogBody>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(linkChannel)}>
|
||||
<div className="flex justify-between rounded-lg p-6">
|
||||
<div className="w-full space-y-4">
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
@@ -301,29 +289,31 @@ export const AddChannelMappingModal = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
{selectedIntegration ? (
|
||||
<Button type="button" variant="destructive" loading={isDeleting} onClick={deleteLink}>
|
||||
{t("common.delete")}
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-slate-200 p-6">
|
||||
<div className="flex space-x-2">
|
||||
{selectedIntegration ? (
|
||||
<Button type="button" variant="destructive" loading={isDeleting} onClick={deleteLink}>
|
||||
{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
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/co
|
||||
import { rateLimit } from "@/lib/utils/rate-limit";
|
||||
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
|
||||
import { sendVerificationNewEmail } from "@/modules/email";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import {
|
||||
@@ -162,21 +162,3 @@ export const removeAvatarAction = authenticatedActionClient.schema(ZRemoveAvatar
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export const resetPasswordAction = authenticatedActionClient.action(
|
||||
withAuditLogging(
|
||||
"passwordReset",
|
||||
"user",
|
||||
async ({ ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: undefined }) => {
|
||||
if (ctx.user.identityProvider !== "email") {
|
||||
throw new OperationNotAllowedError("auth.reset-password.not-allowed");
|
||||
}
|
||||
|
||||
await sendForgotPasswordEmail(ctx.user);
|
||||
|
||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { forgotPasswordAction } from "@/modules/auth/forgot-password/actions";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import toast from "react-hot-toast";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { resetPasswordAction, updateUserAction } from "../actions";
|
||||
import { updateUserAction } from "../actions";
|
||||
import { EditProfileDetailsForm } from "./EditProfileDetailsForm";
|
||||
|
||||
const mockUser = {
|
||||
@@ -37,7 +38,6 @@ beforeEach(() => {
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions", () => ({
|
||||
updateUserAction: vi.fn(),
|
||||
resetPasswordAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/forgot-password/actions", () => ({
|
||||
@@ -144,7 +144,7 @@ describe("EditProfileDetailsForm", () => {
|
||||
});
|
||||
|
||||
test("reset password button works", async () => {
|
||||
vi.mocked(resetPasswordAction).mockResolvedValue({ data: { success: true } });
|
||||
vi.mocked(forgotPasswordAction).mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<EditProfileDetailsForm
|
||||
@@ -158,9 +158,8 @@ describe("EditProfileDetailsForm", () => {
|
||||
await userEvent.click(resetButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(resetPasswordAction).toHaveBeenCalled();
|
||||
expect(forgotPasswordAction).toHaveBeenCalledWith({ email: mockUser.email });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith("auth.forgot-password.email-sent.heading");
|
||||
});
|
||||
@@ -168,7 +167,7 @@ describe("EditProfileDetailsForm", () => {
|
||||
|
||||
test("reset password button handles error correctly", async () => {
|
||||
const errorMessage = "Reset failed";
|
||||
vi.mocked(resetPasswordAction).mockResolvedValue({ serverError: errorMessage });
|
||||
vi.mocked(forgotPasswordAction).mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
render(
|
||||
<EditProfileDetailsForm
|
||||
@@ -182,16 +181,12 @@ describe("EditProfileDetailsForm", () => {
|
||||
await userEvent.click(resetButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(resetPasswordAction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(errorMessage);
|
||||
expect(forgotPasswordAction).toHaveBeenCalledWith({ email: mockUser.email });
|
||||
});
|
||||
});
|
||||
|
||||
test("reset password button shows loading state", async () => {
|
||||
vi.mocked(resetPasswordAction).mockImplementation(() => new Promise(() => {})); // Never resolves
|
||||
vi.mocked(forgotPasswordAction).mockImplementation(() => new Promise(() => {})); // Never resolves
|
||||
|
||||
render(
|
||||
<EditProfileDetailsForm
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal";
|
||||
import { appLanguages } from "@/lib/i18n/utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { forgotPasswordAction } from "@/modules/auth/forgot-password/actions";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
@@ -23,7 +24,7 @@ import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
|
||||
import { resetPasswordAction, updateUserAction } from "../actions";
|
||||
import { updateUserAction } from "../actions";
|
||||
|
||||
// Schema & types
|
||||
const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email: true }).extend({
|
||||
@@ -97,7 +98,6 @@ export const EditProfileDetailsForm = ({
|
||||
redirectUrl: "/email-change-without-verification-success",
|
||||
redirect: true,
|
||||
callbackUrl: "/email-change-without-verification-success",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -130,23 +130,19 @@ export const EditProfileDetailsForm = ({
|
||||
};
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
if (!user.email) return;
|
||||
|
||||
setIsResettingPassword(true);
|
||||
|
||||
const result = await resetPasswordAction();
|
||||
if (result?.data) {
|
||||
toast.success(t("auth.forgot-password.email-sent.heading"));
|
||||
await forgotPasswordAction({ email: user.email });
|
||||
|
||||
await signOutWithAudit({
|
||||
reason: "password_reset",
|
||||
redirectUrl: "/auth/login",
|
||||
redirect: true,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(t(errorMessage));
|
||||
}
|
||||
toast.success(t("auth.forgot-password.email-sent.heading"));
|
||||
await signOutWithAudit({
|
||||
reason: "password_reset",
|
||||
redirectUrl: "/auth/login",
|
||||
redirect: true,
|
||||
callbackUrl: "/auth/login",
|
||||
});
|
||||
|
||||
setIsResettingPassword(false);
|
||||
};
|
||||
|
||||
@@ -4,27 +4,18 @@ import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { PasswordConfirmationModal } from "./password-confirmation-modal";
|
||||
|
||||
// Mock the Dialog component
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: ({ children, open, onOpenChange }: any) =>
|
||||
// Mock the Modal component
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ children, open, setOpen, title }: any) =>
|
||||
open ? (
|
||||
<div data-testid="dialog" role="dialog">
|
||||
<div data-testid="modal">
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
{children}
|
||||
<button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
|
||||
<button data-testid="modal-close" onClick={() => setOpen(false)}>
|
||||
Close
|
||||
</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>,
|
||||
}));
|
||||
|
||||
// Mock the PasswordInput component
|
||||
@@ -63,13 +54,13 @@ describe("PasswordConfirmationModal", () => {
|
||||
|
||||
test("renders nothing when open is false", () => {
|
||||
render(<PasswordConfirmationModal {...defaultProps} open={false} />);
|
||||
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders dialog content when open is true", () => {
|
||||
test("renders modal content when open is true", () => {
|
||||
render(<PasswordConfirmationModal {...defaultProps} />);
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-title")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("modal-title")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays old and new email addresses", () => {
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
"use client";
|
||||
|
||||
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 { Modal } from "@/modules/ui/components/modal";
|
||||
import { PasswordInput } from "@/modules/ui/components/password-input";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
@@ -62,69 +54,64 @@ export const PasswordConfirmationModal = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("auth.forgot-password.reset.confirm_password")}</DialogTitle>
|
||||
<DialogDescription>{t("auth.email-change.confirm_password_description")}</DialogDescription>
|
||||
</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>
|
||||
<Modal open={open} setOpen={setOpen} title={t("auth.forgot-password.reset.confirm_password")}>
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("auth.email-change.confirm_password_description")}
|
||||
</p>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<div>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="*******"
|
||||
aria-placeholder="password"
|
||||
aria-label="password"
|
||||
aria-required="true"
|
||||
required
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
value={field.value}
|
||||
onChange={(password) => field.onChange(password)}
|
||||
/>
|
||||
{error?.message && <FormError className="text-left">{error.message}</FormError>}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={handleCancel}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting || !isDirty || oldEmail.toLowerCase() === newEmail.toLowerCase()}>
|
||||
{t("common.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<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>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<div>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="*******"
|
||||
aria-placeholder="password"
|
||||
aria-label="password"
|
||||
aria-required="true"
|
||||
required
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
value={field.value}
|
||||
onChange={(password) => field.onChange(password)}
|
||||
/>
|
||||
{error?.message && <FormError className="text-left">{error.message}</FormError>}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-x-2 text-right">
|
||||
<Button type="button" variant="secondary" onClick={handleCancel}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting || !isDirty || oldEmail.toLowerCase() === newEmail.toLowerCase()}>
|
||||
{t("common.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,26 +26,8 @@ vi.mock("@/modules/ui/components/button", () => ({
|
||||
)),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
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>),
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: vi.fn(({ children, open }) => (open ? <div data-testid="modal">{children}</div> : null)),
|
||||
}));
|
||||
|
||||
const mockResponses = [
|
||||
@@ -181,12 +163,12 @@ describe("ResponseCardModal", () => {
|
||||
test("should not render if selectedResponseId is null", () => {
|
||||
const { container } = render(<ResponseCardModal {...defaultProps} selectedResponseId={null} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("should render the dialog when a response is selected", () => {
|
||||
test("should render the modal when a response is selected", () => {
|
||||
render(<ResponseCardModal {...defaultProps} />);
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("single-response-card")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -222,6 +204,14 @@ describe("ResponseCardModal", () => {
|
||||
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", () => {
|
||||
render(<ResponseCardModal {...defaultProps} selectedResponseId={mockResponses[1].id} />);
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(true);
|
||||
@@ -239,10 +229,11 @@ describe("ResponseCardModal", () => {
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test("should render ChevronLeft and ChevronRight icons", () => {
|
||||
test("should render ChevronLeft, ChevronRight, and XIcon", () => {
|
||||
render(<ResponseCardModal {...defaultProps} />);
|
||||
expect(document.querySelector(".lucide-chevron-left")).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 { Button } from "@/modules/ui/components/button";
|
||||
import { Dialog, DialogBody, DialogContent, DialogFooter } from "@/modules/ui/components/dialog";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Modal } from "@/modules/ui/components/modal";
|
||||
import { ChevronLeft, ChevronRight, XIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
@@ -64,20 +64,42 @@ export const ResponseCardModal = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = (open: boolean) => {
|
||||
setOpen(open);
|
||||
if (!open) {
|
||||
setSelectedResponseId(null);
|
||||
}
|
||||
const handleClose = () => {
|
||||
setSelectedResponseId(null);
|
||||
};
|
||||
|
||||
// If no response is selected or currentIndex is null, do not render the modal
|
||||
if (selectedResponseId === null || currentIndex === null) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent width="wide">
|
||||
<DialogBody>
|
||||
<Modal
|
||||
hideCloseButton
|
||||
open={open}
|
||||
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
|
||||
survey={survey}
|
||||
response={responses[currentIndex]}
|
||||
@@ -91,20 +113,8 @@ export const ResponseCardModal = ({
|
||||
setSelectedResponseId={setSelectedResponseId}
|
||||
locale={locale}
|
||||
/>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
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 { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
import { IS_FORMBRICKS_CLOUD, RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||
import { RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
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 { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
@@ -35,9 +33,6 @@ const Page = async (props) => {
|
||||
|
||||
const tags = await getTagsByEnvironmentId(params.environmentId);
|
||||
|
||||
const isContactsEnabled = await getIsContactsEnabled();
|
||||
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
|
||||
|
||||
// Get response count for the CTA component
|
||||
const responseCount = await getResponseCountBySurveyId(params.surveyId);
|
||||
|
||||
@@ -56,9 +51,6 @@ const Page = async (props) => {
|
||||
user={user}
|
||||
publicDomain={publicDomain}
|
||||
responseCount={responseCount}
|
||||
segments={segments}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
/>
|
||||
}>
|
||||
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="responses" />
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
"use server";
|
||||
|
||||
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 { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { convertToCsv } from "@/lib/utils/file-conversion";
|
||||
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
|
||||
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 { sendEmbedSurveyPreviewEmail } from "@/modules/email";
|
||||
import { customAlphabet } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
|
||||
const ZSendEmbedSurveyPreviewEmailAction = z.object({
|
||||
surveyId: ZId,
|
||||
@@ -227,89 +222,3 @@ export const getEmailHtmlAction = authenticatedActionClient
|
||||
|
||||
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),
|
||||
}));
|
||||
|
||||
// Mock getSurveyUrl to return a predictable URL
|
||||
vi.mock("@/modules/analysis/utils", () => ({
|
||||
getSurveyUrl: vi.fn().mockResolvedValue("https://public-domain.com/s/survey1"),
|
||||
const mockPanelInfoViewComponent = vi.fn();
|
||||
vi.mock("./shareEmbedModal/PanelInfoView", () => ({
|
||||
PanelInfoView: (props: any) => mockPanelInfoViewComponent(props),
|
||||
}));
|
||||
|
||||
let capturedDialogOnOpenChange: ((open: boolean) => void) | undefined;
|
||||
@@ -133,6 +133,8 @@ vi.mock("@/modules/ui/components/dialog", async () => {
|
||||
capturedDialogOnOpenChange = props.onOpenChange;
|
||||
return <actual.Dialog {...props} />;
|
||||
},
|
||||
// DialogTitle, DialogContent, DialogDescription will be the actual components
|
||||
// due to ...actual spread and no specific mock for them here.
|
||||
};
|
||||
});
|
||||
|
||||
@@ -152,15 +154,13 @@ describe("ShareEmbedSurvey", () => {
|
||||
modalView: "start" as "start" | "embed" | "panel",
|
||||
setOpen: mockSetOpen,
|
||||
user: mockUser,
|
||||
segments: [],
|
||||
isContactsEnabled: true,
|
||||
isFormbricksCloud: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockEmbedViewComponent.mockImplementation(
|
||||
({ tabs, activeId, survey, email, surveyUrl, publicDomain, locale }) => (
|
||||
({ handleInitialPageButton, tabs, activeId, survey, email, surveyUrl, publicDomain, locale }) => (
|
||||
<div>
|
||||
<button onClick={() => handleInitialPageButton()}>EmbedViewMockContent</button>
|
||||
<div data-testid="embedview-tabs">{JSON.stringify(tabs)}</div>
|
||||
<div data-testid="embedview-activeid">{activeId}</div>
|
||||
<div data-testid="embedview-survey-id">{survey.id}</div>
|
||||
@@ -171,6 +171,9 @@ describe("ShareEmbedSurvey", () => {
|
||||
</div>
|
||||
)
|
||||
);
|
||||
mockPanelInfoViewComponent.mockImplementation(({ handleInitialPageButton }) => (
|
||||
<button onClick={() => handleInitialPageButton()}>PanelInfoViewMockContent</button>
|
||||
));
|
||||
});
|
||||
|
||||
test("renders initial 'start' view correctly when open and modalView is 'start' for link survey", () => {
|
||||
@@ -202,15 +205,43 @@ describe("ShareEmbedSurvey", () => {
|
||||
const embedButton = screen.getByText("environments.surveys.summary.embed_survey");
|
||||
await userEvent.click(embedButton);
|
||||
expect(mockEmbedViewComponent).toHaveBeenCalled();
|
||||
expect(screen.getByTestId("embedview-tabs")).toBeInTheDocument();
|
||||
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("switches to 'panel' view when 'Send to panel' button is clicked", async () => {
|
||||
render(<ShareEmbedSurvey {...defaultProps} />);
|
||||
const panelButton = screen.getByText("environments.surveys.summary.send_to_panel");
|
||||
await userEvent.click(panelButton);
|
||||
// Panel view currently just shows a title, no component is rendered
|
||||
expect(screen.getByText("environments.surveys.summary.send_to_panel")).toBeInTheDocument();
|
||||
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
|
||||
expect(screen.getByText("PanelInfoViewMockContent")).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)", () => {
|
||||
@@ -236,7 +267,7 @@ describe("ShareEmbedSurvey", () => {
|
||||
tabs: { id: string; label: string; icon: LucideIcon }[];
|
||||
activeId: string;
|
||||
};
|
||||
expect(embedViewProps.tabs.length).toBe(4);
|
||||
expect(embedViewProps.tabs.length).toBe(3);
|
||||
expect(embedViewProps.tabs.find((tab) => tab.id === "app")).toBeUndefined();
|
||||
expect(embedViewProps.tabs[0].id).toBe("link");
|
||||
expect(embedViewProps.activeId).toBe("link");
|
||||
@@ -266,21 +297,24 @@ describe("ShareEmbedSurvey", () => {
|
||||
test("initial showView is set by modalView prop when open is true", () => {
|
||||
render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="embed" />);
|
||||
expect(mockEmbedViewComponent).toHaveBeenCalled();
|
||||
expect(screen.getByTestId("embedview-tabs")).toBeInTheDocument();
|
||||
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument();
|
||||
cleanup();
|
||||
|
||||
render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="panel" />);
|
||||
// Panel view currently just shows a title
|
||||
expect(screen.getByText("environments.surveys.summary.send_to_panel")).toBeInTheDocument();
|
||||
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
|
||||
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("useEffect sets showView to 'start' when open becomes false", () => {
|
||||
const { rerender } = render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="embed" />);
|
||||
expect(screen.getByTestId("embedview-tabs")).toBeInTheDocument(); // Starts in embed
|
||||
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument(); // Starts in embed
|
||||
|
||||
rerender(<ShareEmbedSurvey {...defaultProps} open={false} modalView="embed" />);
|
||||
// Dialog mock returns null when open is false, so EmbedViewMockContent is not found
|
||||
expect(screen.queryByTestId("embedview-tabs")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("EmbedViewMockContent")).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", () => {
|
||||
|
||||
@@ -12,16 +12,15 @@ import {
|
||||
LinkIcon,
|
||||
MailIcon,
|
||||
SmartphoneIcon,
|
||||
UserIcon,
|
||||
UsersRound,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { EmbedView } from "./shareEmbedModal/EmbedView";
|
||||
import { PanelInfoView } from "./shareEmbedModal/PanelInfoView";
|
||||
|
||||
interface ShareEmbedSurveyProps {
|
||||
survey: TSurvey;
|
||||
@@ -30,9 +29,6 @@ interface ShareEmbedSurveyProps {
|
||||
modalView: "start" | "embed" | "panel";
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
user: TUser;
|
||||
segments: TSegment[];
|
||||
isContactsEnabled: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
}
|
||||
|
||||
export const ShareEmbedSurvey = ({
|
||||
@@ -42,9 +38,6 @@ export const ShareEmbedSurvey = ({
|
||||
modalView,
|
||||
setOpen,
|
||||
user,
|
||||
segments,
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
}: ShareEmbedSurveyProps) => {
|
||||
const router = useRouter();
|
||||
const environmentId = survey.environmentId;
|
||||
@@ -59,7 +52,6 @@ export const ShareEmbedSurvey = ({
|
||||
label: `${isSingleUseLinkSurvey ? t("environments.surveys.summary.single_use_links") : t("environments.surveys.summary.share_the_link")}`,
|
||||
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: "webpage", label: t("environments.surveys.summary.embed_on_website"), icon: Code2Icon },
|
||||
|
||||
@@ -68,8 +60,8 @@ export const ShareEmbedSurvey = ({
|
||||
[t, isSingleUseLinkSurvey, survey.type]
|
||||
);
|
||||
|
||||
const [activeId, setActiveId] = useState(survey.type === "link" ? tabs[0].id : tabs[4].id);
|
||||
const [showView, setShowView] = useState<"start" | "embed" | "panel" | "personal-links">("start");
|
||||
const [activeId, setActiveId] = useState(survey.type === "link" ? tabs[0].id : tabs[3].id);
|
||||
const [showView, setShowView] = useState<"start" | "embed" | "panel">("start");
|
||||
const [surveyUrl, setSurveyUrl] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -88,7 +80,7 @@ export const ShareEmbedSurvey = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (survey.type !== "link") {
|
||||
setActiveId(tabs[4].id);
|
||||
setActiveId(tabs[3].id);
|
||||
}
|
||||
}, [survey.type, tabs]);
|
||||
|
||||
@@ -101,7 +93,7 @@ export const ShareEmbedSurvey = ({
|
||||
}, [open, modalView]);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setActiveId(survey.type === "link" ? tabs[0].id : tabs[4].id);
|
||||
setActiveId(survey.type === "link" ? tabs[0].id : tabs[3].id);
|
||||
setOpen(open);
|
||||
if (!open) {
|
||||
setShowView("start");
|
||||
@@ -109,6 +101,10 @@ export const ShareEmbedSurvey = ({
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const handleInitialPageButton = () => {
|
||||
setShowView("start");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="w-full bg-white p-0 lg:h-[700px]" width="wide">
|
||||
@@ -170,28 +166,22 @@ export const ShareEmbedSurvey = ({
|
||||
</div>
|
||||
</div>
|
||||
) : showView === "embed" ? (
|
||||
<>
|
||||
<DialogTitle className="sr-only">{t("environments.surveys.summary.embed_survey")}</DialogTitle>
|
||||
<EmbedView
|
||||
tabs={survey.type === "link" ? tabs : [tabs[4]]}
|
||||
activeId={activeId}
|
||||
environmentId={environmentId}
|
||||
setActiveId={setActiveId}
|
||||
survey={survey}
|
||||
email={email}
|
||||
surveyUrl={surveyUrl}
|
||||
publicDomain={publicDomain}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={user.locale}
|
||||
segments={segments}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
/>
|
||||
</>
|
||||
<EmbedView
|
||||
handleInitialPageButton={handleInitialPageButton}
|
||||
tabs={survey.type === "link" ? tabs : [tabs[3]]}
|
||||
disableBack={false}
|
||||
activeId={activeId}
|
||||
environmentId={environmentId}
|
||||
setActiveId={setActiveId}
|
||||
survey={survey}
|
||||
email={email}
|
||||
surveyUrl={surveyUrl}
|
||||
publicDomain={publicDomain}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={user.locale}
|
||||
/>
|
||||
) : showView === "panel" ? (
|
||||
<>
|
||||
<DialogTitle className="sr-only">{t("environments.surveys.summary.send_to_panel")}</DialogTitle>
|
||||
</>
|
||||
<PanelInfoView handleInitialPageButton={handleInitialPageButton} disableBack={false} />
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -20,22 +20,9 @@ vi.mock("@/modules/ui/components/button", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock Dialog
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
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 Modal
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: vi.fn(({ children, open }) => (open ? <div data-testid="modal">{children}</div> : null)),
|
||||
}));
|
||||
|
||||
// Mock useTranslate
|
||||
@@ -133,7 +120,7 @@ describe("ShareSurveyResults", () => {
|
||||
|
||||
test("does not render content when modal is closed (open is false)", () => {
|
||||
render(<ShareSurveyResults {...defaultProps} open={false} />);
|
||||
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("environments.surveys.summary.publish_to_web_warning")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("environments.surveys.summary.survey_results_are_public")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Dialog, DialogBody, DialogContent } from "@/modules/ui/components/dialog";
|
||||
import { Modal } from "@/modules/ui/components/modal";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { AlertCircleIcon, CheckCircle2Icon } from "lucide-react";
|
||||
import { Clipboard } from "lucide-react";
|
||||
@@ -26,72 +26,70 @@ export const ShareSurveyResults = ({
|
||||
}: ShareEmbedSurveyProps) => {
|
||||
const { t } = useTranslate();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogBody>
|
||||
{showPublishModal && surveyUrl ? (
|
||||
<div className="flex flex-col items-center gap-y-6 text-center">
|
||||
<CheckCircle2Icon className="text-primary h-20 w-20" />
|
||||
<div>
|
||||
<p className="text-primary text-lg font-medium">
|
||||
{t("environments.surveys.summary.survey_results_are_public")}
|
||||
</p>
|
||||
<p className="text-balanced mt-2 text-sm text-slate-500">
|
||||
{t("environments.surveys.summary.survey_results_are_shared_with_anyone_who_has_the_link")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<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>
|
||||
<Modal open={open} setOpen={setOpen} size="lg">
|
||||
{showPublishModal && surveyUrl ? (
|
||||
<div className="flex flex-col rounded-2xl bg-white px-12 py-6">
|
||||
<div className="flex flex-col items-center gap-y-6 text-center">
|
||||
<CheckCircle2Icon className="h-20 w-20 text-slate-300" />
|
||||
<div>
|
||||
<p className="text-lg font-medium text-slate-600">
|
||||
{t("environments.surveys.summary.survey_results_are_public")}
|
||||
</p>
|
||||
<p className="text-balanced mt-2 text-sm text-slate-500">
|
||||
{t("environments.surveys.summary.survey_results_are_shared_with_anyone_who_has_the_link")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col rounded-2xl bg-white p-8">
|
||||
<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 className="flex gap-2">
|
||||
<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>
|
||||
)}
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<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>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col rounded-2xl bg-white p-8">
|
||||
<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,7 +15,6 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
|
||||
@@ -26,9 +25,6 @@ interface SurveyAnalysisCTAProps {
|
||||
user: TUser;
|
||||
publicDomain: string;
|
||||
responseCount: number;
|
||||
segments: TSegment[];
|
||||
isContactsEnabled: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
}
|
||||
|
||||
interface ModalState {
|
||||
@@ -45,9 +41,6 @@ export const SurveyAnalysisCTA = ({
|
||||
user,
|
||||
publicDomain,
|
||||
responseCount,
|
||||
segments,
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
}: SurveyAnalysisCTAProps) => {
|
||||
const { t } = useTranslate();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -182,9 +175,6 @@ export const SurveyAnalysisCTA = ({
|
||||
setOpen={setOpen}
|
||||
user={user}
|
||||
modalView={modalView}
|
||||
segments={segments}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
/>
|
||||
))}
|
||||
<SuccessMessage environment={environment} survey={survey} />
|
||||
|
||||
@@ -29,22 +29,6 @@ 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
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
@@ -59,21 +43,6 @@ vi.mock("lucide-react", () => ({
|
||||
LinkIcon: () => <div data-testid="link-icon">LinkIcon</div>,
|
||||
GlobeIcon: () => <div data-testid="globe-icon">GlobeIcon</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 = [
|
||||
@@ -87,6 +56,7 @@ const mockSurveyLink = { id: "survey1", type: "link" };
|
||||
const mockSurveyWeb = { id: "survey2", type: "web" };
|
||||
|
||||
const defaultProps = {
|
||||
handleInitialPageButton: vi.fn(),
|
||||
tabs: mockTabs,
|
||||
activeId: "email",
|
||||
setActiveId: vi.fn(),
|
||||
@@ -97,9 +67,7 @@ const defaultProps = {
|
||||
publicDomain: "http://example.com",
|
||||
setSurveyUrl: vi.fn(),
|
||||
locale: "en" as any,
|
||||
segments: [],
|
||||
isContactsEnabled: true,
|
||||
isFormbricksCloud: false,
|
||||
disableBack: false,
|
||||
};
|
||||
|
||||
describe("EmbedView", () => {
|
||||
@@ -108,6 +76,11 @@ describe("EmbedView", () => {
|
||||
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", () => {
|
||||
render(<EmbedView {...defaultProps} survey={mockSurveyWeb} />);
|
||||
// Desktop tabs container should not be present or not have lg:flex if it's a common parent
|
||||
|
||||
@@ -2,32 +2,33 @@
|
||||
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ArrowLeftIcon } from "lucide-react";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { AppTab } from "./AppTab";
|
||||
import { EmailTab } from "./EmailTab";
|
||||
import { LinkTab } from "./LinkTab";
|
||||
import { WebsiteTab } from "./WebsiteTab";
|
||||
import { PersonalLinksTab } from "./personal-links-tab";
|
||||
|
||||
interface EmbedViewProps {
|
||||
handleInitialPageButton: () => void;
|
||||
tabs: Array<{ id: string; label: string; icon: any }>;
|
||||
activeId: string;
|
||||
setActiveId: React.Dispatch<React.SetStateAction<string>>;
|
||||
environmentId: string;
|
||||
disableBack: boolean;
|
||||
survey: any;
|
||||
email: string;
|
||||
surveyUrl: string;
|
||||
publicDomain: string;
|
||||
setSurveyUrl: React.Dispatch<React.SetStateAction<string>>;
|
||||
locale: TUserLocale;
|
||||
segments: TSegment[];
|
||||
isContactsEnabled: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
}
|
||||
|
||||
export const EmbedView = ({
|
||||
handleInitialPageButton,
|
||||
tabs,
|
||||
disableBack,
|
||||
activeId,
|
||||
setActiveId,
|
||||
environmentId,
|
||||
@@ -37,45 +38,18 @@ export const EmbedView = ({
|
||||
publicDomain,
|
||||
setSurveyUrl,
|
||||
locale,
|
||||
segments,
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
}: EmbedViewProps) => {
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
const { t } = useTranslate();
|
||||
return (
|
||||
<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">
|
||||
{survey.type === "link" && (
|
||||
<div className={cn("col-span-1 hidden flex-col gap-3 border-r border-slate-200 p-4 lg:flex")}>
|
||||
@@ -101,7 +75,21 @@ export const EmbedView = ({
|
||||
)}
|
||||
<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`}>
|
||||
{renderActiveTab()}
|
||||
{activeId === "email" ? (
|
||||
<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">
|
||||
{tabs.slice(0, 2).map((tab) => (
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
"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>
|
||||
);
|
||||
};
|
||||
@@ -1,519 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,231 +0,0 @@
|
||||
"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,
|
||||
cardBackgroundColor: { light: "#FFFFFF", dark: "#000000" },
|
||||
cardBorderColor: { light: "#FFFFFF", dark: "#000000" },
|
||||
|
||||
cardShadowColor: { light: "#FFFFFF", dark: "#000000" },
|
||||
questionColor: { light: "#FFFFFF", dark: "#000000" },
|
||||
inputColor: { light: "#FFFFFF", dark: "#000000" },
|
||||
inputBorderColor: { light: "#FFFFFF", dark: "#000000" },
|
||||
@@ -123,7 +123,7 @@ const mockComputedStyling = {
|
||||
inputBorderColor: "#000000",
|
||||
cardBackgroundColor: "#FFFFFF",
|
||||
cardBorderColor: "#EEEEEE",
|
||||
|
||||
cardShadowColor: "#AAAAAA",
|
||||
highlightBorderColor: null,
|
||||
thankYouCardIconColor: "#007BFF",
|
||||
thankYouCardIconBgColor: "#DDDDDD",
|
||||
|
||||
@@ -2,12 +2,10 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
|
||||
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 { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
|
||||
import { DEFAULT_LOCALE, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { DEFAULT_LOCALE } from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getSurvey } from "@/lib/survey/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 { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
@@ -38,8 +36,6 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
if (!user) {
|
||||
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
|
||||
const initialSurveySummary = await getSurveySummary(surveyId);
|
||||
@@ -58,9 +54,6 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
user={user}
|
||||
publicDomain={publicDomain}
|
||||
responseCount={initialSurveySummary?.meta.totalResponses ?? 0}
|
||||
segments={segments}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
/>
|
||||
}>
|
||||
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="summary" />
|
||||
|
||||
@@ -31,8 +31,6 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SENTRY_RELEASE: "mock-sentry-release",
|
||||
SENTRY_ENVIRONMENT: "mock-sentry-environment",
|
||||
}));
|
||||
|
||||
vi.mock("@/tolgee/language", () => ({
|
||||
@@ -61,18 +59,9 @@ vi.mock("@/tolgee/client", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/app/sentry/SentryProvider", () => ({
|
||||
SentryProvider: ({
|
||||
children,
|
||||
sentryDsn,
|
||||
sentryRelease,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
sentryDsn?: string;
|
||||
sentryRelease?: string;
|
||||
}) => (
|
||||
SentryProvider: ({ children, sentryDsn }: { children: React.ReactNode; sentryDsn?: string }) => (
|
||||
<div data-testid="sentry-provider">
|
||||
SentryProvider: {sentryDsn}
|
||||
{sentryRelease && ` - Release: ${sentryRelease}`}
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SentryProvider } from "@/app/sentry/SentryProvider";
|
||||
import { IS_PRODUCTION, SENTRY_DSN, SENTRY_ENVIRONMENT, SENTRY_RELEASE } from "@/lib/constants";
|
||||
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
|
||||
import { TolgeeNextProvider } from "@/tolgee/client";
|
||||
import { getLocale } from "@/tolgee/language";
|
||||
import { getTolgee } from "@/tolgee/server";
|
||||
@@ -25,11 +25,7 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<html lang={locale} translate="no">
|
||||
<body className="flex h-dvh flex-col transition-all ease-in-out">
|
||||
<SentryProvider
|
||||
sentryDsn={SENTRY_DSN}
|
||||
sentryRelease={SENTRY_RELEASE}
|
||||
sentryEnvironment={SENTRY_ENVIRONMENT}
|
||||
isEnabled={IS_PRODUCTION}>
|
||||
<SentryProvider sentryDsn={SENTRY_DSN} isEnabled={IS_PRODUCTION}>
|
||||
<TolgeeNextProvider language={locale} staticData={staticData as unknown as TolgeeStaticData}>
|
||||
{children}
|
||||
</TolgeeNextProvider>
|
||||
|
||||
@@ -48,24 +48,6 @@ 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", () => {
|
||||
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
|
||||
|
||||
|
||||
@@ -6,24 +6,14 @@ import { useEffect } from "react";
|
||||
interface SentryProviderProps {
|
||||
children: React.ReactNode;
|
||||
sentryDsn?: string;
|
||||
sentryRelease?: string;
|
||||
sentryEnvironment?: string;
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
|
||||
export const SentryProvider = ({
|
||||
children,
|
||||
sentryDsn,
|
||||
sentryRelease,
|
||||
sentryEnvironment,
|
||||
isEnabled,
|
||||
}: SentryProviderProps) => {
|
||||
export const SentryProvider = ({ children, sentryDsn, isEnabled }: SentryProviderProps) => {
|
||||
useEffect(() => {
|
||||
if (sentryDsn && isEnabled) {
|
||||
Sentry.init({
|
||||
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
|
||||
tracesSampleRate: 0,
|
||||
|
||||
@@ -233,8 +233,8 @@ export enum STRIPE_PROJECT_NAMES {
|
||||
}
|
||||
|
||||
export enum STRIPE_PRICE_LOOKUP_KEYS {
|
||||
STARTUP_MAY25_MONTHLY = "STARTUP_MAY25_MONTHLY",
|
||||
STARTUP_MAY25_YEARLY = "STARTUP_MAY25_YEARLY",
|
||||
STARTUP_MONTHLY = "formbricks_startup_monthly",
|
||||
STARTUP_YEARLY = "formbricks_startup_yearly",
|
||||
SCALE_MONTHLY = "formbricks_scale_monthly",
|
||||
SCALE_YEARLY = "formbricks_scale_yearly",
|
||||
}
|
||||
@@ -273,24 +273,6 @@ export const RECAPTCHA_SITE_KEY = env.RECAPTCHA_SITE_KEY;
|
||||
export const RECAPTCHA_SECRET_KEY = env.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 PROMETHEUS_ENABLED = env.PROMETHEUS_ENABLED === "1";
|
||||
|
||||
@@ -127,7 +127,6 @@ export const env = createEnv({
|
||||
.string()
|
||||
.transform((val) => parseInt(val))
|
||||
.optional(),
|
||||
SENTRY_ENVIRONMENT: z.string().optional(),
|
||||
},
|
||||
|
||||
/*
|
||||
@@ -226,6 +225,5 @@ export const env = createEnv({
|
||||
AUDIT_LOG_ENABLED: process.env.AUDIT_LOG_ENABLED,
|
||||
AUDIT_LOG_GET_USER_IP: process.env.AUDIT_LOG_GET_USER_IP,
|
||||
SESSION_MAX_AGE: process.env.SESSION_MAX_AGE,
|
||||
SENTRY_ENVIRONMENT: process.env.SENTRY_ENVIRONMENT,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ export const COLOR_DEFAULTS = {
|
||||
inputBorderColor: "#cbd5e1",
|
||||
cardBackgroundColor: "#ffffff",
|
||||
cardBorderColor: "#f8fafc",
|
||||
cardShadowColor: "#000000",
|
||||
highlightBorderColor: "#64748b",
|
||||
} as const;
|
||||
|
||||
@@ -31,6 +32,9 @@ export const defaultStyling: TProjectStyling = {
|
||||
cardBorderColor: {
|
||||
light: COLOR_DEFAULTS.cardBorderColor,
|
||||
},
|
||||
cardShadowColor: {
|
||||
light: COLOR_DEFAULTS.cardShadowColor,
|
||||
},
|
||||
isLogoHidden: false,
|
||||
highlightBorderColor: undefined,
|
||||
isDarkModeEnabled: false,
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { TagError } from "@/modules/projects/settings/types/tag";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { createTag, getTag, getTagsByEnvironmentId } from "./service";
|
||||
|
||||
@@ -113,7 +110,7 @@ describe("Tag Service", () => {
|
||||
vi.mocked(prisma.tag.create).mockResolvedValue(mockTag);
|
||||
|
||||
const result = await createTag("env1", "New Tag");
|
||||
expect(result).toEqual({ ok: true, data: mockTag });
|
||||
expect(result).toEqual(mockTag);
|
||||
expect(prisma.tag.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
name: "New Tag",
|
||||
@@ -121,30 +118,5 @@ 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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import "server-only";
|
||||
import { TagError } from "@/modules/projects/settings/types/tag";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
@@ -46,10 +42,7 @@ export const getTag = reactCache(async (id: string): Promise<TTag | null> => {
|
||||
}
|
||||
});
|
||||
|
||||
export const createTag = async (
|
||||
environmentId: string,
|
||||
name: string
|
||||
): Promise<Result<TTag, { code: TagError; message: string; meta?: Record<string, string> }>> => {
|
||||
export const createTag = async (environmentId: string, name: string): Promise<TTag> => {
|
||||
validateInputs([environmentId, ZId], [name, ZString]);
|
||||
|
||||
try {
|
||||
@@ -60,19 +53,8 @@ export const createTag = async (
|
||||
},
|
||||
});
|
||||
|
||||
return ok(tag);
|
||||
return tag;
|
||||
} catch (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,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -108,10 +108,6 @@
|
||||
"thanks_for_upgrading": "Vielen Dank, dass Du dein Formbricks-Abonnement aktualisiert hast.",
|
||||
"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": {
|
||||
"accepted": "Akzeptiert",
|
||||
"account": "Konto",
|
||||
@@ -317,11 +313,9 @@
|
||||
"question_id": "Frage-ID",
|
||||
"questions": "Fragen",
|
||||
"read_docs": "Dokumentation lesen",
|
||||
"recipients": "Empfänger",
|
||||
"remove": "Entfernen",
|
||||
"reorder_and_hide_columns": "Spalten neu anordnen und ausblenden",
|
||||
"report_survey": "Umfrage melden",
|
||||
"request_pricing": "Preise anfragen",
|
||||
"request_trial_license": "Testlizenz anfordern",
|
||||
"reset_to_default": "Auf Standard zurücksetzen",
|
||||
"response": "Antwort",
|
||||
@@ -602,7 +596,6 @@
|
||||
"contact_not_found": "Kein solcher Kontakt gefunden",
|
||||
"contacts_table_refresh": "Kontakte aktualisieren",
|
||||
"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",
|
||||
"last_name": "Nachname",
|
||||
"no_responses_found": "Keine Antworten gefunden",
|
||||
@@ -639,7 +632,6 @@
|
||||
"airtable_integration": "Airtable Integration",
|
||||
"airtable_integration_description": "Synchronisiere Antworten direkt mit Airtable.",
|
||||
"airtable_integration_is_not_configured": "Airtable Integration ist nicht konfiguriert",
|
||||
"airtable_logo": "Airtable-Logo",
|
||||
"connect_with_airtable": "Mit Airtable verbinden",
|
||||
"link_airtable_table": "Airtable Tabelle verknüpfen",
|
||||
"link_new_table": "Neue Tabelle verknüpfen",
|
||||
@@ -729,7 +721,6 @@
|
||||
"slack_integration": "Slack Integration",
|
||||
"slack_integration_description": "Sende Antworten direkt an Slack.",
|
||||
"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_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."
|
||||
},
|
||||
@@ -914,7 +905,8 @@
|
||||
"tag_already_exists": "Tag existiert bereits",
|
||||
"tag_deleted": "Tag gelöscht",
|
||||
"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": {
|
||||
"manage_teams": "Teams verwalten",
|
||||
@@ -987,53 +979,63 @@
|
||||
"api_keys_description": "Verwalte API-Schlüssel, um auf die Formbricks-Management-APIs zuzugreifen"
|
||||
},
|
||||
"billing": {
|
||||
"1000_monthly_responses": "1,000 monatliche Antworten",
|
||||
"1_project": "1 Projekt",
|
||||
"2000_contacts": "2,000 Kontakte",
|
||||
"10000_monthly_responses": "10,000 monatliche Antworten",
|
||||
"1500_monthly_responses": "1,500 monatliche Antworten",
|
||||
"2000_monthly_identified_users": "2,000 monatlich identifizierte Nutzer",
|
||||
"30000_monthly_identified_users": "30,000 monatlich identifizierte Nutzer",
|
||||
"3_projects": "3 Projekte",
|
||||
"5000_monthly_responses": "5,000 monatliche Antworten",
|
||||
"7500_contacts": "7,500 Kontakte",
|
||||
"5_projects": "5 Projekte",
|
||||
"7500_monthly_identified_users": "7,500 monatlich identifizierte Nutzer",
|
||||
"advanced_targeting": "Erweitertes Targeting",
|
||||
"all_integrations": "Alle Integrationen",
|
||||
"all_surveying_features": "Alle Umfragefunktionen",
|
||||
"annually": "Jährlich",
|
||||
"api_webhooks": "API & Webhooks",
|
||||
"app_surveys": "In-app Umfragen",
|
||||
"attribute_based_targeting": "Attributbasiertes Targeting",
|
||||
"contact_us": "Kontaktiere uns",
|
||||
"current": "aktuell",
|
||||
"current_plan": "Aktueller Plan",
|
||||
"current_tier_limit": "Aktuelles Limit",
|
||||
"custom": "Benutzerdefiniert & Skalierung",
|
||||
"custom_contacts_limit": "Benutzerdefiniertes Kontaktlimit",
|
||||
"custom_miu_limit": "Benutzerdefiniertes MIU-Limit",
|
||||
"custom_project_limit": "Benutzerdefiniertes Projektlimit",
|
||||
"custom_response_limit": "Benutzerdefiniertes Antwortlimit",
|
||||
"customer_success_manager": "Customer Success Manager",
|
||||
"email_embedded_surveys": "Eingebettete Umfragen in E-Mails",
|
||||
"email_follow_ups": "E-Mail Follow-ups",
|
||||
"email_support": "E-Mail-Support",
|
||||
"enterprise": "Enterprise",
|
||||
"enterprise_description": "Premium-Support und benutzerdefinierte Limits.",
|
||||
"everybody_has_the_free_plan_by_default": "Jeder hat standardmäßig den kostenlosen Plan!",
|
||||
"everything_in_free": "Alles in 'Free''",
|
||||
"everything_in_scale": "Alles in 'Scale''",
|
||||
"everything_in_startup": "Alles in 'Startup''",
|
||||
"free": "Kostenlos",
|
||||
"free_description": "Unbegrenzte Umfragen, Teammitglieder und mehr.",
|
||||
"get_2_months_free": "2 Monate gratis",
|
||||
"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)",
|
||||
"logic_jumps_hidden_fields_recurring_surveys": "Logik, versteckte Felder, wiederkehrende Umfragen, usw.",
|
||||
"manage_card_details": "Karteninformationen verwalten",
|
||||
"manage_subscription": "Abonnement verwalten",
|
||||
"monthly": "Monatlich",
|
||||
"monthly_identified_users": "Monatlich identifizierte Nutzer",
|
||||
"multi_language_surveys": "Mehrsprachige Umfragen",
|
||||
"per_month": "pro Monat",
|
||||
"per_year": "pro Jahr",
|
||||
"plan_upgraded_successfully": "Plan erfolgreich aktualisiert",
|
||||
"premium_support_with_slas": "Premium-Support mit SLAs",
|
||||
"priority_support": "Priorisierter Support",
|
||||
"remove_branding": "Branding entfernen",
|
||||
"say_hi": "Sag Hi!",
|
||||
"scale": "Scale",
|
||||
"scale_description": "Erweiterte Funktionen für größere Unternehmen.",
|
||||
"startup": "Start-up",
|
||||
"startup_description": "Alles in 'Free' mit zusätzlichen Funktionen.",
|
||||
"switch_plan": "Plan wechseln",
|
||||
"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",
|
||||
"technical_onboarding": "Technische Einführung",
|
||||
"unable_to_upgrade_plan": "Plan kann nicht aktualisiert werden",
|
||||
"unlimited_apps_websites": "Unbegrenzte Apps & Websites",
|
||||
"unlimited_miu": "Unbegrenzte MIU",
|
||||
"unlimited_projects": "Unbegrenzte Projekte",
|
||||
"unlimited_responses": "Unbegrenzte Antworten",
|
||||
@@ -1072,7 +1074,6 @@
|
||||
"create_new_organization": "Neue Organisation erstellen",
|
||||
"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",
|
||||
"delete_member_confirmation": "Gelöschte Mitglieder verlieren den Zugriff auf alle Projekte und Umfragen deiner Organisation.",
|
||||
"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_warning": "Bevor Du mit dem Löschen dieser Organisation fortfährst, sei dir bitte der folgenden Konsequenzen bewusst:",
|
||||
@@ -1229,9 +1230,8 @@
|
||||
"copy_survey_description": "Kopiere diese Umfrage in eine andere Umgebung",
|
||||
"copy_survey_error": "Kopieren der Umfrage fehlgeschlagen",
|
||||
"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!",
|
||||
"delete_survey_and_responses_warning": "Bist Du sicher, dass Du diese Umfrage und alle ihre Antworten löschen möchtest?",
|
||||
"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.",
|
||||
"edit": {
|
||||
"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:",
|
||||
@@ -1304,6 +1304,7 @@
|
||||
"card_arrangement_for_survey_type_derived": "Kartenanordnung für {surveyTypeDerived} Umfragen",
|
||||
"card_background_color": "Hintergrundfarbe der Karte",
|
||||
"card_border_color": "Farbe des Kartenrandes",
|
||||
"card_shadow_color": "Farbton des Kartenschattens",
|
||||
"card_styling": "Kartenstil",
|
||||
"casual": "Lässig",
|
||||
"caution_edit_duplicate": "Duplizieren & bearbeiten",
|
||||
@@ -1328,6 +1329,7 @@
|
||||
"change_the_brand_color_of_the_survey": "Markenfarbe der 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_shadow_color_of_the_card": "Schattenfarbe der Karte ändern.",
|
||||
"changes_saved": "Änderungen gespeichert.",
|
||||
"character_limit_toggle_description": "Begrenzen Sie, wie kurz oder lang eine Antwort sein kann.",
|
||||
"character_limit_toggle_title": "Fügen Sie Zeichenbeschränkungen hinzu",
|
||||
@@ -1714,7 +1716,6 @@
|
||||
"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.",
|
||||
"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_description": "Akzeptiere nur eine Antwort pro Link. So geht's.",
|
||||
"custom_range": "Benutzerdefinierter Bereich...",
|
||||
@@ -1733,19 +1734,12 @@
|
||||
"embed_on_website": "Auf Website einbetten",
|
||||
"embed_pop_up_survey_title": "Wie man eine Pop-up-Umfrage auf seiner Website einbindet",
|
||||
"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",
|
||||
"filter_added_successfully": "Filter erfolgreich hinzugefügt",
|
||||
"filter_updated_successfully": "Filter erfolgreich aktualisiert",
|
||||
"filtered_responses_csv": "Gefilterte Antworten (CSV)",
|
||||
"filtered_responses_excel": "Gefilterte Antworten (Excel)",
|
||||
"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",
|
||||
"hide_embed_code": "Einbettungscode ausblenden",
|
||||
"how_to_create_a_panel": "Wie man ein Panel erstellt",
|
||||
@@ -1771,18 +1765,12 @@
|
||||
"last_quarter": "Letztes Quartal",
|
||||
"last_year": "Letztes Jahr",
|
||||
"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",
|
||||
"mobile_app": "Mobile App",
|
||||
"no_responses_found": "Keine Antworten gefunden",
|
||||
"no_segments_available": "Keine Segmente verfügbar",
|
||||
"only_completed": "Nur vollständige Antworten",
|
||||
"other_values_found": "Andere Werte gefunden",
|
||||
"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_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.",
|
||||
@@ -1791,7 +1779,6 @@
|
||||
"quickstart_web_apps": "Schnellstart: Web-Apps",
|
||||
"quickstart_web_apps_description": "Bitte folge der Schnellstartanleitung, um loszulegen:",
|
||||
"results_are_public": "Ergebnisse sind öffentlich",
|
||||
"select_segment": "Segment auswählen",
|
||||
"selected_responses_csv": "Ausgewählte Antworten (CSV)",
|
||||
"selected_responses_excel": "Ausgewählte Antworten (Excel)",
|
||||
"send_preview": "Vorschau senden",
|
||||
@@ -1817,7 +1804,6 @@
|
||||
"this_year": "Dieses Jahr",
|
||||
"time_to_complete": "Zeit zur Fertigstellung",
|
||||
"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.",
|
||||
"unknown_question_type": "Unbekannter Fragetyp",
|
||||
"unpublish_from_web": "Aus dem Web entfernen",
|
||||
@@ -2841,4 +2827,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_placeholder": "Tippe deine Antwort hier..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,10 +108,6 @@
|
||||
"thanks_for_upgrading": "Thanks a lot for upgrading your Formbricks subscription.",
|
||||
"upgrade_successful": "Upgrade successful"
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "Your link is expired.",
|
||||
"link_expired_description": "The link you used is no longer valid."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Accepted",
|
||||
"account": "Account",
|
||||
@@ -317,11 +313,9 @@
|
||||
"question_id": "Question ID",
|
||||
"questions": "Questions",
|
||||
"read_docs": "Read Docs",
|
||||
"recipients": "Recipients",
|
||||
"remove": "Remove",
|
||||
"reorder_and_hide_columns": "Reorder and hide columns",
|
||||
"report_survey": "Report Survey",
|
||||
"request_pricing": "Request Pricing",
|
||||
"request_trial_license": "Request trial license",
|
||||
"reset_to_default": "Reset to default",
|
||||
"response": "Response",
|
||||
@@ -602,7 +596,6 @@
|
||||
"contact_not_found": "No such contact found",
|
||||
"contacts_table_refresh": "Refresh contacts",
|
||||
"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",
|
||||
"last_name": "Last Name",
|
||||
"no_responses_found": "No responses found",
|
||||
@@ -639,7 +632,6 @@
|
||||
"airtable_integration": "Airtable Integration",
|
||||
"airtable_integration_description": "Sync responses directly with Airtable.",
|
||||
"airtable_integration_is_not_configured": "Airtable Integration is not configured",
|
||||
"airtable_logo": "Airtable logo",
|
||||
"connect_with_airtable": "Connect with Airtable",
|
||||
"link_airtable_table": "Link Airtable Table",
|
||||
"link_new_table": "Link new table",
|
||||
@@ -729,7 +721,6 @@
|
||||
"slack_integration": "Slack Integration",
|
||||
"slack_integration_description": "Send responses directly to Slack.",
|
||||
"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_description": "<b>Note:</b> We recently changed our Slack integration to also support private channels. Please reconnect your Slack workspace."
|
||||
},
|
||||
@@ -914,7 +905,8 @@
|
||||
"tag_already_exists": "Tag already exists",
|
||||
"tag_deleted": "Tag deleted",
|
||||
"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": {
|
||||
"manage_teams": "Manage teams",
|
||||
@@ -987,53 +979,63 @@
|
||||
"api_keys_description": "Manage API keys to access Formbricks management APIs"
|
||||
},
|
||||
"billing": {
|
||||
"1000_monthly_responses": "Monthly 1,000 Responses",
|
||||
"1_project": "1 Project",
|
||||
"2000_contacts": "2,000 Contacts",
|
||||
"10000_monthly_responses": "10000 Monthly Responses",
|
||||
"1500_monthly_responses": "1500 Monthly Responses",
|
||||
"2000_monthly_identified_users": "2000 Monthly Identified Users",
|
||||
"30000_monthly_identified_users": "30000 Monthly Identified Users",
|
||||
"3_projects": "3 Projects",
|
||||
"5000_monthly_responses": "5,000 Monthly Responses",
|
||||
"7500_contacts": "7,500 Contacts",
|
||||
"5_projects": "5 Projects",
|
||||
"7500_monthly_identified_users": "7500 Monthly Identified Users",
|
||||
"advanced_targeting": "Advanced Targeting",
|
||||
"all_integrations": "All Integrations",
|
||||
"all_surveying_features": "All surveying features",
|
||||
"annually": "Annually",
|
||||
"api_webhooks": "API & Webhooks",
|
||||
"app_surveys": "App Surveys",
|
||||
"attribute_based_targeting": "Attribute-based Targeting",
|
||||
"contact_us": "Contact Us",
|
||||
"current": "Current",
|
||||
"current_plan": "Current Plan",
|
||||
"current_tier_limit": "Current Tier Limit",
|
||||
"custom": "Custom & Scale",
|
||||
"custom_contacts_limit": "Custom Contacts Limit",
|
||||
"custom_miu_limit": "Custom MIU limit",
|
||||
"custom_project_limit": "Custom Project Limit",
|
||||
"custom_response_limit": "Custom Response Limit",
|
||||
"customer_success_manager": "Customer Success Manager",
|
||||
"email_embedded_surveys": "Email Embedded Surveys",
|
||||
"email_follow_ups": "Email Follow-ups",
|
||||
"email_support": "Email Support",
|
||||
"enterprise": "Enterprise",
|
||||
"enterprise_description": "Premium support and custom limits.",
|
||||
"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 Scale",
|
||||
"everything_in_startup": "Everything in Startup",
|
||||
"free": "Free",
|
||||
"free_description": "Unlimited Surveys, Team Members, and more.",
|
||||
"get_2_months_free": "Get 2 months free",
|
||||
"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)",
|
||||
"logic_jumps_hidden_fields_recurring_surveys": "Logic Jumps, Hidden Fields, Recurring Surveys, etc.",
|
||||
"manage_card_details": "Manage Card Details",
|
||||
"manage_subscription": "Manage Subscription",
|
||||
"monthly": "Monthly",
|
||||
"monthly_identified_users": "Monthly Identified Users",
|
||||
"multi_language_surveys": "Multi-Language Surveys",
|
||||
"per_month": "per month",
|
||||
"per_year": "per year",
|
||||
"plan_upgraded_successfully": "Plan upgraded successfully",
|
||||
"premium_support_with_slas": "Premium support with SLAs",
|
||||
"priority_support": "Priority Support",
|
||||
"remove_branding": "Remove Branding",
|
||||
"say_hi": "Say Hi!",
|
||||
"scale": "Scale",
|
||||
"scale_description": "Advanced features for scaling your business.",
|
||||
"startup": "Startup",
|
||||
"startup_description": "Everything in Free with additional features.",
|
||||
"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}.",
|
||||
"team_access_roles": "Team Access Roles",
|
||||
"technical_onboarding": "Technical Onboarding",
|
||||
"unable_to_upgrade_plan": "Unable to upgrade plan",
|
||||
"unlimited_apps_websites": "Unlimited Apps & Websites",
|
||||
"unlimited_miu": "Unlimited MIU",
|
||||
"unlimited_projects": "Unlimited Projects",
|
||||
"unlimited_responses": "Unlimited Responses",
|
||||
@@ -1072,7 +1074,6 @@
|
||||
"create_new_organization": "Create new organization",
|
||||
"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",
|
||||
"delete_member_confirmation": "Deleted members will lose access to all projects and surveys of your organization.",
|
||||
"delete_organization": "Delete Organization",
|
||||
"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:",
|
||||
@@ -1229,9 +1230,8 @@
|
||||
"copy_survey_description": "Copy this survey to another environment",
|
||||
"copy_survey_error": "Failed to copy survey",
|
||||
"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!",
|
||||
"delete_survey_and_responses_warning": "Are you sure you want to delete this survey and all of its responses?",
|
||||
"delete_survey_and_responses_warning": "Are you sure you want to delete this survey and all of its responses? This action cannot be undone.",
|
||||
"edit": {
|
||||
"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:",
|
||||
@@ -1304,6 +1304,7 @@
|
||||
"card_arrangement_for_survey_type_derived": "Card Arrangement for {surveyTypeDerived} Surveys",
|
||||
"card_background_color": "Card background color",
|
||||
"card_border_color": "Card border color",
|
||||
"card_shadow_color": "Card shadow color",
|
||||
"card_styling": "Card Styling",
|
||||
"casual": "Casual",
|
||||
"caution_edit_duplicate": "Duplicate & edit",
|
||||
@@ -1328,6 +1329,7 @@
|
||||
"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": "Change the shadow color of the card.",
|
||||
"changes_saved": "Changes saved.",
|
||||
"character_limit_toggle_description": "Limit how short or long an answer can be.",
|
||||
"character_limit_toggle_title": "Add character limits",
|
||||
@@ -1714,7 +1716,6 @@
|
||||
"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.",
|
||||
"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_description": "Accept only one submission per link. Here is how.",
|
||||
"custom_range": "Custom range...",
|
||||
@@ -1733,19 +1734,12 @@
|
||||
"embed_on_website": "Embed on website",
|
||||
"embed_pop_up_survey_title": "How to embed a pop-up survey on your website",
|
||||
"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",
|
||||
"filter_added_successfully": "Filter added successfully",
|
||||
"filter_updated_successfully": "Filter updated successfully",
|
||||
"filtered_responses_csv": "Filtered responses (CSV)",
|
||||
"filtered_responses_excel": "Filtered responses (Excel)",
|
||||
"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",
|
||||
"hide_embed_code": "Hide embed code",
|
||||
"how_to_create_a_panel": "How to create a panel",
|
||||
@@ -1771,18 +1765,12 @@
|
||||
"last_quarter": "Last quarter",
|
||||
"last_year": "Last year",
|
||||
"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",
|
||||
"mobile_app": "Mobile app",
|
||||
"no_responses_found": "No responses found",
|
||||
"no_segments_available": "No segments available",
|
||||
"only_completed": "Only completed",
|
||||
"other_values_found": "Other values found",
|
||||
"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_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.",
|
||||
@@ -1791,7 +1779,6 @@
|
||||
"quickstart_web_apps": "Quickstart: Web apps",
|
||||
"quickstart_web_apps_description": "Please follow the Quickstart guide to get started:",
|
||||
"results_are_public": "Results are public",
|
||||
"select_segment": "Select segment",
|
||||
"selected_responses_csv": "Selected responses (CSV)",
|
||||
"selected_responses_excel": "Selected responses (Excel)",
|
||||
"send_preview": "Send preview",
|
||||
@@ -1817,7 +1804,6 @@
|
||||
"this_year": "This year",
|
||||
"time_to_complete": "Time to Complete",
|
||||
"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.",
|
||||
"unknown_question_type": "Unknown Question Type",
|
||||
"unpublish_from_web": "Unpublish from web",
|
||||
@@ -2841,4 +2827,4 @@
|
||||
"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..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,10 +108,6 @@
|
||||
"thanks_for_upgrading": "Merci beaucoup d'avoir mis à niveau votre abonnement Formbricks.",
|
||||
"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": {
|
||||
"accepted": "Accepté",
|
||||
"account": "Compte",
|
||||
@@ -317,11 +313,9 @@
|
||||
"question_id": "ID de la question",
|
||||
"questions": "Questions",
|
||||
"read_docs": "Lire les documents",
|
||||
"recipients": "Destinataires",
|
||||
"remove": "Retirer",
|
||||
"reorder_and_hide_columns": "Réorganiser et masquer des colonnes",
|
||||
"report_survey": "Rapport d'enquête",
|
||||
"request_pricing": "Demander la tarification",
|
||||
"request_trial_license": "Demander une licence d'essai",
|
||||
"reset_to_default": "Réinitialiser par défaut",
|
||||
"response": "Réponse",
|
||||
@@ -602,7 +596,6 @@
|
||||
"contact_not_found": "Aucun contact trouvé",
|
||||
"contacts_table_refresh": "Rafraîchir les contacts",
|
||||
"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",
|
||||
"last_name": "Nom de famille",
|
||||
"no_responses_found": "Aucune réponse trouvée",
|
||||
@@ -639,7 +632,6 @@
|
||||
"airtable_integration": "Intégration 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_logo": "Logo Airtable",
|
||||
"connect_with_airtable": "Se connecter à Airtable",
|
||||
"link_airtable_table": "Lier la table Airtable",
|
||||
"link_new_table": "Lier nouvelle table",
|
||||
@@ -729,7 +721,6 @@
|
||||
"slack_integration": "Intégration 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_logo": "logo Slack",
|
||||
"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."
|
||||
},
|
||||
@@ -914,7 +905,8 @@
|
||||
"tag_already_exists": "Le tag existe déjà",
|
||||
"tag_deleted": "Tag supprimé",
|
||||
"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": {
|
||||
"manage_teams": "Gérer les équipes",
|
||||
@@ -987,53 +979,63 @@
|
||||
"api_keys_description": "Gérer les clés API pour accéder aux API de gestion de Formbricks"
|
||||
},
|
||||
"billing": {
|
||||
"1000_monthly_responses": "1000 Réponses Mensuelles",
|
||||
"1_project": "1 Projet",
|
||||
"2000_contacts": "2 000 Contacts",
|
||||
"10000_monthly_responses": "10000 Réponses Mensuelles",
|
||||
"1500_monthly_responses": "1500 Réponses Mensuelles",
|
||||
"2000_monthly_identified_users": "2000 Utilisateurs Identifiés Mensuels",
|
||||
"30000_monthly_identified_users": "30000 Utilisateurs Identifiés Mensuels",
|
||||
"3_projects": "3 Projets",
|
||||
"5000_monthly_responses": "5,000 Réponses Mensuelles",
|
||||
"7500_contacts": "7 500 Contacts",
|
||||
"5_projects": "5 Projets",
|
||||
"7500_monthly_identified_users": "7500 Utilisateurs Identifiés Mensuels",
|
||||
"advanced_targeting": "Ciblage Avancé",
|
||||
"all_integrations": "Toutes les intégrations",
|
||||
"all_surveying_features": "Tous les outils d'arpentage",
|
||||
"annually": "Annuellement",
|
||||
"api_webhooks": "API et Webhooks",
|
||||
"app_surveys": "Sondages d'application",
|
||||
"attribute_based_targeting": "Ciblage basé sur les attributs",
|
||||
"contact_us": "Contactez-nous",
|
||||
"current": "Actuel",
|
||||
"current_plan": "Plan actuel",
|
||||
"current_tier_limit": "Limite de niveau actuel",
|
||||
"custom": "Personnalisé et Échelle",
|
||||
"custom_contacts_limit": "Limite de contacts personnalisé",
|
||||
"custom_miu_limit": "Limite MIU personnalisé",
|
||||
"custom_project_limit": "Limite de projet personnalisé",
|
||||
"custom_response_limit": "Limite de réponse personnalisé",
|
||||
"customer_success_manager": "Responsable de la réussite client",
|
||||
"email_embedded_surveys": "Sondages intégrés par e-mail",
|
||||
"email_follow_ups": "Relances par e-mail",
|
||||
"email_support": "Support par e-mail",
|
||||
"enterprise": "Entreprise",
|
||||
"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 !",
|
||||
"everything_in_free": "Tout est gratuit",
|
||||
"everything_in_scale": "Tout à l'échelle",
|
||||
"everything_in_startup": "Tout dans le Startup",
|
||||
"free": "Gratuit",
|
||||
"free_description": "Sondages illimités, membres d'équipe, et plus encore.",
|
||||
"get_2_months_free": "Obtenez 2 mois gratuits",
|
||||
"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)",
|
||||
"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_subscription": "Gérer l'abonnement",
|
||||
"monthly": "Mensuel",
|
||||
"monthly_identified_users": "Utilisateurs Identifiés Mensuels",
|
||||
"multi_language_surveys": "Sondages multilingues",
|
||||
"per_month": "par mois",
|
||||
"per_year": "par an",
|
||||
"plan_upgraded_successfully": "Plan mis à jour avec succès",
|
||||
"premium_support_with_slas": "Soutien premium avec SLA",
|
||||
"priority_support": "Soutien Prioritaire",
|
||||
"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_description": "Tout est gratuit avec des fonctionnalités supplémentaires.",
|
||||
"switch_plan": "Changer de plan",
|
||||
"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",
|
||||
"technical_onboarding": "Intégration technique",
|
||||
"unable_to_upgrade_plan": "Impossible de mettre à niveau le plan",
|
||||
"unlimited_apps_websites": "Applications et sites Web illimités",
|
||||
"unlimited_miu": "MIU Illimité",
|
||||
"unlimited_projects": "Projets illimités",
|
||||
"unlimited_responses": "Réponses illimitées",
|
||||
@@ -1072,7 +1074,6 @@
|
||||
"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.",
|
||||
"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_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 :",
|
||||
@@ -1229,9 +1230,8 @@
|
||||
"copy_survey_description": "Copier cette enquête dans un autre environnement",
|
||||
"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_partially_success": "{success} enquêtes copiées avec succès, {error} échouées.",
|
||||
"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?",
|
||||
"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.",
|
||||
"edit": {
|
||||
"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 :",
|
||||
@@ -1304,6 +1304,7 @@
|
||||
"card_arrangement_for_survey_type_derived": "Disposition des cartes pour les enquêtes {surveyTypeDerived}",
|
||||
"card_background_color": "Couleur de fond 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",
|
||||
"casual": "Décontracté",
|
||||
"caution_edit_duplicate": "Dupliquer et modifier",
|
||||
@@ -1328,6 +1329,7 @@
|
||||
"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_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.",
|
||||
"character_limit_toggle_description": "Limitez la longueur des réponses.",
|
||||
"character_limit_toggle_title": "Ajouter des limites de caractères",
|
||||
@@ -1714,7 +1716,6 @@
|
||||
"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.",
|
||||
"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_description": "Acceptez uniquement une soumission par lien. Voici comment.",
|
||||
"custom_range": "Plage personnalisée...",
|
||||
@@ -1733,19 +1734,12 @@
|
||||
"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_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",
|
||||
"filter_added_successfully": "Filtre ajouté avec succès",
|
||||
"filter_updated_successfully": "Filtre mis à jour avec succès",
|
||||
"filtered_responses_csv": "Réponses filtrées (CSV)",
|
||||
"filtered_responses_excel": "Réponses filtrées (Excel)",
|
||||
"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",
|
||||
"hide_embed_code": "Cacher le code d'intégration",
|
||||
"how_to_create_a_panel": "Comment créer un panneau",
|
||||
@@ -1771,18 +1765,12 @@
|
||||
"last_quarter": "dernier trimestre",
|
||||
"last_year": "l'année dernière",
|
||||
"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",
|
||||
"mobile_app": "Application mobile",
|
||||
"no_responses_found": "Aucune réponse trouvée",
|
||||
"no_segments_available": "Aucun segment disponible",
|
||||
"only_completed": "Uniquement terminé",
|
||||
"other_values_found": "D'autres valeurs trouvées",
|
||||
"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_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.",
|
||||
@@ -1791,7 +1779,6 @@
|
||||
"quickstart_web_apps": "Démarrage rapide : Applications web",
|
||||
"quickstart_web_apps_description": "Veuillez suivre le guide de démarrage rapide pour commencer :",
|
||||
"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_excel": "Réponses sélectionnées (Excel)",
|
||||
"send_preview": "Envoyer un aperçu",
|
||||
@@ -1817,7 +1804,6 @@
|
||||
"this_year": "Cette année",
|
||||
"time_to_complete": "Temps à compléter",
|
||||
"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.",
|
||||
"unknown_question_type": "Type de question inconnu",
|
||||
"unpublish_from_web": "Désactiver la publication sur le web",
|
||||
@@ -2841,4 +2827,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_placeholder": "Entrez votre réponse ici..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,10 +108,6 @@
|
||||
"thanks_for_upgrading": "Valeu demais por atualizar sua assinatura do Formbricks.",
|
||||
"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": {
|
||||
"accepted": "Aceito",
|
||||
"account": "conta",
|
||||
@@ -317,11 +313,9 @@
|
||||
"question_id": "ID da Pergunta",
|
||||
"questions": "Perguntas",
|
||||
"read_docs": "Ler Documentos",
|
||||
"recipients": "Destinatários",
|
||||
"remove": "remover",
|
||||
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
|
||||
"report_survey": "Relatório de Pesquisa",
|
||||
"request_pricing": "Solicitar Preços",
|
||||
"request_trial_license": "Pedir licença de teste",
|
||||
"reset_to_default": "Restaurar para o padrão",
|
||||
"response": "Resposta",
|
||||
@@ -602,7 +596,6 @@
|
||||
"contact_not_found": "Nenhum contato encontrado",
|
||||
"contacts_table_refresh": "Atualizar contatos",
|
||||
"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",
|
||||
"last_name": "Sobrenome",
|
||||
"no_responses_found": "Nenhuma resposta encontrada",
|
||||
@@ -639,7 +632,6 @@
|
||||
"airtable_integration": "Integração com 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_logo": "Logo do Airtable",
|
||||
"connect_with_airtable": "Conectar com o Airtable",
|
||||
"link_airtable_table": "Vincular Tabela do Airtable",
|
||||
"link_new_table": "Vincular nova tabela",
|
||||
@@ -729,7 +721,6 @@
|
||||
"slack_integration": "Integração com o 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_logo": "Logotipo do Slack",
|
||||
"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."
|
||||
},
|
||||
@@ -914,7 +905,8 @@
|
||||
"tag_already_exists": "Tag já existe",
|
||||
"tag_deleted": "Tag apagada",
|
||||
"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": {
|
||||
"manage_teams": "Gerenciar Equipes",
|
||||
@@ -987,53 +979,63 @@
|
||||
"api_keys_description": "Gerencie chaves de API para acessar as APIs de gerenciamento do Formbricks"
|
||||
},
|
||||
"billing": {
|
||||
"1000_monthly_responses": "1000 Respostas Mensais",
|
||||
"1_project": "1 Projeto",
|
||||
"2000_contacts": "2.000 Contatos",
|
||||
"10000_monthly_responses": "10000 Respostas Mensais",
|
||||
"1500_monthly_responses": "1500 Respostas Mensais",
|
||||
"2000_monthly_identified_users": "2000 Usuários Identificados Mensalmente",
|
||||
"30000_monthly_identified_users": "30000 Usuários Identificados Mensalmente",
|
||||
"3_projects": "3 Projetos",
|
||||
"5000_monthly_responses": "5,000 Respostas Mensais",
|
||||
"7500_contacts": "7.500 Contatos",
|
||||
"5_projects": "5 Projetos",
|
||||
"7500_monthly_identified_users": "7500 Usuários Identificados Mensalmente",
|
||||
"advanced_targeting": "Mira Avançada",
|
||||
"all_integrations": "Todas as Integrações",
|
||||
"all_surveying_features": "Todos os recursos de levantamento",
|
||||
"annually": "anualmente",
|
||||
"api_webhooks": "API e Webhooks",
|
||||
"app_surveys": "Pesquisas de App",
|
||||
"attribute_based_targeting": "Segmentação Baseada em Atributos",
|
||||
"contact_us": "Fale Conosco",
|
||||
"current": "atual",
|
||||
"current_plan": "Plano Atual",
|
||||
"current_tier_limit": "Limite Atual de Nível",
|
||||
"custom": "Personalizado e Escala",
|
||||
"custom_contacts_limit": "Limite de Contatos Personalizado",
|
||||
"custom_miu_limit": "Limite MIU personalizado",
|
||||
"custom_project_limit": "Limite de Projeto Personalizado",
|
||||
"custom_response_limit": "Limite de Resposta Personalizado",
|
||||
"customer_success_manager": "Gerente de Sucesso do Cliente",
|
||||
"email_embedded_surveys": "Pesquisas Incorporadas no Email",
|
||||
"email_follow_ups": "Acompanhamentos por Email",
|
||||
"email_support": "Suporte por Email",
|
||||
"enterprise": "Empresa",
|
||||
"enterprise_description": "Suporte premium e limites personalizados.",
|
||||
"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_scale": "Tudo em Escala",
|
||||
"everything_in_startup": "Tudo em Startup",
|
||||
"free": "grátis",
|
||||
"free_description": "Pesquisas ilimitadas, membros da equipe e mais.",
|
||||
"get_2_months_free": "Ganhe 2 meses grátis",
|
||||
"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)",
|
||||
"logic_jumps_hidden_fields_recurring_surveys": "Pulos Lógicos, Campos Ocultos, Pesquisas Recorrentes, etc.",
|
||||
"manage_card_details": "Gerenciar Detalhes do Cartão",
|
||||
"manage_subscription": "Gerenciar Assinatura",
|
||||
"monthly": "mensal",
|
||||
"monthly_identified_users": "Usuários Identificados Mensalmente",
|
||||
"multi_language_surveys": "Pesquisas Multilíngues",
|
||||
"per_month": "por mês",
|
||||
"per_year": "por ano",
|
||||
"plan_upgraded_successfully": "Plano atualizado com sucesso",
|
||||
"premium_support_with_slas": "Suporte premium com SLAs",
|
||||
"priority_support": "Suporte Prioritário",
|
||||
"remove_branding": "Remover Marca",
|
||||
"say_hi": "Diz oi!",
|
||||
"scale": "escala",
|
||||
"scale_description": "Recursos avançados pra escalar seu negócio.",
|
||||
"startup": "startup",
|
||||
"startup_description": "Tudo no Grátis com recursos adicionais.",
|
||||
"switch_plan": "Mudar Plano",
|
||||
"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",
|
||||
"technical_onboarding": "Integração Técnica",
|
||||
"unable_to_upgrade_plan": "Não foi possível atualizar o plano",
|
||||
"unlimited_apps_websites": "Apps e Sites Ilimitados",
|
||||
"unlimited_miu": "MIU Ilimitado",
|
||||
"unlimited_projects": "Projetos Ilimitados",
|
||||
"unlimited_responses": "Respostas Ilimitadas",
|
||||
@@ -1072,7 +1074,6 @@
|
||||
"create_new_organization": "Criar nova organização",
|
||||
"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",
|
||||
"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_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:",
|
||||
@@ -1229,9 +1230,8 @@
|
||||
"copy_survey_description": "Copiar essa pesquisa para outro ambiente",
|
||||
"copy_survey_error": "Falha ao copiar pesquisa",
|
||||
"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!",
|
||||
"delete_survey_and_responses_warning": "Você tem certeza de que quer deletar essa pesquisa e todas as suas respostas?",
|
||||
"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.",
|
||||
"edit": {
|
||||
"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:",
|
||||
@@ -1304,6 +1304,7 @@
|
||||
"card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Pesquisas {surveyTypeDerived}",
|
||||
"card_background_color": "Cor de fundo 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",
|
||||
"casual": "Casual",
|
||||
"caution_edit_duplicate": "Duplicar e editar",
|
||||
@@ -1328,6 +1329,7 @@
|
||||
"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_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.",
|
||||
"character_limit_toggle_description": "Limite o quão curta ou longa uma resposta pode ser.",
|
||||
"character_limit_toggle_title": "Adicionar limites de caracteres",
|
||||
@@ -1714,7 +1716,6 @@
|
||||
"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.",
|
||||
"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_description": "Aceite apenas uma submissão por link. Aqui está como.",
|
||||
"custom_range": "Intervalo personalizado...",
|
||||
@@ -1733,19 +1734,12 @@
|
||||
"embed_on_website": "Incorporar no site",
|
||||
"embed_pop_up_survey_title": "Como incorporar uma pesquisa pop-up no seu site",
|
||||
"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",
|
||||
"filter_added_successfully": "Filtro adicionado com sucesso",
|
||||
"filter_updated_successfully": "Filtro atualizado com sucesso",
|
||||
"filtered_responses_csv": "Respostas filtradas (CSV)",
|
||||
"filtered_responses_excel": "Respostas filtradas (Excel)",
|
||||
"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",
|
||||
"hide_embed_code": "Esconder código de incorporação",
|
||||
"how_to_create_a_panel": "Como criar um painel",
|
||||
@@ -1771,18 +1765,12 @@
|
||||
"last_quarter": "Último trimestre",
|
||||
"last_year": "Último ano",
|
||||
"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",
|
||||
"mobile_app": "app de celular",
|
||||
"no_responses_found": "Nenhuma resposta encontrada",
|
||||
"no_segments_available": "Nenhum segmento disponível",
|
||||
"only_completed": "Somente concluído",
|
||||
"other_values_found": "Outros valores encontrados",
|
||||
"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_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.",
|
||||
@@ -1791,7 +1779,6 @@
|
||||
"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:",
|
||||
"results_are_public": "Os resultados são públicos",
|
||||
"select_segment": "Selecionar segmento",
|
||||
"selected_responses_csv": "Respostas selecionadas (CSV)",
|
||||
"selected_responses_excel": "Respostas selecionadas (Excel)",
|
||||
"send_preview": "Enviar prévia",
|
||||
@@ -1817,7 +1804,6 @@
|
||||
"this_year": "Este ano",
|
||||
"time_to_complete": "Tempo para Concluir",
|
||||
"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.",
|
||||
"unknown_question_type": "Tipo de pergunta desconhecido",
|
||||
"unpublish_from_web": "Despublicar da web",
|
||||
@@ -2841,4 +2827,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_placeholder": "Digite sua resposta aqui..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,10 +108,6 @@
|
||||
"thanks_for_upgrading": "Muito obrigado por atualizar a sua subscrição do Formbricks.",
|
||||
"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": {
|
||||
"accepted": "Aceite",
|
||||
"account": "Conta",
|
||||
@@ -317,11 +313,9 @@
|
||||
"question_id": "ID da pergunta",
|
||||
"questions": "Perguntas",
|
||||
"read_docs": "Ler Documentos",
|
||||
"recipients": "Destinatários",
|
||||
"remove": "Remover",
|
||||
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
|
||||
"report_survey": "Relatório de Inquérito",
|
||||
"request_pricing": "Pedido de Preços",
|
||||
"request_trial_license": "Solicitar licença de teste",
|
||||
"reset_to_default": "Repor para o padrão",
|
||||
"response": "Resposta",
|
||||
@@ -602,7 +596,6 @@
|
||||
"contact_not_found": "Nenhum contacto encontrado",
|
||||
"contacts_table_refresh": "Atualizar contactos",
|
||||
"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",
|
||||
"last_name": "Apelido",
|
||||
"no_responses_found": "Nenhuma resposta encontrada",
|
||||
@@ -639,7 +632,6 @@
|
||||
"airtable_integration": "Integração 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_logo": "logotipo Airtable",
|
||||
"connect_with_airtable": "Ligar ao Airtable",
|
||||
"link_airtable_table": "Ligar Tabela Airtable",
|
||||
"link_new_table": "Ligar nova tabela",
|
||||
@@ -729,7 +721,6 @@
|
||||
"slack_integration": "Integração com 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_logo": "Logótipo Slack",
|
||||
"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."
|
||||
},
|
||||
@@ -914,7 +905,8 @@
|
||||
"tag_already_exists": "A etiqueta já existe",
|
||||
"tag_deleted": "Etiqueta eliminada",
|
||||
"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": {
|
||||
"manage_teams": "Gerir equipas",
|
||||
@@ -987,53 +979,63 @@
|
||||
"api_keys_description": "Gerir chaves API para aceder às APIs de gestão do Formbricks"
|
||||
},
|
||||
"billing": {
|
||||
"1000_monthly_responses": "1000 Respostas Mensais",
|
||||
"1_project": "1 Projeto",
|
||||
"2000_contacts": "2,000 Contactos",
|
||||
"10000_monthly_responses": "10000 Respostas Mensais",
|
||||
"1500_monthly_responses": "1500 Respostas Mensais",
|
||||
"2000_monthly_identified_users": "2000 Utilizadores Identificados Mensalmente",
|
||||
"30000_monthly_identified_users": "30000 Utilizadores Identificados Mensalmente",
|
||||
"3_projects": "3 Projetos",
|
||||
"5000_monthly_responses": "5,000 Respostas Mensais",
|
||||
"7500_contacts": "7,500 Contactos",
|
||||
"5_projects": "5 Projetos",
|
||||
"7500_monthly_identified_users": "7500 Utilizadores Identificados Mensalmente",
|
||||
"advanced_targeting": "Segmentação Avançada",
|
||||
"all_integrations": "Todas as Integrações",
|
||||
"all_surveying_features": "Todas as funcionalidades de inquérito",
|
||||
"annually": "Anualmente",
|
||||
"api_webhooks": "API e Webhooks",
|
||||
"app_surveys": "Inquéritos da Aplicação",
|
||||
"attribute_based_targeting": "Segmentação Baseada em Atributos",
|
||||
"contact_us": "Contacte-nos",
|
||||
"current": "Atual",
|
||||
"current_plan": "Plano Atual",
|
||||
"current_tier_limit": "Limite Atual do Nível",
|
||||
"custom": "Personalizado e Escala",
|
||||
"custom_contacts_limit": "Limite de Contactos Personalizado",
|
||||
"custom_miu_limit": "Limite MIU Personalizado",
|
||||
"custom_project_limit": "Limite de Projeto Personalizado",
|
||||
"custom_response_limit": "Limite de Resposta Personalizado",
|
||||
"customer_success_manager": "Gestor de Sucesso do Cliente",
|
||||
"email_embedded_surveys": "Inquéritos Incorporados no Email",
|
||||
"email_follow_ups": "Acompanhamentos por Email",
|
||||
"email_support": "Suporte por Email",
|
||||
"enterprise": "Empresa",
|
||||
"enterprise_description": "Suporte premium e limites personalizados.",
|
||||
"everybody_has_the_free_plan_by_default": "Todos têm o plano gratuito por defeito!",
|
||||
"everything_in_free": "Tudo em Gratuito",
|
||||
"everything_in_scale": "Tudo em Escala",
|
||||
"everything_in_startup": "Tudo em Startup",
|
||||
"free": "Grátis",
|
||||
"free_description": "Inquéritos ilimitados, membros da equipa e mais.",
|
||||
"get_2_months_free": "Obtenha 2 meses grátis",
|
||||
"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)",
|
||||
"logic_jumps_hidden_fields_recurring_surveys": "Saltos Lógicos, Campos Ocultos, Inquéritos Recorrentes, etc.",
|
||||
"manage_card_details": "Gerir Detalhes do Cartão",
|
||||
"manage_subscription": "Gerir Subscrição",
|
||||
"monthly": "Mensal",
|
||||
"monthly_identified_users": "Utilizadores Identificados Mensalmente",
|
||||
"multi_language_surveys": "Inquéritos Multilingues",
|
||||
"per_month": "por mês",
|
||||
"per_year": "por ano",
|
||||
"plan_upgraded_successfully": "Plano atualizado com sucesso",
|
||||
"premium_support_with_slas": "Suporte premium com SLAs",
|
||||
"priority_support": "Suporte Prioritário",
|
||||
"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_description": "Tudo no plano Gratuito com funcionalidades adicionais.",
|
||||
"switch_plan": "Mudar Plano",
|
||||
"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",
|
||||
"technical_onboarding": "Integração Técnica",
|
||||
"unable_to_upgrade_plan": "Não é possível atualizar o plano",
|
||||
"unlimited_apps_websites": "Aplicações e Websites Ilimitados",
|
||||
"unlimited_miu": "MIU Ilimitado",
|
||||
"unlimited_projects": "Projetos Ilimitados",
|
||||
"unlimited_responses": "Respostas Ilimitadas",
|
||||
@@ -1072,7 +1074,6 @@
|
||||
"create_new_organization": "Criar nova organização",
|
||||
"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",
|
||||
"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_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:",
|
||||
@@ -1229,9 +1230,8 @@
|
||||
"copy_survey_description": "Copiar este questionário para outro ambiente",
|
||||
"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_partially_success": "{success} inquéritos copiados com sucesso, {error} falharam.",
|
||||
"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?",
|
||||
"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.",
|
||||
"edit": {
|
||||
"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:",
|
||||
@@ -1304,6 +1304,7 @@
|
||||
"card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Inquéritos {surveyTypeDerived}",
|
||||
"card_background_color": "Cor de fundo 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",
|
||||
"casual": "Casual",
|
||||
"caution_edit_duplicate": "Duplicar e editar",
|
||||
@@ -1328,6 +1329,7 @@
|
||||
"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_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.",
|
||||
"character_limit_toggle_description": "Limitar o quão curta ou longa uma resposta pode ser.",
|
||||
"character_limit_toggle_title": "Adicionar limites de caracteres",
|
||||
@@ -1714,7 +1716,6 @@
|
||||
"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.",
|
||||
"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_description": "Aceitar apenas uma submissão por link. Aqui está como.",
|
||||
"custom_range": "Intervalo personalizado...",
|
||||
@@ -1733,19 +1734,12 @@
|
||||
"embed_on_website": "Incorporar no site",
|
||||
"embed_pop_up_survey_title": "Como incorporar um questionário pop-up no seu site",
|
||||
"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",
|
||||
"filter_added_successfully": "Filtro adicionado com sucesso",
|
||||
"filter_updated_successfully": "Filtro atualizado com sucesso",
|
||||
"filtered_responses_csv": "Respostas filtradas (CSV)",
|
||||
"filtered_responses_excel": "Respostas filtradas (Excel)",
|
||||
"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",
|
||||
"hide_embed_code": "Ocultar código de incorporação",
|
||||
"how_to_create_a_panel": "Como criar um painel",
|
||||
@@ -1771,18 +1765,12 @@
|
||||
"last_quarter": "Último trimestre",
|
||||
"last_year": "Ano passado",
|
||||
"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",
|
||||
"mobile_app": "Aplicação móvel",
|
||||
"no_responses_found": "Nenhuma resposta encontrada",
|
||||
"no_segments_available": "Sem segmentos disponíveis",
|
||||
"only_completed": "Apenas concluído",
|
||||
"other_values_found": "Outros valores encontrados",
|
||||
"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_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.",
|
||||
@@ -1791,7 +1779,6 @@
|
||||
"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:",
|
||||
"results_are_public": "Os resultados são públicos",
|
||||
"select_segment": "Selecionar segmento",
|
||||
"selected_responses_csv": "Respostas selecionadas (CSV)",
|
||||
"selected_responses_excel": "Respostas selecionadas (Excel)",
|
||||
"send_preview": "Enviar pré-visualização",
|
||||
@@ -1817,7 +1804,6 @@
|
||||
"this_year": "Este ano",
|
||||
"time_to_complete": "Tempo para Concluir",
|
||||
"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.",
|
||||
"unknown_question_type": "Tipo de Pergunta Desconhecido",
|
||||
"unpublish_from_web": "Despublicar da web",
|
||||
@@ -2841,4 +2827,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_placeholder": "Escreva a sua resposta aqui..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,10 +108,6 @@
|
||||
"thanks_for_upgrading": "非常感謝您升級您的 Formbricks 訂閱。",
|
||||
"upgrade_successful": "升級成功"
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "您 的 連結 已過期。",
|
||||
"link_expired_description": "您 使用 的 連結 已無效。"
|
||||
},
|
||||
"common": {
|
||||
"accepted": "已接受",
|
||||
"account": "帳戶",
|
||||
@@ -317,11 +313,9 @@
|
||||
"question_id": "問題 ID",
|
||||
"questions": "問題",
|
||||
"read_docs": "閱讀文件",
|
||||
"recipients": "收件者",
|
||||
"remove": "移除",
|
||||
"reorder_and_hide_columns": "重新排序和隱藏欄位",
|
||||
"report_survey": "報告問卷",
|
||||
"request_pricing": "請求定價",
|
||||
"request_trial_license": "請求試用授權",
|
||||
"reset_to_default": "重設為預設值",
|
||||
"response": "回應",
|
||||
@@ -602,7 +596,6 @@
|
||||
"contact_not_found": "找不到此聯絡人",
|
||||
"contacts_table_refresh": "重新整理聯絡人",
|
||||
"contacts_table_refresh_success": "聯絡人已成功重新整理",
|
||||
"delete_contact_confirmation": "這將刪除與此聯繫人相關的所有調查回應和聯繫屬性。任何基於此聯繫人數據的定位和個性化將會丟失。",
|
||||
"first_name": "名字",
|
||||
"last_name": "姓氏",
|
||||
"no_responses_found": "找不到回應",
|
||||
@@ -639,7 +632,6 @@
|
||||
"airtable_integration": "Airtable 整合",
|
||||
"airtable_integration_description": "直接與 Airtable 同步回應。",
|
||||
"airtable_integration_is_not_configured": "尚未設定 Airtable 整合",
|
||||
"airtable_logo": "Airtable 標誌",
|
||||
"connect_with_airtable": "連線 Airtable",
|
||||
"link_airtable_table": "連結 Airtable 表格",
|
||||
"link_new_table": "連結新表格",
|
||||
@@ -729,7 +721,6 @@
|
||||
"slack_integration": "Slack 整合",
|
||||
"slack_integration_description": "直接將回應傳送至 Slack。",
|
||||
"slack_integration_is_not_configured": "您的 Formbricks 執行個體中尚未設定 Slack 整合。",
|
||||
"slack_logo": "Slack 標誌",
|
||||
"slack_reconnect_button": "重新連線",
|
||||
"slack_reconnect_button_description": "<b>注意:</b>我們最近變更了我們的 Slack 整合以支援私人頻道。請重新連線您的 Slack 工作區。"
|
||||
},
|
||||
@@ -914,7 +905,8 @@
|
||||
"tag_already_exists": "標籤已存在",
|
||||
"tag_deleted": "標籤已刪除",
|
||||
"tag_updated": "標籤已更新",
|
||||
"tags_merged": "標籤已合併"
|
||||
"tags_merged": "標籤已合併",
|
||||
"unique_constraint_failed_on_the_fields": "欄位上唯一性限制失敗"
|
||||
},
|
||||
"teams": {
|
||||
"manage_teams": "管理團隊",
|
||||
@@ -987,53 +979,63 @@
|
||||
"api_keys_description": "管理 API 金鑰以存取 Formbricks 管理 API"
|
||||
},
|
||||
"billing": {
|
||||
"1000_monthly_responses": "1000 個每月回應",
|
||||
"1_project": "1 個專案",
|
||||
"2000_contacts": "2000 個聯絡人",
|
||||
"10000_monthly_responses": "10000 個每月回應",
|
||||
"1500_monthly_responses": "1500 個每月回應",
|
||||
"2000_monthly_identified_users": "2000 個每月識別使用者",
|
||||
"30000_monthly_identified_users": "30000 個每月識別使用者",
|
||||
"3_projects": "3 個專案",
|
||||
"5000_monthly_responses": "5000 個每月回應",
|
||||
"7500_contacts": "7500 個聯絡人",
|
||||
"5_projects": "5 個專案",
|
||||
"7500_monthly_identified_users": "7500 個每月識別使用者",
|
||||
"advanced_targeting": "進階目標設定",
|
||||
"all_integrations": "所有整合",
|
||||
"all_surveying_features": "所有調查功能",
|
||||
"annually": "每年",
|
||||
"api_webhooks": "API 和 Webhook",
|
||||
"app_surveys": "應用程式問卷",
|
||||
"attribute_based_targeting": "基於屬性的定位",
|
||||
"contact_us": "聯絡我們",
|
||||
"current": "目前",
|
||||
"current_plan": "目前方案",
|
||||
"current_tier_limit": "目前層級限制",
|
||||
"custom": "自訂 & 規模",
|
||||
"custom_contacts_limit": "自訂聯絡人上限",
|
||||
"custom_miu_limit": "自訂 MIU 上限",
|
||||
"custom_project_limit": "自訂專案上限",
|
||||
"custom_response_limit": "自訂回應上限",
|
||||
"customer_success_manager": "客戶成功經理",
|
||||
"email_embedded_surveys": "電子郵件嵌入式問卷",
|
||||
"email_follow_ups": "電子郵件後續追蹤",
|
||||
"email_support": "電子郵件支援",
|
||||
"enterprise": "企業版",
|
||||
"enterprise_description": "頂級支援和自訂限制。",
|
||||
"everybody_has_the_free_plan_by_default": "每個人預設都有免費方案!",
|
||||
"everything_in_free": "免費方案中的所有功能",
|
||||
"everything_in_scale": "進階方案中的所有功能",
|
||||
"everything_in_startup": "啟動方案中的所有功能",
|
||||
"free": "免費",
|
||||
"free_description": "無限問卷、團隊成員等。",
|
||||
"get_2_months_free": "免費獲得 2 個月",
|
||||
"get_in_touch": "取得聯繫",
|
||||
"hosted_in_frankfurt": "託管在 Frankfurt",
|
||||
"ios_android_sdks": "iOS 和 Android SDK 用於行動問卷",
|
||||
"link_surveys": "連結問卷(可分享)",
|
||||
"logic_jumps_hidden_fields_recurring_surveys": "邏輯跳躍、隱藏欄位、定期問卷等。",
|
||||
"manage_card_details": "管理卡片詳細資料",
|
||||
"manage_subscription": "管理訂閱",
|
||||
"monthly": "每月",
|
||||
"monthly_identified_users": "每月識別使用者",
|
||||
"multi_language_surveys": "多語言問卷",
|
||||
"per_month": "每月",
|
||||
"per_year": "每年",
|
||||
"plan_upgraded_successfully": "方案已成功升級",
|
||||
"premium_support_with_slas": "具有 SLA 的頂級支援",
|
||||
"priority_support": "優先支援",
|
||||
"remove_branding": "移除品牌",
|
||||
"say_hi": "打個招呼!",
|
||||
"scale": "進階版",
|
||||
"scale_description": "用於擴展業務的進階功能。",
|
||||
"startup": "啟動版",
|
||||
"startup_description": "免費方案中的所有功能以及其他功能。",
|
||||
"switch_plan": "切換方案",
|
||||
"switch_plan_confirmation_text": "您確定要切換到 {plan} 計劃嗎?您將被收取 {price} {period}。",
|
||||
"team_access_roles": "團隊存取角色",
|
||||
"technical_onboarding": "技術新手上路",
|
||||
"unable_to_upgrade_plan": "無法升級方案",
|
||||
"unlimited_apps_websites": "無限應用程式和網站",
|
||||
"unlimited_miu": "無限 MIU",
|
||||
"unlimited_projects": "無限專案",
|
||||
"unlimited_responses": "無限回應",
|
||||
@@ -1072,7 +1074,6 @@
|
||||
"create_new_organization": "建立新組織",
|
||||
"create_new_organization_description": "建立新組織以處理一組不同的專案。",
|
||||
"customize_email_with_a_higher_plan": "使用更高等級的方案自訂電子郵件",
|
||||
"delete_member_confirmation": "刪除的成員將失去存取您組織的所有專案和問卷的權限。",
|
||||
"delete_organization": "刪除組織",
|
||||
"delete_organization_description": "刪除包含所有專案的組織,包括所有問卷、回應、人員、操作和屬性",
|
||||
"delete_organization_warning": "在您繼續刪除此組織之前,請注意以下後果:",
|
||||
@@ -1229,9 +1230,8 @@
|
||||
"copy_survey_description": "將此問卷複製到另一個環境",
|
||||
"copy_survey_error": "無法複製問卷",
|
||||
"copy_survey_link_to_clipboard": "將問卷連結複製到剪貼簿",
|
||||
"copy_survey_partially_success": "{success} 個問卷已成功複製,{error} 個失敗。",
|
||||
"copy_survey_success": "問卷已成功複製!",
|
||||
"delete_survey_and_responses_warning": "您確定要刪除此問卷及其所有回應嗎?",
|
||||
"delete_survey_and_responses_warning": "您確定要刪除此問卷及其所有回應嗎?此操作無法復原。",
|
||||
"edit": {
|
||||
"1_choose_the_default_language_for_this_survey": "1. 選擇此問卷的預設語言:",
|
||||
"2_activate_translation_for_specific_languages": "2. 啟用特定語言的翻譯:",
|
||||
@@ -1304,6 +1304,7 @@
|
||||
"card_arrangement_for_survey_type_derived": "'{'surveyTypeDerived'}' 問卷的卡片排列",
|
||||
"card_background_color": "卡片背景顏色",
|
||||
"card_border_color": "卡片邊框顏色",
|
||||
"card_shadow_color": "卡片陰影顏色",
|
||||
"card_styling": "卡片樣式設定",
|
||||
"casual": "隨意",
|
||||
"caution_edit_duplicate": "複製 & 編輯",
|
||||
@@ -1328,6 +1329,7 @@
|
||||
"change_the_brand_color_of_the_survey": "變更問卷的品牌顏色。",
|
||||
"change_the_placement_of_this_survey": "變更此問卷的位置。",
|
||||
"change_the_question_color_of_the_survey": "變更問卷的問題顏色。",
|
||||
"change_the_shadow_color_of_the_card": "變更卡片的陰影顏色。",
|
||||
"changes_saved": "已儲存變更。",
|
||||
"character_limit_toggle_description": "限制答案的長度或短度。",
|
||||
"character_limit_toggle_title": "新增字元限制",
|
||||
@@ -1714,7 +1716,6 @@
|
||||
"congrats": "恭喜!您的問卷已上線。",
|
||||
"connect_your_website_or_app_with_formbricks_to_get_started": "將您的網站或應用程式與 Formbricks 連線以開始使用。",
|
||||
"copy_link_to_public_results": "複製公開結果的連結",
|
||||
"create_and_manage_segments": "在 聯絡人 > 分段 中建立和管理您的分段",
|
||||
"create_single_use_links": "建立單次使用連結",
|
||||
"create_single_use_links_description": "每個連結只接受一次提交。以下是如何操作。",
|
||||
"custom_range": "自訂範圍...",
|
||||
@@ -1733,19 +1734,12 @@
|
||||
"embed_on_website": "嵌入網站",
|
||||
"embed_pop_up_survey_title": "如何在您的網站上嵌入彈出式問卷",
|
||||
"embed_survey": "嵌入問卷",
|
||||
"expiry_date_description": "一旦連結過期,收件者將無法再回應 survey。",
|
||||
"expiry_date_optional": "到期日 (可選)",
|
||||
"failed_to_copy_link": "無法複製連結",
|
||||
"filter_added_successfully": "篩選器已成功新增",
|
||||
"filter_updated_successfully": "篩選器已成功更新",
|
||||
"filtered_responses_csv": "篩選回應 (CSV)",
|
||||
"filtered_responses_excel": "篩選回應 (Excel)",
|
||||
"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",
|
||||
"hide_embed_code": "隱藏嵌入程式碼",
|
||||
"how_to_create_a_panel": "如何建立小組",
|
||||
@@ -1771,18 +1765,12 @@
|
||||
"last_quarter": "上一季",
|
||||
"last_year": "去年",
|
||||
"link_to_public_results_copied": "已複製公開結果的連結",
|
||||
"links_generated_success_toast": "連結 成功 生成,您的 下載 將 會 很快 開始。",
|
||||
"make_sure_the_survey_type_is_set_to": "請確保問卷類型設定為",
|
||||
"mobile_app": "行動應用程式",
|
||||
"no_responses_found": "找不到回應",
|
||||
"no_segments_available": "沒有可用的區段",
|
||||
"only_completed": "僅已完成",
|
||||
"other_values_found": "找到其他值",
|
||||
"overall": "整體",
|
||||
"personal_links": "個人 連結",
|
||||
"personal_links_upgrade_prompt_description": "為一個群組生成個人連結,並將調查回應連結到每個聯絡人。",
|
||||
"personal_links_upgrade_prompt_title": "使用 個人 連結 與 更高 的 計劃",
|
||||
"personal_links_work_with_segments": "個人 連結 可 與 分段 一起 使用",
|
||||
"publish_to_web": "發布至網站",
|
||||
"publish_to_web_warning": "您即將將這些問卷結果發布到公共領域。",
|
||||
"publish_to_web_warning_description": "您的問卷結果將會是公開的。任何組織外的人員都可以存取這些結果(如果他們有連結)。",
|
||||
@@ -1791,7 +1779,6 @@
|
||||
"quickstart_web_apps": "快速入門:Web apps",
|
||||
"quickstart_web_apps_description": "請按照 Quickstart 指南開始:",
|
||||
"results_are_public": "結果是公開的",
|
||||
"select_segment": "選擇 區隔",
|
||||
"selected_responses_csv": "選擇的回應 (CSV)",
|
||||
"selected_responses_excel": "選擇的回應 (Excel)",
|
||||
"send_preview": "發送預覽",
|
||||
@@ -1817,7 +1804,6 @@
|
||||
"this_year": "今年",
|
||||
"time_to_complete": "完成時間",
|
||||
"to_connect_your_website_with_formbricks": "以將您的網站與 Formbricks 連線",
|
||||
"to_create_personal_links_segment_required": "要為 問卷 創建 個人連結,您 必須先 設置 一個 分段。",
|
||||
"ttc_tooltip": "完成問卷的平均時間。",
|
||||
"unknown_question_type": "未知的問題類型",
|
||||
"unpublish_from_web": "從網站取消發布",
|
||||
@@ -2841,4 +2827,4 @@
|
||||
"understand_purchase_intention_question_3_headline": "有什麼阻礙您今天進行購買嗎?",
|
||||
"understand_purchase_intention_question_3_placeholder": "在此輸入您的答案..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
@@ -99,6 +100,8 @@ describe("DeleteAccountModal", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const removeItemSpy = vi.spyOn(window.localStorage, "removeItem");
|
||||
|
||||
const input = screen.getByTestId("deleteAccountConfirmation");
|
||||
fireEvent.change(input, { target: { value: mockUser.email } });
|
||||
|
||||
@@ -110,8 +113,8 @@ describe("DeleteAccountModal", () => {
|
||||
expect(mockSignOut).toHaveBeenCalledWith({
|
||||
reason: "account_deletion",
|
||||
redirect: false, // Updated to match new implementation
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
expect(removeItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
expect(window.location.replace).toHaveBeenCalledWith("/auth/login");
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
@@ -148,13 +151,15 @@ describe("DeleteAccountModal", () => {
|
||||
const form = screen.getByTestId("deleteAccountForm");
|
||||
fireEvent.submit(form);
|
||||
|
||||
const removeItemSpy = vi.spyOn(window.localStorage, "removeItem");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteUserAction).toHaveBeenCalled();
|
||||
expect(mockSignOut).toHaveBeenCalledWith({
|
||||
reason: "account_deletion",
|
||||
redirect: false, // Updated to match new implementation
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
expect(removeItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
expect(window.location.replace).toHaveBeenCalledWith(
|
||||
"https://app.formbricks.com/s/clri52y3z8f221225wjdhsoo2"
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
@@ -38,11 +39,12 @@ export const DeleteAccountModal = ({
|
||||
setDeleting(true);
|
||||
await deleteUserAction();
|
||||
|
||||
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
|
||||
// Sign out with account deletion reason (no automatic redirect)
|
||||
await signOutWithAudit({
|
||||
reason: "account_deletion",
|
||||
redirect: false, // Prevent NextAuth automatic redirect
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
|
||||
// Manual redirect after signOut completes
|
||||
|
||||
@@ -13,7 +13,6 @@ export const SurveyLinkDisplay = ({ surveyUrl }: SurveyLinkDisplayProps) => {
|
||||
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"
|
||||
value={surveyUrl}
|
||||
readOnly
|
||||
/>
|
||||
) : (
|
||||
//loading state
|
||||
|
||||
@@ -50,14 +50,8 @@ export const createTagAction = authenticatedActionClient.schema(ZCreateTagAction
|
||||
});
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
const result = await createTag(parsedInput.environmentId, parsedInput.tagName);
|
||||
|
||||
if (result.ok) {
|
||||
ctx.auditLoggingCtx.tagId = result.data.id;
|
||||
ctx.auditLoggingCtx.newObject = result.data;
|
||||
} else {
|
||||
ctx.auditLoggingCtx.newObject = null;
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.tagId = result.id;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
return result;
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
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 userEvent from "@testing-library/user-event";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -152,9 +150,7 @@ describe("ResponseTagsWrapper", () => {
|
||||
});
|
||||
|
||||
test("creates a new tag via TagsCombobox and calls updateFetchedResponses on success", async () => {
|
||||
vi.mocked(createTagAction).mockResolvedValueOnce({
|
||||
data: { ok: true, data: { id: "newTagId", name: "NewTag" } },
|
||||
} as any);
|
||||
vi.mocked(createTagAction).mockResolvedValueOnce({ data: { id: "newTagId", name: "NewTag" } } as any);
|
||||
vi.mocked(createTagToResponseAction).mockResolvedValueOnce({ data: "tagAdded" } as any);
|
||||
render(
|
||||
<ResponseTagsWrapper
|
||||
@@ -180,10 +176,7 @@ describe("ResponseTagsWrapper", () => {
|
||||
|
||||
test("handles createTagAction failure and shows toast error", async () => {
|
||||
vi.mocked(createTagAction).mockResolvedValueOnce({
|
||||
data: {
|
||||
ok: false,
|
||||
error: { message: "Unique constraint failed on the fields", code: TagError.TAG_NAME_ALREADY_EXISTS },
|
||||
},
|
||||
error: { details: [{ issue: "Unique constraint failed on the fields" }] },
|
||||
} as any);
|
||||
render(
|
||||
<ResponseTagsWrapper
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { TagError } from "@/modules/projects/settings/types/tag";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Tag } from "@/modules/ui/components/tag";
|
||||
import { TagsCombobox } from "@/modules/ui/components/tags-combobox";
|
||||
@@ -59,57 +58,6 @@ export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [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 (
|
||||
<div className="flex items-center gap-3 border-t border-slate-200 px-6 py-4">
|
||||
{!isReadOnly && (
|
||||
@@ -145,7 +93,46 @@ export const ResponseTagsWrapper: React.FC<ResponseTagsWrapperProps> = ({
|
||||
setSearchValue={setSearchValue}
|
||||
tags={environmentTags?.map((tag) => ({ value: tag.id, label: tag.name })) ?? []}
|
||||
currentTags={tagsState.map((tag) => ({ value: tag.tagId, label: tag.tagName }))}
|
||||
createTag={handleCreateTag}
|
||||
createTag={async (tagName) => {
|
||||
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) => {
|
||||
setTagsState((prevTags) => [
|
||||
...prevTags,
|
||||
|
||||
@@ -150,10 +150,9 @@ export const SingleResponseCard = ({
|
||||
<DeleteDialog
|
||||
open={deleteDialogOpen}
|
||||
setOpen={setDeleteDialogOpen}
|
||||
deleteWhat={t("common.response")}
|
||||
deleteWhat="response"
|
||||
onDelete={handleDeleteResponse}
|
||||
isDeleting={isDeleting}
|
||||
text={t("environments.surveys.responses.delete_response_confirmation")}
|
||||
/>
|
||||
</div>
|
||||
{user && pageType === "response" && (
|
||||
|
||||
@@ -21,9 +21,9 @@ export const forgotPasswordAction = actionClient
|
||||
|
||||
const user = await getUserByEmail(parsedInput.email);
|
||||
|
||||
if (user && user.identityProvider === "email") {
|
||||
await sendForgotPasswordEmail(user);
|
||||
if (!user || user.identityProvider !== "email") {
|
||||
return;
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
await sendForgotPasswordEmail(user);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
||||
import { logSignOutAction } from "@/modules/auth/actions/sign-out";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { logger } from "@formbricks/logger";
|
||||
@@ -15,7 +14,6 @@ interface UseSignOutOptions {
|
||||
organizationId?: string;
|
||||
redirect?: boolean;
|
||||
callbackUrl?: string;
|
||||
clearEnvironmentId?: boolean;
|
||||
}
|
||||
|
||||
interface SessionUser {
|
||||
@@ -44,10 +42,6 @@ export const useSignOut = (sessionUser?: SessionUser | null) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.clearEnvironmentId) {
|
||||
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
}
|
||||
|
||||
// Call NextAuth signOut
|
||||
return await signOut({
|
||||
redirect: options?.redirect,
|
||||
|
||||
@@ -50,7 +50,6 @@ export const ZAuditAction = z.enum([
|
||||
"twoFactorRequired",
|
||||
"emailVerificationAttempted",
|
||||
"userSignedOut",
|
||||
"passwordReset",
|
||||
]);
|
||||
export const ZActor = z.enum(["user", "api", "system"]);
|
||||
export const ZAuditStatus = z.enum(["success", "failure"]);
|
||||
|
||||
@@ -1,87 +1,87 @@
|
||||
import { TFnType } from "@tolgee/react";
|
||||
|
||||
export type TPricingPlan = {
|
||||
id: string;
|
||||
name: string;
|
||||
featured: boolean;
|
||||
CTA?: string;
|
||||
description: string;
|
||||
price: {
|
||||
monthly: string;
|
||||
yearly: string;
|
||||
};
|
||||
mainFeatures: string[];
|
||||
href?: string;
|
||||
};
|
||||
|
||||
export const getCloudPricingData = (t: TFnType): { plans: TPricingPlan[] } => {
|
||||
const freePlan: TPricingPlan = {
|
||||
id: "free",
|
||||
name: t("environments.settings.billing.free"),
|
||||
featured: false,
|
||||
description: t("environments.settings.billing.free_description"),
|
||||
price: { monthly: "$0", yearly: "$0" },
|
||||
mainFeatures: [
|
||||
t("environments.settings.billing.unlimited_surveys"),
|
||||
t("environments.settings.billing.1000_monthly_responses"),
|
||||
t("environments.settings.billing.2000_contacts"),
|
||||
t("environments.settings.billing.1_project"),
|
||||
t("environments.settings.billing.unlimited_team_members"),
|
||||
t("environments.settings.billing.link_surveys"),
|
||||
t("environments.settings.billing.website_surveys"),
|
||||
t("environments.settings.billing.app_surveys"),
|
||||
t("environments.settings.billing.ios_android_sdks"),
|
||||
t("environments.settings.billing.email_embedded_surveys"),
|
||||
t("environments.settings.billing.logic_jumps_hidden_fields_recurring_surveys"),
|
||||
t("environments.settings.billing.api_webhooks"),
|
||||
t("environments.settings.billing.all_integrations"),
|
||||
t("environments.settings.billing.hosted_in_frankfurt") + " 🇪🇺",
|
||||
],
|
||||
};
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
export const getCloudPricingData = (t: TFnType) => {
|
||||
return {
|
||||
plans: [freePlan, startupPlan, customPlan],
|
||||
plans: [
|
||||
{
|
||||
name: t("environments.settings.billing.free"),
|
||||
id: "free",
|
||||
featured: false,
|
||||
description: t("environments.settings.billing.free_description"),
|
||||
price: { monthly: "$0", yearly: "$0" },
|
||||
mainFeatures: [
|
||||
t("environments.settings.billing.unlimited_surveys"),
|
||||
t("environments.settings.billing.unlimited_team_members"),
|
||||
t("environments.settings.billing.3_projects"),
|
||||
t("environments.settings.billing.1500_monthly_responses"),
|
||||
t("environments.settings.billing.2000_monthly_identified_users"),
|
||||
t("environments.settings.billing.website_surveys"),
|
||||
t("environments.settings.billing.app_surveys"),
|
||||
t("environments.settings.billing.unlimited_apps_websites"),
|
||||
t("environments.settings.billing.link_surveys"),
|
||||
t("environments.settings.billing.email_embedded_surveys"),
|
||||
t("environments.settings.billing.logic_jumps_hidden_fields_recurring_surveys"),
|
||||
t("environments.settings.billing.api_webhooks"),
|
||||
t("environments.settings.billing.all_integrations"),
|
||||
t("environments.settings.billing.all_surveying_features"),
|
||||
],
|
||||
href: "https://app.formbricks.com/auth/signup?plan=free",
|
||||
},
|
||||
{
|
||||
name: t("environments.settings.billing.startup"),
|
||||
id: "startup",
|
||||
featured: false,
|
||||
description: t("environments.settings.billing.startup_description"),
|
||||
price: { monthly: "$39", yearly: "$390 " },
|
||||
mainFeatures: [
|
||||
t("environments.settings.billing.everything_in_free"),
|
||||
t("environments.settings.billing.unlimited_surveys"),
|
||||
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",
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -8,10 +8,19 @@ import { useTranslate } from "@tolgee/react";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { TOrganization, TOrganizationBillingPeriod } from "@formbricks/types/organizations";
|
||||
import { TPricingPlan } from "../api/lib/constants";
|
||||
|
||||
interface PricingCardProps {
|
||||
plan: TPricingPlan;
|
||||
plan: {
|
||||
id: string;
|
||||
name: string;
|
||||
featured: boolean;
|
||||
price: {
|
||||
monthly: string;
|
||||
yearly: string;
|
||||
};
|
||||
mainFeatures: string[];
|
||||
href: string;
|
||||
};
|
||||
planPeriod: TOrganizationBillingPeriod;
|
||||
organization: TOrganization;
|
||||
onUpgrade: () => Promise<void>;
|
||||
@@ -19,6 +28,7 @@ interface PricingCardProps {
|
||||
projectFeatureKeys: {
|
||||
FREE: string;
|
||||
STARTUP: string;
|
||||
SCALE: string;
|
||||
ENTERPRISE: string;
|
||||
};
|
||||
}
|
||||
@@ -62,33 +72,18 @@ export const PricingCard = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
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 (plan.id !== projectFeatureKeys.ENTERPRISE && plan.id !== projectFeatureKeys.FREE) {
|
||||
if (organization.billing.plan === projectFeatureKeys.FREE) {
|
||||
return (
|
||||
<Button
|
||||
loading={loading}
|
||||
variant="default"
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
await onUpgrade();
|
||||
setLoading(false);
|
||||
}}
|
||||
className="flex justify-center">
|
||||
{t(plan.CTA ?? "common.start_free_trial")}
|
||||
{t("common.start_free_trial")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -105,20 +100,15 @@ export const PricingCard = ({
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
return <></>;
|
||||
}, [
|
||||
isCurrentPlan,
|
||||
loading,
|
||||
onUpgrade,
|
||||
organization.billing.plan,
|
||||
plan.CTA,
|
||||
plan.featured,
|
||||
plan.href,
|
||||
plan.id,
|
||||
projectFeatureKeys.ENTERPRISE,
|
||||
projectFeatureKeys.FREE,
|
||||
projectFeatureKeys.STARTUP,
|
||||
t,
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -157,7 +147,7 @@ export const PricingCard = ({
|
||||
: plan.price.yearly
|
||||
: t(plan.price.monthly)}
|
||||
</p>
|
||||
{plan.id !== projectFeatureKeys.ENTERPRISE && (
|
||||
{plan.name !== "Enterprise" && (
|
||||
<div className="text-sm leading-5">
|
||||
<p className={plan.featured ? "text-slate-700" : "text-slate-600"}>
|
||||
/ {planPeriod === "monthly" ? "Month" : "Year"}
|
||||
@@ -181,9 +171,16 @@ export const PricingCard = ({
|
||||
{t("environments.settings.billing.manage_subscription")}
|
||||
</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 className="mt-8 flow-root sm:mt-10">
|
||||
<ul
|
||||
role="list"
|
||||
className={cn(
|
||||
plan.featured
|
||||
? "divide-slate-900/5 border-slate-900/5 text-slate-600"
|
||||
@@ -196,6 +193,7 @@ export const PricingCard = ({
|
||||
className={cn(plan.featured ? "text-brand-dark" : "text-slate-500", "h-6 w-5 flex-none")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{t(mainFeature)}
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -21,12 +21,15 @@ interface PricingTableProps {
|
||||
responseCount: number;
|
||||
projectCount: number;
|
||||
stripePriceLookupKeys: {
|
||||
STARTUP_MAY25_MONTHLY: string;
|
||||
STARTUP_MAY25_YEARLY: string;
|
||||
STARTUP_MONTHLY: string;
|
||||
STARTUP_YEARLY: string;
|
||||
SCALE_MONTHLY: string;
|
||||
SCALE_YEARLY: string;
|
||||
};
|
||||
projectFeatureKeys: {
|
||||
FREE: string;
|
||||
STARTUP: string;
|
||||
SCALE: string;
|
||||
ENTERPRISE: string;
|
||||
};
|
||||
hasBillingRights: boolean;
|
||||
@@ -99,32 +102,35 @@ export const PricingTable = ({
|
||||
throw new Error(t("common.something_went_wrong_please_try_again"));
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
toast.error(err.message);
|
||||
} else {
|
||||
toast.error(t("environments.settings.billing.unable_to_upgrade_plan"));
|
||||
}
|
||||
toast.error(t("environments.settings.billing.unable_to_upgrade_plan"));
|
||||
}
|
||||
};
|
||||
|
||||
const onUpgrade = async (planId: string) => {
|
||||
if (planId === "startup") {
|
||||
if (planId === "scale") {
|
||||
await upgradePlan(
|
||||
planPeriod === "monthly"
|
||||
? stripePriceLookupKeys.STARTUP_MAY25_MONTHLY
|
||||
: stripePriceLookupKeys.STARTUP_MAY25_YEARLY
|
||||
planPeriod === "monthly" ? stripePriceLookupKeys.SCALE_MONTHLY : stripePriceLookupKeys.SCALE_YEARLY
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (planId === "custom") {
|
||||
window.location.href =
|
||||
"https://app.formbricks.com/s/cm7k8esy20001jp030fh8a9o5?source=billingView&delivery=cloud";
|
||||
if (planId === "startup") {
|
||||
await upgradePlan(
|
||||
planPeriod === "monthly"
|
||||
? stripePriceLookupKeys.STARTUP_MONTHLY
|
||||
: stripePriceLookupKeys.STARTUP_YEARLY
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (planId === "enterprise") {
|
||||
window.location.href = "https://cal.com/johannes/license";
|
||||
return;
|
||||
}
|
||||
|
||||
if (planId === "free") {
|
||||
toast.error(t("environments.settings.billing.everybody_has_the_free_plan_by_default"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -227,7 +233,7 @@ export const PricingTable = ({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"relative mx-8 flex flex-col gap-4 pb-6",
|
||||
"relative mx-8 flex flex-col gap-4 pb-12",
|
||||
projectsUnlimitedCheck && "mb-0 mt-4 flex-row pb-0"
|
||||
)}>
|
||||
<p className="text-md font-semibold text-slate-700">{t("common.projects")}</p>
|
||||
@@ -276,7 +282,7 @@ export const PricingTable = ({
|
||||
</span>
|
||||
</button>
|
||||
</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-3">
|
||||
<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="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"
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { deleteContactAction } from "@/modules/ee/contacts/actions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
@@ -49,21 +48,18 @@ export const DeleteContactButton = ({ environmentId, contactId, isReadOnly }: De
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
<button
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(true);
|
||||
}}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
<TrashIcon className="h-5 w-5 text-slate-500 hover:text-red-700" />
|
||||
</button>
|
||||
<DeleteDialog
|
||||
open={deleteDialogOpen}
|
||||
setOpen={setDeleteDialogOpen}
|
||||
deleteWhat="person"
|
||||
onDelete={handleDeletePerson}
|
||||
isDeleting={isDeletingPerson}
|
||||
text={t("environments.contacts.delete_contact_confirmation")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -6,21 +6,12 @@ import { createContactsFromCSVAction } from "@/modules/ee/contacts/actions";
|
||||
import { CsvTable } from "@/modules/ee/contacts/components/csv-table";
|
||||
import { UploadContactsAttributes } from "@/modules/ee/contacts/components/upload-contacts-attribute";
|
||||
import { TContactCSVUploadResponse, ZContactCSVUploadResponse } from "@/modules/ee/contacts/types/contact";
|
||||
import { Alert } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Modal } from "@/modules/ui/components/modal";
|
||||
import { StylingTabs } from "@/modules/ui/components/styling-tabs";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { parse } from "csv-parse/sync";
|
||||
import { ArrowUpFromLineIcon, FileUpIcon, PlusIcon } from "lucide-react";
|
||||
import { ArrowUpFromLineIcon, CircleAlertIcon, FileUpIcon, PlusIcon, XIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
@@ -295,155 +286,190 @@ export const UploadContactsCSVButton = ({
|
||||
{t("common.upload")} CSV
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent disableCloseOnOutsideClick={true} className="overflow-auto">
|
||||
<DialogHeader>
|
||||
<FileUpIcon />
|
||||
<DialogTitle>{t("common.upload")} CSV</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.contacts.upload_contacts_modal_description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<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="no-scrollbar 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>
|
||||
)}
|
||||
<Modal
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
noPadding
|
||||
closeOnOutsideClick={false}
|
||||
className="overflow-auto"
|
||||
size="xl"
|
||||
hideCloseButton>
|
||||
<div className="sticky top-0 flex h-full flex-col rounded-lg">
|
||||
<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>
|
||||
{!csvResponse.length && (
|
||||
<div className="flex justify-start">
|
||||
<Button onClick={handleDownloadExampleCSV} variant="secondary">
|
||||
{t("environments.contacts.upload_contacts_modal_download_example_csv")}
|
||||
</Button>
|
||||
<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>
|
||||
|
||||
{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>
|
||||
</DialogBody>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{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>
|
||||
|
||||
<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 className="sticky bottom-0 w-full bg-white">
|
||||
<div className="flex justify-end rounded-b-lg p-4">
|
||||
{csvResponse.length > 0 ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
resetState();
|
||||
}}>
|
||||
}}
|
||||
className="mr-2">
|
||||
{t("environments.contacts.upload_contacts_modal_pick_different_file")}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Button onClick={handleUpload} loading={loading} disabled={loading || !csvResponse.length}>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleUpload}
|
||||
loading={loading}
|
||||
disabled={loading || !csvResponse.length}>
|
||||
{t("environments.contacts.upload_contacts_modal_upload_btn")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,12 +10,6 @@ vi.mock("jsonwebtoken", () => ({
|
||||
default: {
|
||||
sign: vi.fn(),
|
||||
verify: vi.fn(),
|
||||
TokenExpiredError: class TokenExpiredError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "TokenExpiredError";
|
||||
}
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -151,8 +145,8 @@ describe("Contact Survey Link", () => {
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "bad_request",
|
||||
message: "Invalid survey token",
|
||||
details: [{ field: "token", issue: "invalid_token" }],
|
||||
message: "Invalid or expired survey token",
|
||||
details: [{ field: "token", issue: "Invalid or expired survey token" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -172,8 +166,8 @@ describe("Contact Survey Link", () => {
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "bad_request",
|
||||
message: "Invalid survey token",
|
||||
details: [{ field: "token", issue: "invalid_token" }],
|
||||
message: "Invalid or expired survey token",
|
||||
details: [{ field: "token", issue: "Invalid or expired survey token" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
// Creates an encrypted personalized survey link for a contact
|
||||
@@ -74,22 +73,11 @@ export const verifyContactSurveyToken = (
|
||||
surveyId,
|
||||
});
|
||||
} catch (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
|
||||
console.error("Error verifying contact survey token:", error);
|
||||
return err({
|
||||
type: "bad_request",
|
||||
message: "Invalid survey token",
|
||||
details: [{ field: "token", issue: "invalid_token" }],
|
||||
message: "Invalid or expired survey token",
|
||||
details: [{ field: "token", issue: "Invalid or expired survey token" }],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,31 +6,10 @@ import {
|
||||
buildContactWhereClause,
|
||||
createContactsFromCSV,
|
||||
deleteContact,
|
||||
generatePersonalLinks,
|
||||
getContact,
|
||||
getContacts,
|
||||
getContactsInSegment,
|
||||
} 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", () => ({
|
||||
prisma: {
|
||||
contact: {
|
||||
@@ -52,18 +31,11 @@ vi.mock("@formbricks/database", () => ({
|
||||
},
|
||||
},
|
||||
}));
|
||||
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",
|
||||
}));
|
||||
vi.mock("@/lib/constants", () => ({ ITEMS_PER_PAGE: 2 }));
|
||||
|
||||
const environmentId = "cm123456789012345678901237";
|
||||
const contactId = "cm123456789012345678901238";
|
||||
const userId = "cm123456789012345678901239";
|
||||
const environmentId = "env1";
|
||||
const contactId = "contact1";
|
||||
const userId = "user1";
|
||||
const mockContact: Contact & {
|
||||
attributes: { value: string; attributeKey: { key: string; name: string } }[];
|
||||
} = {
|
||||
@@ -187,7 +159,7 @@ describe("createContactsFromCSV", () => {
|
||||
.mockResolvedValueOnce([
|
||||
{ key: "email", id: "id-email" },
|
||||
{ key: "name", id: "id-name" },
|
||||
] as any);
|
||||
]);
|
||||
vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 2 });
|
||||
vi.mocked(prisma.contact.create).mockResolvedValue({
|
||||
id: "c1",
|
||||
@@ -211,12 +183,12 @@ describe("createContactsFromCSV", () => {
|
||||
test("skips duplicate contact with 'skip' action", async () => {
|
||||
vi.mocked(prisma.contact.findMany).mockResolvedValue([
|
||||
{ id: "c1", attributes: [{ attributeKey: { key: "email" }, value: "john@example.com" }] },
|
||||
] as any);
|
||||
]);
|
||||
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([
|
||||
{ key: "email", id: "id-email" },
|
||||
{ key: "name", id: "id-name" },
|
||||
] as any);
|
||||
]);
|
||||
const csvData = [{ email: "john@example.com", name: "John" }];
|
||||
const result = await createContactsFromCSV(csvData, environmentId, "skip", {
|
||||
email: "email",
|
||||
@@ -234,12 +206,12 @@ describe("createContactsFromCSV", () => {
|
||||
{ attributeKey: { key: "name" }, value: "Old" },
|
||||
],
|
||||
},
|
||||
] as any);
|
||||
]);
|
||||
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([
|
||||
{ key: "email", id: "id-email" },
|
||||
{ key: "name", id: "id-name" },
|
||||
] as any);
|
||||
]);
|
||||
vi.mocked(prisma.contact.update).mockResolvedValue({
|
||||
id: "c1",
|
||||
environmentId,
|
||||
@@ -267,12 +239,12 @@ describe("createContactsFromCSV", () => {
|
||||
{ attributeKey: { key: "name" }, value: "Old" },
|
||||
],
|
||||
},
|
||||
] as any);
|
||||
]);
|
||||
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([
|
||||
{ key: "email", id: "id-email" },
|
||||
{ key: "name", id: "id-name" },
|
||||
] as any);
|
||||
]);
|
||||
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 2 });
|
||||
vi.mocked(prisma.contact.update).mockResolvedValue({
|
||||
id: "c1",
|
||||
@@ -354,333 +326,8 @@ describe("buildContactWhereClause", () => {
|
||||
});
|
||||
|
||||
test("returns where clause without search", () => {
|
||||
const environmentId = "cm123456789012345678901240";
|
||||
const environmentId = "env-1";
|
||||
const result = buildContactWhereClause(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,13 +1,9 @@
|
||||
import "server-only";
|
||||
import { ITEMS_PER_PAGE } from "@/lib/constants";
|
||||
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 { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId, ZOptionalNumber, ZOptionalString } from "@formbricks/types/common";
|
||||
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
|
||||
import {
|
||||
@@ -19,76 +15,6 @@ import {
|
||||
} from "../types/contact";
|
||||
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 = {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
@@ -492,37 +418,3 @@ export const createContactsFromCSV = async (
|
||||
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,66 +6,47 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
|
||||
// Mock the Dialog components
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: ({
|
||||
// Mock the Modal component
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({
|
||||
children,
|
||||
open,
|
||||
onOpenChange,
|
||||
closeOnOutsideClick,
|
||||
setOpen,
|
||||
}: {
|
||||
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,
|
||||
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} />
|
||||
),
|
||||
closeOnOutsideClick?: boolean;
|
||||
setOpen?: (open: boolean) => void;
|
||||
}) => {
|
||||
return open ? ( // NOSONAR // This is a mock
|
||||
<button
|
||||
data-testid="modal-overlay"
|
||||
onClick={(e) => {
|
||||
if (closeOnOutsideClick && e.target === e.currentTarget && setOpen) {
|
||||
setOpen(false);
|
||||
}
|
||||
}}>
|
||||
<div data-testid="modal-content">{children}</div>
|
||||
</button>
|
||||
) : null; // NOSONAR // This is a mock
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the TabBar component
|
||||
vi.mock("@/modules/ui/components/tab-bar", () => ({
|
||||
TabBar: ({ tabs, activeId, setActiveId }: any) => (
|
||||
<div data-testid="tab-bar">
|
||||
{tabs.map((tab: any) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
data-testid={`tab-${tab.id}`}
|
||||
onClick={() => setActiveId(tab.id)}
|
||||
className={activeId === tab.id ? "active" : ""}>
|
||||
TabBar: ({
|
||||
tabs,
|
||||
activeId,
|
||||
setActiveId,
|
||||
}: {
|
||||
tabs: any[];
|
||||
activeId: string;
|
||||
setActiveId: (id: string) => void;
|
||||
}) => (
|
||||
<div>
|
||||
{tabs.map((tab) => (
|
||||
<button key={tab.id} data-testid={`tab-${tab.id}`} onClick={() => setActiveId(tab.id)}>
|
||||
{tab.label} {activeId === tab.id ? "(Active)" : ""}
|
||||
</button>
|
||||
))}
|
||||
@@ -73,94 +54,11 @@ 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
|
||||
vi.mock("@paralleldrive/cuid2", () => ({
|
||||
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[] = [
|
||||
{
|
||||
id: "attr1",
|
||||
@@ -256,20 +154,16 @@ describe("AddFilterModal", () => {
|
||||
/>
|
||||
);
|
||||
// ... 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.getByTestId("tab-all")).toHaveTextContent("All (Active)");
|
||||
expect(screen.getByTestId("tab-all")).toHaveTextContent("common.all (Active)");
|
||||
expect(screen.getByText("Email Address")).toBeInTheDocument();
|
||||
expect(screen.getByText("Plan Type")).toBeInTheDocument();
|
||||
expect(screen.getByText("userId")).toBeInTheDocument();
|
||||
expect(screen.getByText("Active Users")).toBeInTheDocument();
|
||||
expect(screen.getByText("Paying Customers")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Private Segment")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Phone")).toBeInTheDocument();
|
||||
expect(screen.getByText("Desktop")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.segments.phone")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.segments.desktop")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does not render when closed", () => {
|
||||
@@ -282,7 +176,6 @@ describe("AddFilterModal", () => {
|
||||
segments={mockSegments}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText("Browse filters...")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -317,22 +210,22 @@ describe("AddFilterModal", () => {
|
||||
const attributesTabButton = screen.getByTestId("tab-attributes");
|
||||
await user.click(attributesTabButton);
|
||||
// ... assertions ...
|
||||
expect(attributesTabButton).toHaveTextContent("Person & Attributes (Active)");
|
||||
expect(screen.getByText("userId")).toBeInTheDocument();
|
||||
expect(attributesTabButton).toHaveTextContent("environments.segments.person_and_attributes (Active)");
|
||||
expect(screen.getByText("common.user_id")).toBeInTheDocument();
|
||||
|
||||
// Switch to Segments tab
|
||||
const segmentsTabButton = screen.getByTestId("tab-segments");
|
||||
await user.click(segmentsTabButton);
|
||||
// ... assertions ...
|
||||
expect(segmentsTabButton).toHaveTextContent("Segments (Active)");
|
||||
expect(segmentsTabButton).toHaveTextContent("common.segments (Active)");
|
||||
expect(screen.getByText("Active Users")).toBeInTheDocument();
|
||||
|
||||
// Switch to Devices tab
|
||||
const devicesTabButton = screen.getByTestId("tab-devices");
|
||||
await user.click(devicesTabButton);
|
||||
// ... assertions ...
|
||||
expect(devicesTabButton).toHaveTextContent("Devices (Active)");
|
||||
expect(screen.getByText("Phone")).toBeInTheDocument();
|
||||
expect(devicesTabButton).toHaveTextContent("environments.segments.devices (Active)");
|
||||
expect(screen.getByText("environments.segments.phone")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// --- Click and Keydown Tests ---
|
||||
@@ -606,7 +499,7 @@ describe("AddFilterModal", () => {
|
||||
/>
|
||||
);
|
||||
await user.click(screen.getByTestId("tab-attributes"));
|
||||
expect(await screen.findByText("No attributes yet")).toBeInTheDocument();
|
||||
expect(await screen.findByText("environments.segments.no_attributes_yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays 'no segments yet' message", async () => {
|
||||
@@ -620,7 +513,7 @@ describe("AddFilterModal", () => {
|
||||
/>
|
||||
);
|
||||
await user.click(screen.getByTestId("tab-segments"));
|
||||
expect(await screen.findByText("No segments yet")).toBeInTheDocument();
|
||||
expect(await screen.findByText("environments.segments.no_segments_yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays 'no filters match' message when search yields no results", async () => {
|
||||
@@ -635,7 +528,7 @@ describe("AddFilterModal", () => {
|
||||
);
|
||||
const searchInput = screen.getByPlaceholderText("Browse filters...");
|
||||
await user.type(searchInput, "nonexistentfilter");
|
||||
expect(await screen.findByText("No filters yet")).toBeInTheDocument();
|
||||
expect(await screen.findByText("environments.segments.no_filters_yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("verifies keyboard navigation through filter buttons", async () => {
|
||||
@@ -655,19 +548,19 @@ describe("AddFilterModal", () => {
|
||||
|
||||
// Tab to the first tab button ("all")
|
||||
await user.tab();
|
||||
expect(document.activeElement).toHaveTextContent(/All/);
|
||||
expect(document.activeElement).toHaveTextContent(/common\.all/);
|
||||
|
||||
// Tab to the second tab button ("attributes")
|
||||
await user.tab();
|
||||
expect(document.activeElement).toHaveTextContent(/Person & Attributes/);
|
||||
expect(document.activeElement).toHaveTextContent(/person_and_attributes/);
|
||||
|
||||
// Tab to the third tab button ("segments")
|
||||
await user.tab();
|
||||
expect(document.activeElement).toHaveTextContent(/Segments/);
|
||||
expect(document.activeElement).toHaveTextContent(/common\.segments/);
|
||||
|
||||
// Tab to the fourth tab button ("devices")
|
||||
await user.tab();
|
||||
expect(document.activeElement).toHaveTextContent(/Devices/);
|
||||
expect(document.activeElement).toHaveTextContent(/environments\.segments\.devices/);
|
||||
|
||||
// Tab to the first filter button ("Email Address")
|
||||
await user.tab();
|
||||
@@ -702,4 +595,21 @@ describe("AddFilterModal", () => {
|
||||
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";
|
||||
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Dialog, DialogBody, DialogContent, DialogHeader, DialogTitle } from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Modal } from "@/modules/ui/components/modal";
|
||||
import { TabBar } from "@/modules/ui/components/tab-bar";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
@@ -457,31 +457,26 @@ export function AddFilterModal({
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent width="narrow" disableCloseOnOutsideClick>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("common.add_filter")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Modal
|
||||
className="sm:w-[650px] sm:max-w-full"
|
||||
closeOnOutsideClick
|
||||
hideCloseButton
|
||||
open={open}
|
||||
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>
|
||||
|
||||
<DialogBody>
|
||||
<div className="flex flex-col">
|
||||
<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 flex-col gap-1 overflow-y-auto")}>
|
||||
<TabContent />
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<div className={cn("mt-2 flex max-h-80 flex-col gap-1 overflow-y-auto")}>
|
||||
<TabContent />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,55 +27,16 @@ vi.mock("@/modules/ee/contacts/segments/actions", () => ({
|
||||
}));
|
||||
|
||||
// Mock child components that are complex or have their own tests
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: ({
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}) =>
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ open, setOpen, children, noPadding, closeOnOutsideClick, size, className }) =>
|
||||
open ? (
|
||||
<div data-testid="dialog">
|
||||
<div data-testid="modal" className={className} data-size={size} data-nopadding={noPadding}>
|
||||
{children}
|
||||
<button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
<button data-testid="modal-close-outside" onClick={() => closeOnOutsideClick && setOpen(false)}>
|
||||
Close Outside
|
||||
</button>
|
||||
</div>
|
||||
) : 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", () => ({
|
||||
@@ -123,12 +84,12 @@ describe("CreateSegmentModal", () => {
|
||||
render(<CreateSegmentModal {...defaultProps} />);
|
||||
const createButton = screen.getByText("common.create_segment");
|
||||
expect(createButton).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(createButton);
|
||||
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.create_segment", { selector: "h2" })).toBeInTheDocument(); // Modal title
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.create_segment", { selector: "h3" })).toBeInTheDocument(); // Modal title
|
||||
});
|
||||
|
||||
test("closes modal on cancel button click", async () => {
|
||||
@@ -136,11 +97,11 @@ describe("CreateSegmentModal", () => {
|
||||
const createButton = screen.getByText("common.create_segment");
|
||||
await userEvent.click(createButton);
|
||||
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
const cancelButton = screen.getByText("common.cancel");
|
||||
await userEvent.click(cancelButton);
|
||||
|
||||
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("updates title and description state on input change", async () => {
|
||||
@@ -183,7 +144,7 @@ describe("CreateSegmentModal", () => {
|
||||
await userEvent.click(openModalButton);
|
||||
|
||||
// Get modal and scope queries
|
||||
const modal = await screen.findByTestId("dialog");
|
||||
const modal = await screen.findByTestId("modal");
|
||||
|
||||
// Find the save button using getByText with a specific selector within the modal
|
||||
const saveButton = within(modal).getByText("common.create_segment", {
|
||||
@@ -207,7 +168,7 @@ describe("CreateSegmentModal", () => {
|
||||
await userEvent.click(createButton);
|
||||
|
||||
// Get modal and scope queries
|
||||
const modal = await screen.findByTestId("dialog");
|
||||
const modal = await screen.findByTestId("modal");
|
||||
|
||||
const titleInput = within(modal).getByPlaceholderText("environments.segments.ex_power_users");
|
||||
const descriptionInput = within(modal).getByPlaceholderText(
|
||||
@@ -235,7 +196,7 @@ describe("CreateSegmentModal", () => {
|
||||
});
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith("environments.segments.segment_saved_successfully");
|
||||
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument(); // Modal should close on success
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); // Modal should close on success
|
||||
});
|
||||
|
||||
test("shows error toast if createSegmentAction fails", async () => {
|
||||
@@ -258,7 +219,7 @@ describe("CreateSegmentModal", () => {
|
||||
});
|
||||
expect(getFormattedErrorMessage).toHaveBeenCalledWith(errorResponse);
|
||||
expect(toast.error).toHaveBeenCalledWith("Formatted API Error");
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument(); // Modal should stay open on error
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument(); // Modal should stay open on error
|
||||
});
|
||||
|
||||
test("shows generic error toast if Zod parsing succeeds during save error handling", async () => {
|
||||
@@ -269,7 +230,7 @@ describe("CreateSegmentModal", () => {
|
||||
await userEvent.click(openModalButton);
|
||||
|
||||
// Get the modal element
|
||||
const modal = await screen.findByTestId("dialog");
|
||||
const modal = await screen.findByTestId("modal");
|
||||
|
||||
const titleInput = within(modal).getByPlaceholderText("environments.segments.ex_power_users");
|
||||
await userEvent.type(titleInput, "Generic Error Segment");
|
||||
@@ -292,7 +253,7 @@ describe("CreateSegmentModal", () => {
|
||||
|
||||
// Now that we know the catch block ran, verify the action was called
|
||||
expect(createSegmentAction).toHaveBeenCalled();
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument(); // Modal should stay open
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument(); // Modal should stay open
|
||||
});
|
||||
|
||||
test("opens AddFilterModal when 'Add Filter' button is clicked", async () => {
|
||||
|
||||
@@ -4,16 +4,8 @@ import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { createSegmentAction } from "@/modules/ee/contacts/segments/actions";
|
||||
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 { Modal } from "@/modules/ui/components/modal";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { FilterIcon, PlusIcon, UsersIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -140,30 +132,41 @@ export function CreateSegmentModal({
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
|
||||
<Dialog
|
||||
<Modal
|
||||
className="md:w-full"
|
||||
closeOnOutsideClick={false}
|
||||
noPadding
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
handleResetState();
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-4xl" disableCloseOnOutsideClick>
|
||||
<DialogHeader>
|
||||
<UsersIcon />
|
||||
<DialogTitle>{t("common.create_segment")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.segments.segments_help_you_target_users_with_same_characteristics_easily")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
setOpen={() => {
|
||||
handleResetState();
|
||||
}}
|
||||
size="lg">
|
||||
<div className="rounded-lg bg-slate-50">
|
||||
<div className="rounded-t-lg bg-slate-100">
|
||||
<div className="flex w-full items-center gap-4 p-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="mr-1.5 h-6 w-6 text-slate-500">
|
||||
<UsersIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-medium">{t("common.create_segment")}</h3>
|
||||
<p className="text-sm text-slate-600">
|
||||
{t(
|
||||
"environments.segments.segments_help_you_target_users_with_same_characteristics_easily"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogBody>
|
||||
<div className="flex flex-col overflow-auto rounded-lg bg-white p-6">
|
||||
<div className="flex w-full items-center gap-4">
|
||||
<div className="flex w-1/2 flex-col gap-2">
|
||||
<label className="text-sm font-medium text-slate-900">{t("common.title")}</label>
|
||||
<div className="relative flex flex-col gap-1">
|
||||
<Input
|
||||
className="w-auto"
|
||||
value={segment.title}
|
||||
onChange={(e) => {
|
||||
setSegment((prev) => ({
|
||||
...prev,
|
||||
@@ -178,7 +181,6 @@ export function CreateSegmentModal({
|
||||
<div className="flex w-1/2 flex-col gap-2">
|
||||
<label className="text-sm font-medium text-slate-900">{t("common.description")}</label>
|
||||
<Input
|
||||
value={segment.description ?? ""}
|
||||
onChange={(e) => {
|
||||
setSegment((prev) => ({
|
||||
...prev,
|
||||
@@ -189,71 +191,72 @@ export function CreateSegmentModal({
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
|
||||
<SegmentEditor
|
||||
contactAttributeKeys={contactAttributeKeys}
|
||||
environmentId={environmentId}
|
||||
group={segment.filters}
|
||||
segment={segment}
|
||||
segments={segments}
|
||||
setSegment={setSegment}
|
||||
/>
|
||||
<label className="my-4 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>
|
||||
)}
|
||||
|
||||
<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
|
||||
className="w-fit"
|
||||
onClick={() => {
|
||||
setAddFilterModalOpen(true);
|
||||
handleResetState();
|
||||
}}
|
||||
size="sm"
|
||||
variant="secondary">
|
||||
{t("common.add_filter")}
|
||||
type="button"
|
||||
variant="ghost">
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
|
||||
<AddFilterModal
|
||||
contactAttributeKeys={contactAttributeKeys}
|
||||
onAddFilter={(filter) => {
|
||||
handleAddFilterInGroup(filter);
|
||||
<Button
|
||||
disabled={isSaveDisabled}
|
||||
loading={isCreatingSegment}
|
||||
onClick={() => {
|
||||
handleCreateSegment();
|
||||
}}
|
||||
open={addFilterModalOpen}
|
||||
segments={segments}
|
||||
setOpen={setAddFilterModalOpen}
|
||||
/>
|
||||
type="submit">
|
||||
{t("common.create_segment")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,80 +1,32 @@
|
||||
import { EditSegmentModal } from "@/modules/ee/contacts/segments/components/edit-segment-modal";
|
||||
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 { TSegmentWithSurveyNames } from "@formbricks/types/segment";
|
||||
|
||||
// Mock child components
|
||||
vi.mock("@/modules/ee/contacts/segments/components/segment-settings", () => ({
|
||||
SegmentSettings: vi.fn(() => <div data-testid="segment-settings">SegmentSettingsMock</div>),
|
||||
SegmentSettings: vi.fn(() => <div>SegmentSettingsMock</div>),
|
||||
}));
|
||||
vi.mock("@/modules/ee/contacts/segments/components/segment-activity-tab", () => ({
|
||||
SegmentActivityTab: vi.fn(() => <div data-testid="segment-activity-tab">SegmentActivityTabMock</div>),
|
||||
SegmentActivityTab: vi.fn(() => <div>SegmentActivityTabMock</div>),
|
||||
}));
|
||||
|
||||
// Mock the Dialog components
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: ({
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}) =>
|
||||
vi.mock("@/modules/ui/components/modal-with-tabs", () => ({
|
||||
ModalWithTabs: vi.fn(({ open, label, description, tabs, icon }) =>
|
||||
open ? (
|
||||
<div data-testid="dialog">
|
||||
{children}
|
||||
<button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</button>
|
||||
<div>
|
||||
<h1>{label}</h1>
|
||||
<p>{description}</p>
|
||||
<div>{icon}</div>
|
||||
<ul>
|
||||
{tabs.map((tab) => (
|
||||
<li key={tab.title}>
|
||||
<h2>{tab.title}</h2>
|
||||
<div>{tab.children}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</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 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>
|
||||
) : null
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -110,92 +62,77 @@ describe("EditSegmentModal", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders correctly when open and contacts enabled", () => {
|
||||
test("renders correctly when open and contacts enabled", async () => {
|
||||
render(<EditSegmentModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Segment");
|
||||
expect(screen.getByTestId("dialog-description")).toHaveTextContent("This is a test segment");
|
||||
expect(screen.getByTestId("users-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText("Activity")).toBeInTheDocument();
|
||||
expect(screen.getByText("Settings")).toBeInTheDocument();
|
||||
// Only the first tab (Activity) should be active initially
|
||||
expect(screen.getByTestId("segment-activity-tab")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("segment-settings")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Test Segment")).toBeInTheDocument();
|
||||
expect(screen.getByText("This is a test segment")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.activity")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.settings")).toBeInTheDocument();
|
||||
expect(screen.getByText("SegmentActivityTabMock")).toBeInTheDocument();
|
||||
expect(screen.getByText("SegmentSettingsMock")).toBeInTheDocument();
|
||||
|
||||
const ModalWithTabsMock = vi.mocked(
|
||||
await import("@/modules/ui/components/modal-with-tabs")
|
||||
).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", () => {
|
||||
test("renders correctly when open and contacts disabled", async () => {
|
||||
render(<EditSegmentModal {...defaultProps} isContactsEnabled={false} />);
|
||||
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Segment");
|
||||
expect(screen.getByTestId("dialog-description")).toHaveTextContent("This is a test segment");
|
||||
expect(screen.getByText("Activity")).toBeInTheDocument();
|
||||
expect(screen.getByText("Settings")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("segment-activity-tab")).toBeInTheDocument();
|
||||
// Settings tab content should not render when contacts are disabled
|
||||
expect(screen.queryByTestId("segment-settings")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Test Segment")).toBeInTheDocument();
|
||||
expect(screen.getByText("This is a test segment")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.activity")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.settings")).toBeInTheDocument(); // Tab title still exists
|
||||
expect(screen.getByText("SegmentActivityTabMock")).toBeInTheDocument();
|
||||
// Check that the settings content is not rendered, which is the key behavior
|
||||
expect(screen.queryByText("SegmentSettingsMock")).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", () => {
|
||||
render(<EditSegmentModal {...defaultProps} open={false} />);
|
||||
|
||||
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Test Segment")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Activity")).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("");
|
||||
expect(screen.queryByText("common.activity")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("common.settings")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { SegmentSettings } from "@/modules/ee/contacts/segments/components/segment-settings";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { ModalWithTabs } from "@/modules/ui/components/modal-with-tabs";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { UsersIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TSegment, TSegmentWithSurveyNames } from "@formbricks/types/segment";
|
||||
import { SegmentActivityTab } from "./segment-activity-tab";
|
||||
@@ -38,8 +30,6 @@ export const EditSegmentModal = ({
|
||||
isReadOnly,
|
||||
}: EditSegmentModalProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
|
||||
const SettingsTab = () => {
|
||||
if (isContactsEnabled) {
|
||||
return (
|
||||
@@ -68,42 +58,17 @@ export const EditSegmentModal = ({
|
||||
},
|
||||
];
|
||||
|
||||
const handleTabClick = (index: number) => {
|
||||
setActiveTab(index);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setActiveTab(0);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent disableCloseOnOutsideClick>
|
||||
<DialogHeader>
|
||||
<UsersIcon />
|
||||
<DialogTitle>{currentSegment.title}</DialogTitle>
|
||||
<DialogDescription>{currentSegment.description ?? ""}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<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>
|
||||
<>
|
||||
<ModalWithTabs
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
tabs={tabs}
|
||||
icon={<UsersIcon className="h-5 w-5" />}
|
||||
label={currentSegment.title}
|
||||
description={currentSegment.description || ""}
|
||||
closeOnOutsideClick={false}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -138,7 +138,7 @@ export function SegmentSettings({
|
||||
}, [segment]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<div className="rounded-lg bg-slate-50">
|
||||
<div className="flex flex-col overflow-auto rounded-lg bg-white">
|
||||
<div className="flex w-full items-center gap-4">
|
||||
@@ -179,51 +179,50 @@ export function SegmentSettings({
|
||||
</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 max-h-96 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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
<label className="my-4 text-sm font-medium text-slate-900">{t("common.targeting")}</label>
|
||||
<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">
|
||||
{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>
|
||||
)}
|
||||
|
||||
<AddFilterModal
|
||||
contactAttributeKeys={contactAttributeKeys}
|
||||
onAddFilter={(filter) => {
|
||||
handleAddFilterInGroup(filter);
|
||||
<SegmentEditor
|
||||
contactAttributeKeys={contactAttributeKeys}
|
||||
environmentId={environmentId}
|
||||
group={segment.filters}
|
||||
segment={segment}
|
||||
segments={segments}
|
||||
setSegment={setSegment}
|
||||
viewOnly={isReadOnly}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setAddFilterModalOpen(true);
|
||||
}}
|
||||
open={addFilterModalOpen}
|
||||
segments={segments}
|
||||
setOpen={setAddFilterModalOpen}
|
||||
/>
|
||||
size="sm"
|
||||
disabled={isReadOnly}
|
||||
variant="secondary">
|
||||
{t("common.add_filter")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AddFilterModal
|
||||
contactAttributeKeys={contactAttributeKeys}
|
||||
onAddFilter={(filter) => {
|
||||
handleAddFilterInGroup(filter);
|
||||
}}
|
||||
open={addFilterModalOpen}
|
||||
segments={segments}
|
||||
setOpen={setAddFilterModalOpen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center justify-between pt-4">
|
||||
{!isReadOnly && (
|
||||
<>
|
||||
|
||||
@@ -6,24 +6,8 @@ import toast from "react-hot-toast";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { CreateTeamModal } from "./create-team-modal";
|
||||
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
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/ui/components/modal", () => ({
|
||||
Modal: ({ children }: any) => <div data-testid="Modal">{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/teams/team-list/actions", () => ({
|
||||
@@ -40,13 +24,9 @@ describe("CreateTeamModal", () => {
|
||||
|
||||
const setOpen = vi.fn();
|
||||
|
||||
test("renders dialog, form, and tolgee strings", () => {
|
||||
test("renders modal, form, and tolgee strings", () => {
|
||||
render(<CreateTeamModal open={true} setOpen={setOpen} organizationId="org-1" />);
|
||||
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.getByTestId("Modal")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.settings.teams.create_new_team")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.settings.teams.team_name")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.cancel")).toBeInTheDocument();
|
||||
@@ -67,7 +47,7 @@ describe("CreateTeamModal", () => {
|
||||
expect(screen.getByText("environments.settings.teams.create")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("calls createTeamAction, shows success toast, calls onCreate, refreshes and closes dialog on success", async () => {
|
||||
test("calls createTeamAction, shows success toast, calls onCreate, refreshes and closes modal on success", async () => {
|
||||
vi.mocked(createTeamAction).mockResolvedValue({ data: "team-123" });
|
||||
const onCreate = vi.fn();
|
||||
render(<CreateTeamModal open={true} setOpen={setOpen} organizationId="org-1" onCreate={onCreate} />);
|
||||
|
||||
@@ -3,16 +3,10 @@
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { createTeamAction } from "@/modules/ee/teams/team-list/actions";
|
||||
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 { 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 { UsersIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -54,45 +48,45 @@ export const CreateTeamModal = ({ open, setOpen, organizationId, onCreate }: Cre
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<UsersIcon />
|
||||
<DialogTitle>{t("environments.settings.teams.create_new_team")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleTeamCreation} className="gap-y-4 pt-4">
|
||||
<DialogBody>
|
||||
<div className="grid w-full gap-y-2 pb-4">
|
||||
<Label htmlFor="team-name">{t("environments.settings.teams.team_name")}</Label>
|
||||
<Input
|
||||
id="team-name"
|
||||
name="team-name"
|
||||
value={teamName}
|
||||
onChange={(e) => {
|
||||
setTeamName(e.target.value);
|
||||
}}
|
||||
placeholder={t("environments.settings.teams.enter_team_name")}
|
||||
/>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setTeamName("");
|
||||
}}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button disabled={!teamName || isLoading} loading={isLoading} type="submit">
|
||||
{t("environments.settings.teams.create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Modal noPadding closeOnOutsideClick={true} size="md" open={open} setOpen={setOpen}>
|
||||
<div className="rounded-t-lg bg-slate-100">
|
||||
<div className="flex w-full items-center gap-4 p-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<UsersIcon className="h-5 w-5" />
|
||||
<H4>{t("environments.settings.teams.create_new_team")}</H4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleTeamCreation}>
|
||||
<div className="flex flex-col overflow-auto rounded-lg bg-white p-6">
|
||||
<Label htmlFor="team-name" className="mb-1 text-sm font-medium text-slate-900">
|
||||
{t("environments.settings.teams.team_name")}
|
||||
</Label>
|
||||
<Input
|
||||
id="team-name"
|
||||
name="team-name"
|
||||
value={teamName}
|
||||
onChange={(e) => {
|
||||
setTeamName(e.target.value);
|
||||
}}
|
||||
placeholder={t("environments.settings.teams.enter_team_name")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end justify-end gap-2 p-6 pt-0">
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setTeamName("");
|
||||
}}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button disabled={!teamName || isLoading} loading={isLoading} type="submit">
|
||||
{t("environments.settings.teams.create")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -42,7 +42,7 @@ export const DeleteTeam = ({ teamId, onDelete, isOwnerOrManager }: DeleteTeamPro
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row items-baseline space-x-2">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Label htmlFor="deleteTeamButton">{t("common.danger_zone")}</Label>
|
||||
<TooltipRenderer
|
||||
shouldRender={!isOwnerOrManager}
|
||||
@@ -50,6 +50,7 @@ export const DeleteTeam = ({ teamId, onDelete, isOwnerOrManager }: DeleteTeamPro
|
||||
className="w-auto">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
type="button"
|
||||
id="deleteTeamButton"
|
||||
className="w-auto"
|
||||
|
||||
@@ -7,49 +7,8 @@ import toast from "react-hot-toast";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TeamSettingsModal } from "./team-settings-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, 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/ui/components/modal", () => ({
|
||||
Modal: ({ children, ...props }: any) => <div data-testid="Modal">{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/teams/team-list/components/team-settings/delete-team", () => ({
|
||||
@@ -101,15 +60,15 @@ describe("TeamSettingsModal", () => {
|
||||
currentUserId="1"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("Modal")).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("common.team_name")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.members")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.settings.teams.add_members_description")).toBeInTheDocument();
|
||||
expect(screen.getByText("Add member")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.projects")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.add_project")).toBeInTheDocument();
|
||||
expect(screen.getByText("Projects")).toBeInTheDocument();
|
||||
expect(screen.getByText("Add project")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.settings.teams.add_projects_description")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.cancel")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.save")).toBeInTheDocument();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { ZTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
||||
@@ -16,17 +17,9 @@ import {
|
||||
} from "@/modules/ee/teams/team-list/types/team";
|
||||
import { getTeamAccessFlags } from "@/modules/ee/teams/utils/teams";
|
||||
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 { Input } from "@/modules/ui/components/input";
|
||||
import { Modal } from "@/modules/ui/components/modal";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -35,10 +28,10 @@ import {
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
import { Muted } from "@/modules/ui/components/typography";
|
||||
import { H4, Muted } from "@/modules/ui/components/typography";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { PlusIcon, Trash2Icon, XIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
import { FormProvider, SubmitHandler, useForm, useWatch } from "react-hook-form";
|
||||
@@ -203,19 +196,44 @@ export const TeamSettingsModal = ({
|
||||
const hasEmptyProject = watchProjects.some((p) => !p.projectId);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader className="pb-4">
|
||||
<DialogTitle>
|
||||
{t("environments.settings.teams.team_name_settings_title", {
|
||||
teamName: team.name,
|
||||
})}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t("environments.settings.teams.team_settings_description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FormProvider {...form}>
|
||||
<form className="contents space-y-4" onSubmit={handleSubmit(handleUpdateTeam)}>
|
||||
<DialogBody className="flex-grow space-y-6 overflow-y-auto">
|
||||
<Modal
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
noPadding
|
||||
className="flex max-h-[90dvh] flex-col overflow-visible"
|
||||
size="md"
|
||||
hideCloseButton
|
||||
closeOnOutsideClick={true}>
|
||||
<div className="sticky top-0 z-10 rounded-t-lg bg-slate-100">
|
||||
<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={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
|
||||
control={control}
|
||||
name="name"
|
||||
@@ -237,18 +255,13 @@ export const TeamSettingsModal = ({
|
||||
|
||||
{/* Members Section */}
|
||||
<div className="space-y-2">
|
||||
<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>
|
||||
<FormLabel>{t("common.members")}</FormLabel>
|
||||
<FormField
|
||||
control={control}
|
||||
name={`members`}
|
||||
render={({ fieldState: { error } }) => (
|
||||
<FormItem className="flex-1">
|
||||
<div className="space-y-2 overflow-y-auto">
|
||||
<div className="max-h-40 space-y-2 overflow-y-auto p-1">
|
||||
{watchMembers.map((member, index) => {
|
||||
const memberOpts = getMemberOptionsForIndex(index);
|
||||
return (
|
||||
@@ -369,22 +382,20 @@ export const TeamSettingsModal = ({
|
||||
<span>Add member</span>
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
<Muted className="block text-slate-500">
|
||||
{t("environments.settings.teams.add_members_description")}
|
||||
</Muted>
|
||||
</div>
|
||||
|
||||
{/* Projects Section */}
|
||||
<div className="space-y-2">
|
||||
<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>
|
||||
<FormLabel>Projects</FormLabel>
|
||||
<FormField
|
||||
control={control}
|
||||
name={`projects`}
|
||||
render={({ fieldState: { error } }) => (
|
||||
<FormItem className="flex-1">
|
||||
<div className="space-y-2">
|
||||
<div className="max-h-40 space-y-2 overflow-y-auto p-1">
|
||||
{watchProjects.map((project, index) => {
|
||||
const projectOpts = getProjectOptionsForIndex(index);
|
||||
return (
|
||||
@@ -484,19 +495,26 @@ export const TeamSettingsModal = ({
|
||||
!isOwnerOrManager || selectedProjectIds.length === orgProjects.length || hasEmptyProject
|
||||
}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
{t("common.add_project")}
|
||||
<span>Add project</span>
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
|
||||
<Muted className="block text-slate-500">
|
||||
{t("environments.settings.teams.add_projects_description")}
|
||||
</Muted>
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<div className="w-full">
|
||||
|
||||
<div className="w-max">
|
||||
<DeleteTeam
|
||||
teamId={team.id}
|
||||
onDelete={closeSettingsModal}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
/>
|
||||
</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}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
@@ -507,10 +525,10 @@ export const TeamSettingsModal = ({
|
||||
disabled={!isOwnerOrManager && !isTeamAdminMember}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,23 +14,6 @@ vi.mock("@/modules/ee/two-factor-auth/actions", () => ({
|
||||
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", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -48,9 +31,13 @@ describe("ConfirmPasswordForm", () => {
|
||||
test("renders the form with password input", () => {
|
||||
render(<ConfirmPasswordForm {...mockProps} />);
|
||||
|
||||
expect(screen.getByLabelText("Password")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Confirm" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.settings.profile.two_factor_authentication")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.settings.profile.confirm_your_current_password_to_get_started")
|
||||
).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 () => {
|
||||
@@ -69,9 +56,9 @@ describe("ConfirmPasswordForm", () => {
|
||||
|
||||
render(<ConfirmPasswordForm {...mockProps} />);
|
||||
|
||||
const passwordInput = screen.getByLabelText("Password");
|
||||
const passwordInput = screen.getByLabelText("common.password");
|
||||
await user.type(passwordInput, "testPassword123!");
|
||||
const submitButton = screen.getByRole("button", { name: "Confirm" });
|
||||
const submitButton = screen.getByRole("button", { name: "common.confirm" });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -87,7 +74,7 @@ describe("ConfirmPasswordForm", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ConfirmPasswordForm {...mockProps} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await user.click(screen.getByRole("button", { name: "common.cancel" }));
|
||||
expect(mockProps.setOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,8 +57,16 @@ export const ConfirmPasswordForm = ({
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form className="flex flex-col space-y-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="p-6">
|
||||
<h1 className="text-lg font-semibold">
|
||||
{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">
|
||||
{t("common.password")}
|
||||
</label>
|
||||
@@ -87,7 +95,7 @@ export const ConfirmPasswordForm = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center justify-end space-x-2">
|
||||
<div className="flex w-full items-center justify-end space-x-4 border-t border-slate-300 p-4">
|
||||
<Button variant="secondary" size="sm" type="button" onClick={() => setOpen(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
|
||||
@@ -5,49 +5,6 @@ import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
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", () => ({
|
||||
disableTwoFactorAuthAction: vi.fn(),
|
||||
}));
|
||||
@@ -58,23 +15,15 @@ vi.mock("next/navigation", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("DisableTwoFactorModal", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders dialog with correct title and description", () => {
|
||||
test("renders modal with correct title and description", () => {
|
||||
render(<DisableTwoFactorModal open={true} setOpen={() => {}} />);
|
||||
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-content")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.settings.profile.disable_two_factor_authentication")
|
||||
).toBeInTheDocument();
|
||||
|
||||
@@ -3,17 +3,9 @@
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { disableTwoFactorAuthAction } from "@/modules/ee/two-factor-auth/actions";
|
||||
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 { Input } from "@/modules/ui/components/input";
|
||||
import { Modal } from "@/modules/ui/components/modal";
|
||||
import { OTPInput } from "@/modules/ui/components/otp-input";
|
||||
import { PasswordInput } from "@/modules/ui/components/password-input";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@@ -71,45 +63,101 @@ export const DisableTwoFactorModal = ({ open, setOpen }: DisableTwoFactorModalPr
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
}
|
||||
setOpen(open);
|
||||
}}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("environments.settings.profile.disable_two_factor_authentication")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.settings.profile.disable_two_factor_authentication_description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
setOpen={() => {
|
||||
form.reset();
|
||||
setOpen(false);
|
||||
}}
|
||||
noPadding>
|
||||
<FormProvider {...form}>
|
||||
<div>
|
||||
<div className="p-6">
|
||||
<h1 className="text-lg font-semibold">
|
||||
{t("environments.settings.profile.disable_two_factor_authentication")}
|
||||
</h1>
|
||||
<p className="text-sm text-slate-700">
|
||||
{t("environments.settings.profile.disable_two_factor_authentication_description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FormProvider {...form}>
|
||||
<form className="flex flex-col space-y-6" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<DialogBody className="space-y-6">
|
||||
<div className="flex flex-col gap-2 px-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">
|
||||
<label htmlFor="password" className="text-sm font-medium text-slate-700">
|
||||
{t("common.password")}
|
||||
<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="password"
|
||||
name="backupCode"
|
||||
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"
|
||||
<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>
|
||||
@@ -117,70 +165,14 @@ export const DisableTwoFactorModal = ({ open, setOpen }: DisableTwoFactorModalPr
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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 className="flex w-full items-center justify-between border-t border-slate-300 p-4">
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => setBackupCodeInputVisible((prev) => !prev)}>
|
||||
{backupCodeInputVisible
|
||||
@@ -191,6 +183,7 @@ export const DisableTwoFactorModal = ({ open, setOpen }: DisableTwoFactorModalPr
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
@@ -199,12 +192,14 @@ export const DisableTwoFactorModal = ({ open, setOpen }: DisableTwoFactorModalPr
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
|
||||
<Button loading={form.formState.isSubmitting}>{t("common.disable")}</Button>
|
||||
<Button size="sm" loading={form.formState.isSubmitting}>
|
||||
{t("common.disable")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,40 +4,17 @@ import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { EnableTwoFactorModal } from "./enable-two-factor-modal";
|
||||
|
||||
// Mock the Dialog component to expose the close functionality
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: ({
|
||||
children,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
open: boolean;
|
||||
onOpenChange: () => void;
|
||||
}) =>
|
||||
// Mock the Modal component to expose the close functionality
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ children, open, setOpen }: { children: React.ReactNode; open: boolean; setOpen: () => void }) =>
|
||||
open ? (
|
||||
<div data-testid="dialog">
|
||||
<div data-testid="modal">
|
||||
{children}
|
||||
<button data-testid="dialog-close" onClick={onOpenChange}>
|
||||
<button data-testid="modal-close" onClick={setOpen}>
|
||||
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>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock the child components
|
||||
@@ -117,8 +94,6 @@ describe("EnableTwoFactorModal", () => {
|
||||
const setOpen = vi.fn();
|
||||
render(<EnableTwoFactorModal open={true} setOpen={setOpen} />);
|
||||
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-content")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("confirm-password-form")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -126,7 +101,6 @@ describe("EnableTwoFactorModal", () => {
|
||||
const setOpen = vi.fn();
|
||||
render(<EnableTwoFactorModal open={false} setOpen={setOpen} />);
|
||||
|
||||
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("confirm-password-form")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -153,7 +127,7 @@ describe("EnableTwoFactorModal", () => {
|
||||
expect(screen.getByTestId("display-backup-codes")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("resets state when dialog is closed", async () => {
|
||||
test("resets state when modal is closed", async () => {
|
||||
const setOpen = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<EnableTwoFactorModal open={true} setOpen={setOpen} />);
|
||||
@@ -162,13 +136,13 @@ describe("EnableTwoFactorModal", () => {
|
||||
await user.click(screen.getByText("Next"));
|
||||
expect(screen.getByTestId("scan-qr-code")).toBeInTheDocument();
|
||||
|
||||
// Close dialog using the close button
|
||||
await user.click(screen.getByTestId("dialog-close"));
|
||||
// Close modal using the close button
|
||||
await user.click(screen.getByTestId("modal-close"));
|
||||
|
||||
// Verify setOpen was called with false
|
||||
expect(setOpen).toHaveBeenCalledWith(false);
|
||||
|
||||
// Reopen dialog
|
||||
// Reopen modal
|
||||
rerender(<EnableTwoFactorModal open={true} setOpen={setOpen} />);
|
||||
|
||||
// Should be back at the first step
|
||||
|
||||
@@ -4,15 +4,7 @@ import { ConfirmPasswordForm } from "@/modules/ee/two-factor-auth/components/con
|
||||
import { DisplayBackupCodes } from "@/modules/ee/two-factor-auth/components/display-backup-codes";
|
||||
import { EnterCode } from "@/modules/ee/two-factor-auth/components/enter-code";
|
||||
import { ScanQRCode } from "@/modules/ee/two-factor-auth/components/scan-qr-code";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { Modal } from "@/modules/ui/components/modal";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
@@ -34,8 +26,6 @@ export const EnableTwoFactorModal = ({ open, setOpen }: EnableTwoFactorModalProp
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const { t } = useTranslate();
|
||||
|
||||
const resetState = () => {
|
||||
setCurrentStep("confirmPassword");
|
||||
setBackupCodes([]);
|
||||
@@ -45,38 +35,26 @@ export const EnableTwoFactorModal = ({ open, setOpen }: EnableTwoFactorModalProp
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={() => resetState()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("environments.settings.profile.two_factor_authentication")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.settings.profile.confirm_your_current_password_to_get_started")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
{currentStep === "confirmPassword" && (
|
||||
<ConfirmPasswordForm
|
||||
setBackupCodes={setBackupCodes}
|
||||
setCurrentStep={setCurrentStep}
|
||||
setDataUri={setDataUri}
|
||||
setSecret={setSecret}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
)}
|
||||
<Modal open={open} setOpen={() => resetState()} noPadding>
|
||||
{currentStep === "confirmPassword" && (
|
||||
<ConfirmPasswordForm
|
||||
setBackupCodes={setBackupCodes}
|
||||
setCurrentStep={setCurrentStep}
|
||||
setDataUri={setDataUri}
|
||||
setSecret={setSecret}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === "scanQRCode" && (
|
||||
<ScanQRCode setCurrentStep={setCurrentStep} dataUri={dataUri} secret={secret} setOpen={setOpen} />
|
||||
)}
|
||||
{currentStep === "scanQRCode" && (
|
||||
<ScanQRCode setCurrentStep={setCurrentStep} dataUri={dataUri} secret={secret} setOpen={setOpen} />
|
||||
)}
|
||||
|
||||
{currentStep === "enterCode" && (
|
||||
<EnterCode setCurrentStep={setCurrentStep} setOpen={setOpen} refreshData={refreshData} />
|
||||
)}
|
||||
{currentStep === "enterCode" && (
|
||||
<EnterCode setCurrentStep={setCurrentStep} setOpen={setOpen} refreshData={refreshData} />
|
||||
)}
|
||||
|
||||
{currentStep === "backupCodes" && (
|
||||
<DisplayBackupCodes backupCodes={backupCodes} setOpen={resetState} />
|
||||
)}
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{currentStep === "backupCodes" && <DisplayBackupCodes backupCodes={backupCodes} setOpen={resetState} />}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -86,7 +86,7 @@ export const EnterCode = ({ setCurrentStep, setOpen, refreshData }: EnterCodePro
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center justify-end space-x-4">
|
||||
<div className="flex w-full items-center justify-end space-x-4 border-t border-slate-300 p-4">
|
||||
<Button variant="secondary" size="sm" type="button" onClick={() => setOpen(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
|
||||
@@ -47,7 +47,7 @@ export const ScanQRCode = ({ dataUri, secret, setCurrentStep, setOpen }: ScanQRC
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center justify-end space-x-4">
|
||||
<div className="flex w-full items-center justify-end space-x-4 border-t border-slate-300 p-4">
|
||||
<Button variant="secondary" size="sm" type="button" onClick={() => setOpen(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user