Compare commits

..

6 Commits

Author SHA1 Message Date
Victor Santos
0bcd85d658 refactor: enhance removeCondition logic and add comprehensive tests
- Updated the removeCondition function to return a boolean indicating success or failure.
- Implemented cleanup logic to remove empty condition groups after a condition is removed.
- Added tests to ensure correct behavior of removeCondition, including scenarios with nested groups and empty groups.
- Enhanced ConditionsEditor to disable the 'Create Group' button when only one condition exists, and ensure it is enabled when multiple conditions are present.
2025-09-01 08:58:11 -03:00
Matthias Nannt
f59df49588 chore: backport ecr build and push action 2025-08-20 14:55:58 +02:00
Matti Nannt
f08fabfb13 fix(backport): github release action fix (#6425) 2025-08-15 13:26:09 +02:00
Dhruwang Jariwala
ee8af9dd74 chore: metadata tweaks backport (#6421) 2025-08-15 13:02:28 +02:00
Dhruwang Jariwala
1091b40bd1 fix(backport): cross button hover (#6416) 2025-08-14 14:30:05 +02:00
Anshuman Pandey
87a2d727ed fix: disables tabs when single use is enabled [Backport] (#6412) 2025-08-14 04:07:35 -07:00
125 changed files with 4888 additions and 3143 deletions

View File

@@ -1,8 +1,12 @@
---
description: It should be used **only when the agent explicitly requests database schema-level, details** to support tasks such as: writing/debugging Prisma queries, designing/reviewing data models, investigating multi-tenancy behavior, creating API endpoints, or understanding data relationships.
alwaysApply: false
description: >
This rule provides comprehensive knowledge about the Formbricks database structure, relationships,
and data patterns. It should be used **only when the agent explicitly requests database schema-level
details** to support tasks such as: writing/debugging Prisma queries, designing/reviewing data models,
investigating multi-tenancy behavior, creating API endpoints, or understanding data relationships.
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.
@@ -12,7 +16,6 @@ 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
@@ -26,7 +29,6 @@ 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`
@@ -35,22 +37,17 @@ 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
@@ -58,7 +55,6 @@ Configured cascade relationships:
## Common Query Patterns
### Survey with Responses
```typescript
// Include response count and latest responses
const survey = await prisma.survey.findUnique({
@@ -66,40 +62,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.

View File

@@ -1,74 +0,0 @@
---
alwaysApply: true
---
### Formbricks Monorepo Overview
- **Project**: Formbricks — opensource survey and experience management platform. Repo: [formbricks/formbricks](https://github.com/formbricks/formbricks)
- **Monorepo**: Turborepo + pnpm workspaces. Root configs: [package.json](mdc:package.json), [turbo.json](mdc:turbo.json)
- **Core app**: Next.js app in `apps/web` with Prisma, Auth.js, TailwindCSS, Vitest, Playwright. Enterprise modules live in [apps/web/modules/ee](mdc:apps/web/modules/ee)
- **Datastores**: PostgreSQL + Redis. Local dev via [docker-compose.dev.yml](mdc:docker-compose.dev.yml); Prisma schema at [packages/database/schema.prisma](mdc:packages/database/schema.prisma)
- **Docs & Ops**: Docs in `docs/` (Mintlify), Helm in `helm-chart/`, IaC in `infra/`
### Apps
- **apps/web**: Next.js product application (API, UI, SSO, i18n, emails, uploads, integrations)
- **apps/storybook**: Storybook for UI components; a11y addon + Vite builder
### Packages
- **@formbricks/database** (`packages/database`): Prisma schema, DB scripts, migrations, data layer
- **@formbricks/js-core** (`packages/js-core`): Core runtime for web embed / async loader
- **@formbricks/surveys** (`packages/surveys`): Embeddable survey rendering and helpers
- **@formbricks/logger** (`packages/logger`): Shared logging (pino) + Zod types
- **@formbricks/types** (`packages/types`): Shared types (Zod, Prisma clients)
- **@formbricks/i18n-utils** (`packages/i18n-utils`): i18n helpers and build output
- **@formbricks/eslint-config** (`packages/config-eslint`): Central ESLint config (Next, TS, Vitest, Prettier)
- **@formbricks/config-typescript** (`packages/config-typescript`): Central TS config and types
- **@formbricks/vite-plugins** (`packages/vite-plugins`): Internal Vite plugins
- **packages/android, packages/ios**: Native SDKs (built with platform toolchains)
### Enterpriseready by design
- **Quality & safety**: Strict TypeScript, repowide ESLint + Prettier, lintstaged + Husky, CI checks, typed env validation
- **Securityfirst**: Auth.js, SSO/SAML/OIDC, session controls, rate limiting, Sentry, structured logging
### Accessible by design
- **UI foundations**: Radix UI, TailwindCSS, Storybook with `@storybook/addon-a11y`, keyboard and screenreaderfriendly components
### Root pnpm commands
```bash
pnpm clean:all # Clean turbo cache, node_modules, lockfile, coverage, out
pnpm clean # Clean turbo cache, node_modules, coverage, out
pnpm build # Build all packages/apps (turbo)
pnpm build:dev # Dev-optimized builds (where supported)
pnpm dev # Run all dev servers in parallel
pnpm start # Start built apps/services
pnpm go # Start DB (docker compose) and run long-running dev tasks
pnpm generate # Run generators (e.g., Prisma, API specs)
pnpm lint # Lint all
pnpm format # Prettier write across repo
pnpm test # Unit tests
pnpm test:coverage # Unit tests with coverage
pnpm test:e2e # Playwright tests
pnpm test-e2e:azure # Playwright tests with Azure config
pnpm storybook # Run Storybook
pnpm db:up # Start local Postgres/Redis via docker compose
pnpm db:down # Stop local DB stack
pnpm db:start # Project-level DB setup choreography
pnpm db:push # Prisma db push (accept data loss in package script)
pnpm db:migrate:dev # Apply dev migrations
pnpm db:migrate:deploy # Apply prod migrations
pnpm fb-migrate-dev # Create DB migration (database package) and prisma generate
pnpm tolgee-pull # Pull translation keys for current branch and format
```
### Essentials for every prompt
- **Tech stack**: Next.js, React 19, TypeScript, Prisma, Zod, TailwindCSS, Turborepo, Vitest, Playwright
- **Environments**: See `.env.example`. Many tasks require DB up and env variables set
- **Licensing**: Core under AGPLv3; Enterprise code in `apps/web/modules/ee` (included in Docker, unlocked via Enterprise License Key)
For deeper details, consult perpackage `package.json` and scripts (e.g., [apps/web/package.json](mdc:apps/web/package.json)).

View File

@@ -17,34 +17,7 @@ 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:
@@ -62,9 +35,6 @@ jobs:
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@a4e1a019f5e24960714ff6296aee04b736cbc3cf # v3.29.6
if: ${{ always() }}
if: ${{ always() && hashFiles('trivy-results.sarif') != '' }}
with:
sarif_file: "trivy-results.sarif"
ref: ${{ steps.gitref.outputs.ref }}
sha: ${{ steps.gitref.outputs.sha }}
category: "trivy-container-scan"

View File

@@ -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 }}
type=raw,value=latest,enable=${{ inputs.IS_PRERELEASE != 'true' }}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action

View File

@@ -1,4 +1,4 @@
FROM node:22-alpine3.22 AS base
FROM node:22-alpine3.21 AS base
#
## step 1: Prune monorepo

View File

@@ -45,7 +45,7 @@ afterEach(() => {
});
describe("LandingSidebar component", () => {
const user = { id: "u1", name: "Alice", email: "alice@example.com" } as any;
const user = { id: "u1", name: "Alice", email: "alice@example.com", imageUrl: "" } as any;
const organization = { id: "o1", name: "orgOne" } as any;
const organizations = [
{ id: "o2", name: "betaOrg" },

View File

@@ -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} />
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
<>
<div className="grow overflow-hidden">
<p

View File

@@ -113,6 +113,7 @@ const mockUser = {
name: "Test User",
email: "test@example.com",
emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),

View File

@@ -111,6 +111,7 @@ const mockUser = {
id: "user1",
name: "Test User",
email: "test@example.com",
imageUrl: "http://example.com/avatar.png",
emailVerified: new Date(),
twoFactorEnabled: false,
identityProvider: "email",

View File

@@ -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} />
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
{!isCollapsed && !isTextVisible && (
<>
<div

View File

@@ -37,6 +37,7 @@ describe("EnvironmentPage", () => {
id: mockUserId,
name: "Test User",
email: "test@example.com",
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),

View File

@@ -5,6 +5,8 @@ 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";
@@ -13,6 +15,8 @@ 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,
@@ -93,6 +97,58 @@ 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",

View File

@@ -0,0 +1,104 @@
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"
);
});
});
});

View File

@@ -0,0 +1,178 @@
"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>
);
};

View File

@@ -49,12 +49,15 @@ describe("Loading", () => {
);
const loadingCards = screen.getAllByTestId("loading-card");
expect(loadingCards).toHaveLength(2);
expect(loadingCards).toHaveLength(3);
expect(loadingCards[0]).toHaveTextContent("environments.settings.profile.personal_information");
expect(loadingCards[0]).toHaveTextContent("environments.settings.profile.update_personal_info");
expect(loadingCards[1]).toHaveTextContent("environments.settings.profile.delete_account");
expect(loadingCards[1]).toHaveTextContent("environments.settings.profile.confirm_delete_account");
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");
});
});

View File

@@ -19,6 +19,11 @@ 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"),

View File

@@ -55,6 +55,11 @@ 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>
@@ -68,6 +73,7 @@ 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: [] },
@@ -111,6 +117,7 @@ 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();

View File

