mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-14 03:04:00 -05:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 77506a7a3f | |||
| 2ba079da68 | |||
| e1607def05 | |||
| 9d7dac33be | |||
| b9d544f36f | |||
| 7abd0e9aed | |||
| a9db89ecdd | |||
| 0155c41593 | |||
| df63f2e5d9 | |||
| a3764f0316 | |||
| 7dd174ffea | |||
| 7154f6fe74 | |||
| f25f257f24 | |||
| b945900fbf | |||
| f8869e7522 | |||
| 886eb8598a | |||
| fe3c8e010f | |||
| a6a76cc3cf | |||
| 9e7a4e38cf | |||
| ec52bdf3fe | |||
| 9cff5457d6 | |||
| 2e9ad3ce07 | |||
| 654bd232d6 | |||
| a362455878 | |||
| 01984cf8ca | |||
| 3eb18bb120 | |||
| 59859d0e4f | |||
| c60c8cb7bd | |||
| 9fa7aef253 | |||
| a23594428a | |||
| 56e7106d6e | |||
| 318f891540 | |||
| a59881f9ae | |||
| 7ab4a45ad6 | |||
| 2990e3805f | |||
| 29132ab029 | |||
| f860d8d25d | |||
| 3501990a79 | |||
| 41d60c8a02 | |||
| a6269f0fd3 | |||
| 9c0d0a16a7 | |||
| c6241f7e7f | |||
| 92f1c2b75a | |||
| 4d53291c8a |
+20
-11
@@ -7,6 +7,7 @@ description: >
|
||||
globs: []
|
||||
alwaysApply: agent-requested
|
||||
---
|
||||
|
||||
# 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.
|
||||
@@ -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.
|
||||
|
||||
### Core Hierarchy
|
||||
|
||||
```
|
||||
Organization
|
||||
└── Project
|
||||
@@ -29,6 +31,7 @@ Organization
|
||||
## Schema Reference
|
||||
|
||||
For the complete and up-to-date database schema, please refer to:
|
||||
|
||||
- Main schema: `packages/database/schema.prisma`
|
||||
- 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
|
||||
|
||||
### Multi-tenancy
|
||||
|
||||
- All data is scoped by Organization
|
||||
- Environment-level isolation for surveys and contacts
|
||||
- Project-level grouping for related surveys
|
||||
|
||||
### Soft Deletion
|
||||
|
||||
Some models use soft deletion patterns:
|
||||
|
||||
- Check `isActive` fields where present
|
||||
- Use proper filtering in queries
|
||||
|
||||
### Cascading Deletes
|
||||
|
||||
Configured cascade relationships:
|
||||
|
||||
- Organization deletion cascades to all child entities
|
||||
- Survey deletion removes responses, displays, triggers
|
||||
- Contact deletion removes attributes and responses
|
||||
@@ -55,6 +63,7 @@ Configured cascade relationships:
|
||||
## Common Query Patterns
|
||||
|
||||
### Survey with Responses
|
||||
|
||||
```typescript
|
||||
// Include response count and latest responses
|
||||
const survey = await prisma.survey.findUnique({
|
||||
@@ -62,40 +71,40 @@ const survey = await prisma.survey.findUnique({
|
||||
include: {
|
||||
responses: {
|
||||
take: 10,
|
||||
orderBy: { createdAt: 'desc' }
|
||||
orderBy: { createdAt: "desc" },
|
||||
},
|
||||
_count: {
|
||||
select: { responses: true }
|
||||
}
|
||||
}
|
||||
select: { responses: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Environment Scoping
|
||||
|
||||
```typescript
|
||||
// Always scope by environment
|
||||
const surveys = await prisma.survey.findMany({
|
||||
where: {
|
||||
environmentId: environmentId,
|
||||
// Additional filters...
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Contact with Attributes
|
||||
|
||||
```typescript
|
||||
const contact = await prisma.contact.findUnique({
|
||||
where: { id: contactId },
|
||||
include: {
|
||||
attributes: {
|
||||
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.
|
||||
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
@@ -17,7 +17,34 @@ jobs:
|
||||
scan:
|
||||
name: Vulnerability Scan
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
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
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
with:
|
||||
@@ -35,6 +62,9 @@ jobs:
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@a4e1a019f5e24960714ff6296aee04b736cbc3cf # v3.29.6
|
||||
if: ${{ always() && hashFiles('trivy-results.sarif') != '' }}
|
||||
if: ${{ always() }}
|
||||
with:
|
||||
sarif_file: "trivy-results.sarif"
|
||||
ref: ${{ steps.gitref.outputs.ref }}
|
||||
sha: ${{ steps.gitref.outputs.sha }}
|
||||
category: "trivy-container-scan"
|
||||
|
||||
@@ -109,7 +109,7 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
# 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)
|
||||
# https://github.com/docker/build-push-action
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM node:22-alpine3.21 AS base
|
||||
FROM node:22-alpine3.22 AS base
|
||||
|
||||
#
|
||||
## step 1: Prune monorepo
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
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 organizations = [
|
||||
{ id: "o2", name: "betaOrg" },
|
||||
|
||||
+1
-1
@@ -82,7 +82,7 @@ export const LandingSidebar = ({
|
||||
id="userDropdownTrigger"
|
||||
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")}>
|
||||
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
|
||||
<ProfileAvatar userId={user.id} />
|
||||
<>
|
||||
<div className="grow overflow-hidden">
|
||||
<p
|
||||
|
||||
@@ -113,7 +113,6 @@ const mockUser = {
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: new Date(),
|
||||
imageUrl: "",
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
|
||||
@@ -111,7 +111,6 @@ const mockUser = {
|
||||
id: "user1",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
imageUrl: "http://example.com/avatar.png",
|
||||
emailVerified: new Date(),
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
|
||||
@@ -342,7 +342,7 @@ export const MainNavigation = ({
|
||||
"flex cursor-pointer flex-row items-center gap-3",
|
||||
isCollapsed ? "justify-center px-2" : "px-4"
|
||||
)}>
|
||||
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
|
||||
<ProfileAvatar userId={user.id} />
|
||||
{!isCollapsed && !isTextVisible && (
|
||||
<>
|
||||
<div
|
||||
|
||||
@@ -37,7 +37,6 @@ describe("EnvironmentPage", () => {
|
||||
id: mockUserId,
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
imageUrl: "",
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
|
||||
@@ -5,8 +5,6 @@ import {
|
||||
verifyUserPassword,
|
||||
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
|
||||
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 { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
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 { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
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 {
|
||||
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(
|
||||
withAuditLogging(
|
||||
"passwordReset",
|
||||
|
||||
-104
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
-178
@@ -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>
|
||||
);
|
||||
};
|
||||
+3
-6
@@ -49,15 +49,12 @@ describe("Loading", () => {
|
||||
);
|
||||
|
||||
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.update_personal_info");
|
||||
|
||||
expect(loadingCards[1]).toHaveTextContent("common.avatar");
|
||||
expect(loadingCards[1]).toHaveTextContent("environments.settings.profile.organization_identification");
|
||||
|
||||
expect(loadingCards[2]).toHaveTextContent("environments.settings.profile.delete_account");
|
||||
expect(loadingCards[2]).toHaveTextContent("environments.settings.profile.confirm_delete_account");
|
||||
expect(loadingCards[1]).toHaveTextContent("environments.settings.profile.delete_account");
|
||||
expect(loadingCards[1]).toHaveTextContent("environments.settings.profile.confirm_delete_account");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,11 +19,6 @@ const Loading = () => {
|
||||
{ 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"),
|
||||
description: t("environments.settings.profile.confirm_delete_account"),
|
||||
|
||||
-7
@@ -55,11 +55,6 @@ vi.mock(
|
||||
vi.mock("./components/DeleteAccount", () => ({
|
||||
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", () => ({
|
||||
EditProfileDetailsForm: ({ user }) => (
|
||||
<div data-testid="edit-profile-details-form">EditProfileDetailsForm: {user.id}</div>
|
||||
@@ -73,7 +68,6 @@ const mockUser = {
|
||||
id: "user-123",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
imageUrl: "http://example.com/avatar.png",
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] },
|
||||
@@ -117,7 +111,6 @@ describe("ProfilePage", () => {
|
||||
"AccountSettingsNavbar: env-123 profile"
|
||||
);
|
||||
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.queryByTestId("upgrade-prompt")).not.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 { SettingsCard } from "../../components/SettingsCard";
|
||||
import { DeleteAccount } from "./components/DeleteAccount";
|
||||
import { EditProfileAvatarForm } from "./components/EditProfileAvatarForm";
|
||||
import { EditProfileDetailsForm } from "./components/EditProfileDetailsForm";
|
||||
|
||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
@@ -50,17 +49,6 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
isPasswordResetEnabled={isPasswordResetEnabled}
|
||||
/>
|
||||
</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" && (
|
||||
<SettingsCard
|
||||
title={t("common.security")}
|
||||
|
||||
-1
@@ -126,7 +126,6 @@ const mockUser = {
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
emailVerified: new Date(),
|
||||
imageUrl: "",
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
notificationSettings: { alert: {} },
|
||||
|
||||
-1
@@ -128,7 +128,6 @@ const mockUser = {
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: new Date(),
|
||||
imageUrl: "",
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
|
||||
-1
@@ -145,7 +145,6 @@ const mockUser = {
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: new Date(),
|
||||
imageUrl: "",
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
|
||||
+8
-2
@@ -147,8 +147,14 @@ const mockSurvey = {
|
||||
id: "q2matrix",
|
||||
type: TSurveyQuestionTypeEnum.Matrix,
|
||||
headline: { default: "Matrix Question" },
|
||||
rows: [{ default: "Row1" }, { default: "Row2" }],
|
||||
columns: [{ default: "Col1" }, { default: "Col2" }],
|
||||
rows: [
|
||||
{ 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,
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
|
||||
+3
-3
@@ -74,7 +74,7 @@ const getQuestionColumnsData = (
|
||||
case "matrix":
|
||||
return question.rows.map((matrixRow) => {
|
||||
return {
|
||||
accessorKey: matrixRow.default,
|
||||
accessorKey: matrixRow.label.default,
|
||||
header: () => {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -83,14 +83,14 @@ const getQuestionColumnsData = (
|
||||
<span className="truncate">
|
||||
{getLocalizedValue(question.headline, "default") +
|
||||
" - " +
|
||||
getLocalizedValue(matrixRow, "default")}
|
||||
getLocalizedValue(matrixRow.label, "default")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const responseValue = row.original.responseData[matrixRow.default];
|
||||
const responseValue = row.original.responseData[matrixRow.label.default];
|
||||
if (typeof responseValue === "string") {
|
||||
return <p className="text-slate-900">{responseValue}</p>;
|
||||
}
|
||||
|
||||
-1
@@ -291,7 +291,6 @@ const mockUser: TUser = {
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: new Date(),
|
||||
imageUrl: "https://example.com/avatar.jpg",
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
|
||||
-1
@@ -250,7 +250,6 @@ const mockUser: TUser = {
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: new Date(),
|
||||
imageUrl: "https://example.com/avatar.jpg",
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
|
||||
+26
-6
@@ -29,7 +29,7 @@ import {
|
||||
SquareStack,
|
||||
UserIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
@@ -77,6 +77,7 @@ export const ShareSurveyModal = ({
|
||||
description: string;
|
||||
componentType: React.ComponentType<unknown>;
|
||||
componentProps: unknown;
|
||||
disabled?: boolean;
|
||||
}[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -111,6 +112,7 @@ export const ShareSurveyModal = ({
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
},
|
||||
disabled: survey.singleUse?.enabled,
|
||||
},
|
||||
{
|
||||
id: ShareViaType.WEBSITE_EMBED,
|
||||
@@ -121,6 +123,7 @@ export const ShareSurveyModal = ({
|
||||
description: t("environments.surveys.share.embed_on_website.description"),
|
||||
componentType: WebsiteEmbedTab,
|
||||
componentProps: { surveyUrl },
|
||||
disabled: survey.singleUse?.enabled,
|
||||
},
|
||||
{
|
||||
id: ShareViaType.EMAIL,
|
||||
@@ -131,6 +134,7 @@ export const ShareSurveyModal = ({
|
||||
description: t("environments.surveys.share.send_email.description"),
|
||||
componentType: EmailTab,
|
||||
componentProps: { surveyId: survey.id, email },
|
||||
disabled: survey.singleUse?.enabled,
|
||||
},
|
||||
{
|
||||
id: ShareViaType.SOCIAL_MEDIA,
|
||||
@@ -141,6 +145,7 @@ export const ShareSurveyModal = ({
|
||||
description: t("environments.surveys.share.social_media.description"),
|
||||
componentType: SocialMediaTab,
|
||||
componentProps: { surveyUrl, surveyTitle: survey.name },
|
||||
disabled: survey.singleUse?.enabled,
|
||||
},
|
||||
{
|
||||
id: ShareViaType.QR_CODE,
|
||||
@@ -151,6 +156,7 @@ export const ShareSurveyModal = ({
|
||||
description: t("environments.surveys.summary.qr_code_description"),
|
||||
componentType: QRCodeTab,
|
||||
componentProps: { surveyUrl },
|
||||
disabled: survey.singleUse?.enabled,
|
||||
},
|
||||
{
|
||||
id: ShareViaType.DYNAMIC_POPUP,
|
||||
@@ -177,9 +183,9 @@ export const ShareSurveyModal = ({
|
||||
t,
|
||||
survey,
|
||||
publicDomain,
|
||||
setSurveyUrl,
|
||||
user.locale,
|
||||
surveyUrl,
|
||||
isReadOnly,
|
||||
environmentId,
|
||||
segments,
|
||||
isContactsEnabled,
|
||||
@@ -188,9 +194,15 @@ export const ShareSurveyModal = ({
|
||||
]
|
||||
);
|
||||
|
||||
const [activeId, setActiveId] = useState<ShareViaType | ShareSettingsType>(
|
||||
survey.type === "link" ? ShareViaType.ANON_LINKS : ShareViaType.APP
|
||||
);
|
||||
const getDefaultActiveId = useCallback(() => {
|
||||
if (survey.type !== "link") {
|
||||
return ShareViaType.APP;
|
||||
}
|
||||
|
||||
return ShareViaType.ANON_LINKS;
|
||||
}, [survey.type]);
|
||||
|
||||
const [activeId, setActiveId] = useState<ShareViaType | ShareSettingsType>(getDefaultActiveId());
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -198,11 +210,19 @@ export const ShareSurveyModal = ({
|
||||
}
|
||||
}, [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) => {
|
||||
setOpen(open);
|
||||
if (!open) {
|
||||
setShowView("start");
|
||||
setActiveId(ShareViaType.ANON_LINKS);
|
||||
setActiveId(getDefaultActiveId());
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+2
-2
@@ -150,13 +150,13 @@ export const LinkSettingsTab = ({ isReadOnly, locale }: LinkSettingsTabProps) =>
|
||||
name: "title",
|
||||
label: t("environments.surveys.share.link_settings.link_title"),
|
||||
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",
|
||||
label: t("environments.surveys.share.link_settings.link_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.",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
+6
-3
@@ -34,6 +34,7 @@ interface ShareViewProps {
|
||||
componentProps: any;
|
||||
title: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
}>;
|
||||
activeId: ShareViaType | ShareSettingsType;
|
||||
setActiveId: React.Dispatch<React.SetStateAction<ShareViaType | ShareSettingsType>>;
|
||||
@@ -109,12 +110,13 @@ export const ShareView = ({ tabs, activeId, setActiveId }: ShareViewProps) => {
|
||||
onClick={() => setActiveId(tab.id)}
|
||||
className={cn(
|
||||
"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"
|
||||
: "text-slate-700"
|
||||
)}
|
||||
tooltip={tab.label}
|
||||
isActive={tab.id === activeId}>
|
||||
isActive={tab.id === activeId}
|
||||
disabled={tab.disabled}>
|
||||
<tab.icon className="h-4 w-4 text-slate-700" />
|
||||
<span>{tab.label}</span>
|
||||
</SidebarMenuButton>
|
||||
@@ -136,9 +138,10 @@ export const ShareView = ({ tabs, activeId, setActiveId }: ShareViewProps) => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setActiveId(tab.id)}
|
||||
disabled={tab.disabled}
|
||||
className={cn(
|
||||
"rounded-md px-4 py-2",
|
||||
tab.id === activeId
|
||||
tab.id === activeId && !tab.disabled
|
||||
? "bg-white text-slate-900 shadow-sm hover:bg-white"
|
||||
: "border-transparent text-slate-700 hover:text-slate-900"
|
||||
)}>
|
||||
|
||||
+50
-19
@@ -1327,8 +1327,17 @@ describe("Matrix question type tests", () => {
|
||||
type: TSurveyQuestionTypeEnum.Matrix,
|
||||
headline: { default: "Rate these aspects" },
|
||||
required: true,
|
||||
rows: [{ default: "Speed" }, { default: "Quality" }, { default: "Price" }],
|
||||
columns: [{ default: "Poor" }, { default: "Average" }, { default: "Good" }, { default: "Excellent" }],
|
||||
rows: [
|
||||
{ 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 = {
|
||||
@@ -1410,15 +1419,15 @@ describe("Matrix question type tests", () => {
|
||||
headline: { default: "Rate these aspects", es: "Califica estos aspectos" },
|
||||
required: true,
|
||||
rows: [
|
||||
{ default: "Speed", es: "Velocidad" },
|
||||
{ default: "Quality", es: "Calidad" },
|
||||
{ default: "Price", es: "Precio" },
|
||||
{ id: "row-1", label: { default: "Speed", es: "Velocidad" } },
|
||||
{ id: "row-2", label: { default: "Quality", es: "Calidad" } },
|
||||
{ id: "row-3", label: { default: "Price", es: "Precio" } },
|
||||
],
|
||||
columns: [
|
||||
{ default: "Poor", es: "Malo" },
|
||||
{ default: "Average", es: "Promedio" },
|
||||
{ default: "Good", es: "Bueno" },
|
||||
{ default: "Excellent", es: "Excelente" },
|
||||
{ id: "col-1", label: { default: "Poor", es: "Malo" } },
|
||||
{ id: "col-2", label: { default: "Average", es: "Promedio" } },
|
||||
{ id: "col-3", label: { default: "Good", es: "Bueno" } },
|
||||
{ id: "col-4", label: { default: "Excellent", es: "Excelente" } },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1587,8 +1596,16 @@ describe("Matrix question type tests", () => {
|
||||
type: TSurveyQuestionTypeEnum.Matrix,
|
||||
headline: { default: "Rate these aspects" },
|
||||
required: true,
|
||||
rows: [{ default: "Speed" }, { default: "Quality" }, { default: "Price" }],
|
||||
columns: [{ default: "Poor" }, { default: "Average" }, { default: "Good" }],
|
||||
rows: [
|
||||
{ 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 = {
|
||||
@@ -1721,8 +1738,16 @@ describe("Matrix question type tests", () => {
|
||||
type: TSurveyQuestionTypeEnum.Matrix,
|
||||
headline: { default: "Rate these aspects" },
|
||||
required: true,
|
||||
rows: [{ default: "Speed" }, { default: "Quality" }, { default: "Price" }],
|
||||
columns: [{ default: "Poor" }, { default: "Average" }, { default: "Good" }],
|
||||
rows: [
|
||||
{ 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 = {
|
||||
@@ -1785,8 +1810,14 @@ describe("Matrix question type tests", () => {
|
||||
type: TSurveyQuestionTypeEnum.Matrix,
|
||||
headline: { default: "Rate these aspects" },
|
||||
required: true,
|
||||
rows: [{ default: "Speed" }, { default: "Quality" }],
|
||||
columns: [{ default: "Poor" }, { default: "Good" }],
|
||||
rows: [
|
||||
{ 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 = {
|
||||
@@ -1849,12 +1880,12 @@ describe("Matrix question type tests", () => {
|
||||
headline: { default: "Rate these aspects", fr: "Évaluez ces aspects" },
|
||||
required: true,
|
||||
rows: [
|
||||
{ default: "Speed", fr: "Vitesse" },
|
||||
{ default: "Quality", fr: "Qualité" },
|
||||
{ id: "row-1", label: { default: "Speed", fr: "Vitesse" } },
|
||||
{ id: "row-2", label: { default: "Quality", fr: "Qualité" } },
|
||||
],
|
||||
columns: [
|
||||
{ default: "Poor", fr: "Médiocre" },
|
||||
{ default: "Good", fr: "Bon" },
|
||||
{ id: "col-1", label: { default: "Poor", fr: "Médiocre" } },
|
||||
{ id: "col-2", label: { default: "Good", fr: "Bon" } },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
+8
-6
@@ -736,8 +736,8 @@ export const getQuestionSummary = async (
|
||||
break;
|
||||
}
|
||||
case TSurveyQuestionTypeEnum.Matrix: {
|
||||
const rows = question.rows.map((row) => getLocalizedValue(row, "default"));
|
||||
const columns = question.columns.map((column) => getLocalizedValue(column, "default"));
|
||||
const rows = question.rows.map((row) => getLocalizedValue(row.label, "default"));
|
||||
const columns = question.columns.map((column) => getLocalizedValue(column.label, "default"));
|
||||
let totalResponseCount = 0;
|
||||
|
||||
// Initialize count object
|
||||
@@ -755,13 +755,15 @@ export const getQuestionSummary = async (
|
||||
if (selectedResponses) {
|
||||
totalResponseCount++;
|
||||
question.rows.forEach((row) => {
|
||||
const localizedRow = getLocalizedValue(row, responseLanguageCode);
|
||||
const localizedRow = getLocalizedValue(row.label, responseLanguageCode);
|
||||
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)) {
|
||||
countMap[getLocalizedValue(row, "default")][colValueInDefaultLanguage] += 1;
|
||||
countMap[getLocalizedValue(row.label, "default")][colValueInDefaultLanguage] += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
-2
@@ -158,7 +158,6 @@ const mockUser = {
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: new Date(),
|
||||
imageUrl: "",
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
@@ -174,7 +173,6 @@ const mockSession = {
|
||||
id: mockUserId,
|
||||
name: mockUser.name,
|
||||
email: mockUser.email,
|
||||
image: mockUser.imageUrl,
|
||||
role: mockUser.role,
|
||||
plan: "free",
|
||||
status: "active",
|
||||
|
||||
@@ -118,7 +118,6 @@ describe("Page", () => {
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: null,
|
||||
imageUrl: null,
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
@@ -161,7 +160,6 @@ describe("Page", () => {
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: null,
|
||||
imageUrl: null,
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
@@ -250,7 +248,6 @@ describe("Page", () => {
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: null,
|
||||
imageUrl: null,
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
@@ -339,7 +336,6 @@ describe("Page", () => {
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: null,
|
||||
imageUrl: null,
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
|
||||
@@ -625,7 +625,7 @@ export const extractSurveyDetails = (survey: TSurvey, responses: TResponse[]) =>
|
||||
const headline = getLocalizedValue(question.headline, "default") ?? question.id;
|
||||
if (question.type === "matrix") {
|
||||
return question.rows.map((row) => {
|
||||
return `${idx + 1}. ${headline} - ${getLocalizedValue(row, "default")}`;
|
||||
return `${idx + 1}. ${headline} - ${getLocalizedValue(row.label, "default")}`;
|
||||
});
|
||||
} else if (
|
||||
question.type === "multipleChoiceMulti" ||
|
||||
@@ -692,8 +692,8 @@ export const getResponsesJson = (
|
||||
questionHeadline.forEach((headline, index) => {
|
||||
if (answer) {
|
||||
const row = question.rows[index];
|
||||
if (row && row.default && answer[row.default] !== undefined) {
|
||||
jsonData[idx][headline] = answer[row.default];
|
||||
if (row && row.label.default && answer[row.label.default] !== undefined) {
|
||||
jsonData[idx][headline] = answer[row.label.default];
|
||||
} else {
|
||||
jsonData[idx][headline] = "";
|
||||
}
|
||||
|
||||
@@ -122,7 +122,6 @@ export const mockUser: TUser = {
|
||||
name: "mock User",
|
||||
email: "test@unit.com",
|
||||
emailVerified: currentDate,
|
||||
imageUrl: "https://www.google.com",
|
||||
createdAt: currentDate,
|
||||
updatedAt: currentDate,
|
||||
twoFactorEnabled: false,
|
||||
|
||||
@@ -597,8 +597,14 @@ describe("surveyLogic", () => {
|
||||
type: TSurveyQuestionTypeEnum.Matrix,
|
||||
headline: { default: "Matrix Question" },
|
||||
required: true,
|
||||
rows: [{ default: "Row 1" }, { default: "Row 2" }],
|
||||
columns: [{ default: "Column 1" }, { default: "Column 2" }],
|
||||
rows: [
|
||||
{ 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" },
|
||||
shuffleOption: "none",
|
||||
},
|
||||
|
||||
@@ -502,7 +502,11 @@ const getLeftOperandValue = (
|
||||
const responseValue = data[leftOperand.value];
|
||||
|
||||
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") {
|
||||
@@ -552,14 +556,14 @@ const getLeftOperandValue = (
|
||||
if (isNaN(rowIndex) || rowIndex < 0 || rowIndex >= currentQuestion.rows.length) {
|
||||
return undefined;
|
||||
}
|
||||
const row = getLocalizedValue(currentQuestion.rows[rowIndex], selectedLanguage);
|
||||
const row = getLocalizedValue(currentQuestion.rows[rowIndex].label, selectedLanguage);
|
||||
|
||||
const rowValue = responseValue[row];
|
||||
if (rowValue === "") return "";
|
||||
|
||||
if (rowValue) {
|
||||
const columnIndex = currentQuestion.columns.findIndex((column) => {
|
||||
return getLocalizedValue(column, selectedLanguage) === rowValue;
|
||||
return getLocalizedValue(column.label, selectedLanguage) === rowValue;
|
||||
});
|
||||
if (columnIndex === -1) return undefined;
|
||||
return columnIndex.toString();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { IdentityProvider, Objective, Prisma, Role } from "@prisma/client";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
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 { TUserLocale, TUserUpdateInput } from "@formbricks/types/user";
|
||||
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", () => ({
|
||||
getOrganizationsWhereUserIsSingleOwner: vi.fn(),
|
||||
deleteOrganization: vi.fn(),
|
||||
@@ -39,7 +35,6 @@ describe("User Service", () => {
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: new Date(),
|
||||
imageUrl: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
role: Role.project_manager,
|
||||
@@ -200,13 +195,6 @@ describe("User Service", () => {
|
||||
|
||||
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", () => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import "server-only";
|
||||
import { isValidImageFile } from "@/lib/fileValidation";
|
||||
import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
||||
import { deleteBrevoCustomerByEmail } from "@/modules/auth/lib/brevo";
|
||||
import { Prisma } from "@prisma/client";
|
||||
@@ -8,7 +7,7 @@ import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
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 { validateInputs } from "../utils/validate";
|
||||
|
||||
@@ -17,7 +16,6 @@ const responseSelection = {
|
||||
name: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
imageUrl: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
role: true,
|
||||
@@ -79,7 +77,6 @@ export const getUserByEmail = reactCache(async (email: string): Promise<TUser |
|
||||
// function to update a user's user
|
||||
export const updateUser = async (personId: string, data: TUserUpdateInput): Promise<TUser> => {
|
||||
validateInputs([personId, ZId], [data, ZUserUpdateInput.partial()]);
|
||||
if (data.imageUrl && !isValidImageFile(data.imageUrl)) throw new InvalidInputError("Invalid image file");
|
||||
|
||||
try {
|
||||
const updatedUser = await prisma.user.update({
|
||||
|
||||
@@ -112,7 +112,6 @@ describe("withAuditLogging", () => {
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: null,
|
||||
imageUrl: null,
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email" as const,
|
||||
createdAt: new Date(),
|
||||
@@ -151,7 +150,6 @@ describe("withAuditLogging", () => {
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: null,
|
||||
imageUrl: null,
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email" as const,
|
||||
createdAt: new Date(),
|
||||
|
||||
@@ -141,7 +141,6 @@
|
||||
"apply_filters": "Filter anwenden",
|
||||
"are_you_sure": "Bist Du sicher?",
|
||||
"attributes": "Attribute",
|
||||
"avatar": "Avatar",
|
||||
"back": "Zurück",
|
||||
"billing": "Abrechnung",
|
||||
"booked": "Gebucht",
|
||||
@@ -748,6 +747,7 @@
|
||||
"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_updated": "API-Schlüssel aktualisiert",
|
||||
"delete_permission": "Berechtigung löschen",
|
||||
"duplicate_access": "Doppelter Projektzugriff nicht erlaubt",
|
||||
"no_api_keys_yet": "Du hast noch keine API-Schlüssel",
|
||||
"no_env_permissions_found": "Keine Umgebungsberechtigungen gefunden",
|
||||
@@ -1111,9 +1111,7 @@
|
||||
},
|
||||
"profile": {
|
||||
"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",
|
||||
"change_image": "Bild ändern",
|
||||
"confirm_delete_account": "Lösche dein Konto mit all deinen persönlichen Informationen und Daten",
|
||||
"confirm_delete_my_account": "Konto löschen",
|
||||
"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.",
|
||||
"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.",
|
||||
"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",
|
||||
"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>",
|
||||
"permanent_removal_of_all_of_your_personal_information_and_data": "Dauerhafte Entfernung all deiner persönlichen Informationen und Daten",
|
||||
"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:",
|
||||
"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.",
|
||||
"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).",
|
||||
@@ -1144,10 +1138,8 @@
|
||||
"two_factor_code": "Zwei-Faktor-Code",
|
||||
"unlock_two_factor_authentication": "Zwei-Faktor-Authentifizierung mit einem höheren Plan freischalten",
|
||||
"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_undo": "Das kann nicht rückgängig gemacht werden",
|
||||
"you_must_select_a_file": "Du musst eine Datei auswählen."
|
||||
"warning_cannot_undo": "Das kann nicht rückgängig gemacht werden"
|
||||
},
|
||||
"teams": {
|
||||
"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.",
|
||||
"link_description": "Linkbeschreibung",
|
||||
"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_description": "Kurze Titel funktionieren am besten als Meta-Titel.",
|
||||
"link_title_placeholder": "Kundenfeedback-Umfrage",
|
||||
"preview_image": "Vorschaubild",
|
||||
"preview_image_description": "Querformatige Bilder mit kleiner Dateigröße (<4MB) funktionieren am besten.",
|
||||
"title": "Link-Einstellungen"
|
||||
|
||||
@@ -141,7 +141,6 @@
|
||||
"apply_filters": "Apply filters",
|
||||
"are_you_sure": "Are you sure?",
|
||||
"attributes": "Attributes",
|
||||
"avatar": "Avatar",
|
||||
"back": "Back",
|
||||
"billing": "Billing",
|
||||
"booked": "Booked",
|
||||
@@ -748,6 +747,7 @@
|
||||
"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_updated": "API Key updated",
|
||||
"delete_permission": "Delete permission",
|
||||
"duplicate_access": "Duplicate project access not allowed",
|
||||
"no_api_keys_yet": "You don't have any API keys yet",
|
||||
"no_env_permissions_found": "No environment permissions found",
|
||||
@@ -1111,9 +1111,7 @@
|
||||
},
|
||||
"profile": {
|
||||
"account_deletion_consequences_warning": "Account deletion consequences",
|
||||
"avatar_update_failed": "Avatar update failed. Please try again.",
|
||||
"backup_code": "Backup Code",
|
||||
"change_image": "Change image",
|
||||
"confirm_delete_account": "Delete your account with all of your personal information and data",
|
||||
"confirm_delete_my_account": "Delete My Account",
|
||||
"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.",
|
||||
"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": "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",
|
||||
"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>",
|
||||
"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": "Please enter {email} in the following field to confirm the definitive deletion of your account:",
|
||||
"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.",
|
||||
"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).",
|
||||
@@ -1144,10 +1138,8 @@
|
||||
"two_factor_code": "Two-Factor Code",
|
||||
"unlock_two_factor_authentication": "Unlock two-factor authentication with a higher plan",
|
||||
"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_undo": "This cannot be undone",
|
||||
"you_must_select_a_file": "You must select a file."
|
||||
"warning_cannot_undo": "This cannot be undone"
|
||||
},
|
||||
"teams": {
|
||||
"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.",
|
||||
"link_description": "Link description",
|
||||
"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_description": "Short titles perform best as Meta Titles.",
|
||||
"link_title_placeholder": "Customer Feedback Survey",
|
||||
"preview_image": "Preview image",
|
||||
"preview_image_description": "Landscape images with small file sizes (<4MB) perform best.",
|
||||
"title": "Link settings"
|
||||
|
||||
@@ -141,7 +141,6 @@
|
||||
"apply_filters": "Appliquer des filtres",
|
||||
"are_you_sure": "Es-tu sûr ?",
|
||||
"attributes": "Attributs",
|
||||
"avatar": "Avatar",
|
||||
"back": "Retour",
|
||||
"billing": "Facturation",
|
||||
"booked": "Réservé",
|
||||
@@ -748,6 +747,7 @@
|
||||
"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_updated": "Clé API mise à jour",
|
||||
"delete_permission": "Supprimer une permission",
|
||||
"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_env_permissions_found": "Aucune autorisation d'environnement trouvée",
|
||||
@@ -1111,9 +1111,7 @@
|
||||
},
|
||||
"profile": {
|
||||
"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",
|
||||
"change_image": "Changer l'image",
|
||||
"confirm_delete_account": "Supprimez votre compte avec toutes vos informations personnelles et données.",
|
||||
"confirm_delete_my_account": "Supprimer mon compte",
|
||||
"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.",
|
||||
"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.",
|
||||
"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",
|
||||
"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>",
|
||||
"permanent_removal_of_all_of_your_personal_information_and_data": "Suppression permanente de toutes vos informations et données 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 :",
|
||||
"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.",
|
||||
"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).",
|
||||
@@ -1144,10 +1138,8 @@
|
||||
"two_factor_code": "Code à deux facteurs",
|
||||
"unlock_two_factor_authentication": "Débloquez l'authentification à deux facteurs avec une offre supérieure",
|
||||
"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_undo": "Ceci ne peut pas être annulé",
|
||||
"you_must_select_a_file": "Vous devez sélectionner un fichier."
|
||||
"warning_cannot_undo": "Ceci ne peut pas être annulé"
|
||||
},
|
||||
"teams": {
|
||||
"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.",
|
||||
"link_description": "Description du lien",
|
||||
"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_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_description": "Les images en paysage avec de petites tailles de fichier (<4MB) fonctionnent le mieux.",
|
||||
"title": "Paramètres de lien"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -141,7 +141,6 @@
|
||||
"apply_filters": "Aplicar filtros",
|
||||
"are_you_sure": "Certeza?",
|
||||
"attributes": "atributos",
|
||||
"avatar": "Avatar",
|
||||
"back": "Voltar",
|
||||
"billing": "Faturamento",
|
||||
"booked": "Reservado",
|
||||
@@ -748,6 +747,7 @@
|
||||
"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_updated": "Chave de API atualizada",
|
||||
"delete_permission": "Remover permissão",
|
||||
"duplicate_access": "Acesso duplicado ao projeto não permitido",
|
||||
"no_api_keys_yet": "Você ainda não tem nenhuma chave de API",
|
||||
"no_env_permissions_found": "Nenhuma permissão de ambiente encontrada",
|
||||
@@ -1111,9 +1111,7 @@
|
||||
},
|
||||
"profile": {
|
||||
"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",
|
||||
"change_image": "Mudar imagem",
|
||||
"confirm_delete_account": "Apague sua conta com todas as suas informações pessoais e dados",
|
||||
"confirm_delete_my_account": "Excluir Minha Conta",
|
||||
"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.",
|
||||
"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.",
|
||||
"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",
|
||||
"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>",
|
||||
"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",
|
||||
"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",
|
||||
"remove_image": "Remover imagem",
|
||||
"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.",
|
||||
"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",
|
||||
"unlock_two_factor_authentication": "Desbloqueia a autenticação de dois fatores com um plano melhor",
|
||||
"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_undo": "Isso não pode ser desfeito",
|
||||
"you_must_select_a_file": "Você tem que selecionar um arquivo."
|
||||
"warning_cannot_undo": "Isso não pode ser desfeito"
|
||||
},
|
||||
"teams": {
|
||||
"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.",
|
||||
"link_description": "Descrição do link",
|
||||
"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_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_description": "Imagens em paisagem com tamanhos de arquivo pequenos (<4MB) têm o melhor desempenho.",
|
||||
"title": "Configurações de link"
|
||||
|
||||
@@ -141,7 +141,6 @@
|
||||
"apply_filters": "Aplicar filtros",
|
||||
"are_you_sure": "Tem a certeza?",
|
||||
"attributes": "Atributos",
|
||||
"avatar": "Avatar",
|
||||
"back": "Voltar",
|
||||
"billing": "Faturação",
|
||||
"booked": "Reservado",
|
||||
@@ -748,6 +747,7 @@
|
||||
"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_updated": "Chave API atualizada",
|
||||
"delete_permission": "Eliminar permissão",
|
||||
"duplicate_access": "Acesso duplicado ao projeto não permitido",
|
||||
"no_api_keys_yet": "Ainda não tem nenhuma chave API",
|
||||
"no_env_permissions_found": "Nenhuma permissão de ambiente encontrada",
|
||||
@@ -1111,9 +1111,7 @@
|
||||
},
|
||||
"profile": {
|
||||
"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",
|
||||
"change_image": "Alterar imagem",
|
||||
"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_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.",
|
||||
"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.",
|
||||
"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",
|
||||
"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>",
|
||||
"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",
|
||||
"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",
|
||||
"remove_image": "Remover imagem",
|
||||
"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.",
|
||||
"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",
|
||||
"unlock_two_factor_authentication": "Desbloqueie a autenticação de dois fatores com um plano superior",
|
||||
"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_undo": "Isto não pode ser desfeito",
|
||||
"you_must_select_a_file": "Deve selecionar um ficheiro."
|
||||
"warning_cannot_undo": "Isto não pode ser desfeito"
|
||||
},
|
||||
"teams": {
|
||||
"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.",
|
||||
"link_description": "Descrição do link",
|
||||
"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_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_description": "Imagens de paisagem com tamanhos pequenos (<4MB) apresentam melhor desempenho.",
|
||||
"title": "Definições de ligação"
|
||||
|
||||
@@ -141,7 +141,6 @@
|
||||
"apply_filters": "Aplică filtre",
|
||||
"are_you_sure": "Ești sigur?",
|
||||
"attributes": "Atribute",
|
||||
"avatar": "Avatar",
|
||||
"back": "Înapoi",
|
||||
"billing": "Facturare",
|
||||
"booked": "Rezervat",
|
||||
@@ -748,6 +747,7 @@
|
||||
"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_updated": "Cheie API actualizată",
|
||||
"delete_permission": "Șterge permisiunea",
|
||||
"duplicate_access": "Accesul dublu la proiect nu este permis",
|
||||
"no_api_keys_yet": "Nu aveți încă chei API",
|
||||
"no_env_permissions_found": "Nu s-au găsit permisiuni pentru mediu",
|
||||
@@ -1111,9 +1111,7 @@
|
||||
},
|
||||
"profile": {
|
||||
"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ă",
|
||||
"change_image": "Schimbă imaginea",
|
||||
"confirm_delete_account": "Șterge contul tău cu toate informațiile personale și datele tale",
|
||||
"confirm_delete_my_account": "Șterge Contul Meu",
|
||||
"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ă.",
|
||||
"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.",
|
||||
"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",
|
||||
"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>",
|
||||
"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",
|
||||
"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",
|
||||
"remove_image": "Șterge imaginea",
|
||||
"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.",
|
||||
"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",
|
||||
"unlock_two_factor_authentication": "Deblocați autentificarea în doi pași cu un plan superior",
|
||||
"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_undo": "Aceasta nu poate fi anulată",
|
||||
"you_must_select_a_file": "Trebuie să selectați un fișier."
|
||||
"warning_cannot_undo": "Aceasta nu poate fi anulată"
|
||||
},
|
||||
"teams": {
|
||||
"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.",
|
||||
"link_description": "Descriere legătură",
|
||||
"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_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_description": "Imaginile panoramice cu dimensiuni de fișier mici (<4MB) au cel mai bun randament.",
|
||||
"title": "Setări link"
|
||||
|
||||
@@ -141,7 +141,6 @@
|
||||
"apply_filters": "套用篩選器",
|
||||
"are_you_sure": "您確定嗎?",
|
||||
"attributes": "屬性",
|
||||
"avatar": "頭像",
|
||||
"back": "返回",
|
||||
"billing": "帳單",
|
||||
"booked": "已預訂",
|
||||
@@ -748,6 +747,7 @@
|
||||
"api_key_label": "API 金鑰標籤",
|
||||
"api_key_security_warning": "為安全起見,API 金鑰僅在建立後顯示一次。請立即將其複製到您的目的地。",
|
||||
"api_key_updated": "API 金鑰已更新",
|
||||
"delete_permission": "刪除 權限",
|
||||
"duplicate_access": "不允許重複的 project 存取",
|
||||
"no_api_keys_yet": "您還沒有任何 API 金鑰",
|
||||
"no_env_permissions_found": "找不到環境權限",
|
||||
@@ -1111,9 +1111,7 @@
|
||||
},
|
||||
"profile": {
|
||||
"account_deletion_consequences_warning": "帳戶刪除後果",
|
||||
"avatar_update_failed": "頭像更新失敗。請再試一次。",
|
||||
"backup_code": "備份碼",
|
||||
"change_image": "變更圖片",
|
||||
"confirm_delete_account": "刪除您的帳戶以及您的所有個人資訊和資料",
|
||||
"confirm_delete_my_account": "刪除我的帳戶",
|
||||
"confirm_your_current_password_to_get_started": "確認您目前的密碼以開始使用。",
|
||||
@@ -1124,17 +1122,13 @@
|
||||
"email_change_initiated": "您的 email 更改請求已啟動。",
|
||||
"enable_two_factor_authentication": "啟用雙重驗證",
|
||||
"enter_the_code_from_your_authenticator_app_below": "在下方輸入您驗證器應用程式中的程式碼。",
|
||||
"file_size_must_be_less_than_10mb": "檔案大小必須小於 10MB。",
|
||||
"invalid_file_type": "無效的檔案類型。僅允許 JPEG、PNG 和 WEBP 檔案。",
|
||||
"lost_access": "無法存取",
|
||||
"or_enter_the_following_code_manually": "或手動輸入下列程式碼:",
|
||||
"organization_identification": "協助您的組織在 Formbricks 上識別您",
|
||||
"organizations_delete_message": "您是這些組織的唯一擁有者,因此它們也 <b>將被刪除。</b>",
|
||||
"permanent_removal_of_all_of_your_personal_information_and_data": "永久移除您的所有個人資訊和資料",
|
||||
"personal_information": "個人資訊",
|
||||
"please_enter_email_to_confirm_account_deletion": "請在以下欄位中輸入 '{'email'}' 以確認永久刪除您的帳戶:",
|
||||
"profile_updated_successfully": "您的個人資料已成功更新",
|
||||
"remove_image": "移除圖片",
|
||||
"save_the_following_backup_codes_in_a_safe_place": "將下列備份碼儲存在安全的地方。",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "使用您的驗證器應用程式掃描下方的 QR 碼。",
|
||||
"security_description": "管理您的密碼和其他安全性設定,例如雙重驗證 (2FA)。",
|
||||
@@ -1144,10 +1138,8 @@
|
||||
"two_factor_code": "雙重驗證碼",
|
||||
"unlock_two_factor_authentication": "使用更高等級的方案解鎖雙重驗證",
|
||||
"update_personal_info": "更新您的個人資訊",
|
||||
"upload_image": "上傳圖片",
|
||||
"warning_cannot_delete_account": "您是此組織的唯一擁有者。請先將所有權轉讓給其他成員。",
|
||||
"warning_cannot_undo": "此操作無法復原",
|
||||
"you_must_select_a_file": "您必須選取檔案。"
|
||||
"warning_cannot_undo": "此操作無法復原"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "將成員新增至團隊並確定其角色。",
|
||||
@@ -1715,10 +1707,8 @@
|
||||
"language_help_text": "中 繼資料 會 根據 URL 中 的 `lang` 值 載入。",
|
||||
"link_description": "連結描述",
|
||||
"link_description_description": "描述在 55 - 200 個字符之間的表現最好。",
|
||||
"link_description_placeholder": "幫助 我們 改善 , 分享 您 的 想法 。",
|
||||
"link_title": "連結標題",
|
||||
"link_title_description": "短 標題 在 Meta Titles 中表現最佳。",
|
||||
"link_title_placeholder": "顧客 回饋 調查",
|
||||
"preview_image": "預覽 圖片",
|
||||
"preview_image_description": "景觀 圖片 檔案 大小 小於 4MB 效果 最佳。",
|
||||
"title": "連結 設定"
|
||||
|
||||
+13
-2
@@ -59,7 +59,7 @@ vi.mock("@/lib/cn", () => ({
|
||||
cn: (...classes: (string | boolean | undefined)[]) => classes.filter(Boolean).join(" "),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/utils", () => ({
|
||||
getLocalizedValue: vi.fn((val, _) => val),
|
||||
getLocalizedValue: vi.fn((val, _) => val["default"]),
|
||||
getLanguageCode: vi.fn().mockReturnValue("default"),
|
||||
}));
|
||||
|
||||
@@ -178,7 +178,18 @@ describe("RenderResponse", () => {
|
||||
});
|
||||
|
||||
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
|
||||
const responseData = { row1: "answer1", row2: "answer2" };
|
||||
render(
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
|
||||
<>
|
||||
{(question as TSurveyMatrixQuestion).rows.map((row) => {
|
||||
const languagCode = getLanguageCode(survey.languages, language);
|
||||
const rowValueInSelectedLanguage = getLocalizedValue(row, languagCode);
|
||||
const rowValueInSelectedLanguage = getLocalizedValue(row.label, languagCode);
|
||||
if (!responseData[rowValueInSelectedLanguage]) return null;
|
||||
return (
|
||||
<p
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { hasOrganizationAccess } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { NextRequest } from "next/server";
|
||||
import { OrganizationAccessType } from "@formbricks/types/api-key";
|
||||
|
||||
@@ -8,7 +9,7 @@ export const GET = async (request: NextRequest) =>
|
||||
authenticatedApiClient({
|
||||
request,
|
||||
handler: async ({ authentication }) => {
|
||||
if (!authentication.organizationAccess?.accessControl?.[OrganizationAccessType.Read]) {
|
||||
if (!hasOrganizationAccess(authentication, OrganizationAccessType.Read)) {
|
||||
return handleApiError(request, {
|
||||
type: "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 { OrganizationAccessType } from "@formbricks/types/api-key";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
@@ -13,9 +14,5 @@ export const hasOrganizationIdAndAccess = (
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!authentication.organizationAccess?.accessControl?.[accessType]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return hasOrganizationAccess(authentication, accessType);
|
||||
};
|
||||
|
||||
@@ -148,7 +148,6 @@ describe("authOptions", () => {
|
||||
email: mockUser.email,
|
||||
password: mockHashedPassword,
|
||||
emailVerified: new Date(),
|
||||
imageUrl: "http://example.com/avatar.png",
|
||||
twoFactorEnabled: false,
|
||||
};
|
||||
|
||||
@@ -161,7 +160,6 @@ describe("authOptions", () => {
|
||||
id: fakeUser.id,
|
||||
email: fakeUser.email,
|
||||
emailVerified: fakeUser.emailVerified,
|
||||
imageUrl: fakeUser.imageUrl,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -206,7 +206,6 @@ export const authOptions: NextAuthOptions = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
emailVerified: user.emailVerified,
|
||||
imageUrl: user.imageUrl,
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -5,7 +5,6 @@ export const mockUser: TUser = {
|
||||
name: "mock User",
|
||||
email: "john.doe@example.com",
|
||||
emailVerified: new Date("2024-01-01T00:00:00.000Z"),
|
||||
imageUrl: "https://www.google.com",
|
||||
createdAt: new Date("2024-01-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2024-01-01T00:00:00.000Z"),
|
||||
twoFactorEnabled: false,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { isValidImageFile } from "@/lib/fileValidation";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
@@ -11,10 +10,6 @@ import { TUserCreateInput, TUserUpdateInput, ZUserEmail, ZUserUpdateInput } from
|
||||
export const updateUser = async (id: string, data: TUserUpdateInput) => {
|
||||
validateInputs([id, ZId], [data, ZUserUpdateInput.partial()]);
|
||||
|
||||
if (data.imageUrl && !isValidImageFile(data.imageUrl)) {
|
||||
throw new InvalidInputError("Invalid image file");
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: {
|
||||
|
||||
@@ -71,7 +71,7 @@ describe("rateLimitConfigs", () => {
|
||||
|
||||
test("should have all action configurations", () => {
|
||||
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: {
|
||||
emailUpdate: { interval: 3600, allowedPerInterval: 3, namespace: "action:email" }, // 3 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(),
|
||||
email: "test@example.com",
|
||||
emailVerified: null,
|
||||
imageUrl: null,
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
organizationId: "org1",
|
||||
|
||||
@@ -28,7 +28,6 @@ describe("ResponseTimeline", () => {
|
||||
name: "Test User",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
imageUrl: null,
|
||||
objective: null,
|
||||
role: "founder",
|
||||
email: "test@example.com",
|
||||
|
||||
@@ -51,7 +51,6 @@ export const getSSOProviders = () => [
|
||||
id: profile.sub,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
image: profile.picture,
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -76,7 +75,6 @@ export const getSSOProviders = () => [
|
||||
id: profile.id,
|
||||
email: profile.email,
|
||||
name: [profile.firstName, profile.lastName].filter(Boolean).join(" "),
|
||||
image: null,
|
||||
};
|
||||
},
|
||||
options: {
|
||||
|
||||
@@ -13,7 +13,6 @@ export const mockUser: TUser = {
|
||||
unsubscribedOrganizationIds: [],
|
||||
},
|
||||
emailVerified: new Date(),
|
||||
imageUrl: "https://example.com/image.png",
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "google",
|
||||
locale: "en-US",
|
||||
|
||||
@@ -380,8 +380,8 @@ export async function PreviewEmailTemplate({
|
||||
return (
|
||||
<Column
|
||||
className="text-question-color max-w-40 break-words px-4 py-2 text-center"
|
||||
key={getLocalizedValue(column, "default")}>
|
||||
{getLocalizedValue(column, "default")}
|
||||
key={column.id}>
|
||||
{getLocalizedValue(column.label, "default")}
|
||||
</Column>
|
||||
);
|
||||
})}
|
||||
@@ -390,15 +390,13 @@ export async function PreviewEmailTemplate({
|
||||
return (
|
||||
<Row
|
||||
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">
|
||||
{getLocalizedValue(row, "default")}
|
||||
{getLocalizedValue(row.label, "default")}
|
||||
</Column>
|
||||
{firstQuestion.columns.map((_) => {
|
||||
{firstQuestion.columns.map((column) => {
|
||||
return (
|
||||
<Column
|
||||
className="text-question-color px-4 py-2"
|
||||
key={getLocalizedValue(_, "default")}>
|
||||
<Column className="text-question-color px-4 py-2" key={column.id}>
|
||||
<Section className="bg-card-bg-color h-4 w-4 rounded-full p-2 outline" />
|
||||
</Column>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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
|
||||
const methodPermissionMap = {
|
||||
@@ -50,3 +51,19 @@ export const getOrganizationAccessKeyDisplayName = (key: string, t: TFnType) =>
|
||||
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")}
|
||||
childBorder
|
||||
customContainerClass="p-0"
|
||||
childrenContainerClass="overflow-visible"
|
||||
disabled={!isEditing}>
|
||||
{isBgColorEnabled && (
|
||||
<div className="px-2">
|
||||
|
||||
-1
@@ -56,7 +56,6 @@ const mockUser = {
|
||||
id: "user-123",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
imageUrl: null,
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
|
||||
@@ -131,7 +131,6 @@ describe("CreateOrganizationPage", () => {
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: null,
|
||||
imageUrl: null,
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email" as const,
|
||||
createdAt: new Date(),
|
||||
|
||||
@@ -85,6 +85,10 @@ export const QuestionFormInput = ({
|
||||
const isChoice = id.includes("choice");
|
||||
const isMatrixLabelRow = id.includes("row");
|
||||
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 isWelcomeCard = questionIdx === -1;
|
||||
const index = getIndex(id, isChoice || isMatrixLabelColumn || isMatrixLabelRow);
|
||||
@@ -104,8 +108,8 @@ export const QuestionFormInput = ({
|
||||
[localSurvey.languages]
|
||||
);
|
||||
const isTranslationIncomplete = useMemo(
|
||||
() => isValueIncomplete(id, isInvalid, surveyLanguageCodes, value),
|
||||
[value, id, isInvalid, surveyLanguageCodes]
|
||||
() => isValueIncomplete(inputId, isInvalid, surveyLanguageCodes, value),
|
||||
[value, inputId, isInvalid, surveyLanguageCodes]
|
||||
);
|
||||
|
||||
const elementText = useMemo((): TI18nString => {
|
||||
|
||||
@@ -87,12 +87,12 @@ describe("utils", () => {
|
||||
headline: createI18nString("Matrix Question", surveyLanguageCodes),
|
||||
required: true,
|
||||
rows: [
|
||||
createI18nString("Row 1", surveyLanguageCodes),
|
||||
createI18nString("Row 2", surveyLanguageCodes),
|
||||
{ id: "row-1", label: createI18nString("Row 1", surveyLanguageCodes) },
|
||||
{ id: "row-2", label: createI18nString("Row 2", surveyLanguageCodes) },
|
||||
],
|
||||
columns: [
|
||||
createI18nString("Column 1", surveyLanguageCodes),
|
||||
createI18nString("Column 2", surveyLanguageCodes),
|
||||
{ id: "col-1", label: createI18nString("Column 1", surveyLanguageCodes) },
|
||||
{ id: "col-2", label: createI18nString("Column 2", surveyLanguageCodes) },
|
||||
],
|
||||
} as unknown as TSurveyQuestion;
|
||||
|
||||
@@ -108,12 +108,12 @@ describe("utils", () => {
|
||||
headline: createI18nString("Matrix Question", surveyLanguageCodes),
|
||||
required: true,
|
||||
rows: [
|
||||
createI18nString("Row 1", surveyLanguageCodes),
|
||||
createI18nString("Row 2", surveyLanguageCodes),
|
||||
{ id: "row-1", label: createI18nString("Row 1", surveyLanguageCodes) },
|
||||
{ id: "row-2", label: createI18nString("Row 2", surveyLanguageCodes) },
|
||||
],
|
||||
columns: [
|
||||
createI18nString("Column 1", surveyLanguageCodes),
|
||||
createI18nString("Column 2", surveyLanguageCodes),
|
||||
{ id: "col-1", label: createI18nString("Column 1", surveyLanguageCodes) },
|
||||
{ id: "col-2", label: createI18nString("Column 2", surveyLanguageCodes) },
|
||||
],
|
||||
} as unknown as TSurveyQuestion;
|
||||
|
||||
|
||||
@@ -36,8 +36,8 @@ export const getMatrixLabel = (
|
||||
type: "row" | "column"
|
||||
): TI18nString => {
|
||||
const matrixQuestion = question as TSurveyMatrixQuestion;
|
||||
const labels = type === "row" ? matrixQuestion.rows : matrixQuestion.columns;
|
||||
return labels[idx] || createI18nString("", surveyLanguageCodes);
|
||||
const matrixFields = type === "row" ? matrixQuestion.rows : matrixQuestion.columns;
|
||||
return matrixFields[idx]?.label || createI18nString("", surveyLanguageCodes);
|
||||
};
|
||||
|
||||
export const getWelcomeCardText = (
|
||||
@@ -94,6 +94,9 @@ export const isValueIncomplete = (
|
||||
) => {
|
||||
// Define a list of IDs for which a default value needs to be checked.
|
||||
const labelIds = [
|
||||
"row",
|
||||
"column",
|
||||
"choice",
|
||||
"label",
|
||||
"headline",
|
||||
"subheader",
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { isValidImageFile } from "@/lib/fileValidation";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { updateUser } from "./user";
|
||||
|
||||
@@ -24,7 +23,6 @@ describe("updateUser", () => {
|
||||
id: "user-123",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
imageUrl: "https://example.com/image.png",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
role: "project_manager",
|
||||
@@ -41,7 +39,6 @@ describe("updateUser", () => {
|
||||
});
|
||||
|
||||
test("successfully updates a user", async () => {
|
||||
vi.mocked(isValidImageFile).mockReturnValue(true);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue(mockUser as any);
|
||||
|
||||
const updateData = { name: "Updated Name" };
|
||||
@@ -55,7 +52,6 @@ describe("updateUser", () => {
|
||||
name: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
imageUrl: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
role: true,
|
||||
@@ -72,17 +68,7 @@ describe("updateUser", () => {
|
||||
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 () => {
|
||||
vi.mocked(isValidImageFile).mockReturnValue(true);
|
||||
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
|
||||
code: PrismaErrorType.RecordDoesNotExist,
|
||||
clientVersion: "1.0.0",
|
||||
@@ -96,8 +82,6 @@ describe("updateUser", () => {
|
||||
});
|
||||
|
||||
test("re-throws other errors", async () => {
|
||||
vi.mocked(isValidImageFile).mockReturnValue(true);
|
||||
|
||||
const otherError = new Error("Some other error");
|
||||
vi.mocked(prisma.user.update).mockRejectedValue(otherError);
|
||||
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { isValidImageFile } from "@/lib/fileValidation";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
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";
|
||||
|
||||
// function to update a user's user
|
||||
export const updateUser = async (personId: string, data: TUserUpdateInput): Promise<TUser> => {
|
||||
if (data.imageUrl && !isValidImageFile(data.imageUrl)) throw new InvalidInputError("Invalid image file");
|
||||
|
||||
try {
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: {
|
||||
@@ -20,7 +17,6 @@ export const updateUser = async (personId: string, data: TUserUpdateInput): Prom
|
||||
name: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
imageUrl: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
role: true,
|
||||
|
||||
@@ -2,8 +2,7 @@ import { createI18nString } from "@/lib/i18n/utils";
|
||||
import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import React from "react";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyLanguage,
|
||||
@@ -14,12 +13,11 @@ import { TUserLocale } from "@formbricks/types/user";
|
||||
import { MatrixQuestionForm } from "./matrix-question-form";
|
||||
|
||||
// Mock cuid2 to track CUID generation
|
||||
const mockCuids = ["cuid1", "cuid2", "cuid3", "cuid4", "cuid5", "cuid6"];
|
||||
let cuidIndex = 0;
|
||||
|
||||
vi.mock("@paralleldrive/cuid2", () => ({
|
||||
default: {
|
||||
createId: vi.fn(() => mockCuids[cuidIndex++]),
|
||||
createId: vi.fn(() => `cuid${cuidIndex++}`),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -160,14 +158,14 @@ const mockMatrixQuestion: TSurveyMatrixQuestion = {
|
||||
required: false,
|
||||
logic: [],
|
||||
rows: [
|
||||
createI18nString("Row 1", ["en"]),
|
||||
createI18nString("Row 2", ["en"]),
|
||||
createI18nString("Row 3", ["en"]),
|
||||
{ id: "row-1", label: createI18nString("Row 1", ["en"]) },
|
||||
{ id: "row-2", label: createI18nString("Row 2", ["en"]) },
|
||||
{ id: "row-3", label: createI18nString("Row 3", ["en"]) },
|
||||
],
|
||||
columns: [
|
||||
createI18nString("Column 1", ["en"]),
|
||||
createI18nString("Column 2", ["en"]),
|
||||
createI18nString("Column 3", ["en"]),
|
||||
{ id: "col-1", label: createI18nString("Column 1", ["en"]) },
|
||||
{ id: "col-2", label: createI18nString("Column 2", ["en"]) },
|
||||
{ id: "col-3", label: createI18nString("Column 3", ["en"]) },
|
||||
],
|
||||
shuffleOption: "none",
|
||||
};
|
||||
@@ -197,6 +195,7 @@ describe("MatrixQuestionForm", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
cuidIndex = 0;
|
||||
});
|
||||
|
||||
test("renders the matrix question form with rows and columns", () => {
|
||||
@@ -240,40 +239,6 @@ describe("MatrixQuestionForm", () => {
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
const { findAllByTestId } = render(<MatrixQuestionForm {...defaultProps} />);
|
||||
@@ -294,7 +259,10 @@ describe("MatrixQuestionForm", () => {
|
||||
...defaultProps,
|
||||
question: {
|
||||
...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();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const { findOptionUsedInLogic } = await import("@/modules/survey/editor/lib/utils");
|
||||
vi.mocked(findOptionUsedInLogic).mockReturnValueOnce(1); // Mock that this row is used in logic
|
||||
@@ -397,223 +329,4 @@ describe("MatrixQuestionForm", () => {
|
||||
|
||||
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 { 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 { Button } from "@/modules/ui/components/button";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
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 cuid2 from "@paralleldrive/cuid2";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { type JSX, useMemo, useRef } from "react";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { type JSX, useCallback } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TI18nString, TSurvey, TSurveyMatrixQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
@@ -41,51 +43,16 @@ export const MatrixQuestionForm = ({
|
||||
const languageCodes = extractLanguageCodes(localSurvey.languages);
|
||||
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
|
||||
const handleAddLabel = (type: "row" | "column") => {
|
||||
if (type === "row") {
|
||||
const updatedRows = [...question.rows, createI18nString("", languageCodes)];
|
||||
const updatedRows = [...question.rows, { id: createId(), label: createI18nString("", languageCodes) }];
|
||||
updateQuestion(questionIdx, { rows: updatedRows });
|
||||
} else {
|
||||
const updatedColumns = [...question.columns, createI18nString("", languageCodes)];
|
||||
const updatedColumns = [
|
||||
...question.columns,
|
||||
{ id: createId(), label: createI18nString("", languageCodes) },
|
||||
];
|
||||
updateQuestion(questionIdx, { columns: updatedColumns });
|
||||
}
|
||||
};
|
||||
@@ -120,10 +87,6 @@ export const MatrixQuestionForm = ({
|
||||
|
||||
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") {
|
||||
updateQuestion(questionIdx, { rows: updatedLabels });
|
||||
} else {
|
||||
@@ -136,9 +99,9 @@ export const MatrixQuestionForm = ({
|
||||
|
||||
// Update the label at the given index, or add a new label if index is undefined
|
||||
if (index !== undefined) {
|
||||
labels[index] = matrixLabel;
|
||||
labels[index].label = matrixLabel;
|
||||
} else {
|
||||
labels.push(matrixLabel);
|
||||
labels.push({ id: createId(), label: matrixLabel });
|
||||
}
|
||||
if (type === "row") {
|
||||
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 = {
|
||||
none: {
|
||||
id: "none",
|
||||
@@ -173,6 +157,7 @@ export const MatrixQuestionForm = ({
|
||||
};
|
||||
/// Auto animate
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
@@ -226,44 +211,39 @@ export const MatrixQuestionForm = ({
|
||||
<div>
|
||||
{/* Rows section */}
|
||||
<Label htmlFor="rows">{t("environments.surveys.edit.rows")}</Label>
|
||||
<div className="mt-2 flex flex-col gap-2" ref={parent}>
|
||||
{rowsWithCuid.map((row, index) => (
|
||||
<div className="flex items-center" key={row.id}>
|
||||
<QuestionFormInput
|
||||
id={`row-${index}`}
|
||||
label={""}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
value={question.rows[index]}
|
||||
updateMatrixLabel={updateMatrixLabel}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={
|
||||
isInvalid && !isLabelValidForAllLanguages(question.rows[index], localSurvey.languages)
|
||||
}
|
||||
locale={locale}
|
||||
onKeyDown={(e) => handleKeyDown(e, "row")}
|
||||
/>
|
||||
{question.rows.length > 2 && (
|
||||
<TooltipRenderer data-testid="tooltip-renderer" tooltipContent={t("common.delete")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-2"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleDeleteLabel("row", index);
|
||||
}}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-2">
|
||||
<DndContext id="matrix-rows" onDragEnd={(e) => handleMatrixDragEnd("row", e)}>
|
||||
<SortableContext items={question.rows} strategy={verticalListSortingStrategy}>
|
||||
<div className="flex flex-col gap-2" ref={parent}>
|
||||
{question.rows.map((row, index) => (
|
||||
<MatrixSortableItem
|
||||
key={row.id}
|
||||
choice={row}
|
||||
index={index}
|
||||
type="row"
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateMatrixLabel={updateMatrixLabel}
|
||||
onDelete={(index) => handleDeleteLabel("row", index)}
|
||||
onKeyDown={(e) => handleKeyDown(e, "row")}
|
||||
canDelete={question.rows.length > 2}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={
|
||||
isInvalid &&
|
||||
!isLabelValidForAllLanguages(question.rows[index].label, localSurvey.languages)
|
||||
}
|
||||
locale={locale}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
className="mt-2 w-fit"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleAddLabel("row");
|
||||
@@ -276,44 +256,39 @@ export const MatrixQuestionForm = ({
|
||||
<div>
|
||||
{/* Columns section */}
|
||||
<Label htmlFor="columns">{t("environments.surveys.edit.columns")}</Label>
|
||||
<div className="mt-2 flex flex-col gap-2" ref={parent}>
|
||||
{columnsWithCuid.map((column, index) => (
|
||||
<div className="flex items-center" key={column.id}>
|
||||
<QuestionFormInput
|
||||
id={`column-${index}`}
|
||||
label={""}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
value={question.columns[index]}
|
||||
updateMatrixLabel={updateMatrixLabel}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={
|
||||
isInvalid && !isLabelValidForAllLanguages(question.columns[index], localSurvey.languages)
|
||||
}
|
||||
locale={locale}
|
||||
onKeyDown={(e) => handleKeyDown(e, "column")}
|
||||
/>
|
||||
{question.columns.length > 2 && (
|
||||
<TooltipRenderer data-testid="tooltip-renderer" tooltipContent={t("common.delete")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-2"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleDeleteLabel("column", index);
|
||||
}}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-2">
|
||||
<DndContext id="matrix-columns" onDragEnd={(e) => handleMatrixDragEnd("column", e)}>
|
||||
<SortableContext items={question.columns} strategy={verticalListSortingStrategy}>
|
||||
<div className="flex flex-col gap-2" ref={parent}>
|
||||
{question.columns.map((column, index) => (
|
||||
<MatrixSortableItem
|
||||
key={column.id}
|
||||
choice={column}
|
||||
index={index}
|
||||
type="column"
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateMatrixLabel={updateMatrixLabel}
|
||||
onDelete={(index) => handleDeleteLabel("column", index)}
|
||||
onKeyDown={(e) => handleKeyDown(e, "column")}
|
||||
canDelete={question.columns.length > 2}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={
|
||||
isInvalid &&
|
||||
!isLabelValidForAllLanguages(question.columns[index].label, localSurvey.languages)
|
||||
}
|
||||
locale={locale}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
className="mt-2 w-fit"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -118,7 +118,7 @@ export const getConditionValueOptions = (
|
||||
// Rows submenu
|
||||
const rows = question.rows.map((row, rowIdx) => ({
|
||||
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}`,
|
||||
meta: {
|
||||
type: "question",
|
||||
@@ -629,7 +629,7 @@ export const getMatchValueProps = (
|
||||
} else if (selectedQuestion?.type === TSurveyQuestionTypeEnum.Matrix) {
|
||||
const choices = selectedQuestion.columns.map((column, colIdx) => {
|
||||
return {
|
||||
label: getLocalizedValue(column, "default"),
|
||||
label: getLocalizedValue(column.label, "default"),
|
||||
value: colIdx.toString(),
|
||||
meta: {
|
||||
type: "static",
|
||||
|
||||
@@ -61,14 +61,20 @@ const handleI18nCheckForMatrixLabels = (
|
||||
): boolean => {
|
||||
const rowsAndColumns = [...question.rows, ...question.columns];
|
||||
|
||||
const invalidRowsLangCodes = findLanguageCodesForDuplicateLabels(question.rows, languages);
|
||||
const invalidColumnsLangCodes = findLanguageCodesForDuplicateLabels(question.columns, languages);
|
||||
const invalidRowsLangCodes = findLanguageCodesForDuplicateLabels(
|
||||
question.rows.map((row) => row.label),
|
||||
languages
|
||||
);
|
||||
const invalidColumnsLangCodes = findLanguageCodesForDuplicateLabels(
|
||||
question.columns.map((column) => column.label),
|
||||
languages
|
||||
);
|
||||
|
||||
if (invalidRowsLangCodes.length > 0 || invalidColumnsLangCodes.length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return rowsAndColumns.every((label) => isLabelValidForAllLanguages(label, languages));
|
||||
return rowsAndColumns.every((choice) => isLabelValidForAllLanguages(choice.label, languages));
|
||||
};
|
||||
|
||||
const handleI18nCheckForContactAndAddressFields = (
|
||||
|
||||
@@ -181,8 +181,14 @@ export const getQuestionTypes = (t: TFnType): TQuestion[] => [
|
||||
icon: Grid3X3Icon,
|
||||
preset: {
|
||||
headline: createI18nString("", []),
|
||||
rows: [createI18nString("", []), createI18nString("", [])],
|
||||
columns: [createI18nString("", []), createI18nString("", [])],
|
||||
rows: [
|
||||
{ id: createId(), label: createI18nString("", []) },
|
||||
{ id: createId(), label: createI18nString("", []) },
|
||||
],
|
||||
columns: [
|
||||
{ id: createId(), label: createI18nString("", []) },
|
||||
{ id: createId(), label: createI18nString("", []) },
|
||||
],
|
||||
buttonLabel: createI18nString(t("templates.next"), []),
|
||||
backButtonLabel: createI18nString(t("templates.back"), []),
|
||||
shuffleOption: "none",
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { actionClient } from "@/lib/utils/action-client";
|
||||
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 { sendLinkSurveyToVerifiedEmail } from "@/modules/email";
|
||||
import { getSurveyWithMetadata, isSurveyResponsePresent } from "@/modules/survey/link/lib/data";
|
||||
@@ -12,6 +14,14 @@ import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/erro
|
||||
export const sendLinkSurveyEmailAction = actionClient
|
||||
.schema(ZLinkSurveyEmailData)
|
||||
.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 organizationLogoUrl = await getOrganizationLogoUrl(organizationId);
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ describe("contact-survey page", () => {
|
||||
params: Promise.resolve({ jwt: "token" }),
|
||||
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 () => {
|
||||
@@ -103,7 +103,7 @@ describe("contact-survey page", () => {
|
||||
params: Promise.resolve({ jwt: "token" }),
|
||||
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 () => {
|
||||
|
||||
@@ -31,7 +31,7 @@ export const generateMetadata = async (props: ContactSurveyPageProps): Promise<M
|
||||
if (!result.ok) {
|
||||
return {
|
||||
title: "Survey",
|
||||
description: "Complete this survey",
|
||||
description: "Please complete this survey.",
|
||||
};
|
||||
}
|
||||
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
|
||||
return {
|
||||
title: "Survey",
|
||||
description: "Complete this survey",
|
||||
description: "Please complete this survey.",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -77,7 +77,7 @@ describe("Metadata Utils", () => {
|
||||
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(result).toEqual({
|
||||
title: "Survey",
|
||||
description: "Complete this survey",
|
||||
description: "Please complete this survey.",
|
||||
survey: null,
|
||||
ogImage: undefined,
|
||||
});
|
||||
@@ -108,10 +108,9 @@ describe("Metadata Utils", () => {
|
||||
const result = await getBasicSurveyMetadata(mockSurveyId);
|
||||
|
||||
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(getProjectByEnvironmentId).toHaveBeenCalledWith(mockEnvironmentId);
|
||||
expect(result).toEqual({
|
||||
title: "Welcome Headline | Test Project",
|
||||
description: "Complete this survey",
|
||||
title: "Welcome Headline",
|
||||
description: "Please complete this survey.",
|
||||
survey: mockSurvey,
|
||||
ogImage: undefined,
|
||||
});
|
||||
@@ -129,13 +128,12 @@ describe("Metadata Utils", () => {
|
||||
} as TSurvey;
|
||||
|
||||
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
|
||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValue({ name: "Test Project" } as any);
|
||||
|
||||
const result = await getBasicSurveyMetadata(mockSurveyId);
|
||||
|
||||
expect(result).toEqual({
|
||||
title: "Test Survey | Test Project",
|
||||
description: "Complete this survey",
|
||||
title: "Test Survey",
|
||||
description: "Please complete this survey.",
|
||||
survey: mockSurvey,
|
||||
ogImage: undefined,
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { COLOR_DEFAULTS } from "@/lib/styling/constants";
|
||||
import { getSurvey } from "@/modules/survey/lib/survey";
|
||||
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
|
||||
import { Metadata } from "next";
|
||||
|
||||
type TBasicSurveyMetadata = {
|
||||
@@ -12,22 +12,16 @@ type TBasicSurveyMetadata = {
|
||||
ogImage?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility function to encode name for URL usage
|
||||
*/
|
||||
export const getNameForURL = (url: string) => url.replace(/ /g, "%20");
|
||||
export const getNameForURL = (value: string) => encodeURIComponent(value);
|
||||
|
||||
/**
|
||||
* Utility function to encode brand color for URL usage
|
||||
*/
|
||||
export const getBrandColorForURL = (url: string) => url.replace(/#/g, "%23");
|
||||
export const getBrandColorForURL = (value: string) => encodeURIComponent(value);
|
||||
|
||||
/**
|
||||
* Get basic survey metadata (title and description) based on link metadata, welcome card or survey name
|
||||
*/
|
||||
export const getBasicSurveyMetadata = async (
|
||||
surveyId: string,
|
||||
languageCode?: string
|
||||
languageCode = "default"
|
||||
): Promise<TBasicSurveyMetadata> => {
|
||||
const survey = await getSurvey(surveyId);
|
||||
|
||||
@@ -35,7 +29,7 @@ export const getBasicSurveyMetadata = async (
|
||||
if (!survey) {
|
||||
return {
|
||||
title: "Survey",
|
||||
description: "Complete this survey",
|
||||
description: "Please complete this survey.",
|
||||
survey: null,
|
||||
ogImage: undefined,
|
||||
};
|
||||
@@ -43,38 +37,33 @@ export const getBasicSurveyMetadata = async (
|
||||
|
||||
const metadata = survey.metadata;
|
||||
const welcomeCard = survey.welcomeCard;
|
||||
const useDefaultLanguageCode =
|
||||
languageCode === "default" ||
|
||||
survey.languages.find((lang) => lang.language.code === languageCode)?.default;
|
||||
|
||||
// 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
|
||||
let title = "Survey";
|
||||
if (metadata.title?.[langCode]) {
|
||||
title = metadata.title[langCode];
|
||||
} else if (welcomeCard.enabled && welcomeCard.headline?.default) {
|
||||
title = welcomeCard.headline.default;
|
||||
} else {
|
||||
title = survey.name;
|
||||
}
|
||||
const titleFromMetadata = metadata?.title ? getLocalizedValue(metadata.title, langCode) || "" : undefined;
|
||||
const titleFromWelcome =
|
||||
welcomeCard?.enabled && welcomeCard.headline
|
||||
? getLocalizedValue(welcomeCard.headline, langCode) || ""
|
||||
: undefined;
|
||||
let title = titleFromMetadata || titleFromWelcome || survey.name;
|
||||
|
||||
// Set description - priority: custom link metadata > welcome card > default
|
||||
let description = "Complete this survey";
|
||||
if (metadata.description?.[langCode]) {
|
||||
description = metadata.description[langCode];
|
||||
}
|
||||
// Set description - priority: custom link metadata > default
|
||||
const descriptionFromMetadata = metadata?.description
|
||||
? getLocalizedValue(metadata.description, langCode) || ""
|
||||
: undefined;
|
||||
let description = descriptionFromMetadata || "Please complete this survey.";
|
||||
|
||||
// Get OG image from link metadata if available
|
||||
const { ogImage } = metadata;
|
||||
|
||||
// Add product name in title if it's Formbricks cloud and not using custom metadata
|
||||
if (!metadata.title?.[langCode]) {
|
||||
if (!titleFromMetadata) {
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
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
|
||||
*/
|
||||
export const getSurveyOpenGraphMetadata = (surveyId: string, surveyName: string): Metadata => {
|
||||
const brandColor = getBrandColorForURL(COLOR_DEFAULTS.brandColor); // Default color
|
||||
export const getSurveyOpenGraphMetadata = (
|
||||
surveyId: string,
|
||||
surveyName: string,
|
||||
surveyBrandColor?: string
|
||||
): Metadata => {
|
||||
const encodedName = getNameForURL(surveyName);
|
||||
|
||||
const brandColor = getBrandColorForURL(surveyBrandColor ?? COLOR_DEFAULTS.brandColor);
|
||||
const ogImgURL = `/api/v1/client/og?brandColor=${brandColor}&name=${encodedName}`;
|
||||
|
||||
return {
|
||||
|
||||
@@ -20,7 +20,7 @@ vi.mock("./lib/metadata-utils", () => ({
|
||||
describe("getMetadataForLinkSurvey", () => {
|
||||
const mockSurveyId = "survey-123";
|
||||
const mockSurveyName = "Test Survey";
|
||||
const mockDescription = "Complete this survey";
|
||||
const mockDescription = "Please complete this survey.";
|
||||
const mockOgImageUrl = "https://example.com/custom-image.png";
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -60,7 +60,7 @@ describe("getMetadataForLinkSurvey", () => {
|
||||
|
||||
expect(getSurveyMetadata).toHaveBeenCalledWith(mockSurveyId);
|
||||
expect(getBasicSurveyMetadata).toHaveBeenCalledWith(mockSurveyId, undefined);
|
||||
expect(getSurveyOpenGraphMetadata).toHaveBeenCalledWith(mockSurveyId, mockSurveyName);
|
||||
expect(getSurveyOpenGraphMetadata).toHaveBeenCalledWith(mockSurveyId, mockSurveyName, undefined);
|
||||
|
||||
expect(result).toEqual({
|
||||
title: mockSurveyName,
|
||||
|
||||
@@ -15,9 +15,10 @@ export const getMetadataForLinkSurvey = async (
|
||||
|
||||
// Get enhanced metadata that includes custom link metadata
|
||||
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
|
||||
const baseMetadata = getSurveyOpenGraphMetadata(survey.id, title);
|
||||
const baseMetadata = getSurveyOpenGraphMetadata(survey.id, title, surveyBrandColor);
|
||||
|
||||
// Override with the custom image URL
|
||||
if (baseMetadata.openGraph) {
|
||||
|
||||
@@ -12,6 +12,7 @@ interface AdvancedOptionToggleProps {
|
||||
childBorder?: boolean;
|
||||
customContainerClass?: string;
|
||||
disabled?: boolean;
|
||||
childrenContainerClass?: string;
|
||||
}
|
||||
|
||||
export const AdvancedOptionToggle = ({
|
||||
@@ -24,6 +25,7 @@ export const AdvancedOptionToggle = ({
|
||||
childBorder,
|
||||
customContainerClass,
|
||||
disabled = false,
|
||||
childrenContainerClass,
|
||||
}: AdvancedOptionToggleProps) => {
|
||||
return (
|
||||
<div className={cn("px-4 py-2", customContainerClass)}>
|
||||
@@ -40,7 +42,8 @@ export const AdvancedOptionToggle = ({
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 flex w-full items-center space-x-1 overflow-hidden rounded-lg bg-slate-50",
|
||||
childBorder && "border"
|
||||
childBorder && "border",
|
||||
childrenContainerClass
|
||||
)}>
|
||||
{children}
|
||||
</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", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -44,7 +37,7 @@ describe("Avatar Components", () => {
|
||||
});
|
||||
|
||||
describe("ProfileAvatar", () => {
|
||||
test("renders Boring Avatar when imageUrl is not provided", () => {
|
||||
test("renders Boring Avatar", () => {
|
||||
render(<ProfileAvatar userId="user-123" />);
|
||||
|
||||
const avatar = screen.getByTestId("boring-avatar-bauhaus");
|
||||
@@ -52,32 +45,5 @@ describe("Avatar Components", () => {
|
||||
expect(avatar).toHaveAttribute("data-size", "40");
|
||||
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 Image from "next/image";
|
||||
|
||||
const colors = ["#00C4B8", "#ccfbf1", "#334155"];
|
||||
|
||||
@@ -13,20 +12,8 @@ export const PersonAvatar: React.FC<PersonAvatarProps> = ({ personId }) => {
|
||||
|
||||
interface ProfileAvatar {
|
||||
userId: string;
|
||||
imageUrl?: string | null;
|
||||
}
|
||||
|
||||
export const ProfileAvatar: React.FC<ProfileAvatar> = ({ userId, imageUrl }) => {
|
||||
if (imageUrl) {
|
||||
return (
|
||||
<Image
|
||||
src={imageUrl}
|
||||
width="40"
|
||||
height="40"
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
alt="Avatar placeholder"
|
||||
/>
|
||||
);
|
||||
}
|
||||
export const ProfileAvatar: React.FC<ProfileAvatar> = ({ userId }) => {
|
||||
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.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -52,7 +52,7 @@
|
||||
"@opentelemetry/sdk-logs": "0.200.0",
|
||||
"@opentelemetry/sdk-metrics": "2.0.0",
|
||||
"@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-checkbox": "1.3.1",
|
||||
"@radix-ui/react-collapsible": "1.1.10",
|
||||
@@ -82,7 +82,7 @@
|
||||
"@vercel/functions": "2.2.8",
|
||||
"@vercel/og": "0.8.5",
|
||||
"bcryptjs": "3.0.2",
|
||||
"boring-avatars": "1.11.2",
|
||||
"boring-avatars": "2.0.1",
|
||||
"cache-manager": "6.4.3",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
.terraform/
|
||||
builds
|
||||
/.direnv/
|
||||
Generated
+61
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1754767907,
|
||||
"narHash": "sha256-8OnUzRQZkqtUol9vuUuQC30hzpMreKptNyET2T9lB6g=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "c5f08b62ed75415439d48152c2a784e36909b1bc",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-25.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
flake-utils,
|
||||
}:
|
||||
flake-utils.lib.eachDefaultSystem (
|
||||
system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
config.allowUnfree = true;
|
||||
};
|
||||
in
|
||||
with pkgs;
|
||||
{
|
||||
devShells.default = mkShell {
|
||||
buildInputs = [
|
||||
awscli
|
||||
terraform
|
||||
];
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
Generated
+139
-98
@@ -2,51 +2,71 @@
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.terraform.io/hashicorp/aws" {
|
||||
version = "5.89.0"
|
||||
constraints = ">= 5.46.0, >= 5.79.0, >= 5.83.0"
|
||||
version = "5.100.0"
|
||||
constraints = ">= 3.29.0, >= 4.0.0, >= 4.8.0, >= 4.33.0, >= 4.36.0, >= 4.47.0, >= 4.63.0, >= 5.0.0, >= 5.46.0, >= 5.73.0, >= 5.79.0, >= 5.81.0, >= 5.83.0, >= 5.86.0, >= 5.95.0, < 6.0.0"
|
||||
hashes = [
|
||||
"h1:rFvk42jJEKiSUhK1cbERfNgYm4mD+8tq0ZcxCwpXSJs=",
|
||||
"zh:0e55784d6effc33b9098ffab7fb77a242e0223a59cdcf964caa0be94d14684af",
|
||||
"zh:23c64f3eaeffcafb007c89db3dfca94c8adf06b120af55abddaca55a6c6c924c",
|
||||
"zh:338f620133cb607ce980f1725a0a78f61cbd42f4c601808ec1ee01a6c16c9811",
|
||||
"zh:6ab0499172f17484d7b39924cf06782789df1473d31ebae0c7f3294f6e7a1227",
|
||||
"zh:6dcde3e29e538cdf80971cbdce3b285056fd0e31dd64b02d2dcdf4c02f21d0a9",
|
||||
"zh:75c9b594d77c9125bfb1aaf3fbd77a49e392841d53029b5726eb71d64de1233e",
|
||||
"zh:7b334c23091e7b4c142e378416586292197c40a31a5bdb3b29c4f9afddd286f0",
|
||||
"zh:991bbba72e5eb6eb351f466d68080992f5b0495f862a6723f386d1b4c965aa7d",
|
||||
"h1:Ijt7pOlB7Tr7maGQIqtsLFbl7pSMIj06TVdkoSBcYOw=",
|
||||
"zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644",
|
||||
"zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2",
|
||||
"zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274",
|
||||
"zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b",
|
||||
"zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862",
|
||||
"zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342",
|
||||
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
|
||||
"zh:9bd2f12eef4a5dceafc211ab3b9a63f0e3e224007a60c1bbb842f76e0377033d",
|
||||
"zh:b1ac1eb3b3e1a79fa5e5ad3364615f23b9ee0b093ceeb809fd386a4d40e7abb4",
|
||||
"zh:cea91f43151b30c428c441b97c3b98bf1e5fb72ef72f6971308e3895e23437f4",
|
||||
"zh:d3f000a1696a43d8186a516aace7d476d1fd76443627980504133477e19c8ecb",
|
||||
"zh:d6f526fbbb3e51b3acc3b9640a158f7acc4a089632fca8ec6db430b450673f25",
|
||||
"zh:e0c542950f96c93e761d50602e449fef8447f1389a6d5242a0a7dc9b06826d0b",
|
||||
"zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93",
|
||||
"zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2",
|
||||
"zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e",
|
||||
"zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421",
|
||||
"zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4",
|
||||
"zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9",
|
||||
"zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9",
|
||||
"zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/cloudinit" {
|
||||
version = "2.3.6"
|
||||
version = "2.3.7"
|
||||
constraints = ">= 2.0.0"
|
||||
hashes = [
|
||||
"h1:afnqn3XPnO40laFt+SVHPPKsg1j3HXT0VAO0xBVvmrY=",
|
||||
"zh:1321b5ddede56be3f9b35bf75d7cda79adcb357fad62eb8677b6595e0baaa6cd",
|
||||
"zh:265d66e61b9cd16ca1182ebf094cc0a08fb3687e8193a1dbac6899b16c237151",
|
||||
"zh:3875c3a20e082ac55d5ff24bcaf7133ebc90c7f999fd0fb37cf0f0003474c94c",
|
||||
"zh:68ce41ccd07757c451682703840cae1ec270ed5275cd491bbf8279782dfcbb73",
|
||||
"h1:M9TpQxKAE/hyOwytdX9MUNZw30HoD/OXqYIug5fkqH8=",
|
||||
"zh:06f1c54e919425c3139f8aeb8fcf9bceca7e560d48c9f0c1e3bb0a8ad9d9da1e",
|
||||
"zh:0e1e4cf6fd98b019e764c28586a386dc136129fef50af8c7165a067e7e4a31d5",
|
||||
"zh:1871f4337c7c57287d4d67396f633d224b8938708b772abfc664d1f80bd67edd",
|
||||
"zh:2b9269d91b742a71b2248439d5e9824f0447e6d261bfb86a8a88528609b136d1",
|
||||
"zh:3d8ae039af21426072c66d6a59a467d51f2d9189b8198616888c1b7fc42addc7",
|
||||
"zh:3ef4e2db5bcf3e2d915921adced43929214e0946a6fb11793085d9a48995ae01",
|
||||
"zh:42ae54381147437c83cbb8790cc68935d71b6357728a154109d3220b1beb4dc9",
|
||||
"zh:4496b362605ae4cbc9ef7995d102351e2fe311897586ffc7a4a262ccca0c782a",
|
||||
"zh:652a2401257a12706d32842f66dac05a735693abcb3e6517d6b5e2573729ba13",
|
||||
"zh:7406c30806f5979eaed5f50c548eced2ea18ea121e01801d2f0d4d87a04f6a14",
|
||||
"zh:7848429fd5a5bcf35f6fee8487df0fb64b09ec071330f3ff240c0343fe2a5224",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:8dca3bb3f85ff8ac4d1b3f93975dcb751ed788396c56ebf0c3737ce1a4c60492",
|
||||
"zh:9339bdaa99939291cedf543861353c8e7171ec5231c0dfacaa9bdb3338978dab",
|
||||
"zh:a8510c2256e9a78697910bb5542aeca457c81225ea88130335f6d14a36a36c74",
|
||||
"zh:af7ed71b8fceb60a5e3b7fa663be171e0bd41bb0af30e0e1f06a004c7b584e4a",
|
||||
"zh:bc9de0f921b69d07f5fc1ea65f8af71d8d1a7053aafb500788b30bfce64b8fbe",
|
||||
"zh:bccd0a49f161a91660d7d30dd6b389e6820f29752ccf351f10a3297c96973823",
|
||||
"zh:c69321caca20009abead617f888a67aca990276cb7388b738b19157b88749190",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/external" {
|
||||
version = "2.3.5"
|
||||
constraints = ">= 1.0.0"
|
||||
hashes = [
|
||||
"h1:FnUk98MI5nOh3VJ16cHf8mchQLewLfN1qZG/MqNgPrI=",
|
||||
"zh:6e89509d056091266532fa64de8c06950010498adf9070bf6ff85bc485a82562",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:86868aec05b58dc0aa1904646a2c26b9367d69b890c9ad70c33c0d3aa7b1485a",
|
||||
"zh:a2ce38fda83a62fa5fb5a70e6ca8453b168575feb3459fa39803f6f40bd42154",
|
||||
"zh:a6c72798f4a9a36d1d1433c0372006cc9b904e8cfd60a2ae03ac5b7d2abd2398",
|
||||
"zh:a8a3141d2fc71c86bf7f3c13b0b3be8a1b0f0144a47572a15af4dfafc051e28a",
|
||||
"zh:aa20a1242eb97445ad26ebcfb9babf2cd675bdb81cac5f989268ebefa4ef278c",
|
||||
"zh:b58a22445fb8804e933dcf835ab06c29a0f33148dce61316814783ee7f4e4332",
|
||||
"zh:cb5626a661ee761e0576defb2a2d75230a3244799d380864f3089c66e99d0dcc",
|
||||
"zh:d1acb00d20445f682c4e705c965e5220530209c95609194c2dc39324f3d4fcce",
|
||||
"zh:d91a254ba77b69a29d8eae8ed0e9367cbf0ea6ac1a85b58e190f8cb096a40871",
|
||||
"zh:f6592327673c9f85cdb6f20336faef240abae7621b834f189c4a62276ea5db41",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/helm" {
|
||||
version = "2.17.0"
|
||||
constraints = "~> 2.17"
|
||||
constraints = ">= 2.9.0, ~> 2.17, < 3.0.0"
|
||||
hashes = [
|
||||
"h1:kQMkcPVvHOguOqnxoEU2sm1ND9vCHiT8TvZ2x6v/Rsw=",
|
||||
"zh:06fb4e9932f0afc1904d2279e6e99353c2ddac0d765305ce90519af410706bd4",
|
||||
@@ -65,100 +85,121 @@ provider "registry.terraform.io/hashicorp/helm" {
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/kubernetes" {
|
||||
version = "2.36.0"
|
||||
constraints = "~> 2.36"
|
||||
version = "2.38.0"
|
||||
constraints = ">= 2.20.0, ~> 2.36"
|
||||
hashes = [
|
||||
"h1:94wlXkBzfXwyLVuJVhMdzK+VGjFnMjdmFkYhQ1RUFhI=",
|
||||
"zh:07f38fcb7578984a3e2c8cf0397c880f6b3eb2a722a120a08a634a607ea495ca",
|
||||
"zh:1adde61769c50dbb799d8bf8bfd5c8c504a37017dfd06c7820f82bcf44ca0d39",
|
||||
"zh:39707f23ab58fd0e686967c0f973c0f5a39c14d6ccfc757f97c345fdd0cd4624",
|
||||
"zh:4cc3dc2b5d06cc22d1c734f7162b0a8fdc61990ff9efb64e59412d65a7ccc92a",
|
||||
"zh:8382dcb82ba7303715b5e67939e07dd1c8ecddbe01d12f39b82b2b7d7357e1d9",
|
||||
"zh:88e8e4f90034186b8bfdea1b8d394621cbc46a064ff2418027e6dba6807d5227",
|
||||
"zh:a6276a75ad170f76d88263fdb5f9558998bf3a3f7650d7bd3387b396410e59f3",
|
||||
"zh:bc816c7e0606e5df98a0c7634b240bb0c8100c3107b8b17b554af702edc6a0c5",
|
||||
"zh:cb2f31d58f37020e840af52755c18afd1f09a833c4903ac59270ab440fab57b7",
|
||||
"zh:ee0d103b8d0089fb1918311683110b4492a9346f0471b136af46d3b019576b22",
|
||||
"h1:soK8Lt0SZ6dB+HsypFRDzuX/npqlMU6M0fvyaR1yW0k=",
|
||||
"zh:0af928d776eb269b192dc0ea0f8a3f0f5ec117224cd644bdacdc682300f84ba0",
|
||||
"zh:1be998e67206f7cfc4ffe77c01a09ac91ce725de0abaec9030b22c0a832af44f",
|
||||
"zh:326803fe5946023687d603f6f1bab24de7af3d426b01d20e51d4e6fbe4e7ec1b",
|
||||
"zh:4a99ec8d91193af961de1abb1f824be73df07489301d62e6141a656b3ebfff12",
|
||||
"zh:5136e51765d6a0b9e4dbcc3b38821e9736bd2136cf15e9aac11668f22db117d2",
|
||||
"zh:63fab47349852d7802fb032e4f2b6a101ee1ce34b62557a9ad0f0f0f5b6ecfdc",
|
||||
"zh:924fb0257e2d03e03e2bfe9c7b99aa73c195b1f19412ca09960001bee3c50d15",
|
||||
"zh:b63a0be5e233f8f6727c56bed3b61eb9456ca7a8bb29539fba0837f1badf1396",
|
||||
"zh:d39861aa21077f1bc899bc53e7233262e530ba8a3a2d737449b100daeb303e4d",
|
||||
"zh:de0805e10ebe4c83ce3b728a67f6b0f9d18be32b25146aa89116634df5145ad4",
|
||||
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
|
||||
"zh:f688b9ec761721e401f6859c19c083e3be20a650426f4747cd359cdc079d212a",
|
||||
"zh:faf23e45f0090eef8ba28a8aac7ec5d4fdf11a36c40a8d286304567d71c1e7db",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/local" {
|
||||
version = "2.5.3"
|
||||
constraints = ">= 1.0.0"
|
||||
hashes = [
|
||||
"h1:MCzg+hs1/ZQ32u56VzJMWP9ONRQPAAqAjuHuzbyshvI=",
|
||||
"zh:284d4b5b572eacd456e605e94372f740f6de27b71b4e1fd49b63745d8ecd4927",
|
||||
"zh:40d9dfc9c549e406b5aab73c023aa485633c1b6b730c933d7bcc2fa67fd1ae6e",
|
||||
"zh:6243509bb208656eb9dc17d3c525c89acdd27f08def427a0dce22d5db90a4c8b",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:885d85869f927853b6fe330e235cd03c337ac3b933b0d9ae827ec32fa1fdcdbf",
|
||||
"zh:bab66af51039bdfcccf85b25fe562cbba2f54f6b3812202f4873ade834ec201d",
|
||||
"zh:c505ff1bf9442a889ac7dca3ac05a8ee6f852e0118dd9a61796a2f6ff4837f09",
|
||||
"zh:d36c0b5770841ddb6eaf0499ba3de48e5d4fc99f4829b6ab66b0fab59b1aaf4f",
|
||||
"zh:ddb6a407c7f3ec63efb4dad5f948b54f7f4434ee1a2607a49680d494b1776fe1",
|
||||
"zh:e0dafdd4500bec23d3ff221e3a9b60621c5273e5df867bc59ef6b7e41f5c91f6",
|
||||
"zh:ece8742fd2882a8fc9d6efd20e2590010d43db386b920b2a9c220cfecc18de47",
|
||||
"zh:f4c6b3eb8f39105004cf720e202f04f57e3578441cfb76ca27611139bc116a82",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/null" {
|
||||
version = "3.2.3"
|
||||
constraints = ">= 3.0.0"
|
||||
version = "3.2.4"
|
||||
constraints = ">= 2.0.0, >= 3.0.0"
|
||||
hashes = [
|
||||
"h1:I0Um8UkrMUb81Fxq/dxbr3HLP2cecTH2WMJiwKSrwQY=",
|
||||
"zh:22d062e5278d872fe7aed834f5577ba0a5afe34a3bdac2b81f828d8d3e6706d2",
|
||||
"zh:23dead00493ad863729495dc212fd6c29b8293e707b055ce5ba21ee453ce552d",
|
||||
"zh:28299accf21763ca1ca144d8f660688d7c2ad0b105b7202554ca60b02a3856d3",
|
||||
"zh:55c9e8a9ac25a7652df8c51a8a9a422bd67d784061b1de2dc9fe6c3cb4e77f2f",
|
||||
"zh:756586535d11698a216291c06b9ed8a5cc6a4ec43eee1ee09ecd5c6a9e297ac1",
|
||||
"h1:L5V05xwp/Gto1leRryuesxjMfgZwjb7oool4WS1UEFQ=",
|
||||
"zh:59f6b52ab4ff35739647f9509ee6d93d7c032985d9f8c6237d1f8a59471bbbe2",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:9d5eea62fdb587eeb96a8c4d782459f4e6b73baeece4d04b4a40e44faaee9301",
|
||||
"zh:a6355f596a3fb8fc85c2fb054ab14e722991533f87f928e7169a486462c74670",
|
||||
"zh:b5a65a789cff4ada58a5baffc76cb9767dc26ec6b45c00d2ec8b1b027f6db4ed",
|
||||
"zh:db5ab669cf11d0e9f81dc380a6fdfcac437aea3d69109c7aef1a5426639d2d65",
|
||||
"zh:de655d251c470197bcbb5ac45d289595295acb8f829f6c781d4a75c8c8b7c7dd",
|
||||
"zh:f5c68199f2e6076bce92a12230434782bf768103a427e9bb9abee99b116af7b5",
|
||||
"zh:795c897119ff082133150121d39ff26cb5f89a730a2c8c26f3a9c1abf81a9c43",
|
||||
"zh:7b9c7b16f118fbc2b05a983817b8ce2f86df125857966ad356353baf4bff5c0a",
|
||||
"zh:85e33ab43e0e1726e5f97a874b8e24820b6565ff8076523cc2922ba671492991",
|
||||
"zh:9d32ac3619cfc93eb3c4f423492a8e0f79db05fec58e449dee9b2d5873d5f69f",
|
||||
"zh:9e15c3c9dd8e0d1e3731841d44c34571b6c97f5b95e8296a45318b94e5287a6e",
|
||||
"zh:b4c2ab35d1b7696c30b64bf2c0f3a62329107bd1a9121ce70683dec58af19615",
|
||||
"zh:c43723e8cc65bcdf5e0c92581dcbbdcbdcf18b8d2037406a5f2033b1e22de442",
|
||||
"zh:ceb5495d9c31bfb299d246ab333f08c7fb0d67a4f82681fbf47f2a21c3e11ab5",
|
||||
"zh:e171026b3659305c558d9804062762d168f50ba02b88b231d20ec99578a6233f",
|
||||
"zh:ed0fe2acdb61330b01841fa790be00ec6beaac91d41f311fb8254f74eb6a711f",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/random" {
|
||||
version = "3.7.1"
|
||||
version = "3.7.2"
|
||||
constraints = ">= 2.0.0, >= 3.6.0"
|
||||
hashes = [
|
||||
"h1:t152MY0tQH4a8fLzTtEWx70ITd3azVOrFDn/pQblbto=",
|
||||
"zh:3193b89b43bf5805493e290374cdda5132578de6535f8009547c8b5d7a351585",
|
||||
"zh:3218320de4be943e5812ed3de995946056db86eb8d03aa3f074e0c7316599bef",
|
||||
"zh:419861805a37fa443e7d63b69fb3279926ccf98a79d256c422d5d82f0f387d1d",
|
||||
"zh:4df9bd9d839b8fc11a3b8098a604b9b46e2235eb65ef15f4432bde0e175f9ca6",
|
||||
"zh:5814be3f9c9cc39d2955d6f083bae793050d75c572e70ca11ccceb5517ced6b1",
|
||||
"zh:63c6548a06de1231c8ee5570e42ca09c4b3db336578ded39b938f2156f06dd2e",
|
||||
"zh:697e434c6bdee0502cc3deb098263b8dcd63948e8a96d61722811628dce2eba1",
|
||||
"h1:KG4NuIBl1mRWU0KD/BGfCi1YN/j3F7H4YgeeM7iSdNs=",
|
||||
"zh:14829603a32e4bc4d05062f059e545a91e27ff033756b48afbae6b3c835f508f",
|
||||
"zh:1527fb07d9fea400d70e9e6eb4a2b918d5060d604749b6f1c361518e7da546dc",
|
||||
"zh:1e86bcd7ebec85ba336b423ba1db046aeaa3c0e5f921039b3f1a6fc2f978feab",
|
||||
"zh:24536dec8bde66753f4b4030b8f3ef43c196d69cccbea1c382d01b222478c7a3",
|
||||
"zh:29f1786486759fad9b0ce4fdfbbfece9343ad47cd50119045075e05afe49d212",
|
||||
"zh:4d701e978c2dd8604ba1ce962b047607701e65c078cb22e97171513e9e57491f",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:a0b8e44927e6327852bbfdc9d408d802569367f1e22a95bcdd7181b1c3b07601",
|
||||
"zh:b7d3af018683ef22794eea9c218bc72d7c35a2b3ede9233b69653b3c782ee436",
|
||||
"zh:d63b911d618a6fe446c65bfc21e793a7663e934b2fef833d42d3ccd38dd8d68d",
|
||||
"zh:fa985cd0b11e6d651f47cff3055f0a9fd085ec190b6dbe99bf5448174434cdea",
|
||||
"zh:7b8434212eef0f8c83f5a90c6d76feaf850f6502b61b53c329e85b3b281cba34",
|
||||
"zh:ac8a23c212258b7976e1621275e3af7099e7e4a3d4478cf8d5d2a27f3bc3e967",
|
||||
"zh:b516ca74431f3df4c6cf90ddcdb4042c626e026317a33c53f0b445a3d93b720d",
|
||||
"zh:dc76e4326aec2490c1600d6871a95e78f9050f9ce427c71707ea412a2f2f1a62",
|
||||
"zh:eac7b63e86c749c7d48f527671c7aee5b4e26c10be6ad7232d6860167f99dbb0",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/time" {
|
||||
version = "0.12.1"
|
||||
version = "0.13.1"
|
||||
constraints = ">= 0.9.0"
|
||||
hashes = [
|
||||
"h1:JzYsPugN8Fb7C4NlfLoFu7BBPuRVT2/fCOdCaxshveI=",
|
||||
"zh:090023137df8effe8804e81c65f636dadf8f9d35b79c3afff282d39367ba44b2",
|
||||
"zh:26f1e458358ba55f6558613f1427dcfa6ae2be5119b722d0b3adb27cd001efea",
|
||||
"zh:272ccc73a03384b72b964918c7afeb22c2e6be22460d92b150aaf28f29a7d511",
|
||||
"zh:438b8c74f5ed62fe921bd1078abe628a6675e44912933100ea4fa26863e340e9",
|
||||
"h1:ZT5ppCNIModqk3iOkVt5my8b8yBHmDpl663JtXAIRqM=",
|
||||
"zh:02cb9aab1002f0f2a94a4f85acec8893297dc75915f7404c165983f720a54b74",
|
||||
"zh:04429b2b31a492d19e5ecf999b116d396dac0b24bba0d0fb19ecaefe193fdb8f",
|
||||
"zh:26f8e51bb7c275c404ba6028c1b530312066009194db721a8427a7bc5cdbc83a",
|
||||
"zh:772ff8dbdbef968651ab3ae76d04afd355c32f8a868d03244db3f8496e462690",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:85c8bd8eefc4afc33445de2ee7fbf33a7807bc34eb3734b8eefa4e98e4cddf38",
|
||||
"zh:98bbe309c9ff5b2352de6a047e0ec6c7e3764b4ed3dfd370839c4be2fbfff869",
|
||||
"zh:9c7bf8c56da1b124e0e2f3210a1915e778bab2be924481af684695b52672891e",
|
||||
"zh:d2200f7f6ab8ecb8373cda796b864ad4867f5c255cff9d3b032f666e4c78f625",
|
||||
"zh:d8c7926feaddfdc08d5ebb41b03445166df8c125417b28d64712dccd9feef136",
|
||||
"zh:e2412a192fc340c61b373d6c20c9d805d7d3dee6c720c34db23c2a8ff0abd71b",
|
||||
"zh:e6ac6bba391afe728a099df344dbd6481425b06d61697522017b8f7a59957d44",
|
||||
"zh:898db5d2b6bd6ca5457dccb52eedbc7c5b1a71e4a4658381bcbb38cedbbda328",
|
||||
"zh:8de913bf09a3fa7bedc29fec18c47c571d0c7a3d0644322c46f3aa648cf30cd8",
|
||||
"zh:9402102c86a87bdfe7e501ffbb9c685c32bbcefcfcf897fd7d53df414c36877b",
|
||||
"zh:b18b9bb1726bb8cfbefc0a29cf3657c82578001f514bcf4c079839b6776c47f0",
|
||||
"zh:b9d31fdc4faecb909d7c5ce41d2479dd0536862a963df434be4b16e8e4edc94d",
|
||||
"zh:c951e9f39cca3446c060bd63933ebb89cedde9523904813973fbc3d11863ba75",
|
||||
"zh:e5b773c0d07e962291be0e9b413c7a22c044b8c7b58c76e8aa91d1659990dfb5",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/tls" {
|
||||
version = "4.0.6"
|
||||
version = "4.1.0"
|
||||
constraints = ">= 3.0.0"
|
||||
hashes = [
|
||||
"h1:n3M50qfWfRSpQV9Pwcvuse03pEizqrmYEryxKky4so4=",
|
||||
"zh:10de0d8af02f2e578101688fd334da3849f56ea91b0d9bd5b1f7a243417fdda8",
|
||||
"zh:37fc01f8b2bc9d5b055dc3e78bfd1beb7c42cfb776a4c81106e19c8911366297",
|
||||
"zh:4578ca03d1dd0b7f572d96bd03f744be24c726bfd282173d54b100fd221608bb",
|
||||
"zh:6c475491d1250050765a91a493ef330adc24689e8837a0f07da5a0e1269e11c1",
|
||||
"zh:81bde94d53cdababa5b376bbc6947668be4c45ab655de7aa2e8e4736dfd52509",
|
||||
"zh:abdce260840b7b050c4e401d4f75c7a199fafe58a8b213947a258f75ac18b3e8",
|
||||
"zh:b754cebfc5184873840f16a642a7c9ef78c34dc246a8ae29e056c79939963c7a",
|
||||
"zh:c928b66086078f9917aef0eec15982f2e337914c5c4dbc31dd4741403db7eb18",
|
||||
"zh:cded27bee5f24de6f2ee0cfd1df46a7f88e84aaffc2ecbf3ff7094160f193d50",
|
||||
"zh:d65eb3867e8f69aaf1b8bb53bd637c99c6b649ba3db16ded50fa9a01076d1a27",
|
||||
"zh:ecb0c8b528c7a619fa71852bb3fb5c151d47576c5aab2bf3af4db52588722eeb",
|
||||
"h1:zEv9tY1KR5vaLSyp2lkrucNJ+Vq3c+sTFK9GyQGLtFs=",
|
||||
"zh:14c35d89307988c835a7f8e26f1b83ce771e5f9b41e407f86a644c0152089ac2",
|
||||
"zh:2fb9fe7a8b5afdbd3e903acb6776ef1be3f2e587fb236a8c60f11a9fa165faa8",
|
||||
"zh:35808142ef850c0c60dd93dc06b95c747720ed2c40c89031781165f0c2baa2fc",
|
||||
"zh:35b5dc95bc75f0b3b9c5ce54d4d7600c1ebc96fbb8dfca174536e8bf103c8cdc",
|
||||
"zh:38aa27c6a6c98f1712aa5cc30011884dc4b128b4073a4a27883374bfa3ec9fac",
|
||||
"zh:51fb247e3a2e88f0047cb97bb9df7c228254a3b3021c5534e4563b4007e6f882",
|
||||
"zh:62b981ce491e38d892ba6364d1d0cdaadcee37cc218590e07b310b1dfa34be2d",
|
||||
"zh:bc8e47efc611924a79f947ce072a9ad698f311d4a60d0b4dfff6758c912b7298",
|
||||
"zh:c149508bd131765d1bc085c75a870abb314ff5a6d7f5ac1035a8892d686b6297",
|
||||
"zh:d38d40783503d278b63858978d40e07ac48123a2925e1a6b47e62179c046f87a",
|
||||
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
|
||||
"zh:fb07f708e3316615f6d218cec198504984c0ce7000b9f1eebff7516e384f4b54",
|
||||
]
|
||||
}
|
||||
|
||||
+119
-119
@@ -131,7 +131,7 @@ module "ebs_csi_driver_irsa" {
|
||||
|
||||
module "eks" {
|
||||
source = "terraform-aws-modules/eks/aws"
|
||||
version = "20.33.1"
|
||||
version = "20.37.2"
|
||||
|
||||
cluster_name = "${local.name}-eks"
|
||||
cluster_version = "1.32"
|
||||
@@ -149,7 +149,7 @@ module "eks" {
|
||||
most_recent = true
|
||||
}
|
||||
aws-ebs-csi-driver = {
|
||||
most_recent = true
|
||||
addon_version = "v1.46.0-eksbuild.1"
|
||||
service_account_role_arn = module.ebs_csi_driver_irsa.iam_role_arn
|
||||
}
|
||||
kube-proxy = {
|
||||
@@ -278,125 +278,125 @@ output "karpenter_node_role" {
|
||||
|
||||
|
||||
|
||||
resource "helm_release" "karpenter_crds" {
|
||||
name = "karpenter-crds"
|
||||
repository = "oci://public.ecr.aws/karpenter"
|
||||
repository_username = data.aws_ecrpublic_authorization_token.token.user_name
|
||||
repository_password = data.aws_ecrpublic_authorization_token.token.password
|
||||
chart = "karpenter-crd"
|
||||
version = "1.3.1"
|
||||
namespace = local.karpenter_namespace
|
||||
values = [
|
||||
<<-EOT
|
||||
webhook:
|
||||
enabled: true
|
||||
serviceNamespace: ${local.karpenter_namespace}
|
||||
EOT
|
||||
]
|
||||
}
|
||||
# resource "helm_release" "karpenter_crds" {
|
||||
# name = "karpenter-crds"
|
||||
# repository = "oci://public.ecr.aws/karpenter"
|
||||
# repository_username = data.aws_ecrpublic_authorization_token.token.user_name
|
||||
# repository_password = data.aws_ecrpublic_authorization_token.token.password
|
||||
# chart = "karpenter-crd"
|
||||
# version = "1.3.1"
|
||||
# namespace = local.karpenter_namespace
|
||||
# values = [
|
||||
# <<-EOT
|
||||
# webhook:
|
||||
# enabled: true
|
||||
# serviceNamespace: ${local.karpenter_namespace}
|
||||
# EOT
|
||||
# ]
|
||||
# }
|
||||
|
||||
resource "helm_release" "karpenter" {
|
||||
name = "karpenter"
|
||||
repository = "oci://public.ecr.aws/karpenter"
|
||||
repository_username = data.aws_ecrpublic_authorization_token.token.user_name
|
||||
repository_password = data.aws_ecrpublic_authorization_token.token.password
|
||||
chart = "karpenter"
|
||||
version = "1.3.1"
|
||||
namespace = local.karpenter_namespace
|
||||
skip_crds = true
|
||||
# resource "helm_release" "karpenter" {
|
||||
# name = "karpenter"
|
||||
# repository = "oci://public.ecr.aws/karpenter"
|
||||
# repository_username = data.aws_ecrpublic_authorization_token.token.user_name
|
||||
# repository_password = data.aws_ecrpublic_authorization_token.token.password
|
||||
# chart = "karpenter"
|
||||
# version = "1.3.1"
|
||||
# namespace = local.karpenter_namespace
|
||||
# skip_crds = true
|
||||
#
|
||||
# values = [
|
||||
# <<-EOT
|
||||
# nodeSelector:
|
||||
# karpenter.sh/controller: 'true'
|
||||
# dnsPolicy: Default
|
||||
# settings:
|
||||
# clusterName: ${module.eks.cluster_name}
|
||||
# clusterEndpoint: ${module.eks.cluster_endpoint}
|
||||
# interruptionQueue: ${module.karpenter.queue_name}
|
||||
# EOT
|
||||
# ]
|
||||
# }
|
||||
#
|
||||
# resource "kubernetes_manifest" "ec2_node_class" {
|
||||
# manifest = {
|
||||
# apiVersion = "karpenter.k8s.aws/v1"
|
||||
# kind = "EC2NodeClass"
|
||||
# metadata = {
|
||||
# name = "default"
|
||||
# }
|
||||
# spec = {
|
||||
# amiSelectorTerms = [
|
||||
# {
|
||||
# alias = "bottlerocket@latest"
|
||||
# }
|
||||
# ]
|
||||
# role = module.karpenter.node_iam_role_name
|
||||
# subnetSelectorTerms = [
|
||||
# {
|
||||
# tags = {
|
||||
# "karpenter.sh/discovery" = "${local.name}-eks"
|
||||
# }
|
||||
# }
|
||||
# ]
|
||||
# securityGroupSelectorTerms = [
|
||||
# {
|
||||
# tags = {
|
||||
# "karpenter.sh/discovery" = "${local.name}-eks"
|
||||
# }
|
||||
# }
|
||||
# ]
|
||||
# tags = {
|
||||
# "karpenter.sh/discovery" = "${local.name}-eks"
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
|
||||
values = [
|
||||
<<-EOT
|
||||
nodeSelector:
|
||||
karpenter.sh/controller: 'true'
|
||||
dnsPolicy: Default
|
||||
settings:
|
||||
clusterName: ${module.eks.cluster_name}
|
||||
clusterEndpoint: ${module.eks.cluster_endpoint}
|
||||
interruptionQueue: ${module.karpenter.queue_name}
|
||||
EOT
|
||||
]
|
||||
}
|
||||
|
||||
resource "kubernetes_manifest" "ec2_node_class" {
|
||||
manifest = {
|
||||
apiVersion = "karpenter.k8s.aws/v1"
|
||||
kind = "EC2NodeClass"
|
||||
metadata = {
|
||||
name = "default"
|
||||
}
|
||||
spec = {
|
||||
amiSelectorTerms = [
|
||||
{
|
||||
alias = "bottlerocket@latest"
|
||||
}
|
||||
]
|
||||
role = module.karpenter.node_iam_role_name
|
||||
subnetSelectorTerms = [
|
||||
{
|
||||
tags = {
|
||||
"karpenter.sh/discovery" = "${local.name}-eks"
|
||||
}
|
||||
}
|
||||
]
|
||||
securityGroupSelectorTerms = [
|
||||
{
|
||||
tags = {
|
||||
"karpenter.sh/discovery" = "${local.name}-eks"
|
||||
}
|
||||
}
|
||||
]
|
||||
tags = {
|
||||
"karpenter.sh/discovery" = "${local.name}-eks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_manifest" "node_pool" {
|
||||
manifest = {
|
||||
apiVersion = "karpenter.sh/v1"
|
||||
kind = "NodePool"
|
||||
metadata = {
|
||||
name = "default"
|
||||
}
|
||||
spec = {
|
||||
template = {
|
||||
spec = {
|
||||
nodeClassRef = {
|
||||
group = "karpenter.k8s.aws"
|
||||
kind = "EC2NodeClass"
|
||||
name = "default"
|
||||
}
|
||||
requirements = [
|
||||
{
|
||||
key = "karpenter.k8s.aws/instance-family"
|
||||
operator = "In"
|
||||
values = ["c8g", "c7g", "m8g", "m7g", "r8g", "r7g"]
|
||||
},
|
||||
{
|
||||
key = "karpenter.k8s.aws/instance-cpu"
|
||||
operator = "In"
|
||||
values = ["2", "4", "8"]
|
||||
},
|
||||
{
|
||||
key = "karpenter.k8s.aws/instance-hypervisor"
|
||||
operator = "In"
|
||||
values = ["nitro"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
limits = {
|
||||
cpu = 1000
|
||||
}
|
||||
disruption = {
|
||||
consolidationPolicy = "WhenEmptyOrUnderutilized"
|
||||
consolidateAfter = "30s"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
# resource "kubernetes_manifest" "node_pool" {
|
||||
# manifest = {
|
||||
# apiVersion = "karpenter.sh/v1"
|
||||
# kind = "NodePool"
|
||||
# metadata = {
|
||||
# name = "default"
|
||||
# }
|
||||
# spec = {
|
||||
# template = {
|
||||
# spec = {
|
||||
# nodeClassRef = {
|
||||
# group = "karpenter.k8s.aws"
|
||||
# kind = "EC2NodeClass"
|
||||
# name = "default"
|
||||
# }
|
||||
# requirements = [
|
||||
# {
|
||||
# key = "karpenter.k8s.aws/instance-family"
|
||||
# operator = "In"
|
||||
# values = ["c8g", "c7g", "m8g", "m7g", "r8g", "r7g"]
|
||||
# },
|
||||
# {
|
||||
# key = "karpenter.k8s.aws/instance-cpu"
|
||||
# operator = "In"
|
||||
# values = ["2", "4", "8"]
|
||||
# },
|
||||
# {
|
||||
# key = "karpenter.k8s.aws/instance-hypervisor"
|
||||
# operator = "In"
|
||||
# values = ["nitro"]
|
||||
# }
|
||||
# ]
|
||||
# }
|
||||
# }
|
||||
# limits = {
|
||||
# cpu = 1000
|
||||
# }
|
||||
# disruption = {
|
||||
# consolidationPolicy = "WhenEmptyOrUnderutilized"
|
||||
# consolidateAfter = "30s"
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
|
||||
module "eks_blueprints_addons" {
|
||||
source = "aws-ia/eks-blueprints-addons/aws"
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import type { MigrationScript } from "../../src/scripts/migration-runner";
|
||||
|
||||
type I18nString = Record<string, string>;
|
||||
|
||||
interface MatrixChoice {
|
||||
id: string;
|
||||
label: I18nString;
|
||||
}
|
||||
|
||||
interface SurveyRecord {
|
||||
id: string;
|
||||
questions: {
|
||||
type: string;
|
||||
rows?: I18nString[] | MatrixChoice[];
|
||||
columns?: I18nString[] | MatrixChoice[];
|
||||
}[];
|
||||
}
|
||||
|
||||
const isMatrixChoice = (choice: unknown): choice is MatrixChoice => {
|
||||
return typeof choice === "object" && choice !== null && "id" in choice && "label" in choice;
|
||||
};
|
||||
|
||||
const normalizeChoice = (choice: I18nString | MatrixChoice): MatrixChoice => {
|
||||
// If already a MatrixChoice with id and label, return as-is
|
||||
if (isMatrixChoice(choice)) return choice;
|
||||
|
||||
// Otherwise, treat as I18nString and normalize with cuid2
|
||||
return {
|
||||
id: createId(),
|
||||
label: choice,
|
||||
};
|
||||
};
|
||||
|
||||
const processMatrixQuestion = (question: SurveyRecord["questions"][0]): boolean => {
|
||||
const rows = question.rows ?? [];
|
||||
const columns = question.columns ?? [];
|
||||
|
||||
const rowsNeedUpdate = rows.some((r) => !isMatrixChoice(r));
|
||||
const colsNeedUpdate = columns.some((c) => !isMatrixChoice(c));
|
||||
|
||||
if (rowsNeedUpdate || colsNeedUpdate) {
|
||||
const newRows = rowsNeedUpdate ? rows.map((r) => normalizeChoice(r)) : rows;
|
||||
const newColumns = colsNeedUpdate ? columns.map((c) => normalizeChoice(c)) : columns;
|
||||
|
||||
Object.assign(question, {
|
||||
rows: newRows,
|
||||
columns: newColumns,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const addsIdToMatrixQuestionLabels: MigrationScript = {
|
||||
type: "data",
|
||||
id: "dc990fgnxoynpfao31n5kj6q",
|
||||
name: "20250812114145_adds_id_to_matrix_question_labels",
|
||||
run: async ({ tx }) => {
|
||||
// select all surveys with matrix questions (questions is a jsonb field)
|
||||
const matrixSurveys = await tx.$queryRaw<SurveyRecord[]>`
|
||||
SELECT id, questions
|
||||
FROM "Survey"
|
||||
WHERE questions @> '[{"type": "matrix"}]'
|
||||
`;
|
||||
|
||||
if (matrixSurveys.length === 0) {
|
||||
logger.info("No surveys found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Process surveys and collect updates
|
||||
const updates: { id: string; questions: SurveyRecord["questions"] }[] = [];
|
||||
|
||||
for (const survey of matrixSurveys) {
|
||||
let shouldUpdate = false;
|
||||
|
||||
for (const question of survey.questions) {
|
||||
if ((question as { type?: string }).type === "matrix") {
|
||||
const wasUpdated = processMatrixQuestion(question);
|
||||
if (wasUpdated) shouldUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldUpdate) {
|
||||
updates.push({ id: survey.id, questions: survey.questions });
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
logger.info("No surveys needed updating");
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute all updates in a single SQL statement using VALUES
|
||||
const valuesList = updates
|
||||
.map((update) => `('${update.id}', '${JSON.stringify(update.questions).replace(/'/g, "''")}'::jsonb)`)
|
||||
.join(", ");
|
||||
|
||||
const result = await tx.$executeRawUnsafe(`
|
||||
UPDATE "Survey"
|
||||
SET questions = v.questions
|
||||
FROM (VALUES ${valuesList}) AS v(id, questions)
|
||||
WHERE "Survey".id = v.id
|
||||
`);
|
||||
|
||||
logger.info(`Updated ${result.toString()} surveys to add ids to matrix question labels`);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `imageUrl` on the `User` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" DROP COLUMN "imageUrl";
|
||||
@@ -44,7 +44,7 @@
|
||||
"dependencies": {
|
||||
"@formbricks/logger": "workspace:*",
|
||||
"@paralleldrive/cuid2": "2.2.2",
|
||||
"@prisma/client": "6.7.0",
|
||||
"@prisma/client": "6.13.0",
|
||||
"zod": "3.24.4",
|
||||
"zod-openapi": "4.2.4"
|
||||
},
|
||||
@@ -53,8 +53,8 @@
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"dotenv-cli": "8.0.0",
|
||||
"glob": "11.0.2",
|
||||
"prisma": "6.7.0",
|
||||
"prisma-json-types-generator": "3.4.1",
|
||||
"prisma": "6.13.0",
|
||||
"prisma-json-types-generator": "3.5.2",
|
||||
"ts-node": "10.9.2",
|
||||
"vite": "6.3.5",
|
||||
"vite-plugin-dts": "4.5.3"
|
||||
|
||||
@@ -824,7 +824,6 @@ model User {
|
||||
email String @unique
|
||||
emailVerified DateTime? @map(name: "email_verified")
|
||||
|
||||
imageUrl String?
|
||||
twoFactorSecret String?
|
||||
twoFactorEnabled Boolean @default(false)
|
||||
backupCodes String?
|
||||
|
||||
@@ -57,7 +57,6 @@ export const ZUser = z.object({
|
||||
Omit<
|
||||
User,
|
||||
| "emailVerified"
|
||||
| "imageUrl"
|
||||
| "twoFactorSecret"
|
||||
| "twoFactorEnabled"
|
||||
| "backupCodes"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user