mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-28 20:49:34 -05:00
Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3d679d087 | |||
| c79a600efc | |||
| 7a8da3b84b | |||
| 4b2d48397d | |||
| 3ea81dc7c1 | |||
| d9b6b550a9 | |||
| 56a6ba08ba | |||
| 1ba55ff66c | |||
| 0cf621d76c | |||
| 3dc615fdc0 | |||
| 7157b17901 | |||
| 82c26941e4 | |||
| 591d5fa3d4 | |||
| 211bca1bd8 | |||
| 5a20839c5b | |||
| 85743bd3d0 | |||
| 335ec02361 | |||
| 7918523957 | |||
| 3b5fe4cb94 | |||
| 6bbd5ec7ef | |||
| c9542dcf79 | |||
| 4277a9dc34 | |||
| b1da63e47d | |||
| 8c05154a86 | |||
| 45122de652 | |||
| 2180bf98ba | |||
| 2d4a94721b | |||
| b2b97c8bed | |||
| f349f7199d | |||
| e7d8803a13 | |||
| 53a9b218bc | |||
| c618e7d473 | |||
| 3d0f703ae1 | |||
| 33eadaaa7b | |||
| 452617529c | |||
| 5951eea618 | |||
| e314feb416 | |||
| 0910b0f1a7 | |||
| 10ba42eb31 | |||
| 04f1e17e23 | |||
| 4642cc60c9 | |||
| 49fa5c587c | |||
| 4f9b48b5e5 | |||
| 80789327d0 | |||
| 38108a32d1 | |||
| ce4b64da0e | |||
| 9790b071d7 | |||
| 1f5ba0e60e | |||
| b502bbc91e | |||
| 6772ac7c20 |
@@ -179,14 +179,14 @@ For endpoints serving client SDKs, coordinate TTLs across layers:
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Client SDK cache (expiresAt) - longest TTL for fewer requests
|
// Client SDK cache (expiresAt) - longest TTL for fewer requests
|
||||||
const CLIENT_TTL = 60; // 1 minute (seconds for client)
|
const CLIENT_TTL = 60 * 60; // 1 hour (seconds for client)
|
||||||
|
|
||||||
// Server Redis cache - shorter TTL ensures fresh data for clients
|
// Server Redis cache - shorter TTL ensures fresh data for clients
|
||||||
const SERVER_TTL = 60 * 1000; // 1 minutes in milliseconds
|
const SERVER_TTL = 60 * 30 * 1000; // 30 minutes in milliseconds
|
||||||
|
|
||||||
// HTTP cache headers (seconds)
|
// HTTP cache headers (seconds)
|
||||||
const BROWSER_TTL = 60; // 1 minute (max-age)
|
const BROWSER_TTL = 60 * 60; // 1 hour (max-age)
|
||||||
const CDN_TTL = 60; // 1 minute (s-maxage)
|
const CDN_TTL = 60 * 30; // 30 minutes (s-maxage)
|
||||||
const CORS_TTL = 60 * 60; // 1 hour (balanced approach)
|
const CORS_TTL = 60 * 60; // 1 hour (balanced approach)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
---
|
---
|
||||||
description: >
|
description: >
|
||||||
globs: schema.prisma
|
This rule provides comprehensive knowledge about the Formbricks database structure, relationships,
|
||||||
alwaysApply: false
|
and data patterns. It should be used **only when the agent explicitly requests database schema-level
|
||||||
|
details** to support tasks such as: writing/debugging Prisma queries, designing/reviewing data models,
|
||||||
|
investigating multi-tenancy behavior, creating API endpoints, or understanding data relationships.
|
||||||
|
globs: []
|
||||||
|
alwaysApply: agent-requested
|
||||||
---
|
---
|
||||||
|
|
||||||
# Formbricks Database Schema Reference
|
# Formbricks Database Schema Reference
|
||||||
|
|
||||||
This rule provides a reference to the Formbricks database structure. For the most up-to-date and complete schema definitions, please refer to the schema.prisma file directly.
|
This rule provides a reference to the Formbricks database structure. For the most up-to-date and complete schema definitions, please refer to the schema.prisma file directly.
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
@@ -17,6 +17,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
TELEMETRY_DISABLED: 1
|
||||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ jobs:
|
|||||||
- check-latest-release
|
- check-latest-release
|
||||||
with:
|
with:
|
||||||
IS_PRERELEASE: ${{ github.event.release.prerelease }}
|
IS_PRERELEASE: ${{ github.event.release.prerelease }}
|
||||||
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
|
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest }}
|
||||||
|
|
||||||
docker-build-cloud:
|
docker-build-cloud:
|
||||||
name: Build & push Formbricks Cloud to ECR
|
name: Build & push Formbricks Cloud to ECR
|
||||||
@@ -101,7 +101,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
image_tag: ${{ needs.docker-build-community.outputs.VERSION }}
|
image_tag: ${{ needs.docker-build-community.outputs.VERSION }}
|
||||||
IS_PRERELEASE: ${{ github.event.release.prerelease }}
|
IS_PRERELEASE: ${{ github.event.release.prerelease }}
|
||||||
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
|
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest }}
|
||||||
needs:
|
needs:
|
||||||
- check-latest-release
|
- check-latest-release
|
||||||
- docker-build-community
|
- docker-build-community
|
||||||
@@ -154,4 +154,4 @@ jobs:
|
|||||||
release_tag: ${{ github.event.release.tag_name }}
|
release_tag: ${{ github.event.release.tag_name }}
|
||||||
commit_sha: ${{ github.sha }}
|
commit_sha: ${{ github.sha }}
|
||||||
is_prerelease: ${{ github.event.release.prerelease }}
|
is_prerelease: ${{ github.event.release.prerelease }}
|
||||||
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
|
make_latest: ${{ needs.check-latest-release.outputs.is_latest }}
|
||||||
|
|||||||
+1
-1
@@ -124,7 +124,7 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
|
|||||||
COPY --from=installer /app/node_modules/zod ./node_modules/zod
|
COPY --from=installer /app/node_modules/zod ./node_modules/zod
|
||||||
RUN chmod -R 755 ./node_modules/zod
|
RUN chmod -R 755 ./node_modules/zod
|
||||||
|
|
||||||
RUN npm install -g prisma@6
|
RUN npm install -g prisma
|
||||||
|
|
||||||
# Create a startup script to handle the conditional logic
|
# Create a startup script to handle the conditional logic
|
||||||
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
|
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
|
||||||
|
|||||||
+7
-7
@@ -105,7 +105,7 @@ const starRatingSurvey = (t: TFunction): TXMTemplate => {
|
|||||||
id: createId(),
|
id: createId(),
|
||||||
leftOperand: {
|
leftOperand: {
|
||||||
value: reusableElementIds[0],
|
value: reusableElementIds[0],
|
||||||
type: "element",
|
type: "question",
|
||||||
},
|
},
|
||||||
operator: "isLessThanOrEqual",
|
operator: "isLessThanOrEqual",
|
||||||
rightOperand: {
|
rightOperand: {
|
||||||
@@ -133,13 +133,13 @@ const starRatingSurvey = (t: TFunction): TXMTemplate => {
|
|||||||
id: reusableElementIds[1],
|
id: reusableElementIds[1],
|
||||||
subheader: t("templates.star_rating_survey_question_2_html"),
|
subheader: t("templates.star_rating_survey_question_2_html"),
|
||||||
headline: t("templates.star_rating_survey_question_2_headline"),
|
headline: t("templates.star_rating_survey_question_2_headline"),
|
||||||
required: false,
|
required: true,
|
||||||
buttonUrl: "https://formbricks.com/github",
|
buttonUrl: "https://formbricks.com/github",
|
||||||
buttonExternal: true,
|
buttonExternal: true,
|
||||||
ctaButtonLabel: t("templates.star_rating_survey_question_2_button_label"),
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
logic: [createBlockJumpLogic(reusableElementIds[1], defaultSurvey.endings[0].id, "isClicked")],
|
logic: [createBlockJumpLogic(reusableElementIds[1], defaultSurvey.endings[0].id, "isClicked")],
|
||||||
|
buttonLabel: t("templates.star_rating_survey_question_2_button_label"),
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildBlock({
|
buildBlock({
|
||||||
@@ -195,7 +195,7 @@ const csatSurvey = (t: TFunction): TXMTemplate => {
|
|||||||
id: createId(),
|
id: createId(),
|
||||||
leftOperand: {
|
leftOperand: {
|
||||||
value: reusableElementIds[0],
|
value: reusableElementIds[0],
|
||||||
type: "element",
|
type: "question",
|
||||||
},
|
},
|
||||||
operator: "isLessThanOrEqual",
|
operator: "isLessThanOrEqual",
|
||||||
rightOperand: {
|
rightOperand: {
|
||||||
@@ -316,7 +316,7 @@ const smileysRatingSurvey = (t: TFunction): TXMTemplate => {
|
|||||||
id: createId(),
|
id: createId(),
|
||||||
leftOperand: {
|
leftOperand: {
|
||||||
value: reusableElementIds[0],
|
value: reusableElementIds[0],
|
||||||
type: "element",
|
type: "question",
|
||||||
},
|
},
|
||||||
operator: "isLessThanOrEqual",
|
operator: "isLessThanOrEqual",
|
||||||
rightOperand: {
|
rightOperand: {
|
||||||
@@ -344,13 +344,13 @@ const smileysRatingSurvey = (t: TFunction): TXMTemplate => {
|
|||||||
id: reusableElementIds[1],
|
id: reusableElementIds[1],
|
||||||
subheader: t("templates.smileys_survey_question_2_html"),
|
subheader: t("templates.smileys_survey_question_2_html"),
|
||||||
headline: t("templates.smileys_survey_question_2_headline"),
|
headline: t("templates.smileys_survey_question_2_headline"),
|
||||||
required: false,
|
required: true,
|
||||||
buttonUrl: "https://formbricks.com/github",
|
buttonUrl: "https://formbricks.com/github",
|
||||||
buttonExternal: true,
|
buttonExternal: true,
|
||||||
ctaButtonLabel: t("templates.smileys_survey_question_2_button_label"),
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
logic: [createBlockJumpLogic(reusableElementIds[1], defaultSurvey.endings[0].id, "isClicked")],
|
logic: [createBlockJumpLogic(reusableElementIds[1], defaultSurvey.endings[0].id, "isClicked")],
|
||||||
|
buttonLabel: t("templates.smileys_survey_question_2_button_label"),
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildBlock({
|
buildBlock({
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { AuthorizationError } from "@formbricks/types/errors";
|
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 { canUserAccessOrganization } from "@/lib/organization/auth";
|
||||||
import { getOrganization } from "@/lib/organization/service";
|
import { getOrganization } from "@/lib/organization/service";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
@@ -38,6 +40,14 @@ const ProjectOnboardingLayout = async (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 bg-slate-50">
|
<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 />
|
<ToasterClient />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getEnvironment } from "@/lib/environment/service";
|
import { getEnvironment } from "@/lib/environment/service";
|
||||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||||
|
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
|
||||||
|
|
||||||
const SurveyEditorEnvironmentLayout = async (props) => {
|
const SurveyEditorEnvironmentLayout = async (props) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
|
|
||||||
const { t, session, user } = await environmentIdLayoutChecks(params.environmentId);
|
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return redirect(`/auth/login`);
|
return redirect(`/auth/login`);
|
||||||
@@ -24,9 +25,15 @@ const SurveyEditorEnvironmentLayout = async (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col">
|
<EnvironmentIdBaseLayout
|
||||||
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
|
environmentId={params.environmentId}
|
||||||
</div>
|
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"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
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
import React, { createContext, useCallback, useContext, useState } from "react";
|
import React, { createContext, useCallback, useContext, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ElementOption,
|
QuestionOption,
|
||||||
ElementOptions,
|
QuestionOptions,
|
||||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ElementsComboBox";
|
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
||||||
import { ElementFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter";
|
import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter";
|
||||||
import { getTodayDate } from "@/app/lib/surveys/surveys";
|
import { getTodayDate } from "@/app/lib/surveys/surveys";
|
||||||
|
|
||||||
export interface FilterValue {
|
export interface FilterValue {
|
||||||
elementType: Partial<ElementOption>;
|
questionType: Partial<QuestionOption>;
|
||||||
filterType: {
|
filterType: {
|
||||||
filterValue: string | undefined;
|
filterValue: string | undefined;
|
||||||
filterComboBoxValue: string | string[] | undefined;
|
filterComboBoxValue: string | string[] | undefined;
|
||||||
@@ -24,8 +24,8 @@ export interface SelectedFilterValue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SelectedFilterOptions {
|
interface SelectedFilterOptions {
|
||||||
elementOptions: ElementOptions[];
|
questionOptions: QuestionOptions[];
|
||||||
elementFilterOptions: ElementFilterOptions[];
|
questionFilterOptions: QuestionFilterOptions[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DateRange {
|
export interface DateRange {
|
||||||
@@ -53,8 +53,8 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
|
|||||||
});
|
});
|
||||||
// state holds all the options of the responses fetched
|
// state holds all the options of the responses fetched
|
||||||
const [selectedOptions, setSelectedOptions] = useState<SelectedFilterOptions>({
|
const [selectedOptions, setSelectedOptions] = useState<SelectedFilterOptions>({
|
||||||
elementFilterOptions: [],
|
questionFilterOptions: [],
|
||||||
elementOptions: [],
|
questionOptions: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const [dateRange, setDateRange] = useState<DateRange>({
|
const [dateRange, setDateRange] = useState<DateRange>({
|
||||||
@@ -4,6 +4,7 @@ import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/comp
|
|||||||
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
|
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
|
||||||
|
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
|
||||||
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
|
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
|
||||||
|
|
||||||
const EnvLayout = async (props: {
|
const EnvLayout = async (props: {
|
||||||
@@ -23,7 +24,11 @@ const EnvLayout = async (props: {
|
|||||||
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
|
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<EnvironmentIdBaseLayout
|
||||||
|
environmentId={params.environmentId}
|
||||||
|
session={layoutData.session}
|
||||||
|
user={layoutData.user}
|
||||||
|
organization={layoutData.organization}>
|
||||||
<EnvironmentStorageHandler environmentId={params.environmentId} />
|
<EnvironmentStorageHandler environmentId={params.environmentId} />
|
||||||
<EnvironmentContextWrapper
|
<EnvironmentContextWrapper
|
||||||
environment={layoutData.environment}
|
environment={layoutData.environment}
|
||||||
@@ -31,7 +36,7 @@ const EnvLayout = async (props: {
|
|||||||
organization={layoutData.organization}>
|
organization={layoutData.organization}>
|
||||||
<EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>
|
<EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>
|
||||||
</EnvironmentContextWrapper>
|
</EnvironmentContextWrapper>
|
||||||
</>
|
</EnvironmentIdBaseLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+35
-54
@@ -46,45 +46,6 @@ import {
|
|||||||
} from "@/modules/ui/components/select";
|
} from "@/modules/ui/components/select";
|
||||||
import { IntegrationModalInputs } from "../lib/types";
|
import { IntegrationModalInputs } from "../lib/types";
|
||||||
|
|
||||||
const ElementCheckbox = ({
|
|
||||||
element,
|
|
||||||
selectedSurvey,
|
|
||||||
field,
|
|
||||||
}: {
|
|
||||||
element: TSurveyElement;
|
|
||||||
selectedSurvey: TSurvey;
|
|
||||||
field: {
|
|
||||||
value: string[] | undefined;
|
|
||||||
onChange: (value: string[]) => void;
|
|
||||||
};
|
|
||||||
}) => {
|
|
||||||
const handleCheckedChange = (checked: boolean) => {
|
|
||||||
if (checked) {
|
|
||||||
field.onChange([...(field.value || []), element.id]);
|
|
||||||
} else {
|
|
||||||
field.onChange(field.value?.filter((value) => value !== element.id) || []);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="my-1 flex items-center space-x-2">
|
|
||||||
<label htmlFor={element.id} className="flex cursor-pointer items-center">
|
|
||||||
<Checkbox
|
|
||||||
type="button"
|
|
||||||
id={element.id}
|
|
||||||
value={element.id}
|
|
||||||
className="bg-white"
|
|
||||||
checked={field.value?.includes(element.id)}
|
|
||||||
onCheckedChange={handleCheckedChange}
|
|
||||||
/>
|
|
||||||
<span className="ml-2">
|
|
||||||
{getTextContent(recallToHeadline(element.headline, selectedSurvey, false, "default")["default"])}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type EditModeProps =
|
type EditModeProps =
|
||||||
| { isEditMode: false; defaultData?: never }
|
| { isEditMode: false; defaultData?: never }
|
||||||
| { isEditMode: true; defaultData: IntegrationModalInputs & { index: number } };
|
| { isEditMode: true; defaultData: IntegrationModalInputs & { index: number } };
|
||||||
@@ -108,10 +69,10 @@ const NoBaseFoundError = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderElementSelection = ({
|
const renderQuestionSelection = ({
|
||||||
t,
|
t,
|
||||||
selectedSurvey,
|
selectedSurvey,
|
||||||
elements,
|
questions,
|
||||||
control,
|
control,
|
||||||
includeVariables,
|
includeVariables,
|
||||||
setIncludeVariables,
|
setIncludeVariables,
|
||||||
@@ -124,7 +85,7 @@ const renderElementSelection = ({
|
|||||||
}: {
|
}: {
|
||||||
t: TFunction;
|
t: TFunction;
|
||||||
selectedSurvey: TSurvey;
|
selectedSurvey: TSurvey;
|
||||||
elements: TSurveyElement[];
|
questions: TSurveyElement[];
|
||||||
control: Control<IntegrationModalInputs>;
|
control: Control<IntegrationModalInputs>;
|
||||||
includeVariables: boolean;
|
includeVariables: boolean;
|
||||||
setIncludeVariables: (value: boolean) => void;
|
setIncludeVariables: (value: boolean) => void;
|
||||||
@@ -141,13 +102,33 @@ const renderElementSelection = ({
|
|||||||
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
||||||
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
|
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
|
||||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||||
{elements.map((element) => (
|
{questions.map((question) => (
|
||||||
<Controller
|
<Controller
|
||||||
key={element.id}
|
key={question.id}
|
||||||
control={control}
|
control={control}
|
||||||
name={"elements"}
|
name={"questions"}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<ElementCheckbox element={element} selectedSurvey={selectedSurvey} field={field} />
|
<div className="my-1 flex items-center space-x-2">
|
||||||
|
<label htmlFor={question.id} className="flex cursor-pointer items-center">
|
||||||
|
<Checkbox
|
||||||
|
type="button"
|
||||||
|
id={question.id}
|
||||||
|
value={question.id}
|
||||||
|
className="bg-white"
|
||||||
|
checked={field.value?.includes(question.id)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
return checked
|
||||||
|
? field.onChange([...field.value, question.id])
|
||||||
|
: field.onChange(field.value?.filter((value) => value !== question.id));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="ml-2">
|
||||||
|
{getTextContent(
|
||||||
|
recallToHeadline(question.headline, selectedSurvey, false, "default")["default"]
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -218,7 +199,7 @@ export const AddIntegrationModal = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const selectedSurvey = surveys.find((item) => item.id === survey);
|
const selectedSurvey = surveys.find((item) => item.id === survey);
|
||||||
const elements = useMemo(
|
const questions = useMemo(
|
||||||
() => (selectedSurvey ? getElementsFromBlocks(selectedSurvey.blocks) : []),
|
() => (selectedSurvey ? getElementsFromBlocks(selectedSurvey.blocks) : []),
|
||||||
[selectedSurvey]
|
[selectedSurvey]
|
||||||
);
|
);
|
||||||
@@ -237,7 +218,7 @@ export const AddIntegrationModal = ({
|
|||||||
throw new Error(t("environments.integrations.please_select_a_survey_error"));
|
throw new Error(t("environments.integrations.please_select_a_survey_error"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.elements.length === 0) {
|
if (data.questions.length === 0) {
|
||||||
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
|
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,9 +226,9 @@ export const AddIntegrationModal = ({
|
|||||||
const integrationData: TIntegrationAirtableConfigData = {
|
const integrationData: TIntegrationAirtableConfigData = {
|
||||||
surveyId: selectedSurvey.id,
|
surveyId: selectedSurvey.id,
|
||||||
surveyName: selectedSurvey.name,
|
surveyName: selectedSurvey.name,
|
||||||
elementIds: data.elements,
|
questionIds: data.questions,
|
||||||
elements:
|
questions:
|
||||||
data.elements.length === elements.length
|
data.questions.length === questions.length
|
||||||
? t("common.all_questions")
|
? t("common.all_questions")
|
||||||
: t("common.selected_questions"),
|
: t("common.selected_questions"),
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@@ -395,7 +376,7 @@ export const AddIntegrationModal = ({
|
|||||||
required
|
required
|
||||||
onValueChange={(val) => {
|
onValueChange={(val) => {
|
||||||
field.onChange(val);
|
field.onChange(val);
|
||||||
setValue("elements", []);
|
setValue("questions", []);
|
||||||
}}
|
}}
|
||||||
defaultValue={defaultData?.survey}>
|
defaultValue={defaultData?.survey}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
@@ -421,10 +402,10 @@ export const AddIntegrationModal = ({
|
|||||||
|
|
||||||
{survey &&
|
{survey &&
|
||||||
selectedSurvey &&
|
selectedSurvey &&
|
||||||
renderElementSelection({
|
renderQuestionSelection({
|
||||||
t,
|
t,
|
||||||
selectedSurvey,
|
selectedSurvey,
|
||||||
elements: elements,
|
questions,
|
||||||
control,
|
control,
|
||||||
includeVariables,
|
includeVariables,
|
||||||
setIncludeVariables,
|
setIncludeVariables,
|
||||||
|
|||||||
+4
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
@@ -15,6 +16,7 @@ interface AirtableWrapperProps {
|
|||||||
airtableArray: TIntegrationItem[];
|
airtableArray: TIntegrationItem[];
|
||||||
airtableIntegration?: TIntegrationAirtable;
|
airtableIntegration?: TIntegrationAirtable;
|
||||||
surveys: TSurvey[];
|
surveys: TSurvey[];
|
||||||
|
environment: TEnvironment;
|
||||||
isEnabled: boolean;
|
isEnabled: boolean;
|
||||||
webAppUrl: string;
|
webAppUrl: string;
|
||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
@@ -25,6 +27,7 @@ export const AirtableWrapper = ({
|
|||||||
airtableArray,
|
airtableArray,
|
||||||
airtableIntegration,
|
airtableIntegration,
|
||||||
surveys,
|
surveys,
|
||||||
|
environment,
|
||||||
isEnabled,
|
isEnabled,
|
||||||
webAppUrl,
|
webAppUrl,
|
||||||
locale,
|
locale,
|
||||||
@@ -45,6 +48,7 @@ export const AirtableWrapper = ({
|
|||||||
<ManageIntegration
|
<ManageIntegration
|
||||||
airtableArray={airtableArray}
|
airtableArray={airtableArray}
|
||||||
environmentId={environmentId}
|
environmentId={environmentId}
|
||||||
|
environment={environment}
|
||||||
airtableIntegration={airtableIntegration}
|
airtableIntegration={airtableIntegration}
|
||||||
setIsConnected={setIsConnected}
|
setIsConnected={setIsConnected}
|
||||||
surveys={surveys}
|
surveys={surveys}
|
||||||
|
|||||||
+12
-5
@@ -4,6 +4,7 @@ import { Trash2Icon } from "lucide-react";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
@@ -14,11 +15,12 @@ import { timeSince } from "@/lib/time";
|
|||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||||
import { IntegrationModalInputs } from "../lib/types";
|
import { IntegrationModalInputs } from "../lib/types";
|
||||||
|
|
||||||
interface ManageIntegrationProps {
|
interface ManageIntegrationProps {
|
||||||
airtableIntegration: TIntegrationAirtable;
|
airtableIntegration: TIntegrationAirtable;
|
||||||
|
environment: TEnvironment;
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
setIsConnected: (data: boolean) => void;
|
setIsConnected: (data: boolean) => void;
|
||||||
surveys: TSurvey[];
|
surveys: TSurvey[];
|
||||||
@@ -27,7 +29,7 @@ interface ManageIntegrationProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ManageIntegration = (props: ManageIntegrationProps) => {
|
export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||||
const { airtableIntegration, environmentId, setIsConnected, surveys, airtableArray } = props;
|
const { airtableIntegration, environment, environmentId, setIsConnected, surveys, airtableArray } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const tableHeaders = [
|
const tableHeaders = [
|
||||||
@@ -108,7 +110,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDefaultValues({
|
setDefaultValues({
|
||||||
base: data.baseId,
|
base: data.baseId,
|
||||||
elements: data.elementIds,
|
questions: data.questionIds,
|
||||||
survey: data.surveyId,
|
survey: data.surveyId,
|
||||||
table: data.tableId,
|
table: data.tableId,
|
||||||
includeVariables: !!data.includeVariables,
|
includeVariables: !!data.includeVariables,
|
||||||
@@ -121,7 +123,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
|||||||
}}>
|
}}>
|
||||||
<div className="col-span-2 text-center">{data.surveyName}</div>
|
<div className="col-span-2 text-center">{data.surveyName}</div>
|
||||||
<div className="col-span-2 text-center">{data.tableName}</div>
|
<div className="col-span-2 text-center">{data.tableName}</div>
|
||||||
<div className="col-span-2 text-center">{data.elements}</div>
|
<div className="col-span-2 text-center">{data.questions}</div>
|
||||||
<div className="col-span-2 text-center">
|
<div className="col-span-2 text-center">
|
||||||
{timeSince(data.createdAt.toString(), props.locale)}
|
{timeSince(data.createdAt.toString(), props.locale)}
|
||||||
</div>
|
</div>
|
||||||
@@ -130,7 +132,12 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-4 w-full">
|
<div className="mt-4 w-full">
|
||||||
<EmptyState text={t("environments.integrations.airtable.no_integrations_yet")} />
|
<EmptySpaceFiller
|
||||||
|
type="table"
|
||||||
|
environment={environment}
|
||||||
|
noWidgetRequired={true}
|
||||||
|
emptyMessage={t("environments.integrations.airtable.no_integrations_yet")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ export type IntegrationModalInputs = {
|
|||||||
base: string;
|
base: string;
|
||||||
table: string;
|
table: string;
|
||||||
survey: string;
|
survey: string;
|
||||||
elements: string[];
|
questions: string[];
|
||||||
includeVariables: boolean;
|
includeVariables: boolean;
|
||||||
includeHiddenFields: boolean;
|
includeHiddenFields: boolean;
|
||||||
includeMetadata: boolean;
|
includeMetadata: boolean;
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ const Page = async (props) => {
|
|||||||
airtableArray={airtableArray}
|
airtableArray={airtableArray}
|
||||||
environmentId={environment.id}
|
environmentId={environment.id}
|
||||||
surveys={surveys}
|
surveys={surveys}
|
||||||
|
environment={environment}
|
||||||
webAppUrl={WEBAPP_URL}
|
webAppUrl={WEBAPP_URL}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+15
-15
@@ -62,12 +62,12 @@ export const AddIntegrationModal = ({
|
|||||||
spreadsheetName: "",
|
spreadsheetName: "",
|
||||||
surveyId: "",
|
surveyId: "",
|
||||||
surveyName: "",
|
surveyName: "",
|
||||||
elementIds: [""],
|
questionIds: [""],
|
||||||
elements: "",
|
questions: "",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
};
|
};
|
||||||
const { handleSubmit } = useForm();
|
const { handleSubmit } = useForm();
|
||||||
const [selectedElements, setSelectedElements] = useState<string[]>([]);
|
const [selectedQuestions, setSelectedQuestions] = useState<string[]>([]);
|
||||||
const [isLinkingSheet, setIsLinkingSheet] = useState(false);
|
const [isLinkingSheet, setIsLinkingSheet] = useState(false);
|
||||||
const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null);
|
const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null);
|
||||||
const [spreadsheetUrl, setSpreadsheetUrl] = useState("");
|
const [spreadsheetUrl, setSpreadsheetUrl] = useState("");
|
||||||
@@ -86,17 +86,17 @@ export const AddIntegrationModal = ({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const surveyElements = useMemo(
|
const questions = useMemo(
|
||||||
() => (selectedSurvey ? getElementsFromBlocks(selectedSurvey.blocks) : []),
|
() => (selectedSurvey ? getElementsFromBlocks(selectedSurvey.blocks) : []),
|
||||||
[selectedSurvey]
|
[selectedSurvey]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedSurvey && !selectedIntegration) {
|
if (selectedSurvey && !selectedIntegration) {
|
||||||
const elementIds = surveyElements.map((element) => element.id);
|
const questionIds = questions.map((question) => question.id);
|
||||||
setSelectedElements(elementIds);
|
setSelectedQuestions(questionIds);
|
||||||
}
|
}
|
||||||
}, [surveyElements, selectedIntegration, selectedSurvey]);
|
}, [questions, selectedIntegration, selectedSurvey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedIntegration) {
|
if (selectedIntegration) {
|
||||||
@@ -106,7 +106,7 @@ export const AddIntegrationModal = ({
|
|||||||
return survey.id === selectedIntegration.surveyId;
|
return survey.id === selectedIntegration.surveyId;
|
||||||
})!
|
})!
|
||||||
);
|
);
|
||||||
setSelectedElements(selectedIntegration.elementIds);
|
setSelectedQuestions(selectedIntegration.questionIds);
|
||||||
setIncludeVariables(!!selectedIntegration.includeVariables);
|
setIncludeVariables(!!selectedIntegration.includeVariables);
|
||||||
setIncludeHiddenFields(!!selectedIntegration.includeHiddenFields);
|
setIncludeHiddenFields(!!selectedIntegration.includeHiddenFields);
|
||||||
setIncludeMetadata(!!selectedIntegration.includeMetadata);
|
setIncludeMetadata(!!selectedIntegration.includeMetadata);
|
||||||
@@ -126,7 +126,7 @@ export const AddIntegrationModal = ({
|
|||||||
if (!selectedSurvey) {
|
if (!selectedSurvey) {
|
||||||
throw new Error(t("environments.integrations.please_select_a_survey_error"));
|
throw new Error(t("environments.integrations.please_select_a_survey_error"));
|
||||||
}
|
}
|
||||||
if (selectedElements.length === 0) {
|
if (selectedQuestions.length === 0) {
|
||||||
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
|
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
|
||||||
}
|
}
|
||||||
const spreadsheetId = extractSpreadsheetIdFromUrl(spreadsheetUrl);
|
const spreadsheetId = extractSpreadsheetIdFromUrl(spreadsheetUrl);
|
||||||
@@ -148,9 +148,9 @@ export const AddIntegrationModal = ({
|
|||||||
integrationData.spreadsheetName = spreadsheetName;
|
integrationData.spreadsheetName = spreadsheetName;
|
||||||
integrationData.surveyId = selectedSurvey.id;
|
integrationData.surveyId = selectedSurvey.id;
|
||||||
integrationData.surveyName = selectedSurvey.name;
|
integrationData.surveyName = selectedSurvey.name;
|
||||||
integrationData.elementIds = selectedElements;
|
integrationData.questionIds = selectedQuestions;
|
||||||
integrationData.elements =
|
integrationData.questions =
|
||||||
selectedElements.length === surveyElements.length
|
selectedQuestions.length === questions.length
|
||||||
? t("common.all_questions")
|
? t("common.all_questions")
|
||||||
: t("common.selected_questions");
|
: t("common.selected_questions");
|
||||||
integrationData.createdAt = new Date();
|
integrationData.createdAt = new Date();
|
||||||
@@ -181,7 +181,7 @@ export const AddIntegrationModal = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCheckboxChange = (questionId: TSurveyQuestionId) => {
|
const handleCheckboxChange = (questionId: TSurveyQuestionId) => {
|
||||||
setSelectedElements((prevValues) =>
|
setSelectedQuestions((prevValues) =>
|
||||||
prevValues.includes(questionId)
|
prevValues.includes(questionId)
|
||||||
? prevValues.filter((value) => value !== questionId)
|
? prevValues.filter((value) => value !== questionId)
|
||||||
: [...prevValues, questionId]
|
: [...prevValues, questionId]
|
||||||
@@ -268,7 +268,7 @@ export const AddIntegrationModal = ({
|
|||||||
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
||||||
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200">
|
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200">
|
||||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||||
{surveyElements.map((question) => (
|
{questions.map((question) => (
|
||||||
<div key={question.id} className="my-1 flex items-center space-x-2">
|
<div key={question.id} className="my-1 flex items-center space-x-2">
|
||||||
<label htmlFor={question.id} className="flex cursor-pointer items-center">
|
<label htmlFor={question.id} className="flex cursor-pointer items-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -276,7 +276,7 @@ export const AddIntegrationModal = ({
|
|||||||
id={question.id}
|
id={question.id}
|
||||||
value={question.id}
|
value={question.id}
|
||||||
className="bg-white"
|
className="bg-white"
|
||||||
checked={selectedElements.includes(question.id)}
|
checked={selectedQuestions.includes(question.id)}
|
||||||
onCheckedChange={() => {
|
onCheckedChange={() => {
|
||||||
handleCheckboxChange(question.id);
|
handleCheckboxChange(question.id);
|
||||||
}}
|
}}
|
||||||
|
|||||||
+1
@@ -60,6 +60,7 @@ export const GoogleSheetWrapper = ({
|
|||||||
selectedIntegration={selectedIntegration}
|
selectedIntegration={selectedIntegration}
|
||||||
/>
|
/>
|
||||||
<ManageIntegration
|
<ManageIntegration
|
||||||
|
environment={environment}
|
||||||
googleSheetIntegration={googleSheetIntegration}
|
googleSheetIntegration={googleSheetIntegration}
|
||||||
setOpenAddIntegrationModal={setIsModalOpen}
|
setOpenAddIntegrationModal={setIsModalOpen}
|
||||||
setIsConnected={setIsConnected}
|
setIsConnected={setIsConnected}
|
||||||
|
|||||||
+11
-3
@@ -4,6 +4,7 @@ import { Trash2Icon } from "lucide-react";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import {
|
import {
|
||||||
TIntegrationGoogleSheets,
|
TIntegrationGoogleSheets,
|
||||||
TIntegrationGoogleSheetsConfigData,
|
TIntegrationGoogleSheetsConfigData,
|
||||||
@@ -14,9 +15,10 @@ import { timeSince } from "@/lib/time";
|
|||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||||
|
|
||||||
interface ManageIntegrationProps {
|
interface ManageIntegrationProps {
|
||||||
|
environment: TEnvironment;
|
||||||
googleSheetIntegration: TIntegrationGoogleSheets;
|
googleSheetIntegration: TIntegrationGoogleSheets;
|
||||||
setOpenAddIntegrationModal: (v: boolean) => void;
|
setOpenAddIntegrationModal: (v: boolean) => void;
|
||||||
setIsConnected: (v: boolean) => void;
|
setIsConnected: (v: boolean) => void;
|
||||||
@@ -25,6 +27,7 @@ interface ManageIntegrationProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ManageIntegration = ({
|
export const ManageIntegration = ({
|
||||||
|
environment,
|
||||||
googleSheetIntegration,
|
googleSheetIntegration,
|
||||||
setOpenAddIntegrationModal,
|
setOpenAddIntegrationModal,
|
||||||
setIsConnected,
|
setIsConnected,
|
||||||
@@ -87,7 +90,12 @@ export const ManageIntegration = ({
|
|||||||
</div>
|
</div>
|
||||||
{!integrationArray || integrationArray.length === 0 ? (
|
{!integrationArray || integrationArray.length === 0 ? (
|
||||||
<div className="mt-4 w-full">
|
<div className="mt-4 w-full">
|
||||||
<EmptyState text={t("environments.integrations.google_sheets.no_integrations_yet")} />
|
<EmptySpaceFiller
|
||||||
|
type="table"
|
||||||
|
environment={environment}
|
||||||
|
noWidgetRequired={true}
|
||||||
|
emptyMessage={t("environments.integrations.google_sheets.no_integrations_yet")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-4 flex w-full flex-col items-center justify-center">
|
<div className="mt-4 flex w-full flex-col items-center justify-center">
|
||||||
@@ -110,7 +118,7 @@ export const ManageIntegration = ({
|
|||||||
}}>
|
}}>
|
||||||
<div className="col-span-2 text-center">{data.surveyName}</div>
|
<div className="col-span-2 text-center">{data.surveyName}</div>
|
||||||
<div className="col-span-2 text-center">{data.spreadsheetName}</div>
|
<div className="col-span-2 text-center">{data.spreadsheetName}</div>
|
||||||
<div className="col-span-2 text-center">{data.elements}</div>
|
<div className="col-span-2 text-center">{data.questions}</div>
|
||||||
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString(), locale)}</div>
|
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString(), locale)}</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
+80
-92
@@ -12,8 +12,7 @@ import {
|
|||||||
TIntegrationNotionConfigData,
|
TIntegrationNotionConfigData,
|
||||||
TIntegrationNotionDatabase,
|
TIntegrationNotionDatabase,
|
||||||
} from "@formbricks/types/integration/notion";
|
} from "@formbricks/types/integration/notion";
|
||||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||||
import {
|
import {
|
||||||
@@ -25,7 +24,7 @@ import NotionLogo from "@/images/notion.png";
|
|||||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { recallToHeadline } from "@/lib/utils/recall";
|
||||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||||
import { getElementTypes } from "@/modules/survey/lib/elements";
|
import { getQuestionTypes } from "@/modules/survey/lib/questions";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -39,59 +38,6 @@ import {
|
|||||||
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
|
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
|
||||||
import { Label } from "@/modules/ui/components/label";
|
import { Label } from "@/modules/ui/components/label";
|
||||||
|
|
||||||
const MappingErrorMessage = ({
|
|
||||||
error,
|
|
||||||
col,
|
|
||||||
elem,
|
|
||||||
t,
|
|
||||||
}: {
|
|
||||||
error: { type: string; msg?: React.ReactNode | string } | null | undefined;
|
|
||||||
col: { id: string; name: string; type: string };
|
|
||||||
elem: { id: string; name: string; type: string };
|
|
||||||
t: ReturnType<typeof useTranslation>["t"];
|
|
||||||
}) => {
|
|
||||||
const showErrorMsg = useMemo(() => {
|
|
||||||
switch (error?.type) {
|
|
||||||
case ERRORS.UNSUPPORTED_TYPE:
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
-{" "}
|
|
||||||
{t("environments.integrations.notion.col_name_of_type_is_not_supported", {
|
|
||||||
col_name: col.name,
|
|
||||||
type: col.type,
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
case ERRORS.MAPPING:
|
|
||||||
const element = getElementTypes(t).find((et) => et.id === elem.type);
|
|
||||||
if (!element) return null;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{t("environments.integrations.notion.que_name_of_type_cant_be_mapped_to", {
|
|
||||||
que_name: elem.name,
|
|
||||||
question_label: element.label,
|
|
||||||
col_name: col.name,
|
|
||||||
col_type: col.type,
|
|
||||||
mapped_type: TYPE_MAPPING[element.id].join(" ,"),
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [error, col, elem, t]);
|
|
||||||
|
|
||||||
if (!error) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="my-4 w-full rounded-lg bg-red-100 p-4 text-sm text-red-800">
|
|
||||||
<span className="mb-2 block">{error.type}</span>
|
|
||||||
{showErrorMsg}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface AddIntegrationModalProps {
|
interface AddIntegrationModalProps {
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
surveys: TSurvey[];
|
surveys: TSurvey[];
|
||||||
@@ -118,7 +64,7 @@ export const AddIntegrationModal = ({
|
|||||||
const [mapping, setMapping] = useState<
|
const [mapping, setMapping] = useState<
|
||||||
{
|
{
|
||||||
column: { id: string; name: string; type: string };
|
column: { id: string; name: string; type: string };
|
||||||
element: { id: string; name: string; type: string };
|
question: { id: string; name: string; type: string };
|
||||||
error?: {
|
error?: {
|
||||||
type: string;
|
type: string;
|
||||||
msg: React.ReactNode | string;
|
msg: React.ReactNode | string;
|
||||||
@@ -127,7 +73,7 @@ export const AddIntegrationModal = ({
|
|||||||
>([
|
>([
|
||||||
{
|
{
|
||||||
column: { id: "", name: "", type: "" },
|
column: { id: "", name: "", type: "" },
|
||||||
element: { id: "", name: "", type: "" },
|
question: { id: "", name: "", type: "" },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||||
@@ -140,13 +86,13 @@ export const AddIntegrationModal = ({
|
|||||||
mapping: [
|
mapping: [
|
||||||
{
|
{
|
||||||
column: { id: "", name: "", type: "" },
|
column: { id: "", name: "", type: "" },
|
||||||
element: { id: "", name: "", type: "" },
|
question: { id: "", name: "", type: "" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const elements = useMemo(
|
const questions = useMemo(
|
||||||
() => (selectedSurvey ? getElementsFromBlocks(selectedSurvey.blocks) : []),
|
() => (selectedSurvey ? getElementsFromBlocks(selectedSurvey.blocks) : []),
|
||||||
[selectedSurvey]
|
[selectedSurvey]
|
||||||
);
|
);
|
||||||
@@ -178,12 +124,12 @@ export const AddIntegrationModal = ({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [selectedDatabase?.id]);
|
}, [selectedDatabase?.id]);
|
||||||
|
|
||||||
const elementItems = useMemo(() => {
|
const questionItems = useMemo(() => {
|
||||||
const mappedElements = selectedSurvey
|
const mappedQuestions = selectedSurvey
|
||||||
? elements.map((el) => ({
|
? questions.map((q) => ({
|
||||||
id: el.id,
|
id: q.id,
|
||||||
name: getTextContent(recallToHeadline(el.headline, selectedSurvey, false, "default")["default"]),
|
name: getTextContent(recallToHeadline(q.headline, selectedSurvey, false, "default")["default"]),
|
||||||
type: el.type,
|
type: q.type,
|
||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
@@ -191,31 +137,31 @@ export const AddIntegrationModal = ({
|
|||||||
selectedSurvey?.variables.map((variable) => ({
|
selectedSurvey?.variables.map((variable) => ({
|
||||||
id: variable.id,
|
id: variable.id,
|
||||||
name: variable.name,
|
name: variable.name,
|
||||||
type: TSurveyElementTypeEnum.OpenText,
|
type: TSurveyQuestionTypeEnum.OpenText,
|
||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
const hiddenFields =
|
const hiddenFields =
|
||||||
selectedSurvey?.hiddenFields.fieldIds?.map((fId) => ({
|
selectedSurvey?.hiddenFields.fieldIds?.map((fId) => ({
|
||||||
id: fId,
|
id: fId,
|
||||||
name: `${t("common.hidden_field")} : ${fId}`,
|
name: `${t("common.hidden_field")} : ${fId}`,
|
||||||
type: TSurveyElementTypeEnum.OpenText,
|
type: TSurveyQuestionTypeEnum.OpenText,
|
||||||
})) || [];
|
})) || [];
|
||||||
const Metadata = [
|
const Metadata = [
|
||||||
{
|
{
|
||||||
id: "metadata",
|
id: "metadata",
|
||||||
name: t("common.metadata"),
|
name: t("common.metadata"),
|
||||||
type: TSurveyElementTypeEnum.OpenText,
|
type: TSurveyQuestionTypeEnum.OpenText,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const createdAt = [
|
const createdAt = [
|
||||||
{
|
{
|
||||||
id: "createdAt",
|
id: "createdAt",
|
||||||
name: t("common.created_at"),
|
name: t("common.created_at"),
|
||||||
type: TSurveyElementTypeEnum.Date,
|
type: TSurveyQuestionTypeEnum.Date,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return [...mappedElements, ...variables, ...hiddenFields, ...Metadata, ...createdAt];
|
return [...mappedQuestions, ...variables, ...hiddenFields, ...Metadata, ...createdAt];
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [selectedSurvey?.id]);
|
}, [selectedSurvey?.id]);
|
||||||
|
|
||||||
@@ -249,7 +195,7 @@ export const AddIntegrationModal = ({
|
|||||||
throw new Error(t("environments.integrations.please_select_a_survey_error"));
|
throw new Error(t("environments.integrations.please_select_a_survey_error"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mapping.length === 1 && (!mapping[0].element.id || !mapping[0].column.id)) {
|
if (mapping.length === 1 && (!mapping[0].question.id || !mapping[0].column.id)) {
|
||||||
throw new Error(t("environments.integrations.notion.please_select_at_least_one_mapping"));
|
throw new Error(t("environments.integrations.notion.please_select_at_least_one_mapping"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,8 +204,8 @@ export const AddIntegrationModal = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
mapping.filter((m) => m.column.id && !m.element.id).length >= 1 ||
|
mapping.filter((m) => m.column.id && !m.question.id).length >= 1 ||
|
||||||
mapping.filter((m) => m.element.id && !m.column.id).length >= 1
|
mapping.filter((m) => m.question.id && !m.column.id).length >= 1
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
t("environments.integrations.notion.please_complete_mapping_fields_with_notion_property")
|
t("environments.integrations.notion.please_complete_mapping_fields_with_notion_property")
|
||||||
@@ -320,23 +266,23 @@ export const AddIntegrationModal = ({
|
|||||||
setSelectedDatabase(null);
|
setSelectedDatabase(null);
|
||||||
setSelectedSurvey(null);
|
setSelectedSurvey(null);
|
||||||
};
|
};
|
||||||
const getFilteredElementItems = (selectedIdx) => {
|
const getFilteredQuestionItems = (selectedIdx) => {
|
||||||
const selectedElementIds = mapping.filter((_, idx) => idx !== selectedIdx).map((m) => m.element.id);
|
const selectedQuestionIds = mapping.filter((_, idx) => idx !== selectedIdx).map((m) => m.question.id);
|
||||||
|
|
||||||
return elementItems.filter((el) => !selectedElementIds.includes(el.id));
|
return questionItems.filter((q) => !selectedQuestionIds.includes(q.id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const createCopy = (item) => structuredClone(item);
|
const createCopy = (item) => structuredClone(item);
|
||||||
|
|
||||||
const MappingRow = ({ idx }: { idx: number }) => {
|
const MappingRow = ({ idx }: { idx: number }) => {
|
||||||
const filteredElementItems = getFilteredElementItems(idx);
|
const filteredQuestionItems = getFilteredQuestionItems(idx);
|
||||||
|
|
||||||
const addRow = () => {
|
const addRow = () => {
|
||||||
setMapping((prev) => [
|
setMapping((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
column: { id: "", name: "", type: "" },
|
column: { id: "", name: "", type: "" },
|
||||||
element: { id: "", name: "", type: "" },
|
question: { id: "", name: "", type: "" },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
@@ -347,6 +293,49 @@ export const AddIntegrationModal = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ErrorMsg = ({ error, col, ques }) => {
|
||||||
|
const showErrorMsg = useMemo(() => {
|
||||||
|
switch (error?.type) {
|
||||||
|
case ERRORS.UNSUPPORTED_TYPE:
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
-{" "}
|
||||||
|
{t("environments.integrations.notion.col_name_of_type_is_not_supported", {
|
||||||
|
col_name: col.name,
|
||||||
|
type: col.type,
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case ERRORS.MAPPING:
|
||||||
|
const question = getQuestionTypes(t).find((qt) => qt.id === ques.type);
|
||||||
|
if (!question) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{t("environments.integrations.notion.que_name_of_type_cant_be_mapped_to", {
|
||||||
|
que_name: ques.name,
|
||||||
|
question_label: question.label,
|
||||||
|
col_name: col.name,
|
||||||
|
col_type: col.type,
|
||||||
|
mapped_type: TYPE_MAPPING[question.id].join(" ,"),
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
if (!error) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-4 w-full rounded-lg bg-red-100 p-4 text-sm text-red-800">
|
||||||
|
<span className="mb-2 block">{error.type}</span>
|
||||||
|
{showErrorMsg}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const getFilteredDbItems = () => {
|
const getFilteredDbItems = () => {
|
||||||
const colMapping = mapping.map((m) => m.column.id);
|
const colMapping = mapping.map((m) => m.column.id);
|
||||||
return dbItems.filter((item) => !colMapping.includes(item.id));
|
return dbItems.filter((item) => !colMapping.includes(item.id));
|
||||||
@@ -354,20 +343,19 @@ export const AddIntegrationModal = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<MappingErrorMessage
|
<ErrorMsg
|
||||||
key={idx}
|
key={idx}
|
||||||
error={mapping[idx]?.error}
|
error={mapping[idx]?.error}
|
||||||
col={mapping[idx].column}
|
col={mapping[idx].column}
|
||||||
elem={mapping[idx].element}
|
ques={mapping[idx].question}
|
||||||
t={t}
|
|
||||||
/>
|
/>
|
||||||
<div className="flex w-full items-center space-x-2">
|
<div className="flex w-full items-center space-x-2">
|
||||||
<div className="flex w-full items-center">
|
<div className="flex w-full items-center">
|
||||||
<div className="max-w-full flex-1">
|
<div className="max-w-full flex-1">
|
||||||
<DropdownSelector
|
<DropdownSelector
|
||||||
placeholder={t("environments.integrations.notion.select_a_survey_question")}
|
placeholder={t("environments.integrations.notion.select_a_survey_question")}
|
||||||
items={filteredElementItems}
|
items={filteredQuestionItems}
|
||||||
selectedItem={mapping?.[idx]?.element}
|
selectedItem={mapping?.[idx]?.question}
|
||||||
setSelectedItem={(item) => {
|
setSelectedItem={(item) => {
|
||||||
setMapping((prev) => {
|
setMapping((prev) => {
|
||||||
const copy = createCopy(prev);
|
const copy = createCopy(prev);
|
||||||
@@ -379,7 +367,7 @@ export const AddIntegrationModal = ({
|
|||||||
error: {
|
error: {
|
||||||
type: ERRORS.UNSUPPORTED_TYPE,
|
type: ERRORS.UNSUPPORTED_TYPE,
|
||||||
},
|
},
|
||||||
element: item,
|
question: item,
|
||||||
};
|
};
|
||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
@@ -391,7 +379,7 @@ export const AddIntegrationModal = ({
|
|||||||
error: {
|
error: {
|
||||||
type: ERRORS.MAPPING,
|
type: ERRORS.MAPPING,
|
||||||
},
|
},
|
||||||
element: item,
|
question: item,
|
||||||
};
|
};
|
||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
@@ -399,13 +387,13 @@ export const AddIntegrationModal = ({
|
|||||||
|
|
||||||
copy[idx] = {
|
copy[idx] = {
|
||||||
...copy[idx],
|
...copy[idx],
|
||||||
element: item,
|
question: item,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
return copy;
|
return copy;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
disabled={elementItems.length === 0}
|
disabled={questionItems.length === 0}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-px w-4 border-t border-t-slate-300" />
|
<div className="h-px w-4 border-t border-t-slate-300" />
|
||||||
@@ -417,9 +405,9 @@ export const AddIntegrationModal = ({
|
|||||||
setSelectedItem={(item) => {
|
setSelectedItem={(item) => {
|
||||||
setMapping((prev) => {
|
setMapping((prev) => {
|
||||||
const copy = createCopy(prev);
|
const copy = createCopy(prev);
|
||||||
const elem = copy[idx].element;
|
const ques = copy[idx].question;
|
||||||
if (elem.id) {
|
if (ques.id) {
|
||||||
const isValidElemType = TYPE_MAPPING[elem.type].includes(item.type);
|
const isValidQuesType = TYPE_MAPPING[ques.type].includes(item.type);
|
||||||
|
|
||||||
if (UNSUPPORTED_TYPES_BY_NOTION.includes(item.type)) {
|
if (UNSUPPORTED_TYPES_BY_NOTION.includes(item.type)) {
|
||||||
copy[idx] = {
|
copy[idx] = {
|
||||||
@@ -432,7 +420,7 @@ export const AddIntegrationModal = ({
|
|||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isValidElemType) {
|
if (!isValidQuesType) {
|
||||||
copy[idx] = {
|
copy[idx] = {
|
||||||
...copy[idx],
|
...copy[idx],
|
||||||
error: {
|
error: {
|
||||||
|
|||||||
+10
-2
@@ -4,6 +4,7 @@ import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
|
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||||
@@ -11,10 +12,11 @@ import { timeSince } from "@/lib/time";
|
|||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||||
|
|
||||||
interface ManageIntegrationProps {
|
interface ManageIntegrationProps {
|
||||||
|
environment: TEnvironment;
|
||||||
notionIntegration: TIntegrationNotion;
|
notionIntegration: TIntegrationNotion;
|
||||||
setOpenAddIntegrationModal: React.Dispatch<React.SetStateAction<boolean>>;
|
setOpenAddIntegrationModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
setIsConnected: React.Dispatch<React.SetStateAction<boolean>>;
|
setIsConnected: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
@@ -26,6 +28,7 @@ interface ManageIntegrationProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ManageIntegration = ({
|
export const ManageIntegration = ({
|
||||||
|
environment,
|
||||||
notionIntegration,
|
notionIntegration,
|
||||||
setOpenAddIntegrationModal,
|
setOpenAddIntegrationModal,
|
||||||
setIsConnected,
|
setIsConnected,
|
||||||
@@ -98,7 +101,12 @@ export const ManageIntegration = ({
|
|||||||
</div>
|
</div>
|
||||||
{!integrationArray || integrationArray.length === 0 ? (
|
{!integrationArray || integrationArray.length === 0 ? (
|
||||||
<div className="mt-4 w-full">
|
<div className="mt-4 w-full">
|
||||||
<EmptyState text={t("environments.integrations.notion.no_databases_found")} />
|
<EmptySpaceFiller
|
||||||
|
type="table"
|
||||||
|
environment={environment}
|
||||||
|
noWidgetRequired={true}
|
||||||
|
emptyMessage={t("environments.integrations.notion.no_databases_found")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-4 flex w-full flex-col items-center justify-center">
|
<div className="mt-4 flex w-full flex-col items-center justify-center">
|
||||||
|
|||||||
+1
@@ -64,6 +64,7 @@ export const NotionWrapper = ({
|
|||||||
selectedIntegration={selectedIntegration}
|
selectedIntegration={selectedIntegration}
|
||||||
/>
|
/>
|
||||||
<ManageIntegration
|
<ManageIntegration
|
||||||
|
environment={environment}
|
||||||
notionIntegration={notionIntegration}
|
notionIntegration={notionIntegration}
|
||||||
setOpenAddIntegrationModal={setIsModalOpen}
|
setOpenAddIntegrationModal={setIsModalOpen}
|
||||||
setIsConnected={setIsConnected}
|
setIsConnected={setIsConnected}
|
||||||
|
|||||||
+24
-24
@@ -13,7 +13,7 @@ import {
|
|||||||
TIntegrationSlackConfigData,
|
TIntegrationSlackConfigData,
|
||||||
TIntegrationSlackInput,
|
TIntegrationSlackInput,
|
||||||
} from "@formbricks/types/integration/slack";
|
} from "@formbricks/types/integration/slack";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||||
import SlackLogo from "@/images/slacklogo.png";
|
import SlackLogo from "@/images/slacklogo.png";
|
||||||
@@ -55,7 +55,7 @@ export const AddChannelMappingModal = ({
|
|||||||
}: AddChannelMappingModalProps) => {
|
}: AddChannelMappingModalProps) => {
|
||||||
const { handleSubmit } = useForm();
|
const { handleSubmit } = useForm();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [selectedElements, setSelectedElements] = useState<string[]>([]);
|
const [selectedQuestions, setSelectedQuestions] = useState<string[]>([]);
|
||||||
const [isLinkingChannel, setIsLinkingChannel] = useState(false);
|
const [isLinkingChannel, setIsLinkingChannel] = useState(false);
|
||||||
const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null);
|
const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null);
|
||||||
const [selectedChannel, setSelectedChannel] = useState<TIntegrationItem | null>(null);
|
const [selectedChannel, setSelectedChannel] = useState<TIntegrationItem | null>(null);
|
||||||
@@ -73,19 +73,19 @@ export const AddChannelMappingModal = ({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const surveyElements = useMemo(
|
const questions = useMemo(
|
||||||
() => (selectedSurvey ? getElementsFromBlocks(selectedSurvey.blocks) : []),
|
() => (selectedSurvey ? getElementsFromBlocks(selectedSurvey.blocks) : []),
|
||||||
[selectedSurvey]
|
[selectedSurvey]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedSurvey) {
|
if (selectedSurvey) {
|
||||||
const elementIds = surveyElements.map((element) => element.id);
|
const questionIds = questions.map((question) => question.id);
|
||||||
if (!selectedIntegration) {
|
if (!selectedIntegration) {
|
||||||
setSelectedElements(elementIds);
|
setSelectedQuestions(questionIds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [surveyElements, selectedIntegration, selectedSurvey]);
|
}, [questions, selectedIntegration, selectedSurvey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedIntegration) {
|
if (selectedIntegration) {
|
||||||
@@ -98,7 +98,7 @@ export const AddChannelMappingModal = ({
|
|||||||
return survey.id === selectedIntegration.surveyId;
|
return survey.id === selectedIntegration.surveyId;
|
||||||
})!
|
})!
|
||||||
);
|
);
|
||||||
setSelectedElements(selectedIntegration.elementIds);
|
setSelectedQuestions(selectedIntegration.questionIds);
|
||||||
setIncludeVariables(!!selectedIntegration.includeVariables);
|
setIncludeVariables(!!selectedIntegration.includeVariables);
|
||||||
setIncludeHiddenFields(!!selectedIntegration.includeHiddenFields);
|
setIncludeHiddenFields(!!selectedIntegration.includeHiddenFields);
|
||||||
setIncludeMetadata(!!selectedIntegration.includeMetadata);
|
setIncludeMetadata(!!selectedIntegration.includeMetadata);
|
||||||
@@ -117,7 +117,7 @@ export const AddChannelMappingModal = ({
|
|||||||
throw new Error(t("environments.integrations.please_select_a_survey_error"));
|
throw new Error(t("environments.integrations.please_select_a_survey_error"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedElements.length === 0) {
|
if (selectedQuestions.length === 0) {
|
||||||
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
|
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
|
||||||
}
|
}
|
||||||
setIsLinkingChannel(true);
|
setIsLinkingChannel(true);
|
||||||
@@ -126,9 +126,9 @@ export const AddChannelMappingModal = ({
|
|||||||
channelName: selectedChannel.name,
|
channelName: selectedChannel.name,
|
||||||
surveyId: selectedSurvey.id,
|
surveyId: selectedSurvey.id,
|
||||||
surveyName: selectedSurvey.name,
|
surveyName: selectedSurvey.name,
|
||||||
elementIds: selectedElements,
|
questionIds: selectedQuestions,
|
||||||
elements:
|
questions:
|
||||||
selectedElements.length === surveyElements.length
|
selectedQuestions.length === selectedSurvey?.questions.length
|
||||||
? t("common.all_questions")
|
? t("common.all_questions")
|
||||||
: t("common.selected_questions"),
|
: t("common.selected_questions"),
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@@ -159,11 +159,11 @@ export const AddChannelMappingModal = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCheckboxChange = (elementId: string) => {
|
const handleCheckboxChange = (questionId: TSurveyQuestionId) => {
|
||||||
setSelectedElements((prevValues) =>
|
setSelectedQuestions((prevValues) =>
|
||||||
prevValues.includes(elementId)
|
prevValues.includes(questionId)
|
||||||
? prevValues.filter((value) => value !== elementId)
|
? prevValues.filter((value) => value !== questionId)
|
||||||
: [...prevValues, elementId]
|
: [...prevValues, questionId]
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -274,22 +274,22 @@ export const AddChannelMappingModal = ({
|
|||||||
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
||||||
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
|
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
|
||||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||||
{surveyElements.map((element) => (
|
{questions.map((question) => (
|
||||||
<div key={element.id} className="my-1 flex items-center space-x-2">
|
<div key={question.id} className="my-1 flex items-center space-x-2">
|
||||||
<label htmlFor={element.id} className="flex cursor-pointer items-center">
|
<label htmlFor={question.id} className="flex cursor-pointer items-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
type="button"
|
type="button"
|
||||||
id={element.id}
|
id={question.id}
|
||||||
value={element.id}
|
value={question.id}
|
||||||
className="bg-white"
|
className="bg-white"
|
||||||
checked={selectedElements.includes(element.id)}
|
checked={selectedQuestions.includes(question.id)}
|
||||||
onCheckedChange={() => {
|
onCheckedChange={() => {
|
||||||
handleCheckboxChange(element.id);
|
handleCheckboxChange(question.id);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="ml-2">
|
<span className="ml-2">
|
||||||
{getTextContent(
|
{getTextContent(
|
||||||
recallToHeadline(element.headline, selectedSurvey, false, "default")[
|
recallToHeadline(question.headline, selectedSurvey, false, "default")[
|
||||||
"default"
|
"default"
|
||||||
]
|
]
|
||||||
)}
|
)}
|
||||||
|
|||||||
+11
-3
@@ -4,6 +4,7 @@ import { Trash2Icon } from "lucide-react";
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
|
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||||
@@ -11,9 +12,10 @@ import { timeSince } from "@/lib/time";
|
|||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||||
|
|
||||||
interface ManageIntegrationProps {
|
interface ManageIntegrationProps {
|
||||||
|
environment: TEnvironment;
|
||||||
slackIntegration: TIntegrationSlack;
|
slackIntegration: TIntegrationSlack;
|
||||||
setOpenAddIntegrationModal: React.Dispatch<React.SetStateAction<boolean>>;
|
setOpenAddIntegrationModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
setIsConnected: React.Dispatch<React.SetStateAction<boolean>>;
|
setIsConnected: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
@@ -27,6 +29,7 @@ interface ManageIntegrationProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ManageIntegration = ({
|
export const ManageIntegration = ({
|
||||||
|
environment,
|
||||||
slackIntegration,
|
slackIntegration,
|
||||||
setOpenAddIntegrationModal,
|
setOpenAddIntegrationModal,
|
||||||
setIsConnected,
|
setIsConnected,
|
||||||
@@ -103,7 +106,12 @@ export const ManageIntegration = ({
|
|||||||
</div>
|
</div>
|
||||||
{!integrationArray || integrationArray.length === 0 ? (
|
{!integrationArray || integrationArray.length === 0 ? (
|
||||||
<div className="mt-4 w-full">
|
<div className="mt-4 w-full">
|
||||||
<EmptyState text={t("environments.integrations.slack.connect_your_first_slack_channel")} />
|
<EmptySpaceFiller
|
||||||
|
type="table"
|
||||||
|
environment={environment}
|
||||||
|
noWidgetRequired={true}
|
||||||
|
emptyMessage={t("environments.integrations.slack.connect_your_first_slack_channel")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-4 flex w-full flex-col items-center justify-center">
|
<div className="mt-4 flex w-full flex-col items-center justify-center">
|
||||||
@@ -126,7 +134,7 @@ export const ManageIntegration = ({
|
|||||||
}}>
|
}}>
|
||||||
<div className="col-span-2 text-center">{data.surveyName}</div>
|
<div className="col-span-2 text-center">{data.surveyName}</div>
|
||||||
<div className="col-span-2 text-center">{data.channelName}</div>
|
<div className="col-span-2 text-center">{data.channelName}</div>
|
||||||
<div className="col-span-2 text-center">{data.elements}</div>
|
<div className="col-span-2 text-center">{data.questions}</div>
|
||||||
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString(), locale)}</div>
|
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString(), locale)}</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
+1
@@ -78,6 +78,7 @@ export const SlackWrapper = ({
|
|||||||
selectedIntegration={selectedIntegration}
|
selectedIntegration={selectedIntegration}
|
||||||
/>
|
/>
|
||||||
<ManageIntegration
|
<ManageIntegration
|
||||||
|
environment={environment}
|
||||||
slackIntegration={slackIntegration}
|
slackIntegration={slackIntegration}
|
||||||
setOpenAddIntegrationModal={setIsModalOpen}
|
setOpenAddIntegrationModal={setIsModalOpen}
|
||||||
setIsConnected={setIsConnected}
|
setIsConnected={setIsConnected}
|
||||||
|
|||||||
+1
-1
@@ -215,7 +215,7 @@ export const EditProfileDetailsForm = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
|
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-slate-50 text-slate-700"
|
||||||
align="start">
|
align="start">
|
||||||
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
|
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
|
||||||
{appLanguages.map((lang) => (
|
{appLanguages.map((lang) => (
|
||||||
|
|||||||
+1
-2
@@ -1,6 +1,5 @@
|
|||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
import { getServerSession } from "next-auth";
|
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 { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
@@ -26,7 +25,7 @@ export const generateMetadata = async (props: Props): Promise<Metadata> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const SurveyLayout = async ({ children }) => {
|
const SurveyLayout = async ({ children }) => {
|
||||||
return <ResponseFilterProvider>{children}</ResponseFilterProvider>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SurveyLayout;
|
export default SurveyLayout;
|
||||||
|
|||||||
+6
-5
@@ -56,11 +56,12 @@ export const formatContactInfoData = (responseValue: TResponseDataValue): Record
|
|||||||
export const extractResponseData = (response: TResponseWithQuotas, survey: TSurvey): Record<string, any> => {
|
export const extractResponseData = (response: TResponseWithQuotas, survey: TSurvey): Record<string, any> => {
|
||||||
const responseData: Record<string, any> = {};
|
const responseData: Record<string, any> = {};
|
||||||
|
|
||||||
const elements = getElementsFromBlocks(survey.blocks);
|
// Derive questions from blocks
|
||||||
|
const questions = getElementsFromBlocks(survey.blocks);
|
||||||
|
|
||||||
for (const element of elements) {
|
for (const question of questions) {
|
||||||
const responseValue = response.data[element.id];
|
const responseValue = response.data[question.id];
|
||||||
switch (element.type) {
|
switch (question.type) {
|
||||||
case "matrix":
|
case "matrix":
|
||||||
if (typeof responseValue === "object") {
|
if (typeof responseValue === "object") {
|
||||||
Object.assign(responseData, responseValue);
|
Object.assign(responseData, responseValue);
|
||||||
@@ -73,7 +74,7 @@ export const extractResponseData = (response: TResponseWithQuotas, survey: TSurv
|
|||||||
Object.assign(responseData, formatContactInfoData(responseValue));
|
Object.assign(responseData, formatContactInfoData(responseValue));
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
responseData[element.id] = responseValue;
|
responseData[question.id] = responseValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-27
@@ -8,8 +8,8 @@ import { TResponseWithQuotas } from "@formbricks/types/responses";
|
|||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { TTag } from "@formbricks/types/tags";
|
import { TTag } from "@formbricks/types/tags";
|
||||||
import { TUser, TUserLocale } from "@formbricks/types/user";
|
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 { 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 { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView";
|
||||||
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
||||||
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
||||||
@@ -26,7 +26,6 @@ interface ResponsePageProps {
|
|||||||
isReadOnly: boolean;
|
isReadOnly: boolean;
|
||||||
isQuotasAllowed: boolean;
|
isQuotasAllowed: boolean;
|
||||||
quotas: TSurveyQuota[];
|
quotas: TSurveyQuota[];
|
||||||
initialResponses?: TResponseWithQuotas[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ResponsePage = ({
|
export const ResponsePage = ({
|
||||||
@@ -40,12 +39,11 @@ export const ResponsePage = ({
|
|||||||
isReadOnly,
|
isReadOnly,
|
||||||
isQuotasAllowed,
|
isQuotasAllowed,
|
||||||
quotas,
|
quotas,
|
||||||
initialResponses = [],
|
|
||||||
}: ResponsePageProps) => {
|
}: ResponsePageProps) => {
|
||||||
const [responses, setResponses] = useState<TResponseWithQuotas[]>(initialResponses);
|
const [responses, setResponses] = useState<TResponseWithQuotas[]>([]);
|
||||||
const [page, setPage] = useState<number | null>(null);
|
const [page, setPage] = useState<number>(1);
|
||||||
const [hasMore, setHasMore] = useState<boolean>(initialResponses.length >= responsesPerPage);
|
const [hasMore, setHasMore] = useState<boolean>(true);
|
||||||
const [isFetchingFirstPage, setIsFetchingFirstPage] = useState<boolean>(false);
|
const [isFetchingFirstPage, setFetchingFirstPage] = useState<boolean>(true);
|
||||||
const { selectedFilter, dateRange, resetState } = useResponseFilter();
|
const { selectedFilter, dateRange, resetState } = useResponseFilter();
|
||||||
|
|
||||||
const filters = useMemo(
|
const filters = useMemo(
|
||||||
@@ -58,7 +56,6 @@ export const ResponsePage = ({
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const fetchNextPage = useCallback(async () => {
|
const fetchNextPage = useCallback(async () => {
|
||||||
if (page === null) return;
|
|
||||||
const newPage = page + 1;
|
const newPage = page + 1;
|
||||||
|
|
||||||
let newResponses: TResponseWithQuotas[] = [];
|
let newResponses: TResponseWithQuotas[] = [];
|
||||||
@@ -96,22 +93,10 @@ export const ResponsePage = ({
|
|||||||
}
|
}
|
||||||
}, [searchParams, resetState]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const fetchFilteredResponses = async () => {
|
const fetchInitialResponses = async () => {
|
||||||
try {
|
try {
|
||||||
// skip call for initial mount
|
setFetchingFirstPage(true);
|
||||||
if (page === null && !hasFilters) {
|
|
||||||
setPage(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPage(1);
|
|
||||||
setIsFetchingFirstPage(true);
|
|
||||||
let responses: TResponseWithQuotas[] = [];
|
let responses: TResponseWithQuotas[] = [];
|
||||||
|
|
||||||
const getResponsesActionResponse = await getResponsesAction({
|
const getResponsesActionResponse = await getResponsesAction({
|
||||||
@@ -125,16 +110,19 @@ export const ResponsePage = ({
|
|||||||
|
|
||||||
if (responses.length < responsesPerPage) {
|
if (responses.length < responsesPerPage) {
|
||||||
setHasMore(false);
|
setHasMore(false);
|
||||||
} else {
|
|
||||||
setHasMore(true);
|
|
||||||
}
|
}
|
||||||
setResponses(responses);
|
setResponses(responses);
|
||||||
} finally {
|
} finally {
|
||||||
setIsFetchingFirstPage(false);
|
setFetchingFirstPage(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchFilteredResponses();
|
fetchInitialResponses();
|
||||||
}, [filters, responsesPerPage, selectedFilter, dateRange, surveyId]);
|
}, [surveyId, filters, responsesPerPage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
setHasMore(true);
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
+44
-37
@@ -15,7 +15,7 @@ import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
|||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { recallToHeadline } from "@/lib/utils/recall";
|
||||||
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
|
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
|
||||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||||
import { VARIABLES_ICON_MAP, getElementIconMap } from "@/modules/survey/lib/elements";
|
import { VARIABLES_ICON_MAP, getQuestionIconMap } from "@/modules/survey/lib/questions";
|
||||||
import { getSelectionColumn } from "@/modules/ui/components/data-table";
|
import { getSelectionColumn } from "@/modules/ui/components/data-table";
|
||||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||||
import { ResponseBadges } from "@/modules/ui/components/response-badges";
|
import { ResponseBadges } from "@/modules/ui/components/response-badges";
|
||||||
@@ -30,33 +30,35 @@ import {
|
|||||||
getMetadataValue,
|
getMetadataValue,
|
||||||
} from "../lib/utils";
|
} from "../lib/utils";
|
||||||
|
|
||||||
const getElementColumnsData = (
|
const getQuestionColumnsData = (
|
||||||
element: TSurveyElement,
|
question: TSurveyElement,
|
||||||
survey: TSurvey,
|
survey: TSurvey,
|
||||||
isExpanded: boolean,
|
isExpanded: boolean,
|
||||||
t: TFunction
|
t: TFunction
|
||||||
): ColumnDef<TResponseTableData>[] => {
|
): ColumnDef<TResponseTableData>[] => {
|
||||||
const ELEMENTS_ICON_MAP = getElementIconMap(t);
|
const QUESTIONS_ICON_MAP = getQuestionIconMap(t);
|
||||||
const addressFields = ["addressLine1", "addressLine2", "city", "state", "zip", "country"];
|
const addressFields = ["addressLine1", "addressLine2", "city", "state", "zip", "country"];
|
||||||
const contactInfoFields = ["firstName", "lastName", "email", "phone", "company"];
|
const contactInfoFields = ["firstName", "lastName", "email", "phone", "company"];
|
||||||
|
|
||||||
// Helper function to create consistent column headers
|
// Helper function to create consistent column headers
|
||||||
const createElementHeader = (elementType: string, headline: string, suffix?: string) => {
|
const createQuestionHeader = (questionType: string, headline: string, suffix?: string) => {
|
||||||
const title = suffix ? `${headline} - ${suffix}` : headline;
|
const title = suffix ? `${headline} - ${suffix}` : headline;
|
||||||
const ElementHeader = () => (
|
const QuestionHeader = () => (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2 overflow-hidden">
|
<div className="flex items-center space-x-2 overflow-hidden">
|
||||||
<span className="h-4 w-4">{ELEMENTS_ICON_MAP[elementType]}</span>
|
<span className="h-4 w-4">{QUESTIONS_ICON_MAP[questionType]}</span>
|
||||||
<span className="truncate">{title}</span>
|
<span className="truncate">{title}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
return ElementHeader;
|
QuestionHeader.displayName = "QuestionHeader";
|
||||||
|
return QuestionHeader;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getElementHeadline = (element: TSurveyElement, survey: TSurvey) => {
|
// Helper function to get localized question headline
|
||||||
|
const getQuestionHeadline = (question: TSurveyElement, survey: TSurvey) => {
|
||||||
return getTextContent(
|
return getTextContent(
|
||||||
getLocalizedValue(recallToHeadline(element.headline, survey, false, "default"), "default")
|
getLocalizedValue(recallToHeadline(question.headline, survey, false, "default"), "default")
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -75,18 +77,18 @@ const getElementColumnsData = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
switch (element.type) {
|
switch (question.type) {
|
||||||
case "matrix":
|
case "matrix":
|
||||||
return element.rows.map((matrixRow) => {
|
return question.rows.map((matrixRow) => {
|
||||||
return {
|
return {
|
||||||
accessorKey: "ELEMENT_" + element.id + "_" + matrixRow.label.default,
|
accessorKey: "QUESTION_" + question.id + "_" + matrixRow.label.default,
|
||||||
header: () => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2 overflow-hidden">
|
<div className="flex items-center space-x-2 overflow-hidden">
|
||||||
<span className="h-4 w-4">{ELEMENTS_ICON_MAP["matrix"]}</span>
|
<span className="h-4 w-4">{QUESTIONS_ICON_MAP["matrix"]}</span>
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{getTextContent(getLocalizedValue(element.headline, "default")) +
|
{getTextContent(getLocalizedValue(question.headline, "default")) +
|
||||||
" - " +
|
" - " +
|
||||||
getLocalizedValue(matrixRow.label, "default")}
|
getLocalizedValue(matrixRow.label, "default")}
|
||||||
</span>
|
</span>
|
||||||
@@ -106,12 +108,12 @@ const getElementColumnsData = (
|
|||||||
case "address":
|
case "address":
|
||||||
return addressFields.map((addressField) => {
|
return addressFields.map((addressField) => {
|
||||||
return {
|
return {
|
||||||
accessorKey: "ELEMENT_" + element.id + "_" + addressField,
|
accessorKey: "QUESTION_" + question.id + "_" + addressField,
|
||||||
header: () => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2 overflow-hidden">
|
<div className="flex items-center space-x-2 overflow-hidden">
|
||||||
<span className="h-4 w-4">{ELEMENTS_ICON_MAP["address"]}</span>
|
<span className="h-4 w-4">{QUESTIONS_ICON_MAP["address"]}</span>
|
||||||
<span className="truncate">{getAddressFieldLabel(addressField, t)}</span>
|
<span className="truncate">{getAddressFieldLabel(addressField, t)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,12 +131,12 @@ const getElementColumnsData = (
|
|||||||
case "contactInfo":
|
case "contactInfo":
|
||||||
return contactInfoFields.map((contactInfoField) => {
|
return contactInfoFields.map((contactInfoField) => {
|
||||||
return {
|
return {
|
||||||
accessorKey: "ELEMENT_" + element.id + "_" + contactInfoField,
|
accessorKey: "QUESTION_" + question.id + "_" + contactInfoField,
|
||||||
header: () => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2 overflow-hidden">
|
<div className="flex items-center space-x-2 overflow-hidden">
|
||||||
<span className="h-4 w-4">{ELEMENTS_ICON_MAP["contactInfo"]}</span>
|
<span className="h-4 w-4">{QUESTIONS_ICON_MAP["contactInfo"]}</span>
|
||||||
<span className="truncate">{getContactInfoFieldLabel(contactInfoField, t)}</span>
|
<span className="truncate">{getContactInfoFieldLabel(contactInfoField, t)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,17 +155,17 @@ const getElementColumnsData = (
|
|||||||
case "multipleChoiceSingle":
|
case "multipleChoiceSingle":
|
||||||
case "ranking":
|
case "ranking":
|
||||||
case "pictureSelection": {
|
case "pictureSelection": {
|
||||||
const elementHeadline = getElementHeadline(element, survey);
|
const questionHeadline = getQuestionHeadline(question, survey);
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
accessorKey: "ELEMENT_" + element.id,
|
accessorKey: "QUESTION_" + question.id,
|
||||||
header: createElementHeader(element.type, elementHeadline),
|
header: createQuestionHeader(question.type, questionHeadline),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const responseValue = row.original.responseData[element.id];
|
const responseValue = row.original.responseData[question.id];
|
||||||
const language = row.original.language;
|
const language = row.original.language;
|
||||||
return (
|
return (
|
||||||
<RenderResponse
|
<RenderResponse
|
||||||
element={element}
|
question={question}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
responseData={responseValue}
|
responseData={responseValue}
|
||||||
language={language}
|
language={language}
|
||||||
@@ -174,15 +176,15 @@ const getElementColumnsData = (
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "ELEMENT_" + element.id + "optionIds",
|
accessorKey: "QUESTION_" + question.id + "optionIds",
|
||||||
header: createElementHeader(element.type, elementHeadline, t("common.option_id")),
|
header: createQuestionHeader(question.type, questionHeadline, t("common.option_id")),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const responseValue = row.original.responseData[element.id];
|
const responseValue = row.original.responseData[question.id];
|
||||||
// Type guard to ensure responseValue is the correct type
|
// Type guard to ensure responseValue is the correct type
|
||||||
if (typeof responseValue === "string" || Array.isArray(responseValue)) {
|
if (typeof responseValue === "string" || Array.isArray(responseValue)) {
|
||||||
const choiceIds = extractChoiceIdsFromResponse(
|
const choiceIds = extractChoiceIdsFromResponse(
|
||||||
responseValue,
|
responseValue,
|
||||||
element,
|
question,
|
||||||
row.original.language || undefined
|
row.original.language || undefined
|
||||||
);
|
);
|
||||||
return renderChoiceIdBadges(choiceIds, isExpanded);
|
return renderChoiceIdBadges(choiceIds, isExpanded);
|
||||||
@@ -196,25 +198,28 @@ const getElementColumnsData = (
|
|||||||
default:
|
default:
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
accessorKey: "ELEMENT_" + element.id,
|
accessorKey: "QUESTION_" + question.id,
|
||||||
header: () => (
|
header: () => (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2 overflow-hidden">
|
<div className="flex items-center space-x-2 overflow-hidden">
|
||||||
<span className="h-4 w-4">{ELEMENTS_ICON_MAP[element.type]}</span>
|
<span className="h-4 w-4">{QUESTIONS_ICON_MAP[question.type]}</span>
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{getTextContent(
|
{getTextContent(
|
||||||
getLocalizedValue(recallToHeadline(element.headline, survey, false, "default"), "default")
|
getLocalizedValue(
|
||||||
|
recallToHeadline(question.headline, survey, false, "default"),
|
||||||
|
"default"
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const responseValue = row.original.responseData[element.id];
|
const responseValue = row.original.responseData[question.id];
|
||||||
const language = row.original.language;
|
const language = row.original.language;
|
||||||
return (
|
return (
|
||||||
<RenderResponse
|
<RenderResponse
|
||||||
element={element}
|
question={question}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
responseData={responseValue}
|
responseData={responseValue}
|
||||||
language={language}
|
language={language}
|
||||||
@@ -262,8 +267,10 @@ export const generateResponseTableColumns = (
|
|||||||
t: TFunction,
|
t: TFunction,
|
||||||
showQuotasColumn: boolean
|
showQuotasColumn: boolean
|
||||||
): ColumnDef<TResponseTableData>[] => {
|
): ColumnDef<TResponseTableData>[] => {
|
||||||
const elements = getElementsFromBlocks(survey.blocks);
|
const questions = getElementsFromBlocks(survey.blocks);
|
||||||
const elementColumns = elements.flatMap((element) => getElementColumnsData(element, survey, isExpanded, t));
|
const questionColumns = questions.flatMap((question) =>
|
||||||
|
getQuestionColumnsData(question, survey, isExpanded, t)
|
||||||
|
);
|
||||||
|
|
||||||
const dateColumn: ColumnDef<TResponseTableData> = {
|
const dateColumn: ColumnDef<TResponseTableData> = {
|
||||||
accessorKey: "createdAt",
|
accessorKey: "createdAt",
|
||||||
@@ -410,7 +417,7 @@ export const generateResponseTableColumns = (
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Combine the selection column with the dynamic element columns
|
// Combine the selection column with the dynamic question columns
|
||||||
const baseColumns = [
|
const baseColumns = [
|
||||||
personColumn,
|
personColumn,
|
||||||
singleUseIdColumn,
|
singleUseIdColumn,
|
||||||
@@ -418,7 +425,7 @@ export const generateResponseTableColumns = (
|
|||||||
...(showQuotasColumn ? [quotasColumn] : []),
|
...(showQuotasColumn ? [quotasColumn] : []),
|
||||||
statusColumn,
|
statusColumn,
|
||||||
...(survey.isVerifyEmailEnabled ? [verifiedEmailColumn] : []),
|
...(survey.isVerifyEmailEnabled ? [verifiedEmailColumn] : []),
|
||||||
...elementColumns,
|
...questionColumns,
|
||||||
...variableColumns,
|
...variableColumns,
|
||||||
...hiddenFieldColumns,
|
...hiddenFieldColumns,
|
||||||
...metadataColumns,
|
...metadataColumns,
|
||||||
|
|||||||
+21
-18
@@ -2,8 +2,9 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
|
|||||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||||
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED, RESPONSES_PER_PAGE } from "@/lib/constants";
|
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 { getPublicDomain } from "@/lib/getPublicUrl";
|
||||||
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
|
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
@@ -13,6 +14,7 @@ import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
|
|||||||
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
|
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
|
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
|
||||||
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||||
@@ -21,43 +23,44 @@ const Page = async (props) => {
|
|||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
|
|
||||||
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
const { session, environment, isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
||||||
|
|
||||||
const [survey, user, tags, isContactsEnabled, responseCount, locale] = await Promise.all([
|
const survey = await getSurvey(params.surveyId);
|
||||||
getSurvey(params.surveyId),
|
|
||||||
getUser(session.user.id),
|
|
||||||
getTagsByEnvironmentId(params.environmentId),
|
|
||||||
getIsContactsEnabled(),
|
|
||||||
getResponseCountBySurveyId(params.surveyId),
|
|
||||||
findMatchingLocale(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!survey) {
|
if (!survey) {
|
||||||
throw new Error(t("common.survey_not_found"));
|
throw new Error(t("common.survey_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const user = await getUser(session.user.id);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error(t("common.user_not_found"));
|
throw new Error(t("common.user_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!organization) {
|
const tags = await getTagsByEnvironmentId(params.environmentId);
|
||||||
throw new Error(t("common.organization_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const isContactsEnabled = await getIsContactsEnabled();
|
||||||
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
|
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 publicDomain = getPublicDomain();
|
||||||
|
|
||||||
const organizationBilling = await getOrganizationBilling(organization.id);
|
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
||||||
|
if (!organizationId) {
|
||||||
|
throw new Error(t("common.organization_not_found"));
|
||||||
|
}
|
||||||
|
const organizationBilling = await getOrganizationBilling(organizationId);
|
||||||
if (!organizationBilling) {
|
if (!organizationBilling) {
|
||||||
throw new Error(t("common.organization_not_found"));
|
throw new Error(t("common.organization_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
|
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 quotas = isQuotasAllowed ? await getQuotas(survey.id) : [];
|
||||||
const initialResponses = await getResponses(params.surveyId, RESPONSES_PER_PAGE, 0);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
@@ -71,6 +74,7 @@ const Page = async (props) => {
|
|||||||
user={user}
|
user={user}
|
||||||
publicDomain={publicDomain}
|
publicDomain={publicDomain}
|
||||||
responseCount={responseCount}
|
responseCount={responseCount}
|
||||||
|
displayCount={displayCount}
|
||||||
segments={segments}
|
segments={segments}
|
||||||
isContactsEnabled={isContactsEnabled}
|
isContactsEnabled={isContactsEnabled}
|
||||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||||
@@ -90,7 +94,6 @@ const Page = async (props) => {
|
|||||||
isReadOnly={isReadOnly}
|
isReadOnly={isReadOnly}
|
||||||
isQuotasAllowed={isQuotasAllowed}
|
isQuotasAllowed={isQuotasAllowed}
|
||||||
quotas={quotas}
|
quotas={quotas}
|
||||||
initialResponses={initialResponses}
|
|
||||||
/>
|
/>
|
||||||
</PageContentWrapper>
|
</PageContentWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
+38
-45
@@ -8,21 +8,20 @@ import { timeSince } from "@/lib/time";
|
|||||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||||
import { ArrayResponse } from "@/modules/ui/components/array-response";
|
import { ArrayResponse } from "@/modules/ui/components/array-response";
|
||||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
|
||||||
|
|
||||||
interface AddressSummaryProps {
|
interface AddressSummaryProps {
|
||||||
elementSummary: TSurveyElementSummaryAddress;
|
questionSummary: TSurveyElementSummaryAddress;
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AddressSummary = ({ elementSummary, environmentId, survey, locale }: AddressSummaryProps) => {
|
export const AddressSummary = ({ questionSummary, environmentId, survey, locale }: AddressSummaryProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} />
|
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||||
<div>
|
<div>
|
||||||
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
|
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
|
||||||
<div className="pl-4 md:pl-6">{t("common.user")}</div>
|
<div className="pl-4 md:pl-6">{t("common.user")}</div>
|
||||||
@@ -30,48 +29,42 @@ export const AddressSummary = ({ elementSummary, environmentId, survey, locale }
|
|||||||
<div className="px-4 md:px-6">{t("common.time")}</div>
|
<div className="px-4 md:px-6">{t("common.time")}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[62vh] w-full overflow-y-auto">
|
<div className="max-h-[62vh] w-full overflow-y-auto">
|
||||||
{elementSummary.samples.length === 0 ? (
|
{questionSummary.samples.map((response) => {
|
||||||
<div className="p-8">
|
return (
|
||||||
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
|
<div
|
||||||
</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">
|
||||||
elementSummary.samples.map((response) => {
|
<div className="pl-4 md:pl-6">
|
||||||
return (
|
{response.contact ? (
|
||||||
<div
|
<Link
|
||||||
key={response.id}
|
className="ph-no-capture group flex items-center"
|
||||||
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">
|
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||||
<div className="pl-4 md:pl-6">
|
<div className="hidden md:flex">
|
||||||
{response.contact ? (
|
<PersonAvatar personId={response.contact.id} />
|
||||||
<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>
|
||||||
)}
|
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||||
</div>
|
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||||
<div className="ph-no-capture col-span-2 pl-6 font-semibold">
|
</p>
|
||||||
<ArrayResponse value={response.value} />
|
</Link>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="group flex items-center">
|
||||||
<div className="px-4 text-slate-500 md:px-6">
|
<div className="hidden md:flex">
|
||||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
<PersonAvatar personId="anonymous" />
|
||||||
</div>
|
</div>
|
||||||
|
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+13
-13
@@ -5,36 +5,36 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { TSurvey, TSurveyElementSummaryCta } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveyElementSummaryCta } from "@formbricks/types/surveys/types";
|
||||||
import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
||||||
import { convertFloatToNDecimal } from "../lib/utils";
|
import { convertFloatToNDecimal } from "../lib/utils";
|
||||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||||
|
|
||||||
interface CTASummaryProps {
|
interface CTASummaryProps {
|
||||||
elementSummary: TSurveyElementSummaryCta;
|
questionSummary: TSurveyElementSummaryCta;
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CTASummary = ({ elementSummary, survey }: CTASummaryProps) => {
|
export const CTASummary = ({ questionSummary, survey }: CTASummaryProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<ElementSummaryHeader
|
<QuestionSummaryHeader
|
||||||
survey={survey}
|
survey={survey}
|
||||||
elementSummary={elementSummary}
|
questionSummary={questionSummary}
|
||||||
showResponses={false}
|
showResponses={false}
|
||||||
additionalInfo={
|
additionalInfo={
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
<InboxIcon className="mr-2 h-4 w-4" />
|
<InboxIcon className="mr-2 h-4 w-4" />
|
||||||
{`${elementSummary.impressionCount} ${t("common.impressions")}`}
|
{`${questionSummary.impressionCount} ${t("common.impressions")}`}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
<InboxIcon className="mr-2 h-4 w-4" />
|
<InboxIcon className="mr-2 h-4 w-4" />
|
||||||
{`${elementSummary.clickCount} ${t("common.clicks")}`}
|
{`${questionSummary.clickCount} ${t("common.clicks")}`}
|
||||||
</div>
|
</div>
|
||||||
{!elementSummary.element.required && (
|
{!questionSummary.question.required && (
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
<InboxIcon className="mr-2 h-4 w-4" />
|
<InboxIcon className="mr-2 h-4 w-4" />
|
||||||
{`${elementSummary.skipCount} ${t("common.skips")}`}
|
{`${questionSummary.skipCount} ${t("common.skips")}`}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -46,16 +46,16 @@ export const CTASummary = ({ elementSummary, survey }: CTASummaryProps) => {
|
|||||||
<p className="font-semibold text-slate-700">CTR</p>
|
<p className="font-semibold text-slate-700">CTR</p>
|
||||||
<div>
|
<div>
|
||||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||||
{convertFloatToNDecimal(elementSummary.ctr.percentage, 2)}%
|
{convertFloatToNDecimal(questionSummary.ctr.percentage, 2)}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||||
{elementSummary.ctr.count}{" "}
|
{questionSummary.ctr.count}{" "}
|
||||||
{elementSummary.ctr.count === 1 ? t("common.click") : t("common.clicks")}
|
{questionSummary.ctr.count === 1 ? t("common.click") : t("common.clicks")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.ctr.percentage / 100} />
|
<ProgressBar barColor="bg-brand-dark" progress={questionSummary.ctr.percentage / 100} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+12
-12
@@ -4,20 +4,20 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { TSurvey, TSurveyElementSummaryCal } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveyElementSummaryCal } from "@formbricks/types/surveys/types";
|
||||||
import { convertFloatToNDecimal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
|
import { convertFloatToNDecimal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
|
||||||
import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
||||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||||
|
|
||||||
interface CalSummaryProps {
|
interface CalSummaryProps {
|
||||||
elementSummary: TSurveyElementSummaryCal;
|
questionSummary: TSurveyElementSummaryCal;
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
|
export const CalSummary = ({ questionSummary, survey }: CalSummaryProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} />
|
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||||
<div>
|
<div>
|
||||||
<div className="text flex justify-between px-2 pb-2">
|
<div className="text flex justify-between px-2 pb-2">
|
||||||
@@ -25,16 +25,16 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
|
|||||||
<p className="font-semibold text-slate-700">{t("common.booked")}</p>
|
<p className="font-semibold text-slate-700">{t("common.booked")}</p>
|
||||||
<div>
|
<div>
|
||||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||||
{convertFloatToNDecimal(elementSummary.booked.percentage, 2)}%
|
{convertFloatToNDecimal(questionSummary.booked.percentage, 2)}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||||
{elementSummary.booked.count}{" "}
|
{questionSummary.booked.count}{" "}
|
||||||
{elementSummary.booked.count === 1 ? t("common.response") : t("common.responses")}
|
{questionSummary.booked.count === 1 ? t("common.response") : t("common.responses")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.booked.percentage / 100} />
|
<ProgressBar barColor="bg-brand-dark" progress={questionSummary.booked.percentage / 100} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text flex justify-between px-2 pb-2">
|
<div className="text flex justify-between px-2 pb-2">
|
||||||
@@ -42,16 +42,16 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
|
|||||||
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
|
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
|
||||||
<div>
|
<div>
|
||||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||||
{convertFloatToNDecimal(elementSummary.skipped.percentage, 2)}%
|
{convertFloatToNDecimal(questionSummary.skipped.percentage, 2)}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||||
{elementSummary.skipped.count}{" "}
|
{questionSummary.skipped.count}{" "}
|
||||||
{elementSummary.skipped.count === 1 ? t("common.response") : t("common.responses")}
|
{questionSummary.skipped.count === 1 ? t("common.response") : t("common.responses")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.skipped.percentage / 100} />
|
<ProgressBar barColor="bg-brand-dark" progress={questionSummary.skipped.percentage / 100} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
-32
@@ -1,32 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
+14
-14
@@ -3,40 +3,40 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { type TI18nString } from "@formbricks/types/i18n";
|
import { type TI18nString } from "@formbricks/types/i18n";
|
||||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||||
import { TSurvey, TSurveyElementSummaryConsent } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveyElementSummaryConsent, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||||
import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
||||||
import { convertFloatToNDecimal } from "../lib/utils";
|
import { convertFloatToNDecimal } from "../lib/utils";
|
||||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||||
|
|
||||||
interface ConsentSummaryProps {
|
interface ConsentSummaryProps {
|
||||||
elementSummary: TSurveyElementSummaryConsent;
|
questionSummary: TSurveyElementSummaryConsent;
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
setFilter: (
|
setFilter: (
|
||||||
elementId: string,
|
questionId: TSurveyQuestionId,
|
||||||
label: TI18nString,
|
label: TI18nString,
|
||||||
elementType: TSurveyElementTypeEnum,
|
questionType: TSurveyElementTypeEnum,
|
||||||
filterValue: string,
|
filterValue: string,
|
||||||
filterComboBoxValue?: string | string[]
|
filterComboBoxValue?: string | string[]
|
||||||
) => void;
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ConsentSummary = ({ elementSummary, survey, setFilter }: ConsentSummaryProps) => {
|
export const ConsentSummary = ({ questionSummary, survey, setFilter }: ConsentSummaryProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const summaryItems = [
|
const summaryItems = [
|
||||||
{
|
{
|
||||||
title: t("common.accepted"),
|
title: t("common.accepted"),
|
||||||
percentage: elementSummary.accepted.percentage,
|
percentage: questionSummary.accepted.percentage,
|
||||||
count: elementSummary.accepted.count,
|
count: questionSummary.accepted.count,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("common.dismissed"),
|
title: t("common.dismissed"),
|
||||||
percentage: elementSummary.dismissed.percentage,
|
percentage: questionSummary.dismissed.percentage,
|
||||||
count: elementSummary.dismissed.count,
|
count: questionSummary.dismissed.count,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} />
|
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||||
{summaryItems.map((summaryItem) => {
|
{summaryItems.map((summaryItem) => {
|
||||||
return (
|
return (
|
||||||
@@ -45,9 +45,9 @@ export const ConsentSummary = ({ elementSummary, survey, setFilter }: ConsentSum
|
|||||||
key={summaryItem.title}
|
key={summaryItem.title}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setFilter(
|
setFilter(
|
||||||
elementSummary.element.id,
|
questionSummary.question.id,
|
||||||
elementSummary.element.headline,
|
questionSummary.question.headline,
|
||||||
elementSummary.element.type,
|
questionSummary.question.type,
|
||||||
"is",
|
"is",
|
||||||
summaryItem.title
|
summaryItem.title
|
||||||
)
|
)
|
||||||
|
|||||||
+38
-45
@@ -8,18 +8,17 @@ import { timeSince } from "@/lib/time";
|
|||||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||||
import { ArrayResponse } from "@/modules/ui/components/array-response";
|
import { ArrayResponse } from "@/modules/ui/components/array-response";
|
||||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
|
||||||
|
|
||||||
interface ContactInfoSummaryProps {
|
interface ContactInfoSummaryProps {
|
||||||
elementSummary: TSurveyElementSummaryContactInfo;
|
questionSummary: TSurveyElementSummaryContactInfo;
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ContactInfoSummary = ({
|
export const ContactInfoSummary = ({
|
||||||
elementSummary,
|
questionSummary,
|
||||||
environmentId,
|
environmentId,
|
||||||
survey,
|
survey,
|
||||||
locale,
|
locale,
|
||||||
@@ -27,7 +26,7 @@ export const ContactInfoSummary = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} />
|
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||||
<div>
|
<div>
|
||||||
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
|
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
|
||||||
<div className="pl-4 md:pl-6">{t("common.user")}</div>
|
<div className="pl-4 md:pl-6">{t("common.user")}</div>
|
||||||
@@ -35,48 +34,42 @@ export const ContactInfoSummary = ({
|
|||||||
<div className="px-4 md:px-6">{t("common.time")}</div>
|
<div className="px-4 md:px-6">{t("common.time")}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[62vh] w-full overflow-y-auto">
|
<div className="max-h-[62vh] w-full overflow-y-auto">
|
||||||
{elementSummary.samples.length === 0 ? (
|
{questionSummary.samples.map((response) => {
|
||||||
<div className="p-8">
|
return (
|
||||||
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
|
<div
|
||||||
</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">
|
||||||
elementSummary.samples.map((response) => {
|
<div className="pl-4 md:pl-6">
|
||||||
return (
|
{response.contact ? (
|
||||||
<div
|
<Link
|
||||||
key={response.id}
|
className="ph-no-capture group flex items-center"
|
||||||
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">
|
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||||
<div className="pl-4 md:pl-6">
|
<div className="hidden md:flex">
|
||||||
{response.contact ? (
|
<PersonAvatar personId={response.contact.id} />
|
||||||
<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>
|
||||||
)}
|
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||||
</div>
|
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||||
<div className="ph-no-capture col-span-2 pl-6 font-semibold">
|
</p>
|
||||||
<ArrayResponse value={response.value} />
|
</Link>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="group flex items-center">
|
||||||
<div className="px-4 text-slate-500 md:px-6">
|
<div className="hidden md:flex">
|
||||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
<PersonAvatar personId="anonymous" />
|
||||||
</div>
|
</div>
|
||||||
|
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
-104
@@ -1,104 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { TSurvey, TSurveyElementSummaryDate } from "@formbricks/types/surveys/types";
|
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
|
||||||
import { timeSince } from "@/lib/time";
|
|
||||||
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 { ElementSummaryHeader } from "./ElementSummaryHeader";
|
|
||||||
|
|
||||||
interface DateElementSummary {
|
|
||||||
elementSummary: TSurveyElementSummaryDate;
|
|
||||||
environmentId: string;
|
|
||||||
survey: TSurvey;
|
|
||||||
locale: TUserLocale;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DateElementSummary = ({ elementSummary, environmentId, survey, locale }: DateElementSummary) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [visibleResponses, setVisibleResponses] = useState(10);
|
|
||||||
|
|
||||||
const handleLoadMore = () => {
|
|
||||||
// Increase the number of visible responses by 10, not exceeding the total number of responses
|
|
||||||
setVisibleResponses((prevVisibleResponses) =>
|
|
||||||
Math.min(prevVisibleResponses + 10, elementSummary.samples.length)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderResponseValue = (value: string) => {
|
|
||||||
const parsedDate = new Date(value);
|
|
||||||
|
|
||||||
const formattedDate = isNaN(parsedDate.getTime())
|
|
||||||
? `${t("common.invalid_date")}(${value})`
|
|
||||||
: formatDateWithOrdinal(parsedDate);
|
|
||||||
|
|
||||||
return formattedDate;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
|
||||||
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} />
|
|
||||||
<div className="">
|
|
||||||
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
|
|
||||||
<div className="pl-4 md:pl-6">{t("common.user")}</div>
|
|
||||||
<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>
|
|
||||||
<div className="max-h-[62vh] w-full overflow-y-auto">
|
|
||||||
{elementSummary.samples.length === 0 ? (
|
|
||||||
<div className="p-8">
|
|
||||||
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
elementSummary.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>
|
|
||||||
{elementSummary.samples.length > 0 && visibleResponses < elementSummary.samples.length && (
|
|
||||||
<div className="flex justify-center py-4">
|
|
||||||
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
|
||||||
{t("common.load_more")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TSurvey, TSurveyElementSummaryDate } from "@formbricks/types/surveys/types";
|
||||||
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
|
import { timeSince } from "@/lib/time";
|
||||||
|
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 { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||||
|
|
||||||
|
interface DateQuestionSummary {
|
||||||
|
questionSummary: TSurveyElementSummaryDate;
|
||||||
|
environmentId: string;
|
||||||
|
survey: TSurvey;
|
||||||
|
locale: TUserLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DateQuestionSummary = ({
|
||||||
|
questionSummary,
|
||||||
|
environmentId,
|
||||||
|
survey,
|
||||||
|
locale,
|
||||||
|
}: DateQuestionSummary) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [visibleResponses, setVisibleResponses] = useState(10);
|
||||||
|
|
||||||
|
const handleLoadMore = () => {
|
||||||
|
// Increase the number of visible responses by 10, not exceeding the total number of responses
|
||||||
|
setVisibleResponses((prevVisibleResponses) =>
|
||||||
|
Math.min(prevVisibleResponses + 10, questionSummary.samples.length)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderResponseValue = (value: string) => {
|
||||||
|
const parsedDate = new Date(value);
|
||||||
|
|
||||||
|
const formattedDate = isNaN(parsedDate.getTime())
|
||||||
|
? `${t("common.invalid_date")}(${value})`
|
||||||
|
: formatDateWithOrdinal(parsedDate);
|
||||||
|
|
||||||
|
return formattedDate;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||||
|
<div className="">
|
||||||
|
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
|
||||||
|
<div className="pl-4 md:pl-6">{t("common.user")}</div>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+67
-74
@@ -11,18 +11,17 @@ import { getContactIdentifier } from "@/lib/utils/contact";
|
|||||||
import { getOriginalFileNameFromUrl } from "@/modules/storage/utils";
|
import { getOriginalFileNameFromUrl } from "@/modules/storage/utils";
|
||||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
|
||||||
|
|
||||||
interface FileUploadSummaryProps {
|
interface FileUploadSummaryProps {
|
||||||
elementSummary: TSurveyElementSummaryFileUpload;
|
questionSummary: TSurveyElementSummaryFileUpload;
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FileUploadSummary = ({
|
export const FileUploadSummary = ({
|
||||||
elementSummary,
|
questionSummary,
|
||||||
environmentId,
|
environmentId,
|
||||||
survey,
|
survey,
|
||||||
locale,
|
locale,
|
||||||
@@ -32,13 +31,13 @@ export const FileUploadSummary = ({
|
|||||||
const handleLoadMore = () => {
|
const handleLoadMore = () => {
|
||||||
// Increase the number of visible responses by 10, not exceeding the total number of responses
|
// Increase the number of visible responses by 10, not exceeding the total number of responses
|
||||||
setVisibleResponses((prevVisibleResponses) =>
|
setVisibleResponses((prevVisibleResponses) =>
|
||||||
Math.min(prevVisibleResponses + 10, elementSummary.files.length)
|
Math.min(prevVisibleResponses + 10, questionSummary.files.length)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} />
|
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||||
<div className="">
|
<div className="">
|
||||||
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
|
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
|
||||||
<div className="pl-4 md:pl-6">{t("common.user")}</div>
|
<div className="pl-4 md:pl-6">{t("common.user")}</div>
|
||||||
@@ -46,77 +45,71 @@ export const FileUploadSummary = ({
|
|||||||
<div className="px-4 md:px-6">{t("common.time")}</div>
|
<div className="px-4 md:px-6">{t("common.time")}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[62vh] w-full overflow-y-auto">
|
<div className="max-h-[62vh] w-full overflow-y-auto">
|
||||||
{elementSummary.files.length === 0 ? (
|
{questionSummary.files.slice(0, visibleResponses).map((response) => (
|
||||||
<div className="p-8">
|
<div
|
||||||
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
|
key={response.id}
|
||||||
</div>
|
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">
|
||||||
elementSummary.files.slice(0, visibleResponses).map((response) => (
|
{response.contact ? (
|
||||||
<div
|
<Link
|
||||||
key={response.id}
|
className="ph-no-capture group flex items-center"
|
||||||
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">
|
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||||
<div className="pl-4 md:pl-6">
|
<div className="hidden md:flex">
|
||||||
{response.contact ? (
|
<PersonAvatar personId={response.contact.id} />
|
||||||
<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>
|
||||||
)}
|
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||||
</div>
|
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||||
|
</p>
|
||||||
<div className="col-span-2 grid">
|
</Link>
|
||||||
{Array.isArray(response.value) &&
|
) : (
|
||||||
(response.value.length > 0 ? (
|
<div className="group flex items-center">
|
||||||
response.value.map((fileUrl) => {
|
<div className="hidden md:flex">
|
||||||
const fileName = getOriginalFileNameFromUrl(fileUrl);
|
<PersonAvatar personId="anonymous" />
|
||||||
|
</div>
|
||||||
return (
|
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||||
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
|
</div>
|
||||||
<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>
|
||||||
))
|
|
||||||
)}
|
<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>
|
</div>
|
||||||
{elementSummary.files.length > 0 && visibleResponses < elementSummary.files.length && (
|
{visibleResponses < questionSummary.files.length && (
|
||||||
<div className="flex justify-center py-4">
|
<div className="flex justify-center py-4">
|
||||||
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
||||||
{t("common.load_more")}
|
{t("common.load_more")}
|
||||||
|
|||||||
+38
-45
@@ -10,28 +10,27 @@ import { timeSince } from "@/lib/time";
|
|||||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
|
||||||
|
|
||||||
interface HiddenFieldsSummaryProps {
|
interface HiddenFieldsSummaryProps {
|
||||||
environment: TEnvironment;
|
environment: TEnvironment;
|
||||||
elementSummary: TSurveyElementSummaryHiddenFields;
|
questionSummary: TSurveyElementSummaryHiddenFields;
|
||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HiddenFieldsSummary = ({ environment, elementSummary, locale }: HiddenFieldsSummaryProps) => {
|
export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: HiddenFieldsSummaryProps) => {
|
||||||
const [visibleResponses, setVisibleResponses] = useState(10);
|
const [visibleResponses, setVisibleResponses] = useState(10);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const handleLoadMore = () => {
|
const handleLoadMore = () => {
|
||||||
// Increase the number of visible responses by 10, not exceeding the total number of responses
|
// Increase the number of visible responses by 10, not exceeding the total number of responses
|
||||||
setVisibleResponses((prevVisibleResponses) =>
|
setVisibleResponses((prevVisibleResponses) =>
|
||||||
Math.min(prevVisibleResponses + 10, elementSummary.samples.length)
|
Math.min(prevVisibleResponses + 10, questionSummary.samples.length)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
|
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
|
||||||
<div className={"align-center flex justify-between gap-4"}>
|
<div className={"align-center flex justify-between gap-4"}>
|
||||||
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">{elementSummary.id}</h3>
|
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">{questionSummary.id}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
|
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
|
||||||
@@ -41,8 +40,8 @@ export const HiddenFieldsSummary = ({ environment, elementSummary, locale }: Hid
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
<InboxIcon className="mr-2 h-4 w-4" />
|
<InboxIcon className="mr-2 h-4 w-4" />
|
||||||
{elementSummary.responseCount}{" "}
|
{questionSummary.responseCount}{" "}
|
||||||
{elementSummary.responseCount === 1 ? t("common.response") : t("common.responses")}
|
{questionSummary.responseCount === 1 ? t("common.response") : t("common.responses")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,46 +51,40 @@ export const HiddenFieldsSummary = ({ environment, elementSummary, locale }: Hid
|
|||||||
<div className="col-span-2 pl-4 md:pl-6">{t("common.response")}</div>
|
<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 className="px-4 md:px-6">{t("common.time")}</div>
|
||||||
</div>
|
</div>
|
||||||
{elementSummary.samples.length === 0 ? (
|
{questionSummary.samples.slice(0, visibleResponses).map((response, idx) => (
|
||||||
<div className="p-8">
|
<div
|
||||||
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
|
key={`${response.value}-${idx}`}
|
||||||
</div>
|
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">
|
||||||
elementSummary.samples.slice(0, visibleResponses).map((response, idx) => (
|
{response.contact ? (
|
||||||
<div
|
<Link
|
||||||
key={`${response.value}-${idx}`}
|
className="ph-no-capture group flex items-center"
|
||||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
|
href={`/environments/${environment.id}/contacts/${response.contact.id}`}>
|
||||||
<div className="pl-4 md:pl-6">
|
<div className="hidden md:flex">
|
||||||
{response.contact ? (
|
<PersonAvatar personId={response.contact.id} />
|
||||||
<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>
|
||||||
)}
|
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||||
</div>
|
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||||
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
</p>
|
||||||
{response.value}
|
</Link>
|
||||||
</div>
|
) : (
|
||||||
<div className="px-4 text-slate-500 md:px-6">
|
<div className="group flex items-center">
|
||||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
<div className="hidden md:flex">
|
||||||
</div>
|
<PersonAvatar personId="anonymous" />
|
||||||
|
</div>
|
||||||
|
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
||||||
)}
|
{response.value}
|
||||||
{elementSummary.samples.length > 0 && visibleResponses < elementSummary.samples.length && (
|
</div>
|
||||||
|
<div className="px-4 text-slate-500 md:px-6">
|
||||||
|
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{visibleResponses < questionSummary.samples.length && (
|
||||||
<div className="flex justify-center py-4">
|
<div className="flex justify-center py-4">
|
||||||
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
||||||
{t("common.load_more")}
|
{t("common.load_more")}
|
||||||
|
|||||||
+16
-14
@@ -3,23 +3,23 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { type TI18nString } from "@formbricks/types/i18n";
|
import { type TI18nString } from "@formbricks/types/i18n";
|
||||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||||
import { TSurvey, TSurveyElementSummaryMatrix } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveyElementSummaryMatrix, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||||
|
|
||||||
interface MatrixElementSummaryProps {
|
interface MatrixQuestionSummaryProps {
|
||||||
elementSummary: TSurveyElementSummaryMatrix;
|
questionSummary: TSurveyElementSummaryMatrix;
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
setFilter: (
|
setFilter: (
|
||||||
elementId: string,
|
questionId: TSurveyQuestionId,
|
||||||
label: TI18nString,
|
label: TI18nString,
|
||||||
elementType: TSurveyElementTypeEnum,
|
questionType: TSurveyElementTypeEnum,
|
||||||
filterValue: string,
|
filterValue: string,
|
||||||
filterComboBoxValue?: string | string[]
|
filterComboBoxValue?: string | string[]
|
||||||
) => void;
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: MatrixElementSummaryProps) => {
|
export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: MatrixQuestionSummaryProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const getOpacityLevel = (percentage: number): string => {
|
const getOpacityLevel = (percentage: number): string => {
|
||||||
const parsedPercentage = percentage;
|
const parsedPercentage = percentage;
|
||||||
@@ -36,11 +36,13 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
|
|||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns = elementSummary.data[0] ? elementSummary.data[0].columnPercentages.map((c) => c.column) : [];
|
const columns = questionSummary.data[0]
|
||||||
|
? questionSummary.data[0].columnPercentages.map((c) => c.column)
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} />
|
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||||
<div className="overflow-x-auto p-6">
|
<div className="overflow-x-auto p-6">
|
||||||
{/* Summary Table */}
|
{/* Summary Table */}
|
||||||
<table className="mx-auto border-collapse cursor-default text-left">
|
<table className="mx-auto border-collapse cursor-default text-left">
|
||||||
@@ -57,7 +59,7 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{elementSummary.data.map(({ rowLabel, columnPercentages }, rowIndex) => (
|
{questionSummary.data.map(({ rowLabel, columnPercentages }, rowIndex) => (
|
||||||
<tr key={rowLabel}>
|
<tr key={rowLabel}>
|
||||||
<td className="max-w-60 overflow-hidden text-ellipsis whitespace-nowrap p-4">
|
<td className="max-w-60 overflow-hidden text-ellipsis whitespace-nowrap p-4">
|
||||||
<TooltipRenderer tooltipContent={getTooltipContent(rowLabel)} shouldRender={true}>
|
<TooltipRenderer tooltipContent={getTooltipContent(rowLabel)} shouldRender={true}>
|
||||||
@@ -73,16 +75,16 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
|
|||||||
tooltipContent={getTooltipContent(
|
tooltipContent={getTooltipContent(
|
||||||
undefined,
|
undefined,
|
||||||
percentage,
|
percentage,
|
||||||
elementSummary.data[rowIndex].totalResponsesForRow
|
questionSummary.data[rowIndex].totalResponsesForRow
|
||||||
)}>
|
)}>
|
||||||
<button
|
<button
|
||||||
style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }}
|
style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }}
|
||||||
className="hover:outline-brand-dark m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline"
|
className="hover:outline-brand-dark m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setFilter(
|
setFilter(
|
||||||
elementSummary.element.id,
|
questionSummary.question.id,
|
||||||
elementSummary.element.headline,
|
questionSummary.question.headline,
|
||||||
elementSummary.element.type,
|
questionSummary.question.type,
|
||||||
rowLabel,
|
rowLabel,
|
||||||
column
|
column
|
||||||
)
|
)
|
||||||
+101
-99
@@ -6,7 +6,12 @@ import { Fragment, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TI18nString } from "@formbricks/types/i18n";
|
import { TI18nString } from "@formbricks/types/i18n";
|
||||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||||
import { TSurvey, TSurveyElementSummaryMultipleChoice, TSurveyType } from "@formbricks/types/surveys/types";
|
import {
|
||||||
|
TSurvey,
|
||||||
|
TSurveyElementSummaryMultipleChoice,
|
||||||
|
TSurveyQuestionId,
|
||||||
|
TSurveyType,
|
||||||
|
} from "@formbricks/types/surveys/types";
|
||||||
import { getChoiceIdByValue } from "@/lib/response/utils";
|
import { getChoiceIdByValue } from "@/lib/response/utils";
|
||||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||||
@@ -14,24 +19,24 @@ import { Button } from "@/modules/ui/components/button";
|
|||||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||||
import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
||||||
import { convertFloatToNDecimal } from "../lib/utils";
|
import { convertFloatToNDecimal } from "../lib/utils";
|
||||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||||
|
|
||||||
interface MultipleChoiceSummaryProps {
|
interface MultipleChoiceSummaryProps {
|
||||||
elementSummary: TSurveyElementSummaryMultipleChoice;
|
questionSummary: TSurveyElementSummaryMultipleChoice;
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
surveyType: TSurveyType;
|
surveyType: TSurveyType;
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
setFilter: (
|
setFilter: (
|
||||||
elementId: string,
|
questionId: TSurveyQuestionId,
|
||||||
label: TI18nString,
|
label: TI18nString,
|
||||||
elementType: TSurveyElementTypeEnum,
|
questionType: TSurveyElementTypeEnum,
|
||||||
filterValue: string,
|
filterValue: string,
|
||||||
filterComboBoxValue?: string | string[]
|
filterComboBoxValue?: string | string[]
|
||||||
) => void;
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MultipleChoiceSummary = ({
|
export const MultipleChoiceSummary = ({
|
||||||
elementSummary,
|
questionSummary,
|
||||||
environmentId,
|
environmentId,
|
||||||
surveyType,
|
surveyType,
|
||||||
survey,
|
survey,
|
||||||
@@ -39,9 +44,9 @@ export const MultipleChoiceSummary = ({
|
|||||||
}: MultipleChoiceSummaryProps) => {
|
}: MultipleChoiceSummaryProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [visibleOtherResponses, setVisibleOtherResponses] = useState(10);
|
const [visibleOtherResponses, setVisibleOtherResponses] = useState(10);
|
||||||
const otherValue = elementSummary.element.choices.find((choice) => choice.id === "other")?.label.default;
|
const otherValue = questionSummary.question.choices.find((choice) => choice.id === "other")?.label.default;
|
||||||
// sort by count and transform to array
|
// sort by count and transform to array
|
||||||
const results = Object.values(elementSummary.choices).sort((a, b) => {
|
const results = Object.values(questionSummary.choices).sort((a, b) => {
|
||||||
const aHasOthers = (a.others?.length ?? 0) > 0;
|
const aHasOthers = (a.others?.length ?? 0) > 0;
|
||||||
const bHasOthers = (b.others?.length ?? 0) > 0;
|
const bHasOthers = (b.others?.length ?? 0) > 0;
|
||||||
|
|
||||||
@@ -68,111 +73,108 @@ export const MultipleChoiceSummary = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<ElementSummaryHeader
|
<QuestionSummaryHeader
|
||||||
elementSummary={elementSummary}
|
questionSummary={questionSummary}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
additionalInfo={
|
additionalInfo={
|
||||||
elementSummary.type === "multipleChoiceMulti" ? (
|
questionSummary.type === "multipleChoiceMulti" ? (
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
<InboxIcon className="mr-2 h-4 w-4" />
|
<InboxIcon className="mr-2 h-4 w-4" />
|
||||||
{`${elementSummary.selectionCount} ${t("common.selections")}`}
|
{`${questionSummary.selectionCount} ${t("common.selections")}`}
|
||||||
</div>
|
</div>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||||
<div className="space-y-5">
|
{results.map((result) => {
|
||||||
{results.map((result) => {
|
const choiceId = getChoiceIdByValue(result.value, questionSummary.question);
|
||||||
const choiceId = getChoiceIdByValue(result.value, elementSummary.element);
|
return (
|
||||||
return (
|
<Fragment key={result.value}>
|
||||||
<Fragment key={result.value}>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
className="group w-full cursor-pointer"
|
||||||
className="group w-full cursor-pointer"
|
onClick={() =>
|
||||||
onClick={() =>
|
setFilter(
|
||||||
setFilter(
|
questionSummary.question.id,
|
||||||
elementSummary.element.id,
|
questionSummary.question.headline,
|
||||||
elementSummary.element.headline,
|
questionSummary.question.type,
|
||||||
elementSummary.element.type,
|
questionSummary.type === "multipleChoiceSingle" || otherValue === result.value
|
||||||
elementSummary.type === TSurveyElementTypeEnum.MultipleChoiceSingle ||
|
? t("environments.surveys.summary.includes_either")
|
||||||
otherValue === result.value
|
: t("environments.surveys.summary.includes_all"),
|
||||||
? t("environments.surveys.summary.includes_either")
|
[result.value]
|
||||||
: 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">
|
||||||
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
|
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
|
||||||
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
|
{result.value}
|
||||||
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
|
</p>
|
||||||
{result.value}
|
{choiceId && <IdBadge id={choiceId} />}
|
||||||
</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>
|
||||||
<div className="group-hover:opacity-80">
|
<div className="flex w-full space-x-2">
|
||||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
<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>
|
||||||
</button>
|
</div>
|
||||||
{result.others && result.others.length > 0 && (
|
<div className="group-hover:opacity-80">
|
||||||
<div className="mt-4 rounded-lg border border-slate-200">
|
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
||||||
<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>
|
||||||
<div className="col-span-1 pl-6">
|
</button>
|
||||||
{t("environments.surveys.summary.other_values_found")}
|
{result.others && result.others.length > 0 && (
|
||||||
</div>
|
<div className="mt-4 rounded-lg border border-slate-200">
|
||||||
<div className="col-span-1 pl-6">{surveyType === "app" && t("common.user")}</div>
|
<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>
|
||||||
{result.others
|
<div className="col-span-1 pl-6">{surveyType === "app" && t("common.user")}</div>
|
||||||
.filter((otherValue) => otherValue.value !== "")
|
</div>
|
||||||
.slice(0, visibleOtherResponses)
|
{result.others
|
||||||
.map((otherValue, idx) => (
|
.filter((otherValue) => otherValue.value !== "")
|
||||||
<div key={`${idx}-${otherValue}`} dir="auto">
|
.slice(0, visibleOtherResponses)
|
||||||
{surveyType === "link" && (
|
.map((otherValue, idx) => (
|
||||||
<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">
|
<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">
|
||||||
<span>{otherValue.value}</span>
|
<span>{otherValue.value}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="ph-no-capture col-span-1 flex items-center space-x-4 pl-6 font-medium text-slate-900">
|
||||||
{surveyType === "app" && otherValue.contact && (
|
{otherValue.contact.id && <PersonAvatar personId={otherValue.contact.id} />}
|
||||||
<Link
|
<span>
|
||||||
href={
|
{getContactIdentifier(otherValue.contact, otherValue.contactAttributes)}
|
||||||
otherValue.contact.id
|
</span>
|
||||||
? `/environments/${environmentId}/contacts/${otherValue.contact.id}`
|
</div>
|
||||||
: { pathname: null }
|
</Link>
|
||||||
}
|
)}
|
||||||
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>
|
</div>
|
||||||
)}
|
))}
|
||||||
</div>
|
{visibleOtherResponses < result.others.length && (
|
||||||
)}
|
<div className="flex justify-center py-4">
|
||||||
</Fragment>
|
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
||||||
);
|
{t("common.load_more")}
|
||||||
})}
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+41
-133
@@ -1,45 +1,27 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BarChart, BarChartHorizontal } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { type TI18nString } from "@formbricks/types/i18n";
|
import { type TI18nString } from "@formbricks/types/i18n";
|
||||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||||
import { TSurvey, TSurveyElementSummaryNps } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveyElementSummaryNps, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||||
import { HalfCircle, ProgressBar } from "@/modules/ui/components/progress-bar";
|
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 { convertFloatToNDecimal } from "../lib/utils";
|
||||||
import { ClickableBarSegment } from "./ClickableBarSegment";
|
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
|
||||||
import { SatisfactionIndicator } from "./SatisfactionIndicator";
|
|
||||||
|
|
||||||
interface NPSSummaryProps {
|
interface NPSSummaryProps {
|
||||||
elementSummary: TSurveyElementSummaryNps;
|
questionSummary: TSurveyElementSummaryNps;
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
setFilter: (
|
setFilter: (
|
||||||
elementId: string,
|
questionId: TSurveyQuestionId,
|
||||||
label: TI18nString,
|
label: TI18nString,
|
||||||
elementType: TSurveyElementTypeEnum,
|
questionType: TSurveyElementTypeEnum,
|
||||||
filterValue: string,
|
filterValue: string,
|
||||||
filterComboBoxValue?: string | string[]
|
filterComboBoxValue?: string | string[]
|
||||||
) => void;
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const calculateNPSOpacity = (rating: number): number => {
|
export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryProps) => {
|
||||||
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 = ({ elementSummary, survey, setFilter }: NPSSummaryProps) => {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
|
|
||||||
|
|
||||||
const applyFilter = (group: string) => {
|
const applyFilter = (group: string) => {
|
||||||
const filters = {
|
const filters = {
|
||||||
promoters: {
|
promoters: {
|
||||||
@@ -64,9 +46,9 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
|
|||||||
|
|
||||||
if (filter) {
|
if (filter) {
|
||||||
setFilter(
|
setFilter(
|
||||||
elementSummary.element.id,
|
questionSummary.question.id,
|
||||||
elementSummary.element.headline,
|
questionSummary.question.headline,
|
||||||
elementSummary.element.type,
|
questionSummary.question.type,
|
||||||
filter.comparison,
|
filter.comparison,
|
||||||
filter.values
|
filter.values
|
||||||
);
|
);
|
||||||
@@ -75,115 +57,41 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<ElementSummaryHeader
|
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||||
elementSummary={elementSummary}
|
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||||
survey={survey}
|
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
|
||||||
additionalInfo={
|
<button
|
||||||
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
className="w-full cursor-pointer hover:opacity-80"
|
||||||
<SatisfactionIndicator percentage={elementSummary.promoters.percentage} />
|
key={group}
|
||||||
<div>
|
onClick={() => applyFilter(group)}>
|
||||||
{t("environments.surveys.summary.promoters")}:{" "}
|
<div
|
||||||
{convertFloatToNDecimal(elementSummary.promoters.percentage, 2)}%
|
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>
|
</div>
|
||||||
</div>
|
<ProgressBar
|
||||||
}
|
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
|
||||||
/>
|
progress={questionSummary[group]?.percentage / 100}
|
||||||
|
/>
|
||||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "aggregated" | "individual")}>
|
</button>
|
||||||
<div className="flex justify-end px-4 md:px-6">
|
))}
|
||||||
<TabsList>
|
</div>
|
||||||
<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(elementSummary[group]?.percentage, 2)}%
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
|
||||||
{elementSummary[group]?.count}{" "}
|
|
||||||
{elementSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<ProgressBar
|
|
||||||
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
|
|
||||||
progress={elementSummary[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">
|
|
||||||
{elementSummary.choices.map((choice) => {
|
|
||||||
const opacity = calculateNPSOpacity(choice.rating);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ClickableBarSegment
|
|
||||||
key={choice.rating}
|
|
||||||
className="group flex cursor-pointer flex-col items-center"
|
|
||||||
onClick={() =>
|
|
||||||
setFilter(
|
|
||||||
elementSummary.element.id,
|
|
||||||
elementSummary.element.headline,
|
|
||||||
elementSummary.element.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">
|
<div className="flex justify-center pb-4 pt-4">
|
||||||
<HalfCircle value={elementSummary.score} />
|
<HalfCircle value={questionSummary.score} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+57
-64
@@ -10,91 +10,84 @@ import { getContactIdentifier } from "@/lib/utils/contact";
|
|||||||
import { renderHyperlinkedContent } from "@/modules/analysis/utils";
|
import { renderHyperlinkedContent } from "@/modules/analysis/utils";
|
||||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||||
|
|
||||||
interface OpenTextSummaryProps {
|
interface OpenTextSummaryProps {
|
||||||
elementSummary: TSurveyElementSummaryOpenText;
|
questionSummary: TSurveyElementSummaryOpenText;
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OpenTextSummary = ({ elementSummary, environmentId, survey, locale }: OpenTextSummaryProps) => {
|
export const OpenTextSummary = ({ questionSummary, environmentId, survey, locale }: OpenTextSummaryProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [visibleResponses, setVisibleResponses] = useState(10);
|
const [visibleResponses, setVisibleResponses] = useState(10);
|
||||||
|
|
||||||
const handleLoadMore = () => {
|
const handleLoadMore = () => {
|
||||||
// Increase the number of visible responses by 10, not exceeding the total number of responses
|
// Increase the number of visible responses by 10, not exceeding the total number of responses
|
||||||
setVisibleResponses((prevVisibleResponses) =>
|
setVisibleResponses((prevVisibleResponses) =>
|
||||||
Math.min(prevVisibleResponses + 10, elementSummary.samples.length)
|
Math.min(prevVisibleResponses + 10, questionSummary.samples.length)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} />
|
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||||
<div className="border-t border-slate-200"></div>
|
<div className="border-t border-slate-200"></div>
|
||||||
{elementSummary.samples.length === 0 ? (
|
<div className="max-h-[40vh] overflow-y-auto">
|
||||||
<div className="p-8">
|
<Table>
|
||||||
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
|
<TableHeader className="bg-slate-100">
|
||||||
</div>
|
<TableRow>
|
||||||
) : (
|
<TableHead>{t("common.user")}</TableHead>
|
||||||
<div className="max-h-[40vh] overflow-y-auto">
|
<TableHead>{t("common.response")}</TableHead>
|
||||||
<Table>
|
<TableHead>{t("common.time")}</TableHead>
|
||||||
<TableHeader className="bg-slate-100">
|
</TableRow>
|
||||||
<TableRow>
|
</TableHeader>
|
||||||
<TableHead className="w-1/4">{t("common.user")}</TableHead>
|
<TableBody>
|
||||||
<TableHead className="w-2/4">{t("common.response")}</TableHead>
|
{questionSummary.samples.slice(0, visibleResponses).map((response) => (
|
||||||
<TableHead className="w-1/4">{t("common.time")}</TableHead>
|
<TableRow key={response.id}>
|
||||||
</TableRow>
|
<TableCell>
|
||||||
</TableHeader>
|
{response.contact ? (
|
||||||
<TableBody>
|
<Link
|
||||||
{elementSummary.samples.slice(0, visibleResponses).map((response) => (
|
className="ph-no-capture group flex items-center"
|
||||||
<TableRow key={response.id}>
|
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||||
<TableCell className="w-1/4">
|
<div className="hidden md:flex">
|
||||||
{response.contact ? (
|
<PersonAvatar personId={response.contact.id} />
|
||||||
<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>
|
</div>
|
||||||
)}
|
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||||
</TableCell>
|
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||||
<TableCell className="w-2/4 font-medium">
|
</p>
|
||||||
{typeof response.value === "string"
|
</Link>
|
||||||
? renderHyperlinkedContent(response.value)
|
) : (
|
||||||
: response.value}
|
<div className="group flex items-center">
|
||||||
</TableCell>
|
<div className="hidden md:flex">
|
||||||
<TableCell className="w-1/4">
|
<PersonAvatar personId="anonymous" />
|
||||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
</div>
|
||||||
</TableCell>
|
<p className="break-normal text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||||
</TableRow>
|
</div>
|
||||||
))}
|
)}
|
||||||
</TableBody>
|
</TableCell>
|
||||||
</Table>
|
<TableCell className="font-medium">
|
||||||
{visibleResponses < elementSummary.samples.length && (
|
{typeof response.value === "string"
|
||||||
<div className="flex justify-center py-4">
|
? renderHyperlinkedContent(response.value)
|
||||||
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
: response.value}
|
||||||
{t("common.load_more")}
|
</TableCell>
|
||||||
</Button>
|
<TableCell width={120}>
|
||||||
</div>
|
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||||
)}
|
</TableCell>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+19
-15
@@ -5,46 +5,50 @@ import Image from "next/image";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { type TI18nString } from "@formbricks/types/i18n";
|
import { type TI18nString } from "@formbricks/types/i18n";
|
||||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||||
import { TSurvey, TSurveyElementSummaryPictureSelection } from "@formbricks/types/surveys/types";
|
import {
|
||||||
|
TSurvey,
|
||||||
|
TSurveyElementSummaryPictureSelection,
|
||||||
|
TSurveyQuestionId,
|
||||||
|
} from "@formbricks/types/surveys/types";
|
||||||
import { getChoiceIdByValue } from "@/lib/response/utils";
|
import { getChoiceIdByValue } from "@/lib/response/utils";
|
||||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||||
import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
||||||
import { convertFloatToNDecimal } from "../lib/utils";
|
import { convertFloatToNDecimal } from "../lib/utils";
|
||||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||||
|
|
||||||
interface PictureChoiceSummaryProps {
|
interface PictureChoiceSummaryProps {
|
||||||
elementSummary: TSurveyElementSummaryPictureSelection;
|
questionSummary: TSurveyElementSummaryPictureSelection;
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
setFilter: (
|
setFilter: (
|
||||||
elementId: string,
|
questionId: TSurveyQuestionId,
|
||||||
label: TI18nString,
|
label: TI18nString,
|
||||||
elementType: TSurveyElementTypeEnum,
|
questionType: TSurveyElementTypeEnum,
|
||||||
filterValue: string,
|
filterValue: string,
|
||||||
filterComboBoxValue?: string | string[]
|
filterComboBoxValue?: string | string[]
|
||||||
) => void;
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: PictureChoiceSummaryProps) => {
|
export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: PictureChoiceSummaryProps) => {
|
||||||
const results = elementSummary.choices;
|
const results = questionSummary.choices;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<ElementSummaryHeader
|
<QuestionSummaryHeader
|
||||||
elementSummary={elementSummary}
|
questionSummary={questionSummary}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
additionalInfo={
|
additionalInfo={
|
||||||
elementSummary.element.allowMulti ? (
|
questionSummary.question.allowMulti ? (
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
<InboxIcon className="mr-2 h-4 w-4" />
|
<InboxIcon className="mr-2 h-4 w-4" />
|
||||||
{`${elementSummary.selectionCount} ${t("common.selections")}`}
|
{`${questionSummary.selectionCount} ${t("common.selections")}`}
|
||||||
</div>
|
</div>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||||
{results.map((result, index) => {
|
{results.map((result, index) => {
|
||||||
const choiceId = getChoiceIdByValue(result.imageUrl, elementSummary.element);
|
const choiceId = getChoiceIdByValue(result.imageUrl, questionSummary.question);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -52,9 +56,9 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
|
|||||||
key={result.id}
|
key={result.id}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setFilter(
|
setFilter(
|
||||||
elementSummary.element.id,
|
questionSummary.question.id,
|
||||||
elementSummary.element.headline,
|
questionSummary.question.headline,
|
||||||
elementSummary.element.type,
|
questionSummary.question.type,
|
||||||
t("environments.surveys.summary.includes_all"),
|
t("environments.surveys.summary.includes_all"),
|
||||||
[`${t("environments.surveys.edit.picture_idx", { idx: index + 1 })}`]
|
[`${t("environments.surveys.edit.picture_idx", { idx: index + 1 })}`]
|
||||||
)
|
)
|
||||||
|
|||||||
+11
-11
@@ -7,24 +7,24 @@ import { TSurvey, TSurveyElementSummary } from "@formbricks/types/surveys/types"
|
|||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { recallToHeadline } from "@/lib/utils/recall";
|
||||||
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
|
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
|
||||||
import { getElementTypes } from "@/modules/survey/lib/elements";
|
import { getQuestionTypes } from "@/modules/survey/lib/questions";
|
||||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||||
|
|
||||||
interface HeadProps {
|
interface HeadProps {
|
||||||
elementSummary: TSurveyElementSummary;
|
questionSummary: TSurveyElementSummary;
|
||||||
showResponses?: boolean;
|
showResponses?: boolean;
|
||||||
additionalInfo?: JSX.Element;
|
additionalInfo?: JSX.Element;
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ElementSummaryHeader = ({
|
export const QuestionSummaryHeader = ({
|
||||||
elementSummary,
|
questionSummary,
|
||||||
additionalInfo,
|
additionalInfo,
|
||||||
showResponses = true,
|
showResponses = true,
|
||||||
survey,
|
survey,
|
||||||
}: HeadProps) => {
|
}: HeadProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const elementType = getElementTypes(t).find((type) => type.id === elementSummary.element.type);
|
const questionType = getQuestionTypes(t).find((type) => type.id === questionSummary.question.type);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
|
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
|
||||||
@@ -32,7 +32,7 @@ export const ElementSummaryHeader = ({
|
|||||||
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">
|
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">
|
||||||
{formatTextWithSlashes(
|
{formatTextWithSlashes(
|
||||||
getTextContent(
|
getTextContent(
|
||||||
recallToHeadline(elementSummary.element.headline, survey, true, "default")["default"]
|
recallToHeadline(questionSummary.question.headline, survey, true, "default")["default"]
|
||||||
),
|
),
|
||||||
"@",
|
"@",
|
||||||
["text-lg"]
|
["text-lg"]
|
||||||
@@ -41,24 +41,24 @@ export const ElementSummaryHeader = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
|
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
{elementType && <elementType.icon className="mr-2 h-4 w-4" />}
|
{questionType && <questionType.icon className="mr-2 h-4 w-4" />}
|
||||||
{elementType ? elementType.label : t("environments.surveys.summary.unknown_question_type")}{" "}
|
{questionType ? questionType.label : t("environments.surveys.summary.unknown_question_type")}{" "}
|
||||||
{t("common.question")}
|
{t("common.question")}
|
||||||
</div>
|
</div>
|
||||||
{showResponses && (
|
{showResponses && (
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
<InboxIcon className="mr-2 h-4 w-4" />
|
<InboxIcon className="mr-2 h-4 w-4" />
|
||||||
{`${elementSummary.responseCount} ${t("common.responses")}`}
|
{`${questionSummary.responseCount} ${t("common.responses")}`}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{additionalInfo}
|
{additionalInfo}
|
||||||
{!elementSummary.element.required && (
|
{!questionSummary.question.required && (
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
{t("environments.surveys.edit.optional")}
|
{t("environments.surveys.edit.optional")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<IdBadge id={elementSummary.element.id} />
|
|
||||||
</div>
|
</div>
|
||||||
|
<IdBadge id={questionSummary.question.id} label={t("common.question_id")} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
+6
-6
@@ -3,26 +3,26 @@ import { TSurvey, TSurveyElementSummaryRanking } from "@formbricks/types/surveys
|
|||||||
import { getChoiceIdByValue } from "@/lib/response/utils";
|
import { getChoiceIdByValue } from "@/lib/response/utils";
|
||||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||||
import { convertFloatToNDecimal } from "../lib/utils";
|
import { convertFloatToNDecimal } from "../lib/utils";
|
||||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||||
|
|
||||||
interface RankingSummaryProps {
|
interface RankingSummaryProps {
|
||||||
elementSummary: TSurveyElementSummaryRanking;
|
questionSummary: TSurveyElementSummaryRanking;
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RankingSummary = ({ elementSummary, survey }: RankingSummaryProps) => {
|
export const RankingSummary = ({ questionSummary, survey }: RankingSummaryProps) => {
|
||||||
// sort by count and transform to array
|
// sort by count and transform to array
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const results = Object.values(elementSummary.choices).sort((a, b) => {
|
const results = Object.values(questionSummary.choices).sort((a, b) => {
|
||||||
return a.avgRanking - b.avgRanking; // Sort by count
|
return a.avgRanking - b.avgRanking; // Sort by count
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} />
|
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||||
{results.map((result, resultsIdx) => {
|
{results.map((result, resultsIdx) => {
|
||||||
const choiceId = getChoiceIdByValue(result.value, elementSummary.element);
|
const choiceId = getChoiceIdByValue(result.value, questionSummary.question);
|
||||||
return (
|
return (
|
||||||
<div key={result.value} className="group cursor-pointer">
|
<div key={result.value} className="group cursor-pointer">
|
||||||
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
|
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
|
||||||
|
|||||||
-24
@@ -1,24 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
+54
-179
@@ -1,222 +1,97 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BarChart, BarChartHorizontal, CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
|
import { CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { type TI18nString } from "@formbricks/types/i18n";
|
import { type TI18nString } from "@formbricks/types/i18n";
|
||||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||||
import { TSurvey, TSurveyElementSummaryRating } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveyElementSummaryRating, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||||
import { convertFloatToNDecimal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
|
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 { ProgressBar } from "@/modules/ui/components/progress-bar";
|
||||||
import { RatingResponse } from "@/modules/ui/components/rating-response";
|
import { RatingResponse } from "@/modules/ui/components/rating-response";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
|
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||||
import { TooltipProvider } from "@/modules/ui/components/tooltip";
|
|
||||||
import { ClickableBarSegment } from "./ClickableBarSegment";
|
|
||||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
|
||||||
import { RatingScaleLegend } from "./RatingScaleLegend";
|
|
||||||
import { SatisfactionIndicator } from "./SatisfactionIndicator";
|
|
||||||
|
|
||||||
interface RatingSummaryProps {
|
interface RatingSummaryProps {
|
||||||
elementSummary: TSurveyElementSummaryRating;
|
questionSummary: TSurveyElementSummaryRating;
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
setFilter: (
|
setFilter: (
|
||||||
elementId: string,
|
questionId: TSurveyQuestionId,
|
||||||
label: TI18nString,
|
label: TI18nString,
|
||||||
elementType: TSurveyElementTypeEnum,
|
questionType: TSurveyElementTypeEnum,
|
||||||
filterValue: string,
|
filterValue: string,
|
||||||
filterComboBoxValue?: string | string[]
|
filterComboBoxValue?: string | string[]
|
||||||
) => void;
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSummaryProps) => {
|
export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSummaryProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
|
|
||||||
|
|
||||||
const getIconBasedOnScale = useMemo(() => {
|
const getIconBasedOnScale = useMemo(() => {
|
||||||
const scale = elementSummary.element.scale;
|
const scale = questionSummary.question.scale;
|
||||||
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
|
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
|
||||||
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="h-4 w-4 text-yellow-400" />;
|
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="h-4 w-4 text-yellow-400" />;
|
||||||
else if (scale === "smiley") return <SmileIcon className="h-4 w-4" />;
|
else if (scale === "smiley") return <SmileIcon className="h-4 w-4" />;
|
||||||
}, [elementSummary]);
|
}, [questionSummary]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<ElementSummaryHeader
|
<QuestionSummaryHeader
|
||||||
elementSummary={elementSummary}
|
questionSummary={questionSummary}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
additionalInfo={
|
additionalInfo={
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
||||||
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
{getIconBasedOnScale}
|
||||||
{getIconBasedOnScale}
|
<div>
|
||||||
<div>
|
{t("environments.surveys.summary.overall")}: {questionSummary.average.toFixed(2)}
|
||||||
{t("environments.surveys.summary.overall")}: {elementSummary.average.toFixed(2)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
|
||||||
<SatisfactionIndicator percentage={elementSummary.csat.satisfiedPercentage} />
|
|
||||||
<div>
|
|
||||||
CSAT: {elementSummary.csat.satisfiedPercentage}% {t("environments.surveys.summary.satisfied")}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "aggregated" | "individual")}>
|
{questionSummary.choices.map((result) => (
|
||||||
<div className="flex justify-end px-4 md:px-6">
|
<button
|
||||||
<TabsList>
|
className="w-full cursor-pointer hover:opacity-80"
|
||||||
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="h-4 w-4" />}>
|
key={result.rating}
|
||||||
{t("environments.surveys.summary.aggregated")}
|
onClick={() =>
|
||||||
</TabsTrigger>
|
setFilter(
|
||||||
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
|
questionSummary.question.id,
|
||||||
{t("environments.surveys.summary.individual")}
|
questionSummary.question.headline,
|
||||||
</TabsTrigger>
|
questionSummary.question.type,
|
||||||
</TabsList>
|
t("environments.surveys.summary.is_equal_to"),
|
||||||
</div>
|
result.rating.toString()
|
||||||
|
)
|
||||||
<TabsContent value="aggregated" className="mt-4">
|
}>
|
||||||
<div className="px-4 pb-6 pt-4 md:px-6">
|
<div className="text flex justify-between px-2 pb-2">
|
||||||
{elementSummary.responseCount === 0 ? (
|
<div className="mr-8 flex items-center space-x-1">
|
||||||
<>
|
<div className="font-semibold text-slate-700">
|
||||||
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
|
<RatingResponse
|
||||||
<RatingScaleLegend
|
scale={questionSummary.question.scale}
|
||||||
scale={elementSummary.element.scale}
|
answer={result.rating}
|
||||||
range={elementSummary.element.range}
|
range={questionSummary.question.range}
|
||||||
/>
|
addColors={questionSummary.question.isColorCodingEnabled}
|
||||||
</>
|
/>
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<TooltipProvider delayDuration={200}>
|
|
||||||
<div className="flex h-12 w-full overflow-hidden rounded-t-lg border border-slate-200">
|
|
||||||
{elementSummary.choices.map((result, index) => {
|
|
||||||
if (result.percentage === 0) return null;
|
|
||||||
|
|
||||||
const range = elementSummary.element.range;
|
|
||||||
const opacity = 0.3 + (result.rating / range) * 0.8;
|
|
||||||
const isFirst = index === 0;
|
|
||||||
const isLast = index === elementSummary.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(
|
|
||||||
elementSummary.element.id,
|
|
||||||
elementSummary.element.headline,
|
|
||||||
elementSummary.element.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">
|
|
||||||
{elementSummary.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 < elementSummary.choices.length - 1
|
|
||||||
? "1px solid rgb(226, 232, 240)"
|
|
||||||
: "none",
|
|
||||||
}}>
|
|
||||||
<div className="mb-1 flex items-center justify-center">
|
|
||||||
<RatingResponse
|
|
||||||
scale={elementSummary.element.scale}
|
|
||||||
answer={result.rating}
|
|
||||||
range={elementSummary.element.range}
|
|
||||||
addColors={false}
|
|
||||||
variant="aggregated"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs font-medium text-slate-600">
|
|
||||||
{convertFloatToNDecimal(result.percentage, 1)}%
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
<RatingScaleLegend
|
<div>
|
||||||
scale={elementSummary.element.scale}
|
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||||
range={elementSummary.element.range}
|
{convertFloatToNDecimal(result.percentage, 2)}%
|
||||||
/>
|
</p>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</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">
|
|
||||||
{elementSummary.choices.map((result) => (
|
|
||||||
<div key={result.rating}>
|
|
||||||
<button
|
|
||||||
className="w-full cursor-pointer hover:opacity-80"
|
|
||||||
onClick={() =>
|
|
||||||
setFilter(
|
|
||||||
elementSummary.element.id,
|
|
||||||
elementSummary.element.headline,
|
|
||||||
elementSummary.element.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={elementSummary.element.scale}
|
|
||||||
answer={result.rating}
|
|
||||||
range={elementSummary.element.range}
|
|
||||||
addColors={elementSummary.element.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>
|
||||||
))}
|
</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>
|
</div>
|
||||||
</div>
|
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
||||||
</TabsContent>
|
</button>
|
||||||
</Tabs>
|
))}
|
||||||
{elementSummary.dismissed && elementSummary.dismissed.count > 0 && (
|
</div>
|
||||||
|
{questionSummary.dismissed && questionSummary.dismissed.count > 0 && (
|
||||||
<div className="rounded-b-lg border-t bg-white px-6 py-4">
|
<div className="rounded-b-lg border-t bg-white px-6 py-4">
|
||||||
<div key="dismissed">
|
<div key="dismissed">
|
||||||
<div className="text flex justify-between px-2">
|
<div className="text flex justify-between px-2">
|
||||||
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
|
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
|
||||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||||
{elementSummary.dismissed.count}{" "}
|
{questionSummary.dismissed.count}{" "}
|
||||||
{elementSummary.dismissed.count === 1 ? t("common.response") : t("common.responses")}
|
{questionSummary.dismissed.count === 1 ? t("common.response") : t("common.responses")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
-17
@@ -1,17 +0,0 @@
|
|||||||
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}`} />;
|
|
||||||
};
|
|
||||||
+5
-5
@@ -6,7 +6,7 @@ import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
|||||||
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { recallToHeadline } from "@/lib/utils/recall";
|
||||||
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
|
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
|
||||||
import { getElementIcon } from "@/modules/survey/lib/elements";
|
import { getQuestionIcon } from "@/modules/survey/lib/questions";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||||
|
|
||||||
interface SummaryDropOffsProps {
|
interface SummaryDropOffsProps {
|
||||||
@@ -16,8 +16,8 @@ interface SummaryDropOffsProps {
|
|||||||
|
|
||||||
export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
|
export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const getIcon = (elementType: TSurveyElementTypeEnum) => {
|
const getIcon = (questionType: TSurveyElementTypeEnum) => {
|
||||||
const Icon = getElementIcon(elementType, t);
|
const Icon = getQuestionIcon(questionType, t);
|
||||||
return <Icon className="mt-[3px] h-5 w-5 shrink-0 text-slate-600" />;
|
return <Icon className="mt-[3px] h-5 w-5 shrink-0 text-slate-600" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -45,10 +45,10 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
|
|||||||
</div>
|
</div>
|
||||||
{dropOff.map((quesDropOff) => (
|
{dropOff.map((quesDropOff) => (
|
||||||
<div
|
<div
|
||||||
key={quesDropOff.elementId}
|
key={quesDropOff.questionId}
|
||||||
className="grid grid-cols-6 items-start border-b border-slate-100 text-xs text-slate-800 md:text-sm">
|
className="grid grid-cols-6 items-start border-b border-slate-100 text-xs text-slate-800 md:text-sm">
|
||||||
<div className="col-span-3 flex gap-3 px-4 py-2 md:px-6">
|
<div className="col-span-3 flex gap-3 px-4 py-2 md:px-6">
|
||||||
{getIcon(quesDropOff.elementType)}
|
{getIcon(quesDropOff.questionType)}
|
||||||
<p>
|
<p>
|
||||||
{formatTextWithSlashes(
|
{formatTextWithSlashes(
|
||||||
recallToHeadline(
|
recallToHeadline(
|
||||||
|
|||||||
+75
-67
@@ -7,21 +7,20 @@ import { TI18nString } from "@formbricks/types/i18n";
|
|||||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||||
import { TSurveySummary } from "@formbricks/types/surveys/types";
|
import { TSurveySummary } from "@formbricks/types/surveys/types";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { EmptyAppSurveys } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys";
|
|
||||||
import {
|
import {
|
||||||
SelectedFilterValue,
|
SelectedFilterValue,
|
||||||
useResponseFilter,
|
useResponseFilter,
|
||||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||||
|
import { EmptyAppSurveys } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys";
|
||||||
import { CTASummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CTASummary";
|
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 { 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";
|
import { ConsentSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary";
|
||||||
import { ContactInfoSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ContactInfoSummary";
|
import { ContactInfoSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ContactInfoSummary";
|
||||||
import { DateElementSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/DateElementSummary";
|
import { DateQuestionSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/DateQuestionSummary";
|
||||||
import { FileUploadSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary";
|
import { FileUploadSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary";
|
||||||
import { HiddenFieldsSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/HiddenFieldsSummary";
|
import { HiddenFieldsSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/HiddenFieldsSummary";
|
||||||
import { MatrixElementSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MatrixElementSummary";
|
import { MatrixQuestionSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MatrixQuestionSummary";
|
||||||
import { MultipleChoiceSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary";
|
import { MultipleChoiceSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary";
|
||||||
import { NPSSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary";
|
import { NPSSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary";
|
||||||
import { OpenTextSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/OpenTextSummary";
|
import { OpenTextSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/OpenTextSummary";
|
||||||
@@ -29,9 +28,9 @@ import { PictureChoiceSummary } from "@/app/(app)/environments/[environmentId]/s
|
|||||||
import { RankingSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RankingSummary";
|
import { RankingSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RankingSummary";
|
||||||
import { RatingSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary";
|
import { RatingSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary";
|
||||||
import { constructToastMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
|
import { constructToastMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
|
||||||
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ElementsComboBox";
|
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
||||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||||
import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader";
|
import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader";
|
||||||
import { AddressSummary } from "./AddressSummary";
|
import { AddressSummary } from "./AddressSummary";
|
||||||
|
|
||||||
@@ -47,29 +46,29 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
|||||||
const { setSelectedFilter, selectedFilter } = useResponseFilter();
|
const { setSelectedFilter, selectedFilter } = useResponseFilter();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const setFilter = (
|
const setFilter = (
|
||||||
elementId: string,
|
questionId: string,
|
||||||
label: TI18nString,
|
label: TI18nString,
|
||||||
elementType: TSurveyElementTypeEnum,
|
questionType: TSurveyElementTypeEnum,
|
||||||
filterValue: string,
|
filterValue: string,
|
||||||
filterComboBoxValue?: string | string[]
|
filterComboBoxValue?: string | string[]
|
||||||
) => {
|
) => {
|
||||||
const filterObject: SelectedFilterValue = { ...selectedFilter };
|
const filterObject: SelectedFilterValue = { ...selectedFilter };
|
||||||
const value = {
|
const value = {
|
||||||
id: elementId,
|
id: questionId,
|
||||||
label: getTextContent(getLocalizedValue(label, "default")),
|
label: getLocalizedValue(label, "default"),
|
||||||
elementType,
|
questionType: questionType,
|
||||||
type: OptionsType.ELEMENTS,
|
type: OptionsType.QUESTIONS,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Find the index of the existing filter with the same elementId
|
// Find the index of the existing filter with the same questionId
|
||||||
const existingFilterIndex = filterObject.filter.findIndex(
|
const existingFilterIndex = filterObject.filter.findIndex(
|
||||||
(filter) => filter.elementType.id === elementId
|
(filter) => filter.questionType.id === questionId
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existingFilterIndex !== -1) {
|
if (existingFilterIndex !== -1) {
|
||||||
// Replace the existing filter
|
// Replace the existing filter
|
||||||
filterObject.filter[existingFilterIndex] = {
|
filterObject.filter[existingFilterIndex] = {
|
||||||
elementType: value,
|
questionType: value,
|
||||||
filterType: {
|
filterType: {
|
||||||
filterComboBoxValue: filterComboBoxValue,
|
filterComboBoxValue: filterComboBoxValue,
|
||||||
filterValue: filterValue,
|
filterValue: filterValue,
|
||||||
@@ -79,14 +78,14 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
|||||||
} else {
|
} else {
|
||||||
// Add new filter
|
// Add new filter
|
||||||
filterObject.filter.push({
|
filterObject.filter.push({
|
||||||
elementType: value,
|
questionType: value,
|
||||||
filterType: {
|
filterType: {
|
||||||
filterComboBoxValue: filterComboBoxValue,
|
filterComboBoxValue: filterComboBoxValue,
|
||||||
filterValue: filterValue,
|
filterValue: filterValue,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
toast.success(
|
toast.success(
|
||||||
constructToastMessage(elementType, filterValue, survey, elementId, t, filterComboBoxValue) ??
|
constructToastMessage(questionType, filterValue, survey, questionId, t, filterComboBoxValue) ??
|
||||||
t("environments.surveys.summary.filter_added_successfully"),
|
t("environments.surveys.summary.filter_added_successfully"),
|
||||||
{ duration: 5000 }
|
{ duration: 5000 }
|
||||||
);
|
);
|
||||||
@@ -105,14 +104,19 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
|||||||
) : summary.length === 0 ? (
|
) : summary.length === 0 ? (
|
||||||
<SkeletonLoader type="summary" />
|
<SkeletonLoader type="summary" />
|
||||||
) : responseCount === 0 ? (
|
) : responseCount === 0 ? (
|
||||||
<EmptyState text={t("environments.surveys.summary.no_responses_found")} />
|
<EmptySpaceFiller
|
||||||
|
type="response"
|
||||||
|
environment={environment}
|
||||||
|
noWidgetRequired={survey.type === "link"}
|
||||||
|
emptyMessage={t("environments.surveys.summary.no_responses_found")}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
summary.map((elementSummary) => {
|
summary.map((questionSummary) => {
|
||||||
if (elementSummary.type === TSurveyElementTypeEnum.OpenText) {
|
if (questionSummary.type === TSurveyElementTypeEnum.OpenText) {
|
||||||
return (
|
return (
|
||||||
<OpenTextSummary
|
<OpenTextSummary
|
||||||
key={elementSummary.element.id}
|
key={questionSummary.question.id}
|
||||||
elementSummary={elementSummary}
|
questionSummary={questionSummary}
|
||||||
environmentId={environment.id}
|
environmentId={environment.id}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
@@ -120,13 +124,13 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
elementSummary.type === TSurveyElementTypeEnum.MultipleChoiceSingle ||
|
questionSummary.type === TSurveyElementTypeEnum.MultipleChoiceSingle ||
|
||||||
elementSummary.type === TSurveyElementTypeEnum.MultipleChoiceMulti
|
questionSummary.type === TSurveyElementTypeEnum.MultipleChoiceMulti
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<MultipleChoiceSummary
|
<MultipleChoiceSummary
|
||||||
key={elementSummary.element.id}
|
key={questionSummary.question.id}
|
||||||
elementSummary={elementSummary}
|
questionSummary={questionSummary}
|
||||||
environmentId={environment.id}
|
environmentId={environment.id}
|
||||||
surveyType={survey.type}
|
surveyType={survey.type}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
@@ -134,128 +138,132 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (elementSummary.type === TSurveyElementTypeEnum.NPS) {
|
if (questionSummary.type === TSurveyElementTypeEnum.NPS) {
|
||||||
return (
|
return (
|
||||||
<NPSSummary
|
<NPSSummary
|
||||||
key={elementSummary.element.id}
|
key={questionSummary.question.id}
|
||||||
elementSummary={elementSummary}
|
questionSummary={questionSummary}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
setFilter={setFilter}
|
setFilter={setFilter}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (elementSummary.type === TSurveyElementTypeEnum.CTA) {
|
if (questionSummary.type === TSurveyElementTypeEnum.CTA) {
|
||||||
return (
|
return (
|
||||||
<CTASummary key={elementSummary.element.id} elementSummary={elementSummary} survey={survey} />
|
<CTASummary
|
||||||
|
key={questionSummary.question.id}
|
||||||
|
questionSummary={questionSummary}
|
||||||
|
survey={survey}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (elementSummary.type === TSurveyElementTypeEnum.Rating) {
|
if (questionSummary.type === TSurveyElementTypeEnum.Rating) {
|
||||||
return (
|
return (
|
||||||
<RatingSummary
|
<RatingSummary
|
||||||
key={elementSummary.element.id}
|
key={questionSummary.question.id}
|
||||||
elementSummary={elementSummary}
|
questionSummary={questionSummary}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
setFilter={setFilter}
|
setFilter={setFilter}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (elementSummary.type === TSurveyElementTypeEnum.Consent) {
|
if (questionSummary.type === TSurveyElementTypeEnum.Consent) {
|
||||||
return (
|
return (
|
||||||
<ConsentSummary
|
<ConsentSummary
|
||||||
key={elementSummary.element.id}
|
key={questionSummary.question.id}
|
||||||
elementSummary={elementSummary}
|
questionSummary={questionSummary}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
setFilter={setFilter}
|
setFilter={setFilter}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (elementSummary.type === TSurveyElementTypeEnum.PictureSelection) {
|
if (questionSummary.type === TSurveyElementTypeEnum.PictureSelection) {
|
||||||
return (
|
return (
|
||||||
<PictureChoiceSummary
|
<PictureChoiceSummary
|
||||||
key={elementSummary.element.id}
|
key={questionSummary.question.id}
|
||||||
elementSummary={elementSummary}
|
questionSummary={questionSummary}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
setFilter={setFilter}
|
setFilter={setFilter}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (elementSummary.type === TSurveyElementTypeEnum.Date) {
|
if (questionSummary.type === TSurveyElementTypeEnum.Date) {
|
||||||
return (
|
return (
|
||||||
<DateElementSummary
|
<DateQuestionSummary
|
||||||
key={elementSummary.element.id}
|
key={questionSummary.question.id}
|
||||||
elementSummary={elementSummary}
|
questionSummary={questionSummary}
|
||||||
environmentId={environment.id}
|
environmentId={environment.id}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (elementSummary.type === TSurveyElementTypeEnum.FileUpload) {
|
if (questionSummary.type === TSurveyElementTypeEnum.FileUpload) {
|
||||||
return (
|
return (
|
||||||
<FileUploadSummary
|
<FileUploadSummary
|
||||||
key={elementSummary.element.id}
|
key={questionSummary.question.id}
|
||||||
elementSummary={elementSummary}
|
questionSummary={questionSummary}
|
||||||
environmentId={environment.id}
|
environmentId={environment.id}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (elementSummary.type === TSurveyElementTypeEnum.Cal) {
|
if (questionSummary.type === TSurveyElementTypeEnum.Cal) {
|
||||||
return (
|
return (
|
||||||
<CalSummary
|
<CalSummary
|
||||||
key={elementSummary.element.id}
|
key={questionSummary.question.id}
|
||||||
elementSummary={elementSummary}
|
questionSummary={questionSummary}
|
||||||
environmentId={environment.id}
|
environmentId={environment.id}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (elementSummary.type === TSurveyElementTypeEnum.Matrix) {
|
if (questionSummary.type === TSurveyElementTypeEnum.Matrix) {
|
||||||
return (
|
return (
|
||||||
<MatrixElementSummary
|
<MatrixQuestionSummary
|
||||||
key={elementSummary.element.id}
|
key={questionSummary.question.id}
|
||||||
elementSummary={elementSummary}
|
questionSummary={questionSummary}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
setFilter={setFilter}
|
setFilter={setFilter}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (elementSummary.type === TSurveyElementTypeEnum.Address) {
|
if (questionSummary.type === TSurveyElementTypeEnum.Address) {
|
||||||
return (
|
return (
|
||||||
<AddressSummary
|
<AddressSummary
|
||||||
key={elementSummary.element.id}
|
key={questionSummary.question.id}
|
||||||
elementSummary={elementSummary}
|
questionSummary={questionSummary}
|
||||||
environmentId={environment.id}
|
environmentId={environment.id}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (elementSummary.type === TSurveyElementTypeEnum.Ranking) {
|
if (questionSummary.type === TSurveyElementTypeEnum.Ranking) {
|
||||||
return (
|
return (
|
||||||
<RankingSummary
|
<RankingSummary
|
||||||
key={elementSummary.element.id}
|
key={questionSummary.question.id}
|
||||||
elementSummary={elementSummary}
|
questionSummary={questionSummary}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (elementSummary.type === "hiddenField") {
|
if (questionSummary.type === "hiddenField") {
|
||||||
return (
|
return (
|
||||||
<HiddenFieldsSummary
|
<HiddenFieldsSummary
|
||||||
key={elementSummary.id}
|
key={questionSummary.id}
|
||||||
elementSummary={elementSummary}
|
questionSummary={questionSummary}
|
||||||
environment={environment}
|
environment={environment}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (elementSummary.type === TSurveyElementTypeEnum.ContactInfo) {
|
if (questionSummary.type === TSurveyElementTypeEnum.ContactInfo) {
|
||||||
return (
|
return (
|
||||||
<ContactInfoSummary
|
<ContactInfoSummary
|
||||||
key={elementSummary.element.id}
|
key={questionSummary.question.id}
|
||||||
elementSummary={elementSummary}
|
questionSummary={questionSummary}
|
||||||
environmentId={environment.id}
|
environmentId={environment.id}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
|
|||||||
+2
-4
@@ -8,7 +8,6 @@ import { cn } from "@/modules/ui/lib/utils";
|
|||||||
|
|
||||||
interface SummaryMetadataProps {
|
interface SummaryMetadataProps {
|
||||||
surveySummary: TSurveySummary["meta"];
|
surveySummary: TSurveySummary["meta"];
|
||||||
quotasCount: number;
|
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
tab: "dropOffs" | "quotas" | undefined;
|
tab: "dropOffs" | "quotas" | undefined;
|
||||||
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | undefined>>;
|
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | undefined>>;
|
||||||
@@ -32,7 +31,6 @@ const formatTime = (ttc) => {
|
|||||||
|
|
||||||
export const SummaryMetadata = ({
|
export const SummaryMetadata = ({
|
||||||
surveySummary,
|
surveySummary,
|
||||||
quotasCount,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
tab,
|
tab,
|
||||||
setTab,
|
setTab,
|
||||||
@@ -63,7 +61,7 @@ export const SummaryMetadata = ({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
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`,
|
`grid gap-4 sm:grid-cols-2 md:grid-cols-3 md:gap-x-2 lg:grid-cols-3 2xl:grid-cols-5`,
|
||||||
isQuotasAllowed && quotasCount > 0 && "2xl:grid-cols-6"
|
isQuotasAllowed && "2xl:grid-cols-6"
|
||||||
)}>
|
)}>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t("environments.surveys.summary.impressions")}
|
label={t("environments.surveys.summary.impressions")}
|
||||||
@@ -107,7 +105,7 @@ export const SummaryMetadata = ({
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isQuotasAllowed && quotasCount > 0 && (
|
{isQuotasAllowed && (
|
||||||
<InteractiveCard
|
<InteractiveCard
|
||||||
key="quotas"
|
key="quotas"
|
||||||
tab="quotas"
|
tab="quotas"
|
||||||
|
|||||||
+1
-2
@@ -5,8 +5,8 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
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 { 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 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 { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs";
|
||||||
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
||||||
@@ -115,7 +115,6 @@ export const SummaryPage = ({
|
|||||||
<>
|
<>
|
||||||
<SummaryMetadata
|
<SummaryMetadata
|
||||||
surveySummary={surveySummary.meta}
|
surveySummary={surveySummary.meta}
|
||||||
quotasCount={surveySummary.quotas?.length ?? 0}
|
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
tab={tab}
|
tab={tab}
|
||||||
setTab={setTab}
|
setTab={setTab}
|
||||||
|
|||||||
+4
-1
@@ -29,6 +29,7 @@ interface SurveyAnalysisCTAProps {
|
|||||||
user: TUser;
|
user: TUser;
|
||||||
publicDomain: string;
|
publicDomain: string;
|
||||||
responseCount: number;
|
responseCount: number;
|
||||||
|
displayCount: number;
|
||||||
segments: TSegment[];
|
segments: TSegment[];
|
||||||
isContactsEnabled: boolean;
|
isContactsEnabled: boolean;
|
||||||
isFormbricksCloud: boolean;
|
isFormbricksCloud: boolean;
|
||||||
@@ -47,6 +48,7 @@ export const SurveyAnalysisCTA = ({
|
|||||||
user,
|
user,
|
||||||
publicDomain,
|
publicDomain,
|
||||||
responseCount,
|
responseCount,
|
||||||
|
displayCount,
|
||||||
segments,
|
segments,
|
||||||
isContactsEnabled,
|
isContactsEnabled,
|
||||||
isFormbricksCloud,
|
isFormbricksCloud,
|
||||||
@@ -94,6 +96,7 @@ export const SurveyAnalysisCTA = ({
|
|||||||
const duplicateSurveyAndRoute = async (surveyId: string) => {
|
const duplicateSurveyAndRoute = async (surveyId: string) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const duplicatedSurveyResponse = await copySurveyToOtherEnvironmentAction({
|
const duplicatedSurveyResponse = await copySurveyToOtherEnvironmentAction({
|
||||||
|
environmentId: environment.id,
|
||||||
surveyId: surveyId,
|
surveyId: surveyId,
|
||||||
targetEnvironmentId: environment.id,
|
targetEnvironmentId: environment.id,
|
||||||
});
|
});
|
||||||
@@ -167,7 +170,7 @@ export const SurveyAnalysisCTA = ({
|
|||||||
icon: ListRestart,
|
icon: ListRestart,
|
||||||
tooltip: t("environments.surveys.summary.reset_survey"),
|
tooltip: t("environments.surveys.summary.reset_survey"),
|
||||||
onClick: () => setIsResetModalOpen(true),
|
onClick: () => setIsResetModalOpen(true),
|
||||||
isVisible: !isReadOnly,
|
isVisible: !isReadOnly && (responseCount > 0 || displayCount > 0),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: SquarePenIcon,
|
icon: SquarePenIcon,
|
||||||
|
|||||||
+141
-359
@@ -14,7 +14,7 @@ import { getSurvey } from "@/lib/survey/service";
|
|||||||
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
|
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
|
||||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||||
import {
|
import {
|
||||||
getElementSummary,
|
getQuestionSummary,
|
||||||
getResponsesForSummary,
|
getResponsesForSummary,
|
||||||
getSurveySummary,
|
getSurveySummary,
|
||||||
getSurveySummaryDropOff,
|
getSurveySummaryDropOff,
|
||||||
@@ -232,7 +232,7 @@ describe("getSurveySummaryDropOff", () => {
|
|||||||
vi.mocked(evaluateLogic).mockReturnValue(false); // Default: no logic triggers
|
vi.mocked(evaluateLogic).mockReturnValue(false); // Default: no logic triggers
|
||||||
vi.mocked(performActions).mockReturnValue({
|
vi.mocked(performActions).mockReturnValue({
|
||||||
jumpTarget: undefined,
|
jumpTarget: undefined,
|
||||||
requiredElementIds: [],
|
requiredQuestionIds: [],
|
||||||
calculations: {},
|
calculations: {},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -270,14 +270,14 @@ describe("getSurveySummaryDropOff", () => {
|
|||||||
|
|
||||||
expect(dropOff.length).toBe(2);
|
expect(dropOff.length).toBe(2);
|
||||||
// Q1
|
// Q1
|
||||||
expect(dropOff[0].elementId).toBe("q1");
|
expect(dropOff[0].questionId).toBe("q1");
|
||||||
expect(dropOff[0].impressions).toBe(displayCount); // Welcome card disabled, so first question impressions = displayCount
|
expect(dropOff[0].impressions).toBe(displayCount); // Welcome card disabled, so first question impressions = displayCount
|
||||||
expect(dropOff[0].dropOffCount).toBe(displayCount - responses.length); // 5 displays - 2 started = 3 dropped before q1
|
expect(dropOff[0].dropOffCount).toBe(displayCount - responses.length); // 5 displays - 2 started = 3 dropped before q1
|
||||||
expect(dropOff[0].dropOffPercentage).toBe(60); // (3/5)*100
|
expect(dropOff[0].dropOffPercentage).toBe(60); // (3/5)*100
|
||||||
expect(dropOff[0].ttc).toBe(10);
|
expect(dropOff[0].ttc).toBe(10);
|
||||||
|
|
||||||
// Q2
|
// Q2
|
||||||
expect(dropOff[1].elementId).toBe("q2");
|
expect(dropOff[1].questionId).toBe("q2");
|
||||||
expect(dropOff[1].impressions).toBe(responses.length); // 2 responses reached q1, so 2 impressions for q2
|
expect(dropOff[1].impressions).toBe(responses.length); // 2 responses reached q1, so 2 impressions for q2
|
||||||
expect(dropOff[1].dropOffCount).toBe(1); // 1 response dropped at q2
|
expect(dropOff[1].dropOffCount).toBe(1); // 1 response dropped at q2
|
||||||
expect(dropOff[1].dropOffPercentage).toBe(50); // (1/2)*100
|
expect(dropOff[1].dropOffPercentage).toBe(50); // (1/2)*100
|
||||||
@@ -325,7 +325,7 @@ describe("getSurveySummaryDropOff", () => {
|
|||||||
{
|
{
|
||||||
id: "c1",
|
id: "c1",
|
||||||
leftOperand: {
|
leftOperand: {
|
||||||
type: "element" as const,
|
type: "question" as const,
|
||||||
value: "q2",
|
value: "q2",
|
||||||
},
|
},
|
||||||
operator: "equals" as const,
|
operator: "equals" as const,
|
||||||
@@ -395,9 +395,9 @@ describe("getSurveySummaryDropOff", () => {
|
|||||||
});
|
});
|
||||||
vi.mocked(performActions).mockImplementation((_s, actions, _d, _v) => {
|
vi.mocked(performActions).mockImplementation((_s, actions, _d, _v) => {
|
||||||
if (actions[0] && "objective" in actions[0] && actions[0].objective === "jumpToBlock") {
|
if (actions[0] && "objective" in actions[0] && actions[0].objective === "jumpToBlock") {
|
||||||
return { jumpTarget: actions[0].target, requiredElementIds: [], calculations: {} };
|
return { jumpTarget: actions[0].target, requiredQuestionIds: [], calculations: {} };
|
||||||
}
|
}
|
||||||
return { jumpTarget: undefined, requiredElementIds: [], calculations: {} };
|
return { jumpTarget: undefined, requiredQuestionIds: [], calculations: {} };
|
||||||
});
|
});
|
||||||
|
|
||||||
const dropOff = getSurveySummaryDropOff(
|
const dropOff = getSurveySummaryDropOff(
|
||||||
@@ -471,13 +471,13 @@ describe("getQuestionSummary", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("summarizes OpenText questions", async () => {
|
test("summarizes OpenText questions", async () => {
|
||||||
const summary = await getElementSummary(
|
const summary = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
mockDropOff
|
mockDropOff
|
||||||
);
|
);
|
||||||
const openTextSummary = summary.find((s: any) => s.element?.id === "q_open");
|
const openTextSummary = summary.find((s: any) => s.question?.id === "q_open");
|
||||||
expect(openTextSummary?.type).toBe(TSurveyElementTypeEnum.OpenText);
|
expect(openTextSummary?.type).toBe(TSurveyElementTypeEnum.OpenText);
|
||||||
expect(openTextSummary?.responseCount).toBe(1);
|
expect(openTextSummary?.responseCount).toBe(1);
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
@@ -485,13 +485,13 @@ describe("getQuestionSummary", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("summarizes MultipleChoiceSingle questions", async () => {
|
test("summarizes MultipleChoiceSingle questions", async () => {
|
||||||
const summary = await getElementSummary(
|
const summary = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
mockDropOff
|
mockDropOff
|
||||||
);
|
);
|
||||||
const multiSingleSummary = summary.find((s: any) => s.element?.id === "q_multi_single");
|
const multiSingleSummary = summary.find((s: any) => s.question?.id === "q_multi_single");
|
||||||
expect(multiSingleSummary?.type).toBe(TSurveyElementTypeEnum.MultipleChoiceSingle);
|
expect(multiSingleSummary?.type).toBe(TSurveyElementTypeEnum.MultipleChoiceSingle);
|
||||||
expect(multiSingleSummary?.responseCount).toBe(1);
|
expect(multiSingleSummary?.responseCount).toBe(1);
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
@@ -502,86 +502,8 @@ describe("getQuestionSummary", () => {
|
|||||||
expect(multiSingleSummary?.choices[0].percentage).toBe(100);
|
expect(multiSingleSummary?.choices[0].percentage).toBe(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("summarizes MultipleChoiceSingle questions with noneOption", async () => {
|
|
||||||
const surveyWithNone = {
|
|
||||||
...mockBaseSurvey,
|
|
||||||
blocks: [
|
|
||||||
{
|
|
||||||
id: "block1",
|
|
||||||
name: "Block 1",
|
|
||||||
elements: [
|
|
||||||
{
|
|
||||||
id: "q_multi_none",
|
|
||||||
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
|
|
||||||
headline: { default: "Pick one or none" },
|
|
||||||
required: false,
|
|
||||||
choices: [
|
|
||||||
{ id: "c1", label: { default: "Choice 1" } },
|
|
||||||
{ id: "c2", label: { default: "Choice 2" } },
|
|
||||||
{ id: "none", label: { default: "None of the above" } }, // none option with id "none"
|
|
||||||
],
|
|
||||||
shuffleOption: "none",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
questions: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const responsesWithNone = [
|
|
||||||
{
|
|
||||||
id: "r1",
|
|
||||||
data: { q_multi_none: "Choice 1" },
|
|
||||||
updatedAt: new Date(),
|
|
||||||
contact: null,
|
|
||||||
contactAttributes: {},
|
|
||||||
language: "default",
|
|
||||||
ttc: {},
|
|
||||||
finished: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "r2",
|
|
||||||
data: { q_multi_none: "None of the above" },
|
|
||||||
updatedAt: new Date(),
|
|
||||||
contact: null,
|
|
||||||
contactAttributes: {},
|
|
||||||
language: "default",
|
|
||||||
ttc: {},
|
|
||||||
finished: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "r3",
|
|
||||||
data: { q_multi_none: "None of the above" },
|
|
||||||
updatedAt: new Date(),
|
|
||||||
contact: null,
|
|
||||||
contactAttributes: {},
|
|
||||||
language: "default",
|
|
||||||
ttc: {},
|
|
||||||
finished: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const summary = await getElementSummary(
|
|
||||||
surveyWithNone as unknown as TSurvey,
|
|
||||||
getElementsFromBlocks((surveyWithNone as unknown as TSurvey).blocks),
|
|
||||||
responsesWithNone,
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const multiNoneSummary = summary.find((s: any) => s.element?.id === "q_multi_none");
|
|
||||||
expect(multiNoneSummary?.type).toBe(TSurveyElementTypeEnum.MultipleChoiceSingle);
|
|
||||||
expect(multiNoneSummary?.responseCount).toBe(3);
|
|
||||||
|
|
||||||
// Check that "None of the above" option is included in choices
|
|
||||||
// @ts-expect-error
|
|
||||||
const noneChoice = multiNoneSummary?.choices.find((c: any) => c.value === "None of the above");
|
|
||||||
expect(noneChoice).toBeDefined();
|
|
||||||
expect(noneChoice?.count).toBe(2);
|
|
||||||
expect(noneChoice?.percentage).toBeCloseTo(66.67, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("summarizes HiddenFields", async () => {
|
test("summarizes HiddenFields", async () => {
|
||||||
const summary = await getElementSummary(
|
const summary = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -646,10 +568,10 @@ describe("getQuestionSummary", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "ranking-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 },
|
{ questionId: "ranking-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary = await getElementSummary(
|
const summary = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -732,10 +654,10 @@ describe("getQuestionSummary", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "ranking-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 },
|
{ questionId: "ranking-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary = await getElementSummary(
|
const summary = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -814,10 +736,10 @@ describe("getQuestionSummary", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "ranking-q1", impressions: 2, dropOffCount: 2, dropOffPercentage: 100 },
|
{ questionId: "ranking-q1", impressions: 2, dropOffCount: 2, dropOffPercentage: 100 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary = await getElementSummary(
|
const summary = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -878,10 +800,10 @@ describe("getQuestionSummary", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "ranking-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 },
|
{ questionId: "ranking-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary = await getElementSummary(
|
const summary = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -942,10 +864,10 @@ describe("getQuestionSummary", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "ranking-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 },
|
{ questionId: "ranking-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary = await getElementSummary(
|
const summary = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -1218,7 +1140,7 @@ describe("getResponsesForSummary", () => {
|
|||||||
await expect(getResponsesForSummary("survey-1", 10, 0)).rejects.not.toThrow(DatabaseError);
|
await expect(getResponsesForSummary("survey-1", 10, 0)).rejects.not.toThrow(DatabaseError);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("getSurveySummary throws DatabaseError when Prisma throws PrismaClientKnownRequestError from getDisplayCountBySurveyId", async () => {
|
test("getSurveySummary throws DatabaseError when Prisma throws PrismaClientKnownRequestError", async () => {
|
||||||
vi.mocked(getSurvey).mockResolvedValue({
|
vi.mocked(getSurvey).mockResolvedValue({
|
||||||
id: "survey-1",
|
id: "survey-1",
|
||||||
blocks: [],
|
blocks: [],
|
||||||
@@ -1227,27 +1149,20 @@ describe("getResponsesForSummary", () => {
|
|||||||
languages: [],
|
languages: [],
|
||||||
} as unknown as TSurvey);
|
} as unknown as TSurvey);
|
||||||
|
|
||||||
// Mock prisma.response.findMany to return empty array so getResponsesForSummary succeeds
|
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10);
|
||||||
vi.mocked(prisma.response.findMany).mockResolvedValue([]);
|
|
||||||
|
|
||||||
const prismaError = new Prisma.PrismaClientKnownRequestError(
|
const prismaError = new Prisma.PrismaClientKnownRequestError("Database connection error", {
|
||||||
"Database connection error from display count",
|
code: "P2002",
|
||||||
{
|
clientVersion: "4.0.0",
|
||||||
code: "P2002",
|
});
|
||||||
clientVersion: "4.0.0",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Throw Prisma error from getDisplayCountBySurveyId to hit getSurveySummary's catch block
|
vi.mocked(prisma.response.findMany).mockRejectedValue(prismaError);
|
||||||
vi.mocked(getDisplayCountBySurveyId).mockRejectedValue(prismaError);
|
|
||||||
|
|
||||||
await expect(getSurveySummary("survey-1")).rejects.toThrow(DatabaseError);
|
await expect(getSurveySummary("survey-1")).rejects.toThrow(DatabaseError);
|
||||||
await expect(getSurveySummary("survey-1")).rejects.toThrow(
|
await expect(getSurveySummary("survey-1")).rejects.toThrow("Database connection error");
|
||||||
"Database connection error from display count"
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("getSurveySummary rethrows non-Prisma errors from getDisplayCountBySurveyId", async () => {
|
test("getSurveySummary rethrows non-Prisma errors", async () => {
|
||||||
vi.mocked(getSurvey).mockResolvedValue({
|
vi.mocked(getSurvey).mockResolvedValue({
|
||||||
id: "survey-1",
|
id: "survey-1",
|
||||||
blocks: [],
|
blocks: [],
|
||||||
@@ -1256,121 +1171,15 @@ describe("getResponsesForSummary", () => {
|
|||||||
languages: [],
|
languages: [],
|
||||||
} as unknown as TSurvey);
|
} as unknown as TSurvey);
|
||||||
|
|
||||||
// Mock prisma.response.findMany to return empty array so getResponsesForSummary succeeds
|
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10);
|
||||||
vi.mocked(prisma.response.findMany).mockResolvedValue([]);
|
|
||||||
|
|
||||||
const genericError = new Error("Something else went wrong");
|
const genericError = new Error("Something else went wrong");
|
||||||
vi.mocked(getDisplayCountBySurveyId).mockRejectedValue(genericError);
|
vi.mocked(prisma.response.findMany).mockRejectedValue(genericError);
|
||||||
|
|
||||||
await expect(getSurveySummary("survey-1")).rejects.toThrow("Something else went wrong");
|
await expect(getSurveySummary("survey-1")).rejects.toThrow("Something else went wrong");
|
||||||
await expect(getSurveySummary("survey-1")).rejects.toThrow(Error);
|
await expect(getSurveySummary("survey-1")).rejects.toThrow(Error);
|
||||||
await expect(getSurveySummary("survey-1")).rejects.not.toThrow(DatabaseError);
|
await expect(getSurveySummary("survey-1")).rejects.not.toThrow(DatabaseError);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("getSurveySummary handles multiple batches when responses exceed batchSize", async () => {
|
|
||||||
vi.mocked(getSurvey).mockResolvedValue({
|
|
||||||
id: "survey-1",
|
|
||||||
blocks: [],
|
|
||||||
questions: [],
|
|
||||||
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
|
|
||||||
languages: [],
|
|
||||||
} as unknown as TSurvey);
|
|
||||||
|
|
||||||
// Create mock responses for two batches
|
|
||||||
// First batch: 5000 responses (exactly batchSize, triggers continuation)
|
|
||||||
// Second batch: 100 responses (less than batchSize, stops loop)
|
|
||||||
const createMockResponses = (count: number, startIdx: number) =>
|
|
||||||
Array.from({ length: count }, (_, i) => ({
|
|
||||||
id: `response-${startIdx + i}`,
|
|
||||||
data: {},
|
|
||||||
updatedAt: new Date(),
|
|
||||||
contact: null,
|
|
||||||
contactAttributes: {},
|
|
||||||
language: "en",
|
|
||||||
ttc: {},
|
|
||||||
finished: true,
|
|
||||||
createdAt: new Date(),
|
|
||||||
meta: {},
|
|
||||||
variables: {},
|
|
||||||
surveyId: "survey-1",
|
|
||||||
contactId: null,
|
|
||||||
personAttributes: {},
|
|
||||||
singleUseId: null,
|
|
||||||
isFinished: true,
|
|
||||||
displayId: "display-1",
|
|
||||||
endingId: null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const firstBatch = createMockResponses(5000, 0);
|
|
||||||
const secondBatch = createMockResponses(100, 5000);
|
|
||||||
|
|
||||||
// First call returns 5000, second call returns 100
|
|
||||||
vi.mocked(prisma.response.findMany).mockResolvedValueOnce(firstBatch).mockResolvedValueOnce(secondBatch);
|
|
||||||
|
|
||||||
vi.mocked(getDisplayCountBySurveyId).mockResolvedValue(5100);
|
|
||||||
vi.mocked(getQuotasSummary).mockResolvedValue([]);
|
|
||||||
|
|
||||||
const result = await getSurveySummary("survey-1");
|
|
||||||
|
|
||||||
// Verify that prisma.response.findMany was called twice (two batches)
|
|
||||||
expect(prisma.response.findMany).toHaveBeenCalledTimes(2);
|
|
||||||
|
|
||||||
// Second call should have cursor set to last response ID of first batch
|
|
||||||
expect(prisma.response.findMany).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
expect.objectContaining({
|
|
||||||
where: expect.objectContaining({
|
|
||||||
surveyId: "survey-1",
|
|
||||||
id: { lt: "response-4999" }, // Last ID from first batch
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Result should contain data from all responses
|
|
||||||
expect(result.meta.totalResponses).toBe(5100);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("getResponsesForSummary applies cursor-based pagination when cursor is provided", async () => {
|
|
||||||
const mockSurvey = { id: "survey-1" } as unknown as TSurvey;
|
|
||||||
const mockResponse = {
|
|
||||||
id: "response-2",
|
|
||||||
data: {},
|
|
||||||
updatedAt: new Date(),
|
|
||||||
contact: null,
|
|
||||||
contactAttributes: {},
|
|
||||||
language: "en",
|
|
||||||
ttc: {},
|
|
||||||
finished: true,
|
|
||||||
createdAt: new Date(),
|
|
||||||
meta: {},
|
|
||||||
variables: {},
|
|
||||||
surveyId: "survey-1",
|
|
||||||
contactId: null,
|
|
||||||
personAttributes: {},
|
|
||||||
singleUseId: null,
|
|
||||||
isFinished: true,
|
|
||||||
displayId: "display-1",
|
|
||||||
endingId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
|
|
||||||
vi.mocked(prisma.response.findMany).mockResolvedValue([mockResponse]);
|
|
||||||
|
|
||||||
const result = await getResponsesForSummary("survey-1", 10, 0, undefined, "cursor-response-id");
|
|
||||||
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0].id).toBe("response-2");
|
|
||||||
|
|
||||||
// Verify that prisma.response.findMany was called with cursor condition
|
|
||||||
expect(prisma.response.findMany).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
where: expect.objectContaining({
|
|
||||||
surveyId: "survey-1",
|
|
||||||
id: { lt: "cursor-response-id" },
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Address and ContactInfo question types", () => {
|
describe("Address and ContactInfo question types", () => {
|
||||||
@@ -1433,10 +1242,15 @@ describe("Address and ContactInfo question types", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "address-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 },
|
{ questionId: "address-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary = await getElementSummary(survey, getElementsFromBlocks(survey.blocks), responses, dropOff);
|
const summary = await getQuestionSummary(
|
||||||
|
survey,
|
||||||
|
getElementsFromBlocks(survey.blocks),
|
||||||
|
responses,
|
||||||
|
dropOff
|
||||||
|
);
|
||||||
|
|
||||||
expect(summary).toHaveLength(1);
|
expect(summary).toHaveLength(1);
|
||||||
expect(summary[0].type).toBe(TSurveyElementTypeEnum.Address);
|
expect(summary[0].type).toBe(TSurveyElementTypeEnum.Address);
|
||||||
@@ -1505,10 +1319,15 @@ describe("Address and ContactInfo question types", () => {
|
|||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "contact-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 },
|
{ questionId: "contact-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary = await getElementSummary(survey, getElementsFromBlocks(survey.blocks), responses, dropOff);
|
const summary = await getQuestionSummary(
|
||||||
|
survey,
|
||||||
|
getElementsFromBlocks(survey.blocks),
|
||||||
|
responses,
|
||||||
|
dropOff
|
||||||
|
);
|
||||||
|
|
||||||
expect(summary).toHaveLength(1);
|
expect(summary).toHaveLength(1);
|
||||||
expect(summary[0].type).toBe(TSurveyElementTypeEnum.ContactInfo);
|
expect(summary[0].type).toBe(TSurveyElementTypeEnum.ContactInfo);
|
||||||
@@ -1555,10 +1374,15 @@ describe("Address and ContactInfo question types", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "address-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 },
|
{ questionId: "address-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary = await getElementSummary(survey, getElementsFromBlocks(survey.blocks), responses, dropOff);
|
const summary = await getQuestionSummary(
|
||||||
|
survey,
|
||||||
|
getElementsFromBlocks(survey.blocks),
|
||||||
|
responses,
|
||||||
|
dropOff
|
||||||
|
);
|
||||||
|
|
||||||
expect(summary).toHaveLength(1);
|
expect(summary).toHaveLength(1);
|
||||||
expect((summary[0] as any).type).toBe(TSurveyElementTypeEnum.Address);
|
expect((summary[0] as any).type).toBe(TSurveyElementTypeEnum.Address);
|
||||||
@@ -1623,10 +1447,15 @@ describe("Address and ContactInfo question types", () => {
|
|||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "contact-q1", impressions: 3, dropOffCount: 3, dropOffPercentage: 100 },
|
{ questionId: "contact-q1", impressions: 3, dropOffCount: 3, dropOffPercentage: 100 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary = await getElementSummary(survey, getElementsFromBlocks(survey.blocks), responses, dropOff);
|
const summary = await getQuestionSummary(
|
||||||
|
survey,
|
||||||
|
getElementsFromBlocks(survey.blocks),
|
||||||
|
responses,
|
||||||
|
dropOff
|
||||||
|
);
|
||||||
|
|
||||||
expect(summary).toHaveLength(1);
|
expect(summary).toHaveLength(1);
|
||||||
expect((summary[0] as any).type).toBe(TSurveyElementTypeEnum.ContactInfo);
|
expect((summary[0] as any).type).toBe(TSurveyElementTypeEnum.ContactInfo);
|
||||||
@@ -1687,10 +1516,15 @@ describe("Address and ContactInfo question types", () => {
|
|||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "address-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 },
|
{ questionId: "address-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary = await getElementSummary(survey, getElementsFromBlocks(survey.blocks), responses, dropOff);
|
const summary = await getQuestionSummary(
|
||||||
|
survey,
|
||||||
|
getElementsFromBlocks(survey.blocks),
|
||||||
|
responses,
|
||||||
|
dropOff
|
||||||
|
);
|
||||||
|
|
||||||
expect(summary).toHaveLength(1);
|
expect(summary).toHaveLength(1);
|
||||||
expect((summary[0] as any).type).toBe(TSurveyElementTypeEnum.Address);
|
expect((summary[0] as any).type).toBe(TSurveyElementTypeEnum.Address);
|
||||||
@@ -1745,10 +1579,15 @@ describe("Address and ContactInfo question types", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "contact-q1", impressions: 100, dropOffCount: 0, dropOffPercentage: 0 },
|
{ questionId: "contact-q1", impressions: 100, dropOffCount: 0, dropOffPercentage: 0 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary = await getElementSummary(survey, getElementsFromBlocks(survey.blocks), responses, dropOff);
|
const summary = await getQuestionSummary(
|
||||||
|
survey,
|
||||||
|
getElementsFromBlocks(survey.blocks),
|
||||||
|
responses,
|
||||||
|
dropOff
|
||||||
|
);
|
||||||
|
|
||||||
expect(summary).toHaveLength(1);
|
expect(summary).toHaveLength(1);
|
||||||
expect((summary[0] as any).type).toBe(TSurveyElementTypeEnum.ContactInfo);
|
expect((summary[0] as any).type).toBe(TSurveyElementTypeEnum.ContactInfo);
|
||||||
@@ -1827,10 +1666,10 @@ describe("Matrix question type tests", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "matrix-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 },
|
{ questionId: "matrix-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary: any = await getElementSummary(
|
const summary: any = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -1915,7 +1754,7 @@ describe("Matrix question type tests", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "matrix-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 },
|
{ questionId: "matrix-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
// Mock getLocalizedValue for this test
|
// Mock getLocalizedValue for this test
|
||||||
@@ -1934,7 +1773,7 @@ describe("Matrix question type tests", () => {
|
|||||||
return "";
|
return "";
|
||||||
});
|
});
|
||||||
|
|
||||||
const summary: any = await getElementSummary(
|
const summary: any = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -2040,10 +1879,10 @@ describe("Matrix question type tests", () => {
|
|||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "matrix-q1", impressions: 4, dropOffCount: 4, dropOffPercentage: 100 },
|
{ questionId: "matrix-q1", impressions: 4, dropOffCount: 4, dropOffPercentage: 100 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary: any = await getElementSummary(
|
const summary: any = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -2132,10 +1971,10 @@ describe("Matrix question type tests", () => {
|
|||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "matrix-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 },
|
{ questionId: "matrix-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary: any = await getElementSummary(
|
const summary: any = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -2204,10 +2043,10 @@ describe("Matrix question type tests", () => {
|
|||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "matrix-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 },
|
{ questionId: "matrix-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary: any = await getElementSummary(
|
const summary: any = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -2281,10 +2120,10 @@ describe("Matrix question type tests", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "matrix-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 },
|
{ questionId: "matrix-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary: any = await getElementSummary(
|
const summary: any = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -2363,10 +2202,10 @@ describe("Matrix question type tests", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "matrix-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 },
|
{ questionId: "matrix-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary: any = await getElementSummary(
|
const summary: any = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -2459,7 +2298,7 @@ describe("Matrix question type tests", () => {
|
|||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "matrix-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 },
|
{ questionId: "matrix-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
// Mock getLocalizedValue to handle our specific test case
|
// Mock getLocalizedValue to handle our specific test case
|
||||||
@@ -2478,7 +2317,7 @@ describe("Matrix question type tests", () => {
|
|||||||
return "";
|
return "";
|
||||||
});
|
});
|
||||||
|
|
||||||
const summary: any = await getElementSummary(
|
const summary: any = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -2542,10 +2381,10 @@ describe("Matrix question type tests", () => {
|
|||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "matrix-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 },
|
{ questionId: "matrix-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary: any = await getElementSummary(
|
const summary: any = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -2635,10 +2474,10 @@ describe("NPS question type tests", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "nps-q1", impressions: 4, dropOffCount: 0, dropOffPercentage: 0 },
|
{ questionId: "nps-q1", impressions: 4, dropOffCount: 0, dropOffPercentage: 0 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary: any = await getElementSummary(
|
const summary: any = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -2729,10 +2568,10 @@ describe("NPS question type tests", () => {
|
|||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "nps-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
|
{ questionId: "nps-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary: any = await getElementSummary(
|
const summary: any = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -2796,10 +2635,10 @@ describe("NPS question type tests", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "nps-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 },
|
{ questionId: "nps-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary: any = await getElementSummary(
|
const summary: any = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -2882,10 +2721,10 @@ describe("NPS question type tests", () => {
|
|||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "nps-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
|
{ questionId: "nps-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary: any = await getElementSummary(
|
const summary: any = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -2976,10 +2815,10 @@ describe("Rating question type tests", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "rating-q1", impressions: 4, dropOffCount: 0, dropOffPercentage: 0 },
|
{ questionId: "rating-q1", impressions: 4, dropOffCount: 0, dropOffPercentage: 0 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary: any = await getElementSummary(
|
const summary: any = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -3078,10 +2917,10 @@ describe("Rating question type tests", () => {
|
|||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
|
{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary: any = await getElementSummary(
|
const summary: any = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -3138,10 +2977,10 @@ describe("Rating question type tests", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "rating-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 },
|
{ questionId: "rating-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary: any = await getElementSummary(
|
const summary: any = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -3216,10 +3055,10 @@ describe("PictureSelection question type tests", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "picture-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 },
|
{ questionId: "picture-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary: any = await getElementSummary(
|
const summary: any = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -3295,10 +3134,10 @@ describe("PictureSelection question type tests", () => {
|
|||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "picture-q1", impressions: 2, dropOffCount: 2, dropOffPercentage: 100 },
|
{ questionId: "picture-q1", impressions: 2, dropOffCount: 2, dropOffPercentage: 100 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary: any = await getElementSummary(
|
const summary: any = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -3357,10 +3196,10 @@ describe("PictureSelection question type tests", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "picture-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 },
|
{ questionId: "picture-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary: any = await getElementSummary(
|
const summary: any = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -3394,7 +3233,7 @@ describe("CTA question type tests", () => {
|
|||||||
type: TSurveyElementTypeEnum.CTA,
|
type: TSurveyElementTypeEnum.CTA,
|
||||||
headline: { default: "Would you like to try our product?" },
|
headline: { default: "Would you like to try our product?" },
|
||||||
buttonLabel: { default: "Try Now" },
|
buttonLabel: { default: "Try Now" },
|
||||||
buttonExternal: true,
|
buttonExternal: false,
|
||||||
buttonUrl: "https://example.com",
|
buttonUrl: "https://example.com",
|
||||||
required: true,
|
required: true,
|
||||||
};
|
};
|
||||||
@@ -3448,14 +3287,14 @@ describe("CTA question type tests", () => {
|
|||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{
|
{
|
||||||
elementId: "cta-q1",
|
questionId: "cta-q1",
|
||||||
impressions: 5, // 5 total impressions (including 2 that didn't respond)
|
impressions: 5, // 5 total impressions (including 2 that didn't respond)
|
||||||
dropOffCount: 0,
|
dropOffCount: 0,
|
||||||
dropOffPercentage: 0,
|
dropOffPercentage: 0,
|
||||||
},
|
},
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary: any = await getElementSummary(
|
const summary: any = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -3480,7 +3319,7 @@ describe("CTA question type tests", () => {
|
|||||||
type: TSurveyElementTypeEnum.CTA,
|
type: TSurveyElementTypeEnum.CTA,
|
||||||
headline: { default: "Would you like to try our product?" },
|
headline: { default: "Would you like to try our product?" },
|
||||||
buttonLabel: { default: "Try Now" },
|
buttonLabel: { default: "Try Now" },
|
||||||
buttonExternal: true,
|
buttonExternal: false,
|
||||||
buttonUrl: "https://example.com",
|
buttonUrl: "https://example.com",
|
||||||
required: false,
|
required: false,
|
||||||
};
|
};
|
||||||
@@ -3514,14 +3353,14 @@ describe("CTA question type tests", () => {
|
|||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{
|
{
|
||||||
elementId: "cta-q1",
|
questionId: "cta-q1",
|
||||||
impressions: 3, // 3 total impressions
|
impressions: 3, // 3 total impressions
|
||||||
dropOffCount: 3,
|
dropOffCount: 3,
|
||||||
dropOffPercentage: 100,
|
dropOffPercentage: 100,
|
||||||
},
|
},
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary: any = await getElementSummary(
|
const summary: any = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -3538,63 +3377,6 @@ describe("CTA question type tests", () => {
|
|||||||
expect(summary[0].ctr.count).toBe(0);
|
expect(summary[0].ctr.count).toBe(0);
|
||||||
expect(summary[0].ctr.percentage).toBe(0);
|
expect(summary[0].ctr.percentage).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("getQuestionSummary skips CTA summary when buttonExternal is false", async () => {
|
|
||||||
const question = {
|
|
||||||
id: "cta-q1",
|
|
||||||
type: TSurveyElementTypeEnum.CTA,
|
|
||||||
headline: { default: "Internal CTA" },
|
|
||||||
buttonLabel: { default: "Continue" },
|
|
||||||
buttonExternal: false, // Internal button - no CTR tracking
|
|
||||||
required: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const survey = {
|
|
||||||
id: "survey-1",
|
|
||||||
blocks: [
|
|
||||||
{
|
|
||||||
id: "block1",
|
|
||||||
name: "Block 1",
|
|
||||||
elements: [question],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
questions: [],
|
|
||||||
languages: [],
|
|
||||||
welcomeCard: { enabled: false },
|
|
||||||
} as unknown as TSurvey;
|
|
||||||
|
|
||||||
const responses = [
|
|
||||||
{
|
|
||||||
id: "response-1",
|
|
||||||
data: { "cta-q1": "clicked" },
|
|
||||||
updatedAt: new Date(),
|
|
||||||
contact: null,
|
|
||||||
contactAttributes: {},
|
|
||||||
language: null,
|
|
||||||
ttc: {},
|
|
||||||
finished: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const dropOff = [
|
|
||||||
{
|
|
||||||
elementId: "cta-q1",
|
|
||||||
impressions: 1,
|
|
||||||
dropOffCount: 0,
|
|
||||||
dropOffPercentage: 0,
|
|
||||||
},
|
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
|
||||||
|
|
||||||
const summary: any = await getElementSummary(
|
|
||||||
survey,
|
|
||||||
getElementsFromBlocks(survey.blocks),
|
|
||||||
responses,
|
|
||||||
dropOff
|
|
||||||
);
|
|
||||||
|
|
||||||
// CTA with buttonExternal: false should not generate a summary
|
|
||||||
expect(summary).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Consent question type tests", () => {
|
describe("Consent question type tests", () => {
|
||||||
@@ -3655,10 +3437,10 @@ describe("Consent question type tests", () => {
|
|||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "consent-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
|
{ questionId: "consent-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary: any = await getElementSummary(
|
const summary: any = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -3715,10 +3497,10 @@ describe("Consent question type tests", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "consent-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 },
|
{ questionId: "consent-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary: any = await getElementSummary(
|
const summary: any = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -3771,10 +3553,10 @@ describe("Consent question type tests", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "consent-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 },
|
{ questionId: "consent-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary: any = await getElementSummary(
|
const summary: any = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -3836,10 +3618,10 @@ describe("Date question type tests", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "date-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 },
|
{ questionId: "date-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary: any = await getElementSummary(
|
const summary: any = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -3895,10 +3677,10 @@ describe("Date question type tests", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "date-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 },
|
{ questionId: "date-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary: any = await getElementSummary(
|
const summary: any = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -3946,10 +3728,10 @@ describe("Date question type tests", () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "date-q1", impressions: 100, dropOffCount: 0, dropOffPercentage: 0 },
|
{ questionId: "date-q1", impressions: 100, dropOffCount: 0, dropOffPercentage: 0 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary: any = await getElementSummary(
|
const summary: any = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -4014,10 +3796,10 @@ describe("FileUpload question type tests", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "file-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 },
|
{ questionId: "file-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary: any = await getElementSummary(
|
const summary: any = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -4073,10 +3855,10 @@ describe("FileUpload question type tests", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "file-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 },
|
{ questionId: "file-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary: any = await getElementSummary(
|
const summary: any = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -4149,10 +3931,10 @@ describe("Cal question type tests", () => {
|
|||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "cal-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
|
{ questionId: "cal-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary: any = await getElementSummary(
|
const summary: any = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -4210,10 +3992,10 @@ describe("Cal question type tests", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "cal-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 },
|
{ questionId: "cal-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary: any = await getElementSummary(
|
const summary: any = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
@@ -4267,10 +4049,10 @@ describe("Cal question type tests", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const dropOff = [
|
const dropOff = [
|
||||||
{ elementId: "cal-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 },
|
{ questionId: "cal-q1", impressions: 1, dropOffCount: 0, dropOffPercentage: 0 },
|
||||||
] as unknown as TSurveySummary["dropOff"];
|
] as unknown as TSurveySummary["dropOff"];
|
||||||
|
|
||||||
const summary: any = await getElementSummary(
|
const summary: any = await getQuestionSummary(
|
||||||
survey,
|
survey,
|
||||||
getElementsFromBlocks(survey.blocks),
|
getElementsFromBlocks(survey.blocks),
|
||||||
responses,
|
responses,
|
||||||
|
|||||||
+131
-170
@@ -15,8 +15,9 @@ import {
|
|||||||
ZResponseFilterCriteria,
|
ZResponseFilterCriteria,
|
||||||
} from "@formbricks/types/responses";
|
} from "@formbricks/types/responses";
|
||||||
import {
|
import {
|
||||||
|
TSurveyAddressElement,
|
||||||
|
TSurveyContactInfoElement,
|
||||||
TSurveyElement,
|
TSurveyElement,
|
||||||
TSurveyElementChoice,
|
|
||||||
TSurveyElementTypeEnum,
|
TSurveyElementTypeEnum,
|
||||||
} from "@formbricks/types/surveys/elements";
|
} from "@formbricks/types/surveys/elements";
|
||||||
import {
|
import {
|
||||||
@@ -32,6 +33,7 @@ import {
|
|||||||
TSurveyElementSummaryRanking,
|
TSurveyElementSummaryRanking,
|
||||||
TSurveyElementSummaryRating,
|
TSurveyElementSummaryRating,
|
||||||
TSurveyLanguage,
|
TSurveyLanguage,
|
||||||
|
TSurveyQuestionChoice,
|
||||||
TSurveySummary,
|
TSurveySummary,
|
||||||
} from "@formbricks/types/surveys/types";
|
} from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||||
@@ -97,16 +99,16 @@ export const getSurveySummaryMeta = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const evaluateLogicAndGetNextElementId = (
|
const evaluateLogicAndGetNextQuestionId = (
|
||||||
localSurvey: TSurvey,
|
localSurvey: TSurvey,
|
||||||
elements: TSurveyElement[],
|
questions: TSurveyElement[],
|
||||||
data: TResponseData,
|
data: TResponseData,
|
||||||
localVariables: TResponseVariables,
|
localVariables: TResponseVariables,
|
||||||
currentElementIndex: number,
|
currentQuestionIndex: number,
|
||||||
currElementTemp: TSurveyElement,
|
currQuesTemp: TSurveyElement,
|
||||||
selectedLanguage: string | null
|
selectedLanguage: string | null
|
||||||
): {
|
): {
|
||||||
nextElementId: string | undefined;
|
nextQuestionId: string | undefined;
|
||||||
updatedSurvey: TSurvey;
|
updatedSurvey: TSurvey;
|
||||||
updatedVariables: TResponseVariables;
|
updatedVariables: TResponseVariables;
|
||||||
} => {
|
} => {
|
||||||
@@ -115,24 +117,24 @@ const evaluateLogicAndGetNextElementId = (
|
|||||||
|
|
||||||
let firstJumpTarget: string | undefined;
|
let firstJumpTarget: string | undefined;
|
||||||
|
|
||||||
const { block: currentBlock } = findElementLocation(localSurvey, currElementTemp.id);
|
const { block: currentBlock } = findElementLocation(localSurvey, currQuesTemp.id);
|
||||||
|
|
||||||
if (currentBlock?.logic && currentBlock.logic.length > 0) {
|
if (currentBlock?.logic && currentBlock.logic.length > 0) {
|
||||||
for (const logic of currentBlock.logic) {
|
for (const logic of currentBlock.logic) {
|
||||||
if (evaluateLogic(localSurvey, data, localVariables, logic.conditions, selectedLanguage ?? "default")) {
|
if (evaluateLogic(localSurvey, data, localVariables, logic.conditions, selectedLanguage ?? "default")) {
|
||||||
const { jumpTarget, requiredElementIds, calculations } = performActions(
|
const { jumpTarget, requiredQuestionIds, calculations } = performActions(
|
||||||
updatedSurvey,
|
updatedSurvey,
|
||||||
logic.actions,
|
logic.actions,
|
||||||
data,
|
data,
|
||||||
updatedVariables
|
updatedVariables
|
||||||
);
|
);
|
||||||
|
|
||||||
if (requiredElementIds.length > 0) {
|
if (requiredQuestionIds.length > 0) {
|
||||||
// Update blocks to mark elements as required
|
// Update blocks to mark elements as required
|
||||||
updatedSurvey.blocks = updatedSurvey.blocks.map((block) => ({
|
updatedSurvey.blocks = updatedSurvey.blocks.map((block) => ({
|
||||||
...block,
|
...block,
|
||||||
elements: block.elements.map((e) =>
|
elements: block.elements.map((e) =>
|
||||||
requiredElementIds.includes(e.id) ? { ...e, required: true } : e
|
requiredQuestionIds.includes(e.id) ? { ...e, required: true } : e
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -150,29 +152,29 @@ const evaluateLogicAndGetNextElementId = (
|
|||||||
firstJumpTarget = currentBlock.logicFallback;
|
firstJumpTarget = currentBlock.logicFallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the first jump target if found, otherwise go to the next element
|
// Return the first jump target if found, otherwise go to the next question
|
||||||
const nextElementId = firstJumpTarget || elements[currentElementIndex + 1]?.id || undefined;
|
const nextQuestionId = firstJumpTarget || questions[currentQuestionIndex + 1]?.id || undefined;
|
||||||
|
|
||||||
return { nextElementId, updatedSurvey, updatedVariables };
|
return { nextQuestionId, updatedSurvey, updatedVariables };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSurveySummaryDropOff = (
|
export const getSurveySummaryDropOff = (
|
||||||
survey: TSurvey,
|
survey: TSurvey,
|
||||||
elements: TSurveyElement[],
|
questions: TSurveyElement[],
|
||||||
responses: TSurveySummaryResponse[],
|
responses: TSurveySummaryResponse[],
|
||||||
displayCount: number
|
displayCount: number
|
||||||
): TSurveySummary["dropOff"] => {
|
): TSurveySummary["dropOff"] => {
|
||||||
const initialTtc = elements.reduce((acc: Record<string, number>, element) => {
|
const initialTtc = questions.reduce((acc: Record<string, number>, question) => {
|
||||||
acc[element.id] = 0;
|
acc[question.id] = 0;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
let totalTtc = { ...initialTtc };
|
let totalTtc = { ...initialTtc };
|
||||||
let responseCounts = { ...initialTtc };
|
let responseCounts = { ...initialTtc };
|
||||||
|
|
||||||
let dropOffArr = new Array(elements.length).fill(0) as number[];
|
let dropOffArr = new Array(questions.length).fill(0) as number[];
|
||||||
let impressionsArr = new Array(elements.length).fill(0) as number[];
|
let impressionsArr = new Array(questions.length).fill(0) as number[];
|
||||||
let dropOffPercentageArr = new Array(elements.length).fill(0) as number[];
|
let dropOffPercentageArr = new Array(questions.length).fill(0) as number[];
|
||||||
|
|
||||||
const surveyVariablesData = survey.variables?.reduce(
|
const surveyVariablesData = survey.variables?.reduce(
|
||||||
(acc, variable) => {
|
(acc, variable) => {
|
||||||
@@ -184,10 +186,10 @@ export const getSurveySummaryDropOff = (
|
|||||||
|
|
||||||
responses.forEach((response) => {
|
responses.forEach((response) => {
|
||||||
// Calculate total time-to-completion
|
// Calculate total time-to-completion
|
||||||
Object.keys(totalTtc).forEach((elementId) => {
|
Object.keys(totalTtc).forEach((questionId) => {
|
||||||
if (response.ttc && response.ttc[elementId]) {
|
if (response.ttc && response.ttc[questionId]) {
|
||||||
totalTtc[elementId] += response.ttc[elementId];
|
totalTtc[questionId] += response.ttc[questionId];
|
||||||
responseCounts[elementId]++;
|
responseCounts[questionId]++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -199,11 +201,11 @@ export const getSurveySummaryDropOff = (
|
|||||||
|
|
||||||
let currQuesIdx = 0;
|
let currQuesIdx = 0;
|
||||||
|
|
||||||
while (currQuesIdx < elements.length) {
|
while (currQuesIdx < questions.length) {
|
||||||
const currQues = elements[currQuesIdx];
|
const currQues = questions[currQuesIdx];
|
||||||
if (!currQues) break;
|
if (!currQues) break;
|
||||||
|
|
||||||
// element is not answered and required
|
// question is not answered and required
|
||||||
if (response.data[currQues.id] === undefined && currQues.required) {
|
if (response.data[currQues.id] === undefined && currQues.required) {
|
||||||
dropOffArr[currQuesIdx]++;
|
dropOffArr[currQuesIdx]++;
|
||||||
impressionsArr[currQuesIdx]++;
|
impressionsArr[currQuesIdx]++;
|
||||||
@@ -212,9 +214,9 @@ export const getSurveySummaryDropOff = (
|
|||||||
|
|
||||||
impressionsArr[currQuesIdx]++;
|
impressionsArr[currQuesIdx]++;
|
||||||
|
|
||||||
const { nextElementId, updatedSurvey, updatedVariables } = evaluateLogicAndGetNextElementId(
|
const { nextQuestionId, updatedSurvey, updatedVariables } = evaluateLogicAndGetNextQuestionId(
|
||||||
localSurvey,
|
localSurvey,
|
||||||
elements,
|
questions,
|
||||||
localResponseData,
|
localResponseData,
|
||||||
localVariables,
|
localVariables,
|
||||||
currQuesIdx,
|
currQuesIdx,
|
||||||
@@ -225,9 +227,9 @@ export const getSurveySummaryDropOff = (
|
|||||||
localSurvey = updatedSurvey;
|
localSurvey = updatedSurvey;
|
||||||
localVariables = updatedVariables;
|
localVariables = updatedVariables;
|
||||||
|
|
||||||
if (nextElementId) {
|
if (nextQuestionId) {
|
||||||
const nextQuesIdx = elements.findIndex((q) => q.id === nextElementId);
|
const nextQuesIdx = questions.findIndex((q) => q.id === nextQuestionId);
|
||||||
if (!response.data[nextElementId] && !response.finished) {
|
if (!response.data[nextQuestionId] && !response.finished) {
|
||||||
dropOffArr[nextQuesIdx]++;
|
dropOffArr[nextQuesIdx]++;
|
||||||
impressionsArr[nextQuesIdx]++;
|
impressionsArr[nextQuesIdx]++;
|
||||||
break;
|
break;
|
||||||
@@ -239,9 +241,10 @@ export const getSurveySummaryDropOff = (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate the average time for each element
|
// Calculate the average time for each question
|
||||||
Object.keys(totalTtc).forEach((elementId) => {
|
Object.keys(totalTtc).forEach((questionId) => {
|
||||||
totalTtc[elementId] = responseCounts[elementId] > 0 ? totalTtc[elementId] / responseCounts[elementId] : 0;
|
totalTtc[questionId] =
|
||||||
|
responseCounts[questionId] > 0 ? totalTtc[questionId] / responseCounts[questionId] : 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!survey.welcomeCard.enabled) {
|
if (!survey.welcomeCard.enabled) {
|
||||||
@@ -258,18 +261,18 @@ export const getSurveySummaryDropOff = (
|
|||||||
dropOffPercentageArr[0] = (dropOffArr[0] / impressionsArr[0]) * 100;
|
dropOffPercentageArr[0] = (dropOffArr[0] / impressionsArr[0]) * 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 1; i < elements.length; i++) {
|
for (let i = 1; i < questions.length; i++) {
|
||||||
if (impressionsArr[i] !== 0) {
|
if (impressionsArr[i] !== 0) {
|
||||||
dropOffPercentageArr[i] = (dropOffArr[i] / impressionsArr[i]) * 100;
|
dropOffPercentageArr[i] = (dropOffArr[i] / impressionsArr[i]) * 100;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const dropOff = elements.map((element, index) => {
|
const dropOff = questions.map((question, index) => {
|
||||||
return {
|
return {
|
||||||
elementId: element.id,
|
questionId: question.id,
|
||||||
elementType: element.type,
|
questionType: question.type,
|
||||||
headline: getTextContent(getLocalizedValue(element.headline, "default")),
|
headline: getTextContent(getLocalizedValue(question.headline, "default")),
|
||||||
ttc: convertFloatTo2Decimal(totalTtc[element.id]) || 0,
|
ttc: convertFloatTo2Decimal(totalTtc[question.id]) || 0,
|
||||||
impressions: impressionsArr[index] || 0,
|
impressions: impressionsArr[index] || 0,
|
||||||
dropOffCount: dropOffArr[index] || 0,
|
dropOffCount: dropOffArr[index] || 0,
|
||||||
dropOffPercentage: convertFloatTo2Decimal(dropOffPercentageArr[index]) || 0,
|
dropOffPercentage: convertFloatTo2Decimal(dropOffPercentageArr[index]) || 0,
|
||||||
@@ -288,17 +291,17 @@ const getLanguageCode = (surveyLanguages: TSurveyLanguage[], languageCode: strin
|
|||||||
const checkForI18n = (
|
const checkForI18n = (
|
||||||
responseData: TResponseData,
|
responseData: TResponseData,
|
||||||
id: string,
|
id: string,
|
||||||
elements: TSurveyElement[],
|
questions: TSurveyElement[],
|
||||||
languageCode: string
|
languageCode: string
|
||||||
) => {
|
) => {
|
||||||
const element = elements.find((element) => element.id === id);
|
const question = questions.find((question) => question.id === id);
|
||||||
|
|
||||||
if (element?.type === "multipleChoiceMulti" || element?.type === "ranking") {
|
if (question?.type === "multipleChoiceMulti" || question?.type === "ranking") {
|
||||||
// Initialize an array to hold the choice values
|
// Initialize an array to hold the choice values
|
||||||
let choiceValues = [] as string[];
|
let choiceValues = [] as string[];
|
||||||
|
|
||||||
// Type guard: both element types have choices property
|
// Type guard: both question types have choices property
|
||||||
const hasChoices = "choices" in element;
|
const hasChoices = "choices" in question;
|
||||||
if (!hasChoices) return [];
|
if (!hasChoices) return [];
|
||||||
|
|
||||||
(typeof responseData[id] === "string"
|
(typeof responseData[id] === "string"
|
||||||
@@ -307,20 +310,20 @@ const checkForI18n = (
|
|||||||
)?.forEach((data) => {
|
)?.forEach((data) => {
|
||||||
choiceValues.push(
|
choiceValues.push(
|
||||||
getLocalizedValue(
|
getLocalizedValue(
|
||||||
element.choices.find((choice) => choice.label[languageCode] === data)?.label,
|
question.choices.find((choice) => choice.label[languageCode] === data)?.label,
|
||||||
"default"
|
"default"
|
||||||
) || data
|
) || data
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return the array of localized choice values of multiSelect multi elements
|
// Return the array of localized choice values of multiSelect multi questions
|
||||||
return choiceValues;
|
return choiceValues;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the localized value of the choice fo multiSelect single element
|
// Return the localized value of the choice fo multiSelect single question
|
||||||
if (element && "choices" in element) {
|
if (question && "choices" in question) {
|
||||||
const choice = element.choices?.find(
|
const choice = question.choices?.find(
|
||||||
(choice: TSurveyElementChoice) => choice.label?.[languageCode] === responseData[id]
|
(choice: TSurveyQuestionChoice) => choice.label?.[languageCode] === responseData[id]
|
||||||
);
|
);
|
||||||
return choice && "label" in choice
|
return choice && "label" in choice
|
||||||
? getLocalizedValue(choice.label, "default") || responseData[id]
|
? getLocalizedValue(choice.label, "default") || responseData[id]
|
||||||
@@ -330,21 +333,21 @@ const checkForI18n = (
|
|||||||
return responseData[id];
|
return responseData[id];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getElementSummary = async (
|
export const getQuestionSummary = async (
|
||||||
survey: TSurvey,
|
survey: TSurvey,
|
||||||
elements: TSurveyElement[],
|
questions: TSurveyElement[],
|
||||||
responses: TSurveySummaryResponse[],
|
responses: TSurveySummaryResponse[],
|
||||||
dropOff: TSurveySummary["dropOff"]
|
dropOff: TSurveySummary["dropOff"]
|
||||||
): Promise<TSurveySummary["summary"]> => {
|
): Promise<TSurveySummary["summary"]> => {
|
||||||
const VALUES_LIMIT = 50;
|
const VALUES_LIMIT = 50;
|
||||||
let summary: TSurveySummary["summary"] = [];
|
let summary: TSurveySummary["summary"] = [];
|
||||||
|
|
||||||
for (const element of elements) {
|
for (const question of questions) {
|
||||||
switch (element.type) {
|
switch (question.type) {
|
||||||
case TSurveyElementTypeEnum.OpenText: {
|
case TSurveyElementTypeEnum.OpenText: {
|
||||||
let values: TSurveyElementSummaryOpenText["samples"] = [];
|
let values: TSurveyElementSummaryOpenText["samples"] = [];
|
||||||
responses.forEach((response) => {
|
responses.forEach((response) => {
|
||||||
const answer = response.data[element.id];
|
const answer = response.data[question.id];
|
||||||
if (answer && typeof answer === "string") {
|
if (answer && typeof answer === "string") {
|
||||||
values.push({
|
values.push({
|
||||||
id: response.id,
|
id: response.id,
|
||||||
@@ -357,8 +360,8 @@ export const getElementSummary = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
summary.push({
|
summary.push({
|
||||||
type: element.type,
|
type: question.type,
|
||||||
element: element,
|
question: question,
|
||||||
responseCount: values.length,
|
responseCount: values.length,
|
||||||
samples: values.slice(0, VALUES_LIMIT),
|
samples: values.slice(0, VALUES_LIMIT),
|
||||||
});
|
});
|
||||||
@@ -370,14 +373,14 @@ export const getElementSummary = async (
|
|||||||
case TSurveyElementTypeEnum.MultipleChoiceMulti: {
|
case TSurveyElementTypeEnum.MultipleChoiceMulti: {
|
||||||
let values: TSurveyElementSummaryMultipleChoice["choices"] = [];
|
let values: TSurveyElementSummaryMultipleChoice["choices"] = [];
|
||||||
|
|
||||||
const otherOption = element.choices.find((choice) => choice.id === "other");
|
const otherOption = question.choices.find((choice) => choice.id === "other");
|
||||||
const noneOption = element.choices.find((choice) => choice.id === "none");
|
const noneOption = question.choices.find((choice) => choice.id === "none");
|
||||||
|
|
||||||
const elementChoices = element.choices
|
const questionChoices = question.choices
|
||||||
.filter((choice) => choice.id !== "other" && choice.id !== "none")
|
.filter((choice) => choice.id !== "other" && choice.id !== "none")
|
||||||
.map((choice) => getLocalizedValue(choice.label, "default"));
|
.map((choice) => getLocalizedValue(choice.label, "default"));
|
||||||
|
|
||||||
const choiceCountMap = elementChoices.reduce((acc: Record<string, number>, choice) => {
|
const choiceCountMap = questionChoices.reduce((acc: Record<string, number>, choice) => {
|
||||||
acc[choice] = 0;
|
acc[choice] = 0;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
@@ -394,16 +397,16 @@ export const getElementSummary = async (
|
|||||||
|
|
||||||
const answer =
|
const answer =
|
||||||
responseLanguageCode === "default"
|
responseLanguageCode === "default"
|
||||||
? response.data[element.id]
|
? response.data[question.id]
|
||||||
: checkForI18n(response.data, element.id, elements, responseLanguageCode);
|
: checkForI18n(response.data, question.id, questions, responseLanguageCode);
|
||||||
|
|
||||||
let hasValidAnswer = false;
|
let hasValidAnswer = false;
|
||||||
|
|
||||||
if (Array.isArray(answer) && element.type === TSurveyElementTypeEnum.MultipleChoiceMulti) {
|
if (Array.isArray(answer) && question.type === TSurveyElementTypeEnum.MultipleChoiceMulti) {
|
||||||
answer.forEach((value) => {
|
answer.forEach((value) => {
|
||||||
if (value) {
|
if (value) {
|
||||||
totalSelectionCount++;
|
totalSelectionCount++;
|
||||||
if (elementChoices.includes(value)) {
|
if (questionChoices.includes(value)) {
|
||||||
choiceCountMap[value]++;
|
choiceCountMap[value]++;
|
||||||
} else if (noneLabel && value === noneLabel) {
|
} else if (noneLabel && value === noneLabel) {
|
||||||
noneCount++;
|
noneCount++;
|
||||||
@@ -419,11 +422,11 @@ export const getElementSummary = async (
|
|||||||
});
|
});
|
||||||
} else if (
|
} else if (
|
||||||
typeof answer === "string" &&
|
typeof answer === "string" &&
|
||||||
element.type === TSurveyElementTypeEnum.MultipleChoiceSingle
|
question.type === TSurveyElementTypeEnum.MultipleChoiceSingle
|
||||||
) {
|
) {
|
||||||
if (answer) {
|
if (answer) {
|
||||||
totalSelectionCount++;
|
totalSelectionCount++;
|
||||||
if (elementChoices.includes(answer)) {
|
if (questionChoices.includes(answer)) {
|
||||||
choiceCountMap[answer]++;
|
choiceCountMap[answer]++;
|
||||||
} else if (noneLabel && answer === noneLabel) {
|
} else if (noneLabel && answer === noneLabel) {
|
||||||
noneCount++;
|
noneCount++;
|
||||||
@@ -475,8 +478,8 @@ export const getElementSummary = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
summary.push({
|
summary.push({
|
||||||
type: element.type,
|
type: question.type,
|
||||||
element,
|
question,
|
||||||
responseCount: totalResponseCount,
|
responseCount: totalResponseCount,
|
||||||
selectionCount: totalSelectionCount,
|
selectionCount: totalSelectionCount,
|
||||||
choices: values,
|
choices: values,
|
||||||
@@ -489,14 +492,14 @@ export const getElementSummary = async (
|
|||||||
let values: TSurveyElementSummaryPictureSelection["choices"] = [];
|
let values: TSurveyElementSummaryPictureSelection["choices"] = [];
|
||||||
const choiceCountMap: Record<string, number> = {};
|
const choiceCountMap: Record<string, number> = {};
|
||||||
|
|
||||||
element.choices.forEach((choice) => {
|
question.choices.forEach((choice) => {
|
||||||
choiceCountMap[choice.id] = 0;
|
choiceCountMap[choice.id] = 0;
|
||||||
});
|
});
|
||||||
let totalResponseCount = 0;
|
let totalResponseCount = 0;
|
||||||
let totalSelectionCount = 0;
|
let totalSelectionCount = 0;
|
||||||
|
|
||||||
responses.forEach((response) => {
|
responses.forEach((response) => {
|
||||||
const answer = response.data[element.id];
|
const answer = response.data[question.id];
|
||||||
if (Array.isArray(answer)) {
|
if (Array.isArray(answer)) {
|
||||||
totalResponseCount++;
|
totalResponseCount++;
|
||||||
answer.forEach((value) => {
|
answer.forEach((value) => {
|
||||||
@@ -506,7 +509,7 @@ export const getElementSummary = async (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
element.choices.forEach((choice) => {
|
question.choices.forEach((choice) => {
|
||||||
values.push({
|
values.push({
|
||||||
id: choice.id,
|
id: choice.id,
|
||||||
imageUrl: choice.imageUrl,
|
imageUrl: choice.imageUrl,
|
||||||
@@ -519,8 +522,8 @@ export const getElementSummary = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
summary.push({
|
summary.push({
|
||||||
type: element.type,
|
type: question.type,
|
||||||
element,
|
question,
|
||||||
responseCount: totalResponseCount,
|
responseCount: totalResponseCount,
|
||||||
selectionCount: totalSelectionCount,
|
selectionCount: totalSelectionCount,
|
||||||
choices: values,
|
choices: values,
|
||||||
@@ -532,7 +535,7 @@ export const getElementSummary = async (
|
|||||||
case TSurveyElementTypeEnum.Rating: {
|
case TSurveyElementTypeEnum.Rating: {
|
||||||
let values: TSurveyElementSummaryRating["choices"] = [];
|
let values: TSurveyElementSummaryRating["choices"] = [];
|
||||||
const choiceCountMap: Record<number, number> = {};
|
const choiceCountMap: Record<number, number> = {};
|
||||||
const range = element.range;
|
const range = question.range;
|
||||||
|
|
||||||
for (let i = 1; i <= range; i++) {
|
for (let i = 1; i <= range; i++) {
|
||||||
choiceCountMap[i] = 0;
|
choiceCountMap[i] = 0;
|
||||||
@@ -543,56 +546,34 @@ export const getElementSummary = async (
|
|||||||
let dismissed = 0;
|
let dismissed = 0;
|
||||||
|
|
||||||
responses.forEach((response) => {
|
responses.forEach((response) => {
|
||||||
const answer = response.data[element.id];
|
const answer = response.data[question.id];
|
||||||
if (typeof answer === "number") {
|
if (typeof answer === "number") {
|
||||||
totalResponseCount++;
|
totalResponseCount++;
|
||||||
choiceCountMap[answer]++;
|
choiceCountMap[answer]++;
|
||||||
totalRating += answer;
|
totalRating += answer;
|
||||||
} else if (response.ttc && response.ttc[element.id] > 0) {
|
} else if (response.ttc && response.ttc[question.id] > 0) {
|
||||||
dismissed++;
|
dismissed++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Object.entries(choiceCountMap).forEach(([label, count]) => {
|
Object.entries(choiceCountMap).forEach(([label, count]) => {
|
||||||
values.push({
|
values.push({
|
||||||
rating: Number.parseInt(label),
|
rating: parseInt(label),
|
||||||
count,
|
count,
|
||||||
percentage:
|
percentage:
|
||||||
totalResponseCount > 0 ? convertFloatTo2Decimal((count / totalResponseCount) * 100) : 0,
|
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({
|
summary.push({
|
||||||
type: element.type,
|
type: question.type,
|
||||||
element,
|
question,
|
||||||
average: convertFloatTo2Decimal(totalRating / totalResponseCount) || 0,
|
average: convertFloatTo2Decimal(totalRating / totalResponseCount) || 0,
|
||||||
responseCount: totalResponseCount,
|
responseCount: totalResponseCount,
|
||||||
choices: values,
|
choices: values,
|
||||||
dismissed: {
|
dismissed: {
|
||||||
count: dismissed,
|
count: dismissed,
|
||||||
},
|
},
|
||||||
csat: {
|
|
||||||
satisfiedCount,
|
|
||||||
satisfiedPercentage,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
values = [];
|
values = [];
|
||||||
@@ -608,17 +589,10 @@ export const getElementSummary = async (
|
|||||||
score: 0,
|
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) => {
|
responses.forEach((response) => {
|
||||||
const value = response.data[element.id];
|
const value = response.data[question.id];
|
||||||
if (typeof value === "number") {
|
if (typeof value === "number") {
|
||||||
data.total++;
|
data.total++;
|
||||||
scoreCountMap[value]++;
|
|
||||||
if (value >= 9) {
|
if (value >= 9) {
|
||||||
data.promoters++;
|
data.promoters++;
|
||||||
} else if (value >= 7) {
|
} else if (value >= 7) {
|
||||||
@@ -626,7 +600,7 @@ export const getElementSummary = async (
|
|||||||
} else {
|
} else {
|
||||||
data.detractors++;
|
data.detractors++;
|
||||||
}
|
}
|
||||||
} else if (response.ttc && response.ttc[element.id] > 0) {
|
} else if (response.ttc && response.ttc[question.id] > 0) {
|
||||||
data.total++;
|
data.total++;
|
||||||
data.dismissed++;
|
data.dismissed++;
|
||||||
}
|
}
|
||||||
@@ -637,16 +611,9 @@ export const getElementSummary = async (
|
|||||||
? convertFloatTo2Decimal(((data.promoters - data.detractors) / data.total) * 100)
|
? convertFloatTo2Decimal(((data.promoters - data.detractors) / data.total) * 100)
|
||||||
: 0;
|
: 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({
|
summary.push({
|
||||||
type: element.type,
|
type: question.type,
|
||||||
element,
|
question,
|
||||||
responseCount: data.total,
|
responseCount: data.total,
|
||||||
total: data.total,
|
total: data.total,
|
||||||
score: data.score,
|
score: data.score,
|
||||||
@@ -666,23 +633,17 @@ export const getElementSummary = async (
|
|||||||
count: data.dismissed,
|
count: data.dismissed,
|
||||||
percentage: data.total > 0 ? convertFloatTo2Decimal((data.dismissed / data.total) * 100) : 0,
|
percentage: data.total > 0 ? convertFloatTo2Decimal((data.dismissed / data.total) * 100) : 0,
|
||||||
},
|
},
|
||||||
choices,
|
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case TSurveyElementTypeEnum.CTA: {
|
case TSurveyElementTypeEnum.CTA: {
|
||||||
// Only calculate summary for CTA elements with external buttons (CTR tracking is only meaningful for external links)
|
|
||||||
if (!element.buttonExternal) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
clicked: 0,
|
clicked: 0,
|
||||||
dismissed: 0,
|
dismissed: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
responses.forEach((response) => {
|
responses.forEach((response) => {
|
||||||
const value = response.data[element.id];
|
const value = response.data[question.id];
|
||||||
if (value === "clicked") {
|
if (value === "clicked") {
|
||||||
data.clicked++;
|
data.clicked++;
|
||||||
} else if (value === "dismissed") {
|
} else if (value === "dismissed") {
|
||||||
@@ -691,12 +652,12 @@ export const getElementSummary = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
const totalResponses = data.clicked + data.dismissed;
|
const totalResponses = data.clicked + data.dismissed;
|
||||||
const idx = elements.findIndex((q) => q.id === element.id);
|
const idx = questions.findIndex((q) => q.id === question.id);
|
||||||
const impressions = dropOff[idx].impressions;
|
const impressions = dropOff[idx].impressions;
|
||||||
|
|
||||||
summary.push({
|
summary.push({
|
||||||
type: element.type,
|
type: question.type,
|
||||||
element,
|
question,
|
||||||
impressionCount: impressions,
|
impressionCount: impressions,
|
||||||
clickCount: data.clicked,
|
clickCount: data.clicked,
|
||||||
skipCount: data.dismissed,
|
skipCount: data.dismissed,
|
||||||
@@ -715,10 +676,10 @@ export const getElementSummary = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
responses.forEach((response) => {
|
responses.forEach((response) => {
|
||||||
const value = response.data[element.id];
|
const value = response.data[question.id];
|
||||||
if (value === "accepted") {
|
if (value === "accepted") {
|
||||||
data.accepted++;
|
data.accepted++;
|
||||||
} else if (response.ttc && response.ttc[element.id] > 0) {
|
} else if (response.ttc && response.ttc[question.id] > 0) {
|
||||||
data.dismissed++;
|
data.dismissed++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -726,8 +687,8 @@ export const getElementSummary = async (
|
|||||||
const totalResponses = data.accepted + data.dismissed;
|
const totalResponses = data.accepted + data.dismissed;
|
||||||
|
|
||||||
summary.push({
|
summary.push({
|
||||||
type: element.type,
|
type: question.type,
|
||||||
element,
|
question,
|
||||||
responseCount: totalResponses,
|
responseCount: totalResponses,
|
||||||
accepted: {
|
accepted: {
|
||||||
count: data.accepted,
|
count: data.accepted,
|
||||||
@@ -746,7 +707,7 @@ export const getElementSummary = async (
|
|||||||
case TSurveyElementTypeEnum.Date: {
|
case TSurveyElementTypeEnum.Date: {
|
||||||
let values: TSurveyElementSummaryDate["samples"] = [];
|
let values: TSurveyElementSummaryDate["samples"] = [];
|
||||||
responses.forEach((response) => {
|
responses.forEach((response) => {
|
||||||
const answer = response.data[element.id];
|
const answer = response.data[question.id];
|
||||||
if (answer && typeof answer === "string") {
|
if (answer && typeof answer === "string") {
|
||||||
values.push({
|
values.push({
|
||||||
id: response.id,
|
id: response.id,
|
||||||
@@ -759,8 +720,8 @@ export const getElementSummary = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
summary.push({
|
summary.push({
|
||||||
type: element.type,
|
type: question.type,
|
||||||
element,
|
question,
|
||||||
responseCount: values.length,
|
responseCount: values.length,
|
||||||
samples: values.slice(0, VALUES_LIMIT),
|
samples: values.slice(0, VALUES_LIMIT),
|
||||||
});
|
});
|
||||||
@@ -771,7 +732,7 @@ export const getElementSummary = async (
|
|||||||
case TSurveyElementTypeEnum.FileUpload: {
|
case TSurveyElementTypeEnum.FileUpload: {
|
||||||
let values: TSurveyElementSummaryFileUpload["files"] = [];
|
let values: TSurveyElementSummaryFileUpload["files"] = [];
|
||||||
responses.forEach((response) => {
|
responses.forEach((response) => {
|
||||||
const answer = response.data[element.id];
|
const answer = response.data[question.id];
|
||||||
if (Array.isArray(answer)) {
|
if (Array.isArray(answer)) {
|
||||||
values.push({
|
values.push({
|
||||||
id: response.id,
|
id: response.id,
|
||||||
@@ -784,8 +745,8 @@ export const getElementSummary = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
summary.push({
|
summary.push({
|
||||||
type: element.type,
|
type: question.type,
|
||||||
element,
|
question,
|
||||||
responseCount: values.length,
|
responseCount: values.length,
|
||||||
files: values.slice(0, VALUES_LIMIT),
|
files: values.slice(0, VALUES_LIMIT),
|
||||||
});
|
});
|
||||||
@@ -800,18 +761,18 @@ export const getElementSummary = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
responses.forEach((response) => {
|
responses.forEach((response) => {
|
||||||
const value = response.data[element.id];
|
const value = response.data[question.id];
|
||||||
if (value === "booked") {
|
if (value === "booked") {
|
||||||
data.booked++;
|
data.booked++;
|
||||||
} else if (response.ttc && response.ttc[element.id] > 0) {
|
} else if (response.ttc && response.ttc[question.id] > 0) {
|
||||||
data.skipped++;
|
data.skipped++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const totalResponses = data.booked + data.skipped;
|
const totalResponses = data.booked + data.skipped;
|
||||||
|
|
||||||
summary.push({
|
summary.push({
|
||||||
type: element.type,
|
type: question.type,
|
||||||
element,
|
question,
|
||||||
responseCount: totalResponses,
|
responseCount: totalResponses,
|
||||||
booked: {
|
booked: {
|
||||||
count: data.booked,
|
count: data.booked,
|
||||||
@@ -827,8 +788,8 @@ export const getElementSummary = async (
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case TSurveyElementTypeEnum.Matrix: {
|
case TSurveyElementTypeEnum.Matrix: {
|
||||||
const rows = element.rows.map((row) => getLocalizedValue(row.label, "default"));
|
const rows = question.rows.map((row) => getLocalizedValue(row.label, "default"));
|
||||||
const columns = element.columns.map((column) => getLocalizedValue(column.label, "default"));
|
const columns = question.columns.map((column) => getLocalizedValue(column.label, "default"));
|
||||||
let totalResponseCount = 0;
|
let totalResponseCount = 0;
|
||||||
|
|
||||||
// Initialize count object
|
// Initialize count object
|
||||||
@@ -841,13 +802,13 @@ export const getElementSummary = async (
|
|||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
responses.forEach((response) => {
|
responses.forEach((response) => {
|
||||||
const selectedResponses = response.data[element.id] as Record<string, string>;
|
const selectedResponses = response.data[question.id] as Record<string, string>;
|
||||||
const responseLanguageCode = getLanguageCode(survey.languages, response.language);
|
const responseLanguageCode = getLanguageCode(survey.languages, response.language);
|
||||||
if (selectedResponses) {
|
if (selectedResponses) {
|
||||||
totalResponseCount++;
|
totalResponseCount++;
|
||||||
element.rows.forEach((row) => {
|
question.rows.forEach((row) => {
|
||||||
const localizedRow = getLocalizedValue(row.label, responseLanguageCode);
|
const localizedRow = getLocalizedValue(row.label, responseLanguageCode);
|
||||||
const colValue = element.columns.find((column) => {
|
const colValue = question.columns.find((column) => {
|
||||||
return (
|
return (
|
||||||
getLocalizedValue(column.label, responseLanguageCode) === selectedResponses[localizedRow]
|
getLocalizedValue(column.label, responseLanguageCode) === selectedResponses[localizedRow]
|
||||||
);
|
);
|
||||||
@@ -880,8 +841,8 @@ export const getElementSummary = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
summary.push({
|
summary.push({
|
||||||
type: element.type,
|
type: question.type,
|
||||||
element,
|
question,
|
||||||
responseCount: totalResponseCount,
|
responseCount: totalResponseCount,
|
||||||
data: matrixSummary,
|
data: matrixSummary,
|
||||||
});
|
});
|
||||||
@@ -890,7 +851,7 @@ export const getElementSummary = async (
|
|||||||
case TSurveyElementTypeEnum.Address: {
|
case TSurveyElementTypeEnum.Address: {
|
||||||
let values: TSurveyElementSummaryAddress["samples"] = [];
|
let values: TSurveyElementSummaryAddress["samples"] = [];
|
||||||
responses.forEach((response) => {
|
responses.forEach((response) => {
|
||||||
const answer = response.data[element.id];
|
const answer = response.data[question.id];
|
||||||
if (Array.isArray(answer) && answer.length > 0) {
|
if (Array.isArray(answer) && answer.length > 0) {
|
||||||
values.push({
|
values.push({
|
||||||
id: response.id,
|
id: response.id,
|
||||||
@@ -904,7 +865,7 @@ export const getElementSummary = async (
|
|||||||
|
|
||||||
summary.push({
|
summary.push({
|
||||||
type: TSurveyElementTypeEnum.Address,
|
type: TSurveyElementTypeEnum.Address,
|
||||||
element,
|
question: question as TSurveyAddressElement,
|
||||||
responseCount: values.length,
|
responseCount: values.length,
|
||||||
samples: values.slice(0, VALUES_LIMIT),
|
samples: values.slice(0, VALUES_LIMIT),
|
||||||
});
|
});
|
||||||
@@ -915,7 +876,7 @@ export const getElementSummary = async (
|
|||||||
case TSurveyElementTypeEnum.ContactInfo: {
|
case TSurveyElementTypeEnum.ContactInfo: {
|
||||||
let values: TSurveyElementSummaryContactInfo["samples"] = [];
|
let values: TSurveyElementSummaryContactInfo["samples"] = [];
|
||||||
responses.forEach((response) => {
|
responses.forEach((response) => {
|
||||||
const answer = response.data[element.id];
|
const answer = response.data[question.id];
|
||||||
if (Array.isArray(answer) && answer.length > 0) {
|
if (Array.isArray(answer) && answer.length > 0) {
|
||||||
values.push({
|
values.push({
|
||||||
id: response.id,
|
id: response.id,
|
||||||
@@ -929,7 +890,7 @@ export const getElementSummary = async (
|
|||||||
|
|
||||||
summary.push({
|
summary.push({
|
||||||
type: TSurveyElementTypeEnum.ContactInfo,
|
type: TSurveyElementTypeEnum.ContactInfo,
|
||||||
element,
|
question: question as TSurveyContactInfoElement,
|
||||||
responseCount: values.length,
|
responseCount: values.length,
|
||||||
samples: values.slice(0, VALUES_LIMIT),
|
samples: values.slice(0, VALUES_LIMIT),
|
||||||
});
|
});
|
||||||
@@ -939,12 +900,11 @@ export const getElementSummary = async (
|
|||||||
}
|
}
|
||||||
case TSurveyElementTypeEnum.Ranking: {
|
case TSurveyElementTypeEnum.Ranking: {
|
||||||
let values: TSurveyElementSummaryRanking["choices"] = [];
|
let values: TSurveyElementSummaryRanking["choices"] = [];
|
||||||
const elementChoices = element.choices.map((choice) => getLocalizedValue(choice.label, "default"));
|
const questionChoices = question.choices.map((choice) => getLocalizedValue(choice.label, "default"));
|
||||||
let totalResponseCount = 0;
|
let totalResponseCount = 0;
|
||||||
const choiceRankSums: Record<string, number> = {};
|
const choiceRankSums: Record<string, number> = {};
|
||||||
const choiceCountMap: Record<string, number> = {};
|
const choiceCountMap: Record<string, number> = {};
|
||||||
|
questionChoices.forEach((choice: string) => {
|
||||||
elementChoices.forEach((choice: string) => {
|
|
||||||
choiceRankSums[choice] = 0;
|
choiceRankSums[choice] = 0;
|
||||||
choiceCountMap[choice] = 0;
|
choiceCountMap[choice] = 0;
|
||||||
});
|
});
|
||||||
@@ -954,14 +914,14 @@ export const getElementSummary = async (
|
|||||||
|
|
||||||
const answer =
|
const answer =
|
||||||
responseLanguageCode === "default"
|
responseLanguageCode === "default"
|
||||||
? response.data[element.id]
|
? response.data[question.id]
|
||||||
: checkForI18n(response.data, element.id, elements, responseLanguageCode);
|
: checkForI18n(response.data, question.id, questions, responseLanguageCode);
|
||||||
|
|
||||||
if (Array.isArray(answer)) {
|
if (Array.isArray(answer)) {
|
||||||
totalResponseCount++;
|
totalResponseCount++;
|
||||||
answer.forEach((value, index) => {
|
answer.forEach((value, index) => {
|
||||||
const ranking = index + 1; // Calculate ranking based on index
|
const ranking = index + 1; // Calculate ranking based on index
|
||||||
if (elementChoices.includes(value)) {
|
if (questionChoices.includes(value)) {
|
||||||
choiceRankSums[value] += ranking;
|
choiceRankSums[value] += ranking;
|
||||||
choiceCountMap[value]++;
|
choiceCountMap[value]++;
|
||||||
}
|
}
|
||||||
@@ -969,7 +929,7 @@ export const getElementSummary = async (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
elementChoices.forEach((choice: string) => {
|
questionChoices.forEach((choice: string) => {
|
||||||
const count = choiceCountMap[choice];
|
const count = choiceCountMap[choice];
|
||||||
const avgRanking = count > 0 ? choiceRankSums[choice] / count : 0;
|
const avgRanking = count > 0 ? choiceRankSums[choice] / count : 0;
|
||||||
values.push({
|
values.push({
|
||||||
@@ -980,8 +940,8 @@ export const getElementSummary = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
summary.push({
|
summary.push({
|
||||||
type: element.type,
|
type: question.type,
|
||||||
element,
|
question,
|
||||||
responseCount: totalResponseCount,
|
responseCount: totalResponseCount,
|
||||||
choices: values,
|
choices: values,
|
||||||
});
|
});
|
||||||
@@ -1028,7 +988,8 @@ export const getSurveySummary = reactCache(
|
|||||||
throw new ResourceNotFoundError("Survey", surveyId);
|
throw new ResourceNotFoundError("Survey", surveyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const elements = getElementsFromBlocks(survey.blocks);
|
// Derive questions once from blocks
|
||||||
|
const questions = getElementsFromBlocks(survey.blocks);
|
||||||
|
|
||||||
const batchSize = 5000;
|
const batchSize = 5000;
|
||||||
const hasFilter = Object.keys(filterCriteria ?? {}).length > 0;
|
const hasFilter = Object.keys(filterCriteria ?? {}).length > 0;
|
||||||
@@ -1060,16 +1021,16 @@ export const getSurveySummary = reactCache(
|
|||||||
getQuotasSummary(surveyId),
|
getQuotasSummary(surveyId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const dropOff = getSurveySummaryDropOff(survey, elements, responses, displayCount);
|
const dropOff = getSurveySummaryDropOff(survey, questions, responses, displayCount);
|
||||||
const [meta, elementSummary] = await Promise.all([
|
const [meta, questionWiseSummary] = await Promise.all([
|
||||||
getSurveySummaryMeta(responses, displayCount, quotas),
|
getSurveySummaryMeta(responses, displayCount, quotas),
|
||||||
getElementSummary(survey, elements, responses, dropOff),
|
getQuestionSummary(survey, questions, responses, dropOff),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
meta,
|
meta,
|
||||||
dropOff,
|
dropOff,
|
||||||
summary: elementSummary,
|
summary: questionWiseSummary,
|
||||||
quotas,
|
quotas,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+10
-9
@@ -1,6 +1,6 @@
|
|||||||
import { TFunction } from "i18next";
|
import { TFunction } from "i18next";
|
||||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||||
|
|
||||||
export const convertFloatToNDecimal = (num: number, N: number = 2) => {
|
export const convertFloatToNDecimal = (num: number, N: number = 2) => {
|
||||||
@@ -12,28 +12,29 @@ export const convertFloatTo2Decimal = (num: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const constructToastMessage = (
|
export const constructToastMessage = (
|
||||||
elementType: TSurveyElementTypeEnum,
|
questionType: TSurveyElementTypeEnum,
|
||||||
filterValue: string,
|
filterValue: string,
|
||||||
survey: TSurvey,
|
survey: TSurvey,
|
||||||
elementId: string,
|
questionId: TSurveyQuestionId,
|
||||||
t: TFunction,
|
t: TFunction,
|
||||||
filterComboBoxValue?: string | string[]
|
filterComboBoxValue?: string | string[]
|
||||||
) => {
|
) => {
|
||||||
const elements = getElementsFromBlocks(survey.blocks);
|
// Derive questions from blocks
|
||||||
const elementIdx = elements.findIndex((element) => element.id === elementId);
|
const questions = getElementsFromBlocks(survey.blocks);
|
||||||
if (elementType === "matrix") {
|
const questionIdx = questions.findIndex((question) => question.id === questionId);
|
||||||
|
if (questionType === "matrix") {
|
||||||
return t("environments.surveys.summary.added_filter_for_responses_where_answer_to_question", {
|
return t("environments.surveys.summary.added_filter_for_responses_where_answer_to_question", {
|
||||||
questionIdx: elementIdx + 1,
|
questionIdx: questionIdx + 1,
|
||||||
filterComboBoxValue: filterComboBoxValue?.toString() ?? "",
|
filterComboBoxValue: filterComboBoxValue?.toString() ?? "",
|
||||||
filterValue,
|
filterValue,
|
||||||
});
|
});
|
||||||
} else if (filterComboBoxValue === undefined) {
|
} else if (filterComboBoxValue === undefined) {
|
||||||
return t("environments.surveys.summary.added_filter_for_responses_where_answer_to_question_is_skipped", {
|
return t("environments.surveys.summary.added_filter_for_responses_where_answer_to_question_is_skipped", {
|
||||||
questionIdx: elementIdx + 1,
|
questionIdx: questionIdx + 1,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
return t("environments.surveys.summary.added_filter_for_responses_where_answer_to_question", {
|
return t("environments.surveys.summary.added_filter_for_responses_where_answer_to_question", {
|
||||||
questionIdx: elementIdx + 1,
|
questionIdx: questionIdx + 1,
|
||||||
filterComboBoxValue: Array.isArray(filterComboBoxValue)
|
filterComboBoxValue: Array.isArray(filterComboBoxValue)
|
||||||
? filterComboBoxValue.join(",")
|
? filterComboBoxValue.join(",")
|
||||||
: filterComboBoxValue,
|
: filterComboBoxValue,
|
||||||
|
|||||||
+1
@@ -70,6 +70,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
|||||||
user={user}
|
user={user}
|
||||||
publicDomain={publicDomain}
|
publicDomain={publicDomain}
|
||||||
responseCount={initialSurveySummary?.meta.totalResponses ?? 0}
|
responseCount={initialSurveySummary?.meta.totalResponses ?? 0}
|
||||||
|
displayCount={initialSurveySummary?.meta.displayCount ?? 0}
|
||||||
segments={segments}
|
segments={segments}
|
||||||
isContactsEnabled={isContactsEnabled}
|
isContactsEnabled={isContactsEnabled}
|
||||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||||
|
|||||||
+3
-3
@@ -25,7 +25,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
|||||||
import {
|
import {
|
||||||
DateRange,
|
DateRange,
|
||||||
useResponseFilter,
|
useResponseFilter,
|
||||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||||
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
||||||
import { downloadResponsesFile } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/utils";
|
import { downloadResponsesFile } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/utils";
|
||||||
import { getFormattedFilters, getTodayDate } from "@/app/lib/surveys/surveys";
|
import { getFormattedFilters, getTodayDate } from "@/app/lib/surveys/surveys";
|
||||||
@@ -164,12 +164,12 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
|||||||
|
|
||||||
const datePickerRef = useRef<HTMLDivElement>(null);
|
const datePickerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const extractMetadataKeys = useCallback((obj, parentKey = "") => {
|
const extracMetadataKeys = useCallback((obj, parentKey = "") => {
|
||||||
let keys: string[] = [];
|
let keys: string[] = [];
|
||||||
|
|
||||||
for (let key in obj) {
|
for (let key in obj) {
|
||||||
if (typeof obj[key] === "object" && obj[key] !== null) {
|
if (typeof obj[key] === "object" && obj[key] !== null) {
|
||||||
keys = keys.concat(extractMetadataKeys(obj[key], parentKey + key + " - "));
|
keys = keys.concat(extracMetadataKeys(obj[key], parentKey + key + " - "));
|
||||||
} else {
|
} else {
|
||||||
keys.push(parentKey + key);
|
keys.push(parentKey + key);
|
||||||
}
|
}
|
||||||
|
|||||||
+67
-96
@@ -4,9 +4,8 @@ import clsx from "clsx";
|
|||||||
import { ChevronDown, ChevronUp, X } from "lucide-react";
|
import { ChevronDown, ChevronUp, X } from "lucide-react";
|
||||||
import { useMemo, useRef, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TI18nString } from "@formbricks/types/i18n";
|
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
||||||
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ElementsComboBox";
|
|
||||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
|
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
@@ -26,52 +25,20 @@ import {
|
|||||||
} from "@/modules/ui/components/dropdown-menu";
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
import { Input } from "@/modules/ui/components/input";
|
import { Input } from "@/modules/ui/components/input";
|
||||||
|
|
||||||
const DEFAULT_LANGUAGE_CODE = "default";
|
type QuestionFilterComboBoxProps = {
|
||||||
|
filterOptions: string[] | undefined;
|
||||||
// Helper to get localized option value
|
filterComboBoxOptions: string[] | undefined;
|
||||||
const getOptionValue = (option: string | TI18nString): string => {
|
|
||||||
return typeof option === "object" && option !== null
|
|
||||||
? getLocalizedValue(option, DEFAULT_LANGUAGE_CODE)
|
|
||||||
: option;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ElementFilterComboBoxProps = {
|
|
||||||
filterOptions: (string | TI18nString)[] | undefined;
|
|
||||||
filterComboBoxOptions: (string | TI18nString)[] | undefined;
|
|
||||||
filterValue: string | undefined;
|
filterValue: string | undefined;
|
||||||
filterComboBoxValue: string | string[] | undefined;
|
filterComboBoxValue: string | string[] | undefined;
|
||||||
onChangeFilterValue: (o: string) => void;
|
onChangeFilterValue: (o: string) => void;
|
||||||
onChangeFilterComboBoxValue: (o: string | string[]) => void;
|
onChangeFilterComboBoxValue: (o: string | string[]) => void;
|
||||||
type?: TSurveyElementTypeEnum | Omit<OptionsType, OptionsType.ELEMENTS>;
|
type?: TSurveyQuestionTypeEnum | Omit<OptionsType, OptionsType.QUESTIONS>;
|
||||||
handleRemoveMultiSelect: (value: string[]) => void;
|
handleRemoveMultiSelect: (value: string[]) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
fieldId?: string;
|
fieldId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to check if multiple selection is allowed
|
export const QuestionFilterComboBox = ({
|
||||||
const checkIsMultiple = (
|
|
||||||
type: TSurveyElementTypeEnum | Omit<OptionsType, OptionsType.ELEMENTS> | undefined,
|
|
||||||
filterValue: string | undefined
|
|
||||||
): boolean => {
|
|
||||||
const isMultiSelectType =
|
|
||||||
type === TSurveyElementTypeEnum.MultipleChoiceMulti ||
|
|
||||||
type === TSurveyElementTypeEnum.MultipleChoiceSingle ||
|
|
||||||
type === TSurveyElementTypeEnum.PictureSelection;
|
|
||||||
const isNPSIncludesEither = type === TSurveyElementTypeEnum.NPS && filterValue === "Includes either";
|
|
||||||
return isMultiSelectType || isNPSIncludesEither;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to check if combo box should be disabled
|
|
||||||
const checkIsDisabledComboBox = (
|
|
||||||
type: TSurveyElementTypeEnum | Omit<OptionsType, OptionsType.ELEMENTS> | undefined,
|
|
||||||
filterValue: string | undefined
|
|
||||||
): boolean => {
|
|
||||||
const isNPSOrRating = type === TSurveyElementTypeEnum.NPS || type === TSurveyElementTypeEnum.Rating;
|
|
||||||
const isSubmittedOrSkipped = filterValue === "Submitted" || filterValue === "Skipped";
|
|
||||||
return isNPSOrRating && isSubmittedOrSkipped;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ElementFilterComboBox = ({
|
|
||||||
filterComboBoxOptions,
|
filterComboBoxOptions,
|
||||||
filterComboBoxValue,
|
filterComboBoxValue,
|
||||||
filterOptions,
|
filterOptions,
|
||||||
@@ -82,7 +49,7 @@ export const ElementFilterComboBox = ({
|
|||||||
handleRemoveMultiSelect,
|
handleRemoveMultiSelect,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
fieldId,
|
fieldId,
|
||||||
}: ElementFilterComboBoxProps) => {
|
}: QuestionFilterComboBoxProps) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const commandRef = useRef(null);
|
const commandRef = useRef(null);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
@@ -90,19 +57,32 @@ export const ElementFilterComboBox = ({
|
|||||||
|
|
||||||
useClickOutside(commandRef, () => setOpen(false));
|
useClickOutside(commandRef, () => setOpen(false));
|
||||||
|
|
||||||
const isMultiple = checkIsMultiple(type, filterValue);
|
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
|
// Filter out already selected options for multi-select
|
||||||
const options = useMemo(() => {
|
const options = useMemo(() => {
|
||||||
if (!isMultiple) return filterComboBoxOptions;
|
if (!isMultiple) return filterComboBoxOptions;
|
||||||
|
|
||||||
return filterComboBoxOptions?.filter((o) => {
|
return filterComboBoxOptions?.filter((o) => {
|
||||||
const optionValue = getOptionValue(o);
|
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||||
return !filterComboBoxValue?.includes(optionValue);
|
return !filterComboBoxValue?.includes(optionValue);
|
||||||
});
|
});
|
||||||
}, [isMultiple, filterComboBoxOptions, filterComboBoxValue]);
|
}, [isMultiple, filterComboBoxOptions, filterComboBoxValue, defaultLanguageCode]);
|
||||||
|
|
||||||
const isDisabledComboBox = checkIsDisabledComboBox(type, filterValue);
|
// 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 text input field (URL meta field)
|
// Check if this is a text input field (URL meta field)
|
||||||
const isTextInputField = type === OptionsType.META && fieldId === "url";
|
const isTextInputField = type === OptionsType.META && fieldId === "url";
|
||||||
@@ -111,14 +91,14 @@ export const ElementFilterComboBox = ({
|
|||||||
const filteredOptions = useMemo(
|
const filteredOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
options?.filter((o) => {
|
options?.filter((o) => {
|
||||||
const optionValue = getOptionValue(o);
|
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||||
return optionValue.toLowerCase().includes(searchQuery.toLowerCase());
|
return optionValue.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
}),
|
}),
|
||||||
[options, searchQuery]
|
[options, searchQuery, defaultLanguageCode]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCommandItemSelect = (o: string | TI18nString) => {
|
const handleCommandItemSelect = (o: string) => {
|
||||||
const value = getOptionValue(o);
|
const value = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||||
|
|
||||||
if (isMultiple) {
|
if (isMultiple) {
|
||||||
const newValue = Array.isArray(filterComboBoxValue) ? [...filterComboBoxValue, value] : [value];
|
const newValue = Array.isArray(filterComboBoxValue) ? [...filterComboBoxValue, value] : [value];
|
||||||
@@ -131,56 +111,12 @@ export const ElementFilterComboBox = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isComboBoxDisabled = disabled || isDisabledComboBox || !filterValue;
|
const isComboBoxDisabled = disabled || isDisabledComboBox || !filterValue;
|
||||||
const ChevronIcon = open ? ChevronUp : ChevronDown;
|
|
||||||
|
|
||||||
// Render filter options dropdown
|
|
||||||
const renderFilterOptionsDropdown = () => {
|
|
||||||
if (!filterOptions || filterOptions.length <= 1) {
|
|
||||||
return (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<DropdownMenu
|
|
||||||
onOpenChange={(value) => {
|
|
||||||
if (value) setOpen(false);
|
|
||||||
}}>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
disabled={disabled}
|
|
||||||
className={clsx(
|
|
||||||
"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"
|
|
||||||
)}>
|
|
||||||
{filterValue ? (
|
|
||||||
<p className="max-w-[50px] truncate sm:max-w-[80px]">{filterValue}</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-slate-400">{t("common.select")}...</p>
|
|
||||||
)}
|
|
||||||
{filterOptions.length > 1 && <ChevronIcon className="h-4 w-4 flex-shrink-0 opacity-50" />}
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent className="bg-white">
|
|
||||||
{filterOptions.map((o, index) => {
|
|
||||||
const optionValue = getOptionValue(o);
|
|
||||||
return (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={`${optionValue}-${index}`}
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() => onChangeFilterValue(optionValue)}>
|
|
||||||
{optionValue}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenDropdown = () => {
|
const handleOpenDropdown = () => {
|
||||||
if (isComboBoxDisabled) return;
|
if (isComboBoxDisabled) return;
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
};
|
};
|
||||||
|
const ChevronIcon = open ? ChevronUp : ChevronDown;
|
||||||
|
|
||||||
// Helper to filter out a specific value from the array
|
// Helper to filter out a specific value from the array
|
||||||
const getFilteredValues = (valueToRemove: string): string[] => {
|
const getFilteredValues = (valueToRemove: string): string[] => {
|
||||||
@@ -239,7 +175,42 @@ export const ElementFilterComboBox = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex h-fit w-full flex-row rounded-md border border-slate-300 hover:border-slate-400">
|
<div className="inline-flex h-fit w-full flex-row rounded-md border border-slate-300 hover:border-slate-400">
|
||||||
{renderFilterOptionsDropdown()}
|
{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) => {
|
||||||
|
if (value) setOpen(false);
|
||||||
|
}}>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
disabled={disabled}
|
||||||
|
className={clsx(
|
||||||
|
"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"
|
||||||
|
)}>
|
||||||
|
{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">
|
||||||
|
{filterOptions?.map((o, index) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={`${o}-${index}`}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => onChangeFilterValue(o)}>
|
||||||
|
{o}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
|
||||||
{isTextInputField ? (
|
{isTextInputField ? (
|
||||||
<Input
|
<Input
|
||||||
@@ -298,7 +269,7 @@ export const ElementFilterComboBox = ({
|
|||||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{filteredOptions?.map((o) => {
|
{filteredOptions?.map((o) => {
|
||||||
const optionValue = getOptionValue(o);
|
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||||
return (
|
return (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={optionValue}
|
key={optionValue}
|
||||||
+17
-17
@@ -44,7 +44,7 @@ import {
|
|||||||
import { NetPromoterScoreIcon } from "@/modules/ui/components/icons";
|
import { NetPromoterScoreIcon } from "@/modules/ui/components/icons";
|
||||||
|
|
||||||
export enum OptionsType {
|
export enum OptionsType {
|
||||||
ELEMENTS = "Elements",
|
QUESTIONS = "Questions",
|
||||||
TAGS = "Tags",
|
TAGS = "Tags",
|
||||||
ATTRIBUTES = "Attributes",
|
ATTRIBUTES = "Attributes",
|
||||||
OTHERS = "Other Filters",
|
OTHERS = "Other Filters",
|
||||||
@@ -53,25 +53,25 @@ export enum OptionsType {
|
|||||||
QUOTAS = "Quotas",
|
QUOTAS = "Quotas",
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ElementOption = {
|
export type QuestionOption = {
|
||||||
label: string;
|
label: string;
|
||||||
elementType?: TSurveyElementTypeEnum;
|
questionType?: TSurveyElementTypeEnum;
|
||||||
type: OptionsType;
|
type: OptionsType;
|
||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
export type ElementOptions = {
|
export type QuestionOptions = {
|
||||||
header: OptionsType;
|
header: OptionsType;
|
||||||
option: ElementOption[];
|
option: QuestionOption[];
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ElementComboBoxProps {
|
interface QuestionComboBoxProps {
|
||||||
options: ElementOptions[];
|
options: QuestionOptions[];
|
||||||
selected: Partial<ElementOption>;
|
selected: Partial<QuestionOption>;
|
||||||
onChangeValue: (option: ElementOption) => void;
|
onChangeValue: (option: QuestionOption) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const elementIcons = {
|
const questionIcons = {
|
||||||
// elements
|
// questions
|
||||||
[TSurveyElementTypeEnum.OpenText]: MessageSquareTextIcon,
|
[TSurveyElementTypeEnum.OpenText]: MessageSquareTextIcon,
|
||||||
[TSurveyElementTypeEnum.Rating]: StarIcon,
|
[TSurveyElementTypeEnum.Rating]: StarIcon,
|
||||||
[TSurveyElementTypeEnum.CTA]: MousePointerClickIcon,
|
[TSurveyElementTypeEnum.CTA]: MousePointerClickIcon,
|
||||||
@@ -111,14 +111,14 @@ const elementIcons = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getIcon = (type: string) => {
|
const getIcon = (type: string) => {
|
||||||
const IconComponent = elementIcons[type];
|
const IconComponent = questionIcons[type];
|
||||||
return IconComponent ? <IconComponent className="h-5 w-5" strokeWidth={1.5} /> : null;
|
return IconComponent ? <IconComponent className="h-5 w-5" strokeWidth={1.5} /> : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getIconBackground = (type: OptionsType | string): string => {
|
const getIconBackground = (type: OptionsType | string): string => {
|
||||||
const backgroundMap: Record<string, string> = {
|
const backgroundMap: Record<string, string> = {
|
||||||
[OptionsType.ATTRIBUTES]: "bg-indigo-500",
|
[OptionsType.ATTRIBUTES]: "bg-indigo-500",
|
||||||
[OptionsType.ELEMENTS]: "bg-brand-dark",
|
[OptionsType.QUESTIONS]: "bg-brand-dark",
|
||||||
[OptionsType.TAGS]: "bg-indigo-500",
|
[OptionsType.TAGS]: "bg-indigo-500",
|
||||||
[OptionsType.QUOTAS]: "bg-slate-500",
|
[OptionsType.QUOTAS]: "bg-slate-500",
|
||||||
};
|
};
|
||||||
@@ -130,10 +130,10 @@ const getLabelClassName = (type: OptionsType | string, label?: string): string =
|
|||||||
return label === "os" || label === "url" ? "uppercase" : "capitalize";
|
return label === "os" || label === "url" ? "uppercase" : "capitalize";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SelectedCommandItem = ({ label, elementType, type }: Partial<ElementOption>) => {
|
export const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOption>) => {
|
||||||
const getDisplayIcon = () => {
|
const getDisplayIcon = () => {
|
||||||
if (!type) return null;
|
if (!type) return null;
|
||||||
if (type === OptionsType.ELEMENTS && elementType) return getIcon(elementType);
|
if (type === OptionsType.QUESTIONS && questionType) return getIcon(questionType);
|
||||||
if (type === OptionsType.ATTRIBUTES) return getIcon(OptionsType.ATTRIBUTES);
|
if (type === OptionsType.ATTRIBUTES) return getIcon(OptionsType.ATTRIBUTES);
|
||||||
if (type === OptionsType.HIDDEN_FIELDS) return getIcon(OptionsType.HIDDEN_FIELDS);
|
if (type === OptionsType.HIDDEN_FIELDS) return getIcon(OptionsType.HIDDEN_FIELDS);
|
||||||
if ([OptionsType.META, OptionsType.OTHERS].includes(type) && label) return getIcon(label);
|
if ([OptionsType.META, OptionsType.OTHERS].includes(type) && label) return getIcon(label);
|
||||||
@@ -158,7 +158,7 @@ export const SelectedCommandItem = ({ label, elementType, type }: Partial<Elemen
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementComboBoxProps) => {
|
export const QuestionsComboBox = ({ options, selected, onChangeValue }: QuestionComboBoxProps) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const commandRef = useRef(null);
|
const commandRef = useRef(null);
|
||||||
@@ -209,7 +209,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
|
|||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="animate-in absolute top-full z-10 mt-1 w-full overflow-auto rounded-md shadow-md outline-none">
|
<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]">
|
<CommandList>
|
||||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||||
{options?.map((data) => (
|
{options?.map((data) => (
|
||||||
<Fragment key={data.header}>
|
<Fragment key={data.header}>
|
||||||
+45
-64
@@ -4,18 +4,16 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
|
|||||||
import { ChevronDown, ChevronUp, Plus, TrashIcon } from "lucide-react";
|
import { ChevronDown, ChevronUp, Plus, TrashIcon } from "lucide-react";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TI18nString } from "@formbricks/types/i18n";
|
|
||||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import {
|
import {
|
||||||
SelectedFilterValue,
|
SelectedFilterValue,
|
||||||
TResponseStatus,
|
TResponseStatus,
|
||||||
useResponseFilter,
|
useResponseFilter,
|
||||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||||
import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
||||||
import { ElementFilterComboBox } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ElementFilterComboBox";
|
import { QuestionFilterComboBox } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox";
|
||||||
import { generateElementAndFilterOptions } from "@/app/lib/surveys/surveys";
|
import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys";
|
||||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
||||||
import {
|
import {
|
||||||
@@ -25,20 +23,12 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/modules/ui/components/select";
|
} from "@/modules/ui/components/select";
|
||||||
import { ElementOption, ElementsComboBox, OptionsType } from "./ElementsComboBox";
|
import { OptionsType, QuestionOption, QuestionsComboBox } from "./QuestionsComboBox";
|
||||||
|
|
||||||
export type ElementFilterOptions = {
|
export type QuestionFilterOptions = {
|
||||||
type:
|
type: TSurveyElementTypeEnum | "Attributes" | "Tags" | "Languages" | "Quotas";
|
||||||
| TSurveyElementTypeEnum
|
filterOptions: string[];
|
||||||
| "Attributes"
|
filterComboBoxOptions: string[];
|
||||||
| "Tags"
|
|
||||||
| "Languages"
|
|
||||||
| "Quotas"
|
|
||||||
| "Hidden Fields"
|
|
||||||
| "Meta"
|
|
||||||
| OptionsType.OTHERS;
|
|
||||||
filterOptions: (string | TI18nString)[];
|
|
||||||
filterComboBoxOptions: (string | TI18nString)[];
|
|
||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -80,12 +70,6 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
|||||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||||
const [filterValue, setFilterValue] = useState<SelectedFilterValue>(selectedFilter);
|
const [filterValue, setFilterValue] = useState<SelectedFilterValue>(selectedFilter);
|
||||||
|
|
||||||
const getDefaultFilterValue = (option?: ElementFilterOptions): string | undefined => {
|
|
||||||
if (!option || option.filterOptions.length === 0) return undefined;
|
|
||||||
const firstOption = option.filterOptions[0];
|
|
||||||
return typeof firstOption === "object" ? getLocalizedValue(firstOption, "default") : firstOption;
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Fetch the initial data for the filter and load it into the state
|
// Fetch the initial data for the filter and load it into the state
|
||||||
const handleInitialData = async () => {
|
const handleInitialData = async () => {
|
||||||
@@ -95,7 +79,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
|||||||
if (!surveyFilterData?.data) return;
|
if (!surveyFilterData?.data) return;
|
||||||
|
|
||||||
const { attributes, meta, environmentTags, hiddenFields, quotas } = surveyFilterData.data;
|
const { attributes, meta, environmentTags, hiddenFields, quotas } = surveyFilterData.data;
|
||||||
const { elementFilterOptions, elementOptions } = generateElementAndFilterOptions(
|
const { questionFilterOptions, questionOptions } = generateQuestionAndFilterOptions(
|
||||||
survey,
|
survey,
|
||||||
environmentTags,
|
environmentTags,
|
||||||
attributes,
|
attributes,
|
||||||
@@ -103,35 +87,34 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
|||||||
hiddenFields,
|
hiddenFields,
|
||||||
quotas
|
quotas
|
||||||
);
|
);
|
||||||
setSelectedOptions({ elementFilterOptions: elementFilterOptions, elementOptions: elementOptions });
|
setSelectedOptions({ questionFilterOptions, questionOptions });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
handleInitialData();
|
handleInitialData();
|
||||||
}, [isOpen, setSelectedOptions, survey]);
|
}, [isOpen, setSelectedOptions, survey]);
|
||||||
|
|
||||||
const handleOnChangeElementComboBoxValue = (value: ElementOption, index: number) => {
|
const handleOnChangeQuestionComboBoxValue = (value: QuestionOption, index: number) => {
|
||||||
const matchingFilterOption = selectedOptions.elementFilterOptions.find(
|
if (filterValue.filter[index].questionType) {
|
||||||
(q) => q.type === value.type || q.type === value.elementType
|
|
||||||
);
|
|
||||||
const defaultFilterValue = getDefaultFilterValue(matchingFilterOption);
|
|
||||||
|
|
||||||
if (filterValue.filter[index].elementType) {
|
|
||||||
// Create a new array and copy existing values from SelectedFilter
|
// Create a new array and copy existing values from SelectedFilter
|
||||||
filterValue.filter[index] = {
|
filterValue.filter[index] = {
|
||||||
elementType: value,
|
questionType: value,
|
||||||
filterType: {
|
filterType: {
|
||||||
filterComboBoxValue: undefined,
|
filterComboBoxValue: undefined,
|
||||||
filterValue: defaultFilterValue,
|
filterValue: selectedOptions.questionFilterOptions.find(
|
||||||
|
(q) => q.type === value.type || q.type === value.questionType
|
||||||
|
)?.filterOptions[0],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
setFilterValue({ filter: [...filterValue.filter], responseStatus: filterValue.responseStatus });
|
setFilterValue({ filter: [...filterValue.filter], responseStatus: filterValue.responseStatus });
|
||||||
} else {
|
} else {
|
||||||
// Update the existing value at the specified index
|
// Update the existing value at the specified index
|
||||||
filterValue.filter[index].elementType = value;
|
filterValue.filter[index].questionType = value;
|
||||||
filterValue.filter[index].filterType = {
|
filterValue.filter[index].filterType = {
|
||||||
filterComboBoxValue: undefined,
|
filterComboBoxValue: undefined,
|
||||||
filterValue: defaultFilterValue,
|
filterValue: selectedOptions.questionFilterOptions.find(
|
||||||
|
(q) => q.type === value.type || q.type === value.questionType
|
||||||
|
)?.filterOptions[0],
|
||||||
};
|
};
|
||||||
setFilterValue({ ...filterValue });
|
setFilterValue({ ...filterValue });
|
||||||
}
|
}
|
||||||
@@ -141,8 +124,8 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
|||||||
const clearItem = () => {
|
const clearItem = () => {
|
||||||
setFilterValue({
|
setFilterValue({
|
||||||
filter: filterValue.filter.filter((s) => {
|
filter: filterValue.filter.filter((s) => {
|
||||||
// keep the filter if elementType is selected and filterComboBoxValue is selected
|
// keep the filter if questionType is selected and filterComboBoxValue is selected
|
||||||
return s.elementType.hasOwnProperty("label") && s.filterType.filterComboBoxValue?.length;
|
return s.questionType.hasOwnProperty("label") && s.filterType.filterComboBoxValue?.length;
|
||||||
}),
|
}),
|
||||||
responseStatus: filterValue.responseStatus,
|
responseStatus: filterValue.responseStatus,
|
||||||
});
|
});
|
||||||
@@ -162,7 +145,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
|||||||
filter: [
|
filter: [
|
||||||
...filterValue.filter,
|
...filterValue.filter,
|
||||||
{
|
{
|
||||||
elementType: {},
|
questionType: {},
|
||||||
filterType: { filterComboBoxValue: undefined, filterValue: undefined },
|
filterType: { filterComboBoxValue: undefined, filterValue: undefined },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -214,10 +197,10 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// remove the filter which has already been selected
|
// remove the filter which has already been selected
|
||||||
const elementComboBoxOptions = selectedOptions.elementOptions.map((q) => {
|
const questionComboBoxOptions = selectedOptions.questionOptions.map((q) => {
|
||||||
return {
|
return {
|
||||||
...q,
|
...q,
|
||||||
option: q.option.filter((o) => !filterValue.filter.some((f) => f?.elementType?.id === o?.id)),
|
option: q.option.filter((o) => !filterValue.filter.some((f) => f?.questionType?.id === o?.id)),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -235,13 +218,11 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
|||||||
setFilterValue(selectedFilter);
|
setFilterValue(selectedFilter);
|
||||||
}, [selectedFilter]);
|
}, [selectedFilter]);
|
||||||
|
|
||||||
const activeFilterCount = filterValue.filter.length + (filterValue.responseStatus === "all" ? 0 : 1);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={isOpen} onOpenChange={handleOpenChange}>
|
<Popover open={isOpen} onOpenChange={handleOpenChange}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<PopoverTriggerButton isOpen={isOpen}>
|
<PopoverTriggerButton isOpen={isOpen}>
|
||||||
Filter <b>{activeFilterCount > 0 && `(${activeFilterCount})`}</b>
|
Filter <b>{filterValue.filter.length > 0 && `(${filterValue.filter.length})`}</b>
|
||||||
</PopoverTriggerButton>
|
</PopoverTriggerButton>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
@@ -280,41 +261,41 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
|||||||
<div className="flex w-full flex-wrap gap-3 md:flex-nowrap">
|
<div className="flex w-full flex-wrap gap-3 md:flex-nowrap">
|
||||||
<div
|
<div
|
||||||
className="grid w-full grid-cols-1 items-center gap-3 md:grid-cols-2"
|
className="grid w-full grid-cols-1 items-center gap-3 md:grid-cols-2"
|
||||||
key={`${s.elementType.id}-${i}-${s.elementType.label}`}>
|
key={`${s.questionType.id}-${i}-${s.questionType.label}`}>
|
||||||
<ElementsComboBox
|
<QuestionsComboBox
|
||||||
key={`${s.elementType.label}-${i}-${s.elementType.id}`}
|
key={`${s.questionType.label}-${i}-${s.questionType.id}`}
|
||||||
options={elementComboBoxOptions}
|
options={questionComboBoxOptions}
|
||||||
selected={s.elementType}
|
selected={s.questionType}
|
||||||
onChangeValue={(value) => handleOnChangeElementComboBoxValue(value, i)}
|
onChangeValue={(value) => handleOnChangeQuestionComboBoxValue(value, i)}
|
||||||
/>
|
/>
|
||||||
<ElementFilterComboBox
|
<QuestionFilterComboBox
|
||||||
key={`${s.elementType.id}-${i}`}
|
key={`${s.questionType.id}-${i}`}
|
||||||
filterOptions={
|
filterOptions={
|
||||||
selectedOptions.elementFilterOptions.find(
|
selectedOptions.questionFilterOptions.find(
|
||||||
(q) =>
|
(q) =>
|
||||||
(q.type === s.elementType.elementType || q.type === s.elementType.type) &&
|
(q.type === s.questionType.questionType || q.type === s.questionType.type) &&
|
||||||
q.id === s.elementType.id
|
q.id === s.questionType.id
|
||||||
)?.filterOptions
|
)?.filterOptions
|
||||||
}
|
}
|
||||||
filterComboBoxOptions={
|
filterComboBoxOptions={
|
||||||
selectedOptions.elementFilterOptions.find(
|
selectedOptions.questionFilterOptions.find(
|
||||||
(q) =>
|
(q) =>
|
||||||
(q.type === s.elementType.elementType || q.type === s.elementType.type) &&
|
(q.type === s.questionType.questionType || q.type === s.questionType.type) &&
|
||||||
q.id === s.elementType.id
|
q.id === s.questionType.id
|
||||||
)?.filterComboBoxOptions
|
)?.filterComboBoxOptions
|
||||||
}
|
}
|
||||||
filterValue={filterValue.filter[i].filterType.filterValue}
|
filterValue={filterValue.filter[i].filterType.filterValue}
|
||||||
filterComboBoxValue={filterValue.filter[i].filterType.filterComboBoxValue}
|
filterComboBoxValue={filterValue.filter[i].filterType.filterComboBoxValue}
|
||||||
type={
|
type={
|
||||||
s?.elementType?.type === OptionsType.ELEMENTS
|
s?.questionType?.type === OptionsType.QUESTIONS
|
||||||
? s?.elementType?.elementType
|
? s?.questionType?.questionType
|
||||||
: s?.elementType?.type
|
: s?.questionType?.type
|
||||||
}
|
}
|
||||||
fieldId={s?.elementType?.id}
|
fieldId={s?.questionType?.id}
|
||||||
handleRemoveMultiSelect={(value) => handleRemoveMultiSelect(value, i)}
|
handleRemoveMultiSelect={(value) => handleRemoveMultiSelect(value, i)}
|
||||||
onChangeFilterComboBoxValue={(value) => handleOnChangeFilterComboBoxValue(value, i)}
|
onChangeFilterComboBoxValue={(value) => handleOnChangeFilterComboBoxValue(value, i)}
|
||||||
onChangeFilterValue={(value) => handleOnChangeFilterValue(value, i)}
|
onChangeFilterValue={(value) => handleOnChangeFilterValue(value, i)}
|
||||||
disabled={!s?.elementType?.label}
|
disabled={!s?.questionType?.label}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full items-center justify-end gap-1 md:w-auto">
|
<div className="flex w-full items-center justify-end gap-1 md:w-auto">
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
|
import { Suspense } from "react";
|
||||||
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
|
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 { getUser } from "@/lib/user/service";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
import { ClientLogout } from "@/modules/ui/components/client-logout";
|
import { ClientLogout } from "@/modules/ui/components/client-logout";
|
||||||
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
|
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";
|
import { ToasterClient } from "@/modules/ui/components/toaster-client";
|
||||||
|
|
||||||
const AppLayout = async ({ children }) => {
|
const AppLayout = async ({ children }) => {
|
||||||
@@ -18,9 +21,20 @@ const AppLayout = async ({ children }) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NoMobileOverlay />
|
<NoMobileOverlay />
|
||||||
<IntercomClientWrapper user={user} />
|
<Suspense>
|
||||||
<ToasterClient />
|
<PostHogPageview
|
||||||
{children}
|
posthogEnabled={IS_POSTHOG_CONFIGURED}
|
||||||
|
postHogApiHost={POSTHOG_API_HOST}
|
||||||
|
postHogApiKey={POSTHOG_API_KEY}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
<PHProvider posthogEnabled={IS_POSTHOG_CONFIGURED}>
|
||||||
|
<>
|
||||||
|
<IntercomClientWrapper user={user} />
|
||||||
|
<ToasterClient />
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
</PHProvider>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ const mockAirtableIntegration: TIntegrationAirtable = {
|
|||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
surveyId: surveyId,
|
surveyId: surveyId,
|
||||||
elementIds: [questionId1, questionId2],
|
questionIds: [questionId1, questionId2],
|
||||||
baseId: "base1",
|
baseId: "base1",
|
||||||
tableId: "table1",
|
tableId: "table1",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@@ -196,8 +196,8 @@ const mockGoogleSheetsIntegration: TIntegrationGoogleSheets = {
|
|||||||
surveyId: surveyId,
|
surveyId: surveyId,
|
||||||
spreadsheetId: "sheet1",
|
spreadsheetId: "sheet1",
|
||||||
spreadsheetName: "Sheet Name",
|
spreadsheetName: "Sheet Name",
|
||||||
elementIds: [questionId1],
|
questionIds: [questionId1],
|
||||||
elements: "What is Q1?",
|
questions: "What is Q1?",
|
||||||
createdAt: new Date("2024-01-01T00:00:00.000Z"),
|
createdAt: new Date("2024-01-01T00:00:00.000Z"),
|
||||||
includeHiddenFields: false,
|
includeHiddenFields: false,
|
||||||
includeMetadata: false,
|
includeMetadata: false,
|
||||||
@@ -219,8 +219,8 @@ const mockSlackIntegration: TIntegrationSlack = {
|
|||||||
surveyId: surveyId,
|
surveyId: surveyId,
|
||||||
channelId: "channel1",
|
channelId: "channel1",
|
||||||
channelName: "Channel 1",
|
channelName: "Channel 1",
|
||||||
elementIds: [questionId1, questionId2, questionId3],
|
questionIds: [questionId1, questionId2, questionId3],
|
||||||
elements: "Q1, Q2, Q3",
|
questions: "Q1, Q2, Q3",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
includeHiddenFields: true,
|
includeHiddenFields: true,
|
||||||
includeMetadata: true,
|
includeMetadata: true,
|
||||||
@@ -249,19 +249,19 @@ const mockNotionIntegration: TIntegrationNotion = {
|
|||||||
databaseName: "DB 1",
|
databaseName: "DB 1",
|
||||||
mapping: [
|
mapping: [
|
||||||
{
|
{
|
||||||
element: { id: questionId1, name: "Question 1", type: TSurveyQuestionTypeEnum.OpenText },
|
question: { id: questionId1, name: "Question 1", type: TSurveyQuestionTypeEnum.OpenText },
|
||||||
column: { id: "col1", name: "Column 1", type: "rich_text" },
|
column: { id: "col1", name: "Column 1", type: "rich_text" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
element: { id: questionId3, name: "Question 3", type: TSurveyQuestionTypeEnum.PictureSelection },
|
question: { id: questionId3, name: "Question 3", type: TSurveyQuestionTypeEnum.PictureSelection },
|
||||||
column: { id: "col3", name: "Column 3", type: "url" },
|
column: { id: "col3", name: "Column 3", type: "url" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
element: { id: "metadata", name: "Metadata", type: "metadata" },
|
question: { id: "metadata", name: "Metadata", type: "metadata" },
|
||||||
column: { id: "col_meta", name: "Metadata Col", type: "rich_text" },
|
column: { id: "col_meta", name: "Metadata Col", type: "rich_text" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
element: { id: "createdAt", name: "Created At", type: "createdAt" },
|
question: { id: "createdAt", name: "Created At", type: "createdAt" },
|
||||||
column: { id: "col_created", name: "Created Col", type: "date" },
|
column: { id: "col_created", name: "Created Col", type: "date" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -351,14 +351,16 @@ describe("handleIntegrations", () => {
|
|||||||
mockAirtableIntegration.config.key,
|
mockAirtableIntegration.config.key,
|
||||||
mockAirtableIntegration.config.data[0],
|
mockAirtableIntegration.config.data[0],
|
||||||
[
|
[
|
||||||
"Answer 1",
|
[
|
||||||
"Choice 1, Choice 2",
|
"Answer 1",
|
||||||
"Hidden Value",
|
"Choice 1, Choice 2",
|
||||||
expectedMetadataString,
|
"Hidden Value",
|
||||||
"Variable Value",
|
expectedMetadataString,
|
||||||
"2024-01-01 12:00",
|
"Variable Value",
|
||||||
], // responses + hidden + meta + var + created
|
"2024-01-01 12:00",
|
||||||
["Question 1 {{recall:q2}}", "Question 2", hiddenFieldId, "Metadata", "Variable 1", "Created At"] // elements (raw headline for Airtable) + hidden + meta + var + created
|
], // responses + hidden + meta + var + created
|
||||||
|
["Question 1 {{recall:q2}}", "Question 2", hiddenFieldId, "Metadata", "Variable 1", "Created At"], // questions (raw headline for Airtable) + hidden + meta + var + created
|
||||||
|
]
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -393,8 +395,10 @@ describe("handleIntegrations", () => {
|
|||||||
expect(googleSheetWriteData).toHaveBeenCalledWith(
|
expect(googleSheetWriteData).toHaveBeenCalledWith(
|
||||||
expectedIntegrationData,
|
expectedIntegrationData,
|
||||||
mockGoogleSheetsIntegration.config.data[0].spreadsheetId,
|
mockGoogleSheetsIntegration.config.data[0].spreadsheetId,
|
||||||
["Answer 1"], // responses
|
[
|
||||||
["Question 1 {{recall:q2}}"] // elements (raw headline for Google Sheets)
|
["Answer 1"], // responses
|
||||||
|
["Question 1 {{recall:q2}}"], // questions (raw headline for Google Sheets)
|
||||||
|
]
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
|||||||
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
||||||
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
|
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
|
||||||
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
|
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
|
||||||
import { TResponseDataValue, TResponseMeta } from "@formbricks/types/responses";
|
import { TResponseMeta } from "@formbricks/types/responses";
|
||||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||||
@@ -44,40 +44,33 @@ const processDataForIntegration = async (
|
|||||||
includeMetadata: boolean,
|
includeMetadata: boolean,
|
||||||
includeHiddenFields: boolean,
|
includeHiddenFields: boolean,
|
||||||
includeCreatedAt: boolean,
|
includeCreatedAt: boolean,
|
||||||
elementIds: string[]
|
questionIds: string[]
|
||||||
): Promise<{
|
): Promise<string[][]> => {
|
||||||
responses: string[];
|
|
||||||
elements: string[];
|
|
||||||
}> => {
|
|
||||||
const ids =
|
const ids =
|
||||||
includeHiddenFields && survey.hiddenFields.fieldIds
|
includeHiddenFields && survey.hiddenFields.fieldIds
|
||||||
? [...elementIds, ...survey.hiddenFields.fieldIds]
|
? [...questionIds, ...survey.hiddenFields.fieldIds]
|
||||||
: elementIds;
|
: questionIds;
|
||||||
const { responses, elements } = await extractResponses(integrationType, data, ids, survey);
|
const values = await extractResponses(integrationType, data, ids, survey);
|
||||||
|
|
||||||
if (includeMetadata) {
|
if (includeMetadata) {
|
||||||
responses.push(convertMetaObjectToString(data.response.meta));
|
values[0].push(convertMetaObjectToString(data.response.meta));
|
||||||
elements.push("Metadata");
|
values[1].push("Metadata");
|
||||||
}
|
}
|
||||||
if (includeVariables) {
|
if (includeVariables) {
|
||||||
survey.variables?.forEach((variable) => {
|
survey.variables.forEach((variable) => {
|
||||||
const value = data.response.variables[variable.id];
|
const value = data.response.variables[variable.id];
|
||||||
if (value !== undefined) {
|
if (value !== undefined) {
|
||||||
responses.push(String(data.response.variables[variable.id]));
|
values[0].push(String(data.response.variables[variable.id]));
|
||||||
elements.push(variable.name);
|
values[1].push(variable.name);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (includeCreatedAt) {
|
if (includeCreatedAt) {
|
||||||
const date = new Date(data.response.createdAt);
|
const date = new Date(data.response.createdAt);
|
||||||
responses.push(`${getFormattedDateTimeString(date)}`);
|
values[0].push(`${getFormattedDateTimeString(date)}`);
|
||||||
elements.push("Created At");
|
values[1].push("Created At");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return values;
|
||||||
responses,
|
|
||||||
elements,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleIntegrations = async (
|
export const handleIntegrations = async (
|
||||||
@@ -140,9 +133,9 @@ const handleAirtableIntegration = async (
|
|||||||
!!element.includeMetadata,
|
!!element.includeMetadata,
|
||||||
!!element.includeHiddenFields,
|
!!element.includeHiddenFields,
|
||||||
!!element.includeCreatedAt,
|
!!element.includeCreatedAt,
|
||||||
element.elementIds
|
element.questionIds
|
||||||
);
|
);
|
||||||
await airtableWriteData(integration.config.key, element, values.responses, values.elements);
|
await airtableWriteData(integration.config.key, element, values);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,14 +169,14 @@ const handleGoogleSheetsIntegration = async (
|
|||||||
!!element.includeMetadata,
|
!!element.includeMetadata,
|
||||||
!!element.includeHiddenFields,
|
!!element.includeHiddenFields,
|
||||||
!!element.includeCreatedAt,
|
!!element.includeCreatedAt,
|
||||||
element.elementIds
|
element.questionIds
|
||||||
);
|
);
|
||||||
const integrationData = structuredClone(integration);
|
const integrationData = structuredClone(integration);
|
||||||
integrationData.config.data.forEach((data) => {
|
integrationData.config.data.forEach((data) => {
|
||||||
data.createdAt = new Date(data.createdAt);
|
data.createdAt = new Date(data.createdAt);
|
||||||
});
|
});
|
||||||
|
|
||||||
await writeData(integrationData, element.spreadsheetId, values.responses, values.elements);
|
await writeData(integrationData, element.spreadsheetId, values);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,15 +210,9 @@ const handleSlackIntegration = async (
|
|||||||
!!element.includeMetadata,
|
!!element.includeMetadata,
|
||||||
!!element.includeHiddenFields,
|
!!element.includeHiddenFields,
|
||||||
!!element.includeCreatedAt,
|
!!element.includeCreatedAt,
|
||||||
element.elementIds
|
element.questionIds
|
||||||
);
|
|
||||||
await writeDataToSlack(
|
|
||||||
integration.config.key,
|
|
||||||
element.channelId,
|
|
||||||
values.responses,
|
|
||||||
values.elements,
|
|
||||||
survey?.name
|
|
||||||
);
|
);
|
||||||
|
await writeDataToSlack(integration.config.key, element.channelId, values, survey?.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,81 +229,66 @@ const handleSlackIntegration = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper to process a single element's response for integrations
|
|
||||||
const processElementResponse = (
|
|
||||||
element: ReturnType<typeof getElementsFromBlocks>[number],
|
|
||||||
responseValue: TResponseDataValue
|
|
||||||
): string => {
|
|
||||||
if (responseValue === undefined) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (element.type === TSurveyElementTypeEnum.PictureSelection) {
|
|
||||||
const selectedChoiceIds = responseValue as string[];
|
|
||||||
return element.choices
|
|
||||||
.filter((choice) => selectedChoiceIds.includes(choice.id))
|
|
||||||
.map((choice) => choice.imageUrl)
|
|
||||||
.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
return processResponseData(responseValue);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to create empty response object for non-slack integrations
|
|
||||||
const createEmptyResponseObject = (responseData: Record<string, unknown>): Record<string, string> => {
|
|
||||||
return Object.keys(responseData).reduce(
|
|
||||||
(acc, key) => {
|
|
||||||
acc[key] = "";
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, string>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const extractResponses = async (
|
const extractResponses = async (
|
||||||
integrationType: TIntegrationType,
|
integrationType: TIntegrationType,
|
||||||
pipelineData: TPipelineInput,
|
pipelineData: TPipelineInput,
|
||||||
elementIds: string[],
|
questionIds: string[],
|
||||||
survey: TSurvey
|
survey: TSurvey
|
||||||
): Promise<{
|
): Promise<string[][]> => {
|
||||||
responses: string[];
|
|
||||||
elements: string[];
|
|
||||||
}> => {
|
|
||||||
const responses: string[] = [];
|
const responses: string[] = [];
|
||||||
const elements: string[] = [];
|
const questions: string[] = [];
|
||||||
const surveyElements = getElementsFromBlocks(survey.blocks);
|
|
||||||
const emptyResponseObject = createEmptyResponseObject(pipelineData.response.data);
|
|
||||||
|
|
||||||
for (const elementId of elementIds) {
|
// Derive questions from blocks
|
||||||
// Check for hidden field Ids
|
const surveyQuestions = getElementsFromBlocks(survey.blocks);
|
||||||
if (survey.hiddenFields.fieldIds?.includes(elementId)) {
|
|
||||||
responses.push(processResponseData(pipelineData.response.data[elementId]));
|
for (const questionId of questionIds) {
|
||||||
elements.push(elementId);
|
//check for hidden field Ids
|
||||||
|
if (survey.hiddenFields.fieldIds?.includes(questionId)) {
|
||||||
|
responses.push(processResponseData(pipelineData.response.data[questionId]));
|
||||||
|
questions.push(questionId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const question = surveyQuestions.find((q) => q.id === questionId);
|
||||||
|
if (!question) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const element = surveyElements.find((q) => q.id === elementId);
|
const responseValue = pipelineData.response.data[questionId];
|
||||||
if (!element) {
|
|
||||||
continue;
|
if (responseValue !== undefined) {
|
||||||
|
let answer: typeof responseValue;
|
||||||
|
if (question.type === TSurveyElementTypeEnum.PictureSelection) {
|
||||||
|
const selectedChoiceIds = responseValue as string[];
|
||||||
|
answer = question?.choices
|
||||||
|
.filter((choice) => selectedChoiceIds.includes(choice.id))
|
||||||
|
.map((choice) => choice.imageUrl)
|
||||||
|
.join("\n");
|
||||||
|
} else {
|
||||||
|
answer = responseValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
responses.push(processResponseData(answer));
|
||||||
|
} else {
|
||||||
|
responses.push("");
|
||||||
}
|
}
|
||||||
|
// Create emptyResponseObject with same keys but empty string values
|
||||||
const responseValue = pipelineData.response.data[elementId];
|
const emptyResponseObject = Object.keys(pipelineData.response.data).reduce(
|
||||||
responses.push(processElementResponse(element, responseValue));
|
(acc, key) => {
|
||||||
|
acc[key] = "";
|
||||||
const responseDataForRecall =
|
return acc;
|
||||||
integrationType === "slack" ? pipelineData.response.data : emptyResponseObject;
|
},
|
||||||
const variablesForRecall = integrationType === "slack" ? pipelineData.response.variables : {};
|
{} as Record<string, string>
|
||||||
|
);
|
||||||
elements.push(
|
questions.push(
|
||||||
parseRecallInfo(
|
parseRecallInfo(
|
||||||
getTextContent(getLocalizedValue(element.headline, "default")),
|
getTextContent(getLocalizedValue(question?.headline, "default")),
|
||||||
responseDataForRecall,
|
integrationType === "slack" ? pipelineData.response.data : emptyResponseObject,
|
||||||
variablesForRecall
|
integrationType === "slack" ? pipelineData.response.variables : {}
|
||||||
) || ""
|
) || ""
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { responses, elements };
|
return [responses, questions];
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNotionIntegration = async (
|
const handleNotionIntegration = async (
|
||||||
@@ -354,34 +326,35 @@ const buildNotionPayloadProperties = (
|
|||||||
const properties: any = {};
|
const properties: any = {};
|
||||||
const responses = data.response.data;
|
const responses = data.response.data;
|
||||||
|
|
||||||
const surveyElements = getElementsFromBlocks(surveyData.blocks);
|
// Derive questions from blocks
|
||||||
|
const surveyQuestions = getElementsFromBlocks(surveyData.blocks);
|
||||||
|
|
||||||
const mappingElementIds = mapping
|
const mappingQIds = mapping
|
||||||
.filter((m) => m.element.type === TSurveyElementTypeEnum.PictureSelection)
|
.filter((m) => m.question.type === TSurveyElementTypeEnum.PictureSelection)
|
||||||
.map((m) => m.element.id);
|
.map((m) => m.question.id);
|
||||||
|
|
||||||
Object.keys(responses).forEach((resp) => {
|
Object.keys(responses).forEach((resp) => {
|
||||||
if (mappingElementIds.find((elementId) => elementId === resp)) {
|
if (mappingQIds.find((qId) => qId === resp)) {
|
||||||
const selectedChoiceIds = responses[resp] as string[];
|
const selectedChoiceIds = responses[resp] as string[];
|
||||||
const pictureElement = surveyElements.find((el) => el.id === resp);
|
const pictureQuestion = surveyQuestions.find((q) => q.id === resp);
|
||||||
|
|
||||||
responses[resp] = (pictureElement as any)?.choices
|
responses[resp] = (pictureQuestion as any)?.choices
|
||||||
.filter((choice) => selectedChoiceIds.includes(choice.id))
|
.filter((choice) => selectedChoiceIds.includes(choice.id))
|
||||||
.map((choice) => choice.imageUrl);
|
.map((choice) => choice.imageUrl);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
mapping.forEach((map) => {
|
mapping.forEach((map) => {
|
||||||
if (map.element.id === "metadata") {
|
if (map.question.id === "metadata") {
|
||||||
properties[map.column.name] = {
|
properties[map.column.name] = {
|
||||||
[map.column.type]: getValue(map.column.type, convertMetaObjectToString(data.response.meta)) || null,
|
[map.column.type]: getValue(map.column.type, convertMetaObjectToString(data.response.meta)) || null,
|
||||||
};
|
};
|
||||||
} else if (map.element.id === "createdAt") {
|
} else if (map.question.id === "createdAt") {
|
||||||
properties[map.column.name] = {
|
properties[map.column.name] = {
|
||||||
[map.column.type]: getValue(map.column.type, data.response.createdAt) || null,
|
[map.column.type]: getValue(map.column.type, data.response.createdAt) || null,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const value = responses[map.element.id];
|
const value = responses[map.question.id];
|
||||||
properties[map.column.name] = {
|
properties[map.column.name] = {
|
||||||
[map.column.type]: getValue(map.column.type, value) || null,
|
[map.column.type]: getValue(map.column.type, value) || null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,272 +0,0 @@
|
|||||||
import { IntegrationType } from "@prisma/client";
|
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { getCacheService } from "@formbricks/cache";
|
|
||||||
import { prisma } from "@formbricks/database";
|
|
||||||
import { logger } from "@formbricks/logger";
|
|
||||||
import { sendTelemetryEvents } from "./telemetry";
|
|
||||||
|
|
||||||
// Mock dependencies
|
|
||||||
vi.mock("@formbricks/cache");
|
|
||||||
vi.mock("@formbricks/database", () => ({
|
|
||||||
prisma: {
|
|
||||||
organization: {
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
count: vi.fn(),
|
|
||||||
},
|
|
||||||
user: { count: vi.fn() },
|
|
||||||
team: { count: vi.fn() },
|
|
||||||
project: { count: vi.fn() },
|
|
||||||
survey: { count: vi.fn() },
|
|
||||||
response: {
|
|
||||||
count: vi.fn(),
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
},
|
|
||||||
display: { count: vi.fn() },
|
|
||||||
contact: { count: vi.fn() },
|
|
||||||
segment: { count: vi.fn() },
|
|
||||||
integration: { findMany: vi.fn() },
|
|
||||||
account: { findMany: vi.fn() },
|
|
||||||
$queryRaw: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
vi.mock("@formbricks/logger", () => ({
|
|
||||||
logger: {
|
|
||||||
error: vi.fn(),
|
|
||||||
info: vi.fn(),
|
|
||||||
warn: vi.fn(),
|
|
||||||
debug: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/env", () => ({
|
|
||||||
env: {
|
|
||||||
SMTP_HOST: "smtp.example.com",
|
|
||||||
S3_BUCKET_NAME: "my-bucket",
|
|
||||||
PROMETHEUS_ENABLED: true,
|
|
||||||
RECAPTCHA_SITE_KEY: "site-key",
|
|
||||||
RECAPTCHA_SECRET_KEY: "secret-key",
|
|
||||||
GITHUB_ID: "github-id",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock fetch
|
|
||||||
const fetchMock = vi.fn();
|
|
||||||
globalThis.fetch = fetchMock;
|
|
||||||
|
|
||||||
const mockCacheService = {
|
|
||||||
get: vi.fn(),
|
|
||||||
set: vi.fn(),
|
|
||||||
tryLock: vi.fn(),
|
|
||||||
del: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("sendTelemetryEvents", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.resetAllMocks();
|
|
||||||
vi.useFakeTimers();
|
|
||||||
// Set a fixed time far in the past to ensure we can always send telemetry
|
|
||||||
vi.setSystemTime(new Date("2024-01-01T00:00:00.000Z"));
|
|
||||||
|
|
||||||
// Setup default cache behavior
|
|
||||||
vi.mocked(getCacheService).mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
data: mockCacheService as any,
|
|
||||||
});
|
|
||||||
mockCacheService.tryLock.mockResolvedValue({ ok: true, data: true }); // Lock acquired
|
|
||||||
mockCacheService.del.mockResolvedValue({ ok: true, data: undefined });
|
|
||||||
mockCacheService.get.mockResolvedValue({ ok: true, data: null }); // No last sent time
|
|
||||||
mockCacheService.set.mockResolvedValue({ ok: true, data: undefined });
|
|
||||||
|
|
||||||
// Setup default prisma behavior
|
|
||||||
vi.mocked(prisma.organization.findFirst).mockResolvedValue({
|
|
||||||
id: "org-123",
|
|
||||||
createdAt: new Date("2023-01-01"),
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
// Mock raw SQL query for counts (batched query)
|
|
||||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([
|
|
||||||
{
|
|
||||||
organizationCount: BigInt(1),
|
|
||||||
userCount: BigInt(5),
|
|
||||||
teamCount: BigInt(2),
|
|
||||||
projectCount: BigInt(3),
|
|
||||||
surveyCount: BigInt(10),
|
|
||||||
inProgressSurveyCount: BigInt(4),
|
|
||||||
completedSurveyCount: BigInt(6),
|
|
||||||
responseCountAllTime: BigInt(100),
|
|
||||||
responseCountSinceLastUpdate: BigInt(10),
|
|
||||||
displayCount: BigInt(50),
|
|
||||||
contactCount: BigInt(20),
|
|
||||||
segmentCount: BigInt(4),
|
|
||||||
newestResponseAt: new Date("2024-01-01T00:00:00.000Z"),
|
|
||||||
},
|
|
||||||
] as any);
|
|
||||||
|
|
||||||
// Mock other queries
|
|
||||||
vi.mocked(prisma.integration.findMany).mockResolvedValue([{ type: IntegrationType.notion }] as any);
|
|
||||||
vi.mocked(prisma.account.findMany).mockResolvedValue([{ provider: "github" }] as any);
|
|
||||||
|
|
||||||
fetchMock.mockResolvedValue({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should send telemetry successfully when conditions are met", async () => {
|
|
||||||
await sendTelemetryEvents();
|
|
||||||
|
|
||||||
// Check lock acquisition
|
|
||||||
expect(mockCacheService.tryLock).toHaveBeenCalledWith(
|
|
||||||
"telemetry_lock",
|
|
||||||
"locked",
|
|
||||||
60 * 1000 // 1 minute TTL
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check data gathering
|
|
||||||
expect(prisma.organization.findFirst).toHaveBeenCalled();
|
|
||||||
expect(prisma.$queryRaw).toHaveBeenCalled();
|
|
||||||
|
|
||||||
// Check fetch call
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
||||||
const payload = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
||||||
expect(payload.organizationCount).toBe(1);
|
|
||||||
expect(payload.userCount).toBe(5);
|
|
||||||
expect(payload.integrations.notion).toBe(true);
|
|
||||||
expect(payload.sso.github).toBe(true);
|
|
||||||
|
|
||||||
// Check cache update (no TTL parameter)
|
|
||||||
expect(mockCacheService.set).toHaveBeenCalledWith("telemetry_last_sent_ts", expect.any(String));
|
|
||||||
|
|
||||||
// Check lock release
|
|
||||||
expect(mockCacheService.del).toHaveBeenCalledWith(["telemetry_lock"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should skip if in-memory check fails", async () => {
|
|
||||||
// Run once to set nextTelemetryCheck
|
|
||||||
await sendTelemetryEvents();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
|
|
||||||
// Run again immediately (should fail in-memory check)
|
|
||||||
await sendTelemetryEvents();
|
|
||||||
|
|
||||||
expect(getCacheService).not.toHaveBeenCalled();
|
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should skip if Redis last sent time is recent", async () => {
|
|
||||||
// Mock last sent time as recent
|
|
||||||
const recentTime = Date.now() - 1000 * 60 * 60; // 1 hour ago
|
|
||||||
mockCacheService.get.mockResolvedValue({ ok: true, data: String(recentTime) });
|
|
||||||
|
|
||||||
await sendTelemetryEvents();
|
|
||||||
|
|
||||||
expect(mockCacheService.tryLock).not.toHaveBeenCalled(); // No lock attempt
|
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should skip if lock cannot be acquired", async () => {
|
|
||||||
mockCacheService.tryLock.mockResolvedValue({ ok: true, data: false }); // Lock not acquired
|
|
||||||
|
|
||||||
await sendTelemetryEvents();
|
|
||||||
|
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
|
||||||
expect(mockCacheService.del).not.toHaveBeenCalled(); // Shouldn't try to delete lock we didn't acquire
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle cache service failure gracefully", async () => {
|
|
||||||
vi.mocked(getCacheService).mockResolvedValue({
|
|
||||||
ok: false,
|
|
||||||
error: new Error("Cache error"),
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
await sendTelemetryEvents();
|
|
||||||
|
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
|
||||||
// Should verify that nextTelemetryCheck was updated, but it's a module variable.
|
|
||||||
// We can infer it by running again and checking calls
|
|
||||||
vi.clearAllMocks();
|
|
||||||
await sendTelemetryEvents();
|
|
||||||
expect(getCacheService).not.toHaveBeenCalled(); // Should be blocked by in-memory check
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle telemetry send failure and apply cooldown", async () => {
|
|
||||||
// Reset module to clear nextTelemetryCheck state from previous tests
|
|
||||||
vi.resetModules();
|
|
||||||
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
|
|
||||||
|
|
||||||
// Ensure we can acquire lock by setting last sent time far in the past
|
|
||||||
const oldTime = Date.now() - 25 * 60 * 60 * 1000; // 25 hours ago
|
|
||||||
mockCacheService.get.mockResolvedValue({ ok: true, data: String(oldTime) });
|
|
||||||
mockCacheService.tryLock.mockResolvedValue({ ok: true, data: true }); // Lock acquired
|
|
||||||
|
|
||||||
// Make fetch fail to trigger the catch block
|
|
||||||
const networkError = new Error("Network error");
|
|
||||||
fetchMock.mockRejectedValue(networkError);
|
|
||||||
|
|
||||||
await freshSendTelemetryEvents();
|
|
||||||
|
|
||||||
// Verify lock was acquired
|
|
||||||
expect(mockCacheService.tryLock).toHaveBeenCalledWith("telemetry_lock", "locked", 60 * 1000);
|
|
||||||
|
|
||||||
// The error should be caught in the inner catch block
|
|
||||||
// The actual implementation logs as warning, not error
|
|
||||||
expect(logger.warn).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
error: networkError,
|
|
||||||
message: "Network error",
|
|
||||||
}),
|
|
||||||
"Failed to send telemetry - applying 1h cooldown"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Lock should be released in finally block
|
|
||||||
expect(mockCacheService.del).toHaveBeenCalledWith(["telemetry_lock"]);
|
|
||||||
|
|
||||||
// Cache should not be updated on failure
|
|
||||||
expect(mockCacheService.set).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// Verify cooldown: run again immediately (should be blocked by in-memory check)
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockCacheService.get.mockResolvedValue({ ok: true, data: null });
|
|
||||||
mockCacheService.tryLock.mockResolvedValue({ ok: true, data: true });
|
|
||||||
await freshSendTelemetryEvents();
|
|
||||||
expect(getCacheService).not.toHaveBeenCalled(); // Should be blocked by in-memory check
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should skip if no organization exists", async () => {
|
|
||||||
// Reset module to clear nextTelemetryCheck state from previous tests
|
|
||||||
vi.resetModules();
|
|
||||||
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
|
|
||||||
|
|
||||||
// Ensure we can acquire lock by setting last sent time far in the past
|
|
||||||
const oldTime = Date.now() - 25 * 60 * 60 * 1000; // 25 hours ago
|
|
||||||
|
|
||||||
// Re-setup mocks after resetModules
|
|
||||||
vi.mocked(getCacheService).mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
data: mockCacheService as any,
|
|
||||||
});
|
|
||||||
mockCacheService.tryLock.mockResolvedValue({ ok: true, data: true }); // Lock acquired
|
|
||||||
mockCacheService.del.mockResolvedValue({ ok: true, data: undefined });
|
|
||||||
mockCacheService.get.mockResolvedValue({ ok: true, data: String(oldTime) });
|
|
||||||
mockCacheService.set.mockResolvedValue({ ok: true, data: undefined });
|
|
||||||
|
|
||||||
vi.mocked(prisma.organization.findFirst).mockResolvedValue(null);
|
|
||||||
|
|
||||||
await freshSendTelemetryEvents();
|
|
||||||
|
|
||||||
// sendTelemetry returns early when no org exists
|
|
||||||
// Since it returns (not throws), the try block completes successfully
|
|
||||||
// Then cache.set is called, and finally block executes
|
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// Verify lock was acquired (prerequisite for finally block to execute)
|
|
||||||
expect(mockCacheService.tryLock).toHaveBeenCalledWith("telemetry_lock", "locked", 60 * 1000);
|
|
||||||
|
|
||||||
// Lock should be released in finally block
|
|
||||||
expect(mockCacheService.del).toHaveBeenCalledWith(["telemetry_lock"]);
|
|
||||||
|
|
||||||
// Note: The current implementation calls cache.set even when no org exists
|
|
||||||
// This might be a bug, but we test the actual behavior
|
|
||||||
expect(mockCacheService.set).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
import { IntegrationType } from "@prisma/client";
|
|
||||||
import { createHash } from "node:crypto";
|
|
||||||
import { type CacheKey, getCacheService } from "@formbricks/cache";
|
|
||||||
import { prisma } from "@formbricks/database";
|
|
||||||
import { logger } from "@formbricks/logger";
|
|
||||||
import { env } from "@/lib/env";
|
|
||||||
import packageJson from "@/package.json";
|
|
||||||
|
|
||||||
const TELEMETRY_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
||||||
const TELEMETRY_LOCK_KEY = "telemetry_lock" as CacheKey;
|
|
||||||
const TELEMETRY_LAST_SENT_KEY = "telemetry_last_sent_ts" as CacheKey;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In-memory timestamp for the next telemetry check.
|
|
||||||
* This is a fast, process-local check to avoid unnecessary Redis calls.
|
|
||||||
* Updated after each check to prevent redundant executions.
|
|
||||||
*/
|
|
||||||
let nextTelemetryCheck = 0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends telemetry events to Formbricks Enterprise endpoint.
|
|
||||||
* Uses a three-layer check system to prevent duplicate submissions:
|
|
||||||
* 1. In-memory check (fast, process-local)
|
|
||||||
* 2. Redis check (shared across instances, persists across restarts)
|
|
||||||
* 3. Distributed lock (prevents concurrent execution in multi-instance deployments)
|
|
||||||
*/
|
|
||||||
export const sendTelemetryEvents = async () => {
|
|
||||||
try {
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// CHECK 1: In-Memory Check (Fast Path)
|
|
||||||
// ============================================================
|
|
||||||
// Purpose: Quick process-local check to avoid Redis calls if we recently checked.
|
|
||||||
// How it works: If current time is before nextTelemetryCheck, skip entirely.
|
|
||||||
// This is updated after each successful check or failure to prevent spam.
|
|
||||||
if (now < nextTelemetryCheck) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// CHECK 2: Redis Check (Shared State)
|
|
||||||
// ============================================================
|
|
||||||
// Purpose: Check if telemetry was sent recently by ANY instance (shared across cluster).
|
|
||||||
// This persists across restarts and works in multi-instance deployments.
|
|
||||||
|
|
||||||
const cacheServiceResult = await getCacheService();
|
|
||||||
if (!cacheServiceResult.ok) {
|
|
||||||
// Redis unavailable: Fallback to in-memory cooldown to avoid spamming.
|
|
||||||
// Wait 1 hour before trying again. This prevents hammering Redis when it's down.
|
|
||||||
nextTelemetryCheck = now + 60 * 60 * 1000;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const cache = cacheServiceResult.data;
|
|
||||||
|
|
||||||
// Get the timestamp of when telemetry was last sent (from any instance).
|
|
||||||
const lastSentResult = await cache.get(TELEMETRY_LAST_SENT_KEY);
|
|
||||||
const lastSentStr = lastSentResult.ok && lastSentResult.data ? (lastSentResult.data as string) : null;
|
|
||||||
const lastSent = lastSentStr ? Number.parseInt(lastSentStr, 10) : 0;
|
|
||||||
|
|
||||||
// If less than 24 hours have passed since last telemetry, skip.
|
|
||||||
// Update in-memory check to match remaining time for fast-path optimization.
|
|
||||||
if (now - lastSent < TELEMETRY_INTERVAL_MS) {
|
|
||||||
nextTelemetryCheck = lastSent + TELEMETRY_INTERVAL_MS;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// CHECK 3: Distributed Lock (Prevent Concurrent Execution)
|
|
||||||
// ============================================================
|
|
||||||
// Purpose: Ensure only ONE instance executes telemetry at a time in a cluster.
|
|
||||||
// How it works:
|
|
||||||
// - Uses Redis SET NX (only set if not exists) for atomic lock acquisition
|
|
||||||
// - Lock expires after 1 minute (TTL) to prevent deadlocks if instance crashes
|
|
||||||
// - If lock exists, another instance is already running telemetry, so we exit
|
|
||||||
// - Lock is released in finally block after telemetry completes or fails
|
|
||||||
const lockResult = await cache.tryLock(TELEMETRY_LOCK_KEY, "locked", 60 * 1000); // 1 minute TTL
|
|
||||||
|
|
||||||
if (!lockResult.ok || !lockResult.data) {
|
|
||||||
// Lock acquisition failed or already held by another instance.
|
|
||||||
// Exit silently - the other instance will handle telemetry.
|
|
||||||
// No need to update nextTelemetryCheck here since we didn't execute.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// EXECUTION: Send Telemetry
|
|
||||||
// ============================================================
|
|
||||||
// We've passed all checks and acquired the lock. Now execute telemetry.
|
|
||||||
try {
|
|
||||||
await sendTelemetry(lastSent);
|
|
||||||
|
|
||||||
// Success: Update Redis with current timestamp so other instances know telemetry was sent.
|
|
||||||
// No TTL - persists indefinitely to support low-volume instances (responses every few days/weeks).
|
|
||||||
await cache.set(TELEMETRY_LAST_SENT_KEY, now.toString());
|
|
||||||
|
|
||||||
// Update in-memory check to prevent this instance from checking again for 24h.
|
|
||||||
nextTelemetryCheck = now + TELEMETRY_INTERVAL_MS;
|
|
||||||
} catch (e) {
|
|
||||||
// Log as warning since telemetry is non-essential
|
|
||||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
|
||||||
logger.warn(
|
|
||||||
{ error: e, message: errorMessage, lastSent, now },
|
|
||||||
"Failed to send telemetry - applying 1h cooldown"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Failure cooldown: Prevent retrying immediately to avoid hammering the endpoint.
|
|
||||||
// Wait 1 hour before allowing this instance to try again.
|
|
||||||
// Note: Other instances can still try (they'll hit the lock or Redis check).
|
|
||||||
nextTelemetryCheck = now + 60 * 60 * 1000;
|
|
||||||
} finally {
|
|
||||||
// Always release the lock, even if telemetry failed.
|
|
||||||
// This allows other instances to retry if this one failed.
|
|
||||||
await cache.del([TELEMETRY_LOCK_KEY]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Catch-all for any unexpected errors in the wrapper logic (cache failures, lock issues, etc.)
|
|
||||||
// Log as warning since telemetry is non-essential functionality
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
logger.warn(
|
|
||||||
{ error, message: errorMessage, timestamp: Date.now() },
|
|
||||||
"Unexpected error in sendTelemetryEvents wrapper - telemetry check skipped"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gathers telemetry data and sends it to Formbricks Enterprise endpoint.
|
|
||||||
* @param lastSent - Timestamp of last telemetry send (used to calculate incremental metrics)
|
|
||||||
*/
|
|
||||||
const sendTelemetry = async (lastSent: number) => {
|
|
||||||
// Get the oldest organization to generate a stable, anonymized instance ID.
|
|
||||||
// Using the oldest org ensures the ID doesn't change over time.
|
|
||||||
const oldestOrg = await prisma.organization.findFirst({
|
|
||||||
orderBy: { createdAt: "asc" },
|
|
||||||
select: { id: true, createdAt: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!oldestOrg) return; // No organization exists, nothing to report
|
|
||||||
const instanceId = createHash("sha256").update(oldestOrg.id).digest("hex");
|
|
||||||
|
|
||||||
// Optimize database queries to reduce connection pool usage:
|
|
||||||
// Instead of 15 parallel queries (which could exhaust the connection pool),
|
|
||||||
// we batch all count queries into a single raw SQL query.
|
|
||||||
// This reduces connection usage from 15 → 3 (batch counts + integrations + accounts).
|
|
||||||
const [countsResult, integrations, ssoProviders] = await Promise.all([
|
|
||||||
// Single query for all counts (13 metrics in one round-trip)
|
|
||||||
prisma.$queryRaw<
|
|
||||||
[
|
|
||||||
{
|
|
||||||
organizationCount: bigint;
|
|
||||||
userCount: bigint;
|
|
||||||
teamCount: bigint;
|
|
||||||
projectCount: bigint;
|
|
||||||
surveyCount: bigint;
|
|
||||||
inProgressSurveyCount: bigint;
|
|
||||||
completedSurveyCount: bigint;
|
|
||||||
responseCountAllTime: bigint;
|
|
||||||
responseCountSinceLastUpdate: bigint;
|
|
||||||
displayCount: bigint;
|
|
||||||
contactCount: bigint;
|
|
||||||
segmentCount: bigint;
|
|
||||||
newestResponseAt: Date | null;
|
|
||||||
},
|
|
||||||
]
|
|
||||||
>`
|
|
||||||
SELECT
|
|
||||||
(SELECT COUNT(*) FROM "Organization") as "organizationCount",
|
|
||||||
(SELECT COUNT(*) FROM "User") as "userCount",
|
|
||||||
(SELECT COUNT(*) FROM "Team") as "teamCount",
|
|
||||||
(SELECT COUNT(*) FROM "Project") as "projectCount",
|
|
||||||
(SELECT COUNT(*) FROM "Survey") as "surveyCount",
|
|
||||||
(SELECT COUNT(*) FROM "Survey" WHERE status = 'inProgress') as "inProgressSurveyCount",
|
|
||||||
(SELECT COUNT(*) FROM "Survey" WHERE status = 'completed') as "completedSurveyCount",
|
|
||||||
(SELECT COUNT(*) FROM "Response") as "responseCountAllTime",
|
|
||||||
(SELECT COUNT(*) FROM "Response" WHERE "created_at" > ${new Date(lastSent || 0)}) as "responseCountSinceLastUpdate",
|
|
||||||
(SELECT COUNT(*) FROM "Display") as "displayCount",
|
|
||||||
(SELECT COUNT(*) FROM "Contact") as "contactCount",
|
|
||||||
(SELECT COUNT(*) FROM "Segment") as "segmentCount",
|
|
||||||
(SELECT MAX("created_at") FROM "Response") as "newestResponseAt"
|
|
||||||
`,
|
|
||||||
// Keep these as separate queries since they need DISTINCT which is harder to optimize
|
|
||||||
prisma.integration.findMany({ select: { type: true }, distinct: ["type"] }),
|
|
||||||
prisma.account.findMany({ select: { provider: true }, distinct: ["provider"] }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Extract metrics from the batched query result and convert bigints to numbers
|
|
||||||
const counts = countsResult[0];
|
|
||||||
const organizationCount = Number(counts.organizationCount);
|
|
||||||
const userCount = Number(counts.userCount);
|
|
||||||
const teamCount = Number(counts.teamCount);
|
|
||||||
const projectCount = Number(counts.projectCount);
|
|
||||||
const surveyCount = Number(counts.surveyCount);
|
|
||||||
const inProgressSurveyCount = Number(counts.inProgressSurveyCount);
|
|
||||||
const completedSurveyCount = Number(counts.completedSurveyCount);
|
|
||||||
const responseCountAllTime = Number(counts.responseCountAllTime);
|
|
||||||
const responseCountSinceLastUpdate = Number(counts.responseCountSinceLastUpdate);
|
|
||||||
const displayCount = Number(counts.displayCount);
|
|
||||||
const contactCount = Number(counts.contactCount);
|
|
||||||
const segmentCount = Number(counts.segmentCount);
|
|
||||||
const newestResponse = counts.newestResponseAt ? { createdAt: counts.newestResponseAt } : null;
|
|
||||||
|
|
||||||
// Convert integration array to boolean map indicating which integrations are configured.
|
|
||||||
const integrationMap = {
|
|
||||||
notion: integrations.some((i) => i.type === IntegrationType.notion),
|
|
||||||
googleSheets: integrations.some((i) => i.type === IntegrationType.googleSheets),
|
|
||||||
airtable: integrations.some((i) => i.type === IntegrationType.airtable),
|
|
||||||
slack: integrations.some((i) => i.type === IntegrationType.slack),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check SSO configuration: either via environment variables or database records.
|
|
||||||
// This detects which SSO providers are available/configured.
|
|
||||||
const ssoMap = {
|
|
||||||
github: !!env.GITHUB_ID || ssoProviders.some((p) => p.provider === "github"),
|
|
||||||
google: !!env.GOOGLE_CLIENT_ID || ssoProviders.some((p) => p.provider === "google"),
|
|
||||||
azureAd: !!env.AZUREAD_CLIENT_ID || ssoProviders.some((p) => p.provider === "azuread"),
|
|
||||||
oidc: !!env.OIDC_CLIENT_ID || ssoProviders.some((p) => p.provider === "openid"),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Construct telemetry payload with usage statistics and configuration.
|
|
||||||
const payload = {
|
|
||||||
schemaVersion: 1, // Schema version for future compatibility
|
|
||||||
// Core entity counts
|
|
||||||
organizationCount,
|
|
||||||
userCount,
|
|
||||||
teamCount,
|
|
||||||
projectCount,
|
|
||||||
surveyCount,
|
|
||||||
inProgressSurveyCount,
|
|
||||||
completedSurveyCount,
|
|
||||||
// Response metrics
|
|
||||||
responseCountAllTime,
|
|
||||||
responseCountSinceLastUsageUpdate: responseCountSinceLastUpdate, // Incremental since last telemetry
|
|
||||||
displayCount,
|
|
||||||
contactCount,
|
|
||||||
segmentCount,
|
|
||||||
integrations: integrationMap,
|
|
||||||
infrastructure: {
|
|
||||||
smtp: !!env.SMTP_HOST,
|
|
||||||
s3: !!env.S3_BUCKET_NAME,
|
|
||||||
prometheus: !!env.PROMETHEUS_ENABLED,
|
|
||||||
},
|
|
||||||
security: {
|
|
||||||
recaptcha: !!(env.RECAPTCHA_SITE_KEY && env.RECAPTCHA_SECRET_KEY),
|
|
||||||
},
|
|
||||||
sso: ssoMap,
|
|
||||||
meta: {
|
|
||||||
version: packageJson.version, // Formbricks version for compatibility tracking
|
|
||||||
},
|
|
||||||
temporal: {
|
|
||||||
instanceCreatedAt: oldestOrg.createdAt.toISOString(), // When instance was first created
|
|
||||||
newestResponseAt: newestResponse?.createdAt.toISOString() || null, // Most recent activity
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Send telemetry to Formbricks Enterprise endpoint.
|
|
||||||
// This endpoint collects usage statistics for enterprise license validation and analytics.
|
|
||||||
const url = `https://ee.formbricks.com/api/v1/instances/${instanceId}/usage-updates`;
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout(() => controller.abort(), 10000); // 10 second timeout
|
|
||||||
|
|
||||||
await fetch(url, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
clearTimeout(timeout);
|
|
||||||
};
|
|
||||||
@@ -3,7 +3,6 @@ import { headers } from "next/headers";
|
|||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { sendTelemetryEvents } from "@/app/api/(internal)/pipeline/lib/telemetry";
|
|
||||||
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
|
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
@@ -227,10 +226,6 @@ export const POST = async (request: Request) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (event === "responseCreated") {
|
|
||||||
// Send telemetry events
|
|
||||||
await sendTelemetryEvents();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response.json({ data: {} });
|
return Response.json({ data: {} });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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,6 +18,10 @@ import {
|
|||||||
getMonthlyOrganizationResponseCount,
|
getMonthlyOrganizationResponseCount,
|
||||||
getOrganizationByEnvironmentId,
|
getOrganizationByEnvironmentId,
|
||||||
} from "@/lib/organization/service";
|
} from "@/lib/organization/service";
|
||||||
|
import {
|
||||||
|
capturePosthogEnvironmentEvent,
|
||||||
|
sendPlanLimitsReachedEventToPosthogWeekly,
|
||||||
|
} from "@/lib/posthogServer";
|
||||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||||
import { COLOR_DEFAULTS } from "@/lib/styling/constants";
|
import { COLOR_DEFAULTS } from "@/lib/styling/constants";
|
||||||
|
|
||||||
@@ -54,6 +58,20 @@ const checkResponseLimit = async (environmentId: string): Promise<boolean> => {
|
|||||||
const monthlyResponseLimit = organization.billing.limits.monthly.responses;
|
const monthlyResponseLimit = organization.billing.limits.monthly.responses;
|
||||||
const isLimitReached = monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
|
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;
|
return isLimitReached;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -93,7 +111,10 @@ export const GET = withV1ApiWrapper({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!environment.appSetupCompleted) {
|
if (!environment.appSetupCompleted) {
|
||||||
await updateEnvironment(environment.id, { appSetupCompleted: true });
|
await Promise.all([
|
||||||
|
updateEnvironment(environment.id, { appSetupCompleted: true }),
|
||||||
|
capturePosthogEnvironmentEvent(environmentId, "app setup completed"),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// check organization subscriptions and response limits
|
// check organization subscriptions and response limits
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
|
|||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||||
|
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { createDisplay } from "./lib/display";
|
import { createDisplay } from "./lib/display";
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@ export const POST = withV1ApiWrapper({
|
|||||||
try {
|
try {
|
||||||
const response = await createDisplay(inputValidation.data);
|
const response = await createDisplay(inputValidation.data);
|
||||||
|
|
||||||
|
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(response, true),
|
response: responses.successResponse(response, true),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,11 +8,16 @@ import { TOrganization } from "@formbricks/types/organizations";
|
|||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { cache } from "@/lib/cache";
|
import { cache } from "@/lib/cache";
|
||||||
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
|
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
|
||||||
|
import {
|
||||||
|
capturePosthogEnvironmentEvent,
|
||||||
|
sendPlanLimitsReachedEventToPosthogWeekly,
|
||||||
|
} from "@/lib/posthogServer";
|
||||||
import { EnvironmentStateData, getEnvironmentStateData } from "./data";
|
import { EnvironmentStateData, getEnvironmentStateData } from "./data";
|
||||||
import { getEnvironmentState } from "./environmentState";
|
import { getEnvironmentState } from "./environmentState";
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
vi.mock("@/lib/organization/service");
|
vi.mock("@/lib/organization/service");
|
||||||
|
vi.mock("@/lib/posthogServer");
|
||||||
vi.mock("@/lib/cache", () => ({
|
vi.mock("@/lib/cache", () => ({
|
||||||
cache: {
|
cache: {
|
||||||
withCache: vi.fn(),
|
withCache: vi.fn(),
|
||||||
@@ -38,6 +43,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
RECAPTCHA_SECRET_KEY: "mock_recaptcha_secret_key",
|
RECAPTCHA_SECRET_KEY: "mock_recaptcha_secret_key",
|
||||||
IS_RECAPTCHA_CONFIGURED: true,
|
IS_RECAPTCHA_CONFIGURED: true,
|
||||||
IS_PRODUCTION: true,
|
IS_PRODUCTION: true,
|
||||||
|
IS_POSTHOG_CONFIGURED: false,
|
||||||
ENTERPRISE_LICENSE_KEY: "mock_enterprise_license_key",
|
ENTERPRISE_LICENSE_KEY: "mock_enterprise_license_key",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -182,7 +188,9 @@ describe("getEnvironmentState", () => {
|
|||||||
expect(result.data).toEqual(expectedData);
|
expect(result.data).toEqual(expectedData);
|
||||||
expect(getEnvironmentStateData).toHaveBeenCalledWith(environmentId);
|
expect(getEnvironmentStateData).toHaveBeenCalledWith(environmentId);
|
||||||
expect(prisma.environment.update).not.toHaveBeenCalled();
|
expect(prisma.environment.update).not.toHaveBeenCalled();
|
||||||
|
expect(capturePosthogEnvironmentEvent).not.toHaveBeenCalled();
|
||||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
|
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
|
||||||
|
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should throw ResourceNotFoundError if environment not found", async () => {
|
test("should throw ResourceNotFoundError if environment not found", async () => {
|
||||||
@@ -218,6 +226,7 @@ describe("getEnvironmentState", () => {
|
|||||||
where: { id: environmentId },
|
where: { id: environmentId },
|
||||||
data: { appSetupCompleted: true },
|
data: { appSetupCompleted: true },
|
||||||
});
|
});
|
||||||
|
expect(capturePosthogEnvironmentEvent).toHaveBeenCalledWith(environmentId, "app setup completed");
|
||||||
expect(result.data).toBeDefined();
|
expect(result.data).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -228,6 +237,16 @@ describe("getEnvironmentState", () => {
|
|||||||
|
|
||||||
expect(result.data.surveys).toEqual([]);
|
expect(result.data.surveys).toEqual([]);
|
||||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
|
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 () => {
|
test("should return surveys if monthly response limit not reached (Cloud)", async () => {
|
||||||
@@ -237,6 +256,21 @@ describe("getEnvironmentState", () => {
|
|||||||
|
|
||||||
expect(result.data.surveys).toEqual(mockSurveys);
|
expect(result.data.surveys).toEqual(mockSurveys);
|
||||||
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(mockOrganization.id);
|
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 () => {
|
test("should include recaptchaSiteKey if recaptcha variables are set", async () => {
|
||||||
@@ -279,6 +313,7 @@ describe("getEnvironmentState", () => {
|
|||||||
|
|
||||||
// Should return surveys even with high count since limit is null (unlimited)
|
// Should return surveys even with high count since limit is null (unlimited)
|
||||||
expect(result.data.surveys).toEqual(mockSurveys);
|
expect(result.data.surveys).toEqual(mockSurveys);
|
||||||
|
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should propagate database update errors", async () => {
|
test("should propagate database update errors", async () => {
|
||||||
@@ -296,6 +331,21 @@ describe("getEnvironmentState", () => {
|
|||||||
await expect(getEnvironmentState(environmentId)).rejects.toThrow("Database error");
|
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 () => {
|
test("should include recaptchaSiteKey when IS_RECAPTCHA_CONFIGURED is true", async () => {
|
||||||
const result = await getEnvironmentState(environmentId);
|
const result = await getEnvironmentState(environmentId);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { createCacheKey } from "@formbricks/cache";
|
import { createCacheKey } from "@formbricks/cache";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
|
import { logger } from "@formbricks/logger";
|
||||||
import { TJsEnvironmentState } from "@formbricks/types/js";
|
import { TJsEnvironmentState } from "@formbricks/types/js";
|
||||||
import { cache } from "@/lib/cache";
|
import { cache } from "@/lib/cache";
|
||||||
import { IS_FORMBRICKS_CLOUD, IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants";
|
import { IS_FORMBRICKS_CLOUD, IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants";
|
||||||
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
|
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
|
||||||
|
import {
|
||||||
|
capturePosthogEnvironmentEvent,
|
||||||
|
sendPlanLimitsReachedEventToPosthogWeekly,
|
||||||
|
} from "@/lib/posthogServer";
|
||||||
import { getEnvironmentStateData } from "./data";
|
import { getEnvironmentStateData } from "./data";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,10 +33,13 @@ export const getEnvironmentState = async (
|
|||||||
// Handle app setup completion update if needed
|
// Handle app setup completion update if needed
|
||||||
// This is a one-time setup flag that can tolerate TTL-based cache expiration
|
// This is a one-time setup flag that can tolerate TTL-based cache expiration
|
||||||
if (!environment.appSetupCompleted) {
|
if (!environment.appSetupCompleted) {
|
||||||
await prisma.environment.update({
|
await Promise.all([
|
||||||
where: { id: environmentId },
|
prisma.environment.update({
|
||||||
data: { appSetupCompleted: true },
|
where: { id: environmentId },
|
||||||
});
|
data: { appSetupCompleted: true },
|
||||||
|
}),
|
||||||
|
capturePosthogEnvironmentEvent(environmentId, "app setup completed"),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check monthly response limits for Formbricks Cloud
|
// Check monthly response limits for Formbricks Cloud
|
||||||
@@ -41,6 +49,24 @@ export const getEnvironmentState = async (
|
|||||||
const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id);
|
const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id);
|
||||||
isMonthlyResponsesLimitReached =
|
isMonthlyResponsesLimitReached =
|
||||||
monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
|
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
|
// Build the response data
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
import { ZEnvironmentId } from "@formbricks/types/environment";
|
|
||||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { getEnvironmentState } from "@/app/api/v1/client/[environmentId]/environment/lib/environmentState";
|
import { getEnvironmentState } from "@/app/api/v1/client/[environmentId]/environment/lib/environmentState";
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
@@ -29,38 +28,15 @@ export const GET = withV1ApiWrapper({
|
|||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Basic type check for environmentId
|
// Simple validation for environmentId (faster than Zod for high-frequency endpoint)
|
||||||
if (typeof params.environmentId !== "string") {
|
if (typeof params.environmentId !== "string") {
|
||||||
return {
|
return {
|
||||||
response: responses.badRequestResponse("Environment ID is required", undefined, true),
|
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
|
// Use optimized environment state fetcher with new caching approach
|
||||||
const environmentState = await getEnvironmentState(environmentId);
|
const environmentState = await getEnvironmentState(params.environmentId);
|
||||||
const { data } = environmentState;
|
const { data } = environmentState;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -70,12 +46,12 @@ export const GET = withV1ApiWrapper({
|
|||||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60), // 1 hour for SDK to recheck
|
expiresAt: new Date(Date.now() + 1000 * 60 * 60), // 1 hour for SDK to recheck
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
// Cache headers aligned with Redis cache TTL (1 minute)
|
// Optimized cache headers for Cloudflare CDN and browser caching
|
||||||
// max-age=60: 1min browser cache
|
// max-age=3600: 1hr browser cache (per guidelines)
|
||||||
// s-maxage=60: 1min Cloudflare CDN cache
|
// s-maxage=1800: 30min Cloudflare cache (per guidelines)
|
||||||
// stale-while-revalidate=60: 1min stale serving during revalidation
|
// stale-while-revalidate=1800: 30min stale serving during revalidation
|
||||||
// stale-if-error=60: 1min stale serving on origin errors
|
// stale-if-error=3600: 1hr stale serving on origin errors
|
||||||
"public, s-maxage=60, max-age=60, stale-while-revalidate=60, stale-if-error=60"
|
"public, s-maxage=1800, max-age=3600, stale-while-revalidate=1800, stale-if-error=3600"
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
|||||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||||
import { getResponse } from "@/lib/response/service";
|
import { getResponse } from "@/lib/response/service";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
|
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
|
||||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||||
import { validateFileUploads } from "@/modules/storage/utils";
|
import { validateFileUploads } from "@/modules/storage/utils";
|
||||||
import { updateResponseWithQuotaEvaluation } from "./lib/response";
|
import { updateResponseWithQuotaEvaluation } from "./lib/response";
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
|
import { logger } from "@formbricks/logger";
|
||||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||||
import { TResponseInput } from "@formbricks/types/responses";
|
import { TResponseInput } from "@formbricks/types/responses";
|
||||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
import {
|
||||||
|
getMonthlyOrganizationResponseCount,
|
||||||
|
getOrganizationByEnvironmentId,
|
||||||
|
} from "@/lib/organization/service";
|
||||||
|
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
||||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||||
import { createResponse, createResponseWithQuotaEvaluation } from "./response";
|
import { createResponse, createResponseWithQuotaEvaluation } from "./response";
|
||||||
@@ -19,13 +24,22 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/organization/service", () => ({
|
vi.mock("@/lib/organization/service", () => ({
|
||||||
|
getMonthlyOrganizationResponseCount: vi.fn(),
|
||||||
getOrganizationByEnvironmentId: vi.fn(),
|
getOrganizationByEnvironmentId: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/posthogServer", () => ({
|
||||||
|
sendPlanLimitsReachedEventToPosthogWeekly: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/response/utils", () => ({
|
vi.mock("@/lib/response/utils", () => ({
|
||||||
calculateTtcTotal: vi.fn((ttc) => ttc),
|
calculateTtcTotal: vi.fn((ttc) => ttc),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/telemetry", () => ({
|
||||||
|
captureTelemetry: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/utils/validate", () => ({
|
vi.mock("@/lib/utils/validate", () => ({
|
||||||
validateInputs: vi.fn(),
|
validateInputs: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -124,6 +138,35 @@ 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 () => {
|
test("should throw ResourceNotFoundError if organization not found", async () => {
|
||||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
|
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
|
||||||
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(ResourceNotFoundError);
|
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(ResourceNotFoundError);
|
||||||
@@ -143,6 +186,20 @@ describe("createResponse", () => {
|
|||||||
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
|
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
|
||||||
await expect(createResponse(mockResponseInput)).rejects.toThrow(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", () => {
|
describe("createResponseWithQuotaEvaluation", () => {
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
|||||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||||
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||||
import { TTag } from "@formbricks/types/tags";
|
import { TTag } from "@formbricks/types/tags";
|
||||||
|
import { handleBillingLimitsCheck } from "@/app/api/lib/utils";
|
||||||
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
|
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
|
||||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||||
|
import { captureTelemetry } from "@/lib/telemetry";
|
||||||
import { validateInputs } from "@/lib/utils/validate";
|
import { validateInputs } from "@/lib/utils/validate";
|
||||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||||
import { getContactByUserId } from "./contact";
|
import { getContactByUserId } from "./contact";
|
||||||
@@ -81,6 +83,7 @@ export const createResponse = async (
|
|||||||
tx: Prisma.TransactionClient
|
tx: Prisma.TransactionClient
|
||||||
): Promise<TResponse> => {
|
): Promise<TResponse> => {
|
||||||
validateInputs([responseInput, ZResponseInput]);
|
validateInputs([responseInput, ZResponseInput]);
|
||||||
|
captureTelemetry("response created");
|
||||||
|
|
||||||
const { environmentId, userId, finished, ttc: initialTtc } = responseInput;
|
const { environmentId, userId, finished, ttc: initialTtc } = responseInput;
|
||||||
|
|
||||||
@@ -118,6 +121,8 @@ export const createResponse = async (
|
|||||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { headers } from "next/headers";
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { UAParser } from "ua-parser-js";
|
import { UAParser } from "ua-parser-js";
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
import { ZEnvironmentId } from "@formbricks/types/environment";
|
import { ZId } from "@formbricks/types/common";
|
||||||
import { InvalidInputError } from "@formbricks/types/errors";
|
import { InvalidInputError } from "@formbricks/types/errors";
|
||||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||||
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||||
@@ -10,6 +10,7 @@ import { responses } from "@/app/lib/api/response";
|
|||||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||||
|
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||||
@@ -50,7 +51,7 @@ export const POST = withV1ApiWrapper({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { environmentId } = params;
|
const { environmentId } = params;
|
||||||
const environmentIdValidation = ZEnvironmentId.safeParse(environmentId);
|
const environmentIdValidation = ZId.safeParse(environmentId);
|
||||||
const responseInputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId });
|
const responseInputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId });
|
||||||
|
|
||||||
if (!environmentIdValidation.success) {
|
if (!environmentIdValidation.success) {
|
||||||
@@ -171,6 +172,11 @@ export const POST = withV1ApiWrapper({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await capturePosthogEnvironmentEvent(survey.environmentId, "response created", {
|
||||||
|
surveyId: responseData.surveyId,
|
||||||
|
surveyType: survey.type,
|
||||||
|
});
|
||||||
|
|
||||||
const quotaObj = createQuotaFullObject(quotaFull);
|
const quotaObj = createQuotaFullObject(quotaFull);
|
||||||
|
|
||||||
const responseDataWithQuota = {
|
const responseDataWithQuota = {
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import { prisma } from "@formbricks/database";
|
|||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { TResponse, TResponseInput } from "@formbricks/types/responses";
|
import { TResponse, TResponseInput } from "@formbricks/types/responses";
|
||||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
import {
|
||||||
|
getMonthlyOrganizationResponseCount,
|
||||||
|
getOrganizationByEnvironmentId,
|
||||||
|
} from "@/lib/organization/service";
|
||||||
|
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
||||||
import { getResponseContact } from "@/lib/response/service";
|
import { getResponseContact } from "@/lib/response/service";
|
||||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||||
import { validateInputs } from "@/lib/utils/validate";
|
import { validateInputs } from "@/lib/utils/validate";
|
||||||
@@ -92,6 +96,9 @@ const mockTransformedResponses = [mockResponse, { ...mockResponse, id: "response
|
|||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
vi.mock("@/lib/constants", () => ({
|
vi.mock("@/lib/constants", () => ({
|
||||||
IS_FORMBRICKS_CLOUD: true,
|
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",
|
ENCRYPTION_KEY: "mock-encryption-key",
|
||||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||||
GITHUB_ID: "mock-github-id",
|
GITHUB_ID: "mock-github-id",
|
||||||
@@ -111,8 +118,10 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
SENTRY_DSN: "mock-sentry-dsn",
|
SENTRY_DSN: "mock-sentry-dsn",
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/organization/service");
|
vi.mock("@/lib/organization/service");
|
||||||
|
vi.mock("@/lib/posthogServer");
|
||||||
vi.mock("@/lib/response/service");
|
vi.mock("@/lib/response/service");
|
||||||
vi.mock("@/lib/response/utils");
|
vi.mock("@/lib/response/utils");
|
||||||
|
vi.mock("@/lib/telemetry");
|
||||||
vi.mock("@/lib/utils/validate");
|
vi.mock("@/lib/utils/validate");
|
||||||
vi.mock("@formbricks/database", () => ({
|
vi.mock("@formbricks/database", () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
@@ -153,6 +162,7 @@ describe("Response Lib Tests", () => {
|
|||||||
vi.mocked(mockTx.response.create).mockResolvedValue({
|
vi.mocked(mockTx.response.create).mockResolvedValue({
|
||||||
...mockResponsePrisma,
|
...mockResponsePrisma,
|
||||||
});
|
});
|
||||||
|
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
|
||||||
|
|
||||||
const response = await createResponse(mockResponseInputWithUserId, mockTx);
|
const response = await createResponse(mockResponseInputWithUserId, mockTx);
|
||||||
|
|
||||||
@@ -207,6 +217,68 @@ describe("Response Lib Tests", () => {
|
|||||||
|
|
||||||
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(genericError);
|
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", () => {
|
describe("getResponsesByEnvironmentIds", () => {
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
|||||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||||
import { TTag } from "@formbricks/types/tags";
|
import { TTag } from "@formbricks/types/tags";
|
||||||
|
import { handleBillingLimitsCheck } from "@/app/api/lib/utils";
|
||||||
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
|
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
|
||||||
import { RESPONSES_PER_PAGE } from "@/lib/constants";
|
import { RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||||
import { getResponseContact } from "@/lib/response/service";
|
import { getResponseContact } from "@/lib/response/service";
|
||||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
|
import { captureTelemetry } from "@/lib/telemetry";
|
||||||
import { validateInputs } from "@/lib/utils/validate";
|
import { validateInputs } from "@/lib/utils/validate";
|
||||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||||
import { getContactByUserId } from "./contact";
|
import { getContactByUserId } from "./contact";
|
||||||
@@ -91,6 +93,7 @@ export const createResponse = async (
|
|||||||
tx?: Prisma.TransactionClient
|
tx?: Prisma.TransactionClient
|
||||||
): Promise<TResponse> => {
|
): Promise<TResponse> => {
|
||||||
validateInputs([responseInput, ZResponseInput]);
|
validateInputs([responseInput, ZResponseInput]);
|
||||||
|
captureTelemetry("response created");
|
||||||
|
|
||||||
const { environmentId, userId, finished, ttc: initialTtc } = responseInput;
|
const { environmentId, userId, finished, ttc: initialTtc } = responseInput;
|
||||||
|
|
||||||
@@ -128,6 +131,8 @@ export const createResponse = async (
|
|||||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
|||||||
@@ -6,11 +6,6 @@ import { handleErrorResponse } from "@/app/api/v1/auth";
|
|||||||
import { deleteSurvey } from "@/app/api/v1/management/surveys/[surveyId]/lib/surveys";
|
import { deleteSurvey } from "@/app/api/v1/management/surveys/[surveyId]/lib/surveys";
|
||||||
import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils";
|
import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils";
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import {
|
|
||||||
transformBlocksToQuestions,
|
|
||||||
transformQuestionsToBlocks,
|
|
||||||
validateSurveyInput,
|
|
||||||
} from "@/app/lib/api/survey-transformation";
|
|
||||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||||
@@ -50,22 +45,6 @@ export const GET = withV1ApiWrapper({
|
|||||||
response: result.error,
|
response: result.error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldTransformToQuestions =
|
|
||||||
result.survey.blocks &&
|
|
||||||
result.survey.blocks.length > 0 &&
|
|
||||||
result.survey.blocks.every((block) => block.elements.length === 1);
|
|
||||||
|
|
||||||
if (shouldTransformToQuestions) {
|
|
||||||
return {
|
|
||||||
response: responses.successResponse({
|
|
||||||
...result.survey,
|
|
||||||
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
|
|
||||||
blocks: [],
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(result.survey),
|
response: responses.successResponse(result.survey),
|
||||||
};
|
};
|
||||||
@@ -152,23 +131,6 @@ export const PUT = withV1ApiWrapper({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const validateResult = validateSurveyInput({ ...surveyUpdate, updateOnly: true });
|
|
||||||
if (!validateResult.ok) {
|
|
||||||
return {
|
|
||||||
response: responses.badRequestResponse(validateResult.error.message),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const { hasQuestions } = validateResult.data;
|
|
||||||
|
|
||||||
if (hasQuestions) {
|
|
||||||
surveyUpdate.blocks = transformQuestionsToBlocks(
|
|
||||||
surveyUpdate.questions,
|
|
||||||
surveyUpdate.endings || result.survey.endings
|
|
||||||
);
|
|
||||||
surveyUpdate.questions = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputValidation = ZSurveyUpdateInput.safeParse({
|
const inputValidation = ZSurveyUpdateInput.safeParse({
|
||||||
...result.survey,
|
...result.survey,
|
||||||
...surveyUpdate,
|
...surveyUpdate,
|
||||||
@@ -193,19 +155,6 @@ export const PUT = withV1ApiWrapper({
|
|||||||
try {
|
try {
|
||||||
const updatedSurvey = await updateSurvey({ ...inputValidation.data, id: params.surveyId });
|
const updatedSurvey = await updateSurvey({ ...inputValidation.data, id: params.surveyId });
|
||||||
auditLog.newObject = updatedSurvey;
|
auditLog.newObject = updatedSurvey;
|
||||||
|
|
||||||
if (hasQuestions) {
|
|
||||||
const surveyWithQuestions = {
|
|
||||||
...updatedSurvey,
|
|
||||||
questions: transformBlocksToQuestions(updatedSurvey.blocks, updatedSurvey.endings),
|
|
||||||
blocks: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
response: responses.successResponse(surveyWithQuestions),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(updatedSurvey),
|
response: responses.successResponse(updatedSurvey),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,11 +4,6 @@ import { DatabaseError } from "@formbricks/types/errors";
|
|||||||
import { ZSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
|
import { ZSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
|
||||||
import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils";
|
import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils";
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import {
|
|
||||||
transformBlocksToQuestions,
|
|
||||||
transformQuestionsToBlocks,
|
|
||||||
validateSurveyInput,
|
|
||||||
} from "@/app/lib/api/survey-transformation";
|
|
||||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||||
@@ -32,30 +27,10 @@ export const GET = withV1ApiWrapper({
|
|||||||
const environmentIds = authentication.environmentPermissions.map(
|
const environmentIds = authentication.environmentPermissions.map(
|
||||||
(permission) => permission.environmentId
|
(permission) => permission.environmentId
|
||||||
);
|
);
|
||||||
|
|
||||||
const surveys = await getSurveys(environmentIds, limit, offset);
|
const surveys = await getSurveys(environmentIds, limit, offset);
|
||||||
|
|
||||||
const surveysWithQuestions = surveys.map((survey) => {
|
|
||||||
// If the survey has blocks and each block has ONLY ONE element, we can transform the blocks to questions
|
|
||||||
// This is only for backwards compatibility with the older surveys
|
|
||||||
const shouldTransformToQuestions =
|
|
||||||
survey.blocks &&
|
|
||||||
survey.blocks.length > 0 &&
|
|
||||||
survey.blocks.every((block) => block.elements.length === 1);
|
|
||||||
|
|
||||||
if (shouldTransformToQuestions) {
|
|
||||||
return {
|
|
||||||
...survey,
|
|
||||||
questions: transformBlocksToQuestions(survey.blocks, survey.endings),
|
|
||||||
blocks: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return survey;
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(surveysWithQuestions),
|
response: responses.successResponse(surveys),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DatabaseError) {
|
if (error instanceof DatabaseError) {
|
||||||
@@ -88,7 +63,6 @@ export const POST = withV1ApiWrapper({
|
|||||||
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
|
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputValidation = ZSurveyCreateInputWithEnvironmentId.safeParse(surveyInput);
|
const inputValidation = ZSurveyCreateInputWithEnvironmentId.safeParse(surveyInput);
|
||||||
|
|
||||||
if (!inputValidation.success) {
|
if (!inputValidation.success) {
|
||||||
@@ -118,20 +92,6 @@ export const POST = withV1ApiWrapper({
|
|||||||
|
|
||||||
const surveyData = { ...inputValidation.data, environmentId };
|
const surveyData = { ...inputValidation.data, environmentId };
|
||||||
|
|
||||||
const validateResult = validateSurveyInput(surveyData);
|
|
||||||
if (!validateResult.ok) {
|
|
||||||
return {
|
|
||||||
response: responses.badRequestResponse(validateResult.error.message),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const { hasQuestions } = validateResult.data;
|
|
||||||
|
|
||||||
if (hasQuestions) {
|
|
||||||
surveyData.blocks = transformQuestionsToBlocks(surveyData.questions, surveyData.endings || []);
|
|
||||||
surveyData.questions = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const featureCheckResult = await checkFeaturePermissions(surveyData, organization);
|
const featureCheckResult = await checkFeaturePermissions(surveyData, organization);
|
||||||
if (featureCheckResult) {
|
if (featureCheckResult) {
|
||||||
return {
|
return {
|
||||||
@@ -143,18 +103,6 @@ export const POST = withV1ApiWrapper({
|
|||||||
auditLog.targetId = survey.id;
|
auditLog.targetId = survey.id;
|
||||||
auditLog.newObject = survey;
|
auditLog.newObject = survey;
|
||||||
|
|
||||||
if (hasQuestions) {
|
|
||||||
const surveyWithQuestions = {
|
|
||||||
...survey,
|
|
||||||
questions: transformBlocksToQuestions(survey.blocks, survey.endings),
|
|
||||||
blocks: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
response: responses.successResponse(surveyWithQuestions),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(survey),
|
response: responses.successResponse(survey),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
|
|||||||
import { ZDisplayCreateInputV2 } from "@/app/api/v2/client/[environmentId]/displays/types/display";
|
import { ZDisplayCreateInputV2 } from "@/app/api/v2/client/[environmentId]/displays/types/display";
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
|
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { createDisplay } from "./lib/display";
|
import { createDisplay } from "./lib/display";
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
|||||||
try {
|
try {
|
||||||
const response = await createDisplay(inputValidation.data);
|
const response = await createDisplay(inputValidation.data);
|
||||||
|
|
||||||
|
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
|
||||||
return responses.successResponse(response, true);
|
return responses.successResponse(response, true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ResourceNotFoundError) {
|
if (error instanceof ResourceNotFoundError) {
|
||||||
|
|||||||
@@ -8,8 +8,13 @@ import { TResponseWithQuotaFull, TSurveyQuota } from "@formbricks/types/quota";
|
|||||||
import { TResponse } from "@formbricks/types/responses";
|
import { TResponse } from "@formbricks/types/responses";
|
||||||
import { TTag } from "@formbricks/types/tags";
|
import { TTag } from "@formbricks/types/tags";
|
||||||
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
|
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
|
||||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
import {
|
||||||
|
getMonthlyOrganizationResponseCount,
|
||||||
|
getOrganizationByEnvironmentId,
|
||||||
|
} from "@/lib/organization/service";
|
||||||
|
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
|
||||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||||
|
import { captureTelemetry } from "@/lib/telemetry";
|
||||||
import { validateInputs } from "@/lib/utils/validate";
|
import { validateInputs } from "@/lib/utils/validate";
|
||||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||||
import { getContact } from "./contact";
|
import { getContact } from "./contact";
|
||||||
@@ -44,7 +49,9 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/organization/service");
|
vi.mock("@/lib/organization/service");
|
||||||
|
vi.mock("@/lib/posthogServer");
|
||||||
vi.mock("@/lib/response/utils");
|
vi.mock("@/lib/response/utils");
|
||||||
|
vi.mock("@/lib/telemetry");
|
||||||
vi.mock("@/lib/utils/validate");
|
vi.mock("@/lib/utils/validate");
|
||||||
vi.mock("@/modules/ee/quotas/lib/evaluation-service");
|
vi.mock("@/modules/ee/quotas/lib/evaluation-service");
|
||||||
vi.mock("@formbricks/database", () => ({
|
vi.mock("@formbricks/database", () => ({
|
||||||
@@ -159,6 +166,9 @@ describe("createResponse V2", () => {
|
|||||||
...ttc,
|
...ttc,
|
||||||
_total: Object.values(ttc).reduce((a, b) => a + b, 0),
|
_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({
|
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
|
||||||
shouldEndSurvey: false,
|
shouldEndSurvey: false,
|
||||||
quotaFull: null,
|
quotaFull: null,
|
||||||
@@ -169,6 +179,32 @@ describe("createResponse V2", () => {
|
|||||||
mockIsFormbricksCloud = false;
|
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 () => {
|
test("should throw ResourceNotFoundError if organization not found", async () => {
|
||||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
|
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
|
||||||
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(ResourceNotFoundError);
|
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(ResourceNotFoundError);
|
||||||
@@ -189,6 +225,20 @@ describe("createResponse V2", () => {
|
|||||||
await expect(createResponse(mockResponseInput, mockTx)).rejects.toThrow(genericError);
|
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 () => {
|
test("should correctly map prisma tags to response tags", async () => {
|
||||||
const mockTag: TTag = { id: "tag1", name: "Tag 1", environmentId };
|
const mockTag: TTag = { id: "tag1", name: "Tag 1", environmentId };
|
||||||
const prismaResponseWithTags = {
|
const prismaResponseWithTags = {
|
||||||
@@ -219,6 +269,7 @@ describe("createResponseWithQuotaEvaluation V2", () => {
|
|||||||
...ttc,
|
...ttc,
|
||||||
_total: Object.values(ttc).reduce((a, b) => a + b, 0),
|
_total: Object.values(ttc).reduce((a, b) => a + b, 0),
|
||||||
}));
|
}));
|
||||||
|
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
|
||||||
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
|
vi.mocked(evaluateResponseQuotas).mockResolvedValue({
|
||||||
shouldEndSurvey: false,
|
shouldEndSurvey: false,
|
||||||
quotaFull: null,
|
quotaFull: null,
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
|||||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||||
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
|
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
|
||||||
import { TTag } from "@formbricks/types/tags";
|
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 { responseSelection } from "@/app/api/v1/client/[environmentId]/responses/lib/response";
|
||||||
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
|
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
|
||||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||||
|
import { captureTelemetry } from "@/lib/telemetry";
|
||||||
import { validateInputs } from "@/lib/utils/validate";
|
import { validateInputs } from "@/lib/utils/validate";
|
||||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||||
import { getContact } from "./contact";
|
import { getContact } from "./contact";
|
||||||
@@ -89,6 +91,7 @@ export const createResponse = async (
|
|||||||
tx?: Prisma.TransactionClient
|
tx?: Prisma.TransactionClient
|
||||||
): Promise<TResponse> => {
|
): Promise<TResponse> => {
|
||||||
validateInputs([responseInput, ZResponseInput]);
|
validateInputs([responseInput, ZResponseInput]);
|
||||||
|
captureTelemetry("response created");
|
||||||
|
|
||||||
const { environmentId, contactId, finished, ttc: initialTtc } = responseInput;
|
const { environmentId, contactId, finished, ttc: initialTtc } = responseInput;
|
||||||
|
|
||||||
@@ -126,6 +129,8 @@ export const createResponse = async (
|
|||||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
await handleBillingLimitsCheck(environmentId, organization.id, organization.billing);
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { UAParser } from "ua-parser-js";
|
import { UAParser } from "ua-parser-js";
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
import { ZEnvironmentId } from "@formbricks/types/environment";
|
import { ZId } from "@formbricks/types/common";
|
||||||
import { InvalidInputError } from "@formbricks/types/errors";
|
import { InvalidInputError } from "@formbricks/types/errors";
|
||||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||||
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
|
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||||
|
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
|
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
|
||||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||||
import { createResponseWithQuotaEvaluation } from "./lib/response";
|
import { createResponseWithQuotaEvaluation } from "./lib/response";
|
||||||
@@ -43,7 +44,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { environmentId } = params;
|
const { environmentId } = params;
|
||||||
const environmentIdValidation = ZEnvironmentId.safeParse(environmentId);
|
const environmentIdValidation = ZId.safeParse(environmentId);
|
||||||
const responseInputValidation = ZResponseInputV2.safeParse({ ...responseInput, environmentId });
|
const responseInputValidation = ZResponseInputV2.safeParse({ ...responseInput, environmentId });
|
||||||
|
|
||||||
if (!environmentIdValidation.success) {
|
if (!environmentIdValidation.success) {
|
||||||
@@ -148,6 +149,11 @@ 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 quotaObj = createQuotaFullObject(quotaFull);
|
||||||
|
|
||||||
const responseDataWithQuota = {
|
const responseDataWithQuota = {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,520 +0,0 @@
|
|||||||
import { createId } from "@paralleldrive/cuid2";
|
|
||||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
|
||||||
import { InvalidInputError } from "@formbricks/types/errors";
|
|
||||||
import {
|
|
||||||
type TSurveyBlock,
|
|
||||||
type TSurveyBlockLogic,
|
|
||||||
type TSurveyBlockLogicAction,
|
|
||||||
} from "@formbricks/types/surveys/blocks";
|
|
||||||
import { type TConditionGroup, type TSingleCondition } from "@formbricks/types/surveys/logic";
|
|
||||||
import {
|
|
||||||
type TSurveyEnding,
|
|
||||||
TSurveyLogicAction,
|
|
||||||
type TSurveyQuestion,
|
|
||||||
} from "@formbricks/types/surveys/types";
|
|
||||||
import { isConditionGroup, isSingleCondition } from "@formbricks/types/surveys/validation";
|
|
||||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
|
||||||
|
|
||||||
type Condition = TSingleCondition | TConditionGroup;
|
|
||||||
|
|
||||||
const conditionReferencesCTA = (
|
|
||||||
condition: Condition | null | undefined,
|
|
||||||
ctaElementId: string,
|
|
||||||
operator?: string
|
|
||||||
): boolean => {
|
|
||||||
if (!condition) return false;
|
|
||||||
|
|
||||||
if (isSingleCondition(condition)) {
|
|
||||||
if (condition.leftOperand.value === ctaElementId) {
|
|
||||||
if (operator) {
|
|
||||||
return condition.operator === operator;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isConditionGroup(condition)) {
|
|
||||||
return condition.conditions.some((c) => conditionReferencesCTA(c, ctaElementId, operator));
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeCtaConditions = (
|
|
||||||
conditionGroup: TConditionGroup,
|
|
||||||
ctaElementId: string,
|
|
||||||
operatorsToRemove: string[]
|
|
||||||
): TConditionGroup | null => {
|
|
||||||
const filteredConditions = conditionGroup.conditions.filter((condition) => {
|
|
||||||
if (isSingleCondition(condition)) {
|
|
||||||
if (condition.leftOperand.value === ctaElementId) {
|
|
||||||
return !operatorsToRemove.includes(condition.operator);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isConditionGroup(condition)) {
|
|
||||||
const cleaned = removeCtaConditions(condition, ctaElementId, operatorsToRemove);
|
|
||||||
if (!cleaned || cleaned.conditions.length === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
Object.assign(condition, cleaned);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (filteredConditions.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...conditionGroup,
|
|
||||||
conditions: filteredConditions,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const migrateCTAQuestion = (question: Record<string, unknown>): void => {
|
|
||||||
if (question.type !== "cta") return;
|
|
||||||
|
|
||||||
const hasExternalButton = question.buttonExternal === true && Boolean(question.buttonUrl);
|
|
||||||
|
|
||||||
if (hasExternalButton) {
|
|
||||||
if (question.buttonLabel) {
|
|
||||||
question.ctaButtonLabel = question.buttonLabel;
|
|
||||||
}
|
|
||||||
question.buttonExternal = true;
|
|
||||||
} else {
|
|
||||||
delete question.buttonExternal;
|
|
||||||
delete question.buttonUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
delete question.buttonLabel;
|
|
||||||
delete question.dismissButtonLabel;
|
|
||||||
};
|
|
||||||
|
|
||||||
const cleanCTALogicFromQuestion = (
|
|
||||||
question: Record<string, unknown>,
|
|
||||||
ctaQuestions: Map<string, boolean>
|
|
||||||
): void => {
|
|
||||||
if (!question.logic || !Array.isArray(question.logic) || question.logic.length === 0) return;
|
|
||||||
|
|
||||||
const cleanedLogic: unknown[] = [];
|
|
||||||
|
|
||||||
question.logic.forEach((logicRule: { conditions: TConditionGroup; [key: string]: unknown }) => {
|
|
||||||
let shouldKeepRule = true;
|
|
||||||
let modifiedConditions = logicRule.conditions;
|
|
||||||
|
|
||||||
ctaQuestions.forEach((hasExternalButton, ctaId) => {
|
|
||||||
if (!hasExternalButton) {
|
|
||||||
if (conditionReferencesCTA(modifiedConditions, ctaId)) {
|
|
||||||
const cleanedConditions = removeCtaConditions(modifiedConditions, ctaId, [
|
|
||||||
"isClicked",
|
|
||||||
"isSkipped",
|
|
||||||
]);
|
|
||||||
if (!cleanedConditions?.conditions || cleanedConditions.conditions.length === 0) {
|
|
||||||
shouldKeepRule = false;
|
|
||||||
} else {
|
|
||||||
modifiedConditions = cleanedConditions;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (conditionReferencesCTA(modifiedConditions, ctaId, "isSkipped")) {
|
|
||||||
const cleanedConditions = removeCtaConditions(modifiedConditions, ctaId, ["isSkipped"]);
|
|
||||||
if (!cleanedConditions?.conditions || cleanedConditions.conditions.length === 0) {
|
|
||||||
shouldKeepRule = false;
|
|
||||||
} else {
|
|
||||||
modifiedConditions = cleanedConditions;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (shouldKeepRule) {
|
|
||||||
cleanedLogic.push({
|
|
||||||
...logicRule,
|
|
||||||
conditions: modifiedConditions,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (cleanedLogic.length === 0) {
|
|
||||||
delete question.logic;
|
|
||||||
} else {
|
|
||||||
question.logic = cleanedLogic;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const processCTAQuestions = (questions: Record<string, unknown>[]): void => {
|
|
||||||
const ctaQuestions = new Map<string, boolean>();
|
|
||||||
|
|
||||||
questions.forEach((question) => {
|
|
||||||
if (question.type === "cta") {
|
|
||||||
const hasExternalButton = question.buttonExternal === true && Boolean(question.buttonUrl);
|
|
||||||
ctaQuestions.set(question.id as string, hasExternalButton);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (ctaQuestions.size === 0) return;
|
|
||||||
|
|
||||||
questions.forEach((question) => {
|
|
||||||
migrateCTAQuestion(question);
|
|
||||||
});
|
|
||||||
|
|
||||||
questions.forEach((question) => {
|
|
||||||
cleanCTALogicFromQuestion(question, ctaQuestions);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getBlockName = (questionIdx: number): string => {
|
|
||||||
return `Block ${String(questionIdx + 1)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateLogicActions = (
|
|
||||||
actions: TSurveyLogicAction[],
|
|
||||||
questionIdToBlockId: Map<string, string>,
|
|
||||||
endingIds: Set<string>
|
|
||||||
): TSurveyBlockLogicAction[] => {
|
|
||||||
return actions.map((action) => {
|
|
||||||
if (action.objective === "jumpToQuestion") {
|
|
||||||
const target = action.target;
|
|
||||||
const blockId = questionIdToBlockId.get(target);
|
|
||||||
|
|
||||||
if (blockId) {
|
|
||||||
return {
|
|
||||||
...action,
|
|
||||||
objective: "jumpToBlock",
|
|
||||||
target: blockId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endingIds.has(target)) {
|
|
||||||
return {
|
|
||||||
...action,
|
|
||||||
objective: "jumpToBlock",
|
|
||||||
target,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...action,
|
|
||||||
objective: "jumpToBlock",
|
|
||||||
target,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return action as TSurveyBlockLogicAction;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateLogicFallback = (
|
|
||||||
fallback: string,
|
|
||||||
questionIdToBlockId: Map<string, string>,
|
|
||||||
endingIds: Set<string>
|
|
||||||
): string | undefined => {
|
|
||||||
const blockId = questionIdToBlockId.get(fallback);
|
|
||||||
|
|
||||||
if (blockId) {
|
|
||||||
return blockId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endingIds.has(fallback)) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const convertQuestionToElementType = (condition: Condition | null | undefined): Condition | null => {
|
|
||||||
if (!condition) return null;
|
|
||||||
|
|
||||||
if (isSingleCondition(condition)) {
|
|
||||||
const newCondition = { ...condition } as Record<string, unknown>;
|
|
||||||
const leftOperand = { ...condition.leftOperand } as Record<string, unknown>;
|
|
||||||
|
|
||||||
if ((leftOperand.type as string) === "question") {
|
|
||||||
leftOperand.type = "element";
|
|
||||||
}
|
|
||||||
newCondition.leftOperand = leftOperand;
|
|
||||||
|
|
||||||
if (condition.rightOperand) {
|
|
||||||
const rightOperand = { ...condition.rightOperand } as Record<string, unknown>;
|
|
||||||
if ((rightOperand.type as string) === "question") {
|
|
||||||
rightOperand.type = "element";
|
|
||||||
}
|
|
||||||
newCondition.rightOperand = rightOperand;
|
|
||||||
}
|
|
||||||
|
|
||||||
return newCondition as TSingleCondition;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isConditionGroup(condition)) {
|
|
||||||
const newConditionGroup: TConditionGroup = {
|
|
||||||
...condition,
|
|
||||||
conditions: condition.conditions.map((nestedCondition) => {
|
|
||||||
const converted = convertQuestionToElementType(nestedCondition);
|
|
||||||
return converted ?? nestedCondition;
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
return newConditionGroup;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const convertElementToQuestionType = (condition: Condition | null | undefined): Condition | null => {
|
|
||||||
if (!condition) return null;
|
|
||||||
|
|
||||||
if (isSingleCondition(condition)) {
|
|
||||||
const newCondition = { ...condition } as Record<string, unknown>;
|
|
||||||
const leftOperand = { ...condition.leftOperand } as Record<string, unknown>;
|
|
||||||
|
|
||||||
newCondition.leftOperand = {
|
|
||||||
...leftOperand,
|
|
||||||
type: leftOperand.type === "element" ? "question" : leftOperand.type,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (condition.rightOperand) {
|
|
||||||
const rightOperand = { ...condition.rightOperand } as Record<string, unknown>;
|
|
||||||
newCondition.rightOperand = {
|
|
||||||
...rightOperand,
|
|
||||||
type: rightOperand.type === "element" ? "question" : rightOperand.type,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return newCondition as TSingleCondition;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isConditionGroup(condition)) {
|
|
||||||
const newConditionGroup: TConditionGroup = {
|
|
||||||
...condition,
|
|
||||||
conditions: condition.conditions.map((nestedCondition) => {
|
|
||||||
const converted = convertElementToQuestionType(nestedCondition);
|
|
||||||
return converted ?? nestedCondition;
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
return newConditionGroup;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const reverseLogicActions = (
|
|
||||||
actions: TSurveyBlockLogicAction[],
|
|
||||||
blockIdToQuestionId: Map<string, string>,
|
|
||||||
endingIds: Set<string>
|
|
||||||
): TSurveyLogicAction[] => {
|
|
||||||
return actions.map((action) => {
|
|
||||||
if (action.objective === "jumpToBlock") {
|
|
||||||
const target = action.target;
|
|
||||||
const questionId = blockIdToQuestionId.get(target);
|
|
||||||
|
|
||||||
if (questionId) {
|
|
||||||
return {
|
|
||||||
...action,
|
|
||||||
objective: "jumpToQuestion",
|
|
||||||
target: questionId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endingIds.has(target)) {
|
|
||||||
return {
|
|
||||||
...action,
|
|
||||||
objective: "jumpToQuestion",
|
|
||||||
target,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...action,
|
|
||||||
objective: "jumpToQuestion",
|
|
||||||
target,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return action;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const reverseLogicFallback = (
|
|
||||||
fallback: string,
|
|
||||||
blockIdToQuestionId: Map<string, string>,
|
|
||||||
endingIds: Set<string>
|
|
||||||
): string | undefined => {
|
|
||||||
const questionId = blockIdToQuestionId.get(fallback);
|
|
||||||
|
|
||||||
if (questionId) {
|
|
||||||
return questionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endingIds.has(fallback)) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const transformQuestionsToBlocks = (
|
|
||||||
questions: TSurveyQuestion[],
|
|
||||||
endings: TSurveyEnding[] = []
|
|
||||||
): TSurveyBlock[] => {
|
|
||||||
if (questions.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const questionsCopy = structuredClone(questions);
|
|
||||||
|
|
||||||
processCTAQuestions(questionsCopy);
|
|
||||||
|
|
||||||
const endingIds = new Set<string>(endings.map((ending) => ending.id));
|
|
||||||
|
|
||||||
const questionIdToBlockId = new Map<string, string>();
|
|
||||||
const blocks: Record<string, unknown>[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < questionsCopy.length; i++) {
|
|
||||||
const question = questionsCopy[i];
|
|
||||||
|
|
||||||
const blockId = createId();
|
|
||||||
questionIdToBlockId.set(question.id as string, blockId);
|
|
||||||
|
|
||||||
const { logic, logicFallback, buttonLabel, backButtonLabel, ...baseElement } = question;
|
|
||||||
|
|
||||||
blocks.push({
|
|
||||||
id: blockId,
|
|
||||||
name: getBlockName(i),
|
|
||||||
elements: [baseElement],
|
|
||||||
buttonLabel,
|
|
||||||
backButtonLabel,
|
|
||||||
logic,
|
|
||||||
logicFallback,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const block of blocks) {
|
|
||||||
if (Array.isArray(block.logic) && block.logic.length > 0) {
|
|
||||||
block.logic = block.logic.map(
|
|
||||||
(item: { conditions: TConditionGroup; actions: TSurveyLogicAction[] }) => {
|
|
||||||
const updatedConditions = convertQuestionToElementType(item.conditions);
|
|
||||||
|
|
||||||
if (!updatedConditions || !isConditionGroup(updatedConditions)) {
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
conditions: updatedConditions,
|
|
||||||
actions: updateLogicActions(item.actions, questionIdToBlockId, endingIds),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof block.logicFallback === "string") {
|
|
||||||
block.logicFallback = updateLogicFallback(block.logicFallback, questionIdToBlockId, endingIds);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return blocks as TSurveyBlock[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const transformBlockLogicToQuestionLogic = (
|
|
||||||
blockLogic: TSurveyBlockLogic[],
|
|
||||||
blockIdToQuestionId: Map<string, string>,
|
|
||||||
endingIds: Set<string>
|
|
||||||
): unknown[] => {
|
|
||||||
return blockLogic.map((item) => {
|
|
||||||
const updatedConditions = convertElementToQuestionType(item.conditions);
|
|
||||||
|
|
||||||
if (!updatedConditions || !isConditionGroup(updatedConditions)) {
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
conditions: updatedConditions,
|
|
||||||
actions: reverseLogicActions(item.actions, blockIdToQuestionId, endingIds),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyBlockAttributesToElement = (
|
|
||||||
element: Record<string, unknown>,
|
|
||||||
block: TSurveyBlock,
|
|
||||||
blockIdToQuestionId: Map<string, string>,
|
|
||||||
endingIds: Set<string>
|
|
||||||
): void => {
|
|
||||||
if (element.type === "cta" && element.ctaButtonLabel) {
|
|
||||||
element.buttonLabel = element.ctaButtonLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(block.logic) && block.logic.length > 0) {
|
|
||||||
element.logic = transformBlockLogicToQuestionLogic(block.logic, blockIdToQuestionId, endingIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (block.logicFallback) {
|
|
||||||
element.logicFallback = reverseLogicFallback(block.logicFallback, blockIdToQuestionId, endingIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (block.buttonLabel) {
|
|
||||||
element.buttonLabel = block.buttonLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (block.backButtonLabel) {
|
|
||||||
element.backButtonLabel = block.backButtonLabel;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const transformBlocksToQuestions = (
|
|
||||||
blocks: TSurveyBlock[],
|
|
||||||
endings: TSurveyEnding[] = []
|
|
||||||
): TSurveyQuestion[] => {
|
|
||||||
if (blocks.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const endingIds = new Set<string>(endings.map((ending) => ending.id));
|
|
||||||
const questions: Record<string, unknown>[] = [];
|
|
||||||
|
|
||||||
const blockIdToQuestionId = blocks.reduce((acc, block) => {
|
|
||||||
if (block.elements.length === 0) return acc;
|
|
||||||
acc.set(block.id, block.elements[0].id);
|
|
||||||
return acc;
|
|
||||||
}, new Map<string, string>());
|
|
||||||
|
|
||||||
for (const block of blocks) {
|
|
||||||
if (block.elements.length === 0) continue;
|
|
||||||
|
|
||||||
const element = { ...block.elements[0] };
|
|
||||||
|
|
||||||
applyBlockAttributesToElement(element, block, blockIdToQuestionId, endingIds);
|
|
||||||
|
|
||||||
questions.push(element);
|
|
||||||
}
|
|
||||||
|
|
||||||
return questions as TSurveyQuestion[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const validateSurveyInput = (input: {
|
|
||||||
questions?: TSurveyQuestion[];
|
|
||||||
blocks?: TSurveyBlock[];
|
|
||||||
updateOnly?: boolean;
|
|
||||||
}): Result<{ hasQuestions: boolean; hasBlocks: boolean }, InvalidInputError> => {
|
|
||||||
const hasQuestions = Boolean(input.questions && input.questions.length > 0);
|
|
||||||
const hasBlocks = Boolean(input.blocks && input.blocks.length > 0);
|
|
||||||
|
|
||||||
if (hasQuestions && hasBlocks) {
|
|
||||||
return err(
|
|
||||||
new InvalidInputError(
|
|
||||||
"Cannot provide both questions and blocks. Please provide only one of these fields."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasQuestions && !hasBlocks && !input.updateOnly) {
|
|
||||||
return err(new InvalidInputError("Must provide either questions or blocks. Both cannot be empty."));
|
|
||||||
}
|
|
||||||
|
|
||||||
return ok({ hasQuestions, hasBlocks });
|
|
||||||
};
|
|
||||||
@@ -160,15 +160,15 @@ export const buildCTAElement = ({
|
|||||||
subheader,
|
subheader,
|
||||||
buttonExternal,
|
buttonExternal,
|
||||||
required,
|
required,
|
||||||
ctaButtonLabel,
|
dismissButtonLabel,
|
||||||
buttonUrl,
|
buttonUrl,
|
||||||
}: {
|
}: {
|
||||||
id?: string;
|
id?: string;
|
||||||
headline: string;
|
headline: string;
|
||||||
buttonExternal?: boolean;
|
buttonExternal: boolean;
|
||||||
subheader: string;
|
subheader: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
ctaButtonLabel?: string;
|
dismissButtonLabel?: string;
|
||||||
buttonUrl?: string;
|
buttonUrl?: string;
|
||||||
}): TSurveyCTAElement => {
|
}): TSurveyCTAElement => {
|
||||||
return {
|
return {
|
||||||
@@ -176,9 +176,9 @@ export const buildCTAElement = ({
|
|||||||
type: TSurveyElementTypeEnum.CTA,
|
type: TSurveyElementTypeEnum.CTA,
|
||||||
subheader: createI18nString(subheader, []),
|
subheader: createI18nString(subheader, []),
|
||||||
headline: createI18nString(headline, []),
|
headline: createI18nString(headline, []),
|
||||||
ctaButtonLabel: ctaButtonLabel ? createI18nString(ctaButtonLabel, []) : undefined,
|
dismissButtonLabel: dismissButtonLabel ? createI18nString(dismissButtonLabel, []) : undefined,
|
||||||
required: required ?? false,
|
required: required ?? false,
|
||||||
buttonExternal: buttonExternal ?? false,
|
buttonExternal,
|
||||||
buttonUrl,
|
buttonUrl,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -227,7 +227,7 @@ export const createBlockJumpLogic = (
|
|||||||
id: createId(),
|
id: createId(),
|
||||||
leftOperand: {
|
leftOperand: {
|
||||||
value: sourceElementId,
|
value: sourceElementId,
|
||||||
type: "element",
|
type: "question",
|
||||||
},
|
},
|
||||||
operator: operator,
|
operator: operator,
|
||||||
},
|
},
|
||||||
@@ -257,7 +257,7 @@ export const createBlockChoiceJumpLogic = (
|
|||||||
id: createId(),
|
id: createId(),
|
||||||
leftOperand: {
|
leftOperand: {
|
||||||
value: sourceElementId,
|
value: sourceElementId,
|
||||||
type: "element",
|
type: "question",
|
||||||
},
|
},
|
||||||
operator: "equals",
|
operator: "equals",
|
||||||
rightOperand: {
|
rightOperand: {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export const createJumpLogic = (
|
|||||||
id: createId(),
|
id: createId(),
|
||||||
leftOperand: {
|
leftOperand: {
|
||||||
value: sourceQuestionId,
|
value: sourceQuestionId,
|
||||||
type: "element",
|
type: "question",
|
||||||
},
|
},
|
||||||
operator: operator,
|
operator: operator,
|
||||||
},
|
},
|
||||||
@@ -57,7 +57,7 @@ export const createChoiceJumpLogic = (
|
|||||||
id: createId(),
|
id: createId(),
|
||||||
leftOperand: {
|
leftOperand: {
|
||||||
value: sourceQuestionId,
|
value: sourceQuestionId,
|
||||||
type: "element",
|
type: "question",
|
||||||
},
|
},
|
||||||
operator: "equals",
|
operator: "equals",
|
||||||
rightOperand: {
|
rightOperand: {
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import { TTag } from "@formbricks/types/tags";
|
|||||||
import {
|
import {
|
||||||
DateRange,
|
DateRange,
|
||||||
SelectedFilterValue,
|
SelectedFilterValue,
|
||||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||||
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ElementsComboBox";
|
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
||||||
import { generateElementAndFilterOptions, getFormattedFilters, getTodayDate } from "./surveys";
|
import { generateQuestionAndFilterOptions, getFormattedFilters, getTodayDate } from "./surveys";
|
||||||
|
|
||||||
describe("surveys", () => {
|
describe("surveys", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -45,12 +45,12 @@ describe("surveys", () => {
|
|||||||
status: "draft",
|
status: "draft",
|
||||||
} as unknown as TSurvey;
|
} as unknown as TSurvey;
|
||||||
|
|
||||||
const result = generateElementAndFilterOptions(survey, undefined, {}, {}, {}, []);
|
const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, {}, []);
|
||||||
|
|
||||||
expect(result.elementOptions.length).toBeGreaterThan(0);
|
expect(result.questionOptions.length).toBeGreaterThan(0);
|
||||||
expect(result.elementOptions[0].header).toBe(OptionsType.ELEMENTS);
|
expect(result.questionOptions[0].header).toBe(OptionsType.QUESTIONS);
|
||||||
expect(result.elementFilterOptions.length).toBe(1);
|
expect(result.questionFilterOptions.length).toBe(1);
|
||||||
expect(result.elementFilterOptions[0].id).toBe("q1");
|
expect(result.questionFilterOptions[0].id).toBe("q1");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should include tags in options when provided", () => {
|
test("should include tags in options when provided", () => {
|
||||||
@@ -69,9 +69,9 @@ describe("surveys", () => {
|
|||||||
{ id: "tag1", name: "Tag 1", environmentId: "env1", createdAt: new Date(), updatedAt: new Date() },
|
{ id: "tag1", name: "Tag 1", environmentId: "env1", createdAt: new Date(), updatedAt: new Date() },
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = generateElementAndFilterOptions(survey, tags, {}, {}, {}, []);
|
const result = generateQuestionAndFilterOptions(survey, tags, {}, {}, {}, []);
|
||||||
|
|
||||||
const tagsHeader = result.elementOptions.find((opt) => opt.header === OptionsType.TAGS);
|
const tagsHeader = result.questionOptions.find((opt) => opt.header === OptionsType.TAGS);
|
||||||
expect(tagsHeader).toBeDefined();
|
expect(tagsHeader).toBeDefined();
|
||||||
expect(tagsHeader?.option.length).toBe(1);
|
expect(tagsHeader?.option.length).toBe(1);
|
||||||
expect(tagsHeader?.option[0].label).toBe("Tag 1");
|
expect(tagsHeader?.option[0].label).toBe("Tag 1");
|
||||||
@@ -93,9 +93,9 @@ describe("surveys", () => {
|
|||||||
role: ["admin", "user"],
|
role: ["admin", "user"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = generateElementAndFilterOptions(survey, undefined, attributes, {}, {}, []);
|
const result = generateQuestionAndFilterOptions(survey, undefined, attributes, {}, {}, []);
|
||||||
|
|
||||||
const attributesHeader = result.elementOptions.find((opt) => opt.header === OptionsType.ATTRIBUTES);
|
const attributesHeader = result.questionOptions.find((opt) => opt.header === OptionsType.ATTRIBUTES);
|
||||||
expect(attributesHeader).toBeDefined();
|
expect(attributesHeader).toBeDefined();
|
||||||
expect(attributesHeader?.option.length).toBe(1);
|
expect(attributesHeader?.option.length).toBe(1);
|
||||||
expect(attributesHeader?.option[0].label).toBe("role");
|
expect(attributesHeader?.option[0].label).toBe("role");
|
||||||
@@ -117,9 +117,9 @@ describe("surveys", () => {
|
|||||||
source: ["web", "mobile"],
|
source: ["web", "mobile"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = generateElementAndFilterOptions(survey, undefined, {}, meta, {}, []);
|
const result = generateQuestionAndFilterOptions(survey, undefined, {}, meta, {}, []);
|
||||||
|
|
||||||
const metaHeader = result.elementOptions.find((opt) => opt.header === OptionsType.META);
|
const metaHeader = result.questionOptions.find((opt) => opt.header === OptionsType.META);
|
||||||
expect(metaHeader).toBeDefined();
|
expect(metaHeader).toBeDefined();
|
||||||
expect(metaHeader?.option.length).toBe(1);
|
expect(metaHeader?.option.length).toBe(1);
|
||||||
expect(metaHeader?.option[0].label).toBe("source");
|
expect(metaHeader?.option[0].label).toBe("source");
|
||||||
@@ -141,9 +141,9 @@ describe("surveys", () => {
|
|||||||
segment: ["free", "paid"],
|
segment: ["free", "paid"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = generateElementAndFilterOptions(survey, undefined, {}, {}, hiddenFields, []);
|
const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, hiddenFields, []);
|
||||||
|
|
||||||
const hiddenFieldsHeader = result.elementOptions.find(
|
const hiddenFieldsHeader = result.questionOptions.find(
|
||||||
(opt) => opt.header === OptionsType.HIDDEN_FIELDS
|
(opt) => opt.header === OptionsType.HIDDEN_FIELDS
|
||||||
);
|
);
|
||||||
expect(hiddenFieldsHeader).toBeDefined();
|
expect(hiddenFieldsHeader).toBeDefined();
|
||||||
@@ -164,9 +164,9 @@ describe("surveys", () => {
|
|||||||
languages: [{ language: { code: "en" } as unknown as TLanguage } as unknown as TSurveyLanguage],
|
languages: [{ language: { code: "en" } as unknown as TLanguage } as unknown as TSurveyLanguage],
|
||||||
} as unknown as TSurvey;
|
} as unknown as TSurvey;
|
||||||
|
|
||||||
const result = generateElementAndFilterOptions(survey, undefined, {}, {}, {}, []);
|
const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, {}, []);
|
||||||
|
|
||||||
const othersHeader = result.elementOptions.find((opt) => opt.header === OptionsType.OTHERS);
|
const othersHeader = result.questionOptions.find((opt) => opt.header === OptionsType.OTHERS);
|
||||||
expect(othersHeader).toBeDefined();
|
expect(othersHeader).toBeDefined();
|
||||||
expect(othersHeader?.option.some((o) => o.label === "Language")).toBeTruthy();
|
expect(othersHeader?.option.some((o) => o.label === "Language")).toBeTruthy();
|
||||||
});
|
});
|
||||||
@@ -262,13 +262,13 @@ describe("surveys", () => {
|
|||||||
status: "draft",
|
status: "draft",
|
||||||
} as unknown as TSurvey;
|
} as unknown as TSurvey;
|
||||||
|
|
||||||
const result = generateElementAndFilterOptions(survey, undefined, {}, {}, {}, []);
|
const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, {}, []);
|
||||||
|
|
||||||
expect(result.elementFilterOptions.length).toBe(8);
|
expect(result.questionFilterOptions.length).toBe(8);
|
||||||
expect(result.elementFilterOptions.some((o) => o.id === "q1")).toBeTruthy();
|
expect(result.questionFilterOptions.some((o) => o.id === "q1")).toBeTruthy();
|
||||||
expect(result.elementFilterOptions.some((o) => o.id === "q2")).toBeTruthy();
|
expect(result.questionFilterOptions.some((o) => o.id === "q2")).toBeTruthy();
|
||||||
expect(result.elementFilterOptions.some((o) => o.id === "q7")).toBeTruthy();
|
expect(result.questionFilterOptions.some((o) => o.id === "q7")).toBeTruthy();
|
||||||
expect(result.elementFilterOptions.some((o) => o.id === "q8")).toBeTruthy();
|
expect(result.questionFilterOptions.some((o) => o.id === "q8")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should provide extended filter options for URL meta field", () => {
|
test("should provide extended filter options for URL meta field", () => {
|
||||||
@@ -288,10 +288,10 @@ describe("surveys", () => {
|
|||||||
source: ["web", "mobile"],
|
source: ["web", "mobile"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = generateElementAndFilterOptions(survey, undefined, {}, meta, {}, []);
|
const result = generateQuestionAndFilterOptions(survey, undefined, {}, meta, {}, []);
|
||||||
|
|
||||||
const urlFilterOption = result.elementFilterOptions.find((o) => o.id === "url");
|
const urlFilterOption = result.questionFilterOptions.find((o) => o.id === "url");
|
||||||
const sourceFilterOption = result.elementFilterOptions.find((o) => o.id === "source");
|
const sourceFilterOption = result.questionFilterOptions.find((o) => o.id === "source");
|
||||||
|
|
||||||
expect(urlFilterOption).toBeDefined();
|
expect(urlFilterOption).toBeDefined();
|
||||||
expect(urlFilterOption?.filterOptions).toEqual([
|
expect(urlFilterOption?.filterOptions).toEqual([
|
||||||
@@ -308,64 +308,6 @@ describe("surveys", () => {
|
|||||||
expect(sourceFilterOption).toBeDefined();
|
expect(sourceFilterOption).toBeDefined();
|
||||||
expect(sourceFilterOption?.filterOptions).toEqual(["Equals", "Not equals"]);
|
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",
|
|
||||||
blocks: [],
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
environmentId: "env1",
|
|
||||||
status: "draft",
|
|
||||||
} as unknown as TSurvey;
|
|
||||||
|
|
||||||
const quotas = [{ id: "quota1" }];
|
|
||||||
|
|
||||||
const result = generateElementAndFilterOptions(survey, undefined, {}, {}, {}, quotas as any);
|
|
||||||
|
|
||||||
const quotaFilterOption = result.elementFilterOptions.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",
|
|
||||||
blocks: [],
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
environmentId: "env1",
|
|
||||||
status: "draft",
|
|
||||||
} as unknown as TSurvey;
|
|
||||||
|
|
||||||
const quotas = [{ id: "quota1" }, { id: "quota2" }];
|
|
||||||
|
|
||||||
const result = generateElementAndFilterOptions(survey, undefined, {}, {}, {}, quotas as any);
|
|
||||||
|
|
||||||
const quota1 = result.elementFilterOptions.find((o) => o.id === "quota1");
|
|
||||||
const quota2 = result.elementFilterOptions.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", () => {
|
describe("getFormattedFilters", () => {
|
||||||
@@ -538,11 +480,11 @@ describe("surveys", () => {
|
|||||||
responseStatus: "all",
|
responseStatus: "all",
|
||||||
filter: [
|
filter: [
|
||||||
{
|
{
|
||||||
elementType: { type: "Tags", label: "Tag 1", id: "tag1" },
|
questionType: { type: "Tags", label: "Tag 1", id: "tag1" },
|
||||||
filterType: { filterComboBoxValue: "Applied" },
|
filterType: { filterComboBoxValue: "Applied" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
elementType: { type: "Tags", label: "Tag 2", id: "tag2" },
|
questionType: { type: "Tags", label: "Tag 2", id: "tag2" },
|
||||||
filterType: { filterComboBoxValue: "Not applied" },
|
filterType: { filterComboBoxValue: "Not applied" },
|
||||||
},
|
},
|
||||||
] as any,
|
] as any,
|
||||||
@@ -559,11 +501,11 @@ describe("surveys", () => {
|
|||||||
responseStatus: "all",
|
responseStatus: "all",
|
||||||
filter: [
|
filter: [
|
||||||
{
|
{
|
||||||
elementType: {
|
questionType: {
|
||||||
type: "Elements",
|
type: "Questions",
|
||||||
label: "Open Text",
|
label: "Open Text",
|
||||||
id: "openTextQ",
|
id: "openTextQ",
|
||||||
elementType: TSurveyElementTypeEnum.OpenText,
|
questionType: TSurveyElementTypeEnum.OpenText,
|
||||||
},
|
},
|
||||||
filterType: { filterComboBoxValue: "Filled out" },
|
filterType: { filterComboBoxValue: "Filled out" },
|
||||||
},
|
},
|
||||||
@@ -580,11 +522,11 @@ describe("surveys", () => {
|
|||||||
responseStatus: "all",
|
responseStatus: "all",
|
||||||
filter: [
|
filter: [
|
||||||
{
|
{
|
||||||
elementType: {
|
questionType: {
|
||||||
type: "Elements",
|
type: "Questions",
|
||||||
label: "Address",
|
label: "Address",
|
||||||
id: "addressQ",
|
id: "addressQ",
|
||||||
elementType: TSurveyElementTypeEnum.Address,
|
questionType: TSurveyElementTypeEnum.Address,
|
||||||
},
|
},
|
||||||
filterType: { filterComboBoxValue: "Skipped" },
|
filterType: { filterComboBoxValue: "Skipped" },
|
||||||
},
|
},
|
||||||
@@ -601,11 +543,11 @@ describe("surveys", () => {
|
|||||||
responseStatus: "all",
|
responseStatus: "all",
|
||||||
filter: [
|
filter: [
|
||||||
{
|
{
|
||||||
elementType: {
|
questionType: {
|
||||||
type: "Elements",
|
type: "Questions",
|
||||||
label: "Contact Info",
|
label: "Contact Info",
|
||||||
id: "contactQ",
|
id: "contactQ",
|
||||||
elementType: TSurveyElementTypeEnum.ContactInfo,
|
questionType: TSurveyElementTypeEnum.ContactInfo,
|
||||||
},
|
},
|
||||||
filterType: { filterComboBoxValue: "Filled out" },
|
filterType: { filterComboBoxValue: "Filled out" },
|
||||||
},
|
},
|
||||||
@@ -622,11 +564,11 @@ describe("surveys", () => {
|
|||||||
responseStatus: "all",
|
responseStatus: "all",
|
||||||
filter: [
|
filter: [
|
||||||
{
|
{
|
||||||
elementType: {
|
questionType: {
|
||||||
type: "Elements",
|
type: "Questions",
|
||||||
label: "Ranking",
|
label: "Ranking",
|
||||||
id: "rankingQ",
|
id: "rankingQ",
|
||||||
elementType: TSurveyElementTypeEnum.Ranking,
|
questionType: TSurveyElementTypeEnum.Ranking,
|
||||||
},
|
},
|
||||||
filterType: { filterComboBoxValue: "Filled out" },
|
filterType: { filterComboBoxValue: "Filled out" },
|
||||||
},
|
},
|
||||||
@@ -643,11 +585,11 @@ describe("surveys", () => {
|
|||||||
responseStatus: "all",
|
responseStatus: "all",
|
||||||
filter: [
|
filter: [
|
||||||
{
|
{
|
||||||
elementType: {
|
questionType: {
|
||||||
type: "Elements",
|
type: "Questions",
|
||||||
label: "MC Single",
|
label: "MC Single",
|
||||||
id: "mcSingleQ",
|
id: "mcSingleQ",
|
||||||
elementType: TSurveyElementTypeEnum.MultipleChoiceSingle,
|
questionType: TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||||
},
|
},
|
||||||
filterType: { filterValue: "Includes either", filterComboBoxValue: ["Choice 1"] },
|
filterType: { filterValue: "Includes either", filterComboBoxValue: ["Choice 1"] },
|
||||||
},
|
},
|
||||||
@@ -664,11 +606,11 @@ describe("surveys", () => {
|
|||||||
responseStatus: "all",
|
responseStatus: "all",
|
||||||
filter: [
|
filter: [
|
||||||
{
|
{
|
||||||
elementType: {
|
questionType: {
|
||||||
type: "Elements",
|
type: "Questions",
|
||||||
label: "MC Multi",
|
label: "MC Multi",
|
||||||
id: "mcMultiQ",
|
id: "mcMultiQ",
|
||||||
elementType: TSurveyElementTypeEnum.MultipleChoiceMulti,
|
questionType: TSurveyElementTypeEnum.MultipleChoiceMulti,
|
||||||
},
|
},
|
||||||
filterType: { filterValue: "Includes all", filterComboBoxValue: ["Choice 1", "Choice 2"] },
|
filterType: { filterValue: "Includes all", filterComboBoxValue: ["Choice 1", "Choice 2"] },
|
||||||
},
|
},
|
||||||
@@ -685,11 +627,11 @@ describe("surveys", () => {
|
|||||||
responseStatus: "all",
|
responseStatus: "all",
|
||||||
filter: [
|
filter: [
|
||||||
{
|
{
|
||||||
elementType: {
|
questionType: {
|
||||||
type: "Elements",
|
type: "Questions",
|
||||||
label: "NPS",
|
label: "NPS",
|
||||||
id: "npsQ",
|
id: "npsQ",
|
||||||
elementType: TSurveyElementTypeEnum.NPS,
|
questionType: TSurveyElementTypeEnum.NPS,
|
||||||
},
|
},
|
||||||
filterType: { filterValue: "Is equal to", filterComboBoxValue: "7" },
|
filterType: { filterValue: "Is equal to", filterComboBoxValue: "7" },
|
||||||
},
|
},
|
||||||
@@ -706,11 +648,11 @@ describe("surveys", () => {
|
|||||||
responseStatus: "all",
|
responseStatus: "all",
|
||||||
filter: [
|
filter: [
|
||||||
{
|
{
|
||||||
elementType: {
|
questionType: {
|
||||||
type: "Elements",
|
type: "Questions",
|
||||||
label: "Rating",
|
label: "Rating",
|
||||||
id: "ratingQ",
|
id: "ratingQ",
|
||||||
elementType: TSurveyElementTypeEnum.Rating,
|
questionType: TSurveyElementTypeEnum.Rating,
|
||||||
},
|
},
|
||||||
filterType: { filterValue: "Is less than", filterComboBoxValue: "4" },
|
filterType: { filterValue: "Is less than", filterComboBoxValue: "4" },
|
||||||
},
|
},
|
||||||
@@ -727,11 +669,11 @@ describe("surveys", () => {
|
|||||||
responseStatus: "all",
|
responseStatus: "all",
|
||||||
filter: [
|
filter: [
|
||||||
{
|
{
|
||||||
elementType: {
|
questionType: {
|
||||||
type: "Elements",
|
type: "Questions",
|
||||||
label: "CTA",
|
label: "CTA",
|
||||||
id: "ctaQ",
|
id: "ctaQ",
|
||||||
elementType: TSurveyElementTypeEnum.CTA,
|
questionType: TSurveyElementTypeEnum.CTA,
|
||||||
},
|
},
|
||||||
filterType: { filterComboBoxValue: "Clicked" },
|
filterType: { filterComboBoxValue: "Clicked" },
|
||||||
},
|
},
|
||||||
@@ -748,11 +690,11 @@ describe("surveys", () => {
|
|||||||
responseStatus: "all",
|
responseStatus: "all",
|
||||||
filter: [
|
filter: [
|
||||||
{
|
{
|
||||||
elementType: {
|
questionType: {
|
||||||
type: "Elements",
|
type: "Questions",
|
||||||
label: "Consent",
|
label: "Consent",
|
||||||
id: "consentQ",
|
id: "consentQ",
|
||||||
elementType: TSurveyElementTypeEnum.Consent,
|
questionType: TSurveyElementTypeEnum.Consent,
|
||||||
},
|
},
|
||||||
filterType: { filterComboBoxValue: "Accepted" },
|
filterType: { filterComboBoxValue: "Accepted" },
|
||||||
},
|
},
|
||||||
@@ -769,11 +711,11 @@ describe("surveys", () => {
|
|||||||
responseStatus: "all",
|
responseStatus: "all",
|
||||||
filter: [
|
filter: [
|
||||||
{
|
{
|
||||||
elementType: {
|
questionType: {
|
||||||
type: "Elements",
|
type: "Questions",
|
||||||
label: "Picture",
|
label: "Picture",
|
||||||
id: "pictureQ",
|
id: "pictureQ",
|
||||||
elementType: TSurveyElementTypeEnum.PictureSelection,
|
questionType: TSurveyElementTypeEnum.PictureSelection,
|
||||||
},
|
},
|
||||||
filterType: { filterValue: "Includes either", filterComboBoxValue: ["Picture 1"] },
|
filterType: { filterValue: "Includes either", filterComboBoxValue: ["Picture 1"] },
|
||||||
},
|
},
|
||||||
@@ -790,11 +732,11 @@ describe("surveys", () => {
|
|||||||
responseStatus: "all",
|
responseStatus: "all",
|
||||||
filter: [
|
filter: [
|
||||||
{
|
{
|
||||||
elementType: {
|
questionType: {
|
||||||
type: "Elements",
|
type: "Questions",
|
||||||
label: "Matrix",
|
label: "Matrix",
|
||||||
id: "matrixQ",
|
id: "matrixQ",
|
||||||
elementType: TSurveyElementTypeEnum.Matrix,
|
questionType: TSurveyElementTypeEnum.Matrix,
|
||||||
},
|
},
|
||||||
filterType: { filterValue: "Row 1", filterComboBoxValue: "Column 1" },
|
filterType: { filterValue: "Row 1", filterComboBoxValue: "Column 1" },
|
||||||
},
|
},
|
||||||
@@ -811,7 +753,7 @@ describe("surveys", () => {
|
|||||||
responseStatus: "all",
|
responseStatus: "all",
|
||||||
filter: [
|
filter: [
|
||||||
{
|
{
|
||||||
elementType: { type: "Hidden Fields", label: "plan", id: "plan" },
|
questionType: { type: "Hidden Fields", label: "plan", id: "plan" },
|
||||||
filterType: { filterValue: "Equals", filterComboBoxValue: "pro" },
|
filterType: { filterValue: "Equals", filterComboBoxValue: "pro" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -827,7 +769,7 @@ describe("surveys", () => {
|
|||||||
responseStatus: "all",
|
responseStatus: "all",
|
||||||
filter: [
|
filter: [
|
||||||
{
|
{
|
||||||
elementType: { type: "Attributes", label: "role", id: "role" },
|
questionType: { type: "Attributes", label: "role", id: "role" },
|
||||||
filterType: { filterValue: "Not equals", filterComboBoxValue: "admin" },
|
filterType: { filterValue: "Not equals", filterComboBoxValue: "admin" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -843,7 +785,7 @@ describe("surveys", () => {
|
|||||||
responseStatus: "all",
|
responseStatus: "all",
|
||||||
filter: [
|
filter: [
|
||||||
{
|
{
|
||||||
elementType: { type: "Other Filters", label: "Language", id: "language" },
|
questionType: { type: "Other Filters", label: "Language", id: "language" },
|
||||||
filterType: { filterValue: "Equals", filterComboBoxValue: "en" },
|
filterType: { filterValue: "Equals", filterComboBoxValue: "en" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -859,7 +801,7 @@ describe("surveys", () => {
|
|||||||
responseStatus: "all",
|
responseStatus: "all",
|
||||||
filter: [
|
filter: [
|
||||||
{
|
{
|
||||||
elementType: { type: "Meta", label: "source", id: "source" },
|
questionType: { type: "Meta", label: "source", id: "source" },
|
||||||
filterType: { filterValue: "Not equals", filterComboBoxValue: "web" },
|
filterType: { filterValue: "Not equals", filterComboBoxValue: "web" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -875,16 +817,16 @@ describe("surveys", () => {
|
|||||||
responseStatus: "complete",
|
responseStatus: "complete",
|
||||||
filter: [
|
filter: [
|
||||||
{
|
{
|
||||||
elementType: {
|
questionType: {
|
||||||
type: "Elements",
|
type: "Questions",
|
||||||
label: "NPS",
|
label: "NPS",
|
||||||
id: "npsQ",
|
id: "npsQ",
|
||||||
elementType: TSurveyElementTypeEnum.NPS,
|
questionType: TSurveyElementTypeEnum.NPS,
|
||||||
},
|
},
|
||||||
filterType: { filterValue: "Is more than", filterComboBoxValue: "7" },
|
filterType: { filterValue: "Is more than", filterComboBoxValue: "7" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
elementType: { type: "Tags", label: "Tag 1", id: "tag1" },
|
questionType: { type: "Tags", label: "Tag 1", id: "tag1" },
|
||||||
filterType: { filterComboBoxValue: "Applied" },
|
filterType: { filterComboBoxValue: "Applied" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -903,7 +845,7 @@ describe("surveys", () => {
|
|||||||
responseStatus: "all",
|
responseStatus: "all",
|
||||||
filter: [
|
filter: [
|
||||||
{
|
{
|
||||||
elementType: { type: "Meta", label: "url", id: "url" },
|
questionType: { type: "Meta", label: "url", id: "url" },
|
||||||
filterType: { filterValue: "Contains", filterComboBoxValue: "example.com" },
|
filterType: { filterValue: "Contains", filterComboBoxValue: "example.com" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -931,7 +873,7 @@ describe("surveys", () => {
|
|||||||
responseStatus: "all",
|
responseStatus: "all",
|
||||||
filter: [
|
filter: [
|
||||||
{
|
{
|
||||||
elementType: { type: "Meta", label: "url", id: "url" },
|
questionType: { type: "Meta", label: "url", id: "url" },
|
||||||
filterType: { filterValue, filterComboBoxValue: expected.value },
|
filterType: { filterValue, filterComboBoxValue: expected.value },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -947,7 +889,7 @@ describe("surveys", () => {
|
|||||||
responseStatus: "all",
|
responseStatus: "all",
|
||||||
filter: [
|
filter: [
|
||||||
{
|
{
|
||||||
elementType: { type: "Meta", label: "url", id: "url" },
|
questionType: { type: "Meta", label: "url", id: "url" },
|
||||||
filterType: { filterValue: "Contains", filterComboBoxValue: "" },
|
filterType: { filterValue: "Contains", filterComboBoxValue: "" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -963,7 +905,7 @@ describe("surveys", () => {
|
|||||||
responseStatus: "all",
|
responseStatus: "all",
|
||||||
filter: [
|
filter: [
|
||||||
{
|
{
|
||||||
elementType: { type: "Meta", label: "url", id: "url" },
|
questionType: { type: "Meta", label: "url", id: "url" },
|
||||||
filterType: { filterValue: "Contains", filterComboBoxValue: " " },
|
filterType: { filterValue: "Contains", filterComboBoxValue: " " },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -979,7 +921,7 @@ describe("surveys", () => {
|
|||||||
responseStatus: "all",
|
responseStatus: "all",
|
||||||
filter: [
|
filter: [
|
||||||
{
|
{
|
||||||
elementType: { type: "Meta", label: "source", id: "source" },
|
questionType: { type: "Meta", label: "source", id: "source" },
|
||||||
filterType: { filterValue: "Equals", filterComboBoxValue: ["google"] },
|
filterType: { filterValue: "Equals", filterComboBoxValue: ["google"] },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -995,11 +937,11 @@ describe("surveys", () => {
|
|||||||
responseStatus: "all",
|
responseStatus: "all",
|
||||||
filter: [
|
filter: [
|
||||||
{
|
{
|
||||||
elementType: { type: "Meta", label: "url", id: "url" },
|
questionType: { type: "Meta", label: "url", id: "url" },
|
||||||
filterType: { filterValue: "Contains", filterComboBoxValue: "formbricks.com" },
|
filterType: { filterValue: "Contains", filterComboBoxValue: "formbricks.com" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
elementType: { type: "Meta", label: "source", id: "source" },
|
questionType: { type: "Meta", label: "source", id: "source" },
|
||||||
filterType: { filterValue: "Equals", filterComboBoxValue: ["newsletter"] },
|
filterType: { filterValue: "Equals", filterComboBoxValue: ["newsletter"] },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1010,75 +952,6 @@ describe("surveys", () => {
|
|||||||
expect(result.meta?.url).toEqual({ op: "contains", value: "formbricks.com" });
|
expect(result.meta?.url).toEqual({ op: "contains", value: "formbricks.com" });
|
||||||
expect(result.meta?.source).toEqual({ op: "equals", value: "newsletter" });
|
expect(result.meta?.source).toEqual({ op: "equals", value: "newsletter" });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should filter by quota with screened in status", () => {
|
|
||||||
const selectedFilter: SelectedFilterValue = {
|
|
||||||
responseStatus: "all",
|
|
||||||
filter: [
|
|
||||||
{
|
|
||||||
elementType: { 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: [
|
|
||||||
{
|
|
||||||
elementType: { 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: [
|
|
||||||
{
|
|
||||||
elementType: { 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: [
|
|
||||||
{
|
|
||||||
elementType: { type: "Quotas", label: "Quota 1", id: "quota1" },
|
|
||||||
filterType: { filterComboBoxValue: "Screened in" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
elementType: { 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", () => {
|
describe("getTodayDate", () => {
|
||||||
|
|||||||
+321
-398
@@ -13,18 +13,18 @@ import {
|
|||||||
DateRange,
|
DateRange,
|
||||||
FilterValue,
|
FilterValue,
|
||||||
SelectedFilterValue,
|
SelectedFilterValue,
|
||||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||||
import {
|
import {
|
||||||
ElementOption,
|
|
||||||
ElementOptions,
|
|
||||||
OptionsType,
|
OptionsType,
|
||||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ElementsComboBox";
|
QuestionOption,
|
||||||
import { ElementFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter";
|
QuestionOptions,
|
||||||
|
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
||||||
|
import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter";
|
||||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { recallToHeadline } from "@/lib/utils/recall";
|
||||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||||
|
|
||||||
const conditionOptions: Record<string, string[]> = {
|
const conditionOptions = {
|
||||||
openText: ["is"],
|
openText: ["is"],
|
||||||
multipleChoiceSingle: ["Includes either"],
|
multipleChoiceSingle: ["Includes either"],
|
||||||
multipleChoiceMulti: ["Includes all", "Includes either"],
|
multipleChoiceMulti: ["Includes all", "Includes either"],
|
||||||
@@ -41,7 +41,7 @@ const conditionOptions: Record<string, string[]> = {
|
|||||||
contactInfo: ["is"],
|
contactInfo: ["is"],
|
||||||
ranking: ["is"],
|
ranking: ["is"],
|
||||||
};
|
};
|
||||||
const filterOptions: Record<string, string[]> = {
|
const filterOptions = {
|
||||||
openText: ["Filled out", "Skipped"],
|
openText: ["Filled out", "Skipped"],
|
||||||
rating: ["1", "2", "3", "4", "5"],
|
rating: ["1", "2", "3", "4", "5"],
|
||||||
nps: ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"],
|
nps: ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"],
|
||||||
@@ -53,51 +53,6 @@ const filterOptions: Record<string, string[]> = {
|
|||||||
ranking: ["Filled out", "Skipped"],
|
ranking: ["Filled out", "Skipped"],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to get filter options for a specific element type
|
|
||||||
const getElementFilterOption = (
|
|
||||||
element: ReturnType<typeof getElementsFromBlocks>[number]
|
|
||||||
): ElementFilterOptions | null => {
|
|
||||||
if (!Object.keys(conditionOptions).includes(element.type)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseOption = {
|
|
||||||
type: element.type,
|
|
||||||
filterOptions: conditionOptions[element.type],
|
|
||||||
id: element.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (element.type) {
|
|
||||||
case TSurveyElementTypeEnum.MultipleChoiceSingle:
|
|
||||||
return {
|
|
||||||
...baseOption,
|
|
||||||
filterComboBoxOptions: element.choices?.map((c) => c.label) ?? [""],
|
|
||||||
};
|
|
||||||
case TSurveyElementTypeEnum.MultipleChoiceMulti:
|
|
||||||
return {
|
|
||||||
...baseOption,
|
|
||||||
filterComboBoxOptions: element.choices?.filter((c) => c.id !== "other").map((c) => c.label) ?? [""],
|
|
||||||
};
|
|
||||||
case TSurveyElementTypeEnum.PictureSelection:
|
|
||||||
return {
|
|
||||||
...baseOption,
|
|
||||||
filterComboBoxOptions: element.choices?.map((_, idx) => `Picture ${idx + 1}`) ?? [""],
|
|
||||||
};
|
|
||||||
case TSurveyElementTypeEnum.Matrix:
|
|
||||||
return {
|
|
||||||
type: element.type,
|
|
||||||
filterOptions: element.rows.map((row) => getLocalizedValue(row.label, "default")),
|
|
||||||
filterComboBoxOptions: element.columns.map((column) => getLocalizedValue(column.label, "default")),
|
|
||||||
id: element.id,
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
...baseOption,
|
|
||||||
filterComboBoxOptions: filterOptions[element.type],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// URL/meta text operators mapping
|
// URL/meta text operators mapping
|
||||||
const META_OP_MAP = {
|
const META_OP_MAP = {
|
||||||
Equals: "equals",
|
Equals: "equals",
|
||||||
@@ -110,7 +65,8 @@ const META_OP_MAP = {
|
|||||||
"Does not end with": "doesNotEndWith",
|
"Does not end with": "doesNotEndWith",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const generateElementAndFilterOptions = (
|
// creating the options for the filtering to be selected there are 4 types questions, attributes, tags and metadata
|
||||||
|
export const generateQuestionAndFilterOptions = (
|
||||||
survey: TSurvey,
|
survey: TSurvey,
|
||||||
environmentTags: TTag[] | undefined,
|
environmentTags: TTag[] | undefined,
|
||||||
attributes: TSurveyContactAttributes,
|
attributes: TSurveyContactAttributes,
|
||||||
@@ -118,32 +74,68 @@ export const generateElementAndFilterOptions = (
|
|||||||
hiddenFields: TResponseHiddenFieldsFilter,
|
hiddenFields: TResponseHiddenFieldsFilter,
|
||||||
quotas: TSurveyQuota[]
|
quotas: TSurveyQuota[]
|
||||||
): {
|
): {
|
||||||
elementOptions: ElementOptions[];
|
questionOptions: QuestionOptions[];
|
||||||
elementFilterOptions: ElementFilterOptions[];
|
questionFilterOptions: QuestionFilterOptions[];
|
||||||
} => {
|
} => {
|
||||||
let elementOptions: ElementOptions[] = [];
|
let questionOptions: QuestionOptions[] = [];
|
||||||
let elementFilterOptions: ElementFilterOptions[] = [];
|
let questionFilterOptions: any = [];
|
||||||
let elementsOptions: ElementOption[] = [];
|
|
||||||
|
|
||||||
const elements = getElementsFromBlocks(survey.blocks);
|
let questionsOptions: any = [];
|
||||||
|
const questions = getElementsFromBlocks(survey.blocks);
|
||||||
|
|
||||||
elements.forEach((q) => {
|
questions.forEach((q) => {
|
||||||
if (Object.keys(conditionOptions).includes(q.type)) {
|
if (Object.keys(conditionOptions).includes(q.type)) {
|
||||||
elementsOptions.push({
|
questionsOptions.push({
|
||||||
label: getTextContent(
|
label: getTextContent(
|
||||||
getLocalizedValue(recallToHeadline(q.headline, survey, false, "default"), "default")
|
getLocalizedValue(recallToHeadline(q.headline, survey, false, "default"), "default")
|
||||||
),
|
),
|
||||||
elementType: q.type,
|
questionType: q.type,
|
||||||
type: OptionsType.ELEMENTS,
|
type: OptionsType.QUESTIONS,
|
||||||
id: q.id,
|
id: q.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
elementOptions = [...elementOptions, { header: OptionsType.ELEMENTS, option: elementsOptions }];
|
questionOptions = [...questionOptions, { header: OptionsType.QUESTIONS, option: questionsOptions }];
|
||||||
elements.forEach((q) => {
|
questions.forEach((q) => {
|
||||||
const filterOption = getElementFilterOption(q);
|
if (Object.keys(conditionOptions).includes(q.type)) {
|
||||||
if (filterOption) {
|
if (q.type === TSurveyElementTypeEnum.MultipleChoiceSingle) {
|
||||||
elementFilterOptions.push(filterOption);
|
questionFilterOptions.push({
|
||||||
|
type: q.type,
|
||||||
|
filterOptions: conditionOptions[q.type],
|
||||||
|
filterComboBoxOptions: q?.choices ? q?.choices?.map((c) => c?.label) : [""],
|
||||||
|
id: q.id,
|
||||||
|
});
|
||||||
|
} else if (q.type === TSurveyElementTypeEnum.MultipleChoiceMulti) {
|
||||||
|
questionFilterOptions.push({
|
||||||
|
type: q.type,
|
||||||
|
filterOptions: conditionOptions[q.type],
|
||||||
|
filterComboBoxOptions: q?.choices
|
||||||
|
? q?.choices?.filter((c) => c.id !== "other")?.map((c) => c?.label)
|
||||||
|
: [""],
|
||||||
|
id: q.id,
|
||||||
|
});
|
||||||
|
} else if (q.type === TSurveyElementTypeEnum.PictureSelection) {
|
||||||
|
questionFilterOptions.push({
|
||||||
|
type: q.type,
|
||||||
|
filterOptions: conditionOptions[q.type],
|
||||||
|
filterComboBoxOptions: q?.choices ? q?.choices?.map((_, idx) => `Picture ${idx + 1}`) : [""],
|
||||||
|
id: q.id,
|
||||||
|
});
|
||||||
|
} else if (q.type === TSurveyElementTypeEnum.Matrix) {
|
||||||
|
questionFilterOptions.push({
|
||||||
|
type: q.type,
|
||||||
|
filterOptions: q.rows.flatMap((row) => Object.values(row)),
|
||||||
|
filterComboBoxOptions: q.columns.flatMap((column) => Object.values(column)),
|
||||||
|
id: q.id,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
questionFilterOptions.push({
|
||||||
|
type: q.type,
|
||||||
|
filterOptions: conditionOptions[q.type],
|
||||||
|
filterComboBoxOptions: filterOptions[q.type],
|
||||||
|
id: q.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -151,9 +143,9 @@ export const generateElementAndFilterOptions = (
|
|||||||
return { label: t.name, type: OptionsType.TAGS, id: t.id };
|
return { label: t.name, type: OptionsType.TAGS, id: t.id };
|
||||||
});
|
});
|
||||||
if (tagsOptions && tagsOptions?.length > 0) {
|
if (tagsOptions && tagsOptions?.length > 0) {
|
||||||
elementOptions = [...elementOptions, { header: OptionsType.TAGS, option: tagsOptions }];
|
questionOptions = [...questionOptions, { header: OptionsType.TAGS, option: tagsOptions }];
|
||||||
environmentTags?.forEach((t) => {
|
environmentTags?.forEach((t) => {
|
||||||
elementFilterOptions.push({
|
questionFilterOptions.push({
|
||||||
type: "Tags",
|
type: "Tags",
|
||||||
filterOptions: conditionOptions.tags,
|
filterOptions: conditionOptions.tags,
|
||||||
filterComboBoxOptions: filterOptions.tags,
|
filterComboBoxOptions: filterOptions.tags,
|
||||||
@@ -163,8 +155,8 @@ export const generateElementAndFilterOptions = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (attributes) {
|
if (attributes) {
|
||||||
elementOptions = [
|
questionOptions = [
|
||||||
...elementOptions,
|
...questionOptions,
|
||||||
{
|
{
|
||||||
header: OptionsType.ATTRIBUTES,
|
header: OptionsType.ATTRIBUTES,
|
||||||
option: Object.keys(attributes).map((a) => {
|
option: Object.keys(attributes).map((a) => {
|
||||||
@@ -173,7 +165,7 @@ export const generateElementAndFilterOptions = (
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
Object.keys(attributes).forEach((a) => {
|
Object.keys(attributes).forEach((a) => {
|
||||||
elementFilterOptions.push({
|
questionFilterOptions.push({
|
||||||
type: "Attributes",
|
type: "Attributes",
|
||||||
filterOptions: conditionOptions.userAttributes,
|
filterOptions: conditionOptions.userAttributes,
|
||||||
filterComboBoxOptions: attributes[a],
|
filterComboBoxOptions: attributes[a],
|
||||||
@@ -183,8 +175,8 @@ export const generateElementAndFilterOptions = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (meta) {
|
if (meta) {
|
||||||
elementOptions = [
|
questionOptions = [
|
||||||
...elementOptions,
|
...questionOptions,
|
||||||
{
|
{
|
||||||
header: OptionsType.META,
|
header: OptionsType.META,
|
||||||
option: Object.keys(meta).map((m) => {
|
option: Object.keys(meta).map((m) => {
|
||||||
@@ -193,7 +185,7 @@ export const generateElementAndFilterOptions = (
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
Object.keys(meta).forEach((m) => {
|
Object.keys(meta).forEach((m) => {
|
||||||
elementFilterOptions.push({
|
questionFilterOptions.push({
|
||||||
type: "Meta",
|
type: "Meta",
|
||||||
filterOptions: m === "url" ? Object.keys(META_OP_MAP) : ["Equals", "Not equals"],
|
filterOptions: m === "url" ? Object.keys(META_OP_MAP) : ["Equals", "Not equals"],
|
||||||
filterComboBoxOptions: meta[m],
|
filterComboBoxOptions: meta[m],
|
||||||
@@ -203,8 +195,8 @@ export const generateElementAndFilterOptions = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hiddenFields) {
|
if (hiddenFields) {
|
||||||
elementOptions = [
|
questionOptions = [
|
||||||
...elementOptions,
|
...questionOptions,
|
||||||
{
|
{
|
||||||
header: OptionsType.HIDDEN_FIELDS,
|
header: OptionsType.HIDDEN_FIELDS,
|
||||||
option: Object.keys(hiddenFields).map((hiddenField) => {
|
option: Object.keys(hiddenFields).map((hiddenField) => {
|
||||||
@@ -213,7 +205,7 @@ export const generateElementAndFilterOptions = (
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
Object.keys(hiddenFields).forEach((hiddenField) => {
|
Object.keys(hiddenFields).forEach((hiddenField) => {
|
||||||
elementFilterOptions.push({
|
questionFilterOptions.push({
|
||||||
type: "Hidden Fields",
|
type: "Hidden Fields",
|
||||||
filterOptions: ["Equals", "Not equals"],
|
filterOptions: ["Equals", "Not equals"],
|
||||||
filterComboBoxOptions: hiddenFields[hiddenField],
|
filterComboBoxOptions: hiddenFields[hiddenField],
|
||||||
@@ -222,326 +214,38 @@ export const generateElementAndFilterOptions = (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let languageElement: ElementOption[] = [];
|
let languageQuestion: QuestionOption[] = [];
|
||||||
|
|
||||||
//can be extended to include more properties
|
//can be extended to include more properties
|
||||||
if (survey.languages?.length > 0) {
|
if (survey.languages?.length > 0) {
|
||||||
languageElement.push({ label: "Language", type: OptionsType.OTHERS, id: "language" });
|
languageQuestion.push({ label: "Language", type: OptionsType.OTHERS, id: "language" });
|
||||||
const languageOptions = survey.languages.map((sl) => sl.language.code);
|
const languageOptions = survey.languages.map((sl) => sl.language.code);
|
||||||
elementFilterOptions.push({
|
questionFilterOptions.push({
|
||||||
type: OptionsType.OTHERS,
|
type: OptionsType.OTHERS,
|
||||||
filterOptions: conditionOptions.languages,
|
filterOptions: conditionOptions.languages,
|
||||||
filterComboBoxOptions: languageOptions,
|
filterComboBoxOptions: languageOptions,
|
||||||
id: "language",
|
id: "language",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
elementOptions = [...elementOptions, { header: OptionsType.OTHERS, option: languageElement }];
|
questionOptions = [...questionOptions, { header: OptionsType.OTHERS, option: languageQuestion }];
|
||||||
|
|
||||||
if (quotas.length > 0) {
|
if (quotas.length > 0) {
|
||||||
const quotaOptions = quotas.map((quota) => {
|
const quotaOptions = quotas.map((quota) => {
|
||||||
return { label: quota.name, type: OptionsType.QUOTAS, id: quota.id };
|
return { label: quota.name, type: OptionsType.QUOTAS, id: quota.id };
|
||||||
});
|
});
|
||||||
elementOptions = [...elementOptions, { header: OptionsType.QUOTAS, option: quotaOptions }];
|
questionOptions = [...questionOptions, { header: OptionsType.QUOTAS, option: quotaOptions }];
|
||||||
|
|
||||||
quotas.forEach((quota) => {
|
quotas.forEach((quota) => {
|
||||||
elementFilterOptions.push({
|
questionFilterOptions.push({
|
||||||
type: "Quotas",
|
type: "Quotas",
|
||||||
filterOptions: ["Status"],
|
filterOptions: ["Status"],
|
||||||
filterComboBoxOptions: ["Screened in", "Screened out (overquota)", "Not in quota"],
|
filterComboBoxOptions: ["Screened in", "Screened out (overquota)", "Screened out (not in quota)"],
|
||||||
id: quota.id,
|
id: quota.id,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { elementOptions: [...elementOptions], elementFilterOptions: [...elementFilterOptions] };
|
return { questionOptions: [...questionOptions], questionFilterOptions: [...questionFilterOptions] };
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to process filled out/skipped filters
|
|
||||||
const processFilledOutSkippedFilter = (
|
|
||||||
filterType: FilterValue["filterType"],
|
|
||||||
elementId: string,
|
|
||||||
filters: TResponseFilterCriteria
|
|
||||||
) => {
|
|
||||||
if (filterType.filterComboBoxValue === "Filled out") {
|
|
||||||
filters.data![elementId] = { op: "filledOut" };
|
|
||||||
} else if (filterType.filterComboBoxValue === "Skipped") {
|
|
||||||
filters.data![elementId] = { op: "skipped" };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to process ranking filters
|
|
||||||
const processRankingFilter = (
|
|
||||||
filterType: FilterValue["filterType"],
|
|
||||||
elementId: string,
|
|
||||||
filters: TResponseFilterCriteria
|
|
||||||
) => {
|
|
||||||
if (filterType.filterComboBoxValue === "Filled out") {
|
|
||||||
filters.data![elementId] = { op: "submitted" };
|
|
||||||
} else if (filterType.filterComboBoxValue === "Skipped") {
|
|
||||||
filters.data![elementId] = { op: "skipped" };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to process multiple choice filters
|
|
||||||
const processMultipleChoiceFilter = (
|
|
||||||
filterType: FilterValue["filterType"],
|
|
||||||
elementId: string,
|
|
||||||
filters: TResponseFilterCriteria
|
|
||||||
) => {
|
|
||||||
if (filterType.filterValue === "Includes either") {
|
|
||||||
filters.data![elementId] = {
|
|
||||||
op: "includesOne",
|
|
||||||
value: filterType.filterComboBoxValue as string[],
|
|
||||||
};
|
|
||||||
} else if (filterType.filterValue === "Includes all") {
|
|
||||||
filters.data![elementId] = {
|
|
||||||
op: "includesAll",
|
|
||||||
value: filterType.filterComboBoxValue as string[],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to process NPS/Rating filters
|
|
||||||
const processNPSRatingFilter = (
|
|
||||||
filterType: FilterValue["filterType"],
|
|
||||||
elementId: string,
|
|
||||||
filters: TResponseFilterCriteria
|
|
||||||
) => {
|
|
||||||
if (filterType.filterValue === "Is equal to") {
|
|
||||||
filters.data![elementId] = {
|
|
||||||
op: "equals",
|
|
||||||
value: parseInt(filterType.filterComboBoxValue as string),
|
|
||||||
};
|
|
||||||
} else if (filterType.filterValue === "Is less than") {
|
|
||||||
filters.data![elementId] = {
|
|
||||||
op: "lessThan",
|
|
||||||
value: parseInt(filterType.filterComboBoxValue as string),
|
|
||||||
};
|
|
||||||
} else if (filterType.filterValue === "Is more than") {
|
|
||||||
filters.data![elementId] = {
|
|
||||||
op: "greaterThan",
|
|
||||||
value: parseInt(filterType.filterComboBoxValue as string),
|
|
||||||
};
|
|
||||||
} else if (filterType.filterValue === "Submitted") {
|
|
||||||
filters.data![elementId] = { op: "submitted" };
|
|
||||||
} else if (filterType.filterValue === "Skipped") {
|
|
||||||
filters.data![elementId] = { op: "skipped" };
|
|
||||||
} else if (filterType.filterValue === "Includes either") {
|
|
||||||
filters.data![elementId] = {
|
|
||||||
op: "includesOne",
|
|
||||||
value: (filterType.filterComboBoxValue as string[]).map((value) => parseInt(value)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to process CTA filters
|
|
||||||
const processCTAFilter = (
|
|
||||||
filterType: FilterValue["filterType"],
|
|
||||||
elementId: string,
|
|
||||||
filters: TResponseFilterCriteria
|
|
||||||
) => {
|
|
||||||
if (filterType.filterComboBoxValue === "Clicked") {
|
|
||||||
filters.data![elementId] = { op: "clicked" };
|
|
||||||
} else if (filterType.filterComboBoxValue === "Dismissed") {
|
|
||||||
filters.data![elementId] = { op: "skipped" };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to process Consent filters
|
|
||||||
const processConsentFilter = (
|
|
||||||
filterType: FilterValue["filterType"],
|
|
||||||
elementId: string,
|
|
||||||
filters: TResponseFilterCriteria
|
|
||||||
) => {
|
|
||||||
if (filterType.filterComboBoxValue === "Accepted") {
|
|
||||||
filters.data![elementId] = { op: "accepted" };
|
|
||||||
} else if (filterType.filterComboBoxValue === "Dismissed") {
|
|
||||||
filters.data![elementId] = { op: "skipped" };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to process Picture Selection filters
|
|
||||||
const processPictureSelectionFilter = (
|
|
||||||
filterType: FilterValue["filterType"],
|
|
||||||
elementId: string,
|
|
||||||
element: ReturnType<typeof getElementsFromBlocks>[number] | undefined,
|
|
||||||
filters: TResponseFilterCriteria
|
|
||||||
) => {
|
|
||||||
if (
|
|
||||||
element?.type !== TSurveyElementTypeEnum.PictureSelection ||
|
|
||||||
!Array.isArray(filterType.filterComboBoxValue)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedOptions = filterType.filterComboBoxValue
|
|
||||||
.map((option) => {
|
|
||||||
const index = parseInt(option.split(" ")[1]);
|
|
||||||
return element?.choices[index - 1]?.id;
|
|
||||||
})
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
if (filterType.filterValue === "Includes all") {
|
|
||||||
filters.data![elementId] = { op: "includesAll", value: selectedOptions };
|
|
||||||
} else if (filterType.filterValue === "Includes either") {
|
|
||||||
filters.data![elementId] = { op: "includesOne", value: selectedOptions };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to process Matrix filters
|
|
||||||
const processMatrixFilter = (
|
|
||||||
filterType: FilterValue["filterType"],
|
|
||||||
elementId: string,
|
|
||||||
filters: TResponseFilterCriteria
|
|
||||||
) => {
|
|
||||||
if (
|
|
||||||
filterType.filterValue &&
|
|
||||||
filterType.filterComboBoxValue &&
|
|
||||||
typeof filterType.filterComboBoxValue === "string"
|
|
||||||
) {
|
|
||||||
filters.data![elementId] = {
|
|
||||||
op: "matrix",
|
|
||||||
value: { [filterType.filterValue]: filterType.filterComboBoxValue },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to process element filters
|
|
||||||
const processElementFilters = (
|
|
||||||
elements: FilterValue[],
|
|
||||||
survey: TSurvey,
|
|
||||||
filters: TResponseFilterCriteria
|
|
||||||
) => {
|
|
||||||
if (!elements.length) return;
|
|
||||||
|
|
||||||
const surveyElements = getElementsFromBlocks(survey.blocks);
|
|
||||||
filters.data = filters.data || {};
|
|
||||||
|
|
||||||
elements.forEach(({ filterType, elementType }) => {
|
|
||||||
const elementId = elementType.id ?? "";
|
|
||||||
const element = surveyElements.find((q) => q.id === elementId);
|
|
||||||
|
|
||||||
switch (elementType.elementType) {
|
|
||||||
case TSurveyElementTypeEnum.OpenText:
|
|
||||||
case TSurveyElementTypeEnum.Address:
|
|
||||||
case TSurveyElementTypeEnum.ContactInfo:
|
|
||||||
processFilledOutSkippedFilter(filterType, elementId, filters);
|
|
||||||
break;
|
|
||||||
case TSurveyElementTypeEnum.Ranking:
|
|
||||||
processRankingFilter(filterType, elementId, filters);
|
|
||||||
break;
|
|
||||||
case TSurveyElementTypeEnum.MultipleChoiceSingle:
|
|
||||||
case TSurveyElementTypeEnum.MultipleChoiceMulti:
|
|
||||||
processMultipleChoiceFilter(filterType, elementId, filters);
|
|
||||||
break;
|
|
||||||
case TSurveyElementTypeEnum.NPS:
|
|
||||||
case TSurveyElementTypeEnum.Rating:
|
|
||||||
processNPSRatingFilter(filterType, elementId, filters);
|
|
||||||
break;
|
|
||||||
case TSurveyElementTypeEnum.CTA:
|
|
||||||
processCTAFilter(filterType, elementId, filters);
|
|
||||||
break;
|
|
||||||
case TSurveyElementTypeEnum.Consent:
|
|
||||||
processConsentFilter(filterType, elementId, filters);
|
|
||||||
break;
|
|
||||||
case TSurveyElementTypeEnum.PictureSelection:
|
|
||||||
processPictureSelectionFilter(filterType, elementId, element, filters);
|
|
||||||
break;
|
|
||||||
case TSurveyElementTypeEnum.Matrix:
|
|
||||||
processMatrixFilter(filterType, elementId, filters);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to process equals/not equals filters (for hiddenFields, attributes, others)
|
|
||||||
const processEqualsNotEqualsFilter = (
|
|
||||||
filterType: FilterValue["filterType"],
|
|
||||||
label: string | undefined,
|
|
||||||
filters: TResponseFilterCriteria,
|
|
||||||
targetKey: "data" | "contactAttributes" | "others"
|
|
||||||
) => {
|
|
||||||
if (!filterType.filterComboBoxValue) return;
|
|
||||||
|
|
||||||
if (targetKey === "data") {
|
|
||||||
filters.data = filters.data || {};
|
|
||||||
if (filterType.filterValue === "Equals") {
|
|
||||||
filters.data[label ?? ""] = { op: "equals", value: filterType.filterComboBoxValue as string };
|
|
||||||
} else if (filterType.filterValue === "Not equals") {
|
|
||||||
filters.data[label ?? ""] = { op: "notEquals", value: filterType.filterComboBoxValue as string };
|
|
||||||
}
|
|
||||||
} else if (targetKey === "contactAttributes") {
|
|
||||||
filters.contactAttributes = filters.contactAttributes || {};
|
|
||||||
if (filterType.filterValue === "Equals") {
|
|
||||||
filters.contactAttributes[label ?? ""] = {
|
|
||||||
op: "equals",
|
|
||||||
value: filterType.filterComboBoxValue as string,
|
|
||||||
};
|
|
||||||
} else if (filterType.filterValue === "Not equals") {
|
|
||||||
filters.contactAttributes[label ?? ""] = {
|
|
||||||
op: "notEquals",
|
|
||||||
value: filterType.filterComboBoxValue as string,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else if (targetKey === "others") {
|
|
||||||
filters.others = filters.others || {};
|
|
||||||
if (filterType.filterValue === "Equals") {
|
|
||||||
filters.others[label ?? ""] = { op: "equals", value: filterType.filterComboBoxValue as string };
|
|
||||||
} else if (filterType.filterValue === "Not equals") {
|
|
||||||
filters.others[label ?? ""] = { op: "notEquals", value: filterType.filterComboBoxValue as string };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to process meta filters
|
|
||||||
const processMetaFilters = (meta: FilterValue[], filters: TResponseFilterCriteria) => {
|
|
||||||
if (!meta.length) return;
|
|
||||||
|
|
||||||
filters.meta = filters.meta || {};
|
|
||||||
|
|
||||||
meta.forEach(({ filterType, elementType }) => {
|
|
||||||
const label = elementType.label ?? "";
|
|
||||||
const metaFilters = filters.meta!; // Safe because we initialized it above
|
|
||||||
|
|
||||||
// For text input cases (URL filtering)
|
|
||||||
if (typeof filterType.filterComboBoxValue === "string" && filterType.filterComboBoxValue.length > 0) {
|
|
||||||
const value = filterType.filterComboBoxValue.trim();
|
|
||||||
const op = META_OP_MAP[filterType.filterValue as keyof typeof META_OP_MAP];
|
|
||||||
if (op) {
|
|
||||||
metaFilters[label] = { op, value };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// For dropdown/select cases (existing metadata fields)
|
|
||||||
else if (Array.isArray(filterType.filterComboBoxValue) && filterType.filterComboBoxValue.length > 0) {
|
|
||||||
const value = filterType.filterComboBoxValue[0];
|
|
||||||
if (filterType.filterValue === "Equals") {
|
|
||||||
metaFilters[label] = { op: "equals", value };
|
|
||||||
} else if (filterType.filterValue === "Not equals") {
|
|
||||||
metaFilters[label] = { op: "notEquals", value };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to process quota filters
|
|
||||||
const processQuotaFilters = (quotas: FilterValue[], filters: TResponseFilterCriteria) => {
|
|
||||||
if (!quotas.length) return;
|
|
||||||
|
|
||||||
filters.quotas = filters.quotas || {};
|
|
||||||
|
|
||||||
const statusMap: Record<string, "screenedIn" | "screenedOut" | "screenedOutNotInQuota"> = {
|
|
||||||
"Screened in": "screenedIn",
|
|
||||||
"Screened out (overquota)": "screenedOut",
|
|
||||||
"Not in quota": "screenedOutNotInQuota",
|
|
||||||
};
|
|
||||||
|
|
||||||
quotas.forEach(({ filterType, elementType }) => {
|
|
||||||
const quotaId = elementType.id;
|
|
||||||
if (!quotaId) return;
|
|
||||||
|
|
||||||
const op = statusMap[String(filterType.filterComboBoxValue)];
|
|
||||||
if (op) filters.quotas![quotaId] = { op };
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// get the formatted filter expression to fetch filtered responses
|
// get the formatted filter expression to fetch filtered responses
|
||||||
@@ -552,7 +256,7 @@ export const getFormattedFilters = (
|
|||||||
): TResponseFilterCriteria => {
|
): TResponseFilterCriteria => {
|
||||||
const filters: TResponseFilterCriteria = {};
|
const filters: TResponseFilterCriteria = {};
|
||||||
|
|
||||||
const elements: FilterValue[] = [];
|
const questions: FilterValue[] = [];
|
||||||
const tags: FilterValue[] = [];
|
const tags: FilterValue[] = [];
|
||||||
const attributes: FilterValue[] = [];
|
const attributes: FilterValue[] = [];
|
||||||
const others: FilterValue[] = [];
|
const others: FilterValue[] = [];
|
||||||
@@ -561,19 +265,19 @@ export const getFormattedFilters = (
|
|||||||
const quotas: FilterValue[] = [];
|
const quotas: FilterValue[] = [];
|
||||||
|
|
||||||
selectedFilter.filter.forEach((filter) => {
|
selectedFilter.filter.forEach((filter) => {
|
||||||
if (filter.elementType?.type === "Elements") {
|
if (filter.questionType?.type === "Questions") {
|
||||||
elements.push(filter);
|
questions.push(filter);
|
||||||
} else if (filter.elementType?.type === "Tags") {
|
} else if (filter.questionType?.type === "Tags") {
|
||||||
tags.push(filter);
|
tags.push(filter);
|
||||||
} else if (filter.elementType?.type === "Attributes") {
|
} else if (filter.questionType?.type === "Attributes") {
|
||||||
attributes.push(filter);
|
attributes.push(filter);
|
||||||
} else if (filter.elementType?.type === "Other Filters") {
|
} else if (filter.questionType?.type === "Other Filters") {
|
||||||
others.push(filter);
|
others.push(filter);
|
||||||
} else if (filter.elementType?.type === "Meta") {
|
} else if (filter.questionType?.type === "Meta") {
|
||||||
meta.push(filter);
|
meta.push(filter);
|
||||||
} else if (filter.elementType?.type === "Hidden Fields") {
|
} else if (filter.questionType?.type === "Hidden Fields") {
|
||||||
hiddenFields.push(filter);
|
hiddenFields.push(filter);
|
||||||
} else if (filter.elementType?.type === "Quotas") {
|
} else if (filter.questionType?.type === "Quotas") {
|
||||||
quotas.push(filter);
|
quotas.push(filter);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -601,41 +305,260 @@ export const getFormattedFilters = (
|
|||||||
};
|
};
|
||||||
tags.forEach((tag) => {
|
tags.forEach((tag) => {
|
||||||
if (tag.filterType.filterComboBoxValue === "Applied") {
|
if (tag.filterType.filterComboBoxValue === "Applied") {
|
||||||
filters.tags?.applied?.push(tag.elementType.label ?? "");
|
filters.tags?.applied?.push(tag.questionType.label ?? "");
|
||||||
} else {
|
} else {
|
||||||
filters.tags?.notApplied?.push(tag.elementType.label ?? "");
|
filters.tags?.notApplied?.push(tag.questionType.label ?? "");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
processElementFilters(elements, survey, filters);
|
// for questions
|
||||||
|
if (questions.length) {
|
||||||
|
const surveyQuestions = getElementsFromBlocks(survey.blocks);
|
||||||
|
questions.forEach(({ filterType, questionType }) => {
|
||||||
|
if (!filters.data) filters.data = {};
|
||||||
|
switch (questionType.questionType) {
|
||||||
|
case TSurveyElementTypeEnum.OpenText:
|
||||||
|
case TSurveyElementTypeEnum.Address:
|
||||||
|
case TSurveyElementTypeEnum.ContactInfo: {
|
||||||
|
if (filterType.filterComboBoxValue === "Filled out") {
|
||||||
|
filters.data[questionType.id ?? ""] = {
|
||||||
|
op: "filledOut",
|
||||||
|
};
|
||||||
|
} else if (filterType.filterComboBoxValue === "Skipped") {
|
||||||
|
filters.data[questionType.id ?? ""] = {
|
||||||
|
op: "skipped",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case TSurveyElementTypeEnum.Ranking: {
|
||||||
|
if (filterType.filterComboBoxValue === "Filled out") {
|
||||||
|
filters.data[questionType.id ?? ""] = {
|
||||||
|
op: "submitted",
|
||||||
|
};
|
||||||
|
} else if (filterType.filterComboBoxValue === "Skipped") {
|
||||||
|
filters.data[questionType.id ?? ""] = {
|
||||||
|
op: "skipped",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case TSurveyElementTypeEnum.MultipleChoiceSingle:
|
||||||
|
case TSurveyElementTypeEnum.MultipleChoiceMulti: {
|
||||||
|
if (filterType.filterValue === "Includes either") {
|
||||||
|
filters.data[questionType.id ?? ""] = {
|
||||||
|
op: "includesOne",
|
||||||
|
value: filterType.filterComboBoxValue as string[],
|
||||||
|
};
|
||||||
|
} else if (filterType.filterValue === "Includes all") {
|
||||||
|
filters.data[questionType.id ?? ""] = {
|
||||||
|
op: "includesAll",
|
||||||
|
value: filterType.filterComboBoxValue as string[],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case TSurveyElementTypeEnum.NPS:
|
||||||
|
case TSurveyElementTypeEnum.Rating: {
|
||||||
|
if (filterType.filterValue === "Is equal to") {
|
||||||
|
filters.data[questionType.id ?? ""] = {
|
||||||
|
op: "equals",
|
||||||
|
value: parseInt(filterType.filterComboBoxValue as string),
|
||||||
|
};
|
||||||
|
} else if (filterType.filterValue === "Is less than") {
|
||||||
|
filters.data[questionType.id ?? ""] = {
|
||||||
|
op: "lessThan",
|
||||||
|
value: parseInt(filterType.filterComboBoxValue as string),
|
||||||
|
};
|
||||||
|
} else if (filterType.filterValue === "Is more than") {
|
||||||
|
filters.data[questionType.id ?? ""] = {
|
||||||
|
op: "greaterThan",
|
||||||
|
value: parseInt(filterType.filterComboBoxValue as string),
|
||||||
|
};
|
||||||
|
} else if (filterType.filterValue === "Submitted") {
|
||||||
|
filters.data[questionType.id ?? ""] = {
|
||||||
|
op: "submitted",
|
||||||
|
};
|
||||||
|
} else if (filterType.filterValue === "Skipped") {
|
||||||
|
filters.data[questionType.id ?? ""] = {
|
||||||
|
op: "skipped",
|
||||||
|
};
|
||||||
|
} else if (filterType.filterValue === "Includes either") {
|
||||||
|
filters.data[questionType.id ?? ""] = {
|
||||||
|
op: "includesOne",
|
||||||
|
value: (filterType.filterComboBoxValue as string[]).map((value) => parseInt(value)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case TSurveyElementTypeEnum.CTA: {
|
||||||
|
if (filterType.filterComboBoxValue === "Clicked") {
|
||||||
|
filters.data[questionType.id ?? ""] = {
|
||||||
|
op: "clicked",
|
||||||
|
};
|
||||||
|
} else if (filterType.filterComboBoxValue === "Dismissed") {
|
||||||
|
filters.data[questionType.id ?? ""] = {
|
||||||
|
op: "skipped",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case TSurveyElementTypeEnum.Consent: {
|
||||||
|
if (filterType.filterComboBoxValue === "Accepted") {
|
||||||
|
filters.data[questionType.id ?? ""] = {
|
||||||
|
op: "accepted",
|
||||||
|
};
|
||||||
|
} else if (filterType.filterComboBoxValue === "Dismissed") {
|
||||||
|
filters.data[questionType.id ?? ""] = {
|
||||||
|
op: "skipped",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case TSurveyElementTypeEnum.PictureSelection: {
|
||||||
|
const questionId = questionType.id ?? "";
|
||||||
|
const question = surveyQuestions.find((q) => q.id === questionId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
question?.type !== TSurveyElementTypeEnum.PictureSelection ||
|
||||||
|
!Array.isArray(filterType.filterComboBoxValue)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedOptions = filterType.filterComboBoxValue.map((option) => {
|
||||||
|
const index = parseInt(option.split(" ")[1]);
|
||||||
|
return question?.choices[index - 1].id;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filterType.filterValue === "Includes all") {
|
||||||
|
filters.data[questionId] = {
|
||||||
|
op: "includesAll",
|
||||||
|
value: selectedOptions,
|
||||||
|
};
|
||||||
|
} else if (filterType.filterValue === "Includes either") {
|
||||||
|
filters.data[questionId] = {
|
||||||
|
op: "includesOne",
|
||||||
|
value: selectedOptions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case TSurveyElementTypeEnum.Matrix: {
|
||||||
|
if (
|
||||||
|
filterType.filterValue &&
|
||||||
|
filterType.filterComboBoxValue &&
|
||||||
|
typeof filterType.filterComboBoxValue === "string"
|
||||||
|
) {
|
||||||
|
filters.data[questionType.id ?? ""] = {
|
||||||
|
op: "matrix",
|
||||||
|
value: { [filterType.filterValue]: filterType.filterComboBoxValue },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// for hidden fields
|
// for hidden fields
|
||||||
if (hiddenFields.length) {
|
if (hiddenFields.length) {
|
||||||
filters.data = filters.data || {};
|
hiddenFields.forEach(({ filterType, questionType }) => {
|
||||||
hiddenFields.forEach(({ filterType, elementType }) => {
|
if (!filters.data) filters.data = {};
|
||||||
processEqualsNotEqualsFilter(filterType, elementType.label, filters, "data");
|
if (!filterType.filterComboBoxValue) return;
|
||||||
|
if (filterType.filterValue === "Equals") {
|
||||||
|
filters.data[questionType.label ?? ""] = {
|
||||||
|
op: "equals",
|
||||||
|
value: filterType.filterComboBoxValue as string,
|
||||||
|
};
|
||||||
|
} else if (filterType.filterValue === "Not equals") {
|
||||||
|
filters.data[questionType.label ?? ""] = {
|
||||||
|
op: "notEquals",
|
||||||
|
value: filterType.filterComboBoxValue as string,
|
||||||
|
};
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// for attributes
|
// for attributes
|
||||||
if (attributes.length) {
|
if (attributes.length) {
|
||||||
filters.contactAttributes = filters.contactAttributes || {};
|
attributes.forEach(({ filterType, questionType }) => {
|
||||||
attributes.forEach(({ filterType, elementType }) => {
|
if (!filters.contactAttributes) filters.contactAttributes = {};
|
||||||
processEqualsNotEqualsFilter(filterType, elementType.label, filters, "contactAttributes");
|
if (!filterType.filterComboBoxValue) return;
|
||||||
|
if (filterType.filterValue === "Equals") {
|
||||||
|
filters.contactAttributes[questionType.label ?? ""] = {
|
||||||
|
op: "equals",
|
||||||
|
value: filterType.filterComboBoxValue as string,
|
||||||
|
};
|
||||||
|
} else if (filterType.filterValue === "Not equals") {
|
||||||
|
filters.contactAttributes[questionType.label ?? ""] = {
|
||||||
|
op: "notEquals",
|
||||||
|
value: filterType.filterComboBoxValue as string,
|
||||||
|
};
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// for others
|
// for others
|
||||||
if (others.length) {
|
if (others.length) {
|
||||||
filters.others = filters.others || {};
|
others.forEach(({ filterType, questionType }) => {
|
||||||
others.forEach(({ filterType, elementType }) => {
|
if (!filters.others) filters.others = {};
|
||||||
processEqualsNotEqualsFilter(filterType, elementType.label, filters, "others");
|
if (!filterType.filterComboBoxValue) return;
|
||||||
|
if (filterType.filterValue === "Equals") {
|
||||||
|
filters.others[questionType.label ?? ""] = {
|
||||||
|
op: "equals",
|
||||||
|
value: filterType.filterComboBoxValue as string,
|
||||||
|
};
|
||||||
|
} else if (filterType.filterValue === "Not equals") {
|
||||||
|
filters.others[questionType.label ?? ""] = {
|
||||||
|
op: "notEquals",
|
||||||
|
value: filterType.filterComboBoxValue as string,
|
||||||
|
};
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
processMetaFilters(meta, filters);
|
// for meta
|
||||||
processQuotaFilters(quotas, filters);
|
if (meta.length) {
|
||||||
|
meta.forEach(({ filterType, questionType }) => {
|
||||||
|
if (!filters.meta) filters.meta = {};
|
||||||
|
|
||||||
|
// For text input cases (URL filtering)
|
||||||
|
if (typeof filterType.filterComboBoxValue === "string" && filterType.filterComboBoxValue.length > 0) {
|
||||||
|
const value = filterType.filterComboBoxValue.trim();
|
||||||
|
const op = META_OP_MAP[filterType.filterValue as keyof typeof META_OP_MAP];
|
||||||
|
if (op) {
|
||||||
|
filters.meta[questionType.label ?? ""] = { op, value };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// For dropdown/select cases (existing metadata fields)
|
||||||
|
else if (Array.isArray(filterType.filterComboBoxValue) && filterType.filterComboBoxValue.length > 0) {
|
||||||
|
const value = filterType.filterComboBoxValue[0]; // Take first selected value
|
||||||
|
if (filterType.filterValue === "Equals") {
|
||||||
|
filters.meta[questionType.label ?? ""] = { op: "equals", value };
|
||||||
|
} else if (filterType.filterValue === "Not equals") {
|
||||||
|
filters.meta[questionType.label ?? ""] = { op: "notEquals", value };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quotas.length) {
|
||||||
|
quotas.forEach(({ filterType, questionType }) => {
|
||||||
|
filters.quotas ??= {};
|
||||||
|
const quotaId = questionType.id;
|
||||||
|
if (!quotaId) return;
|
||||||
|
|
||||||
|
const statusMap: Record<string, "screenedIn" | "screenedOut" | "screenedOutNotInQuota"> = {
|
||||||
|
"Screened in": "screenedIn",
|
||||||
|
"Screened out (overquota)": "screenedOut",
|
||||||
|
"Screened out (not in quota)": "screenedOutNotInQuota",
|
||||||
|
};
|
||||||
|
const op = statusMap[String(filterType.filterComboBoxValue)];
|
||||||
|
if (op) filters.quotas[quotaId] = { op };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return filters;
|
return filters;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -41,8 +41,11 @@ const cartAbandonmentSurvey = (t: TFunction): TTemplate => {
|
|||||||
subheader: t("templates.card_abandonment_survey_question_1_html"),
|
subheader: t("templates.card_abandonment_survey_question_1_html"),
|
||||||
headline: t("templates.card_abandonment_survey_question_1_headline"),
|
headline: t("templates.card_abandonment_survey_question_1_headline"),
|
||||||
required: false,
|
required: false,
|
||||||
|
buttonExternal: false,
|
||||||
|
dismissButtonLabel: t("templates.card_abandonment_survey_question_1_dismiss_button_label"),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
logic: [createBlockJumpLogic(reusableElementIds[0], localSurvey.endings[0].id, "isSkipped")],
|
||||||
buttonLabel: t("templates.card_abandonment_survey_question_1_button_label"),
|
buttonLabel: t("templates.card_abandonment_survey_question_1_button_label"),
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
@@ -182,8 +185,11 @@ const siteAbandonmentSurvey = (t: TFunction): TTemplate => {
|
|||||||
subheader: t("templates.site_abandonment_survey_question_1_html"),
|
subheader: t("templates.site_abandonment_survey_question_1_html"),
|
||||||
headline: t("templates.site_abandonment_survey_question_2_headline"),
|
headline: t("templates.site_abandonment_survey_question_2_headline"),
|
||||||
required: false,
|
required: false,
|
||||||
|
buttonExternal: false,
|
||||||
|
dismissButtonLabel: t("templates.site_abandonment_survey_question_2_dismiss_button_label"),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
logic: [createBlockJumpLogic(reusableElementIds[0], localSurvey.endings[0].id, "isSkipped")],
|
||||||
buttonLabel: t("templates.site_abandonment_survey_question_2_button_label"),
|
buttonLabel: t("templates.site_abandonment_survey_question_2_button_label"),
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
@@ -320,8 +326,13 @@ const productMarketFitSuperhuman = (t: TFunction): TTemplate => {
|
|||||||
subheader: t("templates.product_market_fit_superhuman_question_1_html"),
|
subheader: t("templates.product_market_fit_superhuman_question_1_html"),
|
||||||
headline: t("templates.product_market_fit_superhuman_question_1_headline"),
|
headline: t("templates.product_market_fit_superhuman_question_1_headline"),
|
||||||
required: false,
|
required: false,
|
||||||
|
buttonExternal: false,
|
||||||
|
dismissButtonLabel: t(
|
||||||
|
"templates.product_market_fit_superhuman_question_1_dismiss_button_label"
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
logic: [createBlockJumpLogic(reusableElementIds[0], localSurvey.endings[0].id, "isSkipped")],
|
||||||
buttonLabel: t("templates.product_market_fit_superhuman_question_1_button_label"),
|
buttonLabel: t("templates.product_market_fit_superhuman_question_1_button_label"),
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
@@ -562,13 +573,14 @@ const churnSurvey = (t: TFunction): TTemplate => {
|
|||||||
id: reusableElementIds[2],
|
id: reusableElementIds[2],
|
||||||
subheader: t("templates.churn_survey_question_3_html"),
|
subheader: t("templates.churn_survey_question_3_html"),
|
||||||
headline: t("templates.churn_survey_question_3_headline"),
|
headline: t("templates.churn_survey_question_3_headline"),
|
||||||
required: false,
|
required: true,
|
||||||
buttonUrl: "https://formbricks.com",
|
buttonUrl: "https://formbricks.com",
|
||||||
buttonExternal: true,
|
buttonExternal: true,
|
||||||
ctaButtonLabel: t("templates.churn_survey_question_3_button_label"),
|
dismissButtonLabel: t("templates.churn_survey_question_3_dismiss_button_label"),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
logic: [createBlockJumpLogic(reusableElementIds[2], localSurvey.endings[0].id, "isClicked")],
|
logic: [createBlockJumpLogic(reusableElementIds[2], localSurvey.endings[0].id, "isClicked")],
|
||||||
|
buttonLabel: t("templates.churn_survey_question_3_button_label"),
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildBlock({
|
buildBlock({
|
||||||
@@ -593,13 +605,14 @@ const churnSurvey = (t: TFunction): TTemplate => {
|
|||||||
id: reusableElementIds[4],
|
id: reusableElementIds[4],
|
||||||
subheader: t("templates.churn_survey_question_5_html"),
|
subheader: t("templates.churn_survey_question_5_html"),
|
||||||
headline: t("templates.churn_survey_question_5_headline"),
|
headline: t("templates.churn_survey_question_5_headline"),
|
||||||
required: false,
|
required: true,
|
||||||
buttonUrl: "mailto:ceo@company.com",
|
buttonUrl: "mailto:ceo@company.com",
|
||||||
buttonExternal: true,
|
buttonExternal: true,
|
||||||
ctaButtonLabel: t("templates.churn_survey_question_5_button_label"),
|
dismissButtonLabel: t("templates.churn_survey_question_5_dismiss_button_label"),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
logic: [createBlockJumpLogic(reusableElementIds[4], localSurvey.endings[0].id, "isClicked")],
|
logic: [createBlockJumpLogic(reusableElementIds[4], localSurvey.endings[0].id, "isClicked")],
|
||||||
|
buttonLabel: t("templates.churn_survey_question_5_button_label"),
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -988,13 +1001,14 @@ const improveTrialConversion = (t: TFunction): TTemplate => {
|
|||||||
id: reusableElementIds[3],
|
id: reusableElementIds[3],
|
||||||
subheader: t("templates.improve_trial_conversion_question_4_html"),
|
subheader: t("templates.improve_trial_conversion_question_4_html"),
|
||||||
headline: t("templates.improve_trial_conversion_question_4_headline"),
|
headline: t("templates.improve_trial_conversion_question_4_headline"),
|
||||||
required: false,
|
required: true,
|
||||||
buttonUrl: "https://formbricks.com/github",
|
buttonUrl: "https://formbricks.com/github",
|
||||||
buttonExternal: true,
|
buttonExternal: true,
|
||||||
ctaButtonLabel: t("templates.improve_trial_conversion_question_4_button_label"),
|
dismissButtonLabel: t("templates.improve_trial_conversion_question_4_dismiss_button_label"),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
logic: [createBlockJumpLogic(reusableElementIds[3], localSurvey.endings[0].id, "isClicked")],
|
logic: [createBlockJumpLogic(reusableElementIds[3], localSurvey.endings[0].id, "isClicked")],
|
||||||
|
buttonLabel: t("templates.improve_trial_conversion_question_4_button_label"),
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildBlock({
|
buildBlock({
|
||||||
@@ -1078,7 +1092,7 @@ const reviewPrompt = (t: TFunction): TTemplate => {
|
|||||||
id: createId(),
|
id: createId(),
|
||||||
leftOperand: {
|
leftOperand: {
|
||||||
value: reusableElementIds[0],
|
value: reusableElementIds[0],
|
||||||
type: "element",
|
type: "question",
|
||||||
},
|
},
|
||||||
operator: "isLessThanOrEqual",
|
operator: "isLessThanOrEqual",
|
||||||
rightOperand: {
|
rightOperand: {
|
||||||
@@ -1106,14 +1120,13 @@ const reviewPrompt = (t: TFunction): TTemplate => {
|
|||||||
id: reusableElementIds[1],
|
id: reusableElementIds[1],
|
||||||
subheader: t("templates.review_prompt_question_2_html"),
|
subheader: t("templates.review_prompt_question_2_html"),
|
||||||
headline: t("templates.review_prompt_question_2_headline"),
|
headline: t("templates.review_prompt_question_2_headline"),
|
||||||
required: false,
|
required: true,
|
||||||
buttonUrl: "https://formbricks.com/github",
|
buttonUrl: "https://formbricks.com/github",
|
||||||
buttonExternal: true,
|
buttonExternal: true,
|
||||||
ctaButtonLabel: t("templates.review_prompt_question_2_button_label"),
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
logic: [createBlockJumpLogic(reusableElementIds[1], localSurvey.endings[0].id, "isClicked")],
|
logic: [createBlockJumpLogic(reusableElementIds[1], localSurvey.endings[0].id, "isClicked")],
|
||||||
buttonLabel: t("templates.next"),
|
buttonLabel: t("templates.review_prompt_question_2_button_label"),
|
||||||
backButtonLabel: t("templates.back"),
|
backButtonLabel: t("templates.back"),
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
@@ -1160,10 +1173,9 @@ const interviewPrompt = (t: TFunction): TTemplate => {
|
|||||||
buttonUrl: "https://cal.com/johannes",
|
buttonUrl: "https://cal.com/johannes",
|
||||||
buttonExternal: true,
|
buttonExternal: true,
|
||||||
required: false,
|
required: false,
|
||||||
ctaButtonLabel: t("templates.interview_prompt_question_1_button_label"),
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
buttonLabel: t("templates.next"),
|
buttonLabel: t("templates.interview_prompt_question_1_button_label"),
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -1837,8 +1849,14 @@ const feedbackBox = (t: TFunction): TTemplate => {
|
|||||||
subheader: t("templates.feedback_box_question_3_html"),
|
subheader: t("templates.feedback_box_question_3_html"),
|
||||||
headline: t("templates.feedback_box_question_3_headline"),
|
headline: t("templates.feedback_box_question_3_headline"),
|
||||||
required: false,
|
required: false,
|
||||||
|
buttonExternal: false,
|
||||||
|
dismissButtonLabel: t("templates.feedback_box_question_3_dismiss_button_label"),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
logic: [
|
||||||
|
createBlockJumpLogic(reusableElementIds[2], localSurvey.endings[0].id, "isClicked"),
|
||||||
|
createBlockJumpLogic(reusableElementIds[2], localSurvey.endings[0].id, "isSkipped"),
|
||||||
|
],
|
||||||
buttonLabel: t("templates.feedback_box_question_3_button_label"),
|
buttonLabel: t("templates.feedback_box_question_3_button_label"),
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
@@ -1904,7 +1922,7 @@ const integrationSetupSurvey = (t: TFunction): TTemplate => {
|
|||||||
id: createId(),
|
id: createId(),
|
||||||
leftOperand: {
|
leftOperand: {
|
||||||
value: reusableElementIds[0],
|
value: reusableElementIds[0],
|
||||||
type: "element",
|
type: "question",
|
||||||
},
|
},
|
||||||
operator: "isGreaterThanOrEqual",
|
operator: "isGreaterThanOrEqual",
|
||||||
rightOperand: {
|
rightOperand: {
|
||||||
@@ -2349,7 +2367,7 @@ const collectFeedback = (t: TFunction): TTemplate => {
|
|||||||
id: createId(),
|
id: createId(),
|
||||||
leftOperand: {
|
leftOperand: {
|
||||||
value: reusableElementIds[0],
|
value: reusableElementIds[0],
|
||||||
type: "element",
|
type: "question",
|
||||||
},
|
},
|
||||||
operator: "isLessThanOrEqual",
|
operator: "isLessThanOrEqual",
|
||||||
rightOperand: {
|
rightOperand: {
|
||||||
@@ -2393,7 +2411,7 @@ const collectFeedback = (t: TFunction): TTemplate => {
|
|||||||
id: createId(),
|
id: createId(),
|
||||||
leftOperand: {
|
leftOperand: {
|
||||||
value: reusableElementIds[1],
|
value: reusableElementIds[1],
|
||||||
type: "element",
|
type: "question",
|
||||||
},
|
},
|
||||||
operator: "isSubmitted",
|
operator: "isSubmitted",
|
||||||
},
|
},
|
||||||
@@ -2696,10 +2714,9 @@ const marketSiteClarity = (t: TFunction): TTemplate => {
|
|||||||
required: false,
|
required: false,
|
||||||
buttonUrl: "https://app.formbricks.com/auth/signup",
|
buttonUrl: "https://app.formbricks.com/auth/signup",
|
||||||
buttonExternal: true,
|
buttonExternal: true,
|
||||||
ctaButtonLabel: t("templates.market_site_clarity_question_3_button_label"),
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
buttonLabel: t("templates.next"),
|
buttonLabel: t("templates.market_site_clarity_question_3_button_label"),
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -2758,7 +2775,7 @@ const careerDevelopmentSurvey = (t: TFunction): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.career_development_survey_name"),
|
name: t("templates.career_development_survey_name"),
|
||||||
role: "peopleManager",
|
role: "productManager",
|
||||||
industries: ["saas", "eCommerce", "other"],
|
industries: ["saas", "eCommerce", "other"],
|
||||||
channels: ["link"],
|
channels: ["link"],
|
||||||
description: t("templates.career_development_survey_description"),
|
description: t("templates.career_development_survey_description"),
|
||||||
@@ -2876,7 +2893,7 @@ const professionalDevelopmentSurvey = (t: TFunction): TTemplate => {
|
|||||||
return buildSurvey(
|
return buildSurvey(
|
||||||
{
|
{
|
||||||
name: t("templates.professional_development_survey_name"),
|
name: t("templates.professional_development_survey_name"),
|
||||||
role: "peopleManager",
|
role: "productManager",
|
||||||
industries: ["saas", "eCommerce", "other"],
|
industries: ["saas", "eCommerce", "other"],
|
||||||
channels: ["link"],
|
channels: ["link"],
|
||||||
description: t("templates.professional_development_survey_description"),
|
description: t("templates.professional_development_survey_description"),
|
||||||
@@ -3019,7 +3036,7 @@ const rateCheckoutExperience = (t: TFunction): TTemplate => {
|
|||||||
id: createId(),
|
id: createId(),
|
||||||
leftOperand: {
|
leftOperand: {
|
||||||
value: reusableElementIds[0],
|
value: reusableElementIds[0],
|
||||||
type: "element",
|
type: "question",
|
||||||
},
|
},
|
||||||
operator: "isGreaterThanOrEqual",
|
operator: "isGreaterThanOrEqual",
|
||||||
rightOperand: {
|
rightOperand: {
|
||||||
@@ -3114,7 +3131,7 @@ const measureSearchExperience = (t: TFunction): TTemplate => {
|
|||||||
id: createId(),
|
id: createId(),
|
||||||
leftOperand: {
|
leftOperand: {
|
||||||
value: reusableElementIds[0],
|
value: reusableElementIds[0],
|
||||||
type: "element",
|
type: "question",
|
||||||
},
|
},
|
||||||
operator: "isGreaterThanOrEqual",
|
operator: "isGreaterThanOrEqual",
|
||||||
rightOperand: {
|
rightOperand: {
|
||||||
@@ -3209,7 +3226,7 @@ const evaluateContentQuality = (t: TFunction): TTemplate => {
|
|||||||
id: createId(),
|
id: createId(),
|
||||||
leftOperand: {
|
leftOperand: {
|
||||||
value: reusableElementIds[0],
|
value: reusableElementIds[0],
|
||||||
type: "element",
|
type: "question",
|
||||||
},
|
},
|
||||||
operator: "isGreaterThanOrEqual",
|
operator: "isGreaterThanOrEqual",
|
||||||
rightOperand: {
|
rightOperand: {
|
||||||
@@ -3332,7 +3349,7 @@ const measureTaskAccomplishment = (t: TFunction): TTemplate => {
|
|||||||
id: createId(),
|
id: createId(),
|
||||||
leftOperand: {
|
leftOperand: {
|
||||||
value: reusableElementIds[1],
|
value: reusableElementIds[1],
|
||||||
type: "element",
|
type: "question",
|
||||||
},
|
},
|
||||||
operator: "isGreaterThanOrEqual",
|
operator: "isGreaterThanOrEqual",
|
||||||
rightOperand: {
|
rightOperand: {
|
||||||
@@ -3375,7 +3392,7 @@ const measureTaskAccomplishment = (t: TFunction): TTemplate => {
|
|||||||
id: createId(),
|
id: createId(),
|
||||||
leftOperand: {
|
leftOperand: {
|
||||||
value: reusableElementIds[2],
|
value: reusableElementIds[2],
|
||||||
type: "element",
|
type: "question",
|
||||||
},
|
},
|
||||||
operator: "isSubmitted",
|
operator: "isSubmitted",
|
||||||
},
|
},
|
||||||
@@ -3383,7 +3400,7 @@ const measureTaskAccomplishment = (t: TFunction): TTemplate => {
|
|||||||
id: createId(),
|
id: createId(),
|
||||||
leftOperand: {
|
leftOperand: {
|
||||||
value: reusableElementIds[1],
|
value: reusableElementIds[1],
|
||||||
type: "element",
|
type: "question",
|
||||||
},
|
},
|
||||||
operator: "isSkipped",
|
operator: "isSkipped",
|
||||||
},
|
},
|
||||||
@@ -3422,7 +3439,7 @@ const measureTaskAccomplishment = (t: TFunction): TTemplate => {
|
|||||||
id: createId(),
|
id: createId(),
|
||||||
leftOperand: {
|
leftOperand: {
|
||||||
value: reusableElementIds[3],
|
value: reusableElementIds[3],
|
||||||
type: "element",
|
type: "question",
|
||||||
},
|
},
|
||||||
operator: "isSubmitted",
|
operator: "isSubmitted",
|
||||||
},
|
},
|
||||||
@@ -3430,7 +3447,7 @@ const measureTaskAccomplishment = (t: TFunction): TTemplate => {
|
|||||||
id: createId(),
|
id: createId(),
|
||||||
leftOperand: {
|
leftOperand: {
|
||||||
value: reusableElementIds[1],
|
value: reusableElementIds[1],
|
||||||
type: "element",
|
type: "question",
|
||||||
},
|
},
|
||||||
operator: "isSkipped",
|
operator: "isSkipped",
|
||||||
},
|
},
|
||||||
@@ -3507,8 +3524,11 @@ const identifySignUpBarriers = (t: TFunction): TTemplate => {
|
|||||||
subheader: t("templates.identify_sign_up_barriers_question_1_html"),
|
subheader: t("templates.identify_sign_up_barriers_question_1_html"),
|
||||||
headline: t("templates.identify_sign_up_barriers_question_1_headline"),
|
headline: t("templates.identify_sign_up_barriers_question_1_headline"),
|
||||||
required: false,
|
required: false,
|
||||||
|
buttonExternal: false,
|
||||||
|
dismissButtonLabel: t("templates.identify_sign_up_barriers_question_1_dismiss_button_label"),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
logic: [createBlockJumpLogic(reusableElementIds[0], localSurvey.endings[0].id, "isSkipped")],
|
||||||
buttonLabel: t("templates.identify_sign_up_barriers_question_1_button_label"),
|
buttonLabel: t("templates.identify_sign_up_barriers_question_1_button_label"),
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
@@ -3537,7 +3557,7 @@ const identifySignUpBarriers = (t: TFunction): TTemplate => {
|
|||||||
id: createId(),
|
id: createId(),
|
||||||
leftOperand: {
|
leftOperand: {
|
||||||
value: reusableElementIds[1],
|
value: reusableElementIds[1],
|
||||||
type: "element",
|
type: "question",
|
||||||
},
|
},
|
||||||
operator: "equals",
|
operator: "equals",
|
||||||
rightOperand: {
|
rightOperand: {
|
||||||
@@ -3677,9 +3697,10 @@ const identifySignUpBarriers = (t: TFunction): TTemplate => {
|
|||||||
required: false,
|
required: false,
|
||||||
buttonUrl: "https://app.formbricks.com/auth/signup",
|
buttonUrl: "https://app.formbricks.com/auth/signup",
|
||||||
buttonExternal: true,
|
buttonExternal: true,
|
||||||
ctaButtonLabel: t("templates.identify_sign_up_barriers_question_9_button_label"),
|
dismissButtonLabel: t("templates.identify_sign_up_barriers_question_9_dismiss_button_label"),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
buttonLabel: t("templates.identify_sign_up_barriers_question_9_button_label"),
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -3851,7 +3872,7 @@ const improveNewsletterContent = (t: TFunction): TTemplate => {
|
|||||||
id: createId(),
|
id: createId(),
|
||||||
leftOperand: {
|
leftOperand: {
|
||||||
value: reusableElementIds[0],
|
value: reusableElementIds[0],
|
||||||
type: "element",
|
type: "question",
|
||||||
},
|
},
|
||||||
operator: "isLessThan",
|
operator: "isLessThan",
|
||||||
rightOperand: {
|
rightOperand: {
|
||||||
@@ -3901,9 +3922,10 @@ const improveNewsletterContent = (t: TFunction): TTemplate => {
|
|||||||
required: false,
|
required: false,
|
||||||
buttonUrl: "https://formbricks.com",
|
buttonUrl: "https://formbricks.com",
|
||||||
buttonExternal: true,
|
buttonExternal: true,
|
||||||
ctaButtonLabel: t("templates.improve_newsletter_content_question_3_button_label"),
|
dismissButtonLabel: t("templates.improve_newsletter_content_question_3_dismiss_button_label"),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
buttonLabel: t("templates.improve_newsletter_content_question_3_button_label"),
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -3946,7 +3968,9 @@ const evaluateAProductIdea = (t: TFunction): TTemplate => {
|
|||||||
id: reusableElementIds[0],
|
id: reusableElementIds[0],
|
||||||
subheader: t("templates.evaluate_a_product_idea_question_1_html"),
|
subheader: t("templates.evaluate_a_product_idea_question_1_html"),
|
||||||
headline: t("templates.evaluate_a_product_idea_question_1_headline"),
|
headline: t("templates.evaluate_a_product_idea_question_1_headline"),
|
||||||
required: false,
|
required: true,
|
||||||
|
buttonExternal: false,
|
||||||
|
dismissButtonLabel: t("templates.evaluate_a_product_idea_question_1_dismiss_button_label"),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
buttonLabel: t("templates.evaluate_a_product_idea_question_1_button_label"),
|
buttonLabel: t("templates.evaluate_a_product_idea_question_1_button_label"),
|
||||||
@@ -3994,7 +4018,9 @@ const evaluateAProductIdea = (t: TFunction): TTemplate => {
|
|||||||
id: reusableElementIds[3],
|
id: reusableElementIds[3],
|
||||||
subheader: t("templates.evaluate_a_product_idea_question_4_html"),
|
subheader: t("templates.evaluate_a_product_idea_question_4_html"),
|
||||||
headline: t("templates.evaluate_a_product_idea_question_4_headline"),
|
headline: t("templates.evaluate_a_product_idea_question_4_headline"),
|
||||||
required: false,
|
required: true,
|
||||||
|
buttonExternal: false,
|
||||||
|
dismissButtonLabel: t("templates.evaluate_a_product_idea_question_4_dismiss_button_label"),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
buttonLabel: t("templates.evaluate_a_product_idea_question_4_button_label"),
|
buttonLabel: t("templates.evaluate_a_product_idea_question_4_button_label"),
|
||||||
@@ -4800,6 +4826,7 @@ export const customSurveyTemplate = (t: TFunction): TTemplate => {
|
|||||||
type: TSurveyElementTypeEnum.OpenText,
|
type: TSurveyElementTypeEnum.OpenText,
|
||||||
headline: createI18nString(t("templates.custom_survey_question_1_headline"), []),
|
headline: createI18nString(t("templates.custom_survey_question_1_headline"), []),
|
||||||
placeholder: createI18nString(t("templates.custom_survey_question_1_placeholder"), []),
|
placeholder: createI18nString(t("templates.custom_survey_question_1_placeholder"), []),
|
||||||
|
buttonLabel: createI18nString(t("templates.next"), []),
|
||||||
required: true,
|
required: true,
|
||||||
inputType: "text",
|
inputType: "text",
|
||||||
charLimit: {
|
charLimit: {
|
||||||
@@ -4807,8 +4834,6 @@ export const customSurveyTemplate = (t: TFunction): TTemplate => {
|
|||||||
},
|
},
|
||||||
} as TSurveyOpenTextElement,
|
} as TSurveyOpenTextElement,
|
||||||
],
|
],
|
||||||
// Button labels at block level with default key for i18n support
|
|
||||||
buttonLabel: createI18nString(t("templates.next"), []),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -4874,8 +4899,6 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
|
|||||||
isDraft: true,
|
isDraft: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
buttonLabel: createI18nString(t("templates.next"), []),
|
|
||||||
backButtonLabel: createI18nString(t("templates.preview_survey_question_2_back_button_label"), []),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
endings: [
|
endings: [
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { LinkSurveyLoading } from "@/modules/survey/link/loading";
|
||||||
|
|
||||||
|
export default LinkSurveyLoading;
|
||||||
+1
-12
@@ -7,18 +7,7 @@
|
|||||||
},
|
},
|
||||||
"locale": {
|
"locale": {
|
||||||
"source": "en-US",
|
"source": "en-US",
|
||||||
"targets": [
|
"targets": ["de-DE", "fr-FR", "ja-JP", "pt-BR", "pt-PT", "ro-RO", "zh-Hans-CN", "zh-Hant-TW"]
|
||||||
"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
|
"version": 1.8
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-73
@@ -126,7 +126,6 @@ checksums:
|
|||||||
common/clear_filters: 8f40ab5af527e4b190da94e7b6221379
|
common/clear_filters: 8f40ab5af527e4b190da94e7b6221379
|
||||||
common/clear_selection: af5d720527735d4253e289400d29ec9e
|
common/clear_selection: af5d720527735d4253e289400d29ec9e
|
||||||
common/click: 9c2744de6b5ac7333d9dae1d5cf4a76d
|
common/click: 9c2744de6b5ac7333d9dae1d5cf4a76d
|
||||||
common/click_to_filter: 527714113ca5fd3504e7d0bd31bca303
|
|
||||||
common/clicks: f9e154545f87d8ede27b529e5fdf2015
|
common/clicks: f9e154545f87d8ede27b529e5fdf2015
|
||||||
common/close: 2c2e22f8424a1031de89063bd0022e16
|
common/close: 2c2e22f8424a1031de89063bd0022e16
|
||||||
common/code: 343bc5386149b97cece2b093c39034b2
|
common/code: 343bc5386149b97cece2b093c39034b2
|
||||||
@@ -184,7 +183,6 @@ checksums:
|
|||||||
common/error_rate_limit_description: 37791a33a947204662ee9c6544e90f51
|
common/error_rate_limit_description: 37791a33a947204662ee9c6544e90f51
|
||||||
common/error_rate_limit_title: 23ac9419e267e610e1bfd38e1dc35dc0
|
common/error_rate_limit_title: 23ac9419e267e610e1bfd38e1dc35dc0
|
||||||
common/expand_rows: b6e06327cb8718dfd6651720843e4dad
|
common/expand_rows: b6e06327cb8718dfd6651720843e4dad
|
||||||
common/failed_to_copy_to_clipboard: de836a7d628d36c832809252f188f784
|
|
||||||
common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
|
common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
|
||||||
common/failed_to_load_projects: 0bba9f9b2b38c189706a486a1bb134c3
|
common/failed_to_load_projects: 0bba9f9b2b38c189706a486a1bb134c3
|
||||||
common/finish: ffa7a10f71182b48fefed7135bee24fa
|
common/finish: ffa7a10f71182b48fefed7135bee24fa
|
||||||
@@ -193,7 +191,6 @@ checksums:
|
|||||||
common/full_name: f45991923345e8322c9ff8cd6b7e2b16
|
common/full_name: f45991923345e8322c9ff8cd6b7e2b16
|
||||||
common/gathering_responses: c5914490ed81bd77f13d411739f0c9ef
|
common/gathering_responses: c5914490ed81bd77f13d411739f0c9ef
|
||||||
common/general: b891e8f15579fc5d97bcaf3637f5ae59
|
common/general: b891e8f15579fc5d97bcaf3637f5ae59
|
||||||
common/generate: 0345bf322c191e70d01fd6607ec5c2f8
|
|
||||||
common/go_back: b917ea82facb90c88c523b255d29f84b
|
common/go_back: b917ea82facb90c88c523b255d29f84b
|
||||||
common/go_to_dashboard: a6efa97d25e36fedc0af794f6ba610f2
|
common/go_to_dashboard: a6efa97d25e36fedc0af794f6ba610f2
|
||||||
common/hidden: fa290c6ada5869d744ed35e9cca64699
|
common/hidden: fa290c6ada5869d744ed35e9cca64699
|
||||||
@@ -304,7 +301,7 @@ checksums:
|
|||||||
common/project_not_found: be3b516c02b05553acb4ae338511f645
|
common/project_not_found: be3b516c02b05553acb4ae338511f645
|
||||||
common/project_permission_not_found: ace6b03f06bd14e884e4295c5022d61b
|
common/project_permission_not_found: ace6b03f06bd14e884e4295c5022d61b
|
||||||
common/projects: fe8af5cfb3c95cb35534872a325b225e
|
common/projects: fe8af5cfb3c95cb35534872a325b225e
|
||||||
common/question: 2a47e06b62410b16003c4979dee0099f
|
common/question: 0576462ce60d4263d7c482463fcc9547
|
||||||
common/question_id: d0c3672976c281411bdccf749faf5ffd
|
common/question_id: d0c3672976c281411bdccf749faf5ffd
|
||||||
common/questions: 38d08215fd7a8026077c7b64eea6bb59
|
common/questions: 38d08215fd7a8026077c7b64eea6bb59
|
||||||
common/quota: edd33b180b463ee7a70a64a5c4ad7f02
|
common/quota: edd33b180b463ee7a70a64a5c4ad7f02
|
||||||
@@ -401,7 +398,6 @@ checksums:
|
|||||||
common/user_id: 37f5ba37f71cb50607af32a6a203b1d4
|
common/user_id: 37f5ba37f71cb50607af32a6a203b1d4
|
||||||
common/user_not_found: 5903581136ac6c1c1351a482a6d8fdf7
|
common/user_not_found: 5903581136ac6c1c1351a482a6d8fdf7
|
||||||
common/variable: c13db5775ba9791b1522cc55c9c7acce
|
common/variable: c13db5775ba9791b1522cc55c9c7acce
|
||||||
common/variable_ids: 44bf93b70703b7699fa9f21bc6c8eed4
|
|
||||||
common/variables: ffd3eec5497af36d7b4e4185bad1313a
|
common/variables: ffd3eec5497af36d7b4e4185bad1313a
|
||||||
common/verified_email: d4a9e5e47d622c6ef2fede44233076c7
|
common/verified_email: d4a9e5e47d622c6ef2fede44233076c7
|
||||||
common/video: 8050c90e4289b105a0780f0fdda6ff66
|
common/video: 8050c90e4289b105a0780f0fdda6ff66
|
||||||
@@ -495,7 +491,6 @@ checksums:
|
|||||||
environments/actions/add_css_class_or_id: cfc4d88412c5b9ef1157e28db4afdcc5
|
environments/actions/add_css_class_or_id: cfc4d88412c5b9ef1157e28db4afdcc5
|
||||||
environments/actions/add_regular_expression_here: 797fde3681996b85bc63c3550dec1fd4
|
environments/actions/add_regular_expression_here: 797fde3681996b85bc63c3550dec1fd4
|
||||||
environments/actions/add_url: 8eba7972136a42da78a8fa4798da8e87
|
environments/actions/add_url: 8eba7972136a42da78a8fa4798da8e87
|
||||||
environments/actions/and: 53e8eb67a396fcb5e419bb4cbf0008df
|
|
||||||
environments/actions/click: 9c2744de6b5ac7333d9dae1d5cf4a76d
|
environments/actions/click: 9c2744de6b5ac7333d9dae1d5cf4a76d
|
||||||
environments/actions/contains: 41c8c25407527a5336404313f4c8d650
|
environments/actions/contains: 41c8c25407527a5336404313f4c8d650
|
||||||
environments/actions/create_action: 3abcc6dbbca18d3218ba49f90c4a66fd
|
environments/actions/create_action: 3abcc6dbbca18d3218ba49f90c4a66fd
|
||||||
@@ -526,7 +521,6 @@ checksums:
|
|||||||
environments/actions/limit_to_specific_pages: f8ba95b2fc68d965689594b8a545417c
|
environments/actions/limit_to_specific_pages: f8ba95b2fc68d965689594b8a545417c
|
||||||
environments/actions/matches_regex: 208b4d02b38714b4523923239e4a66b0
|
environments/actions/matches_regex: 208b4d02b38714b4523923239e4a66b0
|
||||||
environments/actions/on_all_pages: ccb8ee531a55e21eb8157c36fa75ad9a
|
environments/actions/on_all_pages: ccb8ee531a55e21eb8157c36fa75ad9a
|
||||||
environments/actions/or: 0208d355f231c386b19390f0bea41b95
|
|
||||||
environments/actions/page_filter: fe98a0bcbedb938e58cc3730589caa95
|
environments/actions/page_filter: fe98a0bcbedb938e58cc3730589caa95
|
||||||
environments/actions/page_view: 019c12b6739f6f7b1500f96ee275d47c
|
environments/actions/page_view: 019c12b6739f6f7b1500f96ee275d47c
|
||||||
environments/actions/select_match_type: b555dce1cb5c61538d3fbd792b2c71a2
|
environments/actions/select_match_type: b555dce1cb5c61538d3fbd792b2c71a2
|
||||||
@@ -563,18 +557,9 @@ checksums:
|
|||||||
environments/contacts/contacts_table_refresh_success: 40951396e88e5c8fdafa0b3bb4fadca8
|
environments/contacts/contacts_table_refresh_success: 40951396e88e5c8fdafa0b3bb4fadca8
|
||||||
environments/contacts/delete_contact_confirmation: 4304d36277daa205b4aa09f5e0d494ab
|
environments/contacts/delete_contact_confirmation: 4304d36277daa205b4aa09f5e0d494ab
|
||||||
environments/contacts/delete_contact_confirmation_with_quotas: 7c0e2e223ca55101270ac2988c53e616
|
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/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
|
||||||
environments/contacts/not_provided: a09e4d61bbeb04b927406a50116445e2
|
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/search_contact: 020205a93846ab3e12c203ac4fa97c12
|
||||||
environments/contacts/select_a_survey: 1f49086dfb874307aae1136e88c3d514
|
|
||||||
environments/contacts/select_attribute: d93fb60eb4fbb42bf13a22f6216fbd79
|
environments/contacts/select_attribute: d93fb60eb4fbb42bf13a22f6216fbd79
|
||||||
environments/contacts/unlock_contacts_description: c5572047f02b4c39e5109f9de715499d
|
environments/contacts/unlock_contacts_description: c5572047f02b4c39e5109f9de715499d
|
||||||
environments/contacts/unlock_contacts_title: a8b3d7db03eb404d9267fd5cdd6d5ddb
|
environments/contacts/unlock_contacts_title: a8b3d7db03eb404d9267fd5cdd6d5ddb
|
||||||
@@ -596,7 +581,6 @@ checksums:
|
|||||||
environments/contacts/upload_contacts_modal_pick_different_file: e748a6e81a425ef9aa33f96ca4edc157
|
environments/contacts/upload_contacts_modal_pick_different_file: e748a6e81a425ef9aa33f96ca4edc157
|
||||||
environments/contacts/upload_contacts_modal_preview: c4406f8d9a54f131abfff4e9928228bb
|
environments/contacts/upload_contacts_modal_preview: c4406f8d9a54f131abfff4e9928228bb
|
||||||
environments/contacts/upload_contacts_modal_upload_btn: 47b7f3bcf478a7d8dc258d2efc80af37
|
environments/contacts/upload_contacts_modal_upload_btn: 47b7f3bcf478a7d8dc258d2efc80af37
|
||||||
environments/contacts/upload_contacts_success: cd5d6b6d587586dd4f944868c92835bc
|
|
||||||
environments/formbricks_logo: b7ee57de32c8b13463cc8ca8643eddd4
|
environments/formbricks_logo: b7ee57de32c8b13463cc8ca8643eddd4
|
||||||
environments/integrations/activepieces_integration_description: 62a8fbf86762bab01c7d2db2ba60fff4
|
environments/integrations/activepieces_integration_description: 62a8fbf86762bab01c7d2db2ba60fff4
|
||||||
environments/integrations/additional_settings: 20936205a75745fba2c4047375a04db3
|
environments/integrations/additional_settings: 20936205a75745fba2c4047375a04db3
|
||||||
@@ -737,23 +721,20 @@ checksums:
|
|||||||
environments/project/api_keys/secret: f041e5eb96121c8b4f2b8af7e0f83a9b
|
environments/project/api_keys/secret: f041e5eb96121c8b4f2b8af7e0f83a9b
|
||||||
environments/project/api_keys/unable_to_delete_api_key: 1fd76d9a22c5f5f8c241c4891fca8295
|
environments/project/api_keys/unable_to_delete_api_key: 1fd76d9a22c5f5f8c241c4891fca8295
|
||||||
environments/project/app-connection/app_connection: 778d2305e1a9c8efe91c2c7b4af37ae4
|
environments/project/app-connection/app_connection: 778d2305e1a9c8efe91c2c7b4af37ae4
|
||||||
environments/project/app-connection/app_connection_description: dde226414bd2265cbd0daf6635efcfdd
|
environments/project/app-connection/app_connection_description: 01327bfae3da950d796890b6605afed2
|
||||||
environments/project/app-connection/cache_update_delay_description: 3368e4a8090b7684117a16c94f0c409c
|
environments/project/app-connection/cache_update_delay_description: 1cb2c46fdb6762ccb348d21086063a4f
|
||||||
environments/project/app-connection/cache_update_delay_title: 60e4a0fcfbd8850bddf29b5c3f59550c
|
environments/project/app-connection/cache_update_delay_title: fef7f99f0228f9e30093574ac7770e7e
|
||||||
environments/project/app-connection/environment_id: 3dba898b081c18cd4cae131765ef411f
|
environments/project/app-connection/environment_id: 3dba898b081c18cd4cae131765ef411f
|
||||||
environments/project/app-connection/environment_id_description: 8b4a763d069b000cfa1a2025a13df80c
|
environments/project/app-connection/environment_id_description: 8b4a763d069b000cfa1a2025a13df80c
|
||||||
environments/project/app-connection/formbricks_sdk_connected: 29e8a40ad6a7fdb5af5ee9451a70a9aa
|
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: 557c534e665750978ba6edb0eacb428e
|
||||||
environments/project/app-connection/formbricks_sdk_not_connected_description: 4ddbacae084238bd0cefeded0fe9dbb9
|
environments/project/app-connection/formbricks_sdk_not_connected_description: 666b2b25f06e76554cc2d60f925bcd4b
|
||||||
environments/project/app-connection/how_to_setup: 3bad40037f280b47fe6418fcbeb4c717
|
environments/project/app-connection/how_to_setup: 3bad40037f280b47fe6418fcbeb4c717
|
||||||
environments/project/app-connection/how_to_setup_description: 2ae5cd9456a8acd3986e3d3678e70ed2
|
environments/project/app-connection/how_to_setup_description: 2ae5cd9456a8acd3986e3d3678e70ed2
|
||||||
environments/project/app-connection/receiving_data: 9f2a48c0b0278861add70b526061264c
|
environments/project/app-connection/receiving_data: 9f2a48c0b0278861add70b526061264c
|
||||||
environments/project/app-connection/recheck: f95f2bbe6990a123d60255c87bdd59f7
|
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_description: 6d676044d01dc2147731ffab7df6c259
|
||||||
environments/project/app-connection/setup_alert_title: 9561cca2b391e0df81e8a982921ff2bb
|
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/cannot_delete_only_project: 24751701a42d8b4d2ba6112a5f642bad
|
||||||
environments/project/general/delete_project: e4a2a227105c4ec71e561ab1f140eb26
|
environments/project/general/delete_project: e4a2a227105c4ec71e561ab1f140eb26
|
||||||
environments/project/general/delete_project_confirmation: 54a4ee78867537e0244c7170453cdb3f
|
environments/project/general/delete_project_confirmation: 54a4ee78867537e0244c7170453cdb3f
|
||||||
@@ -764,7 +745,7 @@ checksums:
|
|||||||
environments/project/general/project_deleted_successfully: dbedf0f0739b822f3951de4aeb2fc26f
|
environments/project/general/project_deleted_successfully: dbedf0f0739b822f3951de4aeb2fc26f
|
||||||
environments/project/general/project_name_settings_description: 079c6380ad539543a9aa8772bc1b0fa2
|
environments/project/general/project_name_settings_description: 079c6380ad539543a9aa8772bc1b0fa2
|
||||||
environments/project/general/project_name_updated_successfully: f95f70f4a49d451dc0441a51d05a3aa3
|
environments/project/general/project_name_updated_successfully: f95f70f4a49d451dc0441a51d05a3aa3
|
||||||
environments/project/general/recontact_waiting_time: 0566dc710b4b9644e276e311b419c4c0
|
environments/project/general/recontact_waiting_time: 9c5ebb18960dec73def053de89e63272
|
||||||
environments/project/general/recontact_waiting_time_settings_description: 8922cde1f95777f9a2747fb4bed57ab5
|
environments/project/general/recontact_waiting_time_settings_description: 8922cde1f95777f9a2747fb4bed57ab5
|
||||||
environments/project/general/this_action_cannot_be_undone: 3d8b13374ffd3cefc0f3f7ce077bd9c9
|
environments/project/general/this_action_cannot_be_undone: 3d8b13374ffd3cefc0f3f7ce077bd9c9
|
||||||
environments/project/general/wait_x_days_before_showing_next_survey: d96228788d32ec23dc0d8c8ba77150a6
|
environments/project/general/wait_x_days_before_showing_next_survey: d96228788d32ec23dc0d8c8ba77150a6
|
||||||
@@ -827,6 +808,7 @@ checksums:
|
|||||||
environments/project/tags/add_tag: 2cfa04ceea966149f2b5d40d9c131141
|
environments/project/tags/add_tag: 2cfa04ceea966149f2b5d40d9c131141
|
||||||
environments/project/tags/count: 9c5848662eb8024ddf360f7e4001a968
|
environments/project/tags/count: 9c5848662eb8024ddf360f7e4001a968
|
||||||
environments/project/tags/delete_tag_confirmation: a9fb98064cd156242899643f3d2ef032
|
environments/project/tags/delete_tag_confirmation: a9fb98064cd156242899643f3d2ef032
|
||||||
|
environments/project/tags/empty_message: da71bd7c7b5bf634469d20e010d25503
|
||||||
environments/project/tags/manage_tags: 2761d558b82b6104befbc240ae2379c6
|
environments/project/tags/manage_tags: 2761d558b82b6104befbc240ae2379c6
|
||||||
environments/project/tags/manage_tags_description: ce7cc42da3646fba960502d7e4e49cd2
|
environments/project/tags/manage_tags_description: ce7cc42da3646fba960502d7e4e49cd2
|
||||||
environments/project/tags/merge: 95051c859b8778be51226b43be6f1075
|
environments/project/tags/merge: 95051c859b8778be51226b43be6f1075
|
||||||
@@ -1121,9 +1103,9 @@ checksums:
|
|||||||
environments/surveys/edit/add: 5196f5cd4ba3a6ac8edef91345e17f66
|
environments/surveys/edit/add: 5196f5cd4ba3a6ac8edef91345e17f66
|
||||||
environments/surveys/edit/add_a_delay_or_auto_close_the_survey: b5fa358bf3ff324014060eb0baf6dd2f
|
environments/surveys/edit/add_a_delay_or_auto_close_the_survey: b5fa358bf3ff324014060eb0baf6dd2f
|
||||||
environments/surveys/edit/add_a_four_digit_pin: 953cb3673d2135923e3b4474d33ffb2c
|
environments/surveys/edit/add_a_four_digit_pin: 953cb3673d2135923e3b4474d33ffb2c
|
||||||
|
environments/surveys/edit/add_a_new_question_to_your_survey: 65f3a4f0d5132eab7aeaed1ad28df56c
|
||||||
environments/surveys/edit/add_a_variable_to_calculate: c202b50c12fc6f71f06eaf6f1b61e961
|
environments/surveys/edit/add_a_variable_to_calculate: c202b50c12fc6f71f06eaf6f1b61e961
|
||||||
environments/surveys/edit/add_action_below: 46cdbf9a77391aa89593908e508f7af0
|
environments/surveys/edit/add_action_below: 46cdbf9a77391aa89593908e508f7af0
|
||||||
environments/surveys/edit/add_block: ae8fbf8fdb5c6be7e4951a6cdd486473
|
|
||||||
environments/surveys/edit/add_choice_below: abf0416f7a78df61869de63d9766683c
|
environments/surveys/edit/add_choice_below: abf0416f7a78df61869de63d9766683c
|
||||||
environments/surveys/edit/add_color_coding: db738f7be21e08c5dc878c09fdf95e44
|
environments/surveys/edit/add_color_coding: db738f7be21e08c5dc878c09fdf95e44
|
||||||
environments/surveys/edit/add_color_coding_description: da15c619aa00084ad18f30766906527f
|
environments/surveys/edit/add_color_coding_description: da15c619aa00084ad18f30766906527f
|
||||||
@@ -1144,8 +1126,8 @@ checksums:
|
|||||||
environments/surveys/edit/add_other: de75bd3d40f3b5effdbe1c8d536f936b
|
environments/surveys/edit/add_other: de75bd3d40f3b5effdbe1c8d536f936b
|
||||||
environments/surveys/edit/add_photo_or_video: 7fd213e807ad060e415d1d4195397473
|
environments/surveys/edit/add_photo_or_video: 7fd213e807ad060e415d1d4195397473
|
||||||
environments/surveys/edit/add_pin: 1bc282dd7eaea51301655d3e8dd3a9fb
|
environments/surveys/edit/add_pin: 1bc282dd7eaea51301655d3e8dd3a9fb
|
||||||
|
environments/surveys/edit/add_question: 10336b52895385f7390540ad5bb4e208
|
||||||
environments/surveys/edit/add_question_below: 58e64eb2e013f1175ea0dcf79149109f
|
environments/surveys/edit/add_question_below: 58e64eb2e013f1175ea0dcf79149109f
|
||||||
environments/surveys/edit/add_question_to_block: 8589b1042aa93531a836549d6036492c
|
|
||||||
environments/surveys/edit/add_row: a613cef4caf1f0e05697c8de5164e2a3
|
environments/surveys/edit/add_row: a613cef4caf1f0e05697c8de5164e2a3
|
||||||
environments/surveys/edit/add_variable: 23f97e23aba763cc58934df4fa13ffc1
|
environments/surveys/edit/add_variable: 23f97e23aba763cc58934df4fa13ffc1
|
||||||
environments/surveys/edit/address_fields: 9cabb97c3deaff4f6cb3afc3d5cfaf0a
|
environments/surveys/edit/address_fields: 9cabb97c3deaff4f6cb3afc3d5cfaf0a
|
||||||
@@ -1159,6 +1141,7 @@ checksums:
|
|||||||
environments/surveys/edit/allow_multi_select: 7b4b83f7a0205e2a0a8971671a69a174
|
environments/surveys/edit/allow_multi_select: 7b4b83f7a0205e2a0a8971671a69a174
|
||||||
environments/surveys/edit/allow_multiple_files: dbd99f9d1026e4f7c5a5d03f71ba379d
|
environments/surveys/edit/allow_multiple_files: dbd99f9d1026e4f7c5a5d03f71ba379d
|
||||||
environments/surveys/edit/allow_users_to_select_more_than_one_image: d683e0b538d1366400292a771f3fbd08
|
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/and_launch_surveys_in_your_website_or_app: a3edcdb4aea792a27d90aad1930f001a
|
||||||
environments/surveys/edit/animation: 66a18eacfb92fc9fc9db188d2dde4f81
|
environments/surveys/edit/animation: 66a18eacfb92fc9fc9db188d2dde4f81
|
||||||
environments/surveys/edit/app_survey_description: bdfacfce478e97f70b700a1382dfa687
|
environments/surveys/edit/app_survey_description: bdfacfce478e97f70b700a1382dfa687
|
||||||
@@ -1171,14 +1154,12 @@ checksums:
|
|||||||
environments/surveys/edit/automatically_mark_the_survey_as_complete_after: c6ede2a5515a4ca72b36aec2583f43aa
|
environments/surveys/edit/automatically_mark_the_survey_as_complete_after: c6ede2a5515a4ca72b36aec2583f43aa
|
||||||
environments/surveys/edit/back_button_label: 25af945e77336724b5276de291cc92d9
|
environments/surveys/edit/back_button_label: 25af945e77336724b5276de291cc92d9
|
||||||
environments/surveys/edit/background_styling: 4e1e6fd2ec767bbff8767f6c0f68a731
|
environments/surveys/edit/background_styling: 4e1e6fd2ec767bbff8767f6c0f68a731
|
||||||
environments/surveys/edit/block_deleted: c682259eb138ad84f8b4441abfd9b572
|
|
||||||
environments/surveys/edit/block_duplicated: dc9e9fab2b1cd91f6c265324b34c6376
|
|
||||||
environments/surveys/edit/bold: 4d7306bc355ed2befd6a9237c5452ee6
|
environments/surveys/edit/bold: 4d7306bc355ed2befd6a9237c5452ee6
|
||||||
environments/surveys/edit/brand_color: 84ddb5736deb9f5c081ffe4962a6c63e
|
environments/surveys/edit/brand_color: 84ddb5736deb9f5c081ffe4962a6c63e
|
||||||
environments/surveys/edit/brightness: 45425b6db1872225bfff71cf619d0e64
|
environments/surveys/edit/brightness: 45425b6db1872225bfff71cf619d0e64
|
||||||
environments/surveys/edit/button_external: d2de24e06574622baf1c0cdd1b718b1a
|
|
||||||
environments/surveys/edit/button_external_description: cbd10d494a70b362bfee811e012c45b1
|
|
||||||
environments/surveys/edit/button_label: db3cd7c74f393187bd780c5c3d8b9b4f
|
environments/surveys/edit/button_label: db3cd7c74f393187bd780c5c3d8b9b4f
|
||||||
|
environments/surveys/edit/button_to_continue_in_survey: 931d87aaf360ab7521f9dd75795a42d0
|
||||||
|
environments/surveys/edit/button_to_link_to_external_url: 7c7cf54e8dc86240b86964133e802888
|
||||||
environments/surveys/edit/button_url: 6f39f649a165a11873c11ea6403dba90
|
environments/surveys/edit/button_url: 6f39f649a165a11873c11ea6403dba90
|
||||||
environments/surveys/edit/cal_username: a4a9c739af909d975beb1bc4998feae9
|
environments/surveys/edit/cal_username: a4a9c739af909d975beb1bc4998feae9
|
||||||
environments/surveys/edit/calculate: c5fcf8d3a38706ae2071b6f78339ec68
|
environments/surveys/edit/calculate: c5fcf8d3a38706ae2071b6f78339ec68
|
||||||
@@ -1217,7 +1198,6 @@ checksums:
|
|||||||
environments/surveys/edit/character_limit_toggle_title: fdc45bcc6335e5116aec895fecda0d87
|
environments/surveys/edit/character_limit_toggle_title: fdc45bcc6335e5116aec895fecda0d87
|
||||||
environments/surveys/edit/checkbox_label: 12a07d6bdf38e283a2e95892ec49b7f8
|
environments/surveys/edit/checkbox_label: 12a07d6bdf38e283a2e95892ec49b7f8
|
||||||
environments/surveys/edit/choose_the_actions_which_trigger_the_survey: 773b311a148a112243f3b139506b9987
|
environments/surveys/edit/choose_the_actions_which_trigger_the_survey: 773b311a148a112243f3b139506b9987
|
||||||
environments/surveys/edit/choose_the_first_question_on_your_block: bdece06ca04f89d0c445ba1554dd5b80
|
|
||||||
environments/surveys/edit/choose_where_to_run_the_survey: ad87bcae97c445f1fd9ac110ea24f117
|
environments/surveys/edit/choose_where_to_run_the_survey: ad87bcae97c445f1fd9ac110ea24f117
|
||||||
environments/surveys/edit/city: 1831f32e1babbb29af27fac3053504a2
|
environments/surveys/edit/city: 1831f32e1babbb29af27fac3053504a2
|
||||||
environments/surveys/edit/close_survey_on_response_limit: 256d0bccdbcbb3d20e39aabc5b376e5e
|
environments/surveys/edit/close_survey_on_response_limit: 256d0bccdbcbb3d20e39aabc5b376e5e
|
||||||
@@ -1241,12 +1221,11 @@ checksums:
|
|||||||
environments/surveys/edit/create_group: 4566e056e5217dc02a383105892fe18c
|
environments/surveys/edit/create_group: 4566e056e5217dc02a383105892fe18c
|
||||||
environments/surveys/edit/create_your_own_survey: e3ddd53e0cfa409ca8dccfb3d77933e7
|
environments/surveys/edit/create_your_own_survey: e3ddd53e0cfa409ca8dccfb3d77933e7
|
||||||
environments/surveys/edit/css_selector: 615e9f1b74622df29de28a5b5614c6fe
|
environments/surveys/edit/css_selector: 615e9f1b74622df29de28a5b5614c6fe
|
||||||
environments/surveys/edit/cta_button_label: ec070ffba38eae24751bb3a4c1e14c81
|
|
||||||
environments/surveys/edit/custom_hostname: bc2b1c8de3f9b8ef145b45aeba6ab429
|
environments/surveys/edit/custom_hostname: bc2b1c8de3f9b8ef145b45aeba6ab429
|
||||||
environments/surveys/edit/darken_or_lighten_background_of_your_choice: 304a64a8050ebf501d195e948cd25b6f
|
environments/surveys/edit/darken_or_lighten_background_of_your_choice: 304a64a8050ebf501d195e948cd25b6f
|
||||||
environments/surveys/edit/date_format: e95dfc41ac944874868487457ddc057a
|
environments/surveys/edit/date_format: e95dfc41ac944874868487457ddc057a
|
||||||
environments/surveys/edit/days_before_showing_this_survey_again: 354fb28c5ff076f022d82a20c749ee46
|
environments/surveys/edit/days_before_showing_this_survey_again: 8b4623eab862615fa60064400008eb23
|
||||||
environments/surveys/edit/delete_block: c00617cb0724557e486304276063807a
|
environments/surveys/edit/decide_how_often_people_can_answer_this_survey: 58427b0f0a7a258c24fa2acd9913e95e
|
||||||
environments/surveys/edit/delete_choice: fd750208d414b9ad8c980c161a0199e1
|
environments/surveys/edit/delete_choice: fd750208d414b9ad8c980c161a0199e1
|
||||||
environments/surveys/edit/disable_the_visibility_of_survey_progress: 2af631010114307ac2a91612559c9618
|
environments/surveys/edit/disable_the_visibility_of_survey_progress: 2af631010114307ac2a91612559c9618
|
||||||
environments/surveys/edit/display_an_estimate_of_completion_time_for_survey: 03f0a816569399c1c61d08dbc913de06
|
environments/surveys/edit/display_an_estimate_of_completion_time_for_survey: 03f0a816569399c1c61d08dbc913de06
|
||||||
@@ -1258,13 +1237,10 @@ checksums:
|
|||||||
environments/surveys/edit/does_not_include_all_of: c18c1a71e6d96c681a3e95c7bd6c9482
|
environments/surveys/edit/does_not_include_all_of: c18c1a71e6d96c681a3e95c7bd6c9482
|
||||||
environments/surveys/edit/does_not_include_one_of: 91090d2e0667faf654f6a81d9857440f
|
environments/surveys/edit/does_not_include_one_of: 91090d2e0667faf654f6a81d9857440f
|
||||||
environments/surveys/edit/does_not_start_with: 9395869b54cdfb353a51a7e0864f4fd7
|
environments/surveys/edit/does_not_start_with: 9395869b54cdfb353a51a7e0864f4fd7
|
||||||
environments/surveys/edit/duplicate_block: d4ea4afb5fc5b18a81cbe0302fa05997
|
|
||||||
environments/surveys/edit/duplicate_question: 910751de01fdd327165968214717711b
|
|
||||||
environments/surveys/edit/edit_link: 40ba9e15beac77a46c5baf30be84ac54
|
environments/surveys/edit/edit_link: 40ba9e15beac77a46c5baf30be84ac54
|
||||||
environments/surveys/edit/edit_recall: 38a4a7378d02453e35d06f2532eef318
|
environments/surveys/edit/edit_recall: 38a4a7378d02453e35d06f2532eef318
|
||||||
environments/surveys/edit/edit_translations: 2b21bea4b53e88342559272701e9fbf3
|
environments/surveys/edit/edit_translations: 2b21bea4b53e88342559272701e9fbf3
|
||||||
environments/surveys/edit/element_not_found: 196777ff6811dd177971ffc8e27a72c1
|
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_recaptcha_to_protect_your_survey_from_spam: 4483a5763718d201ac97caa1e1216e13
|
||||||
environments/surveys/edit/enable_spam_protection: e1fb0dd0723044bf040b92d8fc58015d
|
environments/surveys/edit/enable_spam_protection: e1fb0dd0723044bf040b92d8fc58015d
|
||||||
environments/surveys/edit/end_screen_card: 6146c2bcb87291e25ecb03abd2d9a479
|
environments/surveys/edit/end_screen_card: 6146c2bcb87291e25ecb03abd2d9a479
|
||||||
@@ -1277,9 +1253,9 @@ checksums:
|
|||||||
environments/surveys/edit/equals_one_of: 369a451add4b79bc003f952f0e1bfcc9
|
environments/surveys/edit/equals_one_of: 369a451add4b79bc003f952f0e1bfcc9
|
||||||
environments/surveys/edit/error_publishing_survey: bf9fab1d8ea7132a2e9b4b7b09f18b1f
|
environments/surveys/edit/error_publishing_survey: bf9fab1d8ea7132a2e9b4b7b09f18b1f
|
||||||
environments/surveys/edit/error_saving_changes: b75aa9e4e42e1d43c8f9c33c2b7dc9a7
|
environments/surveys/edit/error_saving_changes: b75aa9e4e42e1d43c8f9c33c2b7dc9a7
|
||||||
environments/surveys/edit/even_after_they_submitted_a_response_e_g_feedback_box: 7b99f30397dcde76f65e1ab64bdbd113
|
environments/surveys/edit/even_after_they_submitted_a_response_e_g_feedback_box: c6668f9cf127fd922bec695dc548fe12
|
||||||
environments/surveys/edit/everyone: 2112aa71b568773e8e8a792c63f4d413
|
environments/surveys/edit/everyone: 2112aa71b568773e8e8a792c63f4d413
|
||||||
environments/surveys/edit/external_urls_paywall_tooltip: a8860ff0a2ad5f283bc0becba374cd54
|
environments/surveys/edit/external_urls_paywall_tooltip: 0dbb62557e8a6fa817f0e74709eeb3d2
|
||||||
environments/surveys/edit/fallback_missing: 43dbedbe1a178d455e5f80783a7b6722
|
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_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
|
environments/surveys/edit/fieldId_is_used_in_quota_please_remove_it_from_quota_first: 374c563964fc805ab0b8974e781687d9
|
||||||
@@ -1339,19 +1315,17 @@ checksums:
|
|||||||
environments/surveys/edit/hidden_field_used_in_recall: 70dee46bae18209e8861b654ff9a04ae
|
environments/surveys/edit/hidden_field_used_in_recall: 70dee46bae18209e8861b654ff9a04ae
|
||||||
environments/surveys/edit/hidden_field_used_in_recall_ending_card: a985d03d18e33d83521961c9c981d0ee
|
environments/surveys/edit/hidden_field_used_in_recall_ending_card: a985d03d18e33d83521961c9c981d0ee
|
||||||
environments/surveys/edit/hidden_field_used_in_recall_welcome: 22fef7001d5e60edbf877e7b435c1991
|
environments/surveys/edit/hidden_field_used_in_recall_welcome: 22fef7001d5e60edbf877e7b435c1991
|
||||||
|
environments/surveys/edit/hide_advanced_settings: ffa251d7762030b72c12e92f3c69a9b4
|
||||||
environments/surveys/edit/hide_back_button: 9f355fb4a8e80485b9de521a952ffeb9
|
environments/surveys/edit/hide_back_button: 9f355fb4a8e80485b9de521a952ffeb9
|
||||||
environments/surveys/edit/hide_back_button_description: caaa30cf43c5611577933a1c9f44b9ee
|
environments/surveys/edit/hide_back_button_description: caaa30cf43c5611577933a1c9f44b9ee
|
||||||
environments/surveys/edit/hide_block_settings: c24c3d3892c251792e297cdc036d2fde
|
|
||||||
environments/surveys/edit/hide_logo: eef4de2e3fffe8cbe32bff4f6f7250d8
|
environments/surveys/edit/hide_logo: eef4de2e3fffe8cbe32bff4f6f7250d8
|
||||||
environments/surveys/edit/hide_progress_bar: 7eefe7db6a051105bded521d94204933
|
environments/surveys/edit/hide_progress_bar: 7eefe7db6a051105bded521d94204933
|
||||||
environments/surveys/edit/hide_question_settings: 99127cd016db2f7fc80333b36473c0ef
|
|
||||||
environments/surveys/edit/hide_the_logo_in_this_specific_survey: 29d4c6c714886e57bc29ad292d0f5a00
|
environments/surveys/edit/hide_the_logo_in_this_specific_survey: 29d4c6c714886e57bc29ad292d0f5a00
|
||||||
environments/surveys/edit/hostname: 9bdaa7692869999df51bb60d58d9ef62
|
environments/surveys/edit/hostname: 9bdaa7692869999df51bb60d58d9ef62
|
||||||
environments/surveys/edit/how_funky_do_you_want_your_cards_in_survey_type_derived_surveys: 3cb16b37510c01af20a80f51b598346e
|
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_need_more_please: a7d208c283caf6b93800b809fca80768
|
||||||
environments/surveys/edit/if_you_really_want_that_answer_ask_until_you_get_it: 31c18a8c7c578db2ba49eed663d1739f
|
environments/surveys/edit/if_you_really_want_that_answer_ask_until_you_get_it: 33f0320ec85067a06198a841348e9fc6
|
||||||
environments/surveys/edit/ignore_global_waiting_time: 1e7f1465aeb6d26c325ad7f135b207a8
|
environments/surveys/edit/ignore_waiting_time_between_surveys: 8145b6aef535fde5ee54dea63e66f64a
|
||||||
environments/surveys/edit/ignore_global_waiting_time_description: 37d173a4d537622de40677389238d859
|
|
||||||
environments/surveys/edit/image: 048ba7a239de0fbd883ade8558415830
|
environments/surveys/edit/image: 048ba7a239de0fbd883ade8558415830
|
||||||
environments/surveys/edit/includes_all_of: ec72f90c0839d4c3bb518deb03894031
|
environments/surveys/edit/includes_all_of: ec72f90c0839d4c3bb518deb03894031
|
||||||
environments/surveys/edit/includes_one_of: 6d5be5d7c2494179e88bd7302b247884
|
environments/surveys/edit/includes_one_of: 6d5be5d7c2494179e88bd7302b247884
|
||||||
@@ -1371,7 +1345,6 @@ checksums:
|
|||||||
environments/surveys/edit/is_clicked: 8977b8cc9ff07d2b8bdb81bb41bb55cf
|
environments/surveys/edit/is_clicked: 8977b8cc9ff07d2b8bdb81bb41bb55cf
|
||||||
environments/surveys/edit/is_completely_submitted: 8c8f0c0a9cf81dac16e486b2f5cdbb3b
|
environments/surveys/edit/is_completely_submitted: 8c8f0c0a9cf81dac16e486b2f5cdbb3b
|
||||||
environments/surveys/edit/is_empty: dca87bc415341b1cdf9523f3b795a313
|
environments/surveys/edit/is_empty: dca87bc415341b1cdf9523f3b795a313
|
||||||
environments/surveys/edit/is_not_clicked: 04ac5678998edbdf9f431af74bd480da
|
|
||||||
environments/surveys/edit/is_not_empty: 8e53d702b296f172386b1277a8699050
|
environments/surveys/edit/is_not_empty: 8e53d702b296f172386b1277a8699050
|
||||||
environments/surveys/edit/is_not_set: c1a6fd89387686d3a5426a768bb286e9
|
environments/surveys/edit/is_not_set: c1a6fd89387686d3a5426a768bb286e9
|
||||||
environments/surveys/edit/is_partially_submitted: f5acf840b87d0d42c69d49a5714a86f3
|
environments/surveys/edit/is_partially_submitted: f5acf840b87d0d42c69d49a5714a86f3
|
||||||
@@ -1379,7 +1352,7 @@ checksums:
|
|||||||
environments/surveys/edit/is_skipped: 9fb90b6578f603cca37d4e6c912bb401
|
environments/surveys/edit/is_skipped: 9fb90b6578f603cca37d4e6c912bb401
|
||||||
environments/surveys/edit/is_submitted: 13e774a97ad5f5609555e6f99514e70f
|
environments/surveys/edit/is_submitted: 13e774a97ad5f5609555e6f99514e70f
|
||||||
environments/surveys/edit/italic: 555c60fb1d12ae305136202afa6deb3d
|
environments/surveys/edit/italic: 555c60fb1d12ae305136202afa6deb3d
|
||||||
environments/surveys/edit/jump_to_block: 2fc00bd725c44f98861051c57bb2c392
|
environments/surveys/edit/jump_to_question: 742aabed8845190825418aa429f01b2d
|
||||||
environments/surveys/edit/keep_current_order: a7c944ad6b3515f2c4f83a2c81f8fc26
|
environments/surveys/edit/keep_current_order: a7c944ad6b3515f2c4f83a2c81f8fc26
|
||||||
environments/surveys/edit/keep_showing_while_conditions_match: 2574802d87bd6da151c9145aacce7281
|
environments/surveys/edit/keep_showing_while_conditions_match: 2574802d87bd6da151c9145aacce7281
|
||||||
environments/surveys/edit/key: 3d1065ab98a1c2f1210507fd5c7bf515
|
environments/surveys/edit/key: 3d1065ab98a1c2f1210507fd5c7bf515
|
||||||
@@ -1393,18 +1366,16 @@ checksums:
|
|||||||
environments/surveys/edit/logic_error_warning: 542fbb918ffdb29e6f9a4a6196ffb558
|
environments/surveys/edit/logic_error_warning: 542fbb918ffdb29e6f9a4a6196ffb558
|
||||||
environments/surveys/edit/logic_error_warning_text: f2afad8852a95ed169a39959efbf592c
|
environments/surveys/edit/logic_error_warning_text: f2afad8852a95ed169a39959efbf592c
|
||||||
environments/surveys/edit/long_answer: 3a97f8d2e90aba6e679917a0c5670c53
|
environments/surveys/edit/long_answer: 3a97f8d2e90aba6e679917a0c5670c53
|
||||||
environments/surveys/edit/long_answer_toggle_description: 86bcdfeb74d9825c2f2d5a215e92d111
|
|
||||||
environments/surveys/edit/lower_label: 45985bca022d4370bd6e013af75d5160
|
environments/surveys/edit/lower_label: 45985bca022d4370bd6e013af75d5160
|
||||||
environments/surveys/edit/manage_languages: 9c56d5afee8a73dfc283a452470f3a10
|
environments/surveys/edit/manage_languages: 9c56d5afee8a73dfc283a452470f3a10
|
||||||
environments/surveys/edit/matrix_all_fields: 187240509163b2f52a400a565e57c67f
|
environments/surveys/edit/matrix_all_fields: 187240509163b2f52a400a565e57c67f
|
||||||
environments/surveys/edit/matrix_rows: 8f41f34e6ca28221cf1ebd948af4c151
|
environments/surveys/edit/matrix_rows: 8f41f34e6ca28221cf1ebd948af4c151
|
||||||
environments/surveys/edit/max_file_size: 3d35a22048f4d22e24da698fb5fb77d7
|
environments/surveys/edit/max_file_size: 3d35a22048f4d22e24da698fb5fb77d7
|
||||||
environments/surveys/edit/max_file_size_limit_is: 78998639cde3587cecb272ba47e05f9e
|
environments/surveys/edit/max_file_size_limit_is: 78998639cde3587cecb272ba47e05f9e
|
||||||
environments/surveys/edit/move_question_to_block: e8d7ef1e2f727921cb7f5788849492ad
|
|
||||||
environments/surveys/edit/multiply: 89a0bb629167f97750ae1645a46ced0d
|
environments/surveys/edit/multiply: 89a0bb629167f97750ae1645a46ced0d
|
||||||
environments/surveys/edit/needed_for_self_hosted_cal_com_instance: d241e72f0332177d32ce6c35070757dc
|
environments/surveys/edit/needed_for_self_hosted_cal_com_instance: d241e72f0332177d32ce6c35070757dc
|
||||||
environments/surveys/edit/next_block: 53eaa5b1c9333455ab1e99bedd222ba2
|
|
||||||
environments/surveys/edit/next_button_label: e23522dd38f3eabeeccd3f48f32b73a8
|
environments/surveys/edit/next_button_label: e23522dd38f3eabeeccd3f48f32b73a8
|
||||||
|
environments/surveys/edit/next_question: 2e0f1ea264fb4bfcb8378b2b0cf7c18f
|
||||||
environments/surveys/edit/no_hidden_fields_yet_add_first_one_below: 9cc6cab3a6a42dbf835215897b5b8516
|
environments/surveys/edit/no_hidden_fields_yet_add_first_one_below: 9cc6cab3a6a42dbf835215897b5b8516
|
||||||
environments/surveys/edit/no_images_found_for: 90f10f4611ed7b115a49595409b66ebe
|
environments/surveys/edit/no_images_found_for: 90f10f4611ed7b115a49595409b66ebe
|
||||||
environments/surveys/edit/no_languages_found_add_first_one_to_get_started: 22d7782c8504daf693cab3cf7135d6e3
|
environments/surveys/edit/no_languages_found_add_first_one_to_get_started: 22d7782c8504daf693cab3cf7135d6e3
|
||||||
@@ -1421,10 +1392,9 @@ checksums:
|
|||||||
environments/surveys/edit/optional: 396fb9a0472daf401c392bdc3e248943
|
environments/surveys/edit/optional: 396fb9a0472daf401c392bdc3e248943
|
||||||
environments/surveys/edit/options: 59156082418d80acb211f973b1218f11
|
environments/surveys/edit/options: 59156082418d80acb211f973b1218f11
|
||||||
environments/surveys/edit/override_theme_with_individual_styles_for_this_survey: edffc97f5d3372419fe0444de0a5aa3f
|
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_placement: d7278be243e52c5091974e0fc4a7c342
|
||||||
environments/surveys/edit/overwrite_the_global_placement_of_the_survey: 874075712254b1ce92e099d89f675a48
|
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/pick_a_background_from_our_library_or_upload_your_own: b83bcbdc8131fc9524d272ff5dede754
|
||||||
environments/surveys/edit/picture_idx: 55e053ad1ade5d17c582406706036028
|
environments/surveys/edit/picture_idx: 55e053ad1ade5d17c582406706036028
|
||||||
environments/surveys/edit/pin_can_only_contain_numbers: 417c854d44620a7229ebd9ab8cbb3613
|
environments/surveys/edit/pin_can_only_contain_numbers: 417c854d44620a7229ebd9ab8cbb3613
|
||||||
@@ -1481,8 +1451,7 @@ checksums:
|
|||||||
environments/surveys/edit/range: 1fad969ecf3de1c21df046b93053c422
|
environments/surveys/edit/range: 1fad969ecf3de1c21df046b93053c422
|
||||||
environments/surveys/edit/recall_data: 39beabd626c0af15316885cff5d5d9b8
|
environments/surveys/edit/recall_data: 39beabd626c0af15316885cff5d5d9b8
|
||||||
environments/surveys/edit/recall_information_from: 884cfd143456fab1a91f0744cc92f0c8
|
environments/surveys/edit/recall_information_from: 884cfd143456fab1a91f0744cc92f0c8
|
||||||
environments/surveys/edit/recontact_options_section: 57a23e1bcab6baa484b27b615e6c906a
|
environments/surveys/edit/recontact_options: 0f570378a531da60448fde37abd50214
|
||||||
environments/surveys/edit/recontact_options_section_description: 1e04011440c339a3b5cfff12d55b7f12
|
|
||||||
environments/surveys/edit/redirect_thank_you_card: 09f721c4b62e2584e40a53507092ea83
|
environments/surveys/edit/redirect_thank_you_card: 09f721c4b62e2584e40a53507092ea83
|
||||||
environments/surveys/edit/redirect_to_url: f17d726bbc3391561447b3f4010635cf
|
environments/surveys/edit/redirect_to_url: f17d726bbc3391561447b3f4010635cf
|
||||||
environments/surveys/edit/remove_description: b52de820b4bbcb354eb62246c4112a9a
|
environments/surveys/edit/remove_description: b52de820b4bbcb354eb62246c4112a9a
|
||||||
@@ -1491,8 +1460,6 @@ checksums:
|
|||||||
environments/surveys/edit/required: 04d7fb6f37ffe0a6ca97d49e2a8b6eb5
|
environments/surveys/edit/required: 04d7fb6f37ffe0a6ca97d49e2a8b6eb5
|
||||||
environments/surveys/edit/reset_to_theme_styles: f9edc3970ec23d6c4d2d7accc292ef3a
|
environments/surveys/edit/reset_to_theme_styles: f9edc3970ec23d6c4d2d7accc292ef3a
|
||||||
environments/surveys/edit/reset_to_theme_styles_main_text: d86fb2213d3b2efbd0361526dc6cb27b
|
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_can_t_be_set_to_0: 278664873ee3b1046dbcb58848efc12a
|
||||||
environments/surveys/edit/response_limit_needs_to_exceed_number_of_received_responses: 9a9c223c0918ded716ddfaa84fbaa8d9
|
environments/surveys/edit/response_limit_needs_to_exceed_number_of_received_responses: 9a9c223c0918ded716ddfaa84fbaa8d9
|
||||||
environments/surveys/edit/response_limits_redirections_and_more: e4f1cf94e56ad0e1b08701158d688802
|
environments/surveys/edit/response_limits_redirections_and_more: e4f1cf94e56ad0e1b08701158d688802
|
||||||
@@ -1514,17 +1481,17 @@ checksums:
|
|||||||
environments/surveys/edit/set_the_global_placement_in_the_look_feel_settings: e34e579e778a918733702edb041ac929
|
environments/surveys/edit/set_the_global_placement_in_the_look_feel_settings: e34e579e778a918733702edb041ac929
|
||||||
environments/surveys/edit/settings_saved_successfully: eb109269bc59dd67ae09fd9eb53652d2
|
environments/surveys/edit/settings_saved_successfully: eb109269bc59dd67ae09fd9eb53652d2
|
||||||
environments/surveys/edit/seven_points: 4ead50fdfda45e8710767e1b1a84bf42
|
environments/surveys/edit/seven_points: 4ead50fdfda45e8710767e1b1a84bf42
|
||||||
environments/surveys/edit/show_block_settings: bad99d99c9908874e45f5c350a88cc79
|
environments/surveys/edit/show_advanced_settings: b6f5bbbb84f34e51cd72ccd332e9613e
|
||||||
environments/surveys/edit/show_button: 6b364aac9d7ac71f34a438607c9693bc
|
environments/surveys/edit/show_button: 6b364aac9d7ac71f34a438607c9693bc
|
||||||
environments/surveys/edit/show_language_switch: b6915a7f26d7079f2d4d844d74440413
|
environments/surveys/edit/show_language_switch: b6915a7f26d7079f2d4d844d74440413
|
||||||
environments/surveys/edit/show_multiple_times: 05239c532c9c05ef5d2990ba6ce12f60
|
environments/surveys/edit/show_multiple_times: 5e6e0244c20feca78723c79aa1ddcf62
|
||||||
environments/surveys/edit/show_only_once: 31858baf60ebcf193c7e35d9084af0af
|
environments/surveys/edit/show_only_once: 31858baf60ebcf193c7e35d9084af0af
|
||||||
environments/surveys/edit/show_question_settings: a84698a95df0833a35d653edcdbbe501
|
|
||||||
environments/surveys/edit/show_survey_maximum_of: 721ed61b01a9fc8ce4becb72823bb72e
|
environments/surveys/edit/show_survey_maximum_of: 721ed61b01a9fc8ce4becb72823bb72e
|
||||||
environments/surveys/edit/show_survey_to_users: d5e90fd17babfea978fce826e9df89b0
|
environments/surveys/edit/show_survey_to_users: d5e90fd17babfea978fce826e9df89b0
|
||||||
environments/surveys/edit/show_to_x_percentage_of_targeted_users: b745169011fa7e8ca475baa5500c5197
|
environments/surveys/edit/show_to_x_percentage_of_targeted_users: b745169011fa7e8ca475baa5500c5197
|
||||||
environments/surveys/edit/simple: 65575bd903091299bc4a94b7517a6288
|
environments/surveys/edit/simple: 65575bd903091299bc4a94b7517a6288
|
||||||
environments/surveys/edit/six_points: c6c09b3f07171dc388cb5a610ea79af7
|
environments/surveys/edit/six_points: c6c09b3f07171dc388cb5a610ea79af7
|
||||||
|
environments/surveys/edit/skip_button_label: bfc8993b0f13e6f4fc9ef0c570b808e3
|
||||||
environments/surveys/edit/smiley: e68e3b28fc3c04255e236c6a0feb662b
|
environments/surveys/edit/smiley: e68e3b28fc3c04255e236c6a0feb662b
|
||||||
environments/surveys/edit/spam_protection_note: 94059310d07c30f6704e216297036d05
|
environments/surveys/edit/spam_protection_note: 94059310d07c30f6704e216297036d05
|
||||||
environments/surveys/edit/spam_protection_threshold_description: ed8b8c9c583077a88bf5dd3ec8b59e60
|
environments/surveys/edit/spam_protection_threshold_description: ed8b8c9c583077a88bf5dd3ec8b59e60
|
||||||
@@ -1545,15 +1512,15 @@ checksums:
|
|||||||
environments/surveys/edit/survey_placement: 083c10f257337f9648bf9d435b18ec2c
|
environments/surveys/edit/survey_placement: 083c10f257337f9648bf9d435b18ec2c
|
||||||
environments/surveys/edit/survey_trigger: f0c7014a684ca566698b87074fad5579
|
environments/surveys/edit/survey_trigger: f0c7014a684ca566698b87074fad5579
|
||||||
environments/surveys/edit/switch_multi_lanugage_on_to_get_started: d2ca06684af26bd6b5121a4656bb6458
|
environments/surveys/edit/switch_multi_lanugage_on_to_get_started: d2ca06684af26bd6b5121a4656bb6458
|
||||||
environments/surveys/edit/target_block_not_found: 0a0c401017ab32364fec2fcbf815d832
|
|
||||||
environments/surveys/edit/targeted: ca615f1fc3b490d5a2187b27fb4a2073
|
environments/surveys/edit/targeted: ca615f1fc3b490d5a2187b27fb4a2073
|
||||||
environments/surveys/edit/ten_points: a1317b82003859f77fb3138c55450d63
|
environments/surveys/edit/ten_points: a1317b82003859f77fb3138c55450d63
|
||||||
environments/surveys/edit/the_survey_will_be_shown_multiple_times_until_they_respond: 2d8d7d2351bd7533eb3788cce228c654
|
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: 6062aaa5cf8e58e79b75b6b588ae9598
|
environments/surveys/edit/the_survey_will_be_shown_once_even_if_person_doesnt_respond: c145b7be481ae1fe6f66298d9a5cf838
|
||||||
environments/surveys/edit/then: 5e941fb7dd51a18651fcfb865edd5ba6
|
environments/surveys/edit/then: 5e941fb7dd51a18651fcfb865edd5ba6
|
||||||
environments/surveys/edit/this_action_will_remove_all_the_translations_from_this_survey: 3340c89696f10bdc01b9a1047ff0b987
|
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_extension_is_already_added: 201d636539836c95958e28cecd8f3240
|
||||||
environments/surveys/edit/this_file_type_is_not_supported: f365b9a2e05aa062ab0bc1af61f642e2
|
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/three_points: d7f299aec752d7d690ef0ab6373327ae
|
||||||
environments/surveys/edit/times: 5ab156c13df6bfd75c0b17ad0a92c78a
|
environments/surveys/edit/times: 5ab156c13df6bfd75c0b17ad0a92c78a
|
||||||
environments/surveys/edit/to_keep_the_placement_over_all_surveys_consistent_you_can: 7a078e6a39d4c30b465137d2b6ef3e67
|
environments/surveys/edit/to_keep_the_placement_over_all_surveys_consistent_you_can: 7a078e6a39d4c30b465137d2b6ef3e67
|
||||||
@@ -1564,7 +1531,7 @@ checksums:
|
|||||||
environments/surveys/edit/unlock_targeting_description: 8e315dc41c2849754839a1460643c5fb
|
environments/surveys/edit/unlock_targeting_description: 8e315dc41c2849754839a1460643c5fb
|
||||||
environments/surveys/edit/unlock_targeting_title: 6098caf969cac64cd54e217471ae42d4
|
environments/surveys/edit/unlock_targeting_title: 6098caf969cac64cd54e217471ae42d4
|
||||||
environments/surveys/edit/unsaved_changes_warning: a164f276c9f7344022aa4640b32abcf9
|
environments/surveys/edit/unsaved_changes_warning: a164f276c9f7344022aa4640b32abcf9
|
||||||
environments/surveys/edit/until_they_submit_a_response: 2a0fd5dcc6cc40a72ed9b974f22eaf68
|
environments/surveys/edit/until_they_submit_a_response: c980c520f5b5883ed46f2e1c006082b5
|
||||||
environments/surveys/edit/untitled_block: fdaa045139deff5cc65fa027df0cc22e
|
environments/surveys/edit/untitled_block: fdaa045139deff5cc65fa027df0cc22e
|
||||||
environments/surveys/edit/upgrade_notice_description: 32b66a4f257ad8d38bc38dcc95fe23c4
|
environments/surveys/edit/upgrade_notice_description: 32b66a4f257ad8d38bc38dcc95fe23c4
|
||||||
environments/surveys/edit/upgrade_notice_title: 40866066ebc558ad0c92a4f19f12090c
|
environments/surveys/edit/upgrade_notice_title: 40866066ebc558ad0c92a4f19f12090c
|
||||||
@@ -1573,6 +1540,7 @@ checksums:
|
|||||||
environments/surveys/edit/upper_label: 1fa48bce3fade6ffc1a52d9fdddf9e17
|
environments/surveys/edit/upper_label: 1fa48bce3fade6ffc1a52d9fdddf9e17
|
||||||
environments/surveys/edit/url_filters: e524879d2eb74463d7fd06a7e0f53421
|
environments/surveys/edit/url_filters: e524879d2eb74463d7fd06a7e0f53421
|
||||||
environments/surveys/edit/url_not_supported: af8a753467c617b596aadef1aaaed664
|
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_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_is_used_in_quota_please_remove_it_from_quota_first: 0d36e5b2713f5450fe346e0af0aaa29c
|
||||||
environments/surveys/edit/variable_name_is_already_taken_please_choose_another: 6da42fe8733c6379158bce9a176f76d7
|
environments/surveys/edit/variable_name_is_already_taken_please_choose_another: 6da42fe8733c6379158bce9a176f76d7
|
||||||
@@ -1582,13 +1550,11 @@ checksums:
|
|||||||
environments/surveys/edit/variable_used_in_recall_welcome: 60321b2f40ae01cd10f99ed77bb986ba
|
environments/surveys/edit/variable_used_in_recall_welcome: 60321b2f40ae01cd10f99ed77bb986ba
|
||||||
environments/surveys/edit/verify_email_before_submission: c05d345dc35f2d33839e4cfd72d11eb2
|
environments/surveys/edit/verify_email_before_submission: c05d345dc35f2d33839e4cfd72d11eb2
|
||||||
environments/surveys/edit/verify_email_before_submission_description: 434ab3ee6134367513b633a9d4f7d772
|
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: 014d18ade977bf08d75b995076596708
|
||||||
environments/surveys/edit/wait_a_few_seconds_after_the_trigger_before_showing_the_survey: 13d5521cf73be5afeba71f5db5847919
|
environments/surveys/edit/wait_a_few_seconds_after_the_trigger_before_showing_the_survey: 13d5521cf73be5afeba71f5db5847919
|
||||||
environments/surveys/edit/waiting_time_across_surveys: 5c5a7653d797c86c4008f13a40434ad8
|
environments/surveys/edit/waiting_period: 21775d12b2cb831134b1f47450eaf1f3
|
||||||
environments/surveys/edit/waiting_time_across_surveys_description: 1bbee2fee49f842056547c336f8fd788
|
|
||||||
environments/surveys/edit/welcome_message: 986a434e3895c8ee0b267df95cc40051
|
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/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_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
|
environments/surveys/edit/you_need_to_have_two_or_more_languages_set_up_in_your_project_to_work_with_translations: b12b28699e02ff9ba69bcbae838ba5da
|
||||||
@@ -1629,7 +1595,7 @@ checksums:
|
|||||||
environments/surveys/responses/last_name: 2c9a7de7738ca007ba9023c385149c26
|
environments/surveys/responses/last_name: 2c9a7de7738ca007ba9023c385149c26
|
||||||
environments/surveys/responses/not_completed: df34eab65a6291f2c5e15a0e349c4eba
|
environments/surveys/responses/not_completed: df34eab65a6291f2c5e15a0e349c4eba
|
||||||
environments/surveys/responses/os: a4c753bb2c004a58d02faeed6b4da476
|
environments/surveys/responses/os: a4c753bb2c004a58d02faeed6b4da476
|
||||||
environments/surveys/responses/person_attributes: 07ae67ae73d7a2a7c67008694a83f0a3
|
environments/surveys/responses/person_attributes: 8f7f8a9040ce8efb3cb54ce33b590866
|
||||||
environments/surveys/responses/phone: b9537ee90fc5b0116942e0af29d926cc
|
environments/surveys/responses/phone: b9537ee90fc5b0116942e0af29d926cc
|
||||||
environments/surveys/responses/respondent_skipped_questions: d85daf579ade534dc7e639689156fcd5
|
environments/surveys/responses/respondent_skipped_questions: d85daf579ade534dc7e639689156fcd5
|
||||||
environments/surveys/responses/response_deleted_successfully: 6cec5427c271800619fee8c812d7db18
|
environments/surveys/responses/response_deleted_successfully: 6cec5427c271800619fee8c812d7db18
|
||||||
@@ -1724,7 +1690,6 @@ checksums:
|
|||||||
environments/surveys/share/social_media/title: 1bf4899b063ee8f02f7188576555828b
|
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: 5bddf0d4f771efd06d58441d11fa5091
|
||||||
environments/surveys/summary/added_filter_for_responses_where_answer_to_question_is_skipped: 74ca713c491cfc33751a5db3de972821
|
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_csv: 16c0c211853f0839a79f1127ec679ca2
|
||||||
environments/surveys/summary/all_responses_excel: 8bf18916ab127f16bfcf9f38956710b0
|
environments/surveys/summary/all_responses_excel: 8bf18916ab127f16bfcf9f38956710b0
|
||||||
environments/surveys/summary/all_time: 62258944e7c2e83f3ebf69074b2c2156
|
environments/surveys/summary/all_time: 62258944e7c2e83f3ebf69074b2c2156
|
||||||
@@ -1748,6 +1713,7 @@ checksums:
|
|||||||
environments/surveys/summary/filtered_responses_csv: aad66a98be6a09cac8bef9e4db4a75cf
|
environments/surveys/summary/filtered_responses_csv: aad66a98be6a09cac8bef9e4db4a75cf
|
||||||
environments/surveys/summary/filtered_responses_excel: 06e57bae9e41979fd7fc4b8bfe3466f9
|
environments/surveys/summary/filtered_responses_excel: 06e57bae9e41979fd7fc4b8bfe3466f9
|
||||||
environments/surveys/summary/generating_qr_code: 5026d4a76f995db458195e5215d9bbd9
|
environments/surveys/summary/generating_qr_code: 5026d4a76f995db458195e5215d9bbd9
|
||||||
|
environments/surveys/summary/go_to_setup_checklist: d70bd018d651d01c41ae10370e71d0be
|
||||||
environments/surveys/summary/impressions: 7fe38d42d68a64d3fd8436a063751584
|
environments/surveys/summary/impressions: 7fe38d42d68a64d3fd8436a063751584
|
||||||
environments/surveys/summary/impressions_tooltip: 4d0823cbf360304770c7c5913e33fdc8
|
environments/surveys/summary/impressions_tooltip: 4d0823cbf360304770c7c5913e33fdc8
|
||||||
environments/surveys/summary/in_app/connection_description: 9710bbf8048a8a5c3b2b56db9d946b73
|
environments/surveys/summary/in_app/connection_description: 9710bbf8048a8a5c3b2b56db9d946b73
|
||||||
@@ -1779,7 +1745,7 @@ checksums:
|
|||||||
environments/surveys/summary/in_app/title: a2d1b633244d0e0504ec6f8f561c7a6b
|
environments/surveys/summary/in_app/title: a2d1b633244d0e0504ec6f8f561c7a6b
|
||||||
environments/surveys/summary/includes_all: b0e3679282417c62d511c258362f860e
|
environments/surveys/summary/includes_all: b0e3679282417c62d511c258362f860e
|
||||||
environments/surveys/summary/includes_either: 186d6923c1693e80d7b664b8367d4221
|
environments/surveys/summary/includes_either: 186d6923c1693e80d7b664b8367d4221
|
||||||
environments/surveys/summary/individual: 52ebce389ed97a13b6089802055ed667
|
environments/surveys/summary/install_widget: 55d403de32e3d0da7513ab199f1d1934
|
||||||
environments/surveys/summary/is_equal_to: f4aab30ef188eb25dcc0e392cf8e86bb
|
environments/surveys/summary/is_equal_to: f4aab30ef188eb25dcc0e392cf8e86bb
|
||||||
environments/surveys/summary/is_less_than: 6109d595ba21497c59b1c91d7fd09a13
|
environments/surveys/summary/is_less_than: 6109d595ba21497c59b1c91d7fd09a13
|
||||||
environments/surveys/summary/last_30_days: a738894cfc5e592052f1e16787744568
|
environments/surveys/summary/last_30_days: a738894cfc5e592052f1e16787744568
|
||||||
@@ -1792,7 +1758,6 @@ checksums:
|
|||||||
environments/surveys/summary/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
|
environments/surveys/summary/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
|
||||||
environments/surveys/summary/other_values_found: 48a74ee68c05f7fb162072b50c683b6a
|
environments/surveys/summary/other_values_found: 48a74ee68c05f7fb162072b50c683b6a
|
||||||
environments/surveys/summary/overall: 6c6d6533013d4739766af84b2871bca6
|
environments/surveys/summary/overall: 6c6d6533013d4739766af84b2871bca6
|
||||||
environments/surveys/summary/promoters: 41fbb8d0439227661253a82fda39f521
|
|
||||||
environments/surveys/summary/qr_code: 48cb2a8c07a3d1647f766f93bb9e9382
|
environments/surveys/summary/qr_code: 48cb2a8c07a3d1647f766f93bb9e9382
|
||||||
environments/surveys/summary/qr_code_description: 19f48dcf473809f178abf4212657ef14
|
environments/surveys/summary/qr_code_description: 19f48dcf473809f178abf4212657ef14
|
||||||
environments/surveys/summary/qr_code_download_failed: 2764b5615112800da27eecafc21e3472
|
environments/surveys/summary/qr_code_download_failed: 2764b5615112800da27eecafc21e3472
|
||||||
@@ -1802,7 +1767,6 @@ checksums:
|
|||||||
environments/surveys/summary/quotas_completed_tooltip: ec5c4dc67eda27c06764354f695db613
|
environments/surveys/summary/quotas_completed_tooltip: ec5c4dc67eda27c06764354f695db613
|
||||||
environments/surveys/summary/reset_survey: 8c88ddb81f5f787d183d2e7cb43e7c64
|
environments/surveys/summary/reset_survey: 8c88ddb81f5f787d183d2e7cb43e7c64
|
||||||
environments/surveys/summary/reset_survey_warning: 6b44be171d7e2716f234387b100b173d
|
environments/surveys/summary/reset_survey_warning: 6b44be171d7e2716f234387b100b173d
|
||||||
environments/surveys/summary/satisfied: 4d542ba354b85e644acbca5691d2ce45
|
|
||||||
environments/surveys/summary/selected_responses_csv: 9cef3faccd54d4f24647791e6359db90
|
environments/surveys/summary/selected_responses_csv: 9cef3faccd54d4f24647791e6359db90
|
||||||
environments/surveys/summary/selected_responses_excel: a0ade8b2658e887a4a3f2ad3bdb0c686
|
environments/surveys/summary/selected_responses_excel: a0ade8b2658e887a4a3f2ad3bdb0c686
|
||||||
environments/surveys/summary/setup_integrations: 70de06d73be671a0cd58a3fd4fa62e53
|
environments/surveys/summary/setup_integrations: 70de06d73be671a0cd58a3fd4fa62e53
|
||||||
@@ -1818,6 +1782,7 @@ checksums:
|
|||||||
environments/surveys/summary/ttc_tooltip: 9b1cbe32cc81111314bd3b6fd050c2e7
|
environments/surveys/summary/ttc_tooltip: 9b1cbe32cc81111314bd3b6fd050c2e7
|
||||||
environments/surveys/summary/unknown_question_type: e4152a7457d2b94f48dcc70aaba9922f
|
environments/surveys/summary/unknown_question_type: e4152a7457d2b94f48dcc70aaba9922f
|
||||||
environments/surveys/summary/use_personal_links: da2b3e7e1aaf2ea2bd4efed2dda4247c
|
environments/surveys/summary/use_personal_links: da2b3e7e1aaf2ea2bd4efed2dda4247c
|
||||||
|
environments/surveys/summary/waiting_for_response: 0194a84e0850b8e98435632d5331a916
|
||||||
environments/surveys/summary/whats_next: d920145bfa2147014062f6f2d1d451a4
|
environments/surveys/summary/whats_next: d920145bfa2147014062f6f2d1d451a4
|
||||||
environments/surveys/summary/your_survey_is_public: 3f5cb5949a5f4020a3d4d74fdfc95e83
|
environments/surveys/summary/your_survey_is_public: 3f5cb5949a5f4020a3d4d74fdfc95e83
|
||||||
environments/surveys/summary/youre_not_plugged_in_yet: 9217467742cdcf7edf8d59cc1472ede6
|
environments/surveys/summary/youre_not_plugged_in_yet: 9217467742cdcf7edf8d59cc1472ede6
|
||||||
@@ -1937,6 +1902,7 @@ checksums:
|
|||||||
templates/card_abandonment_survey: 705c3dfcc7f6de3a445aaefe0d68c43f
|
templates/card_abandonment_survey: 705c3dfcc7f6de3a445aaefe0d68c43f
|
||||||
templates/card_abandonment_survey_description: a3db29212b51402a7659a76248299798
|
templates/card_abandonment_survey_description: a3db29212b51402a7659a76248299798
|
||||||
templates/card_abandonment_survey_question_1_button_label: 6208ac076107506686eb8eae42ac4450
|
templates/card_abandonment_survey_question_1_button_label: 6208ac076107506686eb8eae42ac4450
|
||||||
|
templates/card_abandonment_survey_question_1_dismiss_button_label: 17961ce57f78e2cbfded4590014e5e06
|
||||||
templates/card_abandonment_survey_question_1_headline: d19fc64f80ef192b124f4f9fb070bccc
|
templates/card_abandonment_survey_question_1_headline: d19fc64f80ef192b124f4f9fb070bccc
|
||||||
templates/card_abandonment_survey_question_1_html: 2a4cbf4a5cc305109d23baa9896a9010
|
templates/card_abandonment_survey_question_1_html: 2a4cbf4a5cc305109d23baa9896a9010
|
||||||
templates/card_abandonment_survey_question_2_choice_1: 7723bcd15400a40303409716854f88f9
|
templates/card_abandonment_survey_question_2_choice_1: 7723bcd15400a40303409716854f88f9
|
||||||
@@ -2023,10 +1989,12 @@ checksums:
|
|||||||
templates/churn_survey_question_2_button_label: 76a8497d7b546628b03bb81d5c1ce995
|
templates/churn_survey_question_2_button_label: 76a8497d7b546628b03bb81d5c1ce995
|
||||||
templates/churn_survey_question_2_headline: 17d3e7e2ce62af5ef9332c0d208f9172
|
templates/churn_survey_question_2_headline: 17d3e7e2ce62af5ef9332c0d208f9172
|
||||||
templates/churn_survey_question_3_button_label: 43834ccf20c1c7cd49382468abe2edce
|
templates/churn_survey_question_3_button_label: 43834ccf20c1c7cd49382468abe2edce
|
||||||
|
templates/churn_survey_question_3_dismiss_button_label: b7f28dfa2f58b80b149bb82b392d0291
|
||||||
templates/churn_survey_question_3_headline: 76444078de5c30666ff65f453f60b420
|
templates/churn_survey_question_3_headline: 76444078de5c30666ff65f453f60b420
|
||||||
templates/churn_survey_question_3_html: 4f723d2aea95570d6fc4559519611b8e
|
templates/churn_survey_question_3_html: 4f723d2aea95570d6fc4559519611b8e
|
||||||
templates/churn_survey_question_4_headline: c64605fecd9342dffe904d809e9e3762
|
templates/churn_survey_question_4_headline: c64605fecd9342dffe904d809e9e3762
|
||||||
templates/churn_survey_question_5_button_label: 03e28ea8c2c970cd1b532fee14b22e2b
|
templates/churn_survey_question_5_button_label: 03e28ea8c2c970cd1b532fee14b22e2b
|
||||||
|
templates/churn_survey_question_5_dismiss_button_label: b7f28dfa2f58b80b149bb82b392d0291
|
||||||
templates/churn_survey_question_5_headline: bab9054d83ebc8c67a5bfe7edcb29c85
|
templates/churn_survey_question_5_headline: bab9054d83ebc8c67a5bfe7edcb29c85
|
||||||
templates/churn_survey_question_5_html: da3da01f91e3e922ea4d09c4bd836023
|
templates/churn_survey_question_5_html: da3da01f91e3e922ea4d09c4bd836023
|
||||||
templates/collect_feedback_description: 450c46ad8406e6ac92940a80ed24c000
|
templates/collect_feedback_description: 450c46ad8406e6ac92940a80ed24c000
|
||||||
@@ -2220,6 +2188,7 @@ checksums:
|
|||||||
templates/evaluate_a_product_idea_description: 734295caa08aac718e9ee01a99c3debe
|
templates/evaluate_a_product_idea_description: 734295caa08aac718e9ee01a99c3debe
|
||||||
templates/evaluate_a_product_idea_name: b0d8039556d686b83dfcd455092b9d9c
|
templates/evaluate_a_product_idea_name: b0d8039556d686b83dfcd455092b9d9c
|
||||||
templates/evaluate_a_product_idea_question_1_button_label: 102449dc2f516eb6259c39fa4ed9c56a
|
templates/evaluate_a_product_idea_question_1_button_label: 102449dc2f516eb6259c39fa4ed9c56a
|
||||||
|
templates/evaluate_a_product_idea_question_1_dismiss_button_label: b7f28dfa2f58b80b149bb82b392d0291
|
||||||
templates/evaluate_a_product_idea_question_1_headline: c94096ba66ad74fb3bbfaaa06bd709a0
|
templates/evaluate_a_product_idea_question_1_headline: c94096ba66ad74fb3bbfaaa06bd709a0
|
||||||
templates/evaluate_a_product_idea_question_1_html: bc0dcb887591e018dfeeb65a3a5c4bb9
|
templates/evaluate_a_product_idea_question_1_html: bc0dcb887591e018dfeeb65a3a5c4bb9
|
||||||
templates/evaluate_a_product_idea_question_2_headline: 10a50778c4559554336e7289a48d021c
|
templates/evaluate_a_product_idea_question_2_headline: 10a50778c4559554336e7289a48d021c
|
||||||
@@ -2228,6 +2197,7 @@ checksums:
|
|||||||
templates/evaluate_a_product_idea_question_3_headline: 69407cff7b3e2706bdc86cb425e88918
|
templates/evaluate_a_product_idea_question_3_headline: 69407cff7b3e2706bdc86cb425e88918
|
||||||
templates/evaluate_a_product_idea_question_3_placeholder: 3ec4d0b6bb1f26bb2c32e9e8bd377ae3
|
templates/evaluate_a_product_idea_question_3_placeholder: 3ec4d0b6bb1f26bb2c32e9e8bd377ae3
|
||||||
templates/evaluate_a_product_idea_question_4_button_label: 89ddbcf710eba274963494f312bdc8a9
|
templates/evaluate_a_product_idea_question_4_button_label: 89ddbcf710eba274963494f312bdc8a9
|
||||||
|
templates/evaluate_a_product_idea_question_4_dismiss_button_label: b7f28dfa2f58b80b149bb82b392d0291
|
||||||
templates/evaluate_a_product_idea_question_4_headline: e7e5b5234f617f38f09b2cac639a7ef8
|
templates/evaluate_a_product_idea_question_4_headline: e7e5b5234f617f38f09b2cac639a7ef8
|
||||||
templates/evaluate_a_product_idea_question_4_html: 8902a0d7738376818d2729644321438f
|
templates/evaluate_a_product_idea_question_4_html: 8902a0d7738376818d2729644321438f
|
||||||
templates/evaluate_a_product_idea_question_5_headline: 1d573c2338e6ba5d3cccb09c785bd8c3
|
templates/evaluate_a_product_idea_question_5_headline: 1d573c2338e6ba5d3cccb09c785bd8c3
|
||||||
@@ -2277,6 +2247,7 @@ checksums:
|
|||||||
templates/feedback_box_question_2_headline: 878b8f17dc18877bfbc07823113cd5d5
|
templates/feedback_box_question_2_headline: 878b8f17dc18877bfbc07823113cd5d5
|
||||||
templates/feedback_box_question_2_subheader: 476ff43369a72225b01633e1bce59b95
|
templates/feedback_box_question_2_subheader: 476ff43369a72225b01633e1bce59b95
|
||||||
templates/feedback_box_question_3_button_label: c631d5b3f14b581c303b782221582fe7
|
templates/feedback_box_question_3_button_label: c631d5b3f14b581c303b782221582fe7
|
||||||
|
templates/feedback_box_question_3_dismiss_button_label: 0d5962c08cdca1a2804dfc4abc308a8f
|
||||||
templates/feedback_box_question_3_headline: 5cfb173d156555227fbc2c97ad921e72
|
templates/feedback_box_question_3_headline: 5cfb173d156555227fbc2c97ad921e72
|
||||||
templates/feedback_box_question_3_html: 7e5877860eec80971969ae83c89b30f6
|
templates/feedback_box_question_3_html: 7e5877860eec80971969ae83c89b30f6
|
||||||
templates/feedback_box_question_4_button_label: 1050569a1ea31d070e0cee55bcab3494
|
templates/feedback_box_question_4_button_label: 1050569a1ea31d070e0cee55bcab3494
|
||||||
@@ -2301,6 +2272,7 @@ checksums:
|
|||||||
templates/identify_sign_up_barriers_description: 5b2fbee8c425d7a4d0706ec3628cea11
|
templates/identify_sign_up_barriers_description: 5b2fbee8c425d7a4d0706ec3628cea11
|
||||||
templates/identify_sign_up_barriers_name: 3bbc5352dfa7a9c237bc2c6b21b608dd
|
templates/identify_sign_up_barriers_name: 3bbc5352dfa7a9c237bc2c6b21b608dd
|
||||||
templates/identify_sign_up_barriers_question_1_button_label: 080fd22c580f56ffdcea6c3d60448b84
|
templates/identify_sign_up_barriers_question_1_button_label: 080fd22c580f56ffdcea6c3d60448b84
|
||||||
|
templates/identify_sign_up_barriers_question_1_dismiss_button_label: 0d5962c08cdca1a2804dfc4abc308a8f
|
||||||
templates/identify_sign_up_barriers_question_1_headline: c8c247363daf4697e1939aaf8dc5770c
|
templates/identify_sign_up_barriers_question_1_headline: c8c247363daf4697e1939aaf8dc5770c
|
||||||
templates/identify_sign_up_barriers_question_1_html: 51029ae64c19101af608684b6f429eb8
|
templates/identify_sign_up_barriers_question_1_html: 51029ae64c19101af608684b6f429eb8
|
||||||
templates/identify_sign_up_barriers_question_2_headline: f768ea3053b07f6bbcba977f714ec3da
|
templates/identify_sign_up_barriers_question_2_headline: f768ea3053b07f6bbcba977f714ec3da
|
||||||
@@ -2323,6 +2295,7 @@ checksums:
|
|||||||
templates/identify_sign_up_barriers_question_8_headline: 1f4ee5675d0d84bf049052be26549037
|
templates/identify_sign_up_barriers_question_8_headline: 1f4ee5675d0d84bf049052be26549037
|
||||||
templates/identify_sign_up_barriers_question_8_placeholder: 3ec4d0b6bb1f26bb2c32e9e8bd377ae3
|
templates/identify_sign_up_barriers_question_8_placeholder: 3ec4d0b6bb1f26bb2c32e9e8bd377ae3
|
||||||
templates/identify_sign_up_barriers_question_9_button_label: 0dd2ae69be4618c1f9e615774a4509ca
|
templates/identify_sign_up_barriers_question_9_button_label: 0dd2ae69be4618c1f9e615774a4509ca
|
||||||
|
templates/identify_sign_up_barriers_question_9_dismiss_button_label: b8bf7f2b6e67a523dc4ff5ce009cdb72
|
||||||
templates/identify_sign_up_barriers_question_9_headline: 54d02e5c8eeb10fed40e2e82f7399f8c
|
templates/identify_sign_up_barriers_question_9_headline: 54d02e5c8eeb10fed40e2e82f7399f8c
|
||||||
templates/identify_sign_up_barriers_question_9_html: ed87aa8d325b6063d4150431e9f80ef0
|
templates/identify_sign_up_barriers_question_9_html: ed87aa8d325b6063d4150431e9f80ef0
|
||||||
templates/identify_upsell_opportunities_description: ed6b8dcb162076a380955d7c98482b06
|
templates/identify_upsell_opportunities_description: ed6b8dcb162076a380955d7c98482b06
|
||||||
@@ -2359,6 +2332,7 @@ checksums:
|
|||||||
templates/improve_newsletter_content_question_2_headline: abbea0e97841b617a878f1de2c968d0e
|
templates/improve_newsletter_content_question_2_headline: abbea0e97841b617a878f1de2c968d0e
|
||||||
templates/improve_newsletter_content_question_2_placeholder: 3ec4d0b6bb1f26bb2c32e9e8bd377ae3
|
templates/improve_newsletter_content_question_2_placeholder: 3ec4d0b6bb1f26bb2c32e9e8bd377ae3
|
||||||
templates/improve_newsletter_content_question_3_button_label: 5d5352aba5272de9b1337909d49d4a4c
|
templates/improve_newsletter_content_question_3_button_label: 5d5352aba5272de9b1337909d49d4a4c
|
||||||
|
templates/improve_newsletter_content_question_3_dismiss_button_label: 6a6d6f71da4a44cca4fe5ad09f83a9d2
|
||||||
templates/improve_newsletter_content_question_3_headline: fcd056a1581f5a538aad57641cd0abad
|
templates/improve_newsletter_content_question_3_headline: fcd056a1581f5a538aad57641cd0abad
|
||||||
templates/improve_newsletter_content_question_3_html: 102e73f836fe99b6c333c88c730fa25b
|
templates/improve_newsletter_content_question_3_html: 102e73f836fe99b6c333c88c730fa25b
|
||||||
templates/improve_trial_conversion_description: 3187c4ac1de993326a988c6665d3d4ae
|
templates/improve_trial_conversion_description: 3187c4ac1de993326a988c6665d3d4ae
|
||||||
@@ -2373,6 +2347,7 @@ checksums:
|
|||||||
templates/improve_trial_conversion_question_2_button_label: 89ddbcf710eba274963494f312bdc8a9
|
templates/improve_trial_conversion_question_2_button_label: 89ddbcf710eba274963494f312bdc8a9
|
||||||
templates/improve_trial_conversion_question_2_headline: 05dd4820f60b9d267a9affc7e662f029
|
templates/improve_trial_conversion_question_2_headline: 05dd4820f60b9d267a9affc7e662f029
|
||||||
templates/improve_trial_conversion_question_4_button_label: d94a6a11cfdf4ebde4c5332e585e2e96
|
templates/improve_trial_conversion_question_4_button_label: d94a6a11cfdf4ebde4c5332e585e2e96
|
||||||
|
templates/improve_trial_conversion_question_4_dismiss_button_label: b7f28dfa2f58b80b149bb82b392d0291
|
||||||
templates/improve_trial_conversion_question_4_headline: 9b07341f65574c4165086ec107cebb45
|
templates/improve_trial_conversion_question_4_headline: 9b07341f65574c4165086ec107cebb45
|
||||||
templates/improve_trial_conversion_question_4_html: 95d13979f92aa0e6c5bce6613ad3b417
|
templates/improve_trial_conversion_question_4_html: 95d13979f92aa0e6c5bce6613ad3b417
|
||||||
templates/improve_trial_conversion_question_5_button_label: 89ddbcf710eba274963494f312bdc8a9
|
templates/improve_trial_conversion_question_5_button_label: 89ddbcf710eba274963494f312bdc8a9
|
||||||
@@ -2561,6 +2536,7 @@ checksums:
|
|||||||
templates/product_market_fit_superhuman: 48b1b2db74562dea0d00483b29942346
|
templates/product_market_fit_superhuman: 48b1b2db74562dea0d00483b29942346
|
||||||
templates/product_market_fit_superhuman_description: d14c8e7f4eb7c98919de171457d10a31
|
templates/product_market_fit_superhuman_description: d14c8e7f4eb7c98919de171457d10a31
|
||||||
templates/product_market_fit_superhuman_question_1_button_label: 5d5352aba5272de9b1337909d49d4a4c
|
templates/product_market_fit_superhuman_question_1_button_label: 5d5352aba5272de9b1337909d49d4a4c
|
||||||
|
templates/product_market_fit_superhuman_question_1_dismiss_button_label: 17961ce57f78e2cbfded4590014e5e06
|
||||||
templates/product_market_fit_superhuman_question_1_headline: 21a16bc7bc801fdd743ad37354eedbfb
|
templates/product_market_fit_superhuman_question_1_headline: 21a16bc7bc801fdd743ad37354eedbfb
|
||||||
templates/product_market_fit_superhuman_question_1_html: fa12924d03a014c4a81e770c3eb2175a
|
templates/product_market_fit_superhuman_question_1_html: fa12924d03a014c4a81e770c3eb2175a
|
||||||
templates/product_market_fit_superhuman_question_2_choice_1: 074b2a608d4bba5706b5c55dae249edf
|
templates/product_market_fit_superhuman_question_2_choice_1: 074b2a608d4bba5706b5c55dae249edf
|
||||||
@@ -2666,6 +2642,7 @@ checksums:
|
|||||||
templates/site_abandonment_survey_description: 46581a9b056f3cbf8c1dc9e630e716b5
|
templates/site_abandonment_survey_description: 46581a9b056f3cbf8c1dc9e630e716b5
|
||||||
templates/site_abandonment_survey_question_1_html: eec37cddb0c530c72544067712e95670
|
templates/site_abandonment_survey_question_1_html: eec37cddb0c530c72544067712e95670
|
||||||
templates/site_abandonment_survey_question_2_button_label: 6208ac076107506686eb8eae42ac4450
|
templates/site_abandonment_survey_question_2_button_label: 6208ac076107506686eb8eae42ac4450
|
||||||
|
templates/site_abandonment_survey_question_2_dismiss_button_label: 17961ce57f78e2cbfded4590014e5e06
|
||||||
templates/site_abandonment_survey_question_2_headline: e11a5c95e6a4ba0a3fe9bb0ad1da0b46
|
templates/site_abandonment_survey_question_2_headline: e11a5c95e6a4ba0a3fe9bb0ad1da0b46
|
||||||
templates/site_abandonment_survey_question_3_choice_1: c86306eb379a1b5f4039e27a0a12caca
|
templates/site_abandonment_survey_question_3_choice_1: c86306eb379a1b5f4039e27a0a12caca
|
||||||
templates/site_abandonment_survey_question_3_choice_2: fee51e29951105d7650c3da72282db6d
|
templates/site_abandonment_survey_question_3_choice_2: fee51e29951105d7650c3da72282db6d
|
||||||
@@ -2690,6 +2667,7 @@ checksums:
|
|||||||
templates/site_abandonment_survey_question_7_label: c0d4407cabb5811192c17cbbb8c1a71e
|
templates/site_abandonment_survey_question_7_label: c0d4407cabb5811192c17cbbb8c1a71e
|
||||||
templates/site_abandonment_survey_question_8_headline: 9e82d6f51788351c7e2c8f73be66d005
|
templates/site_abandonment_survey_question_8_headline: 9e82d6f51788351c7e2c8f73be66d005
|
||||||
templates/site_abandonment_survey_question_9_headline: ef1289130df46b80d43119380095b579
|
templates/site_abandonment_survey_question_9_headline: ef1289130df46b80d43119380095b579
|
||||||
|
templates/skip: b7f28dfa2f58b80b149bb82b392d0291
|
||||||
templates/smileys_survey_name: 6ef64e8182e7820efa53a2d1c81eb912
|
templates/smileys_survey_name: 6ef64e8182e7820efa53a2d1c81eb912
|
||||||
templates/smileys_survey_question_1_headline: 6b15d118037b729138c2214cfef49a68
|
templates/smileys_survey_question_1_headline: 6b15d118037b729138c2214cfef49a68
|
||||||
templates/smileys_survey_question_1_lower_label: ff4681be0a94185111459994fe58478c
|
templates/smileys_survey_question_1_lower_label: ff4681be0a94185111459994fe58478c
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user