Compare commits

..

4 Commits

Author SHA1 Message Date
Dhruwang ddeef4096f fix: test 2026-02-26 18:39:42 +05:30
Dhruwang 9fe4678c47 refactor: improve error handling in wrapThrows and wrapThrowsAsync functions; simplify CSS selector matching logic 2026-02-26 18:10:18 +05:30
Dhruwang 49acc1cbb8 Merge branch 'main' of https://github.com/formbricks/formbricks into fix/nested-click-target-delegate 2026-02-26 16:02:49 +05:30
bharathkumar39293 e29300df2c fix(js-core): use closest() fallback for nested click target matching
When a user clicks a child element inside a button or div matched by
a CSS selector action (e.g. clicking the <svg> or <span> inside
<button class=my-btn>), event.target is the child, not the button.

Previously, evaluateNoCodeConfigClick() only called:
  targetElement.matches(selector)

This returned false for child elements even though an ancestor matched,
silently dropping the click action.

Fix: resolve matchedElement by trying direct .matches() first, then
falling back to .closest(cssSelector) to find the nearest ancestor.
Only if neither matches does the function return false.

Also moved innerHtml check to use matchedElement instead of the raw
click target, so element attributes are read from the correct node.

Regression tests added for:
- Child <span> click inside a matched button → now triggers correctly
- Child with no matching ancestor → still returns false
- Direct target click → closest() not called (fast path preserved)

