Compare commits

..

2 Commits

Author SHA1 Message Date
Johannes 75f05f85e9 remove race condition 2025-10-19 16:18:26 +02:00
Dhruwang Jariwala 74405cc05f fix: update OpenAPI schema for action class creation endpoint (#6617)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-10-18 15:16:48 +00:00
5 changed files with 82 additions and 123 deletions
@@ -1,3 +1,9 @@
import { getQuotasSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey";
import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
@@ -10,12 +16,6 @@ import {
TSurveyQuestionTypeEnum,
TSurveySummary,
} from "@formbricks/types/surveys/types";
import { getQuotasSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey";
import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
import {
getQuestionSummary,
getResponsesForSummary,
@@ -376,102 +376,6 @@ describe("getQuestionSummary", () => {
expect(openTextSummary?.samples[0].value).toBe("Open answer");
});
test("summarizes OpenText questions with array answer format", async () => {
const responsesWithArray = [
{
id: "r1",
data: { q_open: ["Answer 1", "Answer 2", "Answer 3"] },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: "en",
ttc: {},
finished: true,
},
];
const summary = await getQuestionSummary(survey, responsesWithArray, mockDropOff);
const openTextSummary = summary.find((s: any) => s.question?.id === "q_open");
expect(openTextSummary?.type).toBe(TSurveyQuestionTypeEnum.OpenText);
expect(openTextSummary?.responseCount).toBe(1);
// @ts-expect-error
expect(openTextSummary?.samples[0].value).toBe("Answer 1, Answer 2, Answer 3");
});
test("summarizes OpenText questions with array containing null/empty values", async () => {
const responsesWithPartialArray = [
{
id: "r1",
data: { q_open: ["Valid answer", null, "", "Another answer", undefined] },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: "en",
ttc: {},
finished: true,
},
];
const summary = await getQuestionSummary(survey, responsesWithPartialArray, mockDropOff);
const openTextSummary = summary.find((s: any) => s.question?.id === "q_open");
expect(openTextSummary?.type).toBe(TSurveyQuestionTypeEnum.OpenText);
expect(openTextSummary?.responseCount).toBe(1);
// @ts-expect-error - filters out null, empty string, and undefined
expect(openTextSummary?.samples[0].value).toBe("Valid answer, Another answer");
});
test("summarizes OpenText questions ignoring empty arrays", async () => {
const responsesWithEmptyArray = [
{
id: "r1",
data: { q_open: [] },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: "en",
ttc: {},
finished: true,
},
];
const summary = await getQuestionSummary(survey, responsesWithEmptyArray, mockDropOff);
const openTextSummary = summary.find((s: any) => s.question?.id === "q_open");
expect(openTextSummary?.type).toBe(TSurveyQuestionTypeEnum.OpenText);
expect(openTextSummary?.responseCount).toBe(0);
// @ts-expect-error
expect(openTextSummary?.samples).toHaveLength(0);
});
test("summarizes OpenText questions with mixed string and array responses", async () => {
const mixedResponses = [
{
id: "r1",
data: { q_open: "String answer" },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: "en",
ttc: {},
finished: true,
},
{
id: "r2",
data: { q_open: ["Array", "answer"] },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: "en",
ttc: {},
finished: true,
},
];
const summary = await getQuestionSummary(survey, mixedResponses, mockDropOff);
const openTextSummary = summary.find((s: any) => s.question?.id === "q_open");
expect(openTextSummary?.type).toBe(TSurveyQuestionTypeEnum.OpenText);
expect(openTextSummary?.responseCount).toBe(2);
// @ts-expect-error
expect(openTextSummary?.samples[0].value).toBe("String answer");
// @ts-expect-error
expect(openTextSummary?.samples[1].value).toBe("Array, answer");
});
test("summarizes MultipleChoiceSingle questions", async () => {
const summary = await getQuestionSummary(survey, responses, mockDropOff);
const multiSingleSummary = summary.find((s: any) => s.question?.id === "q_multi_single");
@@ -322,18 +322,11 @@ export const getQuestionSummary = async (
let values: TSurveyQuestionSummaryOpenText["samples"] = [];
responses.forEach((response) => {
const answer = response.data[question.id];
let normalizedAnswer: string | null = null;
if (typeof answer === "string" && answer) {
normalizedAnswer = answer;
} else if (Array.isArray(answer) && answer.length > 0) {
normalizedAnswer = answer.filter((v) => v != null && v !== "").join(", ");
}
if (normalizedAnswer) {
if (answer && typeof answer === "string") {
values.push({
id: response.id,
updatedAt: response.updatedAt,
value: normalizedAnswer,
value: answer,
contact: response.contact,
contactAttributes: response.contactAttributes,
});
@@ -83,6 +83,7 @@ export const SurveyEditor = ({
const [activeView, setActiveView] = useState<TSurveyEditorTabs>("questions");
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
const [localSurvey, setLocalSurvey] = useState<TSurvey | null>(() => structuredClone(survey));
const [savedSurvey, setSavedSurvey] = useState<TSurvey>(survey);
const [invalidQuestions, setInvalidQuestions] = useState<string[] | null>(null);
const [selectedLanguageCode, setSelectedLanguageCode] = useState<string>("default");
const surveyEditorRef = useRef(null);
@@ -108,6 +109,7 @@ export const SurveyEditor = ({
const surveyClone = structuredClone(survey);
setLocalSurvey(surveyClone);
setSavedSurvey(surveyClone);
if (survey.questions.length > 0) {
setActiveQuestionId(survey.questions[0].id);
@@ -160,8 +162,9 @@ export const SurveyEditor = ({
<SurveyMenuBar
setLocalSurvey={setLocalSurvey}
localSurvey={localSurvey}
survey={survey}
survey={savedSurvey}
environmentId={environment.id}
setSavedSurvey={setSavedSurvey}
activeId={activeView}
setActiveId={setActiveView}
setInvalidQuestions={setInvalidQuestions}
@@ -1,17 +1,12 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createSegmentAction } from "@/modules/ee/contacts/segments/actions";
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { Project } from "@prisma/client";
import { useTranslate } from "@tolgee/react";
import { isEqual } from "lodash";
import { ArrowLeftIcon, SettingsIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { flushSync } from "react-dom";
import toast from "react-hot-toast";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { TSegment } from "@formbricks/types/segment";
@@ -23,11 +18,18 @@ import {
ZSurveyEndScreenCard,
ZSurveyRedirectUrlCard,
} from "@formbricks/types/surveys/types";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createSegmentAction } from "@/modules/ee/contacts/segments/actions";
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { updateSurveyAction } from "../actions";
import { isSurveyValid } from "../lib/validation";
interface SurveyMenuBarProps {
localSurvey: TSurvey;
setSavedSurvey: (survey: TSurvey) => void;
survey: TSurvey;
setLocalSurvey: (survey: TSurvey) => void;
environmentId: string;
@@ -47,6 +49,7 @@ interface SurveyMenuBarProps {
export const SurveyMenuBar = ({
localSurvey,
survey,
setSavedSurvey,
environmentId,
setLocalSurvey,
activeId,
@@ -247,9 +250,12 @@ export const SurveyMenuBar = ({
setIsSurveySaving(false);
if (updatedSurveyResponse?.data) {
setLocalSurvey(updatedSurveyResponse.data);
const updatedSurvey = updatedSurveyResponse.data;
flushSync(() => {
setLocalSurvey(updatedSurvey);
setSavedSurvey(updatedSurvey);
});
toast.success(t("environments.surveys.edit.changes_saved"));
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(updatedSurveyResponse);
toast.error(errorMessage);
@@ -292,12 +298,18 @@ export const SurveyMenuBar = ({
const segment = await handleSegmentUpdate();
clearSurveyLocalStorage();
await updateSurveyAction({
const publishedSurveyResponse = await updateSurveyAction({
...localSurvey,
status,
segment,
});
setIsSurveyPublishing(false);
if (publishedSurveyResponse?.data) {
const publishedSurvey = publishedSurveyResponse.data;
flushSync(() => {
setSavedSurvey(publishedSurvey);
});
}
router.push(`/environments/${environmentId}/surveys/${localSurvey.id}/summary?success=true`);
} catch (error) {
console.error(error);
+49 -2
View File
@@ -2340,6 +2340,50 @@
"name": "My Action from Postman",
"type": "code"
},
"properties": {
"description": {
"description": "Optional description of the action class",
"type": "string"
},
"environmentId": {
"description": "The environment ID where the action class will be created",
"type": "string"
},
"key": {
"description": "Required when type is 'code'. A unique identifier for the action. Not needed for 'noCode' type.",
"minLength": 1,
"type": "string"
},
"name": {
"description": "Name of the action class",
"minLength": 1,
"type": "string"
},
"noCodeConfig": {
"description": "Configuration object required when type is 'noCode'. Defines the conditions for triggering the action. Not needed for 'code' type.",
"example": {
"elementSelector": {
"cssSelector": ".button-class",
"innerHtml": "Click me"
},
"type": "click",
"urlFilters": [
{
"rule": "contains",
"value": "https://www.google.com"
}
]
},
"nullable": true,
"type": "object"
},
"type": {
"description": "Type of action class",
"enum": ["code", "noCode"],
"type": "string"
}
},
"required": ["environmentId", "name", "type"],
"type": "object"
}
}
@@ -2417,8 +2461,11 @@
"application/json": {
"example": {
"code": "bad_request",
"details": {},
"message": "Database error when creating an action for environment clurwouax000azffxt7n5unn3"
"details": {
"environmentId": "Required",
"key": "Required"
},
"message": "Fields are missing or incorrectly formatted"
},
"schema": {
"type": "object"