mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-26 08:20:29 -06:00
Compare commits
62 Commits
fix-6739-e
...
simplify-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b81235ac71 | ||
|
|
f57497d8b3 | ||
|
|
aab6798b29 | ||
|
|
f07092595f | ||
|
|
c03c7ec1ed | ||
|
|
628de8e6ae | ||
|
|
be4b54a827 | ||
|
|
e03df83e88 | ||
|
|
ed26427302 | ||
|
|
554809742b | ||
|
|
28adfb905c | ||
|
|
05c455ed62 | ||
|
|
f7687bc0ea | ||
|
|
af34391309 | ||
|
|
70978fbbdf | ||
|
|
f6683d1165 | ||
|
|
13be7a8970 | ||
|
|
0472d5e8f0 | ||
|
|
00a61f7abe | ||
|
|
6999abba3b | ||
|
|
9ae66f44ae | ||
|
|
7933d0077a | ||
|
|
cc8289fa33 | ||
|
|
c458051839 | ||
|
|
718a199d5b | ||
|
|
5ab9fdf1e3 | ||
|
|
5741209aa9 | ||
|
|
35d0d8ed54 | ||
|
|
5bce5c0a3b | ||
|
|
c61212964c | ||
|
|
b8d41a6e9b | ||
|
|
eedd5200a4 | ||
|
|
71a85c7126 | ||
|
|
341e2639e1 | ||
|
|
056470e6f0 | ||
|
|
e965ad4b97 | ||
|
|
12e703c02b | ||
|
|
07065f2675 | ||
|
|
7ca45cefeb | ||
|
|
4df28878db | ||
|
|
b355d05b25 | ||
|
|
e757e9aec9 | ||
|
|
cf4119baf6 | ||
|
|
6be2ae3071 | ||
|
|
600b793641 | ||
|
|
cde03b6997 | ||
|
|
00371bfb01 | ||
|
|
6be6782531 | ||
|
|
3ae4f8aa68 | ||
|
|
3d3c69a92b | ||
|
|
b1b94eaa66 | ||
|
|
67cc96449d | ||
|
|
bf41a53b86 | ||
|
|
26292ecf39 | ||
|
|
056e572a31 | ||
|
|
d7bbd219a3 | ||
|
|
fe5ff9a71c | ||
|
|
4e3438683e | ||
|
|
f587446079 | ||
|
|
7a3d05eb9a | ||
|
|
906b4da33c | ||
|
|
33b9ee3a50 |
@@ -179,14 +179,14 @@ For endpoints serving client SDKs, coordinate TTLs across layers:
|
||||
|
||||
```typescript
|
||||
// Client SDK cache (expiresAt) - longest TTL for fewer requests
|
||||
const CLIENT_TTL = 60 * 60; // 1 hour (seconds for client)
|
||||
const CLIENT_TTL = 60; // 1 minute (seconds for client)
|
||||
|
||||
// Server Redis cache - shorter TTL ensures fresh data for clients
|
||||
const SERVER_TTL = 60 * 30 * 1000; // 30 minutes in milliseconds
|
||||
const SERVER_TTL = 60 * 1000; // 1 minutes in milliseconds
|
||||
|
||||
// HTTP cache headers (seconds)
|
||||
const BROWSER_TTL = 60 * 60; // 1 hour (max-age)
|
||||
const CDN_TTL = 60 * 30; // 30 minutes (s-maxage)
|
||||
const BROWSER_TTL = 60; // 1 minute (max-age)
|
||||
const CDN_TTL = 60; // 1 minute (s-maxage)
|
||||
const CORS_TTL = 60 * 60; // 1 hour (balanced approach)
|
||||
```
|
||||
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
---
|
||||
description: >
|
||||
This rule provides comprehensive knowledge about the Formbricks database structure, relationships,
|
||||
and data patterns. It should be used **only when the agent explicitly requests database schema-level
|
||||
details** to support tasks such as: writing/debugging Prisma queries, designing/reviewing data models,
|
||||
investigating multi-tenancy behavior, creating API endpoints, or understanding data relationships.
|
||||
globs: []
|
||||
alwaysApply: agent-requested
|
||||
globs: schema.prisma
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Formbricks Database Schema Reference
|
||||
|
||||
This rule provides a reference to the Formbricks database structure. For the most up-to-date and complete schema definitions, please refer to the schema.prisma file directly.
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
@@ -281,15 +281,9 @@ runs:
|
||||
tags: ${{ inputs.registry_type == 'ecr' && steps.ecr-tags.outputs.tags || (inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'true' && steps.ghcr-meta-experimental.outputs.tags) || (inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'false' && steps.ghcr-extra-tags.outputs.tags) || (inputs.registry_type == 'ghcr' && format('ghcr.io/{0}:{1}', inputs.ghcr_image_name, steps.version.outputs.version)) || (inputs.registry_type == 'ecr' && format('{0}/{1}:{2}', inputs.ecr_registry, inputs.ecr_repository, steps.version.outputs.version)) }}
|
||||
labels: ${{ inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'true' && steps.ghcr-meta-experimental.outputs.labels || '' }}
|
||||
secrets: |
|
||||
database_url=${{ env.DUMMY_DATABASE_URL }}
|
||||
encryption_key=${{ env.DUMMY_ENCRYPTION_KEY }}
|
||||
redis_url=${{ env.DUMMY_REDIS_URL }}
|
||||
sentry_auth_token=${{ env.SENTRY_AUTH_TOKEN }}
|
||||
env:
|
||||
DEPOT_PROJECT_TOKEN: ${{ env.DEPOT_PROJECT_TOKEN }}
|
||||
DUMMY_DATABASE_URL: ${{ env.DUMMY_DATABASE_URL }}
|
||||
DUMMY_ENCRYPTION_KEY: ${{ env.DUMMY_ENCRYPTION_KEY }}
|
||||
DUMMY_REDIS_URL: ${{ env.DUMMY_REDIS_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
- name: Sign GHCR image (GHCR only)
|
||||
|
||||
3
.github/workflows/build-and-push-ecr.yml
vendored
3
.github/workflows/build-and-push-ecr.yml
vendored
@@ -88,7 +88,4 @@ jobs:
|
||||
make_latest: ${{ inputs.MAKE_LATEST }}
|
||||
env:
|
||||
DEPOT_PROJECT_TOKEN: ${{ secrets.DEPOT_PROJECT_TOKEN }}
|
||||
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
|
||||
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
@@ -70,9 +70,7 @@ jobs:
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
secrets: |
|
||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
redis_url=redis://localhost:6379
|
||||
sentry_auth_token=
|
||||
|
||||
- name: Verify and Initialize PostgreSQL
|
||||
run: |
|
||||
@@ -129,7 +127,6 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
run: |
|
||||
echo "🧪 Testing if the Docker image starts correctly..."
|
||||
|
||||
@@ -141,7 +138,7 @@ jobs:
|
||||
$DOCKER_RUN_ARGS \
|
||||
-p 3000:3000 \
|
||||
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
|
||||
-e ENCRYPTION_KEY="$DUMMY_ENCRYPTION_KEY" \
|
||||
-e ENCRYPTION_KEY="test-key-00000000000000000000000000000000000000000000000000" \
|
||||
-e REDIS_URL="redis://host.docker.internal:6379" \
|
||||
-d "formbricks-test:$GITHUB_SHA"
|
||||
|
||||
|
||||
1
.github/workflows/e2e.yml
vendored
1
.github/workflows/e2e.yml
vendored
@@ -17,7 +17,6 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
TELEMETRY_DISABLED: 1
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
|
||||
|
||||
6
.github/workflows/formbricks-release.yml
vendored
6
.github/workflows/formbricks-release.yml
vendored
@@ -89,7 +89,7 @@ jobs:
|
||||
- check-latest-release
|
||||
with:
|
||||
IS_PRERELEASE: ${{ github.event.release.prerelease }}
|
||||
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest }}
|
||||
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
|
||||
|
||||
docker-build-cloud:
|
||||
name: Build & push Formbricks Cloud to ECR
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
with:
|
||||
image_tag: ${{ needs.docker-build-community.outputs.VERSION }}
|
||||
IS_PRERELEASE: ${{ github.event.release.prerelease }}
|
||||
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest }}
|
||||
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
|
||||
needs:
|
||||
- check-latest-release
|
||||
- docker-build-community
|
||||
@@ -154,4 +154,4 @@ jobs:
|
||||
release_tag: ${{ github.event.release.tag_name }}
|
||||
commit_sha: ${{ github.sha }}
|
||||
is_prerelease: ${{ github.event.release.prerelease }}
|
||||
make_latest: ${{ needs.check-latest-release.outputs.is_latest }}
|
||||
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
|
||||
|
||||
@@ -44,7 +44,4 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DEPOT_PROJECT_TOKEN: ${{ secrets.DEPOT_PROJECT_TOKEN }}
|
||||
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
|
||||
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
3
.github/workflows/release-docker-github.yml
vendored
3
.github/workflows/release-docker-github.yml
vendored
@@ -102,7 +102,4 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DEPOT_PROJECT_TOKEN: ${{ secrets.DEPOT_PROJECT_TOKEN }}
|
||||
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
|
||||
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
@@ -25,10 +25,6 @@ RUN corepack prepare pnpm@9.15.9 --activate
|
||||
# Install necessary build tools and compilers
|
||||
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
|
||||
|
||||
# Copy the secrets handling script
|
||||
COPY apps/web/scripts/docker/read-secrets.sh /tmp/read-secrets.sh
|
||||
RUN chmod +x /tmp/read-secrets.sh
|
||||
|
||||
# Increase Node.js memory limit as a regular build argument
|
||||
ARG NODE_OPTIONS="--max_old_space_size=8192"
|
||||
ENV NODE_OPTIONS=${NODE_OPTIONS}
|
||||
@@ -37,6 +33,10 @@ ENV NODE_OPTIONS=${NODE_OPTIONS}
|
||||
# but needs explicit declaration for some build systems (like Depot)
|
||||
ARG TARGETARCH
|
||||
|
||||
# Base path for the application (optional)
|
||||
ARG BASE_PATH=""
|
||||
ENV BASE_PATH=${BASE_PATH}
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
@@ -57,13 +57,11 @@ RUN pnpm install --ignore-scripts
|
||||
# Build the database package first
|
||||
RUN pnpm build --filter=@formbricks/database
|
||||
|
||||
# Build the project using our secret reader script
|
||||
# This mounts the secrets only during this build step without storing them in layers
|
||||
RUN --mount=type=secret,id=database_url \
|
||||
--mount=type=secret,id=encryption_key \
|
||||
--mount=type=secret,id=redis_url \
|
||||
--mount=type=secret,id=sentry_auth_token \
|
||||
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
|
||||
# Build the project - only mount Sentry token for optional sourcemap uploads
|
||||
# DATABASE_URL, REDIS_URL, ENCRYPTION_KEY defaults are provided by env.ts during build
|
||||
RUN --mount=type=secret,id=sentry_auth_token \
|
||||
SENTRY_AUTH_TOKEN=$(cat /run/secrets/sentry_auth_token 2>/dev/null || echo "") \
|
||||
pnpm build --filter=@formbricks/web...
|
||||
|
||||
# Extract Prisma version
|
||||
RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_version.txt
|
||||
@@ -124,7 +122,7 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
|
||||
COPY --from=installer /app/node_modules/zod ./node_modules/zod
|
||||
RUN chmod -R 755 ./node_modules/zod
|
||||
|
||||
RUN npm install -g prisma
|
||||
RUN npm install -g prisma@6
|
||||
|
||||
# Create a startup script to handle the conditional logic
|
||||
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/co
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -24,8 +23,6 @@ const Page = async (props) => {
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) return notFound();
|
||||
|
||||
const organizations = await getOrganizationsByUserId(session.user.id);
|
||||
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
|
||||
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
||||
@@ -37,11 +34,10 @@ const Page = async (props) => {
|
||||
<div className="flex-1">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="p-6">
|
||||
{/* we only need to render organization breadcrumb on this page, so we pass some default value without actually calculating them to ProjectAndOrgSwitch component */}
|
||||
{/* we only need to render organization breadcrumb on this page, organizations/projects are lazy-loaded */}
|
||||
<ProjectAndOrgSwitch
|
||||
currentOrganizationId={organization.id}
|
||||
organizations={organizations}
|
||||
projects={[]}
|
||||
currentOrganizationName={organization.name}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
organizationProjectsLimit={0}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { PosthogIdentify } from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify";
|
||||
import { IS_POSTHOG_CONFIGURED } from "@/lib/constants";
|
||||
import { canUserAccessOrganization } from "@/lib/organization/auth";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
@@ -40,14 +38,6 @@ const ProjectOnboardingLayout = async (props) => {
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-slate-50">
|
||||
<PosthogIdentify
|
||||
session={session}
|
||||
user={user}
|
||||
organizationId={organization.id}
|
||||
organizationName={organization.name}
|
||||
organizationBilling={organization.billing}
|
||||
isPosthogEnabled={IS_POSTHOG_CONFIGURED}
|
||||
/>
|
||||
<ToasterClient />
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
|
||||
|
||||
const SurveyEditorEnvironmentLayout = async (props) => {
|
||||
const params = await props.params;
|
||||
|
||||
const { children } = props;
|
||||
|
||||
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
|
||||
const { t, session, user } = await environmentIdLayoutChecks(params.environmentId);
|
||||
|
||||
if (!session) {
|
||||
return redirect(`/auth/login`);
|
||||
@@ -25,15 +24,9 @@ const SurveyEditorEnvironmentLayout = async (props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<EnvironmentIdBaseLayout
|
||||
environmentId={params.environmentId}
|
||||
session={session}
|
||||
user={user}
|
||||
organization={organization}>
|
||||
<div className="flex h-screen flex-col">
|
||||
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
|
||||
</div>
|
||||
</EnvironmentIdBaseLayout>
|
||||
<div className="flex h-screen flex-col">
|
||||
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { ZProjectUpdateInput } from "@formbricks/types/project";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { getOrganizationProjectsCount } from "@/lib/project/service";
|
||||
import { updateUser } from "@/lib/user/service";
|
||||
@@ -16,6 +17,8 @@ import {
|
||||
getOrganizationProjectsLimit,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { createProject } from "@/modules/projects/settings/lib/project";
|
||||
import { getOrganizationsByUserId } from "./lib/organization";
|
||||
import { getProjectsByUserId } from "./lib/project";
|
||||
|
||||
const ZCreateProjectAction = z.object({
|
||||
organizationId: ZId,
|
||||
@@ -84,3 +87,59 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZGetOrganizationsForSwitcherAction = z.object({
|
||||
organizationId: ZId, // Changed from environmentId to avoid extra query
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetches organizations list for switcher dropdown.
|
||||
* Called on-demand when user opens the organization switcher.
|
||||
*/
|
||||
export const getOrganizationsForSwitcherAction = authenticatedActionClient
|
||||
.schema(ZGetOrganizationsForSwitcherAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager", "member", "billing"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await getOrganizationsByUserId(ctx.user.id);
|
||||
});
|
||||
|
||||
const ZGetProjectsForSwitcherAction = z.object({
|
||||
organizationId: ZId, // Changed from environmentId to avoid extra query
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetches projects list for switcher dropdown.
|
||||
* Called on-demand when user opens the project switcher.
|
||||
*/
|
||||
export const getProjectsForSwitcherAction = authenticatedActionClient
|
||||
.schema(ZGetProjectsForSwitcherAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager", "member", "billing"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Need membership for getProjectsByUserId (1 DB query)
|
||||
const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId);
|
||||
if (!membership) {
|
||||
throw new Error("Membership not found");
|
||||
}
|
||||
|
||||
return await getProjectsByUserId(ctx.user.id, membership);
|
||||
});
|
||||
|
||||
@@ -1,104 +1,49 @@
|
||||
import type { Session } from "next-auth";
|
||||
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
|
||||
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
|
||||
import { getOrganizationsByUserId } from "@/app/(app)/environments/[environmentId]/lib/organization";
|
||||
import { getProjectsByUserId } from "@/app/(app)/environments/[environmentId]/lib/project";
|
||||
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import {
|
||||
getMonthlyActiveOrganizationPeopleCount,
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import {
|
||||
getAccessControlPermission,
|
||||
getOrganizationProjectsLimit,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import { TEnvironmentLayoutData } from "@/modules/environments/types/environment-auth";
|
||||
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
|
||||
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
|
||||
|
||||
interface EnvironmentLayoutProps {
|
||||
environmentId: string;
|
||||
session: Session;
|
||||
layoutData: TEnvironmentLayoutData;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const EnvironmentLayout = async ({ environmentId, session, children }: EnvironmentLayoutProps) => {
|
||||
export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLayoutProps) => {
|
||||
const t = await getTranslate();
|
||||
const [user, environment, organizations, organization] = await Promise.all([
|
||||
getUser(session.user.id),
|
||||
getEnvironment(environmentId),
|
||||
getOrganizationsByUserId(session.user.id),
|
||||
getOrganizationByEnvironmentId(environmentId),
|
||||
]);
|
||||
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
}
|
||||
// Destructure all data from props (NO database queries)
|
||||
const {
|
||||
user,
|
||||
environment,
|
||||
organization,
|
||||
membership,
|
||||
project, // Current project details
|
||||
environments, // All project environments (for environment switcher)
|
||||
isAccessControlAllowed,
|
||||
projectPermission,
|
||||
license,
|
||||
peopleCount,
|
||||
responseCount,
|
||||
} = layoutData;
|
||||
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
// Calculate derived values (no queries)
|
||||
const { isMember, isOwner, isManager } = getAccessFlags(membership.role);
|
||||
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
||||
if (!currentUserMembership) {
|
||||
throw new Error(t("common.membership_not_found"));
|
||||
}
|
||||
const membershipRole = currentUserMembership?.role;
|
||||
|
||||
const [projects, environments, isAccessControlAllowed] = await Promise.all([
|
||||
getProjectsByUserId(user.id, currentUserMembership),
|
||||
getEnvironments(environment.projectId),
|
||||
getAccessControlPermission(organization.billing.plan),
|
||||
]);
|
||||
|
||||
if (!projects || !environments || !organizations) {
|
||||
throw new Error(t("environments.projects_environments_organizations_not_found"));
|
||||
}
|
||||
|
||||
const { isMember } = getAccessFlags(membershipRole);
|
||||
|
||||
const { features, lastChecked, isPendingDowngrade, active } = await getEnterpriseLicense();
|
||||
|
||||
const projectPermission = await getProjectPermissionByUserId(session.user.id, environment.projectId);
|
||||
const { features, lastChecked, isPendingDowngrade, active } = license;
|
||||
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
|
||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
||||
const isOwnerOrManager = isOwner || isManager;
|
||||
|
||||
// Validate that project permission exists for members
|
||||
if (isMember && !projectPermission) {
|
||||
throw new Error(t("common.project_permission_not_found"));
|
||||
}
|
||||
|
||||
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
|
||||
|
||||
let peopleCount = 0;
|
||||
let responseCount = 0;
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
[peopleCount, responseCount] = await Promise.all([
|
||||
getMonthlyActiveOrganizationPeopleCount(organization.id),
|
||||
getMonthlyOrganizationResponseCount(organization.id),
|
||||
]);
|
||||
}
|
||||
|
||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
||||
|
||||
// Find the current project from the projects array
|
||||
const project = projects.find((p) => p.id === environment.projectId);
|
||||
if (!project) {
|
||||
throw new Error(t("common.project_not_found"));
|
||||
}
|
||||
|
||||
const { isManager, isOwner } = getAccessFlags(membershipRole);
|
||||
const isOwnerOrManager = isManager || isOwner;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen min-h-screen flex-col overflow-hidden">
|
||||
{IS_FORMBRICKS_CLOUD && (
|
||||
@@ -122,26 +67,24 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
||||
<MainNavigation
|
||||
environment={environment}
|
||||
organization={organization}
|
||||
projects={projects}
|
||||
user={user}
|
||||
project={{ id: project.id, name: project.name }}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isDevelopment={IS_DEVELOPMENT}
|
||||
membershipRole={membershipRole}
|
||||
membershipRole={membership.role}
|
||||
/>
|
||||
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
|
||||
<TopControlBar
|
||||
environments={environments}
|
||||
currentOrganizationId={organization.id}
|
||||
organizations={organizations}
|
||||
currentProjectId={project.id}
|
||||
projects={projects}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isLicenseActive={active}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
membershipRole={membershipRole}
|
||||
membershipRole={membership.role}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto">{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,7 @@ interface NavigationProps {
|
||||
environment: TEnvironment;
|
||||
user: TUser;
|
||||
organization: TOrganization;
|
||||
projects: { id: string; name: string }[];
|
||||
project: { id: string; name: string };
|
||||
isFormbricksCloud: boolean;
|
||||
isDevelopment: boolean;
|
||||
membershipRole?: TOrganizationRole;
|
||||
@@ -52,7 +52,7 @@ export const MainNavigation = ({
|
||||
environment,
|
||||
organization,
|
||||
user,
|
||||
projects,
|
||||
project,
|
||||
membershipRole,
|
||||
isFormbricksCloud,
|
||||
isDevelopment,
|
||||
@@ -65,7 +65,6 @@ export const MainNavigation = ({
|
||||
const [latestVersion, setLatestVersion] = useState("");
|
||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||
|
||||
const project = projects.find((project) => project.id === environment.projectId);
|
||||
const { isManager, isOwner, isBilling } = getAccessFlags(membershipRole);
|
||||
|
||||
const isOwnerOrManager = isManager || isOwner;
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { Session } from "next-auth";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { useEffect } from "react";
|
||||
import { TOrganizationBilling } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
|
||||
interface PosthogIdentifyProps {
|
||||
session: Session;
|
||||
user: TUser;
|
||||
environmentId?: string;
|
||||
organizationId?: string;
|
||||
organizationName?: string;
|
||||
organizationBilling?: TOrganizationBilling;
|
||||
isPosthogEnabled: boolean;
|
||||
}
|
||||
|
||||
export const PosthogIdentify = ({
|
||||
session,
|
||||
user,
|
||||
environmentId,
|
||||
organizationId,
|
||||
organizationName,
|
||||
organizationBilling,
|
||||
isPosthogEnabled,
|
||||
}: PosthogIdentifyProps) => {
|
||||
const posthog = usePostHog();
|
||||
|
||||
useEffect(() => {
|
||||
if (isPosthogEnabled && session.user && posthog) {
|
||||
posthog.identify(session.user.id, {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
});
|
||||
if (environmentId) {
|
||||
posthog.group("environment", environmentId, { name: environmentId });
|
||||
}
|
||||
if (organizationId) {
|
||||
posthog.group("organization", organizationId, {
|
||||
name: organizationName,
|
||||
plan: organizationBilling?.plan,
|
||||
responseLimit: organizationBilling?.limits.monthly.responses,
|
||||
miuLimit: organizationBilling?.limits.monthly.miu,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [
|
||||
posthog,
|
||||
session.user,
|
||||
environmentId,
|
||||
organizationId,
|
||||
organizationName,
|
||||
organizationBilling,
|
||||
user.name,
|
||||
user.email,
|
||||
isPosthogEnabled,
|
||||
]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -9,9 +9,7 @@ import { getAccessFlags } from "@/lib/membership/utils";
|
||||
interface TopControlBarProps {
|
||||
environments: TEnvironment[];
|
||||
currentOrganizationId: string;
|
||||
organizations: { id: string; name: string }[];
|
||||
currentProjectId: string;
|
||||
projects: { id: string; name: string }[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
organizationProjectsLimit: number;
|
||||
isFormbricksCloud: boolean;
|
||||
@@ -24,9 +22,7 @@ interface TopControlBarProps {
|
||||
export const TopControlBar = ({
|
||||
environments,
|
||||
currentOrganizationId,
|
||||
organizations,
|
||||
currentProjectId,
|
||||
projects,
|
||||
isMultiOrgEnabled,
|
||||
organizationProjectsLimit,
|
||||
isFormbricksCloud,
|
||||
@@ -46,9 +42,7 @@ export const TopControlBar = ({
|
||||
currentEnvironmentId={environment.id}
|
||||
environments={environments}
|
||||
currentOrganizationId={currentOrganizationId}
|
||||
organizations={organizations}
|
||||
currentProjectId={currentProjectId}
|
||||
projects={projects}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
|
||||
@@ -10,9 +10,11 @@ import {
|
||||
SettingsIcon,
|
||||
} from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { getOrganizationsForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
||||
import {
|
||||
@@ -23,10 +25,11 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { useOrganization } from "../context/environment-context";
|
||||
|
||||
interface OrganizationBreadcrumbProps {
|
||||
currentOrganizationId: string;
|
||||
organizations: { id: string; name: string }[];
|
||||
currentOrganizationName?: string; // Optional: pass directly if context not available
|
||||
isMultiOrgEnabled: boolean;
|
||||
currentEnvironmentId?: string;
|
||||
isFormbricksCloud: boolean;
|
||||
@@ -47,7 +50,7 @@ const isActiveOrganizationSetting = (pathname: string, settingId: string): boole
|
||||
|
||||
export const OrganizationBreadcrumb = ({
|
||||
currentOrganizationId,
|
||||
organizations,
|
||||
currentOrganizationName,
|
||||
isMultiOrgEnabled,
|
||||
currentEnvironmentId,
|
||||
isFormbricksCloud,
|
||||
@@ -60,7 +63,45 @@ export const OrganizationBreadcrumb = ({
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const currentOrganization = organizations.find((org) => org.id === currentOrganizationId);
|
||||
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
|
||||
const [organizations, setOrganizations] = useState<{ id: string; name: string }[]>([]);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
// Get current organization name from context OR prop
|
||||
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
|
||||
const { organization: currentOrganization } = useOrganization();
|
||||
const organizationName = currentOrganization?.name || currentOrganizationName || "";
|
||||
|
||||
// Lazy-load organizations when dropdown opens
|
||||
useEffect(() => {
|
||||
// Only fetch when dropdown opened for first time (and no error state)
|
||||
if (isOrganizationDropdownOpen && organizations.length === 0 && !isLoadingOrganizations && !loadError) {
|
||||
setIsLoadingOrganizations(true);
|
||||
setLoadError(null); // Clear any previous errors
|
||||
getOrganizationsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
|
||||
if (result?.data) {
|
||||
// Sort organizations by name
|
||||
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
|
||||
setOrganizations(sorted);
|
||||
} else {
|
||||
// Handle server errors or validation errors
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
const error = new Error(errorMessage);
|
||||
logger.error(error, "Failed to load organizations");
|
||||
Sentry.captureException(error);
|
||||
setLoadError(errorMessage || t("common.failed_to_load_organizations"));
|
||||
}
|
||||
setIsLoadingOrganizations(false);
|
||||
});
|
||||
}
|
||||
}, [
|
||||
isOrganizationDropdownOpen,
|
||||
currentOrganizationId,
|
||||
organizations.length,
|
||||
isLoadingOrganizations,
|
||||
loadError,
|
||||
t,
|
||||
]);
|
||||
|
||||
if (!currentOrganization) {
|
||||
const errorMessage = `Organization not found for organization id: ${currentOrganizationId}`;
|
||||
@@ -126,7 +167,7 @@ export const OrganizationBreadcrumb = ({
|
||||
asChild>
|
||||
<div className="flex items-center gap-1">
|
||||
<BuildingIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
<span>{currentOrganization.name}</span>
|
||||
<span>{organizationName}</span>
|
||||
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||
{isOrganizationDropdownOpen ? (
|
||||
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
@@ -142,30 +183,52 @@ export const OrganizationBreadcrumb = ({
|
||||
<BuildingIcon className="mr-2 inline h-4 w-4" />
|
||||
{t("common.choose_organization")}
|
||||
</div>
|
||||
<DropdownMenuGroup>
|
||||
{organizations.map((org) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={org.id}
|
||||
checked={org.id === currentOrganization.id}
|
||||
onClick={() => handleOrganizationChange(org.id)}
|
||||
className="cursor-pointer">
|
||||
{org.name}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{isMultiOrgEnabled && (
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={() => setOpenCreateOrganizationModal(true)}
|
||||
className="cursor-pointer">
|
||||
<span>{t("common.create_new_organization")}</span>
|
||||
<PlusIcon className="ml-2 h-4 w-4" />
|
||||
</DropdownMenuCheckboxItem>
|
||||
{isLoadingOrganizations && (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingOrganizations && loadError && (
|
||||
<div className="px-2 py-4">
|
||||
<p className="mb-2 text-sm text-red-600">{loadError}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setLoadError(null);
|
||||
setOrganizations([]);
|
||||
}}
|
||||
className="text-xs text-slate-600 underline hover:text-slate-800">
|
||||
{t("common.try_again")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingOrganizations && !loadError && (
|
||||
<>
|
||||
<DropdownMenuGroup>
|
||||
{organizations.map((org) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={org.id}
|
||||
checked={org.id === currentOrganizationId}
|
||||
onClick={() => handleOrganizationChange(org.id)}
|
||||
className="cursor-pointer">
|
||||
{org.name}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{isMultiOrgEnabled && (
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={() => setOpenCreateOrganizationModal(true)}
|
||||
className="cursor-pointer">
|
||||
<span>{t("common.create_new_organization")}</span>
|
||||
<PlusIcon className="ml-2 h-4 w-4" />
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{currentEnvironmentId && (
|
||||
<div>
|
||||
<DropdownMenuSeparator />
|
||||
{showOrganizationDropdown && <DropdownMenuSeparator />}
|
||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||
<SettingsIcon className="mr-2 inline h-4 w-4" />
|
||||
{t("common.organization_settings")}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { EnvironmentBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/environment-breadcrumb";
|
||||
import { OrganizationBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/organization-breadcrumb";
|
||||
import { ProjectBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/project-breadcrumb";
|
||||
@@ -8,9 +7,9 @@ import { Breadcrumb, BreadcrumbList } from "@/modules/ui/components/breadcrumb";
|
||||
|
||||
interface ProjectAndOrgSwitchProps {
|
||||
currentOrganizationId: string;
|
||||
organizations: { id: string; name: string }[];
|
||||
currentOrganizationName?: string; // Optional: for pages without context
|
||||
currentProjectId?: string;
|
||||
projects: { id: string; name: string }[];
|
||||
currentProjectName?: string; // Optional: for pages without context
|
||||
currentEnvironmentId?: string;
|
||||
environments: { id: string; type: string }[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
@@ -18,15 +17,15 @@ interface ProjectAndOrgSwitchProps {
|
||||
isFormbricksCloud: boolean;
|
||||
isLicenseActive: boolean;
|
||||
isOwnerOrManager: boolean;
|
||||
isAccessControlAllowed: boolean;
|
||||
isMember: boolean;
|
||||
isAccessControlAllowed: boolean;
|
||||
}
|
||||
|
||||
export const ProjectAndOrgSwitch = ({
|
||||
currentOrganizationId,
|
||||
organizations,
|
||||
currentOrganizationName,
|
||||
currentProjectId,
|
||||
projects,
|
||||
currentProjectName,
|
||||
currentEnvironmentId,
|
||||
environments,
|
||||
isMultiOrgEnabled,
|
||||
@@ -37,11 +36,6 @@ export const ProjectAndOrgSwitch = ({
|
||||
isAccessControlAllowed,
|
||||
isMember,
|
||||
}: ProjectAndOrgSwitchProps) => {
|
||||
const sortedProjects = useMemo(() => projects.toSorted((a, b) => a.name.localeCompare(b.name)), [projects]);
|
||||
const sortedOrganizations = useMemo(
|
||||
() => organizations.toSorted((a, b) => a.name.localeCompare(b.name)),
|
||||
[organizations]
|
||||
);
|
||||
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
|
||||
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
|
||||
|
||||
@@ -50,9 +44,9 @@ export const ProjectAndOrgSwitch = ({
|
||||
<BreadcrumbList className="gap-0">
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={currentOrganizationId}
|
||||
organizations={sortedOrganizations}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
currentOrganizationName={currentOrganizationName}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isMember={isMember}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
@@ -60,9 +54,9 @@ export const ProjectAndOrgSwitch = ({
|
||||
{currentProjectId && currentEnvironmentId && (
|
||||
<ProjectBreadcrumb
|
||||
currentProjectId={currentProjectId}
|
||||
currentProjectName={currentProjectName}
|
||||
currentOrganizationId={currentOrganizationId}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
projects={sortedProjects}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { ChevronDownIcon, ChevronRightIcon, CogIcon, FolderOpenIcon, Loader2, PlusIcon } from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { getProjectsForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
|
||||
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
|
||||
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
||||
@@ -18,10 +20,11 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
|
||||
import { useProject } from "../context/environment-context";
|
||||
|
||||
interface ProjectBreadcrumbProps {
|
||||
currentProjectId: string;
|
||||
projects: { id: string; name: string }[];
|
||||
currentProjectName?: string; // Optional: pass directly if context not available
|
||||
isOwnerOrManager: boolean;
|
||||
organizationProjectsLimit: number;
|
||||
isFormbricksCloud: boolean;
|
||||
@@ -44,7 +47,7 @@ const isActiveProjectSetting = (pathname: string, settingId: string): boolean =>
|
||||
|
||||
export const ProjectBreadcrumb = ({
|
||||
currentProjectId,
|
||||
projects,
|
||||
currentProjectName,
|
||||
isOwnerOrManager,
|
||||
organizationProjectsLimit,
|
||||
isFormbricksCloud,
|
||||
@@ -59,9 +62,41 @@ export const ProjectBreadcrumb = ({
|
||||
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
|
||||
const [openLimitModal, setOpenLimitModal] = useState(false);
|
||||
const router = useRouter();
|
||||
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
|
||||
const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const pathname = usePathname();
|
||||
|
||||
// Get current project name from context OR prop
|
||||
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
|
||||
const { project: currentProject } = useProject();
|
||||
const projectName = currentProject?.name || currentProjectName || "";
|
||||
|
||||
// Lazy-load projects when dropdown opens
|
||||
useEffect(() => {
|
||||
// Only fetch when dropdown opened for first time (and no error state)
|
||||
if (isProjectDropdownOpen && projects.length === 0 && !isLoadingProjects && !loadError) {
|
||||
setIsLoadingProjects(true);
|
||||
setLoadError(null); // Clear any previous errors
|
||||
getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
|
||||
if (result?.data) {
|
||||
// Sort projects by name
|
||||
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
|
||||
setProjects(sorted);
|
||||
} else {
|
||||
// Handle server errors or validation errors
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
const error = new Error(errorMessage);
|
||||
logger.error(error, "Failed to load projects");
|
||||
Sentry.captureException(error);
|
||||
setLoadError(errorMessage || t("common.failed_to_load_projects"));
|
||||
}
|
||||
setIsLoadingProjects(false);
|
||||
});
|
||||
}
|
||||
}, [isProjectDropdownOpen, currentOrganizationId, projects.length, isLoadingProjects, loadError, t]);
|
||||
|
||||
const projectSettings = [
|
||||
{
|
||||
id: "general",
|
||||
@@ -100,8 +135,6 @@ export const ProjectBreadcrumb = ({
|
||||
},
|
||||
];
|
||||
|
||||
const currentProject = projects.find((project) => project.id === currentProjectId);
|
||||
|
||||
if (!currentProject) {
|
||||
const errorMessage = `Project not found for project id: ${currentProjectId}`;
|
||||
logger.error(errorMessage);
|
||||
@@ -166,7 +199,7 @@ export const ProjectBreadcrumb = ({
|
||||
asChild>
|
||||
<div className="flex items-center gap-1">
|
||||
<FolderOpenIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
<span>{currentProject.name}</span>
|
||||
<span>{projectName}</span>
|
||||
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||
{isProjectDropdownOpen ? (
|
||||
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
@@ -181,26 +214,48 @@ export const ProjectBreadcrumb = ({
|
||||
<FolderOpenIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||
{t("common.choose_project")}
|
||||
</div>
|
||||
<DropdownMenuGroup>
|
||||
{projects.map((proj) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={proj.id}
|
||||
checked={proj.id === currentProject.id}
|
||||
onClick={() => handleProjectChange(proj.id)}
|
||||
className="cursor-pointer">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{proj.name}</span>
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{isOwnerOrManager && (
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={handleAddProject}
|
||||
className="w-full cursor-pointer justify-between">
|
||||
<span>{t("common.add_new_project")}</span>
|
||||
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
||||
</DropdownMenuCheckboxItem>
|
||||
{isLoadingProjects && (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingProjects && loadError && (
|
||||
<div className="px-2 py-4">
|
||||
<p className="mb-2 text-sm text-red-600">{loadError}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setLoadError(null);
|
||||
setProjects([]);
|
||||
}}
|
||||
className="text-xs text-slate-600 underline hover:text-slate-800">
|
||||
{t("common.try_again")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingProjects && !loadError && (
|
||||
<>
|
||||
<DropdownMenuGroup>
|
||||
{projects.map((proj) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={proj.id}
|
||||
checked={proj.id === currentProjectId}
|
||||
onClick={() => handleProjectChange(proj.id)}
|
||||
className="cursor-pointer">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{proj.name}</span>
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{isOwnerOrManager && (
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={handleAddProject}
|
||||
className="w-full cursor-pointer justify-between">
|
||||
<span>{t("common.add_new_project")}</span>
|
||||
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
|
||||
export interface EnvironmentContextType {
|
||||
environment: TEnvironment;
|
||||
project: TProject;
|
||||
organization: TOrganization;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
@@ -20,25 +22,44 @@ export const useEnvironment = () => {
|
||||
return context;
|
||||
};
|
||||
|
||||
export const useProject = () => {
|
||||
const context = useContext(EnvironmentContext);
|
||||
if (!context) {
|
||||
return { project: null };
|
||||
}
|
||||
return { project: context.project };
|
||||
};
|
||||
|
||||
export const useOrganization = () => {
|
||||
const context = useContext(EnvironmentContext);
|
||||
if (!context) {
|
||||
return { organization: null };
|
||||
}
|
||||
return { organization: context.organization };
|
||||
};
|
||||
|
||||
// Client wrapper component to be used in server components
|
||||
interface EnvironmentContextWrapperProps {
|
||||
environment: TEnvironment;
|
||||
project: TProject;
|
||||
organization: TOrganization;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const EnvironmentContextWrapper = ({
|
||||
environment,
|
||||
project,
|
||||
organization,
|
||||
children,
|
||||
}: EnvironmentContextWrapperProps) => {
|
||||
const environmentContextValue = useMemo(
|
||||
() => ({
|
||||
environment,
|
||||
project,
|
||||
organization,
|
||||
organizationId: project.organizationId,
|
||||
}),
|
||||
[environment, project]
|
||||
[environment, project, organization]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
|
||||
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
|
||||
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
|
||||
|
||||
const EnvLayout = async (props: {
|
||||
@@ -15,48 +13,25 @@ const EnvLayout = async (props: {
|
||||
const params = await props.params;
|
||||
const { children } = props;
|
||||
|
||||
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
|
||||
|
||||
if (!session) {
|
||||
// Check session first (required for userId)
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
return redirect(`/auth/login`);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
}
|
||||
|
||||
const [project, environment] = await Promise.all([
|
||||
getProjectByEnvironmentId(params.environmentId),
|
||||
getEnvironment(params.environmentId),
|
||||
]);
|
||||
|
||||
if (!project) {
|
||||
throw new Error(t("common.project_not_found"));
|
||||
}
|
||||
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
|
||||
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
||||
|
||||
if (!membership) {
|
||||
throw new Error(t("common.membership_not_found"));
|
||||
}
|
||||
// Single consolidated data fetch (replaces ~12 individual fetches)
|
||||
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
|
||||
|
||||
return (
|
||||
<EnvironmentIdBaseLayout
|
||||
environmentId={params.environmentId}
|
||||
session={session}
|
||||
user={user}
|
||||
organization={organization}>
|
||||
<>
|
||||
<EnvironmentStorageHandler environmentId={params.environmentId} />
|
||||
<EnvironmentContextWrapper environment={environment} project={project}>
|
||||
<EnvironmentLayout environmentId={params.environmentId} session={session}>
|
||||
{children}
|
||||
</EnvironmentLayout>
|
||||
<EnvironmentContextWrapper
|
||||
environment={layoutData.environment}
|
||||
project={layoutData.project}
|
||||
organization={layoutData.organization}>
|
||||
<EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>
|
||||
</EnvironmentContextWrapper>
|
||||
</EnvironmentIdBaseLayout>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
@@ -16,7 +15,6 @@ interface AirtableWrapperProps {
|
||||
airtableArray: TIntegrationItem[];
|
||||
airtableIntegration?: TIntegrationAirtable;
|
||||
surveys: TSurvey[];
|
||||
environment: TEnvironment;
|
||||
isEnabled: boolean;
|
||||
webAppUrl: string;
|
||||
locale: TUserLocale;
|
||||
@@ -27,7 +25,6 @@ export const AirtableWrapper = ({
|
||||
airtableArray,
|
||||
airtableIntegration,
|
||||
surveys,
|
||||
environment,
|
||||
isEnabled,
|
||||
webAppUrl,
|
||||
locale,
|
||||
@@ -48,7 +45,6 @@ export const AirtableWrapper = ({
|
||||
<ManageIntegration
|
||||
airtableArray={airtableArray}
|
||||
environmentId={environmentId}
|
||||
environment={environment}
|
||||
airtableIntegration={airtableIntegration}
|
||||
setIsConnected={setIsConnected}
|
||||
surveys={surveys}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Trash2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
@@ -15,12 +14,11 @@ import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { IntegrationModalInputs } from "../lib/types";
|
||||
|
||||
interface ManageIntegrationProps {
|
||||
airtableIntegration: TIntegrationAirtable;
|
||||
environment: TEnvironment;
|
||||
environmentId: string;
|
||||
setIsConnected: (data: boolean) => void;
|
||||
surveys: TSurvey[];
|
||||
@@ -29,7 +27,7 @@ interface ManageIntegrationProps {
|
||||
}
|
||||
|
||||
export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
const { airtableIntegration, environment, environmentId, setIsConnected, surveys, airtableArray } = props;
|
||||
const { airtableIntegration, environmentId, setIsConnected, surveys, airtableArray } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const tableHeaders = [
|
||||
@@ -132,12 +130,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 w-full">
|
||||
<EmptySpaceFiller
|
||||
type="table"
|
||||
environment={environment}
|
||||
noWidgetRequired={true}
|
||||
emptyMessage={t("environments.integrations.airtable.no_integrations_yet")}
|
||||
/>
|
||||
<EmptyState text={t("environments.integrations.airtable.no_integrations_yet")} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ const Page = async (props) => {
|
||||
airtableArray={airtableArray}
|
||||
environmentId={environment.id}
|
||||
surveys={surveys}
|
||||
environment={environment}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
locale={locale}
|
||||
/>
|
||||
|
||||
@@ -60,7 +60,6 @@ export const GoogleSheetWrapper = ({
|
||||
selectedIntegration={selectedIntegration}
|
||||
/>
|
||||
<ManageIntegration
|
||||
environment={environment}
|
||||
googleSheetIntegration={googleSheetIntegration}
|
||||
setOpenAddIntegrationModal={setIsModalOpen}
|
||||
setIsConnected={setIsConnected}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Trash2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import {
|
||||
TIntegrationGoogleSheets,
|
||||
TIntegrationGoogleSheetsConfigData,
|
||||
@@ -15,10 +14,9 @@ import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
|
||||
interface ManageIntegrationProps {
|
||||
environment: TEnvironment;
|
||||
googleSheetIntegration: TIntegrationGoogleSheets;
|
||||
setOpenAddIntegrationModal: (v: boolean) => void;
|
||||
setIsConnected: (v: boolean) => void;
|
||||
@@ -27,7 +25,6 @@ interface ManageIntegrationProps {
|
||||
}
|
||||
|
||||
export const ManageIntegration = ({
|
||||
environment,
|
||||
googleSheetIntegration,
|
||||
setOpenAddIntegrationModal,
|
||||
setIsConnected,
|
||||
@@ -90,12 +87,7 @@ export const ManageIntegration = ({
|
||||
</div>
|
||||
{!integrationArray || integrationArray.length === 0 ? (
|
||||
<div className="mt-4 w-full">
|
||||
<EmptySpaceFiller
|
||||
type="table"
|
||||
environment={environment}
|
||||
noWidgetRequired={true}
|
||||
emptyMessage={t("environments.integrations.google_sheets.no_integrations_yet")}
|
||||
/>
|
||||
<EmptyState text={t("environments.integrations.google_sheets.no_integrations_yet")} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex w-full flex-col items-center justify-center">
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
TIntegrationNotionDatabase,
|
||||
} from "@formbricks/types/integration/notion";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
import {
|
||||
ERRORS,
|
||||
@@ -122,7 +123,7 @@ export const AddIntegrationModal = ({
|
||||
const questions = selectedSurvey
|
||||
? replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((q) => ({
|
||||
id: q.id,
|
||||
name: getLocalizedValue(q.headline, "default"),
|
||||
name: getTextContent(getLocalizedValue(q.headline, "default")),
|
||||
type: q.type,
|
||||
}))
|
||||
: [];
|
||||
|
||||
@@ -4,7 +4,6 @@ import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
@@ -12,11 +11,10 @@ import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
|
||||
interface ManageIntegrationProps {
|
||||
environment: TEnvironment;
|
||||
notionIntegration: TIntegrationNotion;
|
||||
setOpenAddIntegrationModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setIsConnected: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
@@ -28,7 +26,6 @@ interface ManageIntegrationProps {
|
||||
}
|
||||
|
||||
export const ManageIntegration = ({
|
||||
environment,
|
||||
notionIntegration,
|
||||
setOpenAddIntegrationModal,
|
||||
setIsConnected,
|
||||
@@ -101,12 +98,7 @@ export const ManageIntegration = ({
|
||||
</div>
|
||||
{!integrationArray || integrationArray.length === 0 ? (
|
||||
<div className="mt-4 w-full">
|
||||
<EmptySpaceFiller
|
||||
type="table"
|
||||
environment={environment}
|
||||
noWidgetRequired={true}
|
||||
emptyMessage={t("environments.integrations.notion.no_databases_found")}
|
||||
/>
|
||||
<EmptyState text={t("environments.integrations.notion.no_databases_found")} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex w-full flex-col items-center justify-center">
|
||||
|
||||
@@ -64,7 +64,6 @@ export const NotionWrapper = ({
|
||||
selectedIntegration={selectedIntegration}
|
||||
/>
|
||||
<ManageIntegration
|
||||
environment={environment}
|
||||
notionIntegration={notionIntegration}
|
||||
setOpenAddIntegrationModal={setIsModalOpen}
|
||||
setIsConnected={setIsConnected}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Trash2Icon } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
@@ -12,10 +11,9 @@ import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
|
||||
interface ManageIntegrationProps {
|
||||
environment: TEnvironment;
|
||||
slackIntegration: TIntegrationSlack;
|
||||
setOpenAddIntegrationModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setIsConnected: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
@@ -29,7 +27,6 @@ interface ManageIntegrationProps {
|
||||
}
|
||||
|
||||
export const ManageIntegration = ({
|
||||
environment,
|
||||
slackIntegration,
|
||||
setOpenAddIntegrationModal,
|
||||
setIsConnected,
|
||||
@@ -106,12 +103,7 @@ export const ManageIntegration = ({
|
||||
</div>
|
||||
{!integrationArray || integrationArray.length === 0 ? (
|
||||
<div className="mt-4 w-full">
|
||||
<EmptySpaceFiller
|
||||
type="table"
|
||||
environment={environment}
|
||||
noWidgetRequired={true}
|
||||
emptyMessage={t("environments.integrations.slack.connect_your_first_slack_channel")}
|
||||
/>
|
||||
<EmptyState text={t("environments.integrations.slack.connect_your_first_slack_channel")} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex w-full flex-col items-center justify-center">
|
||||
|
||||
@@ -78,7 +78,6 @@ export const SlackWrapper = ({
|
||||
selectedIntegration={selectedIntegration}
|
||||
/>
|
||||
<ManageIntegration
|
||||
environment={environment}
|
||||
slackIntegration={slackIntegration}
|
||||
setOpenAddIntegrationModal={setIsModalOpen}
|
||||
setIsConnected={setIsConnected}
|
||||
|
||||
@@ -215,7 +215,7 @@ export const EditProfileDetailsForm = ({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-slate-50 text-slate-700"
|
||||
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
|
||||
align="start">
|
||||
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
|
||||
{appLanguages.map((lang) => (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Metadata } from "next";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
@@ -25,7 +26,7 @@ export const generateMetadata = async (props: Props): Promise<Metadata> => {
|
||||
};
|
||||
|
||||
const SurveyLayout = async ({ children }) => {
|
||||
return <>{children}</>;
|
||||
return <ResponseFilterProvider>{children}</ResponseFilterProvider>;
|
||||
};
|
||||
|
||||
export default SurveyLayout;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
@@ -8,7 +8,14 @@ import { TTag } from "@formbricks/types/tags";
|
||||
import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Dialog, DialogBody, DialogContent, DialogFooter, DialogTitle } from "@/modules/ui/components/dialog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
|
||||
interface ResponseCardModalProps {
|
||||
responses: TResponse[];
|
||||
@@ -42,25 +49,37 @@ export const ResponseCardModal = ({
|
||||
locale,
|
||||
}: ResponseCardModalProps) => {
|
||||
const [currentIndex, setCurrentIndex] = useState<number | null>(null);
|
||||
const [isNavigating, setIsNavigating] = useState(false);
|
||||
|
||||
const idToIndexMap = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (let i = 0; i < responses.length; i++) {
|
||||
map.set(responses[i].id, i);
|
||||
}
|
||||
return map;
|
||||
}, [responses]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedResponseId) {
|
||||
setOpen(true);
|
||||
const index = responses.findIndex((response) => response.id === selectedResponseId);
|
||||
const index = idToIndexMap.get(selectedResponseId) ?? -1;
|
||||
setCurrentIndex(index);
|
||||
setIsNavigating(false);
|
||||
} else {
|
||||
setOpen(false);
|
||||
}
|
||||
}, [selectedResponseId, responses, setOpen]);
|
||||
}, [selectedResponseId, idToIndexMap, setOpen]);
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentIndex !== null && currentIndex < responses.length - 1) {
|
||||
setIsNavigating(true);
|
||||
setSelectedResponseId(responses[currentIndex + 1].id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentIndex !== null && currentIndex > 0) {
|
||||
setIsNavigating(true);
|
||||
setSelectedResponseId(responses[currentIndex - 1].id);
|
||||
}
|
||||
};
|
||||
@@ -72,8 +91,8 @@ export const ResponseCardModal = ({
|
||||
}
|
||||
};
|
||||
|
||||
// If no response is selected or currentIndex is null, do not render the modal
|
||||
if (selectedResponseId === null || currentIndex === null) return null;
|
||||
// If no response is selected or currentIndex is null or invalid, do not render the modal
|
||||
if (selectedResponseId === null || currentIndex === null || currentIndex === -1) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
@@ -81,6 +100,11 @@ export const ResponseCardModal = ({
|
||||
<VisuallyHidden asChild>
|
||||
<DialogTitle>Survey Response Details</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
<VisuallyHidden asChild>
|
||||
<DialogDescription>
|
||||
Response {currentIndex + 1} of {responses.length}
|
||||
</DialogDescription>
|
||||
</VisuallyHidden>
|
||||
<DialogBody>
|
||||
<SingleResponseCard
|
||||
survey={survey}
|
||||
@@ -96,12 +120,16 @@ export const ResponseCardModal = ({
|
||||
/>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button onClick={handleBack} disabled={currentIndex === 0} variant="outline" size="icon">
|
||||
<Button
|
||||
onClick={handleBack}
|
||||
disabled={currentIndex === 0 || isNavigating}
|
||||
variant="outline"
|
||||
size="icon">
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={currentIndex === responses.length - 1}
|
||||
disabled={currentIndex === responses.length - 1 || isNavigating}
|
||||
variant="outline"
|
||||
size="icon">
|
||||
<ChevronRight />
|
||||
|
||||
@@ -28,60 +28,63 @@ interface ResponseDataViewProps {
|
||||
quotas: TSurveyQuota[];
|
||||
}
|
||||
|
||||
// Helper function to format array values to record with specified keys
|
||||
const formatArrayToRecord = (responseValue: TResponseDataValue, keys: string[]): Record<string, string> => {
|
||||
if (!Array.isArray(responseValue)) return {};
|
||||
const result: Record<string, string> = {};
|
||||
for (let index = 0; index < responseValue.length; index++) {
|
||||
const curr = responseValue[index];
|
||||
result[keys[index]] = curr || "";
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Export for testing
|
||||
export const formatAddressData = (responseValue: TResponseDataValue): Record<string, string> => {
|
||||
const addressKeys = ["addressLine1", "addressLine2", "city", "state", "zip", "country"];
|
||||
return Array.isArray(responseValue)
|
||||
? responseValue.reduce((acc, curr, index) => {
|
||||
acc[addressKeys[index]] = curr || ""; // Fallback to empty string if undefined
|
||||
return acc;
|
||||
}, {})
|
||||
: {};
|
||||
return formatArrayToRecord(responseValue, addressKeys);
|
||||
};
|
||||
|
||||
// Export for testing
|
||||
export const formatContactInfoData = (responseValue: TResponseDataValue): Record<string, string> => {
|
||||
const addressKeys = ["firstName", "lastName", "email", "phone", "company"];
|
||||
return Array.isArray(responseValue)
|
||||
? responseValue.reduce((acc, curr, index) => {
|
||||
acc[addressKeys[index]] = curr || ""; // Fallback to empty string if undefined
|
||||
return acc;
|
||||
}, {})
|
||||
: {};
|
||||
const contactInfoKeys = ["firstName", "lastName", "email", "phone", "company"];
|
||||
return formatArrayToRecord(responseValue, contactInfoKeys);
|
||||
};
|
||||
|
||||
// Export for testing
|
||||
export const extractResponseData = (response: TResponseWithQuotas, survey: TSurvey): Record<string, any> => {
|
||||
let responseData: Record<string, any> = {};
|
||||
const responseData: Record<string, any> = {};
|
||||
|
||||
survey.questions.forEach((question) => {
|
||||
for (const question of survey.questions) {
|
||||
const responseValue = response.data[question.id];
|
||||
switch (question.type) {
|
||||
case "matrix":
|
||||
if (typeof responseValue === "object") {
|
||||
responseData = { ...responseData, ...responseValue };
|
||||
Object.assign(responseData, responseValue);
|
||||
}
|
||||
break;
|
||||
case "address":
|
||||
responseData = { ...responseData, ...formatAddressData(responseValue) };
|
||||
Object.assign(responseData, formatAddressData(responseValue));
|
||||
break;
|
||||
case "contactInfo":
|
||||
responseData = { ...responseData, ...formatContactInfoData(responseValue) };
|
||||
Object.assign(responseData, formatContactInfoData(responseValue));
|
||||
break;
|
||||
default:
|
||||
responseData[question.id] = responseValue;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
survey.hiddenFields.fieldIds?.forEach((fieldId) => {
|
||||
responseData[fieldId] = response.data[fieldId];
|
||||
});
|
||||
if (survey.hiddenFields.fieldIds) {
|
||||
for (const fieldId of survey.hiddenFields.fieldIds) {
|
||||
responseData[fieldId] = response.data[fieldId];
|
||||
}
|
||||
}
|
||||
|
||||
return responseData;
|
||||
};
|
||||
|
||||
// Export for testing
|
||||
export const mapResponsesToTableData = (
|
||||
const mapResponsesToTableData = (
|
||||
responses: TResponseWithQuotas[],
|
||||
survey: TSurvey,
|
||||
t: TFunction
|
||||
@@ -93,6 +96,7 @@ export const mapResponsesToTableData = (
|
||||
? t("environments.surveys.responses.completed")
|
||||
: t("environments.surveys.responses.not_completed"),
|
||||
responseId: response.id,
|
||||
singleUseId: response.singleUseId,
|
||||
tags: response.tags,
|
||||
variables: survey.variables.reduce(
|
||||
(acc, curr) => {
|
||||
@@ -126,6 +130,10 @@ export const ResponseDataView: React.FC<ResponseDataViewProps> = ({
|
||||
quotas,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedResponseId, setSelectedResponseId] = React.useState<string | null>(null);
|
||||
const setSelectedResponseIdTransition = React.useCallback((id: string | null) => {
|
||||
React.startTransition(() => setSelectedResponseId(id));
|
||||
}, []);
|
||||
const data = mapResponsesToTableData(responses, survey, t);
|
||||
|
||||
return (
|
||||
@@ -146,6 +154,8 @@ export const ResponseDataView: React.FC<ResponseDataViewProps> = ({
|
||||
locale={locale}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
quotas={quotas}
|
||||
selectedResponseId={selectedResponseId}
|
||||
setSelectedResponseId={setSelectedResponseIdTransition}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,8 +8,8 @@ import { TResponseWithQuotas } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import { getResponsesAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
||||
import { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView";
|
||||
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
||||
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
||||
@@ -26,6 +26,7 @@ interface ResponsePageProps {
|
||||
isReadOnly: boolean;
|
||||
isQuotasAllowed: boolean;
|
||||
quotas: TSurveyQuota[];
|
||||
initialResponses?: TResponseWithQuotas[];
|
||||
}
|
||||
|
||||
export const ResponsePage = ({
|
||||
@@ -39,11 +40,12 @@ export const ResponsePage = ({
|
||||
isReadOnly,
|
||||
isQuotasAllowed,
|
||||
quotas,
|
||||
initialResponses = [],
|
||||
}: ResponsePageProps) => {
|
||||
const [responses, setResponses] = useState<TResponseWithQuotas[]>([]);
|
||||
const [page, setPage] = useState<number>(1);
|
||||
const [hasMore, setHasMore] = useState<boolean>(true);
|
||||
const [isFetchingFirstPage, setFetchingFirstPage] = useState<boolean>(true);
|
||||
const [responses, setResponses] = useState<TResponseWithQuotas[]>(initialResponses);
|
||||
const [page, setPage] = useState<number | null>(null);
|
||||
const [hasMore, setHasMore] = useState<boolean>(initialResponses.length >= responsesPerPage);
|
||||
const [isFetchingFirstPage, setIsFetchingFirstPage] = useState<boolean>(false);
|
||||
const { selectedFilter, dateRange, resetState } = useResponseFilter();
|
||||
|
||||
const filters = useMemo(
|
||||
@@ -56,6 +58,7 @@ export const ResponsePage = ({
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const fetchNextPage = useCallback(async () => {
|
||||
if (page === null) return;
|
||||
const newPage = page + 1;
|
||||
|
||||
let newResponses: TResponseWithQuotas[] = [];
|
||||
@@ -93,10 +96,22 @@ export const ResponsePage = ({
|
||||
}
|
||||
}, [searchParams, resetState]);
|
||||
|
||||
// Only fetch if filters are applied (not on initial mount with no filters)
|
||||
const hasFilters =
|
||||
selectedFilter?.responseStatus !== "all" ||
|
||||
(selectedFilter?.filter && selectedFilter.filter.length > 0) ||
|
||||
(dateRange.from && dateRange.to);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchInitialResponses = async () => {
|
||||
const fetchFilteredResponses = async () => {
|
||||
try {
|
||||
setFetchingFirstPage(true);
|
||||
// skip call for initial mount
|
||||
if (page === null && !hasFilters) {
|
||||
setPage(1);
|
||||
return;
|
||||
}
|
||||
setPage(1);
|
||||
setIsFetchingFirstPage(true);
|
||||
let responses: TResponseWithQuotas[] = [];
|
||||
|
||||
const getResponsesActionResponse = await getResponsesAction({
|
||||
@@ -110,24 +125,20 @@ export const ResponsePage = ({
|
||||
|
||||
if (responses.length < responsesPerPage) {
|
||||
setHasMore(false);
|
||||
} else {
|
||||
setHasMore(true);
|
||||
}
|
||||
setResponses(responses);
|
||||
} finally {
|
||||
setFetchingFirstPage(false);
|
||||
setIsFetchingFirstPage(false);
|
||||
}
|
||||
};
|
||||
fetchInitialResponses();
|
||||
}, [surveyId, filters, responsesPerPage]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
setHasMore(true);
|
||||
setResponses([]);
|
||||
}, [filters]);
|
||||
fetchFilteredResponses();
|
||||
}, [filters, responsesPerPage, selectedFilter, dateRange, surveyId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-1.5">
|
||||
<div className="flex h-9 gap-1.5">
|
||||
<CustomFilter survey={surveyMemoized} />
|
||||
</div>
|
||||
<ResponseDataView
|
||||
|
||||
@@ -39,6 +39,12 @@ import {
|
||||
import { Skeleton } from "@/modules/ui/components/skeleton";
|
||||
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||
|
||||
const SkeletonCell = () => (
|
||||
<Skeleton className="w-full">
|
||||
<div className="h-6"></div>
|
||||
</Skeleton>
|
||||
);
|
||||
|
||||
interface ResponseTableProps {
|
||||
data: TResponseTableData[];
|
||||
survey: TSurvey;
|
||||
@@ -55,6 +61,8 @@ interface ResponseTableProps {
|
||||
locale: TUserLocale;
|
||||
isQuotasAllowed: boolean;
|
||||
quotas: TSurveyQuota[];
|
||||
selectedResponseId: string | null;
|
||||
setSelectedResponseId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export const ResponseTable = ({
|
||||
@@ -73,12 +81,13 @@ export const ResponseTable = ({
|
||||
locale,
|
||||
isQuotasAllowed,
|
||||
quotas,
|
||||
selectedResponseId,
|
||||
setSelectedResponseId,
|
||||
}: ResponseTableProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const [isTableSettingsModalOpen, setIsTableSettingsModalOpen] = useState(false);
|
||||
const [selectedResponseId, setSelectedResponseId] = useState<string | null>(null);
|
||||
const selectedResponse = responses?.find((response) => response.id === selectedResponseId) ?? null;
|
||||
const [isExpanded, setIsExpanded] = useState<boolean | null>(null);
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
||||
@@ -86,7 +95,10 @@ export const ResponseTable = ({
|
||||
|
||||
const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
|
||||
// Generate columns
|
||||
const columns = generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn);
|
||||
const columns = useMemo(
|
||||
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn),
|
||||
[survey, isExpanded, isReadOnly, t, showQuotasColumn]
|
||||
);
|
||||
|
||||
// Save settings to localStorage when they change
|
||||
useEffect(() => {
|
||||
@@ -110,7 +122,13 @@ export const ResponseTable = ({
|
||||
|
||||
// Memoize table data and columns
|
||||
const tableData: TResponseTableData[] = useMemo(
|
||||
() => (isFetchingFirstPage ? Array(10).fill({}) : data),
|
||||
() =>
|
||||
isFetchingFirstPage
|
||||
? Array.from(
|
||||
{ length: 10 },
|
||||
(_, index) => ({ responseId: `skeleton-${index}` }) as TResponseTableData
|
||||
)
|
||||
: data,
|
||||
[data, isFetchingFirstPage]
|
||||
);
|
||||
|
||||
@@ -119,11 +137,7 @@ export const ResponseTable = ({
|
||||
isFetchingFirstPage
|
||||
? columns.map((column) => ({
|
||||
...column,
|
||||
cell: () => (
|
||||
<Skeleton className="w-full">
|
||||
<div className="h-6"></div>
|
||||
</Skeleton>
|
||||
),
|
||||
cell: SkeletonCell,
|
||||
}))
|
||||
: columns,
|
||||
[columns, isFetchingFirstPage]
|
||||
@@ -247,8 +261,8 @@ export const ResponseTable = ({
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
<TableBody ref={parent}>
|
||||
{/* disable auto animation if there are more than 200 responses for performance optimizations */}
|
||||
<TableBody ref={responses && responses.length > 200 ? undefined : parent}>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
@@ -261,7 +275,6 @@ export const ResponseTable = ({
|
||||
row={row}
|
||||
isExpanded={isExpanded ?? false}
|
||||
setSelectedResponseId={setSelectedResponseId}
|
||||
responses={responses}
|
||||
/>
|
||||
))}
|
||||
</TableRow>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Cell, Row, flexRender } from "@tanstack/react-table";
|
||||
import { Maximize2Icon } from "lucide-react";
|
||||
import { TResponse, TResponseTableData } from "@formbricks/types/responses";
|
||||
import React from "react";
|
||||
import { TResponseTableData } from "@formbricks/types/responses";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getCommonPinningStyles } from "@/modules/ui/components/data-table/lib/utils";
|
||||
import { TableCell } from "@/modules/ui/components/table";
|
||||
@@ -10,21 +11,18 @@ interface ResponseTableCellProps {
|
||||
row: Row<TResponseTableData>;
|
||||
isExpanded: boolean;
|
||||
setSelectedResponseId: (responseId: string | null) => void;
|
||||
responses: TResponse[] | null;
|
||||
}
|
||||
|
||||
export const ResponseTableCell = ({
|
||||
const ResponseTableCellComponent = ({
|
||||
cell,
|
||||
row,
|
||||
isExpanded,
|
||||
setSelectedResponseId,
|
||||
responses,
|
||||
}: ResponseTableCellProps) => {
|
||||
// Function to handle cell click
|
||||
const handleCellClick = () => {
|
||||
if (cell.column.id !== "select") {
|
||||
const response = responses?.find((response) => response.id === row.id);
|
||||
if (response) setSelectedResponseId(response.id);
|
||||
setSelectedResponseId(row.id);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -66,3 +64,5 @@ export const ResponseTableCell = ({
|
||||
</TableCell>
|
||||
);
|
||||
};
|
||||
|
||||
export const ResponseTableCell = React.memo(ResponseTableCellComponent);
|
||||
|
||||
@@ -312,6 +312,14 @@ export const generateResponseTableColumns = (
|
||||
},
|
||||
};
|
||||
|
||||
const singleUseIdColumn: ColumnDef<TResponseTableData> = {
|
||||
accessorKey: "singleUseId",
|
||||
header: () => <div className="gap-x-1.5">{t("environments.surveys.responses.single_use_id")}</div>,
|
||||
cell: ({ row }) => {
|
||||
return <p className="truncate text-slate-900">{row.original.singleUseId}</p>;
|
||||
},
|
||||
};
|
||||
|
||||
const quotasColumn: ColumnDef<TResponseTableData> = {
|
||||
accessorKey: "quota",
|
||||
header: t("common.quota"),
|
||||
@@ -409,6 +417,7 @@ export const generateResponseTableColumns = (
|
||||
// Combine the selection column with the dynamic question columns
|
||||
const baseColumns = [
|
||||
personColumn,
|
||||
singleUseIdColumn,
|
||||
dateColumn,
|
||||
...(showQuotasColumn ? [quotasColumn] : []),
|
||||
statusColumn,
|
||||
|
||||
@@ -2,9 +2,8 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
|
||||
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 { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED, RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||
import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
@@ -14,7 +13,6 @@ import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
|
||||
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
@@ -23,45 +21,44 @@ const Page = async (props) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
|
||||
const { session, environment, isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
||||
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const survey = await getSurvey(params.surveyId);
|
||||
const [survey, user, tags, isContactsEnabled, responseCount, locale] = await Promise.all([
|
||||
getSurvey(params.surveyId),
|
||||
getUser(session.user.id),
|
||||
getTagsByEnvironmentId(params.environmentId),
|
||||
getIsContactsEnabled(),
|
||||
getResponseCountBySurveyId(params.surveyId),
|
||||
findMatchingLocale(),
|
||||
]);
|
||||
|
||||
if (!survey) {
|
||||
throw new Error(t("common.survey_not_found"));
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
}
|
||||
|
||||
const tags = await getTagsByEnvironmentId(params.environmentId);
|
||||
|
||||
const isContactsEnabled = await getIsContactsEnabled();
|
||||
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
|
||||
|
||||
// Get response count for the CTA component
|
||||
const responseCount = await getResponseCountBySurveyId(params.surveyId);
|
||||
const displayCount = await getDisplayCountBySurveyId(params.surveyId);
|
||||
|
||||
const locale = await findMatchingLocale();
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
||||
if (!organizationId) {
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
const organizationBilling = await getOrganizationBilling(organizationId);
|
||||
|
||||
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
|
||||
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
const organizationBilling = await getOrganizationBilling(organization.id);
|
||||
if (!organizationBilling) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
|
||||
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
|
||||
|
||||
const quotas = isQuotasAllowed ? await getQuotas(survey.id) : [];
|
||||
|
||||
// Fetch initial responses on the server to prevent duplicate client-side fetch
|
||||
const initialResponses = await getResponses(params.surveyId, RESPONSES_PER_PAGE, 0);
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader
|
||||
@@ -74,7 +71,6 @@ const Page = async (props) => {
|
||||
user={user}
|
||||
publicDomain={publicDomain}
|
||||
responseCount={responseCount}
|
||||
displayCount={displayCount}
|
||||
segments={segments}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
@@ -94,6 +90,7 @@ const Page = async (props) => {
|
||||
isReadOnly={isReadOnly}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
quotas={quotas}
|
||||
initialResponses={initialResponses}
|
||||
/>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { timeSince } from "@/lib/time";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
import { ArrayResponse } from "@/modules/ui/components/array-response";
|
||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
|
||||
interface AddressSummaryProps {
|
||||
@@ -29,42 +30,48 @@ export const AddressSummary = ({ questionSummary, environmentId, survey, locale
|
||||
<div className="px-4 md:px-6">{t("common.time")}</div>
|
||||
</div>
|
||||
<div className="max-h-[62vh] w-full overflow-y-auto">
|
||||
{questionSummary.samples.map((response) => {
|
||||
return (
|
||||
<div
|
||||
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 md:text-base">
|
||||
<div className="pl-4 md:pl-6">
|
||||
{response.contact ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.contact.id} />
|
||||
{questionSummary.samples.length === 0 ? (
|
||||
<div className="p-8">
|
||||
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
|
||||
</div>
|
||||
) : (
|
||||
questionSummary.samples.map((response) => {
|
||||
return (
|
||||
<div
|
||||
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 md:text-base">
|
||||
<div className="pl-4 md:pl-6">
|
||||
{response.contact ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.contact.id} />
|
||||
</div>
|
||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
</div>
|
||||
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||
</div>
|
||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
</div>
|
||||
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-2 pl-6 font-semibold">
|
||||
<ArrayResponse value={response.value} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-2 pl-6 font-semibold">
|
||||
<ArrayResponse value={response.value} />
|
||||
</div>
|
||||
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { CSSProperties, ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
|
||||
interface ClickableBarSegmentProps {
|
||||
children: ReactNode;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const ClickableBarSegment = ({
|
||||
children,
|
||||
onClick,
|
||||
className = "",
|
||||
style,
|
||||
}: ClickableBarSegmentProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button className={className} style={style} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("common.click_to_filter")}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import { timeSince } from "@/lib/time";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
import { ArrayResponse } from "@/modules/ui/components/array-response";
|
||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
|
||||
interface ContactInfoSummaryProps {
|
||||
@@ -34,42 +35,48 @@ export const ContactInfoSummary = ({
|
||||
<div className="px-4 md:px-6">{t("common.time")}</div>
|
||||
</div>
|
||||
<div className="max-h-[62vh] w-full overflow-y-auto">
|
||||
{questionSummary.samples.map((response) => {
|
||||
return (
|
||||
<div
|
||||
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 md:text-base">
|
||||
<div className="pl-4 md:pl-6">
|
||||
{response.contact ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.contact.id} />
|
||||
{questionSummary.samples.length === 0 ? (
|
||||
<div className="p-8">
|
||||
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
|
||||
</div>
|
||||
) : (
|
||||
questionSummary.samples.map((response) => {
|
||||
return (
|
||||
<div
|
||||
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 md:text-base">
|
||||
<div className="pl-4 md:pl-6">
|
||||
{response.contact ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.contact.id} />
|
||||
</div>
|
||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
</div>
|
||||
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||
</div>
|
||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
</div>
|
||||
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-2 pl-6 font-semibold">
|
||||
<ArrayResponse value={response.value} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-2 pl-6 font-semibold">
|
||||
<ArrayResponse value={response.value} />
|
||||
</div>
|
||||
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
|
||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
|
||||
interface DateQuestionSummary {
|
||||
@@ -55,41 +56,47 @@ export const DateQuestionSummary = ({
|
||||
<div className="px-4 md:px-6">{t("common.time")}</div>
|
||||
</div>
|
||||
<div className="max-h-[62vh] w-full overflow-y-auto">
|
||||
{questionSummary.samples.slice(0, visibleResponses).map((response) => (
|
||||
<div
|
||||
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 md:text-base">
|
||||
<div className="pl-4 md:pl-6">
|
||||
{response.contact ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.contact.id} />
|
||||
</div>
|
||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
</div>
|
||||
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
||||
{renderResponseValue(response.value)}
|
||||
</div>
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||
</div>
|
||||
{questionSummary.samples.length === 0 ? (
|
||||
<div className="p-8">
|
||||
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
questionSummary.samples.slice(0, visibleResponses).map((response) => (
|
||||
<div
|
||||
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 md:text-base">
|
||||
<div className="pl-4 md:pl-6">
|
||||
{response.contact ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.contact.id} />
|
||||
</div>
|
||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
</div>
|
||||
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
||||
{renderResponseValue(response.value)}
|
||||
</div>
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{visibleResponses < questionSummary.samples.length && (
|
||||
{questionSummary.samples.length > 0 && visibleResponses < questionSummary.samples.length && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
||||
{t("common.load_more")}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
import { getOriginalFileNameFromUrl } from "@/modules/storage/utils";
|
||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
|
||||
interface FileUploadSummaryProps {
|
||||
@@ -45,71 +46,77 @@ export const FileUploadSummary = ({
|
||||
<div className="px-4 md:px-6">{t("common.time")}</div>
|
||||
</div>
|
||||
<div className="max-h-[62vh] w-full overflow-y-auto">
|
||||
{questionSummary.files.slice(0, visibleResponses).map((response) => (
|
||||
<div
|
||||
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 md:text-base">
|
||||
<div className="pl-4 md:pl-6">
|
||||
{response.contact ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.contact.id} />
|
||||
</div>
|
||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
</div>
|
||||
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 grid">
|
||||
{Array.isArray(response.value) &&
|
||||
(response.value.length > 0 ? (
|
||||
response.value.map((fileUrl) => {
|
||||
const fileName = getOriginalFileNameFromUrl(fileUrl);
|
||||
|
||||
return (
|
||||
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
|
||||
<a href={fileUrl} key={fileUrl} target="_blank" rel="noopener noreferrer">
|
||||
<div className="absolute right-0 top-0 m-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
|
||||
<DownloadIcon className="h-6 text-slate-500" />
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div className="flex flex-col items-center justify-center p-2">
|
||||
<FileIcon className="h-6 text-slate-500" />
|
||||
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">{fileName}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="flex w-full flex-col items-center justify-center p-2">
|
||||
<p className="mt-2 text-sm font-semibold text-slate-500 dark:text-slate-400">
|
||||
{t("common.skipped")}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||
</div>
|
||||
{questionSummary.files.length === 0 ? (
|
||||
<div className="p-8">
|
||||
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
questionSummary.files.slice(0, visibleResponses).map((response) => (
|
||||
<div
|
||||
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 md:text-base">
|
||||
<div className="pl-4 md:pl-6">
|
||||
{response.contact ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.contact.id} />
|
||||
</div>
|
||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
</div>
|
||||
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 grid">
|
||||
{Array.isArray(response.value) &&
|
||||
(response.value.length > 0 ? (
|
||||
response.value.map((fileUrl) => {
|
||||
const fileName = getOriginalFileNameFromUrl(fileUrl);
|
||||
|
||||
return (
|
||||
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
|
||||
<a href={fileUrl} key={fileUrl} target="_blank" rel="noopener noreferrer">
|
||||
<div className="absolute right-0 top-0 m-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
|
||||
<DownloadIcon className="h-6 text-slate-500" />
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div className="flex flex-col items-center justify-center p-2">
|
||||
<FileIcon className="h-6 text-slate-500" />
|
||||
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">{fileName}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="flex w-full flex-col items-center justify-center p-2">
|
||||
<p className="mt-2 text-sm font-semibold text-slate-500 dark:text-slate-400">
|
||||
{t("common.skipped")}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{visibleResponses < questionSummary.files.length && (
|
||||
{questionSummary.files.length > 0 && visibleResponses < questionSummary.files.length && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
||||
{t("common.load_more")}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { timeSince } from "@/lib/time";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
|
||||
interface HiddenFieldsSummaryProps {
|
||||
environment: TEnvironment;
|
||||
@@ -51,40 +52,46 @@ export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: Hi
|
||||
<div className="col-span-2 pl-4 md:pl-6">{t("common.response")}</div>
|
||||
<div className="px-4 md:px-6">{t("common.time")}</div>
|
||||
</div>
|
||||
{questionSummary.samples.slice(0, visibleResponses).map((response, idx) => (
|
||||
<div
|
||||
key={`${response.value}-${idx}`}
|
||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
|
||||
<div className="pl-4 md:pl-6">
|
||||
{response.contact ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environment.id}/contacts/${response.contact.id}`}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.contact.id} />
|
||||
</div>
|
||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
</div>
|
||||
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
||||
{response.value}
|
||||
</div>
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||
</div>
|
||||
{questionSummary.samples.length === 0 ? (
|
||||
<div className="p-8">
|
||||
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
|
||||
</div>
|
||||
))}
|
||||
{visibleResponses < questionSummary.samples.length && (
|
||||
) : (
|
||||
questionSummary.samples.slice(0, visibleResponses).map((response, idx) => (
|
||||
<div
|
||||
key={`${response.value}-${idx}`}
|
||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
|
||||
<div className="pl-4 md:pl-6">
|
||||
{response.contact ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environment.id}/contacts/${response.contact.id}`}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.contact.id} />
|
||||
</div>
|
||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
</div>
|
||||
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
||||
{response.value}
|
||||
</div>
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{questionSummary.samples.length > 0 && visibleResponses < questionSummary.samples.length && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
||||
{t("common.load_more")}
|
||||
|
||||
@@ -85,96 +85,98 @@ export const MultipleChoiceSummary = ({
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{results.map((result, resultsIdx) => {
|
||||
const choiceId = getChoiceIdByValue(result.value, questionSummary.question);
|
||||
return (
|
||||
<Fragment key={result.value}>
|
||||
<button
|
||||
type="button"
|
||||
className="group w-full cursor-pointer"
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
questionSummary.question.id,
|
||||
questionSummary.question.headline,
|
||||
questionSummary.question.type,
|
||||
questionSummary.type === "multipleChoiceSingle" || otherValue === result.value
|
||||
? t("environments.surveys.summary.includes_either")
|
||||
: t("environments.surveys.summary.includes_all"),
|
||||
[result.value]
|
||||
)
|
||||
}>
|
||||
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
|
||||
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
|
||||
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
|
||||
{results.length - resultsIdx} - {result.value}
|
||||
</p>
|
||||
{choiceId && <IdBadge id={choiceId} />}
|
||||
</div>
|
||||
<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">
|
||||
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
|
||||
</p>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(result.percentage, 2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="group-hover:opacity-80">
|
||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
||||
</div>
|
||||
</button>
|
||||
{result.others && result.others.length > 0 && (
|
||||
<div className="mt-4 rounded-lg border border-slate-200">
|
||||
<div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-1 pl-6">
|
||||
{t("environments.surveys.summary.other_values_found")}
|
||||
<div className="px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
<div className="space-y-5">
|
||||
{results.map((result) => {
|
||||
const choiceId = getChoiceIdByValue(result.value, questionSummary.question);
|
||||
return (
|
||||
<Fragment key={result.value}>
|
||||
<button
|
||||
type="button"
|
||||
className="group w-full cursor-pointer"
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
questionSummary.question.id,
|
||||
questionSummary.question.headline,
|
||||
questionSummary.question.type,
|
||||
questionSummary.type === "multipleChoiceSingle" || otherValue === result.value
|
||||
? t("environments.surveys.summary.includes_either")
|
||||
: t("environments.surveys.summary.includes_all"),
|
||||
[result.value]
|
||||
)
|
||||
}>
|
||||
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
|
||||
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
|
||||
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
|
||||
{result.value}
|
||||
</p>
|
||||
{choiceId && <IdBadge id={choiceId} />}
|
||||
</div>
|
||||
<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">
|
||||
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
|
||||
</p>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(result.percentage, 2)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-span-1 pl-6">{surveyType === "app" && t("common.user")}</div>
|
||||
</div>
|
||||
{result.others
|
||||
.filter((otherValue) => otherValue.value !== "")
|
||||
.slice(0, visibleOtherResponses)
|
||||
.map((otherValue, idx) => (
|
||||
<div key={`${idx}-${otherValue}`} dir="auto">
|
||||
{surveyType === "link" && (
|
||||
<div className="ph-no-capture col-span-1 m-2 flex h-10 items-center rounded-lg pl-4 text-sm font-medium text-slate-900">
|
||||
<span>{otherValue.value}</span>
|
||||
</div>
|
||||
)}
|
||||
{surveyType === "app" && otherValue.contact && (
|
||||
<Link
|
||||
href={
|
||||
otherValue.contact.id
|
||||
? `/environments/${environmentId}/contacts/${otherValue.contact.id}`
|
||||
: { pathname: null }
|
||||
}
|
||||
className="m-2 grid h-16 grid-cols-2 items-center rounded-lg text-sm hover:bg-slate-100">
|
||||
<div className="ph-no-capture col-span-1 pl-4 font-medium text-slate-900">
|
||||
<div className="group-hover:opacity-80">
|
||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
||||
</div>
|
||||
</button>
|
||||
{result.others && result.others.length > 0 && (
|
||||
<div className="mt-4 rounded-lg border border-slate-200">
|
||||
<div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-1 pl-6">
|
||||
{t("environments.surveys.summary.other_values_found")}
|
||||
</div>
|
||||
<div className="col-span-1 pl-6">{surveyType === "app" && t("common.user")}</div>
|
||||
</div>
|
||||
{result.others
|
||||
.filter((otherValue) => otherValue.value !== "")
|
||||
.slice(0, visibleOtherResponses)
|
||||
.map((otherValue, idx) => (
|
||||
<div key={`${idx}-${otherValue}`} dir="auto">
|
||||
{surveyType === "link" && (
|
||||
<div className="ph-no-capture col-span-1 m-2 flex h-10 items-center rounded-lg pl-4 text-sm font-medium text-slate-900">
|
||||
<span>{otherValue.value}</span>
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-1 flex items-center space-x-4 pl-6 font-medium text-slate-900">
|
||||
{otherValue.contact.id && <PersonAvatar personId={otherValue.contact.id} />}
|
||||
<span>
|
||||
{getContactIdentifier(otherValue.contact, otherValue.contactAttributes)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
)}
|
||||
{surveyType === "app" && otherValue.contact && (
|
||||
<Link
|
||||
href={
|
||||
otherValue.contact.id
|
||||
? `/environments/${environmentId}/contacts/${otherValue.contact.id}`
|
||||
: { pathname: null }
|
||||
}
|
||||
className="m-2 grid h-16 grid-cols-2 items-center rounded-lg text-sm hover:bg-slate-100">
|
||||
<div className="ph-no-capture col-span-1 pl-4 font-medium text-slate-900">
|
||||
<span>{otherValue.value}</span>
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-1 flex items-center space-x-4 pl-6 font-medium text-slate-900">
|
||||
{otherValue.contact.id && <PersonAvatar personId={otherValue.contact.id} />}
|
||||
<span>
|
||||
{getContactIdentifier(otherValue.contact, otherValue.contactAttributes)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{visibleOtherResponses < result.others.length && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
||||
{t("common.load_more")}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{visibleOtherResponses < result.others.length && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
||||
{t("common.load_more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart, BarChartHorizontal } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
TI18nString,
|
||||
@@ -9,8 +11,12 @@ import {
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { HalfCircle, ProgressBar } from "@/modules/ui/components/progress-bar";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
|
||||
import { TooltipProvider } from "@/modules/ui/components/tooltip";
|
||||
import { convertFloatToNDecimal } from "../lib/utils";
|
||||
import { ClickableBarSegment } from "./ClickableBarSegment";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
import { SatisfactionIndicator } from "./SatisfactionIndicator";
|
||||
|
||||
interface NPSSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryNps;
|
||||
@@ -24,8 +30,20 @@ interface NPSSummaryProps {
|
||||
) => void;
|
||||
}
|
||||
|
||||
const calculateNPSOpacity = (rating: number): number => {
|
||||
if (rating <= 6) {
|
||||
return 0.3 + (rating / 6) * 0.3;
|
||||
}
|
||||
if (rating <= 8) {
|
||||
return 0.6 + ((rating - 6) / 2) * 0.2;
|
||||
}
|
||||
return 0.8 + ((rating - 8) / 2) * 0.2;
|
||||
};
|
||||
|
||||
export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
|
||||
|
||||
const applyFilter = (group: string) => {
|
||||
const filters = {
|
||||
promoters: {
|
||||
@@ -61,38 +79,112 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
|
||||
<button
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
key={group}
|
||||
onClick={() => applyFilter(group)}>
|
||||
<div
|
||||
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
|
||||
<div className="mr-8 flex space-x-1">
|
||||
<p
|
||||
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
|
||||
{group}
|
||||
</p>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(questionSummary[group]?.percentage, 2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{questionSummary[group]?.count}{" "}
|
||||
{questionSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
|
||||
</p>
|
||||
<QuestionSummaryHeader
|
||||
questionSummary={questionSummary}
|
||||
survey={survey}
|
||||
additionalInfo={
|
||||
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
||||
<SatisfactionIndicator percentage={questionSummary.promoters.percentage} />
|
||||
<div>
|
||||
{t("environments.surveys.summary.promoters")}:{" "}
|
||||
{convertFloatToNDecimal(questionSummary.promoters.percentage, 2)}%
|
||||
</div>
|
||||
<ProgressBar
|
||||
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
|
||||
progress={questionSummary[group]?.percentage / 100}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "aggregated" | "individual")}>
|
||||
<div className="flex justify-end px-4 md:px-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="h-4 w-4" />}>
|
||||
{t("environments.surveys.summary.aggregated")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
|
||||
{t("environments.surveys.summary.individual")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="aggregated" className="mt-4">
|
||||
<div className="px-4 pb-6 pt-4 md:px-6">
|
||||
<div className="space-y-5 text-sm md:text-base">
|
||||
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
|
||||
<button
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
key={group}
|
||||
onClick={() => applyFilter(group)}>
|
||||
<div
|
||||
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
|
||||
<div className="mr-8 flex space-x-1">
|
||||
<p
|
||||
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
|
||||
{group}
|
||||
</p>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(questionSummary[group]?.percentage, 2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{questionSummary[group]?.count}{" "}
|
||||
{questionSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar
|
||||
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
|
||||
progress={questionSummary[group]?.percentage / 100}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="individual" className="mt-4">
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="grid grid-cols-11 gap-2 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{questionSummary.choices.map((choice) => {
|
||||
const opacity = calculateNPSOpacity(choice.rating);
|
||||
|
||||
return (
|
||||
<ClickableBarSegment
|
||||
key={choice.rating}
|
||||
className="group flex cursor-pointer flex-col items-center"
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
questionSummary.question.id,
|
||||
questionSummary.question.headline,
|
||||
questionSummary.question.type,
|
||||
t("environments.surveys.summary.is_equal_to"),
|
||||
choice.rating.toString()
|
||||
)
|
||||
}>
|
||||
<div className="flex h-32 w-full flex-col items-center justify-end">
|
||||
<div
|
||||
className="bg-brand-dark w-full rounded-t-lg border border-slate-200 transition-all group-hover:brightness-110"
|
||||
style={{
|
||||
height: `${Math.max(choice.percentage, 2)}%`,
|
||||
opacity,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-center rounded-b-lg border border-t-0 border-slate-200 bg-slate-50 px-1 py-2">
|
||||
<div className="mb-1.5 text-xs font-medium text-slate-500">{choice.rating}</div>
|
||||
<div className="mb-1 flex items-center space-x-1">
|
||||
<div className="text-base font-semibold text-slate-700">{choice.count}</div>
|
||||
<div className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600">
|
||||
{convertFloatToNDecimal(choice.percentage, 1)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ClickableBarSegment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex justify-center pb-4 pt-4">
|
||||
<HalfCircle value={questionSummary.score} />
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
import { renderHyperlinkedContent } from "@/modules/analysis/utils";
|
||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
|
||||
@@ -35,59 +36,65 @@ export const OpenTextSummary = ({ questionSummary, environmentId, survey, locale
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||
<div className="border-t border-slate-200"></div>
|
||||
<div className="max-h-[40vh] overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader className="bg-slate-100">
|
||||
<TableRow>
|
||||
<TableHead>{t("common.user")}</TableHead>
|
||||
<TableHead>{t("common.response")}</TableHead>
|
||||
<TableHead>{t("common.time")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{questionSummary.samples.slice(0, visibleResponses).map((response) => (
|
||||
<TableRow key={response.id}>
|
||||
<TableCell>
|
||||
{response.contact ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.contact.id} />
|
||||
</div>
|
||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
</div>
|
||||
<p className="break-normal text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{typeof response.value === "string"
|
||||
? renderHyperlinkedContent(response.value)
|
||||
: response.value}
|
||||
</TableCell>
|
||||
<TableCell width={120}>
|
||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||
</TableCell>
|
||||
{questionSummary.samples.length === 0 ? (
|
||||
<div className="p-8">
|
||||
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[40vh] overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader className="bg-slate-100">
|
||||
<TableRow>
|
||||
<TableHead className="w-1/4">{t("common.user")}</TableHead>
|
||||
<TableHead className="w-2/4">{t("common.response")}</TableHead>
|
||||
<TableHead className="w-1/4">{t("common.time")}</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{visibleResponses < questionSummary.samples.length && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
||||
{t("common.load_more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{questionSummary.samples.slice(0, visibleResponses).map((response) => (
|
||||
<TableRow key={response.id}>
|
||||
<TableCell className="w-1/4">
|
||||
{response.contact ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.contact.id} />
|
||||
</div>
|
||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
</div>
|
||||
<p className="break-normal text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="w-2/4 font-medium">
|
||||
{typeof response.value === "string"
|
||||
? renderHyperlinkedContent(response.value)
|
||||
: response.value}
|
||||
</TableCell>
|
||||
<TableCell className="w-1/4">
|
||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{visibleResponses < questionSummary.samples.length && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
||||
{t("common.load_more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -57,8 +57,8 @@ export const QuestionSummaryHeader = ({
|
||||
{t("environments.surveys.edit.optional")}
|
||||
</div>
|
||||
)}
|
||||
<IdBadge id={questionSummary.question.id} />
|
||||
</div>
|
||||
<IdBadge id={questionSummary.question.id} label={t("common.question_id")} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { TSurveyRatingQuestion } from "@formbricks/types/surveys/types";
|
||||
import { RatingResponse } from "@/modules/ui/components/rating-response";
|
||||
|
||||
interface RatingScaleLegendProps {
|
||||
scale: TSurveyRatingQuestion["scale"];
|
||||
range: number;
|
||||
}
|
||||
|
||||
export const RatingScaleLegend = ({ scale, range }: RatingScaleLegendProps) => {
|
||||
return (
|
||||
<div className="mt-3 flex w-full items-start justify-between px-1">
|
||||
<div className="flex items-center space-x-1">
|
||||
<RatingResponse scale={scale} answer={1} range={range} addColors={false} variant="scale" />
|
||||
<span className="text-xs text-slate-500">1</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<span className="text-xs text-slate-500">{range}</span>
|
||||
<RatingResponse scale={scale} answer={range} range={range} addColors={false} variant="scale" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { BarChart, BarChartHorizontal, CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
TI18nString,
|
||||
@@ -11,9 +11,15 @@ import {
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { convertFloatToNDecimal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
||||
import { RatingResponse } from "@/modules/ui/components/rating-response";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
|
||||
import { TooltipProvider } from "@/modules/ui/components/tooltip";
|
||||
import { ClickableBarSegment } from "./ClickableBarSegment";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
import { RatingScaleLegend } from "./RatingScaleLegend";
|
||||
import { SatisfactionIndicator } from "./SatisfactionIndicator";
|
||||
|
||||
interface RatingSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryRating;
|
||||
@@ -29,6 +35,8 @@ interface RatingSummaryProps {
|
||||
|
||||
export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSummaryProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
|
||||
|
||||
const getIconBasedOnScale = useMemo(() => {
|
||||
const scale = questionSummary.question.scale;
|
||||
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
|
||||
@@ -42,52 +50,170 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
|
||||
questionSummary={questionSummary}
|
||||
survey={survey}
|
||||
additionalInfo={
|
||||
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
||||
{getIconBasedOnScale}
|
||||
<div>
|
||||
{t("environments.surveys.summary.overall")}: {questionSummary.average.toFixed(2)}
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
||||
{getIconBasedOnScale}
|
||||
<div>
|
||||
{t("environments.surveys.summary.overall")}: {questionSummary.average.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
||||
<SatisfactionIndicator percentage={questionSummary.csat.satisfiedPercentage} />
|
||||
<div>
|
||||
CSAT: {questionSummary.csat.satisfiedPercentage}%{" "}
|
||||
{t("environments.surveys.summary.satisfied")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{questionSummary.choices.map((result) => (
|
||||
<button
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
key={result.rating}
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
questionSummary.question.id,
|
||||
questionSummary.question.headline,
|
||||
questionSummary.question.type,
|
||||
t("environments.surveys.summary.is_equal_to"),
|
||||
result.rating.toString()
|
||||
)
|
||||
}>
|
||||
<div className="text flex justify-between px-2 pb-2">
|
||||
<div className="mr-8 flex items-center space-x-1">
|
||||
<div className="font-semibold text-slate-700">
|
||||
<RatingResponse
|
||||
scale={questionSummary.question.scale}
|
||||
answer={result.rating}
|
||||
range={questionSummary.question.range}
|
||||
addColors={questionSummary.question.isColorCodingEnabled}
|
||||
/>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "aggregated" | "individual")}>
|
||||
<div className="flex justify-end px-4 md:px-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="h-4 w-4" />}>
|
||||
{t("environments.surveys.summary.aggregated")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
|
||||
{t("environments.surveys.summary.individual")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="aggregated" className="mt-4">
|
||||
<div className="px-4 pb-6 pt-4 md:px-6">
|
||||
{questionSummary.responseCount === 0 ? (
|
||||
<>
|
||||
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
|
||||
<RatingScaleLegend
|
||||
scale={questionSummary.question.scale}
|
||||
range={questionSummary.question.range}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="flex h-12 w-full overflow-hidden rounded-t-lg border border-slate-200">
|
||||
{questionSummary.choices.map((result, index) => {
|
||||
if (result.percentage === 0) return null;
|
||||
|
||||
const range = questionSummary.question.range;
|
||||
const opacity = 0.3 + (result.rating / range) * 0.8;
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === questionSummary.choices.length - 1;
|
||||
|
||||
return (
|
||||
<ClickableBarSegment
|
||||
key={result.rating}
|
||||
className="relative h-full cursor-pointer transition-opacity hover:brightness-110"
|
||||
style={{
|
||||
width: `${result.percentage}%`,
|
||||
borderRight: isLast ? "none" : "1px solid rgb(226, 232, 240)",
|
||||
}}
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
questionSummary.question.id,
|
||||
questionSummary.question.headline,
|
||||
questionSummary.question.type,
|
||||
t("environments.surveys.summary.is_equal_to"),
|
||||
result.rating.toString()
|
||||
)
|
||||
}>
|
||||
<div
|
||||
className={`bg-brand-dark h-full ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
|
||||
style={{ opacity }}
|
||||
/>
|
||||
</ClickableBarSegment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<div className="flex w-full overflow-hidden rounded-b-lg border border-t-0 border-slate-200 bg-slate-50">
|
||||
{questionSummary.choices.map((result, index) => {
|
||||
if (result.percentage === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={result.rating}
|
||||
className="flex flex-col items-center justify-center py-2"
|
||||
style={{
|
||||
width: `${result.percentage}%`,
|
||||
borderRight:
|
||||
index < questionSummary.choices.length - 1
|
||||
? "1px solid rgb(226, 232, 240)"
|
||||
: "none",
|
||||
}}>
|
||||
<div className="mb-1 flex items-center justify-center">
|
||||
<RatingResponse
|
||||
scale={questionSummary.question.scale}
|
||||
answer={result.rating}
|
||||
range={questionSummary.question.range}
|
||||
addColors={false}
|
||||
variant="aggregated"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs font-medium text-slate-600">
|
||||
{convertFloatToNDecimal(result.percentage, 1)}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(result.percentage, 2)}%
|
||||
</p>
|
||||
<RatingScaleLegend
|
||||
scale={questionSummary.question.scale}
|
||||
range={questionSummary.question.range}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="individual" className="mt-4">
|
||||
<div className="px-4 pb-6 pt-4 md:px-6">
|
||||
<div className="space-y-5 text-sm md:text-base">
|
||||
{questionSummary.choices.map((result) => (
|
||||
<div key={result.rating}>
|
||||
<button
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
questionSummary.question.id,
|
||||
questionSummary.question.headline,
|
||||
questionSummary.question.type,
|
||||
t("environments.surveys.summary.is_equal_to"),
|
||||
result.rating.toString()
|
||||
)
|
||||
}>
|
||||
<div className="text flex justify-between px-2 pb-2">
|
||||
<div className="mr-8 flex items-center space-x-1">
|
||||
<div className="font-semibold text-slate-700">
|
||||
<RatingResponse
|
||||
scale={questionSummary.question.scale}
|
||||
answer={result.rating}
|
||||
range={questionSummary.question.range}
|
||||
addColors={questionSummary.question.isColorCodingEnabled}
|
||||
variant="individual"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(result.percentage, 2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{result.count} {result.count === 1 ? t("common.response") : t("common.responses")}
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{result.count} {result.count === 1 ? t("common.response") : t("common.responses")}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{questionSummary.dismissed && questionSummary.dismissed.count > 0 && (
|
||||
<div className="rounded-b-lg border-t bg-white px-6 py-4">
|
||||
<div key="dismissed">
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
interface SatisfactionIndicatorProps {
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export const SatisfactionIndicator = ({ percentage }: SatisfactionIndicatorProps) => {
|
||||
let colorClass = "";
|
||||
|
||||
if (percentage > 80) {
|
||||
colorClass = "bg-emerald-500";
|
||||
} else if (percentage >= 55) {
|
||||
colorClass = "bg-orange-500";
|
||||
} else {
|
||||
colorClass = "bg-rose-500";
|
||||
}
|
||||
|
||||
return <div className={`h-3 w-3 rounded-full ${colorClass}`} />;
|
||||
};
|
||||
@@ -3,15 +3,20 @@
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TI18nString, TSurveyQuestionId, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveySummary,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { EmptyAppSurveys } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys";
|
||||
import {
|
||||
SelectedFilterValue,
|
||||
useResponseFilter,
|
||||
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import { EmptyAppSurveys } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys";
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
||||
import { CTASummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CTASummary";
|
||||
import { CalSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary";
|
||||
import { ConsentSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary";
|
||||
@@ -29,7 +34,7 @@ import { RatingSummary } from "@/app/(app)/environments/[environmentId]/surveys/
|
||||
import { constructToastMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
|
||||
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader";
|
||||
import { AddressSummary } from "./AddressSummary";
|
||||
|
||||
@@ -54,7 +59,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
const filterObject: SelectedFilterValue = { ...selectedFilter };
|
||||
const value = {
|
||||
id: questionId,
|
||||
label: getLocalizedValue(label, "default"),
|
||||
label: getTextContent(getLocalizedValue(label, "default")),
|
||||
questionType: questionType,
|
||||
type: OptionsType.QUESTIONS,
|
||||
};
|
||||
@@ -103,12 +108,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
) : summary.length === 0 ? (
|
||||
<SkeletonLoader type="summary" />
|
||||
) : responseCount === 0 ? (
|
||||
<EmptySpaceFiller
|
||||
type="response"
|
||||
environment={environment}
|
||||
noWidgetRequired={survey.type === "link"}
|
||||
emptyMessage={t("environments.surveys.summary.no_responses_found")}
|
||||
/>
|
||||
<EmptyState text={t("environments.surveys.summary.no_responses_found")} />
|
||||
) : (
|
||||
summary.map((questionSummary) => {
|
||||
if (questionSummary.type === TSurveyQuestionTypeEnum.OpenText) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { cn } from "@/modules/ui/lib/utils";
|
||||
|
||||
interface SummaryMetadataProps {
|
||||
surveySummary: TSurveySummary["meta"];
|
||||
quotasCount: number;
|
||||
isLoading: boolean;
|
||||
tab: "dropOffs" | "quotas" | undefined;
|
||||
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | undefined>>;
|
||||
@@ -31,6 +32,7 @@ const formatTime = (ttc) => {
|
||||
|
||||
export const SummaryMetadata = ({
|
||||
surveySummary,
|
||||
quotasCount,
|
||||
isLoading,
|
||||
tab,
|
||||
setTab,
|
||||
@@ -61,7 +63,7 @@ export const SummaryMetadata = ({
|
||||
<div
|
||||
className={cn(
|
||||
`grid gap-4 sm:grid-cols-2 md:grid-cols-3 md:gap-x-2 lg:grid-cols-3 2xl:grid-cols-5`,
|
||||
isQuotasAllowed && "2xl:grid-cols-6"
|
||||
isQuotasAllowed && quotasCount > 0 && "2xl:grid-cols-6"
|
||||
)}>
|
||||
<StatCard
|
||||
label={t("environments.surveys.summary.impressions")}
|
||||
@@ -105,7 +107,7 @@ export const SummaryMetadata = ({
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
{isQuotasAllowed && (
|
||||
{isQuotasAllowed && quotasCount > 0 && (
|
||||
<InteractiveCard
|
||||
key="quotas"
|
||||
tab="quotas"
|
||||
|
||||
@@ -5,8 +5,8 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import { getSurveySummaryAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
||||
import ScrollToTop from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop";
|
||||
import { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs";
|
||||
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
||||
@@ -115,6 +115,7 @@ export const SummaryPage = ({
|
||||
<>
|
||||
<SummaryMetadata
|
||||
surveySummary={surveySummary.meta}
|
||||
quotasCount={surveySummary.quotas?.length ?? 0}
|
||||
isLoading={isLoading}
|
||||
tab={tab}
|
||||
setTab={setTab}
|
||||
|
||||
@@ -29,7 +29,6 @@ interface SurveyAnalysisCTAProps {
|
||||
user: TUser;
|
||||
publicDomain: string;
|
||||
responseCount: number;
|
||||
displayCount: number;
|
||||
segments: TSegment[];
|
||||
isContactsEnabled: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
@@ -48,7 +47,6 @@ export const SurveyAnalysisCTA = ({
|
||||
user,
|
||||
publicDomain,
|
||||
responseCount,
|
||||
displayCount,
|
||||
segments,
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
@@ -96,7 +94,6 @@ export const SurveyAnalysisCTA = ({
|
||||
const duplicateSurveyAndRoute = async (surveyId: string) => {
|
||||
setLoading(true);
|
||||
const duplicatedSurveyResponse = await copySurveyToOtherEnvironmentAction({
|
||||
environmentId: environment.id,
|
||||
surveyId: surveyId,
|
||||
targetEnvironmentId: environment.id,
|
||||
});
|
||||
@@ -170,7 +167,7 @@ export const SurveyAnalysisCTA = ({
|
||||
icon: ListRestart,
|
||||
tooltip: t("environments.surveys.summary.reset_survey"),
|
||||
onClick: () => setIsResetModalOpen(true),
|
||||
isVisible: !isReadOnly && (responseCount > 0 || displayCount > 0),
|
||||
isVisible: !isReadOnly,
|
||||
},
|
||||
{
|
||||
icon: SquarePenIcon,
|
||||
|
||||
@@ -2334,6 +2334,147 @@ describe("NPS question type tests", () => {
|
||||
// Score should be -100 since all valid responses are detractors
|
||||
expect(summary[0].score).toBe(-100);
|
||||
});
|
||||
|
||||
test("getQuestionSummary includes individual score breakdown in choices array for NPS", async () => {
|
||||
const question = {
|
||||
id: "nps-q1",
|
||||
type: TSurveyQuestionTypeEnum.NPS,
|
||||
headline: { default: "How likely are you to recommend us?" },
|
||||
required: true,
|
||||
lowerLabel: { default: "Not likely" },
|
||||
upperLabel: { default: "Very likely" },
|
||||
};
|
||||
|
||||
const survey = {
|
||||
id: "survey-1",
|
||||
questions: [question],
|
||||
languages: [],
|
||||
welcomeCard: { enabled: false },
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const responses = [
|
||||
{
|
||||
id: "r1",
|
||||
data: { "nps-q1": 0 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
data: { "nps-q1": 5 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r3",
|
||||
data: { "nps-q1": 7 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r4",
|
||||
data: { "nps-q1": 9 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r5",
|
||||
data: { "nps-q1": 10 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
];
|
||||
|
||||
const dropOff = [
|
||||
{ questionId: "nps-q1", impressions: 5, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getQuestionSummary(survey, responses, dropOff);
|
||||
|
||||
expect(summary[0].choices).toBeDefined();
|
||||
expect(summary[0].choices).toHaveLength(11); // Scores 0-10
|
||||
|
||||
// Verify specific scores
|
||||
const score0 = summary[0].choices.find((c: any) => c.rating === 0);
|
||||
expect(score0.count).toBe(1);
|
||||
expect(score0.percentage).toBe(20); // 1/5 * 100
|
||||
|
||||
const score5 = summary[0].choices.find((c: any) => c.rating === 5);
|
||||
expect(score5.count).toBe(1);
|
||||
expect(score5.percentage).toBe(20);
|
||||
|
||||
const score7 = summary[0].choices.find((c: any) => c.rating === 7);
|
||||
expect(score7.count).toBe(1);
|
||||
expect(score7.percentage).toBe(20);
|
||||
|
||||
const score9 = summary[0].choices.find((c: any) => c.rating === 9);
|
||||
expect(score9.count).toBe(1);
|
||||
expect(score9.percentage).toBe(20);
|
||||
|
||||
const score10 = summary[0].choices.find((c: any) => c.rating === 10);
|
||||
expect(score10.count).toBe(1);
|
||||
expect(score10.percentage).toBe(20);
|
||||
|
||||
// Verify scores with no responses have 0 count
|
||||
const score1 = summary[0].choices.find((c: any) => c.rating === 1);
|
||||
expect(score1.count).toBe(0);
|
||||
expect(score1.percentage).toBe(0);
|
||||
});
|
||||
|
||||
test("getQuestionSummary handles NPS individual score breakdown with no responses", async () => {
|
||||
const question = {
|
||||
id: "nps-q1",
|
||||
type: TSurveyQuestionTypeEnum.NPS,
|
||||
headline: { default: "How likely are you to recommend us?" },
|
||||
required: true,
|
||||
lowerLabel: { default: "Not likely" },
|
||||
upperLabel: { default: "Very likely" },
|
||||
};
|
||||
|
||||
const survey = {
|
||||
id: "survey-1",
|
||||
questions: [question],
|
||||
languages: [],
|
||||
welcomeCard: { enabled: false },
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const responses: any[] = [];
|
||||
|
||||
const dropOff = [
|
||||
{ questionId: "nps-q1", impressions: 0, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getQuestionSummary(survey, responses, dropOff);
|
||||
|
||||
expect(summary[0].choices).toBeDefined();
|
||||
expect(summary[0].choices).toHaveLength(11); // Scores 0-10
|
||||
|
||||
// All scores should have 0 count and percentage
|
||||
summary[0].choices.forEach((choice: any) => {
|
||||
expect(choice.count).toBe(0);
|
||||
expect(choice.percentage).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rating question type tests", () => {
|
||||
@@ -2557,6 +2698,549 @@ describe("Rating question type tests", () => {
|
||||
// Verify dismissed is 0
|
||||
expect(summary[0].dismissed.count).toBe(0);
|
||||
});
|
||||
|
||||
test("getQuestionSummary calculates CSAT for Rating question with range 3", async () => {
|
||||
const question = {
|
||||
id: "rating-q1",
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
headline: { default: "Rate our service" },
|
||||
required: true,
|
||||
scale: "number",
|
||||
range: 3,
|
||||
lowerLabel: { default: "Poor" },
|
||||
upperLabel: { default: "Excellent" },
|
||||
};
|
||||
|
||||
const survey = {
|
||||
id: "survey-1",
|
||||
questions: [question],
|
||||
languages: [],
|
||||
welcomeCard: { enabled: false },
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const responses = [
|
||||
{
|
||||
id: "r1",
|
||||
data: { "rating-q1": 3 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
data: { "rating-q1": 2 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r3",
|
||||
data: { "rating-q1": 3 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
];
|
||||
|
||||
const dropOff = [
|
||||
{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getQuestionSummary(survey, responses, dropOff);
|
||||
|
||||
// Range 3: satisfied = score 3
|
||||
// 2 out of 3 responses are satisfied (score 3)
|
||||
expect(summary[0].csat.satisfiedCount).toBe(2);
|
||||
expect(summary[0].csat.satisfiedPercentage).toBe(67); // Math.round((2/3) * 100)
|
||||
});
|
||||
|
||||
test("getQuestionSummary calculates CSAT for Rating question with range 4", async () => {
|
||||
const question = {
|
||||
id: "rating-q1",
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
headline: { default: "Rate our service" },
|
||||
required: true,
|
||||
scale: "number",
|
||||
range: 4,
|
||||
lowerLabel: { default: "Poor" },
|
||||
upperLabel: { default: "Excellent" },
|
||||
};
|
||||
|
||||
const survey = {
|
||||
id: "survey-1",
|
||||
questions: [question],
|
||||
languages: [],
|
||||
welcomeCard: { enabled: false },
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const responses = [
|
||||
{
|
||||
id: "r1",
|
||||
data: { "rating-q1": 3 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
data: { "rating-q1": 4 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r3",
|
||||
data: { "rating-q1": 2 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
];
|
||||
|
||||
const dropOff = [
|
||||
{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getQuestionSummary(survey, responses, dropOff);
|
||||
|
||||
// Range 4: satisfied = scores 3-4
|
||||
// 2 out of 3 responses are satisfied (scores 3 and 4)
|
||||
expect(summary[0].csat.satisfiedCount).toBe(2);
|
||||
expect(summary[0].csat.satisfiedPercentage).toBe(67);
|
||||
});
|
||||
|
||||
test("getQuestionSummary calculates CSAT for Rating question with range 5", async () => {
|
||||
const question = {
|
||||
id: "rating-q1",
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
headline: { default: "Rate our service" },
|
||||
required: true,
|
||||
scale: "number",
|
||||
range: 5,
|
||||
lowerLabel: { default: "Poor" },
|
||||
upperLabel: { default: "Excellent" },
|
||||
};
|
||||
|
||||
const survey = {
|
||||
id: "survey-1",
|
||||
questions: [question],
|
||||
languages: [],
|
||||
welcomeCard: { enabled: false },
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const responses = [
|
||||
{
|
||||
id: "r1",
|
||||
data: { "rating-q1": 4 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
data: { "rating-q1": 5 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r3",
|
||||
data: { "rating-q1": 3 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
];
|
||||
|
||||
const dropOff = [
|
||||
{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getQuestionSummary(survey, responses, dropOff);
|
||||
|
||||
// Range 5: satisfied = scores 4-5
|
||||
// 2 out of 3 responses are satisfied (scores 4 and 5)
|
||||
expect(summary[0].csat.satisfiedCount).toBe(2);
|
||||
expect(summary[0].csat.satisfiedPercentage).toBe(67);
|
||||
});
|
||||
|
||||
test("getQuestionSummary calculates CSAT for Rating question with range 6", async () => {
|
||||
const question = {
|
||||
id: "rating-q1",
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
headline: { default: "Rate our service" },
|
||||
required: true,
|
||||
scale: "number",
|
||||
range: 6,
|
||||
lowerLabel: { default: "Poor" },
|
||||
upperLabel: { default: "Excellent" },
|
||||
};
|
||||
|
||||
const survey = {
|
||||
id: "survey-1",
|
||||
questions: [question],
|
||||
languages: [],
|
||||
welcomeCard: { enabled: false },
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const responses = [
|
||||
{
|
||||
id: "r1",
|
||||
data: { "rating-q1": 5 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
data: { "rating-q1": 6 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r3",
|
||||
data: { "rating-q1": 4 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
];
|
||||
|
||||
const dropOff = [
|
||||
{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getQuestionSummary(survey, responses, dropOff);
|
||||
|
||||
// Range 6: satisfied = scores 5-6
|
||||
// 2 out of 3 responses are satisfied (scores 5 and 6)
|
||||
expect(summary[0].csat.satisfiedCount).toBe(2);
|
||||
expect(summary[0].csat.satisfiedPercentage).toBe(67);
|
||||
});
|
||||
|
||||
test("getQuestionSummary calculates CSAT for Rating question with range 7", async () => {
|
||||
const question = {
|
||||
id: "rating-q1",
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
headline: { default: "Rate our service" },
|
||||
required: true,
|
||||
scale: "number",
|
||||
range: 7,
|
||||
lowerLabel: { default: "Poor" },
|
||||
upperLabel: { default: "Excellent" },
|
||||
};
|
||||
|
||||
const survey = {
|
||||
id: "survey-1",
|
||||
questions: [question],
|
||||
languages: [],
|
||||
welcomeCard: { enabled: false },
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const responses = [
|
||||
{
|
||||
id: "r1",
|
||||
data: { "rating-q1": 6 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
data: { "rating-q1": 7 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r3",
|
||||
data: { "rating-q1": 5 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
];
|
||||
|
||||
const dropOff = [
|
||||
{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getQuestionSummary(survey, responses, dropOff);
|
||||
|
||||
// Range 7: satisfied = scores 6-7
|
||||
// 2 out of 3 responses are satisfied (scores 6 and 7)
|
||||
expect(summary[0].csat.satisfiedCount).toBe(2);
|
||||
expect(summary[0].csat.satisfiedPercentage).toBe(67);
|
||||
});
|
||||
|
||||
test("getQuestionSummary calculates CSAT for Rating question with range 10", async () => {
|
||||
const question = {
|
||||
id: "rating-q1",
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
headline: { default: "Rate our service" },
|
||||
required: true,
|
||||
scale: "number",
|
||||
range: 10,
|
||||
lowerLabel: { default: "Poor" },
|
||||
upperLabel: { default: "Excellent" },
|
||||
};
|
||||
|
||||
const survey = {
|
||||
id: "survey-1",
|
||||
questions: [question],
|
||||
languages: [],
|
||||
welcomeCard: { enabled: false },
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const responses = [
|
||||
{
|
||||
id: "r1",
|
||||
data: { "rating-q1": 8 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
data: { "rating-q1": 9 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r3",
|
||||
data: { "rating-q1": 10 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r4",
|
||||
data: { "rating-q1": 7 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
];
|
||||
|
||||
const dropOff = [
|
||||
{ questionId: "rating-q1", impressions: 4, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getQuestionSummary(survey, responses, dropOff);
|
||||
|
||||
// Range 10: satisfied = scores 8-10
|
||||
// 3 out of 4 responses are satisfied (scores 8, 9, 10)
|
||||
expect(summary[0].csat.satisfiedCount).toBe(3);
|
||||
expect(summary[0].csat.satisfiedPercentage).toBe(75);
|
||||
});
|
||||
|
||||
test("getQuestionSummary calculates CSAT for Rating question with all satisfied", async () => {
|
||||
const question = {
|
||||
id: "rating-q1",
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
headline: { default: "Rate our service" },
|
||||
required: true,
|
||||
scale: "number",
|
||||
range: 5,
|
||||
lowerLabel: { default: "Poor" },
|
||||
upperLabel: { default: "Excellent" },
|
||||
};
|
||||
|
||||
const survey = {
|
||||
id: "survey-1",
|
||||
questions: [question],
|
||||
languages: [],
|
||||
welcomeCard: { enabled: false },
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const responses = [
|
||||
{
|
||||
id: "r1",
|
||||
data: { "rating-q1": 4 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
data: { "rating-q1": 5 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
];
|
||||
|
||||
const dropOff = [
|
||||
{ questionId: "rating-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getQuestionSummary(survey, responses, dropOff);
|
||||
|
||||
// Range 5: satisfied = scores 4-5
|
||||
// All 2 responses are satisfied
|
||||
expect(summary[0].csat.satisfiedCount).toBe(2);
|
||||
expect(summary[0].csat.satisfiedPercentage).toBe(100);
|
||||
});
|
||||
|
||||
test("getQuestionSummary calculates CSAT for Rating question with none satisfied", async () => {
|
||||
const question = {
|
||||
id: "rating-q1",
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
headline: { default: "Rate our service" },
|
||||
required: true,
|
||||
scale: "number",
|
||||
range: 5,
|
||||
lowerLabel: { default: "Poor" },
|
||||
upperLabel: { default: "Excellent" },
|
||||
};
|
||||
|
||||
const survey = {
|
||||
id: "survey-1",
|
||||
questions: [question],
|
||||
languages: [],
|
||||
welcomeCard: { enabled: false },
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const responses = [
|
||||
{
|
||||
id: "r1",
|
||||
data: { "rating-q1": 1 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
data: { "rating-q1": 2 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r3",
|
||||
data: { "rating-q1": 3 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
];
|
||||
|
||||
const dropOff = [
|
||||
{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getQuestionSummary(survey, responses, dropOff);
|
||||
|
||||
// Range 5: satisfied = scores 4-5
|
||||
// None of the responses are satisfied (all are 1, 2, or 3)
|
||||
expect(summary[0].csat.satisfiedCount).toBe(0);
|
||||
expect(summary[0].csat.satisfiedPercentage).toBe(0);
|
||||
});
|
||||
|
||||
test("getQuestionSummary calculates CSAT for Rating question with no responses", async () => {
|
||||
const question = {
|
||||
id: "rating-q1",
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
headline: { default: "Rate our service" },
|
||||
required: true,
|
||||
scale: "number",
|
||||
range: 5,
|
||||
lowerLabel: { default: "Poor" },
|
||||
upperLabel: { default: "Excellent" },
|
||||
};
|
||||
|
||||
const survey = {
|
||||
id: "survey-1",
|
||||
questions: [question],
|
||||
languages: [],
|
||||
welcomeCard: { enabled: false },
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const responses: any[] = [];
|
||||
|
||||
const dropOff = [
|
||||
{ questionId: "rating-q1", impressions: 0, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getQuestionSummary(survey, responses, dropOff);
|
||||
|
||||
expect(summary[0].csat.satisfiedCount).toBe(0);
|
||||
expect(summary[0].csat.satisfiedPercentage).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PictureSelection question type tests", () => {
|
||||
|
||||
@@ -532,13 +532,31 @@ export const getQuestionSummary = async (
|
||||
|
||||
Object.entries(choiceCountMap).forEach(([label, count]) => {
|
||||
values.push({
|
||||
rating: parseInt(label),
|
||||
rating: Number.parseInt(label),
|
||||
count,
|
||||
percentage:
|
||||
totalResponseCount > 0 ? convertFloatTo2Decimal((count / totalResponseCount) * 100) : 0,
|
||||
});
|
||||
});
|
||||
|
||||
// Calculate CSAT based on range
|
||||
let satisfiedCount = 0;
|
||||
if (range === 3) {
|
||||
satisfiedCount = choiceCountMap[3] || 0;
|
||||
} else if (range === 4) {
|
||||
satisfiedCount = (choiceCountMap[3] || 0) + (choiceCountMap[4] || 0);
|
||||
} else if (range === 5) {
|
||||
satisfiedCount = (choiceCountMap[4] || 0) + (choiceCountMap[5] || 0);
|
||||
} else if (range === 6) {
|
||||
satisfiedCount = (choiceCountMap[5] || 0) + (choiceCountMap[6] || 0);
|
||||
} else if (range === 7) {
|
||||
satisfiedCount = (choiceCountMap[6] || 0) + (choiceCountMap[7] || 0);
|
||||
} else if (range === 10) {
|
||||
satisfiedCount = (choiceCountMap[8] || 0) + (choiceCountMap[9] || 0) + (choiceCountMap[10] || 0);
|
||||
}
|
||||
const satisfiedPercentage =
|
||||
totalResponseCount > 0 ? Math.round((satisfiedCount / totalResponseCount) * 100) : 0;
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
@@ -548,6 +566,10 @@ export const getQuestionSummary = async (
|
||||
dismissed: {
|
||||
count: dismissed,
|
||||
},
|
||||
csat: {
|
||||
satisfiedCount,
|
||||
satisfiedPercentage,
|
||||
},
|
||||
});
|
||||
|
||||
values = [];
|
||||
@@ -563,10 +585,17 @@ export const getQuestionSummary = async (
|
||||
score: 0,
|
||||
};
|
||||
|
||||
// Track individual score counts (0-10)
|
||||
const scoreCountMap: Record<number, number> = {};
|
||||
for (let i = 0; i <= 10; i++) {
|
||||
scoreCountMap[i] = 0;
|
||||
}
|
||||
|
||||
responses.forEach((response) => {
|
||||
const value = response.data[question.id];
|
||||
if (typeof value === "number") {
|
||||
data.total++;
|
||||
scoreCountMap[value]++;
|
||||
if (value >= 9) {
|
||||
data.promoters++;
|
||||
} else if (value >= 7) {
|
||||
@@ -585,6 +614,13 @@ export const getQuestionSummary = async (
|
||||
? convertFloatTo2Decimal(((data.promoters - data.detractors) / data.total) * 100)
|
||||
: 0;
|
||||
|
||||
// Build choices array with individual score breakdown
|
||||
const choices = Object.entries(scoreCountMap).map(([rating, count]) => ({
|
||||
rating: Number.parseInt(rating),
|
||||
count,
|
||||
percentage: data.total > 0 ? convertFloatTo2Decimal((count / data.total) * 100) : 0,
|
||||
}));
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
@@ -607,6 +643,7 @@ export const getQuestionSummary = async (
|
||||
count: data.dismissed,
|
||||
percentage: data.total > 0 ? convertFloatTo2Decimal((data.dismissed / data.total) * 100) : 0,
|
||||
},
|
||||
choices,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -70,7 +70,6 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
user={user}
|
||||
publicDomain={publicDomain}
|
||||
responseCount={initialSurveySummary?.meta.totalResponses ?? 0}
|
||||
displayCount={initialSurveySummary?.meta.displayCount ?? 0}
|
||||
segments={segments}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
subYears,
|
||||
} from "date-fns";
|
||||
import { TFunction } from "i18next";
|
||||
import { ArrowDownToLineIcon, ChevronDown, ChevronUp, DownloadIcon, Loader2Icon } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -25,7 +25,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
DateRange,
|
||||
useResponseFilter,
|
||||
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
||||
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
||||
import { downloadResponsesFile } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/utils";
|
||||
import { getFormattedFilters, getTodayDate } from "@/app/lib/surveys/surveys";
|
||||
@@ -37,8 +37,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { cn } from "@/modules/ui/lib/utils";
|
||||
import { ResponseFilter } from "./ResponseFilter";
|
||||
import { PopoverTriggerButton, ResponseFilter } from "./ResponseFilter";
|
||||
|
||||
enum DateSelected {
|
||||
FROM = "common.from",
|
||||
@@ -137,6 +136,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
const [selectingDate, setSelectingDate] = useState<DateSelected>(DateSelected.FROM);
|
||||
const [isDatePickerOpen, setIsDatePickerOpen] = useState<boolean>(false);
|
||||
const [isFilterDropDownOpen, setIsFilterDropDownOpen] = useState<boolean>(false);
|
||||
const [isDownloadDropDownOpen, setIsDownloadDropDownOpen] = useState<boolean>(false);
|
||||
const [hoveredRange, setHoveredRange] = useState<DateRange | null>(null);
|
||||
const [isDownloading, setIsDownloading] = useState<boolean>(false);
|
||||
|
||||
@@ -270,201 +270,179 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
|
||||
useClickOutside(datePickerRef, () => handleDatePickerClose());
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex justify-between">
|
||||
<div className="flex justify-stretch gap-x-1.5">
|
||||
<ResponseFilter survey={survey} />
|
||||
<DropdownMenu
|
||||
onOpenChange={(value) => {
|
||||
value && handleDatePickerClose();
|
||||
setIsFilterDropDownOpen(value);
|
||||
}}>
|
||||
<DropdownMenuTrigger>
|
||||
<div className="flex min-w-[8rem] items-center justify-between rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:min-w-[11rem] sm:px-6 sm:py-3">
|
||||
<span className="text-sm text-slate-700">
|
||||
{filterRange === getFilterDropDownLabels(t).CUSTOM_RANGE
|
||||
? `${dateRange?.from ? format(dateRange?.from, "dd LLL") : "Select first date"} - ${
|
||||
dateRange?.to ? format(dateRange.to, "dd LLL") : "Select last date"
|
||||
}`
|
||||
: filterRange}
|
||||
</span>
|
||||
{isFilterDropDownOpen ? (
|
||||
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).ALL_TIME);
|
||||
setDateRange({ from: undefined, to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).ALL_TIME}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_7_DAYS);
|
||||
setDateRange({ from: startOfDay(subDays(new Date(), 7)), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_7_DAYS}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_30_DAYS);
|
||||
setDateRange({ from: startOfDay(subDays(new Date(), 30)), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_30_DAYS}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_MONTH);
|
||||
setDateRange({ from: startOfMonth(new Date()), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_MONTH}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_MONTH);
|
||||
setDateRange({
|
||||
from: startOfMonth(subMonths(new Date(), 1)),
|
||||
to: endOfMonth(subMonths(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_MONTH}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_QUARTER);
|
||||
setDateRange({ from: startOfQuarter(new Date()), to: endOfQuarter(getTodayDate()) });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_QUARTER}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_QUARTER);
|
||||
setDateRange({
|
||||
from: startOfQuarter(subQuarters(new Date(), 1)),
|
||||
to: endOfQuarter(subQuarters(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_QUARTER}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_6_MONTHS);
|
||||
setDateRange({
|
||||
from: startOfMonth(subMonths(new Date(), 6)),
|
||||
to: endOfMonth(getTodayDate()),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_6_MONTHS}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_YEAR);
|
||||
setDateRange({ from: startOfYear(new Date()), to: endOfYear(getTodayDate()) });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_YEAR}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_YEAR);
|
||||
setDateRange({
|
||||
from: startOfYear(subYears(new Date(), 1)),
|
||||
to: endOfYear(subYears(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_YEAR}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setIsDatePickerOpen(true);
|
||||
setFilterRange(getFilterDropDownLabels(t).CUSTOM_RANGE);
|
||||
setSelectingDate(DateSelected.FROM);
|
||||
}}>
|
||||
<p className="text-sm text-slate-700 hover:ring-0">
|
||||
{getFilterDropDownLabels(t).CUSTOM_RANGE}
|
||||
</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu
|
||||
onOpenChange={(value) => {
|
||||
value && handleDatePickerClose();
|
||||
}}>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
className={cn(
|
||||
"focus:bg-muted cursor-pointer outline-none",
|
||||
isDownloading && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
disabled={isDownloading}
|
||||
data-testid="fb__custom-filter-download-responses-button">
|
||||
<div className="min-w-auto h-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex sm:px-6 sm:py-3">
|
||||
<div className="hidden w-full items-center justify-between sm:flex">
|
||||
<span className="text-sm text-slate-700">{t("common.download")}</span>
|
||||
{isDownloading ? (
|
||||
<Loader2Icon className="ml-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowDownToLineIcon className="ml-2 h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<DownloadIcon className="block h-4 sm:hidden" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<div className="relative flex justify-between">
|
||||
<div className="flex justify-stretch gap-x-1.5">
|
||||
<ResponseFilter survey={survey} />
|
||||
<DropdownMenu
|
||||
onOpenChange={(value) => {
|
||||
value && handleDatePickerClose();
|
||||
setIsFilterDropDownOpen(value);
|
||||
}}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<PopoverTriggerButton isOpen={isFilterDropDownOpen}>
|
||||
{filterRange === getFilterDropDownLabels(t).CUSTOM_RANGE
|
||||
? `${dateRange?.from ? format(dateRange?.from, "dd LLL") : "Select first date"} - ${
|
||||
dateRange?.to ? format(dateRange.to, "dd LLL") : "Select last date"
|
||||
}`
|
||||
: filterRange}
|
||||
</PopoverTriggerButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).ALL_TIME);
|
||||
setDateRange({ from: undefined, to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).ALL_TIME}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_7_DAYS);
|
||||
setDateRange({ from: startOfDay(subDays(new Date(), 7)), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_7_DAYS}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_30_DAYS);
|
||||
setDateRange({ from: startOfDay(subDays(new Date(), 30)), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_30_DAYS}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_MONTH);
|
||||
setDateRange({ from: startOfMonth(new Date()), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_MONTH}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_MONTH);
|
||||
setDateRange({
|
||||
from: startOfMonth(subMonths(new Date(), 1)),
|
||||
to: endOfMonth(subMonths(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_MONTH}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_QUARTER);
|
||||
setDateRange({ from: startOfQuarter(new Date()), to: endOfQuarter(getTodayDate()) });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_QUARTER}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_QUARTER);
|
||||
setDateRange({
|
||||
from: startOfQuarter(subQuarters(new Date(), 1)),
|
||||
to: endOfQuarter(subQuarters(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_QUARTER}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_6_MONTHS);
|
||||
setDateRange({
|
||||
from: startOfMonth(subMonths(new Date(), 6)),
|
||||
to: endOfMonth(getTodayDate()),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_6_MONTHS}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_YEAR);
|
||||
setDateRange({ from: startOfYear(new Date()), to: endOfYear(getTodayDate()) });
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_YEAR}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_YEAR);
|
||||
setDateRange({
|
||||
from: startOfYear(subYears(new Date(), 1)),
|
||||
to: endOfYear(subYears(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_YEAR}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setIsDatePickerOpen(true);
|
||||
setFilterRange(getFilterDropDownLabels(t).CUSTOM_RANGE);
|
||||
setSelectingDate(DateSelected.FROM);
|
||||
}}>
|
||||
<p className="text-sm text-slate-700 hover:ring-0">{getFilterDropDownLabels(t).CUSTOM_RANGE}</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu
|
||||
onOpenChange={(value) => {
|
||||
value && handleDatePickerClose();
|
||||
setIsDownloadDropDownOpen(value);
|
||||
}}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<PopoverTriggerButton isOpen={isDownloadDropDownOpen} disabled={isDownloading}>
|
||||
<span className="flex items-center gap-2">
|
||||
{t("common.download")}
|
||||
{isDownloading && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||
</span>
|
||||
</PopoverTriggerButton>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
data-testid="fb__custom-filter-download-all-csv"
|
||||
onClick={async () => {
|
||||
await handleDownloadResponses(FilterDownload.ALL, "csv");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_csv")}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-testid="fb__custom-filter-download-all-xlsx"
|
||||
onClick={async () => {
|
||||
await handleDownloadResponses(FilterDownload.ALL, "xlsx");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_excel")}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-testid="fb__custom-filter-download-filtered-csv"
|
||||
onClick={async () => {
|
||||
await handleDownloadResponses(FilterDownload.FILTER, "csv");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_csv")}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-testid="fb__custom-filter-download-filtered-xlsx"
|
||||
onClick={async () => {
|
||||
await handleDownloadResponses(FilterDownload.FILTER, "xlsx");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_excel")}</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{isDatePickerOpen && (
|
||||
<div ref={datePickerRef} className="absolute top-full z-50 my-2 rounded-md border bg-white">
|
||||
<Calendar
|
||||
autoFocus
|
||||
mode="range"
|
||||
defaultMonth={dateRange?.from}
|
||||
selected={hoveredRange ? hoveredRange : dateRange}
|
||||
numberOfMonths={2}
|
||||
onDayClick={(date) => handleDateChange(date)}
|
||||
onDayMouseEnter={handleDateHoveredChange}
|
||||
onDayMouseLeave={() => setHoveredRange(null)}
|
||||
classNames={{
|
||||
day_today: "hover:bg-slate-200 bg-white",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
data-testid="fb__custom-filter-download-all-csv"
|
||||
onClick={async () => {
|
||||
await handleDownloadResponses(FilterDownload.ALL, "csv");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_csv")}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-testid="fb__custom-filter-download-all-xlsx"
|
||||
onClick={async () => {
|
||||
await handleDownloadResponses(FilterDownload.ALL, "xlsx");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_excel")}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-testid="fb__custom-filter-download-filtered-csv"
|
||||
onClick={async () => {
|
||||
await handleDownloadResponses(FilterDownload.FILTER, "csv");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_csv")}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-testid="fb__custom-filter-download-filtered-xlsx"
|
||||
onClick={async () => {
|
||||
await handleDownloadResponses(FilterDownload.FILTER, "xlsx");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_excel")}</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</>
|
||||
{isDatePickerOpen && (
|
||||
<div ref={datePickerRef} className="absolute top-full z-50 my-2 rounded-md border bg-white">
|
||||
<Calendar
|
||||
autoFocus
|
||||
mode="range"
|
||||
defaultMonth={dateRange?.from}
|
||||
selected={hoveredRange || dateRange}
|
||||
numberOfMonths={2}
|
||||
onDayClick={(date) => handleDateChange(date)}
|
||||
onDayMouseEnter={handleDateHoveredChange}
|
||||
onDayMouseLeave={() => setHoveredRange(null)}
|
||||
classNames={{
|
||||
day_today: "hover:bg-slate-200 bg-white",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
|
||||
import clsx from "clsx";
|
||||
import { ChevronDown, ChevronUp, X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TI18nString, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/modules/ui/components/command";
|
||||
@@ -24,8 +26,8 @@ import {
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
|
||||
type QuestionFilterComboBoxProps = {
|
||||
filterOptions: string[] | undefined;
|
||||
filterComboBoxOptions: string[] | undefined;
|
||||
filterOptions: (string | TI18nString)[] | undefined;
|
||||
filterComboBoxOptions: (string | TI18nString)[] | undefined;
|
||||
filterValue: string | undefined;
|
||||
filterComboBoxValue: string | string[] | undefined;
|
||||
onChangeFilterValue: (o: string) => void;
|
||||
@@ -48,196 +50,245 @@ export const QuestionFilterComboBox = ({
|
||||
disabled = false,
|
||||
fieldId,
|
||||
}: QuestionFilterComboBoxProps) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [openFilterValue, setOpenFilterValue] = React.useState<boolean>(false);
|
||||
const commandRef = React.useRef(null);
|
||||
const [searchQuery, setSearchQuery] = React.useState<string>("");
|
||||
const defaultLanguageCode = "default";
|
||||
useClickOutside(commandRef, () => setOpen(false));
|
||||
const [open, setOpen] = useState(false);
|
||||
const commandRef = useRef(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const { t } = useTranslation();
|
||||
// multiple when question type is multi selection
|
||||
const isMultiple =
|
||||
type === TSurveyQuestionTypeEnum.MultipleChoiceMulti ||
|
||||
type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
|
||||
type === TSurveyQuestionTypeEnum.PictureSelection ||
|
||||
(type === TSurveyQuestionTypeEnum.NPS && filterValue === "Includes either");
|
||||
|
||||
// when question type is multi selection so we remove the option from the options which has been already selected
|
||||
const options = isMultiple
|
||||
? filterComboBoxOptions?.filter(
|
||||
(o) =>
|
||||
!filterComboBoxValue?.includes(
|
||||
typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o
|
||||
)
|
||||
)
|
||||
: filterComboBoxOptions;
|
||||
useClickOutside(commandRef, () => setOpen(false));
|
||||
|
||||
// disable the combo box for selection of value when question type is nps or rating and selected value is submitted or skipped
|
||||
const defaultLanguageCode = "default";
|
||||
|
||||
// Check if multiple selection is allowed
|
||||
const isMultiple = useMemo(
|
||||
() =>
|
||||
type === TSurveyQuestionTypeEnum.MultipleChoiceMulti ||
|
||||
type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
|
||||
type === TSurveyQuestionTypeEnum.PictureSelection ||
|
||||
(type === TSurveyQuestionTypeEnum.NPS && filterValue === "Includes either"),
|
||||
[type, filterValue]
|
||||
);
|
||||
|
||||
// Filter out already selected options for multi-select
|
||||
const options = useMemo(() => {
|
||||
if (!isMultiple) return filterComboBoxOptions;
|
||||
|
||||
return filterComboBoxOptions?.filter((o) => {
|
||||
const optionValue = typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
return !filterComboBoxValue?.includes(optionValue);
|
||||
});
|
||||
}, [isMultiple, filterComboBoxOptions, filterComboBoxValue, defaultLanguageCode]);
|
||||
|
||||
// Disable combo box for NPS/Rating when Submitted/Skipped
|
||||
const isDisabledComboBox =
|
||||
(type === TSurveyQuestionTypeEnum.NPS || type === TSurveyQuestionTypeEnum.Rating) &&
|
||||
(filterValue === "Submitted" || filterValue === "Skipped");
|
||||
|
||||
// Check if this is a URL field with string comparison operations that require text input
|
||||
// Check if this is a text input field (URL meta field)
|
||||
const isTextInputField = type === OptionsType.META && fieldId === "url";
|
||||
|
||||
const filteredOptions = options?.filter((o) =>
|
||||
(typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o)
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase())
|
||||
// Filter options based on search query
|
||||
const filteredOptions = useMemo(
|
||||
() =>
|
||||
options?.filter((o) => {
|
||||
const optionValue =
|
||||
typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
return optionValue.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
}),
|
||||
[options, searchQuery, defaultLanguageCode]
|
||||
);
|
||||
|
||||
const filterComboBoxItem = !Array.isArray(filterComboBoxValue) ? (
|
||||
<p className="text-slate-600">{filterComboBoxValue}</p>
|
||||
) : (
|
||||
<div className="no-scrollbar flex w-[7rem] gap-3 overflow-auto md:w-[10rem] lg:w-[18rem]">
|
||||
{typeof filterComboBoxValue !== "string" &&
|
||||
filterComboBoxValue?.map((o, index) => (
|
||||
<button
|
||||
key={`${o}-${index}`}
|
||||
type="button"
|
||||
onClick={() => handleRemoveMultiSelect(filterComboBoxValue.filter((i) => i !== o))}
|
||||
className="w-30 flex items-center whitespace-nowrap bg-slate-100 px-2 text-slate-600">
|
||||
{o}
|
||||
<X width={14} height={14} className="ml-2" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
const handleCommandItemSelect = (o: string | TI18nString) => {
|
||||
const value = typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
|
||||
if (isMultiple) {
|
||||
const newValue = Array.isArray(filterComboBoxValue) ? [...filterComboBoxValue, value] : [value];
|
||||
onChangeFilterComboBoxValue(newValue);
|
||||
return;
|
||||
}
|
||||
|
||||
onChangeFilterComboBoxValue(value);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const isComboBoxDisabled = disabled || isDisabledComboBox || !filterValue;
|
||||
|
||||
const handleOpenDropdown = () => {
|
||||
if (isComboBoxDisabled) return;
|
||||
setOpen(true);
|
||||
};
|
||||
const ChevronIcon = open ? ChevronUp : ChevronDown;
|
||||
|
||||
// Helper to filter out a specific value from the array
|
||||
const getFilteredValues = (valueToRemove: string): string[] => {
|
||||
if (!Array.isArray(filterComboBoxValue)) return [];
|
||||
return filterComboBoxValue.filter((i) => i !== valueToRemove);
|
||||
};
|
||||
|
||||
// Handle removal of a multi-select tag
|
||||
const handleRemoveTag = (e: React.MouseEvent, valueToRemove: string) => {
|
||||
e.stopPropagation();
|
||||
const filteredValues = getFilteredValues(valueToRemove);
|
||||
handleRemoveMultiSelect(filteredValues);
|
||||
};
|
||||
|
||||
// Render a single multi-select tag
|
||||
const renderTag = (value: string, index: number) => (
|
||||
<button
|
||||
key={`${value}-${index}`}
|
||||
type="button"
|
||||
onClick={(e) => handleRemoveTag(e, value)}
|
||||
className="flex items-center gap-1 whitespace-nowrap rounded bg-slate-100 px-2 py-1 text-sm text-slate-600 hover:bg-slate-200">
|
||||
{value}
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
);
|
||||
|
||||
const commandItemOnSelect = (o: string) => {
|
||||
if (!isMultiple) {
|
||||
onChangeFilterComboBoxValue(typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o);
|
||||
} else {
|
||||
onChangeFilterComboBoxValue(
|
||||
Array.isArray(filterComboBoxValue)
|
||||
? [...filterComboBoxValue, typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o]
|
||||
: [typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o]
|
||||
// Render multi-select tags
|
||||
const renderMultiSelectTags = () => {
|
||||
if (!Array.isArray(filterComboBoxValue) || filterComboBoxValue.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="no-scrollbar flex grow gap-2 overflow-auto">
|
||||
{filterComboBoxValue.map((value, index) => renderTag(value, index))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render the appropriate content based on filterComboBoxValue state
|
||||
const renderComboBoxContent = () => {
|
||||
if (!filterComboBoxValue || filterComboBoxValue.length === 0) {
|
||||
return (
|
||||
<p className={clsx("text-sm", isComboBoxDisabled ? "text-slate-300" : "text-slate-400")}>
|
||||
{t("common.select")}...
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (!isMultiple) {
|
||||
setOpen(false);
|
||||
|
||||
if (Array.isArray(filterComboBoxValue)) {
|
||||
return renderMultiSelectTags();
|
||||
}
|
||||
|
||||
return <p className="truncate text-sm text-slate-600">{filterComboBoxValue}</p>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="inline-flex w-full flex-row">
|
||||
{filterOptions && filterOptions?.length <= 1 ? (
|
||||
<div className="h-9 max-w-fit rounded-md rounded-r-none border-r-[1px] border-slate-300 bg-white p-2 text-sm text-slate-600">
|
||||
<p className="mr-1 max-w-[50px] truncate text-slate-600 sm:max-w-[100px]">{filterValue}</p>
|
||||
<div className="inline-flex h-fit w-full flex-row rounded-md border border-slate-300 hover:border-slate-400">
|
||||
{filterOptions && filterOptions.length <= 1 ? (
|
||||
<div className="flex h-9 max-w-fit items-center rounded-md rounded-r-none border-r border-slate-300 bg-white px-2 text-sm text-slate-600">
|
||||
<p className="mr-1 max-w-[50px] truncate sm:max-w-[100px]">{filterValue}</p>
|
||||
</div>
|
||||
) : (
|
||||
<DropdownMenu
|
||||
onOpenChange={(value) => {
|
||||
value && setOpen(false);
|
||||
setOpenFilterValue(value);
|
||||
if (value) setOpen(false);
|
||||
}}>
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled}
|
||||
className={clsx(
|
||||
"h-9 max-w-fit rounded-md rounded-r-none border-r-[1px] border-slate-300 bg-white p-2 text-sm text-slate-600 focus:outline-transparent focus:ring-0",
|
||||
!disabled ? "cursor-pointer" : "opacity-50"
|
||||
"flex h-9 max-w-fit items-center justify-between gap-2 rounded-md rounded-r-none border-r border-slate-300 bg-white px-2 text-sm text-slate-600 focus:outline-transparent focus:ring-0",
|
||||
disabled ? "opacity-50" : "cursor-pointer hover:bg-slate-50"
|
||||
)}>
|
||||
<div className="flex items-center justify-between">
|
||||
{!filterValue ? (
|
||||
<p className="text-slate-400">{t("common.select")}...</p>
|
||||
) : (
|
||||
<p className="mr-1 max-w-[50px] truncate text-slate-600 sm:max-w-[80px]">{filterValue}</p>
|
||||
)}
|
||||
{filterOptions && filterOptions.length > 1 && (
|
||||
<>
|
||||
{openFilterValue ? (
|
||||
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{filterValue ? (
|
||||
<p className="max-w-[50px] truncate sm:max-w-[80px]">{filterValue}</p>
|
||||
) : (
|
||||
<p className="text-slate-400">{t("common.select")}...</p>
|
||||
)}
|
||||
{filterOptions && filterOptions.length > 1 && (
|
||||
<ChevronIcon className="h-4 w-4 flex-shrink-0 opacity-50" />
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="bg-white p-2">
|
||||
{filterOptions?.map((o, index) => (
|
||||
<DropdownMenuItem
|
||||
key={`${o}-${index}`}
|
||||
className="px-0.5 py-1 dark:bg-slate-700 dark:text-slate-300 dark:ring-slate-700"
|
||||
onClick={() => onChangeFilterValue(o)}>
|
||||
{o}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuContent className="bg-white">
|
||||
{filterOptions?.map((o, index) => {
|
||||
const optionValue =
|
||||
typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={`${optionValue}-${index}`}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onChangeFilterValue(optionValue)}>
|
||||
{optionValue}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{isTextInputField ? (
|
||||
<Input
|
||||
type="text"
|
||||
value={typeof filterComboBoxValue === "string" ? filterComboBoxValue : ""}
|
||||
onChange={(e) => onChangeFilterComboBoxValue(e.target.value)}
|
||||
disabled={disabled || !filterValue}
|
||||
disabled={isComboBoxDisabled}
|
||||
placeholder={t("common.enter_url")}
|
||||
className="h-9 rounded-l-none border-none bg-white text-sm focus:ring-offset-0"
|
||||
/>
|
||||
) : (
|
||||
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent">
|
||||
<Command ref={commandRef} className="relative h-fit w-full min-w-0 overflow-visible bg-transparent">
|
||||
{/* eslint-disable-next-line jsx-a11y/prefer-tag-over-role */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={isComboBoxDisabled ? -1 : 0}
|
||||
className={clsx(
|
||||
"group flex items-center justify-between rounded-md rounded-l-none bg-white px-3 py-2 text-sm"
|
||||
)}>
|
||||
{filterComboBoxValue && filterComboBoxValue.length > 0 ? (
|
||||
filterComboBoxItem
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
|
||||
disabled={disabled || isDisabledComboBox || !filterValue}
|
||||
className={clsx(
|
||||
"flex-1 text-left text-slate-400",
|
||||
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
|
||||
)}>
|
||||
{t("common.select")}...
|
||||
</button>
|
||||
"flex min-w-0 items-center gap-2 rounded-md rounded-l-none bg-white pl-2",
|
||||
isComboBoxDisabled ? "opacity-50" : "cursor-pointer hover:bg-slate-50"
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
|
||||
disabled={disabled || isDisabledComboBox || !filterValue}
|
||||
className={clsx(
|
||||
"ml-2 flex items-center justify-center",
|
||||
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
|
||||
)}>
|
||||
{open ? (
|
||||
<ChevronUp className="h-4 w-4 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</button>
|
||||
onClick={handleOpenDropdown}
|
||||
onKeyDown={(e) => {
|
||||
const isActivationKey = e.key === "Enter" || e.key === " ";
|
||||
if (isActivationKey && !isComboBoxDisabled) {
|
||||
e.preventDefault();
|
||||
handleOpenDropdown();
|
||||
}
|
||||
}}>
|
||||
<div className="min-w-0 flex-1">{renderComboBoxContent()}</div>
|
||||
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isComboBoxDisabled) return;
|
||||
setOpen(!open);
|
||||
}}
|
||||
disabled={isComboBoxDisabled}
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="flex-shrink-0"
|
||||
aria-expanded={open}
|
||||
aria-label={t("common.select")}>
|
||||
<ChevronIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative mt-2 h-full">
|
||||
{open && (
|
||||
<div className="animate-in absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
|
||||
<CommandList>
|
||||
<div className="p-2">
|
||||
<Input
|
||||
type="text"
|
||||
autoFocus
|
||||
placeholder={t("common.search") + "..."}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full rounded-md border border-slate-300 p-2 text-sm focus:border-slate-300"
|
||||
/>
|
||||
</div>
|
||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filteredOptions?.map((o, index) => (
|
||||
|
||||
{open && (
|
||||
<div className="animate-in absolute top-full z-10 mt-1 w-full overflow-auto rounded-md bg-white shadow-md outline-none">
|
||||
<CommandList className="max-h-52">
|
||||
<CommandInput
|
||||
value={searchQuery}
|
||||
onValueChange={setSearchQuery}
|
||||
placeholder={`${t("common.search")}...`}
|
||||
className="border-none"
|
||||
/>
|
||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filteredOptions?.map((o) => {
|
||||
const optionValue =
|
||||
typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`option-${typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}-${index}`}
|
||||
onSelect={() => commandItemOnSelect(o)}
|
||||
key={optionValue}
|
||||
onSelect={() => handleCommandItemSelect(o)}
|
||||
className="cursor-pointer">
|
||||
{typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}
|
||||
{optionValue}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</div>
|
||||
)}
|
||||
</Command>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -111,51 +112,46 @@ const questionIcons = {
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
const IconComponent = questionIcons[type];
|
||||
return IconComponent ? <IconComponent width={18} height={18} className="text-white" /> : null;
|
||||
return IconComponent ? <IconComponent className="h-5 w-5" strokeWidth={1.5} /> : null;
|
||||
};
|
||||
|
||||
const getIconBackground = (type: OptionsType | string): string => {
|
||||
const backgroundMap: Record<string, string> = {
|
||||
[OptionsType.ATTRIBUTES]: "bg-indigo-500",
|
||||
[OptionsType.QUESTIONS]: "bg-brand-dark",
|
||||
[OptionsType.TAGS]: "bg-indigo-500",
|
||||
[OptionsType.QUOTAS]: "bg-slate-500",
|
||||
};
|
||||
return backgroundMap[type] ?? "bg-amber-500";
|
||||
};
|
||||
|
||||
const getLabelClassName = (type: OptionsType | string, label?: string): string => {
|
||||
if (type !== OptionsType.META) return "";
|
||||
return label === "os" || label === "url" ? "uppercase" : "capitalize";
|
||||
};
|
||||
|
||||
export const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOption>) => {
|
||||
const getIconType = () => {
|
||||
if (type) {
|
||||
if (type === OptionsType.QUESTIONS && questionType) {
|
||||
return getIcon(questionType);
|
||||
} else if (type === OptionsType.ATTRIBUTES) {
|
||||
return getIcon(OptionsType.ATTRIBUTES);
|
||||
} else if (type === OptionsType.HIDDEN_FIELDS) {
|
||||
return getIcon(OptionsType.HIDDEN_FIELDS);
|
||||
} else if ([OptionsType.META, OptionsType.OTHERS].includes(type) && label) {
|
||||
return getIcon(label);
|
||||
} else if (type === OptionsType.TAGS) {
|
||||
return getIcon(OptionsType.TAGS);
|
||||
} else if (type === OptionsType.QUOTAS) {
|
||||
return getIcon(OptionsType.QUOTAS);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getColor = () => {
|
||||
if (type === OptionsType.ATTRIBUTES) {
|
||||
return "bg-indigo-500";
|
||||
} else if (type === OptionsType.QUESTIONS) {
|
||||
return "bg-brand-dark";
|
||||
} else if (type === OptionsType.TAGS) {
|
||||
return "bg-indigo-500";
|
||||
} else if (type === OptionsType.QUOTAS) {
|
||||
return "bg-slate-500";
|
||||
} else {
|
||||
return "bg-amber-500";
|
||||
}
|
||||
};
|
||||
|
||||
const getLabelStyle = (): string | undefined => {
|
||||
if (type !== OptionsType.META) return undefined;
|
||||
return label === "os" || label === "url" ? "uppercase" : "capitalize";
|
||||
const getDisplayIcon = () => {
|
||||
if (!type) return null;
|
||||
if (type === OptionsType.QUESTIONS && questionType) return getIcon(questionType);
|
||||
if (type === OptionsType.ATTRIBUTES) return getIcon(OptionsType.ATTRIBUTES);
|
||||
if (type === OptionsType.HIDDEN_FIELDS) return getIcon(OptionsType.HIDDEN_FIELDS);
|
||||
if ([OptionsType.META, OptionsType.OTHERS].includes(type) && label) return getIcon(label);
|
||||
if (type === OptionsType.TAGS) return getIcon(OptionsType.TAGS);
|
||||
if (type === OptionsType.QUOTAS) return getIcon(OptionsType.QUOTAS);
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-5 w-[12rem] items-center sm:w-4/5">
|
||||
<span className={clsx("rounded-md p-1", getColor())}>{getIconType()}</span>
|
||||
<p className={clsx("ml-3 truncate text-sm text-slate-600", getLabelStyle())}>
|
||||
<div className="flex h-full min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={clsx(
|
||||
"flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md text-white",
|
||||
getIconBackground(type ?? "")
|
||||
)}>
|
||||
{getDisplayIcon()}
|
||||
</span>
|
||||
<p className={clsx("truncate text-sm text-slate-600", getLabelClassName(type ?? "", label))}>
|
||||
{typeof label === "string" ? label : getLocalizedValue(label, "default")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -169,64 +165,74 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
useClickOutside(commandRef, () => setOpen(false));
|
||||
|
||||
const hasSelection = selected.hasOwnProperty("label");
|
||||
const ChevronIcon = open ? ChevronUp : ChevronDown;
|
||||
|
||||
return (
|
||||
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent hover:bg-slate-50">
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="group flex cursor-pointer items-center justify-between rounded-md bg-white px-3 py-2 text-sm">
|
||||
{!open && selected.hasOwnProperty("label") && (
|
||||
<SelectedCommandItem
|
||||
label={selected?.label}
|
||||
type={selected?.type}
|
||||
questionType={selected?.questionType}
|
||||
/>
|
||||
)}
|
||||
{(open || !selected.hasOwnProperty("label")) && (
|
||||
<Command
|
||||
ref={commandRef}
|
||||
className="relative h-fit w-full overflow-visible rounded-md border border-slate-300 bg-white hover:border-slate-400">
|
||||
{/* eslint-disable-next-line jsx-a11y/prefer-tag-over-role */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="flex cursor-pointer items-center justify-between"
|
||||
onClick={() => !open && setOpen(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
!open && setOpen(true);
|
||||
}
|
||||
}}>
|
||||
{!open && hasSelection && <SelectedCommandItem {...selected} />}
|
||||
{(open || !hasSelection) && (
|
||||
<CommandInput
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
placeholder={t("common.search") + "..."}
|
||||
className="h-5 border-none border-transparent p-0 shadow-none outline-0 ring-offset-transparent focus:border-none focus:border-transparent focus:shadow-none focus:outline-0 focus:ring-offset-transparent"
|
||||
placeholder={open ? `${t("common.search")}...` : t("common.select_filter")}
|
||||
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none outline-none ring-offset-transparent focus:border-none focus:shadow-none focus:outline-none focus:ring-offset-0"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
{open ? (
|
||||
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<div className="relative mt-2 h-full">
|
||||
{open && (
|
||||
<div className="animate-in absolute top-0 z-50 w-full overflow-auto rounded-md bg-white outline-none">
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||
{options?.map((data) => (
|
||||
<Fragment key={data.header}>
|
||||
{data?.option.length > 0 && (
|
||||
<CommandGroup
|
||||
heading={<p className="text-sm font-normal text-slate-600">{data.header}</p>}>
|
||||
{data?.option?.map((o, i) => (
|
||||
<CommandItem
|
||||
key={`${o.label}-${i}`}
|
||||
onSelect={() => {
|
||||
setInputValue("");
|
||||
onChangeValue(o);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="cursor-pointer">
|
||||
<SelectedCommandItem label={o.label} type={o.type} questionType={o.questionType} />
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</CommandList>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen(!open);
|
||||
}}
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="flex-shrink-0"
|
||||
aria-expanded={open}
|
||||
aria-label={t("common.select")}>
|
||||
<ChevronIcon className="h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="animate-in absolute top-full z-10 mt-1 w-full overflow-auto rounded-md shadow-md outline-none">
|
||||
<CommandList className="max-h-[600px]">
|
||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||
{options?.map((data) => (
|
||||
<Fragment key={data.header}>
|
||||
{data?.option.length > 0 && (
|
||||
<CommandGroup heading={<p className="text-sm font-medium text-slate-600">{data.header}</p>}>
|
||||
{data?.option?.map((o) => (
|
||||
<CommandItem
|
||||
key={o.id}
|
||||
onSelect={() => {
|
||||
setInputValue("");
|
||||
onChangeValue(o);
|
||||
setOpen(false);
|
||||
}}>
|
||||
<SelectedCommandItem {...o} />
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</CommandList>
|
||||
</div>
|
||||
)}
|
||||
</Command>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,15 +4,16 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { ChevronDown, ChevronUp, Plus, TrashIcon } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TI18nString, TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
SelectedFilterValue,
|
||||
TResponseStatus,
|
||||
useResponseFilter,
|
||||
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
||||
import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
||||
import { QuestionFilterComboBox } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox";
|
||||
import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
||||
import {
|
||||
@@ -25,12 +26,46 @@ import {
|
||||
import { OptionsType, QuestionOption, QuestionsComboBox } from "./QuestionsComboBox";
|
||||
|
||||
export type QuestionFilterOptions = {
|
||||
type: TSurveyQuestionTypeEnum | "Attributes" | "Tags" | "Languages" | "Quotas";
|
||||
filterOptions: string[];
|
||||
filterComboBoxOptions: string[];
|
||||
type:
|
||||
| TSurveyQuestionTypeEnum
|
||||
| "Attributes"
|
||||
| "Tags"
|
||||
| "Languages"
|
||||
| "Quotas"
|
||||
| "Hidden Fields"
|
||||
| "Meta"
|
||||
| OptionsType.OTHERS;
|
||||
filterOptions: (string | TI18nString)[];
|
||||
filterComboBoxOptions: (string | TI18nString)[];
|
||||
id: string;
|
||||
};
|
||||
|
||||
interface PopoverTriggerButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
isOpen: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const PopoverTriggerButton = React.forwardRef<HTMLButtonElement, PopoverTriggerButtonProps>(
|
||||
({ isOpen, children, ...props }, ref) => (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
{...props}
|
||||
className="flex min-w-[8rem] cursor-pointer items-center justify-between rounded-md border border-slate-300 bg-white p-2 hover:border-slate-400">
|
||||
<span className="text-sm text-slate-700">{children}</span>
|
||||
<div className="ml-3">
|
||||
{isOpen ? (
|
||||
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
);
|
||||
|
||||
PopoverTriggerButton.displayName = "PopoverTriggerButton";
|
||||
|
||||
interface ResponseFilterProps {
|
||||
survey: TSurvey;
|
||||
}
|
||||
@@ -43,6 +78,12 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [filterValue, setFilterValue] = useState<SelectedFilterValue>(selectedFilter);
|
||||
|
||||
const getDefaultFilterValue = (option?: QuestionFilterOptions): string | undefined => {
|
||||
if (!option || option.filterOptions.length === 0) return undefined;
|
||||
const firstOption = option.filterOptions[0];
|
||||
return typeof firstOption === "object" ? getLocalizedValue(firstOption, "default") : firstOption;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch the initial data for the filter and load it into the state
|
||||
const handleInitialData = async () => {
|
||||
@@ -68,15 +109,18 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
}, [isOpen, setSelectedOptions, survey]);
|
||||
|
||||
const handleOnChangeQuestionComboBoxValue = (value: QuestionOption, index: number) => {
|
||||
const matchingFilterOption = selectedOptions.questionFilterOptions.find(
|
||||
(q) => q.type === value.type || q.type === value.questionType
|
||||
);
|
||||
const defaultFilterValue = getDefaultFilterValue(matchingFilterOption);
|
||||
|
||||
if (filterValue.filter[index].questionType) {
|
||||
// Create a new array and copy existing values from SelectedFilter
|
||||
filterValue.filter[index] = {
|
||||
questionType: value,
|
||||
filterType: {
|
||||
filterComboBoxValue: undefined,
|
||||
filterValue: selectedOptions.questionFilterOptions.find(
|
||||
(q) => q.type === value.type || q.type === value.questionType
|
||||
)?.filterOptions[0],
|
||||
filterValue: defaultFilterValue,
|
||||
},
|
||||
};
|
||||
setFilterValue({ filter: [...filterValue.filter], responseStatus: filterValue.responseStatus });
|
||||
@@ -85,9 +129,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
filterValue.filter[index].questionType = value;
|
||||
filterValue.filter[index].filterType = {
|
||||
filterComboBoxValue: undefined,
|
||||
filterValue: selectedOptions.questionFilterOptions.find(
|
||||
(q) => q.type === value.type || q.type === value.questionType
|
||||
)?.filterOptions[0],
|
||||
filterValue: defaultFilterValue,
|
||||
};
|
||||
setFilterValue({ ...filterValue });
|
||||
}
|
||||
@@ -108,7 +150,6 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
clearItem();
|
||||
handleApplyFilters();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]);
|
||||
@@ -127,8 +168,9 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
};
|
||||
|
||||
const handleClearAllFilters = () => {
|
||||
setFilterValue((filterValue) => ({ ...filterValue, filter: [], responseStatus: "all" }));
|
||||
setSelectedFilter((selectedFilters) => ({ ...selectedFilters, filter: [], responseStatus: "all" }));
|
||||
const clearedFilters = { filter: [], responseStatus: "all" as const };
|
||||
setFilterValue(clearedFilters);
|
||||
setSelectedFilter(clearedFilters);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
@@ -184,9 +226,6 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
};
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
handleApplyFilters();
|
||||
}
|
||||
setIsOpen(open);
|
||||
};
|
||||
|
||||
@@ -194,38 +233,30 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
setFilterValue(selectedFilter);
|
||||
}, [selectedFilter]);
|
||||
|
||||
const activeFilterCount = filterValue.filter.length + (filterValue.responseStatus === "all" ? 0 : 1);
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger className="flex min-w-[8rem] items-center justify-between rounded border border-slate-200 bg-white p-3 text-sm text-slate-600 hover:border-slate-300 sm:min-w-[11rem] sm:px-6 sm:py-3">
|
||||
<span>
|
||||
Filter <b>{filterValue.filter.length > 0 && `(${filterValue.filter.length})`}</b>
|
||||
</span>
|
||||
<div className="ml-3">
|
||||
{isOpen ? (
|
||||
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</div>
|
||||
<PopoverTrigger asChild>
|
||||
<PopoverTriggerButton isOpen={isOpen}>
|
||||
Filter <b>{activeFilterCount > 0 && `(${activeFilterCount})`}</b>
|
||||
</PopoverTriggerButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[300px] border-slate-200 bg-slate-100 p-6 sm:w-[400px] md:w-[750px] lg:w-[1000px]"
|
||||
className="w-[300px] rounded-lg border-slate-200 p-6 sm:w-[400px] md:w-[750px] lg:w-[1000px]"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}>
|
||||
<div className="mb-8 flex flex-wrap items-start justify-between gap-2">
|
||||
<p className="text-slate800 hidden text-lg font-semibold sm:block">
|
||||
<div className="mb-6 flex flex-wrap items-start justify-between gap-2">
|
||||
<p className="font-semibold text-slate-800">
|
||||
{t("environments.surveys.summary.show_all_responses_that_match")}
|
||||
</p>
|
||||
<p className="block text-base text-slate-500 sm:hidden">
|
||||
{t("environments.surveys.summary.show_all_responses_where")}
|
||||
</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Select
|
||||
value={filterValue.responseStatus ?? "all"}
|
||||
onValueChange={(val) => {
|
||||
handleResponseStatusChange(val as TResponseStatus);
|
||||
}}
|
||||
defaultValue={filterValue.responseStatus}>
|
||||
<SelectTrigger className="w-full bg-white">
|
||||
}}>
|
||||
<SelectTrigger className="w-full bg-white text-slate-700">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
@@ -285,35 +316,38 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-end gap-1 md:w-auto">
|
||||
<p className="block font-light text-slate-500 md:hidden">Delete</p>
|
||||
<TrashIcon
|
||||
className="w-4 cursor-pointer text-slate-500 md:text-black"
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteFilter(i)}
|
||||
/>
|
||||
aria-label={t("common.delete")}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{i !== filterValue.filter.length - 1 && (
|
||||
<div className="my-6 flex items-center">
|
||||
<p className="mr-6 text-base text-slate-600">And</p>
|
||||
<div className="my-4 flex items-center">
|
||||
<p className="mr-4 font-semibold text-slate-800">and</p>
|
||||
<hr className="w-full text-slate-600" />
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<Button size="sm" variant="secondary" onClick={handleAddNewFilter}>
|
||||
{t("common.add_filter")}
|
||||
<Plus width={18} height={18} className="ml-2" />
|
||||
</Button>
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={handleAddNewFilter}>
|
||||
{t("common.add_filter")}
|
||||
<Plus />
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleApplyFilters}>
|
||||
{t("common.apply_filters")}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={handleClearAllFilters}>
|
||||
{t("common.clear_all")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button size="sm" variant="destructive" onClick={handleClearAllFilters}>
|
||||
{t("common.clear_all")}
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { Suspense } from "react";
|
||||
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
|
||||
import { IS_POSTHOG_CONFIGURED, POSTHOG_API_HOST, POSTHOG_API_KEY } from "@/lib/constants";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { ClientLogout } from "@/modules/ui/components/client-logout";
|
||||
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
|
||||
import { PHProvider, PostHogPageview } from "@/modules/ui/components/post-hog-client";
|
||||
import { ToasterClient } from "@/modules/ui/components/toaster-client";
|
||||
|
||||
const AppLayout = async ({ children }) => {
|
||||
@@ -21,20 +18,9 @@ const AppLayout = async ({ children }) => {
|
||||
return (
|
||||
<>
|
||||
<NoMobileOverlay />
|
||||
<Suspense>
|
||||
<PostHogPageview
|
||||
posthogEnabled={IS_POSTHOG_CONFIGURED}
|
||||
postHogApiHost={POSTHOG_API_HOST}
|
||||
postHogApiKey={POSTHOG_API_KEY}
|
||||
/>
|
||||
</Suspense>
|
||||
<PHProvider posthogEnabled={IS_POSTHOG_CONFIGURED}>
|
||||
<>
|
||||
<IntercomClientWrapper user={user} />
|
||||
<ToasterClient />
|
||||
{children}
|
||||
</>
|
||||
</PHProvider>
|
||||
<IntercomClientWrapper user={user} />
|
||||
<ToasterClient />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Organization } from "@prisma/client";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
||||
|
||||
export const handleBillingLimitsCheck = async (
|
||||
environmentId: string,
|
||||
organizationId: string,
|
||||
organizationBilling: Organization["billing"]
|
||||
): Promise<void> => {
|
||||
if (!IS_FORMBRICKS_CLOUD) return;
|
||||
|
||||
const responsesCount = await getMonthlyOrganizationResponseCount(organizationId);
|
||||
const responsesLimit = organizationBilling.limits.monthly.responses;
|
||||
|
||||
if (responsesLimit && responsesCount >= responsesLimit) {
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: organizationBilling.plan,
|
||||
limits: {
|
||||
projects: null,
|
||||
monthly: {
|
||||
responses: responsesLimit,
|
||||
miu: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// Log error but do not throw
|
||||
logger.error(err, "Error sending plan limits reached event to Posthog");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -18,10 +18,6 @@ import {
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@/lib/organization/service";
|
||||
import {
|
||||
capturePosthogEnvironmentEvent,
|
||||
sendPlanLimitsReachedEventToPosthogWeekly,
|
||||
} from "@/lib/posthogServer";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { COLOR_DEFAULTS } from "@/lib/styling/constants";
|
||||
|
||||
@@ -58,20 +54,6 @@ const checkResponseLimit = async (environmentId: string): Promise<boolean> => {
|
||||
const monthlyResponseLimit = organization.billing.limits.monthly.responses;
|
||||
const isLimitReached = monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
|
||||
|
||||
if (isLimitReached) {
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: organization.billing.plan,
|
||||
limits: {
|
||||
projects: null,
|
||||
monthly: { responses: monthlyResponseLimit, miu: null },
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error }, `Error sending plan limits reached event to Posthog`);
|
||||
}
|
||||
}
|
||||
|
||||
return isLimitReached;
|
||||
};
|
||||
|
||||
@@ -111,10 +93,7 @@ export const GET = withV1ApiWrapper({
|
||||
}
|
||||
|
||||
if (!environment.appSetupCompleted) {
|
||||
await Promise.all([
|
||||
updateEnvironment(environment.id, { appSetupCompleted: true }),
|
||||
capturePosthogEnvironmentEvent(environmentId, "app setup completed"),
|
||||
]);
|
||||
await updateEnvironment(environment.id, { appSetupCompleted: true });
|
||||
}
|
||||
|
||||
// check organization subscriptions and response limits
|
||||
|
||||
@@ -5,7 +5,6 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { createDisplay } from "./lib/display";
|
||||
|
||||
@@ -59,7 +58,6 @@ export const POST = withV1ApiWrapper({
|
||||
try {
|
||||
const response = await createDisplay(inputValidation.data);
|
||||
|
||||
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
|
||||
return {
|
||||
response: responses.successResponse(response, true),
|
||||
};
|
||||
|
||||
@@ -8,16 +8,11 @@ import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
|
||||
import {
|
||||
capturePosthogEnvironmentEvent,
|
||||
sendPlanLimitsReachedEventToPosthogWeekly,
|
||||
} from "@/lib/posthogServer";
|
||||
import { EnvironmentStateData, getEnvironmentStateData } from "./data";
|
||||
import { getEnvironmentState } from "./environmentState";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/organization/service");
|
||||
vi.mock("@/lib/posthogServer");
|
||||
vi.mock("@/lib/cache", () => ({
|
||||
cache: {
|
||||
withCache: vi.fn(),
|
||||
@@ -43,7 +38,6 @@ vi.mock("@/lib/constants", () => ({
|
||||
RECAPTCHA_SECRET_KEY: "mock_recaptcha_secret_key",
|
||||
IS_RECAPTCHA_CONFIGURED: true,
|
||||
IS_PRODUCTION: true,
|
||||
IS_POSTHOG_CONFIGURED: false,
|
||||
ENTERPRISE_LICENSE_KEY: "mock_enterprise_license_key",
|
||||
}));
|
||||
|
||||
@@ -188,9 +182,7 @@ describe("getEnvironmentState", () => {
|
||||
expect(result.data).toEqual(expectedData);
|
||||
expect(getEnvironmentStateData).toHaveBeenCalledWith(environmentId);
|
||||
expect(prisma.environment.update).not.toHaveBeenCalled();
|
||||
expect(capturePosthogEnvironmentEvent).not.toHaveBeenCalled();
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError if environment not found", async () => {
|
||||
@@ -226,7 +218,6 @@ describe("getEnvironmentState", () => {
|
||||
where: { id: environmentId },
|
||||
data: { appSetupCompleted: true },
|
||||
});
|
||||
expect(capturePosthogEnvironmentEvent).toHaveBeenCalledWith(environmentId, "app setup completed");
|
||||
expect(result.data).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -237,16 +228,6 @@ describe("getEnvironmentState", () => {
|
||||
|
||||
expect(result.data.surveys).toEqual([]);
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, {
|
||||
plan: mockOrganization.billing.plan,
|
||||
limits: {
|
||||
projects: null,
|
||||
monthly: {
|
||||
miu: null,
|
||||
responses: mockOrganization.billing.limits.monthly.responses,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should return surveys if monthly response limit not reached (Cloud)", async () => {
|
||||
@@ -256,21 +237,6 @@ describe("getEnvironmentState", () => {
|
||||
|
||||
expect(result.data.surveys).toEqual(mockSurveys);
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle error when sending Posthog limit reached event", async () => {
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
|
||||
const posthogError = new Error("Posthog failed");
|
||||
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
|
||||
|
||||
const result = await getEnvironmentState(environmentId);
|
||||
|
||||
expect(result.data.surveys).toEqual([]);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
posthogError,
|
||||
"Error sending plan limits reached event to Posthog"
|
||||
);
|
||||
});
|
||||
|
||||
test("should include recaptchaSiteKey if recaptcha variables are set", async () => {
|
||||
@@ -313,7 +279,6 @@ describe("getEnvironmentState", () => {
|
||||
|
||||
// Should return surveys even with high count since limit is null (unlimited)
|
||||
expect(result.data.surveys).toEqual(mockSurveys);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should propagate database update errors", async () => {
|
||||
@@ -331,21 +296,6 @@ describe("getEnvironmentState", () => {
|
||||
await expect(getEnvironmentState(environmentId)).rejects.toThrow("Database error");
|
||||
});
|
||||
|
||||
test("should propagate PostHog event capture errors", async () => {
|
||||
const incompleteEnvironmentData = {
|
||||
...mockEnvironmentStateData,
|
||||
environment: {
|
||||
...mockEnvironmentStateData.environment,
|
||||
appSetupCompleted: false,
|
||||
},
|
||||
};
|
||||
vi.mocked(getEnvironmentStateData).mockResolvedValue(incompleteEnvironmentData);
|
||||
vi.mocked(capturePosthogEnvironmentEvent).mockRejectedValue(new Error("PostHog error"));
|
||||
|
||||
// Should throw error since Promise.all will fail if PostHog event capture fails
|
||||
await expect(getEnvironmentState(environmentId)).rejects.toThrow("PostHog error");
|
||||
});
|
||||
|
||||
test("should include recaptchaSiteKey when IS_RECAPTCHA_CONFIGURED is true", async () => {
|
||||
const result = await getEnvironmentState(environmentId);
|
||||
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import "server-only";
|
||||
import { createCacheKey } from "@formbricks/cache";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TJsEnvironmentState } from "@formbricks/types/js";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { IS_FORMBRICKS_CLOUD, IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants";
|
||||
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
|
||||
import {
|
||||
capturePosthogEnvironmentEvent,
|
||||
sendPlanLimitsReachedEventToPosthogWeekly,
|
||||
} from "@/lib/posthogServer";
|
||||
import { getEnvironmentStateData } from "./data";
|
||||
|
||||
/**
|
||||
@@ -33,13 +28,10 @@ export const getEnvironmentState = async (
|
||||
// Handle app setup completion update if needed
|
||||
// This is a one-time setup flag that can tolerate TTL-based cache expiration
|
||||
if (!environment.appSetupCompleted) {
|
||||
await Promise.all([
|
||||
prisma.environment.update({
|
||||
where: { id: environmentId },
|
||||
data: { appSetupCompleted: true },
|
||||
}),
|
||||
capturePosthogEnvironmentEvent(environmentId, "app setup completed"),
|
||||
]);
|
||||
await prisma.environment.update({
|
||||
where: { id: environmentId },
|
||||
data: { appSetupCompleted: true },
|
||||
});
|
||||
}
|
||||
|
||||
// Check monthly response limits for Formbricks Cloud
|
||||
@@ -49,24 +41,6 @@ export const getEnvironmentState = async (
|
||||
const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id);
|
||||
isMonthlyResponsesLimitReached =
|
||||
monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
|
||||
|
||||
// Send plan limits event if needed
|
||||
if (isMonthlyResponsesLimitReached) {
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: organization.billing.plan,
|
||||
limits: {
|
||||
projects: null,
|
||||
monthly: {
|
||||
miu: null,
|
||||
responses: organization.billing.limits.monthly.responses,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(err, "Error sending plan limits reached event to Posthog");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build the response data
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZEnvironmentId } from "@formbricks/types/environment";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getEnvironmentState } from "@/app/api/v1/client/[environmentId]/environment/lib/environmentState";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
@@ -28,15 +29,38 @@ export const GET = withV1ApiWrapper({
|
||||
const params = await props.params;
|
||||
|
||||
try {
|
||||
// Simple validation for environmentId (faster than Zod for high-frequency endpoint)
|
||||
// Basic type check for environmentId
|
||||
if (typeof params.environmentId !== "string") {
|
||||
return {
|
||||
response: responses.badRequestResponse("Environment ID is required", undefined, true),
|
||||
};
|
||||
}
|
||||
|
||||
const environmentId = params.environmentId.trim();
|
||||
|
||||
// Validate CUID v1 format using Zod (matches Prisma schema @default(cuid()))
|
||||
// This catches all invalid formats including:
|
||||
// - null/undefined passed as string "null" or "undefined"
|
||||
// - HTML-encoded placeholders like <environmentId> or %3C...%3E
|
||||
// - Empty or whitespace-only IDs
|
||||
// - Any other invalid CUID v1 format
|
||||
const cuidValidation = ZEnvironmentId.safeParse(environmentId);
|
||||
if (!cuidValidation.success) {
|
||||
logger.warn(
|
||||
{
|
||||
environmentId: params.environmentId,
|
||||
url: req.url,
|
||||
validationError: cuidValidation.error.errors[0]?.message,
|
||||
},
|
||||
"Invalid CUID v1 format detected"
|
||||
);
|
||||
return {
|
||||
response: responses.badRequestResponse("Invalid environment ID format", undefined, true),
|
||||
};
|
||||
}
|
||||
|
||||
// Use optimized environment state fetcher with new caching approach
|
||||
const environmentState = await getEnvironmentState(params.environmentId);
|
||||
const environmentState = await getEnvironmentState(environmentId);
|
||||
const { data } = environmentState;
|
||||
|
||||
return {
|
||||
@@ -46,12 +70,12 @@ export const GET = withV1ApiWrapper({
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60), // 1 hour for SDK to recheck
|
||||
},
|
||||
true,
|
||||
// Optimized cache headers for Cloudflare CDN and browser caching
|
||||
// max-age=3600: 1hr browser cache (per guidelines)
|
||||
// s-maxage=1800: 30min Cloudflare cache (per guidelines)
|
||||
// stale-while-revalidate=1800: 30min stale serving during revalidation
|
||||
// stale-if-error=3600: 1hr stale serving on origin errors
|
||||
"public, s-maxage=1800, max-age=3600, stale-while-revalidate=1800, stale-if-error=3600"
|
||||
// Cache headers aligned with Redis cache TTL (1 minute)
|
||||
// max-age=60: 1min browser cache
|
||||
// s-maxage=60: 1min Cloudflare CDN cache
|
||||
// stale-while-revalidate=60: 1min stale serving during revalidation
|
||||
// stale-if-error=60: 1min stale serving on origin errors
|
||||
"public, s-maxage=60, max-age=60, stale-while-revalidate=60, stale-if-error=60"
|
||||
),
|
||||
};
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TResponseInput } from "@formbricks/types/responses";
|
||||
import {
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@/lib/organization/service";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||
import { createResponse, createResponseWithQuotaEvaluation } from "./response";
|
||||
@@ -24,22 +19,13 @@ vi.mock("@/lib/constants", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
getMonthlyOrganizationResponseCount: vi.fn(),
|
||||
getOrganizationByEnvironmentId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/posthogServer", () => ({
|
||||
sendPlanLimitsReachedEventToPosthogWeekly: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/response/utils", () => ({
|
||||
calculateTtcTotal: vi.fn((ttc) => ttc),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/telemetry", () => ({
|
||||
captureTelemetry: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
@@ -138,35 +124,6 @@ describe("createResponse", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("should check response limits if IS_FORMBRICKS_CLOUD is true", async () => {
|
||||
mockIsFormbricksCloud = true;
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
|
||||
|
||||
await createResponse(mockResponseInput, prisma);
|
||||
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should send limit reached event if IS_FORMBRICKS_CLOUD is true and limit reached", async () => {
|
||||
mockIsFormbricksCloud = true;
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
|
||||
|
||||
await createResponse(mockResponseInput, prisma);
|
||||
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, {
|
||||
plan: "free",
|
||||
limits: {
|
||||
projects: null,
|
||||
monthly: {
|
||||
responses: 100,
|
||||
miu: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError if organization not found", async () => {
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
|
||||
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(ResourceNotFoundError);
|
||||
@@ -186,20 +143,6 @@ describe("createResponse", () => {
|
||||
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
|
||||
await expect(createResponse(mockResponseInput)).rejects.toThrow(genericError);
|
||||
});
|
||||
|
||||
test("should log error but not throw if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => {
|
||||
mockIsFormbricksCloud = true;
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
|
||||
const posthogError = new Error("PostHog error");
|
||||
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
|
||||
|
||||
await createResponse(mockResponseInput);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
posthogError,
|
||||
"Error sending plan limits reached event to Posthog"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createResponseWithQuotaEvaluation", () => {
|
||||
|
||||
@@ -6,11 +6,9 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { handleBillingLimitsCheck } from "@/app/api/lib/utils";
|
||||
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { captureTelemetry } from "@/lib/telemetry";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||
import { getContactByUserId } from "./contact";
|
||||
@@ -83,7 +81,6 @@ export const createResponse = async (
|
||||
tx: Prisma.TransactionClient
|
||||
): Promise<TResponse> => {
|
||||
validateInputs([responseInput, ZResponseInput]);
|
||||
captureTelemetry("response created");
|
||||
|
||||
const { environmentId, userId, finished, ttc: initialTtc } = responseInput;
|
||||
|
||||
@@ -121,8 +118,6 @@ export const createResponse = async (
|
||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
};
|
||||
|
||||
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { headers } from "next/headers";
|
||||
import { NextRequest } from "next/server";
|
||||
import { UAParser } from "ua-parser-js";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZEnvironmentId } from "@formbricks/types/environment";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||
@@ -10,7 +10,6 @@ import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||
@@ -51,7 +50,7 @@ export const POST = withV1ApiWrapper({
|
||||
}
|
||||
|
||||
const { environmentId } = params;
|
||||
const environmentIdValidation = ZId.safeParse(environmentId);
|
||||
const environmentIdValidation = ZEnvironmentId.safeParse(environmentId);
|
||||
const responseInputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId });
|
||||
|
||||
if (!environmentIdValidation.success) {
|
||||
@@ -172,11 +171,6 @@ export const POST = withV1ApiWrapper({
|
||||
});
|
||||
}
|
||||
|
||||
await capturePosthogEnvironmentEvent(survey.environmentId, "response created", {
|
||||
surveyId: responseData.surveyId,
|
||||
surveyType: survey.type,
|
||||
});
|
||||
|
||||
const quotaObj = createQuotaFullObject(quotaFull);
|
||||
|
||||
const responseDataWithQuota = {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { handleErrorResponse } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { deleteResponse, getResponse } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
@@ -151,6 +152,23 @@ export const PUT = withV1ApiWrapper({
|
||||
|
||||
const updated = await updateResponseWithQuotaEvaluation(params.responseId, inputValidation.data);
|
||||
auditLog.newObject = updated;
|
||||
|
||||
sendToPipeline({
|
||||
event: "responseUpdated",
|
||||
environmentId: result.survey.environmentId,
|
||||
surveyId: result.survey.id,
|
||||
response: updated,
|
||||
});
|
||||
|
||||
if (updated.finished) {
|
||||
sendToPipeline({
|
||||
event: "responseFinished",
|
||||
environmentId: result.survey.environmentId,
|
||||
surveyId: result.survey.id,
|
||||
response: updated,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
response: responses.successResponse(updated),
|
||||
};
|
||||
|
||||
@@ -4,11 +4,7 @@ import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TResponse, TResponseInput } from "@formbricks/types/responses";
|
||||
import {
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@/lib/organization/service";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getResponseContact } from "@/lib/response/service";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
@@ -96,9 +92,6 @@ const mockTransformedResponses = [mockResponse, { ...mockResponse, id: "response
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: true,
|
||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
||||
POSTHOG_HOST: "mock-posthog-host",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
@@ -118,10 +111,8 @@ vi.mock("@/lib/constants", () => ({
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
}));
|
||||
vi.mock("@/lib/organization/service");
|
||||
vi.mock("@/lib/posthogServer");
|
||||
vi.mock("@/lib/response/service");
|
||||
vi.mock("@/lib/response/utils");
|
||||
vi.mock("@/lib/telemetry");
|
||||
vi.mock("@/lib/utils/validate");
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
@@ -162,7 +153,6 @@ describe("Response Lib Tests", () => {
|
||||
vi.mocked(mockTx.response.create).mockResolvedValue({
|
||||
...mockResponsePrisma,
|
||||
});
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
|
||||
|
||||
const response = await createResponse(mockResponseInputWithUserId, mockTx);
|
||||
|
||||
@@ -217,68 +207,6 @@ describe("Response Lib Tests", () => {
|
||||
|
||||
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(genericError);
|
||||
});
|
||||
|
||||
describe("Cloud specific tests", () => {
|
||||
test("should check response limit and send event if limit reached", async () => {
|
||||
// IS_FORMBRICKS_CLOUD is true by default from the top-level mock
|
||||
const limit = 100;
|
||||
const mockOrgWithBilling = {
|
||||
...mockOrganization,
|
||||
billing: { limits: { monthly: { responses: limit } } },
|
||||
} as any;
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling);
|
||||
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
|
||||
vi.mocked(mockTx.response.create).mockResolvedValue(mockResponsePrisma);
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit); // Limit reached
|
||||
|
||||
await createResponse(mockResponseInput, mockTx);
|
||||
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should check response limit and not send event if limit not reached", async () => {
|
||||
const limit = 100;
|
||||
const mockOrgWithBilling = {
|
||||
...mockOrganization,
|
||||
billing: { limits: { monthly: { responses: limit } } },
|
||||
} as any;
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling);
|
||||
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
|
||||
vi.mocked(mockTx.response.create).mockResolvedValue(mockResponsePrisma);
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit - 1); // Limit not reached
|
||||
|
||||
await createResponse(mockResponseInput, mockTx);
|
||||
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should log error if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => {
|
||||
const limit = 100;
|
||||
const mockOrgWithBilling = {
|
||||
...mockOrganization,
|
||||
billing: { limits: { monthly: { responses: limit } } },
|
||||
} as any;
|
||||
const posthogError = new Error("Posthog error");
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling);
|
||||
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
|
||||
vi.mocked(mockTx.response.create).mockResolvedValue(mockResponsePrisma);
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit); // Limit reached
|
||||
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
|
||||
|
||||
// Expecting successful response creation despite PostHog error
|
||||
const response = await createResponse(mockResponseInput, mockTx);
|
||||
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
posthogError,
|
||||
"Error sending plan limits reached event to Posthog"
|
||||
);
|
||||
expect(response).toEqual(mockResponse); // Should still return the created response
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getResponsesByEnvironmentIds", () => {
|
||||
|
||||
@@ -8,14 +8,12 @@ import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { handleBillingLimitsCheck } from "@/app/api/lib/utils";
|
||||
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
|
||||
import { RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getResponseContact } from "@/lib/response/service";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { captureTelemetry } from "@/lib/telemetry";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||
import { getContactByUserId } from "./contact";
|
||||
@@ -93,7 +91,6 @@ export const createResponse = async (
|
||||
tx?: Prisma.TransactionClient
|
||||
): Promise<TResponse> => {
|
||||
validateInputs([responseInput, ZResponseInput]);
|
||||
captureTelemetry("response created");
|
||||
|
||||
const { environmentId, userId, finished, ttc: initialTtc } = responseInput;
|
||||
|
||||
@@ -131,8 +128,6 @@ export const createResponse = async (
|
||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
};
|
||||
|
||||
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/res
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { validateFileUploads } from "@/modules/storage/utils";
|
||||
@@ -156,6 +157,23 @@ export const POST = withV1ApiWrapper({
|
||||
const response = await createResponseWithQuotaEvaluation(responseInput);
|
||||
auditLog.targetId = response.id;
|
||||
auditLog.newObject = response;
|
||||
|
||||
sendToPipeline({
|
||||
event: "responseCreated",
|
||||
environmentId: surveyResult.survey.environmentId,
|
||||
surveyId: response.surveyId,
|
||||
response: response,
|
||||
});
|
||||
|
||||
if (response.finished) {
|
||||
sendToPipeline({
|
||||
event: "responseFinished",
|
||||
environmentId: surveyResult.survey.environmentId,
|
||||
surveyId: response.surveyId,
|
||||
response: response,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
response: responses.successResponse(response, true),
|
||||
};
|
||||
|
||||
@@ -3,7 +3,6 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZDisplayCreateInputV2 } from "@/app/api/v2/client/[environmentId]/displays/types/display";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { createDisplay } from "./lib/display";
|
||||
|
||||
@@ -49,7 +48,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
||||
try {
|
||||
const response = await createDisplay(inputValidation.data);
|
||||
|
||||
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
|
||||
return responses.successResponse(response, true);
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
|
||||
@@ -8,13 +8,8 @@ import { TResponseWithQuotaFull, TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
|
||||
import {
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@/lib/organization/service";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { captureTelemetry } from "@/lib/telemetry";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||
import { getContact } from "./contact";
|
||||
@@ -49,9 +44,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/organization/service");
|
||||
vi.mock("@/lib/posthogServer");
|
||||
vi.mock("@/lib/response/utils");
|
||||
vi.mock("@/lib/telemetry");
|
||||
vi.mock("@/lib/utils/validate");
|
||||
vi.mock("@/modules/ee/quotas/lib/evaluation-service");
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
@@ -166,9 +159,6 @@ describe("createResponse V2", () => {
|
||||
...ttc,
|
||||
_total: Object.values(ttc).reduce((a, b) => a + b, 0),
|
||||
}));
|
||||
vi.mocked(captureTelemetry).mockResolvedValue(undefined);
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
|
||||
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockResolvedValue(undefined);
|
||||
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
|
||||
shouldEndSurvey: false,
|
||||
quotaFull: null,
|
||||
@@ -179,32 +169,6 @@ describe("createResponse V2", () => {
|
||||
mockIsFormbricksCloud = false;
|
||||
});
|
||||
|
||||
test("should check response limits if IS_FORMBRICKS_CLOUD is true", async () => {
|
||||
mockIsFormbricksCloud = true;
|
||||
await createResponse(mockResponseInput, mockTx);
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should send limit reached event if IS_FORMBRICKS_CLOUD is true and limit reached", async () => {
|
||||
mockIsFormbricksCloud = true;
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
|
||||
|
||||
await createResponse(mockResponseInput, mockTx);
|
||||
|
||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, {
|
||||
plan: "free",
|
||||
limits: {
|
||||
projects: null,
|
||||
monthly: {
|
||||
responses: 100,
|
||||
miu: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError if organization not found", async () => {
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
|
||||
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(ResourceNotFoundError);
|
||||
@@ -225,20 +189,6 @@ describe("createResponse V2", () => {
|
||||
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(genericError);
|
||||
});
|
||||
|
||||
test("should log error but not throw if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => {
|
||||
mockIsFormbricksCloud = true;
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
|
||||
const posthogError = new Error("PostHog error");
|
||||
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
|
||||
|
||||
await createResponse(mockResponseInput, mockTx); // Should not throw
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
posthogError,
|
||||
"Error sending plan limits reached event to Posthog"
|
||||
);
|
||||
});
|
||||
|
||||
test("should correctly map prisma tags to response tags", async () => {
|
||||
const mockTag: TTag = { id: "tag1", name: "Tag 1", environmentId };
|
||||
const prismaResponseWithTags = {
|
||||
@@ -269,7 +219,6 @@ describe("createResponseWithQuotaEvaluation V2", () => {
|
||||
...ttc,
|
||||
_total: Object.values(ttc).reduce((a, b) => a + b, 0),
|
||||
}));
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
|
||||
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
|
||||
shouldEndSurvey: false,
|
||||
quotaFull: null,
|
||||
|
||||
@@ -6,12 +6,10 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { handleBillingLimitsCheck } from "@/app/api/lib/utils";
|
||||
import { responseSelection } from "@/app/api/v1/client/[environmentId]/responses/lib/response";
|
||||
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { captureTelemetry } from "@/lib/telemetry";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||
import { getContact } from "./contact";
|
||||
@@ -91,7 +89,6 @@ export const createResponse = async (
|
||||
tx?: Prisma.TransactionClient
|
||||
): Promise<TResponse> => {
|
||||
validateInputs([responseInput, ZResponseInput]);
|
||||
captureTelemetry("response created");
|
||||
|
||||
const { environmentId, contactId, finished, ttc: initialTtc } = responseInput;
|
||||
|
||||
@@ -129,8 +126,6 @@ export const createResponse = async (
|
||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
};
|
||||
|
||||
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { headers } from "next/headers";
|
||||
import { UAParser } from "ua-parser-js";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZEnvironmentId } from "@formbricks/types/environment";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -43,7 +42,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
||||
}
|
||||
|
||||
const { environmentId } = params;
|
||||
const environmentIdValidation = ZId.safeParse(environmentId);
|
||||
const environmentIdValidation = ZEnvironmentId.safeParse(environmentId);
|
||||
const responseInputValidation = ZResponseInputV2.safeParse({ ...responseInput, environmentId });
|
||||
|
||||
if (!environmentIdValidation.success) {
|
||||
@@ -148,11 +147,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
||||
});
|
||||
}
|
||||
|
||||
await capturePosthogEnvironmentEvent(environmentId, "response created", {
|
||||
surveyId: responseData.surveyId,
|
||||
surveyType: survey.type,
|
||||
});
|
||||
|
||||
const quotaObj = createQuotaFullObject(quotaFull);
|
||||
|
||||
const responseDataWithQuota = {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { TTag } from "@formbricks/types/tags";
|
||||
import {
|
||||
DateRange,
|
||||
SelectedFilterValue,
|
||||
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
||||
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
||||
import { generateQuestionAndFilterOptions, getFormattedFilters, getTodayDate } from "./surveys";
|
||||
|
||||
@@ -213,8 +213,8 @@ describe("surveys", () => {
|
||||
id: "q8",
|
||||
type: TSurveyQuestionTypeEnum.Matrix,
|
||||
headline: { default: "Matrix" },
|
||||
rows: [{ id: "r1", label: "Row 1" }],
|
||||
columns: [{ id: "c1", label: "Column 1" }],
|
||||
rows: [{ id: "r1", label: { default: "Row 1" } }],
|
||||
columns: [{ id: "c1", label: { default: "Column 1" } }],
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
createdAt: new Date(),
|
||||
@@ -268,6 +268,64 @@ describe("surveys", () => {
|
||||
expect(sourceFilterOption).toBeDefined();
|
||||
expect(sourceFilterOption?.filterOptions).toEqual(["Equals", "Not equals"]);
|
||||
});
|
||||
|
||||
test("should include quota options in filter options when quotas are provided", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
status: "draft",
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const quotas = [{ id: "quota1" }];
|
||||
|
||||
const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, {}, quotas as any);
|
||||
|
||||
const quotaFilterOption = result.questionFilterOptions.find((o) => o.id === "quota1");
|
||||
expect(quotaFilterOption).toBeDefined();
|
||||
expect(quotaFilterOption?.type).toBe("Quotas");
|
||||
expect(quotaFilterOption?.filterOptions).toEqual(["Status"]);
|
||||
expect(quotaFilterOption?.filterComboBoxOptions).toEqual([
|
||||
"Screened in",
|
||||
"Screened out (overquota)",
|
||||
"Not in quota",
|
||||
]);
|
||||
});
|
||||
|
||||
test("should include multiple quota options when multiple quotas are provided", () => {
|
||||
const survey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
questions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
status: "draft",
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const quotas = [{ id: "quota1" }, { id: "quota2" }];
|
||||
|
||||
const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, {}, quotas as any);
|
||||
|
||||
const quota1 = result.questionFilterOptions.find((o) => o.id === "quota1");
|
||||
const quota2 = result.questionFilterOptions.find((o) => o.id === "quota2");
|
||||
|
||||
expect(quota1).toBeDefined();
|
||||
expect(quota2).toBeDefined();
|
||||
expect(quota1?.filterComboBoxOptions).toEqual([
|
||||
"Screened in",
|
||||
"Screened out (overquota)",
|
||||
"Not in quota",
|
||||
]);
|
||||
expect(quota2?.filterComboBoxOptions).toEqual([
|
||||
"Screened in",
|
||||
"Screened out (overquota)",
|
||||
"Not in quota",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFormattedFilters", () => {
|
||||
@@ -867,6 +925,75 @@ describe("surveys", () => {
|
||||
expect(result.meta?.url).toEqual({ op: "contains", value: "formbricks.com" });
|
||||
expect(result.meta?.source).toEqual({ op: "equals", value: "newsletter" });
|
||||
});
|
||||
|
||||
test("should filter by quota with screened in status", () => {
|
||||
const selectedFilter: SelectedFilterValue = {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
questionType: { type: "Quotas", label: "Quota 1", id: "quota1" },
|
||||
filterType: { filterComboBoxValue: "Screened in" },
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = getFormattedFilters(survey, selectedFilter, {} as any);
|
||||
|
||||
expect(result.quotas?.quota1).toEqual({ op: "screenedIn" });
|
||||
});
|
||||
|
||||
test("should filter by quota with screened out status", () => {
|
||||
const selectedFilter: SelectedFilterValue = {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
questionType: { type: "Quotas", label: "Quota 1", id: "quota1" },
|
||||
filterType: { filterComboBoxValue: "Screened out (overquota)" },
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = getFormattedFilters(survey, selectedFilter, {} as any);
|
||||
|
||||
expect(result.quotas?.quota1).toEqual({ op: "screenedOut" });
|
||||
});
|
||||
|
||||
test("should filter by quota with not in quota status", () => {
|
||||
const selectedFilter: SelectedFilterValue = {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
questionType: { type: "Quotas", label: "Quota 1", id: "quota1" },
|
||||
filterType: { filterComboBoxValue: "Not in quota" },
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = getFormattedFilters(survey, selectedFilter, {} as any);
|
||||
|
||||
expect(result.quotas?.quota1).toEqual({ op: "screenedOutNotInQuota" });
|
||||
});
|
||||
|
||||
test("should filter by multiple quotas with different statuses", () => {
|
||||
const selectedFilter: SelectedFilterValue = {
|
||||
responseStatus: "all",
|
||||
filter: [
|
||||
{
|
||||
questionType: { type: "Quotas", label: "Quota 1", id: "quota1" },
|
||||
filterType: { filterComboBoxValue: "Screened in" },
|
||||
},
|
||||
{
|
||||
questionType: { type: "Quotas", label: "Quota 2", id: "quota2" },
|
||||
filterType: { filterComboBoxValue: "Not in quota" },
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = getFormattedFilters(survey, selectedFilter, {} as any);
|
||||
|
||||
expect(result.quotas?.quota1).toEqual({ op: "screenedIn" });
|
||||
expect(result.quotas?.quota2).toEqual({ op: "screenedOutNotInQuota" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTodayDate", () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
DateRange,
|
||||
FilterValue,
|
||||
SelectedFilterValue,
|
||||
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
||||
import {
|
||||
OptionsType,
|
||||
QuestionOption,
|
||||
@@ -76,9 +76,9 @@ export const generateQuestionAndFilterOptions = (
|
||||
questionFilterOptions: QuestionFilterOptions[];
|
||||
} => {
|
||||
let questionOptions: QuestionOptions[] = [];
|
||||
let questionFilterOptions: any = [];
|
||||
let questionFilterOptions: QuestionFilterOptions[] = [];
|
||||
|
||||
let questionsOptions: any = [];
|
||||
let questionsOptions: QuestionOption[] = [];
|
||||
|
||||
survey.questions.forEach((q) => {
|
||||
if (Object.keys(conditionOptions).includes(q.type)) {
|
||||
@@ -121,8 +121,8 @@ export const generateQuestionAndFilterOptions = (
|
||||
} else if (q.type === TSurveyQuestionTypeEnum.Matrix) {
|
||||
questionFilterOptions.push({
|
||||
type: q.type,
|
||||
filterOptions: q.rows.flatMap((row) => Object.values(row)),
|
||||
filterComboBoxOptions: q.columns.flatMap((column) => Object.values(column)),
|
||||
filterOptions: q.rows.map((row) => getLocalizedValue(row.label, "default")),
|
||||
filterComboBoxOptions: q.columns.map((column) => getLocalizedValue(column.label, "default")),
|
||||
id: q.id,
|
||||
});
|
||||
} else {
|
||||
@@ -236,7 +236,7 @@ export const generateQuestionAndFilterOptions = (
|
||||
questionFilterOptions.push({
|
||||
type: "Quotas",
|
||||
filterOptions: ["Status"],
|
||||
filterComboBoxOptions: ["Screened in", "Screened out (overquota)", "Screened out (not in quota)"],
|
||||
filterComboBoxOptions: ["Screened in", "Screened out (overquota)", "Not in quota"],
|
||||
id: quota.id,
|
||||
});
|
||||
});
|
||||
@@ -549,7 +549,7 @@ export const getFormattedFilters = (
|
||||
const statusMap: Record<string, "screenedIn" | "screenedOut" | "screenedOutNotInQuota"> = {
|
||||
"Screened in": "screenedIn",
|
||||
"Screened out (overquota)": "screenedOut",
|
||||
"Screened out (not in quota)": "screenedOutNotInQuota",
|
||||
"Not in quota": "screenedOutNotInQuota",
|
||||
};
|
||||
const op = statusMap[String(filterType.filterComboBoxValue)];
|
||||
if (op) filters.quotas[quotaId] = { op };
|
||||
|
||||
@@ -1504,7 +1504,7 @@ const docsFeedback = (t: TFunction): TTemplate => {
|
||||
buildOpenTextQuestion({
|
||||
headline: t("templates.docs_feedback_question_2_headline"),
|
||||
required: false,
|
||||
inputType: "text",
|
||||
inputType: "url",
|
||||
t,
|
||||
}),
|
||||
buildOpenTextQuestion({
|
||||
@@ -2073,7 +2073,7 @@ const careerDevelopmentSurvey = (t: TFunction): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.career_development_survey_name"),
|
||||
role: "productManager",
|
||||
role: "peopleManager",
|
||||
industries: ["saas", "eCommerce", "other"],
|
||||
channels: ["link"],
|
||||
description: t("templates.career_development_survey_description"),
|
||||
@@ -2160,7 +2160,7 @@ const professionalDevelopmentSurvey = (t: TFunction): TTemplate => {
|
||||
return buildSurvey(
|
||||
{
|
||||
name: t("templates.professional_development_survey_name"),
|
||||
role: "productManager",
|
||||
role: "peopleManager",
|
||||
industries: ["saas", "eCommerce", "other"],
|
||||
channels: ["link"],
|
||||
description: t("templates.professional_development_survey_description"),
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { LinkSurveyLoading } from "@/modules/survey/link/loading";
|
||||
|
||||
export default LinkSurveyLoading;
|
||||
@@ -7,7 +7,18 @@
|
||||
},
|
||||
"locale": {
|
||||
"source": "en-US",
|
||||
"targets": ["de-DE", "fr-FR", "ja-JP", "pt-BR", "pt-PT", "ro-RO", "zh-Hans-CN", "zh-Hant-TW"]
|
||||
"targets": [
|
||||
"de-DE",
|
||||
"fr-FR",
|
||||
"ja-JP",
|
||||
"pt-BR",
|
||||
"pt-PT",
|
||||
"ro-RO",
|
||||
"zh-Hans-CN",
|
||||
"zh-Hant-TW",
|
||||
"nl-NL",
|
||||
"es-ES"
|
||||
]
|
||||
},
|
||||
"version": 1.8
|
||||
}
|
||||
|
||||
@@ -126,6 +126,7 @@ checksums:
|
||||
common/clear_filters: 8f40ab5af527e4b190da94e7b6221379
|
||||
common/clear_selection: af5d720527735d4253e289400d29ec9e
|
||||
common/click: 9c2744de6b5ac7333d9dae1d5cf4a76d
|
||||
common/click_to_filter: 527714113ca5fd3504e7d0bd31bca303
|
||||
common/clicks: f9e154545f87d8ede27b529e5fdf2015
|
||||
common/close: 2c2e22f8424a1031de89063bd0022e16
|
||||
common/code: 343bc5386149b97cece2b093c39034b2
|
||||
@@ -173,6 +174,7 @@ checksums:
|
||||
common/edit: eee7f39ff90b18852afc1671f21fbaa9
|
||||
common/email: e7f34943a0c2fb849db1839ff6ef5cb5
|
||||
common/ending_card: 16d30d3a36472159da8c2dbd374dfe22
|
||||
common/enter_url: 468c2276d0f2cb971ff5a47a20fa4b97
|
||||
common/enterprise_license: e81bf506f47968870c7bd07245648a0d
|
||||
common/environment_not_found: 4d7610bdb55a8b5e6131bb5b08ce04c5
|
||||
common/environment_notice: 0a860e3fa89407726dd8a2083a6b7fd5
|
||||
@@ -182,12 +184,16 @@ checksums:
|
||||
common/error_rate_limit_description: 37791a33a947204662ee9c6544e90f51
|
||||
common/error_rate_limit_title: 23ac9419e267e610e1bfd38e1dc35dc0
|
||||
common/expand_rows: b6e06327cb8718dfd6651720843e4dad
|
||||
common/failed_to_copy_to_clipboard: de836a7d628d36c832809252f188f784
|
||||
common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
|
||||
common/failed_to_load_projects: 0bba9f9b2b38c189706a486a1bb134c3
|
||||
common/finish: ffa7a10f71182b48fefed7135bee24fa
|
||||
common/follow_these: 3a730b242bb17a3f95e01bf0dae86885
|
||||
common/formbricks_version: d9967c797f3e49ca0cae78bc0ebd19cb
|
||||
common/full_name: f45991923345e8322c9ff8cd6b7e2b16
|
||||
common/gathering_responses: c5914490ed81bd77f13d411739f0c9ef
|
||||
common/general: b891e8f15579fc5d97bcaf3637f5ae59
|
||||
common/generate: 0345bf322c191e70d01fd6607ec5c2f8
|
||||
common/go_back: b917ea82facb90c88c523b255d29f84b
|
||||
common/go_to_dashboard: a6efa97d25e36fedc0af794f6ba610f2
|
||||
common/hidden: fa290c6ada5869d744ed35e9cca64699
|
||||
@@ -298,7 +304,7 @@ checksums:
|
||||
common/project_not_found: be3b516c02b05553acb4ae338511f645
|
||||
common/project_permission_not_found: ace6b03f06bd14e884e4295c5022d61b
|
||||
common/projects: fe8af5cfb3c95cb35534872a325b225e
|
||||
common/question: 0576462ce60d4263d7c482463fcc9547
|
||||
common/question: 2a47e06b62410b16003c4979dee0099f
|
||||
common/question_id: d0c3672976c281411bdccf749faf5ffd
|
||||
common/questions: 38d08215fd7a8026077c7b64eea6bb59
|
||||
common/quota: edd33b180b463ee7a70a64a5c4ad7f02
|
||||
@@ -328,6 +334,7 @@ checksums:
|
||||
common/segments: 271db72d5b973fbc5fadab216177eaae
|
||||
common/select: 5ac04c47a98deb85906bc02e0de91ab0
|
||||
common/select_all: eedc7cdb02de467c15dc418a066a77f2
|
||||
common/select_filter: c50082c3981f1161022f9787a19aed71
|
||||
common/select_survey: bac52e59c7847417bef6fe7b7096b475
|
||||
common/select_teams: ae5d451929846ae6367562bc671a1af9
|
||||
common/selected: 9f09e059ba20c88ed34e2b4e8e032d56
|
||||
@@ -394,6 +401,7 @@ checksums:
|
||||
common/user_id: 37f5ba37f71cb50607af32a6a203b1d4
|
||||
common/user_not_found: 5903581136ac6c1c1351a482a6d8fdf7
|
||||
common/variable: c13db5775ba9791b1522cc55c9c7acce
|
||||
common/variable_ids: 44bf93b70703b7699fa9f21bc6c8eed4
|
||||
common/variables: ffd3eec5497af36d7b4e4185bad1313a
|
||||
common/verified_email: d4a9e5e47d622c6ef2fede44233076c7
|
||||
common/video: 8050c90e4289b105a0780f0fdda6ff66
|
||||
@@ -487,6 +495,7 @@ checksums:
|
||||
environments/actions/add_css_class_or_id: cfc4d88412c5b9ef1157e28db4afdcc5
|
||||
environments/actions/add_regular_expression_here: 797fde3681996b85bc63c3550dec1fd4
|
||||
environments/actions/add_url: 8eba7972136a42da78a8fa4798da8e87
|
||||
environments/actions/and: 53e8eb67a396fcb5e419bb4cbf0008df
|
||||
environments/actions/click: 9c2744de6b5ac7333d9dae1d5cf4a76d
|
||||
environments/actions/contains: 41c8c25407527a5336404313f4c8d650
|
||||
environments/actions/create_action: 3abcc6dbbca18d3218ba49f90c4a66fd
|
||||
@@ -517,6 +526,7 @@ checksums:
|
||||
environments/actions/limit_to_specific_pages: f8ba95b2fc68d965689594b8a545417c
|
||||
environments/actions/matches_regex: 208b4d02b38714b4523923239e4a66b0
|
||||
environments/actions/on_all_pages: ccb8ee531a55e21eb8157c36fa75ad9a
|
||||
environments/actions/or: 0208d355f231c386b19390f0bea41b95
|
||||
environments/actions/page_filter: fe98a0bcbedb938e58cc3730589caa95
|
||||
environments/actions/page_view: 019c12b6739f6f7b1500f96ee275d47c
|
||||
environments/actions/select_match_type: b555dce1cb5c61538d3fbd792b2c71a2
|
||||
@@ -553,9 +563,18 @@ checksums:
|
||||
environments/contacts/contacts_table_refresh_success: 40951396e88e5c8fdafa0b3bb4fadca8
|
||||
environments/contacts/delete_contact_confirmation: 4304d36277daa205b4aa09f5e0d494ab
|
||||
environments/contacts/delete_contact_confirmation_with_quotas: 7c0e2e223ca55101270ac2988c53e616
|
||||
environments/contacts/generate_personal_link: 9ac0865f6876d40fe858f94eae781eb8
|
||||
environments/contacts/generate_personal_link_description: b9dbaf9e2d8362505b7e3cfa40f415a6
|
||||
environments/contacts/no_published_link_surveys_available: 9c1abc5b21aba827443cdf87dd6c8bfe
|
||||
environments/contacts/no_published_surveys: bd945b0e2e2328c17615c94143bdd62b
|
||||
environments/contacts/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
|
||||
environments/contacts/not_provided: a09e4d61bbeb04b927406a50116445e2
|
||||
environments/contacts/personal_link_generated: efb7a0420bd459847eb57bca41a4ab0d
|
||||
environments/contacts/personal_link_generated_but_clipboard_failed: 4eb1e208e729bd5ac00c33f72fc38d53
|
||||
environments/contacts/personal_survey_link: 5b3f1afc53733718c4ed5b1443b6a604
|
||||
environments/contacts/please_select_a_survey: 465aa7048773079c8ffdde8b333b78eb
|
||||
environments/contacts/search_contact: 020205a93846ab3e12c203ac4fa97c12
|
||||
environments/contacts/select_a_survey: 1f49086dfb874307aae1136e88c3d514
|
||||
environments/contacts/select_attribute: d93fb60eb4fbb42bf13a22f6216fbd79
|
||||
environments/contacts/unlock_contacts_description: c5572047f02b4c39e5109f9de715499d
|
||||
environments/contacts/unlock_contacts_title: a8b3d7db03eb404d9267fd5cdd6d5ddb
|
||||
@@ -577,6 +596,7 @@ checksums:
|
||||
environments/contacts/upload_contacts_modal_pick_different_file: e748a6e81a425ef9aa33f96ca4edc157
|
||||
environments/contacts/upload_contacts_modal_preview: c4406f8d9a54f131abfff4e9928228bb
|
||||
environments/contacts/upload_contacts_modal_upload_btn: 47b7f3bcf478a7d8dc258d2efc80af37
|
||||
environments/contacts/upload_contacts_success: cd5d6b6d587586dd4f944868c92835bc
|
||||
environments/formbricks_logo: b7ee57de32c8b13463cc8ca8643eddd4
|
||||
environments/integrations/activepieces_integration_description: 62a8fbf86762bab01c7d2db2ba60fff4
|
||||
environments/integrations/additional_settings: 20936205a75745fba2c4047375a04db3
|
||||
@@ -717,20 +737,23 @@ checksums:
|
||||
environments/project/api_keys/secret: f041e5eb96121c8b4f2b8af7e0f83a9b
|
||||
environments/project/api_keys/unable_to_delete_api_key: 1fd76d9a22c5f5f8c241c4891fca8295
|
||||
environments/project/app-connection/app_connection: 778d2305e1a9c8efe91c2c7b4af37ae4
|
||||
environments/project/app-connection/app_connection_description: 01327bfae3da950d796890b6605afed2
|
||||
environments/project/app-connection/cache_update_delay_description: 1cb2c46fdb6762ccb348d21086063a4f
|
||||
environments/project/app-connection/cache_update_delay_title: fef7f99f0228f9e30093574ac7770e7e
|
||||
environments/project/app-connection/app_connection_description: dde226414bd2265cbd0daf6635efcfdd
|
||||
environments/project/app-connection/cache_update_delay_description: 3368e4a8090b7684117a16c94f0c409c
|
||||
environments/project/app-connection/cache_update_delay_title: 60e4a0fcfbd8850bddf29b5c3f59550c
|
||||
environments/project/app-connection/environment_id: 3dba898b081c18cd4cae131765ef411f
|
||||
environments/project/app-connection/environment_id_description: 8b4a763d069b000cfa1a2025a13df80c
|
||||
environments/project/app-connection/formbricks_sdk_connected: 29e8a40ad6a7fdb5af5ee9451a70a9aa
|
||||
environments/project/app-connection/formbricks_sdk_not_connected: 557c534e665750978ba6edb0eacb428e
|
||||
environments/project/app-connection/formbricks_sdk_not_connected_description: 666b2b25f06e76554cc2d60f925bcd4b
|
||||
environments/project/app-connection/formbricks_sdk_not_connected_description: 4ddbacae084238bd0cefeded0fe9dbb9
|
||||
environments/project/app-connection/how_to_setup: 3bad40037f280b47fe6418fcbeb4c717
|
||||
environments/project/app-connection/how_to_setup_description: 2ae5cd9456a8acd3986e3d3678e70ed2
|
||||
environments/project/app-connection/receiving_data: 9f2a48c0b0278861add70b526061264c
|
||||
environments/project/app-connection/recheck: f95f2bbe6990a123d60255c87bdd59f7
|
||||
environments/project/app-connection/sdk_connection_details: 89f2c169fd1604c1df5a834517f1eae1
|
||||
environments/project/app-connection/sdk_connection_details_description: d9b5d06776a139aef6fc8ed53d71bf0a
|
||||
environments/project/app-connection/setup_alert_description: 6d676044d01dc2147731ffab7df6c259
|
||||
environments/project/app-connection/setup_alert_title: 9561cca2b391e0df81e8a982921ff2bb
|
||||
environments/project/app-connection/webapp_url: d64d8cc3c4c4ecce780d94755f7e4de9
|
||||
environments/project/general/cannot_delete_only_project: 24751701a42d8b4d2ba6112a5f642bad
|
||||
environments/project/general/delete_project: e4a2a227105c4ec71e561ab1f140eb26
|
||||
environments/project/general/delete_project_confirmation: 54a4ee78867537e0244c7170453cdb3f
|
||||
@@ -741,7 +764,7 @@ checksums:
|
||||
environments/project/general/project_deleted_successfully: dbedf0f0739b822f3951de4aeb2fc26f
|
||||
environments/project/general/project_name_settings_description: 079c6380ad539543a9aa8772bc1b0fa2
|
||||
environments/project/general/project_name_updated_successfully: f95f70f4a49d451dc0441a51d05a3aa3
|
||||
environments/project/general/recontact_waiting_time: 9c5ebb18960dec73def053de89e63272
|
||||
environments/project/general/recontact_waiting_time: 0566dc710b4b9644e276e311b419c4c0
|
||||
environments/project/general/recontact_waiting_time_settings_description: 8922cde1f95777f9a2747fb4bed57ab5
|
||||
environments/project/general/this_action_cannot_be_undone: 3d8b13374ffd3cefc0f3f7ce077bd9c9
|
||||
environments/project/general/wait_x_days_before_showing_next_survey: d96228788d32ec23dc0d8c8ba77150a6
|
||||
@@ -804,7 +827,6 @@ checksums:
|
||||
environments/project/tags/add_tag: 2cfa04ceea966149f2b5d40d9c131141
|
||||
environments/project/tags/count: 9c5848662eb8024ddf360f7e4001a968
|
||||
environments/project/tags/delete_tag_confirmation: a9fb98064cd156242899643f3d2ef032
|
||||
environments/project/tags/empty_message: da71bd7c7b5bf634469d20e010d25503
|
||||
environments/project/tags/manage_tags: 2761d558b82b6104befbc240ae2379c6
|
||||
environments/project/tags/manage_tags_description: ce7cc42da3646fba960502d7e4e49cd2
|
||||
environments/project/tags/merge: 95051c859b8778be51226b43be6f1075
|
||||
@@ -821,7 +843,6 @@ checksums:
|
||||
environments/project/teams/permission: cc2ed7274bd8267f9e0a10b079584d8b
|
||||
environments/project/teams/team_name: d1a5f99dbf503ca53f06b3a98b511d02
|
||||
environments/project/teams/team_settings_description: da32d77993f5c5c7547cdf3e1d3fc7d5
|
||||
environments/projects_environments_organizations_not_found: 9d450087c4035083f93bda9aa1889c43
|
||||
environments/segments/add_filter_below: be9b9c51d4d61903e782fb37931d8905
|
||||
environments/segments/add_your_first_filter_to_get_started: 365f9fc1600e2e44e2502e9ad9fde46a
|
||||
environments/segments/cannot_delete_segment_used_in_surveys: 134200217852566d6743245006737093
|
||||
@@ -912,15 +933,12 @@ checksums:
|
||||
environments/settings/billing/manage_subscription: 31cafd367fc70d656d8dd979d537dc96
|
||||
environments/settings/billing/monthly: 818f1192e32bb855597f930d3e78806e
|
||||
environments/settings/billing/monthly_identified_users: 0795735f6b241d31edac576a77dd7e55
|
||||
environments/settings/billing/per_month: 64e96490ee2d7811496cf04adae30aa4
|
||||
environments/settings/billing/per_year: bf02408d157486e53c15a521a5645617
|
||||
environments/settings/billing/plan_upgraded_successfully: 52e2a258cc9ca8a512c288bf6f18cf37
|
||||
environments/settings/billing/premium_support_with_slas: 2e33d4442c16bfececa6cae7b2081e5d
|
||||
environments/settings/billing/remove_branding: 88b6b818750e478bfa153b33dd658280
|
||||
environments/settings/billing/startup: 4c4ac5a0b9dc62100bca6c6465f31c4c
|
||||
environments/settings/billing/startup_description: 964fcb2c77f49b80266c94606e3f4506
|
||||
environments/settings/billing/switch_plan: fb3e1941051a4273ca29224803570f4b
|
||||
environments/settings/billing/switch_plan_confirmation_text: 910a6df56964619975c6ed5651a55db7
|
||||
environments/settings/billing/team_access_roles: 1cc4af14e589f6c09ab92a4f21958049
|
||||
environments/settings/billing/unable_to_upgrade_plan: 50fc725609411d139e534c85eeb2879e
|
||||
environments/settings/billing/unlimited_miu: 29c3f5bd01c2a09fdf1d3601665ce90f
|
||||
@@ -1141,7 +1159,6 @@ checksums:
|
||||
environments/surveys/edit/allow_multi_select: 7b4b83f7a0205e2a0a8971671a69a174
|
||||
environments/surveys/edit/allow_multiple_files: dbd99f9d1026e4f7c5a5d03f71ba379d
|
||||
environments/surveys/edit/allow_users_to_select_more_than_one_image: d683e0b538d1366400292a771f3fbd08
|
||||
environments/surveys/edit/always_show_survey: b0ae6a873ce2eeb0aea2e6d4cb04c540
|
||||
environments/surveys/edit/and_launch_surveys_in_your_website_or_app: a3edcdb4aea792a27d90aad1930f001a
|
||||
environments/surveys/edit/animation: 66a18eacfb92fc9fc9db188d2dde4f81
|
||||
environments/surveys/edit/app_survey_description: bdfacfce478e97f70b700a1382dfa687
|
||||
@@ -1224,8 +1241,7 @@ checksums:
|
||||
environments/surveys/edit/custom_hostname: bc2b1c8de3f9b8ef145b45aeba6ab429
|
||||
environments/surveys/edit/darken_or_lighten_background_of_your_choice: 304a64a8050ebf501d195e948cd25b6f
|
||||
environments/surveys/edit/date_format: e95dfc41ac944874868487457ddc057a
|
||||
environments/surveys/edit/days_before_showing_this_survey_again: 8b4623eab862615fa60064400008eb23
|
||||
environments/surveys/edit/decide_how_often_people_can_answer_this_survey: 58427b0f0a7a258c24fa2acd9913e95e
|
||||
environments/surveys/edit/days_before_showing_this_survey_again: 354fb28c5ff076f022d82a20c749ee46
|
||||
environments/surveys/edit/delete_choice: fd750208d414b9ad8c980c161a0199e1
|
||||
environments/surveys/edit/disable_the_visibility_of_survey_progress: 2af631010114307ac2a91612559c9618
|
||||
environments/surveys/edit/display_an_estimate_of_completion_time_for_survey: 03f0a816569399c1c61d08dbc913de06
|
||||
@@ -1240,7 +1256,7 @@ checksums:
|
||||
environments/surveys/edit/edit_link: 40ba9e15beac77a46c5baf30be84ac54
|
||||
environments/surveys/edit/edit_recall: 38a4a7378d02453e35d06f2532eef318
|
||||
environments/surveys/edit/edit_translations: 2b21bea4b53e88342559272701e9fbf3
|
||||
environments/surveys/edit/enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey: 71977f91ec151b61ee3528ac2618afed
|
||||
environments/surveys/edit/enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey: c70466147d49dcbb3686452f35c46428
|
||||
environments/surveys/edit/enable_recaptcha_to_protect_your_survey_from_spam: 4483a5763718d201ac97caa1e1216e13
|
||||
environments/surveys/edit/enable_spam_protection: e1fb0dd0723044bf040b92d8fc58015d
|
||||
environments/surveys/edit/end_screen_card: 6146c2bcb87291e25ecb03abd2d9a479
|
||||
@@ -1253,9 +1269,9 @@ checksums:
|
||||
environments/surveys/edit/equals_one_of: 369a451add4b79bc003f952f0e1bfcc9
|
||||
environments/surveys/edit/error_publishing_survey: bf9fab1d8ea7132a2e9b4b7b09f18b1f
|
||||
environments/surveys/edit/error_saving_changes: b75aa9e4e42e1d43c8f9c33c2b7dc9a7
|
||||
environments/surveys/edit/even_after_they_submitted_a_response_e_g_feedback_box: c6668f9cf127fd922bec695dc548fe12
|
||||
environments/surveys/edit/even_after_they_submitted_a_response_e_g_feedback_box: 7b99f30397dcde76f65e1ab64bdbd113
|
||||
environments/surveys/edit/everyone: 2112aa71b568773e8e8a792c63f4d413
|
||||
environments/surveys/edit/external_urls_paywall_tooltip: 0dbb62557e8a6fa817f0e74709eeb3d2
|
||||
environments/surveys/edit/external_urls_paywall_tooltip: a8860ff0a2ad5f283bc0becba374cd54
|
||||
environments/surveys/edit/fallback_missing: 43dbedbe1a178d455e5f80783a7b6722
|
||||
environments/surveys/edit/fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first: ad4afe2980e1dfeffb20aa78eb892350
|
||||
environments/surveys/edit/fieldId_is_used_in_quota_please_remove_it_from_quota_first: 374c563964fc805ab0b8974e781687d9
|
||||
@@ -1324,8 +1340,9 @@ checksums:
|
||||
environments/surveys/edit/hostname: 9bdaa7692869999df51bb60d58d9ef62
|
||||
environments/surveys/edit/how_funky_do_you_want_your_cards_in_survey_type_derived_surveys: 3cb16b37510c01af20a80f51b598346e
|
||||
environments/surveys/edit/if_you_need_more_please: a7d208c283caf6b93800b809fca80768
|
||||
environments/surveys/edit/if_you_really_want_that_answer_ask_until_you_get_it: 33f0320ec85067a06198a841348e9fc6
|
||||
environments/surveys/edit/ignore_waiting_time_between_surveys: 8145b6aef535fde5ee54dea63e66f64a
|
||||
environments/surveys/edit/if_you_really_want_that_answer_ask_until_you_get_it: 31c18a8c7c578db2ba49eed663d1739f
|
||||
environments/surveys/edit/ignore_global_waiting_time: 1e7f1465aeb6d26c325ad7f135b207a8
|
||||
environments/surveys/edit/ignore_global_waiting_time_description: 37d173a4d537622de40677389238d859
|
||||
environments/surveys/edit/image: 048ba7a239de0fbd883ade8558415830
|
||||
environments/surveys/edit/includes_all_of: ec72f90c0839d4c3bb518deb03894031
|
||||
environments/surveys/edit/includes_one_of: 6d5be5d7c2494179e88bd7302b247884
|
||||
@@ -1392,9 +1409,10 @@ checksums:
|
||||
environments/surveys/edit/optional: 396fb9a0472daf401c392bdc3e248943
|
||||
environments/surveys/edit/options: 59156082418d80acb211f973b1218f11
|
||||
environments/surveys/edit/override_theme_with_individual_styles_for_this_survey: edffc97f5d3372419fe0444de0a5aa3f
|
||||
environments/surveys/edit/overwrite_global_waiting_time: 7bc23bd502b6bd048356b67acd956d9d
|
||||
environments/surveys/edit/overwrite_global_waiting_time_description: 795cf6e93d4c01d2e43aa0ebab601c6e
|
||||
environments/surveys/edit/overwrite_placement: d7278be243e52c5091974e0fc4a7c342
|
||||
environments/surveys/edit/overwrite_the_global_placement_of_the_survey: 874075712254b1ce92e099d89f675a48
|
||||
environments/surveys/edit/overwrites_waiting_period_between_surveys_to_x_days: 8d5596b024cbe8c82b021dcf6c73ba05
|
||||
environments/surveys/edit/pick_a_background_from_our_library_or_upload_your_own: b83bcbdc8131fc9524d272ff5dede754
|
||||
environments/surveys/edit/picture_idx: 55e053ad1ade5d17c582406706036028
|
||||
environments/surveys/edit/pin_can_only_contain_numbers: 417c854d44620a7229ebd9ab8cbb3613
|
||||
@@ -1451,7 +1469,8 @@ checksums:
|
||||
environments/surveys/edit/range: 1fad969ecf3de1c21df046b93053c422
|
||||
environments/surveys/edit/recall_data: 39beabd626c0af15316885cff5d5d9b8
|
||||
environments/surveys/edit/recall_information_from: 884cfd143456fab1a91f0744cc92f0c8
|
||||
environments/surveys/edit/recontact_options: 0f570378a531da60448fde37abd50214
|
||||
environments/surveys/edit/recontact_options_section: 57a23e1bcab6baa484b27b615e6c906a
|
||||
environments/surveys/edit/recontact_options_section_description: 1e04011440c339a3b5cfff12d55b7f12
|
||||
environments/surveys/edit/redirect_thank_you_card: 09f721c4b62e2584e40a53507092ea83
|
||||
environments/surveys/edit/redirect_to_url: f17d726bbc3391561447b3f4010635cf
|
||||
environments/surveys/edit/remove_description: b52de820b4bbcb354eb62246c4112a9a
|
||||
@@ -1460,6 +1479,8 @@ checksums:
|
||||
environments/surveys/edit/required: 04d7fb6f37ffe0a6ca97d49e2a8b6eb5
|
||||
environments/surveys/edit/reset_to_theme_styles: f9edc3970ec23d6c4d2d7accc292ef3a
|
||||
environments/surveys/edit/reset_to_theme_styles_main_text: d86fb2213d3b2efbd0361526dc6cb27b
|
||||
environments/surveys/edit/respect_global_waiting_time: 850e7e64ec890c591b2d07741ef26e11
|
||||
environments/surveys/edit/respect_global_waiting_time_description: 5235fee102d619cb391c5aa2c75b61be
|
||||
environments/surveys/edit/response_limit_can_t_be_set_to_0: 278664873ee3b1046dbcb58848efc12a
|
||||
environments/surveys/edit/response_limit_needs_to_exceed_number_of_received_responses: 9a9c223c0918ded716ddfaa84fbaa8d9
|
||||
environments/surveys/edit/response_limits_redirections_and_more: e4f1cf94e56ad0e1b08701158d688802
|
||||
@@ -1484,7 +1505,7 @@ checksums:
|
||||
environments/surveys/edit/show_advanced_settings: b6f5bbbb84f34e51cd72ccd332e9613e
|
||||
environments/surveys/edit/show_button: 6b364aac9d7ac71f34a438607c9693bc
|
||||
environments/surveys/edit/show_language_switch: b6915a7f26d7079f2d4d844d74440413
|
||||
environments/surveys/edit/show_multiple_times: 5e6e0244c20feca78723c79aa1ddcf62
|
||||
environments/surveys/edit/show_multiple_times: 05239c532c9c05ef5d2990ba6ce12f60
|
||||
environments/surveys/edit/show_only_once: 31858baf60ebcf193c7e35d9084af0af
|
||||
environments/surveys/edit/show_survey_maximum_of: 721ed61b01a9fc8ce4becb72823bb72e
|
||||
environments/surveys/edit/show_survey_to_users: d5e90fd17babfea978fce826e9df89b0
|
||||
@@ -1514,13 +1535,12 @@ checksums:
|
||||
environments/surveys/edit/switch_multi_lanugage_on_to_get_started: d2ca06684af26bd6b5121a4656bb6458
|
||||
environments/surveys/edit/targeted: ca615f1fc3b490d5a2187b27fb4a2073
|
||||
environments/surveys/edit/ten_points: a1317b82003859f77fb3138c55450d63
|
||||
environments/surveys/edit/the_survey_will_be_shown_multiple_times_until_they_respond: 219b15081cbafaa391e266bd2cc4c9d4
|
||||
environments/surveys/edit/the_survey_will_be_shown_once_even_if_person_doesnt_respond: c145b7be481ae1fe6f66298d9a5cf838
|
||||
environments/surveys/edit/the_survey_will_be_shown_multiple_times_until_they_respond: 2d8d7d2351bd7533eb3788cce228c654
|
||||
environments/surveys/edit/the_survey_will_be_shown_once_even_if_person_doesnt_respond: 6062aaa5cf8e58e79b75b6b588ae9598
|
||||
environments/surveys/edit/then: 5e941fb7dd51a18651fcfb865edd5ba6
|
||||
environments/surveys/edit/this_action_will_remove_all_the_translations_from_this_survey: 3340c89696f10bdc01b9a1047ff0b987
|
||||
environments/surveys/edit/this_extension_is_already_added: 201d636539836c95958e28cecd8f3240
|
||||
environments/surveys/edit/this_file_type_is_not_supported: f365b9a2e05aa062ab0bc1af61f642e2
|
||||
environments/surveys/edit/this_setting_overwrites_your: 6f980149a5a4adc2cfe3dac4f367e7e5
|
||||
environments/surveys/edit/three_points: d7f299aec752d7d690ef0ab6373327ae
|
||||
environments/surveys/edit/times: 5ab156c13df6bfd75c0b17ad0a92c78a
|
||||
environments/surveys/edit/to_keep_the_placement_over_all_surveys_consistent_you_can: 7a078e6a39d4c30b465137d2b6ef3e67
|
||||
@@ -1531,7 +1551,7 @@ checksums:
|
||||
environments/surveys/edit/unlock_targeting_description: 8e315dc41c2849754839a1460643c5fb
|
||||
environments/surveys/edit/unlock_targeting_title: 6098caf969cac64cd54e217471ae42d4
|
||||
environments/surveys/edit/unsaved_changes_warning: a164f276c9f7344022aa4640b32abcf9
|
||||
environments/surveys/edit/until_they_submit_a_response: c980c520f5b5883ed46f2e1c006082b5
|
||||
environments/surveys/edit/until_they_submit_a_response: 2a0fd5dcc6cc40a72ed9b974f22eaf68
|
||||
environments/surveys/edit/upgrade_notice_description: 32b66a4f257ad8d38bc38dcc95fe23c4
|
||||
environments/surveys/edit/upgrade_notice_title: 40866066ebc558ad0c92a4f19f12090c
|
||||
environments/surveys/edit/upload: 4a6c84aa16db0f4e5697f49b45257bc7
|
||||
@@ -1539,7 +1559,6 @@ checksums:
|
||||
environments/surveys/edit/upper_label: 1fa48bce3fade6ffc1a52d9fdddf9e17
|
||||
environments/surveys/edit/url_filters: e524879d2eb74463d7fd06a7e0f53421
|
||||
environments/surveys/edit/url_not_supported: af8a753467c617b596aadef1aaaed664
|
||||
environments/surveys/edit/use_with_caution: 7c35d3ad68dd001e53cbd9d57c96af91
|
||||
environments/surveys/edit/variable_is_used_in_logic_of_question_please_remove_it_from_logic_first: bd9d9c7cf0be671c4e8cf67e2ae6659e
|
||||
environments/surveys/edit/variable_is_used_in_quota_please_remove_it_from_quota_first: 0d36e5b2713f5450fe346e0af0aaa29c
|
||||
environments/surveys/edit/variable_name_is_already_taken_please_choose_another: 6da42fe8733c6379158bce9a176f76d7
|
||||
@@ -1549,11 +1568,13 @@ checksums:
|
||||
environments/surveys/edit/variable_used_in_recall_welcome: 60321b2f40ae01cd10f99ed77bb986ba
|
||||
environments/surveys/edit/verify_email_before_submission: c05d345dc35f2d33839e4cfd72d11eb2
|
||||
environments/surveys/edit/verify_email_before_submission_description: 434ab3ee6134367513b633a9d4f7d772
|
||||
environments/surveys/edit/visibility_and_recontact: c27cb4ff3a4262266902a335c3ad5d84
|
||||
environments/surveys/edit/visibility_and_recontact_description: 2969ab679e1f6111dd96e95cee26e219
|
||||
environments/surveys/edit/wait: 014d18ade977bf08d75b995076596708
|
||||
environments/surveys/edit/wait_a_few_seconds_after_the_trigger_before_showing_the_survey: 13d5521cf73be5afeba71f5db5847919
|
||||
environments/surveys/edit/waiting_period: 21775d12b2cb831134b1f47450eaf1f3
|
||||
environments/surveys/edit/waiting_time_across_surveys: 5c5a7653d797c86c4008f13a40434ad8
|
||||
environments/surveys/edit/waiting_time_across_surveys_description: 1bbee2fee49f842056547c336f8fd788
|
||||
environments/surveys/edit/welcome_message: 986a434e3895c8ee0b267df95cc40051
|
||||
environments/surveys/edit/when_conditions_match_waiting_time_will_be_ignored_and_survey_shown: e7fe9c56664da4670e52e38656d8705d
|
||||
environments/surveys/edit/without_a_filter_all_of_your_users_can_be_surveyed: 451990569c61f25d01044cc45b1ce122
|
||||
environments/surveys/edit/you_have_not_created_a_segment_yet: c6658bd1cee9c5c957c675db044708dd
|
||||
environments/surveys/edit/you_need_to_have_two_or_more_languages_set_up_in_your_project_to_work_with_translations: b12b28699e02ff9ba69bcbae838ba5da
|
||||
@@ -1573,6 +1594,8 @@ checksums:
|
||||
environments/surveys/relevance: 9a5655d1d14efdd35052a8ed09bed127
|
||||
environments/surveys/responses/address_line_1: 44788358e7a7c25b0b79bc3090ed15f5
|
||||
environments/surveys/responses/address_line_2: fc4b5a87de46ac4a28a6616f47a34135
|
||||
environments/surveys/responses/an_error_occurred_adding_the_tag: f211ea1ceb8a93b415d88a8deed874ef
|
||||
environments/surveys/responses/an_error_occurred_creating_the_tag: 89689815f8aff6ff3ba821ab599c540c
|
||||
environments/surveys/responses/an_error_occurred_deleting_the_tag: c63f28ac2a4cda558423ea7f975d5b8b
|
||||
environments/surveys/responses/browser: e58e554eb7b0761ede25f2425173d31f
|
||||
environments/surveys/responses/bulk_delete_response_quotas: ae1b3a7684c53ea681a3de6c7f911e70
|
||||
@@ -1592,7 +1615,7 @@ checksums:
|
||||
environments/surveys/responses/last_name: 2c9a7de7738ca007ba9023c385149c26
|
||||
environments/surveys/responses/not_completed: df34eab65a6291f2c5e15a0e349c4eba
|
||||
environments/surveys/responses/os: a4c753bb2c004a58d02faeed6b4da476
|
||||
environments/surveys/responses/person_attributes: 8f7f8a9040ce8efb3cb54ce33b590866
|
||||
environments/surveys/responses/person_attributes: 07ae67ae73d7a2a7c67008694a83f0a3
|
||||
environments/surveys/responses/phone: b9537ee90fc5b0116942e0af29d926cc
|
||||
environments/surveys/responses/respondent_skipped_questions: d85daf579ade534dc7e639689156fcd5
|
||||
environments/surveys/responses/response_deleted_successfully: 6cec5427c271800619fee8c812d7db18
|
||||
@@ -1687,6 +1710,7 @@ checksums:
|
||||
environments/surveys/share/social_media/title: 1bf4899b063ee8f02f7188576555828b
|
||||
environments/surveys/summary/added_filter_for_responses_where_answer_to_question: 5bddf0d4f771efd06d58441d11fa5091
|
||||
environments/surveys/summary/added_filter_for_responses_where_answer_to_question_is_skipped: 74ca713c491cfc33751a5db3de972821
|
||||
environments/surveys/summary/aggregated: 9d4e77225d5952abed414fffd828c078
|
||||
environments/surveys/summary/all_responses_csv: 16c0c211853f0839a79f1127ec679ca2
|
||||
environments/surveys/summary/all_responses_excel: 8bf18916ab127f16bfcf9f38956710b0
|
||||
environments/surveys/summary/all_time: 62258944e7c2e83f3ebf69074b2c2156
|
||||
@@ -1710,7 +1734,6 @@ checksums:
|
||||
environments/surveys/summary/filtered_responses_csv: aad66a98be6a09cac8bef9e4db4a75cf
|
||||
environments/surveys/summary/filtered_responses_excel: 06e57bae9e41979fd7fc4b8bfe3466f9
|
||||
environments/surveys/summary/generating_qr_code: 5026d4a76f995db458195e5215d9bbd9
|
||||
environments/surveys/summary/go_to_setup_checklist: d70bd018d651d01c41ae10370e71d0be
|
||||
environments/surveys/summary/impressions: 7fe38d42d68a64d3fd8436a063751584
|
||||
environments/surveys/summary/impressions_tooltip: 4d0823cbf360304770c7c5913e33fdc8
|
||||
environments/surveys/summary/in_app/connection_description: 9710bbf8048a8a5c3b2b56db9d946b73
|
||||
@@ -1742,7 +1765,7 @@ checksums:
|
||||
environments/surveys/summary/in_app/title: a2d1b633244d0e0504ec6f8f561c7a6b
|
||||
environments/surveys/summary/includes_all: b0e3679282417c62d511c258362f860e
|
||||
environments/surveys/summary/includes_either: 186d6923c1693e80d7b664b8367d4221
|
||||
environments/surveys/summary/install_widget: 55d403de32e3d0da7513ab199f1d1934
|
||||
environments/surveys/summary/individual: 52ebce389ed97a13b6089802055ed667
|
||||
environments/surveys/summary/is_equal_to: f4aab30ef188eb25dcc0e392cf8e86bb
|
||||
environments/surveys/summary/is_less_than: 6109d595ba21497c59b1c91d7fd09a13
|
||||
environments/surveys/summary/last_30_days: a738894cfc5e592052f1e16787744568
|
||||
@@ -1755,6 +1778,7 @@ checksums:
|
||||
environments/surveys/summary/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
|
||||
environments/surveys/summary/other_values_found: 48a74ee68c05f7fb162072b50c683b6a
|
||||
environments/surveys/summary/overall: 6c6d6533013d4739766af84b2871bca6
|
||||
environments/surveys/summary/promoters: 41fbb8d0439227661253a82fda39f521
|
||||
environments/surveys/summary/qr_code: 48cb2a8c07a3d1647f766f93bb9e9382
|
||||
environments/surveys/summary/qr_code_description: 19f48dcf473809f178abf4212657ef14
|
||||
environments/surveys/summary/qr_code_download_failed: 2764b5615112800da27eecafc21e3472
|
||||
@@ -1764,12 +1788,12 @@ checksums:
|
||||
environments/surveys/summary/quotas_completed_tooltip: ec5c4dc67eda27c06764354f695db613
|
||||
environments/surveys/summary/reset_survey: 8c88ddb81f5f787d183d2e7cb43e7c64
|
||||
environments/surveys/summary/reset_survey_warning: 6b44be171d7e2716f234387b100b173d
|
||||
environments/surveys/summary/satisfied: 4d542ba354b85e644acbca5691d2ce45
|
||||
environments/surveys/summary/selected_responses_csv: 9cef3faccd54d4f24647791e6359db90
|
||||
environments/surveys/summary/selected_responses_excel: a0ade8b2658e887a4a3f2ad3bdb0c686
|
||||
environments/surveys/summary/setup_integrations: 70de06d73be671a0cd58a3fd4fa62e53
|
||||
environments/surveys/summary/share_survey: b77bc25bae24b97f39e95dd2a6d74515
|
||||
environments/surveys/summary/show_all_responses_that_match: c199f03983d7fcdd5972cc2759558c68
|
||||
environments/surveys/summary/show_all_responses_where: 370a56de4692a588f7ebdbf7f1e28f6f
|
||||
environments/surveys/summary/starts: 3153990a4ade414f501a7e63ab771362
|
||||
environments/surveys/summary/starts_tooltip: 0a7dd01320490dbbea923053fa1ccad6
|
||||
environments/surveys/summary/survey_reset_successfully: bd50acaafccb709527072ac0da6c8bfd
|
||||
@@ -1780,7 +1804,6 @@ checksums:
|
||||
environments/surveys/summary/ttc_tooltip: 9b1cbe32cc81111314bd3b6fd050c2e7
|
||||
environments/surveys/summary/unknown_question_type: e4152a7457d2b94f48dcc70aaba9922f
|
||||
environments/surveys/summary/use_personal_links: da2b3e7e1aaf2ea2bd4efed2dda4247c
|
||||
environments/surveys/summary/waiting_for_response: 0194a84e0850b8e98435632d5331a916
|
||||
environments/surveys/summary/whats_next: d920145bfa2147014062f6f2d1d451a4
|
||||
environments/surveys/summary/your_survey_is_public: 3f5cb5949a5f4020a3d4d74fdfc95e83
|
||||
environments/surveys/summary/youre_not_plugged_in_yet: 9217467742cdcf7edf8d59cc1472ede6
|
||||
|
||||
@@ -19,8 +19,7 @@ export const ENCRYPTION_KEY = env.ENCRYPTION_KEY;
|
||||
// Other
|
||||
export const CRON_SECRET = env.CRON_SECRET;
|
||||
export const DEFAULT_BRAND_COLOR = "#64748b";
|
||||
export const FB_LOGO_URL =
|
||||
"https://s3.eu-central-1.amazonaws.com/listmonk-formbricks/Formbricks-Light-transparent.png";
|
||||
export const FB_LOGO_URL = `${WEBAPP_URL}/logo-transparent.png`;
|
||||
|
||||
export const PRIVACY_URL = env.PRIVACY_URL;
|
||||
export const TERMS_URL = env.TERMS_URL;
|
||||
@@ -170,11 +169,13 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [
|
||||
"de-DE",
|
||||
"pt-BR",
|
||||
"fr-FR",
|
||||
"nl-NL",
|
||||
"zh-Hant-TW",
|
||||
"pt-PT",
|
||||
"ro-RO",
|
||||
"ja-JP",
|
||||
"zh-Hans-CN",
|
||||
"es-ES",
|
||||
];
|
||||
|
||||
// Billing constants
|
||||
@@ -182,21 +183,17 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [
|
||||
export enum PROJECT_FEATURE_KEYS {
|
||||
FREE = "free",
|
||||
STARTUP = "startup",
|
||||
SCALE = "scale",
|
||||
ENTERPRISE = "enterprise",
|
||||
CUSTOM = "custom",
|
||||
}
|
||||
|
||||
export enum STRIPE_PROJECT_NAMES {
|
||||
STARTUP = "Formbricks Startup",
|
||||
SCALE = "Formbricks Scale",
|
||||
ENTERPRISE = "Formbricks Enterprise",
|
||||
CUSTOM = "Formbricks Custom",
|
||||
}
|
||||
|
||||
export enum STRIPE_PRICE_LOOKUP_KEYS {
|
||||
STARTUP_MAY25_MONTHLY = "STARTUP_MAY25_MONTHLY",
|
||||
STARTUP_MAY25_YEARLY = "STARTUP_MAY25_YEARLY",
|
||||
SCALE_MONTHLY = "formbricks_scale_monthly",
|
||||
SCALE_YEARLY = "formbricks_scale_yearly",
|
||||
}
|
||||
|
||||
export const BILLING_LIMITS = {
|
||||
@@ -210,10 +207,10 @@ export const BILLING_LIMITS = {
|
||||
RESPONSES: 5000,
|
||||
MIU: 7500,
|
||||
},
|
||||
SCALE: {
|
||||
PROJECTS: 5,
|
||||
RESPONSES: 10000,
|
||||
MIU: 30000,
|
||||
CUSTOM: {
|
||||
PROJECTS: null,
|
||||
RESPONSES: null,
|
||||
MIU: null,
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -221,10 +218,6 @@ export const INTERCOM_SECRET_KEY = env.INTERCOM_SECRET_KEY;
|
||||
export const INTERCOM_APP_ID = env.INTERCOM_APP_ID;
|
||||
export const IS_INTERCOM_CONFIGURED = Boolean(env.INTERCOM_APP_ID && INTERCOM_SECRET_KEY);
|
||||
|
||||
export const POSTHOG_API_KEY = env.POSTHOG_API_KEY;
|
||||
export const POSTHOG_API_HOST = env.POSTHOG_API_HOST;
|
||||
export const IS_POSTHOG_CONFIGURED = Boolean(POSTHOG_API_KEY && POSTHOG_API_HOST);
|
||||
|
||||
export const TURNSTILE_SECRET_KEY = env.TURNSTILE_SECRET_KEY;
|
||||
export const TURNSTILE_SITE_KEY = env.TURNSTILE_SITE_KEY;
|
||||
export const IS_TURNSTILE_CONFIGURED = Boolean(env.TURNSTILE_SITE_KEY && TURNSTILE_SECRET_KEY);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
// During build time, we only need valid-format URLs for Prisma to generate the client.
|
||||
// Actual connectivity and secrets are validated at runtime by the startup script.
|
||||
const isBuildTime = process.env.NEXT_PHASE === "phase-production-build";
|
||||
|
||||
export const env = createEnv({
|
||||
/*
|
||||
* Serverside Environment variables, not available on the client.
|
||||
@@ -14,14 +18,21 @@ export const env = createEnv({
|
||||
CRON_SECRET: z.string().optional(),
|
||||
BREVO_API_KEY: z.string().optional(),
|
||||
BREVO_LIST_ID: z.string().optional(),
|
||||
DATABASE_URL: z.string().url(),
|
||||
DATABASE_URL: isBuildTime
|
||||
? z
|
||||
.string()
|
||||
.optional()
|
||||
.default("postgresql://formbricks:formbricks@localhost:5432/formbricks?schema=public")
|
||||
: z.string().url(),
|
||||
DEBUG: z.enum(["1", "0"]).optional(),
|
||||
AUTH_DEFAULT_TEAM_ID: z.string().optional(),
|
||||
AUTH_SKIP_INVITE_FOR_SSO: z.enum(["1", "0"]).optional(),
|
||||
E2E_TESTING: z.enum(["1", "0"]).optional(),
|
||||
EMAIL_AUTH_DISABLED: z.enum(["1", "0"]).optional(),
|
||||
EMAIL_VERIFICATION_DISABLED: z.enum(["1", "0"]).optional(),
|
||||
ENCRYPTION_KEY: z.string(),
|
||||
ENCRYPTION_KEY: isBuildTime
|
||||
? z.string().optional().default("0000000000000000000000000000000000000000000000000000000000000000")
|
||||
: z.string(),
|
||||
ENTERPRISE_LICENSE_KEY: z.string().optional(),
|
||||
GITHUB_ID: z.string().optional(),
|
||||
GITHUB_SECRET: z.string().optional(),
|
||||
@@ -54,13 +65,12 @@ export const env = createEnv({
|
||||
OIDC_ISSUER: z.string().optional(),
|
||||
OIDC_SIGNING_ALGORITHM: z.string().optional(),
|
||||
OPENTELEMETRY_LISTENER_URL: z.string().optional(),
|
||||
REDIS_URL:
|
||||
process.env.NODE_ENV === "test"
|
||||
REDIS_URL: isBuildTime
|
||||
? z.string().optional().default("redis://localhost:6379")
|
||||
: process.env.NODE_ENV === "test"
|
||||
? z.string().optional()
|
||||
: z.string().url("REDIS_URL is required for caching, rate limiting, and audit logging"),
|
||||
PASSWORD_RESET_DISABLED: z.enum(["1", "0"]).optional(),
|
||||
POSTHOG_API_HOST: z.string().optional(),
|
||||
POSTHOG_API_KEY: z.string().optional(),
|
||||
PRIVACY_URL: z
|
||||
.string()
|
||||
.url()
|
||||
@@ -103,7 +113,6 @@ export const env = createEnv({
|
||||
}
|
||||
)
|
||||
.optional(),
|
||||
TELEMETRY_DISABLED: z.enum(["1", "0"]).optional(),
|
||||
TERMS_URL: z
|
||||
.string()
|
||||
.url()
|
||||
@@ -172,8 +181,6 @@ export const env = createEnv({
|
||||
MAIL_FROM_NAME: process.env.MAIL_FROM_NAME,
|
||||
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
|
||||
SENTRY_DSN: process.env.SENTRY_DSN,
|
||||
POSTHOG_API_KEY: process.env.POSTHOG_API_KEY,
|
||||
POSTHOG_API_HOST: process.env.POSTHOG_API_HOST,
|
||||
OPENTELEMETRY_LISTENER_URL: process.env.OPENTELEMETRY_LISTENER_URL,
|
||||
INTERCOM_APP_ID: process.env.INTERCOM_APP_ID,
|
||||
NOTION_OAUTH_CLIENT_ID: process.env.NOTION_OAUTH_CLIENT_ID,
|
||||
@@ -206,7 +213,6 @@ export const env = createEnv({
|
||||
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
|
||||
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
|
||||
PUBLIC_URL: process.env.PUBLIC_URL,
|
||||
TELEMETRY_DISABLED: process.env.TELEMETRY_DISABLED,
|
||||
TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY,
|
||||
TURNSTILE_SITE_KEY: process.env.TURNSTILE_SITE_KEY,
|
||||
RECAPTCHA_SITE_KEY: process.env.RECAPTCHA_SITE_KEY,
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
} from "@formbricks/types/environment";
|
||||
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
|
||||
import { getOrganizationsByUserId } from "../organization/service";
|
||||
import { capturePosthogEnvironmentEvent } from "../posthogServer";
|
||||
import { getUserProjects } from "../project/service";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
@@ -173,10 +172,6 @@ export const createEnvironment = async (
|
||||
},
|
||||
});
|
||||
|
||||
await capturePosthogEnvironmentEvent(environment.id, "environment created", {
|
||||
environmentType: environment.type,
|
||||
});
|
||||
|
||||
return environment;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
|
||||
@@ -137,6 +137,8 @@ export const appLanguages = [
|
||||
"ro-RO": "Engleză (SUA)",
|
||||
"ja-JP": "英語(米国)",
|
||||
"zh-Hans-CN": "英语(美国)",
|
||||
"nl-NL": "Engels (VS)",
|
||||
"es-ES": "Inglés (EE.UU.)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -151,6 +153,8 @@ export const appLanguages = [
|
||||
"ro-RO": "Germană",
|
||||
"ja-JP": "ドイツ語",
|
||||
"zh-Hans-CN": "德语",
|
||||
"nl-NL": "Duits",
|
||||
"es-ES": "Alemán",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -165,6 +169,8 @@ export const appLanguages = [
|
||||
"ro-RO": "Portugheză (Brazilia)",
|
||||
"ja-JP": "ポルトガル語(ブラジル)",
|
||||
"zh-Hans-CN": "葡萄牙语(巴西)",
|
||||
"nl-NL": "Portugees (Brazilië)",
|
||||
"es-ES": "Portugués (Brasil)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -179,6 +185,8 @@ export const appLanguages = [
|
||||
"ro-RO": "Franceză",
|
||||
"ja-JP": "フランス語",
|
||||
"zh-Hans-CN": "法语",
|
||||
"nl-NL": "Frans",
|
||||
"es-ES": "Francés",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -193,6 +201,8 @@ export const appLanguages = [
|
||||
"ro-RO": "Chineză (Tradicională)",
|
||||
"ja-JP": "中国語(繁体字)",
|
||||
"zh-Hans-CN": "繁体中文",
|
||||
"nl-NL": "Chinees (Traditioneel)",
|
||||
"es-ES": "Chino (Tradicional)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -207,6 +217,8 @@ export const appLanguages = [
|
||||
"ro-RO": "Portugheză (Portugalia)",
|
||||
"ja-JP": "ポルトガル語(ポルトガル)",
|
||||
"zh-Hans-CN": "葡萄牙语(葡萄牙)",
|
||||
"nl-NL": "Portugees (Portugal)",
|
||||
"es-ES": "Portugués (Portugal)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -221,6 +233,8 @@ export const appLanguages = [
|
||||
"ro-RO": "Română",
|
||||
"ja-JP": "ルーマニア語",
|
||||
"zh-Hans-CN": "罗马尼亚语",
|
||||
"nl-NL": "Roemeens",
|
||||
"es-ES": "Rumano",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -235,6 +249,8 @@ export const appLanguages = [
|
||||
"ro-RO": "Japoneză",
|
||||
"ja-JP": "日本語",
|
||||
"zh-Hans-CN": "日语",
|
||||
"nl-NL": "Japans",
|
||||
"es-ES": "Japonés",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -249,6 +265,40 @@ export const appLanguages = [
|
||||
"ro-RO": "Chineză (Simplificată)",
|
||||
"ja-JP": "中国語(簡体字)",
|
||||
"zh-Hans-CN": "简体中文",
|
||||
"nl-NL": "Chinees (Vereenvoudigd)",
|
||||
"es-ES": "Chino (Simplificado)",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "nl-NL",
|
||||
label: {
|
||||
"en-US": "Dutch",
|
||||
"de-DE": "Niederländisch",
|
||||
"pt-BR": "Holandês",
|
||||
"fr-FR": "Néerlandais",
|
||||
"zh-Hant-TW": "荷蘭語",
|
||||
"pt-PT": "Holandês",
|
||||
"ro-RO": "Olandeză",
|
||||
"ja-JP": "オランダ語",
|
||||
"zh-Hans-CN": "荷兰语",
|
||||
"nl-NL": "Nederlands",
|
||||
"es-ES": "Neerlandés",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "es-ES",
|
||||
label: {
|
||||
"en-US": "Spanish",
|
||||
"de-DE": "Spanisch",
|
||||
"pt-BR": "Espanhol",
|
||||
"fr-FR": "Espagnol",
|
||||
"zh-Hant-TW": "西班牙語",
|
||||
"pt-PT": "Espanhol",
|
||||
"ro-RO": "Spaniol",
|
||||
"ja-JP": "スペイン語",
|
||||
"zh-Hans-CN": "西班牙语",
|
||||
"nl-NL": "Spaans",
|
||||
"es-ES": "Español",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { PostHog } from "posthog-node";
|
||||
import { createCacheKey } from "@formbricks/cache";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TOrganizationBillingPlan, TOrganizationBillingPlanLimits } from "@formbricks/types/organizations";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { IS_POSTHOG_CONFIGURED, IS_PRODUCTION, POSTHOG_API_HOST, POSTHOG_API_KEY } from "./constants";
|
||||
|
||||
const enabled = IS_PRODUCTION && IS_POSTHOG_CONFIGURED;
|
||||
|
||||
export const capturePosthogEnvironmentEvent = async (
|
||||
environmentId: string,
|
||||
eventName: string,
|
||||
properties: any = {}
|
||||
) => {
|
||||
if (!enabled || typeof POSTHOG_API_HOST !== "string" || typeof POSTHOG_API_KEY !== "string") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const client = new PostHog(POSTHOG_API_KEY, {
|
||||
host: POSTHOG_API_HOST,
|
||||
});
|
||||
client.capture({
|
||||
// workaround with a static string as exaplained in PostHog docs: https://posthog.com/docs/product-analytics/group-analytics
|
||||
distinctId: "environmentEvents",
|
||||
event: eventName,
|
||||
groups: { environment: environmentId },
|
||||
properties,
|
||||
});
|
||||
await client.shutdown();
|
||||
} catch (error) {
|
||||
logger.error(error, "error sending posthog event");
|
||||
}
|
||||
};
|
||||
|
||||
export const sendPlanLimitsReachedEventToPosthogWeekly = async (
|
||||
environmentId: string,
|
||||
billing: {
|
||||
plan: TOrganizationBillingPlan;
|
||||
limits: TOrganizationBillingPlanLimits;
|
||||
}
|
||||
) =>
|
||||
await cache.withCache(
|
||||
async () => {
|
||||
try {
|
||||
await capturePosthogEnvironmentEvent(environmentId, "plan limit reached", {
|
||||
...billing,
|
||||
});
|
||||
return "success";
|
||||
} catch (error) {
|
||||
logger.error(error, "error sending plan limits reached event to posthog weekly");
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
createCacheKey.custom("analytics", environmentId, `plan_limits_${billing.plan}`),
|
||||
60 * 60 * 24 * 7 * 1000 // 7 days in milliseconds
|
||||
);
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
getOrganizationByEnvironmentId,
|
||||
subscribeOrganizationMembersToSurveyResponses,
|
||||
} from "@/lib/organization/service";
|
||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||
import { evaluateLogic } from "@/lib/surveyLogic/utils";
|
||||
import {
|
||||
mockActionClass,
|
||||
@@ -44,11 +43,6 @@ vi.mock("@/lib/organization/service", () => ({
|
||||
subscribeOrganizationMembersToSurveyResponses: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock posthogServer
|
||||
vi.mock("@/lib/posthogServer", () => ({
|
||||
capturePosthogEnvironmentEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock actionClass service
|
||||
vi.mock("@/lib/actionClass/service", () => ({
|
||||
getActionClasses: vi.fn(),
|
||||
@@ -646,7 +640,6 @@ describe("Tests for createSurvey", () => {
|
||||
expect(prisma.survey.create).toHaveBeenCalled();
|
||||
expect(result.name).toEqual(mockSurveyOutput.name);
|
||||
expect(subscribeOrganizationMembersToSurveyResponses).toHaveBeenCalled();
|
||||
expect(capturePosthogEnvironmentEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("creates a private segment for app surveys", async () => {
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
} from "@/lib/organization/service";
|
||||
import { getActionClasses } from "../actionClass/service";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
import { capturePosthogEnvironmentEvent } from "../posthogServer";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { checkForInvalidImagesInQuestions, transformPrismaSurvey } from "./utils";
|
||||
|
||||
@@ -673,11 +672,6 @@ export const createSurvey = async (
|
||||
await subscribeOrganizationMembersToSurveyResponses(survey.id, createdBy, organization.id);
|
||||
}
|
||||
|
||||
await capturePosthogEnvironmentEvent(survey.environmentId, "survey created", {
|
||||
surveyId: survey.id,
|
||||
surveyType: survey.type,
|
||||
});
|
||||
|
||||
return transformedSurvey;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user