Compare commits

..

16 Commits

Author SHA1 Message Date
Matti Nannt
d49517be91 fix(ci): backport release tag validation fix to release/4.0 (#6609) 2025-09-26 09:41:06 +02:00
Victor Hugo dos Santos
7aedb73378 chore: backport jwt and script fix (#6607)
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
2025-09-25 11:53:45 -03:00
Dhruwang Jariwala
4112722a88 fix: replace button with div in IdBadge to prevent hydration issues (backport) (#6602) 2025-09-25 10:42:29 -03:00
Piyush Gupta
0eddeb46c1 fix: logicfallback cleanup backport (#6603) 2025-09-25 18:49:55 +05:30
Piyush Gupta
774f45b109 chore: reverted translations 2025-09-25 18:21:51 +05:30
Piyush Gupta
3c65c002bb chore: reverted translations 2025-09-25 18:19:28 +05:30
Piyush Gupta
65539e85df fix: logicFallback cleanup backport 2025-09-25 17:42:55 +05:30
Anshuman Pandey
91dab12a81 fix: backports migration script changes (#6583) 2025-09-22 15:30:36 +02:00
Matti Nannt
1c5244e030 fix: s3 storage configured flag fails on minimum setup (#6573) 2025-09-22 09:11:58 +02:00
Dhruwang Jariwala
8b3c0f1547 fix: Backport/follow ups toast to 4.0 (#6569) 2025-09-19 15:04:24 +02:00
Dhruwang Jariwala
07370ac765 fix: (backport) synced translations (#6567) 2025-09-19 12:26:48 +02:00
Anshuman Pandey
0f699405bb fix: backports the formbricks.sh script and adds the migration script (#6564) 2025-09-19 12:25:03 +02:00
Anshuman Pandey
422f05b386 feat: support IAM role authentication for S3 storage (#6560) 2025-09-19 15:32:30 +05:30
Dhruwang Jariwala
bdfbc4b0f6 fix: Backport/critical fixes to 4.0 (#6563)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-09-19 11:02:54 +02:00
Matti Nannt
b1828a2f27 feat: support IAM role authentication for S3 storage
- Update IS_STORAGE_CONFIGURED to only require S3_REGION and S3_BUCKET_NAME
- Make S3_ACCESS_KEY and S3_SECRET_KEY optional in S3 client creation
- Allow AWS SDK to use IAM roles, instance profiles, and credential chains
- Maintain backward compatibility with explicit credentials and MinIO
- Update tests to reflect new IAM role authentication behavior

BREAKING CHANGE: IS_STORAGE_CONFIGURED now returns true with only region and bucket configured
2025-09-18 12:24:58 +02:00
Matti Nannt
3ba6dd9ada chore(backport): updated release workflow (#6557) 2025-09-17 11:53:15 +02:00
227 changed files with 3167 additions and 11177 deletions

View File

@@ -1,179 +0,0 @@
---
description: Apply these quality standards before finalizing code changes to ensure DRY principles, React best practices, TypeScript conventions, and maintainable code.
globs:
alwaysApply: false
---
# Review & Refine
Before finalizing any code changes, review your implementation against these quality standards:
## Core Principles
### DRY (Don't Repeat Yourself)
- Extract duplicated logic into reusable functions or hooks
- If the same code appears in multiple places, consolidate it
- Create helper functions at appropriate scope (component-level, module-level, or utility files)
- Avoid copy-pasting code blocks
### Code Reduction
- Remove unnecessary code, comments, and abstractions
- Prefer built-in solutions over custom implementations
- Consolidate similar logic
- Remove dead code and unused imports
- Question if every line of code is truly needed
## React Best Practices
### Component Design
- Keep components focused on a single responsibility
- Extract complex logic into custom hooks
- Prefer composition over prop drilling
- Use children props and render props when appropriate
- Keep component files under 300 lines when possible
### Hooks Usage
- Follow Rules of Hooks (only call at top level, only in React functions)
- Extract complex `useEffect` logic into custom hooks
- Use `useMemo` and `useCallback` only when you have a measured performance issue
- Declare dependencies arrays correctly - don't ignore exhaustive-deps warnings
- Keep `useEffect` focused on a single concern
### State Management
- Colocate state as close as possible to where it's used
- Lift state only when necessary
- Use `useReducer` for complex state logic with multiple sub-values
- Avoid derived state - compute values during render instead
- Don't store values in state that can be computed from props
### Event Handlers
- Name event handlers with `handle` prefix (e.g., `handleClick`, `handleSubmit`)
- Extract complex event handler logic into separate functions
- Avoid inline arrow functions in JSX when they contain complex logic
## TypeScript Best Practices
### Type Safety
- Prefer type inference over explicit types when possible
- Use `const` assertions for literal types
- Avoid `any` - use `unknown` if type is truly unknown
- Use discriminated unions for complex conditional logic
- Leverage type guards and narrowing
### Interface & Type Usage
- Use existing types from `@formbricks/types` - don't recreate them
- Prefer `interface` for object shapes that might be extended
- Prefer `type` for unions, intersections, and mapped types
- Define types close to where they're used unless they're shared
- Export types from index files for shared types
### Type Assertions
- Avoid type assertions (`as`) when possible
- Use type guards instead of assertions
- Only assert when you have more information than TypeScript
## Code Organization
### Separation of Concerns
- Separate business logic from UI rendering
- Extract API calls into separate functions or modules
- Keep data transformation separate from component logic
- Use custom hooks for stateful logic that doesn't render UI
### Function Clarity
- Functions should do one thing well
- Name functions clearly and descriptively
- Keep functions small (aim for under 20 lines)
- Extract complex conditionals into named boolean variables or functions
- Avoid deep nesting (max 3 levels)
### File Structure
- Group related functions together
- Order declarations logically (types → hooks → helpers → component)
- Keep imports organized (external → internal → relative)
- Consider splitting large files by concern
## Additional Quality Checks
### Performance
- Don't optimize prematurely - measure first
- Avoid creating new objects/arrays/functions in render unnecessarily
- Use keys properly in lists (stable, unique identifiers)
- Lazy load heavy components when appropriate
### Accessibility
- Use semantic HTML elements
- Include ARIA labels where needed
- Ensure keyboard navigation works
- Check color contrast and focus states
### Error Handling
- Handle error states in components
- Provide user feedback for failed operations
- Use error boundaries for component errors
- Log errors appropriately (avoid swallowing errors silently)
### Naming Conventions
- Use descriptive names (avoid abbreviations unless very common)
- Boolean variables/props should sound like yes/no questions (`isLoading`, `hasError`, `canEdit`)
- Arrays should be plural (`users`, `choices`, `items`)
- Event handlers: `handleX` in components, `onX` for props
- Constants in UPPER_SNAKE_CASE only for true constants
### Code Readability
- Prefer early returns to reduce nesting
- Use destructuring to make code clearer
- Break complex expressions into named variables
- Add comments only when code can't be made self-explanatory
- Use whitespace to group related code
### Testing Considerations
- Write code that's easy to test (pure functions, clear inputs/outputs)
- Avoid hard-to-mock dependencies when possible
- Keep side effects at the edges of your code
## Review Checklist
Before submitting your changes, ask yourself:
1. **DRY**: Is there any duplicated logic I can extract?
2. **Clarity**: Would another developer understand this code easily?
3. **Simplicity**: Is this the simplest solution that works?
4. **Types**: Am I using TypeScript effectively?
5. **React**: Am I following React idioms and best practices?
6. **Performance**: Are there obvious performance issues?
7. **Separation**: Are concerns properly separated?
8. **Testing**: Is this code testable?
9. **Maintenance**: Will this be easy to change in 6 months?
10. **Deletion**: Can I remove any code and still accomplish the goal?
## When to Apply This Rule
Apply this rule:
- After implementing a feature but before marking it complete
- When you notice your code feels "messy" or complex
- Before requesting code review
- When you see yourself copy-pasting code
- After receiving feedback about code quality
Don't let perfect be the enemy of good, but always strive for:
**Simple, readable, maintainable code that does one thing well.**

View File

@@ -54,10 +54,6 @@ inputs:
description: "Whether this is a prerelease (auto-tags for staging/production)"
required: false
default: "false"
make_latest:
description: "Whether to tag as latest/production (from GitHub release 'Set as the latest release' option)"
required: false
default: "false"
# Build options
dockerfile:
@@ -158,7 +154,6 @@ runs:
DEPLOY_PRODUCTION: ${{ inputs.deploy_production }}
DEPLOY_STAGING: ${{ inputs.deploy_staging }}
IS_PRERELEASE: ${{ inputs.is_prerelease }}
MAKE_LATEST: ${{ inputs.make_latest }}
run: |
set -euo pipefail
@@ -169,9 +164,9 @@ runs:
if [[ "${IS_PRERELEASE}" == "true" ]]; then
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:staging"
echo "Adding staging tag for prerelease"
elif [[ "${IS_PRERELEASE}" == "false" && "${MAKE_LATEST}" == "true" ]]; then
elif [[ "${IS_PRERELEASE}" == "false" ]]; then
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:production"
echo "Adding production tag for stable release marked as latest"
echo "Adding production tag for stable release"
fi
# Handle manual deployment overrides
@@ -201,7 +196,6 @@ runs:
VERSION: ${{ steps.version.outputs.version }}
IMAGE_NAME: ${{ inputs.ghcr_image_name }}
IS_PRERELEASE: ${{ inputs.is_prerelease }}
MAKE_LATEST: ${{ inputs.make_latest }}
run: |
set -euo pipefail
@@ -220,10 +214,10 @@ runs:
echo "Added SemVer tags: ${MAJOR}.${MINOR}, ${MAJOR}"
fi
# Add latest tag for stable releases marked as latest
if [[ "${IS_PRERELEASE}" == "false" && "${MAKE_LATEST}" == "true" ]]; then
# Add latest tag for stable releases
if [[ "${IS_PRERELEASE}" == "false" ]]; then
TAGS="${TAGS}\nghcr.io/${IMAGE_NAME}:latest"
echo "Added latest tag for stable release marked as latest"
echo "Added latest tag for stable release"
fi
echo "Generated GHCR tags:"
@@ -257,7 +251,6 @@ runs:
echo "Experimental Mode: ${{ inputs.experimental_mode }}"
echo "Event Name: ${{ github.event_name }}"
echo "Is Prerelease: ${{ inputs.is_prerelease }}"
echo "Make Latest: ${{ inputs.make_latest }}"
echo "Version: ${{ steps.version.outputs.version }}"
if [[ "${{ inputs.registry_type }}" == "ecr" ]]; then

View File

@@ -32,11 +32,6 @@ on:
required: false
type: boolean
default: false
MAKE_LATEST:
description: "Whether to tag for production (from GitHub release 'Set as the latest release' option)"
required: false
type: boolean
default: false
outputs:
IMAGE_TAG:
description: "Normalized image tag used for the build"
@@ -85,7 +80,6 @@ jobs:
deploy_production: ${{ inputs.deploy_production }}
deploy_staging: ${{ inputs.deploy_staging }}
is_prerelease: ${{ inputs.IS_PRERELEASE }}
make_latest: ${{ inputs.MAKE_LATEST }}
env:
DEPOT_PROJECT_TOKEN: ${{ secrets.DEPOT_PROJECT_TOKEN }}
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}

View File

@@ -33,7 +33,7 @@ jobs:
timeout-minutes: 60
services:
postgres:
image: pgvector/pgvector@sha256:9ae02a756ba16a2d69dd78058e25915e36e189bb36ddf01ceae86390d7ed786a
image: pgvector/pgvector:pg17
env:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
@@ -166,12 +166,6 @@ jobs:
cd apps/web && pnpm vitest run modules/core/rate-limit/rate-limit-load.test.ts
shell: bash
- name: Run Cache Integration Tests
run: |
echo "Running cache integration tests with Redis/Valkey..."
cd packages/cache && pnpm vitest run src/cache-integration.test.ts
shell: bash
- name: Check for Enterprise License
run: |
LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-)
@@ -181,12 +175,6 @@ jobs:
fi
echo "License key length: ${#LICENSE_KEY}"
- name: Disable rate limiting for E2E tests
run: |
echo "RATE_LIMITING_DISABLED=1" >> .env
echo "Rate limiting disabled for E2E tests"
shell: bash
- name: Run App
run: |
echo "Starting app with enterprise license..."
@@ -228,14 +216,11 @@ jobs:
if: env.AZURE_ENABLED == 'true'
env:
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
CI: true
run: |
pnpm test-e2e:azure
- name: Run E2E Tests (Local)
if: env.AZURE_ENABLED == 'false'
env:
CI: true
run: |
pnpm test:e2e
@@ -254,4 +239,4 @@ jobs:
- name: Output App Logs
if: failure()
run: cat app.log
run: cat app.log

View File

@@ -8,75 +8,6 @@ permissions:
contents: read
jobs:
check-latest-release:
name: Check if this is the latest release
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
outputs:
is_latest: ${{ steps.compare_tags.outputs.is_latest }}
# This job determines if the current release was marked as "Set as the latest release"
# by comparing it with the latest release from GitHub API
steps:
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Get latest release tag from API
id: get_latest_release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
# Get the latest release tag from GitHub API with error handling
echo "Fetching latest release from GitHub API..."
# Use curl with error handling - API returns 404 if no releases exist
http_code=$(curl -s -w "%{http_code}" -H "Authorization: token ${GITHUB_TOKEN}" \
"https://api.github.com/repos/${REPO}/releases/latest" -o /tmp/latest_release.json)
if [[ "$http_code" == "404" ]]; then
echo "⚠️ No previous releases found (404). This appears to be the first release."
echo "latest_release=" >> $GITHUB_OUTPUT
elif [[ "$http_code" == "200" ]]; then
latest_release=$(jq -r .tag_name /tmp/latest_release.json)
if [[ "$latest_release" == "null" || -z "$latest_release" ]]; then
echo "⚠️ API returned null/empty tag_name. Treating as first release."
echo "latest_release=" >> $GITHUB_OUTPUT
else
echo "Latest release from API: ${latest_release}"
echo "latest_release=${latest_release}" >> $GITHUB_OUTPUT
fi
else
echo "❌ GitHub API error (HTTP ${http_code}). Treating as first release."
echo "latest_release=" >> $GITHUB_OUTPUT
fi
echo "Current release tag: ${{ github.event.release.tag_name }}"
- name: Compare release tags
id: compare_tags
env:
CURRENT_TAG: ${{ github.event.release.tag_name }}
LATEST_TAG: ${{ steps.get_latest_release.outputs.latest_release }}
run: |
set -euo pipefail
# Handle first release case (no previous releases)
if [[ -z "${LATEST_TAG}" ]]; then
echo "🎉 This is the first release (${CURRENT_TAG}) - treating as latest"
echo "is_latest=true" >> $GITHUB_OUTPUT
elif [[ "${CURRENT_TAG}" == "${LATEST_TAG}" ]]; then
echo "✅ This release (${CURRENT_TAG}) is marked as the latest release"
echo "is_latest=true" >> $GITHUB_OUTPUT
else
echo " This release (${CURRENT_TAG}) is not the latest release (latest: ${LATEST_TAG})"
echo "is_latest=false" >> $GITHUB_OUTPUT
fi
docker-build-community:
name: Build & release community docker image
permissions:
@@ -85,11 +16,8 @@ jobs:
id-token: write
uses: ./.github/workflows/release-docker-github.yml
secrets: inherit
needs:
- check-latest-release
with:
IS_PRERELEASE: ${{ github.event.release.prerelease }}
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest }}
docker-build-cloud:
name: Build & push Formbricks Cloud to ECR
@@ -101,9 +29,7 @@ jobs:
with:
image_tag: ${{ needs.docker-build-community.outputs.VERSION }}
IS_PRERELEASE: ${{ github.event.release.prerelease }}
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest }}
needs:
- check-latest-release
- docker-build-community
helm-chart-release:
@@ -148,10 +74,8 @@ jobs:
contents: write # Required for tag push operations in called workflow
uses: ./.github/workflows/move-stable-tag.yml
needs:
- check-latest-release
- docker-build-community # Ensure release is successful first
with:
release_tag: ${{ github.event.release.tag_name }}
commit_sha: ${{ github.sha }}
is_prerelease: ${{ github.event.release.prerelease }}
make_latest: ${{ needs.check-latest-release.outputs.is_latest }}

View File

@@ -16,11 +16,6 @@ on:
required: false
type: boolean
default: false
make_latest:
description: "Whether to move stable tag (from GitHub release 'Set as the latest release' option)"
required: false
type: boolean
default: false
permissions:
contents: read
@@ -37,8 +32,8 @@ jobs:
timeout-minutes: 10 # Prevent hung git operations
permissions:
contents: write # Required to push tags
# Only move stable tag for non-prerelease versions AND when make_latest is true
if: ${{ !inputs.is_prerelease && inputs.make_latest }}
# Only move stable tag for non-prerelease versions
if: ${{ !inputs.is_prerelease }}
steps:
- name: Harden the runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0

View File

@@ -13,11 +13,6 @@ on:
required: false
type: boolean
default: false
MAKE_LATEST:
description: "Whether to tag as latest (from GitHub release 'Set as the latest release' option)"
required: false
type: boolean
default: false
outputs:
VERSION:
description: release version
@@ -98,7 +93,6 @@ jobs:
ghcr_image_name: ${{ env.IMAGE_NAME }}
version: ${{ steps.extract_release_tag.outputs.VERSION }}
is_prerelease: ${{ inputs.IS_PRERELEASE }}
make_latest: ${{ inputs.MAKE_LATEST }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEPOT_PROJECT_TOKEN: ${{ secrets.DEPOT_PROJECT_TOKEN }}

View File

@@ -1,14 +1,8 @@
"use client";
import { useTranslate } from "@tolgee/react";
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
@@ -18,6 +12,13 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { useTranslate } from "@tolgee/react";
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
interface LandingSidebarProps {
user: TUser;
@@ -65,8 +66,10 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
)}>
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
</p>
<p title={organization?.name} className="truncate text-sm text-slate-500">
{organization?.name}
<p
title={capitalizeFirstLetter(organization?.name)}
className="truncate text-sm text-slate-500">
{capitalizeFirstLetter(organization?.name)}
</p>
</div>
<ChevronRightIcon className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")} />

View File

@@ -1,3 +1,5 @@
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { usePathname, useRouter } from "next/navigation";
@@ -6,8 +8,6 @@ import { TEnvironment } from "@formbricks/types/environment";
import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
import { TUser } from "@formbricks/types/user";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
import { MainNavigation } from "./MainNavigation";
// Mock constants that this test needs
@@ -210,10 +210,9 @@ describe("MainNavigation", () => {
expect(userTrigger).toBeInTheDocument(); // Ensure the trigger element is found
await userEvent.click(userTrigger);
// Wait for the dropdown content to appear - using getAllByText to handle multiple instances
// Wait for the dropdown content to appear
await waitFor(() => {
const accountElements = screen.getAllByText("common.account");
expect(accountElements).toHaveLength(2);
expect(screen.getByText("common.account")).toBeInTheDocument();
});
expect(screen.getByText("common.documentation")).toBeInTheDocument();

View File

@@ -1,18 +1,5 @@
"use client";
import { useTranslate } from "@tolgee/react";
import { PlusIcon, TrashIcon } from "lucide-react";
import Image from "next/image";
import React, { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { TIntegrationInput } from "@formbricks/types/integration";
import {
TIntegrationNotion,
TIntegrationNotionConfigData,
TIntegrationNotionDatabase,
} from "@formbricks/types/integration/notion";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
import {
ERRORS,
@@ -36,6 +23,19 @@ import {
} from "@/modules/ui/components/dialog";
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
import { Label } from "@/modules/ui/components/label";
import { useTranslate } from "@tolgee/react";
import { PlusIcon, TrashIcon } from "lucide-react";
import Image from "next/image";
import React, { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { TIntegrationInput } from "@formbricks/types/integration";
import {
TIntegrationNotion,
TIntegrationNotionConfigData,
TIntegrationNotionDatabase,
} from "@formbricks/types/integration/notion";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
interface AddIntegrationModalProps {
environmentId: string;
@@ -134,12 +134,13 @@ export const AddIntegrationModal = ({
type: TSurveyQuestionTypeEnum.OpenText,
})) || [];
const hiddenFields =
selectedSurvey?.hiddenFields.fieldIds?.map((fId) => ({
id: fId,
name: `${t("common.hidden_field")} : ${fId}`,
type: TSurveyQuestionTypeEnum.OpenText,
})) || [];
const hiddenFields = selectedSurvey?.hiddenFields.enabled
? selectedSurvey?.hiddenFields.fieldIds?.map((fId) => ({
id: fId,
name: `${t("common.hidden_field")} : ${fId}`,
type: TSurveyQuestionTypeEnum.OpenText,
})) || []
: [];
const Metadata = [
{
id: "metadata",

View File

@@ -120,7 +120,7 @@ describe("PasswordConfirmationModal", () => {
const confirmButton = screen.getByText("common.confirm");
await user.click(confirmButton);
expect(screen.getByText("Password must be at least 8 characters long")).toBeInTheDocument();
expect(screen.getByText("String must contain at least 8 character(s)")).toBeInTheDocument();
});
test("handles cancel button click and resets form", async () => {

View File

@@ -1,10 +1,10 @@
"use client";
import { useTranslate } from "@tolgee/react";
import { cn } from "@/lib/cn";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { H4, Small } from "@/modules/ui/components/typography";
import { useTranslate } from "@tolgee/react";
interface ButtonInfo {
text: string;
@@ -41,7 +41,7 @@ export const SettingsCard = ({
id={title}>
<div className="flex justify-between border-b border-slate-200 px-4 pb-4">
<div>
<H4 className="font-medium tracking-normal">{title}</H4>
<H4 className="font-medium capitalize tracking-normal">{title}</H4>
<div className="ml-2">
{beta && <Badge size="normal" type="warning" text="Beta" />}
{soon && (

View File

@@ -1,3 +1,5 @@
import { ResponseCardModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal";
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
@@ -6,8 +8,6 @@ import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUser, TUserLocale } from "@formbricks/types/user";
import { ResponseCardModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal";
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
vi.mock("@/modules/analysis/components/SingleResponseCard", () => ({
SingleResponseCard: vi.fn(() => <div data-testid="single-response-card">SingleResponseCard</div>),
@@ -46,11 +46,6 @@ vi.mock("@/modules/ui/components/dialog", () => ({
)),
DialogBody: vi.fn(({ children }) => <div data-testid="dialog-body">{children}</div>),
DialogFooter: vi.fn(({ children }) => <div data-testid="dialog-footer">{children}</div>),
DialogTitle: vi.fn(({ children }) => <div data-testid="dialog-title">{children}</div>),
}));
vi.mock("@radix-ui/react-visually-hidden", () => ({
VisuallyHidden: vi.fn(({ children }) => <div data-testid="visually-hidden">{children}</div>),
}));
const mockResponses = [

View File

@@ -1,4 +1,6 @@
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
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 { useEffect, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
@@ -6,9 +8,6 @@ import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUser, TUserLocale } from "@formbricks/types/user";
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
import { Button } from "@/modules/ui/components/button";
import { Dialog, DialogBody, DialogContent, DialogFooter, DialogTitle } from "@/modules/ui/components/dialog";
interface ResponseCardModalProps {
responses: TResponse[];
@@ -78,9 +77,6 @@ export const ResponseCardModal = ({
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent width="wide">
<VisuallyHidden asChild>
<DialogTitle>Survey Response Details</DialogTitle>
</VisuallyHidden>
<DialogBody>
<SingleResponseCard
survey={survey}

View File

@@ -1,3 +1,9 @@
import { cache } from "@/lib/cache";
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
import {
capturePosthogEnvironmentEvent,
sendPlanLimitsReachedEventToPosthogWeekly,
} from "@/lib/posthogServer";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
@@ -6,12 +12,6 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TJsEnvironmentState, TJsEnvironmentStateProject } from "@formbricks/types/js";
import { TOrganization } from "@formbricks/types/organizations";
import { TSurvey } from "@formbricks/types/surveys/types";
import { cache } from "@/lib/cache";
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
import {
capturePosthogEnvironmentEvent,
sendPlanLimitsReachedEventToPosthogWeekly,
} from "@/lib/posthogServer";
import { EnvironmentStateData, getEnvironmentStateData } from "./data";
import { getEnvironmentState } from "./environmentState";
@@ -285,7 +285,7 @@ describe("getEnvironmentState", () => {
expect(cache.withCache).toHaveBeenCalledWith(
expect.any(Function),
"fb:env:test-environment-id:state",
60 * 1000 // 1 minutes in milliseconds
5 * 60 * 1000 // 5 minutes in milliseconds
);
});

View File

@@ -1,8 +1,4 @@
import "server-only";
import { createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TJsEnvironmentState } from "@formbricks/types/js";
import { cache } from "@/lib/cache";
import { IS_FORMBRICKS_CLOUD, IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants";
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
@@ -10,6 +6,10 @@ import {
capturePosthogEnvironmentEvent,
sendPlanLimitsReachedEventToPosthogWeekly,
} from "@/lib/posthogServer";
import { createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TJsEnvironmentState } from "@formbricks/types/js";
import { getEnvironmentStateData } from "./data";
/**
@@ -80,6 +80,6 @@ export const getEnvironmentState = async (
return { data };
},
createCacheKey.environment.state(environmentId),
60 * 1000 // 1 minutes in milliseconds
5 * 60 * 1000 // 5 minutes in milliseconds
);
};

View File

@@ -1,6 +1,3 @@
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { TUploadPublicFileRequest, ZUploadPublicFileRequest } from "@formbricks/types/storage";
import { checkAuth } from "@/app/api/v1/management/storage/lib/utils";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
@@ -8,6 +5,9 @@ import { TApiV1Authentication, withV1ApiWrapper } from "@/app/lib/api/with-api-l
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { getSignedUrlForUpload } from "@/modules/storage/service";
import { getErrorResponseFromStorageError } from "@/modules/storage/utils";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { TUploadPublicFileRequest, ZUploadPublicFileRequest } from "@formbricks/types/storage";
// api endpoint for getting a signed url for uploading a public file
// uploaded files will be public, anyone can access the file
@@ -52,16 +52,7 @@ export const POST = withV1ApiWrapper({
};
}
const MAX_PUBLIC_FILE_SIZE_MB = 5;
const maxFileUploadSize = MAX_PUBLIC_FILE_SIZE_MB * 1024 * 1024;
const signedUrlResponse = await getSignedUrlForUpload(
fileName,
environmentId,
fileType,
"public",
maxFileUploadSize
);
const signedUrlResponse = await getSignedUrlForUpload(fileName, environmentId, fileType, "public");
if (!signedUrlResponse.ok) {
logger.error({ error: signedUrlResponse.error }, "Error getting signed url for upload");

View File

@@ -1 +0,0 @@
export { GET } from "@/modules/api/v2/health/route";

View File

@@ -1,9 +1,9 @@
import { AuthenticationMethod } from "@/app/middleware/endpoint-validator";
import * as Sentry from "@sentry/nextjs";
import { NextRequest } from "next/server";
import { Mock, beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { AuthenticationMethod } from "@/app/middleware/endpoint-validator";
import { responses } from "./response";
// Mocks
@@ -14,10 +14,6 @@ vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
withScope: vi.fn((callback) => {
callback(mockSentryScope);
return mockSentryScope;
}),
}));
// Define these outside the mock factory so they can be referenced in tests and reset by clearAllMocks.
@@ -25,14 +21,6 @@ const mockContextualLoggerError = vi.fn();
const mockContextualLoggerWarn = vi.fn();
const mockContextualLoggerInfo = vi.fn();
// Mock Sentry scope that can be referenced in tests
const mockSentryScope = {
setTag: vi.fn(),
setExtra: vi.fn(),
setContext: vi.fn(),
setLevel: vi.fn(),
};
vi.mock("@formbricks/logger", () => {
const mockWithContextInstance = vi.fn(() => ({
error: mockContextualLoggerError,
@@ -122,12 +110,6 @@ describe("withV1ApiWrapper", () => {
}));
vi.clearAllMocks();
// Reset mock Sentry scope calls
mockSentryScope.setTag.mockClear();
mockSentryScope.setExtra.mockClear();
mockSentryScope.setContext.mockClear();
mockSentryScope.setLevel.mockClear();
});
test("logs and audits on error response with API key authentication", async () => {
@@ -179,9 +161,10 @@ describe("withV1ApiWrapper", () => {
organizationId: "org-1",
})
);
expect(Sentry.withScope).toHaveBeenCalled();
expect(mockSentryScope.setExtra).toHaveBeenCalledWith("originalError", undefined);
expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error));
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({ extra: expect.objectContaining({ correlationId: "abc-123" }) })
);
});
test("does not log Sentry if not 500", async () => {
@@ -286,8 +269,10 @@ describe("withV1ApiWrapper", () => {
organizationId: "org-1",
})
);
expect(Sentry.withScope).toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error));
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({ extra: expect.objectContaining({ correlationId: "err-1" }) })
);
});
test("does not log on success response but still audits", async () => {

View File

@@ -1,8 +1,3 @@
import * as Sentry from "@sentry/nextjs";
import { Session, getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import {
@@ -19,6 +14,11 @@ import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditAction, TAuditTarget, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import * as Sentry from "@sentry/nextjs";
import { Session, getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
export type TApiAuditLog = Parameters<typeof queueAuditEvent>[0];
export type TApiV1Authentication = TAuthenticationApiKey | Session | null;
@@ -173,21 +173,8 @@ const logErrorDetails = (res: Response, req: NextRequest, correlationId: string,
logger.withContext(logContext).error("V1 API Error Details");
if (SENTRY_DSN && IS_PRODUCTION && res.status >= 500) {
// Set correlation ID as a tag for easy filtering
Sentry.withScope((scope) => {
scope.setTag("correlationId", correlationId);
scope.setLevel("error");
// If we have an actual error, capture it with full stacktrace
// Otherwise, create a generic error with context
if (error instanceof Error) {
Sentry.captureException(error);
} else {
scope.setExtra("originalError", error);
const genericError = new Error(`API V1 error, id: ${correlationId}`);
Sentry.captureException(genericError);
}
});
const err = new Error(`API V1 error, id: ${correlationId}`);
Sentry.captureException(err, { extra: { error, correlationId } });
}
};

View File

@@ -100,13 +100,10 @@ export const getAirtableToken = async (environmentId: string) => {
});
if (!newToken) {
logger.error(
{
environmentId,
airtableIntegration,
},
"Failed to fetch new Airtable token"
);
logger.error("Failed to fetch new Airtable token", {
environmentId,
airtableIntegration,
});
throw new Error("Failed to fetch new Airtable token");
}
@@ -124,13 +121,10 @@ export const getAirtableToken = async (environmentId: string) => {
return access_token;
} catch (error) {
logger.error(
{
environmentId,
error,
},
"Failed to get Airtable token"
);
logger.error("Failed to get Airtable token", {
environmentId,
error,
});
throw new Error("Failed to get Airtable token");
}
};

View File

@@ -114,6 +114,9 @@ export const MAX_FILE_UPLOAD_SIZES = {
standard: 1024 * 1024 * 10, // 10MB
big: 1024 * 1024 * 1024, // 1GB
} as const;
// Storage is considered configured if we have the minimum required settings:
// - S3_REGION and S3_BUCKET_NAME are always required
// - S3_ACCESS_KEY and S3_SECRET_KEY are optional (for IAM role-based authentication)
export const IS_STORAGE_CONFIGURED = Boolean(S3_BUCKET_NAME);
// Colors for Survey Bg

View File

@@ -1,24 +1,13 @@
import { createCipheriv, randomBytes } from "crypto";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { describe, expect, test, vi } from "vitest";
import { getHash, symmetricDecrypt, symmetricEncrypt } from "./crypto";
vi.mock("./constants", () => ({ ENCRYPTION_KEY: "0".repeat(32) }));
vi.mock("@formbricks/logger", () => ({
logger: {
warn: vi.fn(),
},
}));
const key = "0".repeat(32);
const plain = "hello";
describe("crypto", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("encrypt + decrypt roundtrip", () => {
const cipher = symmetricEncrypt(plain, key);
expect(symmetricDecrypt(cipher, key)).toBe(plain);
@@ -49,84 +38,4 @@ describe("crypto", () => {
expect(typeof h).toBe("string");
expect(h.length).toBeGreaterThan(0);
});
test("logs warning and throws when GCM decryption fails with invalid auth tag", () => {
// Create a valid GCM payload but corrupt the auth tag
const iv = randomBytes(16);
const bufKey = Buffer.from(key, "utf8");
const cipher = createCipheriv("aes-256-gcm", bufKey, iv);
let enc = cipher.update(plain, "utf8", "hex");
enc += cipher.final("hex");
const validTag = cipher.getAuthTag().toString("hex");
// Corrupt the auth tag by flipping some bits
const corruptedTag = validTag
.split("")
.map((c, i) => (i < 4 ? (parseInt(c, 16) ^ 0xf).toString(16) : c))
.join("");
const corruptedPayload = `${iv.toString("hex")}:${enc}:${corruptedTag}`;
// Should throw an error and log a warning
expect(() => symmetricDecrypt(corruptedPayload, key)).toThrow();
// Verify logger.warn was called with the correct format (object first, message second)
expect(logger.warn).toHaveBeenCalledWith(
{ err: expect.any(Error) },
"AES-GCM decryption failed; refusing to fall back to insecure CBC"
);
expect(logger.warn).toHaveBeenCalledTimes(1);
});
test("logs warning and throws when GCM decryption fails with corrupted encrypted data", () => {
// Create a payload with valid structure but corrupted encrypted data
const iv = randomBytes(16);
const bufKey = Buffer.from(key, "utf8");
const cipher = createCipheriv("aes-256-gcm", bufKey, iv);
let enc = cipher.update(plain, "utf8", "hex");
enc += cipher.final("hex");
const tag = cipher.getAuthTag().toString("hex");
// Corrupt the encrypted data
const corruptedEnc = enc
.split("")
.map((c, i) => (i < 4 ? (parseInt(c, 16) ^ 0xa).toString(16) : c))
.join("");
const corruptedPayload = `${iv.toString("hex")}:${corruptedEnc}:${tag}`;
// Should throw an error and log a warning
expect(() => symmetricDecrypt(corruptedPayload, key)).toThrow();
// Verify logger.warn was called
expect(logger.warn).toHaveBeenCalledWith(
{ err: expect.any(Error) },
"AES-GCM decryption failed; refusing to fall back to insecure CBC"
);
expect(logger.warn).toHaveBeenCalledTimes(1);
});
test("logs warning and throws when GCM decryption fails with wrong key", () => {
// Create a valid GCM payload with one key
const iv = randomBytes(16);
const bufKey = Buffer.from(key, "utf8");
const cipher = createCipheriv("aes-256-gcm", bufKey, iv);
let enc = cipher.update(plain, "utf8", "hex");
enc += cipher.final("hex");
const tag = cipher.getAuthTag().toString("hex");
const payload = `${iv.toString("hex")}:${enc}:${tag}`;
// Try to decrypt with a different key
const wrongKey = "1".repeat(32);
// Should throw an error and log a warning
expect(() => symmetricDecrypt(payload, wrongKey)).toThrow();
// Verify logger.warn was called
expect(logger.warn).toHaveBeenCalledWith(
{ err: expect.any(Error) },
"AES-GCM decryption failed; refusing to fall back to insecure CBC"
);
expect(logger.warn).toHaveBeenCalledTimes(1);
});
});

View File

@@ -85,7 +85,7 @@ export function symmetricDecrypt(payload: string, key: string): string {
try {
return symmetricDecryptV2(payload, key);
} catch (err) {
logger.warn({ err }, "AES-GCM decryption failed; refusing to fall back to insecure CBC");
logger.warn(err, "AES-GCM decryption failed; refusing to fall back to insecure CBC");
throw err;
}

View File

@@ -1,7 +1,7 @@
import { TResponseData, TResponseDataValue, TResponseVariables } from "@formbricks/types/responses";
import { TI18nString, TSurvey, TSurveyQuestion, TSurveyRecallItem } from "@formbricks/types/surveys/types";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { TResponseData, TResponseDataValue, TResponseVariables } from "@formbricks/types/responses";
import { TI18nString, TSurvey, TSurveyQuestion, TSurveyRecallItem } from "@formbricks/types/surveys/types";
import { formatDateWithOrdinal, isValidDateString } from "./datetime";
export interface fallbacks {
@@ -118,16 +118,15 @@ export const replaceRecallInfoWithUnderline = (label: string): string => {
// Checks for survey questions with a "recall" pattern but no fallback value.
export const checkForEmptyFallBackValue = (survey: TSurvey, language: string): TSurveyQuestion | null => {
const doesTextHaveRecall = (text: string) => {
const findRecalls = (text: string) => {
const recalls = text.match(/#recall:[^ ]+/g);
return recalls?.some((recall) => !extractFallbackValue(recall));
return recalls && recalls.some((recall) => !extractFallbackValue(recall));
};
for (const question of survey.questions) {
if (
doesTextHaveRecall(getLocalizedValue(question.headline, language)) ||
(question.subheader && doesTextHaveRecall(getLocalizedValue(question.subheader, language))) ||
("html" in question && doesTextHaveRecall(getLocalizedValue(question.html, language)))
findRecalls(getLocalizedValue(question.headline, language)) ||
(question.subheader && findRecalls(getLocalizedValue(question.subheader, language)))
) {
return question;
}

View File

@@ -1,7 +1,36 @@
import { describe, expect, test } from "vitest";
import { isCapitalized, sanitizeString, startsWithVowel, truncate, truncateText } from "./strings";
import {
capitalizeFirstLetter,
isCapitalized,
sanitizeString,
startsWithVowel,
truncate,
truncateText,
} from "./strings";
describe("String Utilities", () => {
describe("capitalizeFirstLetter", () => {
test("capitalizes the first letter of a string", () => {
expect(capitalizeFirstLetter("hello")).toBe("Hello");
});
test("returns empty string if input is null", () => {
expect(capitalizeFirstLetter(null)).toBe("");
});
test("returns empty string if input is empty string", () => {
expect(capitalizeFirstLetter("")).toBe("");
});
test("doesn't change already capitalized string", () => {
expect(capitalizeFirstLetter("Hello")).toBe("Hello");
});
test("handles single character string", () => {
expect(capitalizeFirstLetter("a")).toBe("A");
});
});
describe("truncate", () => {
test("returns the string as is if length is less than the specified length", () => {
expect(truncate("hello", 10)).toBe("hello");

View File

@@ -1,3 +1,10 @@
export const capitalizeFirstLetter = (string: string | null = "") => {
if (string === null) {
return "";
}
return string.charAt(0).toUpperCase() + string.slice(1);
};
// write a function that takes a string and truncates it to the specified length
export const truncate = (str: string, length: number) => {
if (!str) return "";

View File

@@ -262,9 +262,7 @@
"membership_not_found": "Mitgliedschaft nicht gefunden",
"metadata": "Metadaten",
"minimum": "Minimum",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funktioniert am besten auf einem größeren Bildschirm. Um Umfragen zu verwalten oder zu erstellen, wechsle zu einem anderen Gerät.",
"mobile_overlay_surveys_look_good": "Keine Sorge deine Umfragen sehen auf jedem Gerät und jeder Bildschirmgröße großartig aus!",
"mobile_overlay_title": "Oops, Bildschirm zu klein erkannt!",
"mobile_overlay_text": "Formbricks ist für Geräte mit kleineren Auflösungen nicht verfügbar.",
"move_down": "Nach unten bewegen",
"move_up": "Nach oben bewegen",
"multiple_languages": "Mehrsprachigkeit",
@@ -752,6 +750,7 @@
},
"project": {
"api_keys": {
"access_control": "Zugriffskontrolle",
"add_api_key": "API-Schlüssel hinzufügen",
"api_key": "API-Schlüssel",
"api_key_copied_to_clipboard": "API-Schlüssel in die Zwischenablage kopiert",
@@ -1204,7 +1203,7 @@
"add_ending": "Abschluss hinzufügen",
"add_ending_below": "Abschluss unten hinzufügen",
"add_fallback": "Hinzufügen",
"add_fallback_placeholder": "Platzhalter hinzufügen, falls kein Wert zur Verfügung steht.",
"add_fallback_placeholder": "Hinzufügen eines Platzhalters, der angezeigt wird, wenn die Frage übersprungen wird:",
"add_hidden_field_id": "Verstecktes Feld ID hinzufügen",
"add_highlight_border": "Rahmen hinzufügen",
"add_highlight_border_description": "Füge deiner Umfragekarte einen äußeren Rahmen hinzu.",
@@ -1241,7 +1240,6 @@
"automatically_mark_the_survey_as_complete_after": "Umfrage automatisch als abgeschlossen markieren nach",
"back_button_label": "Zurück\"- Button ",
"background_styling": "Hintergründe",
"bold": "Fett",
"brand_color": "Markenfarbe",
"brightness": "Helligkeit",
"button_label": "Beschriftung",
@@ -1325,7 +1323,6 @@
"does_not_include_all_of": "Enthält nicht alle von",
"does_not_include_one_of": "Enthält nicht eines von",
"does_not_start_with": "Fängt nicht an mit",
"edit_link": "Bearbeitungslink",
"edit_recall": "Erinnerung bearbeiten",
"edit_translations": "{lang} -Übersetzungen bearbeiten",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Teilnehmer können die Umfragesprache jederzeit während der Umfrage ändern.",
@@ -1336,7 +1333,6 @@
"ending_card_used_in_logic": "Diese Abschlusskarte wird in der Logik der Frage {questionIndex} verwendet.",
"ending_used_in_quota": "Dieses Ende wird in der \"{quotaName}\" Quote verwendet",
"ends_with": "endet mit",
"enter_fallback_value": "Ersatzwert eingeben",
"equals": "Gleich",
"equals_one_of": "Entspricht einem von",
"error_publishing_survey": "Beim Veröffentlichen der Umfrage ist ein Fehler aufgetreten.",
@@ -1400,9 +1396,6 @@
"four_points": "4 Punkte",
"heading": "Überschrift",
"hidden_field_added_successfully": "Verstecktes Feld erfolgreich hinzugefügt",
"hidden_field_used_in_recall": "Verstecktes Feld \"{hiddenField}\" wird in Frage {questionIndex} abgerufen.",
"hidden_field_used_in_recall_ending_card": "Verstecktes Feld \"{hiddenField}\" wird in der Abschlusskarte abgerufen.",
"hidden_field_used_in_recall_welcome": "Verstecktes Feld \"{hiddenField}\" wird in der Willkommenskarte abgerufen.",
"hide_advanced_settings": "Erweiterte Einstellungen ausblenden",
"hide_back_button": "'Zurück'-Button ausblenden",
"hide_back_button_description": "Den Zurück-Button in der Umfrage nicht anzeigen",
@@ -1421,7 +1414,6 @@
"inner_text": "Innerer Text",
"input_border_color": "Randfarbe des Eingabefelds",
"input_color": "Farbe des Eingabefelds",
"insert_link": "Link einfügen",
"invalid_targeting": "Ungültiges Targeting: Bitte überprüfe deine Zielgruppenfilter",
"invalid_video_url_warning": "Bitte gib eine gültige YouTube-, Vimeo- oder Loom-URL ein. Andere Video-Plattformen werden derzeit nicht unterstützt.",
"invalid_youtube_url": "Ungültige YouTube-URL",
@@ -1439,7 +1431,6 @@
"is_set": "Ist festgelegt",
"is_skipped": "Wird übersprungen",
"is_submitted": "Wird eingereicht",
"italic": "Kursiv",
"jump_to_question": "Zur Frage springen",
"keep_current_order": "Bestehende Anordnung beibehalten",
"keep_showing_while_conditions_match": "Zeige weiter, solange die Bedingungen übereinstimmen",
@@ -1466,7 +1457,6 @@
"no_images_found_for": "Keine Bilder gefunden für ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "Keine Sprachen gefunden. Füge die erste hinzu, um loszulegen.",
"no_option_found": "Keine Option gefunden",
"no_recall_items_found": "Keine Erinnerungsstücke gefunden",
"no_variables_yet_add_first_one_below": "Noch keine Variablen. Füge die erste hinzu.",
"number": "Nummer",
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Sobald die Standardsprache für diese Umfrage festgelegt ist, kann sie nur geändert werden, indem die Mehrsprachigkeitsoption deaktiviert und alle Übersetzungen gelöscht werden.",
@@ -1486,7 +1476,6 @@
"pin_can_only_contain_numbers": "PIN darf nur Zahlen enthalten.",
"pin_must_be_a_four_digit_number": "Die PIN muss eine vierstellige Zahl sein.",
"please_enter_a_file_extension": "Bitte gib eine Dateierweiterung ein.",
"please_enter_a_valid_url": "Bitte geben Sie eine gültige URL ein (z. B. https://beispiel.de)",
"please_set_a_survey_trigger": "Bitte richte einen Umfrage-Trigger ein",
"please_specify": "Bitte angeben",
"prevent_double_submission": "Doppeltes Anbschicken verhindern",
@@ -1501,8 +1490,6 @@
"question_id_updated": "Frage-ID aktualisiert",
"question_used_in_logic": "Diese Frage wird in der Logik der Frage {questionIndex} verwendet.",
"question_used_in_quota": "Diese Frage wird in der \"{quotaName}\" Quote verwendet",
"question_used_in_recall": "Diese Frage wird in Frage {questionIndex} abgerufen.",
"question_used_in_recall_ending_card": "Diese Frage wird in der Abschlusskarte abgerufen.",
"quotas": {
"add_quota": "Quote hinzufügen",
"change_quota_for_public_survey": "Quote für öffentliche Umfrage ändern?",
@@ -1537,8 +1524,6 @@
"randomize_all": "Alle Optionen zufällig anordnen",
"randomize_all_except_last": "Alle Optionen zufällig anordnen außer der letzten",
"range": "Reichweite",
"recall_data": "Daten abrufen",
"recall_information_from": "Information abrufen von ...",
"recontact_options": "Optionen zur erneuten Kontaktaufnahme",
"redirect_thank_you_card": "Weiterleitung anlegen",
"redirect_to_url": "Zu URL weiterleiten",
@@ -1616,7 +1601,6 @@
"trigger_survey_when_one_of_the_actions_is_fired": "Umfrage auslösen, wenn eine der Aktionen ausgeführt wird...",
"try_lollipop_or_mountain": "Versuch 'Lolli' oder 'Berge'...",
"type_field_id": "Feld-ID eingeben",
"underline": "Unterstreichen",
"unlock_targeting_description": "Spezifische Nutzergruppen basierend auf Attributen oder Geräteinformationen ansprechen",
"unlock_targeting_title": "Targeting mit einem höheren Plan freischalten",
"unsaved_changes_warning": "Du hast ungespeicherte Änderungen in deiner Umfrage. Möchtest Du sie speichern, bevor Du gehst?",
@@ -1633,9 +1617,6 @@
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable \"{variableName}\" wird in der \"{quotaName}\" Quote verwendet",
"variable_name_is_already_taken_please_choose_another": "Variablenname ist bereits vergeben, bitte wähle einen anderen.",
"variable_name_must_start_with_a_letter": "Variablenname muss mit einem Buchstaben beginnen.",
"variable_used_in_recall": "Variable \"{variable}\" wird in Frage {questionIndex} abgerufen.",
"variable_used_in_recall_ending_card": "Variable \"{variable}\" wird in der Abschlusskarte abgerufen.",
"variable_used_in_recall_welcome": "Variable \"{variable}\" wird in der Willkommenskarte abgerufen.",
"verify_email_before_submission": "E-Mail vor dem Absenden überprüfen",
"verify_email_before_submission_description": "Lass nur Leute mit einer echten E-Mail antworten.",
"wait": "Warte",
@@ -2906,4 +2887,4 @@
"usability_rating_description": "Bewerte die wahrgenommene Benutzerfreundlichkeit, indem du die Nutzer bittest, ihre Erfahrung mit deinem Produkt mittels eines standardisierten 10-Fragen-Fragebogens zu bewerten.",
"usability_score_name": "System Usability Score Survey (SUS)"
}
}
}

View File

@@ -262,9 +262,7 @@
"membership_not_found": "Membership not found",
"metadata": "Metadata",
"minimum": "Minimum",
"mobile_overlay_app_works_best_on_desktop": "Formbricks works best on a bigger screen. To manage or build surveys, switch to another device.",
"mobile_overlay_surveys_look_good": "Don't worry your surveys look great on every device and screen size!",
"mobile_overlay_title": "Oops, tiny screen detected!",
"mobile_overlay_text": "Formbricks is not available for devices with smaller resolutions.",
"move_down": "Move down",
"move_up": "Move up",
"multiple_languages": "Multiple languages",
@@ -752,6 +750,7 @@
},
"project": {
"api_keys": {
"access_control": "Access Control",
"add_api_key": "Add API Key",
"api_key": "API Key",
"api_key_copied_to_clipboard": "API key copied to clipboard",
@@ -1204,7 +1203,7 @@
"add_ending": "Add ending",
"add_ending_below": "Add ending below",
"add_fallback": "Add",
"add_fallback_placeholder": "Add a placeholder to show if there is no value to recall.",
"add_fallback_placeholder": "Add a placeholder to show if the question gets skipped:",
"add_hidden_field_id": "Add hidden field ID",
"add_highlight_border": "Add highlight border",
"add_highlight_border_description": "Add an outer border to your survey card.",
@@ -1241,7 +1240,6 @@
"automatically_mark_the_survey_as_complete_after": "Automatically mark the survey as complete after",
"back_button_label": "\"Back\" Button Label",
"background_styling": "Background Styling",
"bold": "Bold",
"brand_color": "Brand color",
"brightness": "Brightness",
"button_label": "Button Label",
@@ -1302,8 +1300,8 @@
"contains": "Contains",
"continue_to_settings": "Continue to Settings",
"control_which_file_types_can_be_uploaded": "Control which file types can be uploaded.",
"convert_to_multiple_choice": "Convert to Multi-select",
"convert_to_single_choice": "Convert to Single-select",
"convert_to_multiple_choice": "Convert to Multiple Choice",
"convert_to_single_choice": "Convert to Single Choice",
"country": "Country",
"create_group": "Create group",
"create_your_own_survey": "Create your own survey",
@@ -1325,7 +1323,6 @@
"does_not_include_all_of": "Does not include all of",
"does_not_include_one_of": "Does not include one of",
"does_not_start_with": "Does not start with",
"edit_link": "Edit link",
"edit_recall": "Edit Recall",
"edit_translations": "Edit {lang} translations",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Enable participants to switch the survey language at any point during the survey.",
@@ -1336,7 +1333,6 @@
"ending_card_used_in_logic": "This ending card is used in logic of question {questionIndex}.",
"ending_used_in_quota": "This ending is being used in \"{quotaName}\" quota",
"ends_with": "Ends with",
"enter_fallback_value": "Enter fallback value",
"equals": "Equals",
"equals_one_of": "Equals one of",
"error_publishing_survey": "An error occured while publishing the survey.",
@@ -1400,9 +1396,6 @@
"four_points": "4 points",
"heading": "Heading",
"hidden_field_added_successfully": "Hidden field added successfully",
"hidden_field_used_in_recall": "Hidden field \"{hiddenField}\" is being recalled in question {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Hidden field \"{hiddenField}\" is being recalled in Ending Card",
"hidden_field_used_in_recall_welcome": "Hidden field \"{hiddenField}\" is being recalled in Welcome card.",
"hide_advanced_settings": "Hide advanced settings",
"hide_back_button": "Hide 'Back' button",
"hide_back_button_description": "Do not display the back button in the survey",
@@ -1421,7 +1414,6 @@
"inner_text": "Inner Text",
"input_border_color": "Input border color",
"input_color": "Input color",
"insert_link": "Insert link",
"invalid_targeting": "Invalid targeting: Please check your audience filters",
"invalid_video_url_warning": "Please enter a valid YouTube, Vimeo, or Loom URL. We currently do not support other video hosting providers.",
"invalid_youtube_url": "Invalid YouTube URL",
@@ -1439,7 +1431,6 @@
"is_set": "Is set",
"is_skipped": "Is skipped",
"is_submitted": "Is submitted",
"italic": "Italic",
"jump_to_question": "Jump to question",
"keep_current_order": "Keep current order",
"keep_showing_while_conditions_match": "Keep showing while conditions match",
@@ -1466,7 +1457,6 @@
"no_images_found_for": "No images found for ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "No languages found. Add the first one to get started.",
"no_option_found": "No option found",
"no_recall_items_found": "No recall items found ",
"no_variables_yet_add_first_one_below": "No variables yet. Add the first one below.",
"number": "Number",
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Once set, the default language for this survey can only be changed by disabling the multi-language option and deleting all translations.",
@@ -1486,7 +1476,6 @@
"pin_can_only_contain_numbers": "PIN can only contain numbers.",
"pin_must_be_a_four_digit_number": "PIN must be a four digit number.",
"please_enter_a_file_extension": "Please enter a file extension.",
"please_enter_a_valid_url": "Please enter a valid URL (e.g., https://example.com)",
"please_set_a_survey_trigger": "Please set a survey trigger",
"please_specify": "Please specify",
"prevent_double_submission": "Prevent double submission",
@@ -1501,8 +1490,6 @@
"question_id_updated": "Question ID updated",
"question_used_in_logic": "This question is used in logic of question {questionIndex}.",
"question_used_in_quota": "This question is being used in \"{quotaName}\" quota",
"question_used_in_recall": "This question is being recalled in question {questionIndex}.",
"question_used_in_recall_ending_card": "This question is being recalled in Ending Card",
"quotas": {
"add_quota": "Add quota",
"change_quota_for_public_survey": "Change quota for public survey?",
@@ -1537,8 +1524,6 @@
"randomize_all": "Randomize all",
"randomize_all_except_last": "Randomize all except last",
"range": "Range",
"recall_data": "Recall data",
"recall_information_from": "Recall information from ...",
"recontact_options": "Recontact Options",
"redirect_thank_you_card": "Redirect thank you card",
"redirect_to_url": "Redirect to Url",
@@ -1616,7 +1601,6 @@
"trigger_survey_when_one_of_the_actions_is_fired": "Trigger survey when one of the actions is fired...",
"try_lollipop_or_mountain": "Try 'lollipop' or 'mountain'...",
"type_field_id": "Type field id",
"underline": "Underline",
"unlock_targeting_description": "Target specific user groups based on attributes or device information",
"unlock_targeting_title": "Unlock targeting with a higher plan",
"unsaved_changes_warning": "You have unsaved changes in your survey. Would you like to save them before leaving?",
@@ -1633,9 +1617,6 @@
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable \"{variableName}\" is being used in \"{quotaName}\" quota",
"variable_name_is_already_taken_please_choose_another": "Variable name is already taken, please choose another.",
"variable_name_must_start_with_a_letter": "Variable name must start with a letter.",
"variable_used_in_recall": "Variable \"{variable}\" is being recalled in question {questionIndex}.",
"variable_used_in_recall_ending_card": "Variable {variable} is being recalled in Ending Card",
"variable_used_in_recall_welcome": "Variable \"{variable}\" is being recalled in Welcome Card.",
"verify_email_before_submission": "Verify email before submission",
"verify_email_before_submission_description": "Only let people with a real email respond.",
"wait": "Wait",
@@ -2906,4 +2887,4 @@
"usability_rating_description": "Measure perceived usability by asking users to rate their experience with your product using a standardized 10-question survey.",
"usability_score_name": "System Usability Score (SUS)"
}
}
}

View File

@@ -262,9 +262,7 @@
"membership_not_found": "Abonnement non trouvé",
"metadata": "Métadonnées",
"minimum": "Min",
"mobile_overlay_app_works_best_on_desktop": "Formbricks fonctionne mieux sur un écran plus grand. Pour gérer ou créer des sondages, passez à un autre appareil.",
"mobile_overlay_surveys_look_good": "Ne t'inquiète pas tes enquêtes sont superbes sur tous les appareils et tailles d'écran!",
"mobile_overlay_title": "Oups, écran minuscule détecté!",
"mobile_overlay_text": "Formbricks n'est pas disponible pour les appareils avec des résolutions plus petites.",
"move_down": "Déplacer vers le bas",
"move_up": "Déplacer vers le haut",
"multiple_languages": "Plusieurs langues",
@@ -752,6 +750,7 @@
},
"project": {
"api_keys": {
"access_control": "Contrôle d'accès",
"add_api_key": "Ajouter une clé API",
"api_key": "Clé API",
"api_key_copied_to_clipboard": "Clé API copiée dans le presse-papiers",
@@ -1204,7 +1203,7 @@
"add_ending": "Ajouter une fin",
"add_ending_below": "Ajouter une fin ci-dessous",
"add_fallback": "Ajouter",
"add_fallback_placeholder": "Ajouter un espace réservé à afficher s'il n'y a pas de valeur à rappeler.",
"add_fallback_placeholder": "Ajouter un espace réservé pour montrer si la question est ignorée :",
"add_hidden_field_id": "Ajouter un champ caché ID",
"add_highlight_border": "Ajouter une bordure de surlignage",
"add_highlight_border_description": "Ajoutez une bordure extérieure à votre carte d'enquête.",
@@ -1241,7 +1240,6 @@
"automatically_mark_the_survey_as_complete_after": "Marquer automatiquement l'enquête comme terminée après",
"back_button_label": "Label du bouton \"Retour''",
"background_styling": "Style de fond",
"bold": "Gras",
"brand_color": "Couleur de marque",
"brightness": "Luminosité",
"button_label": "Label du bouton",
@@ -1325,7 +1323,6 @@
"does_not_include_all_of": "n'inclut pas tout",
"does_not_include_one_of": "n'inclut pas un de",
"does_not_start_with": "Ne commence pas par",
"edit_link": "Modifier le lien",
"edit_recall": "Modifier le rappel",
"edit_translations": "Modifier les traductions {lang}",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permettre aux participants de changer la langue de l'enquête à tout moment pendant celle-ci.",
@@ -1336,7 +1333,6 @@
"ending_card_used_in_logic": "Cette carte de fin est utilisée dans la logique de la question '{'questionIndex'}'.",
"ending_used_in_quota": "Cette fin est utilisée dans le quota \"{quotaName}\"",
"ends_with": "Se termine par",
"enter_fallback_value": "Saisir une valeur de secours",
"equals": "Égal",
"equals_one_of": "Égal à l'un de",
"error_publishing_survey": "Une erreur est survenue lors de la publication de l'enquête.",
@@ -1400,9 +1396,6 @@
"four_points": "4 points",
"heading": "En-tête",
"hidden_field_added_successfully": "Champ caché ajouté avec succès",
"hidden_field_used_in_recall": "Le champ caché \"{hiddenField}\" est rappelé dans la question {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Le champ caché \"{hiddenField}\" est rappelé dans la carte de fin.",
"hidden_field_used_in_recall_welcome": "Le champ caché \"{hiddenField}\" est rappelé dans la carte de bienvenue.",
"hide_advanced_settings": "Cacher les paramètres avancés",
"hide_back_button": "Masquer le bouton 'Retour'",
"hide_back_button_description": "Ne pas afficher le bouton retour dans l'enquête",
@@ -1421,7 +1414,6 @@
"inner_text": "Texte interne",
"input_border_color": "Couleur de bordure d'entrée",
"input_color": "Couleur d'entrée",
"insert_link": "Insérer un lien",
"invalid_targeting": "Ciblage invalide : Veuillez vérifier vos filtres d'audience",
"invalid_video_url_warning": "Merci d'entrer une URL YouTube, Vimeo ou Loom valide. Les autres plateformes vidéo ne sont pas encore supportées.",
"invalid_youtube_url": "URL YouTube invalide",
@@ -1439,7 +1431,6 @@
"is_set": "Est défini",
"is_skipped": "Est ignoré",
"is_submitted": "Est soumis",
"italic": "Italique",
"jump_to_question": "Passer à la question",
"keep_current_order": "Conserver la commande actuelle",
"keep_showing_while_conditions_match": "Continuer à afficher tant que les conditions correspondent",
@@ -1466,7 +1457,6 @@
"no_images_found_for": "Aucune image trouvée pour ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "Aucune langue trouvée. Ajoutez la première pour commencer.",
"no_option_found": "Aucune option trouvée",
"no_recall_items_found": "Aucun élément de rappel trouvé",
"no_variables_yet_add_first_one_below": "Aucune variable pour le moment. Ajoutez la première ci-dessous.",
"number": "Numéro",
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Une fois défini, la langue par défaut de cette enquête ne peut être changée qu'en désactivant l'option multilingue et en supprimant toutes les traductions.",
@@ -1486,7 +1476,6 @@
"pin_can_only_contain_numbers": "Le code PIN ne peut contenir que des chiffres.",
"pin_must_be_a_four_digit_number": "Le code PIN doit être un numéro à quatre chiffres.",
"please_enter_a_file_extension": "Veuillez entrer une extension de fichier.",
"please_enter_a_valid_url": "Veuillez entrer une URL valide (par exemple, https://example.com)",
"please_set_a_survey_trigger": "Veuillez définir un déclencheur d'enquête.",
"please_specify": "Veuillez préciser",
"prevent_double_submission": "Empêcher la double soumission",
@@ -1501,8 +1490,6 @@
"question_id_updated": "ID de la question mis à jour",
"question_used_in_logic": "Cette question est utilisée dans la logique de la question '{'questionIndex'}'.",
"question_used_in_quota": "Cette question est utilisée dans le quota \"{quotaName}\"",
"question_used_in_recall": "Cette question est rappelée dans la question {questionIndex}.",
"question_used_in_recall_ending_card": "Cette question est rappelée dans la carte de fin.",
"quotas": {
"add_quota": "Ajouter un quota",
"change_quota_for_public_survey": "Changer le quota pour le sondage public ?",
@@ -1537,8 +1524,6 @@
"randomize_all": "Randomiser tout",
"randomize_all_except_last": "Randomiser tout sauf le dernier",
"range": "Plage",
"recall_data": "Rappel des données",
"recall_information_from": "Rappeler les informations de ...",
"recontact_options": "Options de recontact",
"redirect_thank_you_card": "Carte de remerciement de redirection",
"redirect_to_url": "Rediriger vers l'URL",
@@ -1616,7 +1601,6 @@
"trigger_survey_when_one_of_the_actions_is_fired": "Déclencher l'enquête lorsqu'une des actions est déclenchée...",
"try_lollipop_or_mountain": "Essayez 'sucette' ou 'montagne'...",
"type_field_id": "Identifiant de champ de type",
"underline": "Souligner",
"unlock_targeting_description": "Cibler des groupes d'utilisateurs spécifiques en fonction des attributs ou des informations sur l'appareil",
"unlock_targeting_title": "Débloquez le ciblage avec un plan supérieur.",
"unsaved_changes_warning": "Vous avez des modifications non enregistrées dans votre enquête. Souhaitez-vous les enregistrer avant de partir ?",
@@ -1633,9 +1617,6 @@
"variable_is_used_in_quota_please_remove_it_from_quota_first": "La variable \"{variableName}\" est utilisée dans le quota \"{quotaName}\"",
"variable_name_is_already_taken_please_choose_another": "Le nom de la variable est déjà pris, veuillez en choisir un autre.",
"variable_name_must_start_with_a_letter": "Le nom de la variable doit commencer par une lettre.",
"variable_used_in_recall": "La variable \"{variable}\" est rappelée dans la question {questionIndex}.",
"variable_used_in_recall_ending_card": "La variable {variable} est rappelée dans la carte de fin.",
"variable_used_in_recall_welcome": "La variable \"{variable}\" est rappelée dans la carte de bienvenue.",
"verify_email_before_submission": "Vérifiez l'email avant la soumission",
"verify_email_before_submission_description": "Ne laissez répondre que les personnes ayant une véritable adresse e-mail.",
"wait": "Attendre",
@@ -2906,4 +2887,4 @@
"usability_rating_description": "Mesurez la convivialité perçue en demandant aux utilisateurs d'évaluer leur expérience avec votre produit via un sondage standardisé de 10 questions.",
"usability_score_name": "Score d'Utilisabilité du Système (SUS)"
}
}
}

View File

@@ -262,9 +262,7 @@
"membership_not_found": "メンバーシップが見つかりません",
"metadata": "メタデータ",
"minimum": "最小",
"mobile_overlay_app_works_best_on_desktop": "Formbricks は より 大きな 画面 で最適に 作動します。 フォーム を 管理または 構築する には、 別の デバイス に 切り替える 必要が あります。",
"mobile_overlay_surveys_look_good": "ご安心ください - お使い の デバイス や 画面 サイズ に 関係なく、 フォーム は 素晴らしく 見えます!",
"mobile_overlay_title": "おっと、 小さな 画面 が 検出されました!",
"mobile_overlay_text": "Formbricksは、解像度の小さいデバイスでは利用できません。",
"move_down": "下に移動",
"move_up": "上に移動",
"multiple_languages": "多言語",
@@ -752,6 +750,7 @@
},
"project": {
"api_keys": {
"access_control": "アクセス制御",
"add_api_key": "APIキーを追加",
"api_key": "APIキー",
"api_key_copied_to_clipboard": "APIキーをクリップボードにコピーしました",
@@ -1241,7 +1240,6 @@
"automatically_mark_the_survey_as_complete_after": "フォームを自動的に完了としてマークする",
"back_button_label": "「戻る」ボタンのラベル",
"background_styling": "背景のスタイル",
"bold": "太字",
"brand_color": "ブランドカラー",
"brightness": "明るさ",
"button_label": "ボタンのラベル",
@@ -1325,7 +1323,6 @@
"does_not_include_all_of": "のすべてを含まない",
"does_not_include_one_of": "のいずれも含まない",
"does_not_start_with": "で始まらない",
"edit_link": "編集 リンク",
"edit_recall": "リコールを編集",
"edit_translations": "{lang} 翻訳を編集",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "回答者がフォームの途中でいつでも言語を切り替えられるようにします。",
@@ -1336,7 +1333,6 @@
"ending_card_used_in_logic": "この終了カードは質問 {questionIndex} のロジックで使用されています。",
"ending_used_in_quota": "この 終了 は \"{quotaName}\" クォータ で使用されています",
"ends_with": "で終わる",
"enter_fallback_value": "フォールバック値を入力",
"equals": "と等しい",
"equals_one_of": "のいずれかと等しい",
"error_publishing_survey": "フォームの公開中にエラーが発生しました。",
@@ -1400,9 +1396,6 @@
"four_points": "4点",
"heading": "見出し",
"hidden_field_added_successfully": "非表示フィールドを正常に追加しました",
"hidden_field_used_in_recall": "隠し フィールド \"{hiddenField}\" が 質問 {questionIndex} で 呼び出され て います 。",
"hidden_field_used_in_recall_ending_card": "隠し フィールド \"{hiddenField}\" が エンディング カード で 呼び出され て います。",
"hidden_field_used_in_recall_welcome": "隠し フィールド \"{hiddenField}\" が ウェルカム カード で 呼び出され て います。",
"hide_advanced_settings": "詳細設定を非表示",
"hide_back_button": "「戻る」ボタンを非表示",
"hide_back_button_description": "フォームに「戻る」ボタンを表示しない",
@@ -1421,7 +1414,6 @@
"inner_text": "内部テキスト",
"input_border_color": "入力の枠線の色",
"input_color": "入力の色",
"insert_link": "リンク を 挿入",
"invalid_targeting": "無効なターゲティング: オーディエンスフィルターを確認してください",
"invalid_video_url_warning": "有効なYouTube、Vimeo、またはLoomのURLを入力してください。現在、他の動画ホスティングプロバイダーはサポートしていません。",
"invalid_youtube_url": "無効なYouTube URL",
@@ -1439,7 +1431,6 @@
"is_set": "設定されている",
"is_skipped": "スキップ済み",
"is_submitted": "送信済み",
"italic": "イタリック",
"jump_to_question": "質問にジャンプ",
"keep_current_order": "現在の順序を維持",
"keep_showing_while_conditions_match": "条件が一致する間、表示し続ける",
@@ -1466,7 +1457,6 @@
"no_images_found_for": "''{query}'' の画像が見つかりません",
"no_languages_found_add_first_one_to_get_started": "言語が見つかりません。始めるには、最初のものを追加してください。",
"no_option_found": "オプションが見つかりません",
"no_recall_items_found": "リコールアイテムが見つかりません ",
"no_variables_yet_add_first_one_below": "まだ変数がありません。以下で最初のものを追加してください。",
"number": "数値",
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "一度設定すると、このフォームのデフォルト言語は、多言語オプションを無効にしてすべての翻訳を削除することによってのみ変更できます。",
@@ -1486,7 +1476,6 @@
"pin_can_only_contain_numbers": "PINは数字のみでなければなりません。",
"pin_must_be_a_four_digit_number": "PINは4桁の数字でなければなりません。",
"please_enter_a_file_extension": "ファイル拡張子を入力してください。",
"please_enter_a_valid_url": "有効な URL を入力してください (例https://example.com)",
"please_set_a_survey_trigger": "フォームのトリガーを設定してください",
"please_specify": "具体的に指定してください",
"prevent_double_submission": "二重送信を防ぐ",
@@ -1501,8 +1490,6 @@
"question_id_updated": "質問IDを更新しました",
"question_used_in_logic": "この質問は質問 {questionIndex} のロジックで使用されています。",
"question_used_in_quota": "この 質問 は \"{quotaName}\" の クオータ に使用されています",
"question_used_in_recall": "この 質問 は 質問 {questionIndex} で 呼び出され て います 。",
"question_used_in_recall_ending_card": "この 質問 は エンディング カード で 呼び出され て います。",
"quotas": {
"add_quota": "クォータを追加",
"change_quota_for_public_survey": "パブリック フォームのクォータを変更しますか?",
@@ -1537,8 +1524,6 @@
"randomize_all": "すべてをランダム化",
"randomize_all_except_last": "最後を除くすべてをランダム化",
"range": "範囲",
"recall_data": "データを呼び出す",
"recall_information_from": "... からの情報を呼び戻す",
"recontact_options": "再接触オプション",
"redirect_thank_you_card": "サンクスクカードをリダイレクト",
"redirect_to_url": "URLにリダイレクト",
@@ -1616,7 +1601,6 @@
"trigger_survey_when_one_of_the_actions_is_fired": "以下のアクションのいずれかが発火したときにフォームをトリガーします...",
"try_lollipop_or_mountain": "「lollipop」や「mountain」を試してみてください...",
"type_field_id": "フィールドIDを入力",
"underline": "下線",
"unlock_targeting_description": "属性またはデバイス情報に基づいて、特定のユーザーグループをターゲットにします",
"unlock_targeting_title": "上位プランでターゲティングをアンロック",
"unsaved_changes_warning": "フォームに未保存の変更があります。離れる前に保存しますか?",
@@ -1633,9 +1617,6 @@
"variable_is_used_in_quota_please_remove_it_from_quota_first": "変数 \"{variableName}\" は \"{quotaName}\" クォータ で使用されています",
"variable_name_is_already_taken_please_choose_another": "変数名はすでに使用されています。別の名前を選択してください。",
"variable_name_must_start_with_a_letter": "変数名はアルファベットで始まらなければなりません。",
"variable_used_in_recall": "変数 \"{variable}\" が 質問 {questionIndex} で 呼び出され て います 。",
"variable_used_in_recall_ending_card": "変数 {variable} が エンディング カード で 呼び出され て います。",
"variable_used_in_recall_welcome": "変数 \"{variable}\" が ウェルカム カード で 呼び出され て います。",
"verify_email_before_submission": "送信前にメールアドレスを認証",
"verify_email_before_submission_description": "有効なメールアドレスを持つ人のみが回答できるようにする",
"wait": "待つ",
@@ -2906,4 +2887,4 @@
"usability_rating_description": "標準化された10の質問アンケートを使用して、製品に対するユーザーの体験を評価し、知覚された使いやすさを測定する。",
"usability_score_name": "システムユーザビリティスコアSUS"
}
}
}

View File

@@ -262,9 +262,7 @@
"membership_not_found": "Assinatura não encontrada",
"metadata": "metadados",
"minimum": "Mínimo",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor em uma tela maior. Para gerenciar ou criar pesquisas, mude para outro dispositivo.",
"mobile_overlay_surveys_look_good": "Não se preocupe suas pesquisas ficam ótimas em qualquer dispositivo e tamanho de tela!",
"mobile_overlay_title": "Eita, tela pequena detectada!",
"mobile_overlay_text": "O Formbricks não está disponível para dispositivos com resoluções menores.",
"move_down": "Descer",
"move_up": "Subir",
"multiple_languages": "Vários idiomas",
@@ -752,6 +750,7 @@
},
"project": {
"api_keys": {
"access_control": "Controle de Acesso",
"add_api_key": "Adicionar Chave API",
"api_key": "Chave de API",
"api_key_copied_to_clipboard": "Chave da API copiada para a área de transferência",
@@ -1241,7 +1240,6 @@
"automatically_mark_the_survey_as_complete_after": "Marcar automaticamente a pesquisa como concluída após",
"back_button_label": "Voltar",
"background_styling": "Estilo de Fundo",
"bold": "Negrito",
"brand_color": "Cor da marca",
"brightness": "brilho",
"button_label": "Rótulo do Botão",
@@ -1325,7 +1323,6 @@
"does_not_include_all_of": "Não inclui todos de",
"does_not_include_one_of": "Não inclui um de",
"does_not_start_with": "Não começa com",
"edit_link": "Editar link",
"edit_recall": "Editar Lembrete",
"edit_translations": "Editar traduções de {lang}",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir que os participantes mudem o idioma da pesquisa a qualquer momento durante a pesquisa.",
@@ -1336,7 +1333,6 @@
"ending_card_used_in_logic": "Esse cartão de encerramento é usado na lógica da pergunta {questionIndex}.",
"ending_used_in_quota": "Este final está sendo usado na cota \"{quotaName}\"",
"ends_with": "Termina com",
"enter_fallback_value": "Insira o valor de fallback",
"equals": "Igual",
"equals_one_of": "É igual a um de",
"error_publishing_survey": "Ocorreu um erro ao publicar a pesquisa.",
@@ -1400,9 +1396,6 @@
"four_points": "4 pontos",
"heading": "Título",
"hidden_field_added_successfully": "Campo oculto adicionado com sucesso",
"hidden_field_used_in_recall": "Campo oculto \"{hiddenField}\" está sendo recordado na pergunta {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Campo oculto \"{hiddenField}\" está sendo recordado no card de Encerramento.",
"hidden_field_used_in_recall_welcome": "Campo oculto \"{hiddenField}\" está sendo recordado no card de Boas-Vindas.",
"hide_advanced_settings": "Ocultar configurações avançadas",
"hide_back_button": "Ocultar botão 'Voltar'",
"hide_back_button_description": "Não exibir o botão de voltar na pesquisa",
@@ -1421,7 +1414,6 @@
"inner_text": "Texto Interno",
"input_border_color": "Cor da borda de entrada",
"input_color": "Cor de entrada",
"insert_link": "Inserir link",
"invalid_targeting": "Segmentação inválida: Por favor, verifique os filtros do seu público",
"invalid_video_url_warning": "Por favor, insira uma URL válida do YouTube, Vimeo ou Loom. No momento, não suportamos outros provedores de vídeo.",
"invalid_youtube_url": "URL do YouTube inválida",
@@ -1439,7 +1431,6 @@
"is_set": "Está definido",
"is_skipped": "é pulado",
"is_submitted": "é submetido",
"italic": "Itálico",
"jump_to_question": "Pular para a pergunta",
"keep_current_order": "Manter pedido atual",
"keep_showing_while_conditions_match": "Continue mostrando enquanto as condições corresponderem",
@@ -1466,7 +1457,6 @@
"no_images_found_for": "Nenhuma imagem encontrada para ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "Nenhum idioma encontrado. Adicione o primeiro para começar.",
"no_option_found": "Nenhuma opção encontrada",
"no_recall_items_found": "Nenhum item de recordação encontrado",
"no_variables_yet_add_first_one_below": "Ainda não há variáveis. Adicione a primeira abaixo.",
"number": "Número",
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Depois de definido, o idioma padrão desta pesquisa só pode ser alterado desativando a opção de vários idiomas e excluindo todas as traduções.",
@@ -1486,7 +1476,6 @@
"pin_can_only_contain_numbers": "O PIN só pode conter números.",
"pin_must_be_a_four_digit_number": "O PIN deve ser um número de quatro dígitos.",
"please_enter_a_file_extension": "Por favor, insira uma extensão de arquivo.",
"please_enter_a_valid_url": "Por favor, insira uma URL válida (ex.: https://example.com)",
"please_set_a_survey_trigger": "Por favor, configure um gatilho para a pesquisa",
"please_specify": "Por favor, especifique",
"prevent_double_submission": "Evitar envio duplicado",
@@ -1501,8 +1490,6 @@
"question_id_updated": "ID da pergunta atualizado",
"question_used_in_logic": "Essa pergunta é usada na lógica da pergunta {questionIndex}.",
"question_used_in_quota": "Esta questão está sendo usada na cota \"{quotaName}\"",
"question_used_in_recall": "Esta pergunta está sendo recordada na pergunta {questionIndex}.",
"question_used_in_recall_ending_card": "Esta pergunta está sendo recordada no card de Encerramento",
"quotas": {
"add_quota": "Adicionar cota",
"change_quota_for_public_survey": "Alterar cota para pesquisa pública?",
@@ -1537,8 +1524,6 @@
"randomize_all": "Randomizar tudo",
"randomize_all_except_last": "Randomizar tudo, exceto o último",
"range": "alcance",
"recall_data": "Lembrar dados",
"recall_information_from": "Recuperar informações de ...",
"recontact_options": "Opções de Recontato",
"redirect_thank_you_card": "Redirecionar cartão de agradecimento",
"redirect_to_url": "Redirecionar para URL",
@@ -1616,7 +1601,6 @@
"trigger_survey_when_one_of_the_actions_is_fired": "Disparar pesquisa quando uma das ações for executada...",
"try_lollipop_or_mountain": "Tenta 'pirulito' ou 'montanha'...",
"type_field_id": "Digite o id do campo",
"underline": "Sublinhar",
"unlock_targeting_description": "Direcione grupos específicos de usuários com base em atributos ou informações do dispositivo",
"unlock_targeting_title": "Desbloqueie o direcionamento com um plano superior",
"unsaved_changes_warning": "Você tem alterações não salvas na sua pesquisa. Quer salvar antes de sair?",
@@ -1633,9 +1617,6 @@
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variável \"{variableName}\" está sendo usada na cota \"{quotaName}\"",
"variable_name_is_already_taken_please_choose_another": "O nome da variável já está em uso, por favor escolha outro.",
"variable_name_must_start_with_a_letter": "O nome da variável deve começar com uma letra.",
"variable_used_in_recall": "Variável \"{variable}\" está sendo recordada na pergunta {questionIndex}.",
"variable_used_in_recall_ending_card": "Variável {variable} está sendo recordada no card de Encerramento",
"variable_used_in_recall_welcome": "Variável \"{variable}\" está sendo recordada no Card de Boas-Vindas.",
"verify_email_before_submission": "Verifique o e-mail antes de enviar",
"verify_email_before_submission_description": "Deixe só quem tem um email real responder.",
"wait": "Espera",
@@ -2906,4 +2887,4 @@
"usability_rating_description": "Meça a usabilidade percebida perguntando aos usuários para avaliar sua experiência com seu produto usando uma pesquisa padronizada de 10 perguntas.",
"usability_score_name": "Pontuação de Usabilidade do Sistema (SUS)"
}
}
}

View File

@@ -262,9 +262,7 @@
"membership_not_found": "Associação não encontrada",
"metadata": "Metadados",
"minimum": "Mínimo",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor num ecrã maior. Para gerir ou criar inquéritos, mude de dispositivo.",
"mobile_overlay_surveys_look_good": "Não se preocupe os seus inquéritos têm uma ótima aparência em todos os dispositivos e tamanhos de ecrã!",
"mobile_overlay_title": "Oops, ecrã pequeno detectado!",
"mobile_overlay_text": "O Formbricks não está disponível para dispositivos com resoluções menores.",
"move_down": "Mover para baixo",
"move_up": "Mover para cima",
"multiple_languages": "Várias línguas",
@@ -752,6 +750,7 @@
},
"project": {
"api_keys": {
"access_control": "Controlo de Acesso",
"add_api_key": "Adicionar Chave API",
"api_key": "Chave API",
"api_key_copied_to_clipboard": "Chave API copiada para a área de transferência",
@@ -1204,7 +1203,7 @@
"add_ending": "Adicionar encerramento",
"add_ending_below": "Adicionar encerramento abaixo",
"add_fallback": "Adicionar",
"add_fallback_placeholder": "Adicionar um espaço reservado para mostrar se não houver valor para recordar.",
"add_fallback_placeholder": "Adicionar um espaço reservado para mostrar se a pergunta for ignorada:",
"add_hidden_field_id": "Adicionar ID do campo oculto",
"add_highlight_border": "Adicionar borda de destaque",
"add_highlight_border_description": "Adicione uma borda externa ao seu cartão de inquérito.",
@@ -1241,7 +1240,6 @@
"automatically_mark_the_survey_as_complete_after": "Marcar automaticamente o inquérito como concluído após",
"back_button_label": "Rótulo do botão \"Voltar\"",
"background_styling": "Estilo de Fundo",
"bold": "Negrito",
"brand_color": "Cor da marca",
"brightness": "Brilho",
"button_label": "Rótulo do botão",
@@ -1302,8 +1300,8 @@
"contains": "Contém",
"continue_to_settings": "Continuar para Definições",
"control_which_file_types_can_be_uploaded": "Controlar quais tipos de ficheiros podem ser carregados.",
"convert_to_multiple_choice": "Converter para Seleção Múltipla",
"convert_to_single_choice": "Converter para Seleção Única",
"convert_to_multiple_choice": "Converter para Escolha Múltipla",
"convert_to_single_choice": "Converter para Escolha Única",
"country": "País",
"create_group": "Criar grupo",
"create_your_own_survey": "Crie o seu próprio inquérito",
@@ -1325,7 +1323,6 @@
"does_not_include_all_of": "Não inclui todos de",
"does_not_include_one_of": "Não inclui um de",
"does_not_start_with": "Não começa com",
"edit_link": "Editar link",
"edit_recall": "Editar Lembrete",
"edit_translations": "Editar traduções {lang}",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir aos participantes mudar a língua do inquérito a qualquer momento durante o inquérito.",
@@ -1336,7 +1333,6 @@
"ending_card_used_in_logic": "Este cartão final é usado na lógica da pergunta {questionIndex}.",
"ending_used_in_quota": "Este final está a ser usado na quota \"{quotaName}\"",
"ends_with": "Termina com",
"enter_fallback_value": "Inserir valor de substituição",
"equals": "Igual",
"equals_one_of": "Igual a um de",
"error_publishing_survey": "Ocorreu um erro ao publicar o questionário.",
@@ -1400,9 +1396,6 @@
"four_points": "4 pontos",
"heading": "Cabeçalho",
"hidden_field_added_successfully": "Campo oculto adicionado com sucesso",
"hidden_field_used_in_recall": "Campo oculto \"{hiddenField}\" está a ser recordado na pergunta {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Campo oculto \"{hiddenField}\" está a ser recordado no Cartão de Conclusão",
"hidden_field_used_in_recall_welcome": "Campo oculto \"{hiddenField}\" está a ser recordado no cartão de boas-vindas.",
"hide_advanced_settings": "Ocultar definições avançadas",
"hide_back_button": "Ocultar botão 'Retroceder'",
"hide_back_button_description": "Não mostrar o botão de retroceder no inquérito",
@@ -1421,7 +1414,6 @@
"inner_text": "Texto Interno",
"input_border_color": "Cor da borda do campo de entrada",
"input_color": "Cor do campo de entrada",
"insert_link": "Inserir ligação",
"invalid_targeting": "Segmentação inválida: Por favor, verifique os seus filtros de audiência",
"invalid_video_url_warning": "Por favor, insira um URL válido do YouTube, Vimeo ou Loom. Atualmente, não suportamos outros fornecedores de hospedagem de vídeo.",
"invalid_youtube_url": "URL do YouTube inválido",
@@ -1439,7 +1431,6 @@
"is_set": "Está definido",
"is_skipped": "É ignorado",
"is_submitted": "Está submetido",
"italic": "Itálico",
"jump_to_question": "Saltar para a pergunta",
"keep_current_order": "Manter ordem atual",
"keep_showing_while_conditions_match": "Continuar a mostrar enquanto as condições corresponderem",
@@ -1466,7 +1457,6 @@
"no_images_found_for": "Não foram encontradas imagens para ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "Nenhuma língua encontrada. Adicione a primeira para começar.",
"no_option_found": "Nenhuma opção encontrada",
"no_recall_items_found": "Nenhum item de recordação encontrado",
"no_variables_yet_add_first_one_below": "Ainda não há variáveis. Adicione a primeira abaixo.",
"number": "Número",
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Depois de definido, o idioma padrão desta pesquisa só pode ser alterado desativando a opção de vários idiomas e eliminando todas as traduções.",
@@ -1486,7 +1476,6 @@
"pin_can_only_contain_numbers": "O PIN só pode conter números.",
"pin_must_be_a_four_digit_number": "O PIN deve ser um número de quatro dígitos.",
"please_enter_a_file_extension": "Por favor, insira uma extensão de ficheiro.",
"please_enter_a_valid_url": "Por favor, insira um URL válido (por exemplo, https://example.com)",
"please_set_a_survey_trigger": "Por favor, defina um desencadeador de inquérito",
"please_specify": "Por favor, especifique",
"prevent_double_submission": "Impedir submissão dupla",
@@ -1501,8 +1490,6 @@
"question_id_updated": "ID da pergunta atualizado",
"question_used_in_logic": "Esta pergunta é usada na lógica da pergunta {questionIndex}.",
"question_used_in_quota": "Esta pergunta está a ser usada na quota \"{quotaName}\"",
"question_used_in_recall": "Esta pergunta está a ser recordada na pergunta {questionIndex}.",
"question_used_in_recall_ending_card": "Esta pergunta está a ser recordada no Cartão de Conclusão",
"quotas": {
"add_quota": "Adicionar quota",
"change_quota_for_public_survey": "Alterar quota para inquérito público?",
@@ -1537,8 +1524,6 @@
"randomize_all": "Aleatorizar todos",
"randomize_all_except_last": "Aleatorizar todos exceto o último",
"range": "Intervalo",
"recall_data": "Recuperar dados",
"recall_information_from": "Recordar informação de ...",
"recontact_options": "Opções de Recontacto",
"redirect_thank_you_card": "Redirecionar cartão de agradecimento",
"redirect_to_url": "Redirecionar para Url",
@@ -1616,7 +1601,6 @@
"trigger_survey_when_one_of_the_actions_is_fired": "Desencadear inquérito quando uma das ações for disparada...",
"try_lollipop_or_mountain": "Experimente 'lollipop' ou 'mountain'...",
"type_field_id": "Escreva o id do campo",
"underline": "Sublinhar",
"unlock_targeting_description": "Alvo de grupos de utilizadores específicos com base em atributos ou informações do dispositivo",
"unlock_targeting_title": "Desbloqueie a segmentação com um plano superior",
"unsaved_changes_warning": "Tem alterações não guardadas no seu inquérito. Gostaria de as guardar antes de sair?",
@@ -1633,9 +1617,6 @@
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variável \"{variableName}\" está a ser utilizada na quota \"{quotaName}\"",
"variable_name_is_already_taken_please_choose_another": "O nome da variável já está em uso, por favor escolha outro.",
"variable_name_must_start_with_a_letter": "O nome da variável deve começar com uma letra.",
"variable_used_in_recall": "Variável \"{variable}\" está a ser recordada na pergunta {questionIndex}.",
"variable_used_in_recall_ending_card": "Variável {variable} está a ser recordada no Cartão de Conclusão",
"variable_used_in_recall_welcome": "Variável \"{variable}\" está a ser recordada no cartão de boas-vindas.",
"verify_email_before_submission": "Verificar email antes da submissão",
"verify_email_before_submission_description": "Permitir apenas que pessoas com um email real respondam.",
"wait": "Aguardar",
@@ -2906,4 +2887,4 @@
"usability_rating_description": "Meça a usabilidade percebida ao solicitar que os utilizadores avaliem a sua experiência com o seu produto usando um questionário padronizado de 10 perguntas.",
"usability_score_name": "Pontuação de Usabilidade do Sistema (SUS)"
}
}
}

View File

@@ -262,9 +262,7 @@
"membership_not_found": "Apartenența nu a fost găsită",
"metadata": "Metadate",
"minimum": "Minim",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funcționează cel mai bine pe un ecran mai mare. Pentru a gestiona sau crea chestionare, treceți la un alt dispozitiv.",
"mobile_overlay_surveys_look_good": "Nu vă faceți griji chestionarele dumneavoastră arată grozav pe orice dispozitiv și dimensiune a ecranului!",
"mobile_overlay_title": "Ups, ecran mic detectat!",
"mobile_overlay_text": "Formbricks nu este disponibil pentru dispozitive cu rezoluții mai mici.",
"move_down": "Mută în jos",
"move_up": "Mută sus",
"multiple_languages": "Mai multe limbi",
@@ -752,6 +750,7 @@
},
"project": {
"api_keys": {
"access_control": "Control acces",
"add_api_key": "Adaugă Cheie API",
"api_key": "Cheie API",
"api_key_copied_to_clipboard": "Cheia API a fost copiată în clipboard",
@@ -1204,7 +1203,7 @@
"add_ending": "Adaugă finalizare",
"add_ending_below": "Adaugă finalizare mai jos",
"add_fallback": "Adaugă",
"add_fallback_placeholder": "Adaugă un placeholder pentru a afișa dacă nu există valoare de reamintit",
"add_fallback_placeholder": "Adaugă un substituent pentru a afișa dacă întrebarea este omisă:",
"add_hidden_field_id": "Adăugați ID câmp ascuns",
"add_highlight_border": "Adaugă bordură evidențiată",
"add_highlight_border_description": "Adaugă o margine exterioară cardului tău de sondaj.",
@@ -1241,7 +1240,6 @@
"automatically_mark_the_survey_as_complete_after": "Marcați automat sondajul ca finalizat după",
"back_button_label": "Etichetă buton \"Înapoi\"",
"background_styling": "Stilizare fundal",
"bold": "Îngroșat",
"brand_color": "Culoarea brandului",
"brightness": "Luminozitate",
"button_label": "Etichetă buton",
@@ -1302,8 +1300,8 @@
"contains": "Conține",
"continue_to_settings": "Continuă către Setări",
"control_which_file_types_can_be_uploaded": "Controlează ce tipuri de fișiere pot fi încărcate.",
"convert_to_multiple_choice": "Convertiți la selectare multiplă",
"convert_to_single_choice": "Convertiți la selectare unică",
"convert_to_multiple_choice": "Convertiți la alegere multiplă",
"convert_to_single_choice": "Convertiți la alegere unică",
"country": "Țară",
"create_group": "Creează grup",
"create_your_own_survey": "Creează-ți propriul chestionar",
@@ -1325,7 +1323,6 @@
"does_not_include_all_of": "Nu include toate",
"does_not_include_one_of": "Nu include una dintre",
"does_not_start_with": "Nu începe cu",
"edit_link": "Editare legătură",
"edit_recall": "Editează Referințele",
"edit_translations": "Editează traducerile {lang}",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permite participanților să schimbe limba sondajului în orice moment în timpul sondajului.",
@@ -1336,7 +1333,6 @@
"ending_card_used_in_logic": "Această carte de încheiere este folosită în logica întrebării {questionIndex}.",
"ending_used_in_quota": "Finalul acesta este folosit în cota \"{quotaName}\"",
"ends_with": "Se termină cu",
"enter_fallback_value": "Introduceți valoarea implicită",
"equals": "Egal",
"equals_one_of": "Egal unu dintre",
"error_publishing_survey": "A apărut o eroare în timpul publicării sondajului.",
@@ -1400,9 +1396,6 @@
"four_points": "4 puncte",
"heading": "Titlu",
"hidden_field_added_successfully": "Câmp ascuns adăugat cu succes",
"hidden_field_used_in_recall": "Câmpul ascuns \"{hiddenField}\" este reamintit în întrebarea {questionIndex}.",
"hidden_field_used_in_recall_ending_card": "Câmpul ascuns \"{hiddenField}\" este reamintit în Cardul de Încheiere.",
"hidden_field_used_in_recall_welcome": "Câmpul ascuns \"{hiddenField}\" este reamintit în cardul de bun venit.",
"hide_advanced_settings": "Ascunde setări avansate",
"hide_back_button": "Ascunde butonul 'Înapoi'",
"hide_back_button_description": "Nu afișa butonul Înapoi în sondaj",
@@ -1421,7 +1414,6 @@
"inner_text": "Text Interior",
"input_border_color": "Culoarea graniței câmpului de introducere",
"input_color": "Culoarea câmpului de introducere",
"insert_link": "Inserează link",
"invalid_targeting": "\"Targetare nevalidă: Vă rugăm să verificați filtrele pentru audiență\"",
"invalid_video_url_warning": "Vă rugăm să introduceți un URL valid de YouTube, Vimeo sau Loom. În prezent nu susținem alți furnizori de găzduire video.",
"invalid_youtube_url": "URL YouTube invalid",
@@ -1439,7 +1431,6 @@
"is_set": "Este setat",
"is_skipped": "Este sărit",
"is_submitted": "Este trimis",
"italic": "Cursiv",
"jump_to_question": "Sări la întrebare",
"keep_current_order": "Păstrați ordinea actuală",
"keep_showing_while_conditions_match": "Continuă să afișezi cât timp condițiile se potrivesc",
@@ -1466,7 +1457,6 @@
"no_images_found_for": "Nicio imagine găsită pentru ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "Nu s-au găsit limbi. Adaugă prima pentru a începe.",
"no_option_found": "Nicio opțiune găsită",
"no_recall_items_found": "Nu s-au găsit elemente de reamintire",
"no_variables_yet_add_first_one_below": "Nu există variabile încă. Adăugați prima mai jos.",
"number": "Număr",
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Odată setată, limba implicită pentru acest sondaj poate fi schimbată doar dezactivând opțiunea multi-limbă și ștergând toate traducerile.",
@@ -1486,7 +1476,6 @@
"pin_can_only_contain_numbers": "PIN-ul poate conține doar numere.",
"pin_must_be_a_four_digit_number": "PIN-ul trebuie să fie un număr de patru cifre",
"please_enter_a_file_extension": "Vă rugăm să introduceți o extensie de fișier.",
"please_enter_a_valid_url": "Vă rugăm să introduceți un URL valid (de exemplu, https://example.com)",
"please_set_a_survey_trigger": "Vă rugăm să setați un declanșator sondaj",
"please_specify": "Vă rugăm să specificați",
"prevent_double_submission": "Prevenire trimitere dublă",
@@ -1501,8 +1490,6 @@
"question_id_updated": "ID întrebare actualizat",
"question_used_in_logic": "Această întrebare este folosită în logica întrebării {questionIndex}.",
"question_used_in_quota": "Întrebarea aceasta este folosită în cota \"{quotaName}\"",
"question_used_in_recall": "Această întrebare este reamintită în întrebarea {questionIndex}.",
"question_used_in_recall_ending_card": "Această întrebare este reamintită în Cardul de Încheiere.",
"quotas": {
"add_quota": "Adăugați cotă",
"change_quota_for_public_survey": "Schimbați cota pentru sondaj public?",
@@ -1537,8 +1524,6 @@
"randomize_all": "Randomizează tot",
"randomize_all_except_last": "Randomizează tot cu excepția ultimului",
"range": "Interval",
"recall_data": "Reamintiți datele",
"recall_information_from": "Reamintiți informațiile din ...",
"recontact_options": "Opțiuni de recontactare",
"redirect_thank_you_card": "Redirecționează cardul de mulțumire",
"redirect_to_url": "Redirecționează către URL",
@@ -1616,7 +1601,6 @@
"trigger_survey_when_one_of_the_actions_is_fired": "Declanșați sondajul atunci când una dintre acțiuni este realizată...",
"try_lollipop_or_mountain": "Încercați „lollipop” sau „mountain”...",
"type_field_id": "ID câmp tip",
"underline": "Subliniază",
"unlock_targeting_description": "Vizează grupuri specifice de utilizatori pe baza atributelor sau a informațiilor despre dispozitiv",
"unlock_targeting_title": "Deblocați țintirea cu un plan superior",
"unsaved_changes_warning": "Aveți modificări nesalvate în sondajul dumneavoastră. Doriți să le salvați înainte de a pleca?",
@@ -1633,9 +1617,6 @@
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabila \"{variableName}\" este folosită în cota \"{quotaName}\"",
"variable_name_is_already_taken_please_choose_another": "Numele variabilei este deja utilizat, vă rugăm să alegeți altul.",
"variable_name_must_start_with_a_letter": "Numele variabilei trebuie să înceapă cu o literă.",
"variable_used_in_recall": "Variabila \"{variable}\" este reamintită în întrebarea {questionIndex}.",
"variable_used_in_recall_ending_card": "Variabila {variable} este reamintită în Cardul de Încheiere.",
"variable_used_in_recall_welcome": "Variabila \"{variable}\" este reamintită în cardul de bun venit.",
"verify_email_before_submission": "Verifică emailul înainte de trimitere",
"verify_email_before_submission_description": "Permite doar persoanelor cu un email real să răspundă.",
"wait": "Așteptați",
@@ -2906,4 +2887,4 @@
"usability_rating_description": "Măsurați uzabilitatea percepută cerând utilizatorilor să își evalueze experiența cu produsul dumneavoastră folosind un chestionar standardizat din 10 întrebări.",
"usability_score_name": "Scor de Uzabilitate al Sistemului (SUS)"
}
}
}

View File

@@ -262,9 +262,7 @@
"membership_not_found": "未找到会员资格",
"metadata": "元数据",
"minimum": "最低",
"mobile_overlay_app_works_best_on_desktop": "Formbricks 在 更大 的 屏幕 上 效果 最佳。 若 需要 管理 或 构建 调查, 请 切换 到 其他 设备",
"mobile_overlay_surveys_look_good": "别 担心 您 的 调查 在 每 一 种 设备 和 屏幕 尺寸 上 看起来 都 很 棒!",
"mobile_overlay_title": "噢, 检测 到 小 屏幕!",
"mobile_overlay_text": "Formbricks 不 适用 于 分辨率 较小 的 设备",
"move_down": "下移",
"move_up": "上移",
"multiple_languages": "多种 语言",
@@ -752,6 +750,7 @@
},
"project": {
"api_keys": {
"access_control": "访问控制",
"add_api_key": "添加 API 密钥",
"api_key": "API Key",
"api_key_copied_to_clipboard": "API 密钥 已复制到 剪贴板",
@@ -1204,7 +1203,7 @@
"add_ending": "添加结尾",
"add_ending_below": "在下方 添加 结尾",
"add_fallback": "添加",
"add_fallback_placeholder": "添加 占位符 显示 如果 没有 值以 回忆",
"add_fallback_placeholder": "添加 一个 占位符,以显示该问题是否被跳过:",
"add_hidden_field_id": "添加 隐藏 字段 ID",
"add_highlight_border": "添加 高亮 边框",
"add_highlight_border_description": "在 你的 调查 卡片 添加 外 边框。",
@@ -1241,7 +1240,6 @@
"automatically_mark_the_survey_as_complete_after": "自动 标记 调查 为 完成 在",
"back_button_label": "\"返回\" 按钮标签",
"background_styling": "背景 样式",
"bold": "粗体",
"brand_color": "品牌 颜色",
"brightness": "亮度",
"button_label": "按钮标签",
@@ -1302,8 +1300,8 @@
"contains": "包含",
"continue_to_settings": "继续 到 设置",
"control_which_file_types_can_be_uploaded": "控制 可以 上传的 文件 类型",
"convert_to_multiple_choice": "转换为 多选",
"convert_to_single_choice": "转换为 单选",
"convert_to_multiple_choice": "转换为多选",
"convert_to_single_choice": "转换为单选",
"country": "国家",
"create_group": "创建 群组",
"create_your_own_survey": "创建 你 的 调查",
@@ -1325,7 +1323,6 @@
"does_not_include_all_of": "不包括所有 ",
"does_not_include_one_of": "不包括一 个",
"does_not_start_with": "不 以 开头",
"edit_link": "编辑 链接",
"edit_recall": "编辑 调用",
"edit_translations": "编辑 {lang} 翻译",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "启用 参与者 在 调查 过程中 的 任何 时间 点 切换 调查 语言。",
@@ -1336,7 +1333,6 @@
"ending_card_used_in_logic": "\"这个 结束卡片 在 问题 {questionIndex} 的 逻辑 中 使用。\"",
"ending_used_in_quota": "此 结尾 正在 被 \"{quotaName}\" 配额 使用",
"ends_with": "以...结束",
"enter_fallback_value": "输入 后备 值",
"equals": "等于",
"equals_one_of": "等于 其中 一个",
"error_publishing_survey": "发布调查时发生了错误",
@@ -1400,9 +1396,6 @@
"four_points": "4 分",
"heading": "标题",
"hidden_field_added_successfully": "隐藏字段 添加成功",
"hidden_field_used_in_recall": "隐藏 字段 \"{hiddenField}\" 正在召回于问题 {questionIndex}。",
"hidden_field_used_in_recall_ending_card": "隐藏 字段 \"{hiddenField}\" 正在召回于结束 卡",
"hidden_field_used_in_recall_welcome": "隐藏 字段 \"{hiddenField}\" 正在召回于欢迎 卡 。",
"hide_advanced_settings": "隐藏 高级设置",
"hide_back_button": "隐藏 \"返回\" 按钮",
"hide_back_button_description": "不 显示 调查 中 的 返回 按钮",
@@ -1421,7 +1414,6 @@
"inner_text": "内文",
"input_border_color": "输入 边框 颜色",
"input_color": "输入颜色",
"insert_link": "插入 链接",
"invalid_targeting": "无效的目标: 请检查 您 的受众过滤器",
"invalid_video_url_warning": "请输入有效的 YouTube、Vimeo 或 Loom URL 。我们目前不支持其他 视频 托管服务提供商。",
"invalid_youtube_url": "无效的 YouTube URL",
@@ -1439,7 +1431,6 @@
"is_set": "已设置",
"is_skipped": "已跳过",
"is_submitted": "已提交",
"italic": "斜体",
"jump_to_question": "跳 转 到 问题",
"keep_current_order": "保持 当前 顺序",
"keep_showing_while_conditions_match": "条件 符合 时 保持 显示",
@@ -1466,7 +1457,6 @@
"no_images_found_for": "未找到与 \"{query}\" 相关的图片",
"no_languages_found_add_first_one_to_get_started": "没有找到语言。添加第一个以开始。",
"no_option_found": "找不到选择",
"no_recall_items_found": "未 找到 召回 项目",
"no_variables_yet_add_first_one_below": "还没有变量。 在下面添加第一个。",
"number": "数字",
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "一旦设置,此调查的默认语言只能通过禁用多语言选项并删除所有翻译来更改。",
@@ -1486,7 +1476,6 @@
"pin_can_only_contain_numbers": "PIN 只能包含数字。",
"pin_must_be_a_four_digit_number": "PIN 必须是 四 位数字。",
"please_enter_a_file_extension": "请输入 文件 扩展名。",
"please_enter_a_valid_url": "请输入有效的 URL例如 https://example.com ",
"please_set_a_survey_trigger": "请 设置 一个 调查 触发",
"please_specify": "请 指定",
"prevent_double_submission": "防止 重复 提交",
@@ -1501,8 +1490,6 @@
"question_id_updated": "问题 ID 更新",
"question_used_in_logic": "\"这个 问题 在 问题 {questionIndex} 的 逻辑 中 使用。\"",
"question_used_in_quota": "此 问题 正在 被 \"{quotaName}\" 配额 使用",
"question_used_in_recall": "此问题正在召回于问题 {questionIndex}。",
"question_used_in_recall_ending_card": "此 问题 正在召回于结束 卡片。",
"quotas": {
"add_quota": "添加 配额",
"change_quota_for_public_survey": "更改 公共调查 的配额?",
@@ -1537,8 +1524,6 @@
"randomize_all": "随机排列",
"randomize_all_except_last": "随机排列,最后一个除外",
"range": "范围",
"recall_data": "调用 数据",
"recall_information_from": "从 ... 召回信息",
"recontact_options": "重新 联系 选项",
"redirect_thank_you_card": "重定向感谢卡",
"redirect_to_url": "重定向到 URL",
@@ -1616,7 +1601,6 @@
"trigger_survey_when_one_of_the_actions_is_fired": "当 其中 一个 动作 被 触发 时 启动 调查…",
"try_lollipop_or_mountain": "尝试 'lollipop' 或 'mountain' ...",
"type_field_id": "类型 字段 ID",
"underline": "下划线",
"unlock_targeting_description": "根据 属性 或 设备信息 定位 特定 用户组",
"unlock_targeting_title": "通过 更 高级 划解锁 定位",
"unsaved_changes_warning": "您在调查中有未保存的更改。离开前是否要保存?",
@@ -1633,9 +1617,6 @@
"variable_is_used_in_quota_please_remove_it_from_quota_first": "变量 \"{variableName}\" 正在 被 \"{quotaName}\" 配额 使用",
"variable_name_is_already_taken_please_choose_another": "变量名已被占用,请选择其他。",
"variable_name_must_start_with_a_letter": "变量名 必须 以字母开头。",
"variable_used_in_recall": "变量 \"{variable}\" 正在召回于问题 {questionIndex}。",
"variable_used_in_recall_ending_card": "变量 {variable} 正在召回于结束 卡片",
"variable_used_in_recall_welcome": "变量 \"{variable}\" 正在召回于欢迎 卡 。",
"verify_email_before_submission": "提交 之前 验证电子邮件",
"verify_email_before_submission_description": "仅允许 拥有 有效 电子邮件 的 人 回应。",
"wait": "等待",
@@ -2906,4 +2887,4 @@
"usability_rating_description": "通过要求用户使用标准化的 10 问 调查 来 评价 他们对您产品的体验,以 测量 感知 的 可用性。",
"usability_score_name": "系统 可用性 得分 ( SUS )"
}
}
}

View File

@@ -262,9 +262,7 @@
"membership_not_found": "找不到成員資格",
"metadata": "元數據",
"minimum": "最小值",
"mobile_overlay_app_works_best_on_desktop": "Formbricks 適合在大螢幕上使用。若要管理或建立問卷,請切換到其他裝置。",
"mobile_overlay_surveys_look_good": "別擔心 -你的 問卷 在每個 裝置 和 螢幕尺寸 上 都 很出色!",
"mobile_overlay_title": "糟糕 ,偵測到小螢幕!",
"mobile_overlay_text": "Formbricks 不適用於較小解析度的裝置。",
"move_down": "下移",
"move_up": "上移",
"multiple_languages": "多種語言",
@@ -752,6 +750,7 @@
},
"project": {
"api_keys": {
"access_control": "存取控制",
"add_api_key": "新增 API 金鑰",
"api_key": "API 金鑰",
"api_key_copied_to_clipboard": "API 金鑰已複製到剪貼簿",
@@ -1204,7 +1203,7 @@
"add_ending": "新增結尾",
"add_ending_below": "在下方新增結尾",
"add_fallback": "新增",
"add_fallback_placeholder": "新增 預設 以顯示是否沒 有 值 可 回憶 。",
"add_fallback_placeholder": "新增用于顯示問題被跳過時的佔位符",
"add_hidden_field_id": "新增隱藏欄位 ID",
"add_highlight_border": "新增醒目提示邊框",
"add_highlight_border_description": "在您的問卷卡片新增外邊框。",
@@ -1241,7 +1240,6 @@
"automatically_mark_the_survey_as_complete_after": "在指定時間後自動將問卷標記為完成",
"back_button_label": "「返回」按鈕標籤",
"background_styling": "背景樣式設定",
"bold": "粗體",
"brand_color": "品牌顏色",
"brightness": "亮度",
"button_label": "按鈕標籤",
@@ -1325,7 +1323,6 @@
"does_not_include_all_of": "不包含全部",
"does_not_include_one_of": "不包含其中之一",
"does_not_start_with": "不以...開頭",
"edit_link": "編輯 連結",
"edit_recall": "編輯回憶",
"edit_translations": "編輯 '{'language'}' 翻譯",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "允許參與者在問卷中的任何時間點切換問卷語言。",
@@ -1336,7 +1333,6 @@
"ending_card_used_in_logic": "此結尾卡片用於問題 '{'questionIndex'}' 的邏輯中。",
"ending_used_in_quota": "此 結尾 正被使用於 \"{quotaName}\" 配額中",
"ends_with": "結尾為",
"enter_fallback_value": "輸入 預設 值",
"equals": "等於",
"equals_one_of": "等於其中之一",
"error_publishing_survey": "發布問卷時發生錯誤。",
@@ -1400,9 +1396,6 @@
"four_points": "4 分",
"heading": "標題",
"hidden_field_added_successfully": "隱藏欄位已成功新增",
"hidden_field_used_in_recall": "隱藏欄位 \"{hiddenField}\" 於問題 {questionIndex} 中被召回。",
"hidden_field_used_in_recall_ending_card": "隱藏欄位 \"{hiddenField}\" 於結束卡中被召回。",
"hidden_field_used_in_recall_welcome": "隱藏欄位 \"{hiddenField}\" 於歡迎卡中被召回。",
"hide_advanced_settings": "隱藏進階設定",
"hide_back_button": "隱藏「Back」按鈕",
"hide_back_button_description": "不要在問卷中顯示返回按鈕",
@@ -1421,7 +1414,6 @@
"inner_text": "內部文字",
"input_border_color": "輸入邊框顏色",
"input_color": "輸入顏色",
"insert_link": "插入 連結",
"invalid_targeting": "目標設定無效:請檢查您的受眾篩選器",
"invalid_video_url_warning": "請輸入有效的 YouTube、Vimeo 或 Loom 網址。我們目前不支援其他影片託管提供者。",
"invalid_youtube_url": "無效的 YouTube 網址",
@@ -1439,7 +1431,6 @@
"is_set": "已設定",
"is_skipped": "已跳過",
"is_submitted": "已提交",
"italic": "斜體",
"jump_to_question": "跳至問題",
"keep_current_order": "保留目前順序",
"keep_showing_while_conditions_match": "在條件符合時持續顯示",
@@ -1466,7 +1457,6 @@
"no_images_found_for": "找不到「'{'query'}'」的圖片",
"no_languages_found_add_first_one_to_get_started": "找不到語言。新增第一個語言以開始使用。",
"no_option_found": "找不到選項",
"no_recall_items_found": "找不到 召回 項目",
"no_variables_yet_add_first_one_below": "尚無變數。在下方新增第一個變數。",
"number": "數字",
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "設定後,此問卷的預設語言只能藉由停用多語言選項並刪除所有翻譯來變更。",
@@ -1486,7 +1476,6 @@
"pin_can_only_contain_numbers": "PIN 碼只能包含數字。",
"pin_must_be_a_four_digit_number": "PIN 碼必須是四位數的數字。",
"please_enter_a_file_extension": "請輸入檔案副檔名。",
"please_enter_a_valid_url": "請輸入有效的 URL例如https://example.com",
"please_set_a_survey_trigger": "請設定問卷觸發器",
"please_specify": "請指定",
"prevent_double_submission": "防止重複提交",
@@ -1501,8 +1490,6 @@
"question_id_updated": "問題 ID 已更新",
"question_used_in_logic": "此問題用於問題 '{'questionIndex'}' 的邏輯中。",
"question_used_in_quota": "此問題 正被使用於 \"{quotaName}\" 配額中",
"question_used_in_recall": "此問題於問題 {questionIndex} 中被召回。",
"question_used_in_recall_ending_card": "此問題於結尾卡中被召回。",
"quotas": {
"add_quota": "新增額度",
"change_quota_for_public_survey": "更改 公開 問卷 的 額度?",
@@ -1537,8 +1524,6 @@
"randomize_all": "全部隨機排序",
"randomize_all_except_last": "全部隨機排序(最後一項除外)",
"range": "範圍",
"recall_data": "回憶數據",
"recall_information_from": "從 ... 獲取 信息",
"recontact_options": "重新聯絡選項",
"redirect_thank_you_card": "重新導向感謝卡片",
"redirect_to_url": "重新導向至網址",
@@ -1616,7 +1601,6 @@
"trigger_survey_when_one_of_the_actions_is_fired": "當觸發其中一個操作時,觸發問卷...",
"try_lollipop_or_mountain": "嘗試「棒棒糖」或「山峰」...",
"type_field_id": "輸入欄位 ID",
"underline": "下 劃 線",
"unlock_targeting_description": "根據屬性或裝置資訊鎖定特定使用者群組",
"unlock_targeting_title": "使用更高等級的方案解鎖目標設定",
"unsaved_changes_warning": "您的問卷中有未儲存的變更。您要先儲存它們再離開嗎?",
@@ -1633,9 +1617,6 @@
"variable_is_used_in_quota_please_remove_it_from_quota_first": "變數 \"{variableName}\" 正被使用於 \"{quotaName}\" 配額中",
"variable_name_is_already_taken_please_choose_another": "已使用此變數名稱,請選擇另一個名稱。",
"variable_name_must_start_with_a_letter": "變數名稱必須以字母開頭。",
"variable_used_in_recall": "變數 \"{variable}\" 於問題 {questionIndex} 中被召回。",
"variable_used_in_recall_ending_card": "變數 {variable} 於 結束 卡 中被召回。",
"variable_used_in_recall_welcome": "變數 \"{variable}\" 於 歡迎 Card 中被召回。",
"verify_email_before_submission": "提交前驗證電子郵件",
"verify_email_before_submission_description": "僅允許擁有真實電子郵件的人員回應。",
"wait": "等待",
@@ -2906,4 +2887,4 @@
"usability_rating_description": "透過使用標準化的 十個問題 問卷,要求使用者評估他們對 您 產品的使用體驗,來衡量感知的 可用性。",
"usability_score_name": "系統 可用性 分數 (SUS)"
}
}
}

View File

@@ -230,7 +230,7 @@ describe("RenderResponse", () => {
showId={false}
/>
);
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("value");
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("Value");
});
test("renders ResponseBadges for 'Consent' question (number)", () => {
@@ -258,7 +258,7 @@ describe("RenderResponse", () => {
showId={false}
/>
);
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("click");
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("Click");
});
test("renders ResponseBadges for 'MultipleChoiceSingle' question (string)", () => {

View File

@@ -1,3 +1,16 @@
import { cn } from "@/lib/cn";
import { getLanguageCode, getLocalizedValue } from "@/lib/i18n/utils";
import { getChoiceIdByValue } from "@/lib/response/utils";
import { processResponseData } from "@/lib/responses";
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { renderHyperlinkedContent } from "@/modules/analysis/utils";
import { ArrayResponse } from "@/modules/ui/components/array-response";
import { FileUploadResponse } from "@/modules/ui/components/file-upload-response";
import { PictureSelectionResponse } from "@/modules/ui/components/picture-selection-response";
import { RankingResponse } from "@/modules/ui/components/ranking-response";
import { RatingResponse } from "@/modules/ui/components/rating-response";
import { ResponseBadges } from "@/modules/ui/components/response-badges";
import { CheckCheckIcon, MousePointerClickIcon, PhoneIcon } from "lucide-react";
import React from "react";
import { TResponseDataValue } from "@formbricks/types/responses";
@@ -9,18 +22,6 @@ import {
TSurveyQuestionTypeEnum,
TSurveyRatingQuestion,
} from "@formbricks/types/surveys/types";
import { cn } from "@/lib/cn";
import { getLanguageCode, getLocalizedValue } from "@/lib/i18n/utils";
import { getChoiceIdByValue } from "@/lib/response/utils";
import { processResponseData } from "@/lib/responses";
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
import { renderHyperlinkedContent } from "@/modules/analysis/utils";
import { ArrayResponse } from "@/modules/ui/components/array-response";
import { FileUploadResponse } from "@/modules/ui/components/file-upload-response";
import { PictureSelectionResponse } from "@/modules/ui/components/picture-selection-response";
import { RankingResponse } from "@/modules/ui/components/ranking-response";
import { RatingResponse } from "@/modules/ui/components/rating-response";
import { ResponseBadges } from "@/modules/ui/components/response-badges";
interface RenderResponseProps {
responseData: TResponseDataValue;
@@ -103,7 +104,9 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
const rowValueInSelectedLanguage = getLocalizedValue(row.label, languagCode);
if (!responseData[rowValueInSelectedLanguage]) return null;
return (
<p key={rowValueInSelectedLanguage} className="ph-no-capture my-1 font-normal text-slate-700">
<p
key={rowValueInSelectedLanguage}
className="ph-no-capture my-1 font-normal capitalize text-slate-700">
{rowValueInSelectedLanguage}:{processResponseData(responseData[rowValueInSelectedLanguage])}
</p>
);
@@ -123,7 +126,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
if (typeof responseData === "string" || typeof responseData === "number") {
return (
<ResponseBadges
items={[{ value: responseData.toString() }]}
items={[{ value: capitalizeFirstLetter(responseData.toString()) }]}
isExpanded={isExpanded}
icon={<PhoneIcon className="h-4 w-4 text-slate-500" />}
showId={showId}
@@ -135,7 +138,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
if (typeof responseData === "string" || typeof responseData === "number") {
return (
<ResponseBadges
items={[{ value: responseData.toString() }]}
items={[{ value: capitalizeFirstLetter(responseData.toString()) }]}
isExpanded={isExpanded}
icon={<CheckCheckIcon className="h-4 w-4 text-slate-500" />}
showId={showId}
@@ -147,7 +150,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
if (typeof responseData === "string" || typeof responseData === "number") {
return (
<ResponseBadges
items={[{ value: responseData.toString() }]}
items={[{ value: capitalizeFirstLetter(responseData.toString()) }]}
isExpanded={isExpanded}
icon={<MousePointerClickIcon className="h-4 w-4 text-slate-500" />}
showId={showId}

View File

@@ -1,12 +1,12 @@
"use client";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { parseRecallInfo } from "@/lib/utils/recall";
import { ResponseCardQuotas } from "@/modules/ee/quotas/components/single-response-card-quotas";
import { useTranslate } from "@tolgee/react";
import { CheckCircle2Icon } from "lucide-react";
import { TResponseWithQuotas } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { parseRecallInfo } from "@/lib/utils/recall";
import { ResponseCardQuotas } from "@/modules/ee/quotas/components/single-response-card-quotas";
import { isValidValue } from "../util";
import { HiddenFields } from "./HiddenFields";
import { QuestionSkip } from "./QuestionSkip";
@@ -118,7 +118,7 @@ export const SingleResponseCardBody = ({
{survey.variables.length > 0 && (
<ResponseVariables variables={survey.variables} variablesData={response.variables} />
)}
{survey.hiddenFields.fieldIds && (
{survey.hiddenFields.enabled && survey.hiddenFields.fieldIds && (
<HiddenFields hiddenFields={survey.hiddenFields} responseData={response.data} />
)}

View File

@@ -1,101 +0,0 @@
import { getCacheService } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { type OverallHealthStatus } from "@/modules/api/v2/health/types/health-status";
import { type ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
/**
* Check if the main database is reachable and responding
* @returns Promise<Result<boolean, ApiErrorResponseV2>> - Result of the database health check
*/
export const checkDatabaseHealth = async (): Promise<Result<boolean, ApiErrorResponseV2>> => {
try {
// Simple query to check if database is reachable
await prisma.$queryRaw`SELECT 1`;
return ok(true);
} catch (error) {
logger
.withContext({
component: "health_check",
check_type: "main_database",
error,
})
.error("Database health check failed");
return err({
type: "internal_server_error",
details: [{ field: "main_database", issue: "Database health check failed" }],
});
}
};
/**
* Check if the Redis cache is reachable and responding
* @returns Promise<Result<boolean, ApiErrorResponseV2>> - Result of the cache health check
*/
export const checkCacheHealth = async (): Promise<Result<boolean, ApiErrorResponseV2>> => {
try {
const cacheServiceResult = await getCacheService();
if (!cacheServiceResult.ok) {
return err({
type: "internal_server_error",
details: [{ field: "cache_database", issue: "Cache service not available" }],
});
}
const isAvailable = await cacheServiceResult.data.isRedisAvailable();
if (isAvailable) {
return ok(true);
}
return err({
type: "internal_server_error",
details: [{ field: "cache_database", issue: "Redis not available" }],
});
} catch (error) {
logger
.withContext({
component: "health_check",
check_type: "cache_database",
error,
})
.error("Redis health check failed");
return err({
type: "internal_server_error",
details: [{ field: "cache_database", issue: "Redis health check failed" }],
});
}
};
/**
* Perform all health checks and return the overall status
* Always returns ok() with health status unless the health check endpoint itself fails
* @returns Promise<Result<OverallHealthStatus, ApiErrorResponseV2>> - Overall health status of all dependencies
*/
export const performHealthChecks = async (): Promise<Result<OverallHealthStatus, ApiErrorResponseV2>> => {
try {
const [databaseResult, cacheResult] = await Promise.all([checkDatabaseHealth(), checkCacheHealth()]);
const healthStatus: OverallHealthStatus = {
main_database: databaseResult.ok ? databaseResult.data : false,
cache_database: cacheResult.ok ? cacheResult.data : false,
};
// Always return ok() with the health status - individual dependency failures
// are reflected in the boolean values
return ok(healthStatus);
} catch (error) {
// Only return err() if the health check endpoint itself fails
logger
.withContext({
component: "health_check",
error,
})
.error("Health check endpoint failed");
return err({
type: "internal_server_error",
details: [{ field: "health", issue: "Failed to perform health checks" }],
});
}
};

View File

@@ -1,29 +0,0 @@
import { ZOverallHealthStatus } from "@/modules/api/v2/health/types/health-status";
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
import { ZodOpenApiOperationObject } from "zod-openapi";
export const healthCheckEndpoint: ZodOpenApiOperationObject = {
tags: ["Health"],
summary: "Health Check",
description: "Check the health status of critical application dependencies including database and cache.",
requestParams: {},
operationId: "healthCheck",
security: [],
responses: {
"200": {
description:
"Health check completed successfully. Check individual dependency status in response data.",
content: {
"application/json": {
schema: makePartialSchema(ZOverallHealthStatus),
},
},
},
},
};
export const healthPaths = {
"/health": {
get: healthCheckEndpoint,
},
};

View File

@@ -1,288 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { ErrorCode, getCacheService } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { err, ok } from "@formbricks/types/error-handlers";
import { checkCacheHealth, checkDatabaseHealth, performHealthChecks } from "../health-checks";
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
$queryRaw: vi.fn(),
},
}));
vi.mock("@formbricks/cache", () => ({
getCacheService: vi.fn(),
ErrorCode: {
RedisConnectionError: "redis_connection_error",
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
info: vi.fn(),
withContext: vi.fn(() => ({
error: vi.fn(),
info: vi.fn(),
})),
},
}));
describe("Health Checks", () => {
beforeEach(() => {
vi.clearAllMocks();
});
// Helper function to create a mock CacheService
const createMockCacheService = (isRedisAvailable: boolean = true) => ({
getRedisClient: vi.fn(),
withTimeout: vi.fn(),
get: vi.fn(),
exists: vi.fn(),
set: vi.fn(),
del: vi.fn(),
keys: vi.fn(),
withCache: vi.fn(),
flush: vi.fn(),
tryGetCachedValue: vi.fn(),
trySetCache: vi.fn(),
isRedisAvailable: vi.fn().mockResolvedValue(isRedisAvailable),
});
describe("checkDatabaseHealth", () => {
test("should return healthy when database query succeeds", async () => {
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ "?column?": 1 }]);
const result = await checkDatabaseHealth();
expect(result).toEqual({ ok: true, data: true });
expect(prisma.$queryRaw).toHaveBeenCalledWith(["SELECT 1"]);
});
test("should return unhealthy when database query fails", async () => {
const dbError = new Error("Database connection failed");
vi.mocked(prisma.$queryRaw).mockRejectedValue(dbError);
const result = await checkDatabaseHealth();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([
{ field: "main_database", issue: "Database health check failed" },
]);
}
});
test("should handle different types of database errors", async () => {
const networkError = new Error("ECONNREFUSED");
vi.mocked(prisma.$queryRaw).mockRejectedValue(networkError);
const result = await checkDatabaseHealth();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([
{ field: "main_database", issue: "Database health check failed" },
]);
}
});
});
describe("checkCacheHealth", () => {
test("should return healthy when Redis is available", async () => {
const mockCacheService = createMockCacheService(true);
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
const result = await checkCacheHealth();
expect(result).toEqual({ ok: true, data: true });
expect(getCacheService).toHaveBeenCalled();
expect(mockCacheService.isRedisAvailable).toHaveBeenCalled();
});
test("should return unhealthy when cache service fails to initialize", async () => {
const cacheError = { code: ErrorCode.RedisConnectionError };
vi.mocked(getCacheService).mockResolvedValue(err(cacheError));
const result = await checkCacheHealth();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([
{ field: "cache_database", issue: "Cache service not available" },
]);
}
});
test("should return unhealthy when Redis is not available", async () => {
const mockCacheService = createMockCacheService(false);
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
const result = await checkCacheHealth();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([{ field: "cache_database", issue: "Redis not available" }]);
}
expect(mockCacheService.isRedisAvailable).toHaveBeenCalled();
});
test("should handle Redis availability check exceptions", async () => {
const mockCacheService = createMockCacheService(true);
mockCacheService.isRedisAvailable.mockRejectedValue(new Error("Redis ping failed"));
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
const result = await checkCacheHealth();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([
{ field: "cache_database", issue: "Redis health check failed" },
]);
}
});
test("should handle cache service initialization exceptions", async () => {
const serviceException = new Error("Cache service unavailable");
vi.mocked(getCacheService).mockRejectedValue(serviceException);
const result = await checkCacheHealth();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([
{ field: "cache_database", issue: "Redis health check failed" },
]);
}
});
test("should verify isRedisAvailable is called asynchronously", async () => {
const mockCacheService = createMockCacheService(true);
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
await checkCacheHealth();
// Verify the async method was called
expect(mockCacheService.isRedisAvailable).toHaveBeenCalledTimes(1);
expect(mockCacheService.isRedisAvailable).toReturnWith(Promise.resolve(true));
});
});
describe("performHealthChecks", () => {
test("should return all healthy when both checks pass", async () => {
// Mock successful database check
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ "?column?": 1 }]);
// Mock successful cache check
const mockCacheService = createMockCacheService(true);
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
const result = await performHealthChecks();
expect(result).toEqual({
ok: true,
data: {
main_database: true,
cache_database: true,
},
});
});
test("should return mixed results when only database is healthy", async () => {
// Mock successful database check
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ "?column?": 1 }]);
// Mock failed cache check
vi.mocked(getCacheService).mockResolvedValue(err({ code: ErrorCode.RedisConnectionError }));
const result = await performHealthChecks();
expect(result).toEqual({
ok: true,
data: {
main_database: true,
cache_database: false,
},
});
});
test("should return mixed results when only cache is healthy", async () => {
// Mock failed database check
vi.mocked(prisma.$queryRaw).mockRejectedValue(new Error("DB Error"));
// Mock successful cache check
const mockCacheService = createMockCacheService(true);
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
const result = await performHealthChecks();
expect(result).toEqual({
ok: true,
data: {
main_database: false,
cache_database: true,
},
});
});
test("should return all unhealthy when both checks fail", async () => {
// Mock failed database check
vi.mocked(prisma.$queryRaw).mockRejectedValue(new Error("DB Error"));
// Mock failed cache check
vi.mocked(getCacheService).mockResolvedValue(err({ code: ErrorCode.RedisConnectionError }));
const result = await performHealthChecks();
expect(result).toEqual({
ok: true,
data: {
main_database: false,
cache_database: false,
},
});
});
test("should run both checks in parallel", async () => {
const dbPromise = new Promise((resolve) => setTimeout(() => resolve([{ "?column?": 1 }]), 100));
const redisPromise = new Promise((resolve) => setTimeout(() => resolve(true), 100));
vi.mocked(prisma.$queryRaw).mockReturnValue(dbPromise as any);
const mockCacheService = createMockCacheService(true);
mockCacheService.isRedisAvailable.mockReturnValue(redisPromise as any);
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
const startTime = Date.now();
await performHealthChecks();
const endTime = Date.now();
// Should complete in roughly 100ms (parallel) rather than 200ms (sequential)
expect(endTime - startTime).toBeLessThan(150);
});
test("should return error only on catastrophic failure (endpoint itself fails)", async () => {
// Mock a catastrophic failure in Promise.all itself
const originalPromiseAll = Promise.all;
vi.spyOn(Promise, "all").mockRejectedValue(new Error("Catastrophic system failure"));
const result = await performHealthChecks();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([{ field: "health", issue: "Failed to perform health checks" }]);
}
// Restore original Promise.all
Promise.all = originalPromiseAll;
});
});
});

View File

@@ -1,15 +0,0 @@
import { responses } from "@/modules/api/v2/lib/response";
import { performHealthChecks } from "./lib/health-checks";
export const GET = async () => {
const healthStatusResult = await performHealthChecks();
if (!healthStatusResult.ok) {
return responses.serviceUnavailableResponse({
details: healthStatusResult.error.details,
});
}
return responses.successResponse({
data: healthStatusResult.data,
});
};

View File

@@ -1,22 +0,0 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
extendZodWithOpenApi(z);
export const ZOverallHealthStatus = z
.object({
main_database: z.boolean().openapi({
description: "Main database connection status - true if database is reachable and running",
example: true,
}),
cache_database: z.boolean().openapi({
description: "Cache database connection status - true if cache database is reachable and running",
example: true,
}),
})
.openapi({
title: "Health Check Response",
description: "Health check status for critical application dependencies",
});
export type OverallHealthStatus = z.infer<typeof ZOverallHealthStatus>;

View File

@@ -232,35 +232,6 @@ const internalServerErrorResponse = ({
);
};
const serviceUnavailableResponse = ({
details = [],
cors = false,
cache = "private, no-store",
}: {
details?: ApiErrorDetails;
cors?: boolean;
cache?: string;
} = {}) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
error: {
code: 503,
message: "Service Unavailable",
details,
},
},
{
status: 503,
headers,
}
);
};
const successResponse = ({
data,
meta,
@@ -354,7 +325,6 @@ export const responses = {
unprocessableEntityResponse,
tooManyRequestsResponse,
internalServerErrorResponse,
serviceUnavailableResponse,
successResponse,
createdResponse,
multiStatusResponse,

View File

@@ -1,8 +1,8 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import * as Sentry from "@sentry/nextjs";
import { describe, expect, test, vi } from "vitest";
import { ZodError } from "zod";
import { logger } from "@formbricks/logger";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { formatZodError, handleApiError, logApiError, logApiRequest } from "../utils";
const mockRequest = new Request("http://localhost");
@@ -12,15 +12,6 @@ mockRequest.headers.set("x-request-id", "123");
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
withScope: vi.fn((callback: (scope: any) => void) => {
const mockScope = {
setTag: vi.fn(),
setContext: vi.fn(),
setLevel: vi.fn(),
setExtra: vi.fn(),
};
callback(mockScope);
}),
}));
// Mock SENTRY_DSN constant
@@ -241,7 +232,7 @@ describe("utils", () => {
});
// Verify error was called on the child logger
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
expect(errorMock).toHaveBeenCalledWith("API Error Details");
// Restore the original method
logger.withContext = originalWithContext;
@@ -275,7 +266,7 @@ describe("utils", () => {
});
// Verify error was called on the child logger
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
expect(errorMock).toHaveBeenCalledWith("API Error Details");
// Restore the original method
logger.withContext = originalWithContext;
@@ -312,7 +303,7 @@ describe("utils", () => {
});
// Verify error was called on the child logger
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
expect(errorMock).toHaveBeenCalledWith("API Error Details");
// Verify Sentry.captureException was called
expect(Sentry.captureException).toHaveBeenCalled();

View File

@@ -1,8 +1,8 @@
// Function is this file can be used in edge runtime functions, like api routes.
import * as Sentry from "@sentry/nextjs";
import { logger } from "@formbricks/logger";
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import * as Sentry from "@sentry/nextjs";
import { logger } from "@formbricks/logger";
export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): void => {
const correlationId = request.headers.get("x-request-id") ?? "";
@@ -10,14 +10,14 @@ export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): vo
// Send the error to Sentry if the DSN is set and the error type is internal_server_error
// This is useful for tracking down issues without overloading Sentry with errors
if (SENTRY_DSN && IS_PRODUCTION && error.type === "internal_server_error") {
// Use Sentry scope to add correlation ID as a tag for easy filtering
Sentry.withScope((scope) => {
scope.setTag("correlationId", correlationId);
scope.setLevel("error");
const err = new Error(`API V2 error, id: ${correlationId}`);
scope.setExtra("originalError", error);
const err = new Error(`API V2 error, id: ${correlationId}`);
Sentry.captureException(err);
Sentry.captureException(err, {
extra: {
details: error.details,
type: error.type,
correlationId,
},
});
}
@@ -26,5 +26,5 @@ export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): vo
correlationId,
error,
})
.error("API V2 Error Details");
.error("API Error Details");
};

