mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-16 02:56:26 -05:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 35c6220e97 | |||
| 10673a59c0 | |||
| 0440aeb143 | |||
| e982fc5c3e | |||
| 041dce2ec5 | |||
| d6e30855f6 | |||
| f20b412a95 | |||
| 99b0713800 | |||
| a2ffea28a1 |
@@ -206,6 +206,12 @@ UNKEY_ROOT_KEY=
|
||||
# Disable custom cache handler if necessary (e.g. if deployed on Vercel)
|
||||
# CUSTOM_CACHE_DISABLED=1
|
||||
|
||||
# Azure AI settings
|
||||
# AI_AZURE_RESSOURCE_NAME=
|
||||
# AI_AZURE_API_KEY=
|
||||
# AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID=
|
||||
# AI_AZURE_LLM_DEPLOYMENT_ID=
|
||||
|
||||
# INTERCOM_APP_ID=
|
||||
# INTERCOM_SECRET_KEY=
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# Testing Instructions
|
||||
|
||||
When generating test files inside the "/app/web" path, follow these rules:
|
||||
|
||||
- Use vitest
|
||||
- Ensure 100% code coverage
|
||||
- Add as few comments as possible
|
||||
- The test file should be located in the same folder as the original file
|
||||
- Use the `test` function instead of `it`
|
||||
- Follow the same test pattern used for other files in the package where the file is located
|
||||
- All imports should be at the top of the file, not inside individual tests
|
||||
- For mocking inside "test" blocks use "vi.mocked"
|
||||
- Add the original file path to the "test.coverage.include"array in the "apps/web/vite.config.mts" file
|
||||
- Don't mock functions that are already mocked in the "apps/web/vitestSetup.ts" file
|
||||
|
||||
If it's a test for a ".tsx" file, follow these extra instructions:
|
||||
|
||||
- Add this code inside the "describe" block and before any test:
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
- the "afterEach" function should only have "cleanup()" inside it and should be adde to the "vitest" imports
|
||||
- For click events, import userEvent from "@testing-library/user-event"
|
||||
- Mock other components that can make the text more complex and but at the same time mocking it wouldn't make the test flaky. It's ok to leave basic and simple components.
|
||||
@@ -12,13 +12,6 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: 'ghcr.io/formbricks/formbricks'
|
||||
ENVIRONMENT:
|
||||
description: 'The environment to deploy to'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- stage
|
||||
- prod
|
||||
workflow_call:
|
||||
inputs:
|
||||
VERSION:
|
||||
@@ -30,10 +23,6 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: 'ghcr.io/formbricks/formbricks'
|
||||
ENVIRONMENT:
|
||||
description: 'The environment to deploy to'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -59,8 +48,6 @@ jobs:
|
||||
AWS_REGION: eu-central-1
|
||||
|
||||
- uses: helmfile/helmfile-action@v2
|
||||
name: Deploy Formbricks Cloud Prod
|
||||
if: (github.event_name == 'workflow_call' || github.event_name == 'workflow_dispatch') && github.event.inputs.ENVIRONMENT == 'prod'
|
||||
env:
|
||||
VERSION: ${{ inputs.VERSION }}
|
||||
REPOSITORY: ${{ inputs.REPOSITORY }}
|
||||
@@ -71,23 +58,7 @@ jobs:
|
||||
helm-plugins: >
|
||||
https://github.com/databus23/helm-diff,
|
||||
https://github.com/jkroepke/helm-secrets
|
||||
helmfile-args: apply -l environment=prod
|
||||
helmfile-auto-init: "false"
|
||||
helmfile-workdirectory: infra/formbricks-cloud-helm
|
||||
|
||||
- uses: helmfile/helmfile-action@v2
|
||||
name: Deploy Formbricks Cloud Stage
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ENVIRONMENT == 'stage'
|
||||
env:
|
||||
VERSION: ${{ inputs.VERSION }}
|
||||
REPOSITORY: ${{ inputs.REPOSITORY }}
|
||||
FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.STAGE_FORMBRICKS_INGRESS_CERT_ARN }}
|
||||
FORMBRICKS_ROLE_ARN: ${{ secrets.STAGE_FORMBRICKS_ROLE_ARN }}
|
||||
with:
|
||||
helm-plugins: >
|
||||
https://github.com/databus23/helm-diff,
|
||||
https://github.com/jkroepke/helm-secrets
|
||||
helmfile-args: apply -l environment=stage
|
||||
helmfile-args: apply
|
||||
helmfile-auto-init: "false"
|
||||
helmfile-workdirectory: infra/formbricks-cloud-helm
|
||||
|
||||
|
||||
@@ -31,4 +31,3 @@ jobs:
|
||||
- helm-chart-release
|
||||
with:
|
||||
VERSION: ${{ needs.docker-build.outputs.VERSION }}
|
||||
ENVIRONMENT: "prod"
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
name: Release Changesets
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
#push:
|
||||
# branches:
|
||||
# - main
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
packages: write
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0
|
||||
|
||||
- name: Setup Node.js 18.x
|
||||
uses: actions/setup-node@7c12f8017d5436eb855f1ed4399f037a36fbd9e8 # v2.5.2
|
||||
with:
|
||||
node-version: 18.x
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@c3b53f6a16e57305370b4ae5a540c2077a1d50dd # v2.2.4
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
|
||||
- name: Create Release Pull Request or Publish to npm
|
||||
id: changesets
|
||||
uses: changesets/action@c8bada60c408975afd1a20b3db81d6eee6789308 # v1.4.9
|
||||
with:
|
||||
# This expects you to have a script called release which does a build for your packages and calls changeset publish
|
||||
publish: pnpm release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
# https://github.com/docker/login-action
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
# https://github.com/docker/login-action
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
|
||||
+1
-1
@@ -16,6 +16,6 @@ if [ -f branch.json ]; then
|
||||
echo "Skipping tolgee-pull: NEXT_PUBLIC_TOLGEE_API_KEY is not set"
|
||||
else
|
||||
pnpm run tolgee-pull
|
||||
git add apps/web/locales
|
||||
git add packages/lib/messages
|
||||
fi
|
||||
fi
|
||||
+7
-7
@@ -4,33 +4,33 @@
|
||||
"patterns": ["./apps/web/**/*.ts?(x)"],
|
||||
"projectId": 10304,
|
||||
"pull": {
|
||||
"path": "./apps/web/locales"
|
||||
"path": "./packages/lib/messages"
|
||||
},
|
||||
"push": {
|
||||
"files": [
|
||||
{
|
||||
"language": "en-US",
|
||||
"path": "./apps/web/locales/en-US.json"
|
||||
"path": "./packages/lib/messages/en-US.json"
|
||||
},
|
||||
{
|
||||
"language": "de-DE",
|
||||
"path": "./apps/web/locales/de-DE.json"
|
||||
"path": "./packages/lib/messages/de-DE.json"
|
||||
},
|
||||
{
|
||||
"language": "fr-FR",
|
||||
"path": "./apps/web/locales/fr-FR.json"
|
||||
"path": "./packages/lib/messages/fr-FR.json"
|
||||
},
|
||||
{
|
||||
"language": "pt-BR",
|
||||
"path": "./apps/web/locales/pt-BR.json"
|
||||
"path": "./packages/lib/messages/pt-BR.json"
|
||||
},
|
||||
{
|
||||
"language": "zh-Hant-TW",
|
||||
"path": "./apps/web/locales/zh-Hant-TW.json"
|
||||
"path": "./packages/lib/messages/zh-Hant-TW.json"
|
||||
},
|
||||
{
|
||||
"language": "pt-PT",
|
||||
"path": "./apps/web/locales/pt-PT.json"
|
||||
"path": "./packages/lib/messages/pt-PT.json"
|
||||
}
|
||||
],
|
||||
"forceMode": "OVERRIDE"
|
||||
|
||||
Vendored
+5
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"github.copilot.chat.codeGeneration.instructions": [
|
||||
{
|
||||
"text": "When generating tests, always use vitest and use the `test` function instead of `it`."
|
||||
}
|
||||
],
|
||||
"javascript.updateImportsOnFileMove.enabled": "always",
|
||||
"sonarlint.connectedMode.project": {
|
||||
"connectionId": "formbricks",
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM node:22-alpine3.21 AS base
|
||||
FROM node:22-alpine3.20@sha256:40be979442621049f40b1d51a26b55e281246b5de4e5f51a18da7beb6e17e3f9 AS base
|
||||
|
||||
#
|
||||
## step 1: Prune monorepo
|
||||
|
||||
+5
-5
@@ -101,17 +101,17 @@ export const OnboardingSetupInstructions = ({
|
||||
<div>
|
||||
{activeTab === "npm" ? (
|
||||
<div className="prose prose-slate w-full">
|
||||
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="sh">
|
||||
<CodeBlock customEditorClass="bg-white! border border-slate-200" language="sh">
|
||||
npm install @formbricks/js
|
||||
</CodeBlock>
|
||||
<p>{t("common.or")}</p>
|
||||
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="sh">
|
||||
<CodeBlock customEditorClass="bg-white! border border-slate-200" language="sh">
|
||||
yarn add @formbricks/js
|
||||
</CodeBlock>
|
||||
<p className="text-sm text-slate-700">
|
||||
{t("environments.connect.import_formbricks_and_initialize_the_widget_in_your_component")}
|
||||
</p>
|
||||
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
|
||||
<CodeBlock customEditorClass="bg-white! border border-slate-200" language="js">
|
||||
{channel === "app" ? npmSnippetForAppSurveys : npmSnippetForWebsiteSurveys}
|
||||
</CodeBlock>
|
||||
<Button id="onboarding-inapp-connect-read-npm-docs" className="mt-3" variant="secondary" asChild>
|
||||
@@ -125,11 +125,11 @@ export const OnboardingSetupInstructions = ({
|
||||
</div>
|
||||
) : activeTab === "html" ? (
|
||||
<div className="prose prose-slate">
|
||||
<p className="-mb-1 mt-6 text-sm text-slate-700">
|
||||
<p className="mt-6 -mb-1 text-sm text-slate-700">
|
||||
{t("environments.connect.insert_this_code_into_the_head_tag_of_your_website")}
|
||||
</p>
|
||||
<div>
|
||||
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
|
||||
<CodeBlock customEditorClass="bg-white! border border-slate-200" language="js">
|
||||
{channel === "app" ? htmlSnippetForAppSurveys : htmlSnippetForWebsiteSurveys}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,7 @@ const Page = async (props: ConnectPageProps) => {
|
||||
channel={channel}
|
||||
/>
|
||||
<Button
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute top-5 right-5 mt-0! text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={`/environments/${environment.id}`}>
|
||||
|
||||
+136
-100
@@ -1,13 +1,8 @@
|
||||
import {
|
||||
buildCTAQuestion,
|
||||
buildNPSQuestion,
|
||||
buildOpenTextQuestion,
|
||||
buildRatingQuestion,
|
||||
getDefaultEndingCard,
|
||||
} from "@/app/lib/survey-builder";
|
||||
import { getDefaultEndingCard } from "@/app/lib/templates";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { TFnType } from "@tolgee/react";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TXMTemplate } from "@formbricks/types/templates";
|
||||
|
||||
export const getXMSurveyDefault = (t: TFnType): TXMTemplate => {
|
||||
@@ -31,26 +26,35 @@ const npsSurvey = (t: TFnType): TXMTemplate => {
|
||||
...getXMSurveyDefault(t),
|
||||
name: t("templates.nps_survey_name"),
|
||||
questions: [
|
||||
buildNPSQuestion({
|
||||
headline: t("templates.nps_survey_question_1_headline"),
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.NPS,
|
||||
headline: { default: t("templates.nps_survey_question_1_headline") },
|
||||
required: true,
|
||||
lowerLabel: t("templates.nps_survey_question_1_lower_label"),
|
||||
upperLabel: t("templates.nps_survey_question_1_upper_label"),
|
||||
lowerLabel: { default: t("templates.nps_survey_question_1_lower_label") },
|
||||
upperLabel: { default: t("templates.nps_survey_question_1_upper_label") },
|
||||
isColorCodingEnabled: true,
|
||||
t,
|
||||
}),
|
||||
buildOpenTextQuestion({
|
||||
headline: t("templates.nps_survey_question_2_headline"),
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: t("templates.nps_survey_question_2_headline") },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
t,
|
||||
}),
|
||||
buildOpenTextQuestion({
|
||||
headline: t("templates.nps_survey_question_3_headline"),
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: t("templates.nps_survey_question_3_headline") },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
t,
|
||||
}),
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -63,8 +67,9 @@ const starRatingSurvey = (t: TFnType): TXMTemplate => {
|
||||
...defaultSurvey,
|
||||
name: t("templates.star_rating_survey_name"),
|
||||
questions: [
|
||||
buildRatingQuestion({
|
||||
{
|
||||
id: reusableQuestionIds[0],
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -97,15 +102,16 @@ const starRatingSurvey = (t: TFnType): TXMTemplate => {
|
||||
],
|
||||
range: 5,
|
||||
scale: "number",
|
||||
headline: t("templates.star_rating_survey_question_1_headline"),
|
||||
headline: { default: t("templates.star_rating_survey_question_1_headline") },
|
||||
required: true,
|
||||
lowerLabel: t("templates.star_rating_survey_question_1_lower_label"),
|
||||
upperLabel: t("templates.star_rating_survey_question_1_upper_label"),
|
||||
t,
|
||||
}),
|
||||
buildCTAQuestion({
|
||||
lowerLabel: { default: t("templates.star_rating_survey_question_1_lower_label") },
|
||||
upperLabel: { default: t("templates.star_rating_survey_question_1_upper_label") },
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
{
|
||||
id: reusableQuestionIds[1],
|
||||
html: t("templates.star_rating_survey_question_2_html"),
|
||||
html: { default: t("templates.star_rating_survey_question_2_html") },
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -132,23 +138,25 @@ const starRatingSurvey = (t: TFnType): TXMTemplate => {
|
||||
],
|
||||
},
|
||||
],
|
||||
headline: t("templates.star_rating_survey_question_2_headline"),
|
||||
headline: { default: t("templates.star_rating_survey_question_2_headline") },
|
||||
required: true,
|
||||
buttonUrl: "https://formbricks.com/github",
|
||||
buttonLabel: t("templates.star_rating_survey_question_2_button_label"),
|
||||
buttonLabel: { default: t("templates.star_rating_survey_question_2_button_label") },
|
||||
buttonExternal: true,
|
||||
t,
|
||||
}),
|
||||
buildOpenTextQuestion({
|
||||
},
|
||||
{
|
||||
id: reusableQuestionIds[2],
|
||||
headline: t("templates.star_rating_survey_question_3_headline"),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: t("templates.star_rating_survey_question_3_headline") },
|
||||
required: true,
|
||||
subheader: t("templates.star_rating_survey_question_3_subheader"),
|
||||
buttonLabel: t("templates.star_rating_survey_question_3_button_label"),
|
||||
placeholder: t("templates.star_rating_survey_question_3_placeholder"),
|
||||
subheader: { default: t("templates.star_rating_survey_question_3_subheader") },
|
||||
buttonLabel: { default: t("templates.star_rating_survey_question_3_button_label") },
|
||||
placeholder: { default: t("templates.star_rating_survey_question_3_placeholder") },
|
||||
inputType: "text",
|
||||
t,
|
||||
}),
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -161,8 +169,9 @@ const csatSurvey = (t: TFnType): TXMTemplate => {
|
||||
...defaultSurvey,
|
||||
name: t("templates.csat_survey_name"),
|
||||
questions: [
|
||||
buildRatingQuestion({
|
||||
{
|
||||
id: reusableQuestionIds[0],
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -195,14 +204,15 @@ const csatSurvey = (t: TFnType): TXMTemplate => {
|
||||
],
|
||||
range: 5,
|
||||
scale: "smiley",
|
||||
headline: t("templates.csat_survey_question_1_headline"),
|
||||
headline: { default: t("templates.csat_survey_question_1_headline") },
|
||||
required: true,
|
||||
lowerLabel: t("templates.csat_survey_question_1_lower_label"),
|
||||
upperLabel: t("templates.csat_survey_question_1_upper_label"),
|
||||
t,
|
||||
}),
|
||||
buildOpenTextQuestion({
|
||||
lowerLabel: { default: t("templates.csat_survey_question_1_lower_label") },
|
||||
upperLabel: { default: t("templates.csat_survey_question_1_upper_label") },
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
{
|
||||
id: reusableQuestionIds[1],
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -229,20 +239,25 @@ const csatSurvey = (t: TFnType): TXMTemplate => {
|
||||
],
|
||||
},
|
||||
],
|
||||
headline: t("templates.csat_survey_question_2_headline"),
|
||||
headline: { default: t("templates.csat_survey_question_2_headline") },
|
||||
required: false,
|
||||
placeholder: t("templates.csat_survey_question_2_placeholder"),
|
||||
placeholder: { default: t("templates.csat_survey_question_2_placeholder") },
|
||||
inputType: "text",
|
||||
t,
|
||||
}),
|
||||
buildOpenTextQuestion({
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: reusableQuestionIds[2],
|
||||
headline: t("templates.csat_survey_question_3_headline"),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: t("templates.csat_survey_question_3_headline") },
|
||||
required: false,
|
||||
placeholder: t("templates.csat_survey_question_3_placeholder"),
|
||||
placeholder: { default: t("templates.csat_survey_question_3_placeholder") },
|
||||
inputType: "text",
|
||||
t,
|
||||
}),
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -252,22 +267,28 @@ const cessSurvey = (t: TFnType): TXMTemplate => {
|
||||
...getXMSurveyDefault(t),
|
||||
name: t("templates.cess_survey_name"),
|
||||
questions: [
|
||||
buildRatingQuestion({
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
range: 5,
|
||||
scale: "number",
|
||||
headline: t("templates.cess_survey_question_1_headline"),
|
||||
headline: { default: t("templates.cess_survey_question_1_headline") },
|
||||
required: true,
|
||||
lowerLabel: t("templates.cess_survey_question_1_lower_label"),
|
||||
upperLabel: t("templates.cess_survey_question_1_upper_label"),
|
||||
t,
|
||||
}),
|
||||
buildOpenTextQuestion({
|
||||
headline: t("templates.cess_survey_question_2_headline"),
|
||||
lowerLabel: { default: t("templates.cess_survey_question_1_lower_label") },
|
||||
upperLabel: { default: t("templates.cess_survey_question_1_upper_label") },
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: t("templates.cess_survey_question_2_headline") },
|
||||
required: true,
|
||||
placeholder: t("templates.cess_survey_question_2_placeholder"),
|
||||
placeholder: { default: t("templates.cess_survey_question_2_placeholder") },
|
||||
inputType: "text",
|
||||
t,
|
||||
}),
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -280,8 +301,9 @@ const smileysRatingSurvey = (t: TFnType): TXMTemplate => {
|
||||
...defaultSurvey,
|
||||
name: t("templates.smileys_survey_name"),
|
||||
questions: [
|
||||
buildRatingQuestion({
|
||||
{
|
||||
id: reusableQuestionIds[0],
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -314,15 +336,16 @@ const smileysRatingSurvey = (t: TFnType): TXMTemplate => {
|
||||
],
|
||||
range: 5,
|
||||
scale: "smiley",
|
||||
headline: t("templates.smileys_survey_question_1_headline"),
|
||||
headline: { default: t("templates.smileys_survey_question_1_headline") },
|
||||
required: true,
|
||||
lowerLabel: t("templates.smileys_survey_question_1_lower_label"),
|
||||
upperLabel: t("templates.smileys_survey_question_1_upper_label"),
|
||||
t,
|
||||
}),
|
||||
buildCTAQuestion({
|
||||
lowerLabel: { default: t("templates.smileys_survey_question_1_lower_label") },
|
||||
upperLabel: { default: t("templates.smileys_survey_question_1_upper_label") },
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
{
|
||||
id: reusableQuestionIds[1],
|
||||
html: t("templates.smileys_survey_question_2_html"),
|
||||
html: { default: t("templates.smileys_survey_question_2_html") },
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -349,23 +372,25 @@ const smileysRatingSurvey = (t: TFnType): TXMTemplate => {
|
||||
],
|
||||
},
|
||||
],
|
||||
headline: t("templates.smileys_survey_question_2_headline"),
|
||||
headline: { default: t("templates.smileys_survey_question_2_headline") },
|
||||
required: true,
|
||||
buttonUrl: "https://formbricks.com/github",
|
||||
buttonLabel: t("templates.smileys_survey_question_2_button_label"),
|
||||
buttonLabel: { default: t("templates.smileys_survey_question_2_button_label") },
|
||||
buttonExternal: true,
|
||||
t,
|
||||
}),
|
||||
buildOpenTextQuestion({
|
||||
},
|
||||
{
|
||||
id: reusableQuestionIds[2],
|
||||
headline: t("templates.smileys_survey_question_3_headline"),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: t("templates.smileys_survey_question_3_headline") },
|
||||
required: true,
|
||||
subheader: t("templates.smileys_survey_question_3_subheader"),
|
||||
buttonLabel: t("templates.smileys_survey_question_3_button_label"),
|
||||
placeholder: t("templates.smileys_survey_question_3_placeholder"),
|
||||
subheader: { default: t("templates.smileys_survey_question_3_subheader") },
|
||||
buttonLabel: { default: t("templates.smileys_survey_question_3_button_label") },
|
||||
placeholder: { default: t("templates.smileys_survey_question_3_placeholder") },
|
||||
inputType: "text",
|
||||
t,
|
||||
}),
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -375,26 +400,37 @@ const enpsSurvey = (t: TFnType): TXMTemplate => {
|
||||
...getXMSurveyDefault(t),
|
||||
name: t("templates.enps_survey_name"),
|
||||
questions: [
|
||||
buildNPSQuestion({
|
||||
headline: t("templates.enps_survey_question_1_headline"),
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.NPS,
|
||||
headline: {
|
||||
default: t("templates.enps_survey_question_1_headline"),
|
||||
},
|
||||
required: false,
|
||||
lowerLabel: t("templates.enps_survey_question_1_lower_label"),
|
||||
upperLabel: t("templates.enps_survey_question_1_upper_label"),
|
||||
lowerLabel: { default: t("templates.enps_survey_question_1_lower_label") },
|
||||
upperLabel: { default: t("templates.enps_survey_question_1_upper_label") },
|
||||
isColorCodingEnabled: true,
|
||||
t,
|
||||
}),
|
||||
buildOpenTextQuestion({
|
||||
headline: t("templates.enps_survey_question_2_headline"),
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: t("templates.enps_survey_question_2_headline") },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
t,
|
||||
}),
|
||||
buildOpenTextQuestion({
|
||||
headline: t("templates.enps_survey_question_3_headline"),
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: t("templates.enps_survey_question_3_headline") },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
t,
|
||||
}),
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -49,7 +49,7 @@ const Page = async (props: XMTemplatePageProps) => {
|
||||
<XMTemplateList project={project} user={user} environmentId={environment.id} />
|
||||
{projects.length >= 2 && (
|
||||
<Button
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute top-5 right-5 mt-0! text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={`/environments/${environment.id}/surveys`}>
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ export const LandingSidebar = ({
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
id="userDropdownTrigger"
|
||||
className="w-full rounded-br-xl border-t py-4 pl-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
|
||||
className="w-full rounded-br-xl border-t py-4 pl-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-hidden">
|
||||
<div tabIndex={0} className={cn("flex cursor-pointer flex-row items-center space-x-3")}>
|
||||
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
|
||||
<>
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ const Page = async (props: ChannelPageProps) => {
|
||||
<OnboardingOptionsContainer options={channelOptions} />
|
||||
{projects.length >= 1 && (
|
||||
<Button
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute top-5 right-5 mt-0! text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={"/"}>
|
||||
|
||||
+1
-1
@@ -47,7 +47,7 @@ const Page = async (props: ModePageProps) => {
|
||||
<OnboardingOptionsContainer options={channelOptions} />
|
||||
{projects.length >= 1 && (
|
||||
<Button
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute top-5 right-5 mt-0! text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={"/"}>
|
||||
|
||||
+1
-1
@@ -218,7 +218,7 @@ export const ProjectSettings = ({
|
||||
</FormProvider>
|
||||
</div>
|
||||
|
||||
<div className="relative flex h-[30rem] w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 shadow">
|
||||
<div className="relative flex h-[30rem] w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 shadow-sm">
|
||||
{logoUrl && (
|
||||
<Image
|
||||
src={logoUrl}
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
||||
/>
|
||||
{projects.length >= 1 && (
|
||||
<Button
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute top-5 right-5 mt-0! text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={"/"}>
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ export const ActionClassesTable = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
|
||||
{TableHeading}
|
||||
<div id="actionClassesWrapper" className="flex flex-col">
|
||||
{actionClasses.length > 0 ? (
|
||||
|
||||
+1
-3
@@ -14,9 +14,7 @@ export const ActionClassDataRow = ({
|
||||
<div className="m-2 grid h-16 grid-cols-6 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100">
|
||||
<div className="col-span-4 flex items-center pl-6 text-sm">
|
||||
<div className="flex items-center">
|
||||
<div className="h-5 w-5 flex-shrink-0 text-slate-500">
|
||||
{ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
|
||||
</div>
|
||||
<div className="h-5 w-5 shrink-0 text-slate-500">{ACTION_TYPE_ICON_LOOKUP[actionClass.type]}</div>
|
||||
<div className="ml-4 text-left">
|
||||
<div className="font-medium text-slate-900">{actionClass.name}</div>
|
||||
<div className="text-xs text-slate-400">{actionClass.description}</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ const Loading = () => {
|
||||
<>
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.actions")} />
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
|
||||
<div className="grid h-12 grid-cols-6 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
|
||||
<span className="sr-only">{t("common.edit")}</span>
|
||||
<div className="col-span-4 pl-6">{t("environments.actions.user_actions")}</div>
|
||||
@@ -22,7 +22,7 @@ const Loading = () => {
|
||||
className="m-2 grid h-16 grid-cols-6 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100">
|
||||
<div className="col-span-4 flex items-center pl-6 text-sm">
|
||||
<div className="flex items-center">
|
||||
<div className="h-6 w-6 flex-shrink-0 animate-pulse rounded-full bg-slate-200 text-slate-500" />
|
||||
<div className="h-6 w-6 shrink-0 animate-pulse rounded-full bg-slate-200 text-slate-500" />
|
||||
<div className="ml-4 text-left">
|
||||
<div className="font-medium text-slate-900">
|
||||
<div className="mt-0 h-4 w-48 animate-pulse rounded-full bg-slate-200"></div>
|
||||
@@ -33,7 +33,7 @@ const Loading = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto flex justify-center whitespace-nowrap text-center text-sm text-slate-500">
|
||||
<div className="col-span-2 my-auto flex justify-center text-center text-sm whitespace-nowrap text-slate-500">
|
||||
<div className="h-4 w-28 animate-pulse rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -265,7 +265,7 @@ export const MainNavigation = ({
|
||||
size="icon"
|
||||
onClick={toggleSidebar}
|
||||
className={cn(
|
||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
|
||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-hidden"
|
||||
)}>
|
||||
{isCollapsed ? (
|
||||
<PanelLeftOpenIcon strokeWidth={1.5} />
|
||||
@@ -332,7 +332,7 @@ export const MainNavigation = ({
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
id="userDropdownTrigger"
|
||||
className="w-full rounded-br-xl border-t py-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
|
||||
className="w-full rounded-br-xl border-t py-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-hidden">
|
||||
<div
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
|
||||
@@ -18,7 +18,7 @@ export const TopControlBar = ({
|
||||
}: SideBarProps) => {
|
||||
return (
|
||||
<div className="fixed inset-0 top-0 z-30 flex h-14 w-full items-center justify-end bg-slate-50 px-6">
|
||||
<div className="shadow-xs z-10">
|
||||
<div className="z-10 shadow-2xs">
|
||||
<div className="flex w-fit items-center space-x-2 py-2">
|
||||
<TopControlButtons
|
||||
environment={environment}
|
||||
|
||||
-151
@@ -1,151 +0,0 @@
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationAirtable, TIntegrationAirtableConfig } from "@formbricks/types/integration/airtable";
|
||||
import { ManageIntegration } from "./ManageIntegration";
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
|
||||
deleteIntegrationAction: vi.fn(),
|
||||
}));
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal",
|
||||
() => ({
|
||||
AddIntegrationModal: ({ open, setOpenWithStates }) =>
|
||||
open ? (
|
||||
<div data-testid="add-modal">
|
||||
<button onClick={() => setOpenWithStates(false)}>close</button>
|
||||
</div>
|
||||
) : null,
|
||||
})
|
||||
);
|
||||
vi.mock("@/modules/ui/components/delete-dialog", () => ({
|
||||
DeleteDialog: ({ open, setOpen, onDelete }) =>
|
||||
open ? (
|
||||
<div data-testid="delete-dialog">
|
||||
<button onClick={onDelete}>confirm</button>
|
||||
<button onClick={() => setOpen(false)}>cancel</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
vi.mock("react-hot-toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
const baseProps = {
|
||||
environment: { id: "env1" } as TEnvironment,
|
||||
environmentId: "env1",
|
||||
setIsConnected: vi.fn(),
|
||||
surveys: [],
|
||||
airtableArray: [],
|
||||
locale: "en-US" as const,
|
||||
};
|
||||
|
||||
describe("ManageIntegration", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("empty state", () => {
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
airtableIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { email: "a@b.com", data: [] } as unknown as TIntegrationAirtableConfig,
|
||||
} as TIntegrationAirtable
|
||||
}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/no_integrations_yet/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/link_new_table/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("open add modal", async () => {
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
airtableIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { email: "a@b.com", data: [] } as unknown as TIntegrationAirtableConfig,
|
||||
} as TIntegrationAirtable
|
||||
}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByText(/link_new_table/));
|
||||
expect(screen.getByTestId("add-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("list integrations and open edit modal", async () => {
|
||||
const item = {
|
||||
baseId: "b",
|
||||
tableId: "t",
|
||||
surveyId: "s",
|
||||
surveyName: "S",
|
||||
tableName: "T",
|
||||
questions: "Q",
|
||||
questionIds: ["x"],
|
||||
createdAt: new Date(),
|
||||
includeVariables: false,
|
||||
includeHiddenFields: false,
|
||||
includeMetadata: false,
|
||||
includeCreatedAt: false,
|
||||
};
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
airtableIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { email: "a@b.com", data: [item] } as unknown as TIntegrationAirtableConfig,
|
||||
} as TIntegrationAirtable
|
||||
}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("S")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText("S"));
|
||||
expect(screen.getByTestId("add-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("delete integration success", async () => {
|
||||
vi.mocked(deleteIntegrationAction).mockResolvedValue({ data: true } as any);
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
airtableIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { email: "a@b.com", data: [] } as unknown as TIntegrationAirtableConfig,
|
||||
} as TIntegrationAirtable
|
||||
}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByText(/delete_integration/));
|
||||
expect(screen.getByTestId("delete-dialog")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText("confirm"));
|
||||
expect(deleteIntegrationAction).toHaveBeenCalledWith({ integrationId: "1" });
|
||||
const { toast } = await import("react-hot-toast");
|
||||
expect(toast.success).toHaveBeenCalled();
|
||||
expect(baseProps.setIsConnected).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test("delete integration error", async () => {
|
||||
vi.mocked(deleteIntegrationAction).mockResolvedValue({ error: "fail" } as any);
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
airtableIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { email: "a@b.com", data: [] } as unknown as TIntegrationAirtableConfig,
|
||||
} as TIntegrationAirtable
|
||||
}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByText(/delete_integration/));
|
||||
await userEvent.click(screen.getByText("confirm"));
|
||||
const { toast } = await import("react-hot-toast");
|
||||
expect(toast.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+6
-6
@@ -98,17 +98,17 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
{integrationData.length ? (
|
||||
<div className="mt-6 w-full rounded-lg border border-slate-200">
|
||||
<div className="grid h-12 grid-cols-8 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||
{tableHeaders.map((header) => (
|
||||
<div key={header} className={`col-span-2 hidden text-center sm:block`}>
|
||||
{tableHeaders.map((header, idx) => (
|
||||
<div key={idx} className={`col-span-2 hidden text-center sm:block`}>
|
||||
{t(header)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{integrationData.map((data, index) => (
|
||||
<button
|
||||
key={`${index}-${data.baseId}-${data.tableId}-${data.surveyId}`}
|
||||
className="grid h-16 w-full grid-cols-8 content-center rounded-lg p-2 hover:bg-slate-100"
|
||||
<div
|
||||
key={index}
|
||||
className="m-2 grid h-16 grid-cols-8 content-center rounded-lg hover:bg-slate-100"
|
||||
onClick={() => {
|
||||
setDefaultValues({
|
||||
base: data.baseId,
|
||||
@@ -129,7 +129,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
<div className="col-span-2 text-center">
|
||||
{timeSince(data.createdAt.toString(), props.locale)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
-162
@@ -1,162 +0,0 @@
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
||||
import { ManageIntegration } from "./ManageIntegration";
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
|
||||
deleteIntegrationAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/delete-dialog", () => ({
|
||||
DeleteDialog: ({ open, setOpen, onDelete }: any) =>
|
||||
open ? (
|
||||
<div data-testid="delete-dialog">
|
||||
<button onClick={onDelete}>confirm</button>
|
||||
<button onClick={() => setOpen(false)}>cancel</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/empty-space-filler", () => ({
|
||||
EmptySpaceFiller: ({ emptyMessage }: any) => <div>{emptyMessage}</div>,
|
||||
}));
|
||||
|
||||
const baseProps = {
|
||||
environment: { id: "env1" } as TEnvironment,
|
||||
setOpenAddIntegrationModal: vi.fn(),
|
||||
setIsConnected: vi.fn(),
|
||||
setSelectedIntegration: vi.fn(),
|
||||
locale: "en-US" as const,
|
||||
} as const;
|
||||
|
||||
describe("ManageIntegration (Google Sheets)", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("empty state", () => {
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
googleSheetIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { email: "a@b.com", data: [] },
|
||||
} as unknown as TIntegrationGoogleSheets
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/no_integrations_yet/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/link_new_sheet/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("click link new sheet", async () => {
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
googleSheetIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { email: "a@b.com", data: [] },
|
||||
} as unknown as TIntegrationGoogleSheets
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText(/link_new_sheet/));
|
||||
|
||||
expect(baseProps.setSelectedIntegration).toHaveBeenCalledWith(null);
|
||||
expect(baseProps.setOpenAddIntegrationModal).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test("list integrations and open edit", async () => {
|
||||
const item = {
|
||||
spreadsheetId: "sid",
|
||||
spreadsheetName: "SheetName",
|
||||
surveyId: "s1",
|
||||
surveyName: "Survey1",
|
||||
questionIds: ["q1"],
|
||||
questions: "Q",
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
googleSheetIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { email: "a@b.com", data: [item] },
|
||||
} as unknown as TIntegrationGoogleSheets
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Survey1")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText("Survey1"));
|
||||
|
||||
expect(baseProps.setSelectedIntegration).toHaveBeenCalledWith({
|
||||
...item,
|
||||
index: 0,
|
||||
});
|
||||
expect(baseProps.setOpenAddIntegrationModal).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test("delete integration success", async () => {
|
||||
vi.mocked(deleteIntegrationAction).mockResolvedValue({ data: true } as any);
|
||||
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
googleSheetIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { email: "a@b.com", data: [] },
|
||||
} as unknown as TIntegrationGoogleSheets
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText(/delete_integration/));
|
||||
expect(screen.getByTestId("delete-dialog")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText("confirm"));
|
||||
|
||||
expect(deleteIntegrationAction).toHaveBeenCalledWith({ integrationId: "1" });
|
||||
|
||||
const { default: toast } = await import("react-hot-toast");
|
||||
expect(toast.success).toHaveBeenCalledWith("environments.integrations.integration_removed_successfully");
|
||||
expect(baseProps.setIsConnected).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test("delete integration error", async () => {
|
||||
vi.mocked(deleteIntegrationAction).mockResolvedValue({ error: "fail" } as any);
|
||||
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
googleSheetIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { email: "a@b.com", data: [] },
|
||||
} as unknown as TIntegrationGoogleSheets
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText(/delete_integration/));
|
||||
await userEvent.click(screen.getByText("confirm"));
|
||||
|
||||
const { default: toast } = await import("react-hot-toast");
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.any(String));
|
||||
});
|
||||
});
|
||||
+9
-8
@@ -36,10 +36,11 @@ export const ManageIntegration = ({
|
||||
}: ManageIntegrationProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
|
||||
let integrationArray: TIntegrationGoogleSheetsConfigData[] = [];
|
||||
if (googleSheetIntegration?.config.data) {
|
||||
integrationArray = googleSheetIntegration.config.data;
|
||||
}
|
||||
const integrationArray = googleSheetIntegration
|
||||
? googleSheetIntegration.config.data
|
||||
? googleSheetIntegration.config.data
|
||||
: []
|
||||
: [];
|
||||
const [isDeleting, setisDeleting] = useState(false);
|
||||
|
||||
const handleDeleteIntegration = async () => {
|
||||
@@ -111,9 +112,9 @@ export const ManageIntegration = ({
|
||||
{integrationArray &&
|
||||
integrationArray.map((data, index) => {
|
||||
return (
|
||||
<button
|
||||
key={`${index}-${data.spreadsheetName}-${data.surveyName}`}
|
||||
className="grid h-16 w-full cursor-pointer grid-cols-8 content-center rounded-lg p-2 hover:bg-slate-100"
|
||||
<div
|
||||
key={index}
|
||||
className="m-2 grid h-16 cursor-pointer grid-cols-8 content-center rounded-lg hover:bg-slate-100"
|
||||
onClick={() => {
|
||||
editIntegration(index);
|
||||
}}>
|
||||
@@ -123,7 +124,7 @@ export const ManageIntegration = ({
|
||||
<div className="col-span-2 text-center">
|
||||
{timeSince(data.createdAt.toString(), locale)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
-91
@@ -1,91 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import type {
|
||||
TIntegrationNotion,
|
||||
TIntegrationNotionConfig,
|
||||
TIntegrationNotionConfigData,
|
||||
TIntegrationNotionCredential,
|
||||
} from "@formbricks/types/integration/notion";
|
||||
import { ManageIntegration } from "./ManageIntegration";
|
||||
|
||||
vi.mock("react-hot-toast", () => ({ success: vi.fn(), error: vi.fn() }));
|
||||
vi.mock("@/lib/time", () => ({ timeSince: () => "ago" }));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
|
||||
deleteIntegrationAction: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("ManageIntegration", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
environment: {} as any,
|
||||
locale: "en-US" as const,
|
||||
setOpenAddIntegrationModal: vi.fn(),
|
||||
setIsConnected: vi.fn(),
|
||||
setSelectedIntegration: vi.fn(),
|
||||
handleNotionAuthorization: vi.fn(),
|
||||
};
|
||||
|
||||
test("shows empty state when no databases", () => {
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...defaultProps}
|
||||
notionIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: {
|
||||
data: [] as TIntegrationNotionConfigData[],
|
||||
key: { workspace_name: "ws" } as TIntegrationNotionCredential,
|
||||
} as TIntegrationNotionConfig,
|
||||
} as TIntegrationNotion
|
||||
}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("environments.integrations.notion.no_databases_found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders list and handles clicks", async () => {
|
||||
const data = [
|
||||
{ surveyName: "S", databaseName: "D", createdAt: new Date().toISOString(), databaseId: "db" },
|
||||
] as unknown as TIntegrationNotionConfigData[];
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...defaultProps}
|
||||
notionIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { data, key: { workspace_name: "ws" } as TIntegrationNotionCredential },
|
||||
} as TIntegrationNotion
|
||||
}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("S")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText("S"));
|
||||
expect(defaultProps.setSelectedIntegration).toHaveBeenCalledWith({ ...data[0], index: 0 });
|
||||
expect(defaultProps.setOpenAddIntegrationModal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("update and link new buttons invoke handlers", async () => {
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...defaultProps}
|
||||
notionIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: {
|
||||
data: [],
|
||||
key: { workspace_name: "ws" } as TIntegrationNotionCredential,
|
||||
} as TIntegrationNotionConfig,
|
||||
} as TIntegrationNotion
|
||||
}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByText("environments.integrations.notion.update_connection"));
|
||||
expect(defaultProps.handleNotionAuthorization).toHaveBeenCalled();
|
||||
await userEvent.click(screen.getByText("environments.integrations.notion.link_new_database"));
|
||||
expect(defaultProps.setOpenAddIntegrationModal).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+9
-9
@@ -39,11 +39,11 @@ export const ManageIntegration = ({
|
||||
const { t } = useTranslate();
|
||||
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
|
||||
const [isDeleting, setisDeleting] = useState(false);
|
||||
|
||||
let integrationArray: TIntegrationNotionConfigData[] = [];
|
||||
if (notionIntegration?.config.data) {
|
||||
integrationArray = notionIntegration.config.data;
|
||||
}
|
||||
const integrationArray = notionIntegration
|
||||
? notionIntegration.config.data
|
||||
? notionIntegration.config.data
|
||||
: []
|
||||
: [];
|
||||
|
||||
const handleDeleteIntegration = async () => {
|
||||
setisDeleting(true);
|
||||
@@ -121,9 +121,9 @@ export const ManageIntegration = ({
|
||||
{integrationArray &&
|
||||
integrationArray.map((data, index) => {
|
||||
return (
|
||||
<button
|
||||
key={`${index}-${data.databaseId}`}
|
||||
className="grid h-16 w-full cursor-pointer grid-cols-6 content-center rounded-lg p-2 hover:bg-slate-100"
|
||||
<div
|
||||
key={index}
|
||||
className="m-2 grid h-16 cursor-pointer grid-cols-6 content-center rounded-lg hover:bg-slate-100"
|
||||
onClick={() => {
|
||||
editIntegration(index);
|
||||
}}>
|
||||
@@ -132,7 +132,7 @@ export const ManageIntegration = ({
|
||||
<div className="col-span-2 text-center">
|
||||
{timeSince(data.createdAt.toString(), locale)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
-158
@@ -1,158 +0,0 @@
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
|
||||
import { ManageIntegration } from "./ManageIntegration";
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
|
||||
deleteIntegrationAction: vi.fn(),
|
||||
}));
|
||||
vi.mock("react-hot-toast", () => ({ default: { success: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock("@/modules/ui/components/delete-dialog", () => ({
|
||||
DeleteDialog: ({ open, setOpen, onDelete }: any) =>
|
||||
open ? (
|
||||
<div data-testid="delete-dialog">
|
||||
<button onClick={onDelete}>confirm</button>
|
||||
<button onClick={() => setOpen(false)}>cancel</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/empty-space-filler", () => ({
|
||||
EmptySpaceFiller: ({ emptyMessage }: any) => <div>{emptyMessage}</div>,
|
||||
}));
|
||||
|
||||
const baseProps = {
|
||||
environment: { id: "env1" } as TEnvironment,
|
||||
setOpenAddIntegrationModal: vi.fn(),
|
||||
setIsConnected: vi.fn(),
|
||||
setSelectedIntegration: vi.fn(),
|
||||
refreshChannels: vi.fn(),
|
||||
handleSlackAuthorization: vi.fn(),
|
||||
showReconnectButton: false,
|
||||
locale: "en-US" as const,
|
||||
};
|
||||
|
||||
describe("ManageIntegration (Slack)", () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
test("empty state", () => {
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
slackIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { data: [], key: { team: { name: "team name" } } },
|
||||
} as unknown as TIntegrationSlack
|
||||
}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/connect_your_first_slack_channel/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/link_channel/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("link channel triggers handlers", async () => {
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
slackIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { data: [], key: { team: { name: "team name" } } },
|
||||
} as unknown as TIntegrationSlack
|
||||
}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByText(/link_channel/));
|
||||
expect(baseProps.refreshChannels).toHaveBeenCalled();
|
||||
expect(baseProps.setSelectedIntegration).toHaveBeenCalledWith(null);
|
||||
expect(baseProps.setOpenAddIntegrationModal).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test("show reconnect button and triggers authorization", async () => {
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
showReconnectButton={true}
|
||||
slackIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { data: [], key: { team: { name: "Team" } } },
|
||||
} as unknown as TIntegrationSlack
|
||||
}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("environments.integrations.slack.slack_reconnect_button")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText("environments.integrations.slack.slack_reconnect_button"));
|
||||
expect(baseProps.handleSlackAuthorization).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("list integrations and open edit", async () => {
|
||||
const item = {
|
||||
surveyName: "S",
|
||||
channelName: "C",
|
||||
questions: "Q",
|
||||
createdAt: new Date().toISOString(),
|
||||
surveyId: "s",
|
||||
channelId: "c",
|
||||
} as unknown as TIntegrationSlackConfigData;
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
slackIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { data: [item], key: { team: { name: "team name" } } },
|
||||
} as unknown as TIntegrationSlack
|
||||
}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("S")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText("S"));
|
||||
expect(baseProps.setSelectedIntegration).toHaveBeenCalledWith({ ...item, index: 0 });
|
||||
expect(baseProps.setOpenAddIntegrationModal).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test("delete integration success", async () => {
|
||||
vi.mocked(deleteIntegrationAction).mockResolvedValue({ data: true } as any);
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
slackIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { data: [], key: { team: { name: "team name" } } },
|
||||
} as unknown as TIntegrationSlack
|
||||
}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByText(/delete_integration/));
|
||||
expect(screen.getByTestId("delete-dialog")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText("confirm"));
|
||||
expect(deleteIntegrationAction).toHaveBeenCalledWith({ integrationId: "1" });
|
||||
const { default: toast } = await import("react-hot-toast");
|
||||
expect(toast.success).toHaveBeenCalledWith("environments.integrations.integration_removed_successfully");
|
||||
expect(baseProps.setIsConnected).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test("delete integration error", async () => {
|
||||
vi.mocked(deleteIntegrationAction).mockResolvedValue({ error: "fail" } as any);
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
slackIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { data: [], key: { team: { name: "team name" } } },
|
||||
} as unknown as TIntegrationSlack
|
||||
}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByText(/delete_integration/));
|
||||
await userEvent.click(screen.getByText("confirm"));
|
||||
const { default: toast } = await import("react-hot-toast");
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.any(String));
|
||||
});
|
||||
});
|
||||
+11
-9
@@ -6,7 +6,8 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||
import { T, useTranslate } from "@tolgee/react";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { T } from "@tolgee/react";
|
||||
import { Trash2Icon } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -42,10 +43,11 @@ export const ManageIntegration = ({
|
||||
const { t } = useTranslate();
|
||||
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
|
||||
const [isDeleting, setisDeleting] = useState(false);
|
||||
let integrationArray: TIntegrationSlackConfigData[] = [];
|
||||
if (slackIntegration?.config.data) {
|
||||
integrationArray = slackIntegration.config.data;
|
||||
}
|
||||
const integrationArray = slackIntegration
|
||||
? slackIntegration.config.data
|
||||
? slackIntegration.config.data
|
||||
: []
|
||||
: [];
|
||||
|
||||
const handleDeleteIntegration = async () => {
|
||||
setisDeleting(true);
|
||||
@@ -127,9 +129,9 @@ export const ManageIntegration = ({
|
||||
{integrationArray &&
|
||||
integrationArray.map((data, index) => {
|
||||
return (
|
||||
<button
|
||||
key={`${index}-${data.surveyName}-${data.channelName}`}
|
||||
className="grid h-16 w-full grid-cols-8 content-center rounded-lg p-2 text-slate-700 hover:cursor-pointer hover:bg-slate-100"
|
||||
<div
|
||||
key={index}
|
||||
className="m-2 grid h-16 grid-cols-8 content-center rounded-lg text-slate-700 hover:cursor-pointer hover:bg-slate-100"
|
||||
onClick={() => {
|
||||
editIntegration(index);
|
||||
}}>
|
||||
@@ -139,7 +141,7 @@ export const ManageIntegration = ({
|
||||
<div className="col-span-2 text-center">
|
||||
{timeSince(data.createdAt.toString(), locale)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -56,7 +56,7 @@ export const EditAlerts = ({
|
||||
<TooltipTrigger>
|
||||
<div className="col-span-1 flex cursor-default items-center justify-center space-x-2">
|
||||
<span>{t("environments.settings.notifications.every_response")}</span>
|
||||
<HelpCircleIcon className="h-4 w-4 flex-shrink-0 text-slate-500" />
|
||||
<HelpCircleIcon className="h-4 w-4 shrink-0 text-slate-500" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -99,7 +99,7 @@ export const EditAlerts = ({
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="m-2 flex h-16 items-center justify-center rounded bg-slate-50 text-sm text-slate-500">
|
||||
<div className="m-2 flex h-16 items-center justify-center rounded-sm bg-slate-50 text-sm text-slate-500">
|
||||
<p>{t("common.no_surveys_found")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ export const IntegrationsTip = ({ environmentId }: IntegrationsTipProps) => {
|
||||
const { t } = useTranslate();
|
||||
return (
|
||||
<div>
|
||||
<div className="flex max-w-4xl items-center space-y-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-sm md:space-y-0 md:text-base">
|
||||
<div className="flex max-w-4xl items-center space-y-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-xs md:space-y-0 md:text-base">
|
||||
<SlackIcon className="mr-3 h-4 w-4 text-blue-400" />
|
||||
<p className="text-sm">
|
||||
{t("environments.settings.notifications.need_slack_or_discord_notifications")}?
|
||||
|
||||
+1
-1
@@ -105,7 +105,7 @@ export const EditProfileAvatarForm = ({ session, environmentId, imageUrl }: Edit
|
||||
<div>
|
||||
<div className="relative h-10 w-10 overflow-hidden rounded-full">
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-30">
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
|
||||
<svg className="h-7 w-7 animate-spin text-slate-200" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
|
||||
+7
-2
@@ -58,6 +58,11 @@ const Page = async (props) => {
|
||||
comingSoon: false,
|
||||
onRequest: false,
|
||||
},
|
||||
{
|
||||
title: t("environments.settings.enterprise.ai"),
|
||||
comingSoon: false,
|
||||
onRequest: true,
|
||||
},
|
||||
{
|
||||
title: t("environments.settings.enterprise.audit_logs"),
|
||||
comingSoon: false,
|
||||
@@ -92,7 +97,7 @@ const Page = async (props) => {
|
||||
</PageHeader>
|
||||
{isEnterpriseEdition ? (
|
||||
<div>
|
||||
<div className="mt-8 max-w-4xl rounded-lg border border-slate-300 bg-slate-100 shadow-sm">
|
||||
<div className="mt-8 max-w-4xl rounded-lg border border-slate-300 bg-slate-100 shadow-xs">
|
||||
<div className="space-y-4 p-8">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="rounded-full border border-green-300 bg-green-100 p-0.5 dark:bg-green-800">
|
||||
@@ -147,7 +152,7 @@ const Page = async (props) => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 rounded-lg border border-slate-300 bg-slate-100 shadow-sm">
|
||||
<div className="mt-8 rounded-lg border border-slate-300 bg-slate-100 shadow-xs">
|
||||
<div className="p-8">
|
||||
<h2 className="mr-2 inline-flex text-2xl font-bold text-slate-700">
|
||||
{t("environments.settings.enterprise.enterprise_features")}
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { updateOrganizationAIEnabledAction } from "@/modules/ee/insights/actions";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
|
||||
interface AIToggleProps {
|
||||
environmentId: string;
|
||||
organization: TOrganization;
|
||||
isOwnerOrManager: boolean;
|
||||
}
|
||||
|
||||
export const AIToggle = ({ organization, isOwnerOrManager }: AIToggleProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [isAIEnabled, setIsAIEnabled] = useState(organization.isAIEnabled);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleUpdateOrganization = async (data) => {
|
||||
try {
|
||||
setIsAIEnabled(data.enabled);
|
||||
setIsSubmitting(true);
|
||||
const updatedOrganizationResponse = await updateOrganizationAIEnabledAction({
|
||||
organizationId: organization.id,
|
||||
data: {
|
||||
isAIEnabled: data.enabled,
|
||||
},
|
||||
});
|
||||
|
||||
if (updatedOrganizationResponse?.data) {
|
||||
if (data.enabled) {
|
||||
toast.success(t("environments.settings.general.formbricks_ai_enable_success_message"));
|
||||
} else {
|
||||
toast.success(t("environments.settings.general.formbricks_ai_disable_success_message"));
|
||||
}
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(updatedOrganizationResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(`Error: ${err.message}`);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
if (typeof window !== "undefined") {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="formbricks-ai-toggle" className="cursor-pointer">
|
||||
{t("environments.settings.general.enable_formbricks_ai")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="formbricks-ai-toggle"
|
||||
disabled={!isOwnerOrManager || isSubmitting}
|
||||
checked={isAIEnabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUpdateOrganization({ enabled: !organization.isAIEnabled });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-slate-600">
|
||||
{t("environments.settings.general.formbricks_ai_privacy_policy_text")}{" "}
|
||||
<Link
|
||||
className="underline"
|
||||
href={"https://formbricks.com/privacy-policy"}
|
||||
rel="noreferrer"
|
||||
target="_blank">
|
||||
{t("common.privacy_policy")}
|
||||
</Link>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
{!isOwnerOrManager && (
|
||||
<Alert variant="warning" className="mt-4">
|
||||
<AlertDescription>
|
||||
{t("environments.settings.general.only_org_owner_can_perform_action")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+13
-1
@@ -1,5 +1,9 @@
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import {
|
||||
getIsMultiOrgEnabled,
|
||||
getIsOrganizationAIReady,
|
||||
getWhiteLabelPermission,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
@@ -29,6 +33,12 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "mock-webapp-url",
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
SMTP_PORT: "mock-smtp-port",
|
||||
AI_AZURE_LLM_RESSOURCE_NAME: "mock-ai-azure-llm-ressource-name",
|
||||
AI_AZURE_LLM_API_KEY: "mock-ai",
|
||||
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-ai-azure-llm-deployment-id",
|
||||
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-ai-azure-embeddings-ressource-name",
|
||||
AI_AZURE_EMBEDDINGS_API_KEY: "mock-ai-azure-embeddings-api-key",
|
||||
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-ai-azure-embeddings-deployment-id",
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
@@ -49,6 +59,7 @@ vi.mock("@/modules/environments/lib/utils", () => ({
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getIsMultiOrgEnabled: vi.fn(),
|
||||
getIsOrganizationAIReady: vi.fn(),
|
||||
getWhiteLabelPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -69,6 +80,7 @@ describe("Page", () => {
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue(mockEnvironmentAuth);
|
||||
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true);
|
||||
vi.mocked(getIsOrganizationAIReady).mockResolvedValue(true);
|
||||
vi.mocked(getWhiteLabelPermission).mockResolvedValue(true);
|
||||
});
|
||||
|
||||
|
||||
+19
-1
@@ -1,7 +1,12 @@
|
||||
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
||||
import { AIToggle } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/components/AIToggle";
|
||||
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import {
|
||||
getIsMultiOrgEnabled,
|
||||
getIsOrganizationAIReady,
|
||||
getWhiteLabelPermission,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
@@ -30,6 +35,8 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
|
||||
const isOwnerOrManager = isManager || isOwner;
|
||||
|
||||
const isOrganizationAIReady = await getIsOrganizationAIReady(organization.billing.plan);
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("environments.settings.general.organization_settings")}>
|
||||
@@ -49,6 +56,17 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
membershipRole={currentUserMembership?.role}
|
||||
/>
|
||||
</SettingsCard>
|
||||
{isOrganizationAIReady && (
|
||||
<SettingsCard
|
||||
title={t("environments.settings.general.formbricks_ai")}
|
||||
description={t("environments.settings.general.formbricks_ai_description")}>
|
||||
<AIToggle
|
||||
environmentId={params.environmentId}
|
||||
organization={organization}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
/>
|
||||
</SettingsCard>
|
||||
)}
|
||||
<EmailCustomizationSettings
|
||||
organization={organization}
|
||||
hasWhiteLabelPermission={hasWhiteLabelPermission}
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ export const SettingsCard = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative my-4 w-full max-w-4xl rounded-xl border border-slate-200 bg-white py-4 text-left shadow-sm",
|
||||
"relative my-4 w-full max-w-4xl rounded-xl border border-slate-200 bg-white py-4 text-left shadow-xs",
|
||||
className
|
||||
)}
|
||||
id={title}>
|
||||
|
||||
+29
@@ -1,5 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { generateInsightsForSurvey } from "@/app/api/(internal)/insights/lib/utils";
|
||||
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
@@ -107,3 +108,31 @@ export const getResponseCountAction = authenticatedActionClient
|
||||
|
||||
return getResponseCountBySurveyId(parsedInput.surveyId, parsedInput.filterCriteria);
|
||||
});
|
||||
|
||||
const ZGenerateInsightsForSurveyAction = z.object({
|
||||
surveyId: ZId,
|
||||
});
|
||||
|
||||
export const generateInsightsForSurveyAction = authenticatedActionClient
|
||||
.schema(ZGenerateInsightsForSurveyAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
schema: ZGenerateInsightsForSurveyAction,
|
||||
data: parsedInput,
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
generateInsightsForSurvey(parsedInput.surveyId);
|
||||
});
|
||||
|
||||
-165
@@ -1,165 +0,0 @@
|
||||
import type { Cell, Row } from "@tanstack/react-table";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import type { TResponse, TResponseTableData } from "@formbricks/types/responses";
|
||||
import { ResponseTableCell } from "./ResponseTableCell";
|
||||
|
||||
const makeCell = (
|
||||
id: string,
|
||||
size = 100,
|
||||
first = false,
|
||||
last = false,
|
||||
content = "CellContent"
|
||||
): Cell<TResponseTableData, unknown> =>
|
||||
({
|
||||
column: {
|
||||
id,
|
||||
getSize: () => size,
|
||||
getIsFirstColumn: () => first,
|
||||
getIsLastColumn: () => last,
|
||||
getStart: () => 0,
|
||||
columnDef: { cell: () => content },
|
||||
},
|
||||
id,
|
||||
getContext: () => ({}),
|
||||
}) as unknown as Cell<TResponseTableData, unknown>;
|
||||
|
||||
const makeRow = (id: string, selected = false): Row<TResponseTableData> =>
|
||||
({ id, getIsSelected: () => selected }) as unknown as Row<TResponseTableData>;
|
||||
|
||||
describe("ResponseTableCell", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders cell content", () => {
|
||||
const cell = makeCell("col1");
|
||||
const row = makeRow("r1");
|
||||
render(
|
||||
<ResponseTableCell
|
||||
cell={cell}
|
||||
row={row}
|
||||
isExpanded={false}
|
||||
setSelectedResponseId={vi.fn()}
|
||||
responses={[]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("CellContent")).toBeDefined();
|
||||
});
|
||||
|
||||
test("calls setSelectedResponseId on cell click when not select column", async () => {
|
||||
const cell = makeCell("col1");
|
||||
const row = makeRow("r1");
|
||||
const setSel = vi.fn();
|
||||
render(
|
||||
<ResponseTableCell
|
||||
cell={cell}
|
||||
row={row}
|
||||
isExpanded={false}
|
||||
setSelectedResponseId={setSel}
|
||||
responses={[{ id: "r1" } as TResponse]}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByText("CellContent"));
|
||||
expect(setSel).toHaveBeenCalledWith("r1");
|
||||
});
|
||||
|
||||
test("does not call setSelectedResponseId on select column click", async () => {
|
||||
const cell = makeCell("select");
|
||||
const row = makeRow("r1");
|
||||
const setSel = vi.fn();
|
||||
render(
|
||||
<ResponseTableCell
|
||||
cell={cell}
|
||||
row={row}
|
||||
isExpanded={false}
|
||||
setSelectedResponseId={setSel}
|
||||
responses={[{ id: "r1" } as TResponse]}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByText("CellContent"));
|
||||
expect(setSel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("renders maximize icon for createdAt column and handles click", async () => {
|
||||
const cell = makeCell("createdAt", 120, false, false);
|
||||
const row = makeRow("r2");
|
||||
const setSel = vi.fn();
|
||||
render(
|
||||
<ResponseTableCell
|
||||
cell={cell}
|
||||
row={row}
|
||||
isExpanded={false}
|
||||
setSelectedResponseId={setSel}
|
||||
responses={[{ id: "r2" } as TResponse]}
|
||||
/>
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: /expand response/i });
|
||||
expect(btn).toBeDefined();
|
||||
await userEvent.click(btn);
|
||||
expect(setSel).toHaveBeenCalledWith("r2");
|
||||
});
|
||||
|
||||
test("does not apply selected style when row.getIsSelected() is false", () => {
|
||||
const cell = makeCell("col1");
|
||||
const row = makeRow("r1", false);
|
||||
const { container } = render(
|
||||
<ResponseTableCell
|
||||
cell={cell}
|
||||
row={row}
|
||||
isExpanded={false}
|
||||
setSelectedResponseId={vi.fn()}
|
||||
responses={[]}
|
||||
/>
|
||||
);
|
||||
expect(container.firstChild).not.toHaveClass("bg-slate-100");
|
||||
});
|
||||
|
||||
test("applies selected style when row.getIsSelected() is true", () => {
|
||||
const cell = makeCell("col1");
|
||||
const row = makeRow("r1", true);
|
||||
const { container } = render(
|
||||
<ResponseTableCell
|
||||
cell={cell}
|
||||
row={row}
|
||||
isExpanded={false}
|
||||
setSelectedResponseId={vi.fn()}
|
||||
responses={[]}
|
||||
/>
|
||||
);
|
||||
expect(container.firstChild).toHaveClass("bg-slate-100");
|
||||
});
|
||||
|
||||
test("renders collapsed height class when isExpanded is false", () => {
|
||||
const cell = makeCell("col1");
|
||||
const row = makeRow("r1");
|
||||
const { container } = render(
|
||||
<ResponseTableCell
|
||||
cell={cell}
|
||||
row={row}
|
||||
isExpanded={false}
|
||||
setSelectedResponseId={vi.fn()}
|
||||
responses={[]}
|
||||
/>
|
||||
);
|
||||
const inner = container.querySelector("div > div");
|
||||
expect(inner).toHaveClass("h-10");
|
||||
});
|
||||
|
||||
test("renders expanded height class when isExpanded is true", () => {
|
||||
const cell = makeCell("col1");
|
||||
const row = makeRow("r1");
|
||||
const { container } = render(
|
||||
<ResponseTableCell
|
||||
cell={cell}
|
||||
row={row}
|
||||
isExpanded={true}
|
||||
setSelectedResponseId={vi.fn()}
|
||||
responses={[]}
|
||||
/>
|
||||
);
|
||||
const inner = container.querySelector("div > div");
|
||||
expect(inner).toHaveClass("h-full");
|
||||
});
|
||||
});
|
||||
+3
-5
@@ -35,13 +35,11 @@ export const ResponseTableCell = ({
|
||||
|
||||
// Conditional rendering of maximize icon
|
||||
const renderMaximizeIcon = cell.column.id === "createdAt" && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Expand response"
|
||||
className="hidden flex-shrink-0 cursor-pointer items-center rounded-md border border-slate-200 bg-white p-2 group-hover:flex hover:border-slate-300 focus:outline-none"
|
||||
<div
|
||||
className="hidden shrink-0 cursor-pointer items-center rounded-md border border-slate-200 bg-white p-2 group-hover:flex hover:border-slate-300"
|
||||
onClick={handleCellClick}>
|
||||
<Maximize2Icon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
+18
-3
@@ -1,13 +1,16 @@
|
||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||
import { EnableInsightsBanner } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/EnableInsightsBanner";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
import { RESPONSES_PER_PAGE, WEBAPP_URL } from "@/lib/constants";
|
||||
import { needsInsightsGeneration } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
|
||||
import { MAX_RESPONSES_FOR_INSIGHT_GENERATION, RESPONSES_PER_PAGE, WEBAPP_URL } from "@/lib/constants";
|
||||
import { getSurveyDomain } from "@/lib/getSurveyUrl";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
@@ -17,7 +20,7 @@ const Page = async (props) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
|
||||
const { session, environment, isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
||||
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const survey = await getSurvey(params.surveyId);
|
||||
|
||||
@@ -35,6 +38,11 @@ const Page = async (props) => {
|
||||
|
||||
const totalResponseCount = await getResponseCountBySurveyId(params.surveyId);
|
||||
|
||||
const isAIEnabled = await getIsAIEnabled({
|
||||
isAIEnabled: organization.isAIEnabled,
|
||||
billing: organization.billing,
|
||||
});
|
||||
const shouldGenerateInsights = needsInsightsGeneration(survey);
|
||||
const locale = await findMatchingLocale();
|
||||
const surveyDomain = getSurveyDomain();
|
||||
|
||||
@@ -49,9 +57,16 @@ const Page = async (props) => {
|
||||
isReadOnly={isReadOnly}
|
||||
user={user}
|
||||
surveyDomain={surveyDomain}
|
||||
responseCount={totalResponseCount}
|
||||
/>
|
||||
}>
|
||||
{isAIEnabled && shouldGenerateInsights && (
|
||||
<EnableInsightsBanner
|
||||
surveyId={survey.id}
|
||||
surveyResponseCount={totalResponseCount}
|
||||
maxResponseCount={MAX_RESPONSES_FOR_INSIGHT_GENERATION}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SurveyAnalysisNavigation
|
||||
environmentId={environment.id}
|
||||
survey={survey}
|
||||
|
||||
-154
@@ -1,154 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey, TSurveyQuestionSummaryAddress } from "@formbricks/types/surveys/types";
|
||||
import { AddressSummary } from "./AddressSummary";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/time", () => ({
|
||||
timeSince: () => "2 hours ago",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/contact", () => ({
|
||||
getContactIdentifier: () => "contact@example.com",
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/avatars", () => ({
|
||||
PersonAvatar: ({ personId }: { personId: string }) => <div data-testid="person-avatar">{personId}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/array-response", () => ({
|
||||
ArrayResponse: ({ value }: { value: string[] }) => (
|
||||
<div data-testid="array-response">{value.join(", ")}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./QuestionSummaryHeader", () => ({
|
||||
QuestionSummaryHeader: () => <div data-testid="question-summary-header" />,
|
||||
}));
|
||||
|
||||
describe("AddressSummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const environmentId = "env-123";
|
||||
const survey = {} as TSurvey;
|
||||
const locale = "en-US";
|
||||
|
||||
test("renders table headers correctly", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Address Question" },
|
||||
samples: [],
|
||||
} as unknown as TSurveyQuestionSummaryAddress;
|
||||
|
||||
render(
|
||||
<AddressSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("question-summary-header")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.user")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.response")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.time")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders contact information correctly", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Address Question" },
|
||||
samples: [
|
||||
{
|
||||
id: "response1",
|
||||
value: ["123 Main St", "Apt 4", "New York", "NY", "10001"],
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: { id: "contact1" },
|
||||
contactAttributes: { email: "user@example.com" },
|
||||
},
|
||||
],
|
||||
} as unknown as TSurveyQuestionSummaryAddress;
|
||||
|
||||
render(
|
||||
<AddressSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("person-avatar")).toHaveTextContent("contact1");
|
||||
expect(screen.getByText("contact@example.com")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("array-response")).toHaveTextContent("123 Main St, Apt 4, New York, NY, 10001");
|
||||
expect(screen.getByText("2 hours ago")).toBeInTheDocument();
|
||||
|
||||
// Check link to contact
|
||||
const contactLink = screen.getByText("contact@example.com").closest("a");
|
||||
expect(contactLink).toHaveAttribute("href", `/environments/${environmentId}/contacts/contact1`);
|
||||
});
|
||||
|
||||
test("renders anonymous user when no contact is provided", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Address Question" },
|
||||
samples: [
|
||||
{
|
||||
id: "response2",
|
||||
value: ["456 Oak St", "London", "UK"],
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
},
|
||||
],
|
||||
} as unknown as TSurveyQuestionSummaryAddress;
|
||||
|
||||
render(
|
||||
<AddressSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("person-avatar")).toHaveTextContent("anonymous");
|
||||
expect(screen.getByText("common.anonymous")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("array-response")).toHaveTextContent("456 Oak St, London, UK");
|
||||
});
|
||||
|
||||
test("renders multiple responses correctly", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Address Question" },
|
||||
samples: [
|
||||
{
|
||||
id: "response1",
|
||||
value: ["123 Main St", "New York"],
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: { id: "contact1" },
|
||||
contactAttributes: {},
|
||||
},
|
||||
{
|
||||
id: "response2",
|
||||
value: ["456 Oak St", "London"],
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: { id: "contact2" },
|
||||
contactAttributes: {},
|
||||
},
|
||||
],
|
||||
} as unknown as TSurveyQuestionSummaryAddress;
|
||||
|
||||
render(
|
||||
<AddressSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getAllByTestId("person-avatar")).toHaveLength(2);
|
||||
expect(screen.getAllByTestId("array-response")).toHaveLength(2);
|
||||
expect(screen.getAllByText("2 hours ago")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -20,7 +20,7 @@ interface AddressSummaryProps {
|
||||
export const AddressSummary = ({ questionSummary, environmentId, survey, locale }: AddressSummaryProps) => {
|
||||
const { t } = useTranslate();
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
|
||||
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||
<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">
|
||||
|
||||
-89
@@ -1,89 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey, TSurveyQuestionSummaryCta } from "@formbricks/types/surveys/types";
|
||||
import { CTASummary } from "./CTASummary";
|
||||
|
||||
vi.mock("@/modules/ui/components/progress-bar", () => ({
|
||||
ProgressBar: ({ progress, barColor }: { progress: number; barColor: string }) => (
|
||||
<div data-testid="progress-bar">{`${progress}-${barColor}`}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./QuestionSummaryHeader", () => ({
|
||||
QuestionSummaryHeader: ({
|
||||
additionalInfo,
|
||||
}: {
|
||||
showResponses: boolean;
|
||||
additionalInfo: React.ReactNode;
|
||||
}) => <div data-testid="question-summary-header">{additionalInfo}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
InboxIcon: () => <div data-testid="inbox-icon" />,
|
||||
}));
|
||||
|
||||
vi.mock("../lib/utils", () => ({
|
||||
convertFloatToNDecimal: (value: number) => value.toFixed(2),
|
||||
}));
|
||||
|
||||
describe("CTASummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const survey = {} as TSurvey;
|
||||
|
||||
test("renders with all metrics and required question", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "CTA Question", required: true },
|
||||
impressionCount: 100,
|
||||
clickCount: 25,
|
||||
skipCount: 10,
|
||||
ctr: { count: 25, percentage: 25 },
|
||||
} as unknown as TSurveyQuestionSummaryCta;
|
||||
|
||||
render(<CTASummary questionSummary={questionSummary} survey={survey} />);
|
||||
|
||||
expect(screen.getByTestId("question-summary-header")).toBeInTheDocument();
|
||||
expect(screen.getByText("100 common.impressions")).toBeInTheDocument();
|
||||
// Use getAllByText instead of getByText for multiple matching elements
|
||||
expect(screen.getAllByText("25 common.clicks")).toHaveLength(2);
|
||||
expect(screen.queryByText("10 common.skips")).not.toBeInTheDocument(); // Should not show skips for required questions
|
||||
|
||||
// Check CTR section
|
||||
expect(screen.getByText("CTR")).toBeInTheDocument();
|
||||
expect(screen.getByText("25.00%")).toBeInTheDocument();
|
||||
|
||||
// Check progress bar
|
||||
expect(screen.getByTestId("progress-bar")).toHaveTextContent("0.25-bg-brand-dark");
|
||||
});
|
||||
|
||||
test("renders skip count for non-required questions", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "CTA Question", required: false },
|
||||
impressionCount: 100,
|
||||
clickCount: 20,
|
||||
skipCount: 30,
|
||||
ctr: { count: 20, percentage: 20 },
|
||||
} as unknown as TSurveyQuestionSummaryCta;
|
||||
|
||||
render(<CTASummary questionSummary={questionSummary} survey={survey} />);
|
||||
|
||||
expect(screen.getByText("30 common.skips")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders singular form for count = 1", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "CTA Question", required: true },
|
||||
impressionCount: 10,
|
||||
clickCount: 1,
|
||||
skipCount: 0,
|
||||
ctr: { count: 1, percentage: 10 },
|
||||
} as unknown as TSurveyQuestionSummaryCta;
|
||||
|
||||
render(<CTASummary questionSummary={questionSummary} survey={survey} />);
|
||||
|
||||
// Use getAllByText instead of getByText for multiple matching elements
|
||||
expect(screen.getAllByText("1 common.click")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -16,7 +16,7 @@ export const CTASummary = ({ questionSummary, survey }: CTASummaryProps) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
|
||||
<QuestionSummaryHeader
|
||||
survey={survey}
|
||||
questionSummary={questionSummary}
|
||||
@@ -40,7 +40,7 @@ export const CTASummary = ({ questionSummary, survey }: CTASummaryProps) => {
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<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 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
<div className="text flex justify-between px-2 pb-2">
|
||||
<div className="mr-8 flex space-x-1">
|
||||
<p className="font-semibold text-slate-700">CTR</p>
|
||||
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey, TSurveyQuestionSummaryCal } from "@formbricks/types/surveys/types";
|
||||
import { CalSummary } from "./CalSummary";
|
||||
|
||||
vi.mock("@/modules/ui/components/progress-bar", () => ({
|
||||
ProgressBar: ({ progress, barColor }: { progress: number; barColor: string }) => (
|
||||
<div data-testid="progress-bar">{`${progress}-${barColor}`}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./QuestionSummaryHeader", () => ({
|
||||
QuestionSummaryHeader: () => <div data-testid="question-summary-header" />,
|
||||
}));
|
||||
|
||||
vi.mock("../lib/utils", () => ({
|
||||
convertFloatToNDecimal: (value: number) => value.toFixed(2),
|
||||
}));
|
||||
|
||||
describe("CalSummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const environmentId = "env-123";
|
||||
const survey = {} as TSurvey;
|
||||
|
||||
test("renders the correct components and data", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Calendar Question" },
|
||||
booked: { count: 5, percentage: 75 },
|
||||
skipped: { count: 1, percentage: 25 },
|
||||
} as unknown as TSurveyQuestionSummaryCal;
|
||||
|
||||
render(<CalSummary questionSummary={questionSummary} environmentId={environmentId} survey={survey} />);
|
||||
|
||||
expect(screen.getByTestId("question-summary-header")).toBeInTheDocument();
|
||||
|
||||
// Check if booked section is displayed
|
||||
expect(screen.getByText("common.booked")).toBeInTheDocument();
|
||||
expect(screen.getByText("75.00%")).toBeInTheDocument();
|
||||
expect(screen.getByText("5 common.responses")).toBeInTheDocument();
|
||||
|
||||
// Check if skipped section is displayed
|
||||
expect(screen.getByText("common.dismissed")).toBeInTheDocument();
|
||||
expect(screen.getByText("25.00%")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 common.response")).toBeInTheDocument();
|
||||
|
||||
// Check progress bars
|
||||
const progressBars = screen.getAllByTestId("progress-bar");
|
||||
expect(progressBars).toHaveLength(2);
|
||||
expect(progressBars[0]).toHaveTextContent("0.75-bg-brand-dark");
|
||||
expect(progressBars[1]).toHaveTextContent("0.25-bg-brand-dark");
|
||||
});
|
||||
|
||||
test("renders singular and plural response counts correctly", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Calendar Question" },
|
||||
booked: { count: 1, percentage: 50 },
|
||||
skipped: { count: 1, percentage: 50 },
|
||||
} as unknown as TSurveyQuestionSummaryCal;
|
||||
|
||||
render(<CalSummary questionSummary={questionSummary} environmentId={environmentId} survey={survey} />);
|
||||
|
||||
// Use getAllByText directly since we know there are multiple matching elements
|
||||
const responseElements = screen.getAllByText("1 common.response");
|
||||
expect(responseElements).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -16,9 +16,9 @@ export const CalSummary = ({ questionSummary, survey }: CalSummaryProps) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
|
||||
<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 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
<div>
|
||||
<div className="text flex justify-between px-2 pb-2">
|
||||
<div className="mr-8 flex space-x-1">
|
||||
|
||||
-80
@@ -1,80 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyConsentQuestion,
|
||||
TSurveyQuestionSummaryConsent,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { ConsentSummary } from "./ConsentSummary";
|
||||
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/QuestionSummaryHeader",
|
||||
() => ({
|
||||
QuestionSummaryHeader: () => <div>QuestionSummaryHeader</div>,
|
||||
})
|
||||
);
|
||||
|
||||
describe("ConsentSummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const mockSetFilter = vi.fn();
|
||||
const questionSummary = {
|
||||
question: {
|
||||
id: "q1",
|
||||
headline: { en: "Headline" },
|
||||
type: TSurveyQuestionTypeEnum.Consent,
|
||||
} as unknown as TSurveyConsentQuestion,
|
||||
accepted: { percentage: 60.5, count: 61 },
|
||||
dismissed: { percentage: 39.5, count: 40 },
|
||||
} as unknown as TSurveyQuestionSummaryConsent;
|
||||
const survey = {} as TSurvey;
|
||||
|
||||
test("renders accepted and dismissed with correct values", () => {
|
||||
render(<ConsentSummary questionSummary={questionSummary} survey={survey} setFilter={mockSetFilter} />);
|
||||
expect(screen.getByText("common.accepted")).toBeInTheDocument();
|
||||
expect(screen.getByText(/60\.5%/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/61/)).toBeInTheDocument();
|
||||
expect(screen.getByText("common.dismissed")).toBeInTheDocument();
|
||||
expect(screen.getByText(/39\.5%/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/40/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls setFilter with correct args on accepted click", async () => {
|
||||
render(<ConsentSummary questionSummary={questionSummary} survey={survey} setFilter={mockSetFilter} />);
|
||||
await userEvent.click(screen.getByText("common.accepted"));
|
||||
expect(mockSetFilter).toHaveBeenCalledWith(
|
||||
"q1",
|
||||
{ en: "Headline" },
|
||||
TSurveyQuestionTypeEnum.Consent,
|
||||
"is",
|
||||
"common.accepted"
|
||||
);
|
||||
});
|
||||
|
||||
test("calls setFilter with correct args on dismissed click", async () => {
|
||||
render(<ConsentSummary questionSummary={questionSummary} survey={survey} setFilter={mockSetFilter} />);
|
||||
await userEvent.click(screen.getByText("common.dismissed"));
|
||||
expect(mockSetFilter).toHaveBeenCalledWith(
|
||||
"q1",
|
||||
{ en: "Headline" },
|
||||
TSurveyQuestionTypeEnum.Consent,
|
||||
"is",
|
||||
"common.dismissed"
|
||||
);
|
||||
});
|
||||
|
||||
test("renders singular and plural response labels", () => {
|
||||
const oneAndTwo = {
|
||||
...questionSummary,
|
||||
accepted: { percentage: questionSummary.accepted.percentage, count: 1 },
|
||||
dismissed: { percentage: questionSummary.dismissed.percentage, count: 2 },
|
||||
};
|
||||
render(<ConsentSummary questionSummary={oneAndTwo} survey={survey} setFilter={mockSetFilter} />);
|
||||
expect(screen.getByText(/1 common\.response/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/2 common\.responses/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+4
-4
@@ -39,13 +39,13 @@ export const ConsentSummary = ({ questionSummary, survey, setFilter }: ConsentSu
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
|
||||
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
{summaryItems.map((summaryItem) => {
|
||||
return (
|
||||
<button
|
||||
className="group w-full cursor-pointer"
|
||||
<div
|
||||
className="group cursor-pointer"
|
||||
key={summaryItem.title}
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
@@ -74,7 +74,7 @@ export const ConsentSummary = ({ questionSummary, survey, setFilter }: ConsentSu
|
||||
<div className="group-hover:opacity-80">
|
||||
<ProgressBar barColor="bg-brand-dark" progress={summaryItem.percentage / 100} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
-153
@@ -1,153 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey, TSurveyQuestionSummaryContactInfo } from "@formbricks/types/surveys/types";
|
||||
import { ContactInfoSummary } from "./ContactInfoSummary";
|
||||
|
||||
vi.mock("@/lib/time", () => ({
|
||||
timeSince: () => "2 hours ago",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/contact", () => ({
|
||||
getContactIdentifier: () => "contact@example.com",
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/avatars", () => ({
|
||||
PersonAvatar: ({ personId }: { personId: string }) => <div data-testid="person-avatar">{personId}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/array-response", () => ({
|
||||
ArrayResponse: ({ value }: { value: string[] }) => (
|
||||
<div data-testid="array-response">{value.join(", ")}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./QuestionSummaryHeader", () => ({
|
||||
QuestionSummaryHeader: () => <div data-testid="question-summary-header" />,
|
||||
}));
|
||||
|
||||
describe("ContactInfoSummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const environmentId = "env-123";
|
||||
const survey = {} as TSurvey;
|
||||
const locale = "en-US";
|
||||
|
||||
test("renders table headers correctly", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Contact Info Question" },
|
||||
samples: [],
|
||||
} as unknown as TSurveyQuestionSummaryContactInfo;
|
||||
|
||||
render(
|
||||
<ContactInfoSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("question-summary-header")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.user")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.response")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.time")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders contact information correctly", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Contact Info Question" },
|
||||
samples: [
|
||||
{
|
||||
id: "response1",
|
||||
value: ["John Doe", "john@example.com", "+1234567890"],
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: { id: "contact1" },
|
||||
contactAttributes: { email: "user@example.com" },
|
||||
},
|
||||
],
|
||||
} as unknown as TSurveyQuestionSummaryContactInfo;
|
||||
|
||||
render(
|
||||
<ContactInfoSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("person-avatar")).toHaveTextContent("contact1");
|
||||
expect(screen.getByText("contact@example.com")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("array-response")).toHaveTextContent("John Doe, john@example.com, +1234567890");
|
||||
expect(screen.getByText("2 hours ago")).toBeInTheDocument();
|
||||
|
||||
// Check link to contact
|
||||
const contactLink = screen.getByText("contact@example.com").closest("a");
|
||||
expect(contactLink).toHaveAttribute("href", `/environments/${environmentId}/contacts/contact1`);
|
||||
});
|
||||
|
||||
test("renders anonymous user when no contact is provided", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Contact Info Question" },
|
||||
samples: [
|
||||
{
|
||||
id: "response2",
|
||||
value: ["Anonymous User", "anonymous@example.com"],
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
},
|
||||
],
|
||||
} as unknown as TSurveyQuestionSummaryContactInfo;
|
||||
|
||||
render(
|
||||
<ContactInfoSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("person-avatar")).toHaveTextContent("anonymous");
|
||||
expect(screen.getByText("common.anonymous")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("array-response")).toHaveTextContent("Anonymous User, anonymous@example.com");
|
||||
});
|
||||
|
||||
test("renders multiple responses correctly", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Contact Info Question" },
|
||||
samples: [
|
||||
{
|
||||
id: "response1",
|
||||
value: ["John Doe", "john@example.com"],
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: { id: "contact1" },
|
||||
contactAttributes: {},
|
||||
},
|
||||
{
|
||||
id: "response2",
|
||||
value: ["Jane Smith", "jane@example.com"],
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: { id: "contact2" },
|
||||
contactAttributes: {},
|
||||
},
|
||||
],
|
||||
} as unknown as TSurveyQuestionSummaryContactInfo;
|
||||
|
||||
render(
|
||||
<ContactInfoSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getAllByTestId("person-avatar")).toHaveLength(2);
|
||||
expect(screen.getAllByTestId("array-response")).toHaveLength(2);
|
||||
expect(screen.getAllByText("2 hours ago")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -25,7 +25,7 @@ export const ContactInfoSummary = ({
|
||||
}: ContactInfoSummaryProps) => {
|
||||
const { t } = useTranslate();
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
|
||||
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||
<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">
|
||||
|
||||
-192
@@ -1,192 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey, TSurveyQuestionSummaryDate } from "@formbricks/types/surveys/types";
|
||||
import { DateQuestionSummary } from "./DateQuestionSummary";
|
||||
|
||||
vi.mock("@/lib/time", () => ({
|
||||
timeSince: () => "2 hours ago",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/contact", () => ({
|
||||
getContactIdentifier: () => "contact@example.com",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/datetime", () => ({
|
||||
formatDateWithOrdinal: (_: Date) => "January 1st, 2023",
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/avatars", () => ({
|
||||
PersonAvatar: ({ personId }: { personId: string }) => <div data-testid="person-avatar">{personId}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick }: { children: React.ReactNode; onClick: () => void }) => (
|
||||
<button onClick={onClick} data-testid="load-more-button">
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
|
||||
<a href={href} data-testid="next-link">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./QuestionSummaryHeader", () => ({
|
||||
QuestionSummaryHeader: () => <div data-testid="question-summary-header" />,
|
||||
}));
|
||||
|
||||
describe("DateQuestionSummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const environmentId = "env-123";
|
||||
const survey = {} as TSurvey;
|
||||
const locale = "en-US";
|
||||
|
||||
test("renders table headers correctly", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Date Question" },
|
||||
samples: [],
|
||||
} as unknown as TSurveyQuestionSummaryDate;
|
||||
|
||||
render(
|
||||
<DateQuestionSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("question-summary-header")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.user")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.response")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.time")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders date responses correctly", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Date Question" },
|
||||
samples: [
|
||||
{
|
||||
id: "response1",
|
||||
value: "2023-01-01",
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: { id: "contact1" },
|
||||
contactAttributes: {},
|
||||
},
|
||||
],
|
||||
} as unknown as TSurveyQuestionSummaryDate;
|
||||
|
||||
render(
|
||||
<DateQuestionSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("January 1st, 2023")).toBeInTheDocument();
|
||||
expect(screen.getByText("contact@example.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("2 hours ago")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders invalid dates with special message", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Date Question" },
|
||||
samples: [
|
||||
{
|
||||
id: "response1",
|
||||
value: "invalid-date",
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: { id: "contact1" },
|
||||
contactAttributes: {},
|
||||
},
|
||||
],
|
||||
} as unknown as TSurveyQuestionSummaryDate;
|
||||
|
||||
render(
|
||||
<DateQuestionSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("common.invalid_date(invalid-date)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders anonymous user when no contact is provided", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Date Question" },
|
||||
samples: [
|
||||
{
|
||||
id: "response1",
|
||||
value: "2023-01-01",
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
},
|
||||
],
|
||||
} as unknown as TSurveyQuestionSummaryDate;
|
||||
|
||||
render(
|
||||
<DateQuestionSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("common.anonymous")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows load more button when there are more responses and loads more on click", async () => {
|
||||
const samples = Array.from({ length: 15 }, (_, i) => ({
|
||||
id: `response${i}`,
|
||||
value: "2023-01-01",
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
}));
|
||||
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Date Question" },
|
||||
samples,
|
||||
} as unknown as TSurveyQuestionSummaryDate;
|
||||
|
||||
render(
|
||||
<DateQuestionSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
// Initially 10 responses should be visible
|
||||
expect(screen.getAllByText("January 1st, 2023")).toHaveLength(10);
|
||||
|
||||
// "Load More" button should be visible
|
||||
const loadMoreButton = screen.getByTestId("load-more-button");
|
||||
expect(loadMoreButton).toBeInTheDocument();
|
||||
|
||||
// Click "Load More"
|
||||
await userEvent.click(loadMoreButton);
|
||||
|
||||
// Now all 15 responses should be visible
|
||||
expect(screen.getAllByText("January 1st, 2023")).toHaveLength(15);
|
||||
|
||||
// "Load More" button should disappear
|
||||
expect(screen.queryByTestId("load-more-button")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -46,7 +46,7 @@ export const DateQuestionSummary = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
|
||||
<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">
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { generateInsightsForSurveyAction } from "@/modules/ee/insights/actions";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { SparklesIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
interface EnableInsightsBannerProps {
|
||||
surveyId: string;
|
||||
maxResponseCount: number;
|
||||
surveyResponseCount: number;
|
||||
}
|
||||
|
||||
export const EnableInsightsBanner = ({
|
||||
surveyId,
|
||||
surveyResponseCount,
|
||||
maxResponseCount,
|
||||
}: EnableInsightsBannerProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [isGeneratingInsights, setIsGeneratingInsights] = useState(false);
|
||||
|
||||
const handleInsightGeneration = async () => {
|
||||
toast.success("Generating insights for this survey. Please check back in a few minutes.", {
|
||||
duration: 3000,
|
||||
});
|
||||
setIsGeneratingInsights(true);
|
||||
toast.success(t("environments.surveys.summary.enable_ai_insights_banner_success"));
|
||||
generateInsightsForSurveyAction({ surveyId });
|
||||
};
|
||||
|
||||
if (isGeneratingInsights) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert className="mb-6 mt-4 flex items-center gap-4 border-slate-400 bg-white">
|
||||
<div>
|
||||
<SparklesIcon strokeWidth={1.5} className="size-7 text-slate-700" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<AlertTitle>
|
||||
<span className="mr-2">{t("environments.surveys.summary.enable_ai_insights_banner_title")}</span>
|
||||
<Badge type="gray" size="normal" text="Beta" />
|
||||
</AlertTitle>
|
||||
<AlertDescription className="flex items-start justify-between gap-4">
|
||||
{t("environments.surveys.summary.enable_ai_insights_banner_description")}
|
||||
</AlertDescription>
|
||||
</div>
|
||||
<TooltipRenderer
|
||||
tooltipContent={
|
||||
surveyResponseCount > maxResponseCount
|
||||
? t("environments.surveys.summary.enable_ai_insights_banner_tooltip")
|
||||
: undefined
|
||||
}>
|
||||
<Button
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={handleInsightGeneration}
|
||||
loading={isGeneratingInsights}
|
||||
disabled={surveyResponseCount > maxResponseCount}>
|
||||
{t("environments.surveys.summary.enable_ai_insights_banner_button")}
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
-231
@@ -1,231 +0,0 @@
|
||||
import { FileUploadSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyFileUploadQuestion,
|
||||
TSurveyQuestionSummaryFileUpload,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
|
||||
// Mock child components and hooks
|
||||
vi.mock("@/modules/ui/components/avatars", () => ({
|
||||
PersonAvatar: vi.fn(() => <div>PersonAvatarMock</div>),
|
||||
}));
|
||||
|
||||
vi.mock("./QuestionSummaryHeader", () => ({
|
||||
QuestionSummaryHeader: vi.fn(() => <div>QuestionSummaryHeaderMock</div>),
|
||||
}));
|
||||
|
||||
// Mock utility functions
|
||||
vi.mock("@/lib/storage/utils", () => ({
|
||||
getOriginalFileNameFromUrl: (url: string) => `original-${url.split("/").pop()}`,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/time", () => ({
|
||||
timeSince: () => "some time ago",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/contact", () => ({
|
||||
getContactIdentifier: () => "contact@example.com",
|
||||
}));
|
||||
|
||||
const environmentId = "test-env-id";
|
||||
const survey = { id: "survey-1" } as TSurvey;
|
||||
const locale = "en-US";
|
||||
|
||||
const createMockResponse = (id: string, value: string[], contactId: string | null = null) => ({
|
||||
id: `response-${id}`,
|
||||
value,
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: contactId ? { id: contactId, name: `Contact ${contactId}` } : null,
|
||||
contactAttributes: contactId ? { email: `contact${contactId}@example.com` } : {},
|
||||
});
|
||||
|
||||
const questionSummaryBase = {
|
||||
question: {
|
||||
id: "q1",
|
||||
headline: { default: "Upload your file" },
|
||||
type: TSurveyQuestionTypeEnum.FileUpload,
|
||||
} as unknown as TSurveyFileUploadQuestion,
|
||||
responseCount: 0,
|
||||
files: [],
|
||||
} as unknown as TSurveyQuestionSummaryFileUpload;
|
||||
|
||||
describe("FileUploadSummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders the component with initial responses", () => {
|
||||
const files = Array.from({ length: 5 }, (_, i) =>
|
||||
createMockResponse(i.toString(), [`https://example.com/file${i}.pdf`], `contact-${i}`)
|
||||
);
|
||||
const questionSummary = {
|
||||
...questionSummaryBase,
|
||||
files,
|
||||
responseCount: files.length,
|
||||
} as unknown as TSurveyQuestionSummaryFileUpload;
|
||||
|
||||
render(
|
||||
<FileUploadSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("QuestionSummaryHeaderMock")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.user")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.response")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.time")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("PersonAvatarMock")).toHaveLength(5);
|
||||
expect(screen.getAllByText("contact@example.com")).toHaveLength(5);
|
||||
expect(screen.getByText("original-file0.pdf")).toBeInTheDocument();
|
||||
expect(screen.getByText("original-file4.pdf")).toBeInTheDocument();
|
||||
expect(screen.queryByText("common.load_more")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders 'Skipped' when value is an empty array", () => {
|
||||
const files = [createMockResponse("skipped", [], "contact-skipped")];
|
||||
const questionSummary = {
|
||||
...questionSummaryBase,
|
||||
files,
|
||||
responseCount: files.length,
|
||||
} as unknown as TSurveyQuestionSummaryFileUpload;
|
||||
|
||||
render(
|
||||
<FileUploadSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("common.skipped")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/original-/)).not.toBeInTheDocument(); // No file name should be rendered
|
||||
});
|
||||
|
||||
test("renders 'Anonymous' when contact is null", () => {
|
||||
const files = [createMockResponse("anon", ["https://example.com/anonfile.jpg"], null)];
|
||||
const questionSummary = {
|
||||
...questionSummaryBase,
|
||||
files,
|
||||
responseCount: files.length,
|
||||
} as unknown as TSurveyQuestionSummaryFileUpload;
|
||||
|
||||
render(
|
||||
<FileUploadSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("common.anonymous")).toBeInTheDocument();
|
||||
expect(screen.getByText("original-anonfile.jpg")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows 'Load More' button when there are more than 10 responses and loads more on click", async () => {
|
||||
const files = Array.from({ length: 15 }, (_, i) =>
|
||||
createMockResponse(i.toString(), [`https://example.com/file${i}.txt`], `contact-${i}`)
|
||||
);
|
||||
const questionSummary = {
|
||||
...questionSummaryBase,
|
||||
files,
|
||||
responseCount: files.length,
|
||||
} as unknown as TSurveyQuestionSummaryFileUpload;
|
||||
|
||||
render(
|
||||
<FileUploadSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
// Initially 10 responses should be visible
|
||||
expect(screen.getAllByText("PersonAvatarMock")).toHaveLength(10);
|
||||
expect(screen.getByText("original-file9.txt")).toBeInTheDocument();
|
||||
expect(screen.queryByText("original-file10.txt")).not.toBeInTheDocument();
|
||||
|
||||
// "Load More" button should be visible
|
||||
const loadMoreButton = screen.getByText("common.load_more");
|
||||
expect(loadMoreButton).toBeInTheDocument();
|
||||
|
||||
// Click "Load More"
|
||||
await userEvent.click(loadMoreButton);
|
||||
|
||||
// Now all 15 responses should be visible
|
||||
expect(screen.getAllByText("PersonAvatarMock")).toHaveLength(15);
|
||||
expect(screen.getByText("original-file14.txt")).toBeInTheDocument();
|
||||
|
||||
// "Load More" button should disappear
|
||||
expect(screen.queryByText("common.load_more")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders multiple files for a single response", () => {
|
||||
const files = [
|
||||
createMockResponse(
|
||||
"multi",
|
||||
["https://example.com/fileA.png", "https://example.com/fileB.docx"],
|
||||
"contact-multi"
|
||||
),
|
||||
];
|
||||
const questionSummary = {
|
||||
...questionSummaryBase,
|
||||
files,
|
||||
responseCount: files.length,
|
||||
} as unknown as TSurveyQuestionSummaryFileUpload;
|
||||
|
||||
render(
|
||||
<FileUploadSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("original-fileA.png")).toBeInTheDocument();
|
||||
expect(screen.getByText("original-fileB.docx")).toBeInTheDocument();
|
||||
// Check that download links exist
|
||||
const links = screen.getAllByRole("link");
|
||||
// 1 contact link + 2 file links
|
||||
expect(links.filter((link) => link.getAttribute("target") === "_blank")).toHaveLength(2);
|
||||
expect(
|
||||
links.find((link) => link.getAttribute("href") === "https://example.com/fileA.png")
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
links.find((link) => link.getAttribute("href") === "https://example.com/fileB.docx")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders contact link correctly", () => {
|
||||
const contactId = "contact-link-test";
|
||||
const files = [createMockResponse("link", ["https://example.com/link.pdf"], contactId)];
|
||||
const questionSummary = {
|
||||
...questionSummaryBase,
|
||||
files,
|
||||
responseCount: files.length,
|
||||
} as unknown as TSurveyQuestionSummaryFileUpload;
|
||||
|
||||
render(
|
||||
<FileUploadSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
const contactLink = screen.getByText("contact@example.com").closest("a");
|
||||
expect(contactLink).toBeInTheDocument();
|
||||
expect(contactLink).toHaveAttribute("href", `/environments/${environmentId}/contacts/${contactId}`);
|
||||
});
|
||||
});
|
||||
+3
-3
@@ -36,7 +36,7 @@ export const FileUploadSummary = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
|
||||
<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">
|
||||
@@ -74,12 +74,12 @@ export const FileUploadSummary = ({
|
||||
<div className="col-span-2 grid">
|
||||
{Array.isArray(response.value) &&
|
||||
(response.value.length > 0 ? (
|
||||
response.value.map((fileUrl) => {
|
||||
response.value.map((fileUrl, index) => {
|
||||
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">
|
||||
<a href={fileUrl} key={index} target="_blank" rel="noopener noreferrer">
|
||||
<div className="absolute top-0 right-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" />
|
||||
|
||||
-183
@@ -1,183 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurveyQuestionSummaryHiddenFields } from "@formbricks/types/surveys/types";
|
||||
import { HiddenFieldsSummary } from "./HiddenFieldsSummary";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/time", () => ({
|
||||
timeSince: () => "2 hours ago",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/contact", () => ({
|
||||
getContactIdentifier: () => "contact@example.com",
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/avatars", () => ({
|
||||
PersonAvatar: ({ personId }: { personId: string }) => <div data-testid="person-avatar">{personId}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick }: { children: React.ReactNode; onClick: () => void }) => (
|
||||
<button onClick={onClick} data-testid="load-more-button">
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock lucide-react components
|
||||
vi.mock("lucide-react", () => ({
|
||||
InboxIcon: () => <div data-testid="inbox-icon" />,
|
||||
MessageSquareTextIcon: () => <div data-testid="message-icon" />,
|
||||
Link: ({ children, href, className }: { children: React.ReactNode; href: string; className: string }) => (
|
||||
<a href={href} className={className} data-testid="lucide-link">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock Next.js Link
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
|
||||
<a href={href} data-testid="next-link">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("HiddenFieldsSummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const environment = { id: "env-123" } as TEnvironment;
|
||||
const locale = "en-US";
|
||||
|
||||
test("renders component with correct header and single response", () => {
|
||||
const questionSummary = {
|
||||
id: "hidden-field-1",
|
||||
responseCount: 1,
|
||||
samples: [
|
||||
{
|
||||
id: "response1",
|
||||
value: "Hidden value",
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: { id: "contact1" },
|
||||
contactAttributes: {},
|
||||
},
|
||||
],
|
||||
} as unknown as TSurveyQuestionSummaryHiddenFields;
|
||||
|
||||
render(
|
||||
<HiddenFieldsSummary environment={environment} questionSummary={questionSummary} locale={locale} />
|
||||
);
|
||||
|
||||
expect(screen.getByText("hidden-field-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Hidden Field")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 common.response")).toBeInTheDocument();
|
||||
|
||||
// Headers
|
||||
expect(screen.getByText("common.user")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.response")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.time")).toBeInTheDocument();
|
||||
|
||||
// We can skip checking for PersonAvatar as it's inside hidden md:flex
|
||||
expect(screen.getByText("contact@example.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("Hidden value")).toBeInTheDocument();
|
||||
expect(screen.getByText("2 hours ago")).toBeInTheDocument();
|
||||
|
||||
// Check for link without checking for specific href
|
||||
expect(screen.getByText("contact@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders anonymous user when no contact is provided", () => {
|
||||
const questionSummary = {
|
||||
id: "hidden-field-1",
|
||||
responseCount: 1,
|
||||
samples: [
|
||||
{
|
||||
id: "response1",
|
||||
value: "Anonymous hidden value",
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
},
|
||||
],
|
||||
} as unknown as TSurveyQuestionSummaryHiddenFields;
|
||||
|
||||
render(
|
||||
<HiddenFieldsSummary environment={environment} questionSummary={questionSummary} locale={locale} />
|
||||
);
|
||||
|
||||
// Instead of checking for avatar, just check for anonymous text
|
||||
expect(screen.getByText("common.anonymous")).toBeInTheDocument();
|
||||
expect(screen.getByText("Anonymous hidden value")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders plural response label when multiple responses", () => {
|
||||
const questionSummary = {
|
||||
id: "hidden-field-1",
|
||||
responseCount: 2,
|
||||
samples: [
|
||||
{
|
||||
id: "response1",
|
||||
value: "Hidden value 1",
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: { id: "contact1" },
|
||||
contactAttributes: {},
|
||||
},
|
||||
{
|
||||
id: "response2",
|
||||
value: "Hidden value 2",
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: { id: "contact2" },
|
||||
contactAttributes: {},
|
||||
},
|
||||
],
|
||||
} as unknown as TSurveyQuestionSummaryHiddenFields;
|
||||
|
||||
render(
|
||||
<HiddenFieldsSummary environment={environment} questionSummary={questionSummary} locale={locale} />
|
||||
);
|
||||
|
||||
expect(screen.getByText("2 common.responses")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("contact@example.com")).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("shows load more button when there are more responses and loads more on click", async () => {
|
||||
const samples = Array.from({ length: 15 }, (_, i) => ({
|
||||
id: `response${i}`,
|
||||
value: `Hidden value ${i}`,
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
}));
|
||||
|
||||
const questionSummary = {
|
||||
id: "hidden-field-1",
|
||||
responseCount: samples.length,
|
||||
samples,
|
||||
} as unknown as TSurveyQuestionSummaryHiddenFields;
|
||||
|
||||
render(
|
||||
<HiddenFieldsSummary environment={environment} questionSummary={questionSummary} locale={locale} />
|
||||
);
|
||||
|
||||
// Initially 10 responses should be visible
|
||||
expect(screen.getAllByText(/Hidden value \d+/)).toHaveLength(10);
|
||||
|
||||
// "Load More" button should be visible
|
||||
const loadMoreButton = screen.getByTestId("load-more-button");
|
||||
expect(loadMoreButton).toBeInTheDocument();
|
||||
|
||||
// Click "Load More"
|
||||
await userEvent.click(loadMoreButton);
|
||||
|
||||
// Now all 15 responses should be visible
|
||||
expect(screen.getAllByText(/Hidden value \d+/)).toHaveLength(15);
|
||||
|
||||
// "Load More" button should disappear
|
||||
expect(screen.queryByTestId("load-more-button")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -27,7 +27,7 @@ export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: Hi
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
|
||||
<div className="space-y-2 px-4 pt-6 pb-5 md:px-6">
|
||||
<div className={"align-center flex justify-between gap-4"}>
|
||||
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">{questionSummary.id}</h3>
|
||||
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { MatrixQuestionSummary } from "./MatrixQuestionSummary";
|
||||
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/QuestionSummaryHeader",
|
||||
() => ({
|
||||
QuestionSummaryHeader: () => <div>QuestionSummaryHeader</div>,
|
||||
})
|
||||
);
|
||||
|
||||
describe("MatrixQuestionSummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const survey = { id: "s1" } as any;
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Q Head", type: "matrix" },
|
||||
data: [
|
||||
{
|
||||
rowLabel: "Row1",
|
||||
totalResponsesForRow: 10,
|
||||
columnPercentages: [
|
||||
{ column: "Yes", percentage: 50 },
|
||||
{ column: "No", percentage: 50 },
|
||||
],
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
test("renders headers and buttons, click triggers setFilter", async () => {
|
||||
const setFilter = vi.fn();
|
||||
render(<MatrixQuestionSummary questionSummary={questionSummary} survey={survey} setFilter={setFilter} />);
|
||||
|
||||
// column headers
|
||||
expect(screen.getByText("Yes")).toBeInTheDocument();
|
||||
expect(screen.getByText("No")).toBeInTheDocument();
|
||||
// row label
|
||||
expect(screen.getByText("Row1")).toBeInTheDocument();
|
||||
// buttons
|
||||
const btn = screen.getAllByRole("button", { name: /50/ });
|
||||
await userEvent.click(btn[0]);
|
||||
expect(setFilter).toHaveBeenCalledWith("q1", "Q Head", "matrix", "Row1", "Yes");
|
||||
});
|
||||
});
|
||||
+4
-4
@@ -45,7 +45,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
|
||||
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||
<div className="overflow-x-auto p-6">
|
||||
{/* Summary Table */}
|
||||
@@ -81,9 +81,9 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
|
||||
percentage,
|
||||
questionSummary.data[rowIndex].totalResponsesForRow
|
||||
)}>
|
||||
<button
|
||||
<div
|
||||
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-sm p-4 text-sm text-slate-950 hover:outline"
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
questionSummary.question.id,
|
||||
@@ -94,7 +94,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
|
||||
)
|
||||
}>
|
||||
{percentage}
|
||||
</button>
|
||||
</div>
|
||||
</TooltipRenderer>
|
||||
</td>
|
||||
))}
|
||||
|
||||
-275
@@ -1,275 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { MultipleChoiceSummary } from "./MultipleChoiceSummary";
|
||||
|
||||
vi.mock("@/modules/ui/components/avatars", () => ({
|
||||
PersonAvatar: ({ personId }: any) => <div data-testid="avatar">{personId}</div>,
|
||||
}));
|
||||
vi.mock("./QuestionSummaryHeader", () => ({ QuestionSummaryHeader: () => <div data-testid="header" /> }));
|
||||
|
||||
describe("MultipleChoiceSummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const baseSurvey = { id: "s1" } as any;
|
||||
const envId = "env";
|
||||
|
||||
test("renders header and choice button", async () => {
|
||||
const setFilter = vi.fn();
|
||||
const q = {
|
||||
question: {
|
||||
id: "q",
|
||||
headline: "H",
|
||||
type: "multipleChoiceSingle",
|
||||
choices: [{ id: "c", label: { default: "C" } }],
|
||||
},
|
||||
choices: { C: { value: "C", count: 1, percentage: 100, others: [] } },
|
||||
type: "multipleChoiceSingle",
|
||||
selectionCount: 0,
|
||||
} as any;
|
||||
render(
|
||||
<MultipleChoiceSummary
|
||||
questionSummary={q}
|
||||
environmentId={envId}
|
||||
surveyType="link"
|
||||
survey={baseSurvey}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("header")).toBeDefined();
|
||||
const btn = screen.getByText("1 - C");
|
||||
await userEvent.click(btn);
|
||||
expect(setFilter).toHaveBeenCalledWith(
|
||||
"q",
|
||||
"H",
|
||||
"multipleChoiceSingle",
|
||||
"environments.surveys.summary.includes_either",
|
||||
["C"]
|
||||
);
|
||||
});
|
||||
|
||||
test("renders others and load more for link", async () => {
|
||||
const setFilter = vi.fn();
|
||||
const others = Array.from({ length: 12 }, (_, i) => ({
|
||||
value: `O${i}`,
|
||||
contact: { id: `id${i}` },
|
||||
contactAttributes: {},
|
||||
}));
|
||||
const q = {
|
||||
question: {
|
||||
id: "q2",
|
||||
headline: "H2",
|
||||
type: "multipleChoiceMulti",
|
||||
choices: [{ id: "c2", label: { default: "X" } }],
|
||||
},
|
||||
choices: { X: { value: "X", count: 0, percentage: 0, others } },
|
||||
type: "multipleChoiceMulti",
|
||||
selectionCount: 5,
|
||||
} as any;
|
||||
render(
|
||||
<MultipleChoiceSummary
|
||||
questionSummary={q}
|
||||
environmentId={envId}
|
||||
surveyType="link"
|
||||
survey={baseSurvey}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("environments.surveys.summary.other_values_found")).toBeDefined();
|
||||
expect(screen.getAllByText(/^O/)).toHaveLength(10);
|
||||
await userEvent.click(screen.getByText("common.load_more"));
|
||||
expect(screen.getAllByText(/^O/)).toHaveLength(12);
|
||||
});
|
||||
|
||||
test("renders others with avatar for app", () => {
|
||||
const setFilter = vi.fn();
|
||||
const others = [{ value: "Val", contact: { id: "uid" }, contactAttributes: {} }];
|
||||
const q = {
|
||||
question: {
|
||||
id: "q3",
|
||||
headline: "H3",
|
||||
type: "multipleChoiceMulti",
|
||||
choices: [{ id: "c3", label: { default: "L" } }],
|
||||
},
|
||||
choices: { L: { value: "L", count: 0, percentage: 0, others } },
|
||||
type: "multipleChoiceMulti",
|
||||
selectionCount: 1,
|
||||
} as any;
|
||||
render(
|
||||
<MultipleChoiceSummary
|
||||
questionSummary={q}
|
||||
environmentId={envId}
|
||||
surveyType="app"
|
||||
survey={baseSurvey}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("avatar")).toBeDefined();
|
||||
expect(screen.getByText("Val")).toBeDefined();
|
||||
});
|
||||
|
||||
test("places choice without others before one with others", () => {
|
||||
const setFilter = vi.fn();
|
||||
const choices = {
|
||||
A: { value: "A", count: 0, percentage: 0, others: [] },
|
||||
B: { value: "B", count: 0, percentage: 0, others: [{ value: "x" }] },
|
||||
};
|
||||
render(
|
||||
<MultipleChoiceSummary
|
||||
questionSummary={
|
||||
{
|
||||
question: { id: "q", headline: "", type: "multipleChoiceSingle", choices: [] },
|
||||
choices,
|
||||
type: "multipleChoiceSingle",
|
||||
selectionCount: 0,
|
||||
} as any
|
||||
}
|
||||
environmentId="e"
|
||||
surveyType="link"
|
||||
survey={{} as any}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
const btns = screen.getAllByRole("button");
|
||||
expect(btns[0]).toHaveTextContent("2 - A");
|
||||
expect(btns[1]).toHaveTextContent("1 - B");
|
||||
});
|
||||
|
||||
test("sorts by count when neither has others", () => {
|
||||
const setFilter = vi.fn();
|
||||
const choices = {
|
||||
X: { value: "X", count: 1, percentage: 50, others: [] },
|
||||
Y: { value: "Y", count: 2, percentage: 50, others: [] },
|
||||
};
|
||||
render(
|
||||
<MultipleChoiceSummary
|
||||
questionSummary={
|
||||
{
|
||||
question: { id: "q", headline: "", type: "multipleChoiceSingle", choices: [] },
|
||||
choices,
|
||||
type: "multipleChoiceSingle",
|
||||
selectionCount: 0,
|
||||
} as any
|
||||
}
|
||||
environmentId="e"
|
||||
surveyType="link"
|
||||
survey={{} as any}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
const btns = screen.getAllByRole("button");
|
||||
expect(btns[0]).toHaveTextContent("2 - Y50%2 common.selections");
|
||||
expect(btns[1]).toHaveTextContent("1 - X50%1 common.selection");
|
||||
});
|
||||
|
||||
test("places choice with others after one without when reversed inputs", () => {
|
||||
const setFilter = vi.fn();
|
||||
const choices = {
|
||||
C: { value: "C", count: 1, percentage: 0, others: [{ value: "z" }] },
|
||||
D: { value: "D", count: 1, percentage: 0, others: [] },
|
||||
};
|
||||
render(
|
||||
<MultipleChoiceSummary
|
||||
questionSummary={
|
||||
{
|
||||
question: { id: "q", headline: "", type: "multipleChoiceSingle", choices: [] },
|
||||
choices,
|
||||
type: "multipleChoiceSingle",
|
||||
selectionCount: 0,
|
||||
} as any
|
||||
}
|
||||
environmentId="e"
|
||||
surveyType="link"
|
||||
survey={{} as any}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
const btns = screen.getAllByRole("button");
|
||||
expect(btns[0]).toHaveTextContent("2 - D");
|
||||
expect(btns[1]).toHaveTextContent("1 - C");
|
||||
});
|
||||
|
||||
test("multi type non-other uses includes_all", async () => {
|
||||
const setFilter = vi.fn();
|
||||
const q = {
|
||||
question: {
|
||||
id: "q4",
|
||||
headline: "H4",
|
||||
type: "multipleChoiceMulti",
|
||||
choices: [
|
||||
{ id: "other", label: { default: "O" } },
|
||||
{ id: "c4", label: { default: "C4" } },
|
||||
],
|
||||
},
|
||||
choices: {
|
||||
O: { value: "O", count: 1, percentage: 10, others: [] },
|
||||
C4: { value: "C4", count: 2, percentage: 20, others: [] },
|
||||
},
|
||||
type: "multipleChoiceMulti",
|
||||
selectionCount: 0,
|
||||
} as any;
|
||||
|
||||
render(
|
||||
<MultipleChoiceSummary
|
||||
questionSummary={q}
|
||||
environmentId={envId}
|
||||
surveyType="link"
|
||||
survey={baseSurvey}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
|
||||
const btn = screen.getByText("2 - C4");
|
||||
await userEvent.click(btn);
|
||||
expect(setFilter).toHaveBeenCalledWith(
|
||||
"q4",
|
||||
"H4",
|
||||
"multipleChoiceMulti",
|
||||
"environments.surveys.summary.includes_all",
|
||||
["C4"]
|
||||
);
|
||||
});
|
||||
|
||||
test("multi type other uses includes_either", async () => {
|
||||
const setFilter = vi.fn();
|
||||
const q = {
|
||||
question: {
|
||||
id: "q5",
|
||||
headline: "H5",
|
||||
type: "multipleChoiceMulti",
|
||||
choices: [
|
||||
{ id: "other", label: { default: "O5" } },
|
||||
{ id: "c5", label: { default: "C5" } },
|
||||
],
|
||||
},
|
||||
choices: {
|
||||
O5: { value: "O5", count: 1, percentage: 10, others: [] },
|
||||
C5: { value: "C5", count: 0, percentage: 0, others: [] },
|
||||
},
|
||||
type: "multipleChoiceMulti",
|
||||
selectionCount: 0,
|
||||
} as any;
|
||||
|
||||
render(
|
||||
<MultipleChoiceSummary
|
||||
questionSummary={q}
|
||||
environmentId={envId}
|
||||
surveyType="link"
|
||||
survey={baseSurvey}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
|
||||
const btn = screen.getByText("2 - O5");
|
||||
await userEvent.click(btn);
|
||||
expect(setFilter).toHaveBeenCalledWith(
|
||||
"q5",
|
||||
"H5",
|
||||
"multipleChoiceMulti",
|
||||
"environments.surveys.summary.includes_either",
|
||||
["O5"]
|
||||
);
|
||||
});
|
||||
});
|
||||
+42
-45
@@ -7,7 +7,7 @@ import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { InboxIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Fragment, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
@@ -45,15 +45,10 @@ export const MultipleChoiceSummary = ({
|
||||
const otherValue = questionSummary.question.choices.find((choice) => choice.id === "other")?.label.default;
|
||||
// sort by count and transform to array
|
||||
const results = Object.values(questionSummary.choices).sort((a, b) => {
|
||||
const aHasOthers = (a.others?.length ?? 0) > 0;
|
||||
const bHasOthers = (b.others?.length ?? 0) > 0;
|
||||
if (a.others) return 1; // Always put a after b if a has 'others'
|
||||
if (b.others) return -1; // Always put b after a if b has 'others'
|
||||
|
||||
// if one has “others” and the other doesn’t, push the one with others to the end
|
||||
if (aHasOthers && !bHasOthers) return 1;
|
||||
if (!aHasOthers && bHasOthers) return -1;
|
||||
|
||||
// if they’re “tied” on having others, fall back to count
|
||||
return b.count - a.count;
|
||||
return b.count - a.count; // Sort by count
|
||||
});
|
||||
|
||||
const handleLoadMore = (e: React.MouseEvent) => {
|
||||
@@ -70,7 +65,7 @@ export const MultipleChoiceSummary = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
|
||||
<QuestionSummaryHeader
|
||||
questionSummary={questionSummary}
|
||||
survey={survey}
|
||||
@@ -85,41 +80,40 @@ export const MultipleChoiceSummary = ({
|
||||
/>
|
||||
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
{results.map((result, resultsIdx) => (
|
||||
<Fragment key={result.value}>
|
||||
<button
|
||||
className="group w-full cursor-pointer"
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
questionSummary.question.id,
|
||||
questionSummary.question.headline,
|
||||
questionSummary.question.type,
|
||||
questionSummary.type === "multipleChoiceSingle" || otherValue === result.value
|
||||
? t("environments.surveys.summary.includes_either")
|
||||
: t("environments.surveys.summary.includes_all"),
|
||||
[result.value]
|
||||
)
|
||||
}>
|
||||
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
|
||||
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
|
||||
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
|
||||
{results.length - resultsIdx} - {result.value}
|
||||
</p>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(result.percentage, 2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<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")}
|
||||
<div
|
||||
key={result.value}
|
||||
className="group cursor-pointer"
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
questionSummary.question.id,
|
||||
questionSummary.question.headline,
|
||||
questionSummary.question.type,
|
||||
questionSummary.type === "multipleChoiceSingle" || otherValue === result.value
|
||||
? t("environments.surveys.summary.includes_either")
|
||||
: t("environments.surveys.summary.includes_all"),
|
||||
[result.value]
|
||||
)
|
||||
}>
|
||||
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
|
||||
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
|
||||
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
|
||||
{results.length - resultsIdx} - {result.value}
|
||||
</p>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(result.percentage, 2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="group-hover:opacity-80">
|
||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
||||
</div>
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
<div className="group-hover:opacity-80">
|
||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
||||
</div>
|
||||
{result.others && result.others.length > 0 && (
|
||||
<div className="mt-4 rounded-lg border border-slate-200">
|
||||
<div className="mt-4 rounded-lg border border-slate-200" onClick={(e) => e.stopPropagation()}>
|
||||
<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")}
|
||||
@@ -130,9 +124,11 @@ export const MultipleChoiceSummary = ({
|
||||
.filter((otherValue) => otherValue.value !== "")
|
||||
.slice(0, visibleOtherResponses)
|
||||
.map((otherValue, idx) => (
|
||||
<div key={`${idx}-${otherValue}`} dir="auto">
|
||||
<div key={idx} 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">
|
||||
<div
|
||||
key={idx}
|
||||
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>
|
||||
)}
|
||||
@@ -143,6 +139,7 @@ export const MultipleChoiceSummary = ({
|
||||
? `/environments/${environmentId}/contacts/${otherValue.contact.id}`
|
||||
: { pathname: null }
|
||||
}
|
||||
key={idx}
|
||||
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>
|
||||
@@ -166,7 +163,7 @@ export const MultipleChoiceSummary = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurveyQuestionSummaryNps } from "@formbricks/types/surveys/types";
|
||||
import { NPSSummary } from "./NPSSummary";
|
||||
|
||||
vi.mock("@/modules/ui/components/progress-bar", () => ({
|
||||
ProgressBar: ({ progress, barColor }: { progress: number; barColor: string }) => (
|
||||
<div data-testid="progress-bar">{`${progress}-${barColor}`}</div>
|
||||
),
|
||||
HalfCircle: ({ value }: { value: number }) => <div data-testid="half-circle">{value}</div>,
|
||||
}));
|
||||
vi.mock("./QuestionSummaryHeader", () => ({
|
||||
QuestionSummaryHeader: () => <div data-testid="question-summary-header" />,
|
||||
}));
|
||||
|
||||
describe("NPSSummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const baseQuestion = { id: "q1", headline: "Question?", type: "nps" as const };
|
||||
const summary = {
|
||||
question: baseQuestion,
|
||||
promoters: { count: 2, percentage: 50 },
|
||||
passives: { count: 1, percentage: 25 },
|
||||
detractors: { count: 1, percentage: 25 },
|
||||
dismissed: { count: 0, percentage: 0 },
|
||||
score: 25,
|
||||
} as unknown as TSurveyQuestionSummaryNps;
|
||||
const survey = {} as any;
|
||||
|
||||
test("renders header, groups, ProgressBar and HalfCircle", () => {
|
||||
render(<NPSSummary questionSummary={summary} survey={survey} setFilter={() => {}} />);
|
||||
expect(screen.getByTestId("question-summary-header")).toBeDefined();
|
||||
["promoters", "passives", "detractors", "dismissed"].forEach((g) =>
|
||||
expect(screen.getByText(g)).toBeDefined()
|
||||
);
|
||||
expect(screen.getAllByTestId("progress-bar")[0]).toBeDefined();
|
||||
expect(screen.getByTestId("half-circle")).toHaveTextContent("25");
|
||||
});
|
||||
|
||||
test.each([
|
||||
["promoters", "environments.surveys.summary.includes_either", ["9", "10"]],
|
||||
["passives", "environments.surveys.summary.includes_either", ["7", "8"]],
|
||||
["detractors", "environments.surveys.summary.is_less_than", "7"],
|
||||
["dismissed", "common.skipped", undefined],
|
||||
])("clicking %s calls setFilter correctly", async (group, cmp, vals) => {
|
||||
const setFilter = vi.fn();
|
||||
render(<NPSSummary questionSummary={summary} survey={survey} setFilter={setFilter} />);
|
||||
await userEvent.click(screen.getByText(group));
|
||||
expect(setFilter).toHaveBeenCalledWith(
|
||||
baseQuestion.id,
|
||||
baseQuestion.headline,
|
||||
baseQuestion.type,
|
||||
cmp,
|
||||
vals
|
||||
);
|
||||
});
|
||||
});
|
||||
+3
-6
@@ -60,14 +60,11 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
|
||||
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
|
||||
<button
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
key={group}
|
||||
onClick={() => applyFilter(group)}>
|
||||
<div className="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">
|
||||
@@ -90,7 +87,7 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
|
||||
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
|
||||
progress={questionSummary[group]?.percentage / 100}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
-174
@@ -1,174 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey, TSurveyQuestionSummaryOpenText } from "@formbricks/types/surveys/types";
|
||||
import { OpenTextSummary } from "./OpenTextSummary";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/time", () => ({
|
||||
timeSince: () => "2 hours ago",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/contact", () => ({
|
||||
getContactIdentifier: () => "contact@example.com",
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/analysis/utils", () => ({
|
||||
renderHyperlinkedContent: (text: string) => <div data-testid="hyperlinked-content">{text}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/avatars", () => ({
|
||||
PersonAvatar: ({ personId }: { personId: string }) => <div data-testid="person-avatar">{personId}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick }: { children: React.ReactNode; onClick: () => void }) => (
|
||||
<button onClick={onClick} data-testid="load-more-button">
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/secondary-navigation", () => ({
|
||||
SecondaryNavigation: ({ activeId, navigation }: any) => (
|
||||
<div data-testid="secondary-navigation">
|
||||
{navigation.map((item: any) => (
|
||||
<button key={item.id} onClick={item.onClick} data-active={activeId === item.id}>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/table", () => ({
|
||||
Table: ({ children }: { children: React.ReactNode }) => <table data-testid="table">{children}</table>,
|
||||
TableHeader: ({ children }: { children: React.ReactNode }) => <thead>{children}</thead>,
|
||||
TableBody: ({ children }: { children: React.ReactNode }) => <tbody>{children}</tbody>,
|
||||
TableRow: ({ children }: { children: React.ReactNode }) => <tr>{children}</tr>,
|
||||
TableHead: ({ children }: { children: React.ReactNode }) => <th>{children}</th>,
|
||||
TableCell: ({ children, width }: { children: React.ReactNode; width?: number }) => (
|
||||
<td style={width ? { width } : {}}>{children}</td>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/insights/components/insights-view", () => ({
|
||||
InsightView: () => <div data-testid="insight-view"></div>,
|
||||
}));
|
||||
|
||||
vi.mock("./QuestionSummaryHeader", () => ({
|
||||
QuestionSummaryHeader: ({ additionalInfo }: { additionalInfo?: React.ReactNode }) => (
|
||||
<div data-testid="question-summary-header">{additionalInfo}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("OpenTextSummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const environmentId = "env-123";
|
||||
const survey = { id: "survey-1" } as TSurvey;
|
||||
const locale = "en-US";
|
||||
|
||||
test("renders response mode by default when insights not enabled", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Open Text Question" },
|
||||
samples: [
|
||||
{
|
||||
id: "response1",
|
||||
value: "Sample response text",
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: { id: "contact1" },
|
||||
contactAttributes: {},
|
||||
},
|
||||
],
|
||||
} as unknown as TSurveyQuestionSummaryOpenText;
|
||||
|
||||
render(
|
||||
<OpenTextSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("question-summary-header")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("table")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("person-avatar")).toHaveTextContent("contact1");
|
||||
expect(screen.getByText("contact@example.com")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("hyperlinked-content")).toHaveTextContent("Sample response text");
|
||||
expect(screen.getByText("2 hours ago")).toBeInTheDocument();
|
||||
|
||||
// No secondary navigation when insights not enabled
|
||||
expect(screen.queryByTestId("secondary-navigation")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders anonymous user when no contact is provided", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Open Text Question" },
|
||||
samples: [
|
||||
{
|
||||
id: "response1",
|
||||
value: "Anonymous response",
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
},
|
||||
],
|
||||
} as unknown as TSurveyQuestionSummaryOpenText;
|
||||
|
||||
render(
|
||||
<OpenTextSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("person-avatar")).toHaveTextContent("anonymous");
|
||||
expect(screen.getByText("common.anonymous")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows load more button when there are more responses and loads more on click", async () => {
|
||||
const samples = Array.from({ length: 15 }, (_, i) => ({
|
||||
id: `response${i}`,
|
||||
value: `Response ${i}`,
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
}));
|
||||
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Open Text Question" },
|
||||
samples,
|
||||
} as unknown as TSurveyQuestionSummaryOpenText;
|
||||
|
||||
render(
|
||||
<OpenTextSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
// Initially 10 responses should be visible
|
||||
expect(screen.getAllByTestId("hyperlinked-content")).toHaveLength(10);
|
||||
|
||||
// "Load More" button should be visible
|
||||
const loadMoreButton = screen.getByTestId("load-more-button");
|
||||
expect(loadMoreButton).toBeInTheDocument();
|
||||
|
||||
// Click "Load More"
|
||||
await userEvent.click(loadMoreButton);
|
||||
|
||||
// Now all 15 responses should be visible
|
||||
expect(screen.getAllByTestId("hyperlinked-content")).toHaveLength(15);
|
||||
|
||||
// "Load More" button should disappear
|
||||
expect(screen.queryByTestId("load-more-button")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+111
-54
@@ -3,8 +3,10 @@
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
import { renderHyperlinkedContent } from "@/modules/analysis/utils";
|
||||
import { InsightView } from "@/modules/ee/insights/components/insights-view";
|
||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import Link from "next/link";
|
||||
@@ -17,12 +19,25 @@ interface OpenTextSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryOpenText;
|
||||
environmentId: string;
|
||||
survey: TSurvey;
|
||||
isAIEnabled: boolean;
|
||||
documentsPerPage?: number;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const OpenTextSummary = ({ questionSummary, environmentId, survey, locale }: OpenTextSummaryProps) => {
|
||||
export const OpenTextSummary = ({
|
||||
questionSummary,
|
||||
environmentId,
|
||||
survey,
|
||||
isAIEnabled,
|
||||
documentsPerPage,
|
||||
locale,
|
||||
}: OpenTextSummaryProps) => {
|
||||
const { t } = useTranslate();
|
||||
const isInsightsEnabled = isAIEnabled && questionSummary.insightsEnabled;
|
||||
const [visibleResponses, setVisibleResponses] = useState(10);
|
||||
const [activeTab, setActiveTab] = useState<"insights" | "responses">(
|
||||
isInsightsEnabled && questionSummary.insights.length ? "insights" : "responses"
|
||||
);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
// Increase the number of visible responses by 10, not exceeding the total number of responses
|
||||
@@ -31,62 +46,104 @@ export const OpenTextSummary = ({ questionSummary, environmentId, survey, locale
|
||||
);
|
||||
};
|
||||
|
||||
const tabNavigation = [
|
||||
{
|
||||
id: "insights",
|
||||
label: t("common.insights"),
|
||||
onClick: () => setActiveTab("insights"),
|
||||
},
|
||||
{
|
||||
id: "responses",
|
||||
label: t("common.responses"),
|
||||
onClick: () => setActiveTab("responses"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-xs">
|
||||
<QuestionSummaryHeader
|
||||
questionSummary={questionSummary}
|
||||
survey={survey}
|
||||
additionalInfo={
|
||||
isAIEnabled && questionSummary.insightsEnabled === false ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||
{t("environments.surveys.summary.insights_disabled")}
|
||||
</div>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{isInsightsEnabled && (
|
||||
<div className="ml-4">
|
||||
<SecondaryNavigation activeId={activeTab} navigation={tabNavigation} />
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t border-slate-200"></div>
|
||||
<div className="max-h-[40vh] overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader className="bg-slate-100">
|
||||
<TableRow>
|
||||
<TableHead>{t("common.user")}</TableHead>
|
||||
<TableHead>{t("common.response")}</TableHead>
|
||||
<TableHead>{t("common.time")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{questionSummary.samples.slice(0, visibleResponses).map((response) => (
|
||||
<TableRow key={response.id}>
|
||||
<TableCell>
|
||||
{response.contact ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.contact.id} />
|
||||
</div>
|
||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
</div>
|
||||
<p className="break-normal text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{typeof response.value === "string"
|
||||
? renderHyperlinkedContent(response.value)
|
||||
: response.value}
|
||||
</TableCell>
|
||||
<TableCell width={120}>
|
||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||
</TableCell>
|
||||
</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>
|
||||
)}
|
||||
{activeTab === "insights" ? (
|
||||
<InsightView
|
||||
insights={questionSummary.insights}
|
||||
questionId={questionSummary.question.id}
|
||||
surveyId={survey.id}
|
||||
documentsPerPage={documentsPerPage}
|
||||
locale={locale}
|
||||
/>
|
||||
) : activeTab === "responses" ? (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader className="bg-slate-100">
|
||||
<TableRow>
|
||||
<TableHead>{t("common.user")}</TableHead>
|
||||
<TableHead>{t("common.response")}</TableHead>
|
||||
<TableHead>{t("common.time")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{questionSummary.samples.slice(0, visibleResponses).map((response) => (
|
||||
<TableRow key={response.id}>
|
||||
<TableCell>
|
||||
{response.contact ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.contact.id} />
|
||||
</div>
|
||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
</div>
|
||||
<p className="break-normal text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{typeof response.value === "string"
|
||||
? renderHyperlinkedContent(response.value)
|
||||
: response.value}
|
||||
</TableCell>
|
||||
<TableCell width={120}>
|
||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||
</TableCell>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
-91
@@ -1,91 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { PictureChoiceSummary } from "./PictureChoiceSummary";
|
||||
|
||||
vi.mock("@/modules/ui/components/progress-bar", () => ({
|
||||
ProgressBar: ({ progress }: { progress: number }) => (
|
||||
<div data-testid="progress-bar" data-progress={progress} />
|
||||
),
|
||||
}));
|
||||
vi.mock("./QuestionSummaryHeader", () => ({
|
||||
QuestionSummaryHeader: ({ additionalInfo }: any) => <div data-testid="header">{additionalInfo}</div>,
|
||||
}));
|
||||
|
||||
// mock next image
|
||||
vi.mock("next/image", () => ({
|
||||
__esModule: true,
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
default: ({ src }: { src: string }) => <img src={src} alt="" />,
|
||||
}));
|
||||
|
||||
const survey = {} as TSurvey;
|
||||
|
||||
describe("PictureChoiceSummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders choices with formatted percentages and counts", () => {
|
||||
const choices = [
|
||||
{ id: "1", imageUrl: "img1.png", percentage: 33.3333, count: 1 },
|
||||
{ id: "2", imageUrl: "img2.png", percentage: 66.6667, count: 2 },
|
||||
];
|
||||
const questionSummary = {
|
||||
choices,
|
||||
question: { id: "q1", type: TSurveyQuestionTypeEnum.PictureSelection, headline: "H", allowMulti: true },
|
||||
selectionCount: 3,
|
||||
} as any;
|
||||
render(<PictureChoiceSummary questionSummary={questionSummary} survey={survey} setFilter={() => {}} />);
|
||||
|
||||
expect(screen.getAllByRole("button")).toHaveLength(2);
|
||||
expect(screen.getByText("33.33%")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 common.selection")).toBeInTheDocument();
|
||||
expect(screen.getByText("2 common.selections")).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId("progress-bar")).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("calls setFilter with correct args on click", async () => {
|
||||
const choices = [{ id: "1", imageUrl: "img1.png", percentage: 25, count: 10 }];
|
||||
const questionSummary = {
|
||||
choices,
|
||||
question: {
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.PictureSelection,
|
||||
headline: "H1",
|
||||
allowMulti: true,
|
||||
},
|
||||
selectionCount: 10,
|
||||
} as any;
|
||||
const setFilter = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<PictureChoiceSummary questionSummary={questionSummary} survey={survey} setFilter={setFilter} />);
|
||||
|
||||
await user.click(screen.getByRole("button"));
|
||||
expect(setFilter).toHaveBeenCalledWith(
|
||||
"q1",
|
||||
"H1",
|
||||
TSurveyQuestionTypeEnum.PictureSelection,
|
||||
"environments.surveys.summary.includes_all",
|
||||
["environments.surveys.edit.picture_idx"]
|
||||
);
|
||||
});
|
||||
|
||||
test("hides additionalInfo when allowMulti is false", () => {
|
||||
const choices = [{ id: "1", imageUrl: "img1.png", percentage: 50, count: 5 }];
|
||||
const questionSummary = {
|
||||
choices,
|
||||
question: {
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.PictureSelection,
|
||||
headline: "H2",
|
||||
allowMulti: false,
|
||||
},
|
||||
selectionCount: 5,
|
||||
} as any;
|
||||
render(<PictureChoiceSummary questionSummary={questionSummary} survey={survey} setFilter={() => {}} />);
|
||||
|
||||
expect(screen.getByTestId("header")).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
+4
-4
@@ -30,7 +30,7 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic
|
||||
const results = questionSummary.choices;
|
||||
const { t } = useTranslate();
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
|
||||
<QuestionSummaryHeader
|
||||
questionSummary={questionSummary}
|
||||
survey={survey}
|
||||
@@ -45,8 +45,8 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic
|
||||
/>
|
||||
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
{results.map((result, index) => (
|
||||
<button
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
<div
|
||||
className="cursor-pointer hover:opacity-80"
|
||||
key={result.id}
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
@@ -79,7 +79,7 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100 || 0} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
-164
@@ -1,164 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey, TSurveyQuestionSummary, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/utils/recall", () => ({
|
||||
recallToHeadline: () => ({ default: "Recalled Headline" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/editor/lib/utils", () => ({
|
||||
formatTextWithSlashes: (text: string) => <span data-testid="formatted-headline">{text}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/lib/questions", () => ({
|
||||
getQuestionTypes: () => [
|
||||
{
|
||||
id: "openText",
|
||||
label: "Open Text",
|
||||
icon: () => <div data-testid="question-icon">Icon</div>,
|
||||
},
|
||||
{
|
||||
id: "multipleChoice",
|
||||
label: "Multiple Choice",
|
||||
icon: () => <div data-testid="question-icon">Icon</div>,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/settings-id", () => ({
|
||||
SettingsId: ({ title, id }: { title: string; id: string }) => (
|
||||
<div data-testid="settings-id">
|
||||
{title}: {id}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock InboxIcon
|
||||
vi.mock("lucide-react", () => ({
|
||||
InboxIcon: () => <div data-testid="inbox-icon"></div>,
|
||||
}));
|
||||
|
||||
describe("QuestionSummaryHeader", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const survey = {} as TSurvey;
|
||||
|
||||
test("renders header with question headline and type", () => {
|
||||
const questionSummary = {
|
||||
question: {
|
||||
id: "q1",
|
||||
headline: { default: "Test Question" },
|
||||
type: "openText" as TSurveyQuestionTypeEnum,
|
||||
required: true,
|
||||
},
|
||||
responseCount: 42,
|
||||
} as unknown as TSurveyQuestionSummary;
|
||||
|
||||
render(<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />);
|
||||
|
||||
expect(screen.getByTestId("formatted-headline")).toHaveTextContent("Recalled Headline");
|
||||
|
||||
// Look for text content with a more specific approach
|
||||
const questionTypeElement = screen.getByText((content) => {
|
||||
return content.includes("Open Text") && !content.includes("common.question_id");
|
||||
});
|
||||
expect(questionTypeElement).toBeInTheDocument();
|
||||
|
||||
// Check for responses text specifically
|
||||
expect(
|
||||
screen.getByText((content) => {
|
||||
return content.includes("42") && content.includes("common.responses");
|
||||
})
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByTestId("question-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("settings-id")).toHaveTextContent("common.question_id: q1");
|
||||
expect(screen.queryByText("environments.surveys.edit.optional")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows 'optional' tag when question is not required", () => {
|
||||
const questionSummary = {
|
||||
question: {
|
||||
id: "q2",
|
||||
headline: { default: "Optional Question" },
|
||||
type: "multipleChoice" as TSurveyQuestionTypeEnum,
|
||||
required: false,
|
||||
},
|
||||
responseCount: 10,
|
||||
} as unknown as TSurveyQuestionSummary;
|
||||
|
||||
render(<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />);
|
||||
|
||||
expect(screen.getByText("environments.surveys.edit.optional")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("hides response count when showResponses is false", () => {
|
||||
const questionSummary = {
|
||||
question: {
|
||||
id: "q3",
|
||||
headline: { default: "No Response Count Question" },
|
||||
type: "openText" as TSurveyQuestionTypeEnum,
|
||||
required: true,
|
||||
},
|
||||
responseCount: 15,
|
||||
} as unknown as TSurveyQuestionSummary;
|
||||
|
||||
render(<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} showResponses={false} />);
|
||||
|
||||
expect(
|
||||
screen.queryByText((content) => content.includes("15") && content.includes("common.responses"))
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows unknown question type for unrecognized type", () => {
|
||||
const questionSummary = {
|
||||
question: {
|
||||
id: "q4",
|
||||
headline: { default: "Unknown Type Question" },
|
||||
type: "unknownType" as TSurveyQuestionTypeEnum,
|
||||
required: true,
|
||||
},
|
||||
responseCount: 5,
|
||||
} as unknown as TSurveyQuestionSummary;
|
||||
|
||||
render(<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />);
|
||||
|
||||
// Look for text in the question type element specifically
|
||||
const unknownTypeElement = screen.getByText((content) => {
|
||||
return (
|
||||
content.includes("environments.surveys.summary.unknown_question_type") &&
|
||||
!content.includes("common.question_id")
|
||||
);
|
||||
});
|
||||
expect(unknownTypeElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders additional info when provided", () => {
|
||||
const questionSummary = {
|
||||
question: {
|
||||
id: "q5",
|
||||
headline: { default: "With Additional Info" },
|
||||
type: "openText" as TSurveyQuestionTypeEnum,
|
||||
required: true,
|
||||
},
|
||||
responseCount: 20,
|
||||
} as unknown as TSurveyQuestionSummary;
|
||||
|
||||
const additionalInfo = <div data-testid="additional-info">Extra Information</div>;
|
||||
|
||||
render(
|
||||
<QuestionSummaryHeader
|
||||
questionSummary={questionSummary}
|
||||
survey={survey}
|
||||
additionalInfo={additionalInfo}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("additional-info")).toBeInTheDocument();
|
||||
expect(screen.getByText("Extra Information")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+19
-4
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
|
||||
import { getQuestionTypes } from "@/modules/survey/lib/questions";
|
||||
import { SettingsId } from "@/modules/ui/components/settings-id";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
@@ -24,15 +23,31 @@ export const QuestionSummaryHeader = ({
|
||||
}: HeadProps) => {
|
||||
const { t } = useTranslate();
|
||||
const questionType = getQuestionTypes(t).find((type) => type.id === questionSummary.question.type);
|
||||
// formats the text to highlight specific parts of the text with slashes
|
||||
const formatTextWithSlashes = (text: string): (string | JSX.Element)[] => {
|
||||
const regex = /\/(.*?)\\/g;
|
||||
const parts = text.split(regex);
|
||||
|
||||
return parts.map((part, index) => {
|
||||
// Check if the part was inside slashes
|
||||
if (index % 2 !== 0) {
|
||||
return (
|
||||
<span key={index} className="mx-1 rounded-md bg-slate-100 p-1 px-2 text-lg">
|
||||
@{part}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return part;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 px-4 pt-6 pb-5 md:px-6">
|
||||
<div className={"align-center flex justify-between gap-4"}>
|
||||
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">
|
||||
{formatTextWithSlashes(
|
||||
recallToHeadline(questionSummary.question.headline, survey, true, "default")["default"],
|
||||
"@",
|
||||
["text-lg"]
|
||||
recallToHeadline(questionSummary.question.headline, survey, true, "default")["default"]
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
-104
@@ -1,104 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey, TSurveyQuestionSummaryRanking, TSurveyType } from "@formbricks/types/surveys/types";
|
||||
import { RankingSummary } from "./RankingSummary";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("./QuestionSummaryHeader", () => ({
|
||||
QuestionSummaryHeader: () => <div data-testid="question-summary-header" />,
|
||||
}));
|
||||
|
||||
vi.mock("../lib/utils", () => ({
|
||||
convertFloatToNDecimal: (value: number) => value.toFixed(2),
|
||||
}));
|
||||
|
||||
describe("RankingSummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const survey = {} as TSurvey;
|
||||
const surveyType: TSurveyType = "app";
|
||||
|
||||
test("renders ranking results in correct order", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Rank the following" },
|
||||
choices: {
|
||||
option1: { value: "Option A", avgRanking: 1.5, others: [] },
|
||||
option2: { value: "Option B", avgRanking: 2.3, others: [] },
|
||||
option3: { value: "Option C", avgRanking: 1.2, others: [] },
|
||||
},
|
||||
} as unknown as TSurveyQuestionSummaryRanking;
|
||||
|
||||
render(<RankingSummary questionSummary={questionSummary} survey={survey} surveyType={surveyType} />);
|
||||
|
||||
expect(screen.getByTestId("question-summary-header")).toBeInTheDocument();
|
||||
|
||||
// Check order: should be sorted by avgRanking (ascending)
|
||||
const options = screen.getAllByText(/Option [A-C]/);
|
||||
expect(options[0]).toHaveTextContent("Option C"); // 1.2 (lowest avgRanking first)
|
||||
expect(options[1]).toHaveTextContent("Option A"); // 1.5
|
||||
expect(options[2]).toHaveTextContent("Option B"); // 2.3
|
||||
|
||||
// Check rankings are displayed
|
||||
expect(screen.getByText("#1")).toBeInTheDocument();
|
||||
expect(screen.getByText("#2")).toBeInTheDocument();
|
||||
expect(screen.getByText("#3")).toBeInTheDocument();
|
||||
|
||||
// Check average values are displayed
|
||||
expect(screen.getByText("#1.20")).toBeInTheDocument();
|
||||
expect(screen.getByText("#1.50")).toBeInTheDocument();
|
||||
expect(screen.getByText("#2.30")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders 'other values found' section when others exist", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Rank the following" },
|
||||
choices: {
|
||||
option1: {
|
||||
value: "Option A",
|
||||
avgRanking: 1.0,
|
||||
others: [{ value: "Other value", count: 2 }],
|
||||
},
|
||||
},
|
||||
} as unknown as TSurveyQuestionSummaryRanking;
|
||||
|
||||
render(<RankingSummary questionSummary={questionSummary} survey={survey} surveyType={surveyType} />);
|
||||
|
||||
expect(screen.getByText("environments.surveys.summary.other_values_found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows 'User' column in other values section for app survey type", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Rank the following" },
|
||||
choices: {
|
||||
option1: {
|
||||
value: "Option A",
|
||||
avgRanking: 1.0,
|
||||
others: [{ value: "Other value", count: 1 }],
|
||||
},
|
||||
},
|
||||
} as unknown as TSurveyQuestionSummaryRanking;
|
||||
|
||||
render(<RankingSummary questionSummary={questionSummary} survey={survey} surveyType="app" />);
|
||||
|
||||
expect(screen.getByText("common.user")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("doesn't show 'User' column for link survey type", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Rank the following" },
|
||||
choices: {
|
||||
option1: {
|
||||
value: "Option A",
|
||||
avgRanking: 1.0,
|
||||
others: [{ value: "Other value", count: 1 }],
|
||||
},
|
||||
},
|
||||
} as unknown as TSurveyQuestionSummaryRanking;
|
||||
|
||||
render(<RankingSummary questionSummary={questionSummary} survey={survey} surveyType="link" />);
|
||||
|
||||
expect(screen.queryByText("common.user")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+3
-3
@@ -17,16 +17,16 @@ export const RankingSummary = ({ questionSummary, surveyType, survey }: RankingS
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
|
||||
<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 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
{results.map((result, resultsIdx) => (
|
||||
<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="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
|
||||
<div className="flex w-full items-center">
|
||||
<span className="mr-2 text-slate-400">#{resultsIdx + 1}</span>
|
||||
<div className="rounded bg-slate-100 px-2 py-1">{result.value}</div>
|
||||
<div className="rounded-sm bg-slate-100 px-2 py-1">{result.value}</div>
|
||||
<span className="ml-auto flex items-center space-x-1">
|
||||
<span className="font-bold text-slate-600">
|
||||
#{convertFloatToNDecimal(result.avgRanking, 2)}
|
||||
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurveyQuestionSummaryRating } from "@formbricks/types/surveys/types";
|
||||
import { RatingSummary } from "./RatingSummary";
|
||||
|
||||
vi.mock("./QuestionSummaryHeader", () => ({
|
||||
QuestionSummaryHeader: ({ additionalInfo }: any) => <div data-testid="header">{additionalInfo}</div>,
|
||||
}));
|
||||
|
||||
describe("RatingSummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders overall average and choices", () => {
|
||||
const questionSummary = {
|
||||
question: {
|
||||
id: "q1",
|
||||
scale: "star",
|
||||
headline: "Headline",
|
||||
type: "rating",
|
||||
range: [1, 5],
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
average: 3.1415,
|
||||
choices: [
|
||||
{ rating: 1, percentage: 50, count: 2 },
|
||||
{ rating: 2, percentage: 50, count: 3 },
|
||||
],
|
||||
dismissed: { count: 0 },
|
||||
} as unknown as TSurveyQuestionSummaryRating;
|
||||
const survey = {};
|
||||
const setFilter = vi.fn();
|
||||
render(<RatingSummary questionSummary={questionSummary} survey={survey as any} setFilter={setFilter} />);
|
||||
expect(screen.getByText("environments.surveys.summary.overall: 3.14")).toBeDefined();
|
||||
expect(screen.getAllByRole("button")).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("clicking a choice calls setFilter with correct args", async () => {
|
||||
const questionSummary = {
|
||||
question: {
|
||||
id: "q1",
|
||||
scale: "number",
|
||||
headline: "Headline",
|
||||
type: "rating",
|
||||
range: [1, 5],
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
average: 2,
|
||||
choices: [{ rating: 3, percentage: 100, count: 1 }],
|
||||
dismissed: { count: 0 },
|
||||
} as unknown as TSurveyQuestionSummaryRating;
|
||||
const survey = {};
|
||||
const setFilter = vi.fn();
|
||||
render(<RatingSummary questionSummary={questionSummary} survey={survey as any} setFilter={setFilter} />);
|
||||
await userEvent.click(screen.getByRole("button"));
|
||||
expect(setFilter).toHaveBeenCalledWith(
|
||||
"q1",
|
||||
"Headline",
|
||||
"rating",
|
||||
"environments.surveys.summary.is_equal_to",
|
||||
"3"
|
||||
);
|
||||
});
|
||||
|
||||
test("renders dismissed section when dismissed count > 0", () => {
|
||||
const questionSummary = {
|
||||
question: {
|
||||
id: "q1",
|
||||
scale: "smiley",
|
||||
headline: "Headline",
|
||||
type: "rating",
|
||||
range: [1, 5],
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
average: 4,
|
||||
choices: [],
|
||||
dismissed: { count: 1 },
|
||||
} as unknown as TSurveyQuestionSummaryRating;
|
||||
const survey = {};
|
||||
const setFilter = vi.fn();
|
||||
render(<RatingSummary questionSummary={questionSummary} survey={survey as any} setFilter={setFilter} />);
|
||||
expect(screen.getByText("common.dismissed")).toBeDefined();
|
||||
expect(screen.getByText("1 common.response")).toBeDefined();
|
||||
});
|
||||
});
|
||||
+4
-4
@@ -37,7 +37,7 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
|
||||
}, [questionSummary]);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
|
||||
<QuestionSummaryHeader
|
||||
questionSummary={questionSummary}
|
||||
survey={survey}
|
||||
@@ -52,8 +52,8 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
|
||||
/>
|
||||
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
{questionSummary.choices.map((result) => (
|
||||
<button
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
<div
|
||||
className="cursor-pointer hover:opacity-80"
|
||||
key={result.rating}
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
@@ -85,7 +85,7 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{questionSummary.dismissed && questionSummary.dismissed.count > 0 && (
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ const ScrollToTop: React.FC<ScrollToTopProps> = ({ containerId }) => {
|
||||
return (
|
||||
<button
|
||||
onClick={scrollToTop}
|
||||
className={`fixed bottom-4 right-4 z-[1] flex h-10 w-10 justify-center rounded-md bg-slate-500 p-2 text-white transition-opacity ${
|
||||
className={`fixed right-4 bottom-4 z-1 flex h-10 w-10 justify-center rounded-md bg-slate-500 p-2 text-white transition-opacity ${
|
||||
showButton ? "opacity-80" : "opacity-0"
|
||||
}`}>
|
||||
↑
|
||||
|
||||
-125
@@ -1,125 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
import { SummaryDropOffs } from "./SummaryDropOffs";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/utils/recall", () => ({
|
||||
recallToHeadline: () => ({ default: "Recalled Question" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/editor/lib/utils", () => ({
|
||||
formatTextWithSlashes: (text) => <span data-testid="formatted-text">{text}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/lib/questions", () => ({
|
||||
getQuestionIcon: () => () => <div data-testid="question-icon" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/tooltip", () => ({
|
||||
TooltipProvider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="tooltip-trigger">{children}</div>
|
||||
),
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="tooltip-content">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
TimerIcon: () => <div data-testid="timer-icon" />,
|
||||
}));
|
||||
|
||||
describe("SummaryDropOffs", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const mockSurvey = {} as TSurvey;
|
||||
const mockDropOff: TSurveySummary["dropOff"] = [
|
||||
{
|
||||
questionId: "q1",
|
||||
headline: "First Question",
|
||||
questionType: TSurveyQuestionTypeEnum.OpenText,
|
||||
ttc: 15000, // 15 seconds
|
||||
impressions: 100,
|
||||
dropOffCount: 20,
|
||||
dropOffPercentage: 20,
|
||||
},
|
||||
{
|
||||
questionId: "q2",
|
||||
headline: "Second Question",
|
||||
questionType: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
ttc: 30000, // 30 seconds
|
||||
impressions: 80,
|
||||
dropOffCount: 15,
|
||||
dropOffPercentage: 18.75,
|
||||
},
|
||||
{
|
||||
questionId: "q3",
|
||||
headline: "Third Question",
|
||||
questionType: TSurveyQuestionTypeEnum.Rating,
|
||||
ttc: 0, // No time data
|
||||
impressions: 65,
|
||||
dropOffCount: 10,
|
||||
dropOffPercentage: 15.38,
|
||||
},
|
||||
];
|
||||
|
||||
test("renders header row with correct columns", () => {
|
||||
render(<SummaryDropOffs dropOff={mockDropOff} survey={mockSurvey} />);
|
||||
|
||||
// Check header
|
||||
expect(screen.getByText("common.questions")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip-trigger")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("timer-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.impressions")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.drop_offs")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders tooltip with correct content", () => {
|
||||
render(<SummaryDropOffs dropOff={mockDropOff} survey={mockSurvey} />);
|
||||
|
||||
expect(screen.getByTestId("tooltip-content")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.ttc_tooltip")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders all drop-off items with correct data", () => {
|
||||
render(<SummaryDropOffs dropOff={mockDropOff} survey={mockSurvey} />);
|
||||
|
||||
// There should be 3 rows of data (one for each question)
|
||||
expect(screen.getAllByTestId("question-icon")).toHaveLength(3);
|
||||
expect(screen.getAllByTestId("formatted-text")).toHaveLength(3);
|
||||
|
||||
// Check time to complete values
|
||||
expect(screen.getByText("15.00s")).toBeInTheDocument(); // 15000ms converted to seconds
|
||||
expect(screen.getByText("30.00s")).toBeInTheDocument(); // 30000ms converted to seconds
|
||||
expect(screen.getByText("N/A")).toBeInTheDocument(); // 0ms shown as N/A
|
||||
|
||||
// Check impressions values
|
||||
expect(screen.getByText("100")).toBeInTheDocument();
|
||||
expect(screen.getByText("80")).toBeInTheDocument();
|
||||
expect(screen.getByText("65")).toBeInTheDocument();
|
||||
|
||||
// Check drop-off counts and percentages
|
||||
expect(screen.getByText("20")).toBeInTheDocument();
|
||||
expect(screen.getByText("(20%)")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("15")).toBeInTheDocument();
|
||||
expect(screen.getByText("(19%)")).toBeInTheDocument(); // 18.75% rounded to 19%
|
||||
|
||||
expect(screen.getByText("10")).toBeInTheDocument();
|
||||
expect(screen.getByText("(15%)")).toBeInTheDocument(); // 15.38% rounded to 15%
|
||||
});
|
||||
|
||||
test("renders empty state when dropOff array is empty", () => {
|
||||
render(<SummaryDropOffs dropOff={[]} survey={mockSurvey} />);
|
||||
|
||||
// Header should still be visible
|
||||
expect(screen.getByText("common.questions")).toBeInTheDocument();
|
||||
|
||||
// But no question icons
|
||||
expect(screen.queryByTestId("question-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+21
-5
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
|
||||
import { getQuestionIcon } from "@/modules/survey/lib/questions";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { TimerIcon } from "lucide-react";
|
||||
import { JSX } from "react";
|
||||
import { TSurvey, TSurveyQuestionType, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface SummaryDropOffsProps {
|
||||
@@ -20,8 +20,26 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
|
||||
return <Icon className="mt-[3px] h-5 w-5 shrink-0 text-slate-600" />;
|
||||
};
|
||||
|
||||
const formatTextWithSlashes = (text: string): (string | JSX.Element)[] => {
|
||||
const regex = /\/(.*?)\\/g;
|
||||
const parts = text.split(regex);
|
||||
|
||||
return parts.map((part, index) => {
|
||||
// Check if the part was inside slashes
|
||||
if (index % 2 !== 0) {
|
||||
return (
|
||||
<span key={index} className="mx-1 rounded-md bg-slate-100 p-1 px-2 text-lg">
|
||||
@{part}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return part;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-xs">
|
||||
<div className="">
|
||||
<div className="grid h-10 grid-cols-6 items-center border-y border-slate-200 bg-slate-100 text-sm font-semibold text-slate-600">
|
||||
<div className="col-span-3 pl-4 md:pl-6">{t("common.questions")}</div>
|
||||
@@ -55,9 +73,7 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
|
||||
survey,
|
||||
true,
|
||||
"default"
|
||||
)["default"],
|
||||
"@",
|
||||
["text-lg"]
|
||||
)["default"]
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+6
@@ -39,6 +39,8 @@ interface SummaryListProps {
|
||||
environment: TEnvironment;
|
||||
survey: TSurvey;
|
||||
totalResponseCount: number;
|
||||
isAIEnabled: boolean;
|
||||
documentsPerPage?: number;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
@@ -48,6 +50,8 @@ export const SummaryList = ({
|
||||
responseCount,
|
||||
survey,
|
||||
totalResponseCount,
|
||||
isAIEnabled,
|
||||
documentsPerPage,
|
||||
locale,
|
||||
}: SummaryListProps) => {
|
||||
const { setSelectedFilter, selectedFilter } = useResponseFilter();
|
||||
@@ -130,6 +134,8 @@ export const SummaryList = ({
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environment.id}
|
||||
survey={survey}
|
||||
isAIEnabled={isAIEnabled}
|
||||
documentsPerPage={documentsPerPage}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
-135
@@ -1,135 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { SummaryMetadata } from "./SummaryMetadata";
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
ChevronDownIcon: () => <div data-testid="down" />,
|
||||
ChevronUpIcon: () => <div data-testid="up" />,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/tooltip", () => ({
|
||||
TooltipProvider: ({ children }) => <>{children}</>,
|
||||
Tooltip: ({ children }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }) => <>{children}</>,
|
||||
TooltipContent: ({ children }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
const baseSummary = {
|
||||
completedPercentage: 50,
|
||||
completedResponses: 2,
|
||||
displayCount: 3,
|
||||
dropOffPercentage: 25,
|
||||
dropOffCount: 1,
|
||||
startsPercentage: 75,
|
||||
totalResponses: 4,
|
||||
ttcAverage: 65000,
|
||||
};
|
||||
|
||||
describe("SummaryMetadata", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders loading skeletons when isLoading=true", () => {
|
||||
const { container } = render(
|
||||
<SummaryMetadata
|
||||
showDropOffs={false}
|
||||
setShowDropOffs={() => {}}
|
||||
surveySummary={baseSummary}
|
||||
isLoading={true}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.getElementsByClassName("animate-pulse")).toHaveLength(5);
|
||||
});
|
||||
|
||||
test("renders all stats and formats time correctly, toggles dropOffs icon", async () => {
|
||||
const Wrapper = () => {
|
||||
const [show, setShow] = useState(false);
|
||||
return (
|
||||
<SummaryMetadata
|
||||
showDropOffs={show}
|
||||
setShowDropOffs={setShow}
|
||||
surveySummary={baseSummary}
|
||||
isLoading={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
render(<Wrapper />);
|
||||
// impressions, starts, completed, drop_offs, ttc
|
||||
expect(screen.getByText("environments.surveys.summary.impressions")).toBeInTheDocument();
|
||||
expect(screen.getByText("3")).toBeInTheDocument();
|
||||
expect(screen.getByText("75%")).toBeInTheDocument();
|
||||
expect(screen.getByText("4")).toBeInTheDocument();
|
||||
expect(screen.getByText("50%")).toBeInTheDocument();
|
||||
expect(screen.getByText("2")).toBeInTheDocument();
|
||||
expect(screen.getByText("25%")).toBeInTheDocument();
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
expect(screen.getByText("1m 5.00s")).toBeInTheDocument();
|
||||
const btn = screen.getByRole("button");
|
||||
expect(screen.queryByTestId("down")).toBeInTheDocument();
|
||||
await userEvent.click(btn);
|
||||
expect(screen.queryByTestId("up")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("formats time correctly when < 60 seconds", () => {
|
||||
const smallSummary = { ...baseSummary, ttcAverage: 5000 };
|
||||
render(
|
||||
<SummaryMetadata
|
||||
showDropOffs={false}
|
||||
setShowDropOffs={() => {}}
|
||||
surveySummary={smallSummary}
|
||||
isLoading={false}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("5.00s")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders '-' for dropOffCount=0 and still toggles icon", async () => {
|
||||
const zeroSummary = { ...baseSummary, dropOffCount: 0 };
|
||||
const Wrapper = () => {
|
||||
const [show, setShow] = useState(false);
|
||||
return (
|
||||
<SummaryMetadata
|
||||
showDropOffs={show}
|
||||
setShowDropOffs={setShow}
|
||||
surveySummary={zeroSummary}
|
||||
isLoading={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
render(<Wrapper />);
|
||||
expect(screen.getAllByText("-")).toHaveLength(1);
|
||||
const btn = screen.getByRole("button");
|
||||
expect(screen.queryByTestId("down")).toBeInTheDocument();
|
||||
await userEvent.click(btn);
|
||||
expect(screen.queryByTestId("up")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders '-' for displayCount=0", () => {
|
||||
const dispZero = { ...baseSummary, displayCount: 0 };
|
||||
render(
|
||||
<SummaryMetadata
|
||||
showDropOffs={false}
|
||||
setShowDropOffs={() => {}}
|
||||
surveySummary={dispZero}
|
||||
isLoading={false}
|
||||
/>
|
||||
);
|
||||
expect(screen.getAllByText("-")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("renders '-' for totalResponses=0", () => {
|
||||
const totZero = { ...baseSummary, totalResponses: 0 };
|
||||
render(
|
||||
<SummaryMetadata
|
||||
showDropOffs={false}
|
||||
setShowDropOffs={() => {}}
|
||||
surveySummary={totZero}
|
||||
isLoading={false}
|
||||
/>
|
||||
);
|
||||
expect(screen.getAllByText("-")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
+9
-10
@@ -17,7 +17,7 @@ const StatCard = ({ label, percentage, value, tooltipText, isLoading }) => {
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="flex h-full cursor-default flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm">
|
||||
<div className="flex h-full cursor-default flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-xs">
|
||||
<p className="flex items-center gap-1 text-sm text-slate-600">
|
||||
{label}
|
||||
{typeof percentage === "number" && !isNaN(percentage) && !isLoading && (
|
||||
@@ -71,8 +71,6 @@ export const SummaryMetadata = ({
|
||||
ttcAverage,
|
||||
} = surveySummary;
|
||||
const { t } = useTranslate();
|
||||
const displayCountValue = dropOffCount === 0 ? <span>-</span> : dropOffCount;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-5 md:gap-x-2 lg:col-span-4">
|
||||
@@ -101,7 +99,9 @@ export const SummaryMetadata = ({
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="flex h-full cursor-default flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm">
|
||||
<div
|
||||
onClick={() => setShowDropOffs(!showDropOffs)}
|
||||
className="group flex h-full w-full cursor-pointer flex-col justify-between space-y-2 rounded-lg border border-slate-200 bg-white p-4 text-left shadow-xs">
|
||||
<span className="text-sm text-slate-600">
|
||||
{t("environments.surveys.summary.drop_offs")}
|
||||
{`${Math.round(dropOffPercentage)}%` !== "NaN%" && !isLoading && (
|
||||
@@ -112,20 +112,20 @@ export const SummaryMetadata = ({
|
||||
<span className="text-2xl font-bold text-slate-800">
|
||||
{isLoading ? (
|
||||
<div className="h-6 w-12 animate-pulse rounded-full bg-slate-200"></div>
|
||||
) : dropOffCount === 0 ? (
|
||||
<span>-</span>
|
||||
) : (
|
||||
displayCountValue
|
||||
dropOffCount
|
||||
)}
|
||||
</span>
|
||||
{!isLoading && (
|
||||
<button
|
||||
className="ml-1 flex items-center rounded-md bg-slate-800 px-2 py-1 text-xs text-slate-50 group-hover:bg-slate-700"
|
||||
onClick={() => setShowDropOffs(!showDropOffs)}>
|
||||
<span className="ml-1 flex items-center rounded-md bg-slate-800 px-2 py-1 text-xs text-slate-50 group-hover:bg-slate-700">
|
||||
{showDropOffs ? (
|
||||
<ChevronUpIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -135,7 +135,6 @@ export const SummaryMetadata = ({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<StatCard
|
||||
label={t("environments.surveys.summary.time_to_complete")}
|
||||
percentage={null}
|
||||
|
||||
-228
@@ -1,228 +0,0 @@
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { SummaryPage } from "./SummaryPage";
|
||||
|
||||
// Mock actions
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions", () => ({
|
||||
getResponseCountAction: vi.fn().mockResolvedValue({ data: 42 }),
|
||||
getSurveySummaryAction: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
meta: {
|
||||
completedPercentage: 80,
|
||||
completedResponses: 40,
|
||||
displayCount: 50,
|
||||
dropOffPercentage: 20,
|
||||
dropOffCount: 10,
|
||||
startsPercentage: 100,
|
||||
totalResponses: 50,
|
||||
ttcAverage: 120,
|
||||
},
|
||||
dropOff: [
|
||||
{
|
||||
questionId: "q1",
|
||||
headline: "Question 1",
|
||||
questionType: "openText",
|
||||
ttc: 20000,
|
||||
impressions: 50,
|
||||
dropOffCount: 5,
|
||||
dropOffPercentage: 10,
|
||||
},
|
||||
],
|
||||
summary: [
|
||||
{
|
||||
question: { id: "q1", headline: "Question 1", type: "openText", required: true },
|
||||
responseCount: 45,
|
||||
type: "openText",
|
||||
samples: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/share/[sharingKey]/actions", () => ({
|
||||
getResponseCountBySurveySharingKeyAction: vi.fn().mockResolvedValue({ data: 42 }),
|
||||
getSummaryBySurveySharingKeyAction: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
meta: {
|
||||
completedPercentage: 80,
|
||||
completedResponses: 40,
|
||||
displayCount: 50,
|
||||
dropOffPercentage: 20,
|
||||
dropOffCount: 10,
|
||||
startsPercentage: 100,
|
||||
totalResponses: 50,
|
||||
ttcAverage: 120,
|
||||
},
|
||||
dropOff: [
|
||||
{
|
||||
questionId: "q1",
|
||||
headline: "Question 1",
|
||||
questionType: "openText",
|
||||
ttc: 20000,
|
||||
impressions: 50,
|
||||
dropOffCount: 5,
|
||||
dropOffPercentage: 10,
|
||||
},
|
||||
],
|
||||
summary: [
|
||||
{
|
||||
question: { id: "q1", headline: "Question 1", type: "openText", required: true },
|
||||
responseCount: 45,
|
||||
type: "openText",
|
||||
samples: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock components
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs",
|
||||
() => ({
|
||||
SummaryDropOffs: () => <div data-testid="summary-drop-offs">DropOffs Component</div>,
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList",
|
||||
() => ({
|
||||
SummaryList: ({ summary, responseCount }: any) => (
|
||||
<div data-testid="summary-list">
|
||||
<span>Response Count: {responseCount}</span>
|
||||
<span>Summary Items: {summary.length}</span>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata",
|
||||
() => ({
|
||||
SummaryMetadata: ({ showDropOffs, setShowDropOffs, isLoading }: any) => (
|
||||
<div data-testid="summary-metadata">
|
||||
<span>Is Loading: {isLoading ? "true" : "false"}</span>
|
||||
<button onClick={() => setShowDropOffs(!showDropOffs)}>Toggle Dropoffs</button>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop",
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="scroll-to-top">Scroll To Top</div>,
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter", () => ({
|
||||
CustomFilter: () => <div data-testid="custom-filter">Custom Filter</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton", () => ({
|
||||
ResultsShareButton: () => <div data-testid="results-share-button">Share Results</div>,
|
||||
}));
|
||||
|
||||
// Mock context
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
|
||||
useResponseFilter: () => ({
|
||||
selectedFilter: { filter: [], onlyComplete: false },
|
||||
dateRange: { from: null, to: null },
|
||||
resetState: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock hooks
|
||||
vi.mock("@/lib/utils/hooks/useIntervalWhenFocused", () => ({
|
||||
useIntervalWhenFocused: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/recall", () => ({
|
||||
replaceHeadlineRecall: (survey: any) => survey,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useParams: () => ({}),
|
||||
useSearchParams: () => ({ get: () => null }),
|
||||
}));
|
||||
|
||||
describe("SummaryPage", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const mockEnvironment = { id: "env-123" } as TEnvironment;
|
||||
const mockSurvey = {
|
||||
id: "survey-123",
|
||||
environmentId: "env-123",
|
||||
} as TSurvey;
|
||||
const locale = "en-US" as TUserLocale;
|
||||
|
||||
const defaultProps = {
|
||||
environment: mockEnvironment,
|
||||
survey: mockSurvey,
|
||||
surveyId: "survey-123",
|
||||
webAppUrl: "https://app.example.com",
|
||||
totalResponseCount: 50,
|
||||
locale,
|
||||
isReadOnly: false,
|
||||
};
|
||||
|
||||
test("renders loading state initially", () => {
|
||||
render(<SummaryPage {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("summary-metadata")).toBeInTheDocument();
|
||||
expect(screen.getByText("Is Loading: true")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders summary components after loading", async () => {
|
||||
render(<SummaryPage {...defaultProps} />);
|
||||
|
||||
// Wait for loading to complete
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Is Loading: false")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("custom-filter")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("results-share-button")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("scroll-to-top")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("summary-list")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows drop-offs component when toggled", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SummaryPage {...defaultProps} />);
|
||||
|
||||
// Wait for loading to complete
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Is Loading: false")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Drop-offs should initially be hidden
|
||||
expect(screen.queryByTestId("summary-drop-offs")).not.toBeInTheDocument();
|
||||
|
||||
// Toggle drop-offs
|
||||
await user.click(screen.getByText("Toggle Dropoffs"));
|
||||
|
||||
// Drop-offs should now be visible
|
||||
expect(screen.getByTestId("summary-drop-offs")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("doesn't show share button in read-only mode", async () => {
|
||||
render(<SummaryPage {...defaultProps} isReadOnly={true} />);
|
||||
|
||||
// Wait for loading to complete
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Is Loading: false")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("results-share-button")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+5
@@ -46,6 +46,7 @@ interface SummaryPageProps {
|
||||
webAppUrl: string;
|
||||
user?: TUser;
|
||||
totalResponseCount: number;
|
||||
isAIEnabled: boolean;
|
||||
documentsPerPage?: number;
|
||||
locale: TUserLocale;
|
||||
isReadOnly: boolean;
|
||||
@@ -57,6 +58,8 @@ export const SummaryPage = ({
|
||||
surveyId,
|
||||
webAppUrl,
|
||||
totalResponseCount,
|
||||
isAIEnabled,
|
||||
documentsPerPage,
|
||||
locale,
|
||||
isReadOnly,
|
||||
}: SummaryPageProps) => {
|
||||
@@ -181,6 +184,8 @@ export const SummaryPage = ({
|
||||
survey={surveyMemoized}
|
||||
environment={environment}
|
||||
totalResponseCount={totalResponseCount}
|
||||
isAIEnabled={isAIEnabled}
|
||||
documentsPerPage={documentsPerPage}
|
||||
locale={locale}
|
||||
/>
|
||||
</>
|
||||
|
||||
+1
-45
@@ -3,11 +3,8 @@
|
||||
import { ShareEmbedSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey";
|
||||
import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
|
||||
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
|
||||
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
|
||||
import { copySurveyLink } from "@/modules/survey/lib/client-utils";
|
||||
import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { IconBar } from "@/modules/ui/components/iconbar";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
@@ -25,7 +22,6 @@ interface SurveyAnalysisCTAProps {
|
||||
isReadOnly: boolean;
|
||||
user: TUser;
|
||||
surveyDomain: string;
|
||||
responseCount: number;
|
||||
}
|
||||
|
||||
interface ModalState {
|
||||
@@ -41,13 +37,11 @@ export const SurveyAnalysisCTA = ({
|
||||
isReadOnly,
|
||||
user,
|
||||
surveyDomain,
|
||||
responseCount,
|
||||
}: SurveyAnalysisCTAProps) => {
|
||||
const { t } = useTranslate();
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [modalState, setModalState] = useState<ModalState>({
|
||||
share: searchParams.get("share") === "true",
|
||||
@@ -95,24 +89,6 @@ export const SurveyAnalysisCTA = ({
|
||||
setModalState((prev) => ({ ...prev, dropdown: false }));
|
||||
};
|
||||
|
||||
const duplicateSurveyAndRoute = async (surveyId: string) => {
|
||||
setLoading(true);
|
||||
const duplicatedSurveyResponse = await copySurveyToOtherEnvironmentAction({
|
||||
environmentId: environment.id,
|
||||
surveyId: surveyId,
|
||||
targetEnvironmentId: environment.id,
|
||||
});
|
||||
if (duplicatedSurveyResponse?.data) {
|
||||
toast.success(t("environments.surveys.survey_duplicated_successfully"));
|
||||
router.push(`/environments/${environment.id}/surveys/${duplicatedSurveyResponse.data.id}/edit`);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(duplicatedSurveyResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
setIsCautionDialogOpen(false);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const getPreviewUrl = () => {
|
||||
const separator = surveyUrl.includes("?") ? "&" : "?";
|
||||
return `${surveyUrl}${separator}preview=true`;
|
||||
@@ -131,8 +107,6 @@ export const SurveyAnalysisCTA = ({
|
||||
{ key: "panel", modalView: "panel" as const, setOpen: handleModalState("panel") },
|
||||
];
|
||||
|
||||
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
|
||||
|
||||
const iconActions = [
|
||||
{
|
||||
icon: Eye,
|
||||
@@ -170,11 +144,7 @@ export const SurveyAnalysisCTA = ({
|
||||
{
|
||||
icon: SquarePenIcon,
|
||||
tooltip: t("common.edit"),
|
||||
onClick: () => {
|
||||
responseCount && responseCount > 0
|
||||
? setIsCautionDialogOpen(true)
|
||||
: router.push(`/environments/${environment.id}/surveys/${survey.id}/edit`);
|
||||
},
|
||||
onClick: () => router.push(`/environments/${environment.id}/surveys/${survey.id}/edit`),
|
||||
isVisible: !isReadOnly,
|
||||
},
|
||||
];
|
||||
@@ -212,20 +182,6 @@ export const SurveyAnalysisCTA = ({
|
||||
<SuccessMessage environment={environment} survey={survey} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{responseCount > 0 && (
|
||||
<EditPublicSurveyAlertDialog
|
||||
open={isCautionDialogOpen}
|
||||
setOpen={setIsCautionDialogOpen}
|
||||
isLoading={loading}
|
||||
primaryButtonAction={() => duplicateSurveyAndRoute(survey.id)}
|
||||
primaryButtonText={t("environments.surveys.edit.caution_edit_duplicate")}
|
||||
secondaryButtonAction={() =>
|
||||
router.push(`/environments/${environment.id}/surveys/${survey.id}/edit`)
|
||||
}
|
||||
secondaryButtonText={t("common.edit")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+1
-1
@@ -99,7 +99,7 @@ export const EmbedView = ({
|
||||
className={cn(
|
||||
"rounded-md px-4 py-2",
|
||||
tab.id === activeId
|
||||
? "bg-white text-slate-900 shadow-sm"
|
||||
? "bg-white text-slate-900 shadow-xs"
|
||||
: "border-transparent text-slate-700 hover:text-slate-900"
|
||||
)}>
|
||||
{tab.label}
|
||||
|
||||
+13
-247
@@ -1,7 +1,7 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import toast from "react-hot-toast";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
@@ -25,6 +25,12 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_DISPLAY_NAME: "mock-oidc-display-name",
|
||||
OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "mock-webapp-url",
|
||||
AI_AZURE_LLM_RESSOURCE_NAME: "mock-azure-llm-resource-name",
|
||||
AI_AZURE_LLM_API_KEY: "mock-azure-llm-api-key",
|
||||
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-azure-llm-deployment-id",
|
||||
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-azure-embeddings-resource-name",
|
||||
AI_AZURE_EMBEDDINGS_API_KEY: "mock-azure-embeddings-api-key",
|
||||
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-azure-embeddings-deployment-id",
|
||||
IS_PRODUCTION: true,
|
||||
FB_LOGO_URL: "https://example.com/mock-logo.png",
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
@@ -43,12 +49,10 @@ vi.mock("@/modules/survey/hooks/useSingleUseId", () => ({
|
||||
}));
|
||||
|
||||
const mockSearchParams = new URLSearchParams();
|
||||
const mockPush = vi.fn();
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
useSearchParams: () => mockSearchParams,
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
useSearchParams: () => mockSearchParams, // Reuse the same object
|
||||
usePathname: () => "/current",
|
||||
}));
|
||||
|
||||
@@ -57,27 +61,13 @@ vi.mock("@/modules/survey/lib/client-utils", () => ({
|
||||
copySurveyLink: vi.fn((url: string, id: string) => `${url}?id=${id}`),
|
||||
}));
|
||||
|
||||
// Mock the copy survey action
|
||||
const mockCopySurveyToOtherEnvironmentAction = vi.fn();
|
||||
vi.mock("@/modules/survey/list/actions", () => ({
|
||||
copySurveyToOtherEnvironmentAction: (args: any) => mockCopySurveyToOtherEnvironmentAction(args),
|
||||
}));
|
||||
|
||||
// Mock getFormattedErrorMessage function
|
||||
vi.mock("@/lib/utils/helper", () => ({
|
||||
getFormattedErrorMessage: vi.fn((response) => response?.error || "Unknown error"),
|
||||
}));
|
||||
|
||||
vi.spyOn(toast, "success");
|
||||
vi.spyOn(toast, "error");
|
||||
|
||||
// Mock clipboard API
|
||||
const writeTextMock = vi.fn().mockImplementation(() => Promise.resolve());
|
||||
|
||||
// Define it at the global level
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText: writeTextMock },
|
||||
configurable: true,
|
||||
// Set up a fake clipboard
|
||||
const writeTextMock = vi.fn(() => Promise.resolve());
|
||||
Object.assign(navigator, {
|
||||
clipboard: { writeText: writeTextMock },
|
||||
});
|
||||
|
||||
const dummySurvey = {
|
||||
@@ -103,7 +93,6 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
|
||||
isReadOnly={false}
|
||||
surveyDomain={surveyDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -128,7 +117,6 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
|
||||
isReadOnly={false}
|
||||
surveyDomain={surveyDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -142,225 +130,3 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// New tests for squarePenIcon and edit functionality
|
||||
describe("SurveyAnalysisCTA - Edit functionality", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("opens EditPublicSurveyAlertDialog when edit icon is clicked and response count > 0", async () => {
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
surveyDomain={surveyDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
|
||||
// Find the edit button
|
||||
const editButton = screen.getByRole("button", { name: "common.edit" });
|
||||
await fireEvent.click(editButton);
|
||||
|
||||
// Check if dialog is shown
|
||||
const dialogTitle = screen.getByText("environments.surveys.edit.caution_edit_published_survey");
|
||||
expect(dialogTitle).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("navigates directly to edit page when response count = 0", async () => {
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
surveyDomain={surveyDomain}
|
||||
user={dummyUser}
|
||||
responseCount={0}
|
||||
/>
|
||||
);
|
||||
|
||||
// Find the edit button
|
||||
const editButton = screen.getByRole("button", { name: "common.edit" });
|
||||
await fireEvent.click(editButton);
|
||||
|
||||
// Should navigate directly to edit page
|
||||
expect(mockPush).toHaveBeenCalledWith(
|
||||
`/environments/${dummyEnvironment.id}/surveys/${dummySurvey.id}/edit`
|
||||
);
|
||||
});
|
||||
|
||||
test("doesn't show edit button when isReadOnly is true", () => {
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={true}
|
||||
surveyDomain={surveyDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
|
||||
// Try to find the edit button (it shouldn't exist)
|
||||
const editButton = screen.queryByRole("button", { name: "common.edit" });
|
||||
expect(editButton).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// Updated test description to mention EditPublicSurveyAlertDialog
|
||||
describe("SurveyAnalysisCTA - duplicateSurveyAndRoute and EditPublicSurveyAlertDialog", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("duplicates survey successfully and navigates to edit page", async () => {
|
||||
// Mock the API response
|
||||
mockCopySurveyToOtherEnvironmentAction.mockResolvedValueOnce({
|
||||
data: { id: "duplicated-survey-456" },
|
||||
});
|
||||
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
surveyDomain={surveyDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
|
||||
// Find and click the edit button to show dialog
|
||||
const editButton = screen.getByRole("button", { name: "common.edit" });
|
||||
await fireEvent.click(editButton);
|
||||
|
||||
// Find and click the duplicate button in dialog
|
||||
const duplicateButton = screen.getByRole("button", {
|
||||
name: "environments.surveys.edit.caution_edit_duplicate",
|
||||
});
|
||||
await fireEvent.click(duplicateButton);
|
||||
|
||||
// Verify the API was called with correct parameters
|
||||
expect(mockCopySurveyToOtherEnvironmentAction).toHaveBeenCalledWith({
|
||||
environmentId: dummyEnvironment.id,
|
||||
surveyId: dummySurvey.id,
|
||||
targetEnvironmentId: dummyEnvironment.id,
|
||||
});
|
||||
|
||||
// Verify success toast was shown
|
||||
expect(toast.success).toHaveBeenCalledWith("environments.surveys.survey_duplicated_successfully");
|
||||
|
||||
// Verify navigation to edit page
|
||||
expect(mockPush).toHaveBeenCalledWith(
|
||||
`/environments/${dummyEnvironment.id}/surveys/duplicated-survey-456/edit`
|
||||
);
|
||||
});
|
||||
|
||||
test("shows error toast when duplication fails with error object", async () => {
|
||||
// Mock API failure with error object
|
||||
mockCopySurveyToOtherEnvironmentAction.mockResolvedValueOnce({
|
||||
error: "Test error message",
|
||||
});
|
||||
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
surveyDomain={surveyDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
|
||||
// Open dialog
|
||||
const editButton = screen.getByRole("button", { name: "common.edit" });
|
||||
await fireEvent.click(editButton);
|
||||
|
||||
// Click duplicate
|
||||
const duplicateButton = screen.getByRole("button", {
|
||||
name: "environments.surveys.edit.caution_edit_duplicate",
|
||||
});
|
||||
await fireEvent.click(duplicateButton);
|
||||
|
||||
// Verify error toast
|
||||
expect(toast.error).toHaveBeenCalledWith("Test error message");
|
||||
});
|
||||
|
||||
test("navigates to edit page when cancel button is clicked in dialog", async () => {
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
surveyDomain={surveyDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
|
||||
// Open dialog
|
||||
const editButton = screen.getByRole("button", { name: "common.edit" });
|
||||
await fireEvent.click(editButton);
|
||||
|
||||
// Click edit (cancel) button
|
||||
const editButtonInDialog = screen.getByRole("button", { name: "common.edit" });
|
||||
await fireEvent.click(editButtonInDialog);
|
||||
|
||||
// Verify navigation
|
||||
expect(mockPush).toHaveBeenCalledWith(
|
||||
`/environments/${dummyEnvironment.id}/surveys/${dummySurvey.id}/edit`
|
||||
);
|
||||
});
|
||||
|
||||
test("shows loading state when duplicating survey", async () => {
|
||||
// Create a promise that we can resolve manually
|
||||
let resolvePromise: (value: any) => void;
|
||||
const promise = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
|
||||
mockCopySurveyToOtherEnvironmentAction.mockImplementation(() => promise);
|
||||
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
surveyDomain={surveyDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
|
||||
// Open dialog
|
||||
const editButton = screen.getByRole("button", { name: "common.edit" });
|
||||
await fireEvent.click(editButton);
|
||||
|
||||
// Click duplicate
|
||||
const duplicateButton = screen.getByRole("button", {
|
||||
name: "environments.surveys.edit.caution_edit_duplicate",
|
||||
});
|
||||
await fireEvent.click(duplicateButton);
|
||||
|
||||
// Button should now be in loading state
|
||||
// expect(duplicateButton).toHaveAttribute("data-state", "loading");
|
||||
|
||||
// Resolve the promise
|
||||
resolvePromise!({
|
||||
data: { id: "duplicated-survey-456" },
|
||||
});
|
||||
|
||||
// Wait for the promise to resolve
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { documentCache } from "@/lib/cache/document";
|
||||
import { INSIGHTS_PER_PAGE } from "@/lib/constants";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import {
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionSummaryOpenText,
|
||||
ZSurveyQuestionId,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
|
||||
export const getInsightsBySurveyIdQuestionId = reactCache(
|
||||
async (
|
||||
surveyId: string,
|
||||
questionId: TSurveyQuestionId,
|
||||
insightResponsesIds: string[],
|
||||
limit?: number,
|
||||
offset?: number
|
||||
): Promise<TSurveyQuestionSummaryOpenText["insights"]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([surveyId, ZId], [questionId, ZSurveyQuestionId]);
|
||||
|
||||
limit = limit ?? INSIGHTS_PER_PAGE;
|
||||
try {
|
||||
const insights = await prisma.insight.findMany({
|
||||
where: {
|
||||
documentInsights: {
|
||||
some: {
|
||||
document: {
|
||||
surveyId,
|
||||
questionId,
|
||||
...(insightResponsesIds.length > 0 && {
|
||||
responseId: {
|
||||
in: insightResponsesIds,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
documentInsights: {
|
||||
where: {
|
||||
document: {
|
||||
surveyId,
|
||||
questionId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
documentInsights: {
|
||||
_count: "desc",
|
||||
},
|
||||
},
|
||||
{
|
||||
createdAt: "desc",
|
||||
},
|
||||
],
|
||||
take: limit ? limit : undefined,
|
||||
skip: offset ? offset : undefined,
|
||||
});
|
||||
|
||||
return insights;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getInsightsBySurveyIdQuestionId-${surveyId}-${questionId}-${limit}-${offset}`],
|
||||
{
|
||||
tags: [documentCache.tag.bySurveyId(surveyId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
+11
@@ -1,4 +1,5 @@
|
||||
import "server-only";
|
||||
import { getInsightsBySurveyIdQuestionId } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/insights";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||
import { displayCache } from "@/lib/display/cache";
|
||||
@@ -316,9 +317,11 @@ export const getQuestionSummary = async (
|
||||
switch (question.type) {
|
||||
case TSurveyQuestionTypeEnum.OpenText: {
|
||||
let values: TSurveyQuestionSummaryOpenText["samples"] = [];
|
||||
const insightResponsesIds: string[] = [];
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question.id];
|
||||
if (answer && typeof answer === "string") {
|
||||
insightResponsesIds.push(response.id);
|
||||
values.push({
|
||||
id: response.id,
|
||||
updatedAt: response.updatedAt,
|
||||
@@ -328,12 +331,20 @@ export const getQuestionSummary = async (
|
||||
});
|
||||
}
|
||||
});
|
||||
const insights = await getInsightsBySurveyIdQuestionId(
|
||||
survey.id,
|
||||
question.id,
|
||||
insightResponsesIds,
|
||||
50
|
||||
);
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
responseCount: values.length,
|
||||
samples: values.slice(0, VALUES_LIMIT),
|
||||
insights,
|
||||
insightsEnabled: question.insightsEnabled,
|
||||
});
|
||||
|
||||
values = [];
|
||||
|
||||
+9
@@ -38,3 +38,12 @@ export const constructToastMessage = (
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const needsInsightsGeneration = (survey: TSurvey): boolean => {
|
||||
const openTextQuestions = survey.questions.filter((question) => question.type === "openText");
|
||||
const questionWithoutInsightsEnabled = openTextQuestions.some(
|
||||
(question) => question.type === "openText" && typeof question.insightsEnabled === "undefined"
|
||||
);
|
||||
|
||||
return openTextQuestions.length > 0 && questionWithoutInsightsEnabled;
|
||||
};
|
||||
|
||||
+23
-3
@@ -1,11 +1,19 @@
|
||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||
import { EnableInsightsBanner } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/EnableInsightsBanner";
|
||||
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
import { DEFAULT_LOCALE, DOCUMENTS_PER_PAGE, WEBAPP_URL } from "@/lib/constants";
|
||||
import { needsInsightsGeneration } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
DOCUMENTS_PER_PAGE,
|
||||
MAX_RESPONSES_FOR_INSIGHT_GENERATION,
|
||||
WEBAPP_URL,
|
||||
} from "@/lib/constants";
|
||||
import { getSurveyDomain } from "@/lib/getSurveyUrl";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
@@ -17,7 +25,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
|
||||
const { session, environment, isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
||||
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const surveyId = params.surveyId;
|
||||
|
||||
@@ -42,6 +50,11 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
// I took this out cause it's cloud only right?
|
||||
// const { active: isEnterpriseEdition } = await getEnterpriseLicense();
|
||||
|
||||
const isAIEnabled = await getIsAIEnabled({
|
||||
isAIEnabled: organization.isAIEnabled,
|
||||
billing: organization.billing,
|
||||
});
|
||||
const shouldGenerateInsights = needsInsightsGeneration(survey);
|
||||
const surveyDomain = getSurveyDomain();
|
||||
|
||||
return (
|
||||
@@ -55,9 +68,15 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
isReadOnly={isReadOnly}
|
||||
user={user}
|
||||
surveyDomain={surveyDomain}
|
||||
responseCount={totalResponseCount}
|
||||
/>
|
||||
}>
|
||||
{isAIEnabled && shouldGenerateInsights && (
|
||||
<EnableInsightsBanner
|
||||
surveyId={survey.id}
|
||||
surveyResponseCount={totalResponseCount}
|
||||
maxResponseCount={MAX_RESPONSES_FOR_INSIGHT_GENERATION}
|
||||
/>
|
||||
)}
|
||||
<SurveyAnalysisNavigation
|
||||
environmentId={environment.id}
|
||||
survey={survey}
|
||||
@@ -72,6 +91,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
webAppUrl={WEBAPP_URL}
|
||||
user={user}
|
||||
totalResponseCount={totalResponseCount}
|
||||
isAIEnabled={isAIEnabled}
|
||||
documentsPerPage={DOCUMENTS_PER_PAGE}
|
||||
isReadOnly={isReadOnly}
|
||||
locale={user.locale ?? DEFAULT_LOCALE}
|
||||
|
||||
+1
-1
@@ -389,7 +389,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
onOpenChange={(value) => {
|
||||
value && handleDatePickerClose();
|
||||
}}>
|
||||
<DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-none">
|
||||
<DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-hidden">
|
||||
<div className="h-auto min-w-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex sm:px-6 sm:py-3">
|
||||
<div className="hidden w-full items-center justify-between sm:flex">
|
||||
<span className="text-sm text-slate-700">{t("common.download")}</span>
|
||||
|
||||
-88
@@ -1,88 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { QuestionFilterComboBox } from "./QuestionFilterComboBox";
|
||||
|
||||
describe("QuestionFilterComboBox", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
filterOptions: ["A", "B"],
|
||||
filterComboBoxOptions: ["X", "Y"],
|
||||
filterValue: undefined,
|
||||
filterComboBoxValue: undefined,
|
||||
onChangeFilterValue: vi.fn(),
|
||||
onChangeFilterComboBoxValue: vi.fn(),
|
||||
handleRemoveMultiSelect: vi.fn(),
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
test("renders select placeholders", () => {
|
||||
render(<QuestionFilterComboBox {...defaultProps} />);
|
||||
expect(screen.getAllByText(/common.select\.../).length).toBe(2);
|
||||
});
|
||||
|
||||
test("calls onChangeFilterValue when selecting filter", async () => {
|
||||
render(<QuestionFilterComboBox {...defaultProps} />);
|
||||
await userEvent.click(screen.getAllByRole("button")[0]);
|
||||
await userEvent.click(screen.getByText("A"));
|
||||
expect(defaultProps.onChangeFilterValue).toHaveBeenCalledWith("A");
|
||||
});
|
||||
|
||||
test("calls onChangeFilterComboBoxValue when selecting combo box option", async () => {
|
||||
render(<QuestionFilterComboBox {...defaultProps} filterValue="A" />);
|
||||
await userEvent.click(screen.getAllByRole("button")[1]);
|
||||
await userEvent.click(screen.getByText("X"));
|
||||
expect(defaultProps.onChangeFilterComboBoxValue).toHaveBeenCalledWith("X");
|
||||
});
|
||||
|
||||
test("multi-select removal works", async () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
type: "multipleChoiceMulti",
|
||||
filterValue: "A",
|
||||
filterComboBoxValue: ["X", "Y"],
|
||||
};
|
||||
render(<QuestionFilterComboBox {...props} />);
|
||||
const removeButtons = screen.getAllByRole("button", { name: /X/i });
|
||||
await userEvent.click(removeButtons[0]);
|
||||
expect(props.handleRemoveMultiSelect).toHaveBeenCalledWith(["Y"]);
|
||||
});
|
||||
|
||||
test("disabled state prevents opening", async () => {
|
||||
render(<QuestionFilterComboBox {...defaultProps} disabled />);
|
||||
await userEvent.click(screen.getAllByRole("button")[0]);
|
||||
expect(screen.queryByText("A")).toBeNull();
|
||||
});
|
||||
|
||||
test("handles object options correctly", async () => {
|
||||
const obj = { default: "Obj1", en: "ObjEN" };
|
||||
const props = {
|
||||
...defaultProps,
|
||||
type: "multipleChoiceMulti",
|
||||
filterValue: "A",
|
||||
filterComboBoxOptions: [obj],
|
||||
filterComboBoxValue: [],
|
||||
} as any;
|
||||
render(<QuestionFilterComboBox {...props} />);
|
||||
await userEvent.click(screen.getAllByRole("button")[1]);
|
||||
await userEvent.click(screen.getByText("Obj1"));
|
||||
expect(props.onChangeFilterComboBoxValue).toHaveBeenCalledWith(["Obj1"]);
|
||||
});
|
||||
|
||||
test("prevent combo-box opening when filterValue is Submitted", async () => {
|
||||
const props = { ...defaultProps, type: "NPS", filterValue: "Submitted" } as any;
|
||||
render(<QuestionFilterComboBox {...props} />);
|
||||
await userEvent.click(screen.getAllByRole("button")[1]);
|
||||
expect(screen.queryByText("X")).toHaveClass("data-[disabled='true']:opacity-50");
|
||||
});
|
||||
|
||||
test("prevent combo-box opening when filterValue is Skipped", async () => {
|
||||
const props = { ...defaultProps, type: "Rating", filterValue: "Skipped" } as any;
|
||||
render(<QuestionFilterComboBox {...props} />);
|
||||
await userEvent.click(screen.getAllByRole("button")[1]);
|
||||
expect(screen.queryByText("X")).toHaveClass("data-[disabled='true']:opacity-50");
|
||||
});
|
||||
});
|
||||
+45
-62
@@ -81,39 +81,6 @@ export const QuestionFilterComboBox = ({
|
||||
.includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const filterComboBoxItem = !Array.isArray(filterComboBoxValue) ? (
|
||||
<p className="text-slate-600">{filterComboBoxValue}</p>
|
||||
) : (
|
||||
<div className="no-scrollbar flex w-[7rem] gap-3 overflow-auto md:w-[10rem] lg:w-[18rem]">
|
||||
{typeof filterComboBoxValue !== "string" &&
|
||||
filterComboBoxValue?.map((o, index) => (
|
||||
<button
|
||||
key={`${o}-${index}`}
|
||||
type="button"
|
||||
onClick={() => handleRemoveMultiSelect(filterComboBoxValue.filter((i) => i !== o))}
|
||||
className="flex w-30 items-center bg-slate-100 px-2 whitespace-nowrap text-slate-600">
|
||||
{o}
|
||||
<X width={14} height={14} className="ml-2" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const commandItemOnSelect = (o: string) => {
|
||||
if (!isMultiple) {
|
||||
onChangeFilterComboBoxValue(typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o);
|
||||
} else {
|
||||
onChangeFilterComboBoxValue(
|
||||
Array.isArray(filterComboBoxValue)
|
||||
? [...filterComboBoxValue, typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o]
|
||||
: [typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o]
|
||||
);
|
||||
}
|
||||
if (!isMultiple) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="inline-flex w-full flex-row">
|
||||
{filterOptions && filterOptions?.length <= 1 ? (
|
||||
@@ -163,41 +130,43 @@ export const QuestionFilterComboBox = ({
|
||||
)}
|
||||
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent">
|
||||
<div
|
||||
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
|
||||
className={clsx(
|
||||
"group flex items-center justify-between rounded-md rounded-l-none bg-white px-3 py-2 text-sm"
|
||||
"group flex items-center justify-between rounded-md rounded-l-none bg-white px-3 py-2 text-sm",
|
||||
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
|
||||
)}>
|
||||
{filterComboBoxValue && filterComboBoxValue.length > 0 ? (
|
||||
filterComboBoxItem
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
|
||||
disabled={disabled || isDisabledComboBox || !filterValue}
|
||||
className={clsx(
|
||||
"flex-1 text-left text-slate-400",
|
||||
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
|
||||
)}>
|
||||
{t("common.select")}...
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
|
||||
disabled={disabled || isDisabledComboBox || !filterValue}
|
||||
className={clsx(
|
||||
"ml-2 flex items-center justify-center",
|
||||
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
|
||||
)}>
|
||||
{open ? (
|
||||
<ChevronUp className="h-4 w-4 opacity-50" />
|
||||
{filterComboBoxValue && filterComboBoxValue?.length > 0 ? (
|
||||
!Array.isArray(filterComboBoxValue) ? (
|
||||
<p className="text-slate-600">{filterComboBoxValue}</p>
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
<div className="no-scrollbar flex w-[7rem] gap-3 overflow-auto md:w-[10rem] lg:w-[18rem]">
|
||||
{typeof filterComboBoxValue !== "string" &&
|
||||
filterComboBoxValue?.map((o, index) => (
|
||||
<button
|
||||
key={`${o}-${index}`}
|
||||
type="button"
|
||||
onClick={() => handleRemoveMultiSelect(filterComboBoxValue.filter((i) => i !== o))}
|
||||
className="flex w-30 items-center bg-slate-100 px-2 whitespace-nowrap text-slate-600">
|
||||
{o}
|
||||
<X width={14} height={14} className="ml-2" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<p className="text-slate-400">{t("common.select")}...</p>
|
||||
)}
|
||||
<div>
|
||||
{open ? (
|
||||
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative mt-2 h-full">
|
||||
{open && (
|
||||
<div className="animate-in bg-popover absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
|
||||
<div className="animate-in bg-popover absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-hidden">
|
||||
<CommandList>
|
||||
<div className="p-2">
|
||||
<Input
|
||||
@@ -214,7 +183,21 @@ export const QuestionFilterComboBox = ({
|
||||
{filteredOptions?.map((o, index) => (
|
||||
<CommandItem
|
||||
key={`option-${typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}-${index}`}
|
||||
onSelect={() => commandItemOnSelect(o)}
|
||||
onSelect={() => {
|
||||
!isMultiple
|
||||
? onChangeFilterComboBoxValue(
|
||||
typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o
|
||||
)
|
||||
: onChangeFilterComboBoxValue(
|
||||
Array.isArray(filterComboBoxValue)
|
||||
? [
|
||||
...filterComboBoxValue,
|
||||
typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o,
|
||||
]
|
||||
: [typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o]
|
||||
);
|
||||
!isMultiple && setOpen(false);
|
||||
}}
|
||||
className="cursor-pointer">
|
||||
{typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}
|
||||
</CommandItem>
|
||||
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { OptionsType, QuestionOption, QuestionOptions, QuestionsComboBox } from "./QuestionsComboBox";
|
||||
|
||||
describe("QuestionsComboBox", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const mockOptions: QuestionOptions[] = [
|
||||
{
|
||||
header: OptionsType.QUESTIONS,
|
||||
option: [{ label: "Q1", type: OptionsType.QUESTIONS, questionType: undefined, id: "1" }],
|
||||
},
|
||||
{
|
||||
header: OptionsType.TAGS,
|
||||
option: [{ label: "Tag1", type: OptionsType.TAGS, id: "t1" }],
|
||||
},
|
||||
];
|
||||
|
||||
test("renders selected label when closed", () => {
|
||||
const selected: Partial<QuestionOption> = { label: "Q1", type: OptionsType.QUESTIONS, id: "1" };
|
||||
render(<QuestionsComboBox options={mockOptions} selected={selected} onChangeValue={() => {}} />);
|
||||
expect(screen.getByText("Q1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens dropdown, selects an option, and closes", async () => {
|
||||
let currentSelected: Partial<QuestionOption> = {};
|
||||
const onChange = vi.fn((option) => {
|
||||
currentSelected = option;
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<QuestionsComboBox options={mockOptions} selected={currentSelected} onChangeValue={onChange} />
|
||||
);
|
||||
|
||||
// Open the dropdown
|
||||
await userEvent.click(screen.getByRole("button"));
|
||||
expect(screen.getByPlaceholderText("common.search...")).toBeInTheDocument();
|
||||
|
||||
// Select an option
|
||||
await userEvent.click(screen.getByText("Q1"));
|
||||
|
||||
// Check if onChange was called
|
||||
expect(onChange).toHaveBeenCalledWith(mockOptions[0].option[0]);
|
||||
|
||||
// Rerender with the new selected value
|
||||
rerender(<QuestionsComboBox options={mockOptions} selected={currentSelected} onChangeValue={onChange} />);
|
||||
|
||||
// Check if the input is gone and the selected item is displayed
|
||||
expect(screen.queryByPlaceholderText("common.search...")).toBeNull();
|
||||
expect(screen.getByText("Q1")).toBeInTheDocument(); // Verify the selected item is now displayed
|
||||
});
|
||||
});
|
||||
+9
-9
@@ -34,7 +34,7 @@ import {
|
||||
StarIcon,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { Fragment, useRef, useState } from "react";
|
||||
import * as React from "react";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
|
||||
export enum OptionsType {
|
||||
@@ -141,15 +141,15 @@ const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOpti
|
||||
};
|
||||
|
||||
export const QuestionsComboBox = ({ options, selected, onChangeValue }: QuestionComboBoxProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const { t } = useTranslate();
|
||||
const commandRef = useRef(null);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const commandRef = React.useRef(null);
|
||||
const [inputValue, setInputValue] = React.useState("");
|
||||
useClickOutside(commandRef, () => setOpen(false));
|
||||
|
||||
return (
|
||||
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent hover:bg-slate-50">
|
||||
<button
|
||||
<div
|
||||
onClick={() => setOpen(true)}
|
||||
className="group flex cursor-pointer items-center justify-between rounded-md bg-white px-3 py-2 text-sm">
|
||||
{!open && selected.hasOwnProperty("label") && (
|
||||
@@ -174,14 +174,14 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative mt-2 h-full">
|
||||
{open && (
|
||||
<div className="animate-in bg-popover absolute top-0 z-50 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
|
||||
<div className="animate-in bg-popover absolute top-0 z-50 max-h-52 w-full overflow-auto rounded-md bg-white outline-hidden">
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||
{options?.map((data) => (
|
||||
<Fragment key={data.header}>
|
||||
<>
|
||||
{data?.option.length > 0 && (
|
||||
<CommandGroup
|
||||
heading={<p className="text-sm font-normal text-slate-600">{data.header}</p>}>
|
||||
@@ -199,7 +199,7 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</Fragment>
|
||||
</>
|
||||
))}
|
||||
</CommandList>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -199,7 +199,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger className="flex min-w-[8rem] items-center justify-between rounded border border-slate-200 bg-white p-3 text-sm text-slate-600 hover:border-slate-300 sm:min-w-[11rem] sm:px-6 sm:py-3">
|
||||
<PopoverTrigger className="flex min-w-[8rem] items-center justify-between rounded-sm border border-slate-200 bg-white p-3 text-sm text-slate-600 hover:border-slate-300 sm:min-w-[11rem] sm:px-6 sm:py-3">
|
||||
<span>
|
||||
Filter <b>{filterValue.filter.length > 0 && `(${filterValue.filter.length})`}</b>
|
||||
</span>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user