Fixes: https://github.com/formbricks/formbricks/issues/7314
2026-02-22 07:49:20 +06:00
1239 changed files with 24493 additions and 52802 deletions
-9
View File
@@ -1,9 +0,0 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "formbricks"
[setup]
script = '''
pnpm install
pnpm dev:setup
'''
+1 -41
View File
@@ -94,12 +94,6 @@ EMAIL_VERIFICATION_DISABLED=1
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too. # Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
PASSWORD_RESET_DISABLED=1 PASSWORD_RESET_DISABLED=1
# Password reset token lifetime in minutes. Must be between 5 and 120 if set.
# PASSWORD_RESET_TOKEN_LIFETIME_MINUTES=30
# Development-only helper: log the password reset link to the server console instead of sending reset emails.
# DEBUG_SHOW_RESET_LINK=1
# Email login. Disable the ability for users to login with email. # Email login. Disable the ability for users to login with email.
# EMAIL_AUTH_DISABLED=1 # EMAIL_AUTH_DISABLED=1
@@ -138,31 +132,6 @@ AZUREAD_CLIENT_ID=
AZUREAD_CLIENT_SECRET= AZUREAD_CLIENT_SECRET=
AZUREAD_TENANT_ID= AZUREAD_TENANT_ID=
# Configure Formbricks AI at the instance level
# Set the provider used for AI features on this instance.
# Accepted values for AI_PROVIDER: aws, gcp, azure
# Set AI_MODEL to the provider-specific model or deployment name and configure the matching credentials below.
# AI_PROVIDER=gcp
# AI_MODEL=gemini-2.5-flash
# Google Vertex AI credentials
# AI_GCP_PROJECT=
# AI_GCP_LOCATION=
# AI_GCP_CREDENTIALS_JSON=
# AI_GCP_APPLICATION_CREDENTIALS=
# Amazon Bedrock credentials
# AI_AWS_REGION=
# AI_AWS_ACCESS_KEY_ID=
# AI_AWS_SECRET_ACCESS_KEY=
# AI_AWS_SESSION_TOKEN=
# Azure AI / Microsoft Foundry credentials
# AI_AZURE_BASE_URL=
# AI_AZURE_RESOURCE_NAME=
# AI_AZURE_API_KEY=
# AI_AZURE_API_VERSION=v1
# OpenID Connect (OIDC) configuration # OpenID Connect (OIDC) configuration
# OIDC_CLIENT_ID= # OIDC_CLIENT_ID=
# OIDC_CLIENT_SECRET= # OIDC_CLIENT_SECRET=
@@ -181,7 +150,6 @@ NOTION_OAUTH_CLIENT_ID=
NOTION_OAUTH_CLIENT_SECRET= NOTION_OAUTH_CLIENT_SECRET=
# Stripe Billing Variables # Stripe Billing Variables
STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY= STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET= STRIPE_WEBHOOK_SECRET=
@@ -216,14 +184,6 @@ ENTERPRISE_LICENSE_KEY=
# Ignore Rate Limiting across the Formbricks app # Ignore Rate Limiting across the Formbricks app
# RATE_LIMITING_DISABLED=1 # RATE_LIMITING_DISABLED=1
# Disable telemetry reporting (usage stats sent to Formbricks). Ignored when an EE license is active.
# TELEMETRY_DISABLED=1
# Allow webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x)
# WARNING: Only enable this if you understand the SSRF risks. Useful for self-hosted instances
# that need to send webhooks to internal services.
# DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS=1
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics) # OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 # OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf # OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
@@ -270,4 +230,4 @@ REDIS_URL=redis://localhost:6379
# Lingo.dev API key for translation generation # Lingo.dev API key for translation generation
LINGO_API_KEY=your_api_key_here LINGODOTDEV_API_KEY=your_api_key_here
@@ -285,14 +285,12 @@ runs:
encryption_key=${{ env.DUMMY_ENCRYPTION_KEY }} encryption_key=${{ env.DUMMY_ENCRYPTION_KEY }}
redis_url=${{ env.DUMMY_REDIS_URL }} redis_url=${{ env.DUMMY_REDIS_URL }}
sentry_auth_token=${{ env.SENTRY_AUTH_TOKEN }} sentry_auth_token=${{ env.SENTRY_AUTH_TOKEN }}
posthog_key=${{ env.POSTHOG_KEY }}
env: env:
DEPOT_PROJECT_TOKEN: ${{ env.DEPOT_PROJECT_TOKEN }} DEPOT_PROJECT_TOKEN: ${{ env.DEPOT_PROJECT_TOKEN }}
DUMMY_DATABASE_URL: ${{ env.DUMMY_DATABASE_URL }} DUMMY_DATABASE_URL: ${{ env.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ env.DUMMY_ENCRYPTION_KEY }} DUMMY_ENCRYPTION_KEY: ${{ env.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ env.DUMMY_REDIS_URL }} DUMMY_REDIS_URL: ${{ env.DUMMY_REDIS_URL }}
SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }}
POSTHOG_KEY: ${{ env.POSTHOG_KEY }}
- name: Sign GHCR image (GHCR only) - name: Sign GHCR image (GHCR only)
if: ${{ inputs.registry_type == 'ghcr' && (github.event_name == 'workflow_call' || github.event_name == 'release' || github.event_name == 'workflow_dispatch') }} if: ${{ inputs.registry_type == 'ghcr' && (github.event_name == 'workflow_call' || github.event_name == 'release' || github.event_name == 'workflow_dispatch') }}
-1
View File
@@ -92,4 +92,3 @@ jobs:
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }} DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }} DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
+1 -1
View File
@@ -45,7 +45,7 @@ yarn-error.log*
.direnv .direnv
# Playwright # Playwright
**/test-results/ /test-results/
/playwright-report/ /playwright-report/
/blob-report/ /blob-report/
/playwright/.cache/ /playwright/.cache/
+2
View File
@@ -0,0 +1,2 @@
echo "{\"branchName\": \"$(git rev-parse --abbrev-ref HEAD)\"}" > ./branch.json
prettier --write ./branch.json
+1 -13
View File
@@ -1,13 +1 @@
#!/usr/bin/env sh pnpm lint-staged
if command -v pnpm >/dev/null 2>&1; then
pnpm lint-staged
elif command -v npm >/dev/null 2>&1; then
npm exec --yes pnpm@10.32.1 lint-staged
elif command -v corepack >/dev/null 2>&1; then
corepack pnpm lint-staged
else
echo "Error: pnpm, npm, and corepack are unavailable in this Git hook PATH."
echo "Install Node.js tooling or update your PATH, then retry the commit."
exit 127
fi
-8
View File
@@ -52,14 +52,6 @@ We are using SonarQube to identify code smells and security hotspots.
- Translations are in `apps/web/locales/`. Default is `en-US.json`. - Translations are in `apps/web/locales/`. Default is `en-US.json`.
- Lingo.dev is automatically translating strings from en-US into other languages on commit. Run `pnpm i18n` to generate missing translations and validate keys. - Lingo.dev is automatically translating strings from en-US into other languages on commit. Run `pnpm i18n` to generate missing translations and validate keys.
## Date and Time Rendering
- All user-facing dates and times must use shared formatting helpers instead of ad hoc `date-fns`, `Intl`, or `toLocale*` calls in components.
- Locale for display must come from the app language source of truth (`user.locale`, `getLocale()`, or `i18n.resolvedLanguage`), not browser defaults or implicit `undefined` locale behavior.
- Locale and time zone are different concerns: locale controls formatting, time zone controls the represented clock/calendar moment.
- Never infer a time zone from locale. If a product-level time zone source of truth exists, use it explicitly; otherwise preserve the existing semantic meaning of the stored value and avoid introducing browser-dependent conversions.
- Machine-facing values for storage, APIs, exports, integrations, and logs must remain stable and non-localized (`ISO 8601` / UTC where applicable).
## Database & Prisma Performance ## Database & Prisma Performance
- Multi-tenancy: All data must be scoped by Organization or Environment. - Multi-tenancy: All data must be scoped by Organization or Environment.
+25 -1
View File
@@ -127,10 +127,34 @@ Formbricks has a hosted cloud offering with a generous free plan to get you up a
Formbricks is available Open-Source under AGPLv3 license. You can host Formbricks on your own servers using Docker without a subscription. Formbricks is available Open-Source under AGPLv3 license. You can host Formbricks on your own servers using Docker without a subscription.
If you opt for self-hosting Formbricks, here are a few options to consider:
#### Docker #### Docker
To get started with self-hosting with Docker, take a look at our [self-hosting docs](https://formbricks.com/docs/self-hosting/deployment). To get started with self-hosting with Docker, take a look at our [self-hosting docs](https://formbricks.com/docs/self-hosting/deployment).
#### Community-managed One Click Hosting
##### Railway
You can deploy Formbricks on [Railway](https://railway.app) using the button below.
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template/PPDzCd)
##### RepoCloud
Or you can also deploy Formbricks on [RepoCloud](https://repocloud.io) using the button below.
[![Deploy on RepoCloud](https://d16t0pc4846x52.cloudfront.net/deploy.png)](https://repocloud.io/details/?app_id=254)
##### Zeabur
Or you can also deploy Formbricks on [Zeabur](https://zeabur.com) using the button below.
[![Deploy to Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/G4TUJL)
<a id="development"></a>
## 👨‍💻 Development ## 👨‍💻 Development
### Prerequisites ### Prerequisites
@@ -223,4 +247,4 @@ We currently do not offer Formbricks white-labeled. That means that we don't sel
The Enterprise Edition allows us to fund the development of Formbricks sustainably. It guarantees that the free and open-source surveying infrastructure we're building will be around for decades to come. The Enterprise Edition allows us to fund the development of Formbricks sustainably. It guarantees that the free and open-source surveying infrastructure we're building will be around for decades to come.
<a id="readme-de"></a> <p align="right"><a href="#top">🔼 Back to top</a></p>
+17 -12
View File
@@ -10,20 +10,25 @@
"build-storybook": "storybook build", "build-storybook": "storybook build",
"clean": "rimraf .turbo node_modules dist storybook-static" "clean": "rimraf .turbo node_modules dist storybook-static"
}, },
"dependencies": {
"@formbricks/survey-ui": "workspace:*"
},
"devDependencies": { "devDependencies": {
"@chromatic-com/storybook": "^5.0.1", "@chromatic-com/storybook": "^5.0.0",
"@storybook/addon-a11y": "10.2.17", "@storybook/addon-a11y": "10.1.11",
"@storybook/addon-links": "10.2.17", "@storybook/addon-links": "10.1.11",
"@storybook/addon-onboarding": "10.2.17", "@storybook/addon-onboarding": "10.1.11",
"@storybook/react-vite": "10.2.17", "@storybook/react-vite": "10.1.11",
"@typescript-eslint/eslint-plugin": "8.57.0", "@typescript-eslint/eslint-plugin": "8.53.0",
"@tailwindcss/vite": "4.2.1", "@tailwindcss/vite": "4.1.18",
"@typescript-eslint/parser": "8.57.0", "@typescript-eslint/parser": "8.53.0",
"@vitejs/plugin-react": "5.1.4", "@vitejs/plugin-react": "5.1.2",
"esbuild": "0.25.12",
"eslint-plugin-react-refresh": "0.4.26", "eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.2.17", "eslint-plugin-storybook": "10.1.11",
"storybook": "10.2.17", "prop-types": "15.8.1",
"storybook": "10.1.11",
"vite": "7.3.1", "vite": "7.3.1",
"@storybook/addon-docs": "10.2.17" "@storybook/addon-docs": "10.1.11"
} }
} }
-6
View File
@@ -1,6 +0,0 @@
const baseConfig = require("../../.prettierrc.js");
module.exports = {
...baseConfig,
tailwindConfig: "./tailwind.config.js",
};
+6 -14
View File
@@ -18,7 +18,7 @@ FROM node:24-alpine3.23 AS base
FROM base AS installer FROM base AS installer
# Enable corepack and prepare pnpm # Enable corepack and prepare pnpm
RUN npm install --ignore-scripts -g corepack@latest RUN npm install --ignore-scripts -g corepack@latest
RUN corepack enable RUN corepack enable
RUN corepack prepare pnpm@10.28.2 --activate RUN corepack prepare pnpm@10.28.2 --activate
@@ -67,7 +67,6 @@ RUN --mount=type=secret,id=database_url \
--mount=type=secret,id=encryption_key \ --mount=type=secret,id=encryption_key \
--mount=type=secret,id=redis_url \ --mount=type=secret,id=redis_url \
--mount=type=secret,id=sentry_auth_token \ --mount=type=secret,id=sentry_auth_token \
--mount=type=secret,id=posthog_key \
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web... /tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
# #
@@ -75,10 +74,9 @@ RUN --mount=type=secret,id=database_url \
# #
FROM base AS runner FROM base AS runner
# Upgrade Alpine system packages to pick up security patches, update npm to latest, then create user # Update npm to latest, then create user
# Note: npm's bundled tar has a known vulnerability but npm is only used during build, not at runtime # Note: npm's bundled tar has a known vulnerability but npm is only used during build, not at runtime
RUN apk update && apk upgrade --no-cache \ RUN npm install --ignore-scripts -g npm@latest \
&& npm install --ignore-scripts -g npm@latest \
&& addgroup -S nextjs \ && addgroup -S nextjs \
&& adduser -S -u 1001 -G nextjs nextjs && adduser -S -u 1001 -G nextjs nextjs
@@ -103,9 +101,6 @@ RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
# Create packages/database directory structure with proper ownership for runtime migrations # Create packages/database directory structure with proper ownership for runtime migrations
RUN mkdir -p ./packages/database/migrations && chown -R nextjs:nextjs ./packages/database RUN mkdir -p ./packages/database/migrations && chown -R nextjs:nextjs ./packages/database
COPY --from=installer /app/packages/database/package.json ./packages/database/package.json
RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
@@ -122,11 +117,8 @@ RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2 COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2 RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
# Runtime migrations import uuid v7 from the database package, so copy the COPY --from=installer /app/node_modules/uuid ./node_modules/uuid
# database package's resolved install instead of the repo-root hoisted version. RUN chmod -R 755 ./node_modules/uuid
COPY --from=installer /app/packages/database/node_modules/uuid ./node_modules/uuid
RUN chmod -R 755 ./node_modules/uuid \
&& node --input-type=module -e "import('uuid').then((module) => { if (typeof module.v7 !== 'function') throw new Error('uuid v7 missing in runtime image'); })"
COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes
RUN chmod -R 755 ./node_modules/@noble/hashes RUN chmod -R 755 ./node_modules/@noble/hashes
@@ -169,4 +161,4 @@ RUN mkdir -p /home/nextjs/apps/web/uploads/ && \
VOLUME /home/nextjs/apps/web/uploads/ VOLUME /home/nextjs/apps/web/uploads/
VOLUME /home/nextjs/apps/web/saml-connection VOLUME /home/nextjs/apps/web/saml-connection
CMD ["/home/nextjs/start.sh"] CMD ["/home/nextjs/start.sh"]
@@ -69,7 +69,7 @@ export const ConnectWithFormbricks = ({
) : ( ) : (
<div className="flex animate-pulse flex-col items-center space-y-4"> <div className="flex animate-pulse flex-col items-center space-y-4">
<span className="relative flex h-10 w-10"> <span className="relative flex h-10 w-10">
<span className="absolute inline-flex h-full w-full animate-ping-slow rounded-full bg-slate-400 opacity-75"></span> <span className="animate-ping-slow absolute inline-flex h-full w-full rounded-full bg-slate-400 opacity-75"></span>
<span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span> <span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span>
</span> </span>
<p className="pt-4 text-sm font-medium text-slate-600"> <p className="pt-4 text-sm font-medium text-slate-600">
@@ -1,6 +1,5 @@
import { XIcon } from "lucide-react"; import { XIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks"; import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
import { getEnvironment } from "@/lib/environment/service"; import { getEnvironment } from "@/lib/environment/service";
import { getPublicDomain } from "@/lib/getPublicUrl"; import { getPublicDomain } from "@/lib/getPublicUrl";
@@ -21,12 +20,12 @@ const Page = async (props: ConnectPageProps) => {
const environment = await getEnvironment(params.environmentId); const environment = await getEnvironment(params.environmentId);
if (!environment) { if (!environment) {
throw new ResourceNotFoundError(t("common.environment"), params.environmentId); throw new Error(t("common.environment_not_found"));
} }
const project = await getProjectByEnvironmentId(environment.id); const project = await getProjectByEnvironmentId(environment.id);
if (!project) { if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null); throw new Error(t("common.workspace_not_found"));
} }
const channel = project.config.channel || null; const channel = project.config.channel || null;
@@ -47,7 +46,7 @@ const Page = async (props: ConnectPageProps) => {
channel={channel} channel={channel}
/> />
<Button <Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={`/environments/${environment.id}`}> <Link href={`/environments/${environment.id}`}>
@@ -4,10 +4,7 @@ import { AuthorizationError } from "@formbricks/types/errors";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth"; import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
const OnboardingLayout = async (props: { const OnboardingLayout = async (props) => {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params; const params = await props.params;
const { children } = props; const { children } = props;
@@ -2,7 +2,6 @@ import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react"; import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest"; import { afterEach, describe, expect, test } from "vitest";
import { TProject } from "@formbricks/types/project"; import { TProject } from "@formbricks/types/project";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import { TXMTemplate } from "@formbricks/types/templates"; import { TXMTemplate } from "@formbricks/types/templates";
import { replacePresetPlaceholders } from "./utils"; import { replacePresetPlaceholders } from "./utils";
@@ -40,13 +39,13 @@ const mockTemplate: TXMTemplate = {
elements: [ elements: [
{ {
id: "q1", id: "q1",
type: "openText" as TSurveyElementTypeEnum.OpenText, type: "openText" as const,
inputType: "text" as const, inputType: "text" as const,
headline: { default: "$[projectName] Question" }, headline: { default: "$[projectName] Question" },
subheader: { default: "" }, subheader: { default: "" },
required: false, required: false,
placeholder: { default: "" }, placeholder: { default: "" },
charLimit: { enabled: true, max: 1000 }, charLimit: 1000,
}, },
], ],
}, },
@@ -14,7 +14,7 @@ describe("xm-templates", () => {
}); });
test("getXMSurveyDefault returns default survey template", () => { test("getXMSurveyDefault returns default survey template", () => {
const tMock = vi.fn((key: string) => key) as unknown as TFunction; const tMock = vi.fn((key) => key) as TFunction;
const result = getXMSurveyDefault(tMock); const result = getXMSurveyDefault(tMock);
expect(result).toEqual({ expect(result).toEqual({
@@ -29,7 +29,7 @@ describe("xm-templates", () => {
}); });
test("getXMTemplates returns all templates", () => { test("getXMTemplates returns all templates", () => {
const tMock = vi.fn((key: string) => key) as unknown as TFunction; const tMock = vi.fn((key) => key) as TFunction;
const result = getXMTemplates(tMock); const result = getXMTemplates(tMock);
expect(result).toHaveLength(6); expect(result).toHaveLength(6);
@@ -44,7 +44,7 @@ describe("xm-templates", () => {
test("getXMTemplates handles errors gracefully", async () => { test("getXMTemplates handles errors gracefully", async () => {
const tMock = vi.fn(() => { const tMock = vi.fn(() => {
throw new Error("Test error"); throw new Error("Test error");
}) as unknown as TFunction; }) as TFunction;
const result = getXMTemplates(tMock); const result = getXMTemplates(tMock);
@@ -1,7 +1,6 @@
import { XIcon } from "lucide-react"; import { XIcon } from "lucide-react";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import Link from "next/link"; import Link from "next/link";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList"; import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
import { getEnvironment } from "@/lib/environment/service"; import { getEnvironment } from "@/lib/environment/service";
import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service"; import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service";
@@ -24,22 +23,22 @@ const Page = async (props: XMTemplatePageProps) => {
const environment = await getEnvironment(params.environmentId); const environment = await getEnvironment(params.environmentId);
const t = await getTranslate(); const t = await getTranslate();
if (!session) { if (!session) {
throw new AuthenticationError(t("common.not_authenticated")); throw new Error(t("common.session_not_found"));
} }
const user = await getUser(session.user.id); const user = await getUser(session.user.id);
if (!user) { if (!user) {
throw new AuthenticationError(t("common.not_authenticated")); throw new Error(t("common.user_not_found"));
} }
if (!environment) { if (!environment) {
throw new ResourceNotFoundError(t("common.environment"), params.environmentId); throw new Error(t("common.environment_not_found"));
} }
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id); const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
const project = await getProjectByEnvironmentId(environment.id); const project = await getProjectByEnvironmentId(environment.id);
if (!project) { if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null); throw new Error(t("common.workspace_not_found"));
} }
const projects = await getUserProjects(session.user.id, organizationId); const projects = await getUserProjects(session.user.id, organizationId);
@@ -50,7 +49,7 @@ const Page = async (props: XMTemplatePageProps) => {
<XMTemplateList project={project} user={user} environmentId={environment.id} /> <XMTemplateList project={project} user={user} environmentId={environment.id} />
{projects.length >= 2 && ( {projects.length >= 2 && (
<Button <Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={`/environments/${environment.id}/surveys`}> <Link href={`/environments/${environment.id}/surveys`}>
@@ -19,8 +19,8 @@ describe("getTeamsByOrganizationId", () => {
test("returns mapped teams", async () => { test("returns mapped teams", async () => {
const mockTeams = [ const mockTeams = [
{ id: "t1", name: "Team 1", createdAt: new Date(), updatedAt: new Date(), organizationId: "org1" }, { id: "t1", name: "Team 1" },
{ id: "t2", name: "Team 2", createdAt: new Date(), updatedAt: new Date(), organizationId: "org1" }, { id: "t2", name: "Team 2" },
]; ];
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockTeams); vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockTeams);
const result = await getTeamsByOrganizationId("org1"); const result = await getTeamsByOrganizationId("org1");
@@ -22,10 +22,12 @@ export const getTeamsByOrganizationId = reactCache(
}, },
}); });
return teams.map((team: TOrganizationTeam) => ({ const projectTeams = teams.map((team) => ({
id: team.id, id: team.id,
name: team.name, name: team.name,
})); }));
return projectTeams;
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message); throw new DatabaseError(error.message);
@@ -42,7 +42,7 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
return ( return (
<aside <aside
className={cn( className={cn(
"z-40 flex w-sidebar-collapsed flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100" "w-sidebar-collapsed z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100"
)}> )}>
<Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} /> <Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} />
@@ -5,10 +5,7 @@ import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getUserProjects } from "@/lib/project/service"; import { getUserProjects } from "@/lib/project/service";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
const LandingLayout = async (props: { const LandingLayout = async (props) => {
params: Promise<{ organizationId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params; const params = await props.params;
const { children } = props; const { children } = props;
@@ -10,7 +10,7 @@ import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Header } from "@/modules/ui/components/header"; import { Header } from "@/modules/ui/components/header";
const Page = async (props: { params: Promise<{ organizationId: string }> }) => { const Page = async (props) => {
const params = await props.params; const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
@@ -26,8 +26,7 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled(); const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id); const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
const { isMember, isBilling } = getAccessFlags(membership?.role); const { isMember } = getAccessFlags(membership?.role);
const isMembershipPending = membership?.role === undefined;
return ( return (
<div className="flex min-h-full min-w-full flex-row"> <div className="flex min-h-full min-w-full flex-row">
@@ -46,8 +45,6 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
isOwnerOrManager={false} isOwnerOrManager={false}
isAccessControlAllowed={false} isAccessControlAllowed={false}
isMember={isMember} isMember={isMember}
isBilling={isBilling}
isMembershipPending={isMembershipPending}
environments={[]} environments={[]}
/> />
</div> </div>
@@ -1,6 +1,6 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors"; import { AuthorizationError } from "@formbricks/types/errors";
import { canUserAccessOrganization } from "@/lib/organization/auth"; import { canUserAccessOrganization } from "@/lib/organization/auth";
import { getOrganization } from "@/lib/organization/service"; import { getOrganization } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
@@ -8,10 +8,7 @@ import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { ToasterClient } from "@/modules/ui/components/toaster-client"; import { ToasterClient } from "@/modules/ui/components/toaster-client";
const ProjectOnboardingLayout = async (props: { const ProjectOnboardingLayout = async (props) => {
params: Promise<{ organizationId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params; const params = await props.params;
const { children } = props; const { children } = props;
@@ -25,7 +22,7 @@ const ProjectOnboardingLayout = async (props: {
const user = await getUser(session.user.id); const user = await getUser(session.user.id);
if (!user) { if (!user) {
throw new AuthenticationError(t("common.not_authenticated")); throw new Error(t("common.user_not_found"));
} }
const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId); const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
@@ -36,7 +33,7 @@ const ProjectOnboardingLayout = async (props: {
const organization = await getOrganization(params.organizationId); const organization = await getOrganization(params.organizationId);
if (!organization) { if (!organization) {
throw new ResourceNotFoundError(t("common.organization"), params.organizationId); throw new Error(t("common.organization_not_found"));
} }
return ( return (
@@ -50,7 +50,7 @@ const Page = async (props: ChannelPageProps) => {
<OnboardingOptionsContainer options={channelOptions} /> <OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && ( {projects.length >= 1 && (
<Button <Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={"/"}> <Link href={"/"}>
@@ -1,6 +1,5 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
import { getOrganization } from "@/lib/organization/service"; import { getOrganization } from "@/lib/organization/service";
@@ -9,10 +8,7 @@ import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils"; import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
const OnboardingLayout = async (props: { const OnboardingLayout = async (props) => {
params: Promise<{ organizationId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params; const params = await props.params;
const { children } = props; const { children } = props;
@@ -29,13 +25,11 @@ const OnboardingLayout = async (props: {
const organization = await getOrganization(params.organizationId); const organization = await getOrganization(params.organizationId);
if (!organization) { if (!organization) {
throw new ResourceNotFoundError(t("common.organization"), params.organizationId); throw new Error(t("common.organization_not_found"));
} }
const [organizationProjectsLimit, organizationProjectsCount] = await Promise.all([ const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
getOrganizationProjectsLimit(organization.id), const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
getOrganizationProjectsCount(organization.id),
]);
if (organizationProjectsCount >= organizationProjectsLimit) { if (organizationProjectsCount >= organizationProjectsLimit) {
return redirect(`/`); return redirect(`/`);
@@ -47,7 +47,7 @@ const Page = async (props: ModePageProps) => {
<OnboardingOptionsContainer options={channelOptions} /> <OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && ( {projects.length >= 1 && (
<Button <Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={"/"}> <Link href={"/"}>
@@ -1,22 +0,0 @@
import { getTranslate } from "@/lingodotdev/server";
import { SelectPlanCard } from "@/modules/ee/billing/components/select-plan-card";
import { Header } from "@/modules/ui/components/header";
interface SelectPlanOnboardingProps {
organizationId: string;
}
export const SelectPlanOnboarding = async ({ organizationId }: SelectPlanOnboardingProps) => {
const t = await getTranslate();
const nextUrl = `/organizations/${organizationId}/workspaces/new/mode`;
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-8">
<Header
title={t("environments.settings.billing.select_plan_header_title")}
subtitle={t("environments.settings.billing.select_plan_header_subtitle")}
/>
<SelectPlanCard nextUrl={nextUrl} organizationId={organizationId} />
</div>
);
};
@@ -1,42 +0,0 @@
import { redirect } from "next/navigation";
import { TCloudBillingPlan } from "@formbricks/types/organizations";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getOrganizationBillingWithReadThroughSync } from "@/modules/ee/billing/lib/organization-billing";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { SelectPlanOnboarding } from "./components/select-plan-onboarding";
const PAID_PLANS = new Set<TCloudBillingPlan>(["pro", "scale", "custom"]);
interface PlanPageProps {
params: Promise<{
organizationId: string;
}>;
}
const Page = async (props: PlanPageProps) => {
const params = await props.params;
if (!IS_FORMBRICKS_CLOUD) {
return redirect(`/organizations/${params.organizationId}/workspaces/new/mode`);
}
const { session } = await getOrganizationAuth(params.organizationId);
if (!session?.user) {
return redirect(`/auth/login`);
}
// Users with an existing paid/trial subscription should not be shown the trial page.
// Redirect them directly to the next onboarding step.
const billing = await getOrganizationBillingWithReadThroughSync(params.organizationId);
const currentPlan = billing?.stripe?.plan;
const hasExistingSubscription = currentPlan !== undefined && PAID_PLANS.has(currentPlan);
if (hasExistingSubscription) {
return redirect(`/organizations/${params.organizationId}/workspaces/new/mode`);
}
return <SelectPlanOnboarding organizationId={params.organizationId} />;
};
export default Page;
@@ -228,7 +228,7 @@ export const ProjectSettings = ({
</FormProvider> </FormProvider>
</div> </div>
<div className="relative flex w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 p-6 shadow"> <div className="relative flex h-[30rem] w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 shadow">
{logoUrl && ( {logoUrl && (
<Image <Image
src={logoUrl} src={logoUrl}
@@ -239,16 +239,18 @@ export const ProjectSettings = ({
/> />
)} )}
<p className="text-sm text-slate-400">{t("common.preview")}</p> <p className="text-sm text-slate-400">{t("common.preview")}</p>
<SurveyInline <div className="z-0 h-3/4 w-3/4">
appUrl={publicDomain} <SurveyInline
isPreviewMode={true} appUrl={publicDomain}
survey={previewSurvey(projectName || t("common.my_product"), t)} isPreviewMode={true}
styling={previewStyling} survey={previewSurvey(projectName || "my Product", t)}
isBrandingEnabled={false} styling={previewStyling}
languageCode="default" isBrandingEnabled={false}
onFileUpload={async (file) => file.name} languageCode="default"
autoFocus={false} onFileUpload={async (file) => file.name}
/> autoFocus={false}
/>
</div>
</div> </div>
<CreateTeamModal <CreateTeamModal
open={createTeamModalOpen} open={createTeamModalOpen}
@@ -1,7 +1,6 @@
import { XIcon } from "lucide-react"; import { XIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project"; import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding"; import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings"; import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
@@ -43,10 +42,10 @@ const Page = async (props: ProjectSettingsPageProps) => {
const organizationTeams = await getTeamsByOrganizationId(params.organizationId); const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
const isAccessControlAllowed = await getAccessControlPermission(organization.id); const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
if (!organizationTeams) { if (!organizationTeams) {
throw new ResourceNotFoundError(t("common.team"), null); throw new Error(t("common.organization_teams_not_found"));
} }
const publicDomain = getPublicDomain(); const publicDomain = getPublicDomain();
@@ -70,7 +69,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
/> />
{projects.length >= 1 && ( {projects.length >= 1 && (
<Button <Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700" className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={"/"}> <Link href={"/"}>
@@ -16,7 +16,7 @@ interface OnboardingOptionsContainerProps {
} }
export const OnboardingOptionsContainer = ({ options }: OnboardingOptionsContainerProps) => { export const OnboardingOptionsContainer = ({ options }: OnboardingOptionsContainerProps) => {
const getOptionCard = (option: OnboardingOptionsContainerProps["options"][number]) => { const getOptionCard = (option) => {
const Icon = option.icon; const Icon = option.icon;
return ( return (
<OptionCard <OptionCard
@@ -1,7 +1,7 @@
import { z } from "zod"; import { z } from "zod";
export const ZOrganizationTeam = z.object({ export const ZOrganizationTeam = z.object({
id: z.cuid2(), id: z.string().cuid2(),
name: z.string(), name: z.string(),
}); });
@@ -1,12 +1,8 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getEnvironment } from "@/lib/environment/service"; import { getEnvironment } from "@/lib/environment/service";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils"; import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
const SurveyEditorEnvironmentLayout = async (props: { const SurveyEditorEnvironmentLayout = async (props) => {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params; const params = await props.params;
const { children } = props; const { children } = props;
@@ -18,13 +14,13 @@ const SurveyEditorEnvironmentLayout = async (props: {
} }
if (!user) { if (!user) {
throw new AuthenticationError(t("common.not_authenticated")); throw new Error(t("common.user_not_found"));
} }
const environment = await getEnvironment(params.environmentId); const environment = await getEnvironment(params.environmentId);
if (!environment) { if (!environment) {
throw new ResourceNotFoundError(t("common.environment"), params.environmentId); throw new Error(t("common.environment_not_found"));
} }
return ( return (
@@ -6,26 +6,15 @@ import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Confetti } from "@/modules/ui/components/confetti"; import { Confetti } from "@/modules/ui/components/confetti";
const BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY = "billingConfirmationEnvironmentId"; interface ConfirmationPageProps {
environmentId: string;
}
export const ConfirmationPage = () => { export const ConfirmationPage = ({ environmentId }: ConfirmationPageProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [showConfetti, setShowConfetti] = useState(false); const [showConfetti, setShowConfetti] = useState(false);
const [resolvedEnvironmentId, setResolvedEnvironmentId] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
setShowConfetti(true); setShowConfetti(true);
if (globalThis.window === undefined) {
return;
}
const storedEnvironmentId = globalThis.window.sessionStorage.getItem(
BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY
);
if (storedEnvironmentId) {
setResolvedEnvironmentId(storedEnvironmentId);
}
}, []); }, []);
return ( return (
@@ -41,12 +30,7 @@ export const ConfirmationPage = () => {
</p> </p>
</div> </div>
<Button asChild className="w-full justify-center"> <Button asChild className="w-full justify-center">
<Link <Link href={`/environments/${environmentId}/settings/billing`}>
href={
resolvedEnvironmentId
? `/environments/${resolvedEnvironmentId}/settings/billing`
: "/environments"
}>
{t("billing_confirmation.back_to_billing_overview")} {t("billing_confirmation.back_to_billing_overview")}
</Link> </Link>
</Button> </Button>
@@ -3,10 +3,13 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const Page = async () => { const Page = async (props) => {
const searchParams = await props.searchParams;
const { environmentId } = searchParams;
return ( return (
<PageContentWrapper> <PageContentWrapper>
<ConfirmationPage /> <ConfirmationPage environmentId={environmentId?.toString()} />
</PageContentWrapper> </PageContentWrapper>
); );
}; };
@@ -2,11 +2,7 @@
import { z } from "zod"; import { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
AuthorizationError,
OperationNotAllowedError,
ResourceNotFoundError,
} from "@formbricks/types/errors";
import { ZProjectUpdateInput } from "@formbricks/types/project"; import { ZProjectUpdateInput } from "@formbricks/types/project";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service"; import { getOrganization } from "@/lib/organization/service";
@@ -14,6 +10,7 @@ import { getOrganizationProjectsCount } from "@/lib/project/service";
import { updateUser } from "@/lib/user/service"; import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { import {
getAccessControlPermission, getAccessControlPermission,
@@ -28,63 +25,67 @@ const ZCreateProjectAction = z.object({
data: ZProjectUpdateInput, data: ZProjectUpdateInput,
}); });
export const createProjectAction = authenticatedActionClient.inputSchema(ZCreateProjectAction).action( export const createProjectAction = authenticatedActionClient.schema(ZCreateProjectAction).action(
withAuditLogging("created", "project", async ({ ctx, parsedInput }) => { withAuditLogging(
const { user } = ctx; "created",
"project",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const { user } = ctx;
const organizationId = parsedInput.organizationId; const organizationId = parsedInput.organizationId;
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: user.id, userId: user.id,
organizationId: parsedInput.organizationId, organizationId: parsedInput.organizationId,
access: [ access: [
{ {
data: parsedInput.data, data: parsedInput.data,
schema: ZProjectUpdateInput, schema: ZProjectUpdateInput,
type: "organization", type: "organization",
roles: ["owner", "manager"], roles: ["owner", "manager"],
}, },
], ],
}); });
const organization = await getOrganization(organizationId); const organization = await getOrganization(organizationId);
if (!organization) { if (!organization) {
throw new ResourceNotFoundError("Organization", organizationId); throw new Error("Organization not found");
}
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
if (organizationProjectsCount >= organizationProjectsLimit) {
throw new OperationNotAllowedError("Organization workspace limit reached");
}
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
if (!isAccessControlAllowed) {
throw new OperationNotAllowedError("You do not have permission to manage roles");
} }
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
if (organizationProjectsCount >= organizationProjectsLimit) {
throw new OperationNotAllowedError("Organization workspace limit reached");
}
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
if (!isAccessControlAllowed) {
throw new OperationNotAllowedError("You do not have permission to manage roles");
}
}
const project = await createProject(parsedInput.organizationId, parsedInput.data);
const updatedNotificationSettings = {
...user.notificationSettings,
alert: {
...user.notificationSettings?.alert,
},
};
await updateUser(user.id, {
notificationSettings: updatedNotificationSettings,
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = project.id;
ctx.auditLoggingCtx.newObject = project;
return project;
} }
)
const project = await createProject(parsedInput.organizationId, parsedInput.data);
const updatedNotificationSettings = {
...user.notificationSettings,
alert: {
...user.notificationSettings?.alert,
},
};
await updateUser(user.id, {
notificationSettings: updatedNotificationSettings,
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = project.id;
ctx.auditLoggingCtx.newObject = project;
return project;
})
); );
const ZGetOrganizationsForSwitcherAction = z.object({ const ZGetOrganizationsForSwitcherAction = z.object({
@@ -96,7 +97,7 @@ const ZGetOrganizationsForSwitcherAction = z.object({
* Called on-demand when user opens the organization switcher. * Called on-demand when user opens the organization switcher.
*/ */
export const getOrganizationsForSwitcherAction = authenticatedActionClient export const getOrganizationsForSwitcherAction = authenticatedActionClient
.inputSchema(ZGetOrganizationsForSwitcherAction) .schema(ZGetOrganizationsForSwitcherAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -121,7 +122,7 @@ const ZGetProjectsForSwitcherAction = z.object({
* Called on-demand when user opens the project switcher. * Called on-demand when user opens the project switcher.
*/ */
export const getProjectsForSwitcherAction = authenticatedActionClient export const getProjectsForSwitcherAction = authenticatedActionClient
.inputSchema(ZGetProjectsForSwitcherAction) .schema(ZGetProjectsForSwitcherAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -1,4 +1,3 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation"; import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar"; import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
@@ -30,6 +29,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
isAccessControlAllowed, isAccessControlAllowed,
projectPermission, projectPermission,
license, license,
peopleCount,
responseCount, responseCount,
} = layoutData; } = layoutData;
@@ -38,12 +38,12 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
const { features, lastChecked, isPendingDowngrade, active, status } = license; const { features, lastChecked, isPendingDowngrade, active, status } = license;
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false; const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id); const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const isOwnerOrManager = isOwner || isManager; const isOwnerOrManager = isOwner || isManager;
// Validate that project permission exists for members // Validate that project permission exists for members
if (isMember && !projectPermission) { if (isMember && !projectPermission) {
throw new ResourceNotFoundError(t("common.workspace"), null); throw new Error(t("common.workspace_permission_not_found"));
} }
return ( return (
@@ -52,6 +52,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
<LimitsReachedBanner <LimitsReachedBanner
organization={organization} organization={organization}
environmentId={environment.id} environmentId={environment.id}
peopleCount={peopleCount}
responseCount={responseCount} responseCount={responseCount}
/> />
)} )}
@@ -75,10 +76,6 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
isDevelopment={IS_DEVELOPMENT} isDevelopment={IS_DEVELOPMENT}
membershipRole={membership.role} membershipRole={membership.role}
publicDomain={publicDomain} publicDomain={publicDomain}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={organizationProjectsLimit}
isLicenseActive={active}
isAccessControlAllowed={isAccessControlAllowed}
/> />
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50"> <div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
<TopControlBar <TopControlBar
@@ -2,59 +2,40 @@
import { import {
ArrowUpRightIcon, ArrowUpRightIcon,
Building2Icon,
ChevronRightIcon, ChevronRightIcon,
Cog, Cog,
FoldersIcon,
Loader2,
LogOutIcon, LogOutIcon,
MessageCircle, MessageCircle,
PanelLeftCloseIcon, PanelLeftCloseIcon,
PanelLeftOpenIcon, PanelLeftOpenIcon,
PlusIcon,
RocketIcon, RocketIcon,
SettingsIcon,
UserCircleIcon, UserCircleIcon,
UserIcon, UserIcon,
} from "lucide-react"; } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState, useTransition } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships"; import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations"; import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import {
getOrganizationsForSwitcherAction,
getProjectsForSwitcherAction,
} from "@/app/(app)/environments/[environmentId]/actions";
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink"; import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
import { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils"; import { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils";
import FBLogo from "@/images/formbricks-wordmark.svg"; import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out"; import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions"; import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
import { ProfileAvatar } from "@/modules/ui/components/avatars"; import { ProfileAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu"; } from "@/modules/ui/components/dropdown-menu";
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
import packageJson from "../../../../../package.json"; import packageJson from "../../../../../package.json";
interface NavigationProps { interface NavigationProps {
@@ -66,31 +47,8 @@ interface NavigationProps {
isDevelopment: boolean; isDevelopment: boolean;
membershipRole?: TOrganizationRole; membershipRole?: TOrganizationRole;
publicDomain: string; publicDomain: string;
isMultiOrgEnabled: boolean;
organizationProjectsLimit: number;
isLicenseActive: boolean;
isAccessControlAllowed: boolean;
} }
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
if (pathname.includes("/settings/")) {
return false;
}
const pattern = new RegExp(`/workspace/${settingId}(?:/|$)`);
return pattern.test(pathname);
};
const isActiveOrganizationSetting = (pathname: string, settingId: string): boolean => {
const accountSettingsPattern = /\/settings\/(profile|account|notifications|security|appearance)(?:\/|$)/;
if (accountSettingsPattern.test(pathname)) {
return false;
}
const pattern = new RegExp(`/settings/${settingId}(?:/|$)`);
return pattern.test(pathname);
};
export const MainNavigation = ({ export const MainNavigation = ({
environment, environment,
organization, organization,
@@ -100,10 +58,6 @@ export const MainNavigation = ({
isFormbricksCloud, isFormbricksCloud,
isDevelopment, isDevelopment,
publicDomain, publicDomain,
isMultiOrgEnabled,
organizationProjectsLimit,
isLicenseActive,
isAccessControlAllowed,
}: NavigationProps) => { }: NavigationProps) => {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
@@ -113,12 +67,7 @@ export const MainNavigation = ({
const [latestVersion, setLatestVersion] = useState(""); const [latestVersion, setLatestVersion] = useState("");
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email }); const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const [isPending, startTransition] = useTransition(); const { isManager, isOwner, isBilling } = getAccessFlags(membershipRole);
const { isManager, isOwner, isBilling, isMember } = getAccessFlags(membershipRole);
const isMembershipPending = membershipRole === undefined;
const disabledNavigationMessage = isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action");
const isOwnerOrManager = isManager || isOwner; const isOwnerOrManager = isManager || isOwner;
@@ -155,7 +104,6 @@ export const MainNavigation = ({
icon: MessageCircle, icon: MessageCircle,
isActive: pathname?.includes("/surveys"), isActive: pathname?.includes("/surveys"),
isHidden: false, isHidden: false,
disabled: isMembershipPending || isBilling,
}, },
{ {
href: `/environments/${environment.id}/contacts`, href: `/environments/${environment.id}/contacts`,
@@ -165,17 +113,15 @@ export const MainNavigation = ({
pathname?.includes("/contacts") || pathname?.includes("/contacts") ||
pathname?.includes("/segments") || pathname?.includes("/segments") ||
pathname?.includes("/attributes"), pathname?.includes("/attributes"),
disabled: isMembershipPending || isBilling,
}, },
{ {
name: t("common.configuration"), name: t("common.configuration"),
href: `/environments/${environment.id}/workspace/general`, href: `/environments/${environment.id}/workspace/general`,
icon: Cog, icon: Cog,
isActive: pathname?.includes("/workspace"), isActive: pathname?.includes("/project"),
disabled: isMembershipPending || isBilling,
}, },
], ],
[t, environment.id, pathname, isMembershipPending, isBilling] [t, environment.id, pathname]
); );
const dropdownNavigation = [ const dropdownNavigation = [
@@ -198,183 +144,6 @@ export const MainNavigation = ({
}, },
]; ];
const [isWorkspaceDropdownOpen, setIsWorkspaceDropdownOpen] = useState(false);
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
const [organizations, setOrganizations] = useState<{ id: string; name: string }[]>([]);
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
const [hasInitializedProjects, setHasInitializedProjects] = useState(false);
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
const [workspaceLoadError, setWorkspaceLoadError] = useState<string | null>(null);
const [organizationLoadError, setOrganizationLoadError] = useState<string | null>(null);
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState(false);
const [openProjectLimitModal, setOpenProjectLimitModal] = useState(false);
const renderSwitcherError = (error: string, onRetry: () => void, retryLabel: string) => (
<div className="px-2 py-4">
<p className="mb-2 text-sm text-red-600">{error}</p>
<button onClick={onRetry} className="text-xs text-slate-600 underline hover:text-slate-800">
{retryLabel}
</button>
</div>
);
const projectSettings = [
{
id: "general",
label: t("common.general"),
href: `/environments/${environment.id}/workspace/general`,
},
{
id: "look",
label: t("common.look_and_feel"),
href: `/environments/${environment.id}/workspace/look`,
},
{
id: "app-connection",
label: t("common.website_and_app_connection"),
href: `/environments/${environment.id}/workspace/app-connection`,
},
{
id: "integrations",
label: t("common.integrations"),
href: `/environments/${environment.id}/workspace/integrations`,
},
{
id: "teams",
label: t("common.team_access"),
href: `/environments/${environment.id}/workspace/teams`,
},
{
id: "languages",
label: t("common.survey_languages"),
href: `/environments/${environment.id}/workspace/languages`,
},
{
id: "tags",
label: t("common.tags"),
href: `/environments/${environment.id}/workspace/tags`,
},
];
const organizationSettings = [
{
id: "general",
label: t("common.general"),
href: `/environments/${environment.id}/settings/general`,
},
{
id: "teams",
label: t("common.members_and_teams"),
href: `/environments/${environment.id}/settings/teams`,
},
{
id: "api-keys",
label: t("common.api_keys"),
href: `/environments/${environment.id}/settings/api-keys`,
hidden: !isOwnerOrManager,
},
{
id: "domain",
label: t("common.domain"),
href: `/environments/${environment.id}/settings/domain`,
hidden: isFormbricksCloud,
},
{
id: "billing",
label: t("common.billing"),
href: `/environments/${environment.id}/settings/billing`,
hidden: !isFormbricksCloud,
},
{
id: "enterprise",
label: t("common.enterprise_license"),
href: `/environments/${environment.id}/settings/enterprise`,
hidden: isFormbricksCloud || isMember,
},
];
const loadProjects = useCallback(async () => {
setIsLoadingProjects(true);
setWorkspaceLoadError(null);
try {
const result = await getProjectsForSwitcherAction({ organizationId: organization.id });
if (result?.data) {
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
setProjects(sorted);
} else {
setWorkspaceLoadError(getFormattedErrorMessage(result) || t("common.failed_to_load_workspaces"));
}
} catch (error) {
const formattedError =
typeof error === "object" && error !== null
? getFormattedErrorMessage(error as { serverError?: string; validationErrors?: unknown })
: "";
setWorkspaceLoadError(
formattedError || (error instanceof Error ? error.message : t("common.failed_to_load_workspaces"))
);
} finally {
setIsLoadingProjects(false);
setHasInitializedProjects(true);
}
}, [organization.id, t]);
useEffect(() => {
if (!isWorkspaceDropdownOpen || projects.length > 0 || isLoadingProjects || workspaceLoadError) {
return;
}
loadProjects();
}, [isWorkspaceDropdownOpen, projects.length, isLoadingProjects, workspaceLoadError, loadProjects]);
const loadOrganizations = useCallback(async () => {
setIsLoadingOrganizations(true);
setOrganizationLoadError(null);
try {
const result = await getOrganizationsForSwitcherAction({ organizationId: organization.id });
if (result?.data) {
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
setOrganizations(sorted);
} else {
setOrganizationLoadError(
getFormattedErrorMessage(result) || t("common.failed_to_load_organizations")
);
}
} catch (error) {
const formattedError =
typeof error === "object" && error !== null
? getFormattedErrorMessage(error as { serverError?: string; validationErrors?: unknown })
: "";
setOrganizationLoadError(
formattedError || (error instanceof Error ? error.message : t("common.failed_to_load_organizations"))
);
} finally {
setIsLoadingOrganizations(false);
}
}, [organization.id, t]);
useEffect(() => {
if (
!isOrganizationDropdownOpen ||
organizations.length > 0 ||
isLoadingOrganizations ||
organizationLoadError
) {
return;
}
loadOrganizations();
}, [
isOrganizationDropdownOpen,
organizations.length,
isLoadingOrganizations,
organizationLoadError,
loadOrganizations,
]);
useEffect(() => { useEffect(() => {
async function loadReleases() { async function loadReleases() {
const res = await getLatestStableFbReleaseAction(); const res = await getLatestStableFbReleaseAction();
@@ -390,93 +159,7 @@ export const MainNavigation = ({
if (isOwnerOrManager) loadReleases(); if (isOwnerOrManager) loadReleases();
}, [isOwnerOrManager]); }, [isOwnerOrManager]);
const trialDaysRemaining = useMemo(() => { const mainNavigationLink = `/environments/${environment.id}/${isBilling ? "settings/billing/" : "surveys/"}`;
if (!isFormbricksCloud || organization.billing?.stripe?.subscriptionStatus !== "trialing") return null;
const trialEnd = organization.billing.stripe.trialEnd;
if (!trialEnd) return null;
const ts = new Date(trialEnd).getTime();
if (!Number.isFinite(ts)) return null;
const msPerDay = 86_400_000;
return Math.ceil((ts - Date.now()) / msPerDay);
}, [
isFormbricksCloud,
organization.billing?.stripe?.subscriptionStatus,
organization.billing?.stripe?.trialEnd,
]);
const mainNavigationLink = isBilling
? getBillingFallbackPath(environment.id, isFormbricksCloud)
: `/environments/${environment.id}/surveys/`;
const handleProjectChange = (projectId: string) => {
if (projectId === project.id) return;
startTransition(() => {
router.push(`/workspaces/${projectId}/`);
});
};
const handleOrganizationChange = (organizationId: string) => {
if (organizationId === organization.id) return;
startTransition(() => {
router.push(`/organizations/${organizationId}/`);
});
};
const handleSettingNavigation = (href: string) => {
startTransition(() => {
router.push(href);
});
};
const handleProjectCreate = () => {
if (!hasInitializedProjects || isLoadingProjects) {
return;
}
if (projects.length >= organizationProjectsLimit) {
setOpenProjectLimitModal(true);
return;
}
setOpenCreateProjectModal(true);
};
const projectLimitModalButtons = (): [ModalButton, ModalButton] => {
if (isFormbricksCloud) {
return [
{
text: t("environments.settings.billing.upgrade"),
href: `/environments/${environment.id}/settings/billing`,
},
{
text: t("common.cancel"),
onClick: () => setOpenProjectLimitModal(false),
},
];
}
return [
{
text: t("environments.settings.billing.upgrade"),
href: isLicenseActive
? `/environments/${environment.id}/settings/enterprise`
: "https://formbricks.com/upgrade-self-hosted-license",
},
{
text: t("common.cancel"),
onClick: () => setOpenProjectLimitModal(false),
},
];
};
const switcherTriggerClasses = cn(
"w-full border-t px-3 py-3 text-left transition-colors duration-200 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-500 focus-visible:ring-inset",
isCollapsed ? "flex items-center justify-center" : ""
);
const switcherIconClasses =
"flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-slate-600";
const isInitialProjectsLoading = isWorkspaceDropdownOpen && !hasInitializedProjects && !workspaceLoadError;
return ( return (
<> <>
@@ -505,7 +188,7 @@ export const MainNavigation = ({
size="icon" size="icon"
onClick={toggleSidebar} onClick={toggleSidebar}
className={cn( className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent" "rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
)}> )}>
{isCollapsed ? ( {isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} /> <PanelLeftOpenIcon strokeWidth={1.5} />
@@ -516,24 +199,24 @@ export const MainNavigation = ({
</div> </div>
{/* Main Nav Switch */} {/* Main Nav Switch */}
<ul> {!isBilling && (
{mainNavigation.map( <ul>
(item) => {mainNavigation.map(
!item.isHidden && ( (item) =>
<NavigationLink !item.isHidden && (
key={item.name} <NavigationLink
href={item.href} key={item.name}
isActive={item.isActive} href={item.href}
isCollapsed={isCollapsed} isActive={item.isActive}
isTextVisible={isTextVisible} isCollapsed={isCollapsed}
disabled={item.disabled} isTextVisible={isTextVisible}
disabledMessage={item.disabled ? disabledNavigationMessage : undefined} linkText={item.name}>
linkText={item.name}> <item.icon strokeWidth={1.5} />
<item.icon strokeWidth={1.5} /> </NavigationLink>
</NavigationLink> )
) )}
)} </ul>
</ul> )}
</div> </div>
<div> <div>
@@ -550,217 +233,38 @@ export const MainNavigation = ({
</Link> </Link>
)} )}
{/* Trial Days Remaining */} {/* User Switch */}
{!isCollapsed && isFormbricksCloud && trialDaysRemaining !== null && ( <div className="flex items-center">
<Link href={`/environments/${environment.id}/settings/billing`} className="m-2 block">
<TrialAlert trialDaysRemaining={trialDaysRemaining} size="small" />
</Link>
)}
<div className="flex flex-col">
<DropdownMenu onOpenChange={setIsWorkspaceDropdownOpen}>
<DropdownMenuTrigger asChild id="workspaceDropdownTrigger" className={switcherTriggerClasses}>
<button
type="button"
aria-label={isCollapsed ? t("common.change_workspace") : undefined}
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
<span className={switcherIconClasses}>
<FoldersIcon className="h-4 w-4" strokeWidth={1.5} />
</span>
{!isCollapsed && !isTextVisible && (
<>
<div className="grow overflow-hidden">
<p className="truncate text-sm font-bold text-slate-700">{project.name}</p>
<p className="text-sm text-slate-500">{t("common.workspace")}</p>
</div>
{isPending && (
<Loader2 className="h-4 w-4 animate-spin text-slate-600" strokeWidth={1.5} />
)}
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<FoldersIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.change_workspace")}
</div>
{(isLoadingProjects || isInitialProjectsLoading) && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingProjects &&
!isInitialProjectsLoading &&
workspaceLoadError &&
renderSwitcherError(
workspaceLoadError,
() => {
setWorkspaceLoadError(null);
setProjects([]);
},
t("common.try_again")
)}
{!isLoadingProjects && !isInitialProjectsLoading && !workspaceLoadError && (
<>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{projects.map((proj) => (
<DropdownMenuCheckboxItem
key={proj.id}
checked={proj.id === project.id}
onClick={() => handleProjectChange(proj.id)}
className="cursor-pointer">
{proj.name}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isOwnerOrManager && (
<DropdownMenuCheckboxItem
onClick={handleProjectCreate}
className="w-full cursor-pointer justify-between">
<span>{t("common.add_new_workspace")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
</>
)}
<DropdownMenuSeparator />
<DropdownMenuGroup>
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<Cog className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.workspace_configuration")}
</div>
{projectSettings.map((setting) => (
<DropdownMenuCheckboxItem
key={setting.id}
checked={isActiveProjectSetting(pathname, setting.id)}
onClick={() => handleSettingNavigation(setting.href)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu onOpenChange={setIsOrganizationDropdownOpen}>
<DropdownMenuTrigger
asChild
id="organizationDropdownTriggerSidebar"
className={switcherTriggerClasses}>
<button
type="button"
aria-label={isCollapsed ? t("common.change_organization") : undefined}
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
<span className={switcherIconClasses}>
<Building2Icon className="h-4 w-4" strokeWidth={1.5} />
</span>
{!isCollapsed && !isTextVisible && (
<>
<div className="grow overflow-hidden">
<p className="truncate text-sm font-bold text-slate-700">{organization.name}</p>
<p className="text-sm text-slate-500">{t("common.organization")}</p>
</div>
{isPending && (
<Loader2 className="h-4 w-4 animate-spin text-slate-600" strokeWidth={1.5} />
)}
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<Building2Icon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.change_organization")}
</div>
{isLoadingOrganizations && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingOrganizations &&
organizationLoadError &&
renderSwitcherError(
organizationLoadError,
() => {
setOrganizationLoadError(null);
setOrganizations([]);
},
t("common.try_again")
)}
{!isLoadingOrganizations && !organizationLoadError && (
<>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{organizations.map((org) => (
<DropdownMenuCheckboxItem
key={org.id}
checked={org.id === organization.id}
onClick={() => handleOrganizationChange(org.id)}
className="cursor-pointer">
{org.name}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isMultiOrgEnabled && (
<DropdownMenuCheckboxItem
onClick={() => setOpenCreateOrganizationModal(true)}
className="w-full cursor-pointer justify-between">
<span>{t("common.create_new_organization")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
</>
)}
<DropdownMenuSeparator />
<DropdownMenuGroup>
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<SettingsIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.organization_settings")}
</div>
{organizationSettings.map((setting) => {
if (setting.hidden) return null;
return (
<DropdownMenuCheckboxItem
key={setting.id}
checked={isActiveOrganizationSetting(pathname, setting.id)}
onClick={() => handleSettingNavigation(setting.href)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
asChild asChild
id="userDropdownTrigger" id="userDropdownTrigger"
className={cn(switcherTriggerClasses, "rounded-br-xl")}> className="w-full rounded-br-xl border-t py-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
<button <div
type="button" className={cn(
aria-label={isCollapsed ? t("common.account_settings") : undefined} "flex cursor-pointer flex-row items-center gap-3",
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}> isCollapsed ? "justify-center px-2" : "px-4"
<span className={switcherIconClasses}> )}>
<ProfileAvatar userId={user.id} /> <ProfileAvatar userId={user.id} />
</span>
{!isCollapsed && !isTextVisible && ( {!isCollapsed && !isTextVisible && (
<> <>
<div className="grow overflow-hidden"> <div
className={cn(isTextVisible ? "opacity-0" : "opacity-100", "grow overflow-hidden")}>
<p <p
title={user?.email} title={user?.email}
className="ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"> className={cn(
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
)}>
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>} {user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
</p> </p>
<p className="text-sm text-slate-500">{t("common.account")}</p> <p className="text-sm text-slate-700">{t("common.account")}</p>
</div> </div>
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} /> <ChevronRightIcon
className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")}
/>
</> </>
)} )}
</button> </div>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
@@ -769,6 +273,8 @@ export const MainNavigation = ({
sideOffset={10} sideOffset={10}
alignOffset={5} alignOffset={5}
align="end"> align="end">
{/* Dropdown Items */}
{dropdownNavigation.map((link) => ( {dropdownNavigation.map((link) => (
<Link <Link
href={link.href} href={link.href}
@@ -782,6 +288,7 @@ export const MainNavigation = ({
</DropdownMenuItem> </DropdownMenuItem>
</Link> </Link>
))} ))}
{/* Logout */}
<DropdownMenuItem <DropdownMenuItem
onClick={async () => { onClick={async () => {
const loginUrl = `${publicDomain}/auth/login`; const loginUrl = `${publicDomain}/auth/login`;
@@ -804,28 +311,6 @@ export const MainNavigation = ({
</div> </div>
</aside> </aside>
)} )}
{openProjectLimitModal && (
<ProjectLimitModal
open={openProjectLimitModal}
setOpen={setOpenProjectLimitModal}
buttons={projectLimitModalButtons()}
projectLimit={organizationProjectsLimit}
/>
)}
{openCreateProjectModal && (
<CreateProjectModal
open={openCreateProjectModal}
setOpen={setOpenCreateProjectModal}
organizationId={organization.id}
isAccessControlAllowed={isAccessControlAllowed}
/>
)}
{openCreateOrganizationModal && (
<CreateOrganizationModal
open={openCreateOrganizationModal}
setOpen={setOpenCreateOrganizationModal}
/>
)}
</> </>
); );
}; };
@@ -1,7 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import React from "react"; import React from "react";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface NavigationLinkProps { interface NavigationLinkProps {
@@ -11,8 +10,6 @@ interface NavigationLinkProps {
children: React.ReactNode; children: React.ReactNode;
linkText: string; linkText: string;
isTextVisible: boolean; isTextVisible: boolean;
disabled?: boolean;
disabledMessage?: string;
} }
export const NavigationLink = ({ export const NavigationLink = ({
@@ -22,34 +19,10 @@ export const NavigationLink = ({
children, children,
linkText, linkText,
isTextVisible = true, isTextVisible = true,
disabled = false,
disabledMessage,
}: NavigationLinkProps) => { }: NavigationLinkProps) => {
const tooltipText = disabled ? disabledMessage || linkText : linkText;
const activeClass = "bg-slate-50 border-r-4 border-brand-dark font-semibold text-slate-900"; const activeClass = "bg-slate-50 border-r-4 border-brand-dark font-semibold text-slate-900";
const inactiveClass = const inactiveClass =
"hover:bg-slate-50 border-r-4 border-transparent hover:border-slate-300 transition-all duration-150 ease-in-out"; "hover:bg-slate-50 border-r-4 border-transparent hover:border-slate-300 transition-all duration-150 ease-in-out";
const disabledClass = "cursor-not-allowed border-r-4 border-transparent text-slate-400";
const getColorClass = (baseClass: string) => {
if (disabled) {
return disabledClass;
}
return cn(baseClass, isActive ? activeClass : inactiveClass);
};
const collapsedColorClass = getColorClass("text-slate-700 hover:text-slate-900");
const expandedColorClass = getColorClass("text-slate-600 hover:text-slate-900");
const label = (
<span
className={cn(
"ml-2 flex transition-opacity duration-100",
isTextVisible ? "opacity-0" : "opacity-100"
)}>
{linkText}
</span>
);
return ( return (
<> <>
@@ -57,37 +30,35 @@ export const NavigationLink = ({
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<li className={cn("mb-1 ml-2 rounded-l-md py-2 pl-2 text-sm", collapsedColorClass)}> <li
{disabled ? ( className={cn(
<div className="flex items-center">{children}</div> "mb-1 ml-2 rounded-l-md py-2 pl-2 text-sm text-slate-700 hover:text-slate-900",
) : ( isActive ? activeClass : inactiveClass
<Link href={href}>{children}</Link> )}>
)} <Link href={href} className="flex items-center">
{children}
</Link>
</li> </li>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right">{tooltipText}</TooltipContent> <TooltipContent side="right">{linkText}</TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
) : ( ) : (
<li className={cn("mb-1 rounded-l-md py-2 pl-5 text-sm", expandedColorClass)}> <li
{disabled ? ( className={cn(
<Popover> "mb-1 rounded-l-md py-2 pl-5 text-sm text-slate-600 hover:text-slate-900",
<PopoverTrigger asChild> isActive ? activeClass : inactiveClass
<div className="flex items-center"> )}>
{children} <Link href={href} className="flex items-center">
{label} {children}
</div> <span
</PopoverTrigger> className={cn(
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700"> "ml-2 flex transition-opacity duration-100",
{disabledMessage || linkText} isTextVisible ? "opacity-0" : "opacity-100"
</PopoverContent> )}>
</Popover> {linkText}
) : ( </span>
<Link href={href} className="flex items-center"> </Link>
{children}
{label}
</Link>
)}
</li> </li>
)} )}
</> </>
@@ -31,8 +31,7 @@ export const TopControlBar = ({
isAccessControlAllowed, isAccessControlAllowed,
membershipRole, membershipRole,
}: TopControlBarProps) => { }: TopControlBarProps) => {
const { isMember, isBilling } = getAccessFlags(membershipRole); const { isMember } = getAccessFlags(membershipRole);
const isMembershipPending = membershipRole === undefined;
const { environment } = useEnvironment(); const { environment } = useEnvironment();
return ( return (
@@ -50,8 +49,6 @@ export const TopControlBar = ({
isLicenseActive={isLicenseActive} isLicenseActive={isLicenseActive}
isOwnerOrManager={isOwnerOrManager} isOwnerOrManager={isOwnerOrManager}
isMember={isMember} isMember={isMember}
isBilling={isBilling}
isMembershipPending={isMembershipPending}
isAccessControlAllowed={isAccessControlAllowed} isAccessControlAllowed={isAccessControlAllowed}
/> />
</div> </div>
@@ -53,7 +53,7 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
<currentStatus.icon /> <currentStatus.icon />
</div> </div>
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p> <p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
<p className="w-2/3 text-balance text-sm text-slate-600">{currentStatus.subtitle}</p> <p className="w-2/3 text-sm text-balance text-slate-600">{currentStatus.subtitle}</p>
{status === "notImplemented" && ( {status === "notImplemented" && (
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}> <Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
<RotateCcwIcon /> <RotateCcwIcon />
@@ -25,7 +25,6 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu"; } from "@/modules/ui/components/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import { useOrganization } from "../context/environment-context"; import { useOrganization } from "../context/environment-context";
interface OrganizationBreadcrumbProps { interface OrganizationBreadcrumbProps {
@@ -36,7 +35,6 @@ interface OrganizationBreadcrumbProps {
isFormbricksCloud: boolean; isFormbricksCloud: boolean;
isMember: boolean; isMember: boolean;
isOwnerOrManager: boolean; isOwnerOrManager: boolean;
isMembershipPending: boolean;
} }
const isActiveOrganizationSetting = (pathname: string, settingId: string): boolean => { const isActiveOrganizationSetting = (pathname: string, settingId: string): boolean => {
@@ -58,7 +56,6 @@ export const OrganizationBreadcrumb = ({
isFormbricksCloud, isFormbricksCloud,
isMember, isMember,
isOwnerOrManager, isOwnerOrManager,
isMembershipPending,
}: OrganizationBreadcrumbProps) => { }: OrganizationBreadcrumbProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false); const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
@@ -145,10 +142,7 @@ export const OrganizationBreadcrumb = ({
id: "api-keys", id: "api-keys",
label: t("common.api_keys"), label: t("common.api_keys"),
href: `/environments/${currentEnvironmentId}/settings/api-keys`, href: `/environments/${currentEnvironmentId}/settings/api-keys`,
disabled: isMembershipPending || !isOwnerOrManager, hidden: !isOwnerOrManager,
disabledMessage: isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
}, },
{ {
id: "domain", id: "domain",
@@ -166,11 +160,7 @@ export const OrganizationBreadcrumb = ({
id: "enterprise", id: "enterprise",
label: t("common.enterprise_license"), label: t("common.enterprise_license"),
href: `/environments/${currentEnvironmentId}/settings/enterprise`, href: `/environments/${currentEnvironmentId}/settings/enterprise`,
hidden: isFormbricksCloud, hidden: isFormbricksCloud || isMember,
disabled: isMembershipPending || isMember,
disabledMessage: isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
}, },
]; ];
@@ -252,30 +242,14 @@ export const OrganizationBreadcrumb = ({
{organizationSettings.map((setting) => { {organizationSettings.map((setting) => {
return setting.hidden ? null : ( return setting.hidden ? null : (
<div key={setting.id}> <DropdownMenuCheckboxItem
{setting.disabled ? ( key={setting.id}
<Popover> checked={isActiveOrganizationSetting(pathname, setting.id)}
<PopoverTrigger asChild> hidden={setting.hidden}
<button onClick={() => handleSettingChange(setting.href)}
type="button" className="cursor-pointer">
aria-disabled="true" {setting.label}
className="relative flex w-full cursor-not-allowed select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400"> </DropdownMenuCheckboxItem>
{setting.label}
</button>
</PopoverTrigger>
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
{setting.disabledMessage}
</PopoverContent>
</Popover>
) : (
<DropdownMenuCheckboxItem
checked={isActiveOrganizationSetting(pathname, setting.id)}
onClick={() => handleSettingChange(setting.href)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
)}
</div>
); );
})} })}
</div> </div>
@@ -18,8 +18,6 @@ interface ProjectAndOrgSwitchProps {
isLicenseActive: boolean; isLicenseActive: boolean;
isOwnerOrManager: boolean; isOwnerOrManager: boolean;
isMember: boolean; isMember: boolean;
isBilling: boolean;
isMembershipPending: boolean;
isAccessControlAllowed: boolean; isAccessControlAllowed: boolean;
} }
@@ -37,8 +35,6 @@ export const ProjectAndOrgSwitch = ({
isOwnerOrManager, isOwnerOrManager,
isAccessControlAllowed, isAccessControlAllowed,
isMember, isMember,
isBilling,
isMembershipPending,
}: ProjectAndOrgSwitchProps) => { }: ProjectAndOrgSwitchProps) => {
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId); const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development"; const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
@@ -54,7 +50,6 @@ export const ProjectAndOrgSwitch = ({
isFormbricksCloud={isFormbricksCloud} isFormbricksCloud={isFormbricksCloud}
isMember={isMember} isMember={isMember}
isOwnerOrManager={isOwnerOrManager} isOwnerOrManager={isOwnerOrManager}
isMembershipPending={isMembershipPending}
/> />
{currentProjectId && currentEnvironmentId && ( {currentProjectId && currentEnvironmentId && (
<ProjectBreadcrumb <ProjectBreadcrumb
@@ -68,8 +63,6 @@ export const ProjectAndOrgSwitch = ({
isLicenseActive={isLicenseActive} isLicenseActive={isLicenseActive}
isAccessControlAllowed={isAccessControlAllowed} isAccessControlAllowed={isAccessControlAllowed}
isEnvironmentBreadcrumbVisible={showEnvironmentBreadcrumb} isEnvironmentBreadcrumbVisible={showEnvironmentBreadcrumb}
isBilling={isBilling}
isMembershipPending={isMembershipPending}
/> />
)} )}
{showEnvironmentBreadcrumb && ( {showEnvironmentBreadcrumb && (
@@ -1,7 +1,7 @@
"use client"; "use client";
import * as Sentry from "@sentry/nextjs"; import * as Sentry from "@sentry/nextjs";
import { ChevronDownIcon, ChevronRightIcon, CogIcon, FoldersIcon, Loader2, PlusIcon } from "lucide-react"; import { ChevronDownIcon, ChevronRightIcon, CogIcon, HotelIcon, Loader2, PlusIcon } from "lucide-react";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react"; import { useEffect, useState, useTransition } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -19,7 +19,6 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu"; } from "@/modules/ui/components/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import { ModalButton } from "@/modules/ui/components/upgrade-prompt"; import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
import { useProject } from "../context/environment-context"; import { useProject } from "../context/environment-context";
@@ -34,8 +33,6 @@ interface ProjectBreadcrumbProps {
currentEnvironmentId: string; currentEnvironmentId: string;
isAccessControlAllowed: boolean; isAccessControlAllowed: boolean;
isEnvironmentBreadcrumbVisible: boolean; isEnvironmentBreadcrumbVisible: boolean;
isBilling: boolean;
isMembershipPending: boolean;
} }
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => { const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
@@ -59,8 +56,6 @@ export const ProjectBreadcrumb = ({
currentEnvironmentId, currentEnvironmentId,
isAccessControlAllowed, isAccessControlAllowed,
isEnvironmentBreadcrumbVisible, isEnvironmentBreadcrumbVisible,
isBilling,
isMembershipPending,
}: ProjectBreadcrumbProps) => { }: ProjectBreadcrumbProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false); const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false);
@@ -139,10 +134,6 @@ export const ProjectBreadcrumb = ({
href: `/environments/${currentEnvironmentId}/workspace/tags`, href: `/environments/${currentEnvironmentId}/workspace/tags`,
}, },
]; ];
const areProjectSettingsDisabled = isMembershipPending || isBilling;
const projectSettingsDisabledMessage = isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action");
if (!currentProject) { if (!currentProject) {
const errorMessage = `Workspace not found for workspace id: ${currentProjectId}`; const errorMessage = `Workspace not found for workspace id: ${currentProjectId}`;
@@ -207,7 +198,7 @@ export const ProjectBreadcrumb = ({
id="projectDropdownTrigger" id="projectDropdownTrigger"
asChild> asChild>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<FoldersIcon className="h-3 w-3" strokeWidth={1.5} /> <HotelIcon className="h-3 w-3" strokeWidth={1.5} />
<span>{projectName}</span> <span>{projectName}</span>
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />} {isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
{isEnvironmentBreadcrumbVisible && !isProjectDropdownOpen ? ( {isEnvironmentBreadcrumbVisible && !isProjectDropdownOpen ? (
@@ -220,7 +211,7 @@ export const ProjectBreadcrumb = ({
<DropdownMenuContent align="start" className="mt-2"> <DropdownMenuContent align="start" className="mt-2">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500"> <div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<FoldersIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} /> <HotelIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.choose_workspace")} {t("common.choose_workspace")}
</div> </div>
{isLoadingProjects && ( {isLoadingProjects && (
@@ -256,24 +247,7 @@ export const ProjectBreadcrumb = ({
</DropdownMenuCheckboxItem> </DropdownMenuCheckboxItem>
))} ))}
</DropdownMenuGroup> </DropdownMenuGroup>
{isMembershipPending || !isOwnerOrManager ? ( {isOwnerOrManager && (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-disabled="true"
className="relative flex w-full cursor-not-allowed select-none items-center justify-between rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
<span>{t("common.add_new_workspace")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</button>
</PopoverTrigger>
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
{isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action")}
</PopoverContent>
</Popover>
) : (
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
onClick={handleAddProject} onClick={handleAddProject}
className="w-full cursor-pointer justify-between"> className="w-full cursor-pointer justify-between">
@@ -290,30 +264,13 @@ export const ProjectBreadcrumb = ({
{t("common.workspace_configuration")} {t("common.workspace_configuration")}
</div> </div>
{projectSettings.map((setting) => ( {projectSettings.map((setting) => (
<div key={setting.id}> <DropdownMenuCheckboxItem
{areProjectSettingsDisabled ? ( key={setting.id}
<Popover> checked={isActiveProjectSetting(pathname, setting.id)}
<PopoverTrigger asChild> onClick={() => handleProjectSettingsNavigation(setting.id)}
<button className="cursor-pointer">
type="button" {setting.label}
aria-disabled="true" </DropdownMenuCheckboxItem>
className="relative flex w-full cursor-not-allowed select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
{setting.label}
</button>
</PopoverTrigger>
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
{projectSettingsDisabledMessage}
</PopoverContent>
</Popover>
) : (
<DropdownMenuCheckboxItem
checked={isActiveProjectSetting(pathname, setting.id)}
onClick={() => handleProjectSettingsNavigation(setting.id)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
)}
</div>
))} ))}
</DropdownMenuGroup> </DropdownMenuGroup>
</DropdownMenuContent> </DropdownMenuContent>
@@ -1,11 +1,10 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
const EnvironmentPage = async (props: { params: Promise<{ environmentId: string }> }) => { const EnvironmentPage = async (props) => {
const params = await props.params; const params = await props.params;
const { session, organization } = await getEnvironmentAuth(params.environmentId); const { session, organization } = await getEnvironmentAuth(params.environmentId);
@@ -13,7 +12,11 @@ const EnvironmentPage = async (props: { params: Promise<{ environmentId: string
const { isBilling } = getAccessFlags(currentUserMembership?.role); const { isBilling } = getAccessFlags(currentUserMembership?.role);
if (isBilling) { if (isBilling) {
return redirect(getBillingFallbackPath(params.environmentId, IS_FORMBRICKS_CLOUD)); if (IS_FORMBRICKS_CLOUD) {
return redirect(`/environments/${params.environmentId}/settings/billing`);
} else {
return redirect(`/environments/${params.environmentId}/settings/enterprise`);
}
} }
return redirect(`/environments/${params.environmentId}/surveys`); return redirect(`/environments/${params.environmentId}/surveys`);
@@ -1,14 +1,10 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service"; import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
const AccountSettingsLayout = async (props: { const AccountSettingsLayout = async (props) => {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params; const params = await props.params;
const { children } = props; const { children } = props;
@@ -21,15 +17,15 @@ const AccountSettingsLayout = async (props: {
]); ]);
if (!organization) { if (!organization) {
throw new ResourceNotFoundError(t("common.organization"), null); throw new Error(t("common.organization_not_found"));
} }
if (!project) { if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null); throw new Error(t("common.workspace_not_found"));
} }
if (!session) { if (!session) {
throw new AuthenticationError(t("common.not_authenticated")); throw new Error(t("common.session_not_found"));
} }
return <>{children}</>; return <>{children}</>;
@@ -4,6 +4,7 @@ import { z } from "zod";
import { ZUserNotificationSettings } from "@formbricks/types/user"; import { ZUserNotificationSettings } from "@formbricks/types/user";
import { getUser, updateUser } from "@/lib/user/service"; import { getUser, updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
const ZUpdateNotificationSettingsAction = z.object({ const ZUpdateNotificationSettingsAction = z.object({
@@ -11,16 +12,26 @@ const ZUpdateNotificationSettingsAction = z.object({
}); });
export const updateNotificationSettingsAction = authenticatedActionClient export const updateNotificationSettingsAction = authenticatedActionClient
.inputSchema(ZUpdateNotificationSettingsAction) .schema(ZUpdateNotificationSettingsAction)
.action( .action(
withAuditLogging("updated", "user", async ({ ctx, parsedInput }) => { withAuditLogging(
const oldObject = await getUser(ctx.user.id); "updated",
const result = await updateUser(ctx.user.id, { "user",
notificationSettings: parsedInput.notificationSettings, async ({
}); ctx,
ctx.auditLoggingCtx.userId = ctx.user.id; parsedInput,
ctx.auditLoggingCtx.oldObject = oldObject; }: {
ctx.auditLoggingCtx.newObject = result; ctx: AuthenticatedActionClientCtx;
return result; parsedInput: Record<string, any>;
}) }) => {
const oldObject = await getUser(ctx.user.id);
const result = await updateUser(ctx.user.id, {
notificationSettings: parsedInput.notificationSettings,
});
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
); );
@@ -1,6 +1,5 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TUserNotificationSettings } from "@formbricks/types/user"; import { TUserNotificationSettings } from "@formbricks/types/user";
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar"; import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard"; import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
@@ -17,8 +16,8 @@ const setCompleteNotificationSettings = (
notificationSettings: TUserNotificationSettings, notificationSettings: TUserNotificationSettings,
memberships: Membership[] memberships: Membership[]
): TUserNotificationSettings => { ): TUserNotificationSettings => {
const newNotificationSettings: TUserNotificationSettings = { const newNotificationSettings = {
alert: {} as Record<string, boolean>, alert: {},
unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [], unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [],
}; };
for (const membership of memberships) { for (const membership of memberships) {
@@ -27,8 +26,7 @@ const setCompleteNotificationSettings = (
for (const environment of project.environments) { for (const environment of project.environments) {
for (const survey of environment.surveys) { for (const survey of environment.surveys) {
newNotificationSettings.alert[survey.id] = newNotificationSettings.alert[survey.id] =
(notificationSettings as unknown as Record<string, Record<string, boolean>>)[survey.id] notificationSettings[survey.id]?.responseFinished ||
?.responseFinished ||
(notificationSettings.alert && notificationSettings.alert[survey.id]) || (notificationSettings.alert && notificationSettings.alert[survey.id]) ||
false; // check for legacy notification settings w/o "alerts" key false; // check for legacy notification settings w/o "alerts" key
} }
@@ -138,27 +136,24 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
return memberships; return memberships;
}; };
const Page = async (props: { const Page = async (props) => {
params: Promise<{ environmentId: string }>;
searchParams: Promise<Record<string, string>>;
}) => {
const searchParams = await props.searchParams; const searchParams = await props.searchParams;
const params = await props.params; const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session) { if (!session) {
throw new AuthenticationError(t("common.not_authenticated")); throw new Error(t("common.session_not_found"));
} }
const autoDisableNotificationType = searchParams["type"]; const autoDisableNotificationType = searchParams["type"];
const autoDisableNotificationElementId = searchParams["elementId"]; const autoDisableNotificationElementId = searchParams["elementId"];
const [user, memberships] = await Promise.all([getUser(session.user.id), getMemberships(session.user.id)]); const [user, memberships] = await Promise.all([getUser(session.user.id), getMemberships(session.user.id)]);
if (!user) { if (!user) {
throw new AuthenticationError(t("common.not_authenticated")); throw new Error(t("common.user_not_found"));
} }
if (!memberships) { if (!memberships) {
throw new ResourceNotFoundError(t("common.membership"), null); throw new Error(t("common.membership_not_found"));
} }
if (user?.notificationSettings) { if (user?.notificationSettings) {
@@ -10,18 +10,17 @@ import {
getIsEmailUnique, getIsEmailUnique,
verifyUserPassword, verifyUserPassword,
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user"; } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
import { EMAIL_VERIFICATION_DISABLED, PASSWORD_RESET_DISABLED } from "@/lib/constants"; import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
import { getUser, updateUser } from "@/lib/user/service"; import { getUser, updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { requestPasswordReset } from "@/modules/auth/forgot-password/lib/password-reset-service";
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo"; import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { sendVerificationNewEmail } from "@/modules/email"; import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
function buildUserUpdatePayload(parsedInput: TUserPersonalInfoUpdateInput): TUserUpdateInput { function buildUserUpdatePayload(parsedInput: any): TUserUpdateInput {
return { return {
...(parsedInput.name && { name: parsedInput.name }), ...(parsedInput.name && { name: parsedInput.name }),
...(parsedInput.locale && { locale: parsedInput.locale }), ...(parsedInput.locale && { locale: parsedInput.locale }),
@@ -64,40 +63,50 @@ async function handleEmailUpdate({
return payload; return payload;
} }
export const updateUserAction = authenticatedActionClient.inputSchema(ZUserPersonalInfoUpdateInput).action( export const updateUserAction = authenticatedActionClient.schema(ZUserPersonalInfoUpdateInput).action(
withAuditLogging("updated", "user", async ({ ctx, parsedInput }) => { withAuditLogging(
const oldObject = await getUser(ctx.user.id); "updated",
let payload = buildUserUpdatePayload(parsedInput); "user",
payload = await handleEmailUpdate({ ctx, parsedInput, payload }); async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: TUserPersonalInfoUpdateInput;
}) => {
const oldObject = await getUser(ctx.user.id);
let payload = buildUserUpdatePayload(parsedInput);
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
// Only proceed with updateUser if we have actual changes to make // Only proceed with updateUser if we have actual changes to make
let newObject = oldObject; let newObject = oldObject;
if (Object.keys(payload).length > 0) { if (Object.keys(payload).length > 0) {
newObject = await updateUser(ctx.user.id, payload); newObject = await updateUser(ctx.user.id, payload);
}
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = newObject;
return true;
} }
)
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = newObject;
return true;
})
); );
export const resetPasswordAction = authenticatedActionClient.action( export const resetPasswordAction = authenticatedActionClient.action(
withAuditLogging("passwordReset", "user", async ({ ctx }) => { withAuditLogging(
if (PASSWORD_RESET_DISABLED) { "passwordReset",
throw new OperationNotAllowedError("Password reset is disabled"); "user",
async ({ ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: undefined }) => {
if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
}
await sendForgotPasswordEmail(ctx.user);
ctx.auditLoggingCtx.userId = ctx.user.id;
return { success: true };
} }
)
if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
}
await requestPasswordReset(ctx.user, "profile");
ctx.auditLoggingCtx.userId = ctx.user.id;
return { success: true };
})
); );
@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
import { z } from "zod"; import { z } from "zod";
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user"; import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal"; import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal";
import { appLanguages, sortedAppLanguages } from "@/lib/i18n/utils"; import { appLanguages } from "@/lib/i18n/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out"; import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
@@ -116,14 +116,10 @@ export const EditProfileDetailsForm = ({
setShowModal(true); setShowModal(true);
} else { } else {
try { try {
const result = await updateUserAction({ await updateUserAction({
...data, ...data,
name: data.name.trim(), name: data.name.trim(),
}); });
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
toast.success(t("environments.settings.profile.profile_updated_successfully")); toast.success(t("environments.settings.profile.profile_updated_successfully"));
window.location.reload(); window.location.reload();
form.reset(data); form.reset(data);
@@ -202,54 +198,41 @@ export const EditProfileDetailsForm = ({
<FormField <FormField
control={form.control} control={form.control}
name="locale" name="locale"
render={({ field }) => { render={({ field }) => (
const selectedLanguage = appLanguages.find((l) => l.code === field.value); <FormItem className="mt-4">
<FormLabel>{t("common.language")}</FormLabel>
return ( <FormControl>
<FormItem className="mt-4"> <DropdownMenu>
<FormLabel>{t("common.language")}</FormLabel> <DropdownMenuTrigger asChild>
<FormControl> <Button
<DropdownMenu> type="button"
<DropdownMenuTrigger asChild> variant="ghost"
<Button className="h-10 w-full border border-slate-300 px-3 text-left">
type="button" <div className="flex w-full items-center justify-between">
variant="ghost" {appLanguages.find((l) => l.code === field.value)?.label["en-US"] ?? "NA"}
className="h-10 w-full border border-slate-300 px-3 text-left"> <ChevronDownIcon className="h-4 w-4 text-slate-500" />
<div className="flex w-full items-center justify-between"> </div>
{selectedLanguage ? ( </Button>
<> </DropdownMenuTrigger>
{selectedLanguage.label["en-US"]} <DropdownMenuContent
{selectedLanguage.label.native !== selectedLanguage.label["en-US"] && className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
` (${selectedLanguage.label.native})`} align="start">
</> <DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
) : ( {appLanguages.map((lang) => (
t("common.select") <DropdownMenuRadioItem
)} key={lang.code}
<ChevronDownIcon className="h-4 w-4 text-slate-500" /> value={lang.code}
</div> className="min-h-8 cursor-pointer">
</Button> {lang.label["en-US"]}
</DropdownMenuTrigger> </DropdownMenuRadioItem>
<DropdownMenuContent ))}
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700" </DropdownMenuRadioGroup>
align="start"> </DropdownMenuContent>
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}> </DropdownMenu>
{sortedAppLanguages.map((lang) => ( </FormControl>
<DropdownMenuRadioItem <FormError />
key={lang.code} </FormItem>
value={lang.code} )}
className="min-h-8 cursor-pointer">
{lang.label["en-US"]}
{lang.label.native !== lang.label["en-US"] && ` (${lang.label.native})`}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
<FormError />
</FormItem>
);
}}
/> />
{isPasswordResetEnabled && ( {isPasswordResetEnabled && (
@@ -98,7 +98,7 @@ export const PasswordConfirmationModal = ({
aria-label="password" aria-label="password"
aria-required="true" aria-required="true"
required required
className="block w-full rounded-md border-slate-300 shadow-sm focus:border-brand-dark focus:ring-brand-dark sm:text-sm" className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
value={field.value} value={field.value}
onChange={(password) => field.onChange(password)} onChange={(password) => field.onChange(password)}
/> />
@@ -1,4 +1,3 @@
import { AuthenticationError } from "@formbricks/types/errors";
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar"; import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity"; import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants"; import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
@@ -29,7 +28,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const user = session?.user ? await getUser(session.user.id) : null; const user = session?.user ? await getUser(session.user.id) : null;
if (!user) { if (!user) {
throw new AuthenticationError(t("common.not_authenticated")); throw new Error(t("common.user_not_found"));
} }
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email"; const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
@@ -61,7 +60,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
buttons={[ buttons={[
{ {
text: IS_FORMBRICKS_CLOUD text: IS_FORMBRICKS_CLOUD
? t("common.upgrade_plan") ? t("common.start_free_trial")
: t("common.request_trial_license"), : t("common.request_trial_license"),
href: IS_FORMBRICKS_CLOUD href: IS_FORMBRICKS_CLOUD
? `/environments/${params.environmentId}/settings/billing` ? `/environments/${params.environmentId}/settings/billing`
@@ -22,9 +22,8 @@ export const OrganizationSettingsNavbar = ({
loading, loading,
}: OrganizationSettingsNavbarProps) => { }: OrganizationSettingsNavbarProps) => {
const pathname = usePathname(); const pathname = usePathname();
const { isMember, isOwner, isManager } = getAccessFlags(membershipRole); const { isMember, isOwner } = getAccessFlags(membershipRole);
const isOwnerOrManager = isOwner || isManager; const isPricingDisabled = isMember;
const isMembershipPending = membershipRole === undefined || loading;
const { t } = useTranslation(); const { t } = useTranslation();
const navigation = [ const navigation = [
@@ -46,10 +45,7 @@ export const OrganizationSettingsNavbar = ({
label: t("common.api_keys"), label: t("common.api_keys"),
href: `/environments/${environmentId}/settings/api-keys`, href: `/environments/${environmentId}/settings/api-keys`,
current: pathname?.includes("/api-keys"), current: pathname?.includes("/api-keys"),
disabled: isMembershipPending || !isOwnerOrManager, hidden: !isOwner,
disabledMessage: isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
}, },
{ {
id: "domain", id: "domain",
@@ -62,18 +58,14 @@ export const OrganizationSettingsNavbar = ({
id: "billing", id: "billing",
label: t("common.billing"), label: t("common.billing"),
href: `/environments/${environmentId}/settings/billing`, href: `/environments/${environmentId}/settings/billing`,
hidden: !isFormbricksCloud, hidden: !isFormbricksCloud || loading,
current: pathname?.includes("/billing"), current: pathname?.includes("/billing"),
}, },
{ {
id: "enterprise", id: "enterprise",
label: t("common.enterprise_license"), label: t("common.enterprise_license"),
href: `/environments/${environmentId}/settings/enterprise`, href: `/environments/${environmentId}/settings/enterprise`,
hidden: isFormbricksCloud, hidden: isFormbricksCloud || isPricingDisabled,
disabled: isMembershipPending || isMember,
disabledMessage: isMembershipPending
? t("common.loading")
: t("common.you_are_not_authorized_to_perform_this_action"),
current: pathname?.includes("/enterprise"), current: pathname?.includes("/enterprise"),
}, },
]; ];
@@ -1,5 +1,4 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { AuthenticationError } from "@formbricks/types/errors";
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants"; import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils"; import { getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
@@ -26,10 +25,10 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
); );
if (!session) { if (!session) {
throw new AuthenticationError(t("common.not_authenticated")); throw new Error(t("common.session_not_found"));
} }
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id); const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.billing.plan);
const isOwnerOrManager = isManager || isOwner; const isOwnerOrManager = isManager || isOwner;
const surveys = await getSurveysWithSlugsByOrganizationId(organization.id); const surveys = await getSurveysWithSlugsByOrganizationId(organization.id);
@@ -1,146 +0,0 @@
"use client";
import type { TFunction } from "i18next";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import type { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/types/enterprise-license";
import { Badge } from "@/modules/ui/components/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
type TPublicLicenseFeatureKey = Exclude<keyof TEnterpriseLicenseFeatures, "isMultiOrgEnabled" | "ai">;
type TFeatureDefinition = {
key: TPublicLicenseFeatureKey;
labelKey: string;
docsUrl: string;
};
const getFeatureDefinitions = (t: TFunction): TFeatureDefinition[] => {
return [
{
key: "contacts",
labelKey: t("environments.settings.enterprise.license_feature_contacts"),
docsUrl:
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/contact-management-segments",
},
{
key: "projects",
labelKey: t("environments.settings.enterprise.license_feature_projects"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/license",
},
{
key: "whitelabel",
labelKey: t("environments.settings.enterprise.license_feature_whitelabel"),
docsUrl:
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/whitelabel-email-follow-ups",
},
{
key: "removeBranding",
labelKey: t("environments.settings.enterprise.license_feature_remove_branding"),
docsUrl:
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/hide-powered-by-formbricks",
},
{
key: "twoFactorAuth",
labelKey: t("environments.settings.enterprise.license_feature_two_factor_auth"),
docsUrl: "https://formbricks.com/docs/xm-and-surveys/core-features/user-management/two-factor-auth",
},
{
key: "sso",
labelKey: t("environments.settings.enterprise.license_feature_sso"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/oidc-sso",
},
{
key: "saml",
labelKey: t("environments.settings.enterprise.license_feature_saml"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/saml-sso",
},
{
key: "spamProtection",
labelKey: t("environments.settings.enterprise.license_feature_spam_protection"),
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/spam-protection",
},
{
key: "auditLogs",
labelKey: t("environments.settings.enterprise.license_feature_audit_logs"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/audit-logging",
},
{
key: "accessControl",
labelKey: t("environments.settings.enterprise.license_feature_access_control"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/team-access",
},
{
key: "quotas",
labelKey: t("environments.settings.enterprise.license_feature_quotas"),
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/quota-management",
},
];
};
interface EnterpriseLicenseFeaturesTableProps {
features: TEnterpriseLicenseFeatures;
}
export const EnterpriseLicenseFeaturesTable = ({ features }: EnterpriseLicenseFeaturesTableProps) => {
const { t } = useTranslation();
return (
<SettingsCard
title={t("environments.settings.enterprise.license_features_table_title")}
description={t("environments.settings.enterprise.license_features_table_description")}
noPadding>
<Table>
<TableHeader>
<TableRow className="hover:bg-white">
<TableHead>{t("environments.settings.enterprise.license_features_table_feature")}</TableHead>
<TableHead>{t("environments.settings.enterprise.license_features_table_access")}</TableHead>
<TableHead>{t("environments.settings.enterprise.license_features_table_value")}</TableHead>
<TableHead>{t("common.documentation")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{getFeatureDefinitions(t).map((feature) => {
const value = features[feature.key];
const isEnabled = typeof value === "boolean" ? value : value === null || value > 0;
let displayValue: number | string = "—";
if (typeof value === "number") {
displayValue = value;
} else if (value === null) {
displayValue = t("environments.settings.enterprise.license_features_table_unlimited");
}
return (
<TableRow key={feature.key} className="hover:bg-white">
<TableCell className="font-medium text-slate-900">{t(feature.labelKey)}</TableCell>
<TableCell>
<Badge
type={isEnabled ? "success" : "gray"}
size="normal"
text={
isEnabled
? t("environments.settings.enterprise.license_features_table_enabled")
: t("environments.settings.enterprise.license_features_table_disabled")
}
/>
</TableCell>
<TableCell className="text-slate-600">{displayValue}</TableCell>
<TableCell>
<Link
href={feature.docsUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-slate-700 underline underline-offset-2 hover:text-slate-900">
{t("common.read_docs")}
</Link>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</SettingsCard>
);
};
@@ -6,23 +6,22 @@ import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatDateForDisplay, formatDateTimeForDisplay } from "@/lib/utils/datetime";
import { recheckLicenseAction } from "@/modules/ee/license-check/actions"; import { recheckLicenseAction } from "@/modules/ee/license-check/actions";
import type { TLicenseStatus } from "@/modules/ee/license-check/types/enterprise-license";
import { Alert, AlertDescription } from "@/modules/ui/components/alert"; import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Badge } from "@/modules/ui/components/badge"; import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { SettingsCard } from "../../../components/SettingsCard"; import { SettingsCard } from "../../../components/SettingsCard";
type LicenseStatus = "active" | "expired" | "unreachable" | "invalid_license";
interface EnterpriseLicenseStatusProps { interface EnterpriseLicenseStatusProps {
status: TLicenseStatus; status: LicenseStatus;
lastChecked: Date;
gracePeriodEnd?: Date; gracePeriodEnd?: Date;
environmentId: string; environmentId: string;
} }
const getBadgeConfig = ( const getBadgeConfig = (
status: TLicenseStatus, status: LicenseStatus,
t: TFunction t: TFunction
): { type: "success" | "error" | "warning" | "gray"; label: string } => { ): { type: "success" | "error" | "warning" | "gray"; label: string } => {
switch (status) { switch (status) {
@@ -30,11 +29,6 @@ const getBadgeConfig = (
return { type: "success", label: t("environments.settings.enterprise.license_status_active") }; return { type: "success", label: t("environments.settings.enterprise.license_status_active") };
case "expired": case "expired":
return { type: "error", label: t("environments.settings.enterprise.license_status_expired") }; return { type: "error", label: t("environments.settings.enterprise.license_status_expired") };
case "instance_mismatch":
return {
type: "error",
label: t("environments.settings.enterprise.license_status_instance_mismatch"),
};
case "unreachable": case "unreachable":
return { type: "warning", label: t("environments.settings.enterprise.license_status_unreachable") }; return { type: "warning", label: t("environments.settings.enterprise.license_status_unreachable") };
case "invalid_license": case "invalid_license":
@@ -46,12 +40,10 @@ const getBadgeConfig = (
export const EnterpriseLicenseStatus = ({ export const EnterpriseLicenseStatus = ({
status, status,
lastChecked,
gracePeriodEnd, gracePeriodEnd,
environmentId, environmentId,
}: EnterpriseLicenseStatusProps) => { }: EnterpriseLicenseStatusProps) => {
const { t, i18n } = useTranslation(); const { t } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
const router = useRouter(); const router = useRouter();
const [isRechecking, setIsRechecking] = useState(false); const [isRechecking, setIsRechecking] = useState(false);
@@ -67,8 +59,6 @@ export const EnterpriseLicenseStatus = ({
if (result?.data) { if (result?.data) {
if (result.data.status === "unreachable") { if (result.data.status === "unreachable") {
toast.error(t("environments.settings.enterprise.recheck_license_unreachable")); toast.error(t("environments.settings.enterprise.recheck_license_unreachable"));
} else if (result.data.status === "instance_mismatch") {
toast.error(t("environments.settings.enterprise.recheck_license_instance_mismatch"));
} else if (result.data.status === "invalid_license") { } else if (result.data.status === "invalid_license") {
toast.error(t("environments.settings.enterprise.recheck_license_invalid")); toast.error(t("environments.settings.enterprise.recheck_license_invalid"));
} else { } else {
@@ -96,12 +86,7 @@ export const EnterpriseLicenseStatus = ({
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<div className="flex flex-wrap items-center gap-3"> <Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
<span className="text-sm text-slate-500">
{t("common.updated_at")} {formatDateTimeForDisplay(new Date(lastChecked), locale)}
</span>
</div>
</div> </div>
<Button <Button
type="button" type="button"
@@ -127,7 +112,7 @@ export const EnterpriseLicenseStatus = ({
<Alert variant="warning" size="small"> <Alert variant="warning" size="small">
<AlertDescription className="overflow-visible whitespace-normal"> <AlertDescription className="overflow-visible whitespace-normal">
{t("environments.settings.enterprise.license_unreachable_grace_period", { {t("environments.settings.enterprise.license_unreachable_grace_period", {
gracePeriodEnd: formatDateForDisplay(new Date(gracePeriodEnd), locale, { gracePeriodEnd: new Date(gracePeriodEnd).toLocaleDateString(undefined, {
year: "numeric", year: "numeric",
month: "short", month: "short",
day: "numeric", day: "numeric",
@@ -143,13 +128,6 @@ export const EnterpriseLicenseStatus = ({
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
{status === "instance_mismatch" && (
<Alert variant="error" size="small">
<AlertDescription className="overflow-visible whitespace-normal">
{t("environments.settings.enterprise.license_instance_mismatch_description")}
</AlertDescription>
</Alert>
)}
<p className="border-t border-slate-100 pt-4 text-sm text-slate-500"> <p className="border-t border-slate-100 pt-4 text-sm text-slate-500">
{t("environments.settings.enterprise.questions_please_reach_out_to")}{" "} {t("environments.settings.enterprise.questions_please_reach_out_to")}{" "}
<a <a
@@ -10,9 +10,8 @@ import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { EnterpriseLicenseFeaturesTable } from "./components/EnterpriseLicenseFeaturesTable";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => { const Page = async (props) => {
const params = await props.params; const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
if (IS_FORMBRICKS_CLOUD) { if (IS_FORMBRICKS_CLOUD) {
@@ -94,19 +93,15 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
/> />
</PageHeader> </PageHeader>
{hasLicense ? ( {hasLicense ? (
<> <EnterpriseLicenseStatus
<EnterpriseLicenseStatus status={licenseState.status as "active" | "expired" | "unreachable" | "invalid_license"}
status={licenseState.status} gracePeriodEnd={
lastChecked={licenseState.lastChecked} licenseState.status === "unreachable"
gracePeriodEnd={ ? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
licenseState.status === "unreachable" : undefined
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS) }
: undefined environmentId={params.environmentId}
} />
environmentId={params.environmentId}
/>
{licenseState.features && <EnterpriseLicenseFeaturesTable features={licenseState.features} />}
</>
) : ( ) : (
<div> <div>
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0"> <div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
@@ -1,218 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
import { updateOrganizationAISettingsAction } from "./actions";
import { ZOrganizationAISettingsInput } from "./schemas";
const mocks = vi.hoisted(() => ({
isInstanceAIConfigured: vi.fn(),
checkAuthorizationUpdated: vi.fn(),
deleteOrganization: vi.fn(),
getOrganization: vi.fn(),
getIsMultiOrgEnabled: vi.fn(),
getTranslate: vi.fn(),
updateOrganization: vi.fn(),
}));
vi.mock("@/lib/utils/action-client", () => ({
authenticatedActionClient: {
inputSchema: vi.fn(() => ({
action: vi.fn((fn) => fn),
})),
},
}));
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
checkAuthorizationUpdated: mocks.checkAuthorizationUpdated,
}));
vi.mock("@/lib/organization/service", () => ({
deleteOrganization: mocks.deleteOrganization,
getOrganization: mocks.getOrganization,
updateOrganization: mocks.updateOrganization,
}));
vi.mock("@/lib/ai/service", () => ({
isInstanceAIConfigured: mocks.isInstanceAIConfigured,
}));
vi.mock("@/lingodotdev/server", () => ({
getTranslate: mocks.getTranslate,
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
withAuditLogging: vi.fn((_eventName, _objectType, fn) => fn),
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsMultiOrgEnabled: mocks.getIsMultiOrgEnabled,
}));
const organizationId = "cm9gptbhg0000192zceq9ayuc";
describe("organization AI settings actions", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.checkAuthorizationUpdated.mockResolvedValue(undefined);
mocks.getOrganization.mockResolvedValue({
id: organizationId,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
});
mocks.isInstanceAIConfigured.mockReturnValue(true);
mocks.getTranslate.mockResolvedValue((key: string, values?: Record<string, string>) =>
values ? `${key}:${JSON.stringify(values)}` : key
);
mocks.updateOrganization.mockResolvedValue({
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
});
mocks.getIsMultiOrgEnabled.mockResolvedValue(true);
});
test("accepts AI toggle updates", () => {
expect(
ZOrganizationAISettingsInput.parse({
isAISmartToolsEnabled: true,
})
).toEqual({
isAISmartToolsEnabled: true,
});
});
test("passes owner and manager roles to the authorization check and updates organization settings", async () => {
const ctx = {
user: { id: "user_1", locale: "en-US" },
auditLoggingCtx: {},
};
const parsedInput = {
organizationId,
data: {
isAISmartToolsEnabled: true,
},
};
const result = await updateOrganizationAISettingsAction({ ctx, parsedInput } as any);
expect(mocks.checkAuthorizationUpdated).toHaveBeenCalledWith({
userId: "user_1",
organizationId,
access: [
{
type: "organization",
schema: ZOrganizationAISettingsInput,
data: parsedInput.data,
roles: ["owner", "manager"],
},
],
});
expect(mocks.getOrganization).toHaveBeenCalledWith(organizationId);
expect(mocks.updateOrganization).toHaveBeenCalledWith(organizationId, parsedInput.data);
expect(ctx.auditLoggingCtx).toMatchObject({
organizationId,
oldObject: {
id: organizationId,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
},
newObject: {
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
},
});
expect(result).toEqual({
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
});
});
test("propagates authorization failures so members cannot update AI settings", async () => {
mocks.checkAuthorizationUpdated.mockRejectedValueOnce(new AuthorizationError("Not authorized"));
await expect(
updateOrganizationAISettingsAction({
ctx: {
user: { id: "user_member", locale: "en-US" },
auditLoggingCtx: {},
},
parsedInput: {
organizationId,
data: {
isAISmartToolsEnabled: true,
},
},
} as any)
).rejects.toThrow(AuthorizationError);
expect(mocks.updateOrganization).not.toHaveBeenCalled();
});
test("rejects enabling AI when the instance AI provider is not configured", async () => {
mocks.isInstanceAIConfigured.mockReturnValueOnce(false);
await expect(
updateOrganizationAISettingsAction({
ctx: {
user: { id: "user_owner", locale: "en-US" },
auditLoggingCtx: {},
},
parsedInput: {
organizationId,
data: {
isAISmartToolsEnabled: true,
},
},
} as any)
).rejects.toThrow(OperationNotAllowedError);
expect(mocks.updateOrganization).not.toHaveBeenCalled();
});
test("allows enabling AI when the instance configuration is valid", async () => {
await updateOrganizationAISettingsAction({
ctx: {
user: { id: "user_owner", locale: "en-US" },
auditLoggingCtx: {},
},
parsedInput: {
organizationId,
data: {
isAISmartToolsEnabled: true,
},
},
} as any);
expect(mocks.updateOrganization).toHaveBeenCalledWith(organizationId, {
isAISmartToolsEnabled: true,
});
});
test("allows disabling AI when the instance configuration later becomes invalid", async () => {
mocks.getOrganization.mockResolvedValueOnce({
id: organizationId,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: false,
});
mocks.isInstanceAIConfigured.mockReturnValueOnce(false);
await updateOrganizationAISettingsAction({
ctx: {
user: { id: "user_owner", locale: "en-US" },
auditLoggingCtx: {},
},
parsedInput: {
organizationId,
data: {
isAISmartToolsEnabled: false,
},
},
} as any);
expect(mocks.updateOrganization).toHaveBeenCalledWith(organizationId, {
isAISmartToolsEnabled: false,
});
});
});
@@ -2,44 +2,14 @@
import { z } from "zod"; import { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors"; import { OperationNotAllowedError } from "@formbricks/types/errors";
import type { TOrganizationRole } from "@formbricks/types/memberships";
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations"; import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
import { isInstanceAIConfigured } from "@/lib/ai/service";
import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service"; import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { getTranslate } from "@/lingodotdev/server";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { ZOrganizationAISettingsInput, ZUpdateOrganizationAISettingsAction } from "./schemas";
async function updateOrganizationAction<T extends z.ZodRawShape>({
ctx,
organizationId,
schema,
data,
roles,
}: {
ctx: AuthenticatedActionClientCtx;
organizationId: string;
schema: z.ZodObject<T>;
data: z.infer<z.ZodObject<T>>;
roles: TOrganizationRole[];
}) {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [{ type: "organization", schema, data, roles }],
});
ctx.auditLoggingCtx.organizationId = organizationId;
const oldObject = await getOrganization(organizationId);
const result = await updateOrganization(organizationId, data);
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
const ZUpdateOrganizationNameAction = z.object({ const ZUpdateOrganizationNameAction = z.object({
organizationId: ZId, organizationId: ZId,
@@ -47,7 +17,7 @@ const ZUpdateOrganizationNameAction = z.object({
}); });
export const updateOrganizationNameAction = authenticatedActionClient export const updateOrganizationNameAction = authenticatedActionClient
.inputSchema(ZUpdateOrganizationNameAction) .schema(ZUpdateOrganizationNameAction)
.action( .action(
withAuditLogging( withAuditLogging(
"updated", "updated",
@@ -57,104 +27,26 @@ export const updateOrganizationNameAction = authenticatedActionClient
parsedInput, parsedInput,
}: { }: {
ctx: AuthenticatedActionClientCtx; ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZUpdateOrganizationNameAction>; parsedInput: Record<string, any>;
}) =>
updateOrganizationAction({
ctx,
organizationId: parsedInput.organizationId,
schema: ZOrganizationUpdateInput.pick({ name: true }),
data: parsedInput.data,
roles: ["owner"],
})
)
);
type TOrganizationAISettings = Pick<
NonNullable<Awaited<ReturnType<typeof getOrganization>>>,
"isAISmartToolsEnabled" | "isAIDataAnalysisEnabled"
>;
type TResolvedOrganizationAISettings = {
smartToolsEnabled: boolean;
dataAnalysisEnabled: boolean;
isEnablingAnyAISetting: boolean;
};
const resolveOrganizationAISettings = ({
data,
organization,
}: {
data: z.infer<typeof ZOrganizationAISettingsInput>;
organization: TOrganizationAISettings;
}): TResolvedOrganizationAISettings => {
const smartToolsEnabled = Object.hasOwn(data, "isAISmartToolsEnabled")
? (data.isAISmartToolsEnabled ?? organization.isAISmartToolsEnabled)
: organization.isAISmartToolsEnabled;
const dataAnalysisEnabled = Object.hasOwn(data, "isAIDataAnalysisEnabled")
? (data.isAIDataAnalysisEnabled ?? organization.isAIDataAnalysisEnabled)
: organization.isAIDataAnalysisEnabled;
return {
smartToolsEnabled,
dataAnalysisEnabled,
isEnablingAnyAISetting:
(smartToolsEnabled && !organization.isAISmartToolsEnabled) ||
(dataAnalysisEnabled && !organization.isAIDataAnalysisEnabled),
};
};
const assertOrganizationAISettingsUpdateAllowed = ({
isInstanceAIConfigured,
resolvedSettings,
t,
}: {
isInstanceAIConfigured: boolean;
resolvedSettings: TResolvedOrganizationAISettings;
t: Awaited<ReturnType<typeof getTranslate>>;
}) => {
if (resolvedSettings.isEnablingAnyAISetting && !isInstanceAIConfigured) {
throw new OperationNotAllowedError(t("environments.settings.general.ai_instance_not_configured"));
}
};
export const updateOrganizationAISettingsAction = authenticatedActionClient
.inputSchema(ZUpdateOrganizationAISettingsAction)
.action(
withAuditLogging(
"updated",
"organization",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZUpdateOrganizationAISettingsAction>;
}) => { }) => {
const t = await getTranslate(ctx.user.locale); await checkAuthorizationUpdated({
const organization = await getOrganization(parsedInput.organizationId); userId: ctx.user.id,
if (!organization) {
throw new ResourceNotFoundError("Organization", parsedInput.organizationId);
}
const resolvedSettings = resolveOrganizationAISettings({
data: parsedInput.data,
organization,
});
assertOrganizationAISettingsUpdateAllowed({
isInstanceAIConfigured: isInstanceAIConfigured(),
resolvedSettings,
t,
});
return updateOrganizationAction({
ctx,
organizationId: parsedInput.organizationId, organizationId: parsedInput.organizationId,
schema: ZOrganizationAISettingsInput, access: [
data: parsedInput.data, {
roles: ["owner", "manager"], type: "organization",
schema: ZOrganizationUpdateInput.pick({ name: true }),
data: parsedInput.data,
roles: ["owner"],
},
],
}); });
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
const oldObject = await getOrganization(parsedInput.organizationId);
const result = await updateOrganization(parsedInput.organizationId, parsedInput.data);
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
} }
) )
); );
@@ -163,15 +55,13 @@ const ZDeleteOrganizationAction = z.object({
organizationId: ZId, organizationId: ZId,
}); });
export const deleteOrganizationAction = authenticatedActionClient export const deleteOrganizationAction = authenticatedActionClient.schema(ZDeleteOrganizationAction).action(
.inputSchema(ZDeleteOrganizationAction) withAuditLogging(
.action( "deleted",
withAuditLogging("deleted", "organization", async ({ ctx, parsedInput }) => { "organization",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled(); const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled) { if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
const t = await getTranslate(ctx.user.locale);
throw new OperationNotAllowedError(t("environments.settings.general.organization_deletion_disabled"));
}
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -187,5 +77,6 @@ export const deleteOrganizationAction = authenticatedActionClient
const oldObject = await getOrganization(parsedInput.organizationId); const oldObject = await getOrganization(parsedInput.organizationId);
ctx.auditLoggingCtx.oldObject = oldObject; ctx.auditLoggingCtx.oldObject = oldObject;
return await deleteOrganization(parsedInput.organizationId); return await deleteOrganization(parsedInput.organizationId);
}) }
); )
);
@@ -1,118 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { updateOrganizationAISettingsAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
import { getDisplayedOrganizationAISettingValue, getOrganizationAIEnablementState } from "@/lib/ai/utils";
import { getAccessFlags } from "@/lib/membership/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
interface AISettingsToggleProps {
organization: TOrganization;
membershipRole?: TOrganizationRole;
isInstanceAIConfigured: boolean;
}
export const AISettingsToggle = ({
organization,
membershipRole,
isInstanceAIConfigured,
}: Readonly<AISettingsToggleProps>) => {
const [loadingField, setLoadingField] = useState<string | null>(null);
const { t } = useTranslation();
const router = useRouter();
const { isOwner, isManager } = getAccessFlags(membershipRole);
const canEdit = isOwner || isManager;
const aiEnablementState = getOrganizationAIEnablementState({
isInstanceConfigured: isInstanceAIConfigured,
});
const showInstanceConfigWarning = aiEnablementState.blockReason === "instanceNotConfigured";
const isToggleDisabled = loadingField !== null || !canEdit || !aiEnablementState.canEnableFeatures;
const aiEnablementBlockedMessage = t("environments.settings.general.ai_instance_not_configured");
const displayedSmartToolsValue = getDisplayedOrganizationAISettingValue({
currentValue: organization.isAISmartToolsEnabled,
isInstanceConfigured: isInstanceAIConfigured,
});
const displayedDataAnalysisValue = getDisplayedOrganizationAISettingValue({
currentValue: organization.isAIDataAnalysisEnabled,
isInstanceConfigured: isInstanceAIConfigured,
});
const handleToggle = async (
field: "isAISmartToolsEnabled" | "isAIDataAnalysisEnabled",
checked: boolean
) => {
if (checked && !aiEnablementState.canEnableFeatures) {
toast.error(aiEnablementBlockedMessage);
return;
}
setLoadingField(field);
try {
const data =
field === "isAISmartToolsEnabled"
? { isAISmartToolsEnabled: checked }
: { isAIDataAnalysisEnabled: checked };
const response = await updateOrganizationAISettingsAction({
organizationId: organization.id,
data,
});
if (response?.data) {
toast.success(t("environments.settings.general.ai_settings_updated_successfully"));
router.refresh();
} else {
toast.error(getFormattedErrorMessage(response));
}
} catch (error) {
toast.error(error instanceof Error ? error.message : t("common.something_went_wrong_please_try_again"));
} finally {
setLoadingField(null);
}
};
return (
<div className="space-y-4">
{showInstanceConfigWarning && (
<Alert variant="warning">
<AlertDescription>{aiEnablementBlockedMessage}</AlertDescription>
</Alert>
)}
<AdvancedOptionToggle
isChecked={displayedSmartToolsValue}
onToggle={(checked) => handleToggle("isAISmartToolsEnabled", checked)}
htmlId="ai-smart-tools-toggle"
title={t("environments.settings.general.ai_smart_tools_enabled")}
description={t("environments.settings.general.ai_smart_tools_enabled_description")}
disabled={isToggleDisabled}
customContainerClass="px-0"
/>
<AdvancedOptionToggle
isChecked={displayedDataAnalysisValue}
onToggle={(checked) => handleToggle("isAIDataAnalysisEnabled", checked)}
htmlId="ai-data-analysis-toggle"
title={t("environments.settings.general.ai_data_analysis_enabled")}
description={t("environments.settings.general.ai_data_analysis_enabled_description")}
disabled={isToggleDisabled}
customContainerClass="px-0"
/>
{!canEdit && (
<Alert variant="warning">
<AlertDescription>
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
</AlertDescription>
</Alert>
)}
</div>
);
};
@@ -107,7 +107,7 @@ const DeleteOrganizationModal = ({
}: DeleteOrganizationModalProps) => { }: DeleteOrganizationModalProps) => {
const [inputValue, setInputValue] = useState(""); const [inputValue, setInputValue] = useState("");
const { t } = useTranslation(); const { t } = useTranslation();
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e) => {
setInputValue(e.target.value); setInputValue(e.target.value);
}; };
@@ -61,7 +61,7 @@ export const EditOrganizationNameForm = ({ organization, membershipRole }: EditO
toast.error(errorMessage); toast.error(errorMessage);
} }
} catch (err) { } catch (err) {
toast.error(`Error: ${err instanceof Error ? err.message : "Unknown error occurred"}`); toast.error(`Error: ${err.message}`);
} }
}; };
@@ -1,5 +1,4 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar"; import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { isInstanceAIConfigured } from "@/lib/ai/service";
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants"; import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
@@ -10,9 +9,7 @@ import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { IdBadge } from "@/modules/ui/components/id-badge"; import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import packageJson from "@/package.json";
import { SettingsCard } from "../../components/SettingsCard"; import { SettingsCard } from "../../components/SettingsCard";
import { AISettingsToggle } from "./components/AISettingsToggle";
import { DeleteOrganization } from "./components/DeleteOrganization"; import { DeleteOrganization } from "./components/DeleteOrganization";
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm"; import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
import { SecurityListTip } from "./components/SecurityListTip"; import { SecurityListTip } from "./components/SecurityListTip";
@@ -28,7 +25,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const user = session?.user?.id ? await getUser(session.user.id) : null; const user = session?.user?.id ? await getUser(session.user.id) : null;
const isMultiOrgEnabled = await getIsMultiOrgEnabled(); const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id); const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.billing.plan);
const isDeleteDisabled = !isOwner || !isMultiOrgEnabled; const isDeleteDisabled = !isOwner || !isMultiOrgEnabled;
const currentUserRole = currentUserMembership?.role; const currentUserRole = currentUserMembership?.role;
@@ -62,15 +59,6 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
membershipRole={currentUserMembership?.role} membershipRole={currentUserMembership?.role}
/> />
</SettingsCard> </SettingsCard>
<SettingsCard
title={t("environments.settings.general.ai_enabled")}
description={t("environments.settings.general.ai_enabled_description")}>
<AISettingsToggle
organization={organization}
membershipRole={currentUserMembership?.role}
isInstanceAIConfigured={isInstanceAIConfigured()}
/>
</SettingsCard>
<EmailCustomizationSettings <EmailCustomizationSettings
organization={organization} organization={organization}
hasWhiteLabelPermission={hasWhiteLabelPermission} hasWhiteLabelPermission={hasWhiteLabelPermission}
@@ -93,10 +81,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
</SettingsCard> </SettingsCard>
)} )}
<div className="space-y-2"> <IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
<IdBadge id={packageJson.version} label={t("common.formbricks_version")} variant="column" />
</div>
</PageContentWrapper> </PageContentWrapper>
); );
}; };
@@ -1,13 +0,0 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
export const ZOrganizationAISettingsInput = ZOrganizationUpdateInput.pick({
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
});
export const ZUpdateOrganizationAISettingsAction = z.object({
organizationId: ZId,
data: ZOrganizationAISettingsInput,
});
@@ -1,11 +1,10 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service"; import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
const Layout = async (props: { params: Promise<{ environmentId: string }>; children: React.ReactNode }) => { const Layout = async (props) => {
const params = await props.params; const params = await props.params;
const { children } = props; const { children } = props;
@@ -18,15 +17,15 @@ const Layout = async (props: { params: Promise<{ environmentId: string }>; child
]); ]);
if (!organization) { if (!organization) {
throw new ResourceNotFoundError(t("common.organization"), null); throw new Error(t("common.organization_not_found"));
} }
if (!project) { if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null); throw new Error(t("common.workspace_not_found"));
} }
if (!session) { if (!session) {
throw new AuthenticationError(t("common.not_authenticated")); throw new Error(t("common.session_not_found"));
} }
return <>{children}</>; return <>{children}</>;
@@ -1,3 +1,3 @@
export const SettingsTitle = ({ title }: { title: string }) => { export const SettingsTitle = ({ title }) => {
return <h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">{title}</h2>; return <h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">{title}</h2>;
}; };
@@ -1,6 +1,6 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => { const Page = async (props) => {
const params = await props.params; const params = await props.params;
return redirect(`/environments/${params.environmentId}/settings/profile`); return redirect(`/environments/${params.environmentId}/settings/profile`);
}; };
@@ -23,7 +23,7 @@ const ZGetResponsesAction = z.object({
}); });
export const getResponsesAction = authenticatedActionClient export const getResponsesAction = authenticatedActionClient
.inputSchema(ZGetResponsesAction) .schema(ZGetResponsesAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -57,7 +57,7 @@ const ZGetSurveySummaryAction = z.object({
}); });
export const getSurveySummaryAction = authenticatedActionClient export const getSurveySummaryAction = authenticatedActionClient
.inputSchema(ZGetSurveySummaryAction) .schema(ZGetSurveySummaryAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -85,7 +85,7 @@ const ZGetResponseCountAction = z.object({
}); });
export const getResponseCountAction = authenticatedActionClient export const getResponseCountAction = authenticatedActionClient
.inputSchema(ZGetResponseCountAction) .schema(ZGetResponseCountAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -110,12 +110,12 @@ export const getResponseCountAction = authenticatedActionClient
const ZGetDisplaysWithContactAction = z.object({ const ZGetDisplaysWithContactAction = z.object({
surveyId: ZId, surveyId: ZId,
limit: z.int().min(1).max(100), limit: z.number().int().min(1).max(100),
offset: z.int().nonnegative(), offset: z.number().int().nonnegative(),
}); });
export const getDisplaysWithContactAction = authenticatedActionClient export const getDisplaysWithContactAction = authenticatedActionClient
.inputSchema(ZGetDisplaysWithContactAction) .schema(ZGetDisplaysWithContactAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -3,7 +3,6 @@ import { getServerSession } from "next-auth";
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context"; import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
import { getResponseCountBySurveyId } from "@/lib/response/service"; import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
type Props = { type Props = {
@@ -15,11 +14,10 @@ export const generateMetadata = async (props: Props): Promise<Metadata> => {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
const survey = await getSurvey(params.surveyId); const survey = await getSurvey(params.surveyId);
const responseCount = await getResponseCountBySurveyId(params.surveyId); const responseCount = await getResponseCountBySurveyId(params.surveyId);
const t = await getTranslate();
if (session) { if (session) {
return { return {
title: `${t("common.count_responses", { count: responseCount })} | ${t("environments.surveys.summary.survey_results", { surveyName: survey?.name })}`, title: `${responseCount} Responses | ${survey?.name} Results`,
}; };
} }
return { return {
@@ -27,7 +25,7 @@ export const generateMetadata = async (props: Props): Promise<Metadata> => {
}; };
}; };
const SurveyLayout = async ({ children }: { children: React.ReactNode }) => { const SurveyLayout = async ({ children }) => {
return <ResponseFilterProvider>{children}</ResponseFilterProvider>; return <ResponseFilterProvider>{children}</ResponseFilterProvider>;
}; };
@@ -29,7 +29,6 @@ import { ResponseTableCell } from "@/app/(app)/environments/[environmentId]/surv
import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns"; import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions"; import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
import { downloadResponsesFile } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/utils"; import { downloadResponsesFile } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions"; import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { import {
@@ -97,8 +96,8 @@ export const ResponseTable = ({
const showQuotasColumn = isQuotasAllowed && quotas.length > 0; const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
// Generate columns // Generate columns
const columns = useMemo( const columns = useMemo(
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, locale, t, showQuotasColumn), () => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn),
[survey, isExpanded, isReadOnly, locale, t, showQuotasColumn] [survey, isExpanded, isReadOnly, t, showQuotasColumn]
); );
// Save settings to localStorage when they change // Save settings to localStorage when they change
@@ -202,21 +201,15 @@ export const ResponseTable = ({
}; };
const deleteResponse = async (responseId: string, params?: { decrementQuotas?: boolean }) => { const deleteResponse = async (responseId: string, params?: { decrementQuotas?: boolean }) => {
const result = await deleteResponseAction({ await deleteResponseAction({ responseId, decrementQuotas: params?.decrementQuotas ?? false });
responseId,
decrementQuotas: params?.decrementQuotas ?? false,
});
if (result?.serverError) {
throw new Error(getFormattedErrorMessage(result));
}
}; };
// Handle downloading selected responses // Handle downloading selected responses
const downloadSelectedRows = async (responseIds: string[], format: "xlsx" | "csv") => { const downloadSelectedRows = async (responseIds: string[], format: "csv" | "xlsx") => {
try { try {
const downloadResponse = await getResponsesDownloadUrlAction({ const downloadResponse = await getResponsesDownloadUrlAction({
surveyId: survey.id, surveyId: survey.id,
format, format: format,
filterCriteria: { responseIds }, filterCriteria: { responseIds },
}); });
@@ -307,6 +300,7 @@ export const ResponseTable = ({
<DataTableSettingsModal <DataTableSettingsModal
open={isTableSettingsModalOpen} open={isTableSettingsModalOpen}
setOpen={setIsTableSettingsModalOpen} setOpen={setIsTableSettingsModalOpen}
survey={survey}
table={table} table={table}
columnOrder={columnOrder} columnOrder={columnOrder}
handleDragEnd={handleDragEnd} handleDragEnd={handleDragEnd}
@@ -5,14 +5,13 @@ import { TFunction } from "i18next";
import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react"; import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { TResponseTableData } from "@formbricks/types/responses"; import { TResponseTableData } from "@formbricks/types/responses";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation"; import { getTextContent } from "@formbricks/types/surveys/validation";
import { TUserLocale } from "@formbricks/types/user";
import { getLocalizedValue } from "@/lib/i18n/utils"; import { getLocalizedValue } from "@/lib/i18n/utils";
import { extractChoiceIdsFromResponse } from "@/lib/response/utils"; import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
import { getContactIdentifier } from "@/lib/utils/contact"; import { getContactIdentifier } from "@/lib/utils/contact";
import { formatDateTimeForDisplay } from "@/lib/utils/datetime"; import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { recallToHeadline } from "@/lib/utils/recall"; import { recallToHeadline } from "@/lib/utils/recall";
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse"; import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils"; import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
@@ -35,7 +34,6 @@ const getElementColumnsData = (
element: TSurveyElement, element: TSurveyElement,
survey: TSurvey, survey: TSurvey,
isExpanded: boolean, isExpanded: boolean,
locale: TUserLocale,
t: TFunction t: TFunction
): ColumnDef<TResponseTableData>[] => { ): ColumnDef<TResponseTableData>[] => {
const ELEMENTS_ICON_MAP = getElementIconMap(t); const ELEMENTS_ICON_MAP = getElementIconMap(t);
@@ -43,7 +41,7 @@ const getElementColumnsData = (
const contactInfoFields = ["firstName", "lastName", "email", "phone", "company"]; const contactInfoFields = ["firstName", "lastName", "email", "phone", "company"];
// Helper function to create consistent column headers // Helper function to create consistent column headers
const createElementHeader = (elementType: TSurveyElementTypeEnum, headline: string, suffix?: string) => { const createElementHeader = (elementType: string, headline: string, suffix?: string) => {
const title = suffix ? `${headline} - ${suffix}` : headline; const title = suffix ? `${headline} - ${suffix}` : headline;
const ElementHeader = () => ( const ElementHeader = () => (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -169,7 +167,6 @@ const getElementColumnsData = (
survey={survey} survey={survey}
responseData={responseValue} responseData={responseValue}
language={language} language={language}
locale={locale}
isExpanded={isExpanded} isExpanded={isExpanded}
showId={false} showId={false}
/> />
@@ -221,7 +218,6 @@ const getElementColumnsData = (
survey={survey} survey={survey}
responseData={responseValue} responseData={responseValue}
language={language} language={language}
locale={locale}
isExpanded={isExpanded} isExpanded={isExpanded}
showId={false} showId={false}
/> />
@@ -236,7 +232,7 @@ const getMetadataColumnsData = (t: TFunction): ColumnDef<TResponseTableData>[] =
const metadataColumns: ColumnDef<TResponseTableData>[] = []; const metadataColumns: ColumnDef<TResponseTableData>[] = [];
METADATA_FIELDS.forEach((label) => { METADATA_FIELDS.forEach((label) => {
const IconComponent = COLUMNS_ICON_MAP[label as keyof typeof COLUMNS_ICON_MAP]; const IconComponent = COLUMNS_ICON_MAP[label];
metadataColumns.push({ metadataColumns.push({
accessorKey: "METADATA_" + label, accessorKey: "METADATA_" + label,
@@ -263,14 +259,11 @@ export const generateResponseTableColumns = (
survey: TSurvey, survey: TSurvey,
isExpanded: boolean, isExpanded: boolean,
isReadOnly: boolean, isReadOnly: boolean,
locale: TUserLocale,
t: TFunction, t: TFunction,
showQuotasColumn: boolean showQuotasColumn: boolean
): ColumnDef<TResponseTableData>[] => { ): ColumnDef<TResponseTableData>[] => {
const elements = getElementsFromBlocks(survey.blocks); const elements = getElementsFromBlocks(survey.blocks);
const elementColumns = elements.flatMap((element) => const elementColumns = elements.flatMap((element) => getElementColumnsData(element, survey, isExpanded, t));
getElementColumnsData(element, survey, isExpanded, locale, t)
);
const dateColumn: ColumnDef<TResponseTableData> = { const dateColumn: ColumnDef<TResponseTableData> = {
accessorKey: "createdAt", accessorKey: "createdAt",
@@ -278,7 +271,7 @@ export const generateResponseTableColumns = (
size: 200, size: 200,
cell: ({ row }) => { cell: ({ row }) => {
const date = new Date(row.original.createdAt); const date = new Date(row.original.createdAt);
return <p className="text-slate-900">{formatDateTimeForDisplay(date, locale)}</p>; return <p className="text-slate-900">{getFormattedDateTimeString(date)}</p>;
}, },
}; };
@@ -1,5 +1,4 @@
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { TFunction } from "i18next";
import { import {
AirplayIcon, AirplayIcon,
ArrowUpFromDotIcon, ArrowUpFromDotIcon,
@@ -39,7 +38,7 @@ describe("utils", () => {
"environments.surveys.responses.source": "Source", "environments.surveys.responses.source": "Source",
}; };
return translations[key] || key; return translations[key] || key;
}) as unknown as TFunction; });
describe("getAddressFieldLabel", () => { describe("getAddressFieldLabel", () => {
test("returns correct label for addressLine1", () => { test("returns correct label for addressLine1", () => {
@@ -80,24 +80,9 @@ export const COLUMNS_ICON_MAP = {
const userAgentFields = ["browser", "os", "device"]; const userAgentFields = ["browser", "os", "device"];
export const METADATA_FIELDS = ["action", "country", ...userAgentFields, "source", "url"]; export const METADATA_FIELDS = ["action", "country", ...userAgentFields, "source", "url"];
export const getMetadataValue = ( export const getMetadataValue = (meta: TResponseMeta, label: string) => {
meta: TResponseMeta, if (userAgentFields.includes(label)) {
label: (typeof METADATA_FIELDS)[number] return meta.userAgent?.[label];
): string | undefined => {
switch (label) {
case "browser":
return meta.userAgent?.browser;
case "os":
return meta.userAgent?.os;
case "device":
return meta.userAgent?.device;
case "action":
return meta.action;
case "country":
return meta.country;
case "source":
return meta.source;
case "url":
return meta.url;
} }
return meta[label];
}; };
@@ -1,4 +1,3 @@
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation"; import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage"; import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA"; import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
@@ -8,6 +7,7 @@ import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service"; import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments"; import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -17,30 +17,31 @@ import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
const Page = async (props: { params: Promise<{ environmentId: string; surveyId: string }> }) => { const Page = async (props) => {
const params = await props.params; const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId); const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
const [survey, user, tags, isContactsEnabled, responseCount] = await Promise.all([ const [survey, user, tags, isContactsEnabled, responseCount, locale] = await Promise.all([
getSurvey(params.surveyId), getSurvey(params.surveyId),
getUser(session.user.id), getUser(session.user.id),
getTagsByEnvironmentId(params.environmentId), getTagsByEnvironmentId(params.environmentId),
getIsContactsEnabled(organization.id), getIsContactsEnabled(),
getResponseCountBySurveyId(params.surveyId), getResponseCountBySurveyId(params.surveyId),
findMatchingLocale(),
]); ]);
if (!survey) { if (!survey) {
throw new ResourceNotFoundError(t("common.survey"), params.surveyId); throw new Error(t("common.survey_not_found"));
} }
if (!user) { if (!user) {
throw new AuthenticationError(t("common.not_authenticated")); throw new Error(t("common.user_not_found"));
} }
if (!organization) { if (!organization) {
throw new ResourceNotFoundError(t("common.organization"), null); throw new Error(t("common.organization_not_found"));
} }
const segments = isContactsEnabled ? await getSegments(params.environmentId) : []; const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
@@ -49,10 +50,10 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
const organizationBilling = await getOrganizationBilling(organization.id); const organizationBilling = await getOrganizationBilling(organization.id);
if (!organizationBilling) { if (!organizationBilling) {
throw new ResourceNotFoundError(t("common.organization"), organization.id); throw new Error(t("common.organization_not_found"));
} }
const isQuotasAllowed = await getIsQuotasEnabled(organization.id); const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
const quotas = isQuotasAllowed ? await getQuotas(survey.id) : []; const quotas = isQuotasAllowed ? await getQuotas(survey.id) : [];
// Fetch initial responses on the server to prevent duplicate client-side fetch // Fetch initial responses on the server to prevent duplicate client-side fetch
@@ -85,7 +86,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
environmentTags={tags} environmentTags={tags}
user={user} user={user}
responsesPerPage={RESPONSES_PER_PAGE} responsesPerPage={RESPONSES_PER_PAGE}
locale={user.locale} locale={locale}
isReadOnly={isReadOnly} isReadOnly={isReadOnly}
isQuotasAllowed={isQuotasAllowed} isQuotasAllowed={isQuotasAllowed}
quotas={quotas} quotas={quotas}
@@ -7,6 +7,7 @@ import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/s
import { getSurvey, updateSurvey } from "@/lib/survey/service"; import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { convertToCsv } from "@/lib/utils/file-conversion"; import { convertToCsv } from "@/lib/utils/file-conversion";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper"; import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
@@ -21,7 +22,7 @@ const ZSendEmbedSurveyPreviewEmailAction = z.object({
}); });
export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
.inputSchema(ZSendEmbedSurveyPreviewEmailAction) .schema(ZSendEmbedSurveyPreviewEmailAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId); const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const organizationLogoUrl = await getOrganizationLogoUrl(organizationId); const organizationLogoUrl = await getOrganizationLogoUrl(organizationId);
@@ -64,49 +65,57 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
const ZResetSurveyAction = z.object({ const ZResetSurveyAction = z.object({
surveyId: ZId, surveyId: ZId,
organizationId: ZId,
projectId: ZId, projectId: ZId,
}); });
export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action( export const resetSurveyAction = authenticatedActionClient.schema(ZResetSurveyAction).action(
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => { withAuditLogging(
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId); "updated",
const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId); "survey",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZResetSurveyAction>;
}) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: parsedInput.projectId,
},
],
});
await checkAuthorizationUpdated({ ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
userId: ctx.user.id, ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
organizationId, ctx.auditLoggingCtx.oldObject = null;
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId; const { deletedResponsesCount, deletedDisplaysCount } = await deleteResponsesAndDisplaysForSurvey(
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId; parsedInput.surveyId
ctx.auditLoggingCtx.oldObject = null; );
const { deletedResponsesCount, deletedDisplaysCount } = await deleteResponsesAndDisplaysForSurvey( ctx.auditLoggingCtx.newObject = {
parsedInput.surveyId deletedResponsesCount: deletedResponsesCount,
); deletedDisplaysCount: deletedDisplaysCount,
};
ctx.auditLoggingCtx.newObject = { return {
deletedResponsesCount: deletedResponsesCount, success: true,
deletedDisplaysCount: deletedDisplaysCount, deletedResponsesCount: deletedResponsesCount,
}; deletedDisplaysCount: deletedDisplaysCount,
};
return { }
success: true, )
deletedResponsesCount: deletedResponsesCount,
deletedDisplaysCount: deletedDisplaysCount,
};
})
); );
const ZGetEmailHtmlAction = z.object({ const ZGetEmailHtmlAction = z.object({
@@ -114,7 +123,7 @@ const ZGetEmailHtmlAction = z.object({
}); });
export const getEmailHtmlAction = authenticatedActionClient export const getEmailHtmlAction = authenticatedActionClient
.inputSchema(ZGetEmailHtmlAction) .schema(ZGetEmailHtmlAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -143,10 +152,9 @@ const ZGeneratePersonalLinksAction = z.object({
}); });
export const generatePersonalLinksAction = authenticatedActionClient export const generatePersonalLinksAction = authenticatedActionClient
.inputSchema(ZGeneratePersonalLinksAction) .schema(ZGeneratePersonalLinksAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId); const isContactsEnabled = await getIsContactsEnabled();
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) { if (!isContactsEnabled) {
throw new OperationNotAllowedError("Contacts are not enabled for this environment"); throw new OperationNotAllowedError("Contacts are not enabled for this environment");
} }
@@ -223,7 +231,7 @@ const ZUpdateSingleUseLinksAction = z.object({
}); });
export const updateSingleUseLinksAction = authenticatedActionClient export const updateSingleUseLinksAction = authenticatedActionClient
.inputSchema(ZUpdateSingleUseLinksAction) .schema(ZUpdateSingleUseLinksAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -30,7 +30,8 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
</div> </div>
</div> </div>
<p className="flex w-32 items-end justify-end text-slate-600"> <p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: elementSummary.booked.count })} {elementSummary.booked.count}{" "}
{elementSummary.booked.count === 1 ? t("common.response") : t("common.responses")}
</p> </p>
</div> </div>
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.booked.percentage / 100} /> <ProgressBar barColor="bg-brand-dark" progress={elementSummary.booked.percentage / 100} />
@@ -46,7 +47,8 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
</div> </div>
</div> </div>
<p className="flex w-32 items-end justify-end text-slate-600"> <p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: elementSummary.skipped.count })} {elementSummary.skipped.count}{" "}
{elementSummary.skipped.count === 1 ? t("common.response") : t("common.responses")}
</p> </p>
</div> </div>
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.skipped.percentage / 100} /> <ProgressBar barColor="bg-brand-dark" progress={elementSummary.skipped.percentage / 100} />
@@ -64,7 +64,7 @@ export const ConsentSummary = ({ elementSummary, survey, setFilter }: ConsentSum
</div> </div>
</div> </div>
<p className="flex w-32 items-end justify-end text-slate-600"> <p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: summaryItem.count })} {summaryItem.count} {summaryItem.count === 1 ? t("common.response") : t("common.responses")}
</p> </p>
</div> </div>
<div className="group-hover:opacity-80"> <div className="group-hover:opacity-80">
@@ -7,7 +7,7 @@ import { TSurvey, TSurveyElementSummaryDate } from "@formbricks/types/surveys/ty
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time"; import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact"; import { getContactIdentifier } from "@/lib/utils/contact";
import { formatStoredDateForDisplay } from "@/lib/utils/date-display"; import { formatDateWithOrdinal } from "@/lib/utils/datetime";
import { PersonAvatar } from "@/modules/ui/components/avatars"; import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { EmptyState } from "@/modules/ui/components/empty-state"; import { EmptyState } from "@/modules/ui/components/empty-state";
@@ -32,14 +32,13 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
}; };
const renderResponseValue = (value: string) => { const renderResponseValue = (value: string) => {
const formattedDate = formatStoredDateForDisplay(value, elementSummary.element.format, locale); const parsedDate = new Date(value);
return ( const formattedDate = isNaN(parsedDate.getTime())
formattedDate ?? ? `${t("common.invalid_date")}(${value})`
t("common.invalid_date_with_value", { : formatDateWithOrdinal(parsedDate);
value,
}) return formattedDate;
);
}; };
return ( return (
@@ -60,7 +59,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
elementSummary.samples.slice(0, visibleResponses).map((response) => ( elementSummary.samples.slice(0, visibleResponses).map((response) => (
<div <div
key={response.id} key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent"> className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
<div className="pl-4 md:pl-6"> <div className="pl-4 md:pl-6">
{response.contact ? ( {response.contact ? (
<Link <Link
@@ -85,7 +84,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold"> <div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{renderResponseValue(response.value)} {renderResponseValue(response.value)}
</div> </div>
<div className="px-4 md:px-6"> <div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)} {timeSince(new Date(response.updatedAt).toISOString(), locale)}
</div> </div>
</div> </div>
@@ -48,7 +48,7 @@ export const ElementSummaryHeader = ({
{showResponses && ( {showResponses && (
<div className="flex items-center rounded-lg bg-slate-100 p-2"> <div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" /> <InboxIcon className="mr-2 h-4 w-4" />
{t("common.count_responses", { count: elementSummary.responseCount })} {`${elementSummary.responseCount} ${t("common.responses")}`}
</div> </div>
)} )}
{additionalInfo} {additionalInfo}
@@ -41,7 +41,8 @@ export const HiddenFieldsSummary = ({ environment, elementSummary, locale }: Hid
</div> </div>
<div className="flex items-center rounded-lg bg-slate-100 p-2"> <div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" /> <InboxIcon className="mr-2 h-4 w-4" />
{t("common.count_responses", { count: elementSummary.responseCount })} {elementSummary.responseCount}{" "}
{elementSummary.responseCount === 1 ? t("common.response") : t("common.responses")}
</div> </div>
</div> </div>
</div> </div>
@@ -31,7 +31,7 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
if (label) { if (label) {
return label; return label;
} else if (percentage !== undefined && totalResponsesForRow !== undefined) { } else if (percentage !== undefined && totalResponsesForRow !== undefined) {
return t("common.count_responses", { count: Math.round((percentage / 100) * totalResponsesForRow) }); return `${Math.round((percentage / 100) * totalResponsesForRow)} ${t("common.responses")}`;
} }
return ""; return "";
}; };
@@ -77,7 +77,7 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
)}> )}>
<button <button
style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }} style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }}
className="m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline hover:outline-brand-dark" className="hover:outline-brand-dark m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline"
onClick={() => onClick={() =>
setFilter( setFilter(
elementSummary.element.id, elementSummary.element.id,
@@ -75,7 +75,7 @@ export const MultipleChoiceSummary = ({
elementSummary.type === "multipleChoiceMulti" ? ( elementSummary.type === "multipleChoiceMulti" ? (
<div className="flex items-center rounded-lg bg-slate-100 p-2"> <div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" /> <InboxIcon className="mr-2 h-4 w-4" />
{t("common.count_selections", { count: elementSummary.selectionCount })} {`${elementSummary.selectionCount} ${t("common.selections")}`}
</div> </div>
) : undefined ) : undefined
} }
@@ -110,7 +110,7 @@ export const MultipleChoiceSummary = ({
</div> </div>
<div className="flex w-full space-x-2"> <div className="flex w-full space-x-2">
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0"> <p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{t("common.count_selections", { count: result.count })} {result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
</p> </p>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700"> <p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}% {convertFloatToNDecimal(result.percentage, 2)}%
@@ -60,9 +60,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
}, },
}; };
const filter = (filters as Record<string, { comparison: string; values: string | string[] | undefined }>)[ const filter = filters[group];
group
];
if (filter) { if (filter) {
setFilter( setFilter(
@@ -106,7 +104,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
<TabsContent value="aggregated" className="mt-4"> <TabsContent value="aggregated" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6"> <div className="px-4 pb-6 pt-4 md:px-6">
<div className="space-y-5 text-sm md:text-base"> <div className="space-y-5 text-sm md:text-base">
{(["promoters", "passives", "detractors", "dismissed"] as const).map((group) => ( {["promoters", "passives", "detractors", "dismissed"].map((group) => (
<button <button
className="w-full cursor-pointer hover:opacity-80" className="w-full cursor-pointer hover:opacity-80"
key={group} key={group}
@@ -125,7 +123,8 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
</div> </div>
</div> </div>
<p className="flex w-32 items-end justify-end text-slate-600"> <p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: elementSummary[group]?.count })} {elementSummary[group]?.count}{" "}
{elementSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
</p> </p>
</div> </div>
<ProgressBar <ProgressBar
@@ -159,7 +158,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
}> }>
<div className="flex h-32 w-full flex-col items-center justify-end"> <div className="flex h-32 w-full flex-col items-center justify-end">
<div <div
className="w-full rounded-t-lg border border-slate-200 bg-brand-dark transition-all group-hover:brightness-110" className="bg-brand-dark w-full rounded-t-lg border border-slate-200 transition-all group-hover:brightness-110"
style={{ style={{
height: `${Math.max(choice.percentage, 2)}%`, height: `${Math.max(choice.percentage, 2)}%`,
opacity, opacity,
@@ -37,7 +37,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
elementSummary.element.allowMulti ? ( elementSummary.element.allowMulti ? (
<div className="flex items-center rounded-lg bg-slate-100 p-2"> <div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" /> <InboxIcon className="mr-2 h-4 w-4" />
{t("common.count_selections", { count: elementSummary.selectionCount })} {`${elementSummary.selectionCount} ${t("common.selections")}`}
</div> </div>
) : undefined ) : undefined
} }
@@ -74,7 +74,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
</div> </div>
<div className="flex w-full space-x-2"> <div className="flex w-full space-x-2">
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0"> <p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{t("common.count_selections", { count: result.count })} {result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
</p> </p>
<p className="self-end rounded-lg bg-slate-100 px-2 text-slate-700"> <p className="self-end rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}% {convertFloatToNDecimal(result.percentage, 2)}%
@@ -116,7 +116,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
) )
}> }>
<div <div
className={`h-full bg-brand-dark ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`} className={`bg-brand-dark h-full ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
style={{ opacity }} style={{ opacity }}
/> />
</ClickableBarSegment> </ClickableBarSegment>
@@ -198,7 +198,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
</div> </div>
</div> </div>
<p className="flex w-32 items-end justify-end text-slate-600"> <p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: result.count })} {result.count} {result.count === 1 ? t("common.response") : t("common.responses")}
</p> </p>
</div> </div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} /> <ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
@@ -215,7 +215,8 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
<div className="text flex justify-between px-2"> <div className="text flex justify-between px-2">
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p> <p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
<p className="flex w-32 items-end justify-end text-slate-600"> <p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: elementSummary.dismissed.count })} {elementSummary.dismissed.count}{" "}
{elementSummary.dismissed.count === 1 ? t("common.response") : t("common.responses")}
</p> </p>
</div> </div>
</div> </div>
@@ -15,7 +15,7 @@ interface SummaryMetadataProps {
isQuotasAllowed: boolean; isQuotasAllowed: boolean;
} }
const formatTime = (ttc: number) => { const formatTime = (ttc) => {
const seconds = ttc / 1000; const seconds = ttc / 1000;
let formattedValue; let formattedValue;
@@ -107,9 +107,7 @@ export const SummaryMetadata = ({
label={t("environments.surveys.summary.time_to_complete")} label={t("environments.surveys.summary.time_to_complete")}
percentage={null} percentage={null}
value={ttcAverage === 0 ? <span>-</span> : `${formatTime(ttcAverage)}`} value={ttcAverage === 0 ? <span>-</span> : `${formatTime(ttcAverage)}`}
tooltipText={t("environments.surveys.summary.ttc_survey_tooltip", { tooltipText={t("environments.surveys.summary.ttc_tooltip")}
defaultValue: "Average time to complete the survey.",
})}
isLoading={isLoading} isLoading={isLoading}
/> />
@@ -105,7 +105,7 @@ export const SummaryPage = ({
setDisplays(data); setDisplays(data);
setHasMoreDisplays(data.length === DISPLAYS_PER_PAGE); setHasMoreDisplays(data.length === DISPLAYS_PER_PAGE);
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : String(error)); toast.error(error);
setDisplays([]); setDisplays([]);
setHasMoreDisplays(false); setHasMoreDisplays(false);
} finally { } finally {
@@ -64,7 +64,7 @@ export const SurveyAnalysisCTA = ({
const [isResetModalOpen, setIsResetModalOpen] = useState(false); const [isResetModalOpen, setIsResetModalOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false); const [isResetting, setIsResetting] = useState(false);
const { project } = useEnvironment(); const { organizationId, project } = useEnvironment();
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly); const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted; const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -128,6 +128,7 @@ export const SurveyAnalysisCTA = ({
setIsResetting(true); setIsResetting(true);
const result = await resetSurveyAction({ const result = await resetSurveyAction({
surveyId: survey.id, surveyId: survey.id,
organizationId: organizationId,
projectId: project.id, projectId: project.id,
}); });
if (result?.data) { if (result?.data) {
@@ -75,7 +75,17 @@ export const ShareSurveyModal = ({
const [showView, setShowView] = useState<ModalView>(modalView); const [showView, setShowView] = useState<ModalView>(modalView);
const { email } = user; const { email } = user;
const { t } = useTranslation(); const { t } = useTranslation();
const linkTabs = useMemo(() => { const linkTabs: {
id: ShareViaType | ShareSettingsType;
type: LinkTabsType;
label: string;
icon: React.ElementType;
title: string;
description: string;
componentType: React.ComponentType<unknown>;
componentProps: unknown;
disabled?: boolean;
}[] = useMemo(() => {
const tabs = [ const tabs = [
{ {
id: ShareViaType.ANON_LINKS, id: ShareViaType.ANON_LINKS,
@@ -47,7 +47,6 @@ const createNoCodeConfigType = (t: ReturnType<typeof useTranslation>["t"]) => ({
pageView: t("environments.actions.page_view"), pageView: t("environments.actions.page_view"),
exitIntent: t("environments.actions.exit_intent"), exitIntent: t("environments.actions.exit_intent"),
fiftyPercentScroll: t("environments.actions.fifty_percent_scroll"), fiftyPercentScroll: t("environments.actions.fifty_percent_scroll"),
pageDwell: t("environments.actions.time_on_page"),
}); });
const formatRecontactDaysString = (days: number, t: ReturnType<typeof useTranslation>["t"]) => { const formatRecontactDaysString = (days: number, t: ReturnType<typeof useTranslation>["t"]) => {
@@ -105,7 +105,7 @@ export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTa
<div className={scriptsMode === "replace" ? "opacity-50" : ""}> <div className={scriptsMode === "replace" ? "opacity-50" : ""}>
<FormLabel>{t("environments.surveys.share.custom_html.workspace_scripts_label")}</FormLabel> <FormLabel>{t("environments.surveys.share.custom_html.workspace_scripts_label")}</FormLabel>
<div className="mt-2 max-h-32 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-3"> <div className="mt-2 max-h-32 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-3">
<pre className="whitespace-pre-wrap font-mono text-xs text-slate-600"> <pre className="font-mono text-xs whitespace-pre-wrap text-slate-600">
{projectCustomScripts} {projectCustomScripts}
</pre> </pre>
</div> </div>
@@ -135,7 +135,7 @@ export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTa
rows={8} rows={8}
placeholder={t("environments.surveys.share.custom_html.placeholder")} placeholder={t("environments.surveys.share.custom_html.placeholder")}
className={cn( className={cn(
"flex w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:border-brand-dark focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" "focus:border-brand-dark flex w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
)} )}
{...field} {...field}
disabled={isReadOnly} disabled={isReadOnly}
@@ -163,10 +163,9 @@ export const PersonalLinksTab = ({
<UpgradePrompt <UpgradePrompt
title={t("environments.surveys.share.personal_links.upgrade_prompt_title")} title={t("environments.surveys.share.personal_links.upgrade_prompt_title")}
description={t("environments.surveys.share.personal_links.upgrade_prompt_description")} description={t("environments.surveys.share.personal_links.upgrade_prompt_description")}
feature="personal_links"
buttons={[ buttons={[
{ {
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"), text: isFormbricksCloud ? t("common.start_free_trial") : t("common.request_trial_license"),
href: isFormbricksCloud href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing` ? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license", : "https://formbricks.com/upgrade-self-hosting-license",
@@ -39,7 +39,7 @@ export const QRCodeTab = ({ surveyUrl }: QRCodeTabProps) => {
} }
} }
} catch (error) { } catch (error) {
logger.error(error as Error, "Failed to generate QR code"); logger.error("Failed to generate QR code:", error);
setHasError(true); setHasError(true);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@@ -66,7 +66,7 @@ export const QRCodeTab = ({ surveyUrl }: QRCodeTabProps) => {
downloadInstance.download({ name: "survey-qr-code", extension: "png" }); downloadInstance.download({ name: "survey-qr-code", extension: "png" });
toast.success(t("environments.surveys.summary.qr_code_download_with_start_soon")); toast.success(t("environments.surveys.summary.qr_code_download_with_start_soon"));
} catch (error) { } catch (error) {
logger.error(error as Error, "Failed to download QR code"); logger.error("Failed to download QR code:", error);
toast.error(t("environments.surveys.summary.qr_code_download_failed")); toast.error(t("environments.surveys.summary.qr_code_download_failed"));
} finally { } finally {
setIsDownloading(false); setIsDownloading(false);
@@ -4,10 +4,6 @@ import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import {
ShareSettingsType,
ShareViaType,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink"; import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink";
import { Badge } from "@/modules/ui/components/badge"; import { Badge } from "@/modules/ui/components/badge";
@@ -17,9 +13,9 @@ interface SuccessViewProps {
publicDomain: string; publicDomain: string;
setSurveyUrl: (url: string) => void; setSurveyUrl: (url: string) => void;
user: TUser; user: TUser;
tabs: { id: ShareViaType | ShareSettingsType; label: string; icon: React.ElementType }[]; tabs: { id: string; label: string; icon: React.ElementType }[];
handleViewChange: (view: "start" | "share") => void; handleViewChange: (view: string) => void;
handleEmbedViewWithTab: (tabId: ShareViaType | ShareSettingsType) => void; handleEmbedViewWithTab: (tabId: string) => void;
isReadOnly: boolean; isReadOnly: boolean;
} }
@@ -70,7 +66,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
className="relative flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8"> className="relative flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
<UserIcon className="h-8 w-8 stroke-1 text-slate-900" /> <UserIcon className="h-8 w-8 stroke-1 text-slate-900" />
{t("environments.surveys.summary.use_personal_links")} {t("environments.surveys.summary.use_personal_links")}
<Badge size="normal" type="success" className="absolute right-3 top-3" text={t("common.new")} /> <Badge size="normal" type="success" className="absolute top-3 right-3" text={t("common.new")} />
</button> </button>
<Link <Link
href={`/environments/${environmentId}/settings/notifications`} href={`/environments/${environmentId}/settings/notifications`}
@@ -1,4 +1,3 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getPublicDomain } from "@/lib/getPublicUrl"; import { getPublicDomain } from "@/lib/getPublicUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service"; import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
@@ -10,11 +9,11 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
const t = await getTranslate(); const t = await getTranslate();
const survey = await getSurvey(surveyId); const survey = await getSurvey(surveyId);
if (!survey) { if (!survey) {
throw new ResourceNotFoundError(t("common.survey"), surveyId); throw new Error("Survey not found");
} }
const project = await getProjectByEnvironmentId(survey.environmentId); const project = await getProjectByEnvironmentId(survey.environmentId);
if (!project) { if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null); throw new Error("Workspace not found");
} }
const styling = getStyling(project, survey); const styling = getStyling(project, survey);
@@ -96,7 +96,7 @@ describe("Tests for getQuotasSummary service", () => {
_count: { _count: {
quotaLinks: 0, quotaLinks: 0,
}, },
} as unknown as Awaited<ReturnType<typeof prisma.surveyQuota.findMany>>[number], },
]); ]);
const result = await getQuotasSummary(surveyId); const result = await getQuotasSummary(surveyId);
@@ -120,7 +120,7 @@ describe("Tests for getQuotasSummary service", () => {
_count: { _count: {
quotaLinks: 0, quotaLinks: 0,
}, },
} as unknown as Awaited<ReturnType<typeof prisma.surveyQuota.findMany>>[number], },
]); ]);
const result = await getQuotasSummary(surveyId); const result = await getQuotasSummary(surveyId);
@@ -11,7 +11,8 @@ import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils"; import { getLocalizedValue } from "@/lib/i18n/utils";
import { getResponseCountBySurveyId } from "@/lib/response/service"; import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { getElementsFromBlocks } from "@/lib/survey/utils"; import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { import {
getElementSummary, getElementSummary,
getResponsesForSummary, getResponsesForSummary,
@@ -43,7 +44,7 @@ vi.mock("@/lib/survey/service", () => ({
})); }));
vi.mock("@/lib/surveyLogic/utils", () => ({ vi.mock("@/lib/surveyLogic/utils", () => ({
evaluateLogic: vi.fn(), evaluateLogic: vi.fn(),
performActions: vi.fn(() => ({ jumpTarget: undefined, requiredElementIds: [], calculations: {} })), performActions: vi.fn(() => ({ jumpTarget: undefined, requiredQuestionIds: [], calculations: {} })),
})); }));
vi.mock("@/lib/utils/validate", () => ({ vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(), validateInputs: vi.fn(),
@@ -164,7 +165,7 @@ describe("getSurveySummaryMeta", () => {
}); });
test("calculates meta correctly", () => { test("calculates meta correctly", () => {
const meta = getSurveySummaryMeta(mockBaseSurvey, mockResponses, 10, mockQuotas); const meta = getSurveySummaryMeta(mockResponses, 10, mockQuotas);
expect(meta.displayCount).toBe(10); expect(meta.displayCount).toBe(10);
expect(meta.totalResponses).toBe(3); expect(meta.totalResponses).toBe(3);
expect(meta.startsPercentage).toBe(30); expect(meta.startsPercentage).toBe(30);
@@ -178,13 +179,13 @@ describe("getSurveySummaryMeta", () => {
}); });
test("handles zero display count", () => { test("handles zero display count", () => {
const meta = getSurveySummaryMeta(mockBaseSurvey, mockResponses, 0, mockQuotas); const meta = getSurveySummaryMeta(mockResponses, 0, mockQuotas);
expect(meta.startsPercentage).toBe(0); expect(meta.startsPercentage).toBe(0);
expect(meta.completedPercentage).toBe(0); expect(meta.completedPercentage).toBe(0);
}); });
test("handles zero responses", () => { test("handles zero responses", () => {
const meta = getSurveySummaryMeta(mockBaseSurvey, [], 10, mockQuotas); const meta = getSurveySummaryMeta([], 10, mockQuotas);
expect(meta.totalResponses).toBe(0); expect(meta.totalResponses).toBe(0);
expect(meta.completedResponses).toBe(0); expect(meta.completedResponses).toBe(0);
expect(meta.dropOffCount).toBe(0); expect(meta.dropOffCount).toBe(0);
@@ -228,6 +229,12 @@ describe("getSurveySummaryDropOff", () => {
vi.mocked(convertFloatTo2Decimal).mockImplementation((num) => vi.mocked(convertFloatTo2Decimal).mockImplementation((num) =>
num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0 num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0
); );
vi.mocked(evaluateLogic).mockReturnValue(false); // Default: no logic triggers
vi.mocked(performActions).mockReturnValue({
jumpTarget: undefined,
requiredElementIds: [],
calculations: {},
});
}); });
test("calculates dropOff correctly with welcome card disabled", () => { test("calculates dropOff correctly with welcome card disabled", () => {
@@ -239,7 +246,7 @@ describe("getSurveySummaryDropOff", () => {
contact: null, contact: null,
contactAttributes: {}, contactAttributes: {},
language: "en", language: "en",
ttc: { q1: 10, q2: 5 }, // Saw q2 but didn't answer it ttc: { q1: 10 },
finished: false, finished: false,
}, // Dropped at q2 }, // Dropped at q2
{ {
@@ -262,55 +269,22 @@ describe("getSurveySummaryDropOff", () => {
); );
expect(dropOff.length).toBe(2); expect(dropOff.length).toBe(2);
// Q1: welcome card disabled so impressions = displayCount // Q1
expect(dropOff[0].elementId).toBe("q1"); expect(dropOff[0].elementId).toBe("q1");
expect(dropOff[0].impressions).toBe(displayCount); expect(dropOff[0].impressions).toBe(displayCount); // Welcome card disabled, so first question impressions = displayCount
expect(dropOff[0].dropOffCount).toBe(displayCount - responses.length); // 5 displays - 2 started = 3 dropped before q1 expect(dropOff[0].dropOffCount).toBe(displayCount - responses.length); // 5 displays - 2 started = 3 dropped before q1
expect(dropOff[0].dropOffPercentage).toBe(60); // (3/5)*100 expect(dropOff[0].dropOffPercentage).toBe(60); // (3/5)*100
expect(dropOff[0].ttc).toBe(10); expect(dropOff[0].ttc).toBe(10);
// Q2: both responses saw q2 (r1 has ttc for q2, r2 answered q2) // Q2
expect(dropOff[1].elementId).toBe("q2"); expect(dropOff[1].elementId).toBe("q2");
expect(dropOff[1].impressions).toBe(2); expect(dropOff[1].impressions).toBe(responses.length); // 2 responses reached q1, so 2 impressions for q2
expect(dropOff[1].dropOffCount).toBe(1); // r1 dropped at q2 (last seen element) expect(dropOff[1].dropOffCount).toBe(1); // 1 response dropped at q2
expect(dropOff[1].dropOffPercentage).toBe(50); // (1/2)*100 expect(dropOff[1].dropOffPercentage).toBe(50); // (1/2)*100
expect(dropOff[1].ttc).toBe(10); // block-level TTC uses max block time per response expect(dropOff[1].ttc).toBe(10);
}); });
test("drop-off attributed to last seen element when user doesn't reach next question", () => { test("handles logic jumps", () => {
// Welcome card enabled so first element drop-off is NOT overridden by displayCount
const surveyWithWelcome: TSurvey = {
...surveyWithBlocks,
welcomeCard: { enabled: true, headline: { default: "Welcome" } } as unknown as TSurvey["welcomeCard"],
};
const responses = [
{
id: "r1",
data: { q1: "a" },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: "en",
ttc: { q1: 10 }, // Only saw q1, never reached q2
finished: false,
},
] as any;
const displayCount = 1;
const dropOff = getSurveySummaryDropOff(
surveyWithWelcome,
getElementsFromBlocks(surveyWithWelcome.blocks),
responses,
displayCount
);
expect(dropOff[0].impressions).toBe(1); // Saw q1
expect(dropOff[0].dropOffCount).toBe(1); // Dropped at q1 (last seen element)
expect(dropOff[1].impressions).toBe(0); // Never saw q2
expect(dropOff[1].dropOffCount).toBe(0);
});
test("handles logic jumps — impressions based on actual ttc/data, not logic replay", () => {
// Survey with 4 questions across 4 blocks, logic on block2 jumps q2->q4 (skipping q3)
const surveyWithLogic: TSurvey = { const surveyWithLogic: TSurvey = {
...mockBaseSurvey, ...mockBaseSurvey,
blocks: [ blocks: [
@@ -341,6 +315,36 @@ describe("getSurveySummaryDropOff", () => {
charLimit: { enabled: false }, charLimit: { enabled: false },
}, },
] as TSurveyElement[], ] as TSurveyElement[],
logic: [
{
id: "logic1",
conditions: {
id: "condition1",
connector: "and" as const,
conditions: [
{
id: "c1",
leftOperand: {
type: "element" as const,
value: "q2",
},
operator: "equals" as const,
rightOperand: {
type: "static" as const,
value: "b",
},
},
],
},
actions: [
{
id: "action1",
objective: "jumpToBlock" as const,
target: "q4",
},
],
},
],
}, },
{ {
id: "block3", id: "block3",
@@ -373,21 +377,28 @@ describe("getSurveySummaryDropOff", () => {
], ],
questions: [], questions: [],
}; };
// Response where user answered q1, q2, then logic jumped to q4 (skipping q3).
// The ttc/data reflects exactly what elements were shown — no logic replay needed.
const responses = [ const responses = [
{ {
id: "r1", id: "r1",
data: { q1: "a", q2: "b", q4: "d" }, data: { q1: "a", q2: "b" },
updatedAt: new Date(), updatedAt: new Date(),
contact: null, contact: null,
contactAttributes: {}, contactAttributes: {},
language: "en", language: "en",
ttc: { q1: 10, q2: 10, q4: 10 }, // q3 has no ttc entry — was skipped by logic ttc: { q1: 10, q2: 10 },
finished: false, finished: false,
}, }, // Jumps from q2 to q4, drops at q4
]; ];
vi.mocked(evaluateLogic).mockImplementation((_s, data, _v, _, _l) => {
// Simulate logic on q2 triggering
return data.q2 === "b";
});
vi.mocked(performActions).mockImplementation((_s, actions, _d, _v) => {
if (actions[0] && "objective" in actions[0] && actions[0].objective === "jumpToBlock") {
return { jumpTarget: actions[0].target, requiredElementIds: [], calculations: {} };
}
return { jumpTarget: undefined, requiredElementIds: [], calculations: {} };
});
const dropOff = getSurveySummaryDropOff( const dropOff = getSurveySummaryDropOff(
surveyWithLogic, surveyWithLogic,
@@ -396,11 +407,11 @@ describe("getSurveySummaryDropOff", () => {
1 1
); );
expect(dropOff[0].impressions).toBe(1); // q1: seen expect(dropOff[0].impressions).toBe(1); // q1
expect(dropOff[1].impressions).toBe(1); // q2: seen expect(dropOff[1].impressions).toBe(1); // q2
expect(dropOff[2].impressions).toBe(0); // q3: skipped by logic (no ttc, no data) expect(dropOff[2].impressions).toBe(0); // q3 (skipped)
expect(dropOff[3].impressions).toBe(1); // q4: jumped to, seen expect(dropOff[3].impressions).toBe(1); // q4 (jumped to)
expect(dropOff[3].dropOffCount).toBe(1); // Dropped at q4 (last seen element, not finished) expect(dropOff[3].dropOffCount).toBe(1); // Dropped at q4
}); });
}); });
@@ -651,23 +662,17 @@ describe("getQuestionSummary", () => {
expect((summary[0] as any).choices).toHaveLength(3); expect((summary[0] as any).choices).toHaveLength(3);
// Item 1 is in position 1 once and position 2 once, so avg ranking should be (1+2)/2 = 1.5 // Item 1 is in position 1 once and position 2 once, so avg ranking should be (1+2)/2 = 1.5
const item1 = (summary[0] as any).choices.find( const item1 = (summary[0] as any).choices.find((c) => c.value === "Item 1");
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 1"
);
expect(item1.count).toBe(2); expect(item1.count).toBe(2);
expect(item1.avgRanking).toBe(1.5); expect(item1.avgRanking).toBe(1.5);
// Item 2 is in position 1 once and position 2 once, so avg ranking should be (1+2)/2 = 1.5 // Item 2 is in position 1 once and position 2 once, so avg ranking should be (1+2)/2 = 1.5
const item2 = (summary[0] as any).choices.find( const item2 = (summary[0] as any).choices.find((c) => c.value === "Item 2");
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 2"
);
expect(item2.count).toBe(2); expect(item2.count).toBe(2);
expect(item2.avgRanking).toBe(1.5); expect(item2.avgRanking).toBe(1.5);
// Item 3 is in position 3 twice, so avg ranking should be 3 // Item 3 is in position 3 twice, so avg ranking should be 3
const item3 = (summary[0] as any).choices.find( const item3 = (summary[0] as any).choices.find((c) => c.value === "Item 3");
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 3"
);
expect(item3.count).toBe(2); expect(item3.count).toBe(2);
expect(item3.avgRanking).toBe(3); expect(item3.avgRanking).toBe(3);
}); });
@@ -742,23 +747,17 @@ describe("getQuestionSummary", () => {
expect(summary[0].responseCount).toBe(1); expect(summary[0].responseCount).toBe(1);
// Item 1 is in position 2, so avg ranking should be 2 // Item 1 is in position 2, so avg ranking should be 2
const item1 = (summary[0] as any).choices.find( const item1 = (summary[0] as any).choices.find((c) => c.value === "Item 1");
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 1"
);
expect(item1.count).toBe(1); expect(item1.count).toBe(1);
expect(item1.avgRanking).toBe(2); expect(item1.avgRanking).toBe(2);
// Item 2 is in position 1, so avg ranking should be 1 // Item 2 is in position 1, so avg ranking should be 1
const item2 = (summary[0] as any).choices.find( const item2 = (summary[0] as any).choices.find((c) => c.value === "Item 2");
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 2"
);
expect(item2.count).toBe(1); expect(item2.count).toBe(1);
expect(item2.avgRanking).toBe(1); expect(item2.avgRanking).toBe(1);
// Item 3 is in position 3, so avg ranking should be 3 // Item 3 is in position 3, so avg ranking should be 3
const item3 = (summary[0] as any).choices.find( const item3 = (summary[0] as any).choices.find((c) => c.value === "Item 3");
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 3"
);
expect(item3.count).toBe(1); expect(item3.count).toBe(1);
expect(item3.avgRanking).toBe(3); expect(item3.avgRanking).toBe(3);
}); });
@@ -831,12 +830,10 @@ describe("getQuestionSummary", () => {
expect((summary[0] as any).choices).toHaveLength(3); expect((summary[0] as any).choices).toHaveLength(3);
// All items should have count 0 and avgRanking 0 // All items should have count 0 and avgRanking 0
(summary[0] as any).choices.forEach( (summary[0] as any).choices.forEach((choice) => {
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => { expect(choice.count).toBe(0);
expect(choice.count).toBe(0); expect(choice.avgRanking).toBe(0);
expect(choice.avgRanking).toBe(0); });
}
);
}); });
test("getQuestionSummary handles ranking question with non-array answers", async () => { test("getQuestionSummary handles ranking question with non-array answers", async () => {
@@ -897,12 +894,10 @@ describe("getQuestionSummary", () => {
expect((summary[0] as any).choices).toHaveLength(3); expect((summary[0] as any).choices).toHaveLength(3);
// All items should have count 0 and avgRanking 0 since we had no valid ranking data // All items should have count 0 and avgRanking 0 since we had no valid ranking data
(summary[0] as any).choices.forEach( (summary[0] as any).choices.forEach((choice) => {
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => { expect(choice.count).toBe(0);
expect(choice.count).toBe(0); expect(choice.avgRanking).toBe(0);
expect(choice.avgRanking).toBe(0); });
}
);
}); });
test("getQuestionSummary handles ranking question with values not in choices", async () => { test("getQuestionSummary handles ranking question with values not in choices", async () => {
@@ -963,23 +958,17 @@ describe("getQuestionSummary", () => {
expect((summary[0] as any).choices).toHaveLength(3); expect((summary[0] as any).choices).toHaveLength(3);
// Item 1 is in position 1, so avg ranking should be 1 // Item 1 is in position 1, so avg ranking should be 1
const item1 = (summary[0] as any).choices.find( const item1 = (summary[0] as any).choices.find((c) => c.value === "Item 1");
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 1"
);
expect(item1.count).toBe(1); expect(item1.count).toBe(1);
expect(item1.avgRanking).toBe(1); expect(item1.avgRanking).toBe(1);
// Item 2 was not ranked, so should have count 0 and avgRanking 0 // Item 2 was not ranked, so should have count 0 and avgRanking 0
const item2 = (summary[0] as any).choices.find( const item2 = (summary[0] as any).choices.find((c) => c.value === "Item 2");
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 2"
);
expect(item2.count).toBe(0); expect(item2.count).toBe(0);
expect(item2.avgRanking).toBe(0); expect(item2.avgRanking).toBe(0);
// Item 3 is in position 3, so avg ranking should be 3 // Item 3 is in position 3, so avg ranking should be 3
const item3 = (summary[0] as any).choices.find( const item3 = (summary[0] as any).choices.find((c) => c.value === "Item 3");
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 3"
);
expect(item3.count).toBe(1); expect(item3.count).toBe(1);
expect(item3.avgRanking).toBe(3); expect(item3.avgRanking).toBe(3);
}); });
@@ -997,11 +986,7 @@ describe("getSurveySummary", () => {
// Since getSurveySummary calls getResponsesForSummary internally, we'll mock prisma.response.findMany // Since getSurveySummary calls getResponsesForSummary internally, we'll mock prisma.response.findMany
// which is used by the actual implementation of getResponsesForSummary. // which is used by the actual implementation of getResponsesForSummary.
vi.mocked(prisma.response.findMany).mockResolvedValue( vi.mocked(prisma.response.findMany).mockResolvedValue(
mockResponses.map((r: Record<string, unknown>) => ({ mockResponses.map((r) => ({ ...r, contactId: null, personAttributes: {} })) as any
...r,
contactId: null,
personAttributes: {},
})) as any
); );
vi.mocked(getDisplayCountBySurveyId).mockResolvedValue(10); vi.mocked(getDisplayCountBySurveyId).mockResolvedValue(10);
@@ -1035,8 +1020,8 @@ describe("getSurveySummary", () => {
test("handles filterCriteria", async () => { test("handles filterCriteria", async () => {
const filterCriteria: TResponseFilterCriteria = { finished: true }; const filterCriteria: TResponseFilterCriteria = { finished: true };
const finishedResponses = mockResponses const finishedResponses = mockResponses
.filter((r: Record<string, unknown>) => r.finished) .filter((r) => r.finished)
.map((r: Record<string, unknown>) => ({ ...r, contactId: null, personAttributes: {} })); .map((r) => ({ ...r, contactId: null, personAttributes: {} }));
vi.mocked(prisma.response.findMany).mockResolvedValue(finishedResponses as any); vi.mocked(prisma.response.findMany).mockResolvedValue(finishedResponses as any);
await getSurveySummary(mockSurveyId, filterCriteria); await getSurveySummary(mockSurveyId, filterCriteria);
@@ -1058,11 +1043,7 @@ describe("getResponsesForSummary", () => {
vi.resetAllMocks(); vi.resetAllMocks();
vi.mocked(getSurvey).mockResolvedValue(mockBaseSurvey); vi.mocked(getSurvey).mockResolvedValue(mockBaseSurvey);
vi.mocked(prisma.response.findMany).mockResolvedValue( vi.mocked(prisma.response.findMany).mockResolvedValue(
mockResponses.map((r: Record<string, unknown>) => ({ mockResponses.map((r) => ({ ...r, contactId: null, personAttributes: {} })) as any
...r,
contactId: null,
personAttributes: {},
})) as any
); );
// React cache is already mocked globally - no need to mock it again // React cache is already mocked globally - no need to mock it again
}); });
@@ -1861,63 +1842,23 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(2); expect(summary[0].responseCount).toBe(2);
// Verify Speed row // Verify Speed row
const speedRow = summary[0].data.find( const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Speed"
);
expect(speedRow.totalResponsesForRow).toBe(2); expect(speedRow.totalResponsesForRow).toBe(2);
expect(speedRow.columnPercentages).toHaveLength(4); // 4 columns expect(speedRow.columnPercentages).toHaveLength(4); // 4 columns
expect( expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(50);
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good") expect(speedRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(50);
.percentage
).toBe(50);
expect(
speedRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Average"
).percentage
).toBe(50);
// Verify Quality row // Verify Quality row
const qualityRow = summary[0].data.find( const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Quality"
);
expect(qualityRow.totalResponsesForRow).toBe(2); expect(qualityRow.totalResponsesForRow).toBe(2);
expect( expect(qualityRow.columnPercentages.find((col) => col.column === "Excellent").percentage).toBe(50);
qualityRow.columnPercentages.find( expect(qualityRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(50);
(col: { column: string; percentage: number }) => col.column === "Excellent"
).percentage
).toBe(50);
expect(
qualityRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Good"
).percentage
).toBe(50);
// Verify Price row // Verify Price row
const priceRow = summary[0].data.find( const priceRow = summary[0].data.find((row) => row.rowLabel === "Price");
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Price"
);
expect(priceRow.totalResponsesForRow).toBe(2); expect(priceRow.totalResponsesForRow).toBe(2);
expect( expect(priceRow.columnPercentages.find((col) => col.column === "Poor").percentage).toBe(50);
priceRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Poor") expect(priceRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(50);
.percentage
).toBe(50);
expect(
priceRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Average"
).percentage
).toBe(50);
}); });
test("getQuestionSummary correctly processes Matrix question with non-default language responses", async () => { test("getQuestionSummary correctly processes Matrix question with non-default language responses", async () => {
@@ -2008,48 +1949,19 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(1); expect(summary[0].responseCount).toBe(1);
// Verify Speed row with localized values mapped to default language // Verify Speed row with localized values mapped to default language
const speedRow = summary[0].data.find( const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Speed"
);
expect(speedRow.totalResponsesForRow).toBe(1); expect(speedRow.totalResponsesForRow).toBe(1);
expect( expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
.percentage
).toBe(100);
// Verify Quality row // Verify Quality row
const qualityRow = summary[0].data.find( const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Quality"
);
expect(qualityRow.totalResponsesForRow).toBe(1); expect(qualityRow.totalResponsesForRow).toBe(1);
expect( expect(qualityRow.columnPercentages.find((col) => col.column === "Excellent").percentage).toBe(100);
qualityRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Excellent"
).percentage
).toBe(100);
// Verify Price row // Verify Price row
const priceRow = summary[0].data.find( const priceRow = summary[0].data.find((row) => row.rowLabel === "Price");
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Price"
);
expect(priceRow.totalResponsesForRow).toBe(1); expect(priceRow.totalResponsesForRow).toBe(1);
expect( expect(priceRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(100);
priceRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Average"
).percentage
).toBe(100);
}); });
test("getQuestionSummary handles missing or invalid data for Matrix questions", async () => { test("getQuestionSummary handles missing or invalid data for Matrix questions", async () => {
@@ -2143,18 +2055,12 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(3); // Count is 3 because responses 2, 3, and 4 have the "matrix-q1" property expect(summary[0].responseCount).toBe(3); // Count is 3 because responses 2, 3, and 4 have the "matrix-q1" property
// All rows should have zero responses for all columns // All rows should have zero responses for all columns
summary[0].data.forEach( summary[0].data.forEach((row) => {
(row: { expect(row.totalResponsesForRow).toBe(0);
rowLabel: string; row.columnPercentages.forEach((col) => {
totalResponsesForRow: number; expect(col.percentage).toBe(0);
columnPercentages: { column: string; percentage: number }[]; });
}) => { });
expect(row.totalResponsesForRow).toBe(0);
row.columnPercentages.forEach((col: { column: string; percentage: number }) => {
expect(col.percentage).toBe(0);
});
}
);
}); });
test("getQuestionSummary handles partial and incomplete matrix responses", async () => { test("getQuestionSummary handles partial and incomplete matrix responses", async () => {
@@ -2241,59 +2147,22 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(2); expect(summary[0].responseCount).toBe(2);
// Verify Speed row - both responses provided data // Verify Speed row - both responses provided data
const speedRow = summary[0].data.find( const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Speed"
);
expect(speedRow.totalResponsesForRow).toBe(2); expect(speedRow.totalResponsesForRow).toBe(2);
expect( expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(50);
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good") expect(speedRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(50);
.percentage
).toBe(50);
expect(
speedRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Average"
).percentage
).toBe(50);
// Verify Quality row - only one response provided data // Verify Quality row - only one response provided data
const qualityRow = summary[0].data.find( const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Quality"
);
expect(qualityRow.totalResponsesForRow).toBe(1); expect(qualityRow.totalResponsesForRow).toBe(1);
expect( expect(qualityRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
qualityRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Good"
).percentage
).toBe(100);
// Verify Price row - both responses provided data // Verify Price row - both responses provided data
const priceRow = summary[0].data.find( const priceRow = summary[0].data.find((row) => row.rowLabel === "Price");
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Price"
);
expect(priceRow.totalResponsesForRow).toBe(2); expect(priceRow.totalResponsesForRow).toBe(2);
// ExtraRow should not appear in the summary // ExtraRow should not appear in the summary
expect( expect(summary[0].data.find((row) => row.rowLabel === "ExtraRow")).toBeUndefined();
summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "ExtraRow"
)
).toBeUndefined();
}); });
test("getQuestionSummary handles zero responses for Matrix question correctly", async () => { test("getQuestionSummary handles zero responses for Matrix question correctly", async () => {
@@ -2352,18 +2221,12 @@ describe("Matrix question type tests", () => {
// All rows should have proper structure but zero counts // All rows should have proper structure but zero counts
expect(summary[0].data).toHaveLength(2); // 2 rows expect(summary[0].data).toHaveLength(2); // 2 rows
summary[0].data.forEach( summary[0].data.forEach((row) => {
(row: { expect(row.columnPercentages).toHaveLength(2); // 2 columns
rowLabel: string; expect(row.totalResponsesForRow).toBe(0);
totalResponsesForRow: number; expect(row.columnPercentages[0].percentage).toBe(0);
columnPercentages: { column: string; percentage: number }[]; expect(row.columnPercentages[1].percentage).toBe(0);
}) => { });
expect(row.columnPercentages).toHaveLength(2); // 2 columns
expect(row.totalResponsesForRow).toBe(0);
expect(row.columnPercentages[0].percentage).toBe(0);
expect(row.columnPercentages[1].percentage).toBe(0);
}
);
}); });
test("getQuestionSummary handles Matrix question with mixed valid and invalid column values", async () => { test("getQuestionSummary handles Matrix question with mixed valid and invalid column values", async () => {
@@ -2433,46 +2296,21 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(1); expect(summary[0].responseCount).toBe(1);
// Speed row should have a valid response // Speed row should have a valid response
const speedRow = summary[0].data.find( const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Speed"
);
expect(speedRow.totalResponsesForRow).toBe(1); expect(speedRow.totalResponsesForRow).toBe(1);
expect( expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
.percentage
).toBe(100);
// Quality row should have no valid responses // Quality row should have no valid responses
const qualityRow = summary[0].data.find( const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Quality"
);
expect(qualityRow.totalResponsesForRow).toBe(0); expect(qualityRow.totalResponsesForRow).toBe(0);
qualityRow.columnPercentages.forEach((col: { column: string; percentage: number }) => { qualityRow.columnPercentages.forEach((col) => {
expect(col.percentage).toBe(0); expect(col.percentage).toBe(0);
}); });
// Price row should have a valid response // Price row should have a valid response
const priceRow = summary[0].data.find( const priceRow = summary[0].data.find((row) => row.rowLabel === "Price");
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Price"
);
expect(priceRow.totalResponsesForRow).toBe(1); expect(priceRow.totalResponsesForRow).toBe(1);
expect( expect(priceRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(100);
priceRow.columnPercentages.find(
(col: { column: string; percentage: number }) => col.column === "Average"
).percentage
).toBe(100);
}); });
test("getQuestionSummary handles Matrix question with invalid row labels", async () => { test("getQuestionSummary handles Matrix question with invalid row labels", async () => {
@@ -2543,48 +2381,17 @@ describe("Matrix question type tests", () => {
expect(summary[0].data).toHaveLength(2); // 2 rows expect(summary[0].data).toHaveLength(2); // 2 rows
// Speed row should have a valid response // Speed row should have a valid response
const speedRow = summary[0].data.find( const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Speed"
);
expect(speedRow.totalResponsesForRow).toBe(1); expect(speedRow.totalResponsesForRow).toBe(1);
expect( expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
.percentage
).toBe(100);
// Quality row should have no responses // Quality row should have no responses
const qualityRow = summary[0].data.find( const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Quality"
);
expect(qualityRow.totalResponsesForRow).toBe(0); expect(qualityRow.totalResponsesForRow).toBe(0);
// Invalid rows should not appear in the summary // Invalid rows should not appear in the summary
expect( expect(summary[0].data.find((row) => row.rowLabel === "InvalidRow")).toBeUndefined();
summary[0].data.find( expect(summary[0].data.find((row) => row.rowLabel === "AnotherInvalidRow")).toBeUndefined();
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "InvalidRow"
)
).toBeUndefined();
expect(
summary[0].data.find(
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "AnotherInvalidRow"
)
).toBeUndefined();
}); });
test("getQuestionSummary handles Matrix question with mixed language responses", async () => { test("getQuestionSummary handles Matrix question with mixed language responses", async () => {
@@ -2686,27 +2493,12 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(2); expect(summary[0].responseCount).toBe(2);
// Speed row should have both responses // Speed row should have both responses
const speedRow = summary[0].data.find( const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Speed"
);
expect(speedRow.totalResponsesForRow).toBe(2); expect(speedRow.totalResponsesForRow).toBe(2);
expect( expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
.percentage
).toBe(100);
// Quality row should have no responses // Quality row should have no responses
const qualityRow = summary[0].data.find( const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
(row: {
rowLabel: string;
totalResponsesForRow: number;
columnPercentages: { column: string; percentage: number }[];
}) => row.rowLabel === "Quality"
);
expect(qualityRow.totalResponsesForRow).toBe(0); expect(qualityRow.totalResponsesForRow).toBe(0);
}); });
@@ -2765,18 +2557,12 @@ describe("Matrix question type tests", () => {
expect(summary[0].responseCount).toBe(0); // Counts as response even with null data expect(summary[0].responseCount).toBe(0); // Counts as response even with null data
// Both rows should have zero responses // Both rows should have zero responses
summary[0].data.forEach( summary[0].data.forEach((row) => {
(row: { expect(row.totalResponsesForRow).toBe(0);
rowLabel: string; row.columnPercentages.forEach((col) => {
totalResponsesForRow: number; expect(col.percentage).toBe(0);
columnPercentages: { column: string; percentage: number }[]; });
}) => { });
expect(row.totalResponsesForRow).toBe(0);
row.columnPercentages.forEach((col: { column: string; percentage: number }) => {
expect(col.percentage).toBe(0);
});
}
);
}); });
}); });
@@ -3208,33 +2994,23 @@ describe("Rating question type tests", () => {
expect(summary[0].average).toBe(4.25); expect(summary[0].average).toBe(4.25);
// Verify each rating option count and percentage // Verify each rating option count and percentage
const rating5 = summary[0].choices.find( const rating5 = summary[0].choices.find((c) => c.rating === 5);
(c: { rating: number; count: number; percentage: number }) => c.rating === 5
);
expect(rating5.count).toBe(2); expect(rating5.count).toBe(2);
expect(rating5.percentage).toBe(50); // 2/4 * 100 expect(rating5.percentage).toBe(50); // 2/4 * 100
const rating4 = summary[0].choices.find( const rating4 = summary[0].choices.find((c) => c.rating === 4);
(c: { rating: number; count: number; percentage: number }) => c.rating === 4
);
expect(rating4.count).toBe(1); expect(rating4.count).toBe(1);
expect(rating4.percentage).toBe(25); // 1/4 * 100 expect(rating4.percentage).toBe(25); // 1/4 * 100
const rating3 = summary[0].choices.find( const rating3 = summary[0].choices.find((c) => c.rating === 3);
(c: { rating: number; count: number; percentage: number }) => c.rating === 3
);
expect(rating3.count).toBe(1); expect(rating3.count).toBe(1);
expect(rating3.percentage).toBe(25); // 1/4 * 100 expect(rating3.percentage).toBe(25); // 1/4 * 100
const rating2 = summary[0].choices.find( const rating2 = summary[0].choices.find((c) => c.rating === 2);
(c: { rating: number; count: number; percentage: number }) => c.rating === 2
);
expect(rating2.count).toBe(0); expect(rating2.count).toBe(0);
expect(rating2.percentage).toBe(0); expect(rating2.percentage).toBe(0);
const rating1 = summary[0].choices.find( const rating1 = summary[0].choices.find((c) => c.rating === 1);
(c: { rating: number; count: number; percentage: number }) => c.rating === 1
);
expect(rating1.count).toBe(0); expect(rating1.count).toBe(0);
expect(rating1.percentage).toBe(0); expect(rating1.percentage).toBe(0);
@@ -3378,12 +3154,10 @@ describe("Rating question type tests", () => {
expect(summary[0].average).toBe(0); expect(summary[0].average).toBe(0);
// Verify all ratings have 0 count and percentage // Verify all ratings have 0 count and percentage
summary[0].choices.forEach( summary[0].choices.forEach((choice) => {
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => { expect(choice.count).toBe(0);
expect(choice.count).toBe(0); expect(choice.percentage).toBe(0);
expect(choice.percentage).toBe(0); });
}
);
// Verify dismissed is 0 // Verify dismissed is 0
expect(summary[0].dismissed.count).toBe(0); expect(summary[0].dismissed.count).toBe(0);
@@ -3458,21 +3232,15 @@ describe("PictureSelection question type tests", () => {
expect(summary[0].selectionCount).toBe(3); // Total selections: img1, img2, img3 expect(summary[0].selectionCount).toBe(3); // Total selections: img1, img2, img3
// Check individual choice counts // Check individual choice counts
const img1 = summary[0].choices.find( const img1 = summary[0].choices.find((c) => c.id === "img1");
(c: { id: string; count: number; percentage: number }) => c.id === "img1"
);
expect(img1.count).toBe(1); expect(img1.count).toBe(1);
expect(img1.percentage).toBe(50); expect(img1.percentage).toBe(50);
const img2 = summary[0].choices.find( const img2 = summary[0].choices.find((c) => c.id === "img2");
(c: { id: string; count: number; percentage: number }) => c.id === "img2"
);
expect(img2.count).toBe(1); expect(img2.count).toBe(1);
expect(img2.percentage).toBe(50); expect(img2.percentage).toBe(50);
const img3 = summary[0].choices.find( const img3 = summary[0].choices.find((c) => c.id === "img3");
(c: { id: string; count: number; percentage: number }) => c.id === "img3"
);
expect(img3.count).toBe(1); expect(img3.count).toBe(1);
expect(img3.percentage).toBe(50); expect(img3.percentage).toBe(50);
}); });
@@ -3543,12 +3311,10 @@ describe("PictureSelection question type tests", () => {
expect(summary[0].selectionCount).toBe(0); expect(summary[0].selectionCount).toBe(0);
// All choices should have zero count // All choices should have zero count
summary[0].choices.forEach( summary[0].choices.forEach((choice) => {
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => { expect(choice.count).toBe(0);
expect(choice.count).toBe(0); expect(choice.percentage).toBe(0);
expect(choice.percentage).toBe(0); });
}
);
}); });
test("getQuestionSummary handles PictureSelection with invalid choice ids", async () => { test("getQuestionSummary handles PictureSelection with invalid choice ids", async () => {
@@ -3607,23 +3373,17 @@ describe("PictureSelection question type tests", () => {
expect(summary[0].selectionCount).toBe(2); // Total selections including invalid one expect(summary[0].selectionCount).toBe(2); // Total selections including invalid one
// img1 should be counted // img1 should be counted
const img1 = summary[0].choices.find( const img1 = summary[0].choices.find((c) => c.id === "img1");
(c: { id: string; count: number; percentage: number }) => c.id === "img1"
);
expect(img1.count).toBe(1); expect(img1.count).toBe(1);
expect(img1.percentage).toBe(100); expect(img1.percentage).toBe(100);
// img2 should not be counted // img2 should not be counted
const img2 = summary[0].choices.find( const img2 = summary[0].choices.find((c) => c.id === "img2");
(c: { id: string; count: number; percentage: number }) => c.id === "img2"
);
expect(img2.count).toBe(0); expect(img2.count).toBe(0);
expect(img2.percentage).toBe(0); expect(img2.percentage).toBe(0);
// Invalid ID should not appear in choices // Invalid ID should not appear in choices
expect( expect(summary[0].choices.find((c) => c.id === "invalid-id")).toBeUndefined();
summary[0].choices.find((c: { id: string; count: number; percentage: number }) => c.id === "invalid-id")
).toBeUndefined();
}); });
}); });
@@ -11,9 +11,14 @@ import {
TResponseData, TResponseData,
TResponseFilterCriteria, TResponseFilterCriteria,
TResponseTtc, TResponseTtc,
TResponseVariables,
ZResponseFilterCriteria, ZResponseFilterCriteria,
} from "@formbricks/types/responses"; } from "@formbricks/types/responses";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; import {
TSurveyElement,
TSurveyElementChoice,
TSurveyElementTypeEnum,
} from "@formbricks/types/surveys/elements";
import { import {
TSurvey, TSurvey,
TSurveyElementSummaryAddress, TSurveyElementSummaryAddress,
@@ -36,7 +41,8 @@ import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils"; import { getLocalizedValue } from "@/lib/i18n/utils";
import { buildWhereClause } from "@/lib/response/utils"; import { buildWhereClause } from "@/lib/response/utils";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { getElementsFromBlocks } from "@/lib/survey/utils"; import { findElementLocation, getElementsFromBlocks } from "@/lib/survey/utils";
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
import { convertFloatTo2Decimal } from "./utils"; import { convertFloatTo2Decimal } from "./utils";
@@ -51,32 +57,7 @@ interface TSurveySummaryResponse {
finished: boolean; finished: boolean;
} }
const getElementIdToBlockIdMap = (survey: TSurvey): Record<string, string> => {
return survey.blocks.reduce<Record<string, string>>((acc, block) => {
block.elements.forEach((element) => {
acc[element.id] = block.id;
});
return acc;
}, {});
};
const getBlockTimesForResponse = (
response: TSurveySummaryResponse,
survey: TSurvey
): Record<string, number> => {
return survey.blocks.reduce<Record<string, number>>((acc, block) => {
const maxElementTtc = block.elements.reduce((maxTtc, element) => {
const elementTtc = response.ttc?.[element.id] ?? 0;
return Math.max(maxTtc, elementTtc);
}, 0);
acc[block.id] = maxElementTtc;
return acc;
}, {});
};
export const getSurveySummaryMeta = ( export const getSurveySummaryMeta = (
survey: TSurvey,
responses: TSurveySummaryResponse[], responses: TSurveySummaryResponse[],
displayCount: number, displayCount: number,
quotas: TSurveySummary["quotas"] quotas: TSurveySummary["quotas"]
@@ -85,15 +66,9 @@ export const getSurveySummaryMeta = (
let ttcResponseCount = 0; let ttcResponseCount = 0;
const ttcSum = responses.reduce((acc, response) => { const ttcSum = responses.reduce((acc, response) => {
const blockTimes = getBlockTimesForResponse(response, survey); if (response.ttc?._total) {
const responseBlockTtcTotal = Object.values(blockTimes).reduce((sum, ttc) => sum + ttc, 0);
// Fallback to _total for malformed surveys with no block mappings.
const responseTtcTotal = responseBlockTtcTotal > 0 ? responseBlockTtcTotal : (response.ttc?._total ?? 0);
if (responseTtcTotal > 0) {
ttcResponseCount++; ttcResponseCount++;
return acc + responseTtcTotal; return acc + response.ttc._total;
} }
return acc; return acc;
}, 0); }, 0);
@@ -122,13 +97,63 @@ export const getSurveySummaryMeta = (
}; };
}; };
// Determine whether a response interacted with a given element. const evaluateLogicAndGetNextElementId = (
// An element was "seen" if the respondent has a ttc entry for it OR provided an answer. localSurvey: TSurvey,
// This is more reliable than replaying survey logic, which can misattribute impressions elements: TSurveyElement[],
// when branching logic skips elements or when partial response data is insufficient data: TResponseData,
// to evaluate conditions correctly. localVariables: TResponseVariables,
const wasElementSeen = (response: TSurveySummaryResponse, elementId: string): boolean => { currentElementIndex: number,
return (response.ttc != null && response.ttc[elementId] > 0) || response.data[elementId] !== undefined; currElementTemp: TSurveyElement,
selectedLanguage: string | null
): {
nextElementId: string | undefined;
updatedSurvey: TSurvey;
updatedVariables: TResponseVariables;
} => {
let updatedSurvey = { ...localSurvey };
let updatedVariables = { ...localVariables };
let firstJumpTarget: string | undefined;
const { block: currentBlock } = findElementLocation(localSurvey, currElementTemp.id);
if (currentBlock?.logic && currentBlock.logic.length > 0) {
for (const logic of currentBlock.logic) {
if (evaluateLogic(localSurvey, data, localVariables, logic.conditions, selectedLanguage ?? "default")) {
const { jumpTarget, requiredElementIds, calculations } = performActions(
updatedSurvey,
logic.actions,
data,
updatedVariables
);
if (requiredElementIds.length > 0) {
// Update blocks to mark elements as required
updatedSurvey.blocks = updatedSurvey.blocks.map((block) => ({
...block,
elements: block.elements.map((e) =>
requiredElementIds.includes(e.id) ? { ...e, required: true } : e
),
}));
}
updatedVariables = { ...updatedVariables, ...calculations };
if (jumpTarget && !firstJumpTarget) {
firstJumpTarget = jumpTarget;
}
}
}
}
// If no jump target was set, check for a fallback logic
if (!firstJumpTarget && currentBlock?.logicFallback) {
firstJumpTarget = currentBlock.logicFallback;
}
// Return the first jump target if found, otherwise go to the next element
const nextElementId = firstJumpTarget || elements[currentElementIndex + 1]?.id || undefined;
return { nextElementId, updatedSurvey, updatedVariables };
}; };
export const getSurveySummaryDropOff = ( export const getSurveySummaryDropOff = (
@@ -148,35 +173,69 @@ export const getSurveySummaryDropOff = (
let dropOffArr = new Array(elements.length).fill(0) as number[]; let dropOffArr = new Array(elements.length).fill(0) as number[];
let impressionsArr = new Array(elements.length).fill(0) as number[]; let impressionsArr = new Array(elements.length).fill(0) as number[];
let dropOffPercentageArr = new Array(elements.length).fill(0) as number[]; let dropOffPercentageArr = new Array(elements.length).fill(0) as number[];
const elementIdToBlockId = getElementIdToBlockIdMap(survey);
const surveyVariablesData = survey.variables?.reduce(
(acc, variable) => {
acc[variable.id] = variable.value;
return acc;
},
{} as Record<string, string | number>
);
responses.forEach((response) => { responses.forEach((response) => {
// Calculate total time-to-completion per element // Calculate total time-to-completion
const blockTimes = getBlockTimesForResponse(response, survey);
Object.keys(totalTtc).forEach((elementId) => { Object.keys(totalTtc).forEach((elementId) => {
const blockId = elementIdToBlockId[elementId]; if (response.ttc && response.ttc[elementId]) {
const blockTtc = blockId ? (blockTimes[blockId] ?? 0) : 0; totalTtc[elementId] += response.ttc[elementId];
if (blockTtc > 0) {
totalTtc[elementId] += blockTtc;
responseCounts[elementId]++; responseCounts[elementId]++;
} }
}); });
// Count impressions based on actual interaction data (ttc + response data) let localSurvey = structuredClone(survey);
// instead of replaying survey logic which is unreliable with branching let localResponseData: TResponseData = { ...response.data };
let lastSeenIdx = -1; let localVariables: TResponseVariables = {
...surveyVariablesData,
};
for (let i = 0; i < elements.length; i++) { let currQuesIdx = 0;
const element = elements[i];
if (wasElementSeen(response, element.id)) { while (currQuesIdx < elements.length) {
impressionsArr[i]++; const currQues = elements[currQuesIdx];
lastSeenIdx = i; if (!currQues) break;
// element is not answered and required
if (response.data[currQues.id] === undefined && currQues.required) {
dropOffArr[currQuesIdx]++;
impressionsArr[currQuesIdx]++;
break;
} }
}
// Attribute drop-off to the last element the respondent interacted with impressionsArr[currQuesIdx]++;
if (!response.finished && lastSeenIdx >= 0) {
dropOffArr[lastSeenIdx]++; const { nextElementId, updatedSurvey, updatedVariables } = evaluateLogicAndGetNextElementId(
localSurvey,
elements,
localResponseData,
localVariables,
currQuesIdx,
currQues,
response.language
);
localSurvey = updatedSurvey;
localVariables = updatedVariables;
if (nextElementId) {
const nextQuesIdx = elements.findIndex((q) => q.id === nextElementId);
if (!response.data[nextElementId] && !response.finished) {
dropOffArr[nextQuesIdx]++;
impressionsArr[nextQuesIdx]++;
break;
}
currQuesIdx = nextQuesIdx;
} else {
currQuesIdx++;
}
} }
}); });
@@ -185,8 +244,6 @@ export const getSurveySummaryDropOff = (
totalTtc[elementId] = responseCounts[elementId] > 0 ? totalTtc[elementId] / responseCounts[elementId] : 0; totalTtc[elementId] = responseCounts[elementId] > 0 ? totalTtc[elementId] / responseCounts[elementId] : 0;
}); });
// When the welcome card is disabled, the first element's impressions should equal displayCount
// because every survey display is an impression of the first element
if (!survey.welcomeCard.enabled) { if (!survey.welcomeCard.enabled) {
dropOffArr[0] = displayCount - impressionsArr[0]; dropOffArr[0] = displayCount - impressionsArr[0];
if (impressionsArr[0] > displayCount) dropOffPercentageArr[0] = 0; if (impressionsArr[0] > displayCount) dropOffPercentageArr[0] = 0;
@@ -198,7 +255,7 @@ export const getSurveySummaryDropOff = (
impressionsArr[0] = displayCount; impressionsArr[0] = displayCount;
} else { } else {
dropOffPercentageArr[0] = impressionsArr[0] > 0 ? (dropOffArr[0] / impressionsArr[0]) * 100 : 0; dropOffPercentageArr[0] = (dropOffArr[0] / impressionsArr[0]) * 100;
} }
for (let i = 1; i < elements.length; i++) { for (let i = 1; i < elements.length; i++) {
@@ -236,10 +293,7 @@ const checkForI18n = (
) => { ) => {
const element = elements.find((element) => element.id === id); const element = elements.find((element) => element.id === id);
if ( if (element?.type === "multipleChoiceMulti" || element?.type === "ranking") {
element?.type === TSurveyElementTypeEnum.MultipleChoiceMulti ||
element?.type === TSurveyElementTypeEnum.Ranking
) {
// Initialize an array to hold the choice values // Initialize an array to hold the choice values
let choiceValues = [] as string[]; let choiceValues = [] as string[];
@@ -264,9 +318,13 @@ const checkForI18n = (
} }
// Return the localized value of the choice fo multiSelect single element // Return the localized value of the choice fo multiSelect single element
if (element?.type === TSurveyElementTypeEnum.MultipleChoiceSingle) { if (element && "choices" in element) {
const choice = element.choices?.find((choice) => choice.label[languageCode] === responseData[id]); const choice = element.choices?.find(
return choice ? getLocalizedValue(choice.label, "default") || responseData[id] : responseData[id]; (choice: TSurveyElementChoice) => choice.label?.[languageCode] === responseData[id]
);
return choice && "label" in choice
? getLocalizedValue(choice.label, "default") || responseData[id]
: responseData[id];
} }
return responseData[id]; return responseData[id];
@@ -774,19 +832,13 @@ export const getElementSummary = async (
let totalResponseCount = 0; let totalResponseCount = 0;
// Initialize count object // Initialize count object
const countMap: Record<string, Record<string, number>> = rows.reduce( const countMap: Record<string, string> = rows.reduce((acc, row) => {
(acc: Record<string, Record<string, number>>, row) => { acc[row] = columns.reduce((colAcc, col) => {
acc[row] = columns.reduce( colAcc[col] = 0;
(colAcc: Record<string, number>, col) => { return colAcc;
colAcc[col] = 0; }, {});
return colAcc; return acc;
}, }, {});
{} as Record<string, number>
);
return acc;
},
{} as Record<string, Record<string, number>>
);
responses.forEach((response) => { responses.forEach((response) => {
const selectedResponses = response.data[element.id] as Record<string, string>; const selectedResponses = response.data[element.id] as Record<string, string>;
@@ -1009,8 +1061,10 @@ export const getSurveySummary = reactCache(
]); ]);
const dropOff = getSurveySummaryDropOff(survey, elements, responses, displayCount); const dropOff = getSurveySummaryDropOff(survey, elements, responses, displayCount);
const meta = getSurveySummaryMeta(survey, responses, displayCount, quotas); const [meta, elementSummary] = await Promise.all([
const elementSummary = await getElementSummary(survey, elements, responses, dropOff); getSurveySummaryMeta(responses, displayCount, quotas),
getElementSummary(survey, elements, responses, dropOff),
]);
return { return {
meta, meta,
@@ -1041,7 +1095,7 @@ export const getResponsesForSummary = reactCache(
[limit, ZOptionalNumber], [limit, ZOptionalNumber],
[offset, ZOptionalNumber], [offset, ZOptionalNumber],
[filterCriteria, ZResponseFilterCriteria.optional()], [filterCriteria, ZResponseFilterCriteria.optional()],
[cursor, z.cuid2().optional()] [cursor, z.string().cuid2().optional()]
); );
const queryLimit = limit ?? RESPONSES_PER_PAGE; const queryLimit = limit ?? RESPONSES_PER_PAGE;
@@ -1,5 +1,4 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation"; import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage"; import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA"; import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
@@ -33,27 +32,26 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
const survey = await getSurvey(params.surveyId); const survey = await getSurvey(params.surveyId);
if (!survey) { if (!survey) {
throw new ResourceNotFoundError(t("common.survey"), params.surveyId); throw new Error(t("common.survey_not_found"));
} }
const user = await getUser(session.user.id); const user = await getUser(session.user.id);
if (!user) { if (!user) {
throw new AuthenticationError(t("common.not_authenticated")); throw new Error(t("common.user_not_found"));
} }
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id); const isContactsEnabled = await getIsContactsEnabled();
const isContactsEnabled = await getIsContactsEnabled(organizationId);
const segments = isContactsEnabled ? await getSegments(environment.id) : []; const segments = isContactsEnabled ? await getSegments(environment.id) : [];
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
if (!organizationId) { if (!organizationId) {
throw new ResourceNotFoundError(t("common.organization"), null); throw new Error(t("common.organization_not_found"));
} }
const organizationBilling = await getOrganizationBilling(organizationId); const organizationBilling = await getOrganizationBilling(organizationId);
if (!organizationBilling) { if (!organizationBilling) {
throw new ResourceNotFoundError(t("common.organization"), organizationId); throw new Error(t("common.organization_not_found"));
} }
const isQuotasAllowed = await getIsQuotasEnabled(organizationId); const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration // Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
const initialSurveySummary = await getSurveySummary(surveyId); const initialSurveySummary = await getSurveySummary(surveyId);

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