@@ -12,6 +12,7 @@ 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 }> }) => {
@@ -49,6 +50,17 @@ 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")}

View File

@@ -126,6 +126,7 @@ const mockUser = {
createdAt: new Date(),
updatedAt: new Date(),
emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
notificationSettings: { alert: {} },

View File

@@ -128,6 +128,7 @@ const mockUser = {
name: "Test User",
email: "test@example.com",
emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),

View File

@@ -145,6 +145,7 @@ const mockUser = {
name: "Test User",
email: "test@example.com",
emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),

View File

@@ -147,14 +147,8 @@ const mockSurvey = {
id: "q2matrix",
type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Matrix Question" },
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" } },
],
rows: [{ default: "Row1" }, { default: "Row2" }],
columns: [{ default: "Col1" }, { default: "Col2" }],
required: false,
} as unknown as TSurveyQuestion,
{

View File

@@ -74,7 +74,7 @@ const getQuestionColumnsData = (
case "matrix":
return question.rows.map((matrixRow) => {
return {
accessorKey: matrixRow.label.default,
accessorKey: matrixRow.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.label, "default")}
getLocalizedValue(matrixRow, "default")}
</span>
</div>
</div>
);
},
cell: ({ row }) => {
const responseValue = row.original.responseData[matrixRow.label.default];
const responseValue = row.original.responseData[matrixRow.default];
if (typeof responseValue === "string") {
return <p className="text-slate-900">{responseValue}</p>;
}

View File

@@ -291,6 +291,7 @@ 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(),

View File

@@ -250,6 +250,7 @@ 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(),

View File

@@ -198,7 +198,6 @@ export const ShareSurveyModal = ({
if (survey.type !== "link") {
return ShareViaType.APP;
}
return ShareViaType.ANON_LINKS;
}, [survey.type]);

View File

@@ -1327,17 +1327,8 @@ describe("Matrix question type tests", () => {
type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Rate these aspects" },
required: true,
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" } },
],
rows: [{ default: "Speed" }, { default: "Quality" }, { default: "Price" }],
columns: [{ default: "Poor" }, { default: "Average" }, { default: "Good" }, { default: "Excellent" }],
};
const survey = {
@@ -1419,15 +1410,15 @@ describe("Matrix question type tests", () => {
headline: { default: "Rate these aspects", es: "Califica estos aspectos" },
required: true,
rows: [
{ id: "row-1", label: { default: "Speed", es: "Velocidad" } },
{ id: "row-2", label: { default: "Quality", es: "Calidad" } },
{ id: "row-3", label: { default: "Price", es: "Precio" } },
{ default: "Speed", es: "Velocidad" },
{ default: "Quality", es: "Calidad" },
{ default: "Price", es: "Precio" },
],
columns: [
{ 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" } },
{ default: "Poor", es: "Malo" },
{ default: "Average", es: "Promedio" },
{ default: "Good", es: "Bueno" },
{ default: "Excellent", es: "Excelente" },
],
};
@@ -1596,16 +1587,8 @@ describe("Matrix question type tests", () => {
type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Rate these aspects" },
required: true,
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" } },
],
rows: [{ default: "Speed" }, { default: "Quality" }, { default: "Price" }],
columns: [{ default: "Poor" }, { default: "Average" }, { default: "Good" }],
};
const survey = {
@@ -1738,16 +1721,8 @@ describe("Matrix question type tests", () => {
type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Rate these aspects" },
required: true,
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" } },
],
rows: [{ default: "Speed" }, { default: "Quality" }, { default: "Price" }],
columns: [{ default: "Poor" }, { default: "Average" }, { default: "Good" }],
};
const survey = {
@@ -1810,14 +1785,8 @@ describe("Matrix question type tests", () => {
type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Rate these aspects" },
required: true,
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" } },
],
rows: [{ default: "Speed" }, { default: "Quality" }],
columns: [{ default: "Poor" }, { default: "Good" }],
};
const survey = {
@@ -1880,12 +1849,12 @@ describe("Matrix question type tests", () => {
headline: { default: "Rate these aspects", fr: "Évaluez ces aspects" },
required: true,
rows: [
{ id: "row-1", label: { default: "Speed", fr: "Vitesse" } },
{ id: "row-2", label: { default: "Quality", fr: "Qualité" } },
{ default: "Speed", fr: "Vitesse" },
{ default: "Quality", fr: "Qualité" },
],
columns: [
{ id: "col-1", label: { default: "Poor", fr: "Médiocre" } },
{ id: "col-2", label: { default: "Good", fr: "Bon" } },
{ default: "Poor", fr: "Médiocre" },
{ default: "Good", fr: "Bon" },
],
};

View File

@@ -736,8 +736,8 @@ export const getQuestionSummary = async (
break;
}
case TSurveyQuestionTypeEnum.Matrix: {
const rows = question.rows.map((row) => getLocalizedValue(row.label, "default"));
const columns = question.columns.map((column) => getLocalizedValue(column.label, "default"));
const rows = question.rows.map((row) => getLocalizedValue(row, "default"));
const columns = question.columns.map((column) => getLocalizedValue(column, "default"));
let totalResponseCount = 0;
// Initialize count object
@@ -755,15 +755,13 @@ export const getQuestionSummary = async (
if (selectedResponses) {
totalResponseCount++;
question.rows.forEach((row) => {
const localizedRow = getLocalizedValue(row.label, responseLanguageCode);
const localizedRow = getLocalizedValue(row, responseLanguageCode);
const colValue = question.columns.find((column) => {
return (
getLocalizedValue(column.label, responseLanguageCode) === selectedResponses[localizedRow]
);
return getLocalizedValue(column, responseLanguageCode) === selectedResponses[localizedRow];
});
const colValueInDefaultLanguage = getLocalizedValue(colValue?.label, "default");
const colValueInDefaultLanguage = getLocalizedValue(colValue, "default");
if (colValueInDefaultLanguage && columns.includes(colValueInDefaultLanguage)) {
countMap[getLocalizedValue(row.label, "default")][colValueInDefaultLanguage] += 1;
countMap[getLocalizedValue(row, "default")][colValueInDefaultLanguage] += 1;
}
});
}

View File

@@ -158,6 +158,7 @@ const mockUser = {
name: "Test User",
email: "test@example.com",
emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
@@ -173,6 +174,7 @@ const mockSession = {
id: mockUserId,
name: mockUser.name,
email: mockUser.email,
image: mockUser.imageUrl,
role: mockUser.role,
plan: "free",
status: "active",

View File

@@ -89,94 +89,4 @@ describe("QuestionFilterComboBox", () => {
await userEvent.click(comboBoxOpenerButton);
expect(screen.queryByText("X")).not.toBeInTheDocument();
});
test("shows text input for URL meta field", () => {
const props = {
...defaultProps,
type: "Meta",
fieldId: "url",
filterValue: "Contains",
filterComboBoxValue: "example.com",
} as any;
render(<QuestionFilterComboBox {...props} />);
const textInput = screen.getByDisplayValue("example.com");
expect(textInput).toBeInTheDocument();
expect(textInput).toHaveAttribute("type", "text");
});
test("text input is disabled when no filter value is selected for URL field", () => {
const props = {
...defaultProps,
type: "Meta",
fieldId: "url",
filterValue: undefined,
} as any;
render(<QuestionFilterComboBox {...props} />);
const textInput = screen.getByRole("textbox");
expect(textInput).toBeDisabled();
});
test("text input calls onChangeFilterComboBoxValue when typing for URL field", async () => {
const props = {
...defaultProps,
type: "Meta",
fieldId: "url",
filterValue: "Contains",
filterComboBoxValue: "",
} as any;
render(<QuestionFilterComboBox {...props} />);
const textInput = screen.getByRole("textbox");
await userEvent.type(textInput, "t");
expect(props.onChangeFilterComboBoxValue).toHaveBeenCalledWith("t");
});
test("shows regular combobox for non-URL meta fields", () => {
const props = {
...defaultProps,
type: "Meta",
fieldId: "source",
filterValue: "Equals",
} as any;
render(<QuestionFilterComboBox {...props} />);
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
expect(screen.getAllByRole("button").length).toBeGreaterThanOrEqual(2);
});
test("shows regular combobox for URL field with non-text operations", () => {
const props = {
...defaultProps,
type: "Other",
fieldId: "url",
filterValue: "Equals",
} as any;
render(<QuestionFilterComboBox {...props} />);
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
expect(screen.getAllByRole("button").length).toBeGreaterThanOrEqual(2);
});
test("text input handles string filter combo box values correctly", () => {
const props = {
...defaultProps,
type: "Meta",
fieldId: "url",
filterValue: "Contains",
filterComboBoxValue: "test-url",
} as any;
render(<QuestionFilterComboBox {...props} />);
const textInput = screen.getByDisplayValue("test-url");
expect(textInput).toBeInTheDocument();
});
test("text input handles non-string filter combo box values gracefully", () => {
const props = {
...defaultProps,
type: "Meta",
fieldId: "url",
filterValue: "Contains",
filterComboBoxValue: ["array-value"],
} as any;
render(<QuestionFilterComboBox {...props} />);
const textInput = screen.getByRole("textbox");
expect(textInput).toHaveValue("");
});
});

View File

@@ -33,7 +33,6 @@ type QuestionFilterComboBoxProps = {
type?: TSurveyQuestionTypeEnum | Omit<OptionsType, OptionsType.QUESTIONS>;
handleRemoveMultiSelect: (value: string[]) => void;
disabled?: boolean;
fieldId?: string;
};
export const QuestionFilterComboBox = ({
@@ -46,7 +45,6 @@ export const QuestionFilterComboBox = ({
type,
handleRemoveMultiSelect,
disabled = false,
fieldId,
}: QuestionFilterComboBoxProps) => {
const [open, setOpen] = React.useState(false);
const [openFilterValue, setOpenFilterValue] = React.useState<boolean>(false);
@@ -77,9 +75,6 @@ export const QuestionFilterComboBox = ({
(type === TSurveyQuestionTypeEnum.NPS || type === TSurveyQuestionTypeEnum.Rating) &&
(filterValue === "Submitted" || filterValue === "Skipped");
// Check if this is a URL field with string comparison operations that require text input
const isTextInputField = type === OptionsType.META && fieldId === "url";
const filteredOptions = options?.filter((o) =>
(typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o)
.toLowerCase()
@@ -166,80 +161,70 @@ export const QuestionFilterComboBox = ({
</DropdownMenuContent>
</DropdownMenu>
)}
{isTextInputField ? (
<Input
type="text"
value={typeof filterComboBoxValue === "string" ? filterComboBoxValue : ""}
onChange={(e) => onChangeFilterComboBoxValue(e.target.value)}
disabled={disabled || !filterValue}
className="h-9 rounded-l-none border-none bg-white text-sm focus:ring-offset-0"
/>
) : (
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent">
<div
className={clsx(
"group flex items-center justify-between rounded-md rounded-l-none bg-white px-3 py-2 text-sm"
)}>
{filterComboBoxValue && filterComboBoxValue.length > 0 ? (
filterComboBoxItem
) : (
<button
type="button"
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
disabled={disabled || isDisabledComboBox || !filterValue}
className={clsx(
"flex-1 text-left text-slate-400",
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
)}>
{t("common.select")}...
</button>
)}
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent">
<div
className={clsx(
"group flex items-center justify-between rounded-md rounded-l-none bg-white px-3 py-2 text-sm"
)}>
{filterComboBoxValue && filterComboBoxValue.length > 0 ? (
filterComboBoxItem
) : (
<button
type="button"
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
disabled={disabled || isDisabledComboBox || !filterValue}
className={clsx(
"ml-2 flex items-center justify-center",
"flex-1 text-left text-slate-400",
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
)}>
{open ? (
<ChevronUp className="h-4 w-4 opacity-50" />
) : (
<ChevronDown className="h-4 w-4 opacity-50" />
)}
{t("common.select")}...
</button>
</div>
<div className="relative mt-2 h-full">
{open && (
<div className="animate-in absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
<CommandList>
<div className="p-2">
<Input
type="text"
autoFocus
placeholder={t("common.search") + "..."}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded-md border border-slate-300 p-2 text-sm focus:border-slate-300"
/>
</div>
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
<CommandGroup>
{filteredOptions?.map((o, index) => (
<CommandItem
key={`option-${typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}-${index}`}
onSelect={() => commandItemOnSelect(o)}
className="cursor-pointer">
{typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</div>
)}
<button
type="button"
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
disabled={disabled || isDisabledComboBox || !filterValue}
className={clsx(
"ml-2 flex items-center justify-center",
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
)}>
{open ? (
<ChevronUp className="h-4 w-4 opacity-50" />
) : (
<ChevronDown className="h-4 w-4 opacity-50" />
)}
</div>
</Command>
)}
</button>
</div>
<div className="relative mt-2 h-full">
{open && (
<div className="animate-in absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
<CommandList>
<div className="p-2">
<Input
type="text"
autoFocus
placeholder={t("common.search") + "..."}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded-md border border-slate-300 p-2 text-sm focus:border-slate-300"
/>
</div>
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
<CommandGroup>
{filteredOptions?.map((o, index) => (
<CommandItem
key={`option-${typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}-${index}`}
onSelect={() => commandItemOnSelect(o)}
className="cursor-pointer">
{typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</div>
)}
</div>
</Command>
</div>
);
};

View File

@@ -28,7 +28,6 @@ import {
HomeIcon,
ImageIcon,
LanguagesIcon,
LinkIcon,
ListIcon,
ListOrderedIcon,
MessageSquareTextIcon,
@@ -95,7 +94,6 @@ const questionIcons = {
source: ArrowUpFromDotIcon,
action: MousePointerClickIcon,
country: FlagIcon,
url: LinkIcon,
// others
Language: LanguagesIcon,
@@ -140,7 +138,7 @@ export const SelectedCommandItem = ({ label, questionType, type }: Partial<Quest
const getLabelStyle = (): string | undefined => {
if (type !== OptionsType.META) return undefined;
return label === "os" || label === "url" ? "uppercase" : "capitalize";
return label === "os" ? "uppercase" : "capitalize";
};
return (

View File

@@ -246,9 +246,9 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
<div className="flex w-full flex-wrap gap-3 md:flex-nowrap">
<div
className="grid w-full grid-cols-1 items-center gap-3 md:grid-cols-2"
key={`${s.questionType.id}-${i}-${s.questionType.label}`}>
key={`${s.questionType.id}-${i}`}>
<QuestionsComboBox
key={`${s.questionType.label}-${i}-${s.questionType.id}`}
key={`${s.questionType.label}-${i}`}
options={questionComboBoxOptions}
selected={s.questionType}
onChangeValue={(value) => handleOnChangeQuestionComboBoxValue(value, i)}
@@ -276,7 +276,6 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
? s?.questionType?.questionType
: s?.questionType?.type
}
fieldId={s?.questionType?.id}
handleRemoveMultiSelect={(value) => handleRemoveMultiSelect(value, i)}
onChangeFilterComboBoxValue={(value) => handleOnChangeFilterComboBoxValue(value, i)}
onChangeFilterValue={(value) => handleOnChangeFilterValue(value, i)}

View File

@@ -231,43 +231,6 @@ describe("surveys", () => {
expect(result.questionFilterOptions.some((o) => o.id === "q7")).toBeTruthy();
expect(result.questionFilterOptions.some((o) => o.id === "q8")).toBeTruthy();
});
test("should provide extended filter options for URL meta field", () => {
const survey = {
id: "survey1",
name: "Test Survey",
questions: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
status: "draft",
} as unknown as TSurvey;
const meta = {
url: ["https://example.com", "https://test.com"],
source: ["web", "mobile"],
};
const result = generateQuestionAndFilterOptions(survey, undefined, {}, meta, {});
const urlFilterOption = result.questionFilterOptions.find((o) => o.id === "url");
const sourceFilterOption = result.questionFilterOptions.find((o) => o.id === "source");
expect(urlFilterOption).toBeDefined();
expect(urlFilterOption?.filterOptions).toEqual([
"Equals",
"Not equals",
"Contains",
"Does not contain",
"Starts with",
"Does not start with",
"Ends with",
"Does not end with",
]);
expect(sourceFilterOption).toBeDefined();
expect(sourceFilterOption?.filterOptions).toEqual(["Equals", "Not equals"]);
});
});
describe("getFormattedFilters", () => {
@@ -754,119 +717,6 @@ describe("surveys", () => {
expect(result.data?.npsQ).toEqual({ op: "greaterThan", value: 7 });
expect(result.tags?.applied).toContain("Tag 1");
});
test("should format URL meta filters with string operations", () => {
const selectedFilter = {
responseStatus: "all",
filter: [
{
questionType: { type: "Meta", label: "url", id: "url" },
filterType: { filterValue: "Contains", filterComboBoxValue: "example.com" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, dateRange);
expect(result.meta?.url).toEqual({ op: "contains", value: "example.com" });
});
test("should format URL meta filters with all supported string operations", () => {
const testCases = [
{ filterValue: "Equals", expected: { op: "equals", value: "https://example.com" } },
{ filterValue: "Not equals", expected: { op: "notEquals", value: "https://example.com" } },
{ filterValue: "Contains", expected: { op: "contains", value: "example.com" } },
{ filterValue: "Does not contain", expected: { op: "doesNotContain", value: "test.com" } },
{ filterValue: "Starts with", expected: { op: "startsWith", value: "https://" } },
{ filterValue: "Does not start with", expected: { op: "doesNotStartWith", value: "http://" } },
{ filterValue: "Ends with", expected: { op: "endsWith", value: ".com" } },
{ filterValue: "Does not end with", expected: { op: "doesNotEndWith", value: ".org" } },
];
testCases.forEach(({ filterValue, expected }) => {
const selectedFilter = {
responseStatus: "all",
filter: [
{
questionType: { type: "Meta", label: "url", id: "url" },
filterType: { filterValue, filterComboBoxValue: expected.value },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, dateRange);
expect(result.meta?.url).toEqual(expected);
});
});
test("should handle URL meta filters with empty string values", () => {
const selectedFilter = {
responseStatus: "all",
filter: [
{
questionType: { type: "Meta", label: "url", id: "url" },
filterType: { filterValue: "Contains", filterComboBoxValue: "" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, dateRange);
expect(result.meta?.url).toBeUndefined();
});
test("should handle URL meta filters with whitespace-only values", () => {
const selectedFilter = {
responseStatus: "all",
filter: [
{
questionType: { type: "Meta", label: "url", id: "url" },
filterType: { filterValue: "Contains", filterComboBoxValue: " " },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, dateRange);
expect(result.meta?.url).toEqual({ op: "contains", value: "" });
});
test("should still handle existing meta filters with array values", () => {
const selectedFilter = {
responseStatus: "all",
filter: [
{
questionType: { type: "Meta", label: "source", id: "source" },
filterType: { filterValue: "Equals", filterComboBoxValue: ["google"] },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, dateRange);
expect(result.meta?.source).toEqual({ op: "equals", value: "google" });
});
test("should handle mixed URL and traditional meta filters", () => {
const selectedFilter = {
responseStatus: "all",
filter: [
{
questionType: { type: "Meta", label: "url", id: "url" },
filterType: { filterValue: "Contains", filterComboBoxValue: "formbricks.com" },
},
{
questionType: { type: "Meta", label: "source", id: "source" },
filterType: { filterValue: "Equals", filterComboBoxValue: ["newsletter"] },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, dateRange);
expect(result.meta?.url).toEqual({ op: "contains", value: "formbricks.com" });
expect(result.meta?.source).toEqual({ op: "equals", value: "newsletter" });
});
});
describe("getTodayDate", () => {

View File

@@ -47,18 +47,6 @@ const filterOptions = {
ranking: ["Filled out", "Skipped"],
};
// URL/meta text operators mapping
const META_OP_MAP = {
Equals: "equals",
"Not equals": "notEquals",
Contains: "contains",
"Does not contain": "doesNotContain",
"Starts with": "startsWith",
"Does not start with": "doesNotStartWith",
"Ends with": "endsWith",
"Does not end with": "doesNotEndWith",
} as const;
// creating the options for the filtering to be selected there are 4 types questions, attributes, tags and metadata
export const generateQuestionAndFilterOptions = (
survey: TSurvey,
@@ -177,7 +165,7 @@ export const generateQuestionAndFilterOptions = (
Object.keys(meta).forEach((m) => {
questionFilterOptions.push({
type: "Meta",
filterOptions: m === "url" ? Object.keys(META_OP_MAP) : ["Equals", "Not equals"],
filterOptions: ["Equals", "Not equals"],
filterComboBoxOptions: meta[m],
id: m,
});
@@ -493,23 +481,17 @@ export const getFormattedFilters = (
if (meta.length) {
meta.forEach(({ filterType, questionType }) => {
if (!filters.meta) filters.meta = {};
// For text input cases (URL filtering)
if (typeof filterType.filterComboBoxValue === "string" && filterType.filterComboBoxValue.length > 0) {
const value = filterType.filterComboBoxValue.trim();
const op = META_OP_MAP[filterType.filterValue as keyof typeof META_OP_MAP];
if (op) {
filters.meta[questionType.label ?? ""] = { op, value };
}
}
// For dropdown/select cases (existing metadata fields)
else if (Array.isArray(filterType.filterComboBoxValue) && filterType.filterComboBoxValue.length > 0) {
const value = filterType.filterComboBoxValue[0]; // Take first selected value
if (filterType.filterValue === "Equals") {
filters.meta[questionType.label ?? ""] = { op: "equals", value };
} else if (filterType.filterValue === "Not equals") {
filters.meta[questionType.label ?? ""] = { op: "notEquals", value };
}
if (!filterType.filterComboBoxValue) return;
if (filterType.filterValue === "Equals") {
filters.meta[questionType.label ?? ""] = {
op: "equals",
value: filterType.filterComboBoxValue as string,
};
} else if (filterType.filterValue === "Not equals") {
filters.meta[questionType.label ?? ""] = {
op: "notEquals",
value: filterType.filterComboBoxValue as string,
};
}
});
}

View File

@@ -118,6 +118,7 @@ describe("Page", () => {
name: "Test User",
email: "test@example.com",
emailVerified: null,
imageUrl: null,
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
@@ -160,6 +161,7 @@ describe("Page", () => {
name: "Test User",
email: "test@example.com",
emailVerified: null,
imageUrl: null,
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
@@ -248,6 +250,7 @@ describe("Page", () => {
name: "Test User",
email: "test@example.com",
emailVerified: null,
imageUrl: null,
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
@@ -336,6 +339,7 @@ describe("Page", () => {
name: "Test User",
email: "test@example.com",
emailVerified: null,
imageUrl: null,
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),

View File

@@ -157,46 +157,6 @@ describe("Response Utils", () => {
},
]);
});
test("meta: URL string comparison operations", () => {
const testCases = [
{
name: "contains",
criteria: { meta: { url: { op: "contains" as const, value: "example.com" } } },
expected: { meta: { path: ["url"], string_contains: "example.com" } },
},
{
name: "doesNotContain",
criteria: { meta: { url: { op: "doesNotContain" as const, value: "test.com" } } },
expected: { NOT: { meta: { path: ["url"], string_contains: "test.com" } } },
},
{
name: "startsWith",
criteria: { meta: { url: { op: "startsWith" as const, value: "https://" } } },
expected: { meta: { path: ["url"], string_starts_with: "https://" } },
},
{
name: "doesNotStartWith",
criteria: { meta: { url: { op: "doesNotStartWith" as const, value: "http://" } } },
expected: { NOT: { meta: { path: ["url"], string_starts_with: "http://" } } },
},
{
name: "endsWith",
criteria: { meta: { url: { op: "endsWith" as const, value: ".com" } } },
expected: { meta: { path: ["url"], string_ends_with: ".com" } },
},
{
name: "doesNotEndWith",
criteria: { meta: { url: { op: "doesNotEndWith" as const, value: ".org" } } },
expected: { NOT: { meta: { path: ["url"], string_ends_with: ".org" } } },
},
];
testCases.forEach(({ criteria, expected }) => {
const result = buildWhereClause(baseSurvey as TSurvey, criteria);
expect(result.AND).toEqual([{ AND: [expected] }]);
});
});
});
describe("buildWhereClause datafield filter operations", () => {
@@ -535,98 +495,10 @@ describe("Response Utils", () => {
expect(result.os).toContain("MacOS");
});
test("should extract URL data correctly", () => {
const responses = [
{
contactAttributes: {},
data: {},
meta: {
url: "https://example.com/page1",
source: "direct",
},
},
{
contactAttributes: {},
data: {},
meta: {
url: "https://test.com/page2?param=value",
source: "google",
},
},
];
const result = getResponseMeta(responses as Pick<TResponse, "contactAttributes" | "data" | "meta">[]);
expect(result.url).toEqual([]);
expect(result.source).toContain("direct");
expect(result.source).toContain("google");
});
test("should handle mixed meta data with URLs", () => {
const responses = [
{
contactAttributes: {},
data: {},
meta: {
userAgent: { browser: "Chrome", device: "desktop" },
url: "https://formbricks.com/dashboard",
country: "US",
},
},
{
contactAttributes: {},
data: {},
meta: {
userAgent: { browser: "Safari", device: "mobile" },
url: "https://formbricks.com/surveys/123",
country: "UK",
},
},
];
const result = getResponseMeta(responses as Pick<TResponse, "contactAttributes" | "data" | "meta">[]);
expect(result.browser).toContain("Chrome");
expect(result.browser).toContain("Safari");
expect(result.device).toContain("desktop");
expect(result.device).toContain("mobile");
expect(result.url).toEqual([]);
expect(result.country).toContain("US");
expect(result.country).toContain("UK");
});
test("should handle empty responses", () => {
const result = getResponseMeta([]);
expect(result).toEqual({});
});
test("should ignore empty or null URL values", () => {
const responses = [
{
contactAttributes: {},
data: {},
meta: {
url: "",
source: "direct",
},
},
{
contactAttributes: {},
data: {},
meta: {
url: null as any,
source: "newsletter",
},
},
{
contactAttributes: {},
data: {},
meta: {
url: "https://valid.com",
source: "google",
},
},
];
const result = getResponseMeta(responses as Pick<TResponse, "contactAttributes" | "data" | "meta">[]);
expect(result.url).toEqual([]);
expect(result.source).toEqual(expect.arrayContaining(["direct", "newsletter", "google"]));
});
});
describe("getResponseHiddenFields", () => {

View File

@@ -234,60 +234,6 @@ export const buildWhereClause = (survey: TSurvey, filterCriteria?: TResponseFilt
},
});
break;
case "contains":
meta.push({
meta: {
path: updatedKey,
string_contains: val.value,
},
});
break;
case "doesNotContain":
meta.push({
NOT: {
meta: {
path: updatedKey,
string_contains: val.value,
},
},
});
break;
case "startsWith":
meta.push({
meta: {
path: updatedKey,
string_starts_with: val.value,
},
});
break;
case "doesNotStartWith":
meta.push({
NOT: {
meta: {
path: updatedKey,
string_starts_with: val.value,
},
},
});
break;
case "endsWith":
meta.push({
meta: {
path: updatedKey,
string_ends_with: val.value,
},
});
break;
case "doesNotEndWith":
meta.push({
NOT: {
meta: {
path: updatedKey,
string_ends_with: val.value,
},
},
});
break;
}
});
@@ -625,7 +571,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.label, "default")}`;
return `${idx + 1}. ${headline} - ${getLocalizedValue(row, "default")}`;
});
} else if (
question.type === "multipleChoiceMulti" ||
@@ -692,8 +638,8 @@ export const getResponsesJson = (
questionHeadline.forEach((headline, index) => {
if (answer) {
const row = question.rows[index];
if (row && row.label.default && answer[row.label.default] !== undefined) {
jsonData[idx][headline] = answer[row.label.default];
if (row && row.default && answer[row.default] !== undefined) {
jsonData[idx][headline] = answer[row.default];
} else {
jsonData[idx][headline] = "";
}
@@ -780,13 +726,10 @@ export const getResponseMeta = (
responses.forEach((response) => {
Object.entries(response.meta).forEach(([key, value]) => {
// skip url
if (key === "url") return;
// Handling nested objects (like userAgent)
if (key === "url") {
if (!meta[key]) {
meta[key] = new Set();
}
return;
}
if (typeof value === "object" && value !== null) {
Object.entries(value).forEach(([nestedKey, nestedValue]) => {
if (typeof nestedValue === "string" && nestedValue) {

View File

@@ -122,6 +122,7 @@ export const mockUser: TUser = {
name: "mock User",
email: "test@unit.com",
emailVerified: currentDate,
imageUrl: "https://www.google.com",
createdAt: currentDate,
updatedAt: currentDate,
twoFactorEnabled: false,

View File

@@ -1,12 +1,12 @@
import { describe, expect, test, vi } from "vitest";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TResponseData, TResponseVariables } from "@formbricks/types/responses";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import {
TConditionGroup,
TSingleCondition,
TSurveyLogic,
TSurveyLogicAction,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import {
addConditionBelow,
@@ -109,6 +109,7 @@ describe("surveyLogic", () => {
languages: [],
triggers: [],
segment: null,
recaptcha: null,
};
const simpleGroup = (): TConditionGroup => ({
@@ -175,7 +176,8 @@ describe("surveyLogic", () => {
},
],
};
removeCondition(group, "c");
const result = removeCondition(group, "c");
expect(result).toBe(true);
expect(group.conditions).toHaveLength(0);
});
@@ -433,6 +435,8 @@ describe("surveyLogic", () => {
)
).toBe(true);
expect(evaluateLogic(mockSurvey, { f: "foo" }, vars, group(baseCond("isSet")), "en")).toBe(true);
expect(evaluateLogic(mockSurvey, { f: "foo" }, vars, group(baseCond("isNotEmpty")), "en")).toBe(true);
expect(evaluateLogic(mockSurvey, { f: "" }, vars, group(baseCond("isNotSet")), "en")).toBe(true);
expect(evaluateLogic(mockSurvey, { f: "" }, vars, group(baseCond("isEmpty")), "en")).toBe(true);
expect(
evaluateLogic(mockSurvey, { f: "foo" }, vars, group({ ...baseCond("isAnyOf", ["foo", "bar"]) }), "en")
@@ -510,7 +514,8 @@ describe("surveyLogic", () => {
expect(group.conditions.length).toBe(2);
toggleGroupConnector(group, "notfound");
expect(group.connector).toBe("and");
removeCondition(group, "notfound");
const result = removeCondition(group, "notfound");
expect(result).toBe(false);
expect(group.conditions.length).toBe(2);
duplicateCondition(group, "notfound");
expect(group.conditions.length).toBe(2);
@@ -520,6 +525,192 @@ describe("surveyLogic", () => {
expect(group.conditions.length).toBe(2);
});
test("removeCondition returns false when condition not found in nested groups", () => {
const nestedGroup: TConditionGroup = {
id: "nested",
connector: "and",
conditions: [
{
id: "nestedC1",
leftOperand: { type: "hiddenField", value: "nf1" },
operator: "equals",
rightOperand: { type: "static", value: "nv1" },
},
],
};
const group: TConditionGroup = {
id: "parent",
connector: "and",
conditions: [nestedGroup],
};
const result = removeCondition(group, "nonexistent");
expect(result).toBe(false);
expect(group.conditions).toHaveLength(1);
});
test("removeCondition successfully removes from nested groups and cleans up", () => {
const nestedGroup: TConditionGroup = {
id: "nested",
connector: "and",
conditions: [
{
id: "nestedC1",
leftOperand: { type: "hiddenField", value: "nf1" },
operator: "equals",
rightOperand: { type: "static", value: "nv1" },
},
{
id: "nestedC2",
leftOperand: { type: "hiddenField", value: "nf2" },
operator: "equals",
rightOperand: { type: "static", value: "nv2" },
},
],
};
const otherCondition: TSingleCondition = {
id: "otherCondition",
leftOperand: { type: "hiddenField", value: "other" },
operator: "equals",
rightOperand: { type: "static", value: "value" },
};
const group: TConditionGroup = {
id: "parent",
connector: "and",
conditions: [nestedGroup, otherCondition],
};
const result = removeCondition(group, "nestedC1");
expect(result).toBe(true);
expect(group.conditions).toHaveLength(2);
expect((group.conditions[0] as TConditionGroup).conditions).toHaveLength(1);
expect((group.conditions[0] as TConditionGroup).conditions[0].id).toBe("nestedC2");
expect(group.conditions[1].id).toBe("otherCondition");
});
test("removeCondition flattens group when nested group has only one condition left", () => {
const deeplyNestedGroup: TConditionGroup = {
id: "deepNested",
connector: "or",
conditions: [
{
id: "deepC1",
leftOperand: { type: "hiddenField", value: "df1" },
operator: "equals",
rightOperand: { type: "static", value: "dv1" },
},
],
};
const nestedGroup: TConditionGroup = {
id: "nested",
connector: "and",
conditions: [
{
id: "nestedC1",
leftOperand: { type: "hiddenField", value: "nf1" },
operator: "equals",
rightOperand: { type: "static", value: "nv1" },
},
deeplyNestedGroup,
],
};
const otherCondition: TSingleCondition = {
id: "otherCondition",
leftOperand: { type: "hiddenField", value: "other" },
operator: "equals",
rightOperand: { type: "static", value: "value" },
};
const group: TConditionGroup = {
id: "parent",
connector: "and",
conditions: [nestedGroup, otherCondition],
};
// Remove the regular condition, leaving only the deeply nested group in the nested group
const result = removeCondition(group, "nestedC1");
expect(result).toBe(true);
// The parent group should still have 2 conditions: the nested group and the other condition
expect(group.conditions).toHaveLength(2);
// The nested group should still be there but now contain only the deeply nested group
expect(group.conditions[0].id).toBe("nested");
expect((group.conditions[0] as TConditionGroup).conditions).toHaveLength(1);
// The nested group should contain the flattened content from the deeply nested group
expect((group.conditions[0] as TConditionGroup).conditions[0].id).toBe("deepC1");
expect(group.conditions[1].id).toBe("otherCondition");
});
test("removeCondition removes empty groups after cleanup", () => {
const emptyNestedGroup: TConditionGroup = {
id: "emptyNested",
connector: "and",
conditions: [
{
id: "toBeRemoved",
leftOperand: { type: "hiddenField", value: "f1" },
operator: "equals",
rightOperand: { type: "static", value: "v1" },
},
],
};
const group: TConditionGroup = {
id: "parent",
connector: "and",
conditions: [
emptyNestedGroup,
{
id: "keepThis",
leftOperand: { type: "hiddenField", value: "f2" },
operator: "equals",
rightOperand: { type: "static", value: "v2" },
},
],
};
// Remove the only condition from the nested group
const result = removeCondition(group, "toBeRemoved");
expect(result).toBe(true);
// The empty nested group should be removed, leaving only the other condition
expect(group.conditions).toHaveLength(1);
expect(group.conditions[0].id).toBe("keepThis");
});
test("deleteEmptyGroups with complex nested structure", () => {
const deepEmptyGroup: TConditionGroup = { id: "deepEmpty", connector: "and", conditions: [] };
const middleGroup: TConditionGroup = {
id: "middle",
connector: "or",
conditions: [deepEmptyGroup],
};
const topGroup: TConditionGroup = {
id: "top",
connector: "and",
conditions: [
middleGroup,
{
id: "validCondition",
leftOperand: { type: "hiddenField", value: "f" },
operator: "equals",
rightOperand: { type: "static", value: "v" },
},
],
};
deleteEmptyGroups(topGroup);
// Should remove the nested empty groups and keep only the valid condition
expect(topGroup.conditions).toHaveLength(1);
expect(topGroup.conditions[0].id).toBe("validCondition");
});
// Additional tests for complete coverage
test("addConditionBelow with nested group correctly adds condition", () => {
@@ -597,14 +788,8 @@ describe("surveyLogic", () => {
type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Matrix Question" },
required: true,
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" } },
],
rows: [{ default: "Row 1" }, { default: "Row 2" }],
columns: [{ default: "Column 1" }, { default: "Column 2" }],
buttonLabel: { default: "Next" },
shuffleOption: "none",
},

View File

@@ -94,21 +94,48 @@ export const toggleGroupConnector = (group: TConditionGroup, resourceId: string)
}
};
export const removeCondition = (group: TConditionGroup, resourceId: string) => {
for (let i = 0; i < group.conditions.length; i++) {
export const removeCondition = (group: TConditionGroup, resourceId: string): boolean => {
for (let i = group.conditions.length - 1; i >= 0; i--) {
const item = group.conditions[i];
if (item.id === resourceId) {
group.conditions.splice(i, 1);
return;
cleanupGroup(group);
return true;
}
if (isConditionGroup(item)) {
removeCondition(item, resourceId);
if (isConditionGroup(item) && removeCondition(item, resourceId)) {
cleanupGroup(group);
return true;
}
}
deleteEmptyGroups(group);
return false;
};
const cleanupGroup = (group: TConditionGroup) => {
// Remove empty condition groups first
for (let i = group.conditions.length - 1; i >= 0; i--) {
const condition = group.conditions[i];
if (isConditionGroup(condition)) {
cleanupGroup(condition);
// Remove if empty after cleanup
if (condition.conditions.length === 0) {
group.conditions.splice(i, 1);
}
}
}
// Flatten if group has only one condition and it's a condition group
if (group.conditions.length === 1 && isConditionGroup(group.conditions[0])) {
group.connector = group.conditions[0].connector || "and";
group.conditions = group.conditions[0].conditions;
}
};
export const deleteEmptyGroups = (group: TConditionGroup) => {
cleanupGroup(group);
};
export const duplicateCondition = (group: TConditionGroup, resourceId: string) => {
@@ -130,18 +157,6 @@ export const duplicateCondition = (group: TConditionGroup, resourceId: string) =
}
};
export const deleteEmptyGroups = (group: TConditionGroup) => {
for (let i = 0; i < group.conditions.length; i++) {
const resource = group.conditions[i];
if (isConditionGroup(resource) && resource.conditions.length === 0) {
group.conditions.splice(i, 1);
} else if (isConditionGroup(resource)) {
deleteEmptyGroups(resource);
}
}
};
export const createGroupFromResource = (group: TConditionGroup, resourceId: string) => {
for (let i = 0; i < group.conditions.length; i++) {
const item = group.conditions[i];
@@ -502,11 +517,7 @@ const getLeftOperandValue = (
const responseValue = data[leftOperand.value];
if (currentQuestion.type === "openText" && currentQuestion.inputType === "number") {
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;
return Number(responseValue) || undefined;
}
if (currentQuestion.type === "multipleChoiceSingle" || currentQuestion.type === "multipleChoiceMulti") {
@@ -556,14 +567,14 @@ const getLeftOperandValue = (
if (isNaN(rowIndex) || rowIndex < 0 || rowIndex >= currentQuestion.rows.length) {
return undefined;
}
const row = getLocalizedValue(currentQuestion.rows[rowIndex].label, selectedLanguage);
const row = getLocalizedValue(currentQuestion.rows[rowIndex], selectedLanguage);
const rowValue = responseValue[row];
if (rowValue === "") return "";
if (rowValue) {
const columnIndex = currentQuestion.columns.findIndex((column) => {
return getLocalizedValue(column.label, selectedLanguage) === rowValue;
return getLocalizedValue(column, selectedLanguage) === rowValue;
});
if (columnIndex === -1) return undefined;
return columnIndex.toString();
@@ -674,8 +685,9 @@ const performCalculation = (
if (typeof val === "number" || typeof val === "string") {
if (variable.type === "number" && !isNaN(Number(val))) {
operandValue = Number(val);
} else {
operandValue = val;
}
operandValue = val;
}
break;
}

View File

@@ -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, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError, InvalidInputError, 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,6 +20,10 @@ vi.mock("@formbricks/database", () => ({
},
}));
vi.mock("@/lib/fileValidation", () => ({
isValidImageFile: vi.fn(),
}));
vi.mock("@/lib/organization/service", () => ({
getOrganizationsWhereUserIsSingleOwner: vi.fn(),
deleteOrganization: vi.fn(),
@@ -35,6 +39,7 @@ 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,
@@ -195,6 +200,13 @@ 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", () => {

View File

@@ -1,4 +1,5 @@
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";
@@ -7,7 +8,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, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TUser, TUserLocale, TUserUpdateInput, ZUserUpdateInput } from "@formbricks/types/user";
import { validateInputs } from "../utils/validate";
@@ -16,6 +17,7 @@ const responseSelection = {
name: true,
email: true,
emailVerified: true,
imageUrl: true,
createdAt: true,
updatedAt: true,
role: true,
@@ -77,6 +79,7 @@ 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({

View File

@@ -112,6 +112,7 @@ describe("withAuditLogging", () => {
name: "Test User",
email: "test@example.com",
emailVerified: null,
imageUrl: null,
twoFactorEnabled: false,
identityProvider: "email" as const,
createdAt: new Date(),
@@ -150,6 +151,7 @@ describe("withAuditLogging", () => {
name: "Test User",
email: "test@example.com",
emailVerified: null,
imageUrl: null,
twoFactorEnabled: false,
identityProvider: "email" as const,
createdAt: new Date(),

2865
apps/web/locales/ja-JP.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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["default"]),
getLocalizedValue: vi.fn((val, _) => val),
getLanguageCode: vi.fn().mockReturnValue("default"),
}));
@@ -178,18 +178,7 @@ describe("RenderResponse", () => {
});
test("renders Matrix response", () => {
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;
const question = { id: "q1", type: "matrix", rows: ["row1", "row2"] } as any;
// getLocalizedValue returns the row value itself
const responseData = { row1: "answer1", row2: "answer2" };
render(

View File

@@ -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.label, languagCode);
const rowValueInSelectedLanguage = getLocalizedValue(row, languagCode);
if (!responseData[rowValueInSelectedLanguage]) return null;
return (
<p

View File

@@ -1,7 +1,6 @@
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";
@@ -9,7 +8,7 @@ export const GET = async (request: NextRequest) =>
authenticatedApiClient({
request,
handler: async ({ authentication }) => {
if (!hasOrganizationAccess(authentication, OrganizationAccessType.Read)) {
if (!authentication.organizationAccess?.accessControl?.[OrganizationAccessType.Read]) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],

View File

@@ -1,4 +1,3 @@
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";
@@ -14,5 +13,9 @@ export const hasOrganizationIdAndAccess = (
return false;
}
return hasOrganizationAccess(authentication, accessType);
if (!authentication.organizationAccess?.accessControl?.[accessType]) {
return false;
}
return true;
};

View File

@@ -10,12 +10,8 @@ export const FormWrapper = ({ children }: FormWrapperProps) => {
<div className="mx-auto flex flex-1 flex-col justify-center px-4 py-12 sm:px-6 lg:flex-none lg:px-20 xl:px-24">
<div className="mx-auto w-full max-w-sm rounded-xl bg-white p-8 shadow-xl lg:w-96">
<div className="mb-8 text-center">
<Link
target="_blank"
href="https://formbricks.com?utm_source=ce"
rel="noopener noreferrer"
aria-label="Formbricks website">
<Logo className="mx-auto w-3/4" variant="wordmark" aria-hidden="true" />
<Link target="_blank" href="https://formbricks.com?utm_source=ce" rel="noopener noreferrer">
<Logo className="mx-auto w-3/4" />
</Link>
</div>
{children}

View File

@@ -148,6 +148,7 @@ describe("authOptions", () => {
email: mockUser.email,
password: mockHashedPassword,
emailVerified: new Date(),
imageUrl: "http://example.com/avatar.png",
twoFactorEnabled: false,
};
@@ -160,6 +161,7 @@ describe("authOptions", () => {
id: fakeUser.id,
email: fakeUser.email,
emailVerified: fakeUser.emailVerified,
imageUrl: fakeUser.imageUrl,
});
});

View File

@@ -206,6 +206,7 @@ export const authOptions: NextAuthOptions = {
id: user.id,
email: user.email,
emailVerified: user.emailVerified,
imageUrl: user.imageUrl,
};
},
}),

View File

@@ -5,6 +5,7 @@ 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,

View File

@@ -1,3 +1,4 @@
import { isValidImageFile } from "@/lib/fileValidation";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
@@ -10,6 +11,10 @@ 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: {

View File

@@ -71,7 +71,7 @@ describe("rateLimitConfigs", () => {
test("should have all action configurations", () => {
const actionConfigs = Object.keys(rateLimitConfigs.actions);
expect(actionConfigs).toEqual(["emailUpdate", "surveyFollowUp", "sendLinkSurveyEmail"]);
expect(actionConfigs).toEqual(["emailUpdate", "surveyFollowUp"]);
});
});

View File

@@ -23,10 +23,5 @@ 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
},
};

View File

@@ -94,6 +94,7 @@ const fullUser = {
updatedAt: new Date(),
email: "test@example.com",
emailVerified: null,
imageUrl: null,
twoFactorEnabled: false,
identityProvider: "email",
organizationId: "org1",

View File

@@ -28,6 +28,7 @@ describe("ResponseTimeline", () => {
name: "Test User",
createdAt: new Date(),
updatedAt: new Date(),
imageUrl: null,
objective: null,
role: "founder",
email: "test@example.com",

View File

@@ -51,6 +51,7 @@ export const getSSOProviders = () => [
id: profile.sub,
name: profile.name,
email: profile.email,
image: profile.picture,
};
},
},
@@ -75,6 +76,7 @@ export const getSSOProviders = () => [
id: profile.id,
email: profile.email,
name: [profile.firstName, profile.lastName].filter(Boolean).join(" "),
image: null,
};
},
options: {

View File

@@ -13,6 +13,7 @@ export const mockUser: TUser = {
unsubscribedOrganizationIds: [],
},
emailVerified: new Date(),
imageUrl: "https://example.com/image.png",
twoFactorEnabled: false,
identityProvider: "google",
locale: "en-US",

View File

@@ -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={column.id}>
{getLocalizedValue(column.label, "default")}
key={getLocalizedValue(column, "default")}>
{getLocalizedValue(column, "default")}
</Column>
);
})}
@@ -390,13 +390,15 @@ export async function PreviewEmailTemplate({
return (
<Row
className={`${rowIndex % 2 === 0 ? "bg-input-color" : ""} rounded-custom`}
key={row.id}>
key={getLocalizedValue(row, "default")}>
<Column className="w-40 break-words px-4 py-2">
{getLocalizedValue(row.label, "default")}
{getLocalizedValue(row, "default")}
</Column>
{firstQuestion.columns.map((column) => {
{firstQuestion.columns.map((_) => {
return (
<Column className="text-question-color px-4 py-2" key={column.id}>
<Column
className="text-question-color px-4 py-2"
key={getLocalizedValue(_, "default")}>
<Section className="bg-card-bg-color h-4 w-4 rounded-full p-2 outline" />
</Column>
);

View File

@@ -149,10 +149,10 @@ describe("AddApiKeyModal", () => {
test("handles label input", async () => {
render(<AddApiKeyModal {...defaultProps} />);
const labelInput = screen.getByPlaceholderText("e.g. GitHub, PostHog, Slack");
const labelInput = screen.getByPlaceholderText("e.g. GitHub, PostHog, Slack") as HTMLInputElement;
await userEvent.type(labelInput, "Test API Key");
expect((labelInput as HTMLInputElement).value).toBe("Test API Key");
expect(labelInput.value).toBe("Test API Key");
});
test("handles permission changes", async () => {
@@ -184,120 +184,21 @@ describe("AddApiKeyModal", () => {
await userEvent.click(addButton);
// Verify new permission row is added
const deleteButtons = await screen.findAllByRole("button", {
name: "environments.project.api_keys.delete_permission",
});
const deleteButtons = screen.getAllByRole("button", { name: "" }); // Trash icons
expect(deleteButtons).toHaveLength(2);
// Remove the new permission
await userEvent.click(deleteButtons[1]);
// Check that only the original permission row remains
const remainingDeleteButtons = await screen.findAllByRole("button", {
name: "environments.project.api_keys.delete_permission",
});
expect(remainingDeleteButtons).toHaveLength(1);
});
test("removes permissions from middle of list without breaking indices", async () => {
render(<AddApiKeyModal {...defaultProps} />);
// Add first permission
const addButton = screen.getByRole("button", { name: /add_permission/i });
await userEvent.click(addButton);
// Add second permission
await userEvent.click(addButton);
// Add third permission
await userEvent.click(addButton);
// Verify we have 3 permission rows
let deleteButtons = await screen.findAllByRole("button", {
name: "environments.project.api_keys.delete_permission",
});
expect(deleteButtons).toHaveLength(3);
// Remove the middle permission (index 1)
await userEvent.click(deleteButtons[1]);
// Verify we now have 2 permission rows
deleteButtons = await screen.findAllByRole("button", {
name: "environments.project.api_keys.delete_permission",
});
expect(deleteButtons).toHaveLength(2);
// Try to remove the second remaining permission (this was previously index 2, now index 1)
await userEvent.click(deleteButtons[1]);
// Verify we now have 1 permission row
deleteButtons = await screen.findAllByRole("button", {
name: "environments.project.api_keys.delete_permission",
});
expect(deleteButtons).toHaveLength(1);
// Remove the last remaining permission
await userEvent.click(deleteButtons[0]);
// Verify no permission rows remain
expect(
screen.queryAllByRole("button", { name: "environments.project.api_keys.delete_permission" })
).toHaveLength(0);
});
test("can modify permissions after deleting items from list", async () => {
render(<AddApiKeyModal {...defaultProps} />);
// Add multiple permissions
const addButton = screen.getByRole("button", { name: /add_permission/i });
await userEvent.click(addButton); // First permission
await userEvent.click(addButton); // Second permission
await userEvent.click(addButton); // Third permission
// Verify we have 3 permission rows
let deleteButtons = await screen.findAllByRole("button", {
name: "environments.project.api_keys.delete_permission",
});
expect(deleteButtons).toHaveLength(3);
// Remove the first permission (index 0)
await userEvent.click(deleteButtons[0]);
// Verify we now have 2 permission rows
deleteButtons = await screen.findAllByRole("button", {
name: "environments.project.api_keys.delete_permission",
});
expect(deleteButtons).toHaveLength(2);
// Try to modify the first remaining permission (which was originally index 1, now index 0)
const projectDropdowns = screen.getAllByRole("button", { name: /Project 1/i });
expect(projectDropdowns.length).toBeGreaterThan(0);
await userEvent.click(projectDropdowns[0]);
// Wait for dropdown content and select 'Project 2'
const project2Option = await screen.findByRole("menuitem", { name: "Project 2" });
await userEvent.click(project2Option);
// Verify project selection by checking the updated button text
const updatedButton = await screen.findByRole("button", { name: "Project 2" });
expect(updatedButton).toBeInTheDocument();
// Add another permission to verify the list is still functional
await userEvent.click(addButton);
// Verify we now have 3 permission rows again
deleteButtons = await screen.findAllByRole("button", {
name: "environments.project.api_keys.delete_permission",
});
expect(deleteButtons).toHaveLength(3);
expect(screen.getAllByRole("button", { name: "" })).toHaveLength(1);
});
test("submits form with correct data", async () => {
render(<AddApiKeyModal {...defaultProps} />);
// Fill in label
const labelInput = screen.getByPlaceholderText("e.g. GitHub, PostHog, Slack");
const labelInput = screen.getByPlaceholderText("e.g. GitHub, PostHog, Slack") as HTMLInputElement;
await userEvent.type(labelInput, "Test API Key");
const addButton = screen.getByRole("button", { name: /add_permission/i });
@@ -377,7 +278,7 @@ describe("AddApiKeyModal", () => {
render(<AddApiKeyModal {...defaultProps} />);
// Type something into the label
const labelInput = screen.getByPlaceholderText("e.g. GitHub, PostHog, Slack");
const labelInput = screen.getByPlaceholderText("e.g. GitHub, PostHog, Slack") as HTMLInputElement;
await userEvent.type(labelInput, "Test API Key");
// Click the cancel button
@@ -386,219 +287,6 @@ describe("AddApiKeyModal", () => {
// Verify modal is closed and form is reset
expect(mockSetOpen).toHaveBeenCalledWith(false);
expect((labelInput as HTMLInputElement).value).toBe("");
});
test("updates permission field (non-environmentId)", async () => {
render(<AddApiKeyModal {...defaultProps} />);
// Add a permission first
const addButton = screen.getByRole("button", { name: /add_permission/i });
await userEvent.click(addButton);
// Click on permission level dropdown (third dropdown in the row)
const permissionDropdowns = screen.getAllByRole("button", { name: /read/i });
await userEvent.click(permissionDropdowns[0]);
// Select 'write' permission
const writeOption = await screen.findByRole("menuitem", { name: "write" });
await userEvent.click(writeOption);
// Verify permission selection by checking the updated button text
const updatedButton = await screen.findByRole("button", { name: "write" });
expect(updatedButton).toBeInTheDocument();
});
test("updates environmentId with valid environment", async () => {
render(<AddApiKeyModal {...defaultProps} />);
// Add a permission first
const addButton = screen.getByRole("button", { name: /add_permission/i });
await userEvent.click(addButton);
// Click on environment dropdown (second dropdown in the row)
const environmentDropdowns = screen.getAllByRole("button", { name: /production/i });
await userEvent.click(environmentDropdowns[0]);
// Select 'development' environment
const developmentOption = await screen.findByRole("menuitem", { name: "development" });
await userEvent.click(developmentOption);
// Verify environment selection by checking the updated button text
const updatedButton = await screen.findByRole("button", { name: "development" });
expect(updatedButton).toBeInTheDocument();
});
test("updates project and automatically selects first environment", async () => {
render(<AddApiKeyModal {...defaultProps} />);
// Add a permission first
const addButton = screen.getByRole("button", { name: /add_permission/i });
await userEvent.click(addButton);
// Initially should show Project 1 and production environment
expect(screen.getByRole("button", { name: "Project 1" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /production/i })).toBeInTheDocument();
// Click on project dropdown (first dropdown in the row)
const projectDropdowns = screen.getAllByRole("button", { name: /Project 1/i });
await userEvent.click(projectDropdowns[0]);
// Select 'Project 2'
const project2Option = await screen.findByRole("menuitem", { name: "Project 2" });
await userEvent.click(project2Option);
// Verify project selection and that environment was auto-updated
const updatedProjectButton = await screen.findByRole("button", { name: "Project 2" });
expect(updatedProjectButton).toBeInTheDocument();
// Environment should still be production (first environment of Project 2)
expect(screen.getByRole("button", { name: /production/i })).toBeInTheDocument();
});
test("handles edge case when project is not found", async () => {
// Create a modified mock with corrupted project reference
const corruptedProjects = [
{
...mockProjects[0],
id: "different-id", // This will cause project lookup to fail
},
];
render(<AddApiKeyModal {...defaultProps} projects={corruptedProjects} />);
// Add a permission first
const addButton = screen.getByRole("button", { name: /add_permission/i });
await userEvent.click(addButton);
// The component should still render without crashing
expect(screen.getByRole("button", { name: /add_permission/i })).toBeInTheDocument();
// Try to interact with environment dropdown - should not crash
const environmentDropdowns = screen.getAllByRole("button", { name: /production/i });
await userEvent.click(environmentDropdowns[0]);
// Should be able to find and click on development option
const developmentOption = await screen.findByRole("menuitem", { name: "development" });
await userEvent.click(developmentOption);
// Verify environment selection works even when project lookup fails
const updatedButton = await screen.findByRole("button", { name: "development" });
expect(updatedButton).toBeInTheDocument();
});
test("handles edge case when environment is not found", async () => {
// Create a project with no environments
const projectWithNoEnvs = [
{
...mockProjects[0],
environments: [], // No environments available
},
];
render(<AddApiKeyModal {...defaultProps} projects={projectWithNoEnvs} />);
// Try to add a permission - this should handle the case gracefully
const addButton = screen.getByRole("button", { name: /add_permission/i });
// This might not add a permission if no environments exist, which is expected behavior
await userEvent.click(addButton);
// Component should still be functional
expect(screen.getByRole("button", { name: /add_permission/i })).toBeInTheDocument();
});
test("validates duplicate permissions detection", async () => {
render(<AddApiKeyModal {...defaultProps} />);
// Fill in a label
const labelInput = screen.getByPlaceholderText("e.g. GitHub, PostHog, Slack");
await userEvent.type(labelInput, "Test API Key");
// Add first permission
const addButton = screen.getByRole("button", { name: /add_permission/i });
await userEvent.click(addButton);
// Add second permission with same project/environment
await userEvent.click(addButton);
// Both permissions should now have the same project and environment (Project 1, production)
// Try to submit the form - it should show duplicate error
const submitButton = screen.getByRole("button", {
name: "environments.project.api_keys.add_api_key",
});
await userEvent.click(submitButton);
// The submit should not have been called due to duplicate detection
expect(mockOnSubmit).not.toHaveBeenCalled();
});
test("handles updatePermission with environmentId but environment not found", async () => {
// Create a project with limited environments to test the edge case
const limitedProjects = [
{
...mockProjects[0],
environments: [
{
id: "env1",
type: "production" as const,
createdAt: new Date(),
updatedAt: new Date(),
projectId: "project1",
appSetupCompleted: true,
},
// Only one environment, so we can test when trying to update to non-existent env
],
},
];
render(<AddApiKeyModal {...defaultProps} projects={limitedProjects} />);
// Add a permission first
const addButton = screen.getByRole("button", { name: /add_permission/i });
await userEvent.click(addButton);
// Verify permission was added with production environment
expect(screen.getByRole("button", { name: /production/i })).toBeInTheDocument();
// Now test the edge case by manually calling the component's internal logic
// Since we can't directly access the updatePermission function in tests,
// we test through the UI interactions and verify the component doesn't crash
// The component should handle gracefully when environment lookup fails
// This tests the branch: field === "environmentId" && !environment
expect(screen.getByRole("button", { name: /production/i })).toBeInTheDocument();
});
test("covers all branches of updatePermission function", async () => {
render(<AddApiKeyModal {...defaultProps} />);
// Add a permission to have something to update
const addButton = screen.getByRole("button", { name: /add_permission/i });
await userEvent.click(addButton);
// Test Branch 1: Update non-environmentId field (permission level)
const permissionDropdowns = screen.getAllByRole("button", { name: /read/i });
await userEvent.click(permissionDropdowns[0]);
const manageOption = await screen.findByRole("menuitem", { name: "manage" });
await userEvent.click(manageOption);
expect(await screen.findByRole("button", { name: "manage" })).toBeInTheDocument();
// Test Branch 2: Update environmentId with valid environment
const environmentDropdowns = screen.getAllByRole("button", { name: /production/i });
await userEvent.click(environmentDropdowns[0]);
const developmentOption = await screen.findByRole("menuitem", { name: "development" });
await userEvent.click(developmentOption);
expect(await screen.findByRole("button", { name: "development" })).toBeInTheDocument();
// Test Branch 3: Update project (which calls updateProjectAndEnvironment)
const projectDropdowns = screen.getAllByRole("button", { name: /Project 1/i });
await userEvent.click(projectDropdowns[0]);
const project2Option = await screen.findByRole("menuitem", { name: "Project 2" });
await userEvent.click(project2Option);
expect(await screen.findByRole("button", { name: "Project 2" })).toBeInTheDocument();
// Verify all updates worked correctly and component is still functional
expect(screen.getByRole("button", { name: /add_permission/i })).toBeInTheDocument();
expect(labelInput.value).toBe("");
});
});

View File

@@ -80,22 +80,23 @@ export const AddApiKeyModal = ({
const [selectedOrganizationAccess, setSelectedOrganizationAccess] =
useState<TOrganizationAccess>(defaultOrganizationAccess);
const getInitialPermissions = (): PermissionRecord[] => {
const getInitialPermissions = () => {
if (projects.length > 0 && projects[0].environments.length > 0) {
return [
{
return {
"permission-0": {
projectId: projects[0].id,
environmentId: projects[0].environments[0].id,
permission: ApiKeyPermission.read,
projectName: projects[0].name,
environmentType: projects[0].environments[0].type,
},
];
};
}
return [];
return {} as Record<string, PermissionRecord>;
};
const [selectedPermissions, setSelectedPermissions] = useState<PermissionRecord[]>([]);
// Initialize with one permission by default
const [selectedPermissions, setSelectedPermissions] = useState<Record<string, PermissionRecord>>({});
const projectOptions: ProjectOption[] = projects.map((project) => ({
id: project.id,
@@ -103,54 +104,58 @@ export const AddApiKeyModal = ({
}));
const removePermission = (index: number) => {
const updatedPermissions = [...selectedPermissions];
updatedPermissions.splice(index, 1);
const updatedPermissions = { ...selectedPermissions };
delete updatedPermissions[`permission-${index}`];
setSelectedPermissions(updatedPermissions);
};
const addPermission = () => {
const initialPermissions = getInitialPermissions();
if (initialPermissions.length > 0) {
setSelectedPermissions([...selectedPermissions, initialPermissions[0]]);
const newIndex = Object.keys(selectedPermissions).length;
const initialPermission = getInitialPermissions()["permission-0"];
if (initialPermission) {
setSelectedPermissions({
...selectedPermissions,
[`permission-${newIndex}`]: initialPermission,
});
}
};
const updatePermission = (index: number, field: string, value: string) => {
const updatedPermissions = [...selectedPermissions];
const project = projects.find((p) => p.id === updatedPermissions[index].projectId);
const updatePermission = (key: string, field: string, value: string) => {
const project = projects.find((p) => p.id === selectedPermissions[key].projectId);
const environment = project?.environments.find((env) => env.id === value);
updatedPermissions[index] = {
...updatedPermissions[index],
[field]: value,
...(field === "environmentId" && environment ? { environmentType: environment.type } : {}),
};
setSelectedPermissions(updatedPermissions);
setSelectedPermissions({
...selectedPermissions,
[key]: {
...selectedPermissions[key],
[field]: value,
...(field === "environmentId" && environment ? { environmentType: environment.type } : {}),
},
});
};
// Update environment when project changes
const updateProjectAndEnvironment = (index: number, projectId: string) => {
const updateProjectAndEnvironment = (key: string, projectId: string) => {
const project = projects.find((p) => p.id === projectId);
if (project && project.environments.length > 0) {
const environment = project.environments[0];
const updatedPermissions = [...selectedPermissions];
updatedPermissions[index] = {
...updatedPermissions[index],
projectId,
environmentId: environment.id,
projectName: project.name,
environmentType: environment.type,
};
setSelectedPermissions(updatedPermissions);
setSelectedPermissions({
...selectedPermissions,
[key]: {
...selectedPermissions[key],
projectId,
environmentId: environment.id,
projectName: project.name,
environmentType: environment.type,
},
});
}
};
const checkForDuplicatePermissions = () => {
const uniquePermissions = new Set(selectedPermissions.map((p) => `${p.projectId}-${p.environmentId}`));
return uniquePermissions.size !== selectedPermissions.length;
const permissions = Object.values(selectedPermissions);
const uniquePermissions = new Set(permissions.map((p) => `${p.projectId}-${p.environmentId}`));
return uniquePermissions.size !== permissions.length;
};
const submitAPIKey = async () => {
@@ -162,7 +167,7 @@ export const AddApiKeyModal = ({
}
// Convert permissions to the format expected by the API
const environmentPermissions = selectedPermissions.map((permission) => ({
const environmentPermissions = Object.values(selectedPermissions).map((permission) => ({
environmentId: permission.environmentId,
permission: permission.permission,
}));
@@ -174,7 +179,7 @@ export const AddApiKeyModal = ({
});
reset();
setSelectedPermissions([]);
setSelectedPermissions({});
setSelectedOrganizationAccess(defaultOrganizationAccess);
};
@@ -191,7 +196,7 @@ export const AddApiKeyModal = ({
}
// Check if at least one project permission is set or one organization access toggle is ON
const hasProjectAccess = selectedPermissions.length > 0;
const hasProjectAccess = Object.keys(selectedPermissions).length > 0;
const hasOrganizationAccess = Object.values(selectedOrganizationAccess).some((accessGroup) =>
Object.values(accessGroup).some((value) => value === true)
@@ -230,9 +235,13 @@ export const AddApiKeyModal = ({
<div className="space-y-2">
<Label>{t("environments.project.api_keys.project_access")}</Label>
<div className="space-y-2">
{selectedPermissions.map((permission, index) => {
{/* Permission rows */}
{Object.keys(selectedPermissions).map((key) => {
const permissionIndex = parseInt(key.split("-")[1]);
const permission = selectedPermissions[key];
return (
<div key={index + permission.projectId} className="flex items-center gap-2">
<div key={key} className="flex items-center gap-2">
{/* Project dropdown */}
<div className="w-1/3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -252,7 +261,7 @@ export const AddApiKeyModal = ({
<DropdownMenuItem
key={option.id}
onClick={() => {
updateProjectAndEnvironment(index, option.id);
updateProjectAndEnvironment(key, option.id);
}}>
{option.name}
</DropdownMenuItem>
@@ -260,6 +269,8 @@ export const AddApiKeyModal = ({
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Environment dropdown */}
<div className="w-1/3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -281,7 +292,7 @@ export const AddApiKeyModal = ({
<DropdownMenuItem
key={env.id}
onClick={() => {
updatePermission(index, "environmentId", env.id);
updatePermission(key, "environmentId", env.id);
}}>
{env.type}
</DropdownMenuItem>
@@ -289,6 +300,8 @@ export const AddApiKeyModal = ({
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Permission level dropdown */}
<div className="w-1/3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -310,7 +323,7 @@ export const AddApiKeyModal = ({
<DropdownMenuItem
key={option}
onClick={() => {
updatePermission(index, "permission", option);
updatePermission(key, "permission", option);
}}>
{option}
</DropdownMenuItem>
@@ -318,16 +331,16 @@ export const AddApiKeyModal = ({
</DropdownMenuContent>
</DropdownMenu>
</div>
<button
type="button"
className="p-2"
onClick={() => removePermission(index)}
aria-label={t("environments.project.api_keys.delete_permission")}>
{/* Delete button */}
<button type="button" className="p-2" onClick={() => removePermission(permissionIndex)}>
<Trash2Icon className={"h-5 w-5 text-slate-500 hover:text-red-500"} />
</button>
</div>
);
})}
{/* Add permission button */}
<Button type="button" variant="outline" onClick={addPermission}>
<span className="mr-2">+</span> {t("environments.settings.api_keys.add_permission")}
</Button>
@@ -384,7 +397,7 @@ export const AddApiKeyModal = ({
onClick={() => {
setOpen(false);
reset();
setSelectedPermissions([]);
setSelectedPermissions({});
}}>
{t("common.cancel")}
</Button>

View File

@@ -1,6 +1,5 @@
import { TFnType } from "@tolgee/react";
import { OrganizationAccessType } from "@formbricks/types/api-key";
import { TAPIKeyEnvironmentPermission, TAuthenticationApiKey } from "@formbricks/types/auth";
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth";
// Permission level required for different HTTP methods
const methodPermissionMap = {
@@ -51,19 +50,3 @@ 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;
}
};

View File

@@ -179,7 +179,6 @@ 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">

View File

@@ -1,4 +1,4 @@
import { Logo } from "@/modules/ui/components/logo";
import { FormbricksLogo } from "@/modules/ui/components/formbricks-logo";
import { Toaster } from "react-hot-toast";
export const SetupLayout = ({ children }: { children: React.ReactNode }) => {
@@ -10,7 +10,7 @@ export const SetupLayout = ({ children }: { children: React.ReactNode }) => {
style={{ scrollbarGutter: "stable both-edges" }}
className="flex max-h-[90vh] w-[40rem] flex-col items-center space-y-4 overflow-auto rounded-lg border bg-white p-12 text-center shadow-md">
<div className="h-20 w-20 rounded-lg bg-slate-900 p-2">
<Logo className="h-full w-full" variant="image" />
<FormbricksLogo className="h-full w-full" />
</div>
{children}
</div>

View File

@@ -56,6 +56,7 @@ const mockUser = {
id: "user-123",
name: "Test User",
email: "test@example.com",
imageUrl: null,
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),

View File

@@ -131,6 +131,7 @@ describe("CreateOrganizationPage", () => {
name: "Test User",
email: "test@example.com",
emailVerified: null,
imageUrl: null,
twoFactorEnabled: false,
identityProvider: "email" as const,
createdAt: new Date(),

View File

@@ -85,10 +85,6 @@ 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);
@@ -108,8 +104,8 @@ export const QuestionFormInput = ({
[localSurvey.languages]
);
const isTranslationIncomplete = useMemo(
() => isValueIncomplete(inputId, isInvalid, surveyLanguageCodes, value),
[value, inputId, isInvalid, surveyLanguageCodes]
() => isValueIncomplete(id, isInvalid, surveyLanguageCodes, value),
[value, id, isInvalid, surveyLanguageCodes]
);
const elementText = useMemo((): TI18nString => {

View File

@@ -87,12 +87,12 @@ describe("utils", () => {
headline: createI18nString("Matrix Question", surveyLanguageCodes),
required: true,
rows: [
{ id: "row-1", label: createI18nString("Row 1", surveyLanguageCodes) },
{ id: "row-2", label: createI18nString("Row 2", surveyLanguageCodes) },
createI18nString("Row 1", surveyLanguageCodes),
createI18nString("Row 2", surveyLanguageCodes),
],
columns: [
{ id: "col-1", label: createI18nString("Column 1", surveyLanguageCodes) },
{ id: "col-2", label: createI18nString("Column 2", surveyLanguageCodes) },
createI18nString("Column 1", surveyLanguageCodes),
createI18nString("Column 2", surveyLanguageCodes),
],
} as unknown as TSurveyQuestion;
@@ -108,12 +108,12 @@ describe("utils", () => {
headline: createI18nString("Matrix Question", surveyLanguageCodes),
required: true,
rows: [
{ id: "row-1", label: createI18nString("Row 1", surveyLanguageCodes) },
{ id: "row-2", label: createI18nString("Row 2", surveyLanguageCodes) },
createI18nString("Row 1", surveyLanguageCodes),
createI18nString("Row 2", surveyLanguageCodes),
],
columns: [
{ id: "col-1", label: createI18nString("Column 1", surveyLanguageCodes) },
{ id: "col-2", label: createI18nString("Column 2", surveyLanguageCodes) },
createI18nString("Column 1", surveyLanguageCodes),
createI18nString("Column 2", surveyLanguageCodes),
],
} as unknown as TSurveyQuestion;

View File

@@ -36,8 +36,8 @@ export const getMatrixLabel = (
type: "row" | "column"
): TI18nString => {
const matrixQuestion = question as TSurveyMatrixQuestion;
const matrixFields = type === "row" ? matrixQuestion.rows : matrixQuestion.columns;
return matrixFields[idx]?.label || createI18nString("", surveyLanguageCodes);
const labels = type === "row" ? matrixQuestion.rows : matrixQuestion.columns;
return labels[idx] || createI18nString("", surveyLanguageCodes);
};
export const getWelcomeCardText = (
@@ -94,9 +94,6 @@ 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",

View File

@@ -1,8 +1,9 @@
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 { ResourceNotFoundError } from "@formbricks/types/errors";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TUser } from "@formbricks/types/user";
import { updateUser } from "./user";
@@ -23,6 +24,7 @@ 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",
@@ -39,6 +41,7 @@ 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" };
@@ -52,6 +55,7 @@ describe("updateUser", () => {
name: true,
email: true,
emailVerified: true,
imageUrl: true,
createdAt: true,
updatedAt: true,
role: true,
@@ -68,7 +72,17 @@ 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",
@@ -82,6 +96,8 @@ 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);

View File

@@ -1,11 +1,14 @@
import { isValidImageFile } from "@/lib/fileValidation";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { InvalidInputError, 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: {
@@ -17,6 +20,7 @@ export const updateUser = async (personId: string, data: TUserUpdateInput): Prom
name: true,
email: true,
emailVerified: true,
imageUrl: true,
createdAt: true,
updatedAt: true,
role: true,

View File

@@ -2,7 +2,8 @@ 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 { afterEach, describe, expect, test, vi } from "vitest";
import React from "react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
TSurvey,
TSurveyLanguage,
@@ -13,11 +14,12 @@ 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(() => `cuid${cuidIndex++}`),
createId: vi.fn(() => mockCuids[cuidIndex++]),
},
}));
@@ -158,14 +160,14 @@ const mockMatrixQuestion: TSurveyMatrixQuestion = {
required: false,
logic: [],
rows: [
{ id: "row-1", label: createI18nString("Row 1", ["en"]) },
{ id: "row-2", label: createI18nString("Row 2", ["en"]) },
{ id: "row-3", label: createI18nString("Row 3", ["en"]) },
createI18nString("Row 1", ["en"]),
createI18nString("Row 2", ["en"]),
createI18nString("Row 3", ["en"]),
],
columns: [
{ id: "col-1", label: createI18nString("Column 1", ["en"]) },
{ id: "col-2", label: createI18nString("Column 2", ["en"]) },
{ id: "col-3", label: createI18nString("Column 3", ["en"]) },
createI18nString("Column 1", ["en"]),
createI18nString("Column 2", ["en"]),
createI18nString("Column 3", ["en"]),
],
shuffleOption: "none",
};
@@ -195,7 +197,6 @@ describe("MatrixQuestionForm", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
cuidIndex = 0;
});
test("renders the matrix question form with rows and columns", () => {
@@ -239,6 +240,40 @@ 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} />);
@@ -259,10 +294,7 @@ describe("MatrixQuestionForm", () => {
...defaultProps,
question: {
...mockMatrixQuestion,
rows: [
{ id: "row-1", label: createI18nString("Row 1", ["en"]) },
{ id: "row-2", label: createI18nString("Row 2", ["en"]) },
],
rows: [createI18nString("Row 1", ["en"]), createI18nString("Row 2", ["en"])],
},
};
@@ -302,6 +334,42 @@ 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
@@ -329,4 +397,223 @@ 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);
});
});
});

View File

@@ -2,18 +2,16 @@
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 { DndContext, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { createId } from "@paralleldrive/cuid2";
import cuid2 from "@paralleldrive/cuid2";
import { useTranslate } from "@tolgee/react";
import { PlusIcon } from "lucide-react";
import { type JSX, useCallback } from "react";
import { PlusIcon, TrashIcon } from "lucide-react";
import { type JSX, useMemo, useRef } from "react";
import toast from "react-hot-toast";
import { TI18nString, TSurvey, TSurveyMatrixQuestion } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
@@ -43,16 +41,51 @@ 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, { id: createId(), label: createI18nString("", languageCodes) }];
const updatedRows = [...question.rows, createI18nString("", languageCodes)];
updateQuestion(questionIdx, { rows: updatedRows });
} else {
const updatedColumns = [
...question.columns,
{ id: createId(), label: createI18nString("", languageCodes) },
];
const updatedColumns = [...question.columns, createI18nString("", languageCodes)];
updateQuestion(questionIdx, { columns: updatedColumns });
}
};
@@ -87,6 +120,10 @@ 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 {
@@ -99,9 +136,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].label = matrixLabel;
labels[index] = matrixLabel;
} else {
labels.push({ id: createId(), label: matrixLabel });
labels.push(matrixLabel);
}
if (type === "row") {
updateQuestion(questionIdx, { rows: labels });
@@ -117,27 +154,6 @@ 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",
@@ -157,7 +173,6 @@ export const MatrixQuestionForm = ({
};
/// Auto animate
const [parent] = useAutoAnimate();
return (
<form>
<QuestionFormInput
@@ -211,39 +226,44 @@ export const MatrixQuestionForm = ({
<div>
{/* Rows section */}
<Label htmlFor="rows">{t("environments.surveys.edit.rows")}</Label>
<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>
<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>
))}
<Button
variant="secondary"
size="sm"
className="mt-2 w-fit"
className="w-fit"
onClick={(e) => {
e.preventDefault();
handleAddLabel("row");
@@ -256,39 +276,44 @@ export const MatrixQuestionForm = ({
<div>
{/* Columns section */}
<Label htmlFor="columns">{t("environments.surveys.edit.columns")}</Label>
<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>
<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>
))}
<Button
variant="secondary"
size="sm"
className="mt-2 w-fit"
className="w-fit"
onClick={(e) => {
e.preventDefault();
handleAddLabel("column");

View File

@@ -1,100 +0,0 @@
"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>
);
};

View File

@@ -118,7 +118,7 @@ export const getConditionValueOptions = (
// Rows submenu
const rows = question.rows.map((row, rowIdx) => ({
icon: getQuestionIconMapping(t)[question.type],
label: `${getLocalizedValue(row.label, "default")} (${getLocalizedValue(question.headline, "default")})`,
label: `${getLocalizedValue(row, "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.label, "default"),
label: getLocalizedValue(column, "default"),
value: colIdx.toString(),
meta: {
type: "static",

View File

@@ -61,20 +61,14 @@ const handleI18nCheckForMatrixLabels = (
): boolean => {
const rowsAndColumns = [...question.rows, ...question.columns];
const invalidRowsLangCodes = findLanguageCodesForDuplicateLabels(
question.rows.map((row) => row.label),
languages
);
const invalidColumnsLangCodes = findLanguageCodesForDuplicateLabels(
question.columns.map((column) => column.label),
languages
);
const invalidRowsLangCodes = findLanguageCodesForDuplicateLabels(question.rows, languages);
const invalidColumnsLangCodes = findLanguageCodesForDuplicateLabels(question.columns, languages);
if (invalidRowsLangCodes.length > 0 || invalidColumnsLangCodes.length > 0) {
return false;
}
return rowsAndColumns.every((choice) => isLabelValidForAllLanguages(choice.label, languages));
return rowsAndColumns.every((label) => isLabelValidForAllLanguages(label, languages));
};
const handleI18nCheckForContactAndAddressFields = (

View File

@@ -181,14 +181,8 @@ export const getQuestionTypes = (t: TFnType): TQuestion[] => [
icon: Grid3X3Icon,
preset: {
headline: createI18nString("", []),
rows: [
{ id: createId(), label: createI18nString("", []) },
{ id: createId(), label: createI18nString("", []) },
],
columns: [
{ id: createId(), label: createI18nString("", []) },
{ id: createId(), label: createI18nString("", []) },
],
rows: [createI18nString("", []), createI18nString("", [])],
columns: [createI18nString("", []), createI18nString("", [])],
buttonLabel: createI18nString(t("templates.next"), []),
backButtonLabel: createI18nString(t("templates.back"), []),
shuffleOption: "none",

View File

@@ -2,8 +2,6 @@
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";
@@ -14,14 +12,6 @@ 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);

View File

@@ -52,7 +52,7 @@ export const getBasicSurveyMetadata = async (
: undefined;
let title = titleFromMetadata || titleFromWelcome || survey.name;
// Set description - priority: custom link metadata > default
// Set description - priority: custom link metadata > welcome card > default
const descriptionFromMetadata = metadata?.description
? getLocalizedValue(metadata.description, langCode) || ""
: undefined;

View File

@@ -12,7 +12,6 @@ interface AdvancedOptionToggleProps {
childBorder?: boolean;
customContainerClass?: string;
disabled?: boolean;
childrenContainerClass?: string;
}
export const AdvancedOptionToggle = ({
@@ -25,7 +24,6 @@ export const AdvancedOptionToggle = ({
childBorder,
customContainerClass,
disabled = false,
childrenContainerClass,
}: AdvancedOptionToggleProps) => {
return (
<div className={cn("px-4 py-2", customContainerClass)}>
@@ -42,8 +40,7 @@ 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",
childrenContainerClass
childBorder && "border"
)}>
{children}
</div>

View File

@@ -12,6 +12,13 @@ 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();
@@ -37,7 +44,7 @@ describe("Avatar Components", () => {
});
describe("ProfileAvatar", () => {
test("renders Boring Avatar", () => {
test("renders Boring Avatar when imageUrl is not provided", () => {
render(<ProfileAvatar userId="user-123" />);
const avatar = screen.getByTestId("boring-avatar-bauhaus");
@@ -45,5 +52,32 @@ 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");
});
});
});

View File

@@ -1,4 +1,5 @@
import Avatar from "boring-avatars";
import Image from "next/image";
const colors = ["#00C4B8", "#ccfbf1", "#334155"];
@@ -12,8 +13,20 @@ export const PersonAvatar: React.FC<PersonAvatarProps> = ({ personId }) => {
interface ProfileAvatar {
userId: string;
imageUrl?: string | null;
}
export const ProfileAvatar: React.FC<ProfileAvatar> = ({ userId }) => {
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"
/>
);
}
return <Avatar size={40} name={userId} variant="bauhaus" colors={colors} />;
};

View File

@@ -233,12 +233,31 @@ describe("ConditionsEditor", () => {
expect(mockCallbacks.onDuplicateCondition).toHaveBeenCalledWith("cond1");
});
test("calls onCreateGroup from the dropdown menu", async () => {
test("calls onCreateGroup from the dropdown menu when enabled", async () => {
const user = userEvent.setup();
render(
<ConditionsEditor conditions={multipleConditions} config={mockConfig} callbacks={mockCallbacks} />
);
const createGroupButtons = screen.getAllByText("environments.surveys.edit.create_group");
await user.click(createGroupButtons[0]); // Click the first one
expect(mockCallbacks.onCreateGroup).toHaveBeenCalledWith("cond1");
});
test("disables the 'Create Group' button when there's only one condition", () => {
render(<ConditionsEditor conditions={singleCondition} config={mockConfig} callbacks={mockCallbacks} />);
const createGroupButton = screen.getByText("environments.surveys.edit.create_group");
await user.click(createGroupButton);
expect(mockCallbacks.onCreateGroup).toHaveBeenCalledWith("cond1");
expect(createGroupButton).toBeDisabled();
});
test("enables the 'Create Group' button when there are multiple conditions", () => {
render(
<ConditionsEditor conditions={multipleConditions} config={mockConfig} callbacks={mockCallbacks} />
);
const createGroupButtons = screen.getAllByText("environments.surveys.edit.create_group");
// Both buttons should be enabled since the main group has multiple conditions
createGroupButtons.forEach((button) => {
expect(button).not.toBeDisabled();
});
});
test("calls onToggleGroupConnector when the connector is changed", async () => {

View File

@@ -233,7 +233,8 @@ export function ConditionsEditor({ conditions, config, callbacks, depth = 0 }: C
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => callbacks.onCreateGroup(condition.id)}
icon={<WorkflowIcon className="h-4 w-4" />}>
icon={<WorkflowIcon className="h-4 w-4" />}
disabled={conditions.conditions.length <= 1}>
{t("environments.surveys.edit.create_group")}
</DropdownMenuItem>
</DropdownMenuContent>

View File

@@ -41,8 +41,8 @@ vi.mock("next/link", () => ({
),
}));
vi.mock("@/modules/ui/components/logo", () => ({
Logo: () => <div data-testid="logo">Logo</div>,
vi.mock("@/modules/ui/components/formbricks-logo", () => ({
FormbricksLogo: () => <div data-testid="formbricks-logo">FormbricksLogo</div>,
}));
vi.mock("@/modules/ui/components/button", () => ({

View File

@@ -1,7 +1,7 @@
"use client";
import { Button } from "@/modules/ui/components/button";
import { Logo } from "@/modules/ui/components/logo";
import { FormbricksLogo } from "@/modules/ui/components/formbricks-logo";
import { useTranslate } from "@tolgee/react";
import Image, { StaticImageData } from "next/image";
import Link from "next/link";
@@ -51,7 +51,7 @@ export const ConnectIntegration = ({
<div className="flex w-1/2 flex-col items-center justify-center rounded-lg bg-white p-8 shadow">
<div className="flex w-1/2 justify-center -space-x-4">
<div className="flex h-32 w-32 items-center justify-center rounded-full bg-white p-6 shadow-md">
<Logo variant="image" />
<FormbricksLogo />
</div>
<div className="flex h-32 w-32 items-center justify-center rounded-full bg-white p-4 shadow-md">
<Image className="w-1/2" src={integrationLogoSrc} alt="logo" />

View File

@@ -0,0 +1,197 @@
interface FormbricksLogoProps {
className?: string;
}
export const FormbricksLogo = ({ className }: FormbricksLogoProps) => {
return (
<svg
width="220"
height="220"
viewBox="0 0 220 220"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}>
<path
d="M39.1602 147.334H95.8321V175.67C95.8321 191.32 83.1457 204.006 67.4962 204.006C51.8466 204.006 39.1602 191.32 39.1602 175.67V147.334Z"
fill="url(#paint0_linear_415_2)"
/>
<path
d="M39.1602 81.8071H152.504C168.154 81.8071 180.84 94.4936 180.84 110.143C180.84 125.793 168.154 138.479 152.504 138.479H39.1602V81.8071Z"
fill="url(#paint1_linear_415_2)"
/>
<path
d="M39.1602 62.7322C39.1602 37.0773 59.9576 16.2798 85.6126 16.2798H152.504C168.154 16.2798 180.84 28.9662 180.84 44.6158C180.84 60.2653 168.154 72.9518 152.504 72.9518H39.1602V62.7322Z"
fill="url(#paint2_linear_415_2)"
/>
<mask
id="mask0_415_2"
style={{ maskType: "alpha" }}
maskUnits="userSpaceOnUse"
x="39"
y="16"
width="142"
height="189">
<path
d="M39.1602 147.335H95.8321V175.671C95.8321 191.32 83.1457 204.007 67.4962 204.007C51.8466 204.007 39.1602 191.32 39.1602 175.671V147.335Z"
fill="url(#paint3_linear_415_2)"
/>
<path
d="M39.1602 81.8081H152.504C168.154 81.8081 180.84 94.4946 180.84 110.144C180.84 125.794 168.154 138.48 152.504 138.48H39.1602V81.8081Z"
fill="url(#paint4_linear_415_2)"
/>
<path
d="M39.1602 62.7322C39.1602 37.0773 59.9576 16.2798 85.6126 16.2798H152.504C168.154 16.2798 180.84 28.9662 180.84 44.6158C180.84 60.2653 168.154 72.9518 152.504 72.9518H39.1602V62.7322Z"
fill="url(#paint5_linear_415_2)"
/>
</mask>
<g mask="url(#mask0_415_2)">
<g filter="url(#filter0_d_415_2)">
<mask
id="mask1_415_2"
style={{ maskType: "alpha" }}
maskUnits="userSpaceOnUse"
x="39"
y="16"
width="142"
height="189">
<path
d="M39.1602 147.335H95.8321V175.671C95.8321 191.32 83.1457 204.007 67.4962 204.007C51.8466 204.007 39.1602 191.32 39.1602 175.671V147.335Z"
fill="black"
fillOpacity="0.1"
/>
<path
d="M39.1602 62.7322C39.1602 37.0773 59.9576 16.2798 85.6126 16.2798H152.504C168.154 16.2798 180.84 28.9662 180.84 44.6158C180.84 60.2653 168.154 72.9518 152.504 72.9518H39.1602V62.7322Z"
fill="black"
fillOpacity="0.1"
/>
<path
d="M39.1602 81.8081H152.504C168.154 81.8081 180.84 94.4946 180.84 110.144C180.84 125.794 168.154 138.48 152.504 138.48H39.1602V81.8081Z"
fill="black"
fillOpacity="0.1"
/>
</mask>
<g mask="url(#mask1_415_2)">
<path
d="M42.1331 -32.5321C64.3329 -54.1986 120.626 -32.5321 120.626 -32.5321H42.1331C36.6806 -27.2105 33.2847 -19.2749 33.2847 -7.76218C33.2847 50.6243 96.5317 71.8561 96.5317 112.55C96.5317 152.386 35.9231 176.962 33.3678 231.092H120.626C120.626 231.092 33.2847 291.248 33.2847 234.631C33.2847 233.437 33.3128 232.258 33.3678 231.092H-5.11523L2.41417 -32.5321H42.1331Z"
fill="black"
fillOpacity="0.1"
/>
</g>
</g>
<g filter="url(#filter1_f_415_2)">
<circle cx="21.4498" cy="179.212" r="53.13" fill="#00C4B8" />
</g>
<g filter="url(#filter2_f_415_2)">
<circle cx="21.4498" cy="44.6163" r="53.13" fill="#00C4B8" />
</g>
</g>
<defs>
<filter
id="filter0_d_415_2"
x="34.5149"
y="-11.5917"
width="137.209"
height="243.47"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="23.2262" />
<feGaussianBlur stdDeviation="13.9357" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" />
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_415_2" />
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_415_2" result="shape" />
</filter>
<filter
id="filter1_f_415_2"
x="-78.1326"
y="79.6296"
width="199.165"
height="199.165"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="23.2262" result="effect1_foregroundBlur_415_2" />
</filter>
<filter
id="filter2_f_415_2"
x="-78.1326"
y="-54.9661"
width="199.165"
height="199.165"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="23.2262" result="effect1_foregroundBlur_415_2" />
</filter>
<linearGradient
id="paint0_linear_415_2"
x1="96.0786"
y1="174.643"
x2="39.1553"
y2="174.873"
gradientUnits="userSpaceOnUse">
<stop offset="1" stopColor="#00C4B8" />
</linearGradient>
<linearGradient
id="paint1_linear_415_2"
x1="181.456"
y1="109.116"
x2="39.1602"
y2="110.554"
gradientUnits="userSpaceOnUse">
<stop stopColor="#00DDD0" />
<stop offset="1" stopColor="#01E0C6" />
</linearGradient>
<linearGradient
id="paint2_linear_415_2"
x1="181.456"
y1="43.5891"
x2="39.1602"
y2="45.0264"
gradientUnits="userSpaceOnUse">
<stop stopColor="#00DDD0" />
<stop offset="1" stopColor="#01E0C6" />
</linearGradient>
<linearGradient
id="paint3_linear_415_2"
x1="96.0786"
y1="174.644"
x2="39.1553"
y2="174.874"
gradientUnits="userSpaceOnUse">
<stop stopColor="#00FFE1" />
<stop offset="1" stopColor="#01E0C6" />
</linearGradient>
<linearGradient
id="paint4_linear_415_2"
x1="181.456"
y1="109.117"
x2="39.1602"
y2="110.555"
gradientUnits="userSpaceOnUse">
<stop stopColor="#00FFE1" />
<stop offset="1" stopColor="#01E0C6" />
</linearGradient>
<linearGradient
id="paint5_linear_415_2"
x1="181.456"
y1="43.5891"
x2="39.1602"
y2="45.0264"
gradientUnits="userSpaceOnUse">
<stop stopColor="#00FFE1" />
<stop offset="1" stopColor="#01E0C6" />
</linearGradient>
</defs>
</svg>
);
};

View File

@@ -8,59 +8,33 @@ describe("Logo", () => {
cleanup();
});
describe("default variant", () => {
test("renders default logo correctly", () => {
const { container } = render(<Logo />);
const svg = container.querySelector("svg");
test("renders correctly", () => {
const { container } = render(<Logo />);
const svg = container.querySelector("svg");
expect(svg).toBeInTheDocument();
});
expect(svg).toBeInTheDocument();
expect(svg).toHaveAttribute("viewBox", "0 0 697 150");
expect(svg).toHaveAttribute("fill", "none");
expect(svg).toHaveAttribute("xmlns", "http://www.w3.org/2000/svg");
});
describe("image variant", () => {
test("renders image logo correctly", () => {
const { container } = render(<Logo variant="image" />);
const svg = container.querySelector("svg");
test("accepts and passes through props", () => {
const testClassName = "test-class";
const { container } = render(<Logo className={testClassName} />);
const svg = container.querySelector("svg");
expect(svg).toBeInTheDocument();
});
test("renders image logo with className correctly", () => {
const testClassName = "test-class";
const { container } = render(<Logo variant="image" className={testClassName} />);
const svg = container.querySelector("svg");
expect(svg).toBeInTheDocument();
expect(svg).toHaveAttribute("class", testClassName);
});
expect(svg).toBeInTheDocument();
expect(svg).toHaveAttribute("class", testClassName);
});
describe("wordmark variant", () => {
test("renders wordmark logo correctly", () => {
const { container } = render(<Logo variant="wordmark" />);
const svg = container.querySelector("svg");
test("contains expected svg elements", () => {
const { container } = render(<Logo />);
const svg = container.querySelector("svg");
expect(svg).toBeInTheDocument();
});
test("renders wordmark logo with className correctly", () => {
const testClassName = "test-class";
const { container } = render(<Logo variant="wordmark" className={testClassName} />);
const svg = container.querySelector("svg");
expect(svg).toBeInTheDocument();
expect(svg).toHaveAttribute("class", testClassName);
});
test("contains expected svg elements", () => {
const { container } = render(<Logo variant="wordmark" />);
const svg = container.querySelector("svg");
expect(svg?.querySelectorAll("path").length).toBeGreaterThan(0);
expect(svg?.querySelector("line")).toBeInTheDocument();
expect(svg?.querySelectorAll("mask").length).toBe(2);
expect(svg?.querySelectorAll("filter").length).toBe(3);
expect(svg?.querySelectorAll("linearGradient").length).toBe(6);
});
expect(svg?.querySelectorAll("path").length).toBeGreaterThan(0);
expect(svg?.querySelector("line")).toBeInTheDocument();
expect(svg?.querySelectorAll("mask").length).toBe(2);
expect(svg?.querySelectorAll("filter").length).toBe(3);
expect(svg?.querySelectorAll("linearGradient").length).toBe(6);
});
});

View File

@@ -1,208 +1,4 @@
interface LogoProps extends React.SVGProps<SVGSVGElement> {
variant?: "image" | "wordmark";
}
export const Logo = ({ variant = "wordmark", ...props }: LogoProps) => {
if (variant === "image") return <ImageLogo {...props} />;
return <WordmarkLogo {...props} />;
};
const ImageLogo = (props: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="220"
height="220"
viewBox="0 0 220 220"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}>
<path
d="M39.1602 147.334H95.8321V175.67C95.8321 191.32 83.1457 204.006 67.4962 204.006C51.8466 204.006 39.1602 191.32 39.1602 175.67V147.334Z"
fill="url(#paint0_linear_415_2)"
/>
<path
d="M39.1602 81.8071H152.504C168.154 81.8071 180.84 94.4936 180.84 110.143C180.84 125.793 168.154 138.479 152.504 138.479H39.1602V81.8071Z"
fill="url(#paint1_linear_415_2)"
/>
<path
d="M39.1602 62.7322C39.1602 37.0773 59.9576 16.2798 85.6126 16.2798H152.504C168.154 16.2798 180.84 28.9662 180.84 44.6158C180.84 60.2653 168.154 72.9518 152.504 72.9518H39.1602V62.7322Z"
fill="url(#paint2_linear_415_2)"
/>
<mask
id="mask0_415_2"
style={{ maskType: "alpha" }}
maskUnits="userSpaceOnUse"
x="39"
y="16"
width="142"
height="189">
<path
d="M39.1602 147.335H95.8321V175.671C95.8321 191.32 83.1457 204.007 67.4962 204.007C51.8466 204.007 39.1602 191.32 39.1602 175.671V147.335Z"
fill="url(#paint3_linear_415_2)"
/>
<path
d="M39.1602 81.8081H152.504C168.154 81.8081 180.84 94.4946 180.84 110.144C180.84 125.794 168.154 138.48 152.504 138.48H39.1602V81.8081Z"
fill="url(#paint4_linear_415_2)"
/>
<path
d="M39.1602 62.7322C39.1602 37.0773 59.9576 16.2798 85.6126 16.2798H152.504C168.154 16.2798 180.84 28.9662 180.84 44.6158C180.84 60.2653 168.154 72.9518 152.504 72.9518H39.1602V62.7322Z"
fill="url(#paint5_linear_415_2)"
/>
</mask>
<g mask="url(#mask0_415_2)">
<g filter="url(#filter0_d_415_2)">
<mask
id="mask1_415_2"
style={{ maskType: "alpha" }}
maskUnits="userSpaceOnUse"
x="39"
y="16"
width="142"
height="189">
<path
d="M39.1602 147.335H95.8321V175.671C95.8321 191.32 83.1457 204.007 67.4962 204.007C51.8466 204.007 39.1602 191.32 39.1602 175.671V147.335Z"
fill="black"
fillOpacity="0.1"
/>
<path
d="M39.1602 62.7322C39.1602 37.0773 59.9576 16.2798 85.6126 16.2798H152.504C168.154 16.2798 180.84 28.9662 180.84 44.6158C180.84 60.2653 168.154 72.9518 152.504 72.9518H39.1602V62.7322Z"
fill="black"
fillOpacity="0.1"
/>
<path
d="M39.1602 81.8081H152.504C168.154 81.8081 180.84 94.4946 180.84 110.144C180.84 125.794 168.154 138.48 152.504 138.48H39.1602V81.8081Z"
fill="black"
fillOpacity="0.1"
/>
</mask>
<g mask="url(#mask1_415_2)">
<path
d="M42.1331 -32.5321C64.3329 -54.1986 120.626 -32.5321 120.626 -32.5321H42.1331C36.6806 -27.2105 33.2847 -19.2749 33.2847 -7.76218C33.2847 50.6243 96.5317 71.8561 96.5317 112.55C96.5317 152.386 35.9231 176.962 33.3678 231.092H120.626C120.626 231.092 33.2847 291.248 33.2847 234.631C33.2847 233.437 33.3128 232.258 33.3678 231.092H-5.11523L2.41417 -32.5321H42.1331Z"
fill="black"
fillOpacity="0.1"
/>
</g>
</g>
<g filter="url(#filter1_f_415_2)">
<circle cx="21.4498" cy="179.212" r="53.13" fill="#00C4B8" />
</g>
<g filter="url(#filter2_f_415_2)">
<circle cx="21.4498" cy="44.6163" r="53.13" fill="#00C4B8" />
</g>
</g>
<defs>
<filter
id="filter0_d_415_2"
x="34.5149"
y="-11.5917"
width="137.209"
height="243.47"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="23.2262" />
<feGaussianBlur stdDeviation="13.9357" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" />
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_415_2" />
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_415_2" result="shape" />
</filter>
<filter
id="filter1_f_415_2"
x="-78.1326"
y="79.6296"
width="199.165"
height="199.165"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="23.2262" result="effect1_foregroundBlur_415_2" />
</filter>
<filter
id="filter2_f_415_2"
x="-78.1326"
y="-54.9661"
width="199.165"
height="199.165"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="23.2262" result="effect1_foregroundBlur_415_2" />
</filter>
<linearGradient
id="paint0_linear_415_2"
x1="96.0786"
y1="174.643"
x2="39.1553"
y2="174.873"
gradientUnits="userSpaceOnUse">
<stop offset="1" stopColor="#00C4B8" />
</linearGradient>
<linearGradient
id="paint1_linear_415_2"
x1="181.456"
y1="109.116"
x2="39.1602"
y2="110.554"
gradientUnits="userSpaceOnUse">
<stop stopColor="#00DDD0" />
<stop offset="1" stopColor="#01E0C6" />
</linearGradient>
<linearGradient
id="paint2_linear_415_2"
x1="181.456"
y1="43.5891"
x2="39.1602"
y2="45.0264"
gradientUnits="userSpaceOnUse">
<stop stopColor="#00DDD0" />
<stop offset="1" stopColor="#01E0C6" />
</linearGradient>
<linearGradient
id="paint3_linear_415_2"
x1="96.0786"
y1="174.644"
x2="39.1553"
y2="174.874"
gradientUnits="userSpaceOnUse">
<stop stopColor="#00FFE1" />
<stop offset="1" stopColor="#01E0C6" />
</linearGradient>
<linearGradient
id="paint4_linear_415_2"
x1="181.456"
y1="109.117"
x2="39.1602"
y2="110.555"
gradientUnits="userSpaceOnUse">
<stop stopColor="#00FFE1" />
<stop offset="1" stopColor="#01E0C6" />
</linearGradient>
<linearGradient
id="paint5_linear_415_2"
x1="181.456"
y1="43.5891"
x2="39.1602"
y2="45.0264"
gradientUnits="userSpaceOnUse">
<stop stopColor="#00FFE1" />
<stop offset="1" stopColor="#01E0C6" />
</linearGradient>
</defs>
</svg>
);
};
const WordmarkLogo = (props: React.SVGProps<SVGSVGElement>) => {
export const Logo = (props: any) => {
return (
<svg viewBox="0 0 697 150" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path

View File

@@ -1,74 +0,0 @@
import { Meta, StoryObj } from "@storybook/react-vite";
import { Logo } from "./index";
type StoryProps = React.ComponentProps<typeof Logo>;
const meta: Meta<StoryProps> = {
title: "UI/Logo",
component: Logo,
tags: ["autodocs"],
parameters: {
layout: "centered",
controls: { sort: "alpha", exclude: [] },
docs: {
description: {
component:
"** Logo ** renders the Formbricks brand as scalable SVG.It supports two variants('image' and 'wordmark') and is suitable for headers, navigation, and other branding areas.",
},
},
},
argTypes: {
variant: {
control: "select",
options: ["image", "wordmark"],
description: "The variant of the logo to display",
table: {
category: "Appearance",
type: { summary: "string" },
defaultValue: { summary: "wordmark" },
},
order: 1,
},
className: {
control: "text",
description: "Additional CSS classes for styling",
table: {
category: "Appearance",
type: { summary: "string" },
},
order: 1,
},
},
};
export default meta;
type Story = StoryObj<StoryProps>;
const renderLogoWithOptions = (args: StoryProps) => {
const { ...logoProps } = args;
return <Logo {...logoProps} />;
};
export const Default: Story = {
render: renderLogoWithOptions,
args: {
className: "h-20",
},
};
export const Image: Story = {
render: renderLogoWithOptions,
args: {
className: "h-20",
variant: "image",
},
};
export const Wordmark: Story = {
render: renderLogoWithOptions,
args: {
className: "h-20",
variant: "wordmark",
},
};

View File

@@ -1,217 +0,0 @@
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.",
},
},
},
};

View File

@@ -52,7 +52,7 @@
"@opentelemetry/sdk-logs": "0.200.0",
"@opentelemetry/sdk-metrics": "2.0.0",
"@paralleldrive/cuid2": "2.2.2",
"@prisma/client": "6.13.0",
"@prisma/client": "6.7.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": "2.0.1",
"boring-avatars": "1.11.2",
"cache-manager": "6.4.3",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",

View File

@@ -55,7 +55,7 @@ else
fi
echo "🗃️ Running database migrations..."
run_with_timeout 600 "database migration" sh -c '(cd packages/database && npm run db:migrate:deploy)'
run_with_timeout 300 "database migration" sh -c '(cd packages/database && npm run db:migrate:deploy)'
echo "🗃️ Running SAML database setup..."
run_with_timeout 60 "SAML database setup" sh -c '(cd packages/database && npm run db:create-saml-database:deploy)'

View File

@@ -13,8 +13,6 @@ This guide explains the settings you need to use to configure SAML with your Ide
**Entity ID / Identifier / Audience URI / Audience Restriction:** [https://saml.formbricks.com](https://saml.formbricks.com)
> **Note:** [https://saml.formbricks.com](https://saml.formbricks.com) is hardcoded in Formbricks — do not replace it with your instance URL. It is the fixed SP Entity ID and must match exactly as shown in SAML assertions.
**Response:** Signed
**Assertion Signature:** Signed
@@ -79,7 +77,7 @@ This guide explains the settings you need to use to configure SAML with your Ide
</Step>
<Step title="Enter the SAML Integration Settings as shown and click Next">
- **Single Sign-On URL**: `https://<your-formbricks-instance>/api/auth/saml/callback` or `http://localhost:3000/api/auth/saml/callback` (if you are running Formbricks locally)
- **Audience URI (SP Entity ID)**: `https://saml.formbricks.com` (hardcoded; do not replace with your instance URL)
- **Audience URI (SP Entity ID)**: `https://saml.formbricks.com`
<img src="/images/development/guides/auth-and-provision/okta/saml-integration-settings.webp" />
</Step>
<Step title="Fill the fields mapping as shown and click Next">

View File

@@ -1,8 +0,0 @@
#!/bin/bash
# This is a better (faster) alternative to the built-in Nix support
if ! has nix_direnv_version || ! nix_direnv_version 3.0.4; then
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.4/direnvrc" "sha256-DzlYZ33mWF/Gs8DDeyjr8mnVmQGx7ASYqA5WlxwvBG4="
fi
use flake

3
infra/.gitignore vendored
View File

@@ -1,3 +0,0 @@
.terraform/
builds
/.direnv/

61
infra/flake.lock generated
View File

@@ -1,61 +0,0 @@
{
"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
}

View File

@@ -1,30 +0,0 @@
{
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
];
};
}
);
}

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