View File

@@ -1,18 +1,17 @@
// @ts-nocheck // We can remove this when we update the prisma client and the typescript version
// if we don't add this we get build errors with prisma due to type-nesting
import { ZodCustomIssue, ZodIssue } from "zod";
import { logger } from "@formbricks/logger";
import { TApiAuditLog } from "@/app/lib/api/with-api-logging";
import { AUDIT_LOG_ENABLED } from "@/lib/constants";
import { responses } from "@/modules/api/v2/lib/response";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { ZodCustomIssue, ZodIssue } from "zod";
import { logger } from "@formbricks/logger";
import { logApiErrorEdge } from "./utils-edge";
export const handleApiError = (
request: Request,
err: ApiErrorResponseV2,
auditLog?: TApiAuditLog
auditLog?: ApiAuditLog
): Response => {
logApiError(request, err, auditLog);
@@ -56,7 +55,7 @@ export const formatZodError = (error: { issues: (ZodIssue | ZodCustomIssue)[] })
});
};
export const logApiRequest = (request: Request, responseStatus: number, auditLog?: TApiAuditLog): void => {
export const logApiRequest = (request: Request, responseStatus: number, auditLog?: ApiAuditLog): void => {
const method = request.method;
const url = new URL(request.url);
const path = url.pathname;
@@ -83,13 +82,13 @@ export const logApiRequest = (request: Request, responseStatus: number, auditLog
logAuditLog(request, auditLog);
};
export const logApiError = (request: Request, error: ApiErrorResponseV2, auditLog?: TApiAuditLog): void => {
export const logApiError = (request: Request, error: ApiErrorResponseV2, auditLog?: ApiAuditLog): void => {
logApiErrorEdge(request, error);
logAuditLog(request, auditLog);
};
const logAuditLog = (request: Request, auditLog?: TApiAuditLog): void => {
const logAuditLog = (request: Request, auditLog?: ApiAuditLog): void => {
if (AUDIT_LOG_ENABLED && auditLog) {
const correlationId = request.headers.get("x-request-id") ?? "";
queueAuditEvent({

View File

@@ -1,10 +1,7 @@
import { ZContactLinkParams } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/types/survey";
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
import { z } from "zod";
import { ZodOpenApiOperationObject } from "zod-openapi";
import {
ZContactLinkParams,
ZContactLinkQuery,
} from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/types/survey";
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
export const getPersonalizedSurveyLink: ZodOpenApiOperationObject = {
operationId: "getPersonalizedSurveyLink",
@@ -12,7 +9,6 @@ export const getPersonalizedSurveyLink: ZodOpenApiOperationObject = {
description: "Retrieves a personalized link for a specific survey.",
requestParams: {
path: ZContactLinkParams,
query: ZContactLinkQuery,
},
tags: ["Management API - Surveys - Contact Links"],
responses: {
@@ -24,10 +20,6 @@ export const getPersonalizedSurveyLink: ZodOpenApiOperationObject = {
z.object({
data: z.object({
surveyUrl: z.string().url(),
expiresAt: z
.string()
.nullable()
.describe("The date and time the link expires, null if no expiration"),
}),
})
),

View File

@@ -8,9 +8,7 @@ import { getSurvey } from "@/modules/api/v2/management/surveys/[surveyId]/contac
import {
TContactLinkParams,
ZContactLinkParams,
ZContactLinkQuery,
} from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/types/survey";
import { calculateExpirationDate } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/lib/utils";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
@@ -21,10 +19,9 @@ export const GET = async (request: Request, props: { params: Promise<TContactLin
externalParams: props.params,
schemas: {
params: ZContactLinkParams,
query: ZContactLinkQuery,
},
handler: async ({ authentication, parsedInput }) => {
const { params, query } = parsedInput;
const { params } = parsedInput;
if (!params) {
return handleApiError(request, {
@@ -95,27 +92,12 @@ export const GET = async (request: Request, props: { params: Promise<TContactLin
});
}
// Calculate expiration date based on expirationDays
let expiresAt: string | null = null;
if (query?.expirationDays) {
expiresAt = calculateExpirationDate(query.expirationDays);
}
const surveyUrlResult = await getContactSurveyLink(
params.contactId,
params.surveyId,
query?.expirationDays || undefined
);
const surveyUrlResult = await getContactSurveyLink(params.contactId, params.surveyId, 7);
if (!surveyUrlResult.ok) {
return handleApiError(request, surveyUrlResult.error);
}
return responses.successResponse({
data: {
surveyUrl: surveyUrlResult.data,
expiresAt,
},
});
return responses.successResponse({ data: { surveyUrl: surveyUrlResult.data } });
},
});

View File

@@ -20,15 +20,4 @@ export const ZContactLinkParams = z.object({
}),
});
export const ZContactLinkQuery = z.object({
expirationDays: z.coerce
.number()
.int()
.min(1)
.max(365)
.optional()
.describe("Number of days until the generated JWT expires. If not provided, there is no expiration."),
});
export type TContactLinkParams = z.infer<typeof ZContactLinkParams>;
export type TContactLinkQuery = z.infer<typeof ZContactLinkQuery>;

View File

@@ -1,51 +0,0 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { calculateExpirationDate } from "./utils";
describe("calculateExpirationDate", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
test("calculates expiration date for positive days", () => {
const baseDate = new Date("2024-01-15T12:00:00.000Z");
vi.setSystemTime(baseDate);
const result = calculateExpirationDate(7);
const expectedDate = new Date("2024-01-22T12:00:00.000Z");
expect(result).toBe(expectedDate.toISOString());
});
test("handles zero expiration days", () => {
const baseDate = new Date("2024-01-15T12:00:00.000Z");
vi.setSystemTime(baseDate);
const result = calculateExpirationDate(0);
expect(result).toBe(baseDate.toISOString());
});
test("handles negative expiration days", () => {
const baseDate = new Date("2024-01-15T12:00:00.000Z");
vi.setSystemTime(baseDate);
const result = calculateExpirationDate(-5);
const expectedDate = new Date("2024-01-10T12:00:00.000Z");
expect(result).toBe(expectedDate.toISOString());
});
test("returns valid ISO string format", () => {
const baseDate = new Date("2024-01-15T12:00:00.000Z");
vi.setSystemTime(baseDate);
const result = calculateExpirationDate(10);
const isoRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
expect(result).toMatch(isoRegex);
});
});

View File

@@ -1,5 +0,0 @@
export const calculateExpirationDate = (expirationDays: number) => {
const expirationDate = new Date();
expirationDate.setDate(expirationDate.getDate() + expirationDays);
return expirationDate.toISOString();
};

View File

@@ -1,9 +1,7 @@
import { logger } from "@formbricks/logger";
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
import { calculateExpirationDate } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/lib/utils";
import { getContactsInSegment } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/contact";
import {
ZContactLinksBySegmentParams,
@@ -13,6 +11,7 @@ import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { logger } from "@formbricks/logger";
export const GET = async (
request: Request,
@@ -77,7 +76,9 @@ export const GET = async (
// Calculate expiration date based on expirationDays
let expiresAt: string | null = null;
if (query?.expirationDays) {
expiresAt = calculateExpirationDate(query.expirationDays);
const expirationDate = new Date();
expirationDate.setDate(expirationDate.getDate() + query.expirationDays);
expiresAt = expirationDate.toISOString();
}
// Generate survey links for each contact

View File

@@ -1,5 +1,3 @@
import { healthPaths } from "@/modules/api/v2/health/lib/openapi";
import { ZOverallHealthStatus } from "@/modules/api/v2/health/types/health-status";
import { contactAttributeKeyPaths } from "@/modules/api/v2/management/contact-attribute-keys/lib/openapi";
import { responsePaths } from "@/modules/api/v2/management/responses/lib/openapi";
import { surveyContactLinksBySegmentPaths } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/lib/openapi";
@@ -37,7 +35,6 @@ const document = createDocument({
version: "2.0.0",
},
paths: {
...healthPaths,
...rolePaths,
...mePaths,
...responsePaths,
@@ -58,10 +55,6 @@ const document = createDocument({
},
],
tags: [
{
name: "Health",
description: "Operations for checking critical application dependencies health status.",
},
{
name: "Roles",
description: "Operations for managing roles.",
@@ -121,7 +114,6 @@ const document = createDocument({
},
},
schemas: {
health: ZOverallHealthStatus,
role: ZRoles,
me: ZApiKeyData,
response: ZResponse,

View File

@@ -1,6 +1,6 @@
import { logSignOut } from "@/modules/auth/lib/utils";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { logSignOut } from "@/modules/auth/lib/utils";
import { logSignOutAction } from "./sign-out";
// Mock the dependencies
@@ -80,7 +80,6 @@ describe("logSignOutAction", () => {
"email_change",
"session_timeout",
"forced_logout",
"password_reset",
] as const;
for (const reason of reasons) {
@@ -101,14 +100,11 @@ describe("logSignOutAction", () => {
await expect(() => logSignOutAction(mockUserId, mockUserEmail, mockContext)).rejects.toThrow(mockError);
expect(logger.error).toHaveBeenCalledWith(
{
userId: mockUserId,
context: mockContext,
error: mockError.message,
},
"Failed to log sign out event"
);
expect(logger.error).toHaveBeenCalledWith("Failed to log sign out event", {
userId: mockUserId,
context: mockContext,
error: mockError.message,
});
expect(logger.error).toHaveBeenCalledTimes(1);
});
@@ -120,14 +116,11 @@ describe("logSignOutAction", () => {
await expect(() => logSignOutAction(mockUserId, mockUserEmail, mockContext)).rejects.toThrow(mockError);
expect(logger.error).toHaveBeenCalledWith(
{
userId: mockUserId,
context: mockContext,
error: mockError,
},
"Failed to log sign out event"
);
expect(logger.error).toHaveBeenCalledWith("Failed to log sign out event", {
userId: mockUserId,
context: mockContext,
error: mockError,
});
expect(logger.error).toHaveBeenCalledTimes(1);
});
@@ -140,14 +133,11 @@ describe("logSignOutAction", () => {
await expect(() => logSignOutAction(mockUserId, mockUserEmail, emptyContext)).rejects.toThrow(mockError);
expect(logger.error).toHaveBeenCalledWith(
{
userId: mockUserId,
context: emptyContext,
error: mockError.message,
},
"Failed to log sign out event"
);
expect(logger.error).toHaveBeenCalledWith("Failed to log sign out event", {
userId: mockUserId,
context: emptyContext,
error: mockError.message,
});
expect(logger.error).toHaveBeenCalledTimes(1);
});

View File

@@ -1,7 +1,7 @@
"use server";
import { logger } from "@formbricks/logger";
import { logSignOut } from "@/modules/auth/lib/utils";
import { logger } from "@formbricks/logger";
/**
* Logs a sign out event
@@ -27,14 +27,11 @@ export const logSignOutAction = async (
try {
logSignOut(userId, userEmail, context);
} catch (error) {
logger.error(
{
userId,
context,
error: error instanceof Error ? error.message : String(error),
},
"Failed to log sign out event"
);
logger.error("Failed to log sign out event", {
userId,
context,
error: error instanceof Error ? error.message : String(error),
});
// Re-throw to ensure callers are aware of the failure
throw error;
}

View File

@@ -66,21 +66,8 @@ export const authOptions: NextAuthOptions = {
throw new Error("Invalid credentials");
}
// Validate password length to prevent CPU DoS attacks
// bcrypt processes passwords up to 72 bytes, but we limit to 128 characters for security
if (credentials.password && credentials.password.length > 128) {
if (await shouldLogAuthFailure(identifier)) {
logAuthAttempt("password_too_long", "credentials", "password_validation", UNKNOWN_DATA, credentials?.email);
}
throw new Error("Invalid credentials");
}
// Use a control hash when user doesn't exist to maintain constant timing.
const controlHash = "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q";
let user;
try {
// Perform database lookup
user = await prisma.user.findUnique({
where: {
email: credentials?.email,
@@ -92,12 +79,6 @@ export const authOptions: NextAuthOptions = {
throw Error("Internal server error. Please try again later");
}
// Always perform password verification to maintain constant timing. This is important to prevent timing attacks for user enumeration.
// Use actual hash if user exists, control hash if user doesn't exist
const hashToVerify = user?.password || controlHash;
const isValid = await verifyPassword(credentials.password, hashToVerify);
// Now check all conditions after constant-time operations are complete
if (!user) {
if (await shouldLogAuthFailure(identifier)) {
logAuthAttempt("user_not_found", "credentials", "user_lookup", UNKNOWN_DATA, credentials?.email);
@@ -115,6 +96,8 @@ export const authOptions: NextAuthOptions = {
throw new Error("Your account is currently inactive. Please contact the organization admin.");
}
const isValid = await verifyPassword(credentials.password, user.password);
if (!isValid) {
if (await shouldLogAuthFailure(user.email)) {
logAuthAttempt("invalid_password", "credentials", "password_validation", user.id, user.email);

View File

@@ -1,7 +1,7 @@
import * as Sentry from "@sentry/nextjs";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import * as Sentry from "@sentry/nextjs";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
createAuditIdentifier,
hashPassword,
@@ -43,26 +43,16 @@ vi.mock("@/lib/constants", () => ({
}));
// Mock cache module
const { mockCache, mockLogger } = vi.hoisted(() => ({
const { mockCache } = vi.hoisted(() => ({
mockCache: {
getRedisClient: vi.fn(),
},
mockLogger: {
warn: vi.fn(),
error: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock("@/lib/cache", () => ({
cache: mockCache,
}));
vi.mock("@formbricks/logger", () => ({
logger: mockLogger,
}));
// Mock @formbricks/cache
vi.mock("@formbricks/cache", () => ({
createCacheKey: {
@@ -135,38 +125,6 @@ describe("Auth Utils", () => {
expect(await verifyPassword(complexPassword, hashedComplex)).toBe(true);
expect(await verifyPassword("wrong", hashedComplex)).toBe(false);
});
test("should handle bcrypt errors gracefully and log warning", async () => {
// Save the original bcryptjs implementation
const originalModule = await import("bcryptjs");
// Mock bcryptjs to throw an error on compare
vi.doMock("bcryptjs", () => ({
...originalModule,
compare: vi.fn().mockRejectedValue(new Error("Invalid salt version")),
hash: originalModule.hash, // Keep hash working
}));
// Re-import the utils module to use the mocked bcryptjs
const { verifyPassword: verifyPasswordMocked } = await import("./utils?t=" + Date.now());
const password = "testPassword";
const invalidHash = "invalid-hash-format";
const result = await verifyPasswordMocked(password, invalidHash);
// Should return false for security
expect(result).toBe(false);
// Should log warning
expect(mockLogger.warn).toHaveBeenCalledWith(
{ error: expect.any(Error) },
"Password verification failed due to invalid hash format"
);
// Restore the module
vi.doUnmock("bcryptjs");
});
});
describe("Audit Identifier Utils", () => {

View File

@@ -1,12 +1,12 @@
import { cache } from "@/lib/cache";
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditAction, TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import * as Sentry from "@sentry/nextjs";
import { compare, hash } from "bcryptjs";
import { createHash, randomUUID } from "crypto";
import { createCacheKey } from "@formbricks/cache";
import { logger } from "@formbricks/logger";
import { cache } from "@/lib/cache";
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditAction, TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
export const hashPassword = async (password: string) => {
const hashedPassword = await hash(password, 12);
@@ -19,7 +19,7 @@ export const verifyPassword = async (password: string, hashedPassword: string) =
return isValid;
} catch (error) {
// Log warning for debugging purposes, but don't throw to maintain security
logger.warn({ error }, "Password verification failed due to invalid hash format");
logger.warn("Password verification failed due to invalid hash format", { error });
// Return false for invalid hashes or other bcrypt errors
return false;
}
@@ -279,7 +279,7 @@ export const shouldLogAuthFailure = async (
return currentCount % 10 === 0 || timeSinceLastLog > 60000;
} catch (error) {
logger.warn({ error }, "Redis rate limiting failed, not logging due to Redis requirement");
logger.warn("Redis rate limiting failed, not logging due to Redis requirement", { error });
// If Redis fails, do not log as Redis is required for audit logs
return false;
}

View File

@@ -1,14 +1,5 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react";
import { signIn } from "next-auth/react";
import Link from "next/dist/client/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { z } from "zod";
import { cn } from "@/lib/cn";
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@/lib/localStorage";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
@@ -19,13 +10,19 @@ import { TwoFactorBackup } from "@/modules/ee/two-factor-auth/components/two-fac
import { Button } from "@/modules/ui/components/button";
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
import { PasswordInput } from "@/modules/ui/components/password-input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react";
import { signIn } from "next-auth/react";
import Link from "next/dist/client/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { z } from "zod";
const ZLoginForm = z.object({
email: z.string().email(),
password: z
.string()
.min(8, { message: "Password must be at least 8 characters long" })
.max(128, { message: "Password must be 128 characters or less" }),
password: z.string().min(8),
totpCode: z.string().optional(),
backupCode: z.string().optional(),
});

View File

@@ -1,9 +1,9 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { err, ok } from "@formbricks/types/error-handlers";
import { hashString } from "@/lib/hash-string";
// Import modules after mocking
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { err, ok } from "@formbricks/types/error-handlers";
import { applyIPRateLimit, applyRateLimit, getClientIdentifier } from "./helpers";
import { checkRateLimit } from "./rate-limit";
@@ -67,8 +67,8 @@ describe("helpers", () => {
await expect(getClientIdentifier()).rejects.toThrow("Failed to hash IP");
// Verify that the error was logged with proper context (pino 10 format: object first, message second)
expect(logger.error).toHaveBeenCalledWith({ error: originalError }, "Failed to hash IP");
// Verify that the error was logged with proper context
expect(logger.error).toHaveBeenCalledWith("Failed to hash IP", { error: originalError });
});
});

View File

@@ -1,7 +1,7 @@
import { logger } from "@formbricks/logger";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { hashString } from "@/lib/hash-string";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { logger } from "@formbricks/logger";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { checkRateLimit } from "./rate-limit";
import { type TRateLimitConfig } from "./types/rate-limit";
@@ -19,7 +19,7 @@ export const getClientIdentifier = async (): Promise<string> => {
return hashString(ip);
} catch (error) {
const errorMessage = "Failed to hash IP";
logger.error({ error }, errorMessage);
logger.error(errorMessage, { error });
throw new Error(errorMessage);
}
};

View File

@@ -1,7 +1,7 @@
export const rateLimitConfigs = {
// Authentication endpoints - stricter limits for security
auth: {
login: { interval: 900, allowedPerInterval: 10, namespace: "auth:login" }, // 10 per 15 minutes
login: { interval: 900, allowedPerInterval: 30, namespace: "auth:login" }, // 30 per 15 minutes
signup: { interval: 3600, allowedPerInterval: 30, namespace: "auth:signup" }, // 30 per hour
forgotPassword: { interval: 3600, allowedPerInterval: 5, namespace: "auth:forgot" }, // 5 per hour
verifyEmail: { interval: 3600, allowedPerInterval: 10, namespace: "auth:verify" }, // 10 per hour

View File

@@ -1,13 +1,13 @@
"use client";
import { useTranslate } from "@tolgee/react";
import { CheckIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { TOrganization, TOrganizationBillingPeriod } from "@formbricks/types/organizations";
import { cn } from "@/lib/cn";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
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 {
@@ -170,13 +170,14 @@ export const PricingCard = ({
{plan.id !== projectFeatureKeys.FREE && isCurrentPlan && (
<Button
variant="secondary"
loading={loading}
onClick={async () => {
setLoading(true);
await onManageSubscription();
setLoading(false);
}}
className="flex justify-center bg-[#635bff]">
className="flex justify-center">
{t("environments.settings.billing.manage_subscription")}
</Button>
)}

View File

@@ -1,13 +1,14 @@
"use client";
import { cn } from "@/lib/cn";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { TOrganization, TOrganizationBillingPeriod } from "@formbricks/types/organizations";
import { cn } from "@/lib/cn";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { isSubscriptionCancelledAction, manageSubscriptionAction, upgradePlanAction } from "../actions";
import { getCloudPricingData } from "../api/lib/constants";
import { BillingSlider } from "./billing-slider";
@@ -140,7 +141,7 @@ export const PricingTable = ({
<div className="flex w-full">
<h2 className="mb-3 mr-2 inline-flex w-full text-2xl font-bold text-slate-700">
{t("environments.settings.billing.current_plan")}:{" "}
<span className="capitalize">{organization.billing.plan}</span>
{capitalizeFirstLetter(organization.billing.plan)}
{cancellingOn && (
<Badge
className="mx-2"
@@ -174,7 +175,7 @@ export const PricingTable = ({
)}
</div>
<div className="mt-2 flex flex-col rounded-xl border border-slate-200 bg-white py-4 shadow-sm dark:bg-slate-800">
<div className="mt-2 flex flex-col rounded-xl border border-slate-200 bg-white py-4 capitalize shadow-sm dark:bg-slate-800">
<div
className={cn(
"relative mx-8 mb-8 flex flex-col gap-4",

View File

@@ -1,4 +1,5 @@
import { getResponsesByContactId } from "@/lib/response/service";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
import { getContact } from "@/modules/ee/contacts/lib/contacts";
import { IdBadge } from "@/modules/ui/components/id-badge";
@@ -58,7 +59,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
.map(([key, attributeData]) => {
return (
<div key={key}>
<dt className="text-sm font-medium text-slate-500">{key.toString()}</dt>
<dt className="text-sm font-medium text-slate-500">{capitalizeFirstLetter(key.toString())}</dt>
<dd className="mt-1 text-sm text-slate-900">{attributeData}</dd>
</div>
);

View File

@@ -1,3 +1,6 @@
import { cache } from "@/lib/cache";
import { validateInputs } from "@/lib/utils/validate";
import { evaluateSegment } from "@/modules/ee/contacts/segments/lib/segments";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { createCacheKey } from "@formbricks/cache";
@@ -6,9 +9,6 @@ import { logger } from "@formbricks/logger";
import { ZId, ZString } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { TBaseFilter } from "@formbricks/types/segment";
import { cache } from "@/lib/cache";
import { validateInputs } from "@/lib/utils/validate";
import { evaluateSegment } from "@/modules/ee/contacts/segments/lib/segments";
export const getSegments = reactCache(
async (environmentId: string) =>
@@ -34,7 +34,7 @@ export const getSegments = reactCache(
}
},
createCacheKey.environment.segments(environmentId),
60 * 1000 // 1 minutes in milliseconds
5 * 60 * 1000 // 5 minutes in milliseconds
)
);

View File

@@ -1,5 +1,34 @@
"use client";
import { cn } from "@/lib/cn";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { isCapitalized } from "@/lib/utils/strings";
import {
convertOperatorToText,
convertOperatorToTitle,
toggleFilterConnector,
updateContactAttributeKeyInFilter,
updateDeviceTypeInFilter,
updateFilterValue,
updateOperatorInFilter,
updatePersonIdentifierInFilter,
updateSegmentIdInFilter,
} from "@/modules/ee/contacts/segments/lib/utils";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { useTranslate } from "@tolgee/react";
import {
ArrowDownIcon,
@@ -35,35 +64,6 @@ import {
DEVICE_OPERATORS,
PERSON_OPERATORS,
} from "@formbricks/types/segment";
import { cn } from "@/lib/cn";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { isCapitalized } from "@/lib/utils/strings";
import {
convertOperatorToText,
convertOperatorToTitle,
toggleFilterConnector,
updateContactAttributeKeyInFilter,
updateDeviceTypeInFilter,
updateFilterValue,
updateOperatorInFilter,
updatePersonIdentifierInFilter,
updateSegmentIdInFilter,
} from "@/modules/ee/contacts/segments/lib/utils";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { AddFilterModal } from "./add-filter-modal";
interface TSegmentFilterProps {
@@ -314,7 +314,7 @@ function AttributeSegmentFilter({
}}
value={attrKeyValue}>
<SelectTrigger
className="flex w-auto items-center justify-center whitespace-nowrap bg-white"
className="flex w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
hideArrow>
<SelectValue>
<div className={cn("flex items-center gap-2", !isCapitalized(attrKeyValue ?? "") && "lowercase")}>
@@ -496,7 +496,7 @@ function PersonSegmentFilter({
}}
value={personIdentifier}>
<SelectTrigger
className="flex w-auto items-center justify-center whitespace-nowrap bg-white"
className="flex w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
hideArrow>
<SelectValue>
<div className="flex items-center gap-1 lowercase">
@@ -647,7 +647,7 @@ function SegmentSegmentFilter({
}}
value={currentSegment?.id}>
<SelectTrigger
className="flex w-auto items-center justify-center whitespace-nowrap bg-white"
className="flex w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
hideArrow>
<div className="flex items-center gap-1">
<Users2Icon className="h-4 w-4 text-sm" />

View File

@@ -1,10 +1,10 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import type { Mock } from "vitest";
import { prisma } from "@formbricks/database";
import {
TEnterpriseLicenseDetails,
TEnterpriseLicenseFeatures,
} from "@/modules/ee/license-check/types/enterprise-license";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import type { Mock } from "vitest";
import { prisma } from "@formbricks/database";
// Mock declarations must be at the top level
vi.mock("@/lib/env", () => ({
@@ -59,17 +59,6 @@ vi.mock("@formbricks/database", () => ({
},
}));
const mockLogger = {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
};
vi.mock("@formbricks/logger", () => ({
logger: mockLogger,
}));
// Mock constants as they are used in the original license.ts indirectly
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal();
@@ -91,10 +80,6 @@ describe("License Core Logic", () => {
mockCache.set.mockReset();
mockCache.del.mockReset();
mockCache.withCache.mockReset();
mockLogger.error.mockReset();
mockLogger.warn.mockReset();
mockLogger.info.mockReset();
mockLogger.debug.mockReset();
// Set up default mock implementations for Result types
mockCache.get.mockResolvedValue({ ok: true, data: null });
@@ -542,136 +527,4 @@ describe("License Core Logic", () => {
);
});
});
describe("Error and Warning Logging", () => {
test("should log warning when setPreviousResult cache.set fails (line 176-178)", async () => {
const { getEnterpriseLicense } = await import("./license");
const fetch = (await import("node-fetch")).default as Mock;
const mockFetchedLicenseDetails: TEnterpriseLicenseDetails = {
status: "active",
features: {
isMultiOrgEnabled: true,
contacts: true,
projects: 10,
whitelabel: true,
removeBranding: true,
twoFactorAuth: true,
sso: true,
saml: true,
spamProtection: true,
ai: false,
auditLogs: true,
multiLanguageSurveys: true,
accessControl: true,
quotas: true,
},
};
// Mock successful fetch from API
mockCache.withCache.mockResolvedValue(mockFetchedLicenseDetails);
// Mock cache.set to fail when saving previous result
mockCache.set.mockResolvedValue({
ok: false,
error: new Error("Redis connection failed"),
});
await getEnterpriseLicense();
// Verify that the warning was logged
expect(mockLogger.warn).toHaveBeenCalledWith(
{ error: new Error("Redis connection failed") },
"Failed to cache previous result"
);
});
test("should log error when trackApiError is called (line 196-203)", async () => {
const { getEnterpriseLicense } = await import("./license");
const fetch = (await import("node-fetch")).default as Mock;
// Mock cache.withCache to execute the function (simulating cache miss)
mockCache.withCache.mockImplementation(async (fn) => await fn());
// Mock API response with 500 status
const mockStatus = 500;
fetch.mockResolvedValueOnce({
ok: false,
status: mockStatus,
json: async () => ({ error: "Internal Server Error" }),
} as any);
await getEnterpriseLicense();
// Verify that the API error was logged with correct structure
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
status: mockStatus,
code: "API_ERROR",
timestamp: expect.any(String),
}),
expect.stringContaining("License API error:")
);
});
test("should log error when trackApiError is called with different status codes (line 196-203)", async () => {
const { getEnterpriseLicense } = await import("./license");
const fetch = (await import("node-fetch")).default as Mock;
// Test with 403 Forbidden
mockCache.withCache.mockImplementation(async (fn) => await fn());
const mockStatus = 403;
fetch.mockResolvedValueOnce({
ok: false,
status: mockStatus,
json: async () => ({ error: "Forbidden" }),
} as any);
await getEnterpriseLicense();
// Verify that the API error was logged with correct structure
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
status: mockStatus,
code: "API_ERROR",
timestamp: expect.any(String),
}),
expect.stringContaining("License API error:")
);
});
test("should log info when trackFallbackUsage is called during grace period", async () => {
const { getEnterpriseLicense } = await import("./license");
const fetch = (await import("node-fetch")).default as Mock;
const previousTime = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000); // 1 day ago
const mockPreviousResult = {
active: true,
features: { removeBranding: true, projects: 5 },
lastChecked: previousTime,
version: 1,
};
mockCache.withCache.mockResolvedValue(null);
mockCache.get.mockImplementation(async (key) => {
if (key.includes(":previous_result")) {
return { ok: true, data: mockPreviousResult };
}
return { ok: true, data: null };
});
fetch.mockResolvedValueOnce({ ok: false, status: 500 } as any);
await getEnterpriseLicense();
// Verify that the fallback info was logged
expect(mockLogger.info).toHaveBeenCalledWith(
expect.objectContaining({
fallbackLevel: "grace",
timestamp: expect.any(String),
}),
expect.stringContaining("Using license fallback level: grace")
);
});
});
});

View File

@@ -1,11 +1,4 @@
import "server-only";
import { HttpsProxyAgent } from "https-proxy-agent";
import fetch from "node-fetch";
import { cache as reactCache } from "react";
import { z } from "zod";
import { createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { cache } from "@/lib/cache";
import { env } from "@/lib/env";
import { hashString } from "@/lib/hash-string";
@@ -13,6 +6,13 @@ import {
TEnterpriseLicenseDetails,
TEnterpriseLicenseFeatures,
} from "@/modules/ee/license-check/types/enterprise-license";
import { HttpsProxyAgent } from "https-proxy-agent";
import fetch from "node-fetch";
import { cache as reactCache } from "react";
import { z } from "zod";
import { createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
// Configuration
const CONFIG = {
@@ -154,7 +154,7 @@ const getPreviousResult = async (): Promise<TPreviousResult> => {
};
}
} catch (error) {
logger.error({ error }, "Failed to get previous result from cache");
logger.error("Failed to get previous result from cache", { error });
}
return {
@@ -174,33 +174,27 @@ const setPreviousResult = async (previousResult: TPreviousResult) => {
CONFIG.CACHE.PREVIOUS_RESULT_TTL_MS
);
if (!result.ok) {
logger.warn({ error: result.error }, "Failed to cache previous result");
logger.warn("Failed to cache previous result", { error: result.error });
}
} catch (error) {
logger.error({ error }, "Failed to set previous result in cache");
logger.error("Failed to set previous result in cache", { error });
}
};
// Monitoring functions
const trackFallbackUsage = (level: FallbackLevel) => {
logger.info(
{
fallbackLevel: level,
timestamp: new Date().toISOString(),
},
`Using license fallback level: ${level}`
);
logger.info(`Using license fallback level: ${level}`, {
fallbackLevel: level,
timestamp: new Date().toISOString(),
});
};
const trackApiError = (error: LicenseApiError) => {
logger.error(
{
status: error.status,
code: error.code,
timestamp: new Date().toISOString(),
},
`License API error: ${error.message}`
);
logger.error(`License API error: ${error.message}`, {
status: error.status,
code: error.code,
timestamp: new Date().toISOString(),
});
};
// Validation functions

View File

@@ -1,15 +1,15 @@
"use client";
import { extractLanguageCodes, isLabelValidForAllLanguages } from "@/lib/i18n/utils";
import { md } from "@/lib/markdownIt";
import { recallToHeadline } from "@/lib/utils/recall";
import { Editor } from "@/modules/ui/components/editor";
import { useTranslate } from "@tolgee/react";
import DOMPurify from "dompurify";
import type { Dispatch, SetStateAction } from "react";
import { useMemo } from "react";
import type { TI18nString, TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { extractLanguageCodes, isLabelValidForAllLanguages } from "@/lib/i18n/utils";
import { md } from "@/lib/markdownIt";
import { recallToHeadline } from "@/lib/utils/recall";
import { Editor } from "@/modules/ui/components/editor";
import { LanguageIndicator } from "./language-indicator";
interface LocalizedEditorProps {
@@ -24,7 +24,6 @@ interface LocalizedEditorProps {
firstRender: boolean;
setFirstRender?: Dispatch<SetStateAction<boolean>>;
locale: TUserLocale;
questionId: string;
}
const checkIfValueIsIncomplete = (
@@ -51,8 +50,7 @@ export function LocalizedEditor({
firstRender,
setFirstRender,
locale,
questionId,
}: Readonly<LocalizedEditorProps>) {
}: LocalizedEditorProps) {
const { t } = useTranslate();
const surveyLanguageCodes = useMemo(
() => extractLanguageCodes(localSurvey.languages),
@@ -86,9 +84,6 @@ export function LocalizedEditor({
updateQuestion(questionIdx, { html: translatedHtml });
}
}}
localSurvey={localSurvey}
questionId={questionId}
selectedLanguageCode={selectedLanguageCode}
/>
{localSurvey.languages.length > 1 && (
<div>

View File

@@ -2,7 +2,6 @@
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react";
import { CopyIcon, Trash2Icon } from "lucide-react";
import { TSurveyQuota } from "@formbricks/types/quota";
@@ -38,30 +37,26 @@ export const QuotaList = ({ quotas, onEdit, deleteQuota, duplicateQuota }: Quota
</div>
<div className="flex items-center gap-2">
<TooltipRenderer tooltipContent={t("common.delete")}>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
deleteQuota(quota);
}}
className="h-8 w-8 p-0 text-slate-500">
<Trash2Icon className="h-4 w-4" />
</Button>
</TooltipRenderer>
<TooltipRenderer tooltipContent={t("common.duplicate")}>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
duplicateQuota(quota);
}}
className="h-8 w-8 p-0 text-slate-500">
<CopyIcon className="h-4 w-4" />
</Button>
</TooltipRenderer>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
deleteQuota(quota);
}}
className="h-8 w-8 p-0 text-slate-500">
<Trash2Icon className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
duplicateQuota(quota);
}}
className="h-8 w-8 p-0 text-slate-500">
<CopyIcon className="h-4 w-4" />
</Button>
</div>
</div>
))}

View File

@@ -25,15 +25,6 @@ vi.mock("../actions", () => ({
updateInviteAction: vi.fn(),
}));
vi.mock("@/lib/membership/utils", () => ({
getAccessFlags: (role: string) => ({
isOwner: role === "owner",
isManager: role === "manager",
isMember: role === "member",
isBilling: role === "billing",
}),
}));
describe("EditMembershipRole Component", () => {
const mockRouter = {
refresh: vi.fn(),
@@ -62,21 +53,15 @@ describe("EditMembershipRole Component", () => {
describe("Rendering", () => {
test("renders a dropdown when user is owner", () => {
render(<EditMembershipRole {...defaultProps} isUserManagementDisabledFromUi={false} />);
render(<EditMembershipRole {...defaultProps} />);
const button = screen.queryByRole("button-role");
expect(button).toBeInTheDocument();
expect(button).toHaveTextContent("member");
expect(button).toHaveTextContent("Member");
});
test("renders a badge when user is not owner or manager", () => {
render(
<EditMembershipRole
{...defaultProps}
currentUserRole="member"
isUserManagementDisabledFromUi={false}
/>
);
render(<EditMembershipRole {...defaultProps} currentUserRole="member" />);
const badge = screen.queryByRole("badge-role");
expect(badge).toBeInTheDocument();
@@ -85,42 +70,21 @@ describe("EditMembershipRole Component", () => {
});
test("disables the dropdown when editing own role", () => {
render(
<EditMembershipRole
{...defaultProps}
memberId="user-456"
userId="user-456"
isUserManagementDisabledFromUi={false}
/>
);
render(<EditMembershipRole {...defaultProps} memberId="user-456" userId="user-456" />);
const button = screen.getByRole("button-role");
expect(button).toBeDisabled();
});
test("disables the dropdown when the user is the only owner", () => {
render(
<EditMembershipRole
{...defaultProps}
memberRole="owner"
doesOrgHaveMoreThanOneOwner={false}
isUserManagementDisabledFromUi={false}
/>
);
render(<EditMembershipRole {...defaultProps} memberRole="owner" doesOrgHaveMoreThanOneOwner={false} />);
const button = screen.getByRole("button-role");
expect(button).toBeDisabled();
});
test("disables the dropdown when a manager tries to edit an owner", () => {
render(
<EditMembershipRole
{...defaultProps}
currentUserRole="manager"
memberRole="owner"
isUserManagementDisabledFromUi={false}
/>
);
render(<EditMembershipRole {...defaultProps} currentUserRole="manager" memberRole="owner" />);
const button = screen.getByRole("button-role");
expect(button).toBeDisabled();

View File

@@ -1,12 +1,7 @@
"use client";
import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import type { TOrganizationRole } from "@formbricks/types/memberships";
import { getAccessFlags } from "@/lib/membership/utils";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import {
@@ -16,6 +11,12 @@ import {
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import type { TOrganizationRole } from "@formbricks/types/memberships";
import { updateInviteAction, updateMembershipAction } from "../actions";
interface Role {
@@ -103,7 +104,7 @@ export function EditMembershipRole({
size="sm"
variant="secondary"
role="button-role">
<span className="ml-1 capitalize">{memberRole}</span>
<span className="ml-1">{capitalizeFirstLetter(memberRole)}</span>
<ChevronDownIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
@@ -127,5 +128,5 @@ export function EditMembershipRole({
);
}
return <Badge size="tiny" type="gray" role="badge-role" text={memberRole} className="capitalize" />;
return <Badge size="tiny" type="gray" role="badge-role" text={capitalizeFirstLetter(memberRole)} />;
}

View File

@@ -1,15 +1,15 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import {
removeOrganizationEmailLogoUrlAction,
sendTestEmailAction,
updateOrganizationEmailLogoUrlAction,
} from "@/modules/ee/whitelabel/email-customization/actions";
import { handleFileUpload } from "@/modules/storage/file-upload";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { EmailCustomizationSettings } from "./email-customization-settings";
vi.mock("@/lib/constants", () => ({
@@ -107,6 +107,7 @@ describe("EmailCustomizationSettings", () => {
const saveButton = screen.getAllByRole("button", { name: /save/i });
await user.click(saveButton[0]);
// The component calls `uploadFile` then `updateOrganizationEmailLogoUrlAction`
expect(handleFileUpload).toHaveBeenCalledWith(testFile, "env-123", ["jpeg", "png", "jpg", "webp"]);
expect(updateOrganizationEmailLogoUrlAction).toHaveBeenCalledWith({
organizationId: "org-123",

View File

@@ -1,14 +1,5 @@
"use client";
import { useTranslate } from "@tolgee/react";
import { RepeatIcon, Trash2Icon } from "lucide-react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import React, { useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { TOrganization } from "@formbricks/types/organizations";
import { TAllowedFileExtension } from "@formbricks/types/storage";
import { TUser } from "@formbricks/types/user";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { cn } from "@/lib/cn";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
@@ -24,6 +15,15 @@ import { Uploader } from "@/modules/ui/components/file-input/components/uploader
import { showStorageNotConfiguredToast } from "@/modules/ui/components/storage-not-configured-toast/lib/utils";
import { Muted, P, Small } from "@/modules/ui/components/typography";
import { ModalButton, UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { useTranslate } from "@tolgee/react";
import { RepeatIcon, Trash2Icon } from "lucide-react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import React, { useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { TOrganization } from "@formbricks/types/organizations";
import { TAllowedFileExtension } from "@formbricks/types/storage";
import { TUser } from "@formbricks/types/user";
const allowedFileExtensions: TAllowedFileExtension[] = ["jpeg", "png", "jpg", "webp"];

View File

@@ -1,10 +1,11 @@
"use client";
import { convertDateTimeStringShort } from "@/lib/time";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { Label } from "@/modules/ui/components/label";
import { Webhook } from "@prisma/client";
import { TFnType, useTranslate } from "@tolgee/react";
import { TSurvey } from "@formbricks/types/surveys/types";
import { convertDateTimeStringShort } from "@/lib/time";
import { Label } from "@/modules/ui/components/label";
interface ActivityTabProps {
webhook: Webhook;
@@ -49,8 +50,8 @@ export const WebhookOverviewTab = ({ webhook, surveys }: ActivityTabProps) => {
<Label className="text-slate-500">
{t("environments.integrations.webhooks.created_by_third_party")}
</Label>
<p className="text-sm capitalize text-slate-900">
{webhook.source === "user" ? "No" : webhook.source}
<p className="text-sm text-slate-900">
{webhook.source === "user" ? "No" : capitalizeFirstLetter(webhook.source)}
</p>
</div>

View File

@@ -1,11 +1,12 @@
"use client";
import { timeSince } from "@/lib/time";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { Badge } from "@/modules/ui/components/badge";
import { Webhook } from "@prisma/client";
import { TFnType, useTranslate } from "@tolgee/react";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { Badge } from "@/modules/ui/components/badge";
const renderSelectedSurveysText = (webhook: Webhook, allSurveys: TSurvey[]) => {
if (webhook.surveyIds.length === 0) {
@@ -81,7 +82,7 @@ export const WebhookRowData = ({
</div>
</div>
<div className="col-span-1 my-auto text-center text-sm text-slate-800">
<Badge type="gray" size="tiny" text={webhook.source || t("common.user")} className="capitalize" />
<Badge type="gray" size="tiny" text={capitalizeFirstLetter(webhook.source) || t("common.user")} />
</div>
<div className="col-span-4 my-auto text-center text-sm text-slate-800">
{renderSelectedSurveysText(webhook, surveys)}

View File

@@ -1,5 +1,6 @@
"use client";
import { getOrganizationAccessKeyDisplayName } from "@/modules/organization/settings/api-keys/lib/utils";
import { TOrganizationProject } from "@/modules/organization/settings/api-keys/types/api-keys";
import { Alert, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
@@ -23,7 +24,7 @@ import { Switch } from "@/modules/ui/components/switch";
import { ApiKeyPermission } from "@prisma/client";
import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon, Trash2Icon } from "lucide-react";
import { useState } from "react";
import { Fragment, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { TOrganizationAccess } from "@formbricks/types/api-key";
@@ -219,10 +220,10 @@ export const AddApiKeyModal = ({
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="px-1">{t("environments.project.api_keys.add_api_key")}</DialogTitle>
<DialogTitle>{t("environments.project.api_keys.add_api_key")}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(submitAPIKey)} className="contents">
<DialogBody className="space-y-4 overflow-y-auto px-1 py-4">
<DialogBody className="space-y-4 overflow-y-auto py-4">
<div className="space-y-2">
<Label>{t("environments.project.api_keys.api_key_label")}</Label>
<Input
@@ -347,31 +348,43 @@ export const AddApiKeyModal = ({
</div>
<div className="space-y-4">
<Label>{t("environments.project.api_keys.organization_access")}</Label>
{Object.keys(selectedOrganizationAccess).map((key) => (
<div key={key} className="mt-2 flex items-center gap-6">
<div className="flex items-center gap-2">
<Label>Read</Label>
<Switch
data-testid={`organization-access-${key}-read`}
checked={selectedOrganizationAccess[key].read || selectedOrganizationAccess[key].write}
onCheckedChange={(newVal) => setSelectedOrganizationAccessValue(key, "read", newVal)}
disabled={selectedOrganizationAccess[key].write}
/>
</div>
<div className="flex items-center gap-2">
<Label>Write</Label>
<Switch
data-testid={`organization-access-${key}-write`}
checked={selectedOrganizationAccess[key].write}
onCheckedChange={(newVal) => setSelectedOrganizationAccessValue(key, "write", newVal)}
/>
</div>
<div>
<Label>{t("environments.project.api_keys.organization_access")}</Label>
<p className="text-sm text-slate-500">
{t("environments.project.api_keys.organization_access_description")}
</p>
</div>
<div className="space-y-2">
<div className="grid grid-cols-[auto_100px_100px] gap-4">
<div></div>
<span className="flex items-center justify-center text-sm font-medium">Read</span>
<span className="flex items-center justify-center text-sm font-medium">Write</span>
{Object.keys(selectedOrganizationAccess).map((key) => (
<Fragment key={key}>
<div className="py-1 text-sm">{getOrganizationAccessKeyDisplayName(key, t)}</div>
<div className="flex items-center justify-center py-1">
<Switch
data-testid={`organization-access-${key}-read`}
checked={selectedOrganizationAccess[key].read}
onCheckedChange={(newVal) =>
setSelectedOrganizationAccessValue(key, "read", newVal)
}
/>
</div>
<div className="flex items-center justify-center py-1">
<Switch
data-testid={`organization-access-${key}-write`}
checked={selectedOrganizationAccess[key].write}
onCheckedChange={(newVal) =>
setSelectedOrganizationAccessValue(key, "write", newVal)
}
/>
</div>
</Fragment>
))}
</div>
))}
<p className="text-sm text-slate-500">
{t("environments.project.api_keys.organization_access_description")}
</p>
</div>
</div>
<Alert variant="warning">
<AlertTitle>{t("environments.project.api_keys.api_key_security_warning")}</AlertTitle>

View File

@@ -1,6 +1,7 @@
import { ApiKeyPermission } from "@prisma/client";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import React from "react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TProject } from "@formbricks/types/project";
import { TApiKeyWithEnvironmentPermission } from "../types/api-keys";
@@ -103,8 +104,6 @@ describe("ViewPermissionModal", () => {
setOpen: vi.fn(),
projects: mockProjects,
apiKey: mockApiKey,
onSubmit: vi.fn(),
isUpdating: false,
};
test("renders the modal with correct title", () => {
@@ -155,7 +154,7 @@ describe("ViewPermissionModal", () => {
expect(screen.getByTestId("organization-access-accessControl-read")).toBeDisabled();
expect(screen.getByTestId("organization-access-accessControl-write")).not.toBeChecked();
expect(screen.getByTestId("organization-access-accessControl-write")).toBeDisabled();
expect(screen.getByTestId("organization-access-otherAccess-read")).toBeChecked();
expect(screen.getByTestId("organization-access-otherAccess-read")).not.toBeChecked();
expect(screen.getByTestId("organization-access-otherAccess-write")).toBeChecked();
});
});

View File

@@ -1,5 +1,6 @@
"use client";
import { getOrganizationAccessKeyDisplayName } from "@/modules/organization/settings/api-keys/lib/utils";
import {
TApiKeyUpdateInput,
TApiKeyWithEnvironmentPermission,
@@ -21,7 +22,7 @@ import { Label } from "@/modules/ui/components/label";
import { Switch } from "@/modules/ui/components/switch";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react";
import { useEffect } from "react";
import { Fragment, useEffect } from "react";
import { useForm } from "react-hook-form";
import { TOrganizationAccess } from "@formbricks/types/api-key";
@@ -167,28 +168,36 @@ export const ViewPermissionModal = ({
})}
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label>{t("environments.project.api_keys.organization_access")}</Label>
{Object.keys(organizationAccess).map((key) => (
<div key={key} className="mb-2 flex items-center gap-6">
<div className="flex items-center gap-2">
<Label className="text-sm font-medium">Read</Label>
<Switch
disabled={true}
data-testid={`organization-access-${key}-read`}
checked={organizationAccess[key].read || organizationAccess[key].write}
/>
</div>
<div className="flex items-center gap-2">
<Label className="text-sm font-medium">Write</Label>
<Switch
disabled={true}
data-testid={`organization-access-${key}-write`}
checked={organizationAccess[key].write}
/>
</div>
<div className="space-y-2">
<div className="grid grid-cols-[auto_100px_100px] gap-4">
<div></div>
<span className="flex items-center justify-center text-sm font-medium">Read</span>
<span className="flex items-center justify-center text-sm font-medium">Write</span>
{Object.keys(organizationAccess).map((key) => (
<Fragment key={key}>
<div className="py-1 text-sm">{getOrganizationAccessKeyDisplayName(key, t)}</div>
<div className="flex items-center justify-center py-1">
<Switch
disabled={true}
data-testid={`organization-access-${key}-read`}
checked={organizationAccess[key].read}
/>
</div>
<div className="flex items-center justify-center py-1">
<Switch
disabled={true}
data-testid={`organization-access-${key}-write`}
checked={organizationAccess[key].write}
/>
</div>
</Fragment>
))}
</div>
))}
</div>
</div>
</div>
</form>

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth";
import { hasPermission } from "./utils";
import { getOrganizationAccessKeyDisplayName, hasPermission } from "./utils";
describe("hasPermission", () => {
const envId = "env1";
@@ -83,3 +83,17 @@ describe("hasPermission", () => {
expect(hasPermission(permissions, "other", "GET")).toBe(false);
});
});
describe("getOrganizationAccessKeyDisplayName", () => {
test("returns tolgee string for accessControl", () => {
const t = vi.fn((k) => k);
expect(getOrganizationAccessKeyDisplayName("accessControl", t)).toBe(
"environments.project.api_keys.access_control"
);
expect(t).toHaveBeenCalledWith("environments.project.api_keys.access_control");
});
test("returns tolgee string for other keys", () => {
const t = vi.fn((k) => k);
expect(getOrganizationAccessKeyDisplayName("otherKey", t)).toBe("otherKey");
});
});

View File

@@ -1,3 +1,4 @@
import { TFnType } from "@tolgee/react";
import { OrganizationAccessType } from "@formbricks/types/api-key";
import { TAPIKeyEnvironmentPermission, TAuthenticationApiKey } from "@formbricks/types/auth";
@@ -42,6 +43,15 @@ export const hasPermission = (
}
};
export const getOrganizationAccessKeyDisplayName = (key: string, t: TFnType) => {
switch (key) {
case "accessControl":
return t("environments.project.api_keys.access_control");
default:
return key;
}
};
export const hasOrganizationAccess = (
authentication: TAuthenticationApiKey,
accessType: OrganizationAccessType

View File

@@ -1,11 +1,11 @@
import { getActiveInactiveSurveysAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
import { createActionClassAction } from "@/modules/survey/editor/actions";
import { cleanup, render, screen } 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 { TActionClass } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment";
import { getActiveInactiveSurveysAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
import { createActionClassAction } from "@/modules/survey/editor/actions";
import { ActionActivityTab } from "./ActionActivityTab";
// Mock dependencies
@@ -51,6 +51,10 @@ vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: (error: any) => `Formatted error: ${error?.message || "Unknown error"}`,
}));
vi.mock("@/lib/utils/strings", () => ({
capitalizeFirstLetter: (str: string) => str.charAt(0).toUpperCase() + str.slice(1),
}));
vi.mock("@/modules/survey/editor/actions", () => ({
createActionClassAction: vi.fn(),
}));
@@ -205,7 +209,7 @@ describe("ActionActivityTab", () => {
expect(screen.getByText(`formatted-${mockActionClass.createdAt.toString()}`)).toBeInTheDocument(); // Created on
expect(screen.getByText(`formatted-${mockActionClass.updatedAt.toString()}`)).toBeInTheDocument(); // Last updated
expect(screen.getByText("NoCodeIcon")).toBeInTheDocument(); // Type icon
expect(screen.getByText("noCode")).toBeInTheDocument(); // Type text (now lowercase, capitalized via CSS)
expect(screen.getByText("NoCode")).toBeInTheDocument(); // Type text
expect(screen.getByText("Development")).toBeInTheDocument(); // Environment
expect(screen.getByText("Copy to Production")).toBeInTheDocument(); // Copy button text
});

View File

@@ -1,12 +1,8 @@
"use client";
import { useTranslate } from "@tolgee/react";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { TActionClass, TActionClassInput, TActionClassInputCode } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment";
import { convertDateTimeStringShort } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { getActiveInactiveSurveysAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
import { ACTION_TYPE_ICON_LOOKUP } from "@/modules/projects/settings/(setup)/app-connection/utils";
import { createActionClassAction } from "@/modules/survey/editor/actions";
@@ -14,6 +10,11 @@ import { Button } from "@/modules/ui/components/button";
import { ErrorComponent } from "@/modules/ui/components/error-component";
import { Label } from "@/modules/ui/components/label";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
import { useTranslate } from "@tolgee/react";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { TActionClass, TActionClassInput, TActionClassInputCode } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment";
interface ActivityTabProps {
actionClass: TActionClass;
@@ -151,7 +152,7 @@ export const ActionActivityTab = ({
<Label className="block text-xs font-normal text-slate-500">Type</Label>
<div className="mt-1 flex items-center">
<div className="mr-1.5 h-4 w-4 text-slate-600">{ACTION_TYPE_ICON_LOOKUP[actionClass.type]}</div>
<p className="text-sm capitalize text-slate-700">{actionClass.type}</p>
<p className="text-sm text-slate-700">{capitalizeFirstLetter(actionClass.type)}</p>
</div>
</div>
<div className="">

View File

@@ -1,10 +1,5 @@
"use client";
import { Project } from "@prisma/client";
import { useTranslate } from "@tolgee/react";
import Image from "next/image";
import { ChangeEvent, useRef, useState } from "react";
import toast from "react-hot-toast";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateProjectAction } from "@/modules/projects/settings/actions";
import { handleFileUpload } from "@/modules/storage/file-upload";
@@ -16,6 +11,11 @@ import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { FileInput } from "@/modules/ui/components/file-input";
import { Input } from "@/modules/ui/components/input";
import { showStorageNotConfiguredToast } from "@/modules/ui/components/storage-not-configured-toast/lib/utils";
import { Project } from "@prisma/client";
import { useTranslate } from "@tolgee/react";
import Image from "next/image";
import { ChangeEvent, useRef, useState } from "react";
import toast from "react-hot-toast";
interface EditLogoProps {
project: Project;
@@ -151,7 +151,6 @@ export const EditLogo = ({ project, environmentId, isReadOnly, isStorageConfigur
setIsEditing(true);
}}
disabled={isReadOnly}
maxSizeInMB={5}
isStorageConfigured={isStorageConfigured}
/>
)}

View File

@@ -1,7 +1,7 @@
export enum FileUploadError {
NO_FILE = "No file provided or invalid file type. Expected a File or Blob.",
INVALID_FILE_TYPE = "Please upload an image file.",
FILE_SIZE_EXCEEDED = "File size must be less than 5 MB.",
FILE_SIZE_EXCEEDED = "File size must be less than 10 MB.",
UPLOAD_FAILED = "Upload failed. Please try again.",
INVALID_FILE_NAME = "Invalid file name. Please rename your file and try again.",
}
@@ -36,9 +36,7 @@ export const handleFileUpload = async (
const bufferBytes = fileBuffer.byteLength;
const bufferKB = bufferBytes / 1024;
const MAX_FILE_SIZE_MB = 5;
const maxSizeInKB = MAX_FILE_SIZE_MB * 1024;
if (bufferKB > maxSizeInKB) {
if (bufferKB > 10240) {
return {
error: FileUploadError.FILE_SIZE_EXCEEDED,
url: "",

View File

@@ -57,15 +57,15 @@ describe("FallbackInput", () => {
render(<FallbackInput {...defaultProps} />);
expect(screen.getByText("Add a placeholder to show if the question gets skipped:")).toBeInTheDocument();
expect(screen.getByLabelText("Item 1")).toBeInTheDocument();
expect(screen.getByLabelText("Item 2")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "common.save" })).toBeDisabled();
expect(screen.getByPlaceholderText("Fallback for Item 1")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Fallback for Item 2")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Add" })).toBeDisabled();
});
test("enables Save button when fallbacks are provided for all items", () => {
test("enables Add button when fallbacks are provided for all items", () => {
render(<FallbackInput {...defaultProps} fallbacks={{ item1: "fallback1", item2: "fallback2" }} />);
expect(screen.getByRole("button", { name: "common.save" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Add" })).toBeEnabled();
});
test("updates fallbacks when input changes", async () => {
@@ -73,11 +73,10 @@ describe("FallbackInput", () => {
render(<FallbackInput {...defaultProps} />);
const input1 = screen.getByLabelText("Item 1");
await user.type(input1, "test");
const input1 = screen.getByPlaceholderText("Fallback for Item 1");
await user.type(input1, "new fallback");
// Check that setFallbacks was called (at least once)
expect(mockSetFallbacks).toHaveBeenCalled();
expect(mockSetFallbacks).toHaveBeenCalledWith({ item1: "new fallback" });
});
test("handles Enter key press correctly when input is valid", async () => {
@@ -85,7 +84,7 @@ describe("FallbackInput", () => {
render(<FallbackInput {...defaultProps} fallbacks={{ item1: "fallback1", item2: "fallback2" }} />);
const input = screen.getByLabelText("Item 1");
const input = screen.getByPlaceholderText("Fallback for Item 1");
await user.type(input, "{Enter}");
expect(mockAddFallback).toHaveBeenCalled();
@@ -97,7 +96,7 @@ describe("FallbackInput", () => {
render(<FallbackInput {...defaultProps} fallbacks={{ item1: "" }} />);
const input = screen.getByLabelText("Item 1");
const input = screen.getByPlaceholderText("Fallback for Item 1");
await user.type(input, "{Enter}");
expect(toast.error).toHaveBeenCalledWith("Fallback missing");
@@ -105,13 +104,13 @@ describe("FallbackInput", () => {
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("calls addFallback when Save button is clicked", async () => {
test("calls addFallback when Add button is clicked", async () => {
const user = userEvent.setup();
render(<FallbackInput {...defaultProps} fallbacks={{ item1: "fallback1", item2: "fallback2" }} />);
const saveButton = screen.getByRole("button", { name: "common.save" });
await user.click(saveButton);
const addButton = screen.getByRole("button", { name: "Add" });
await user.click(addButton);
expect(mockAddFallback).toHaveBeenCalled();
expect(mockSetOpen).toHaveBeenCalledWith(false);
@@ -125,14 +124,14 @@ describe("FallbackInput", () => {
render(<FallbackInput {...defaultProps} filteredRecallItems={mixedRecallItems} />);
expect(screen.getByLabelText("Item 1")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Fallback for Item 1")).toBeInTheDocument();
expect(screen.queryByText("undefined")).not.toBeInTheDocument();
});
test("replaces 'nbsp' with space in fallback value", () => {
render(<FallbackInput {...defaultProps} fallbacks={{ item1: "fallbacknbsptext" }} />);
const input = screen.getByLabelText("Item 1");
const input = screen.getByPlaceholderText("Fallback for Item 1");
expect(input).toHaveValue("fallback text");
});

View File

@@ -1,31 +1,29 @@
import { useTranslate } from "@tolgee/react";
import { ReactNode } from "react";
import { toast } from "react-hot-toast";
import { TSurveyRecallItem } from "@formbricks/types/surveys/types";
import { replaceRecallInfoWithUnderline } from "@/lib/utils/recall";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import { useTranslate } from "@tolgee/react";
import { RefObject } from "react";
import { toast } from "react-hot-toast";
import { TSurveyRecallItem } from "@formbricks/types/surveys/types";
interface FallbackInputProps {
filteredRecallItems: (TSurveyRecallItem | undefined)[];
fallbacks: { [type: string]: string };
setFallbacks: (fallbacks: { [type: string]: string }) => void;
fallbackInputRef: RefObject<HTMLInputElement>;
addFallback: () => void;
open: boolean;
setOpen: (open: boolean) => void;
triggerButton?: ReactNode;
}
export const FallbackInput = ({
filteredRecallItems,
fallbacks,
setFallbacks,
fallbackInputRef,
addFallback,
open,
setOpen,
triggerButton,
}: FallbackInputProps) => {
const { t } = useTranslate();
const containsEmptyFallback = () => {
@@ -34,9 +32,9 @@ export const FallbackInput = ({
};
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open}>
<PopoverTrigger asChild>
{open ? <div className="z-10 h-0 w-full cursor-pointer" /> : triggerButton}
<div className="z-10 h-0 w-full cursor-pointer" />
</PopoverTrigger>
<PopoverContent
@@ -46,21 +44,18 @@ export const FallbackInput = ({
sideOffset={4}>
<p className="font-medium">{t("environments.surveys.edit.add_fallback_placeholder")}</p>
<div className="mt-2 space-y-3">
<div className="mt-2 space-y-2">
{filteredRecallItems.map((recallItem, idx) => {
if (!recallItem) return null;
const inputId = `fallback-${recallItem.id}`;
return (
<div key={recallItem.id} className="flex flex-col gap-1">
<Label htmlFor={inputId} className="text-xs font-medium text-slate-700">
{replaceRecallInfoWithUnderline(recallItem.label)}
</Label>
<div key={recallItem.id} className="flex flex-col">
<Input
className="h-9 bg-white"
id={inputId}
className="placeholder:text-md h-full bg-white"
ref={idx === 0 ? fallbackInputRef : undefined}
id="fallback"
autoFocus={idx === filteredRecallItems.length - 1}
value={fallbacks[recallItem.id]?.replaceAll("nbsp", " ") || ""}
placeholder={t("environments.surveys.edit.enter_fallback_value")}
value={fallbacks[recallItem.id]?.replaceAll("nbsp", " ")}
placeholder={`${t("environments.surveys.edit.fallback_for")} ${recallItem.label}`}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
@@ -85,14 +80,14 @@ export const FallbackInput = ({
<div className="flex w-full justify-end">
<Button
className="mt-2 h-9"
className="mt-2 h-full py-2"
disabled={containsEmptyFallback()}
onClick={(e) => {
e.preventDefault();
addFallback();
setOpen(false);
}}>
{t("common.save")}
{t("environments.surveys.edit.add_fallback")}
</Button>
</div>
</PopoverContent>

View File

@@ -197,6 +197,6 @@ describe("RecallItemSelect", () => {
const searchInput = screen.getByPlaceholderText("Search options");
await user.type(searchInput, "nonexistent");
expect(screen.getByText("environments.surveys.edit.no_recall_items_found")).toBeInTheDocument();
expect(screen.getByText("No recall items found 🤷")).toBeInTheDocument();
});
});

View File

@@ -1,4 +1,11 @@
import { useTranslate } from "@tolgee/react";
import { replaceRecallInfoWithUnderline } from "@/lib/utils/recall";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Input } from "@/modules/ui/components/input";
import { DropdownMenuItem } from "@radix-ui/react-dropdown-menu";
import {
CalendarDaysIcon,
ContactIcon,
@@ -22,14 +29,6 @@ import {
TSurveyQuestionId,
TSurveyRecallItem,
} from "@formbricks/types/surveys/types";
import { replaceRecallInfoWithUnderline } from "@/lib/utils/recall";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Input } from "@/modules/ui/components/input";
const questionIconMapping = {
openText: MessageSquareTextIcon,
@@ -63,7 +62,6 @@ export const RecallItemSelect = ({
selectedLanguageCode,
}: RecallItemSelectProps) => {
const [searchValue, setSearchValue] = useState("");
const { t } = useTranslate();
const isNotAllowedQuestionType = (question: TSurveyQuestion): boolean => {
return (
question.type === "fileUpload" ||
@@ -164,66 +162,60 @@ export const RecallItemSelect = ({
};
return (
<DropdownMenu defaultOpen={true} modal={true}>
<DropdownMenuTrigger className="z-10 cursor-pointer" asChild>
<div className="flex w-full items-center justify-between overflow-hidden" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="flex w-96 flex-col gap-2 bg-slate-50 p-3 text-xs text-slate-700"
align="start"
side="bottom"
data-recall-dropdown>
<p className="font-medium">{t("environments.surveys.edit.recall_information_from")}</p>
<Input
id="recallItemSearchInput"
placeholder="Search options"
className="w-full bg-white"
onChange={(e) => setSearchValue(e.target.value)}
autoFocus={true}
value={searchValue}
onKeyDown={(e) => {
if (e.key === "ArrowDown") {
document.getElementById("recallItem-0")?.focus();
}
}}
/>
<div className="max-h-72 overflow-y-auto overflow-x-hidden">
{filteredRecallItems.map((recallItem, index) => {
const IconComponent = getRecallItemIcon(recallItem);
return (
<DropdownMenuItem
id={"recallItem-" + index}
key={recallItem.id}
title={recallItem.type}
onSelect={() => {
addRecallItem({ id: recallItem.id, label: recallItem.label, type: recallItem.type });
setShowRecallItemSelect(false);
}}
autoFocus={false}
className="flex w-full cursor-pointer items-center rounded-md p-2 focus:bg-slate-200 focus:outline-none"
onKeyDown={(e) => {
if (
(e.key === "ArrowUp" && index === 0) ||
(e.key === "ArrowDown" && index === filteredRecallItems.length - 1)
) {
e.preventDefault();
document.getElementById("recallItemSearchInput")?.focus();
}
}}>
<div>{IconComponent && <IconComponent className="mr-2 w-4" />}</div>
<p className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm">
{getRecallLabel(recallItem.label)}
</p>
</DropdownMenuItem>
);
})}
{filteredRecallItems.length === 0 && (
<p className="p-2 text-sm font-medium text-slate-700">
{t("environments.surveys.edit.no_recall_items_found")}
</p>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
<>
<DropdownMenu defaultOpen={true} modal={false}>
<DropdownMenuTrigger className="z-10 cursor-pointer" asChild>
<div className="flex h-0 w-full items-center justify-between overflow-hidden" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-96 bg-slate-50 text-slate-700" align="start" side="bottom">
<p className="m-2 text-sm font-medium">Recall Information from...</p>
<Input
id="recallItemSearchInput"
placeholder="Search options"
className="mb-1 w-full bg-white"
onChange={(e) => setSearchValue(e.target.value)}
autoFocus={true}
value={searchValue}
onKeyDown={(e) => {
if (e.key === "ArrowDown") {
document.getElementById("recallItem-0")?.focus();
}
}}
/>
<div className="max-h-72 overflow-y-auto overflow-x-hidden">
{filteredRecallItems.map((recallItem, index) => {
const IconComponent = getRecallItemIcon(recallItem);
return (
<DropdownMenuItem
id={"recallItem-" + index}
key={recallItem.id}
title={recallItem.label}
onSelect={() => {
addRecallItem({ id: recallItem.id, label: recallItem.label, type: recallItem.type });
setShowRecallItemSelect(false);
}}
autoFocus={false}
className="flex w-full cursor-pointer items-center rounded-md p-2 focus:bg-slate-200 focus:outline-none"
onKeyDown={(e) => {
if (e.key === "ArrowUp" && index === 0) {
document.getElementById("recallItemSearchInput")?.focus();
} else if (e.key === "ArrowDown" && index === filteredRecallItems.length - 1) {
document.getElementById("recallItemSearchInput")?.focus();
}
}}>
<div>{IconComponent && <IconComponent className="mr-2 w-4" />}</div>
<p className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm">
{getRecallLabel(recallItem.label)}
</p>
</DropdownMenuItem>
);
})}
{filteredRecallItems.length === 0 && (
<p className="p-2 text-sm font-medium text-slate-700">No recall items found 🤷</p>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
</>
);
};

View File

@@ -1,11 +1,11 @@
import * as recallUtils from "@/lib/utils/recall";
import { RecallItemSelect } from "@/modules/survey/components/question-form-input/components/recall-item-select";
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen } 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 { TSurvey, TSurveyRecallItem } from "@formbricks/types/surveys/types";
import * as recallUtils from "@/lib/utils/recall";
import { RecallItemSelect } from "@/modules/survey/components/question-form-input/components/recall-item-select";
import { RecallWrapper } from "./recall-wrapper";
vi.mock("react-hot-toast", () => ({
@@ -144,16 +144,82 @@ describe("RecallWrapper", () => {
expect(RecallItemSelect).toHaveBeenCalled();
});
test("detects recall items when value contains recall syntax", () => {
test("handles fallback addition through user interaction and verifies state changes", async () => {
// Start with a value that already contains a recall item
const valueWithRecall = "Test with #recall:testId/fallback:# inside";
const recallItems = [{ id: "testId", label: "testLabel", type: "question" }] as TSurveyRecallItem[];
// Set up mocks to simulate the component's recall detection and fallback functionality
vi.mocked(recallUtils.getRecallItems).mockReturnValue(recallItems);
vi.mocked(recallUtils.findRecallInfoById).mockReturnValue("#recall:testId/fallback:#");
vi.mocked(recallUtils.getFallbackValues).mockReturnValue({ testId: "" });
render(<RecallWrapper {...defaultProps} value={valueWithRecall} />);
// Track onChange and onAddFallback calls to verify component state changes
const onChangeMock = vi.fn();
const onAddFallbackMock = vi.fn();
// Verify that recall items are detected
expect(recallUtils.getRecallItems).toHaveBeenCalledWith(valueWithRecall, expect.any(Object), "en");
render(
<RecallWrapper
{...defaultProps}
value={valueWithRecall}
onChange={onChangeMock}
onAddFallback={onAddFallbackMock}
/>
);
// Verify that the edit recall button appears (indicating recall item is detected)
expect(screen.getByText("Edit Recall")).toBeInTheDocument();
// Click the "Edit Recall" button to trigger the fallback addition flow
await userEvent.click(screen.getByText("Edit Recall"));
// Since the mocked FallbackInput renders a simplified version,
// check if the fallback input interface is shown
const { FallbackInput } = await import(
"@/modules/survey/components/question-form-input/components/fallback-input"
);
const FallbackInputMock = vi.mocked(FallbackInput);
// If the FallbackInput is rendered, verify its state and simulate the fallback addition
if (FallbackInputMock.mock.calls.length > 0) {
// Get the functions from the mock call
const lastCall = FallbackInputMock.mock.calls[FallbackInputMock.mock.calls.length - 1][0];
const { addFallback, setFallbacks } = lastCall;
// Simulate user adding a fallback value
setFallbacks({ testId: "test fallback value" });
// Simulate clicking the "Add Fallback" button
addFallback();
// Verify that the component's state was updated through the callbacks
expect(onChangeMock).toHaveBeenCalled();
expect(onAddFallbackMock).toHaveBeenCalled();
// Verify that the final value reflects the fallback addition
const finalValue = onAddFallbackMock.mock.calls[0][0];
expect(finalValue).toContain("#recall:testId/fallback:");
expect(finalValue).toContain("test fallback value");
expect(finalValue).toContain("# inside");
} else {
// Verify that the component is in a state that would allow fallback addition
expect(screen.getByText("Edit Recall")).toBeInTheDocument();
// Verify that the callbacks are configured and would handle fallback addition
expect(onChangeMock).toBeDefined();
expect(onAddFallbackMock).toBeDefined();
// Simulate the expected behavior of fallback addition
// This tests that the component would handle fallback addition correctly
const simulatedFallbackValue = "Test with #recall:testId/fallback:test fallback value# inside";
onAddFallbackMock(simulatedFallbackValue);
// Verify that the simulated fallback value has the correct structure
expect(onAddFallbackMock).toHaveBeenCalledWith(simulatedFallbackValue);
expect(simulatedFallbackValue).toContain("#recall:testId/fallback:");
expect(simulatedFallbackValue).toContain("test fallback value");
expect(simulatedFallbackValue).toContain("# inside");
}
});
test("displays error when trying to add empty recall item", async () => {
@@ -197,27 +263,37 @@ describe("RecallWrapper", () => {
expect(screen.getByTestId("recall-select-visible").textContent).toBe("false");
});
test("renders recall items when value contains recall syntax", () => {
test("shows edit recall button when value contains recall syntax", () => {
const valueWithRecall = "Test with #recall:testId/fallback:# inside";
const recallItems = [{ id: "testId", label: "testLabel", type: "question" }] as TSurveyRecallItem[];
vi.mocked(recallUtils.getRecallItems).mockReturnValue(recallItems);
render(<RecallWrapper {...defaultProps} value={valueWithRecall} />);
// Verify that recall items are detected and rendered
expect(recallUtils.getRecallItems).toHaveBeenCalledWith(valueWithRecall, expect.any(Object), "en");
expect(screen.getByText("Edit Recall")).toBeInTheDocument();
});
test("handles recall item state changes", () => {
test("edit recall button toggles visibility state", async () => {
const valueWithRecall = "Test with #recall:testId/fallback:# inside";
const recallItems = [{ id: "testId", label: "testLabel", type: "question" }] as TSurveyRecallItem[];
vi.mocked(recallUtils.getRecallItems).mockReturnValue(recallItems);
render(<RecallWrapper {...defaultProps} value={valueWithRecall} />);
// Verify that recall items are detected
expect(recallUtils.getRecallItems).toHaveBeenCalledWith(valueWithRecall, expect.any(Object), "en");
const editButton = screen.getByText("Edit Recall");
// Verify the edit button is functional and clickable
expect(editButton).toBeInTheDocument();
expect(editButton).toBeEnabled();
// Click the "Edit Recall" button - this should work without errors
await userEvent.click(editButton);
// The button should still be present and functional after clicking
expect(editButton).toBeInTheDocument();
expect(editButton).toBeEnabled();
// Click again to verify the button can be clicked multiple times
await userEvent.click(editButton);
// Button should still be functional
expect(editButton).toBeInTheDocument();
expect(editButton).toBeEnabled();
});
});

View File

@@ -1,10 +1,5 @@
"use client";
import { useTranslate } from "@tolgee/react";
import { PencilIcon } from "lucide-react";
import React, { JSX, ReactNode, useCallback, useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { TSurvey, TSurveyRecallItem } from "@formbricks/types/surveys/types";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import {
extractId,
@@ -19,6 +14,11 @@ import {
import { FallbackInput } from "@/modules/survey/components/question-form-input/components/fallback-input";
import { RecallItemSelect } from "@/modules/survey/components/question-form-input/components/recall-item-select";
import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react";
import { PencilIcon } from "lucide-react";
import React, { JSX, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { TSurvey, TSurveyRecallItem } from "@formbricks/types/surveys/types";
interface RecallWrapperRenderProps {
value: string;
@@ -61,19 +61,16 @@ export const RecallWrapper = ({
const [internalValue, setInternalValue] = useState<string>(headlineToRecall(value, recallItems, fallbacks));
const [renderedText, setRenderedText] = useState<JSX.Element[]>([]);
const fallbackInputRef = useRef<HTMLInputElement>(null);
const hasRecallItems = useMemo(() => {
return recallItems.length > 0 || value?.includes("recall:");
}, [recallItems.length, value]);
useEffect(() => {
setInternalValue(headlineToRecall(value, recallItems, fallbacks));
}, [value, recallItems, fallbacks]);
// Update recall items when value changes
useEffect(() => {
if (value?.includes("#recall:")) {
const newRecallItems = getRecallItems(value, localSurvey, usedLanguageCode);
setRecallItems(newRecallItems);
}
}, [value, localSurvey, usedLanguageCode]);
const checkForRecallSymbol = useCallback((str: string) => {
// Get cursor position by finding last character
// Only trigger when @ is the last character typed
@@ -181,6 +178,12 @@ export const RecallWrapper = ({
[fallbacks, internalValue, onChange, recallItems, setInternalValue]
);
useEffect(() => {
if (showFallbackInput && fallbackInputRef.current) {
fallbackInputRef.current.focus();
}
}, [showFallbackInput]);
useEffect(() => {
const recallItemLabels = recallItems.flatMap((recallItem) => {
if (!recallItem.label.includes("#recall:")) {
@@ -252,6 +255,20 @@ export const RecallWrapper = ({
isRecallSelectVisible: showRecallItemSelect,
children: (
<div>
{hasRecallItems && (
<Button
variant="ghost"
type="button"
className="absolute right-2 top-full z-[1] flex h-6 cursor-pointer items-center rounded-b-lg rounded-t-none bg-slate-100 px-2.5 py-0 text-xs hover:bg-slate-200"
onClick={(e) => {
e.preventDefault();
setShowFallbackInput(!showFallbackInput);
}}>
{t("environments.surveys.edit.edit_recall")}
<PencilIcon className="h-3 w-3" />
</Button>
)}
{showRecallItemSelect && (
<RecallItemSelect
localSurvey={localSurvey}
@@ -264,23 +281,15 @@ export const RecallWrapper = ({
/>
)}
{recallItems.length > 0 && (
{showFallbackInput && recallItems.length > 0 && (
<FallbackInput
filteredRecallItems={recallItems}
fallbacks={fallbacks}
setFallbacks={setFallbacks}
fallbackInputRef={fallbackInputRef as React.RefObject<HTMLInputElement>}
addFallback={addFallback}
open={showFallbackInput}
setOpen={setShowFallbackInput}
triggerButton={
<Button
variant="ghost"
type="button"
className="absolute right-2 top-full z-[1] flex h-6 cursor-pointer items-center rounded-b-lg rounded-t-none bg-slate-100 px-2.5 py-0 text-xs hover:bg-slate-200">
{t("environments.surveys.edit.edit_recall")}
<PencilIcon className="h-3 w-3" />
</Button>
}
/>
)}
</div>

View File

@@ -1,19 +1,5 @@
"use client";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useTranslate } from "@tolgee/react";
import { debounce } from "lodash";
import { ImagePlusIcon, TrashIcon } from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react";
import {
TI18nString,
TSurvey,
TSurveyEndScreenCard,
TSurveyQuestion,
TSurveyQuestionChoice,
TSurveyRedirectUrlCard,
} from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { useSyncScroll } from "@/lib/utils/hooks/useSyncScroll";
import { recallToHeadline } from "@/lib/utils/recall";
@@ -24,6 +10,20 @@ import { FileInput } from "@/modules/ui/components/file-input";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useTranslate } from "@tolgee/react";
import { debounce } from "lodash";
import { ImagePlusIcon, TrashIcon } from "lucide-react";
import { RefObject, useCallback, useMemo, useRef, useState } from "react";
import {
TI18nString,
TSurvey,
TSurveyEndScreenCard,
TSurveyQuestion,
TSurveyQuestionChoice,
TSurveyRedirectUrlCard,
} from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import {
determineImageUploaderVisibility,
getChoiceLabel,
@@ -50,6 +50,7 @@ interface QuestionFormInputProps {
label: string;
maxLength?: number;
placeholder?: string;
ref?: RefObject<HTMLInputElement | null>;
onBlur?: React.FocusEventHandler<HTMLInputElement>;
className?: string;
locale: TUserLocale;
@@ -346,7 +347,6 @@ export const QuestionFormInput = ({
fileUrl={getFileUrl()}
videoUrl={getVideoUrl()}
isVideoAllowed={true}
maxSizeInMB={5}
isStorageConfigured={isStorageConfigured}
/>
)}

View File

@@ -1,19 +1,5 @@
"use client";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { createId } from "@paralleldrive/cuid2";
import { useTranslate } from "@tolgee/react";
import {
ArrowDownIcon,
ArrowUpIcon,
CopyIcon,
EllipsisVerticalIcon,
PlusIcon,
SplitIcon,
TrashIcon,
} from "lucide-react";
import { useEffect, useMemo } from "react";
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
import { duplicateLogicItem } from "@/lib/surveyLogic/utils";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { LogicEditor } from "@/modules/survey/editor/components/logic-editor";
@@ -29,6 +15,20 @@ import {
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Label } from "@/modules/ui/components/label";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { createId } from "@paralleldrive/cuid2";
import { useTranslate } from "@tolgee/react";
import {
ArrowDownIcon,
ArrowUpIcon,
CopyIcon,
EllipsisVerticalIcon,
PlusIcon,
SplitIcon,
TrashIcon,
} from "lucide-react";
import { useEffect, useMemo } from "react";
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
interface ConditionalLogicProps {
localSurvey: TSurvey;

View File

@@ -1,12 +1,12 @@
"use client";
import { LocalizedEditor } from "@/modules/ee/multi-language-surveys/components/localized-editor";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { Label } from "@/modules/ui/components/label";
import { useTranslate } from "@tolgee/react";
import { type JSX, useState } from "react";
import { TSurvey, TSurveyConsentQuestion } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { LocalizedEditor } from "@/modules/ee/multi-language-surveys/components/localized-editor";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { Label } from "@/modules/ui/components/label";
interface ConsentQuestionFormProps {
localSurvey: TSurvey;
@@ -64,7 +64,6 @@ export const ConsentQuestionForm = ({
setFirstRender={setFirstRender}
questionIdx={questionIdx}
locale={locale}
questionId={question.id}
/>
</div>
</div>

View File

@@ -1,15 +1,15 @@
"use client";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useTranslate } from "@tolgee/react";
import { type JSX, useState } from "react";
import { TSurvey, TSurveyCTAQuestion } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { LocalizedEditor } from "@/modules/ee/multi-language-surveys/components/localized-editor";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { OptionsSwitch } from "@/modules/ui/components/options-switch";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useTranslate } from "@tolgee/react";
import { type JSX, useState } from "react";
import { TSurvey, TSurveyCTAQuestion } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
interface CTAQuestionFormProps {
localSurvey: TSurvey;
@@ -77,7 +77,6 @@ export const CTAQuestionForm = ({
setFirstRender={setFirstRender}
questionIdx={questionIdx}
locale={locale}
questionId={question.id}
/>
</div>
</div>

View File

@@ -1,5 +1,11 @@
"use client";
import { cn } from "@/lib/cn";
import { LocalizedEditor } from "@/modules/ee/multi-language-surveys/components/localized-editor";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { FileInput } from "@/modules/ui/components/file-input";
import { Label } from "@/modules/ui/components/label";
import { Switch } from "@/modules/ui/components/switch";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useTranslate } from "@tolgee/react";
import { Hand } from "lucide-react";
@@ -7,12 +13,6 @@ import { usePathname } from "next/navigation";
import { useState } from "react";
import { TSurvey, TSurveyQuestionId, TSurveyWelcomeCard } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { cn } from "@/lib/cn";
import { LocalizedEditor } from "@/modules/ee/multi-language-surveys/components/localized-editor";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { FileInput } from "@/modules/ui/components/file-input";
import { Label } from "@/modules/ui/components/label";
import { Switch } from "@/modules/ui/components/switch";
interface EditWelcomeCardProps {
localSurvey: TSurvey;
@@ -156,7 +156,6 @@ export const EditWelcomeCard = ({
setFirstRender={setFirstRender}
questionIdx={-1}
locale={locale}
questionId="start"
/>
</div>
</div>

View File

@@ -3,20 +3,11 @@ import userEvent from "@testing-library/user-event";
import { toast } from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey } from "@formbricks/types/surveys/types";
import { extractRecallInfo } from "@/lib/utils/recall";
import { findHiddenFieldUsedInLogic, isUsedInQuota, isUsedInRecall } from "@/modules/survey/editor/lib/utils";
import { HiddenFieldsCard } from "./hidden-fields-card";
// Mock the Tag component to avoid rendering its internal logic
vi.mock("@/modules/ui/components/tag", () => ({
Tag: ({ tagName, onDelete }: { tagName: string; onDelete: (fieldId: string) => void }) => (
<div>
{tagName}
<button onClick={() => onDelete(tagName)} aria-label={`Delete ${tagName}`}>
Delete
</button>
</div>
),
Tag: ({ tagName }: { tagName: string }) => <div>{tagName}</div>,
}));
// Mock window.matchMedia
@@ -38,21 +29,9 @@ vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: () => [null],
}));
// Mock the recall utility functions
vi.mock("@/lib/utils/recall", () => ({
extractRecallInfo: vi.fn(),
}));
vi.mock("@/modules/survey/editor/lib/utils", () => ({
findHiddenFieldUsedInLogic: vi.fn(),
isUsedInQuota: vi.fn(),
isUsedInRecall: vi.fn(),
}));
describe("HiddenFieldsCard", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("should render all hidden fields when localSurvey.hiddenFields.fieldIds is populated", () => {
@@ -79,7 +58,6 @@ describe("HiddenFieldsCard", () => {
setLocalSurvey={vi.fn()}
activeQuestionId={"hidden"}
setActiveQuestionId={vi.fn()}
quotas={[]}
/>
);
@@ -111,7 +89,6 @@ describe("HiddenFieldsCard", () => {
setLocalSurvey={vi.fn()}
activeQuestionId={"hidden"}
setActiveQuestionId={vi.fn()}
quotas={[]}
/>
);
@@ -145,7 +122,6 @@ describe("HiddenFieldsCard", () => {
setLocalSurvey={setLocalSurvey}
activeQuestionId={"hidden"}
setActiveQuestionId={vi.fn()}
quotas={[]}
/>
);
@@ -193,7 +169,6 @@ describe("HiddenFieldsCard", () => {
setLocalSurvey={setLocalSurveyMock}
activeQuestionId={"hidden"}
setActiveQuestionId={vi.fn()}
quotas={[]}
/>
);
@@ -211,303 +186,4 @@ describe("HiddenFieldsCard", () => {
expect(toastErrorSpy).toHaveBeenCalled();
expect(setLocalSurveyMock).not.toHaveBeenCalled();
});
describe("Recall Functionality", () => {
const createMockSurveyWithRecall = (fieldId: string) =>
({
id: "survey1",
name: "Test Survey",
welcomeCard: { enabled: false, headline: {} } as unknown as TSurvey["welcomeCard"],
questions: [
{
id: "question1",
headline: { en: `Question with #recall:${fieldId}/fallback:default#` },
type: "shortText",
},
],
endings: [],
hiddenFields: {
enabled: true,
fieldIds: [fieldId],
},
followUps: [],
type: "link",
createdAt: new Date("2024-01-01T00:00:00.000Z"),
updatedAt: new Date("2024-01-01T00:00:00.000Z"),
languages: [],
}) as unknown as TSurvey;
test("should remove recall info from question headlines when deleting hidden field", () => {
const fieldId = "testField";
const localSurvey = createMockSurveyWithRecall(fieldId);
const setLocalSurvey = vi.fn();
// Mock extractRecallInfo to return the recall pattern
vi.mocked(extractRecallInfo).mockReturnValue(`#recall:${fieldId}/fallback:default#`);
// Mock the utility functions to allow deletion
vi.mocked(findHiddenFieldUsedInLogic).mockReturnValue(-1);
vi.mocked(isUsedInRecall).mockReturnValue(-1);
vi.mocked(isUsedInQuota).mockReturnValue(false);
render(
<HiddenFieldsCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
activeQuestionId={"hidden"}
setActiveQuestionId={vi.fn()}
quotas={[]}
/>
);
// Find and click the delete button for the hidden field
const deleteButton = screen.getByLabelText(`Delete ${fieldId}`);
fireEvent.click(deleteButton);
expect(setLocalSurvey).toHaveBeenCalledWith(
expect.objectContaining({
questions: expect.arrayContaining([
expect.objectContaining({
headline: { en: "Question with " }, // Recall info should be removed
}),
]),
hiddenFields: expect.objectContaining({
fieldIds: [], // Field should be removed
}),
})
);
});
test("should prevent deletion when hidden field is used in recall in welcome card", () => {
const fieldId = "testField";
const localSurvey = createMockSurveyWithRecall(fieldId);
const setLocalSurvey = vi.fn();
const toastErrorSpy = vi.mocked(toast.error);
// Mock findHiddenFieldUsedInLogic to return -1 (not found in logic)
vi.mocked(findHiddenFieldUsedInLogic).mockReturnValue(-1);
// Mock isUsedInRecall to return -2 (welcome card)
vi.mocked(isUsedInRecall).mockReturnValue(-2);
render(
<HiddenFieldsCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
activeQuestionId={"hidden"}
setActiveQuestionId={vi.fn()}
quotas={[]}
/>
);
// Find and click the delete button for the hidden field
const deleteButton = screen.getByLabelText(`Delete ${fieldId}`);
fireEvent.click(deleteButton);
expect(toastErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("environments.surveys.edit.hidden_field_used_in_recall_welcome")
);
expect(setLocalSurvey).not.toHaveBeenCalled();
});
test("should prevent deletion when hidden field is used in recall in ending card", () => {
const fieldId = "testField";
const localSurvey = createMockSurveyWithRecall(fieldId);
const setLocalSurvey = vi.fn();
const toastErrorSpy = vi.mocked(toast.error);
// Mock findHiddenFieldUsedInLogic to return -1 (not found in logic)
vi.mocked(findHiddenFieldUsedInLogic).mockReturnValue(-1);
// Mock isUsedInRecall to return questions.length (ending card)
vi.mocked(isUsedInRecall).mockReturnValue(localSurvey.questions.length);
render(
<HiddenFieldsCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
activeQuestionId={"hidden"}
setActiveQuestionId={vi.fn()}
quotas={[]}
/>
);
// Find and click the delete button for the hidden field
const deleteButton = screen.getByLabelText(`Delete ${fieldId}`);
fireEvent.click(deleteButton);
expect(toastErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("environments.surveys.edit.hidden_field_used_in_recall_ending_card")
);
expect(setLocalSurvey).not.toHaveBeenCalled();
});
test("should prevent deletion when hidden field is used in recall in question", () => {
const fieldId = "testField";
const localSurvey = createMockSurveyWithRecall(fieldId);
const setLocalSurvey = vi.fn();
const toastErrorSpy = vi.mocked(toast.error);
// Mock findHiddenFieldUsedInLogic to return -1 (not found in logic)
vi.mocked(findHiddenFieldUsedInLogic).mockReturnValue(-1);
// Mock isUsedInRecall to return question index
vi.mocked(isUsedInRecall).mockReturnValue(0);
render(
<HiddenFieldsCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
activeQuestionId={"hidden"}
setActiveQuestionId={vi.fn()}
quotas={[]}
/>
);
// Find and click the delete button for the hidden field
const deleteButton = screen.getByLabelText(`Delete ${fieldId}`);
fireEvent.click(deleteButton);
expect(toastErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("environments.surveys.edit.hidden_field_used_in_recall")
);
expect(setLocalSurvey).not.toHaveBeenCalled();
});
test("should handle multiple language codes when removing recall info", () => {
const fieldId = "testField";
const localSurvey = {
id: "survey1",
name: "Test Survey",
welcomeCard: { enabled: false, headline: {} } as unknown as TSurvey["welcomeCard"],
questions: [
{
id: "question1",
headline: {
en: `Question with #recall:${fieldId}/fallback:default#`,
es: `Pregunta con #recall:${fieldId}/fallback:default#`,
},
type: "shortText",
},
],
endings: [],
hiddenFields: {
enabled: true,
fieldIds: [fieldId],
},
followUps: [],
type: "link",
createdAt: new Date("2024-01-01T00:00:00.000Z"),
updatedAt: new Date("2024-01-01T00:00:00.000Z"),
languages: [],
} as unknown as TSurvey;
const setLocalSurvey = vi.fn();
// Mock extractRecallInfo to return the recall pattern
vi.mocked(extractRecallInfo).mockReturnValue(`#recall:${fieldId}/fallback:default#`);
render(
<HiddenFieldsCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
activeQuestionId={"hidden"}
setActiveQuestionId={vi.fn()}
quotas={[]}
/>
);
// Mock the utility functions to allow deletion
vi.mocked(findHiddenFieldUsedInLogic).mockReturnValue(-1);
vi.mocked(isUsedInRecall).mockReturnValue(-1);
vi.mocked(isUsedInQuota).mockReturnValue(false);
// Find and click the delete button for the hidden field
const deleteButton = screen.getByLabelText(`Delete ${fieldId}`);
fireEvent.click(deleteButton);
expect(setLocalSurvey).toHaveBeenCalledWith(
expect.objectContaining({
questions: expect.arrayContaining([
expect.objectContaining({
headline: {
en: "Question with ",
es: "Pregunta con ",
}, // Recall info should be removed from both languages
}),
]),
})
);
});
test("should not remove recall info when extractRecallInfo returns null", () => {
const fieldId = "testField";
const localSurvey = createMockSurveyWithRecall(fieldId);
const setLocalSurvey = vi.fn();
// Mock extractRecallInfo to return null
vi.mocked(extractRecallInfo).mockReturnValue(null);
render(
<HiddenFieldsCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
activeQuestionId={"hidden"}
setActiveQuestionId={vi.fn()}
quotas={[]}
/>
);
// Mock the utility functions to allow deletion
vi.mocked(findHiddenFieldUsedInLogic).mockReturnValue(-1);
vi.mocked(isUsedInRecall).mockReturnValue(-1);
vi.mocked(isUsedInQuota).mockReturnValue(false);
// Find and click the delete button for the hidden field
const deleteButton = screen.getByLabelText(`Delete ${fieldId}`);
fireEvent.click(deleteButton);
expect(setLocalSurvey).toHaveBeenCalledWith(
expect.objectContaining({
questions: expect.arrayContaining([
expect.objectContaining({
headline: { en: `Question with #recall:${fieldId}/fallback:default#` }, // Recall info should remain
}),
]),
})
);
});
test("should handle deletion when hidden field is not used in recall", () => {
const fieldId = "testField";
const localSurvey = createMockSurveyWithRecall(fieldId);
const setLocalSurvey = vi.fn();
// Mock findHiddenFieldUsedInLogic to return -1 (not found in logic)
vi.mocked(findHiddenFieldUsedInLogic).mockReturnValue(-1);
// Mock isUsedInRecall to return -1 (not found)
vi.mocked(isUsedInRecall).mockReturnValue(-1);
// Mock isUsedInQuota to return false (not used in quota)
vi.mocked(isUsedInQuota).mockReturnValue(false);
render(
<HiddenFieldsCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
activeQuestionId={"hidden"}
setActiveQuestionId={vi.fn()}
quotas={[]}
/>
);
// Find and click the delete button for the hidden field
const deleteButton = screen.getByLabelText(`Delete ${fieldId}`);
fireEvent.click(deleteButton);
expect(setLocalSurvey).toHaveBeenCalledWith(
expect.objectContaining({
hiddenFields: expect.objectContaining({
fieldIds: [], // Field should be removed
}),
})
);
});
});
});

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