Compare commits

...

42 Commits

Author SHA1 Message Date
pandeymangg a9db89ecdd fix: rollback merge 2025-08-25 13:43:53 +05:30
pandeymangg 0155c41593 fix: adds deleteFilesByPrefix service 2025-08-25 12:42:18 +05:30
pandeymangg df63f2e5d9 Merge branch 'main' into feat/storage-package 2025-08-25 11:42:57 +05:30
Matti Nannt a3764f0316 chore: increase data migration timeout to 600s (#6455) 2025-08-21 21:46:30 +02:00
pandeymangg 7dd174ffea fix: adds maxSize 2025-08-21 22:10:33 +05:30
pandeymangg 7154f6fe74 fix: feedback 2025-08-21 21:53:03 +05:30
pandeymangg f25f257f24 fix: jsdoc comments 2025-08-21 18:08:06 +05:30
pandeymangg b945900fbf fix 2025-08-21 18:04:50 +05:30
pandeymangg f8869e7522 reverts package versions 2025-08-21 18:02:05 +05:30
pandeymangg 886eb8598a fix: adds file existence check 2025-08-21 17:56:13 +05:30
pandeymangg fe3c8e010f fix 2025-08-21 17:07:54 +05:30
pandeymangg a6a76cc3cf adds cursor rules 2025-08-21 17:06:54 +05:30
pandeymangg 9e7a4e38cf feedback 2025-08-21 16:51:35 +05:30
Piyush Gupta ec52bdf3fe feat: adds stories for logo component (#6448) 2025-08-20 14:57:43 +00:00
pandeymangg 9cff5457d6 fixes 2025-08-20 17:28:05 +05:30
Victor Hugo dos Santos 2e9ad3ce07 fix: community PR check 6400 (#6427)
Co-authored-by: Alex <alexander.seliakov@gmail.com>
2025-08-20 09:04:48 +00:00
Matti Nannt 654bd232d6 chore(cursor): add monorepo overview rule and refine database rule metadata (#6444) 2025-08-20 07:29:03 +00:00
pandeymangg a362455878 adds storage package 2025-08-20 12:23:39 +05:30
Piyush Gupta 01984cf8ca chore: upgrades prisma to latest version (#6442) 2025-08-19 14:40:31 +00:00
Anshuman Pandey 3eb18bb120 fix: alert message on invalid file in file upload question (#6431) 2025-08-19 12:42:31 +00:00
Piyush Gupta 59859d0e4f fix: organization access checks (#6441) 2025-08-19 11:23:59 +00:00
Piyush Gupta c60c8cb7bd feat: adds stories for tooltip component (#6433) 2025-08-19 07:48:46 +00:00
Matti Nannt 9fa7aef253 chore: upgrade node-alpine image to 3.22 (#6437) 2025-08-19 07:45:46 +00:00
Piyush Gupta a23594428a fix: color picker in product logo (#6434)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-08-19 07:14:23 +00:00
Piyush Gupta 56e7106d6e fix: open text number question logic evaluation (#6439) 2025-08-19 06:03:49 +00:00
Matti Nannt 318f891540 fix: ECR workflow action failing (#6435) 2025-08-18 16:58:30 +02:00
Piyush Gupta a59881f9ae feat: adds drag and drop to matrix question fields (#6386) 2025-08-18 14:38:53 +00:00
Matti Nannt 7ab4a45ad6 feat: add ecr workflow (#6414) 2025-08-18 15:17:14 +02:00
Matti Nannt 2990e3805f fix: docker security scan action not uploading results (#6432) 2025-08-18 13:54:58 +02:00
Dhruwang Jariwala 29132ab029 fix: metadata issue (#6422)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-08-15 09:50:15 +00:00
Dhruwang Jariwala f860d8d25d fix: link preview settings tweaks (#6418) 2025-08-14 15:48:05 +00:00
Anshuman Pandey 3501990a79 fix: syntax issue in docker release action (#6415) 2025-08-14 12:12:42 +00:00
Piyush Gupta 41d60c8a02 chore: custom avatar removal (#6408)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-08-14 10:17:05 +00:00
Anshuman Pandey a6269f0fd3 fix: disables share tabs when single use is active (#6410) 2025-08-14 08:49:15 +00:00
Dhruwang Jariwala 9c0d0a16a7 fix: hover on survey close button (#6405) 2025-08-14 08:11:15 +00:00
Piyush Gupta c6241f7e7f fix: Inconsistent icon - Picture select vs. question header image (#6409) 2025-08-13 13:09:23 +00:00
Piotr Gaczkowski 92f1c2b75a fix: make terraform apply work again (#6403)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-08-13 12:19:18 +00:00
Dhruwang Jariwala 4d53291c8a fix: checks and rate limiting for email verification survey action (#6406) 2025-08-13 06:42:08 +00:00
Matti Nannt 14b7a69cea fix: permissions in release workflow (#6399) 2025-08-13 08:35:26 +02:00
Piyush Gupta a9015b008d docs: adds identifier note in saml sso docs (#6402) 2025-08-12 11:18:44 +00:00
Dhruwang Jariwala d19d624c0c feat: filters for url in metadata (#6387)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-08-12 09:37:12 +00:00
Matti Nannt 3edaab6c2b fix: release workflow environment is not accessible (#6398) 2025-08-12 10:31:05 +02:00
143 changed files with 5945 additions and 1632 deletions
+20 -11
View File
@@ -7,6 +7,7 @@ description: >
globs: [] globs: []
alwaysApply: agent-requested alwaysApply: agent-requested
--- ---
# Formbricks Database Schema Reference # Formbricks Database Schema Reference
This rule provides a reference to the Formbricks database structure. For the most up-to-date and complete schema definitions, please refer to the schema.prisma file directly. This rule provides a reference to the Formbricks database structure. For the most up-to-date and complete schema definitions, please refer to the schema.prisma file directly.
@@ -16,6 +17,7 @@ This rule provides a reference to the Formbricks database structure. For the mos
Formbricks uses PostgreSQL with Prisma ORM. The schema is designed for multi-tenancy with strong data isolation between organizations. Formbricks uses PostgreSQL with Prisma ORM. The schema is designed for multi-tenancy with strong data isolation between organizations.
### Core Hierarchy ### Core Hierarchy
``` ```
Organization Organization
└── Project └── Project
@@ -29,6 +31,7 @@ Organization
## Schema Reference ## Schema Reference
For the complete and up-to-date database schema, please refer to: For the complete and up-to-date database schema, please refer to:
- Main schema: `packages/database/schema.prisma` - Main schema: `packages/database/schema.prisma`
- JSON type definitions: `packages/database/json-types.ts` - JSON type definitions: `packages/database/json-types.ts`
@@ -37,17 +40,22 @@ The schema.prisma file contains all model definitions, relationships, enums, and
## Data Access Patterns ## Data Access Patterns
### Multi-tenancy ### Multi-tenancy
- All data is scoped by Organization - All data is scoped by Organization
- Environment-level isolation for surveys and contacts - Environment-level isolation for surveys and contacts
- Project-level grouping for related surveys - Project-level grouping for related surveys
### Soft Deletion ### Soft Deletion
Some models use soft deletion patterns: Some models use soft deletion patterns:
- Check `isActive` fields where present - Check `isActive` fields where present
- Use proper filtering in queries - Use proper filtering in queries
### Cascading Deletes ### Cascading Deletes
Configured cascade relationships: Configured cascade relationships:
- Organization deletion cascades to all child entities - Organization deletion cascades to all child entities
- Survey deletion removes responses, displays, triggers - Survey deletion removes responses, displays, triggers
- Contact deletion removes attributes and responses - Contact deletion removes attributes and responses
@@ -55,6 +63,7 @@ Configured cascade relationships:
## Common Query Patterns ## Common Query Patterns
### Survey with Responses ### Survey with Responses
```typescript ```typescript
// Include response count and latest responses // Include response count and latest responses
const survey = await prisma.survey.findUnique({ const survey = await prisma.survey.findUnique({
@@ -62,40 +71,40 @@ const survey = await prisma.survey.findUnique({
include: { include: {
responses: { responses: {
take: 10, take: 10,
orderBy: { createdAt: 'desc' } orderBy: { createdAt: "desc" },
}, },
_count: { _count: {
select: { responses: true } select: { responses: true },
} },
} },
}); });
``` ```
### Environment Scoping ### Environment Scoping
```typescript ```typescript
// Always scope by environment // Always scope by environment
const surveys = await prisma.survey.findMany({ const surveys = await prisma.survey.findMany({
where: { where: {
environmentId: environmentId, environmentId: environmentId,
// Additional filters... // Additional filters...
} },
}); });
``` ```
### Contact with Attributes ### Contact with Attributes
```typescript ```typescript
const contact = await prisma.contact.findUnique({ const contact = await prisma.contact.findUnique({
where: { id: contactId }, where: { id: contactId },
include: { include: {
attributes: { attributes: {
include: { include: {
attributeKey: true attributeKey: true,
} },
} },
} },
}); });
``` ```
This schema supports Formbricks' core functionality: multi-tenant survey management, user targeting, response collection, and analysis, all while maintaining strict data isolation and security. This schema supports Formbricks' core functionality: multi-tenant survey management, user targeting, response collection, and analysis, all while maintaining strict data isolation and security.
+99
View File
@@ -0,0 +1,99 @@
name: Build & Push Docker to ECR
on:
workflow_dispatch:
inputs:
image_tag:
description: "Image tag to push (e.g., v3.16.1)"
required: true
default: "v3.16.1"
permissions:
contents: read
id-token: write
env:
ECR_REGION: ${{ vars.ECR_REGION }}
# ECR settings are sourced from repository/environment variables for portability across envs/forks
ECR_REGISTRY: ${{ vars.ECR_REGISTRY }}
ECR_REPOSITORY: ${{ vars.ECR_REPOSITORY }}
DOCKERFILE: apps/web/Dockerfile
CONTEXT: .
jobs:
build-and-push:
name: Build and Push
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Validate image tag input
shell: bash
env:
IMAGE_TAG: ${{ inputs.image_tag }}
run: |
set -euo pipefail
if [[ -z "${IMAGE_TAG}" ]]; then
echo "❌ Image tag is required (non-empty)."
exit 1
fi
if (( ${#IMAGE_TAG} > 128 )); then
echo "❌ Image tag must be at most 128 characters."
exit 1
fi
if [[ ! "${IMAGE_TAG}" =~ ^[a-z0-9._-]+$ ]]; then
echo "❌ Image tag may only contain lowercase letters, digits, '.', '_' and '-'."
exit 1
fi
if [[ "${IMAGE_TAG}" =~ ^[.-] || "${IMAGE_TAG}" =~ [.-]$ ]]; then
echo "❌ Image tag must not start or end with '.' or '-'."
exit 1
fi
- name: Validate required variables
shell: bash
env:
ECR_REGISTRY: ${{ env.ECR_REGISTRY }}
ECR_REPOSITORY: ${{ env.ECR_REPOSITORY }}
ECR_REGION: ${{ env.ECR_REGION }}
run: |
set -euo pipefail
if [[ -z "${ECR_REGISTRY}" || -z "${ECR_REPOSITORY}" || -z "${ECR_REGION}" ]]; then
echo "ECR_REGION, ECR_REGISTRY and ECR_REPOSITORY must be set via repository or environment variables (Settings → Variables)."
exit 1
fi
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a
with:
role-to-assume: ${{ secrets.AWS_ECR_PUSH_ROLE_ARN }}
aws-region: ${{ env.ECR_REGION }}
- name: Log in to Amazon ECR
uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076
- name: Set up Depot CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
- name: Build and push image (Depot remote builder)
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
with:
project: tw0fqmsx3c
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
context: ${{ env.CONTEXT }}
file: ${{ env.DOCKERFILE }}
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ inputs.image_tag }}
${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:latest
secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
@@ -37,7 +37,7 @@ on:
permissions: permissions:
id-token: write id-token: write
contents: write contents: read
jobs: jobs:
helmfile-deploy: helmfile-deploy:
+31 -1
View File
@@ -17,7 +17,34 @@ jobs:
scan: scan:
name: Vulnerability Scan name: Vulnerability Scan
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30
steps: steps:
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout (for SARIF fingerprinting only)
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 1
- name: Determine ref and commit for upload
id: gitref
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
set -euo pipefail
if [[ "${EVENT_NAME}" == "workflow_run" ]]; then
echo "ref=refs/heads/${HEAD_BRANCH}" >> "$GITHUB_OUTPUT"
echo "sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT"
else
echo "ref=${GITHUB_REF}" >> "$GITHUB_OUTPUT"
echo "sha=${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
fi
- name: Log in to GitHub Container Registry - name: Log in to GitHub Container Registry
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with: with:
@@ -35,6 +62,9 @@ jobs:
- name: Upload Trivy scan results to GitHub Security tab - name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@a4e1a019f5e24960714ff6296aee04b736cbc3cf # v3.29.6 uses: github/codeql-action/upload-sarif@a4e1a019f5e24960714ff6296aee04b736cbc3cf # v3.29.6
if: ${{ always() && hashFiles('trivy-results.sarif') != '' }} if: ${{ always() }}
with: with:
sarif_file: "trivy-results.sarif" sarif_file: "trivy-results.sarif"
ref: ${{ steps.gitref.outputs.ref }}
sha: ${{ steps.gitref.outputs.sha }}
category: "trivy-container-scan"
+12 -5
View File
@@ -7,12 +7,13 @@ on:
permissions: permissions:
contents: read contents: read
env:
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
jobs: jobs:
docker-build: docker-build:
name: Build & release docker image name: Build & release docker image
permissions:
contents: read
packages: write
id-token: write
uses: ./.github/workflows/release-docker-github.yml uses: ./.github/workflows/release-docker-github.yml
secrets: inherit secrets: inherit
with: with:
@@ -20,6 +21,9 @@ jobs:
helm-chart-release: helm-chart-release:
name: Release Helm Chart name: Release Helm Chart
permissions:
contents: read
packages: write
uses: ./.github/workflows/release-helm-chart.yml uses: ./.github/workflows/release-helm-chart.yml
secrets: inherit secrets: inherit
needs: needs:
@@ -29,6 +33,9 @@ jobs:
deploy-formbricks-cloud: deploy-formbricks-cloud:
name: Deploy Helm Chart to Formbricks Cloud name: Deploy Helm Chart to Formbricks Cloud
permissions:
contents: read
id-token: write
secrets: inherit secrets: inherit
uses: ./.github/workflows/deploy-formbricks-cloud.yml uses: ./.github/workflows/deploy-formbricks-cloud.yml
needs: needs:
@@ -36,7 +43,7 @@ jobs:
- helm-chart-release - helm-chart-release
with: with:
VERSION: v${{ needs.docker-build.outputs.VERSION }} VERSION: v${{ needs.docker-build.outputs.VERSION }}
ENVIRONMENT: ${{ env.ENVIRONMENT }} ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
upload-sentry-sourcemaps: upload-sentry-sourcemaps:
name: Upload Sentry Sourcemaps name: Upload Sentry Sourcemaps
@@ -64,4 +71,4 @@ jobs:
docker_image: ghcr.io/formbricks/formbricks:v${{ needs.docker-build.outputs.VERSION }} docker_image: ghcr.io/formbricks/formbricks:v${{ needs.docker-build.outputs.VERSION }}
release_version: v${{ needs.docker-build.outputs.VERSION }} release_version: v${{ needs.docker-build.outputs.VERSION }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }} sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
environment: ${{ env.ENVIRONMENT }} environment: ${{ github.event.release.prerelease && 'staging' || 'production' }}
+1 -1
View File
@@ -109,7 +109,7 @@ jobs:
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}} type=semver,pattern={{major}}
# Only tag as 'latest' for stable releases (not prereleases) # Only tag as 'latest' for stable releases (not prereleases)
type=raw,value=latest,enable=${{ inputs.IS_PRERELEASE != 'true' }} type=raw,value=latest,enable=${{ !inputs.IS_PRERELEASE }}
# Build and push Docker image with Buildx (don't push on PR) # Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action # https://github.com/docker/build-push-action
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:22-alpine3.21 AS base FROM node:22-alpine3.22 AS base
# #
## step 1: Prune monorepo ## step 1: Prune monorepo
@@ -45,7 +45,7 @@ afterEach(() => {
}); });
describe("LandingSidebar component", () => { describe("LandingSidebar component", () => {
const user = { id: "u1", name: "Alice", email: "alice@example.com", imageUrl: "" } as any; const user = { id: "u1", name: "Alice", email: "alice@example.com" } as any;
const organization = { id: "o1", name: "orgOne" } as any; const organization = { id: "o1", name: "orgOne" } as any;
const organizations = [ const organizations = [
{ id: "o2", name: "betaOrg" }, { id: "o2", name: "betaOrg" },
@@ -82,7 +82,7 @@ export const LandingSidebar = ({
id="userDropdownTrigger" id="userDropdownTrigger"
className="w-full rounded-br-xl border-t p-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none"> className="w-full rounded-br-xl border-t p-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
<div tabIndex={0} className={cn("flex cursor-pointer flex-row items-center gap-3")}> <div tabIndex={0} className={cn("flex cursor-pointer flex-row items-center gap-3")}>
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} /> <ProfileAvatar userId={user.id} />
<> <>
<div className="grow overflow-hidden"> <div className="grow overflow-hidden">
<p <p
@@ -113,7 +113,6 @@ const mockUser = {
name: "Test User", name: "Test User",
email: "test@example.com", email: "test@example.com",
emailVerified: new Date(), emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false, twoFactorEnabled: false,
identityProvider: "email", identityProvider: "email",
createdAt: new Date(), createdAt: new Date(),
@@ -111,7 +111,6 @@ const mockUser = {
id: "user1", id: "user1",
name: "Test User", name: "Test User",
email: "test@example.com", email: "test@example.com",
imageUrl: "http://example.com/avatar.png",
emailVerified: new Date(), emailVerified: new Date(),
twoFactorEnabled: false, twoFactorEnabled: false,
identityProvider: "email", identityProvider: "email",
@@ -342,7 +342,7 @@ export const MainNavigation = ({
"flex cursor-pointer flex-row items-center gap-3", "flex cursor-pointer flex-row items-center gap-3",
isCollapsed ? "justify-center px-2" : "px-4" isCollapsed ? "justify-center px-2" : "px-4"
)}> )}>
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} /> <ProfileAvatar userId={user.id} />
{!isCollapsed && !isTextVisible && ( {!isCollapsed && !isTextVisible && (
<> <>
<div <div
@@ -37,7 +37,6 @@ describe("EnvironmentPage", () => {
id: mockUserId, id: mockUserId,
name: "Test User", name: "Test User",
email: "test@example.com", email: "test@example.com",
imageUrl: "",
twoFactorEnabled: false, twoFactorEnabled: false,
identityProvider: "email", identityProvider: "email",
createdAt: new Date(), createdAt: new Date(),
@@ -5,8 +5,6 @@ import {
verifyUserPassword, verifyUserPassword,
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user"; } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants"; import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
import { deleteFile } from "@/lib/storage/service";
import { getFileNameWithIdFromUrl } from "@/lib/storage/utils";
import { getUser, updateUser } from "@/lib/user/service"; import { getUser, updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
@@ -15,8 +13,6 @@ import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email"; import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { AuthenticationError, AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors"; import { AuthenticationError, AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
import { import {
TUserPersonalInfoUpdateInput, TUserPersonalInfoUpdateInput,
@@ -97,58 +93,6 @@ export const updateUserAction = authenticatedActionClient.schema(ZUserPersonalIn
) )
); );
const ZUpdateAvatarAction = z.object({
avatarUrl: z.string(),
});
export const updateAvatarAction = authenticatedActionClient.schema(ZUpdateAvatarAction).action(
withAuditLogging(
"updated",
"user",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const oldObject = await getUser(ctx.user.id);
const result = await updateUser(ctx.user.id, { imageUrl: parsedInput.avatarUrl });
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
);
const ZRemoveAvatarAction = z.object({
environmentId: ZId,
});
export const removeAvatarAction = authenticatedActionClient.schema(ZRemoveAvatarAction).action(
withAuditLogging(
"updated",
"user",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const oldObject = await getUser(ctx.user.id);
const imageUrl = ctx.user.imageUrl;
if (!imageUrl) {
throw new Error("Image not found");
}
const fileName = getFileNameWithIdFromUrl(imageUrl);
if (!fileName) {
throw new Error("Invalid filename");
}
const deletionResult = await deleteFile(parsedInput.environmentId, "public", fileName);
if (!deletionResult.success) {
throw new Error("Deletion failed");
}
const result = await updateUser(ctx.user.id, { imageUrl: null });
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
);
export const resetPasswordAction = authenticatedActionClient.action( export const resetPasswordAction = authenticatedActionClient.action(
withAuditLogging( withAuditLogging(
"passwordReset", "passwordReset",
@@ -1,104 +0,0 @@
import * as profileActions from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions";
import * as fileUploadHooks from "@/app/lib/fileUpload";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Session } from "next-auth";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { EditProfileAvatarForm } from "./EditProfileAvatarForm";
vi.mock("@/modules/ui/components/avatars", () => ({
ProfileAvatar: ({ imageUrl }) => <div data-testid="profile-avatar">{imageUrl || "No Avatar"}</div>,
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({
refresh: vi.fn(),
}),
}));
vi.mock("@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions", () => ({
updateAvatarAction: vi.fn(),
removeAvatarAction: vi.fn(),
}));
vi.mock("@/app/lib/fileUpload", () => ({
handleFileUpload: vi.fn(),
}));
const mockSession: Session = {
user: { id: "user-id" },
expires: "session-expires-at",
};
const environmentId = "test-env-id";
describe("EditProfileAvatarForm", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
vi.mocked(profileActions.updateAvatarAction).mockResolvedValue({});
vi.mocked(profileActions.removeAvatarAction).mockResolvedValue({});
vi.mocked(fileUploadHooks.handleFileUpload).mockResolvedValue({
url: "new-avatar.jpg",
error: undefined,
});
});
test("renders correctly without an existing image", () => {
render(<EditProfileAvatarForm session={mockSession} environmentId={environmentId} imageUrl={null} />);
expect(screen.getByTestId("profile-avatar")).toHaveTextContent("No Avatar");
expect(screen.getByText("environments.settings.profile.upload_image")).toBeInTheDocument();
expect(screen.queryByText("environments.settings.profile.remove_image")).not.toBeInTheDocument();
});
test("renders correctly with an existing image", () => {
render(
<EditProfileAvatarForm
session={mockSession}
environmentId={environmentId}
imageUrl="existing-avatar.jpg"
/>
);
expect(screen.getByTestId("profile-avatar")).toHaveTextContent("existing-avatar.jpg");
expect(screen.getByText("environments.settings.profile.change_image")).toBeInTheDocument();
expect(screen.getByText("environments.settings.profile.remove_image")).toBeInTheDocument();
});
test("handles image removal successfully", async () => {
render(
<EditProfileAvatarForm
session={mockSession}
environmentId={environmentId}
imageUrl="existing-avatar.jpg"
/>
);
const removeButton = screen.getByText("environments.settings.profile.remove_image");
await userEvent.click(removeButton);
await waitFor(() => {
expect(profileActions.removeAvatarAction).toHaveBeenCalledWith({ environmentId });
});
});
test("shows error if removeAvatarAction fails", async () => {
vi.mocked(profileActions.removeAvatarAction).mockRejectedValue(new Error("API error"));
render(
<EditProfileAvatarForm
session={mockSession}
environmentId={environmentId}
imageUrl="existing-avatar.jpg"
/>
);
const removeButton = screen.getByText("environments.settings.profile.remove_image");
await userEvent.click(removeButton);
await waitFor(() => {
expect(vi.mocked(toast.error)).toHaveBeenCalledWith(
"environments.settings.profile.avatar_update_failed"
);
});
});
});
@@ -1,178 +0,0 @@
"use client";
import {
removeAvatarAction,
updateAvatarAction,
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions";
import { handleFileUpload } from "@/app/lib/fileUpload";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import { FormError, FormField, FormItem, FormProvider } from "@/modules/ui/components/form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react";
import { Session } from "next-auth";
import { useRouter } from "next/navigation";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { z } from "zod";
interface EditProfileAvatarFormProps {
session: Session;
environmentId: string;
imageUrl: string | null;
}
export const EditProfileAvatarForm = ({ session, environmentId, imageUrl }: EditProfileAvatarFormProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const { t } = useTranslate();
const fileSchema =
typeof window !== "undefined"
? z
.instanceof(FileList)
.refine((files) => files.length === 1, t("environments.settings.profile.you_must_select_a_file"))
.refine((files) => {
const file = files[0];
const allowedTypes = ["image/jpeg", "image/png", "image/webp"];
return allowedTypes.includes(file.type);
}, t("environments.settings.profile.invalid_file_type"))
.refine((files) => {
const file = files[0];
const maxSize = 10 * 1024 * 1024;
return file.size <= maxSize;
}, t("environments.settings.profile.file_size_must_be_less_than_10mb"))
: z.any();
const formSchema = z.object({
file: fileSchema,
});
type FormValues = z.infer<typeof formSchema>;
const form = useForm<FormValues>({
mode: "onChange",
resolver: zodResolver(formSchema),
});
const handleUpload = async (file: File, environmentId: string) => {
setIsLoading(true);
try {
if (imageUrl) {
// If avatar image already exists, then remove it before update action
await removeAvatarAction({ environmentId });
}
const { url, error } = await handleFileUpload(file, environmentId);
if (error) {
toast.error(error);
setIsLoading(false);
return;
}
await updateAvatarAction({ avatarUrl: url });
router.refresh();
} catch (err) {
toast.error(t("environments.settings.profile.avatar_update_failed"));
setIsLoading(false);
}
setIsLoading(false);
};
const handleRemove = async () => {
setIsLoading(true);
try {
await removeAvatarAction({ environmentId });
} catch (err) {
toast.error(t("environments.settings.profile.avatar_update_failed"));
} finally {
setIsLoading(false);
form.reset();
}
};
const onSubmit = async (data: FormValues) => {
const file = data.file[0];
if (file) {
await handleUpload(file, environmentId);
}
};
return (
<div>
<div className="relative h-10 w-10 overflow-hidden rounded-full">
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-30">
<svg className="h-7 w-7 animate-spin text-slate-200" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
)}
<ProfileAvatar userId={session.user.id} imageUrl={imageUrl} />
</div>
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-4">
<FormField
name="file"
control={form.control}
render={({ field, fieldState }) => (
<FormItem>
<div className="flex">
<Button
type="button"
size="sm"
className="mr-2"
variant={!!fieldState.error?.message ? "destructive" : "secondary"}
onClick={() => {
inputRef.current?.click();
}}>
{imageUrl
? t("environments.settings.profile.change_image")
: t("environments.settings.profile.upload_image")}
<input
type="file"
id="hiddenFileInput"
ref={(e) => {
field.ref(e);
inputRef.current = e;
}}
className="hidden"
accept="image/jpeg, image/png, image/webp"
onChange={(e) => {
field.onChange(e.target.files);
form.handleSubmit(onSubmit)();
}}
/>
</Button>
{imageUrl && (
<Button
type="button"
className="mr-2"
variant="destructive"
size="sm"
onClick={handleRemove}>
{t("environments.settings.profile.remove_image")}
</Button>
)}
</div>
<FormError />
</FormItem>
)}
/>
</form>
</FormProvider>
</div>
);
};
@@ -49,15 +49,12 @@ describe("Loading", () => {
); );
const loadingCards = screen.getAllByTestId("loading-card"); const loadingCards = screen.getAllByTestId("loading-card");
expect(loadingCards).toHaveLength(3); expect(loadingCards).toHaveLength(2);
expect(loadingCards[0]).toHaveTextContent("environments.settings.profile.personal_information"); expect(loadingCards[0]).toHaveTextContent("environments.settings.profile.personal_information");
expect(loadingCards[0]).toHaveTextContent("environments.settings.profile.update_personal_info"); expect(loadingCards[0]).toHaveTextContent("environments.settings.profile.update_personal_info");
expect(loadingCards[1]).toHaveTextContent("common.avatar"); expect(loadingCards[1]).toHaveTextContent("environments.settings.profile.delete_account");
expect(loadingCards[1]).toHaveTextContent("environments.settings.profile.organization_identification"); expect(loadingCards[1]).toHaveTextContent("environments.settings.profile.confirm_delete_account");
expect(loadingCards[2]).toHaveTextContent("environments.settings.profile.delete_account");
expect(loadingCards[2]).toHaveTextContent("environments.settings.profile.confirm_delete_account");
}); });
}); });
@@ -19,11 +19,6 @@ const Loading = () => {
{ classes: "h-6 w-64" }, { classes: "h-6 w-64" },
], ],
}, },
{
title: t("common.avatar"),
description: t("environments.settings.profile.organization_identification"),
skeletonLines: [{ classes: "h-10 w-10" }, { classes: "h-8 w-24" }],
},
{ {
title: t("environments.settings.profile.delete_account"), title: t("environments.settings.profile.delete_account"),
description: t("environments.settings.profile.confirm_delete_account"), description: t("environments.settings.profile.confirm_delete_account"),
@@ -55,11 +55,6 @@ vi.mock(
vi.mock("./components/DeleteAccount", () => ({ vi.mock("./components/DeleteAccount", () => ({
DeleteAccount: ({ user }) => <div data-testid="delete-account">DeleteAccount: {user.id}</div>, DeleteAccount: ({ user }) => <div data-testid="delete-account">DeleteAccount: {user.id}</div>,
})); }));
vi.mock("./components/EditProfileAvatarForm", () => ({
EditProfileAvatarForm: ({ _, environmentId }) => (
<div data-testid="edit-profile-avatar-form">EditProfileAvatarForm: {environmentId}</div>
),
}));
vi.mock("./components/EditProfileDetailsForm", () => ({ vi.mock("./components/EditProfileDetailsForm", () => ({
EditProfileDetailsForm: ({ user }) => ( EditProfileDetailsForm: ({ user }) => (
<div data-testid="edit-profile-details-form">EditProfileDetailsForm: {user.id}</div> <div data-testid="edit-profile-details-form">EditProfileDetailsForm: {user.id}</div>
@@ -73,7 +68,6 @@ const mockUser = {
id: "user-123", id: "user-123",
name: "Test User", name: "Test User",
email: "test@example.com", email: "test@example.com",
imageUrl: "http://example.com/avatar.png",
twoFactorEnabled: false, twoFactorEnabled: false,
identityProvider: "email", identityProvider: "email",
notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] }, notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] },
@@ -117,7 +111,6 @@ describe("ProfilePage", () => {
"AccountSettingsNavbar: env-123 profile" "AccountSettingsNavbar: env-123 profile"
); );
expect(screen.getByTestId("edit-profile-details-form")).toBeInTheDocument(); expect(screen.getByTestId("edit-profile-details-form")).toBeInTheDocument();
expect(screen.getByTestId("edit-profile-avatar-form")).toBeInTheDocument();
expect(screen.getByTestId("account-security")).toBeInTheDocument(); // Shown because 2FA license is enabled expect(screen.getByTestId("account-security")).toBeInTheDocument(); // Shown because 2FA license is enabled
expect(screen.queryByTestId("upgrade-prompt")).not.toBeInTheDocument(); expect(screen.queryByTestId("upgrade-prompt")).not.toBeInTheDocument();
expect(screen.getByTestId("delete-account")).toBeInTheDocument(); expect(screen.getByTestId("delete-account")).toBeInTheDocument();
@@ -12,7 +12,6 @@ import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import { SettingsCard } from "../../components/SettingsCard"; import { SettingsCard } from "../../components/SettingsCard";
import { DeleteAccount } from "./components/DeleteAccount"; import { DeleteAccount } from "./components/DeleteAccount";
import { EditProfileAvatarForm } from "./components/EditProfileAvatarForm";
import { EditProfileDetailsForm } from "./components/EditProfileDetailsForm"; import { EditProfileDetailsForm } from "./components/EditProfileDetailsForm";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => { const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
@@ -50,17 +49,6 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
isPasswordResetEnabled={isPasswordResetEnabled} isPasswordResetEnabled={isPasswordResetEnabled}
/> />
</SettingsCard> </SettingsCard>
<SettingsCard
title={t("common.avatar")}
description={t("environments.settings.profile.organization_identification")}>
{user && (
<EditProfileAvatarForm
session={session}
environmentId={environmentId}
imageUrl={user.imageUrl}
/>
)}
</SettingsCard>
{user.identityProvider === "email" && ( {user.identityProvider === "email" && (
<SettingsCard <SettingsCard
title={t("common.security")} title={t("common.security")}
@@ -126,7 +126,6 @@ const mockUser = {
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
emailVerified: new Date(), emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false, twoFactorEnabled: false,
identityProvider: "email", identityProvider: "email",
notificationSettings: { alert: {} }, notificationSettings: { alert: {} },
@@ -128,7 +128,6 @@ const mockUser = {
name: "Test User", name: "Test User",
email: "test@example.com", email: "test@example.com",
emailVerified: new Date(), emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false, twoFactorEnabled: false,
identityProvider: "email", identityProvider: "email",
createdAt: new Date(), createdAt: new Date(),
@@ -145,7 +145,6 @@ const mockUser = {
name: "Test User", name: "Test User",
email: "test@example.com", email: "test@example.com",
emailVerified: new Date(), emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false, twoFactorEnabled: false,
identityProvider: "email", identityProvider: "email",
createdAt: new Date(), createdAt: new Date(),
@@ -147,8 +147,14 @@ const mockSurvey = {
id: "q2matrix", id: "q2matrix",
type: TSurveyQuestionTypeEnum.Matrix, type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Matrix Question" }, headline: { default: "Matrix Question" },
rows: [{ default: "Row1" }, { default: "Row2" }], rows: [
columns: [{ default: "Col1" }, { default: "Col2" }], { id: "row-1", label: { default: "Row1" } },
{ id: "row-2", label: { default: "Row2" } },
],
columns: [
{ id: "col-1", label: { default: "Col1" } },
{ id: "col-2", label: { default: "Col2" } },
],
required: false, required: false,
} as unknown as TSurveyQuestion, } as unknown as TSurveyQuestion,
{ {
@@ -74,7 +74,7 @@ const getQuestionColumnsData = (
case "matrix": case "matrix":
return question.rows.map((matrixRow) => { return question.rows.map((matrixRow) => {
return { return {
accessorKey: matrixRow.default, accessorKey: matrixRow.label.default,
header: () => { header: () => {
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -83,14 +83,14 @@ const getQuestionColumnsData = (
<span className="truncate"> <span className="truncate">
{getLocalizedValue(question.headline, "default") + {getLocalizedValue(question.headline, "default") +
" - " + " - " +
getLocalizedValue(matrixRow, "default")} getLocalizedValue(matrixRow.label, "default")}
</span> </span>
</div> </div>
</div> </div>
); );
}, },
cell: ({ row }) => { cell: ({ row }) => {
const responseValue = row.original.responseData[matrixRow.default]; const responseValue = row.original.responseData[matrixRow.label.default];
if (typeof responseValue === "string") { if (typeof responseValue === "string") {
return <p className="text-slate-900">{responseValue}</p>; return <p className="text-slate-900">{responseValue}</p>;
} }
@@ -291,7 +291,6 @@ const mockUser: TUser = {
name: "Test User", name: "Test User",
email: "test@example.com", email: "test@example.com",
emailVerified: new Date(), emailVerified: new Date(),
imageUrl: "https://example.com/avatar.jpg",
twoFactorEnabled: false, twoFactorEnabled: false,
identityProvider: "email", identityProvider: "email",
createdAt: new Date(), createdAt: new Date(),
@@ -250,7 +250,6 @@ const mockUser: TUser = {
name: "Test User", name: "Test User",
email: "test@example.com", email: "test@example.com",
emailVerified: new Date(), emailVerified: new Date(),
imageUrl: "https://example.com/avatar.jpg",
twoFactorEnabled: false, twoFactorEnabled: false,
identityProvider: "email", identityProvider: "email",
createdAt: new Date(), createdAt: new Date(),
@@ -29,7 +29,7 @@ import {
SquareStack, SquareStack,
UserIcon, UserIcon,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { TSegment } from "@formbricks/types/segment"; import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
@@ -77,6 +77,7 @@ export const ShareSurveyModal = ({
description: string; description: string;
componentType: React.ComponentType<unknown>; componentType: React.ComponentType<unknown>;
componentProps: unknown; componentProps: unknown;
disabled?: boolean;
}[] = useMemo( }[] = useMemo(
() => [ () => [
{ {
@@ -111,6 +112,7 @@ export const ShareSurveyModal = ({
isContactsEnabled, isContactsEnabled,
isFormbricksCloud, isFormbricksCloud,
}, },
disabled: survey.singleUse?.enabled,
}, },
{ {
id: ShareViaType.WEBSITE_EMBED, id: ShareViaType.WEBSITE_EMBED,
@@ -121,6 +123,7 @@ export const ShareSurveyModal = ({
description: t("environments.surveys.share.embed_on_website.description"), description: t("environments.surveys.share.embed_on_website.description"),
componentType: WebsiteEmbedTab, componentType: WebsiteEmbedTab,
componentProps: { surveyUrl }, componentProps: { surveyUrl },
disabled: survey.singleUse?.enabled,
}, },
{ {
id: ShareViaType.EMAIL, id: ShareViaType.EMAIL,
@@ -131,6 +134,7 @@ export const ShareSurveyModal = ({
description: t("environments.surveys.share.send_email.description"), description: t("environments.surveys.share.send_email.description"),
componentType: EmailTab, componentType: EmailTab,
componentProps: { surveyId: survey.id, email }, componentProps: { surveyId: survey.id, email },
disabled: survey.singleUse?.enabled,
}, },
{ {
id: ShareViaType.SOCIAL_MEDIA, id: ShareViaType.SOCIAL_MEDIA,
@@ -141,6 +145,7 @@ export const ShareSurveyModal = ({
description: t("environments.surveys.share.social_media.description"), description: t("environments.surveys.share.social_media.description"),
componentType: SocialMediaTab, componentType: SocialMediaTab,
componentProps: { surveyUrl, surveyTitle: survey.name }, componentProps: { surveyUrl, surveyTitle: survey.name },
disabled: survey.singleUse?.enabled,
}, },
{ {
id: ShareViaType.QR_CODE, id: ShareViaType.QR_CODE,
@@ -151,6 +156,7 @@ export const ShareSurveyModal = ({
description: t("environments.surveys.summary.qr_code_description"), description: t("environments.surveys.summary.qr_code_description"),
componentType: QRCodeTab, componentType: QRCodeTab,
componentProps: { surveyUrl }, componentProps: { surveyUrl },
disabled: survey.singleUse?.enabled,
}, },
{ {
id: ShareViaType.DYNAMIC_POPUP, id: ShareViaType.DYNAMIC_POPUP,
@@ -177,9 +183,9 @@ export const ShareSurveyModal = ({
t, t,
survey, survey,
publicDomain, publicDomain,
setSurveyUrl,
user.locale, user.locale,
surveyUrl, surveyUrl,
isReadOnly,
environmentId, environmentId,
segments, segments,
isContactsEnabled, isContactsEnabled,
@@ -188,9 +194,15 @@ export const ShareSurveyModal = ({
] ]
); );
const [activeId, setActiveId] = useState<ShareViaType | ShareSettingsType>( const getDefaultActiveId = useCallback(() => {
survey.type === "link" ? ShareViaType.ANON_LINKS : ShareViaType.APP if (survey.type !== "link") {
); return ShareViaType.APP;
}
return ShareViaType.ANON_LINKS;
}, [survey.type]);
const [activeId, setActiveId] = useState<ShareViaType | ShareSettingsType>(getDefaultActiveId());
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -198,11 +210,19 @@ export const ShareSurveyModal = ({
} }
}, [open, modalView]); }, [open, modalView]);
// Ensure active tab is not disabled - if it is, switch to default
useEffect(() => {
const activeTab = linkTabs.find((tab) => tab.id === activeId);
if (activeTab?.disabled) {
setActiveId(getDefaultActiveId());
}
}, [activeId, linkTabs, getDefaultActiveId]);
const handleOpenChange = (open: boolean) => { const handleOpenChange = (open: boolean) => {
setOpen(open); setOpen(open);
if (!open) { if (!open) {
setShowView("start"); setShowView("start");
setActiveId(ShareViaType.ANON_LINKS); setActiveId(getDefaultActiveId());
} }
}; };
@@ -150,13 +150,13 @@ export const LinkSettingsTab = ({ isReadOnly, locale }: LinkSettingsTabProps) =>
name: "title", name: "title",
label: t("environments.surveys.share.link_settings.link_title"), label: t("environments.surveys.share.link_settings.link_title"),
description: t("environments.surveys.share.link_settings.link_title_description"), description: t("environments.surveys.share.link_settings.link_title_description"),
placeholder: t("environments.surveys.share.link_settings.link_title_placeholder"), placeholder: survey.name,
}, },
{ {
name: "description", name: "description",
label: t("environments.surveys.share.link_settings.link_description"), label: t("environments.surveys.share.link_settings.link_description"),
description: t("environments.surveys.share.link_settings.link_description_description"), description: t("environments.surveys.share.link_settings.link_description_description"),
placeholder: t("environments.surveys.share.link_settings.link_description_placeholder"), placeholder: "Please complete this survey.",
}, },
]; ];
@@ -34,6 +34,7 @@ interface ShareViewProps {
componentProps: any; componentProps: any;
title: string; title: string;
description?: string; description?: string;
disabled?: boolean;
}>; }>;
activeId: ShareViaType | ShareSettingsType; activeId: ShareViaType | ShareSettingsType;
setActiveId: React.Dispatch<React.SetStateAction<ShareViaType | ShareSettingsType>>; setActiveId: React.Dispatch<React.SetStateAction<ShareViaType | ShareSettingsType>>;
@@ -109,12 +110,13 @@ export const ShareView = ({ tabs, activeId, setActiveId }: ShareViewProps) => {
onClick={() => setActiveId(tab.id)} onClick={() => setActiveId(tab.id)}
className={cn( className={cn(
"flex w-full justify-start rounded-md p-2 text-slate-600 hover:bg-slate-100 hover:text-slate-900", "flex w-full justify-start rounded-md p-2 text-slate-600 hover:bg-slate-100 hover:text-slate-900",
tab.id === activeId tab.id === activeId && !tab.disabled
? "bg-slate-100 font-medium text-slate-900" ? "bg-slate-100 font-medium text-slate-900"
: "text-slate-700" : "text-slate-700"
)} )}
tooltip={tab.label} tooltip={tab.label}
isActive={tab.id === activeId}> isActive={tab.id === activeId}
disabled={tab.disabled}>
<tab.icon className="h-4 w-4 text-slate-700" /> <tab.icon className="h-4 w-4 text-slate-700" />
<span>{tab.label}</span> <span>{tab.label}</span>
</SidebarMenuButton> </SidebarMenuButton>
@@ -136,9 +138,10 @@ export const ShareView = ({ tabs, activeId, setActiveId }: ShareViewProps) => {
<Button <Button
variant="ghost" variant="ghost"
onClick={() => setActiveId(tab.id)} onClick={() => setActiveId(tab.id)}
disabled={tab.disabled}
className={cn( className={cn(
"rounded-md px-4 py-2", "rounded-md px-4 py-2",
tab.id === activeId tab.id === activeId && !tab.disabled
? "bg-white text-slate-900 shadow-sm hover:bg-white" ? "bg-white text-slate-900 shadow-sm hover:bg-white"
: "border-transparent text-slate-700 hover:text-slate-900" : "border-transparent text-slate-700 hover:text-slate-900"
)}> )}>
@@ -1327,8 +1327,17 @@ describe("Matrix question type tests", () => {
type: TSurveyQuestionTypeEnum.Matrix, type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Rate these aspects" }, headline: { default: "Rate these aspects" },
required: true, required: true,
rows: [{ default: "Speed" }, { default: "Quality" }, { default: "Price" }], rows: [
columns: [{ default: "Poor" }, { default: "Average" }, { default: "Good" }, { default: "Excellent" }], { id: "row-1", label: { default: "Speed" } },
{ id: "row-2", label: { default: "Quality" } },
{ id: "row-3", label: { default: "Price" } },
],
columns: [
{ id: "col-1", label: { default: "Poor" } },
{ id: "col-2", label: { default: "Average" } },
{ id: "col-3", label: { default: "Good" } },
{ id: "col-4", label: { default: "Excellent" } },
],
}; };
const survey = { const survey = {
@@ -1410,15 +1419,15 @@ describe("Matrix question type tests", () => {
headline: { default: "Rate these aspects", es: "Califica estos aspectos" }, headline: { default: "Rate these aspects", es: "Califica estos aspectos" },
required: true, required: true,
rows: [ rows: [
{ default: "Speed", es: "Velocidad" }, { id: "row-1", label: { default: "Speed", es: "Velocidad" } },
{ default: "Quality", es: "Calidad" }, { id: "row-2", label: { default: "Quality", es: "Calidad" } },
{ default: "Price", es: "Precio" }, { id: "row-3", label: { default: "Price", es: "Precio" } },
], ],
columns: [ columns: [
{ default: "Poor", es: "Malo" }, { id: "col-1", label: { default: "Poor", es: "Malo" } },
{ default: "Average", es: "Promedio" }, { id: "col-2", label: { default: "Average", es: "Promedio" } },
{ default: "Good", es: "Bueno" }, { id: "col-3", label: { default: "Good", es: "Bueno" } },
{ default: "Excellent", es: "Excelente" }, { id: "col-4", label: { default: "Excellent", es: "Excelente" } },
], ],
}; };
@@ -1587,8 +1596,16 @@ describe("Matrix question type tests", () => {
type: TSurveyQuestionTypeEnum.Matrix, type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Rate these aspects" }, headline: { default: "Rate these aspects" },
required: true, required: true,
rows: [{ default: "Speed" }, { default: "Quality" }, { default: "Price" }], rows: [
columns: [{ default: "Poor" }, { default: "Average" }, { default: "Good" }], { id: "row-1", label: { default: "Speed" } },
{ id: "row-2", label: { default: "Quality" } },
{ id: "row-3", label: { default: "Price" } },
],
columns: [
{ id: "col-1", label: { default: "Poor" } },
{ id: "col-2", label: { default: "Average" } },
{ id: "col-3", label: { default: "Good" } },
],
}; };
const survey = { const survey = {
@@ -1721,8 +1738,16 @@ describe("Matrix question type tests", () => {
type: TSurveyQuestionTypeEnum.Matrix, type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Rate these aspects" }, headline: { default: "Rate these aspects" },
required: true, required: true,
rows: [{ default: "Speed" }, { default: "Quality" }, { default: "Price" }], rows: [
columns: [{ default: "Poor" }, { default: "Average" }, { default: "Good" }], { id: "row-1", label: { default: "Speed" } },
{ id: "row-2", label: { default: "Quality" } },
{ id: "row-3", label: { default: "Price" } },
],
columns: [
{ id: "col-1", label: { default: "Poor" } },
{ id: "col-2", label: { default: "Average" } },
{ id: "col-3", label: { default: "Good" } },
],
}; };
const survey = { const survey = {
@@ -1785,8 +1810,14 @@ describe("Matrix question type tests", () => {
type: TSurveyQuestionTypeEnum.Matrix, type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Rate these aspects" }, headline: { default: "Rate these aspects" },
required: true, required: true,
rows: [{ default: "Speed" }, { default: "Quality" }], rows: [
columns: [{ default: "Poor" }, { default: "Good" }], { id: "row-1", label: { default: "Speed" } },
{ id: "row-2", label: { default: "Quality" } },
],
columns: [
{ id: "col-1", label: { default: "Poor" } },
{ id: "col-2", label: { default: "Good" } },
],
}; };
const survey = { const survey = {
@@ -1849,12 +1880,12 @@ describe("Matrix question type tests", () => {
headline: { default: "Rate these aspects", fr: "Évaluez ces aspects" }, headline: { default: "Rate these aspects", fr: "Évaluez ces aspects" },
required: true, required: true,
rows: [ rows: [
{ default: "Speed", fr: "Vitesse" }, { id: "row-1", label: { default: "Speed", fr: "Vitesse" } },
{ default: "Quality", fr: "Qualité" }, { id: "row-2", label: { default: "Quality", fr: "Qualité" } },
], ],
columns: [ columns: [
{ default: "Poor", fr: "Médiocre" }, { id: "col-1", label: { default: "Poor", fr: "Médiocre" } },
{ default: "Good", fr: "Bon" }, { id: "col-2", label: { default: "Good", fr: "Bon" } },
], ],
}; };
@@ -736,8 +736,8 @@ export const getQuestionSummary = async (
break; break;
} }
case TSurveyQuestionTypeEnum.Matrix: { case TSurveyQuestionTypeEnum.Matrix: {
const rows = question.rows.map((row) => getLocalizedValue(row, "default")); const rows = question.rows.map((row) => getLocalizedValue(row.label, "default"));
const columns = question.columns.map((column) => getLocalizedValue(column, "default")); const columns = question.columns.map((column) => getLocalizedValue(column.label, "default"));
let totalResponseCount = 0; let totalResponseCount = 0;
// Initialize count object // Initialize count object
@@ -755,13 +755,15 @@ export const getQuestionSummary = async (
if (selectedResponses) { if (selectedResponses) {
totalResponseCount++; totalResponseCount++;
question.rows.forEach((row) => { question.rows.forEach((row) => {
const localizedRow = getLocalizedValue(row, responseLanguageCode); const localizedRow = getLocalizedValue(row.label, responseLanguageCode);
const colValue = question.columns.find((column) => { const colValue = question.columns.find((column) => {
return getLocalizedValue(column, responseLanguageCode) === selectedResponses[localizedRow]; return (
getLocalizedValue(column.label, responseLanguageCode) === selectedResponses[localizedRow]
);
}); });
const colValueInDefaultLanguage = getLocalizedValue(colValue, "default"); const colValueInDefaultLanguage = getLocalizedValue(colValue?.label, "default");
if (colValueInDefaultLanguage && columns.includes(colValueInDefaultLanguage)) { if (colValueInDefaultLanguage && columns.includes(colValueInDefaultLanguage)) {
countMap[getLocalizedValue(row, "default")][colValueInDefaultLanguage] += 1; countMap[getLocalizedValue(row.label, "default")][colValueInDefaultLanguage] += 1;
} }
}); });
} }
@@ -158,7 +158,6 @@ const mockUser = {
name: "Test User", name: "Test User",
email: "test@example.com", email: "test@example.com",
emailVerified: new Date(), emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false, twoFactorEnabled: false,
identityProvider: "email", identityProvider: "email",
createdAt: new Date(), createdAt: new Date(),
@@ -174,7 +173,6 @@ const mockSession = {
id: mockUserId, id: mockUserId,
name: mockUser.name, name: mockUser.name,
email: mockUser.email, email: mockUser.email,
image: mockUser.imageUrl,
role: mockUser.role, role: mockUser.role,
plan: "free", plan: "free",
status: "active", status: "active",
@@ -89,4 +89,94 @@ describe("QuestionFilterComboBox", () => {
await userEvent.click(comboBoxOpenerButton); await userEvent.click(comboBoxOpenerButton);
expect(screen.queryByText("X")).not.toBeInTheDocument(); expect(screen.queryByText("X")).not.toBeInTheDocument();
}); });
test("shows text input for URL meta field", () => {
const props = {
...defaultProps,
type: "Meta",
fieldId: "url",
filterValue: "Contains",
filterComboBoxValue: "example.com",
} as any;
render(<QuestionFilterComboBox {...props} />);
const textInput = screen.getByDisplayValue("example.com");
expect(textInput).toBeInTheDocument();
expect(textInput).toHaveAttribute("type", "text");
});
test("text input is disabled when no filter value is selected for URL field", () => {
const props = {
...defaultProps,
type: "Meta",
fieldId: "url",
filterValue: undefined,
} as any;
render(<QuestionFilterComboBox {...props} />);
const textInput = screen.getByRole("textbox");
expect(textInput).toBeDisabled();
});
test("text input calls onChangeFilterComboBoxValue when typing for URL field", async () => {
const props = {
...defaultProps,
type: "Meta",
fieldId: "url",
filterValue: "Contains",
filterComboBoxValue: "",
} as any;
render(<QuestionFilterComboBox {...props} />);
const textInput = screen.getByRole("textbox");
await userEvent.type(textInput, "t");
expect(props.onChangeFilterComboBoxValue).toHaveBeenCalledWith("t");
});
test("shows regular combobox for non-URL meta fields", () => {
const props = {
...defaultProps,
type: "Meta",
fieldId: "source",
filterValue: "Equals",
} as any;
render(<QuestionFilterComboBox {...props} />);
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
expect(screen.getAllByRole("button").length).toBeGreaterThanOrEqual(2);
});
test("shows regular combobox for URL field with non-text operations", () => {
const props = {
...defaultProps,
type: "Other",
fieldId: "url",
filterValue: "Equals",
} as any;
render(<QuestionFilterComboBox {...props} />);
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
expect(screen.getAllByRole("button").length).toBeGreaterThanOrEqual(2);
});
test("text input handles string filter combo box values correctly", () => {
const props = {
...defaultProps,
type: "Meta",
fieldId: "url",
filterValue: "Contains",
filterComboBoxValue: "test-url",
} as any;
render(<QuestionFilterComboBox {...props} />);
const textInput = screen.getByDisplayValue("test-url");
expect(textInput).toBeInTheDocument();
});
test("text input handles non-string filter combo box values gracefully", () => {
const props = {
...defaultProps,
type: "Meta",
fieldId: "url",
filterValue: "Contains",
filterComboBoxValue: ["array-value"],
} as any;
render(<QuestionFilterComboBox {...props} />);
const textInput = screen.getByRole("textbox");
expect(textInput).toHaveValue("");
});
}); });
@@ -33,6 +33,7 @@ type QuestionFilterComboBoxProps = {
type?: TSurveyQuestionTypeEnum | Omit<OptionsType, OptionsType.QUESTIONS>; type?: TSurveyQuestionTypeEnum | Omit<OptionsType, OptionsType.QUESTIONS>;
handleRemoveMultiSelect: (value: string[]) => void; handleRemoveMultiSelect: (value: string[]) => void;
disabled?: boolean; disabled?: boolean;
fieldId?: string;
}; };
export const QuestionFilterComboBox = ({ export const QuestionFilterComboBox = ({
@@ -45,6 +46,7 @@ export const QuestionFilterComboBox = ({
type, type,
handleRemoveMultiSelect, handleRemoveMultiSelect,
disabled = false, disabled = false,
fieldId,
}: QuestionFilterComboBoxProps) => { }: QuestionFilterComboBoxProps) => {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [openFilterValue, setOpenFilterValue] = React.useState<boolean>(false); const [openFilterValue, setOpenFilterValue] = React.useState<boolean>(false);
@@ -75,6 +77,9 @@ export const QuestionFilterComboBox = ({
(type === TSurveyQuestionTypeEnum.NPS || type === TSurveyQuestionTypeEnum.Rating) && (type === TSurveyQuestionTypeEnum.NPS || type === TSurveyQuestionTypeEnum.Rating) &&
(filterValue === "Submitted" || filterValue === "Skipped"); (filterValue === "Submitted" || filterValue === "Skipped");
// Check if this is a URL field with string comparison operations that require text input
const isTextInputField = type === OptionsType.META && fieldId === "url";
const filteredOptions = options?.filter((o) => const filteredOptions = options?.filter((o) =>
(typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o) (typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o)
.toLowerCase() .toLowerCase()
@@ -161,70 +166,80 @@ export const QuestionFilterComboBox = ({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)} )}
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent"> {isTextInputField ? (
<div <Input
className={clsx( type="text"
"group flex items-center justify-between rounded-md rounded-l-none bg-white px-3 py-2 text-sm" value={typeof filterComboBoxValue === "string" ? filterComboBoxValue : ""}
)}> onChange={(e) => onChangeFilterComboBoxValue(e.target.value)}
{filterComboBoxValue && filterComboBoxValue.length > 0 ? ( disabled={disabled || !filterValue}
filterComboBoxItem className="h-9 rounded-l-none border-none bg-white text-sm focus:ring-offset-0"
) : ( />
) : (
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent">
<div
className={clsx(
"group flex items-center justify-between rounded-md rounded-l-none bg-white px-3 py-2 text-sm"
)}>
{filterComboBoxValue && filterComboBoxValue.length > 0 ? (
filterComboBoxItem
) : (
<button
type="button"
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
disabled={disabled || isDisabledComboBox || !filterValue}
className={clsx(
"flex-1 text-left text-slate-400",
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
)}>
{t("common.select")}...
</button>
)}
<button <button
type="button" type="button"
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)} onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
disabled={disabled || isDisabledComboBox || !filterValue} disabled={disabled || isDisabledComboBox || !filterValue}
className={clsx( className={clsx(
"flex-1 text-left text-slate-400", "ml-2 flex items-center justify-center",
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer" disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
)}> )}>
{t("common.select")}... {open ? (
<ChevronUp className="h-4 w-4 opacity-50" />
) : (
<ChevronDown className="h-4 w-4 opacity-50" />
)}
</button> </button>
)} </div>
<button <div className="relative mt-2 h-full">
type="button" {open && (
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)} <div className="animate-in absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
disabled={disabled || isDisabledComboBox || !filterValue} <CommandList>
className={clsx( <div className="p-2">
"ml-2 flex items-center justify-center", <Input
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer" type="text"
)}> autoFocus
{open ? ( placeholder={t("common.search") + "..."}
<ChevronUp className="h-4 w-4 opacity-50" /> value={searchQuery}
) : ( onChange={(e) => setSearchQuery(e.target.value)}
<ChevronDown className="h-4 w-4 opacity-50" /> className="w-full rounded-md border border-slate-300 p-2 text-sm focus:border-slate-300"
/>
</div>
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
<CommandGroup>
{filteredOptions?.map((o, index) => (
<CommandItem
key={`option-${typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}-${index}`}
onSelect={() => commandItemOnSelect(o)}
className="cursor-pointer">
{typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</div>
)} )}
</button> </div>
</div> </Command>
<div className="relative mt-2 h-full"> )}
{open && (
<div className="animate-in absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
<CommandList>
<div className="p-2">
<Input
type="text"
autoFocus
placeholder={t("common.search") + "..."}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded-md border border-slate-300 p-2 text-sm focus:border-slate-300"
/>
</div>
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
<CommandGroup>
{filteredOptions?.map((o, index) => (
<CommandItem
key={`option-${typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}-${index}`}
onSelect={() => commandItemOnSelect(o)}
className="cursor-pointer">
{typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</div>
)}
</div>
</Command>
</div> </div>
); );
}; };
@@ -28,6 +28,7 @@ import {
HomeIcon, HomeIcon,
ImageIcon, ImageIcon,
LanguagesIcon, LanguagesIcon,
LinkIcon,
ListIcon, ListIcon,
ListOrderedIcon, ListOrderedIcon,
MessageSquareTextIcon, MessageSquareTextIcon,
@@ -94,6 +95,7 @@ const questionIcons = {
source: ArrowUpFromDotIcon, source: ArrowUpFromDotIcon,
action: MousePointerClickIcon, action: MousePointerClickIcon,
country: FlagIcon, country: FlagIcon,
url: LinkIcon,
// others // others
Language: LanguagesIcon, Language: LanguagesIcon,
@@ -138,7 +140,7 @@ export const SelectedCommandItem = ({ label, questionType, type }: Partial<Quest
const getLabelStyle = (): string | undefined => { const getLabelStyle = (): string | undefined => {
if (type !== OptionsType.META) return undefined; if (type !== OptionsType.META) return undefined;
return label === "os" ? "uppercase" : "capitalize"; return label === "os" || label === "url" ? "uppercase" : "capitalize";
}; };
return ( return (
@@ -246,9 +246,9 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
<div className="flex w-full flex-wrap gap-3 md:flex-nowrap"> <div className="flex w-full flex-wrap gap-3 md:flex-nowrap">
<div <div
className="grid w-full grid-cols-1 items-center gap-3 md:grid-cols-2" className="grid w-full grid-cols-1 items-center gap-3 md:grid-cols-2"
key={`${s.questionType.id}-${i}`}> key={`${s.questionType.id}-${i}-${s.questionType.label}`}>
<QuestionsComboBox <QuestionsComboBox
key={`${s.questionType.label}-${i}`} key={`${s.questionType.label}-${i}-${s.questionType.id}`}
options={questionComboBoxOptions} options={questionComboBoxOptions}
selected={s.questionType} selected={s.questionType}
onChangeValue={(value) => handleOnChangeQuestionComboBoxValue(value, i)} onChangeValue={(value) => handleOnChangeQuestionComboBoxValue(value, i)}
@@ -276,6 +276,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
? s?.questionType?.questionType ? s?.questionType?.questionType
: s?.questionType?.type : s?.questionType?.type
} }
fieldId={s?.questionType?.id}
handleRemoveMultiSelect={(value) => handleRemoveMultiSelect(value, i)} handleRemoveMultiSelect={(value) => handleRemoveMultiSelect(value, i)}
onChangeFilterComboBoxValue={(value) => handleOnChangeFilterComboBoxValue(value, i)} onChangeFilterComboBoxValue={(value) => handleOnChangeFilterComboBoxValue(value, i)}
onChangeFilterValue={(value) => handleOnChangeFilterValue(value, i)} onChangeFilterValue={(value) => handleOnChangeFilterValue(value, i)}
+150
View File
@@ -231,6 +231,43 @@ describe("surveys", () => {
expect(result.questionFilterOptions.some((o) => o.id === "q7")).toBeTruthy(); expect(result.questionFilterOptions.some((o) => o.id === "q7")).toBeTruthy();
expect(result.questionFilterOptions.some((o) => o.id === "q8")).toBeTruthy(); expect(result.questionFilterOptions.some((o) => o.id === "q8")).toBeTruthy();
}); });
test("should provide extended filter options for URL meta field", () => {
const survey = {
id: "survey1",
name: "Test Survey",
questions: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
status: "draft",
} as unknown as TSurvey;
const meta = {
url: ["https://example.com", "https://test.com"],
source: ["web", "mobile"],
};
const result = generateQuestionAndFilterOptions(survey, undefined, {}, meta, {});
const urlFilterOption = result.questionFilterOptions.find((o) => o.id === "url");
const sourceFilterOption = result.questionFilterOptions.find((o) => o.id === "source");
expect(urlFilterOption).toBeDefined();
expect(urlFilterOption?.filterOptions).toEqual([
"Equals",
"Not equals",
"Contains",
"Does not contain",
"Starts with",
"Does not start with",
"Ends with",
"Does not end with",
]);
expect(sourceFilterOption).toBeDefined();
expect(sourceFilterOption?.filterOptions).toEqual(["Equals", "Not equals"]);
});
}); });
describe("getFormattedFilters", () => { describe("getFormattedFilters", () => {
@@ -717,6 +754,119 @@ describe("surveys", () => {
expect(result.data?.npsQ).toEqual({ op: "greaterThan", value: 7 }); expect(result.data?.npsQ).toEqual({ op: "greaterThan", value: 7 });
expect(result.tags?.applied).toContain("Tag 1"); expect(result.tags?.applied).toContain("Tag 1");
}); });
test("should format URL meta filters with string operations", () => {
const selectedFilter = {
responseStatus: "all",
filter: [
{
questionType: { type: "Meta", label: "url", id: "url" },
filterType: { filterValue: "Contains", filterComboBoxValue: "example.com" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, dateRange);
expect(result.meta?.url).toEqual({ op: "contains", value: "example.com" });
});
test("should format URL meta filters with all supported string operations", () => {
const testCases = [
{ filterValue: "Equals", expected: { op: "equals", value: "https://example.com" } },
{ filterValue: "Not equals", expected: { op: "notEquals", value: "https://example.com" } },
{ filterValue: "Contains", expected: { op: "contains", value: "example.com" } },
{ filterValue: "Does not contain", expected: { op: "doesNotContain", value: "test.com" } },
{ filterValue: "Starts with", expected: { op: "startsWith", value: "https://" } },
{ filterValue: "Does not start with", expected: { op: "doesNotStartWith", value: "http://" } },
{ filterValue: "Ends with", expected: { op: "endsWith", value: ".com" } },
{ filterValue: "Does not end with", expected: { op: "doesNotEndWith", value: ".org" } },
];
testCases.forEach(({ filterValue, expected }) => {
const selectedFilter = {
responseStatus: "all",
filter: [
{
questionType: { type: "Meta", label: "url", id: "url" },
filterType: { filterValue, filterComboBoxValue: expected.value },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, dateRange);
expect(result.meta?.url).toEqual(expected);
});
});
test("should handle URL meta filters with empty string values", () => {
const selectedFilter = {
responseStatus: "all",
filter: [
{
questionType: { type: "Meta", label: "url", id: "url" },
filterType: { filterValue: "Contains", filterComboBoxValue: "" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, dateRange);
expect(result.meta?.url).toBeUndefined();
});
test("should handle URL meta filters with whitespace-only values", () => {
const selectedFilter = {
responseStatus: "all",
filter: [
{
questionType: { type: "Meta", label: "url", id: "url" },
filterType: { filterValue: "Contains", filterComboBoxValue: " " },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, dateRange);
expect(result.meta?.url).toEqual({ op: "contains", value: "" });
});
test("should still handle existing meta filters with array values", () => {
const selectedFilter = {
responseStatus: "all",
filter: [
{
questionType: { type: "Meta", label: "source", id: "source" },
filterType: { filterValue: "Equals", filterComboBoxValue: ["google"] },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, dateRange);
expect(result.meta?.source).toEqual({ op: "equals", value: "google" });
});
test("should handle mixed URL and traditional meta filters", () => {
const selectedFilter = {
responseStatus: "all",
filter: [
{
questionType: { type: "Meta", label: "url", id: "url" },
filterType: { filterValue: "Contains", filterComboBoxValue: "formbricks.com" },
},
{
questionType: { type: "Meta", label: "source", id: "source" },
filterType: { filterValue: "Equals", filterComboBoxValue: ["newsletter"] },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, dateRange);
expect(result.meta?.url).toEqual({ op: "contains", value: "formbricks.com" });
expect(result.meta?.source).toEqual({ op: "equals", value: "newsletter" });
});
}); });
describe("getTodayDate", () => { describe("getTodayDate", () => {
+30 -12
View File
@@ -47,6 +47,18 @@ const filterOptions = {
ranking: ["Filled out", "Skipped"], ranking: ["Filled out", "Skipped"],
}; };
// URL/meta text operators mapping
const META_OP_MAP = {
Equals: "equals",
"Not equals": "notEquals",
Contains: "contains",
"Does not contain": "doesNotContain",
"Starts with": "startsWith",
"Does not start with": "doesNotStartWith",
"Ends with": "endsWith",
"Does not end with": "doesNotEndWith",
} as const;
// creating the options for the filtering to be selected there are 4 types questions, attributes, tags and metadata // creating the options for the filtering to be selected there are 4 types questions, attributes, tags and metadata
export const generateQuestionAndFilterOptions = ( export const generateQuestionAndFilterOptions = (
survey: TSurvey, survey: TSurvey,
@@ -165,7 +177,7 @@ export const generateQuestionAndFilterOptions = (
Object.keys(meta).forEach((m) => { Object.keys(meta).forEach((m) => {
questionFilterOptions.push({ questionFilterOptions.push({
type: "Meta", type: "Meta",
filterOptions: ["Equals", "Not equals"], filterOptions: m === "url" ? Object.keys(META_OP_MAP) : ["Equals", "Not equals"],
filterComboBoxOptions: meta[m], filterComboBoxOptions: meta[m],
id: m, id: m,
}); });
@@ -481,17 +493,23 @@ export const getFormattedFilters = (
if (meta.length) { if (meta.length) {
meta.forEach(({ filterType, questionType }) => { meta.forEach(({ filterType, questionType }) => {
if (!filters.meta) filters.meta = {}; if (!filters.meta) filters.meta = {};
if (!filterType.filterComboBoxValue) return;
if (filterType.filterValue === "Equals") { // For text input cases (URL filtering)
filters.meta[questionType.label ?? ""] = { if (typeof filterType.filterComboBoxValue === "string" && filterType.filterComboBoxValue.length > 0) {
op: "equals", const value = filterType.filterComboBoxValue.trim();
value: filterType.filterComboBoxValue as string, const op = META_OP_MAP[filterType.filterValue as keyof typeof META_OP_MAP];
}; if (op) {
} else if (filterType.filterValue === "Not equals") { filters.meta[questionType.label ?? ""] = { op, value };
filters.meta[questionType.label ?? ""] = { }
op: "notEquals", }
value: filterType.filterComboBoxValue as string, // For dropdown/select cases (existing metadata fields)
}; else if (Array.isArray(filterType.filterComboBoxValue) && filterType.filterComboBoxValue.length > 0) {
const value = filterType.filterComboBoxValue[0]; // Take first selected value
if (filterType.filterValue === "Equals") {
filters.meta[questionType.label ?? ""] = { op: "equals", value };
} else if (filterType.filterValue === "Not equals") {
filters.meta[questionType.label ?? ""] = { op: "notEquals", value };
}
} }
}); });
} }
-4
View File
@@ -118,7 +118,6 @@ describe("Page", () => {
name: "Test User", name: "Test User",
email: "test@example.com", email: "test@example.com",
emailVerified: null, emailVerified: null,
imageUrl: null,
twoFactorEnabled: false, twoFactorEnabled: false,
identityProvider: "email", identityProvider: "email",
createdAt: new Date(), createdAt: new Date(),
@@ -161,7 +160,6 @@ describe("Page", () => {
name: "Test User", name: "Test User",
email: "test@example.com", email: "test@example.com",
emailVerified: null, emailVerified: null,
imageUrl: null,
twoFactorEnabled: false, twoFactorEnabled: false,
identityProvider: "email", identityProvider: "email",
createdAt: new Date(), createdAt: new Date(),
@@ -250,7 +248,6 @@ describe("Page", () => {
name: "Test User", name: "Test User",
email: "test@example.com", email: "test@example.com",
emailVerified: null, emailVerified: null,
imageUrl: null,
twoFactorEnabled: false, twoFactorEnabled: false,
identityProvider: "email", identityProvider: "email",
createdAt: new Date(), createdAt: new Date(),
@@ -339,7 +336,6 @@ describe("Page", () => {
name: "Test User", name: "Test User",
email: "test@example.com", email: "test@example.com",
emailVerified: null, emailVerified: null,
imageUrl: null,
twoFactorEnabled: false, twoFactorEnabled: false,
identityProvider: "email", identityProvider: "email",
createdAt: new Date(), createdAt: new Date(),
+128
View File
@@ -157,6 +157,46 @@ describe("Response Utils", () => {
}, },
]); ]);
}); });
test("meta: URL string comparison operations", () => {
const testCases = [
{
name: "contains",
criteria: { meta: { url: { op: "contains" as const, value: "example.com" } } },
expected: { meta: { path: ["url"], string_contains: "example.com" } },
},
{
name: "doesNotContain",
criteria: { meta: { url: { op: "doesNotContain" as const, value: "test.com" } } },
expected: { NOT: { meta: { path: ["url"], string_contains: "test.com" } } },
},
{
name: "startsWith",
criteria: { meta: { url: { op: "startsWith" as const, value: "https://" } } },
expected: { meta: { path: ["url"], string_starts_with: "https://" } },
},
{
name: "doesNotStartWith",
criteria: { meta: { url: { op: "doesNotStartWith" as const, value: "http://" } } },
expected: { NOT: { meta: { path: ["url"], string_starts_with: "http://" } } },
},
{
name: "endsWith",
criteria: { meta: { url: { op: "endsWith" as const, value: ".com" } } },
expected: { meta: { path: ["url"], string_ends_with: ".com" } },
},
{
name: "doesNotEndWith",
criteria: { meta: { url: { op: "doesNotEndWith" as const, value: ".org" } } },
expected: { NOT: { meta: { path: ["url"], string_ends_with: ".org" } } },
},
];
testCases.forEach(({ criteria, expected }) => {
const result = buildWhereClause(baseSurvey as TSurvey, criteria);
expect(result.AND).toEqual([{ AND: [expected] }]);
});
});
}); });
describe("buildWhereClause datafield filter operations", () => { describe("buildWhereClause datafield filter operations", () => {
@@ -495,10 +535,98 @@ describe("Response Utils", () => {
expect(result.os).toContain("MacOS"); expect(result.os).toContain("MacOS");
}); });
test("should extract URL data correctly", () => {
const responses = [
{
contactAttributes: {},
data: {},
meta: {
url: "https://example.com/page1",
source: "direct",
},
},
{
contactAttributes: {},
data: {},
meta: {
url: "https://test.com/page2?param=value",
source: "google",
},
},
];
const result = getResponseMeta(responses as Pick<TResponse, "contactAttributes" | "data" | "meta">[]);
expect(result.url).toEqual([]);
expect(result.source).toContain("direct");
expect(result.source).toContain("google");
});
test("should handle mixed meta data with URLs", () => {
const responses = [
{
contactAttributes: {},
data: {},
meta: {
userAgent: { browser: "Chrome", device: "desktop" },
url: "https://formbricks.com/dashboard",
country: "US",
},
},
{
contactAttributes: {},
data: {},
meta: {
userAgent: { browser: "Safari", device: "mobile" },
url: "https://formbricks.com/surveys/123",
country: "UK",
},
},
];
const result = getResponseMeta(responses as Pick<TResponse, "contactAttributes" | "data" | "meta">[]);
expect(result.browser).toContain("Chrome");
expect(result.browser).toContain("Safari");
expect(result.device).toContain("desktop");
expect(result.device).toContain("mobile");
expect(result.url).toEqual([]);
expect(result.country).toContain("US");
expect(result.country).toContain("UK");
});
test("should handle empty responses", () => { test("should handle empty responses", () => {
const result = getResponseMeta([]); const result = getResponseMeta([]);
expect(result).toEqual({}); expect(result).toEqual({});
}); });
test("should ignore empty or null URL values", () => {
const responses = [
{
contactAttributes: {},
data: {},
meta: {
url: "",
source: "direct",
},
},
{
contactAttributes: {},
data: {},
meta: {
url: null as any,
source: "newsletter",
},
},
{
contactAttributes: {},
data: {},
meta: {
url: "https://valid.com",
source: "google",
},
},
];
const result = getResponseMeta(responses as Pick<TResponse, "contactAttributes" | "data" | "meta">[]);
expect(result.url).toEqual([]);
expect(result.source).toEqual(expect.arrayContaining(["direct", "newsletter", "google"]));
});
}); });
describe("getResponseHiddenFields", () => { describe("getResponseHiddenFields", () => {
+63 -6
View File
@@ -234,6 +234,60 @@ export const buildWhereClause = (survey: TSurvey, filterCriteria?: TResponseFilt
}, },
}); });
break; break;
case "contains":
meta.push({
meta: {
path: updatedKey,
string_contains: val.value,
},
});
break;
case "doesNotContain":
meta.push({
NOT: {
meta: {
path: updatedKey,
string_contains: val.value,
},
},
});
break;
case "startsWith":
meta.push({
meta: {
path: updatedKey,
string_starts_with: val.value,
},
});
break;
case "doesNotStartWith":
meta.push({
NOT: {
meta: {
path: updatedKey,
string_starts_with: val.value,
},
},
});
break;
case "endsWith":
meta.push({
meta: {
path: updatedKey,
string_ends_with: val.value,
},
});
break;
case "doesNotEndWith":
meta.push({
NOT: {
meta: {
path: updatedKey,
string_ends_with: val.value,
},
},
});
break;
} }
}); });
@@ -571,7 +625,7 @@ export const extractSurveyDetails = (survey: TSurvey, responses: TResponse[]) =>
const headline = getLocalizedValue(question.headline, "default") ?? question.id; const headline = getLocalizedValue(question.headline, "default") ?? question.id;
if (question.type === "matrix") { if (question.type === "matrix") {
return question.rows.map((row) => { return question.rows.map((row) => {
return `${idx + 1}. ${headline} - ${getLocalizedValue(row, "default")}`; return `${idx + 1}. ${headline} - ${getLocalizedValue(row.label, "default")}`;
}); });
} else if ( } else if (
question.type === "multipleChoiceMulti" || question.type === "multipleChoiceMulti" ||
@@ -638,8 +692,8 @@ export const getResponsesJson = (
questionHeadline.forEach((headline, index) => { questionHeadline.forEach((headline, index) => {
if (answer) { if (answer) {
const row = question.rows[index]; const row = question.rows[index];
if (row && row.default && answer[row.default] !== undefined) { if (row && row.label.default && answer[row.label.default] !== undefined) {
jsonData[idx][headline] = answer[row.default]; jsonData[idx][headline] = answer[row.label.default];
} else { } else {
jsonData[idx][headline] = ""; jsonData[idx][headline] = "";
} }
@@ -726,10 +780,13 @@ export const getResponseMeta = (
responses.forEach((response) => { responses.forEach((response) => {
Object.entries(response.meta).forEach(([key, value]) => { Object.entries(response.meta).forEach(([key, value]) => {
// skip url
if (key === "url") return;
// Handling nested objects (like userAgent) // Handling nested objects (like userAgent)
if (key === "url") {
if (!meta[key]) {
meta[key] = new Set();
}
return;
}
if (typeof value === "object" && value !== null) { if (typeof value === "object" && value !== null) {
Object.entries(value).forEach(([nestedKey, nestedValue]) => { Object.entries(value).forEach(([nestedKey, nestedValue]) => {
if (typeof nestedValue === "string" && nestedValue) { if (typeof nestedValue === "string" && nestedValue) {
@@ -122,7 +122,6 @@ export const mockUser: TUser = {
name: "mock User", name: "mock User",
email: "test@unit.com", email: "test@unit.com",
emailVerified: currentDate, emailVerified: currentDate,
imageUrl: "https://www.google.com",
createdAt: currentDate, createdAt: currentDate,
updatedAt: currentDate, updatedAt: currentDate,
twoFactorEnabled: false, twoFactorEnabled: false,
+8 -2
View File
@@ -597,8 +597,14 @@ describe("surveyLogic", () => {
type: TSurveyQuestionTypeEnum.Matrix, type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Matrix Question" }, headline: { default: "Matrix Question" },
required: true, required: true,
rows: [{ default: "Row 1" }, { default: "Row 2" }], rows: [
columns: [{ default: "Column 1" }, { default: "Column 2" }], { id: "row-1", label: { default: "Row 1" } },
{ id: "row-2", label: { default: "Row 2" } },
],
columns: [
{ id: "col-1", label: { default: "Column 1" } },
{ id: "col-2", label: { default: "Column 2" } },
],
buttonLabel: { default: "Next" }, buttonLabel: { default: "Next" },
shuffleOption: "none", shuffleOption: "none",
}, },
+7 -3
View File
@@ -502,7 +502,11 @@ const getLeftOperandValue = (
const responseValue = data[leftOperand.value]; const responseValue = data[leftOperand.value];
if (currentQuestion.type === "openText" && currentQuestion.inputType === "number") { if (currentQuestion.type === "openText" && currentQuestion.inputType === "number") {
return Number(responseValue) || undefined; if (responseValue === undefined) return undefined;
if (typeof responseValue === "string" && responseValue.trim() === "") return undefined;
const numberValue = typeof responseValue === "number" ? responseValue : Number(responseValue);
return isNaN(numberValue) ? undefined : numberValue;
} }
if (currentQuestion.type === "multipleChoiceSingle" || currentQuestion.type === "multipleChoiceMulti") { if (currentQuestion.type === "multipleChoiceSingle" || currentQuestion.type === "multipleChoiceMulti") {
@@ -552,14 +556,14 @@ const getLeftOperandValue = (
if (isNaN(rowIndex) || rowIndex < 0 || rowIndex >= currentQuestion.rows.length) { if (isNaN(rowIndex) || rowIndex < 0 || rowIndex >= currentQuestion.rows.length) {
return undefined; return undefined;
} }
const row = getLocalizedValue(currentQuestion.rows[rowIndex], selectedLanguage); const row = getLocalizedValue(currentQuestion.rows[rowIndex].label, selectedLanguage);
const rowValue = responseValue[row]; const rowValue = responseValue[row];
if (rowValue === "") return ""; if (rowValue === "") return "";
if (rowValue) { if (rowValue) {
const columnIndex = currentQuestion.columns.findIndex((column) => { const columnIndex = currentQuestion.columns.findIndex((column) => {
return getLocalizedValue(column, selectedLanguage) === rowValue; return getLocalizedValue(column.label, selectedLanguage) === rowValue;
}); });
if (columnIndex === -1) return undefined; if (columnIndex === -1) return undefined;
return columnIndex.toString(); return columnIndex.toString();
+1 -13
View File
@@ -3,7 +3,7 @@ import { IdentityProvider, Objective, Prisma, Role } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error"; import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TOrganization } from "@formbricks/types/organizations"; import { TOrganization } from "@formbricks/types/organizations";
import { TUserLocale, TUserUpdateInput } from "@formbricks/types/user"; import { TUserLocale, TUserUpdateInput } from "@formbricks/types/user";
import { deleteUser, getUser, getUserByEmail, getUsersWithOrganization, updateUser } from "./service"; import { deleteUser, getUser, getUserByEmail, getUsersWithOrganization, updateUser } from "./service";
@@ -20,10 +20,6 @@ vi.mock("@formbricks/database", () => ({
}, },
})); }));
vi.mock("@/lib/fileValidation", () => ({
isValidImageFile: vi.fn(),
}));
vi.mock("@/lib/organization/service", () => ({ vi.mock("@/lib/organization/service", () => ({
getOrganizationsWhereUserIsSingleOwner: vi.fn(), getOrganizationsWhereUserIsSingleOwner: vi.fn(),
deleteOrganization: vi.fn(), deleteOrganization: vi.fn(),
@@ -39,7 +35,6 @@ describe("User Service", () => {
name: "Test User", name: "Test User",
email: "test@example.com", email: "test@example.com",
emailVerified: new Date(), emailVerified: new Date(),
imageUrl: null,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
role: Role.project_manager, role: Role.project_manager,
@@ -200,13 +195,6 @@ describe("User Service", () => {
await expect(updateUser("nonexistent", { name: "New Name" })).rejects.toThrow(ResourceNotFoundError); await expect(updateUser("nonexistent", { name: "New Name" })).rejects.toThrow(ResourceNotFoundError);
}); });
test("should throw InvalidInputError when invalid image URL is provided", async () => {
const { isValidImageFile } = await import("@/lib/fileValidation");
vi.mocked(isValidImageFile).mockReturnValue(false);
await expect(updateUser("user1", { imageUrl: "invalid-image-url" })).rejects.toThrow(InvalidInputError);
});
}); });
describe("deleteUser", () => { describe("deleteUser", () => {
+1 -4
View File
@@ -1,5 +1,4 @@
import "server-only"; import "server-only";
import { isValidImageFile } from "@/lib/fileValidation";
import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service"; import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { deleteBrevoCustomerByEmail } from "@/modules/auth/lib/brevo"; import { deleteBrevoCustomerByEmail } from "@/modules/auth/lib/brevo";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
@@ -8,7 +7,7 @@ import { z } from "zod";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error"; import { PrismaErrorType } from "@formbricks/database/types/error";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TUser, TUserLocale, TUserUpdateInput, ZUserUpdateInput } from "@formbricks/types/user"; import { TUser, TUserLocale, TUserUpdateInput, ZUserUpdateInput } from "@formbricks/types/user";
import { validateInputs } from "../utils/validate"; import { validateInputs } from "../utils/validate";
@@ -17,7 +16,6 @@ const responseSelection = {
name: true, name: true,
email: true, email: true,
emailVerified: true, emailVerified: true,
imageUrl: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
role: true, role: true,
@@ -79,7 +77,6 @@ export const getUserByEmail = reactCache(async (email: string): Promise<TUser |
// function to update a user's user // function to update a user's user
export const updateUser = async (personId: string, data: TUserUpdateInput): Promise<TUser> => { export const updateUser = async (personId: string, data: TUserUpdateInput): Promise<TUser> => {
validateInputs([personId, ZId], [data, ZUserUpdateInput.partial()]); validateInputs([personId, ZId], [data, ZUserUpdateInput.partial()]);
if (data.imageUrl && !isValidImageFile(data.imageUrl)) throw new InvalidInputError("Invalid image file");
try { try {
const updatedUser = await prisma.user.update({ const updatedUser = await prisma.user.update({
@@ -112,7 +112,6 @@ describe("withAuditLogging", () => {
name: "Test User", name: "Test User",
email: "test@example.com", email: "test@example.com",
emailVerified: null, emailVerified: null,
imageUrl: null,
twoFactorEnabled: false, twoFactorEnabled: false,
identityProvider: "email" as const, identityProvider: "email" as const,
createdAt: new Date(), createdAt: new Date(),
@@ -151,7 +150,6 @@ describe("withAuditLogging", () => {
name: "Test User", name: "Test User",
email: "test@example.com", email: "test@example.com",
emailVerified: null, emailVerified: null,
imageUrl: null,
twoFactorEnabled: false, twoFactorEnabled: false,
identityProvider: "email" as const, identityProvider: "email" as const,
createdAt: new Date(), createdAt: new Date(),
+2 -12
View File
@@ -141,7 +141,6 @@
"apply_filters": "Filter anwenden", "apply_filters": "Filter anwenden",
"are_you_sure": "Bist Du sicher?", "are_you_sure": "Bist Du sicher?",
"attributes": "Attribute", "attributes": "Attribute",
"avatar": "Avatar",
"back": "Zurück", "back": "Zurück",
"billing": "Abrechnung", "billing": "Abrechnung",
"booked": "Gebucht", "booked": "Gebucht",
@@ -748,6 +747,7 @@
"api_key_label": "API-Schlüssel Label", "api_key_label": "API-Schlüssel Label",
"api_key_security_warning": "Aus Sicherheitsgründen wird der API-Schlüssel nur einmal nach der Erstellung angezeigt. Bitte kopiere ihn sofort an einen sicheren Ort.", "api_key_security_warning": "Aus Sicherheitsgründen wird der API-Schlüssel nur einmal nach der Erstellung angezeigt. Bitte kopiere ihn sofort an einen sicheren Ort.",
"api_key_updated": "API-Schlüssel aktualisiert", "api_key_updated": "API-Schlüssel aktualisiert",
"delete_permission": "Berechtigung löschen",
"duplicate_access": "Doppelter Projektzugriff nicht erlaubt", "duplicate_access": "Doppelter Projektzugriff nicht erlaubt",
"no_api_keys_yet": "Du hast noch keine API-Schlüssel", "no_api_keys_yet": "Du hast noch keine API-Schlüssel",
"no_env_permissions_found": "Keine Umgebungsberechtigungen gefunden", "no_env_permissions_found": "Keine Umgebungsberechtigungen gefunden",
@@ -1111,9 +1111,7 @@
}, },
"profile": { "profile": {
"account_deletion_consequences_warning": "Was passiert, wenn Du das Konto löschst", "account_deletion_consequences_warning": "Was passiert, wenn Du das Konto löschst",
"avatar_update_failed": "Aktualisierung des Avatars fehlgeschlagen. Bitte versuche es erneut.",
"backup_code": "Backup-Code", "backup_code": "Backup-Code",
"change_image": "Bild ändern",
"confirm_delete_account": "Lösche dein Konto mit all deinen persönlichen Informationen und Daten", "confirm_delete_account": "Lösche dein Konto mit all deinen persönlichen Informationen und Daten",
"confirm_delete_my_account": "Konto löschen", "confirm_delete_my_account": "Konto löschen",
"confirm_your_current_password_to_get_started": "Bestätige dein aktuelles Passwort, um loszulegen.", "confirm_your_current_password_to_get_started": "Bestätige dein aktuelles Passwort, um loszulegen.",
@@ -1124,17 +1122,13 @@
"email_change_initiated": "Deine Anfrage zur Änderung der E-Mail wurde eingeleitet.", "email_change_initiated": "Deine Anfrage zur Änderung der E-Mail wurde eingeleitet.",
"enable_two_factor_authentication": "Zwei-Faktor-Authentifizierung aktivieren", "enable_two_factor_authentication": "Zwei-Faktor-Authentifizierung aktivieren",
"enter_the_code_from_your_authenticator_app_below": "Gib den Code aus deiner Authentifizierungs-App unten ein.", "enter_the_code_from_your_authenticator_app_below": "Gib den Code aus deiner Authentifizierungs-App unten ein.",
"file_size_must_be_less_than_10mb": "Dateigröße muss weniger als 10MB sein.",
"invalid_file_type": "Ungültiger Dateityp. Nur JPEG-, PNG- und WEBP-Dateien sind erlaubt.",
"lost_access": "Zugriff verloren", "lost_access": "Zugriff verloren",
"or_enter_the_following_code_manually": "Oder gib den folgenden Code manuell ein:", "or_enter_the_following_code_manually": "Oder gib den folgenden Code manuell ein:",
"organization_identification": "Hilf deiner Organisation, Dich auf Formbricks zu identifizieren",
"organizations_delete_message": "Du bist der einzige Besitzer dieser Organisationen, also werden sie <b>auch gelöscht.</b>", "organizations_delete_message": "Du bist der einzige Besitzer dieser Organisationen, also werden sie <b>auch gelöscht.</b>",
"permanent_removal_of_all_of_your_personal_information_and_data": "Dauerhafte Entfernung all deiner persönlichen Informationen und Daten", "permanent_removal_of_all_of_your_personal_information_and_data": "Dauerhafte Entfernung all deiner persönlichen Informationen und Daten",
"personal_information": "Persönliche Informationen", "personal_information": "Persönliche Informationen",
"please_enter_email_to_confirm_account_deletion": "Bitte gib {email} in das folgende Feld ein, um die endgültige Löschung deines Kontos zu bestätigen:", "please_enter_email_to_confirm_account_deletion": "Bitte gib {email} in das folgende Feld ein, um die endgültige Löschung deines Kontos zu bestätigen:",
"profile_updated_successfully": "Dein Profil wurde erfolgreich aktualisiert", "profile_updated_successfully": "Dein Profil wurde erfolgreich aktualisiert",
"remove_image": "Bild entfernen",
"save_the_following_backup_codes_in_a_safe_place": "Speichere die folgenden Backup-Codes an einem sicheren Ort.", "save_the_following_backup_codes_in_a_safe_place": "Speichere die folgenden Backup-Codes an einem sicheren Ort.",
"scan_the_qr_code_below_with_your_authenticator_app": "Scanne den QR-Code unten mit deiner Authentifizierungs-App.", "scan_the_qr_code_below_with_your_authenticator_app": "Scanne den QR-Code unten mit deiner Authentifizierungs-App.",
"security_description": "Verwalte dein Passwort und andere Sicherheitseinstellungen wie Zwei-Faktor-Authentifizierung (2FA).", "security_description": "Verwalte dein Passwort und andere Sicherheitseinstellungen wie Zwei-Faktor-Authentifizierung (2FA).",
@@ -1144,10 +1138,8 @@
"two_factor_code": "Zwei-Faktor-Code", "two_factor_code": "Zwei-Faktor-Code",
"unlock_two_factor_authentication": "Zwei-Faktor-Authentifizierung mit einem höheren Plan freischalten", "unlock_two_factor_authentication": "Zwei-Faktor-Authentifizierung mit einem höheren Plan freischalten",
"update_personal_info": "Persönliche Daten aktualisieren", "update_personal_info": "Persönliche Daten aktualisieren",
"upload_image": "Bild hochladen",
"warning_cannot_delete_account": "Du bist der einzige Besitzer dieser Organisation. Bitte übertrage das Eigentum zuerst an ein anderes Mitglied.", "warning_cannot_delete_account": "Du bist der einzige Besitzer dieser Organisation. Bitte übertrage das Eigentum zuerst an ein anderes Mitglied.",
"warning_cannot_undo": "Das kann nicht rückgängig gemacht werden", "warning_cannot_undo": "Das kann nicht rückgängig gemacht werden"
"you_must_select_a_file": "Du musst eine Datei auswählen."
}, },
"teams": { "teams": {
"add_members_description": "Füge Mitglieder zum Team hinzu und bestimme ihre Rolle.", "add_members_description": "Füge Mitglieder zum Team hinzu und bestimme ihre Rolle.",
@@ -1715,10 +1707,8 @@
"language_help_text": "Die Meta-Daten werden basierend auf dem `lang` Wert in der URL geladen.", "language_help_text": "Die Meta-Daten werden basierend auf dem `lang` Wert in der URL geladen.",
"link_description": "Linkbeschreibung", "link_description": "Linkbeschreibung",
"link_description_description": "Beschreibung mit 55-200 Zeichen funktionieren am besten.", "link_description_description": "Beschreibung mit 55-200 Zeichen funktionieren am besten.",
"link_description_placeholder": "Hilf uns, indem du deine Gedanken teilst.",
"link_title": "Linktitel", "link_title": "Linktitel",
"link_title_description": "Kurze Titel funktionieren am besten als Meta-Titel.", "link_title_description": "Kurze Titel funktionieren am besten als Meta-Titel.",
"link_title_placeholder": "Kundenfeedback-Umfrage",
"preview_image": "Vorschaubild", "preview_image": "Vorschaubild",
"preview_image_description": "Querformatige Bilder mit kleiner Dateigröße (<4MB) funktionieren am besten.", "preview_image_description": "Querformatige Bilder mit kleiner Dateigröße (<4MB) funktionieren am besten.",
"title": "Link-Einstellungen" "title": "Link-Einstellungen"
+2 -12
View File
@@ -141,7 +141,6 @@
"apply_filters": "Apply filters", "apply_filters": "Apply filters",
"are_you_sure": "Are you sure?", "are_you_sure": "Are you sure?",
"attributes": "Attributes", "attributes": "Attributes",
"avatar": "Avatar",
"back": "Back", "back": "Back",
"billing": "Billing", "billing": "Billing",
"booked": "Booked", "booked": "Booked",
@@ -748,6 +747,7 @@
"api_key_label": "API Key Label", "api_key_label": "API Key Label",
"api_key_security_warning": "For security reasons, the API key will only be shown once after creation. Please copy it to your destination right away.", "api_key_security_warning": "For security reasons, the API key will only be shown once after creation. Please copy it to your destination right away.",
"api_key_updated": "API Key updated", "api_key_updated": "API Key updated",
"delete_permission": "Delete permission",
"duplicate_access": "Duplicate project access not allowed", "duplicate_access": "Duplicate project access not allowed",
"no_api_keys_yet": "You don't have any API keys yet", "no_api_keys_yet": "You don't have any API keys yet",
"no_env_permissions_found": "No environment permissions found", "no_env_permissions_found": "No environment permissions found",
@@ -1111,9 +1111,7 @@
}, },
"profile": { "profile": {
"account_deletion_consequences_warning": "Account deletion consequences", "account_deletion_consequences_warning": "Account deletion consequences",
"avatar_update_failed": "Avatar update failed. Please try again.",
"backup_code": "Backup Code", "backup_code": "Backup Code",
"change_image": "Change image",
"confirm_delete_account": "Delete your account with all of your personal information and data", "confirm_delete_account": "Delete your account with all of your personal information and data",
"confirm_delete_my_account": "Delete My Account", "confirm_delete_my_account": "Delete My Account",
"confirm_your_current_password_to_get_started": "Confirm your current password to get started.", "confirm_your_current_password_to_get_started": "Confirm your current password to get started.",
@@ -1124,17 +1122,13 @@
"email_change_initiated": "Your email change request has been initiated.", "email_change_initiated": "Your email change request has been initiated.",
"enable_two_factor_authentication": "Enable two factor authentication", "enable_two_factor_authentication": "Enable two factor authentication",
"enter_the_code_from_your_authenticator_app_below": "Enter the code from your authenticator app below.", "enter_the_code_from_your_authenticator_app_below": "Enter the code from your authenticator app below.",
"file_size_must_be_less_than_10mb": "File size must be less than 10MB.",
"invalid_file_type": "Invalid file type. Only JPEG, PNG, and WEBP files are allowed.",
"lost_access": "Lost access", "lost_access": "Lost access",
"or_enter_the_following_code_manually": "Or enter the following code manually:", "or_enter_the_following_code_manually": "Or enter the following code manually:",
"organization_identification": "Assist your organization in identifying you on Formbricks",
"organizations_delete_message": "You are the only owner of these organizations, so they <b>will be deleted as well.</b>", "organizations_delete_message": "You are the only owner of these organizations, so they <b>will be deleted as well.</b>",
"permanent_removal_of_all_of_your_personal_information_and_data": "Permanent removal of all of your personal information and data", "permanent_removal_of_all_of_your_personal_information_and_data": "Permanent removal of all of your personal information and data",
"personal_information": "Personal information", "personal_information": "Personal information",
"please_enter_email_to_confirm_account_deletion": "Please enter {email} in the following field to confirm the definitive deletion of your account:", "please_enter_email_to_confirm_account_deletion": "Please enter {email} in the following field to confirm the definitive deletion of your account:",
"profile_updated_successfully": "Your profile was updated successfully", "profile_updated_successfully": "Your profile was updated successfully",
"remove_image": "Remove image",
"save_the_following_backup_codes_in_a_safe_place": "Save the following backup codes in a safe place.", "save_the_following_backup_codes_in_a_safe_place": "Save the following backup codes in a safe place.",
"scan_the_qr_code_below_with_your_authenticator_app": "Scan the QR code below with your authenticator app.", "scan_the_qr_code_below_with_your_authenticator_app": "Scan the QR code below with your authenticator app.",
"security_description": "Manage your password and other security settings like two-factor authentication (2FA).", "security_description": "Manage your password and other security settings like two-factor authentication (2FA).",
@@ -1144,10 +1138,8 @@
"two_factor_code": "Two-Factor Code", "two_factor_code": "Two-Factor Code",
"unlock_two_factor_authentication": "Unlock two-factor authentication with a higher plan", "unlock_two_factor_authentication": "Unlock two-factor authentication with a higher plan",
"update_personal_info": "Update your personal information", "update_personal_info": "Update your personal information",
"upload_image": "Upload image",
"warning_cannot_delete_account": "You are the only owner of this organization. Please transfer ownership to another member first.", "warning_cannot_delete_account": "You are the only owner of this organization. Please transfer ownership to another member first.",
"warning_cannot_undo": "This cannot be undone", "warning_cannot_undo": "This cannot be undone"
"you_must_select_a_file": "You must select a file."
}, },
"teams": { "teams": {
"add_members_description": "Add members to the team and determine their role.", "add_members_description": "Add members to the team and determine their role.",
@@ -1715,10 +1707,8 @@
"language_help_text": "The meta data is loaded based on the `lang` value in the URL.", "language_help_text": "The meta data is loaded based on the `lang` value in the URL.",
"link_description": "Link description", "link_description": "Link description",
"link_description_description": "Descriptions between 55-200 characters perform best.", "link_description_description": "Descriptions between 55-200 characters perform best.",
"link_description_placeholder": "Help us improve by sharing your thoughts.",
"link_title": "Link title", "link_title": "Link title",
"link_title_description": "Short titles perform best as Meta Titles.", "link_title_description": "Short titles perform best as Meta Titles.",
"link_title_placeholder": "Customer Feedback Survey",
"preview_image": "Preview image", "preview_image": "Preview image",
"preview_image_description": "Landscape images with small file sizes (<4MB) perform best.", "preview_image_description": "Landscape images with small file sizes (<4MB) perform best.",
"title": "Link settings" "title": "Link settings"
+2 -12
View File
@@ -141,7 +141,6 @@
"apply_filters": "Appliquer des filtres", "apply_filters": "Appliquer des filtres",
"are_you_sure": "Es-tu sûr ?", "are_you_sure": "Es-tu sûr ?",
"attributes": "Attributs", "attributes": "Attributs",
"avatar": "Avatar",
"back": "Retour", "back": "Retour",
"billing": "Facturation", "billing": "Facturation",
"booked": "Réservé", "booked": "Réservé",
@@ -748,6 +747,7 @@
"api_key_label": "Étiquette de clé API", "api_key_label": "Étiquette de clé API",
"api_key_security_warning": "Pour des raisons de sécurité, la clé API ne sera affichée qu'une seule fois après sa création. Veuillez la copier immédiatement à votre destination.", "api_key_security_warning": "Pour des raisons de sécurité, la clé API ne sera affichée qu'une seule fois après sa création. Veuillez la copier immédiatement à votre destination.",
"api_key_updated": "Clé API mise à jour", "api_key_updated": "Clé API mise à jour",
"delete_permission": "Supprimer une permission",
"duplicate_access": "L'accès en double au projet n'est pas autorisé", "duplicate_access": "L'accès en double au projet n'est pas autorisé",
"no_api_keys_yet": "Vous n'avez pas encore de clés API.", "no_api_keys_yet": "Vous n'avez pas encore de clés API.",
"no_env_permissions_found": "Aucune autorisation d'environnement trouvée", "no_env_permissions_found": "Aucune autorisation d'environnement trouvée",
@@ -1111,9 +1111,7 @@
}, },
"profile": { "profile": {
"account_deletion_consequences_warning": "Conséquences de la suppression de compte", "account_deletion_consequences_warning": "Conséquences de la suppression de compte",
"avatar_update_failed": "La mise à jour de l'avatar a échoué. Veuillez réessayer.",
"backup_code": "Code de sauvegarde", "backup_code": "Code de sauvegarde",
"change_image": "Changer l'image",
"confirm_delete_account": "Supprimez votre compte avec toutes vos informations personnelles et données.", "confirm_delete_account": "Supprimez votre compte avec toutes vos informations personnelles et données.",
"confirm_delete_my_account": "Supprimer mon compte", "confirm_delete_my_account": "Supprimer mon compte",
"confirm_your_current_password_to_get_started": "Confirmez votre mot de passe actuel pour commencer.", "confirm_your_current_password_to_get_started": "Confirmez votre mot de passe actuel pour commencer.",
@@ -1124,17 +1122,13 @@
"email_change_initiated": "Votre demande de changement d'email a été initiée.", "email_change_initiated": "Votre demande de changement d'email a été initiée.",
"enable_two_factor_authentication": "Activer l'authentification à deux facteurs", "enable_two_factor_authentication": "Activer l'authentification à deux facteurs",
"enter_the_code_from_your_authenticator_app_below": "Entrez le code de votre application d'authentification ci-dessous.", "enter_the_code_from_your_authenticator_app_below": "Entrez le code de votre application d'authentification ci-dessous.",
"file_size_must_be_less_than_10mb": "La taille du fichier doit être inférieure à 10 Mo.",
"invalid_file_type": "Type de fichier invalide. Seuls les fichiers JPEG, PNG et WEBP sont autorisés.",
"lost_access": "Accès perdu", "lost_access": "Accès perdu",
"or_enter_the_following_code_manually": "Ou entrez le code suivant manuellement :", "or_enter_the_following_code_manually": "Ou entrez le code suivant manuellement :",
"organization_identification": "Aidez votre organisation à vous identifier sur Formbricks",
"organizations_delete_message": "Tu es le seul propriétaire de ces organisations, elles <b>seront aussi supprimées.</b>", "organizations_delete_message": "Tu es le seul propriétaire de ces organisations, elles <b>seront aussi supprimées.</b>",
"permanent_removal_of_all_of_your_personal_information_and_data": "Suppression permanente de toutes vos informations et données personnelles.", "permanent_removal_of_all_of_your_personal_information_and_data": "Suppression permanente de toutes vos informations et données personnelles.",
"personal_information": "Informations personnelles", "personal_information": "Informations personnelles",
"please_enter_email_to_confirm_account_deletion": "Veuillez entrer {email} dans le champ suivant pour confirmer la suppression définitive de votre compte :", "please_enter_email_to_confirm_account_deletion": "Veuillez entrer {email} dans le champ suivant pour confirmer la suppression définitive de votre compte :",
"profile_updated_successfully": "Votre profil a été mis à jour avec succès.", "profile_updated_successfully": "Votre profil a été mis à jour avec succès.",
"remove_image": "Supprimer l'image",
"save_the_following_backup_codes_in_a_safe_place": "Enregistrez les codes de sauvegarde suivants dans un endroit sûr.", "save_the_following_backup_codes_in_a_safe_place": "Enregistrez les codes de sauvegarde suivants dans un endroit sûr.",
"scan_the_qr_code_below_with_your_authenticator_app": "Scannez le code QR ci-dessous avec votre application d'authentification.", "scan_the_qr_code_below_with_your_authenticator_app": "Scannez le code QR ci-dessous avec votre application d'authentification.",
"security_description": "Gérez votre mot de passe et d'autres paramètres de sécurité comme l'authentification à deux facteurs (2FA).", "security_description": "Gérez votre mot de passe et d'autres paramètres de sécurité comme l'authentification à deux facteurs (2FA).",
@@ -1144,10 +1138,8 @@
"two_factor_code": "Code à deux facteurs", "two_factor_code": "Code à deux facteurs",
"unlock_two_factor_authentication": "Débloquez l'authentification à deux facteurs avec une offre supérieure", "unlock_two_factor_authentication": "Débloquez l'authentification à deux facteurs avec une offre supérieure",
"update_personal_info": "Mettez à jour vos informations personnelles", "update_personal_info": "Mettez à jour vos informations personnelles",
"upload_image": "Télécharger l'image",
"warning_cannot_delete_account": "Tu es le seul propriétaire de cette organisation. Transfère la propriété à un autre membre d'abord.", "warning_cannot_delete_account": "Tu es le seul propriétaire de cette organisation. Transfère la propriété à un autre membre d'abord.",
"warning_cannot_undo": "Ceci ne peut pas être annulé", "warning_cannot_undo": "Ceci ne peut pas être annulé"
"you_must_select_a_file": "Vous devez sélectionner un fichier."
}, },
"teams": { "teams": {
"add_members_description": "Ajoutez des membres à l'équipe et déterminez leur rôle.", "add_members_description": "Ajoutez des membres à l'équipe et déterminez leur rôle.",
@@ -1715,10 +1707,8 @@
"language_help_text": "Les métadonnées sont chargées en fonction de la valeur « lang » dans l'URL.", "language_help_text": "Les métadonnées sont chargées en fonction de la valeur « lang » dans l'URL.",
"link_description": "Description du lien", "link_description": "Description du lien",
"link_description_description": "« Les descriptions entre 55 et 200 caractères donnent les meilleurs résultats. »", "link_description_description": "« Les descriptions entre 55 et 200 caractères donnent les meilleurs résultats. »",
"link_description_placeholder": "Aidez-nous à nous améliorer en partageant vos pensées.",
"link_title": "Titre du lien", "link_title": "Titre du lien",
"link_title_description": "Les titres courts fonctionnent mieux comme titres méta.", "link_title_description": "Les titres courts fonctionnent mieux comme titres méta.",
"link_title_placeholder": "Sondage de Retour Clients",
"preview_image": "Aperçu de l'image", "preview_image": "Aperçu de l'image",
"preview_image_description": "Les images en paysage avec de petites tailles de fichier (<4MB) fonctionnent le mieux.", "preview_image_description": "Les images en paysage avec de petites tailles de fichier (<4MB) fonctionnent le mieux.",
"title": "Paramètres de lien" "title": "Paramètres de lien"
+2 -12
View File
@@ -141,7 +141,6 @@
"apply_filters": "Aplicar filtros", "apply_filters": "Aplicar filtros",
"are_you_sure": "Certeza?", "are_you_sure": "Certeza?",
"attributes": "atributos", "attributes": "atributos",
"avatar": "Avatar",
"back": "Voltar", "back": "Voltar",
"billing": "Faturamento", "billing": "Faturamento",
"booked": "Reservado", "booked": "Reservado",
@@ -748,6 +747,7 @@
"api_key_label": "Rótulo da Chave API", "api_key_label": "Rótulo da Chave API",
"api_key_security_warning": "Por motivos de segurança, a chave da API será mostrada apenas uma vez após a criação. Por favor, copie-a para o seu destino imediatamente.", "api_key_security_warning": "Por motivos de segurança, a chave da API será mostrada apenas uma vez após a criação. Por favor, copie-a para o seu destino imediatamente.",
"api_key_updated": "Chave de API atualizada", "api_key_updated": "Chave de API atualizada",
"delete_permission": "Remover permissão",
"duplicate_access": "Acesso duplicado ao projeto não permitido", "duplicate_access": "Acesso duplicado ao projeto não permitido",
"no_api_keys_yet": "Você ainda não tem nenhuma chave de API", "no_api_keys_yet": "Você ainda não tem nenhuma chave de API",
"no_env_permissions_found": "Nenhuma permissão de ambiente encontrada", "no_env_permissions_found": "Nenhuma permissão de ambiente encontrada",
@@ -1111,9 +1111,7 @@
}, },
"profile": { "profile": {
"account_deletion_consequences_warning": "Consequências da exclusão da conta", "account_deletion_consequences_warning": "Consequências da exclusão da conta",
"avatar_update_failed": "Falha ao atualizar o avatar. Por favor, tente novamente.",
"backup_code": "Código de Backup", "backup_code": "Código de Backup",
"change_image": "Mudar imagem",
"confirm_delete_account": "Apague sua conta com todas as suas informações pessoais e dados", "confirm_delete_account": "Apague sua conta com todas as suas informações pessoais e dados",
"confirm_delete_my_account": "Excluir Minha Conta", "confirm_delete_my_account": "Excluir Minha Conta",
"confirm_your_current_password_to_get_started": "Confirme sua senha atual para começar.", "confirm_your_current_password_to_get_started": "Confirme sua senha atual para começar.",
@@ -1124,17 +1122,13 @@
"email_change_initiated": "Sua solicitação de alteração de e-mail foi iniciada.", "email_change_initiated": "Sua solicitação de alteração de e-mail foi iniciada.",
"enable_two_factor_authentication": "Ativar autenticação de dois fatores", "enable_two_factor_authentication": "Ativar autenticação de dois fatores",
"enter_the_code_from_your_authenticator_app_below": "Digite o código do seu app autenticador abaixo.", "enter_the_code_from_your_authenticator_app_below": "Digite o código do seu app autenticador abaixo.",
"file_size_must_be_less_than_10mb": "O tamanho do arquivo deve ser menor que 10MB.",
"invalid_file_type": "Tipo de arquivo inválido. Só são permitidos arquivos JPEG, PNG e WEBP.",
"lost_access": "Perdi o acesso", "lost_access": "Perdi o acesso",
"or_enter_the_following_code_manually": "Ou insira o seguinte código manualmente:", "or_enter_the_following_code_manually": "Ou insira o seguinte código manualmente:",
"organization_identification": "Ajude sua organização a te identificar no Formbricks",
"organizations_delete_message": "Você é o único dono dessas organizações, então elas <b>também serão apagadas.</b>", "organizations_delete_message": "Você é o único dono dessas organizações, então elas <b>também serão apagadas.</b>",
"permanent_removal_of_all_of_your_personal_information_and_data": "Remoção permanente de todas as suas informações e dados pessoais", "permanent_removal_of_all_of_your_personal_information_and_data": "Remoção permanente de todas as suas informações e dados pessoais",
"personal_information": "Informações pessoais", "personal_information": "Informações pessoais",
"please_enter_email_to_confirm_account_deletion": "Por favor, insira {email} no campo abaixo para confirmar a exclusão definitiva da sua conta:", "please_enter_email_to_confirm_account_deletion": "Por favor, insira {email} no campo abaixo para confirmar a exclusão definitiva da sua conta:",
"profile_updated_successfully": "Seu perfil foi atualizado com sucesso", "profile_updated_successfully": "Seu perfil foi atualizado com sucesso",
"remove_image": "Remover imagem",
"save_the_following_backup_codes_in_a_safe_place": "Guarde os seguintes códigos de backup em um lugar seguro.", "save_the_following_backup_codes_in_a_safe_place": "Guarde os seguintes códigos de backup em um lugar seguro.",
"scan_the_qr_code_below_with_your_authenticator_app": "Escaneie o código QR abaixo com seu app autenticador.", "scan_the_qr_code_below_with_your_authenticator_app": "Escaneie o código QR abaixo com seu app autenticador.",
"security_description": "Gerencie sua senha e outras configurações de segurança como a autenticação de dois fatores (2FA).", "security_description": "Gerencie sua senha e outras configurações de segurança como a autenticação de dois fatores (2FA).",
@@ -1144,10 +1138,8 @@
"two_factor_code": "Código de Dois Fatores", "two_factor_code": "Código de Dois Fatores",
"unlock_two_factor_authentication": "Desbloqueia a autenticação de dois fatores com um plano melhor", "unlock_two_factor_authentication": "Desbloqueia a autenticação de dois fatores com um plano melhor",
"update_personal_info": "Atualize suas informações pessoais", "update_personal_info": "Atualize suas informações pessoais",
"upload_image": "Enviar imagem",
"warning_cannot_delete_account": "Você é o único dono desta organização. Transfere a propriedade para outra pessoa primeiro.", "warning_cannot_delete_account": "Você é o único dono desta organização. Transfere a propriedade para outra pessoa primeiro.",
"warning_cannot_undo": "Isso não pode ser desfeito", "warning_cannot_undo": "Isso não pode ser desfeito"
"you_must_select_a_file": "Você tem que selecionar um arquivo."
}, },
"teams": { "teams": {
"add_members_description": "Adicione membros à equipe e determine sua função.", "add_members_description": "Adicione membros à equipe e determine sua função.",
@@ -1715,10 +1707,8 @@
"language_help_text": "Os metadados são carregados com base no valor `lang` na URL.", "language_help_text": "Os metadados são carregados com base no valor `lang` na URL.",
"link_description": "Descrição do link", "link_description": "Descrição do link",
"link_description_description": "\"Descrições entre 55-200 caracteres têm um melhor desempenho.\"", "link_description_description": "\"Descrições entre 55-200 caracteres têm um melhor desempenho.\"",
"link_description_placeholder": "Ajude-nos a melhorar compartilhando suas opiniões.",
"link_title": "Título do link", "link_title": "Título do link",
"link_title_description": "Títulos curtos têm melhor desempenho como Meta Títulos.", "link_title_description": "Títulos curtos têm melhor desempenho como Meta Títulos.",
"link_title_placeholder": "Pesquisa de Feedback do Cliente",
"preview_image": "Imagem de prévia", "preview_image": "Imagem de prévia",
"preview_image_description": "Imagens em paisagem com tamanhos de arquivo pequenos (<4MB) têm o melhor desempenho.", "preview_image_description": "Imagens em paisagem com tamanhos de arquivo pequenos (<4MB) têm o melhor desempenho.",
"title": "Configurações de link" "title": "Configurações de link"
+2 -12
View File
@@ -141,7 +141,6 @@
"apply_filters": "Aplicar filtros", "apply_filters": "Aplicar filtros",
"are_you_sure": "Tem a certeza?", "are_you_sure": "Tem a certeza?",
"attributes": "Atributos", "attributes": "Atributos",
"avatar": "Avatar",
"back": "Voltar", "back": "Voltar",
"billing": "Faturação", "billing": "Faturação",
"booked": "Reservado", "booked": "Reservado",
@@ -748,6 +747,7 @@
"api_key_label": "Etiqueta da Chave API", "api_key_label": "Etiqueta da Chave API",
"api_key_security_warning": "Por razões de segurança, a chave API será mostrada apenas uma vez após a criação. Por favor, copie-a para o seu destino imediatamente.", "api_key_security_warning": "Por razões de segurança, a chave API será mostrada apenas uma vez após a criação. Por favor, copie-a para o seu destino imediatamente.",
"api_key_updated": "Chave API atualizada", "api_key_updated": "Chave API atualizada",
"delete_permission": "Eliminar permissão",
"duplicate_access": "Acesso duplicado ao projeto não permitido", "duplicate_access": "Acesso duplicado ao projeto não permitido",
"no_api_keys_yet": "Ainda não tem nenhuma chave API", "no_api_keys_yet": "Ainda não tem nenhuma chave API",
"no_env_permissions_found": "Nenhuma permissão de ambiente encontrada", "no_env_permissions_found": "Nenhuma permissão de ambiente encontrada",
@@ -1111,9 +1111,7 @@
}, },
"profile": { "profile": {
"account_deletion_consequences_warning": "Consequências da eliminação da conta", "account_deletion_consequences_warning": "Consequências da eliminação da conta",
"avatar_update_failed": "Falha na atualização do avatar. Por favor, tente novamente.",
"backup_code": "Código de Backup", "backup_code": "Código de Backup",
"change_image": "Alterar imagem",
"confirm_delete_account": "Eliminar a sua conta com todas as suas informações e dados pessoais", "confirm_delete_account": "Eliminar a sua conta com todas as suas informações e dados pessoais",
"confirm_delete_my_account": "Eliminar a Minha Conta", "confirm_delete_my_account": "Eliminar a Minha Conta",
"confirm_your_current_password_to_get_started": "Confirme a sua palavra-passe atual para começar.", "confirm_your_current_password_to_get_started": "Confirme a sua palavra-passe atual para começar.",
@@ -1124,17 +1122,13 @@
"email_change_initiated": "O seu pedido de alteração de email foi iniciado.", "email_change_initiated": "O seu pedido de alteração de email foi iniciado.",
"enable_two_factor_authentication": "Ativar autenticação de dois fatores", "enable_two_factor_authentication": "Ativar autenticação de dois fatores",
"enter_the_code_from_your_authenticator_app_below": "Introduza o código da sua aplicação de autenticação abaixo.", "enter_the_code_from_your_authenticator_app_below": "Introduza o código da sua aplicação de autenticação abaixo.",
"file_size_must_be_less_than_10mb": "O tamanho do ficheiro deve ser inferior a 10MB.",
"invalid_file_type": "Tipo de ficheiro inválido. Apenas são permitidos ficheiros JPEG, PNG e WEBP.",
"lost_access": "Perdeu o acesso", "lost_access": "Perdeu o acesso",
"or_enter_the_following_code_manually": "Ou insira o seguinte código manualmente:", "or_enter_the_following_code_manually": "Ou insira o seguinte código manualmente:",
"organization_identification": "Ajude a sua organização a identificá-lo no Formbricks",
"organizations_delete_message": "É o único proprietário destas organizações, por isso <b>também serão eliminadas.</b>", "organizations_delete_message": "É o único proprietário destas organizações, por isso <b>também serão eliminadas.</b>",
"permanent_removal_of_all_of_your_personal_information_and_data": "Remoção permanente de todas as suas informações e dados pessoais", "permanent_removal_of_all_of_your_personal_information_and_data": "Remoção permanente de todas as suas informações e dados pessoais",
"personal_information": "Informações pessoais", "personal_information": "Informações pessoais",
"please_enter_email_to_confirm_account_deletion": "Por favor, insira {email} no campo seguinte para confirmar a eliminação definitiva da sua conta:", "please_enter_email_to_confirm_account_deletion": "Por favor, insira {email} no campo seguinte para confirmar a eliminação definitiva da sua conta:",
"profile_updated_successfully": "O seu perfil foi atualizado com sucesso", "profile_updated_successfully": "O seu perfil foi atualizado com sucesso",
"remove_image": "Remover imagem",
"save_the_following_backup_codes_in_a_safe_place": "Guarde os seguintes códigos de backup num local seguro.", "save_the_following_backup_codes_in_a_safe_place": "Guarde os seguintes códigos de backup num local seguro.",
"scan_the_qr_code_below_with_your_authenticator_app": "Digitalize o código QR abaixo com a sua aplicação de autenticação.", "scan_the_qr_code_below_with_your_authenticator_app": "Digitalize o código QR abaixo com a sua aplicação de autenticação.",
"security_description": "Gerir a sua palavra-passe e outras definições de segurança, como a autenticação de dois fatores (2FA).", "security_description": "Gerir a sua palavra-passe e outras definições de segurança, como a autenticação de dois fatores (2FA).",
@@ -1144,10 +1138,8 @@
"two_factor_code": "Código de Dois Fatores", "two_factor_code": "Código de Dois Fatores",
"unlock_two_factor_authentication": "Desbloqueie a autenticação de dois fatores com um plano superior", "unlock_two_factor_authentication": "Desbloqueie a autenticação de dois fatores com um plano superior",
"update_personal_info": "Atualize as suas informações pessoais", "update_personal_info": "Atualize as suas informações pessoais",
"upload_image": "Carregar imagem",
"warning_cannot_delete_account": "É o único proprietário desta organização. Transfira a propriedade para outro membro primeiro.", "warning_cannot_delete_account": "É o único proprietário desta organização. Transfira a propriedade para outro membro primeiro.",
"warning_cannot_undo": "Isto não pode ser desfeito", "warning_cannot_undo": "Isto não pode ser desfeito"
"you_must_select_a_file": "Deve selecionar um ficheiro."
}, },
"teams": { "teams": {
"add_members_description": "Adicionar membros à equipa e determinar o seu papel.", "add_members_description": "Adicionar membros à equipa e determinar o seu papel.",
@@ -1715,10 +1707,8 @@
"language_help_text": "Os metadados são carregados com base no valor `lang` no URL.", "language_help_text": "Os metadados são carregados com base no valor `lang` no URL.",
"link_description": "Descrição do link", "link_description": "Descrição do link",
"link_description_description": "Descrições entre 55 a 200 caracteres têm melhor desempenho.", "link_description_description": "Descrições entre 55 a 200 caracteres têm melhor desempenho.",
"link_description_placeholder": "Ajude-nos a melhorar compartilhando suas opiniões.",
"link_title": "Título do Link", "link_title": "Título do Link",
"link_title_description": "Títulos curtos têm melhor desempenho como Meta Titles.", "link_title_description": "Títulos curtos têm melhor desempenho como Meta Titles.",
"link_title_placeholder": "Inquérito de Feedback do Cliente",
"preview_image": "Pré-visualização da imagem", "preview_image": "Pré-visualização da imagem",
"preview_image_description": "Imagens de paisagem com tamanhos pequenos (<4MB) apresentam melhor desempenho.", "preview_image_description": "Imagens de paisagem com tamanhos pequenos (<4MB) apresentam melhor desempenho.",
"title": "Definições de ligação" "title": "Definições de ligação"
+2 -12
View File
@@ -141,7 +141,6 @@
"apply_filters": "Aplică filtre", "apply_filters": "Aplică filtre",
"are_you_sure": "Ești sigur?", "are_you_sure": "Ești sigur?",
"attributes": "Atribute", "attributes": "Atribute",
"avatar": "Avatar",
"back": "Înapoi", "back": "Înapoi",
"billing": "Facturare", "billing": "Facturare",
"booked": "Rezervat", "booked": "Rezervat",
@@ -748,6 +747,7 @@
"api_key_label": "Etichetă Cheie API", "api_key_label": "Etichetă Cheie API",
"api_key_security_warning": "Din motive de securitate, cheia API va fi afișată o singură dată după creare. Vă rugăm să o copiați imediat la destinație.", "api_key_security_warning": "Din motive de securitate, cheia API va fi afișată o singură dată după creare. Vă rugăm să o copiați imediat la destinație.",
"api_key_updated": "Cheie API actualizată", "api_key_updated": "Cheie API actualizată",
"delete_permission": "Șterge permisiunea",
"duplicate_access": "Accesul dublu la proiect nu este permis", "duplicate_access": "Accesul dublu la proiect nu este permis",
"no_api_keys_yet": "Nu aveți încă chei API", "no_api_keys_yet": "Nu aveți încă chei API",
"no_env_permissions_found": "Nu s-au găsit permisiuni pentru mediu", "no_env_permissions_found": "Nu s-au găsit permisiuni pentru mediu",
@@ -1111,9 +1111,7 @@
}, },
"profile": { "profile": {
"account_deletion_consequences_warning": "Consecințele ștergerii contului", "account_deletion_consequences_warning": "Consecințele ștergerii contului",
"avatar_update_failed": "Actualizarea avatarului a eșuat. Vă rugăm să încercați din nou.",
"backup_code": "Cod de rezervă", "backup_code": "Cod de rezervă",
"change_image": "Schimbă imaginea",
"confirm_delete_account": "Șterge contul tău cu toate informațiile personale și datele tale", "confirm_delete_account": "Șterge contul tău cu toate informațiile personale și datele tale",
"confirm_delete_my_account": "Șterge Contul Meu", "confirm_delete_my_account": "Șterge Contul Meu",
"confirm_your_current_password_to_get_started": "Confirmaţi parola curentă pentru a începe.", "confirm_your_current_password_to_get_started": "Confirmaţi parola curentă pentru a începe.",
@@ -1124,17 +1122,13 @@
"email_change_initiated": "Cererea dvs. de schimbare a e-mailului a fost inițiată.", "email_change_initiated": "Cererea dvs. de schimbare a e-mailului a fost inițiată.",
"enable_two_factor_authentication": "Activează autentificarea în doi pași", "enable_two_factor_authentication": "Activează autentificarea în doi pași",
"enter_the_code_from_your_authenticator_app_below": "Introduceți codul din aplicația dvs. de autentificare mai jos.", "enter_the_code_from_your_authenticator_app_below": "Introduceți codul din aplicația dvs. de autentificare mai jos.",
"file_size_must_be_less_than_10mb": "Dimensiunea fișierului trebuie să fie mai mică de 10MB.",
"invalid_file_type": "Tip de fișier invalid. Sunt permise numai fișiere JPEG, PNG și WEBP.",
"lost_access": "Acces pierdut", "lost_access": "Acces pierdut",
"or_enter_the_following_code_manually": "Sau introduceți manual următorul cod:", "or_enter_the_following_code_manually": "Sau introduceți manual următorul cod:",
"organization_identification": "Ajutați organizația să vă identifice pe Formbricks",
"organizations_delete_message": "Ești singurul proprietar al acestor organizații, deci ele <b>vor fi șterse și ele.</b>", "organizations_delete_message": "Ești singurul proprietar al acestor organizații, deci ele <b>vor fi șterse și ele.</b>",
"permanent_removal_of_all_of_your_personal_information_and_data": "Ștergerea permanentă a tuturor informațiilor și datelor tale personale", "permanent_removal_of_all_of_your_personal_information_and_data": "Ștergerea permanentă a tuturor informațiilor și datelor tale personale",
"personal_information": "Informații personale", "personal_information": "Informații personale",
"please_enter_email_to_confirm_account_deletion": "Vă rugăm să introduceți {email} în câmpul următor pentru a confirma ștergerea definitivă a contului dumneavoastră:", "please_enter_email_to_confirm_account_deletion": "Vă rugăm să introduceți {email} în câmpul următor pentru a confirma ștergerea definitivă a contului dumneavoastră:",
"profile_updated_successfully": "Profilul dvs. a fost actualizat cu succes", "profile_updated_successfully": "Profilul dvs. a fost actualizat cu succes",
"remove_image": "Șterge imaginea",
"save_the_following_backup_codes_in_a_safe_place": "Salvează următoarele coduri de rezervă într-un loc sigur.", "save_the_following_backup_codes_in_a_safe_place": "Salvează următoarele coduri de rezervă într-un loc sigur.",
"scan_the_qr_code_below_with_your_authenticator_app": "Scanați codul QR de mai jos cu aplicația dvs. de autentificare.", "scan_the_qr_code_below_with_your_authenticator_app": "Scanați codul QR de mai jos cu aplicația dvs. de autentificare.",
"security_description": "Gestionează parola și alte setări de securitate, precum autentificarea în doi pași (2FA).", "security_description": "Gestionează parola și alte setări de securitate, precum autentificarea în doi pași (2FA).",
@@ -1144,10 +1138,8 @@
"two_factor_code": "Codul cu doi factori", "two_factor_code": "Codul cu doi factori",
"unlock_two_factor_authentication": "Deblocați autentificarea în doi pași cu un plan superior", "unlock_two_factor_authentication": "Deblocați autentificarea în doi pași cu un plan superior",
"update_personal_info": "Actualizează informațiile tale personale", "update_personal_info": "Actualizează informațiile tale personale",
"upload_image": "Încărcați imagine",
"warning_cannot_delete_account": "Ești singurul proprietar al acestei organizații. Te rugăm să transferi proprietatea către un alt membru mai întâi.", "warning_cannot_delete_account": "Ești singurul proprietar al acestei organizații. Te rugăm să transferi proprietatea către un alt membru mai întâi.",
"warning_cannot_undo": "Aceasta nu poate fi anulată", "warning_cannot_undo": "Aceasta nu poate fi anulată"
"you_must_select_a_file": "Trebuie să selectați un fișier."
}, },
"teams": { "teams": {
"add_members_description": "Adaugă membri în echipă și stabilește rolul lor.", "add_members_description": "Adaugă membri în echipă și stabilește rolul lor.",
@@ -1715,10 +1707,8 @@
"language_help_text": "Meta datele sunt încărcate pe baza valorii `lang` din URL.", "language_help_text": "Meta datele sunt încărcate pe baza valorii `lang` din URL.",
"link_description": "Descriere legătură", "link_description": "Descriere legătură",
"link_description_description": "Descrierile între 55-200 de caractere au cele mai bune performanțe.", "link_description_description": "Descrierile între 55-200 de caractere au cele mai bune performanțe.",
"link_description_placeholder": "Ajutați-ne să ne îmbunătățim împărtășindu-vă gândurile.",
"link_title": "Titlu link", "link_title": "Titlu link",
"link_title_description": "Titlurile scurte funcționează cel mai bine ca Meta Title-uri.", "link_title_description": "Titlurile scurte funcționează cel mai bine ca Meta Title-uri.",
"link_title_placeholder": "Chestionar de feedback al clienților",
"preview_image": "Previzualizare imagine", "preview_image": "Previzualizare imagine",
"preview_image_description": "Imaginile panoramice cu dimensiuni de fișier mici (<4MB) au cel mai bun randament.", "preview_image_description": "Imaginile panoramice cu dimensiuni de fișier mici (<4MB) au cel mai bun randament.",
"title": "Setări link" "title": "Setări link"
+2 -12
View File
@@ -141,7 +141,6 @@
"apply_filters": "套用篩選器", "apply_filters": "套用篩選器",
"are_you_sure": "您確定嗎?", "are_you_sure": "您確定嗎?",
"attributes": "屬性", "attributes": "屬性",
"avatar": "頭像",
"back": "返回", "back": "返回",
"billing": "帳單", "billing": "帳單",
"booked": "已預訂", "booked": "已預訂",
@@ -748,6 +747,7 @@
"api_key_label": "API 金鑰標籤", "api_key_label": "API 金鑰標籤",
"api_key_security_warning": "為安全起見,API 金鑰僅在建立後顯示一次。請立即將其複製到您的目的地。", "api_key_security_warning": "為安全起見,API 金鑰僅在建立後顯示一次。請立即將其複製到您的目的地。",
"api_key_updated": "API 金鑰已更新", "api_key_updated": "API 金鑰已更新",
"delete_permission": "刪除 權限",
"duplicate_access": "不允許重複的 project 存取", "duplicate_access": "不允許重複的 project 存取",
"no_api_keys_yet": "您還沒有任何 API 金鑰", "no_api_keys_yet": "您還沒有任何 API 金鑰",
"no_env_permissions_found": "找不到環境權限", "no_env_permissions_found": "找不到環境權限",
@@ -1111,9 +1111,7 @@
}, },
"profile": { "profile": {
"account_deletion_consequences_warning": "帳戶刪除後果", "account_deletion_consequences_warning": "帳戶刪除後果",
"avatar_update_failed": "頭像更新失敗。請再試一次。",
"backup_code": "備份碼", "backup_code": "備份碼",
"change_image": "變更圖片",
"confirm_delete_account": "刪除您的帳戶以及您的所有個人資訊和資料", "confirm_delete_account": "刪除您的帳戶以及您的所有個人資訊和資料",
"confirm_delete_my_account": "刪除我的帳戶", "confirm_delete_my_account": "刪除我的帳戶",
"confirm_your_current_password_to_get_started": "確認您目前的密碼以開始使用。", "confirm_your_current_password_to_get_started": "確認您目前的密碼以開始使用。",
@@ -1124,17 +1122,13 @@
"email_change_initiated": "您的 email 更改請求已啟動。", "email_change_initiated": "您的 email 更改請求已啟動。",
"enable_two_factor_authentication": "啟用雙重驗證", "enable_two_factor_authentication": "啟用雙重驗證",
"enter_the_code_from_your_authenticator_app_below": "在下方輸入您驗證器應用程式中的程式碼。", "enter_the_code_from_your_authenticator_app_below": "在下方輸入您驗證器應用程式中的程式碼。",
"file_size_must_be_less_than_10mb": "檔案大小必須小於 10MB。",
"invalid_file_type": "無效的檔案類型。僅允許 JPEG、PNG 和 WEBP 檔案。",
"lost_access": "無法存取", "lost_access": "無法存取",
"or_enter_the_following_code_manually": "或手動輸入下列程式碼:", "or_enter_the_following_code_manually": "或手動輸入下列程式碼:",
"organization_identification": "協助您的組織在 Formbricks 上識別您",
"organizations_delete_message": "您是這些組織的唯一擁有者,因此它們也 <b>將被刪除。</b>", "organizations_delete_message": "您是這些組織的唯一擁有者,因此它們也 <b>將被刪除。</b>",
"permanent_removal_of_all_of_your_personal_information_and_data": "永久移除您的所有個人資訊和資料", "permanent_removal_of_all_of_your_personal_information_and_data": "永久移除您的所有個人資訊和資料",
"personal_information": "個人資訊", "personal_information": "個人資訊",
"please_enter_email_to_confirm_account_deletion": "請在以下欄位中輸入 '{'email'}' 以確認永久刪除您的帳戶:", "please_enter_email_to_confirm_account_deletion": "請在以下欄位中輸入 '{'email'}' 以確認永久刪除您的帳戶:",
"profile_updated_successfully": "您的個人資料已成功更新", "profile_updated_successfully": "您的個人資料已成功更新",
"remove_image": "移除圖片",
"save_the_following_backup_codes_in_a_safe_place": "將下列備份碼儲存在安全的地方。", "save_the_following_backup_codes_in_a_safe_place": "將下列備份碼儲存在安全的地方。",
"scan_the_qr_code_below_with_your_authenticator_app": "使用您的驗證器應用程式掃描下方的 QR 碼。", "scan_the_qr_code_below_with_your_authenticator_app": "使用您的驗證器應用程式掃描下方的 QR 碼。",
"security_description": "管理您的密碼和其他安全性設定,例如雙重驗證 (2FA)。", "security_description": "管理您的密碼和其他安全性設定,例如雙重驗證 (2FA)。",
@@ -1144,10 +1138,8 @@
"two_factor_code": "雙重驗證碼", "two_factor_code": "雙重驗證碼",
"unlock_two_factor_authentication": "使用更高等級的方案解鎖雙重驗證", "unlock_two_factor_authentication": "使用更高等級的方案解鎖雙重驗證",
"update_personal_info": "更新您的個人資訊", "update_personal_info": "更新您的個人資訊",
"upload_image": "上傳圖片",
"warning_cannot_delete_account": "您是此組織的唯一擁有者。請先將所有權轉讓給其他成員。", "warning_cannot_delete_account": "您是此組織的唯一擁有者。請先將所有權轉讓給其他成員。",
"warning_cannot_undo": "此操作無法復原", "warning_cannot_undo": "此操作無法復原"
"you_must_select_a_file": "您必須選取檔案。"
}, },
"teams": { "teams": {
"add_members_description": "將成員新增至團隊並確定其角色。", "add_members_description": "將成員新增至團隊並確定其角色。",
@@ -1715,10 +1707,8 @@
"language_help_text": "中 繼資料 會 根據 URL 中 的 `lang` 值 載入。", "language_help_text": "中 繼資料 會 根據 URL 中 的 `lang` 值 載入。",
"link_description": "連結描述", "link_description": "連結描述",
"link_description_description": "描述在 55 - 200 個字符之間的表現最好。", "link_description_description": "描述在 55 - 200 個字符之間的表現最好。",
"link_description_placeholder": "幫助 我們 改善 , 分享 您 的 想法 。",
"link_title": "連結標題", "link_title": "連結標題",
"link_title_description": "短 標題 在 Meta Titles 中表現最佳。", "link_title_description": "短 標題 在 Meta Titles 中表現最佳。",
"link_title_placeholder": "顧客 回饋 調查",
"preview_image": "預覽 圖片", "preview_image": "預覽 圖片",
"preview_image_description": "景觀 圖片 檔案 大小 小於 4MB 效果 最佳。", "preview_image_description": "景觀 圖片 檔案 大小 小於 4MB 效果 最佳。",
"title": "連結 設定" "title": "連結 設定"
@@ -59,7 +59,7 @@ vi.mock("@/lib/cn", () => ({
cn: (...classes: (string | boolean | undefined)[]) => classes.filter(Boolean).join(" "), cn: (...classes: (string | boolean | undefined)[]) => classes.filter(Boolean).join(" "),
})); }));
vi.mock("@/lib/i18n/utils", () => ({ vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: vi.fn((val, _) => val), getLocalizedValue: vi.fn((val, _) => val["default"]),
getLanguageCode: vi.fn().mockReturnValue("default"), getLanguageCode: vi.fn().mockReturnValue("default"),
})); }));
@@ -178,7 +178,18 @@ describe("RenderResponse", () => {
}); });
test("renders Matrix response", () => { test("renders Matrix response", () => {
const question = { id: "q1", type: "matrix", rows: ["row1", "row2"] } as any; const question = {
id: "q1",
type: "matrix",
rows: [
{ id: "row1", label: { default: "row1" } },
{ id: "row2", label: { default: "row2" } },
],
columns: [
{ id: "col1", label: { default: "answer1" } },
{ id: "col2", label: { default: "answer2" } },
],
} as any;
// getLocalizedValue returns the row value itself // getLocalizedValue returns the row value itself
const responseData = { row1: "answer1", row2: "answer2" }; const responseData = { row1: "answer1", row2: "answer2" };
render( render(
@@ -100,7 +100,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
<> <>
{(question as TSurveyMatrixQuestion).rows.map((row) => { {(question as TSurveyMatrixQuestion).rows.map((row) => {
const languagCode = getLanguageCode(survey.languages, language); const languagCode = getLanguageCode(survey.languages, language);
const rowValueInSelectedLanguage = getLocalizedValue(row, languagCode); const rowValueInSelectedLanguage = getLocalizedValue(row.label, languagCode);
if (!responseData[rowValueInSelectedLanguage]) return null; if (!responseData[rowValueInSelectedLanguage]) return null;
return ( return (
<p <p
+2 -1
View File
@@ -1,6 +1,7 @@
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client"; import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response"; import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils"; import { handleApiError } from "@/modules/api/v2/lib/utils";
import { hasOrganizationAccess } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { OrganizationAccessType } from "@formbricks/types/api-key"; import { OrganizationAccessType } from "@formbricks/types/api-key";
@@ -8,7 +9,7 @@ export const GET = async (request: NextRequest) =>
authenticatedApiClient({ authenticatedApiClient({
request, request,
handler: async ({ authentication }) => { handler: async ({ authentication }) => {
if (!authentication.organizationAccess?.accessControl?.[OrganizationAccessType.Read]) { if (!hasOrganizationAccess(authentication, OrganizationAccessType.Read)) {
return handleApiError(request, { return handleApiError(request, {
type: "unauthorized", type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }], details: [{ field: "organizationId", issue: "unauthorized" }],
@@ -1,3 +1,4 @@
import { hasOrganizationAccess } from "@/modules/organization/settings/api-keys/lib/utils";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { OrganizationAccessType } from "@formbricks/types/api-key"; import { OrganizationAccessType } from "@formbricks/types/api-key";
import { TAuthenticationApiKey } from "@formbricks/types/auth"; import { TAuthenticationApiKey } from "@formbricks/types/auth";
@@ -13,9 +14,5 @@ export const hasOrganizationIdAndAccess = (
return false; return false;
} }
if (!authentication.organizationAccess?.accessControl?.[accessType]) { return hasOrganizationAccess(authentication, accessType);
return false;
}
return true;
}; };
@@ -148,7 +148,6 @@ describe("authOptions", () => {
email: mockUser.email, email: mockUser.email,
password: mockHashedPassword, password: mockHashedPassword,
emailVerified: new Date(), emailVerified: new Date(),
imageUrl: "http://example.com/avatar.png",
twoFactorEnabled: false, twoFactorEnabled: false,
}; };
@@ -161,7 +160,6 @@ describe("authOptions", () => {
id: fakeUser.id, id: fakeUser.id,
email: fakeUser.email, email: fakeUser.email,
emailVerified: fakeUser.emailVerified, emailVerified: fakeUser.emailVerified,
imageUrl: fakeUser.imageUrl,
}); });
}); });
-1
View File
@@ -206,7 +206,6 @@ export const authOptions: NextAuthOptions = {
id: user.id, id: user.id,
email: user.email, email: user.email,
emailVerified: user.emailVerified, emailVerified: user.emailVerified,
imageUrl: user.imageUrl,
}; };
}, },
}), }),
-1
View File
@@ -5,7 +5,6 @@ export const mockUser: TUser = {
name: "mock User", name: "mock User",
email: "john.doe@example.com", email: "john.doe@example.com",
emailVerified: new Date("2024-01-01T00:00:00.000Z"), emailVerified: new Date("2024-01-01T00:00:00.000Z"),
imageUrl: "https://www.google.com",
createdAt: new Date("2024-01-01T00:00:00.000Z"), createdAt: new Date("2024-01-01T00:00:00.000Z"),
updatedAt: new Date("2024-01-01T00:00:00.000Z"), updatedAt: new Date("2024-01-01T00:00:00.000Z"),
twoFactorEnabled: false, twoFactorEnabled: false,
-5
View File
@@ -1,4 +1,3 @@
import { isValidImageFile } from "@/lib/fileValidation";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react"; import { cache as reactCache } from "react";
@@ -11,10 +10,6 @@ import { TUserCreateInput, TUserUpdateInput, ZUserEmail, ZUserUpdateInput } from
export const updateUser = async (id: string, data: TUserUpdateInput) => { export const updateUser = async (id: string, data: TUserUpdateInput) => {
validateInputs([id, ZId], [data, ZUserUpdateInput.partial()]); validateInputs([id, ZId], [data, ZUserUpdateInput.partial()]);
if (data.imageUrl && !isValidImageFile(data.imageUrl)) {
throw new InvalidInputError("Invalid image file");
}
try { try {
const updatedUser = await prisma.user.update({ const updatedUser = await prisma.user.update({
where: { where: {
@@ -71,7 +71,7 @@ describe("rateLimitConfigs", () => {
test("should have all action configurations", () => { test("should have all action configurations", () => {
const actionConfigs = Object.keys(rateLimitConfigs.actions); const actionConfigs = Object.keys(rateLimitConfigs.actions);
expect(actionConfigs).toEqual(["emailUpdate", "surveyFollowUp"]); expect(actionConfigs).toEqual(["emailUpdate", "surveyFollowUp", "sendLinkSurveyEmail"]);
}); });
}); });
@@ -23,5 +23,10 @@ export const rateLimitConfigs = {
actions: { actions: {
emailUpdate: { interval: 3600, allowedPerInterval: 3, namespace: "action:email" }, // 3 per hour emailUpdate: { interval: 3600, allowedPerInterval: 3, namespace: "action:email" }, // 3 per hour
surveyFollowUp: { interval: 3600, allowedPerInterval: 50, namespace: "action:followup" }, // 50 per hour surveyFollowUp: { interval: 3600, allowedPerInterval: 50, namespace: "action:followup" }, // 50 per hour
sendLinkSurveyEmail: {
interval: 3600,
allowedPerInterval: 10,
namespace: "action:send-link-survey-email",
}, // 10 per hour
}, },
}; };
@@ -94,7 +94,6 @@ const fullUser = {
updatedAt: new Date(), updatedAt: new Date(),
email: "test@example.com", email: "test@example.com",
emailVerified: null, emailVerified: null,
imageUrl: null,
twoFactorEnabled: false, twoFactorEnabled: false,
identityProvider: "email", identityProvider: "email",
organizationId: "org1", organizationId: "org1",
@@ -28,7 +28,6 @@ describe("ResponseTimeline", () => {
name: "Test User", name: "Test User",
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
imageUrl: null,
objective: null, objective: null,
role: "founder", role: "founder",
email: "test@example.com", email: "test@example.com",
-2
View File
@@ -51,7 +51,6 @@ export const getSSOProviders = () => [
id: profile.sub, id: profile.sub,
name: profile.name, name: profile.name,
email: profile.email, email: profile.email,
image: profile.picture,
}; };
}, },
}, },
@@ -76,7 +75,6 @@ export const getSSOProviders = () => [
id: profile.id, id: profile.id,
email: profile.email, email: profile.email,
name: [profile.firstName, profile.lastName].filter(Boolean).join(" "), name: [profile.firstName, profile.lastName].filter(Boolean).join(" "),
image: null,
}; };
}, },
options: { options: {
@@ -13,7 +13,6 @@ export const mockUser: TUser = {
unsubscribedOrganizationIds: [], unsubscribedOrganizationIds: [],
}, },
emailVerified: new Date(), emailVerified: new Date(),
imageUrl: "https://example.com/image.png",
twoFactorEnabled: false, twoFactorEnabled: false,
identityProvider: "google", identityProvider: "google",
locale: "en-US", locale: "en-US",
@@ -380,8 +380,8 @@ export async function PreviewEmailTemplate({
return ( return (
<Column <Column
className="text-question-color max-w-40 break-words px-4 py-2 text-center" className="text-question-color max-w-40 break-words px-4 py-2 text-center"
key={getLocalizedValue(column, "default")}> key={column.id}>
{getLocalizedValue(column, "default")} {getLocalizedValue(column.label, "default")}
</Column> </Column>
); );
})} })}
@@ -390,15 +390,13 @@ export async function PreviewEmailTemplate({
return ( return (
<Row <Row
className={`${rowIndex % 2 === 0 ? "bg-input-color" : ""} rounded-custom`} className={`${rowIndex % 2 === 0 ? "bg-input-color" : ""} rounded-custom`}
key={getLocalizedValue(row, "default")}> key={row.id}>
<Column className="w-40 break-words px-4 py-2"> <Column className="w-40 break-words px-4 py-2">
{getLocalizedValue(row, "default")} {getLocalizedValue(row.label, "default")}
</Column> </Column>
{firstQuestion.columns.map((_) => { {firstQuestion.columns.map((column) => {
return ( return (
<Column <Column className="text-question-color px-4 py-2" key={column.id}>
className="text-question-color px-4 py-2"
key={getLocalizedValue(_, "default")}>
<Section className="bg-card-bg-color h-4 w-4 rounded-full p-2 outline" /> <Section className="bg-card-bg-color h-4 w-4 rounded-full p-2 outline" />
</Column> </Column>
); );
@@ -1,5 +1,6 @@
import { TFnType } from "@tolgee/react"; import { TFnType } from "@tolgee/react";
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth"; import { OrganizationAccessType } from "@formbricks/types/api-key";
import { TAPIKeyEnvironmentPermission, TAuthenticationApiKey } from "@formbricks/types/auth";
// Permission level required for different HTTP methods // Permission level required for different HTTP methods
const methodPermissionMap = { const methodPermissionMap = {
@@ -50,3 +51,19 @@ export const getOrganizationAccessKeyDisplayName = (key: string, t: TFnType) =>
return key; return key;
} }
}; };
export const hasOrganizationAccess = (
authentication: TAuthenticationApiKey,
accessType: OrganizationAccessType
): boolean => {
const organizationAccess = authentication.organizationAccess?.accessControl;
switch (accessType) {
case OrganizationAccessType.Read:
return organizationAccess?.read === true || organizationAccess?.write === true;
case OrganizationAccessType.Write:
return organizationAccess?.write === true;
default:
return false;
}
};
@@ -179,6 +179,7 @@ export const EditLogo = ({ project, environmentId, isReadOnly }: EditLogoProps)
description={t("environments.project.look.add_background_color_description")} description={t("environments.project.look.add_background_color_description")}
childBorder childBorder
customContainerClass="p-0" customContainerClass="p-0"
childrenContainerClass="overflow-visible"
disabled={!isEditing}> disabled={!isEditing}>
{isBgColorEnabled && ( {isBgColorEnabled && (
<div className="px-2"> <div className="px-2">
@@ -56,7 +56,6 @@ const mockUser = {
id: "user-123", id: "user-123",
name: "Test User", name: "Test User",
email: "test@example.com", email: "test@example.com",
imageUrl: null,
twoFactorEnabled: false, twoFactorEnabled: false,
identityProvider: "email", identityProvider: "email",
createdAt: new Date(), createdAt: new Date(),
@@ -131,7 +131,6 @@ describe("CreateOrganizationPage", () => {
name: "Test User", name: "Test User",
email: "test@example.com", email: "test@example.com",
emailVerified: null, emailVerified: null,
imageUrl: null,
twoFactorEnabled: false, twoFactorEnabled: false,
identityProvider: "email" as const, identityProvider: "email" as const,
createdAt: new Date(), createdAt: new Date(),
@@ -85,6 +85,10 @@ export const QuestionFormInput = ({
const isChoice = id.includes("choice"); const isChoice = id.includes("choice");
const isMatrixLabelRow = id.includes("row"); const isMatrixLabelRow = id.includes("row");
const isMatrixLabelColumn = id.includes("column"); const isMatrixLabelColumn = id.includes("column");
const inputId = useMemo(() => {
return isChoice || isMatrixLabelColumn || isMatrixLabelRow ? id.split("-")[0] : id;
}, [id, isChoice, isMatrixLabelColumn, isMatrixLabelRow]);
const isEndingCard = questionIdx >= localSurvey.questions.length; const isEndingCard = questionIdx >= localSurvey.questions.length;
const isWelcomeCard = questionIdx === -1; const isWelcomeCard = questionIdx === -1;
const index = getIndex(id, isChoice || isMatrixLabelColumn || isMatrixLabelRow); const index = getIndex(id, isChoice || isMatrixLabelColumn || isMatrixLabelRow);
@@ -104,8 +108,8 @@ export const QuestionFormInput = ({
[localSurvey.languages] [localSurvey.languages]
); );
const isTranslationIncomplete = useMemo( const isTranslationIncomplete = useMemo(
() => isValueIncomplete(id, isInvalid, surveyLanguageCodes, value), () => isValueIncomplete(inputId, isInvalid, surveyLanguageCodes, value),
[value, id, isInvalid, surveyLanguageCodes] [value, inputId, isInvalid, surveyLanguageCodes]
); );
const elementText = useMemo((): TI18nString => { const elementText = useMemo((): TI18nString => {
@@ -87,12 +87,12 @@ describe("utils", () => {
headline: createI18nString("Matrix Question", surveyLanguageCodes), headline: createI18nString("Matrix Question", surveyLanguageCodes),
required: true, required: true,
rows: [ rows: [
createI18nString("Row 1", surveyLanguageCodes), { id: "row-1", label: createI18nString("Row 1", surveyLanguageCodes) },
createI18nString("Row 2", surveyLanguageCodes), { id: "row-2", label: createI18nString("Row 2", surveyLanguageCodes) },
], ],
columns: [ columns: [
createI18nString("Column 1", surveyLanguageCodes), { id: "col-1", label: createI18nString("Column 1", surveyLanguageCodes) },
createI18nString("Column 2", surveyLanguageCodes), { id: "col-2", label: createI18nString("Column 2", surveyLanguageCodes) },
], ],
} as unknown as TSurveyQuestion; } as unknown as TSurveyQuestion;
@@ -108,12 +108,12 @@ describe("utils", () => {
headline: createI18nString("Matrix Question", surveyLanguageCodes), headline: createI18nString("Matrix Question", surveyLanguageCodes),
required: true, required: true,
rows: [ rows: [
createI18nString("Row 1", surveyLanguageCodes), { id: "row-1", label: createI18nString("Row 1", surveyLanguageCodes) },
createI18nString("Row 2", surveyLanguageCodes), { id: "row-2", label: createI18nString("Row 2", surveyLanguageCodes) },
], ],
columns: [ columns: [
createI18nString("Column 1", surveyLanguageCodes), { id: "col-1", label: createI18nString("Column 1", surveyLanguageCodes) },
createI18nString("Column 2", surveyLanguageCodes), { id: "col-2", label: createI18nString("Column 2", surveyLanguageCodes) },
], ],
} as unknown as TSurveyQuestion; } as unknown as TSurveyQuestion;
@@ -36,8 +36,8 @@ export const getMatrixLabel = (
type: "row" | "column" type: "row" | "column"
): TI18nString => { ): TI18nString => {
const matrixQuestion = question as TSurveyMatrixQuestion; const matrixQuestion = question as TSurveyMatrixQuestion;
const labels = type === "row" ? matrixQuestion.rows : matrixQuestion.columns; const matrixFields = type === "row" ? matrixQuestion.rows : matrixQuestion.columns;
return labels[idx] || createI18nString("", surveyLanguageCodes); return matrixFields[idx]?.label || createI18nString("", surveyLanguageCodes);
}; };
export const getWelcomeCardText = ( export const getWelcomeCardText = (
@@ -94,6 +94,9 @@ export const isValueIncomplete = (
) => { ) => {
// Define a list of IDs for which a default value needs to be checked. // Define a list of IDs for which a default value needs to be checked.
const labelIds = [ const labelIds = [
"row",
"column",
"choice",
"label", "label",
"headline", "headline",
"subheader", "subheader",
@@ -1,9 +1,8 @@
import { isValidImageFile } from "@/lib/fileValidation";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest"; import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error"; import { PrismaErrorType } from "@formbricks/database/types/error";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import { updateUser } from "./user"; import { updateUser } from "./user";
@@ -24,7 +23,6 @@ describe("updateUser", () => {
id: "user-123", id: "user-123",
name: "Test User", name: "Test User",
email: "test@example.com", email: "test@example.com",
imageUrl: "https://example.com/image.png",
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
role: "project_manager", role: "project_manager",
@@ -41,7 +39,6 @@ describe("updateUser", () => {
}); });
test("successfully updates a user", async () => { test("successfully updates a user", async () => {
vi.mocked(isValidImageFile).mockReturnValue(true);
vi.mocked(prisma.user.update).mockResolvedValue(mockUser as any); vi.mocked(prisma.user.update).mockResolvedValue(mockUser as any);
const updateData = { name: "Updated Name" }; const updateData = { name: "Updated Name" };
@@ -55,7 +52,6 @@ describe("updateUser", () => {
name: true, name: true,
email: true, email: true,
emailVerified: true, emailVerified: true,
imageUrl: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
role: true, role: true,
@@ -72,17 +68,7 @@ describe("updateUser", () => {
expect(result).toEqual(mockUser); expect(result).toEqual(mockUser);
}); });
test("throws InvalidInputError when image file is invalid", async () => {
vi.mocked(isValidImageFile).mockReturnValue(false);
const updateData = { imageUrl: "invalid-image.xyz" };
await expect(updateUser("user-123", updateData)).rejects.toThrow(InvalidInputError);
expect(prisma.user.update).not.toHaveBeenCalled();
});
test("throws ResourceNotFoundError when user does not exist", async () => { test("throws ResourceNotFoundError when user does not exist", async () => {
vi.mocked(isValidImageFile).mockReturnValue(true);
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", { const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
code: PrismaErrorType.RecordDoesNotExist, code: PrismaErrorType.RecordDoesNotExist,
clientVersion: "1.0.0", clientVersion: "1.0.0",
@@ -96,8 +82,6 @@ describe("updateUser", () => {
}); });
test("re-throws other errors", async () => { test("re-throws other errors", async () => {
vi.mocked(isValidImageFile).mockReturnValue(true);
const otherError = new Error("Some other error"); const otherError = new Error("Some other error");
vi.mocked(prisma.user.update).mockRejectedValue(otherError); vi.mocked(prisma.user.update).mockRejectedValue(otherError);
@@ -1,14 +1,11 @@
import { isValidImageFile } from "@/lib/fileValidation";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error"; import { PrismaErrorType } from "@formbricks/database/types/error";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TUser, TUserUpdateInput } from "@formbricks/types/user"; import { TUser, TUserUpdateInput } from "@formbricks/types/user";
// function to update a user's user // function to update a user's user
export const updateUser = async (personId: string, data: TUserUpdateInput): Promise<TUser> => { export const updateUser = async (personId: string, data: TUserUpdateInput): Promise<TUser> => {
if (data.imageUrl && !isValidImageFile(data.imageUrl)) throw new InvalidInputError("Invalid image file");
try { try {
const updatedUser = await prisma.user.update({ const updatedUser = await prisma.user.update({
where: { where: {
@@ -20,7 +17,6 @@ export const updateUser = async (personId: string, data: TUserUpdateInput): Prom
name: true, name: true,
email: true, email: true,
emailVerified: true, emailVerified: true,
imageUrl: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
role: true, role: true,
@@ -2,8 +2,7 @@ import { createI18nString } from "@/lib/i18n/utils";
import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils"; import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import React from "react"; import { afterEach, describe, expect, test, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { import {
TSurvey, TSurvey,
TSurveyLanguage, TSurveyLanguage,
@@ -14,12 +13,11 @@ import { TUserLocale } from "@formbricks/types/user";
import { MatrixQuestionForm } from "./matrix-question-form"; import { MatrixQuestionForm } from "./matrix-question-form";
// Mock cuid2 to track CUID generation // Mock cuid2 to track CUID generation
const mockCuids = ["cuid1", "cuid2", "cuid3", "cuid4", "cuid5", "cuid6"];
let cuidIndex = 0; let cuidIndex = 0;
vi.mock("@paralleldrive/cuid2", () => ({ vi.mock("@paralleldrive/cuid2", () => ({
default: { default: {
createId: vi.fn(() => mockCuids[cuidIndex++]), createId: vi.fn(() => `cuid${cuidIndex++}`),
}, },
})); }));
@@ -160,14 +158,14 @@ const mockMatrixQuestion: TSurveyMatrixQuestion = {
required: false, required: false,
logic: [], logic: [],
rows: [ rows: [
createI18nString("Row 1", ["en"]), { id: "row-1", label: createI18nString("Row 1", ["en"]) },
createI18nString("Row 2", ["en"]), { id: "row-2", label: createI18nString("Row 2", ["en"]) },
createI18nString("Row 3", ["en"]), { id: "row-3", label: createI18nString("Row 3", ["en"]) },
], ],
columns: [ columns: [
createI18nString("Column 1", ["en"]), { id: "col-1", label: createI18nString("Column 1", ["en"]) },
createI18nString("Column 2", ["en"]), { id: "col-2", label: createI18nString("Column 2", ["en"]) },
createI18nString("Column 3", ["en"]), { id: "col-3", label: createI18nString("Column 3", ["en"]) },
], ],
shuffleOption: "none", shuffleOption: "none",
}; };
@@ -197,6 +195,7 @@ describe("MatrixQuestionForm", () => {
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.clearAllMocks(); vi.clearAllMocks();
cuidIndex = 0;
}); });
test("renders the matrix question form with rows and columns", () => { test("renders the matrix question form with rows and columns", () => {
@@ -240,40 +239,6 @@ describe("MatrixQuestionForm", () => {
expect(screen.getByTestId("question-input-subheader")).toBeInTheDocument(); expect(screen.getByTestId("question-input-subheader")).toBeInTheDocument();
}); });
test("adds a new row when 'Add Row' button is clicked", async () => {
const user = userEvent.setup();
const { getByText } = render(<MatrixQuestionForm {...defaultProps} />);
const addRowButton = getByText("environments.surveys.edit.add_row");
await user.click(addRowButton);
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, {
rows: [
mockMatrixQuestion.rows[0],
mockMatrixQuestion.rows[1],
mockMatrixQuestion.rows[2],
{ default: "" },
],
});
});
test("adds a new column when 'Add Column' button is clicked", async () => {
const user = userEvent.setup();
const { getByText } = render(<MatrixQuestionForm {...defaultProps} />);
const addColumnButton = getByText("environments.surveys.edit.add_column");
await user.click(addColumnButton);
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, {
columns: [
mockMatrixQuestion.columns[0],
mockMatrixQuestion.columns[1],
mockMatrixQuestion.columns[2],
{ default: "" },
],
});
});
test("deletes a row when delete button is clicked", async () => { test("deletes a row when delete button is clicked", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const { findAllByTestId } = render(<MatrixQuestionForm {...defaultProps} />); const { findAllByTestId } = render(<MatrixQuestionForm {...defaultProps} />);
@@ -294,7 +259,10 @@ describe("MatrixQuestionForm", () => {
...defaultProps, ...defaultProps,
question: { question: {
...mockMatrixQuestion, ...mockMatrixQuestion,
rows: [createI18nString("Row 1", ["en"]), createI18nString("Row 2", ["en"])], rows: [
{ id: "row-1", label: createI18nString("Row 1", ["en"]) },
{ id: "row-2", label: createI18nString("Row 2", ["en"]) },
],
}, },
}; };
@@ -334,42 +302,6 @@ describe("MatrixQuestionForm", () => {
expect(mockUpdateQuestion).toHaveBeenCalled(); expect(mockUpdateQuestion).toHaveBeenCalled();
}); });
test("handles Enter key to add a new row from row input", async () => {
const user = userEvent.setup();
const { getByTestId } = render(<MatrixQuestionForm {...defaultProps} />);
const rowInput = getByTestId("input-row-0");
await user.click(rowInput);
await user.keyboard("{Enter}");
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, {
rows: [
mockMatrixQuestion.rows[0],
mockMatrixQuestion.rows[1],
mockMatrixQuestion.rows[2],
expect.any(Object),
],
});
});
test("handles Enter key to add a new column from column input", async () => {
const user = userEvent.setup();
const { getByTestId } = render(<MatrixQuestionForm {...defaultProps} />);
const columnInput = getByTestId("input-column-0");
await user.click(columnInput);
await user.keyboard("{Enter}");
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, {
columns: [
mockMatrixQuestion.columns[0],
mockMatrixQuestion.columns[1],
mockMatrixQuestion.columns[2],
expect.any(Object),
],
});
});
test("prevents deletion of a row used in logic", async () => { test("prevents deletion of a row used in logic", async () => {
const { findOptionUsedInLogic } = await import("@/modules/survey/editor/lib/utils"); const { findOptionUsedInLogic } = await import("@/modules/survey/editor/lib/utils");
vi.mocked(findOptionUsedInLogic).mockReturnValueOnce(1); // Mock that this row is used in logic vi.mocked(findOptionUsedInLogic).mockReturnValueOnce(1); // Mock that this row is used in logic
@@ -397,223 +329,4 @@ describe("MatrixQuestionForm", () => {
expect(mockUpdateQuestion).not.toHaveBeenCalled(); expect(mockUpdateQuestion).not.toHaveBeenCalled();
}); });
// CUID functionality tests
describe("CUID Management", () => {
beforeEach(() => {
// Reset CUID index before each test
cuidIndex = 0;
});
test("generates stable CUIDs for rows and columns on initial render", () => {
const { rerender } = render(<MatrixQuestionForm {...defaultProps} />);
// Check that CUIDs are generated for initial items
expect(cuidIndex).toBe(6); // 3 rows + 3 columns
// Rerender with the same props - no new CUIDs should be generated
rerender(<MatrixQuestionForm {...defaultProps} />);
expect(cuidIndex).toBe(6); // Should remain the same
});
test("maintains stable CUIDs across rerenders", () => {
const TestComponent = ({ question }: { question: TSurveyMatrixQuestion }) => {
return <MatrixQuestionForm {...defaultProps} question={question} />;
};
const { rerender } = render(<TestComponent question={mockMatrixQuestion} />);
// Check initial CUID count
expect(cuidIndex).toBe(6); // 3 rows + 3 columns
// Rerender multiple times
rerender(<TestComponent question={mockMatrixQuestion} />);
rerender(<TestComponent question={mockMatrixQuestion} />);
rerender(<TestComponent question={mockMatrixQuestion} />);
// CUIDs should remain stable
expect(cuidIndex).toBe(6); // Should not increase
});
test("generates new CUIDs only when rows are added", async () => {
const user = userEvent.setup();
// Create a test component that can update its props
const TestComponent = () => {
const [question, setQuestion] = React.useState(mockMatrixQuestion);
const handleUpdateQuestion = (_: number, updates: Partial<TSurveyMatrixQuestion>) => {
setQuestion((prev) => ({ ...prev, ...updates }));
};
return (
<MatrixQuestionForm {...defaultProps} question={question} updateQuestion={handleUpdateQuestion} />
);
};
const { getByText } = render(<TestComponent />);
// Initial render should generate 6 CUIDs (3 rows + 3 columns)
expect(cuidIndex).toBe(6);
// Add a new row
const addRowButton = getByText("environments.surveys.edit.add_row");
await user.click(addRowButton);
// Should generate 1 new CUID for the new row
expect(cuidIndex).toBe(7);
});
test("generates new CUIDs only when columns are added", async () => {
const user = userEvent.setup();
// Create a test component that can update its props
const TestComponent = () => {
const [question, setQuestion] = React.useState(mockMatrixQuestion);
const handleUpdateQuestion = (_: number, updates: Partial<TSurveyMatrixQuestion>) => {
setQuestion((prev) => ({ ...prev, ...updates }));
};
return (
<MatrixQuestionForm {...defaultProps} question={question} updateQuestion={handleUpdateQuestion} />
);
};
const { getByText } = render(<TestComponent />);
// Initial render should generate 6 CUIDs (3 rows + 3 columns)
expect(cuidIndex).toBe(6);
// Add a new column
const addColumnButton = getByText("environments.surveys.edit.add_column");
await user.click(addColumnButton);
// Should generate 1 new CUID for the new column
expect(cuidIndex).toBe(7);
});
test("maintains CUID stability when items are deleted", async () => {
const user = userEvent.setup();
const { findAllByTestId, rerender } = render(<MatrixQuestionForm {...defaultProps} />);
// Mock that no items are used in logic
vi.mocked(findOptionUsedInLogic).mockReturnValue(-1);
// Initial render: 6 CUIDs generated
expect(cuidIndex).toBe(6);
// Delete a row
const deleteButtons = await findAllByTestId("tooltip-renderer");
await user.click(deleteButtons[0].querySelector("button") as HTMLButtonElement);
// No new CUIDs should be generated for deletion
expect(cuidIndex).toBe(6);
// Rerender should not generate new CUIDs
rerender(<MatrixQuestionForm {...defaultProps} />);
expect(cuidIndex).toBe(6);
});
test("handles mixed operations maintaining CUID stability", async () => {
const user = userEvent.setup();
// Create a test component that can update its props
const TestComponent = () => {
const [question, setQuestion] = React.useState(mockMatrixQuestion);
const handleUpdateQuestion = (_: number, updates: Partial<TSurveyMatrixQuestion>) => {
setQuestion((prev) => ({ ...prev, ...updates }));
};
return (
<MatrixQuestionForm {...defaultProps} question={question} updateQuestion={handleUpdateQuestion} />
);
};
const { getByText, findAllByTestId } = render(<TestComponent />);
// Mock that no items are used in logic
vi.mocked(findOptionUsedInLogic).mockReturnValue(-1);
// Initial: 6 CUIDs
expect(cuidIndex).toBe(6);
// Add a row: +1 CUID
const addRowButton = getByText("environments.surveys.edit.add_row");
await user.click(addRowButton);
expect(cuidIndex).toBe(7);
// Add a column: +1 CUID
const addColumnButton = getByText("environments.surveys.edit.add_column");
await user.click(addColumnButton);
expect(cuidIndex).toBe(8);
// Delete a row: no new CUIDs
const deleteButtons = await findAllByTestId("tooltip-renderer");
await user.click(deleteButtons[0].querySelector("button") as HTMLButtonElement);
expect(cuidIndex).toBe(8);
// Delete a column: no new CUIDs
const updatedDeleteButtons = await findAllByTestId("tooltip-renderer");
await user.click(updatedDeleteButtons[2].querySelector("button") as HTMLButtonElement);
expect(cuidIndex).toBe(8);
});
test("CUID arrays are properly maintained when items are deleted in order", async () => {
const user = userEvent.setup();
const propsWithManyRows = {
...defaultProps,
question: {
...mockMatrixQuestion,
rows: [
createI18nString("Row 1", ["en"]),
createI18nString("Row 2", ["en"]),
createI18nString("Row 3", ["en"]),
createI18nString("Row 4", ["en"]),
],
},
};
const { findAllByTestId } = render(<MatrixQuestionForm {...propsWithManyRows} />);
// Mock that no items are used in logic
vi.mocked(findOptionUsedInLogic).mockReturnValue(-1);
// Initial: 7 CUIDs (4 rows + 3 columns)
expect(cuidIndex).toBe(7);
// Delete first row
const deleteButtons = await findAllByTestId("tooltip-renderer");
await user.click(deleteButtons[0].querySelector("button") as HTMLButtonElement);
// Verify the correct row was deleted (should be Row 2, Row 3, Row 4 remaining)
expect(mockUpdateQuestion).toHaveBeenLastCalledWith(0, {
rows: [
propsWithManyRows.question.rows[1],
propsWithManyRows.question.rows[2],
propsWithManyRows.question.rows[3],
],
});
// No new CUIDs should be generated
expect(cuidIndex).toBe(7);
});
test("CUID generation is consistent across component instances", () => {
// Reset CUID index
cuidIndex = 0;
// Render first instance
const { unmount } = render(<MatrixQuestionForm {...defaultProps} />);
expect(cuidIndex).toBe(6);
// Unmount and render second instance
unmount();
render(<MatrixQuestionForm {...defaultProps} />);
// Should generate 6 more CUIDs for the new instance
expect(cuidIndex).toBe(12);
});
});
}); });
@@ -2,16 +2,18 @@
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils"; import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input"; import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { MatrixSortableItem } from "@/modules/survey/editor/components/matrix-sortable-item";
import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils"; import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label"; import { Label } from "@/modules/ui/components/label";
import { ShuffleOptionSelect } from "@/modules/ui/components/shuffle-option-select"; import { ShuffleOptionSelect } from "@/modules/ui/components/shuffle-option-select";
import { TooltipRenderer } from "@/modules/ui/components/tooltip"; import { DndContext, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useAutoAnimate } from "@formkit/auto-animate/react"; import { useAutoAnimate } from "@formkit/auto-animate/react";
import cuid2 from "@paralleldrive/cuid2"; import { createId } from "@paralleldrive/cuid2";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { PlusIcon, TrashIcon } from "lucide-react"; import { PlusIcon } from "lucide-react";
import { type JSX, useMemo, useRef } from "react"; import { type JSX, useCallback } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { TI18nString, TSurvey, TSurveyMatrixQuestion } from "@formbricks/types/surveys/types"; import { TI18nString, TSurvey, TSurveyMatrixQuestion } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
@@ -41,51 +43,16 @@ export const MatrixQuestionForm = ({
const languageCodes = extractLanguageCodes(localSurvey.languages); const languageCodes = extractLanguageCodes(localSurvey.languages);
const { t } = useTranslate(); const { t } = useTranslate();
// Refs to maintain stable CUIDs across renders
const cuidRefs = useRef<{
rows: string[];
columns: string[];
}>({
rows: [],
columns: [],
});
// Generic function to ensure CUIDs are synchronized with the current state
const ensureCuids = (type: "rows" | "columns", currentItems: TI18nString[]) => {
const currentCuids = cuidRefs.current[type];
if (currentCuids.length !== currentItems.length) {
if (currentItems.length > currentCuids.length) {
// Add new CUIDs for added items
const newCuids = Array(currentItems.length - currentCuids.length)
.fill(null)
.map(() => cuid2.createId());
cuidRefs.current[type] = [...currentCuids, ...newCuids];
} else {
// Remove CUIDs for deleted items (keep the remaining ones in order)
cuidRefs.current[type] = currentCuids.slice(0, currentItems.length);
}
}
};
// Generic function to get items with CUIDs
const getItemsWithCuid = (type: "rows" | "columns", items: TI18nString[]) => {
ensureCuids(type, items);
return items.map((item, index) => ({
...item,
id: cuidRefs.current[type][index],
}));
};
const rowsWithCuid = useMemo(() => getItemsWithCuid("rows", question.rows), [question.rows]);
const columnsWithCuid = useMemo(() => getItemsWithCuid("columns", question.columns), [question.columns]);
// Function to add a new Label input field // Function to add a new Label input field
const handleAddLabel = (type: "row" | "column") => { const handleAddLabel = (type: "row" | "column") => {
if (type === "row") { if (type === "row") {
const updatedRows = [...question.rows, createI18nString("", languageCodes)]; const updatedRows = [...question.rows, { id: createId(), label: createI18nString("", languageCodes) }];
updateQuestion(questionIdx, { rows: updatedRows }); updateQuestion(questionIdx, { rows: updatedRows });
} else { } else {
const updatedColumns = [...question.columns, createI18nString("", languageCodes)]; const updatedColumns = [
...question.columns,
{ id: createId(), label: createI18nString("", languageCodes) },
];
updateQuestion(questionIdx, { columns: updatedColumns }); updateQuestion(questionIdx, { columns: updatedColumns });
} }
}; };
@@ -120,10 +87,6 @@ export const MatrixQuestionForm = ({
const updatedLabels = labels.filter((_, idx) => idx !== index); const updatedLabels = labels.filter((_, idx) => idx !== index);
// Update the CUID arrays when deleting
const cuidType = type === "row" ? "rows" : "columns";
cuidRefs.current[cuidType] = cuidRefs.current[cuidType].filter((_, idx) => idx !== index);
if (type === "row") { if (type === "row") {
updateQuestion(questionIdx, { rows: updatedLabels }); updateQuestion(questionIdx, { rows: updatedLabels });
} else { } else {
@@ -136,9 +99,9 @@ export const MatrixQuestionForm = ({
// Update the label at the given index, or add a new label if index is undefined // Update the label at the given index, or add a new label if index is undefined
if (index !== undefined) { if (index !== undefined) {
labels[index] = matrixLabel; labels[index].label = matrixLabel;
} else { } else {
labels.push(matrixLabel); labels.push({ id: createId(), label: matrixLabel });
} }
if (type === "row") { if (type === "row") {
updateQuestion(questionIdx, { rows: labels }); updateQuestion(questionIdx, { rows: labels });
@@ -154,6 +117,27 @@ export const MatrixQuestionForm = ({
} }
}; };
const handleMatrixDragEnd = useCallback(
(type: "row" | "column", event: DragEndEvent) => {
const { active, over } = event;
if (!active || !over || active.id === over.id) return;
const items = type === "row" ? [...question.rows] : [...question.columns];
const activeIndex = items.findIndex((item) => item.id === active.id);
const overIndex = items.findIndex((item) => item.id === over.id);
if (activeIndex === -1 || overIndex === -1) return;
const movedItem = items[activeIndex];
items.splice(activeIndex, 1);
items.splice(overIndex, 0, movedItem);
updateQuestion(questionIdx, type === "row" ? { rows: items } : { columns: items });
},
[questionIdx, updateQuestion, question.rows, question.columns]
);
const shuffleOptionsTypes = { const shuffleOptionsTypes = {
none: { none: {
id: "none", id: "none",
@@ -173,6 +157,7 @@ export const MatrixQuestionForm = ({
}; };
/// Auto animate /// Auto animate
const [parent] = useAutoAnimate(); const [parent] = useAutoAnimate();
return ( return (
<form> <form>
<QuestionFormInput <QuestionFormInput
@@ -226,44 +211,39 @@ export const MatrixQuestionForm = ({
<div> <div>
{/* Rows section */} {/* Rows section */}
<Label htmlFor="rows">{t("environments.surveys.edit.rows")}</Label> <Label htmlFor="rows">{t("environments.surveys.edit.rows")}</Label>
<div className="mt-2 flex flex-col gap-2" ref={parent}> <div className="mt-2">
{rowsWithCuid.map((row, index) => ( <DndContext id="matrix-rows" onDragEnd={(e) => handleMatrixDragEnd("row", e)}>
<div className="flex items-center" key={row.id}> <SortableContext items={question.rows} strategy={verticalListSortingStrategy}>
<QuestionFormInput <div className="flex flex-col gap-2" ref={parent}>
id={`row-${index}`} {question.rows.map((row, index) => (
label={""} <MatrixSortableItem
localSurvey={localSurvey} key={row.id}
questionIdx={questionIdx} choice={row}
value={question.rows[index]} index={index}
updateMatrixLabel={updateMatrixLabel} type="row"
selectedLanguageCode={selectedLanguageCode} localSurvey={localSurvey}
setSelectedLanguageCode={setSelectedLanguageCode} question={question}
isInvalid={ questionIdx={questionIdx}
isInvalid && !isLabelValidForAllLanguages(question.rows[index], localSurvey.languages) updateMatrixLabel={updateMatrixLabel}
} onDelete={(index) => handleDeleteLabel("row", index)}
locale={locale} onKeyDown={(e) => handleKeyDown(e, "row")}
onKeyDown={(e) => handleKeyDown(e, "row")} canDelete={question.rows.length > 2}
/> selectedLanguageCode={selectedLanguageCode}
{question.rows.length > 2 && ( setSelectedLanguageCode={setSelectedLanguageCode}
<TooltipRenderer data-testid="tooltip-renderer" tooltipContent={t("common.delete")}> isInvalid={
<Button isInvalid &&
variant="ghost" !isLabelValidForAllLanguages(question.rows[index].label, localSurvey.languages)
size="icon" }
className="ml-2" locale={locale}
onClick={(e) => { />
e.preventDefault(); ))}
handleDeleteLabel("row", index); </div>
}}> </SortableContext>
<TrashIcon /> </DndContext>
</Button>
</TooltipRenderer>
)}
</div>
))}
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
className="w-fit" className="mt-2 w-fit"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
handleAddLabel("row"); handleAddLabel("row");
@@ -276,44 +256,39 @@ export const MatrixQuestionForm = ({
<div> <div>
{/* Columns section */} {/* Columns section */}
<Label htmlFor="columns">{t("environments.surveys.edit.columns")}</Label> <Label htmlFor="columns">{t("environments.surveys.edit.columns")}</Label>
<div className="mt-2 flex flex-col gap-2" ref={parent}> <div className="mt-2">
{columnsWithCuid.map((column, index) => ( <DndContext id="matrix-columns" onDragEnd={(e) => handleMatrixDragEnd("column", e)}>
<div className="flex items-center" key={column.id}> <SortableContext items={question.columns} strategy={verticalListSortingStrategy}>
<QuestionFormInput <div className="flex flex-col gap-2" ref={parent}>
id={`column-${index}`} {question.columns.map((column, index) => (
label={""} <MatrixSortableItem
localSurvey={localSurvey} key={column.id}
questionIdx={questionIdx} choice={column}
value={question.columns[index]} index={index}
updateMatrixLabel={updateMatrixLabel} type="column"
selectedLanguageCode={selectedLanguageCode} localSurvey={localSurvey}
setSelectedLanguageCode={setSelectedLanguageCode} question={question}
isInvalid={ questionIdx={questionIdx}
isInvalid && !isLabelValidForAllLanguages(question.columns[index], localSurvey.languages) updateMatrixLabel={updateMatrixLabel}
} onDelete={(index) => handleDeleteLabel("column", index)}
locale={locale} onKeyDown={(e) => handleKeyDown(e, "column")}
onKeyDown={(e) => handleKeyDown(e, "column")} canDelete={question.columns.length > 2}
/> selectedLanguageCode={selectedLanguageCode}
{question.columns.length > 2 && ( setSelectedLanguageCode={setSelectedLanguageCode}
<TooltipRenderer data-testid="tooltip-renderer" tooltipContent={t("common.delete")}> isInvalid={
<Button isInvalid &&
variant="ghost" !isLabelValidForAllLanguages(question.columns[index].label, localSurvey.languages)
size="icon" }
className="ml-2" locale={locale}
onClick={(e) => { />
e.preventDefault(); ))}
handleDeleteLabel("column", index); </div>
}}> </SortableContext>
<TrashIcon /> </DndContext>
</Button>
</TooltipRenderer>
)}
</div>
))}
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
className="w-fit" className="mt-2 w-fit"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
handleAddLabel("column"); handleAddLabel("column");
@@ -0,0 +1,100 @@
"use client";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useTranslate } from "@tolgee/react";
import { GripVerticalIcon, TrashIcon } from "lucide-react";
import type { JSX } from "react";
import {
TI18nString,
TSurvey,
TSurveyMatrixQuestion,
TSurveyMatrixQuestionChoice,
} from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
interface MatrixSortableItemProps {
choice: TSurveyMatrixQuestionChoice;
type: "row" | "column";
index: number;
localSurvey: TSurvey;
question: TSurveyMatrixQuestion;
questionIdx: number;
updateMatrixLabel: (index: number, type: "row" | "column", matrixLabel: TI18nString) => void;
onDelete: (index: number) => void;
onKeyDown: (e: React.KeyboardEvent) => void;
canDelete: boolean;
selectedLanguageCode: string;
setSelectedLanguageCode: (language: string) => void;
isInvalid: boolean;
locale: TUserLocale;
}
export const MatrixSortableItem = ({
choice,
type,
index,
localSurvey,
questionIdx,
updateMatrixLabel,
onDelete,
onKeyDown,
canDelete,
selectedLanguageCode,
setSelectedLanguageCode,
isInvalid,
locale,
}: MatrixSortableItemProps): JSX.Element => {
const { t } = useTranslate();
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
id: choice.id,
});
const style = {
transition: transition ?? "transform 100ms ease",
transform: CSS.Translate.toString(transform),
};
return (
<div className="flex w-full items-center gap-2" ref={setNodeRef} style={style}>
<div {...listeners} {...attributes}>
<GripVerticalIcon className="h-4 w-4 cursor-move text-slate-400" />
</div>
<div className="flex w-full items-center">
<QuestionFormInput
key={choice.id}
id={`${type}-${index}`}
label=""
localSurvey={localSurvey}
questionIdx={questionIdx}
value={choice.label}
updateMatrixLabel={updateMatrixLabel}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
locale={locale}
onKeyDown={onKeyDown}
/>
{canDelete && (
<TooltipRenderer data-testid="tooltip-renderer" tooltipContent={t("common.delete")}>
<Button
variant="ghost"
size="icon"
className="ml-2"
onClick={(e) => {
e.preventDefault();
onDelete(index);
}}>
<TrashIcon />
</Button>
</TooltipRenderer>
)}
</div>
</div>
);
};
+2 -2
View File
@@ -118,7 +118,7 @@ export const getConditionValueOptions = (
// Rows submenu // Rows submenu
const rows = question.rows.map((row, rowIdx) => ({ const rows = question.rows.map((row, rowIdx) => ({
icon: getQuestionIconMapping(t)[question.type], icon: getQuestionIconMapping(t)[question.type],
label: `${getLocalizedValue(row, "default")} (${getLocalizedValue(question.headline, "default")})`, label: `${getLocalizedValue(row.label, "default")} (${getLocalizedValue(question.headline, "default")})`,
value: `${question.id}.${rowIdx}`, value: `${question.id}.${rowIdx}`,
meta: { meta: {
type: "question", type: "question",
@@ -629,7 +629,7 @@ export const getMatchValueProps = (
} else if (selectedQuestion?.type === TSurveyQuestionTypeEnum.Matrix) { } else if (selectedQuestion?.type === TSurveyQuestionTypeEnum.Matrix) {
const choices = selectedQuestion.columns.map((column, colIdx) => { const choices = selectedQuestion.columns.map((column, colIdx) => {
return { return {
label: getLocalizedValue(column, "default"), label: getLocalizedValue(column.label, "default"),
value: colIdx.toString(), value: colIdx.toString(),
meta: { meta: {
type: "static", type: "static",
@@ -61,14 +61,20 @@ const handleI18nCheckForMatrixLabels = (
): boolean => { ): boolean => {
const rowsAndColumns = [...question.rows, ...question.columns]; const rowsAndColumns = [...question.rows, ...question.columns];
const invalidRowsLangCodes = findLanguageCodesForDuplicateLabels(question.rows, languages); const invalidRowsLangCodes = findLanguageCodesForDuplicateLabels(
const invalidColumnsLangCodes = findLanguageCodesForDuplicateLabels(question.columns, languages); question.rows.map((row) => row.label),
languages
);
const invalidColumnsLangCodes = findLanguageCodesForDuplicateLabels(
question.columns.map((column) => column.label),
languages
);
if (invalidRowsLangCodes.length > 0 || invalidColumnsLangCodes.length > 0) { if (invalidRowsLangCodes.length > 0 || invalidColumnsLangCodes.length > 0) {
return false; return false;
} }
return rowsAndColumns.every((label) => isLabelValidForAllLanguages(label, languages)); return rowsAndColumns.every((choice) => isLabelValidForAllLanguages(choice.label, languages));
}; };
const handleI18nCheckForContactAndAddressFields = ( const handleI18nCheckForContactAndAddressFields = (
+8 -2
View File
@@ -181,8 +181,14 @@ export const getQuestionTypes = (t: TFnType): TQuestion[] => [
icon: Grid3X3Icon, icon: Grid3X3Icon,
preset: { preset: {
headline: createI18nString("", []), headline: createI18nString("", []),
rows: [createI18nString("", []), createI18nString("", [])], rows: [
columns: [createI18nString("", []), createI18nString("", [])], { id: createId(), label: createI18nString("", []) },
{ id: createId(), label: createI18nString("", []) },
],
columns: [
{ id: createId(), label: createI18nString("", []) },
{ id: createId(), label: createI18nString("", []) },
],
buttonLabel: createI18nString(t("templates.next"), []), buttonLabel: createI18nString(t("templates.next"), []),
backButtonLabel: createI18nString(t("templates.back"), []), backButtonLabel: createI18nString(t("templates.back"), []),
shuffleOption: "none", shuffleOption: "none",
+10
View File
@@ -2,6 +2,8 @@
import { actionClient } from "@/lib/utils/action-client"; import { actionClient } from "@/lib/utils/action-client";
import { getOrganizationIdFromSurveyId } from "@/lib/utils/helper"; import { getOrganizationIdFromSurveyId } from "@/lib/utils/helper";
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customization/lib/organization"; import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customization/lib/organization";
import { sendLinkSurveyToVerifiedEmail } from "@/modules/email"; import { sendLinkSurveyToVerifiedEmail } from "@/modules/email";
import { getSurveyWithMetadata, isSurveyResponsePresent } from "@/modules/survey/link/lib/data"; import { getSurveyWithMetadata, isSurveyResponsePresent } from "@/modules/survey/link/lib/data";
@@ -12,6 +14,14 @@ import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/erro
export const sendLinkSurveyEmailAction = actionClient export const sendLinkSurveyEmailAction = actionClient
.schema(ZLinkSurveyEmailData) .schema(ZLinkSurveyEmailData)
.action(async ({ parsedInput }) => { .action(async ({ parsedInput }) => {
await applyIPRateLimit(rateLimitConfigs.actions.sendLinkSurveyEmail);
const survey = await getSurveyWithMetadata(parsedInput.surveyId);
if (!survey.isVerifyEmailEnabled) {
throw new InvalidInputError("EMAIL_VERIFICATION_NOT_ENABLED");
}
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId); const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const organizationLogoUrl = await getOrganizationLogoUrl(organizationId); const organizationLogoUrl = await getOrganizationLogoUrl(organizationId);
@@ -92,7 +92,7 @@ describe("contact-survey page", () => {
params: Promise.resolve({ jwt: "token" }), params: Promise.resolve({ jwt: "token" }),
searchParams: Promise.resolve({}), searchParams: Promise.resolve({}),
}); });
expect(meta).toEqual({ title: "Survey", description: "Complete this survey" }); expect(meta).toEqual({ title: "Survey", description: "Please complete this survey." });
}); });
test("generateMetadata returns default when verify throws", async () => { test("generateMetadata returns default when verify throws", async () => {
@@ -103,7 +103,7 @@ describe("contact-survey page", () => {
params: Promise.resolve({ jwt: "token" }), params: Promise.resolve({ jwt: "token" }),
searchParams: Promise.resolve({}), searchParams: Promise.resolve({}),
}); });
expect(meta).toEqual({ title: "Survey", description: "Complete this survey" }); expect(meta).toEqual({ title: "Survey", description: "Please complete this survey." });
}); });
test("generateMetadata returns basic metadata when token valid", async () => { test("generateMetadata returns basic metadata when token valid", async () => {
@@ -31,7 +31,7 @@ export const generateMetadata = async (props: ContactSurveyPageProps): Promise<M
if (!result.ok) { if (!result.ok) {
return { return {
title: "Survey", title: "Survey",
description: "Complete this survey", description: "Please complete this survey.",
}; };
} }
const { surveyId } = result.data; const { surveyId } = result.data;
@@ -40,7 +40,7 @@ export const generateMetadata = async (props: ContactSurveyPageProps): Promise<M
// If the token is invalid, we'll return generic metadata // If the token is invalid, we'll return generic metadata
return { return {
title: "Survey", title: "Survey",
description: "Complete this survey", description: "Please complete this survey.",
}; };
} }
}; };
@@ -77,7 +77,7 @@ describe("Metadata Utils", () => {
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId); expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(result).toEqual({ expect(result).toEqual({
title: "Survey", title: "Survey",
description: "Complete this survey", description: "Please complete this survey.",
survey: null, survey: null,
ogImage: undefined, ogImage: undefined,
}); });
@@ -108,10 +108,9 @@ describe("Metadata Utils", () => {
const result = await getBasicSurveyMetadata(mockSurveyId); const result = await getBasicSurveyMetadata(mockSurveyId);
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId); expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getProjectByEnvironmentId).toHaveBeenCalledWith(mockEnvironmentId);
expect(result).toEqual({ expect(result).toEqual({
title: "Welcome Headline | Test Project", title: "Welcome Headline",
description: "Complete this survey", description: "Please complete this survey.",
survey: mockSurvey, survey: mockSurvey,
ogImage: undefined, ogImage: undefined,
}); });
@@ -129,13 +128,12 @@ describe("Metadata Utils", () => {
} as TSurvey; } as TSurvey;
vi.mocked(getSurvey).mockResolvedValue(mockSurvey); vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
vi.mocked(getProjectByEnvironmentId).mockResolvedValue({ name: "Test Project" } as any);
const result = await getBasicSurveyMetadata(mockSurveyId); const result = await getBasicSurveyMetadata(mockSurveyId);
expect(result).toEqual({ expect(result).toEqual({
title: "Test Survey | Test Project", title: "Test Survey",
description: "Complete this survey", description: "Please complete this survey.",
survey: mockSurvey, survey: mockSurvey,
ogImage: undefined, ogImage: undefined,
}); });
@@ -1,8 +1,8 @@
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl"; import { getPublicDomain } from "@/lib/getPublicUrl";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { COLOR_DEFAULTS } from "@/lib/styling/constants"; import { COLOR_DEFAULTS } from "@/lib/styling/constants";
import { getSurvey } from "@/modules/survey/lib/survey"; import { getSurvey } from "@/modules/survey/lib/survey";
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
import { Metadata } from "next"; import { Metadata } from "next";
type TBasicSurveyMetadata = { type TBasicSurveyMetadata = {
@@ -12,22 +12,16 @@ type TBasicSurveyMetadata = {
ogImage?: string; ogImage?: string;
}; };
/** export const getNameForURL = (value: string) => encodeURIComponent(value);
* Utility function to encode name for URL usage
*/
export const getNameForURL = (url: string) => url.replace(/ /g, "%20");
/** export const getBrandColorForURL = (value: string) => encodeURIComponent(value);
* Utility function to encode brand color for URL usage
*/
export const getBrandColorForURL = (url: string) => url.replace(/#/g, "%23");
/** /**
* Get basic survey metadata (title and description) based on link metadata, welcome card or survey name * Get basic survey metadata (title and description) based on link metadata, welcome card or survey name
*/ */
export const getBasicSurveyMetadata = async ( export const getBasicSurveyMetadata = async (
surveyId: string, surveyId: string,
languageCode?: string languageCode = "default"
): Promise<TBasicSurveyMetadata> => { ): Promise<TBasicSurveyMetadata> => {
const survey = await getSurvey(surveyId); const survey = await getSurvey(surveyId);
@@ -35,7 +29,7 @@ export const getBasicSurveyMetadata = async (
if (!survey) { if (!survey) {
return { return {
title: "Survey", title: "Survey",
description: "Complete this survey", description: "Please complete this survey.",
survey: null, survey: null,
ogImage: undefined, ogImage: undefined,
}; };
@@ -43,38 +37,33 @@ export const getBasicSurveyMetadata = async (
const metadata = survey.metadata; const metadata = survey.metadata;
const welcomeCard = survey.welcomeCard; const welcomeCard = survey.welcomeCard;
const useDefaultLanguageCode =
languageCode === "default" ||
survey.languages.find((lang) => lang.language.code === languageCode)?.default;
// Determine language code to use for metadata // Determine language code to use for metadata
const langCode = languageCode || "default"; const langCode = useDefaultLanguageCode ? "default" : languageCode;
// Set title - priority: custom link metadata > welcome card > survey name // Set title - priority: custom link metadata > welcome card > survey name
let title = "Survey"; const titleFromMetadata = metadata?.title ? getLocalizedValue(metadata.title, langCode) || "" : undefined;
if (metadata.title?.[langCode]) { const titleFromWelcome =
title = metadata.title[langCode]; welcomeCard?.enabled && welcomeCard.headline
} else if (welcomeCard.enabled && welcomeCard.headline?.default) { ? getLocalizedValue(welcomeCard.headline, langCode) || ""
title = welcomeCard.headline.default; : undefined;
} else { let title = titleFromMetadata || titleFromWelcome || survey.name;
title = survey.name;
}
// Set description - priority: custom link metadata > welcome card > default // Set description - priority: custom link metadata > default
let description = "Complete this survey"; const descriptionFromMetadata = metadata?.description
if (metadata.description?.[langCode]) { ? getLocalizedValue(metadata.description, langCode) || ""
description = metadata.description[langCode]; : undefined;
} let description = descriptionFromMetadata || "Please complete this survey.";
// Get OG image from link metadata if available // Get OG image from link metadata if available
const { ogImage } = metadata; const { ogImage } = metadata;
// Add product name in title if it's Formbricks cloud and not using custom metadata if (!titleFromMetadata) {
if (!metadata.title?.[langCode]) {
if (IS_FORMBRICKS_CLOUD) { if (IS_FORMBRICKS_CLOUD) {
title = `${title} | Formbricks`; title = `${title} | Formbricks`;
} else {
const project = await getProjectByEnvironmentId(survey.environmentId);
if (project) {
title = `${title} | ${project.name}`;
}
} }
} }
@@ -89,10 +78,13 @@ export const getBasicSurveyMetadata = async (
/** /**
* Generate Open Graph metadata for survey * Generate Open Graph metadata for survey
*/ */
export const getSurveyOpenGraphMetadata = (surveyId: string, surveyName: string): Metadata => { export const getSurveyOpenGraphMetadata = (
const brandColor = getBrandColorForURL(COLOR_DEFAULTS.brandColor); // Default color surveyId: string,
surveyName: string,
surveyBrandColor?: string
): Metadata => {
const encodedName = getNameForURL(surveyName); const encodedName = getNameForURL(surveyName);
const brandColor = getBrandColorForURL(surveyBrandColor ?? COLOR_DEFAULTS.brandColor);
const ogImgURL = `/api/v1/client/og?brandColor=${brandColor}&name=${encodedName}`; const ogImgURL = `/api/v1/client/og?brandColor=${brandColor}&name=${encodedName}`;
return { return {
@@ -20,7 +20,7 @@ vi.mock("./lib/metadata-utils", () => ({
describe("getMetadataForLinkSurvey", () => { describe("getMetadataForLinkSurvey", () => {
const mockSurveyId = "survey-123"; const mockSurveyId = "survey-123";
const mockSurveyName = "Test Survey"; const mockSurveyName = "Test Survey";
const mockDescription = "Complete this survey"; const mockDescription = "Please complete this survey.";
const mockOgImageUrl = "https://example.com/custom-image.png"; const mockOgImageUrl = "https://example.com/custom-image.png";
beforeEach(() => { beforeEach(() => {
@@ -60,7 +60,7 @@ describe("getMetadataForLinkSurvey", () => {
expect(getSurveyMetadata).toHaveBeenCalledWith(mockSurveyId); expect(getSurveyMetadata).toHaveBeenCalledWith(mockSurveyId);
expect(getBasicSurveyMetadata).toHaveBeenCalledWith(mockSurveyId, undefined); expect(getBasicSurveyMetadata).toHaveBeenCalledWith(mockSurveyId, undefined);
expect(getSurveyOpenGraphMetadata).toHaveBeenCalledWith(mockSurveyId, mockSurveyName); expect(getSurveyOpenGraphMetadata).toHaveBeenCalledWith(mockSurveyId, mockSurveyName, undefined);
expect(result).toEqual({ expect(result).toEqual({
title: mockSurveyName, title: mockSurveyName,
+2 -1
View File
@@ -15,9 +15,10 @@ export const getMetadataForLinkSurvey = async (
// Get enhanced metadata that includes custom link metadata // Get enhanced metadata that includes custom link metadata
const { title, description, ogImage } = await getBasicSurveyMetadata(surveyId, languageCode); const { title, description, ogImage } = await getBasicSurveyMetadata(surveyId, languageCode);
const surveyBrandColor = survey.styling?.brandColor?.light;
// Use the shared function for creating the base metadata but override with custom data // Use the shared function for creating the base metadata but override with custom data
const baseMetadata = getSurveyOpenGraphMetadata(survey.id, title); const baseMetadata = getSurveyOpenGraphMetadata(survey.id, title, surveyBrandColor);
// Override with the custom image URL // Override with the custom image URL
if (baseMetadata.openGraph) { if (baseMetadata.openGraph) {
@@ -12,6 +12,7 @@ interface AdvancedOptionToggleProps {
childBorder?: boolean; childBorder?: boolean;
customContainerClass?: string; customContainerClass?: string;
disabled?: boolean; disabled?: boolean;
childrenContainerClass?: string;
} }
export const AdvancedOptionToggle = ({ export const AdvancedOptionToggle = ({
@@ -24,6 +25,7 @@ export const AdvancedOptionToggle = ({
childBorder, childBorder,
customContainerClass, customContainerClass,
disabled = false, disabled = false,
childrenContainerClass,
}: AdvancedOptionToggleProps) => { }: AdvancedOptionToggleProps) => {
return ( return (
<div className={cn("px-4 py-2", customContainerClass)}> <div className={cn("px-4 py-2", customContainerClass)}>
@@ -40,7 +42,8 @@ export const AdvancedOptionToggle = ({
<div <div
className={cn( className={cn(
"mt-4 flex w-full items-center space-x-1 overflow-hidden rounded-lg bg-slate-50", "mt-4 flex w-full items-center space-x-1 overflow-hidden rounded-lg bg-slate-50",
childBorder && "border" childBorder && "border",
childrenContainerClass
)}> )}>
{children} {children}
</div> </div>
@@ -12,13 +12,6 @@ vi.mock("boring-avatars", () => ({
), ),
})); }));
// Mock next/image
vi.mock("next/image", () => ({
default: ({ src, width, height, className, alt }: any) => (
<img src={src} width={width} height={height} className={className} alt={alt} data-testid="next-image" />
),
}));
describe("Avatar Components", () => { describe("Avatar Components", () => {
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
@@ -44,7 +37,7 @@ describe("Avatar Components", () => {
}); });
describe("ProfileAvatar", () => { describe("ProfileAvatar", () => {
test("renders Boring Avatar when imageUrl is not provided", () => { test("renders Boring Avatar", () => {
render(<ProfileAvatar userId="user-123" />); render(<ProfileAvatar userId="user-123" />);
const avatar = screen.getByTestId("boring-avatar-bauhaus"); const avatar = screen.getByTestId("boring-avatar-bauhaus");
@@ -52,32 +45,5 @@ describe("Avatar Components", () => {
expect(avatar).toHaveAttribute("data-size", "40"); expect(avatar).toHaveAttribute("data-size", "40");
expect(avatar).toHaveAttribute("data-name", "user-123"); expect(avatar).toHaveAttribute("data-name", "user-123");
}); });
test("renders Boring Avatar when imageUrl is null", () => {
render(<ProfileAvatar userId="user-123" imageUrl={null} />);
const avatar = screen.getByTestId("boring-avatar-bauhaus");
expect(avatar).toBeInTheDocument();
});
test("renders Image component when imageUrl is provided", () => {
render(<ProfileAvatar userId="user-123" imageUrl="https://example.com/avatar.jpg" />);
const image = screen.getByTestId("next-image");
expect(image).toBeInTheDocument();
expect(image).toHaveAttribute("src", "https://example.com/avatar.jpg");
expect(image).toHaveAttribute("width", "40");
expect(image).toHaveAttribute("height", "40");
expect(image).toHaveAttribute("alt", "Avatar placeholder");
expect(image).toHaveClass("h-10", "w-10", "rounded-full", "object-cover");
});
test("renders Image component with different imageUrl", () => {
render(<ProfileAvatar userId="user-123" imageUrl="https://example.com/different-avatar.png" />);
const image = screen.getByTestId("next-image");
expect(image).toBeInTheDocument();
expect(image).toHaveAttribute("src", "https://example.com/different-avatar.png");
});
}); });
}); });
@@ -1,5 +1,4 @@
import Avatar from "boring-avatars"; import Avatar from "boring-avatars";
import Image from "next/image";
const colors = ["#00C4B8", "#ccfbf1", "#334155"]; const colors = ["#00C4B8", "#ccfbf1", "#334155"];
@@ -13,20 +12,8 @@ export const PersonAvatar: React.FC<PersonAvatarProps> = ({ personId }) => {
interface ProfileAvatar { interface ProfileAvatar {
userId: string; userId: string;
imageUrl?: string | null;
} }
export const ProfileAvatar: React.FC<ProfileAvatar> = ({ userId, imageUrl }) => { export const ProfileAvatar: React.FC<ProfileAvatar> = ({ userId }) => {
if (imageUrl) {
return (
<Image
src={imageUrl}
width="40"
height="40"
className="h-10 w-10 rounded-full object-cover"
alt="Avatar placeholder"
/>
);
}
return <Avatar size={40} name={userId} variant="bauhaus" colors={colors} />; return <Avatar size={40} name={userId} variant="bauhaus" colors={colors} />;
}; };
@@ -0,0 +1,217 @@
import { Meta, StoryObj } from "@storybook/react-vite";
import { Button } from "../button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./index";
interface StoryOptions {
side: "top" | "right" | "bottom" | "left";
delayDuration: number;
sideOffset: number;
buttonText: string;
tooltipText: string;
className?: string;
}
type TooltipStoryProps = StoryOptions;
const meta: Meta<TooltipStoryProps> = {
title: "UI/Tooltip",
tags: ["autodocs"],
parameters: {
layout: "centered",
controls: { sort: "alpha", exclude: [] },
docs: {
description: {
component:
"The **Tooltip** component provides contextual information in a compact overlay. Use tooltips to explain buttons, provide additional context, or show helpful hints without cluttering the interface.",
},
},
},
argTypes: {
tooltipText: {
control: "text",
description: "The text content to display in the tooltip",
table: {
category: "Content",
type: { summary: "string" },
},
order: 1,
},
buttonText: {
control: "text",
description: "The text to display on the button trigger",
table: {
category: "Content",
type: { summary: "string" },
},
order: 2,
},
side: {
control: "select",
options: ["top", "right", "bottom", "left"],
description: "Side where the tooltip appears relative to the trigger",
table: {
category: "Behavior",
type: { summary: "string" },
defaultValue: { summary: "top" },
},
order: 3,
},
delayDuration: {
control: { type: "number", min: 0, max: 1000, step: 100 },
description: "Delay in milliseconds before tooltip appears",
table: {
category: "Behavior",
type: { summary: "number" },
defaultValue: { summary: "700" },
},
order: 4,
},
sideOffset: {
control: { type: "number", min: 0, max: 20, step: 1 },
description: "Distance in pixels from the trigger",
table: {
category: "Appearance",
type: { summary: "number" },
defaultValue: { summary: "4" },
},
order: 5,
},
className: {
control: "text",
description: "Additional CSS classes for the tooltip content",
table: {
category: "Appearance",
type: { summary: "string" },
},
order: 6,
},
},
};
export default meta;
type Story = StoryObj<TooltipStoryProps>;
const renderTooltip = (args: TooltipStoryProps) => {
const { side, delayDuration, sideOffset, buttonText, tooltipText, className } = args;
return (
<TooltipProvider delayDuration={delayDuration}>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline">{buttonText}</Button>
</TooltipTrigger>
<TooltipContent side={side} sideOffset={sideOffset} className={className}>
{tooltipText}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
export const Default: Story = {
render: renderTooltip,
args: {
tooltipText: "This is a helpful tooltip",
buttonText: "Hover me",
side: "top",
delayDuration: 0,
sideOffset: 4,
className: "",
},
};
export const WithButton: Story = {
render: renderTooltip,
args: {
tooltipText: "Create a new survey to collect responses",
buttonText: "Create Survey",
side: "top",
delayDuration: 700,
sideOffset: 4,
className: "",
},
parameters: {
docs: {
description: {
story: "Use tooltips with buttons to provide additional context about the action.",
},
},
},
};
export const BottomPosition: Story = {
render: renderTooltip,
args: {
tooltipText: "This tooltip appears below the button",
buttonText: "Bottom tooltip",
side: "bottom",
delayDuration: 700,
sideOffset: 8,
className: "",
},
parameters: {
docs: {
description: {
story: "Position tooltips on different sides of the trigger element.",
},
},
},
};
export const NoDelay: Story = {
render: renderTooltip,
args: {
tooltipText: "This tooltip shows immediately",
buttonText: "Instant tooltip",
side: "top",
delayDuration: 0,
sideOffset: 4,
className: "",
},
parameters: {
docs: {
description: {
story: "Remove delay for immediate tooltip display.",
},
},
},
};
export const LongContent: Story = {
render: renderTooltip,
args: {
tooltipText:
"This is a very long tooltip content that demonstrates how tooltips handle extended text. It provides comprehensive information that might be needed by users to understand the feature better.",
buttonText: "Long tooltip",
side: "top",
delayDuration: 700,
sideOffset: 4,
className: "",
},
parameters: {
docs: {
description: {
story: "Tooltips automatically handle longer content and wrap text appropriately.",
},
},
},
};
export const CustomStyling: StoryObj = {
render: renderTooltip,
args: {
tooltipText: "This tooltip has custom styling",
buttonText: "Custom styling",
side: "top",
delayDuration: 700,
sideOffset: 4,
className: "bg-blue-900 text-blue-50 border-blue-700",
},
parameters: {
docs: {
description: {
story: "Customize the appearance of tooltips with custom CSS classes.",
},
},
},
};
+2 -2
View File
@@ -52,7 +52,7 @@
"@opentelemetry/sdk-logs": "0.200.0", "@opentelemetry/sdk-logs": "0.200.0",
"@opentelemetry/sdk-metrics": "2.0.0", "@opentelemetry/sdk-metrics": "2.0.0",
"@paralleldrive/cuid2": "2.2.2", "@paralleldrive/cuid2": "2.2.2",
"@prisma/client": "6.7.0", "@prisma/client": "6.13.0",
"@radix-ui/react-accordion": "1.2.10", "@radix-ui/react-accordion": "1.2.10",
"@radix-ui/react-checkbox": "1.3.1", "@radix-ui/react-checkbox": "1.3.1",
"@radix-ui/react-collapsible": "1.1.10", "@radix-ui/react-collapsible": "1.1.10",
@@ -82,7 +82,7 @@
"@vercel/functions": "2.2.8", "@vercel/functions": "2.2.8",
"@vercel/og": "0.8.5", "@vercel/og": "0.8.5",
"bcryptjs": "3.0.2", "bcryptjs": "3.0.2",
"boring-avatars": "1.11.2", "boring-avatars": "2.0.1",
"cache-manager": "6.4.3", "cache-manager": "6.4.3",
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
"clsx": "2.1.1", "clsx": "2.1.1",
@@ -13,6 +13,8 @@ This guide explains the settings you need to use to configure SAML with your Ide
**Entity ID / Identifier / Audience URI / Audience Restriction:** [https://saml.formbricks.com](https://saml.formbricks.com) **Entity ID / Identifier / Audience URI / Audience Restriction:** [https://saml.formbricks.com](https://saml.formbricks.com)
> **Note:** [https://saml.formbricks.com](https://saml.formbricks.com) is hardcoded in Formbricks — do not replace it with your instance URL. It is the fixed SP Entity ID and must match exactly as shown in SAML assertions.
**Response:** Signed **Response:** Signed
**Assertion Signature:** Signed **Assertion Signature:** Signed
@@ -77,7 +79,7 @@ This guide explains the settings you need to use to configure SAML with your Ide
</Step> </Step>
<Step title="Enter the SAML Integration Settings as shown and click Next"> <Step title="Enter the SAML Integration Settings as shown and click Next">
- **Single Sign-On URL**: `https://<your-formbricks-instance>/api/auth/saml/callback` or `http://localhost:3000/api/auth/saml/callback` (if you are running Formbricks locally) - **Single Sign-On URL**: `https://<your-formbricks-instance>/api/auth/saml/callback` or `http://localhost:3000/api/auth/saml/callback` (if you are running Formbricks locally)
- **Audience URI (SP Entity ID)**: `https://saml.formbricks.com` - **Audience URI (SP Entity ID)**: `https://saml.formbricks.com` (hardcoded; do not replace with your instance URL)
<img src="/images/development/guides/auth-and-provision/okta/saml-integration-settings.webp" /> <img src="/images/development/guides/auth-and-provision/okta/saml-integration-settings.webp" />
</Step> </Step>
<Step title="Fill the fields mapping as shown and click Next"> <Step title="Fill the fields mapping as shown and click Next">
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
# This is a better (faster) alternative to the built-in Nix support
if ! has nix_direnv_version || ! nix_direnv_version 3.0.4; then
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.4/direnvrc" "sha256-DzlYZ33mWF/Gs8DDeyjr8mnVmQGx7ASYqA5WlxwvBG4="
fi
use flake
+3
View File
@@ -0,0 +1,3 @@
.terraform/
builds
/.direnv/

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