mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-21 13:40:31 -06:00
Compare commits
9 Commits
v3.10.3
...
chore/impr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4811351be | ||
|
|
9fcbe4e8c5 | ||
|
|
5aeb92eb4f | ||
|
|
00dfa629b5 | ||
|
|
3ca471b6a2 | ||
|
|
a525589186 | ||
|
|
59ed10398d | ||
|
|
25a86e31df | ||
|
|
7d6743a81a |
@@ -211,5 +211,5 @@ UNKEY_ROOT_KEY=
|
||||
# It's used automatically by Sentry during the build for authentication when uploading source maps.
|
||||
# SENTRY_AUTH_TOKEN=
|
||||
|
||||
# Disable the user management from UI
|
||||
# DISABLE_USER_MANAGEMENT=1
|
||||
# Configure the minimum role for user management from UI(owner, manager, disabled)
|
||||
# USER_MANAGEMENT_MINIMUM_ROLE="manager"
|
||||
5
.github/workflows/chromatic.yml
vendored
5
.github/workflows/chromatic.yml
vendored
@@ -10,6 +10,11 @@ jobs:
|
||||
chromatic:
|
||||
name: Run Chromatic
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
actions: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
|
||||
1
.github/workflows/e2e.yml
vendored
1
.github/workflows/e2e.yml
vendored
@@ -25,7 +25,6 @@ permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
actions: read
|
||||
checks: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
5
.github/workflows/release-docker-github.yml
vendored
5
.github/workflows/release-docker-github.yml
vendored
@@ -20,18 +20,15 @@ env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
# This is used to complete the identity challenge
|
||||
# with sigstore/fulcio when running outside of PRs.
|
||||
id-token: write
|
||||
|
||||
outputs:
|
||||
VERSION: ${{ steps.extract_release_tag.outputs.VERSION }}
|
||||
|
||||
2
.github/workflows/release-helm-chart.yml
vendored
2
.github/workflows/release-helm-chart.yml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
VERSION:
|
||||
description: 'The version of the Helm chart to release'
|
||||
description: "The version of the Helm chart to release"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
|
||||
14
.github/workflows/terraform-plan-and-apply.yml
vendored
14
.github/workflows/terraform-plan-and-apply.yml
vendored
@@ -1,8 +1,8 @@
|
||||
name: 'Terraform'
|
||||
name: "Terraform"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# TODO: enable it back when migration is completed.
|
||||
# TODO: enable it back when migration is completed.
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -14,14 +14,13 @@ on:
|
||||
paths:
|
||||
- "infra/terraform/**"
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
terraform:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
pull-requests: write
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
@@ -83,4 +82,3 @@ jobs:
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
run: terraform apply .planfile
|
||||
working-directory: "infra/terraform"
|
||||
|
||||
|
||||
@@ -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 right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={`/environments/${environment.id}`}>
|
||||
|
||||
@@ -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 right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={`/environments/${environment.id}/surveys`}>
|
||||
|
||||
@@ -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 right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={"/"}>
|
||||
|
||||
@@ -225,7 +225,7 @@ export const ProjectSettings = ({
|
||||
alt="Logo"
|
||||
width={256}
|
||||
height={56}
|
||||
className="absolute top-2 left-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
|
||||
className="absolute left-2 top-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
|
||||
/>
|
||||
)}
|
||||
<p className="text-sm text-slate-400">{t("common.preview")}</p>
|
||||
|
||||
@@ -23,7 +23,7 @@ export const ActionClassDataRow = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto text-center text-sm whitespace-nowrap text-slate-500">
|
||||
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
|
||||
{timeSince(actionClass.createdAt.toString(), locale)}
|
||||
</div>
|
||||
<div className="text-center"></div>
|
||||
|
||||
@@ -53,7 +53,7 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
|
||||
<currentStatus.icon />
|
||||
</div>
|
||||
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
|
||||
<p className="w-2/3 text-sm text-balance text-slate-600">{currentStatus.subtitle}</p>
|
||||
<p className="w-2/3 text-balance text-sm text-slate-600">{currentStatus.subtitle}</p>
|
||||
{status === "notImplemented" && (
|
||||
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
|
||||
<RotateCcwIcon />
|
||||
|
||||
@@ -255,7 +255,7 @@ export const AddIntegrationModal = ({
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
||||
<div className="mt-1 max-h-[15vh] overflow-x-hidden overflow-y-auto rounded-lg border border-slate-200">
|
||||
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
|
||||
<div key={question.id} className="my-1 flex items-center space-x-2">
|
||||
|
||||
@@ -31,7 +31,7 @@ export const SettingsCard = ({
|
||||
id={title}>
|
||||
<div className="border-b border-slate-200 px-4 pb-4">
|
||||
<div className="flex">
|
||||
<h3 className="text-lg leading-6 font-medium text-slate-900 capitalize">{title}</h3>
|
||||
<h3 className="text-lg font-medium capitalize leading-6 text-slate-900">{title}</h3>
|
||||
<div className="ml-2">
|
||||
{beta && <Badge size="normal" type="warning" text="Beta" />}
|
||||
{soon && (
|
||||
|
||||
@@ -38,7 +38,7 @@ export const ResponseTableCell = ({
|
||||
<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"
|
||||
className="hidden flex-shrink-0 cursor-pointer items-center rounded-md border border-slate-200 bg-white p-2 hover:border-slate-300 focus:outline-none group-hover:flex"
|
||||
onClick={handleCellClick}>
|
||||
<Maximize2Icon className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
@@ -41,7 +41,7 @@ export const ConsentSummary = ({ questionSummary, survey, setFilter }: ConsentSu
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{summaryItems.map((summaryItem) => {
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -80,7 +80,7 @@ export const DateQuestionSummary = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-2 pl-6 font-semibold whitespace-pre-wrap">
|
||||
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
||||
{renderResponseValue(response.value)}
|
||||
</div>
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
|
||||
@@ -80,7 +80,7 @@ export const FileUploadSummary = ({
|
||||
return (
|
||||
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
|
||||
<a href={fileUrl} key={fileUrl} target="_blank" rel="noopener noreferrer">
|
||||
<div className="absolute top-0 right-0 m-2">
|
||||
<div className="absolute right-0 top-0 m-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
|
||||
<DownloadIcon className="h-6 text-slate-500" />
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,7 @@ export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: Hi
|
||||
};
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="space-y-2 px-4 pt-6 pb-5 md:px-6">
|
||||
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
|
||||
<div className={"align-center flex justify-between gap-4"}>
|
||||
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">{questionSummary.id}</h3>
|
||||
</div>
|
||||
@@ -76,7 +76,7 @@ export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: Hi
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-2 pl-6 font-semibold whitespace-pre-wrap">
|
||||
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
||||
{response.value}
|
||||
</div>
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
|
||||
@@ -52,7 +52,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
|
||||
<table className="mx-auto border-collapse cursor-default text-left">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="p-4 pt-0 pb-3 font-medium text-slate-400 dark:border-slate-600 dark:text-slate-200"></th>
|
||||
<th className="p-4 pb-3 pt-0 font-medium text-slate-400 dark:border-slate-600 dark:text-slate-200"></th>
|
||||
{columns.map((column) => (
|
||||
<th key={column} className="text-center font-medium">
|
||||
<TooltipRenderer tooltipContent={getTooltipContent(column)} shouldRender={true}>
|
||||
@@ -65,7 +65,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
|
||||
<tbody>
|
||||
{questionSummary.data.map(({ rowLabel, columnPercentages }, rowIndex) => (
|
||||
<tr key={rowLabel}>
|
||||
<td className="max-w-60 overflow-hidden p-4 text-ellipsis whitespace-nowrap">
|
||||
<td className="max-w-60 overflow-hidden text-ellipsis whitespace-nowrap p-4">
|
||||
<TooltipRenderer tooltipContent={getTooltipContent(rowLabel)} shouldRender={true}>
|
||||
<p className="max-w-40 overflow-hidden text-ellipsis whitespace-nowrap">{rowLabel}</p>
|
||||
</TooltipRenderer>
|
||||
|
||||
@@ -83,7 +83,7 @@ export const MultipleChoiceSummary = ({
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{results.map((result, resultsIdx) => (
|
||||
<Fragment key={result.value}>
|
||||
<button
|
||||
|
||||
@@ -62,7 +62,7 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
|
||||
<button
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
@@ -72,7 +72,7 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
|
||||
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
|
||||
<div className="mr-8 flex space-x-1">
|
||||
<p
|
||||
className={`font-semibold text-slate-700 capitalize ${group === "dismissed" ? "" : "text-slate-700"}`}>
|
||||
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
|
||||
{group}
|
||||
</p>
|
||||
<div>
|
||||
@@ -94,7 +94,7 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center pt-4 pb-4">
|
||||
<div className="flex justify-center pb-4 pt-4">
|
||||
<HalfCircle value={questionSummary.score} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{results.map((result, index) => (
|
||||
<button
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
|
||||
@@ -26,7 +26,7 @@ export const QuestionSummaryHeader = ({
|
||||
const questionType = getQuestionTypes(t).find((type) => type.id === questionSummary.question.type);
|
||||
|
||||
return (
|
||||
<div className="space-y-2 px-4 pt-6 pb-5 md:px-6">
|
||||
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
|
||||
<div className={"align-center flex justify-between gap-4"}>
|
||||
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">
|
||||
{formatTextWithSlashes(
|
||||
|
||||
@@ -50,7 +50,7 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{questionSummary.choices.map((result) => (
|
||||
<button
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
|
||||
@@ -61,10 +61,10 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center font-semibold whitespace-pre-wrap">
|
||||
<div className="whitespace-pre-wrap text-center font-semibold">
|
||||
{quesDropOff.ttc > 0 ? (quesDropOff.ttc / 1000).toFixed(2) + "s" : "N/A"}
|
||||
</div>
|
||||
<div className="text-center font-semibold whitespace-pre-wrap">{quesDropOff.impressions}</div>
|
||||
<div className="whitespace-pre-wrap text-center font-semibold">{quesDropOff.impressions}</div>
|
||||
<div className="pl-6 text-center md:px-6">
|
||||
<span className="mr-1.5 font-semibold">{quesDropOff.dropOffCount}</span>
|
||||
<span>({Math.round(quesDropOff.dropOffPercentage)}%)</span>
|
||||
|
||||
@@ -28,7 +28,7 @@ export const useSurveyQRCode = (surveyUrl: string) => {
|
||||
} catch (error) {
|
||||
toast.error(t("environments.surveys.summary.failed_to_generate_qr_code"));
|
||||
}
|
||||
}, [surveyUrl]);
|
||||
}, [surveyUrl, t]);
|
||||
|
||||
const downloadQRCode = () => {
|
||||
try {
|
||||
|
||||
@@ -96,7 +96,7 @@ const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<v
|
||||
throw new ResourceNotFoundError("Organization not found", organizationId);
|
||||
}
|
||||
|
||||
const isSurveyFollowUpsEnabled = getSurveyFollowUpsPermission(organization.billing.plan);
|
||||
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization.billing.plan);
|
||||
if (!isSurveyFollowUpsEnabled) {
|
||||
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
|
||||
}
|
||||
|
||||
@@ -390,7 +390,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
value && handleDatePickerClose();
|
||||
}}>
|
||||
<DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-none">
|
||||
<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="min-w-auto h-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex sm:px-6 sm:py-3">
|
||||
<div className="hidden w-full items-center justify-between sm:flex">
|
||||
<span className="text-sm text-slate-700">{t("common.download")}</span>
|
||||
<ArrowDownToLineIcon className="ml-2 h-4 w-4" />
|
||||
|
||||
@@ -91,7 +91,7 @@ export const QuestionFilterComboBox = ({
|
||||
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">
|
||||
className="w-30 flex items-center whitespace-nowrap bg-slate-100 px-2 text-slate-600">
|
||||
{o}
|
||||
<X width={14} height={14} className="ml-2" />
|
||||
</button>
|
||||
@@ -129,7 +129,7 @@ export const QuestionFilterComboBox = ({
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled}
|
||||
className={clsx(
|
||||
"h-9 max-w-fit rounded-md rounded-r-none border-r-[1px] border-slate-300 bg-white p-2 text-sm text-slate-600 focus:ring-0 focus:outline-transparent",
|
||||
"h-9 max-w-fit rounded-md rounded-r-none border-r-[1px] border-slate-300 bg-white p-2 text-sm text-slate-600 focus:outline-transparent focus:ring-0",
|
||||
!disabled ? "cursor-pointer" : "opacity-50"
|
||||
)}>
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -57,6 +57,10 @@ export const PUT = async (
|
||||
return handleDatabaseError(error, request.url, endpoint, responseId);
|
||||
}
|
||||
|
||||
if (response.finished) {
|
||||
return responses.badRequestResponse("Response is already finished", undefined, true);
|
||||
}
|
||||
|
||||
// get survey to get environmentId
|
||||
let survey;
|
||||
try {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { verifyRecaptchaToken } from "@/app/api/v2/client/[environmentId]/respon
|
||||
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
|
||||
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { symmetricDecrypt } from "@/lib/crypto";
|
||||
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { Organization } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
@@ -40,6 +41,13 @@ vi.mock("@formbricks/logger", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
symmetricDecrypt: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
ENCRYPTION_KEY: "test-key",
|
||||
}));
|
||||
|
||||
const mockSurvey: TSurvey = {
|
||||
id: "survey-1",
|
||||
createdAt: new Date(),
|
||||
@@ -206,4 +214,119 @@ describe("checkSurveyValidity", () => {
|
||||
const result = await checkSurveyValidity(survey, "env-1", mockResponseInput);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should return badRequestResponse if singleUse is enabled and singleUseId is missing", async () => {
|
||||
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
|
||||
const result = await checkSurveyValidity(survey, "env-1", { ...mockResponseInput });
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result?.status).toBe(400);
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("Missing single use id", {
|
||||
surveyId: survey.id,
|
||||
environmentId: "env-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return badRequestResponse if singleUse is enabled and meta.url is missing", async () => {
|
||||
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
|
||||
const result = await checkSurveyValidity(survey, "env-1", {
|
||||
...mockResponseInput,
|
||||
singleUseId: "su-1",
|
||||
meta: {},
|
||||
});
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result?.status).toBe(400);
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("Missing or invalid URL in response metadata", {
|
||||
surveyId: survey.id,
|
||||
environmentId: "env-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return badRequestResponse if meta.url is invalid", async () => {
|
||||
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
|
||||
const result = await checkSurveyValidity(survey, "env-1", {
|
||||
...mockResponseInput,
|
||||
singleUseId: "su-1",
|
||||
meta: { url: "not-a-url" },
|
||||
});
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result?.status).toBe(400);
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith(
|
||||
"Invalid URL in response metadata",
|
||||
expect.objectContaining({ surveyId: survey.id, environmentId: "env-1" })
|
||||
);
|
||||
});
|
||||
|
||||
test("should return badRequestResponse if suId is missing from url", async () => {
|
||||
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
|
||||
const url = "https://example.com/?foo=bar";
|
||||
const result = await checkSurveyValidity(survey, "env-1", {
|
||||
...mockResponseInput,
|
||||
singleUseId: "su-1",
|
||||
meta: { url },
|
||||
});
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result?.status).toBe(400);
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("Missing single use id", {
|
||||
surveyId: survey.id,
|
||||
environmentId: "env-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return badRequestResponse if isEncrypted and decrypted suId does not match singleUseId", async () => {
|
||||
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: true } };
|
||||
const url = "https://example.com/?suId=encrypted-id";
|
||||
vi.mocked(symmetricDecrypt).mockReturnValue("decrypted-id");
|
||||
const resultEncryptedMismatch = await checkSurveyValidity(survey, "env-1", {
|
||||
...mockResponseInput,
|
||||
singleUseId: "su-1",
|
||||
meta: { url },
|
||||
});
|
||||
expect(symmetricDecrypt).toHaveBeenCalledWith("encrypted-id", "test-key");
|
||||
expect(resultEncryptedMismatch).toBeInstanceOf(Response);
|
||||
expect(resultEncryptedMismatch?.status).toBe(400);
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("Invalid single use id", {
|
||||
surveyId: survey.id,
|
||||
environmentId: "env-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return badRequestResponse if not encrypted and suId does not match singleUseId", async () => {
|
||||
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
|
||||
const url = "https://example.com/?suId=su-2";
|
||||
const result = await checkSurveyValidity(survey, "env-1", {
|
||||
...mockResponseInput,
|
||||
singleUseId: "su-1",
|
||||
meta: { url },
|
||||
});
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result?.status).toBe(400);
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("Invalid single use id", {
|
||||
surveyId: survey.id,
|
||||
environmentId: "env-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return null if singleUse is enabled, not encrypted, and suId matches singleUseId", async () => {
|
||||
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
|
||||
const url = "https://example.com/?suId=su-1";
|
||||
const result = await checkSurveyValidity(survey, "env-1", {
|
||||
...mockResponseInput,
|
||||
singleUseId: "su-1",
|
||||
meta: { url },
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should return null if singleUse is enabled, encrypted, and decrypted suId matches singleUseId", async () => {
|
||||
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: true } };
|
||||
const url = "https://example.com/?suId=encrypted-id";
|
||||
vi.mocked(symmetricDecrypt).mockReturnValue("su-1");
|
||||
const _resultEncryptedMatch = await checkSurveyValidity(survey, "env-1", {
|
||||
...mockResponseInput,
|
||||
singleUseId: "su-1",
|
||||
meta: { url },
|
||||
});
|
||||
expect(symmetricDecrypt).toHaveBeenCalledWith("encrypted-id", "test-key");
|
||||
expect(_resultEncryptedMatch).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ import { getOrganizationBillingByEnvironmentId } from "@/app/api/v2/client/[envi
|
||||
import { verifyRecaptchaToken } from "@/app/api/v2/client/[environmentId]/responses/lib/recaptcha";
|
||||
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { ENCRYPTION_KEY } from "@/lib/constants";
|
||||
import { symmetricDecrypt } from "@/lib/crypto";
|
||||
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
@@ -24,6 +26,55 @@ export const checkSurveyValidity = async (
|
||||
);
|
||||
}
|
||||
|
||||
if (survey.singleUse?.enabled) {
|
||||
if (!responseInput.singleUseId) {
|
||||
return responses.badRequestResponse("Missing single use id", {
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
});
|
||||
}
|
||||
|
||||
if (!responseInput.meta?.url) {
|
||||
return responses.badRequestResponse("Missing or invalid URL in response metadata", {
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
});
|
||||
}
|
||||
|
||||
let url;
|
||||
try {
|
||||
url = new URL(responseInput.meta.url);
|
||||
} catch (error) {
|
||||
return responses.badRequestResponse("Invalid URL in response metadata", {
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
const suId = url.searchParams.get("suId");
|
||||
if (!suId) {
|
||||
return responses.badRequestResponse("Missing single use id", {
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
});
|
||||
}
|
||||
|
||||
if (survey.singleUse.isEncrypted) {
|
||||
const decryptedSuId = symmetricDecrypt(suId, ENCRYPTION_KEY);
|
||||
if (decryptedSuId !== responseInput.singleUseId) {
|
||||
return responses.badRequestResponse("Invalid single use id", {
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
});
|
||||
}
|
||||
} else if (responseInput.singleUseId !== suId) {
|
||||
return responses.badRequestResponse("Invalid single use id", {
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (survey.recaptcha?.enabled) {
|
||||
if (!responseInput.recaptchaToken) {
|
||||
logger.error("Missing recaptcha token");
|
||||
|
||||
@@ -282,4 +282,4 @@ export const SENTRY_DSN = env.SENTRY_DSN;
|
||||
|
||||
export const PROMETHEUS_ENABLED = env.PROMETHEUS_ENABLED === "1";
|
||||
|
||||
export const DISABLE_USER_MANAGEMENT = env.DISABLE_USER_MANAGEMENT === "1";
|
||||
export const USER_MANAGEMENT_MINIMUM_ROLE = env.USER_MANAGEMENT_MINIMUM_ROLE ?? "manager";
|
||||
|
||||
@@ -104,7 +104,7 @@ export const env = createEnv({
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).optional(),
|
||||
PROMETHEUS_EXPORTER_PORT: z.string().optional(),
|
||||
PROMETHEUS_ENABLED: z.enum(["1", "0"]).optional(),
|
||||
DISABLE_USER_MANAGEMENT: z.enum(["1", "0"]).optional(),
|
||||
USER_MANAGEMENT_MINIMUM_ROLE: z.enum(["owner", "manager", "disabled"]).optional(),
|
||||
},
|
||||
|
||||
/*
|
||||
@@ -199,6 +199,6 @@ export const env = createEnv({
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
PROMETHEUS_ENABLED: process.env.PROMETHEUS_ENABLED,
|
||||
PROMETHEUS_EXPORTER_PORT: process.env.PROMETHEUS_EXPORTER_PORT,
|
||||
DISABLE_USER_MANAGEMENT: process.env.DISABLE_USER_MANAGEMENT,
|
||||
USER_MANAGEMENT_MINIMUM_ROLE: process.env.USER_MANAGEMENT_MINIMUM_ROLE,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -13,3 +13,21 @@ export const getAccessFlags = (role?: TOrganizationRole) => {
|
||||
isMember,
|
||||
};
|
||||
};
|
||||
|
||||
export const getUserManagementAccess = (
|
||||
role: TOrganizationRole,
|
||||
minimumRole: "owner" | "manager" | "disabled"
|
||||
): boolean => {
|
||||
// If minimum role is "disabled", no one has access
|
||||
if (minimumRole === "disabled") {
|
||||
return false;
|
||||
}
|
||||
if (minimumRole === "owner") {
|
||||
return role === "owner";
|
||||
}
|
||||
|
||||
if (minimumRole === "manager") {
|
||||
return role === "owner" || role === "manager";
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -533,6 +533,7 @@ export const updateResponse = async (
|
||||
id: response.id,
|
||||
contactId: response.contact?.id,
|
||||
surveyId: response.surveyId,
|
||||
...(response.singleUseId ? { singleUseId: response.singleUseId } : {}),
|
||||
});
|
||||
|
||||
responseNoteCache.revalidate({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
export const useIntervalWhenFocused = (
|
||||
callback: () => void,
|
||||
@@ -8,7 +8,7 @@ export const useIntervalWhenFocused = (
|
||||
) => {
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleFocus = () => {
|
||||
const handleFocus = useCallback(() => {
|
||||
if (isActive) {
|
||||
if (shouldExecuteImmediately) {
|
||||
// Execute the callback immediately when the tab comes into focus
|
||||
@@ -20,7 +20,7 @@ export const useIntervalWhenFocused = (
|
||||
callback();
|
||||
}, intervalDuration);
|
||||
}
|
||||
};
|
||||
}, [isActive, intervalDuration, callback, shouldExecuteImmediately]);
|
||||
|
||||
const handleBlur = () => {
|
||||
// Clear the interval when the tab loses focus
|
||||
@@ -46,7 +46,7 @@ export const useIntervalWhenFocused = (
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
window.removeEventListener("blur", handleBlur);
|
||||
};
|
||||
}, [isActive, intervalDuration]);
|
||||
}, [isActive, intervalDuration, handleFocus]);
|
||||
};
|
||||
|
||||
export default useIntervalWhenFocused;
|
||||
|
||||
@@ -12,13 +12,12 @@ import {
|
||||
isClientSideApiRoute,
|
||||
isForgotPasswordRoute,
|
||||
isLoginRoute,
|
||||
isManagementApiRoute,
|
||||
isShareUrlRoute,
|
||||
isSignupRoute,
|
||||
isSyncWithUserIdentificationEndpoint,
|
||||
isVerifyEmailRoute,
|
||||
} from "@/app/middleware/endpoint-validator";
|
||||
import { E2E_TESTING, IS_PRODUCTION, RATE_LIMITING_DISABLED, SURVEY_URL, WEBAPP_URL } from "@/lib/constants";
|
||||
import { IS_PRODUCTION, RATE_LIMITING_DISABLED, SURVEY_URL, WEBAPP_URL } from "@/lib/constants";
|
||||
import { isValidCallbackUrl } from "@/lib/utils/url";
|
||||
import { logApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
@@ -28,24 +27,6 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
const enforceHttps = (request: NextRequest): Response | null => {
|
||||
const forwardedProto = request.headers.get("x-forwarded-proto") ?? "http";
|
||||
if (IS_PRODUCTION && !E2E_TESTING && forwardedProto !== "https") {
|
||||
const apiError: ApiErrorResponseV2 = {
|
||||
type: "forbidden",
|
||||
details: [
|
||||
{
|
||||
field: "",
|
||||
issue: "Only HTTPS connections are allowed on the management endpoints.",
|
||||
},
|
||||
],
|
||||
};
|
||||
logApiError(request, apiError);
|
||||
return NextResponse.json(apiError, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleAuth = async (request: NextRequest): Promise<Response | null> => {
|
||||
const token = await getToken({ req: request as any });
|
||||
|
||||
@@ -132,12 +113,6 @@ export const middleware = async (originalRequest: NextRequest) => {
|
||||
},
|
||||
});
|
||||
|
||||
// Enforce HTTPS for management endpoints
|
||||
if (isManagementApiRoute(request.nextUrl.pathname)) {
|
||||
const httpsResponse = enforceHttps(request);
|
||||
if (httpsResponse) return httpsResponse;
|
||||
}
|
||||
|
||||
// Handle authentication
|
||||
const authResponse = await handleAuth(request);
|
||||
if (authResponse) return authResponse;
|
||||
|
||||
@@ -11,7 +11,7 @@ export const SurveyLinkDisplay = ({ surveyUrl }: SurveyLinkDisplayProps) => {
|
||||
<Input
|
||||
data-testid="survey-url-input"
|
||||
autoFocus={true}
|
||||
className="mt-2 w-full min-w-96 rounded-lg border bg-white px-4 py-2 text-ellipsis text-slate-800 caret-transparent"
|
||||
className="mt-2 w-full min-w-96 text-ellipsis rounded-lg border bg-white px-4 py-2 text-slate-800 caret-transparent"
|
||||
value={surveyUrl}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -39,7 +39,7 @@ export const QuestionSkip = ({
|
||||
background:
|
||||
"repeating-linear-gradient(rgb(148, 163, 184), rgb(148, 163, 184) 5px, transparent 5px, transparent 8px)", // adjust the values to fit your design
|
||||
}}>
|
||||
<CheckCircle2Icon className="absolute top-0 w-[1.5rem] min-w-[1.5rem] rounded-full bg-white p-0.25 text-slate-400" />
|
||||
<CheckCircle2Icon className="p-0.25 absolute top-0 w-[1.5rem] min-w-[1.5rem] rounded-full bg-white text-slate-400" />
|
||||
</div>
|
||||
}
|
||||
<div className="ml-6 flex flex-col text-slate-700">{t("common.welcome_card")}</div>
|
||||
|
||||
@@ -101,7 +101,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
|
||||
return (
|
||||
<p
|
||||
key={rowValueInSelectedLanguage}
|
||||
className="ph-no-capture my-1 font-normal text-slate-700 capitalize">
|
||||
className="ph-no-capture my-1 font-normal capitalize text-slate-700">
|
||||
{rowValueInSelectedLanguage}:{processResponseData(responseData[rowValueInSelectedLanguage])}
|
||||
</p>
|
||||
);
|
||||
|
||||
@@ -104,10 +104,10 @@ export const ResponseNotes = ({
|
||||
!isOpen && unresolvedNotes.length && "group/hint cursor-pointer bg-white hover:-right-3",
|
||||
!isOpen && !unresolvedNotes.length && "cursor-pointer bg-slate-50",
|
||||
isOpen
|
||||
? "top-0 -right-2 h-5/6 max-h-[600px] w-1/4 bg-white"
|
||||
? "-right-2 top-0 h-5/6 max-h-[600px] w-1/4 bg-white"
|
||||
: unresolvedNotes.length
|
||||
? "top-[8.33%] right-0 h-5/6 max-h-[600px] w-1/12"
|
||||
: "top-[8.333%] right-[120px] h-5/6 max-h-[600px] w-1/12 group-hover:right-[0]"
|
||||
? "right-0 top-[8.33%] h-5/6 max-h-[600px] w-1/12"
|
||||
: "right-[120px] top-[8.333%] h-5/6 max-h-[600px] w-1/12 group-hover:right-[0]"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isOpen) setIsOpen(true);
|
||||
@@ -116,7 +116,7 @@ export const ResponseNotes = ({
|
||||
<div className="flex h-full flex-col">
|
||||
<div
|
||||
className={clsx(
|
||||
"space-y-2 rounded-t-lg px-2 pt-2 pb-2",
|
||||
"space-y-2 rounded-t-lg px-2 pb-2 pt-2",
|
||||
unresolvedNotes.length ? "flex h-12 items-center justify-end bg-amber-50" : "bg-slate-200"
|
||||
)}>
|
||||
{!unresolvedNotes.length ? (
|
||||
@@ -127,7 +127,7 @@ export const ResponseNotes = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className="float-left mr-1.5">
|
||||
<Maximize2Icon className="h-4 w-4 text-amber-500 group-hover/hint:scale-110 hover:text-amber-600" />
|
||||
<Maximize2Icon className="h-4 w-4 text-amber-500 hover:text-amber-600 group-hover/hint:scale-110" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -141,7 +141,7 @@ export const ResponseNotes = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative flex h-full flex-col">
|
||||
<div className="rounded-t-lg bg-amber-50 px-4 pt-4 pb-3">
|
||||
<div className="rounded-t-lg bg-amber-50 px-4 pb-3 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="group flex items-center">
|
||||
<h3 className="pb-1 text-sm text-amber-500">{t("common.note")}</h3>
|
||||
|
||||
@@ -37,7 +37,7 @@ export const SingleResponseCardBody = ({
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className="mr-0.5 ml-0.5 rounded-md border border-slate-200 bg-slate-50 px-1 py-0.5 text-sm first:ml-0">
|
||||
className="ml-0.5 mr-0.5 rounded-md border border-slate-200 bg-slate-50 px-1 py-0.5 text-sm first:ml-0">
|
||||
@{part}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -153,7 +153,7 @@ export const SingleResponseCardHeader = ({
|
||||
const deleteSubmissionToolTip = <>{t("environments.surveys.responses.this_response_is_in_progress")}</>;
|
||||
|
||||
return (
|
||||
<div className="space-y-2 border-b border-slate-200 px-6 pt-4 pb-4">
|
||||
<div className="space-y-2 border-b border-slate-200 px-6 pb-4 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-center space-x-4">
|
||||
{pageType === "response" && (
|
||||
|
||||
@@ -111,6 +111,7 @@ export const updateResponse = async (
|
||||
responseCache.revalidate({
|
||||
id: updatedResponse.id,
|
||||
surveyId: updatedResponse.surveyId,
|
||||
...(updatedResponse.singleUseId ? { singleUseId: updatedResponse.singleUseId } : {}),
|
||||
});
|
||||
|
||||
responseNoteCache.revalidate({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { response, responseId, responseInput, survey } from "./__mocks__/response.mock";
|
||||
import { responseCache } from "@/lib/response/cache";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
@@ -21,6 +22,16 @@ vi.mock("../utils", () => ({
|
||||
findAndDeleteUploadedFilesInResponse: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/response/cache", () => ({
|
||||
responseCache: {
|
||||
revalidate: vi.fn(),
|
||||
tag: {
|
||||
byId: vi.fn(),
|
||||
byResponseId: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
response: {
|
||||
@@ -175,7 +186,7 @@ describe("Response Lib", () => {
|
||||
});
|
||||
|
||||
describe("updateResponse", () => {
|
||||
test("update the response and revalidate caches", async () => {
|
||||
test("update the response and revalidate caches including singleUseId", async () => {
|
||||
vi.mocked(prisma.response.update).mockResolvedValue(response);
|
||||
|
||||
const result = await updateResponse(responseId, responseInput);
|
||||
@@ -184,12 +195,39 @@ describe("Response Lib", () => {
|
||||
data: responseInput,
|
||||
});
|
||||
|
||||
expect(responseCache.revalidate).toHaveBeenCalledWith({
|
||||
id: response.id,
|
||||
surveyId: response.surveyId,
|
||||
singleUseId: response.singleUseId,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(response);
|
||||
}
|
||||
});
|
||||
|
||||
test("update the response and revalidate caches", async () => {
|
||||
const responseWithoutSingleUseId = { ...response, singleUseId: null };
|
||||
vi.mocked(prisma.response.update).mockResolvedValue(responseWithoutSingleUseId);
|
||||
|
||||
const result = await updateResponse(responseId, responseInput);
|
||||
expect(prisma.response.update).toHaveBeenCalledWith({
|
||||
where: { id: responseId },
|
||||
data: responseInput,
|
||||
});
|
||||
|
||||
expect(responseCache.revalidate).toHaveBeenCalledWith({
|
||||
id: response.id,
|
||||
surveyId: response.surveyId,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(responseWithoutSingleUseId);
|
||||
}
|
||||
});
|
||||
|
||||
test("return a not_found error when the response is not found", async () => {
|
||||
vi.mocked(prisma.response.update).mockRejectedValue(
|
||||
new PrismaClientKnownRequestError("Response not found", {
|
||||
|
||||
@@ -14,7 +14,7 @@ export const VerificationRequestedPage = async ({ searchParams }) => {
|
||||
return (
|
||||
<FormWrapper>
|
||||
<>
|
||||
<h1 className="mb-4 text-center text-lg leading-2 font-semibold text-slate-900">
|
||||
<h1 className="leading-2 mb-4 text-center text-lg font-semibold text-slate-900">
|
||||
{t("auth.verification-requested.please_confirm_your_email_address")}
|
||||
</h1>
|
||||
<p className="text-center text-sm text-slate-700">
|
||||
|
||||
@@ -19,7 +19,7 @@ export const BillingSlider = React.forwardRef<React.ElementRef<typeof SliderPrim
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative flex w-full touch-none items-center select-none", className)}
|
||||
className={cn("relative flex w-full touch-none select-none items-center", className)}
|
||||
{...props}>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-r-full bg-slate-300">
|
||||
<div
|
||||
|
||||
@@ -126,7 +126,7 @@ export const PricingCard = ({
|
||||
id={plan.id}
|
||||
className={cn(
|
||||
plan.featured ? "text-slate-900" : "text-slate-800",
|
||||
"text-sm leading-6 font-semibold"
|
||||
"text-sm font-semibold leading-6"
|
||||
)}>
|
||||
{t(plan.name)}
|
||||
</h2>
|
||||
|
||||
@@ -41,7 +41,7 @@ export const SingleContactPage = async (props: {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={getContactIdentifier(contactAttributes)} cta={getDeletePersonButton()} />
|
||||
<section className="pt-6 pb-24">
|
||||
<section className="pb-24 pt-6">
|
||||
<div className="grid grid-cols-4 gap-x-8">
|
||||
<AttributesSection contactId={params.contactId} />
|
||||
<ResponseSection
|
||||
|
||||
@@ -312,7 +312,7 @@ function AttributeSegmentFilter({
|
||||
}}
|
||||
value={attrKeyValue}>
|
||||
<SelectTrigger
|
||||
className="flex w-auto items-center justify-center bg-white whitespace-nowrap capitalize"
|
||||
className="flex w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
|
||||
hideArrow>
|
||||
<SelectValue>
|
||||
<div className={cn("flex items-center gap-2", !isCapitalized(attrKeyValue ?? "") && "lowercase")}>
|
||||
@@ -494,7 +494,7 @@ function PersonSegmentFilter({
|
||||
}}
|
||||
value={personIdentifier}>
|
||||
<SelectTrigger
|
||||
className="flex w-auto items-center justify-center bg-white whitespace-nowrap capitalize"
|
||||
className="flex w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
|
||||
hideArrow>
|
||||
<SelectValue>
|
||||
<div className="flex items-center gap-1 lowercase">
|
||||
@@ -643,7 +643,7 @@ function SegmentSegmentFilter({
|
||||
}}
|
||||
value={currentSegment?.id}>
|
||||
<SelectTrigger
|
||||
className="flex w-auto items-center justify-center bg-white whitespace-nowrap capitalize"
|
||||
className="flex w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
|
||||
hideArrow>
|
||||
<div className="flex items-center gap-1">
|
||||
<Users2Icon className="h-4 w-4 text-sm" />
|
||||
|
||||
@@ -171,7 +171,7 @@ export function TargetingCard({
|
||||
asChild
|
||||
className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50">
|
||||
<div className="inline-flex px-4 py-6">
|
||||
<div className="flex items-center pr-5 pl-2">
|
||||
<div className="flex items-center pl-2 pr-5">
|
||||
<CheckIcon
|
||||
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
|
||||
strokeWidth={3}
|
||||
|
||||
@@ -228,7 +228,7 @@ export function EditLanguage({
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500 italic">
|
||||
<p className="text-sm italic text-slate-500">
|
||||
{t("environments.project.languages.no_language_found")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -44,7 +44,7 @@ export function LanguageIndicator({
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="absolute top-2 right-2">
|
||||
<div className="absolute right-2 top-2">
|
||||
<button
|
||||
aria-expanded={showLanguageDropdown}
|
||||
aria-haspopup="true"
|
||||
|
||||
@@ -65,7 +65,7 @@ export function LanguageSelect({ language, onLanguageChange, disabled, locale }:
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<div
|
||||
className={`ring-opacity-5 absolute right-0 z-30 mt-2 space-y-1 rounded-md bg-white p-1 shadow-lg ring-1 ring-black ${isOpen ? "" : "hidden"}`}>
|
||||
className={`absolute right-0 z-30 mt-2 space-y-1 rounded-md bg-white p-1 shadow-lg ring-1 ring-black ring-opacity-5 ${isOpen ? "" : "hidden"}`}>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
onChange={(e) => {
|
||||
|
||||
@@ -186,7 +186,7 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
|
||||
<div
|
||||
className={cn(
|
||||
open ? "bg-slate-50" : "bg-white group-hover:bg-slate-50",
|
||||
"flex w-10 items-center justify-center rounded-l-lg border-t border-b border-l group-aria-expanded:rounded-bl-none"
|
||||
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none"
|
||||
)}>
|
||||
<p>
|
||||
<Languages className="h-6 w-6 rounded-full bg-indigo-500 p-1 text-white" />
|
||||
@@ -248,7 +248,7 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
|
||||
) : (
|
||||
<>
|
||||
{projectLanguages.length <= 1 && (
|
||||
<div className="mb-4 text-sm text-slate-500 italic">
|
||||
<div className="mb-4 text-sm italic text-slate-500">
|
||||
{projectLanguages.length === 0
|
||||
? t("environments.surveys.edit.no_languages_found_add_first_one_to_get_started")
|
||||
: t(
|
||||
@@ -260,7 +260,7 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
|
||||
<div className="my-4 space-y-4">
|
||||
<div>
|
||||
{isMultiLanguageAllowed && !isMultiLanguageActivated ? (
|
||||
<div className="text-sm text-slate-500 italic">
|
||||
<div className="text-sm italic text-slate-500">
|
||||
{t("environments.surveys.edit.switch_multi_lanugage_on_to_get_started")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -15,14 +15,14 @@ import { AuthenticationError, OperationNotAllowedError, ValidationError } from "
|
||||
|
||||
// Mock constants with getter functions to allow overriding in tests
|
||||
let mockIsFormbricksCloud = false;
|
||||
let mockDisableUserManagement = false;
|
||||
let mockUserManagementMinimumRole = "owner";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
get IS_FORMBRICKS_CLOUD() {
|
||||
return mockIsFormbricksCloud;
|
||||
},
|
||||
get DISABLE_USER_MANAGEMENT() {
|
||||
return mockDisableUserManagement;
|
||||
get USER_MANAGEMENT_MINIMUM_ROLE() {
|
||||
return mockUserManagementMinimumRole;
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -62,7 +62,7 @@ describe("Role Management Actions", () => {
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockIsFormbricksCloud = false;
|
||||
mockDisableUserManagement = false;
|
||||
mockUserManagementMinimumRole = "owner";
|
||||
});
|
||||
|
||||
describe("checkRoleManagementPermission", () => {
|
||||
@@ -220,7 +220,7 @@ describe("Role Management Actions", () => {
|
||||
|
||||
test("throws error if user management is disabled", async () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "owner" } as any);
|
||||
mockDisableUserManagement = true;
|
||||
mockUserManagementMinimumRole = "disabled";
|
||||
|
||||
await expect(
|
||||
updateMembershipAction({
|
||||
@@ -231,12 +231,12 @@ describe("Role Management Actions", () => {
|
||||
data: { role: "member" },
|
||||
},
|
||||
} as any)
|
||||
).rejects.toThrow(new OperationNotAllowedError("User management is disabled"));
|
||||
).rejects.toThrow(new OperationNotAllowedError("User management is not allowed for your role"));
|
||||
});
|
||||
|
||||
test("throws error if billing role is not allowed in self-hosted", async () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "owner" } as any);
|
||||
mockDisableUserManagement = false;
|
||||
mockUserManagementMinimumRole = "owner";
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
@@ -253,7 +253,7 @@ describe("Role Management Actions", () => {
|
||||
|
||||
test("allows billing role in cloud environment", async () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "owner" } as any);
|
||||
mockDisableUserManagement = false;
|
||||
mockUserManagementMinimumRole = "owner";
|
||||
mockIsFormbricksCloud = true;
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
vi.mocked(getOrganization).mockResolvedValue({ billing: { plan: "pro" } } as any);
|
||||
@@ -274,7 +274,7 @@ describe("Role Management Actions", () => {
|
||||
|
||||
test("throws error if manager tries to assign a role other than member", async () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "manager" } as any);
|
||||
mockDisableUserManagement = false;
|
||||
mockUserManagementMinimumRole = "manager";
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
@@ -291,7 +291,7 @@ describe("Role Management Actions", () => {
|
||||
|
||||
test("allows manager to assign member role", async () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "manager" } as any);
|
||||
mockDisableUserManagement = false;
|
||||
mockUserManagementMinimumRole = "manager";
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
vi.mocked(getOrganization).mockResolvedValue({ billing: { plan: "pro" } } as any);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
|
||||
@@ -312,7 +312,7 @@ describe("Role Management Actions", () => {
|
||||
|
||||
test("successful membership update as owner", async () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "owner" } as any);
|
||||
mockDisableUserManagement = false;
|
||||
mockUserManagementMinimumRole = "owner";
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
vi.mocked(getOrganization).mockResolvedValue({ billing: { plan: "pro" } } as any);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use server";
|
||||
|
||||
import { DISABLE_USER_MANAGEMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { IS_FORMBRICKS_CLOUD, USER_MANAGEMENT_MINIMUM_ROLE } from "@/lib/constants";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getUserManagementAccess } from "@/lib/membership/utils";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
@@ -87,8 +88,13 @@ export const updateMembershipAction = authenticatedActionClient
|
||||
if (!currentUserMembership) {
|
||||
throw new AuthenticationError("User not a member of this organization");
|
||||
}
|
||||
if (DISABLE_USER_MANAGEMENT) {
|
||||
throw new OperationNotAllowedError("User management is disabled");
|
||||
const hasUserManagementAccess = getUserManagementAccess(
|
||||
currentUserMembership.role,
|
||||
USER_MANAGEMENT_MINIMUM_ROLE
|
||||
);
|
||||
|
||||
if (!hasUserManagementAccess) {
|
||||
throw new OperationNotAllowedError("User management is not allowed for your role");
|
||||
}
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { TInviteUpdateInput } from "@/modules/ee/role-management/types/invites";
|
||||
import { Session } from "next-auth";
|
||||
import { TMembership, TMembershipUpdateInput } from "@formbricks/types/memberships";
|
||||
import { TOrganization, TOrganizationBillingPlan } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
|
||||
// Common mock IDs
|
||||
export const mockOrganizationId = "cblt7dwr7d0hvdifl4iw6d5x";
|
||||
export const mockUserId = "wl43gybf3pxmqqx3fcmsk8eb";
|
||||
export const mockInviteId = "dc0b6ea6-bb65-4a22-88e1-847df2e85af4";
|
||||
export const mockTargetUserId = "vevt9qm7sqmh44e3za6a2vzd";
|
||||
|
||||
// Mock user
|
||||
export const mockUser: TUser = {
|
||||
id: mockUserId,
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
identityProvider: "email",
|
||||
twoFactorEnabled: false,
|
||||
objective: null,
|
||||
notificationSettings: {
|
||||
alert: {},
|
||||
weeklySummary: {},
|
||||
},
|
||||
locale: "en-US",
|
||||
imageUrl: null,
|
||||
role: null,
|
||||
lastLoginAt: new Date(),
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
// Mock session
|
||||
export const mockSession: Session = {
|
||||
user: {
|
||||
id: mockUserId,
|
||||
},
|
||||
expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||
};
|
||||
|
||||
// Mock organizations
|
||||
export const createMockOrganization = (plan: TOrganizationBillingPlan): TOrganization => ({
|
||||
id: mockOrganizationId,
|
||||
name: "Test Organization",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isAIEnabled: false,
|
||||
billing: {
|
||||
stripeCustomerId: null,
|
||||
plan,
|
||||
period: "monthly",
|
||||
periodStart: new Date(),
|
||||
limits: {
|
||||
projects: plan === "free" ? 3 : null,
|
||||
monthly: {
|
||||
responses: plan === "free" ? 1500 : null,
|
||||
miu: plan === "free" ? 2000 : null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const mockOrganizationFree = createMockOrganization("free");
|
||||
export const mockOrganizationStartup = createMockOrganization("startup");
|
||||
export const mockOrganizationScale = createMockOrganization("scale");
|
||||
|
||||
// Mock membership data
|
||||
export const createMockMembership = (role: TMembership["role"]): TMembership => ({
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrganizationId,
|
||||
role,
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
export const mockMembershipMember = createMockMembership("member");
|
||||
export const mockMembershipManager = createMockMembership("manager");
|
||||
export const mockMembershipOwner = createMockMembership("owner");
|
||||
|
||||
// Mock data payloads
|
||||
export const mockInviteDataMember: TInviteUpdateInput = { role: "member" };
|
||||
export const mockInviteDataOwner: TInviteUpdateInput = { role: "owner" };
|
||||
export const mockInviteDataBilling: TInviteUpdateInput = { role: "billing" };
|
||||
|
||||
export const mockMembershipUpdateMember: TMembershipUpdateInput = { role: "member" };
|
||||
export const mockMembershipUpdateOwner: TMembershipUpdateInput = { role: "owner" };
|
||||
export const mockMembershipUpdateBilling: TMembershipUpdateInput = { role: "billing" };
|
||||
|
||||
// Mock input objects for actions
|
||||
export const mockUpdateInviteInput = {
|
||||
inviteId: mockInviteId,
|
||||
organizationId: mockOrganizationId,
|
||||
data: mockInviteDataMember,
|
||||
};
|
||||
|
||||
export const mockUpdateMembershipInput = {
|
||||
userId: mockTargetUserId,
|
||||
organizationId: mockOrganizationId,
|
||||
data: mockMembershipUpdateMember,
|
||||
};
|
||||
|
||||
// Mock responses
|
||||
export const mockUpdatedMembership: TMembership = {
|
||||
userId: mockTargetUserId,
|
||||
organizationId: mockOrganizationId,
|
||||
role: "member",
|
||||
accepted: true,
|
||||
};
|
||||
@@ -1,257 +0,0 @@
|
||||
import {
|
||||
mockInviteDataBilling,
|
||||
mockInviteDataOwner,
|
||||
mockMembershipManager,
|
||||
mockMembershipMember,
|
||||
mockMembershipUpdateBilling,
|
||||
mockMembershipUpdateOwner,
|
||||
mockOrganizationFree,
|
||||
mockOrganizationId,
|
||||
mockOrganizationScale,
|
||||
mockOrganizationStartup,
|
||||
mockSession,
|
||||
mockUpdateInviteInput,
|
||||
mockUpdateMembershipInput,
|
||||
mockUpdatedMembership,
|
||||
mockUser,
|
||||
} from "./__mocks__/actions.mock";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import "@/lib/utils/action-client-middleware";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { updateInvite } from "@/modules/ee/role-management/lib/invite";
|
||||
import { updateMembership } from "@/modules/ee/role-management/lib/membership";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { OperationNotAllowedError, ValidationError } from "@formbricks/types/errors";
|
||||
import { checkRoleManagementPermission } from "../actions";
|
||||
import { updateInviteAction, updateMembershipAction } from "../actions";
|
||||
|
||||
// Mock all external dependencies
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getRoleManagementPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/role-management/lib/invite", () => ({
|
||||
updateInvite: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/user/service", () => ({
|
||||
getUser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/role-management/lib/membership", () => ({
|
||||
updateMembership: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/membership/service", () => ({
|
||||
getMembershipByUserIdOrganizationId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
getOrganization: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/action-client-middleware", () => ({
|
||||
checkAuthorizationUpdated: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock constants without importing the actual module
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
IS_MULTI_ORG_ENABLED: true,
|
||||
ENCRYPTION_KEY: "test-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "test-enterprise-license-key",
|
||||
GITHUB_ID: "test-github-id",
|
||||
GITHUB_SECRET: "test-github-secret",
|
||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "test-azure-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "test-azure-client-secret",
|
||||
AZUREAD_TENANT_ID: "test-azure-tenant-id",
|
||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_ISSUER: "test-oidc-issuer",
|
||||
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-algorithm",
|
||||
SAML_DATABASE_URL: "test-saml-db-url",
|
||||
NEXTAUTH_SECRET: "test-nextauth-secret",
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
DISABLE_USER_MANAGEMENT: false,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/action-client-middleware", () => ({
|
||||
checkAuthorizationUpdated: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/errors", () => ({
|
||||
OperationNotAllowedError: vi.fn(),
|
||||
ValidationError: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("role-management/actions.ts", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("checkRoleManagementPermission", () => {
|
||||
test("throws error when organization not found", async () => {
|
||||
vi.mocked(getOrganization).mockResolvedValue(null);
|
||||
|
||||
await expect(checkRoleManagementPermission(mockOrganizationId)).rejects.toThrow(
|
||||
"Organization not found"
|
||||
);
|
||||
|
||||
expect(getOrganization).toHaveBeenCalledWith(mockOrganizationId);
|
||||
});
|
||||
|
||||
test("throws error when role management is not allowed", async () => {
|
||||
vi.mocked(getOrganization).mockResolvedValue(mockOrganizationFree);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValue(false);
|
||||
|
||||
await expect(checkRoleManagementPermission(mockOrganizationId)).rejects.toThrow(
|
||||
new OperationNotAllowedError("Role management is not allowed for this organization")
|
||||
);
|
||||
|
||||
expect(getRoleManagementPermission).toHaveBeenCalledWith("free");
|
||||
|
||||
expect(getOrganization).toHaveBeenCalledWith(mockOrganizationId);
|
||||
});
|
||||
|
||||
test("succeeds when role management is allowed", async () => {
|
||||
vi.mocked(getOrganization).mockResolvedValue(mockOrganizationStartup);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
|
||||
|
||||
await expect(checkRoleManagementPermission(mockOrganizationId)).resolves.toBeUndefined();
|
||||
await expect(getRoleManagementPermission).toHaveBeenCalledWith("startup");
|
||||
expect(getOrganization).toHaveBeenCalledWith(mockOrganizationId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateInviteAction", () => {
|
||||
test("throws error when user is not a member of the organization", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession);
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(null);
|
||||
|
||||
expect(await updateInviteAction(mockUpdateInviteInput)).toStrictEqual({
|
||||
serverError: "User not a member of this organization",
|
||||
});
|
||||
});
|
||||
|
||||
test("throws error when billing role is not allowed in self-hosted", async () => {
|
||||
const inputWithBillingRole = {
|
||||
...mockUpdateInviteInput,
|
||||
data: mockInviteDataBilling,
|
||||
};
|
||||
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession);
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembershipMember);
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
|
||||
expect(await updateInviteAction(inputWithBillingRole)).toStrictEqual({
|
||||
serverError: "Something went wrong while executing the operation.",
|
||||
});
|
||||
});
|
||||
|
||||
test("throws error when manager tries to assign non-member role", async () => {
|
||||
const inputWithOwnerRole = {
|
||||
...mockUpdateInviteInput,
|
||||
data: mockInviteDataOwner,
|
||||
};
|
||||
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession);
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembershipManager);
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
|
||||
expect(await updateInviteAction(inputWithOwnerRole)).toStrictEqual({
|
||||
serverError: "Managers can only invite members",
|
||||
});
|
||||
});
|
||||
|
||||
test("successfully updates invite", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession);
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembershipManager);
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
vi.mocked(getOrganization).mockResolvedValue(mockOrganizationScale);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
|
||||
vi.mocked(updateInvite).mockResolvedValue(true);
|
||||
|
||||
const result = await updateInviteAction(mockUpdateInviteInput);
|
||||
|
||||
expect(result).toEqual({ data: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateMembershipAction", () => {
|
||||
test("throws error when user is not a member of the organization", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession);
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(null);
|
||||
|
||||
expect(await updateMembershipAction(mockUpdateMembershipInput)).toStrictEqual({
|
||||
serverError: "User not a member of this organization",
|
||||
});
|
||||
});
|
||||
|
||||
test("throws error when billing role is not allowed in self-hosted", async () => {
|
||||
const inputWithBillingRole = {
|
||||
...mockUpdateMembershipInput,
|
||||
data: mockMembershipUpdateBilling,
|
||||
};
|
||||
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession);
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembershipMember);
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
|
||||
expect(await updateMembershipAction(inputWithBillingRole)).toStrictEqual({
|
||||
serverError: "Something went wrong while executing the operation.",
|
||||
});
|
||||
});
|
||||
|
||||
test("throws error when manager tries to assign non-member role", async () => {
|
||||
const inputWithOwnerRole = {
|
||||
...mockUpdateMembershipInput,
|
||||
data: mockMembershipUpdateOwner,
|
||||
};
|
||||
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession);
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembershipManager);
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
|
||||
expect(await updateMembershipAction(inputWithOwnerRole)).toStrictEqual({
|
||||
serverError: "Managers can only assign users to the member role",
|
||||
});
|
||||
});
|
||||
|
||||
test("successfully updates membership", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession);
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembershipManager);
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
vi.mocked(getOrganization).mockResolvedValue(mockOrganizationScale);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
|
||||
vi.mocked(updateMembership).mockResolvedValue(mockUpdatedMembership);
|
||||
|
||||
const result = await updateMembershipAction(mockUpdateMembershipInput);
|
||||
|
||||
expect(result).toEqual({
|
||||
data: mockUpdatedMembership,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -193,7 +193,7 @@ export const EmailCustomizationSettings = ({
|
||||
<div className="mb-10">
|
||||
<Small>{t("environments.settings.general.logo_in_email_header")}</Small>
|
||||
|
||||
<div className="mt-2 mb-6 flex items-center gap-4">
|
||||
<div className="mb-6 mt-2 flex items-center gap-4">
|
||||
{logoUrl && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex w-max items-center justify-center rounded-lg border border-slate-200 px-4 py-2">
|
||||
@@ -256,7 +256,7 @@ export const EmailCustomizationSettings = ({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shadow-card-xl min-h-52 w-[446px] rounded-t-lg border border-slate-100 px-10 pt-10 pb-4">
|
||||
<div className="shadow-card-xl min-h-52 w-[446px] rounded-t-lg border border-slate-100 px-10 pb-4 pt-10">
|
||||
<Image
|
||||
data-testid="email-customization-preview-image"
|
||||
src={logoUrl || fbLogoUrl}
|
||||
@@ -284,7 +284,7 @@ export const EmailCustomizationSettings = ({
|
||||
)}
|
||||
|
||||
{hasWhiteLabelPermission && isReadOnly && (
|
||||
<Alert variant="warning" className="mt-4 mb-6">
|
||||
<Alert variant="warning" className="mb-6 mt-4">
|
||||
<AlertDescription>
|
||||
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
|
||||
</AlertDescription>
|
||||
|
||||
@@ -10,11 +10,11 @@ interface QuestionHeaderProps {
|
||||
export function QuestionHeader({ headline, subheader, className }: QuestionHeaderProps): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Text className={cn("text-question-color m-0 block text-base leading-6 font-semibold", className)}>
|
||||
<Text className={cn("text-question-color m-0 block text-base font-semibold leading-6", className)}>
|
||||
{headline}
|
||||
</Text>
|
||||
{subheader && (
|
||||
<Text className="text-question-color m-0 block p-0 text-sm leading-6 font-normal">{subheader}</Text>
|
||||
<Text className="text-question-color m-0 block p-0 text-sm font-normal leading-6">{subheader}</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -72,8 +72,8 @@ export async function PreviewEmailTemplate({
|
||||
case TSurveyQuestionTypeEnum.Consent:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<Text className="text-question-color m-0 block text-base leading-6 font-semibold">{headline}</Text>
|
||||
<Container className="text-question-color m-0 text-sm leading-6 font-normal">
|
||||
<Text className="text-question-color m-0 block text-base font-semibold leading-6">{headline}</Text>
|
||||
<Container className="text-question-color m-0 text-sm font-normal leading-6">
|
||||
<div
|
||||
className="m-0 p-0"
|
||||
dangerouslySetInnerHTML={{
|
||||
@@ -131,7 +131,7 @@ export async function PreviewEmailTemplate({
|
||||
)}>
|
||||
{firstQuestion.isColorCodingEnabled ? (
|
||||
<Section
|
||||
className={`absolute top-0 left-0 h-[6px] w-full ${getNPSOptionColor(i)}`}
|
||||
className={`absolute left-0 top-0 h-[6px] w-full ${getNPSOptionColor(i)}`}
|
||||
/>
|
||||
) : null}
|
||||
{i}
|
||||
@@ -162,8 +162,8 @@ export async function PreviewEmailTemplate({
|
||||
case TSurveyQuestionTypeEnum.CTA:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<Text className="text-question-color m-0 block text-base leading-6 font-semibold">{headline}</Text>
|
||||
<Container className="text-question-color mt-2 ml-0 text-sm leading-6 font-normal">
|
||||
<Text className="text-question-color m-0 block text-base font-semibold leading-6">{headline}</Text>
|
||||
<Container className="text-question-color ml-0 mt-2 text-sm font-normal leading-6">
|
||||
<div
|
||||
className="m-0 p-0"
|
||||
dangerouslySetInnerHTML={{
|
||||
@@ -227,7 +227,7 @@ export async function PreviewEmailTemplate({
|
||||
<>
|
||||
{firstQuestion.isColorCodingEnabled ? (
|
||||
<Section
|
||||
className={`absolute top-0 left-0 h-[6px] w-full ${getRatingNumberOptionColor(firstQuestion.range, i + 1)}`}
|
||||
className={`absolute left-0 top-0 h-[6px] w-full ${getRatingNumberOptionColor(firstQuestion.range, i + 1)}`}
|
||||
/>
|
||||
) : null}
|
||||
<Text className="m-0 flex h-10 items-center">{i + 1}</Text>
|
||||
@@ -315,13 +315,13 @@ export async function PreviewEmailTemplate({
|
||||
{firstQuestion.choices.map((choice) =>
|
||||
firstQuestion.allowMulti ? (
|
||||
<Img
|
||||
className="rounded-custom mr-1 mb-1 inline-block h-[140px] w-[220px]"
|
||||
className="rounded-custom mb-1 mr-1 inline-block h-[140px] w-[220px]"
|
||||
key={choice.id}
|
||||
src={choice.imageUrl}
|
||||
/>
|
||||
) : (
|
||||
<Link
|
||||
className="rounded-custom mr-1 mb-1 inline-block h-[140px] w-[220px]"
|
||||
className="rounded-custom mb-1 mr-1 inline-block h-[140px] w-[220px]"
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
|
||||
key={choice.id}
|
||||
target="_blank">
|
||||
@@ -369,11 +369,11 @@ export async function PreviewEmailTemplate({
|
||||
<Container className="mx-0">
|
||||
<Section className="w-full table-auto">
|
||||
<Row>
|
||||
<Column className="w-40 px-4 py-2 break-words" />
|
||||
<Column className="w-40 break-words px-4 py-2" />
|
||||
{firstQuestion.columns.map((column) => {
|
||||
return (
|
||||
<Column
|
||||
className="text-question-color max-w-40 px-4 py-2 text-center break-words"
|
||||
className="text-question-color max-w-40 break-words px-4 py-2 text-center"
|
||||
key={getLocalizedValue(column, "default")}>
|
||||
{getLocalizedValue(column, "default")}
|
||||
</Column>
|
||||
@@ -385,7 +385,7 @@ export async function PreviewEmailTemplate({
|
||||
<Row
|
||||
className={`${rowIndex % 2 === 0 ? "bg-input-color" : ""} rounded-custom`}
|
||||
key={getLocalizedValue(row, "default")}>
|
||||
<Column className="w-40 px-4 py-2 break-words">
|
||||
<Column className="w-40 break-words px-4 py-2">
|
||||
{getLocalizedValue(row, "default")}
|
||||
</Column>
|
||||
{firstQuestion.columns.map((_) => {
|
||||
|
||||
@@ -15,7 +15,7 @@ export const renderEmailResponseValue = async (
|
||||
return (
|
||||
<Container>
|
||||
{overrideFileUploadResponse ? (
|
||||
<Text className="mt-0 font-bold break-words whitespace-pre-wrap italic">
|
||||
<Text className="mt-0 whitespace-pre-wrap break-words font-bold italic">
|
||||
{t("emails.render_email_response_value_file_upload_response_link_not_included")}
|
||||
</Text>
|
||||
) : (
|
||||
@@ -66,6 +66,6 @@ export const renderEmailResponseValue = async (
|
||||
);
|
||||
|
||||
default:
|
||||
return <Text className="mt-0 font-bold break-words whitespace-pre-wrap">{response}</Text>;
|
||||
return <Text className="mt-0 whitespace-pre-wrap break-words font-bold">{response}</Text>;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -66,7 +66,7 @@ export async function ResponseFinishedEmail({
|
||||
)}
|
||||
{variable.name}
|
||||
</Text>
|
||||
<Text className="mt-0 font-bold break-words whitespace-pre-wrap">
|
||||
<Text className="mt-0 whitespace-pre-wrap break-words font-bold">
|
||||
{variableResponse}
|
||||
</Text>
|
||||
</Column>
|
||||
@@ -84,7 +84,7 @@ export async function ResponseFinishedEmail({
|
||||
<Text className="mb-2 flex items-center gap-2 font-medium">
|
||||
{hiddenFieldId} <EyeOffIcon />
|
||||
</Text>
|
||||
<Text className="mt-0 font-bold break-words whitespace-pre-wrap">
|
||||
<Text className="mt-0 whitespace-pre-wrap break-words font-bold">
|
||||
{hiddenFieldResponse}
|
||||
</Text>
|
||||
</Column>
|
||||
|
||||
@@ -90,7 +90,7 @@ export const WebhookRowData = ({
|
||||
<div className="col-span-2 my-auto text-center text-sm text-slate-800">
|
||||
{renderSelectedTriggersText(webhook, t)}
|
||||
</div>
|
||||
<div className="col-span-2 my-auto text-center text-sm whitespace-nowrap text-slate-500">
|
||||
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
|
||||
{timeSince(webhook.createdAt.toString(), locale)}
|
||||
</div>
|
||||
<div className="text-center"></div>
|
||||
|
||||
@@ -162,7 +162,7 @@ export const EditAPIKeys = ({ organizationId, apiKeys, locale, isReadOnly, proje
|
||||
</div>
|
||||
<div className="grid-cols-9">
|
||||
{apiKeysLocal?.length === 0 ? (
|
||||
<div className="flex h-12 items-center justify-center px-6 text-sm font-medium whitespace-nowrap text-slate-400">
|
||||
<div className="flex h-12 items-center justify-center whitespace-nowrap px-6 text-sm font-medium text-slate-400">
|
||||
{t("environments.project.api_keys.no_api_keys_yet")}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -10,7 +10,7 @@ const LoadingCard = () => {
|
||||
return (
|
||||
<div className="w-full max-w-4xl rounded-xl border border-slate-200 bg-white py-4 shadow-sm">
|
||||
<div className="grid content-center border-b border-slate-200 px-4 pb-4 text-left text-slate-900">
|
||||
<h3 className="h-6 w-full max-w-56 animate-pulse rounded-lg bg-slate-100 text-lg leading-6 font-medium">
|
||||
<h3 className="h-6 w-full max-w-56 animate-pulse rounded-lg bg-slate-100 text-lg font-medium leading-6">
|
||||
<span className="sr-only">{t("common.loading")}</span>
|
||||
</h3>
|
||||
<p className="mt-3 h-4 w-full max-w-80 animate-pulse rounded-lg bg-slate-100 text-sm text-slate-500">
|
||||
|
||||
@@ -71,7 +71,7 @@ export const InviteMemberModal = ({
|
||||
<div className="sticky top-0 flex h-full flex-col rounded-lg">
|
||||
<button
|
||||
className={cn(
|
||||
"absolute top-0 right-0 hidden pt-4 pr-4 text-slate-400 hover:text-slate-500 focus:ring-0 focus:outline-none sm:block"
|
||||
"absolute right-0 top-0 hidden pr-4 pt-4 text-slate-400 hover:text-slate-500 focus:outline-none focus:ring-0 sm:block"
|
||||
)}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
|
||||
@@ -13,7 +13,7 @@ vi.mock(
|
||||
);
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
DISABLE_USER_MANAGEMENT: 0,
|
||||
USER_MANAGEMENT_MINIMUM_ROLE: "owner",
|
||||
IS_FORMBRICKS_CLOUD: 1,
|
||||
ENCRYPTION_KEY: "test-key",
|
||||
ENTERPRISE_LICENSE_KEY: "test-enterprise-key",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
||||
import { DISABLE_USER_MANAGEMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { IS_FORMBRICKS_CLOUD, USER_MANAGEMENT_MINIMUM_ROLE } from "@/lib/constants";
|
||||
import { getUserManagementAccess } from "@/lib/membership/utils";
|
||||
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { TeamsView } from "@/modules/ee/teams/team-list/components/teams-view";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
@@ -15,6 +16,10 @@ export const TeamsPage = async (props) => {
|
||||
const { session, currentUserMembership, organization } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
|
||||
const hasUserManagementAccess = getUserManagementAccess(
|
||||
currentUserMembership?.role,
|
||||
USER_MANAGEMENT_MINIMUM_ROLE
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
@@ -32,7 +37,7 @@ export const TeamsPage = async (props) => {
|
||||
currentUserId={session.user.id}
|
||||
environmentId={params.environmentId}
|
||||
canDoRoleManagement={canDoRoleManagement}
|
||||
isUserManagementDisabledFromUi={DISABLE_USER_MANAGEMENT}
|
||||
isUserManagementDisabledFromUi={!hasUserManagementAccess}
|
||||
/>
|
||||
<TeamsView
|
||||
organizationId={organization.id}
|
||||
|
||||
@@ -149,7 +149,7 @@ export const ProjectLookSettingsLoading = () => {
|
||||
<div className={cn("absolute bottom-3 h-16 w-16 rounded bg-slate-700 sm:right-3")}></div>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="pointer-events-none mt-4 animate-pulse cursor-not-allowed bg-slate-200 select-none">
|
||||
<Button className="pointer-events-none mt-4 animate-pulse cursor-not-allowed select-none bg-slate-200">
|
||||
{t("common.loading")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -159,7 +159,7 @@ export const ProjectLookSettingsLoading = () => {
|
||||
title="Formbricks Signature"
|
||||
description="We love your support but understand if you toggle it off.">
|
||||
<div className="w-full items-center">
|
||||
<div className="pointer-events-none flex cursor-not-allowed items-center space-x-2 select-none">
|
||||
<div className="pointer-events-none flex cursor-not-allowed select-none items-center space-x-2">
|
||||
<Switch id="signature" checked={false} />
|
||||
<Label htmlFor="signature">{t("environments.project.look.show_powered_by_formbricks")}</Label>
|
||||
</div>
|
||||
|
||||
@@ -99,12 +99,12 @@ export const SingleTag: React.FC<SingleTagProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-1 my-auto text-center text-sm whitespace-nowrap text-slate-500">
|
||||
<div className="col-span-1 my-auto whitespace-nowrap text-center text-sm text-slate-500">
|
||||
<div className="text-slate-900">{tagCountLoading ? <LoadingSpinner /> : <p>{tagCount}</p>}</div>
|
||||
</div>
|
||||
|
||||
{!isReadOnly && (
|
||||
<div className="col-span-1 my-auto flex items-center justify-center gap-2 text-center text-sm whitespace-nowrap text-slate-500">
|
||||
<div className="col-span-1 my-auto flex items-center justify-center gap-2 whitespace-nowrap text-center text-sm text-slate-500">
|
||||
<div>
|
||||
{isMergingTags ? (
|
||||
<div className="w-24">
|
||||
@@ -139,7 +139,7 @@ export const SingleTag: React.FC<SingleTagProps> = ({
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="font-medium text-slate-50 focus:border-transparent focus:ring-0 focus:shadow-transparent focus:ring-transparent focus:outline-transparent"
|
||||
className="font-medium text-slate-50 focus:border-transparent focus:shadow-transparent focus:outline-transparent focus:ring-0 focus:ring-transparent"
|
||||
onClick={() => setOpenDeleteTagDialog(true)}>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
|
||||
@@ -179,7 +179,7 @@ export const RecallItemSelect = ({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="max-h-72 overflow-x-hidden overflow-y-auto">
|
||||
<div className="max-h-72 overflow-y-auto overflow-x-hidden">
|
||||
{filteredRecallItems.map((recallItem, index) => {
|
||||
const IconComponent = getRecallItemIcon(recallItem);
|
||||
return (
|
||||
@@ -201,7 +201,7 @@ export const RecallItemSelect = ({
|
||||
}
|
||||
}}>
|
||||
<div>{IconComponent && <IconComponent className="mr-2 w-4" />}</div>
|
||||
<p className="max-w-full overflow-hidden text-sm text-ellipsis whitespace-nowrap">
|
||||
<p className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm">
|
||||
{getRecallLabel(recallItem.label)}
|
||||
</p>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -220,7 +220,7 @@ export const RecallWrapper = ({
|
||||
}
|
||||
parts.push(
|
||||
<span
|
||||
className="z-30 flex h-fit cursor-pointer justify-center rounded-md bg-slate-100 text-sm whitespace-pre text-transparent"
|
||||
className="z-30 flex h-fit cursor-pointer justify-center whitespace-pre rounded-md bg-slate-100 text-sm text-transparent"
|
||||
key={`recall-${parts.length}`}>
|
||||
{"@" + label}
|
||||
</span>
|
||||
@@ -255,7 +255,7 @@ export const RecallWrapper = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
className="absolute top-full right-2 z-[1] flex h-6 cursor-pointer items-center rounded-t-none rounded-b-lg bg-slate-100 px-2.5 py-0 text-xs hover:bg-slate-200"
|
||||
className="absolute right-2 top-full z-[1] flex h-6 cursor-pointer items-center rounded-b-lg rounded-t-none bg-slate-100 px-2.5 py-0 text-xs hover:bg-slate-200"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowFallbackInput(true);
|
||||
|
||||
@@ -271,7 +271,7 @@ export const QuestionFormInput = ({
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<div className="mt-3 mb-2">
|
||||
<div className="mb-2 mt-3">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
</div>
|
||||
)}
|
||||
@@ -342,7 +342,7 @@ export const QuestionFormInput = ({
|
||||
<div className="h-10 w-full"></div>
|
||||
<div
|
||||
ref={highlightContainerRef}
|
||||
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll px-3 py-2 text-center text-sm whitespace-nowrap text-transparent ${
|
||||
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll whitespace-nowrap px-3 py-2 text-center text-sm text-transparent ${
|
||||
localSurvey.languages?.length > 1 ? "pr-24" : ""
|
||||
}`}
|
||||
dir="auto"
|
||||
|
||||
@@ -45,10 +45,10 @@ export const StartFromScratchTemplate = ({
|
||||
activeTemplate?.name === customSurvey.name
|
||||
? "ring-brand-dark border-transparent ring-2"
|
||||
: "hover:border-brand-dark border-dashed border-slate-300",
|
||||
"group relative rounded-lg border-2 bg-transparent p-6 transition-colors duration-120 duration-150"
|
||||
"duration-120 group relative rounded-lg border-2 bg-transparent p-6 transition-colors duration-150"
|
||||
)}>
|
||||
<PlusCircleIcon className="text-brand-dark h-8 w-8 transition-all duration-150 group-hover:scale-110" />
|
||||
<h3 className="text-md mt-3 mb-1 text-left font-bold text-slate-700">{customSurvey.name}</h3>
|
||||
<h3 className="text-md mb-1 mt-3 text-left font-bold text-slate-700">{customSurvey.name}</h3>
|
||||
<p className="text-left text-xs text-slate-600">{customSurvey.description}</p>
|
||||
{activeTemplate?.name === customSurvey.name && (
|
||||
<div className="text-left">
|
||||
|
||||
@@ -41,7 +41,7 @@ export const TemplateFilters = ({
|
||||
className={cn(
|
||||
selectedFilter[index] === null
|
||||
? "bg-slate-800 font-semibold text-white"
|
||||
: "bg-white text-slate-700 hover:bg-slate-100 focus:scale-105 focus:bg-slate-100 focus:ring-0 focus:outline-none",
|
||||
: "bg-white text-slate-700 hover:bg-slate-100 focus:scale-105 focus:bg-slate-100 focus:outline-none focus:ring-0",
|
||||
"rounded border border-slate-800 px-2 py-1 text-xs transition-all duration-150"
|
||||
)}>
|
||||
{index === 0
|
||||
@@ -59,7 +59,7 @@ export const TemplateFilters = ({
|
||||
className={cn(
|
||||
selectedFilter[index] === filter.value
|
||||
? "bg-slate-800 font-semibold text-white"
|
||||
: "bg-white text-slate-700 hover:bg-slate-100 focus:scale-105 focus:bg-slate-100 focus:ring-0 focus:outline-none",
|
||||
: "bg-white text-slate-700 hover:bg-slate-100 focus:scale-105 focus:bg-slate-100 focus:outline-none focus:ring-0",
|
||||
"rounded border border-slate-800 px-2 py-1 text-xs transition-all duration-150"
|
||||
)}>
|
||||
{t(filter.label)}
|
||||
|
||||
@@ -46,10 +46,10 @@ export const Template = ({
|
||||
key={template.name}
|
||||
className={cn(
|
||||
activeTemplate?.name === template.name && "ring-2 ring-slate-400",
|
||||
"group relative cursor-pointer rounded-lg bg-white p-6 shadow transition-all duration-120 duration-150 hover:ring-2 hover:ring-slate-300"
|
||||
"duration-120 group relative cursor-pointer rounded-lg bg-white p-6 shadow transition-all duration-150 hover:ring-2 hover:ring-slate-300"
|
||||
)}>
|
||||
<TemplateTags template={template} selectedFilter={selectedFilter} />
|
||||
<h3 className="text-md mt-3 mb-1 text-left font-bold text-slate-700">{template.name}</h3>
|
||||
<h3 className="text-md mb-1 mt-3 text-left font-bold text-slate-700">{template.name}</h3>
|
||||
<p className="text-left text-xs text-slate-600">{template.description}</p>
|
||||
{activeTemplate?.name === template.name && (
|
||||
<div className="flex justify-start">
|
||||
|
||||
@@ -105,7 +105,7 @@ export const TemplateList = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="relative z-0 flex-1 overflow-y-auto px-6 pt-2 pb-6 focus:outline-none">
|
||||
<main className="relative z-0 flex-1 overflow-y-auto px-6 pb-6 pt-2 focus:outline-none">
|
||||
{showFilters && !templateSearch && (
|
||||
<TemplateFilters
|
||||
selectedFilter={selectedFilter}
|
||||
|
||||
@@ -38,7 +38,7 @@ export const AddQuestionButton = ({ addQuestion, project, isCxMode }: AddQuestio
|
||||
)}>
|
||||
<Collapsible.CollapsibleTrigger asChild className="group h-full w-full">
|
||||
<div className="inline-flex">
|
||||
<div className="bg-brand-dark flex w-10 items-center justify-center rounded-l-lg group-aria-expanded:rounded-br group-aria-expanded:rounded-bl-none">
|
||||
<div className="bg-brand-dark flex w-10 items-center justify-center rounded-l-lg group-aria-expanded:rounded-bl-none group-aria-expanded:rounded-br">
|
||||
<PlusIcon className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div className="px-4 py-3">
|
||||
@@ -68,7 +68,7 @@ export const AddQuestionButton = ({ addQuestion, project, isCxMode }: AddQuestio
|
||||
onMouseEnter={() => setHoveredQuestionId(questionType.id)}
|
||||
onMouseLeave={() => setHoveredQuestionId(null)}>
|
||||
<div className="flex items-center">
|
||||
<questionType.icon className="text-brand-dark mr-2 -ml-0.5 h-4 w-4" aria-hidden="true" />
|
||||
<questionType.icon className="text-brand-dark -ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{questionType.label}
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -87,21 +87,6 @@ export const CTAQuestionForm = ({
|
||||
|
||||
<div className="mt-2 flex justify-between gap-8">
|
||||
<div className="flex w-full space-x-2">
|
||||
<QuestionFormInput
|
||||
id="buttonLabel"
|
||||
value={question.buttonLabel}
|
||||
label={t("environments.surveys.edit.next_button_label")}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
maxLength={48}
|
||||
placeholder={lastQuestion ? t("common.finish") : t("common.next")}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
/>
|
||||
|
||||
{questionIdx !== 0 && (
|
||||
<QuestionFormInput
|
||||
id="backButtonLabel"
|
||||
@@ -118,6 +103,20 @@ export const CTAQuestionForm = ({
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
<QuestionFormInput
|
||||
id="buttonLabel"
|
||||
value={question.buttonLabel}
|
||||
label={t("environments.surveys.edit.next_button_label")}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
maxLength={48}
|
||||
placeholder={lastQuestion ? t("common.finish") : t("common.next")}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -167,7 +167,7 @@ export const EditEndingCard = ({
|
||||
{...attributes}
|
||||
className={cn(
|
||||
open ? "bg-slate-50" : "",
|
||||
"flex w-10 flex-col items-center justify-between rounded-l-lg border-t border-b border-l py-2 group-aria-expanded:rounded-bl-none",
|
||||
"flex w-10 flex-col items-center justify-between rounded-l-lg border-b border-l border-t py-2 group-aria-expanded:rounded-bl-none",
|
||||
isInvalid ? "bg-red-400" : "bg-white group-hover:bg-slate-50"
|
||||
)}>
|
||||
<div className="mt-3 flex w-full justify-center">
|
||||
@@ -177,7 +177,7 @@ export const EditEndingCard = ({
|
||||
<Undo2 className="h-4 w-4 rotate-180" />
|
||||
)}
|
||||
</div>
|
||||
<button className="opacity-0 transition-all duration-300 group-hover:opacity-100 hover:cursor-move">
|
||||
<button className="opacity-0 transition-all duration-300 hover:cursor-move group-hover:opacity-100">
|
||||
<GripIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -123,7 +123,7 @@ export const EndScreenForm = ({
|
||||
</Label>
|
||||
</div>
|
||||
{showEndingCardCTA && (
|
||||
<div className="mt-4 space-y-4 rounded-md border border-1 bg-slate-100 p-4 pt-2">
|
||||
<div className="border-1 mt-4 space-y-4 rounded-md border bg-slate-100 p-4 pt-2">
|
||||
<div className="space-y-2">
|
||||
<QuestionFormInput
|
||||
id="buttonLabel"
|
||||
@@ -166,7 +166,7 @@ export const EndScreenForm = ({
|
||||
<div className="group relative">
|
||||
{/* The highlight container is absolutely positioned behind the input */}
|
||||
<div
|
||||
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll px-3 py-2 text-center text-sm whitespace-nowrap text-transparent`}
|
||||
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll whitespace-nowrap px-3 py-2 text-center text-sm text-transparent`}
|
||||
dir="auto"
|
||||
key={highlightedJSX.toString()}>
|
||||
{highlightedJSX}
|
||||
|
||||
@@ -89,7 +89,7 @@ export const FormStylingSettings = ({
|
||||
)}>
|
||||
<div className="inline-flex px-4 py-4">
|
||||
{!isSettingsPage && (
|
||||
<div className="flex items-center pr-5 pl-2">
|
||||
<div className="flex items-center pl-2 pr-5">
|
||||
<CheckIcon
|
||||
strokeWidth={3}
|
||||
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
|
||||
|
||||
@@ -113,7 +113,7 @@ export const HiddenFieldsCard = ({
|
||||
<div
|
||||
className={cn(
|
||||
open ? "bg-slate-50" : "bg-white group-hover:bg-slate-50",
|
||||
"flex w-10 items-center justify-center rounded-l-lg border-t border-b border-l group-aria-expanded:rounded-bl-none"
|
||||
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none"
|
||||
)}>
|
||||
<EyeOff className="h-4 w-4" />
|
||||
</div>
|
||||
@@ -161,7 +161,7 @@ export const HiddenFieldsCard = ({
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-slate-500 italic">
|
||||
<p className="mt-2 text-sm italic text-slate-500">
|
||||
{t("environments.surveys.edit.no_hidden_fields_yet_add_first_one_below")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -106,7 +106,7 @@ export const HowToSendCard = ({ localSurvey, setLocalSurvey, environment }: HowT
|
||||
className="h-full w-full cursor-pointer"
|
||||
id="howToSendCardTrigger">
|
||||
<div className="inline-flex px-4 py-4">
|
||||
<div className="flex items-center pr-5 pl-2">
|
||||
<div className="flex items-center pl-2 pr-5">
|
||||
<CheckIcon
|
||||
strokeWidth={3}
|
||||
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { QuestionCard } from "@/modules/survey/editor/components/question-card";
|
||||
import { Project } from "@prisma/client";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
// Import waitFor
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyAddressQuestion,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
// Import waitFor
|
||||
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
|
||||
// Mock child components
|
||||
vi.mock("@/modules/survey/components/question-form-input", () => ({
|
||||
@@ -371,8 +366,9 @@ describe("QuestionCard Component", () => {
|
||||
|
||||
test("applies invalid styling when isInvalid is true", () => {
|
||||
render(<QuestionCard {...defaultProps} isInvalid={true} />);
|
||||
const dragHandle = screen.getByRole("button", { name: "" }).parentElement; // Get the div containing the GripIcon
|
||||
const dragHandle = screen.getByRole("button", { name: "Drag to reorder question" }).parentElement; // Get the div containing the GripIcon
|
||||
expect(dragHandle).toHaveClass("bg-red-400");
|
||||
expect(dragHandle).toHaveClass("hover:bg-red-600");
|
||||
});
|
||||
|
||||
test("disables required toggle for Address question if all fields are optional", () => {
|
||||
@@ -507,4 +503,94 @@ describe("QuestionCard Component", () => {
|
||||
// First question should never have back button
|
||||
expect(screen.queryByTestId("question-form-input-backButtonLabel")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Accessibility Tests
|
||||
test("maintains proper focus management when toggling advanced settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<QuestionCard {...defaultProps} activeQuestionId="q1" />);
|
||||
|
||||
const advancedSettingsTrigger = screen.getByText("environments.surveys.edit.show_advanced_settings");
|
||||
await user.click(advancedSettingsTrigger);
|
||||
|
||||
const closeTrigger = screen.getByText("environments.surveys.edit.hide_advanced_settings");
|
||||
expect(closeTrigger).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("ensures proper ARIA attributes for collapsible sections", () => {
|
||||
render(<QuestionCard {...defaultProps} activeQuestionId="q1" />);
|
||||
|
||||
const collapsibleTrigger = screen.getByText("environments.surveys.edit.show_advanced_settings");
|
||||
expect(collapsibleTrigger).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
fireEvent.click(collapsibleTrigger);
|
||||
expect(collapsibleTrigger).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
test("maintains keyboard accessibility for required toggle", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<QuestionCard {...defaultProps} activeQuestionId="q1" />);
|
||||
|
||||
const requiredToggle = screen.getByRole("switch", { name: "environments.surveys.edit.required" });
|
||||
await user.click(requiredToggle);
|
||||
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, { required: false });
|
||||
});
|
||||
|
||||
test("provides screen reader text for drag handle", () => {
|
||||
render(<QuestionCard {...defaultProps} />);
|
||||
const dragHandle = screen.getByRole("button", { name: "Drag to reorder question" });
|
||||
const svg = dragHandle.querySelector("svg");
|
||||
expect(svg).toHaveAttribute("aria-hidden", "true");
|
||||
});
|
||||
|
||||
test("maintains proper heading hierarchy", () => {
|
||||
render(<QuestionCard {...defaultProps} activeQuestionId="q1" />);
|
||||
const headline = screen.getByText("Question Headline");
|
||||
expect(headline.tagName).toBe("H3");
|
||||
expect(headline).toHaveClass("text-sm", "font-semibold");
|
||||
});
|
||||
|
||||
test("ensures proper focus order for form elements", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<QuestionCard {...defaultProps} activeQuestionId="q1" />);
|
||||
|
||||
// Open advanced settings
|
||||
fireEvent.click(screen.getByText("environments.surveys.edit.show_advanced_settings"));
|
||||
|
||||
const requiredToggle = screen.getByRole("switch", { name: "environments.surveys.edit.required" });
|
||||
await user.click(requiredToggle);
|
||||
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, { required: false });
|
||||
});
|
||||
|
||||
test("provides proper ARIA attributes for interactive elements", () => {
|
||||
render(<QuestionCard {...defaultProps} activeQuestionId="q1" />);
|
||||
|
||||
const requiredToggle = screen.getByRole("switch", { name: "environments.surveys.edit.required" });
|
||||
expect(requiredToggle).toHaveAttribute("aria-checked", "true");
|
||||
|
||||
const longAnswerToggle = screen.getByRole("switch", { name: "environments.surveys.edit.long_answer" });
|
||||
expect(longAnswerToggle).toHaveAttribute("aria-checked", "false");
|
||||
});
|
||||
|
||||
test("ensures proper role attributes for interactive elements", () => {
|
||||
render(<QuestionCard {...defaultProps} activeQuestionId="q1" />);
|
||||
|
||||
const toggles = screen.getAllByRole("switch");
|
||||
expect(toggles).toHaveLength(2); // Required and Long Answer toggles
|
||||
|
||||
const collapsibleTrigger = screen.getByText("environments.surveys.edit.show_advanced_settings");
|
||||
expect(collapsibleTrigger).toHaveAttribute("type", "button");
|
||||
});
|
||||
|
||||
test("maintains proper focus management when closing advanced settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<QuestionCard {...defaultProps} activeQuestionId="q1" />);
|
||||
|
||||
const advancedSettingsTrigger = screen.getByText("environments.surveys.edit.show_advanced_settings");
|
||||
await user.click(advancedSettingsTrigger);
|
||||
|
||||
const closeTrigger = screen.getByText("environments.surveys.edit.hide_advanced_settings");
|
||||
await user.click(closeTrigger);
|
||||
|
||||
expect(screen.getByText("environments.surveys.edit.show_advanced_settings")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -196,7 +196,9 @@ export const QuestionCard = ({
|
||||
)}>
|
||||
<div className="mt-3 flex w-full justify-center">{QUESTIONS_ICON_MAP[question.type]}</div>
|
||||
|
||||
<button className="opacity-0 hover:cursor-move group-hover:opacity-100">
|
||||
<button
|
||||
className="opacity-0 hover:cursor-move group-hover:opacity-100"
|
||||
aria-label="Drag to reorder question">
|
||||
<GripIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -215,14 +217,15 @@ export const QuestionCard = ({
|
||||
className={cn(
|
||||
open ? "" : " ",
|
||||
"flex cursor-pointer justify-between gap-4 rounded-r-lg p-4 hover:bg-slate-50"
|
||||
)}>
|
||||
)}
|
||||
aria-label="Toggle question details">
|
||||
<div>
|
||||
<div className="flex grow">
|
||||
{/* <div className="-ml-0.5 mr-3 h-6 min-w-[1.5rem] text-slate-400">
|
||||
{QUESTIONS_ICON_MAP[question.type]}
|
||||
</div> */}
|
||||
<div className="flex grow flex-col justify-center" dir="auto">
|
||||
<p className="text-sm font-semibold">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{recallToHeadline(question.headline, localSurvey, true, selectedLanguageCode)[
|
||||
selectedLanguageCode
|
||||
]
|
||||
@@ -232,7 +235,7 @@ export const QuestionCard = ({
|
||||
] ?? ""
|
||||
)
|
||||
: getTSurveyQuestionTypeEnumName(question.type, t)}
|
||||
</p>
|
||||
</h3>
|
||||
{!open && (
|
||||
<p className="mt-1 truncate text-xs text-slate-500">
|
||||
{question?.required
|
||||
@@ -272,7 +275,7 @@ export const QuestionCard = ({
|
||||
TSurveyQuestionTypeEnum.Ranking,
|
||||
TSurveyQuestionTypeEnum.Matrix,
|
||||
].includes(question.type) ? (
|
||||
<Alert variant="warning" size="small" className="w-fill">
|
||||
<Alert variant="warning" size="small" className="w-fill" role="alert">
|
||||
<AlertTitle>{t("environments.surveys.edit.caution_text")}</AlertTitle>
|
||||
<AlertButton onClick={() => onAlertTrigger()}>{t("common.learn_more")}</AlertButton>
|
||||
</Alert>
|
||||
@@ -457,7 +460,9 @@ export const QuestionCard = ({
|
||||
) : null}
|
||||
<div className="mt-4">
|
||||
<Collapsible.Root open={openAdvanced} onOpenChange={setOpenAdvanced} className="mt-5">
|
||||
<Collapsible.CollapsibleTrigger className="flex items-center text-sm text-slate-700">
|
||||
<Collapsible.CollapsibleTrigger
|
||||
className="flex items-center text-sm text-slate-700"
|
||||
aria-label="Toggle advanced settings">
|
||||
{openAdvanced ? (
|
||||
<ChevronDownIcon className="mr-1 h-4 w-3" />
|
||||
) : (
|
||||
@@ -473,6 +478,30 @@ export const QuestionCard = ({
|
||||
question.type !== TSurveyQuestionTypeEnum.Rating &&
|
||||
question.type !== TSurveyQuestionTypeEnum.CTA ? (
|
||||
<div className="mt-2 flex space-x-2">
|
||||
{questionIdx !== 0 && (
|
||||
<QuestionFormInput
|
||||
id="backButtonLabel"
|
||||
value={question.backButtonLabel}
|
||||
label={t("environments.surveys.edit.back_button_label")}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
maxLength={48}
|
||||
placeholder={t("common.back")}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
onBlur={(e) => {
|
||||
if (!question.backButtonLabel) return;
|
||||
let translatedBackButtonLabel = {
|
||||
...question.backButtonLabel,
|
||||
[selectedLanguageCode]: e.target.value,
|
||||
};
|
||||
updateEmptyButtonLabels("backButtonLabel", translatedBackButtonLabel, 0);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="w-full">
|
||||
<QuestionFormInput
|
||||
id="buttonLabel"
|
||||
@@ -503,30 +532,6 @@ export const QuestionCard = ({
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
{questionIdx !== 0 && (
|
||||
<QuestionFormInput
|
||||
id="backButtonLabel"
|
||||
value={question.backButtonLabel}
|
||||
label={t("environments.surveys.edit.back_button_label")}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
maxLength={48}
|
||||
placeholder={t("common.back")}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
onBlur={(e) => {
|
||||
if (!question.backButtonLabel) return;
|
||||
let translatedBackButtonLabel = {
|
||||
...question.backButtonLabel,
|
||||
[selectedLanguageCode]: e.target.value,
|
||||
};
|
||||
updateEmptyButtonLabels("backButtonLabel", translatedBackButtonLabel, 0);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{(question.type === TSurveyQuestionTypeEnum.Rating ||
|
||||
|
||||
@@ -121,7 +121,7 @@ export const RecontactOptionsCard = ({
|
||||
className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50"
|
||||
id="recontactOptionsCardTrigger">
|
||||
<div className="inline-flex px-4 py-4">
|
||||
<div className="flex items-center pr-5 pl-2">
|
||||
<div className="flex items-center pl-2 pr-5">
|
||||
<CheckIcon
|
||||
strokeWidth={3}
|
||||
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
|
||||
@@ -256,7 +256,7 @@ export const RecontactOptionsCard = ({
|
||||
id="inputDays"
|
||||
value={inputDays === 0 ? 1 : inputDays}
|
||||
onChange={handleRecontactDaysChange}
|
||||
className="mr-2 ml-2 inline w-16 bg-white text-center text-sm"
|
||||
className="ml-2 mr-2 inline w-16 bg-white text-center text-sm"
|
||||
/>
|
||||
{t("environments.surveys.edit.days_before_showing_this_survey_again")}.
|
||||
</p>
|
||||
|
||||
@@ -45,7 +45,7 @@ export const RedirectUrlForm = ({ localSurvey, endingCard, updateSurvey }: Redir
|
||||
<div className="group relative">
|
||||
{/* The highlight container is absolutely positioned behind the input */}
|
||||
<div
|
||||
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll px-3 py-2 text-center text-sm whitespace-nowrap text-transparent`}
|
||||
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll whitespace-nowrap px-3 py-2 text-center text-sm text-transparent`}
|
||||
dir="auto"
|
||||
key={highlightedJSX.toString()}>
|
||||
{highlightedJSX}
|
||||
|
||||
@@ -64,7 +64,7 @@ export const SavedActionsTab = ({
|
||||
(actions, i) =>
|
||||
actions.length > 0 && (
|
||||
<div key={i} className="me-4">
|
||||
<h2 className="mt-4 mb-2 font-semibold">
|
||||
<h2 className="mb-2 mt-4 font-semibold">
|
||||
{i === 0 ? t("common.no_code") : t("common.code")}
|
||||
</h2>
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
@@ -329,7 +329,7 @@ export const SurveyMenuBar = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2 sm:mt-0 sm:ml-4">
|
||||
<div className="mt-3 flex items-center gap-2 sm:ml-4 sm:mt-0">
|
||||
{responseCount > 0 && (
|
||||
<div>
|
||||
<Alert variant="warning" size="small">
|
||||
|
||||
@@ -24,7 +24,7 @@ export const TargetingLockedCard = ({ isFormbricksCloud, environmentId }: Target
|
||||
asChild
|
||||
className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50">
|
||||
<div className="inline-flex px-4 py-6">
|
||||
<div className="flex items-center pr-5 pl-2">
|
||||
<div className="flex items-center pl-2 pr-5">
|
||||
<div className="rounded-full border border-slate-300 bg-slate-100 p-1">
|
||||
<LockIcon className="h-4 w-4 text-slate-500" strokeWidth={3} />
|
||||
</div>
|
||||
|
||||
@@ -192,7 +192,7 @@ export const ImageFromUnsplashSurveyBg = ({ handleBgChange }: ImageFromUnsplashS
|
||||
return (
|
||||
<div className="relative mt-2 w-full">
|
||||
<div className="relative">
|
||||
<SearchIcon className="absolute top-1/2 left-2 h-6 w-4 -translate-y-1/2 text-slate-500" />
|
||||
<SearchIcon className="absolute left-2 top-1/2 h-6 w-4 -translate-y-1/2 text-slate-500" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={handleChange}
|
||||
@@ -215,7 +215,7 @@ export const ImageFromUnsplashSurveyBg = ({ handleBgChange }: ImageFromUnsplashS
|
||||
className="h-full cursor-pointer rounded-lg object-cover"
|
||||
/>
|
||||
{image.authorName && (
|
||||
<span className="bg-opacity-75 absolute right-1 bottom-1 hidden rounded bg-black px-2 py-1 text-xs text-white group-hover:block">
|
||||
<span className="absolute bottom-1 right-1 hidden rounded bg-black bg-opacity-75 px-2 py-1 text-xs text-white group-hover:block">
|
||||
{image.authorName}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -155,7 +155,7 @@ export const WhenToSendCard = ({
|
||||
className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50"
|
||||
id="whenToSendCardTrigger">
|
||||
<div className="inline-flex px-4 py-4">
|
||||
<div className="flex items-center pr-5 pl-2">
|
||||
<div className="flex items-center pl-2 pr-5">
|
||||
{containsEmptyTriggers ? (
|
||||
<div className="h-7 w-7 rounded-full border border-amber-500 bg-amber-50" />
|
||||
) : (
|
||||
@@ -178,7 +178,7 @@ export const WhenToSendCard = ({
|
||||
<Collapsible.CollapsibleContent className="flex flex-col" ref={parent}>
|
||||
<hr className="py-1 text-slate-600" />
|
||||
|
||||
<div className="px-3 pt-1 pb-3">
|
||||
<div className="px-3 pb-3 pt-1">
|
||||
<div className="filter-scrollbar flex flex-col gap-4 overflow-auto rounded-lg border border-slate-300 bg-slate-50 p-4">
|
||||
<p className="text-sm font-semibold text-slate-800">
|
||||
{t("environments.surveys.edit.trigger_survey_when_one_of_the_actions_is_fired")}
|
||||
@@ -265,7 +265,7 @@ export const WhenToSendCard = ({
|
||||
</div>
|
||||
|
||||
{/* Survey Display Settings */}
|
||||
<div className="mt-8 mb-4 space-y-1 px-4">
|
||||
<div className="mb-4 mt-8 space-y-1 px-4">
|
||||
<h3 className="font-semibold text-slate-800">
|
||||
{t("environments.surveys.edit.survey_display_settings")}
|
||||
</h3>
|
||||
@@ -294,7 +294,7 @@ export const WhenToSendCard = ({
|
||||
id="triggerDelay"
|
||||
value={localSurvey.delay.toString()}
|
||||
onChange={(e) => handleTriggerDelay(e)}
|
||||
className="mr-2 ml-2 inline w-16 bg-white text-center text-sm"
|
||||
className="ml-2 mr-2 inline w-16 bg-white text-center text-sm"
|
||||
/>
|
||||
{t("environments.surveys.edit.seconds_before_showing_the_survey")}
|
||||
</p>
|
||||
|
||||
@@ -146,7 +146,7 @@ export const FollowUpItem = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-4 right-4 flex items-center">
|
||||
<div className="absolute right-4 top-4 flex items-center">
|
||||
<TooltipRenderer tooltipContent={t("common.delete")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -856,7 +856,7 @@ export const FollowUpModal = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute right-0 bottom-0 z-20 h-12 w-full bg-white p-2">
|
||||
<div className="absolute bottom-0 right-0 z-20 h-12 w-full bg-white p-2">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -82,7 +82,7 @@ export const LinkSurveyWrapper = ({
|
||||
{!styling.isLogoHidden && project.logo?.url && <ClientLogo projectLogo={project.logo} />}
|
||||
<div className="h-full w-full max-w-4xl space-y-6 px-1.5">
|
||||
{isPreview && (
|
||||
<div className="fixed top-0 left-0 flex w-full items-center justify-between bg-slate-600 p-2 px-4 text-center text-sm text-white shadow-sm">
|
||||
<div className="fixed left-0 top-0 flex w-full items-center justify-between bg-slate-600 p-2 px-4 text-center text-sm text-white shadow-sm">
|
||||
<div />
|
||||
Survey Preview 👀
|
||||
<ResetProgressButton onClick={handleResetSurvey} />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user