fix: fixes ttc tracking (#6900)

Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
This commit is contained in:
Anshuman Pandey
2025-11-28 12:54:25 +05:30
committed by GitHub
parent 527f8fd0d5
commit 0622407772
5 changed files with 395 additions and 24 deletions

View File

@@ -0,0 +1,328 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseUpdateInput } from "@formbricks/types/responses";
import { updateResponse } from "./service";
import { calculateTtcTotal } from "./utils";
vi.mock("@formbricks/database", () => ({
prisma: {
response: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
vi.mock("./utils", async (importOriginal) => {
const actual = await importOriginal<typeof import("./utils")>();
return {
...actual,
calculateTtcTotal: vi.fn((ttc) => ({
...ttc,
_total: Object.values(ttc as Record<string, number>).reduce((a, b) => a + b, 0),
})),
};
});
const mockResponseId = "response-123";
const createMockCurrentResponse = (overrides: Record<string, unknown> = {}) => ({
id: mockResponseId,
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "survey-123",
finished: false,
endingId: null,
data: {},
meta: {},
ttc: {},
variables: {},
contactAttributes: {},
singleUseId: null,
language: "en",
displayId: "display-123",
contact: null,
tags: [],
...overrides,
});
const createMockResponseInput = (overrides: Partial<TResponseUpdateInput> = {}): TResponseUpdateInput => ({
finished: false,
data: {},
...overrides,
});
describe("updateResponse", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("TTC merging behavior", () => {
test("should merge new TTC with existing TTC from previous blocks", async () => {
const currentResponse = createMockCurrentResponse({
ttc: { element1: 1000, element2: 2000 },
});
const responseInput = createMockResponseInput({
ttc: { element3: 3000 },
finished: false,
});
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
vi.mocked(prisma.response.update).mockResolvedValue({
...currentResponse,
ttc: { element1: 1000, element2: 2000, element3: 3000 },
} as any);
await updateResponse(mockResponseId, responseInput);
expect(prisma.response.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
ttc: { element1: 1000, element2: 2000, element3: 3000 },
}),
})
);
});
test("should preserve existing TTC when no new TTC is provided", async () => {
const currentResponse = createMockCurrentResponse({
ttc: { element1: 1000, element2: 2000 },
});
const responseInput = createMockResponseInput({
finished: false,
});
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
vi.mocked(prisma.response.update).mockResolvedValue(currentResponse as any);
await updateResponse(mockResponseId, responseInput);
expect(prisma.response.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
ttc: { element1: 1000, element2: 2000 },
}),
})
);
});
test("should calculate total TTC when response is finished", async () => {
const currentResponse = createMockCurrentResponse({
ttc: { element1: 1000, element2: 2000 },
});
const responseInput = createMockResponseInput({
ttc: { element3: 3000 },
finished: true,
});
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
vi.mocked(prisma.response.update).mockResolvedValue({
...currentResponse,
finished: true,
ttc: { element1: 1000, element2: 2000, element3: 3000, _total: 6000 },
} as any);
await updateResponse(mockResponseId, responseInput);
expect(calculateTtcTotal).toHaveBeenCalledWith({
element1: 1000,
element2: 2000,
element3: 3000,
});
});
test("should not calculate total TTC when response is not finished", async () => {
const currentResponse = createMockCurrentResponse({
ttc: { element1: 1000 },
});
const responseInput = createMockResponseInput({
ttc: { element2: 2000 },
finished: false,
});
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
vi.mocked(prisma.response.update).mockResolvedValue({
...currentResponse,
ttc: { element1: 1000, element2: 2000 },
} as any);
await updateResponse(mockResponseId, responseInput);
expect(calculateTtcTotal).not.toHaveBeenCalled();
});
test("should handle empty existing TTC", async () => {
const currentResponse = createMockCurrentResponse({
ttc: {},
});
const responseInput = createMockResponseInput({
ttc: { element1: 1000 },
finished: false,
});
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
vi.mocked(prisma.response.update).mockResolvedValue({
...currentResponse,
ttc: { element1: 1000 },
} as any);
await updateResponse(mockResponseId, responseInput);
expect(prisma.response.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
ttc: { element1: 1000 },
}),
})
);
});
test("should handle null existing TTC", async () => {
const currentResponse = createMockCurrentResponse({
ttc: null,
});
const responseInput = createMockResponseInput({
ttc: { element1: 1000 },
finished: false,
});
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
vi.mocked(prisma.response.update).mockResolvedValue({
...currentResponse,
ttc: { element1: 1000 },
} as any);
await updateResponse(mockResponseId, responseInput);
expect(prisma.response.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
ttc: { element1: 1000 },
}),
})
);
});
test("should overwrite existing element TTC with new value for same element", async () => {
const currentResponse = createMockCurrentResponse({
ttc: { element1: 1000 },
});
const responseInput = createMockResponseInput({
ttc: { element1: 1500 },
finished: false,
});
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
vi.mocked(prisma.response.update).mockResolvedValue({
...currentResponse,
ttc: { element1: 1500 },
} as any);
await updateResponse(mockResponseId, responseInput);
expect(prisma.response.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
ttc: { element1: 1500 },
}),
})
);
});
});
describe("data merging behavior", () => {
test("should merge new data with existing data", async () => {
const currentResponse = createMockCurrentResponse({
data: { question1: "answer1" },
});
const responseInput = createMockResponseInput({
data: { question2: "answer2" },
finished: false,
});
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
vi.mocked(prisma.response.update).mockResolvedValue({
...currentResponse,
data: { question1: "answer1", question2: "answer2" },
} as any);
await updateResponse(mockResponseId, responseInput);
expect(prisma.response.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
data: { question1: "answer1", question2: "answer2" },
}),
})
);
});
});
describe("variables merging behavior", () => {
test("should merge new variables with existing variables", async () => {
const currentResponse = createMockCurrentResponse({
variables: { var1: "value1" },
});
const responseInput = createMockResponseInput({
variables: { var2: "value2" },
finished: false,
});
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
vi.mocked(prisma.response.update).mockResolvedValue({
...currentResponse,
variables: { var1: "value1", var2: "value2" },
} as any);
await updateResponse(mockResponseId, responseInput);
expect(prisma.response.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
variables: { var1: "value1", var2: "value2" },
}),
})
);
});
});
describe("error handling", () => {
test("should throw ResourceNotFoundError when response does not exist", async () => {
vi.mocked(prisma.response.findUnique).mockResolvedValue(null);
const responseInput = createMockResponseInput();
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(ResourceNotFoundError);
});
test("should throw DatabaseError on Prisma errors", async () => {
const currentResponse = createMockCurrentResponse();
vi.mocked(prisma.response.findUnique).mockResolvedValue(currentResponse as any);
vi.mocked(prisma.response.update).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "5.0.0",
})
);
const responseInput = createMockResponseInput();
await expect(updateResponse(mockResponseId, responseInput)).rejects.toThrow(DatabaseError);
});
});
});

View File

@@ -507,11 +507,16 @@ export const updateResponse = async (
...currentResponse.data,
...responseInput.data,
};
const ttc = responseInput.ttc
? responseInput.finished
? calculateTtcTotal(responseInput.ttc)
: responseInput.ttc
: {};
// merge ttc object (similar to data) to preserve TTC from previous blocks
const currentTtc = currentResponse.ttc;
const mergedTtc = responseInput.ttc
? {
...currentTtc,
...responseInput.ttc,
}
: currentTtc;
// Calculate total only when finished
const ttc = responseInput.finished ? calculateTtcTotal(mergedTtc) : mergedTtc;
const language = responseInput.language;
const variables = {
...currentResponse.variables,

View File

@@ -690,7 +690,9 @@ export const ElementsView = ({
return;
}
// Set active element to the first element of the first remaining block or ending card
setLocalSurvey(result.data);
// Then set active element to the first element of the first remaining block or ending card
const newBlocks = result.data.blocks ?? [];
if (newBlocks.length > 0 && newBlocks[0].elements.length > 0) {
setActiveElementId(newBlocks[0].elements[0].id);
@@ -698,7 +700,6 @@ export const ElementsView = ({
setActiveElementId(result.data.endings[0].id);
}
setLocalSurvey(result.data);
toast.success(t("environments.surveys.edit.block_deleted"));
};

View File

@@ -60,6 +60,9 @@ export function BlockConditional({
// Refs to store form elements for each element so we can trigger their validation
const elementFormRefs = useRef<Map<string, HTMLFormElement>>(new Map());
// Ref to collect TTC values synchronously (state updates are async)
const ttcCollectorRef = useRef<TResponseTtc>({});
// Handle change for an individual element
const handleElementChange = (elementId: string, responseData: TResponseData) => {
// If user moved to a different element, we should track it
@@ -69,6 +72,11 @@ export function BlockConditional({
onChange(responseData);
};
// Handler to collect TTC values synchronously (called from element form submissions)
const handleTtcCollect = (elementId: string, elementTtc: number) => {
ttcCollectorRef.current[elementId] = elementTtc;
};
// Handle skipPrefilled at block level
useEffect(() => {
if (skipPrefilled && prefilledResponseData) {
@@ -171,10 +179,26 @@ export function BlockConditional({
return;
}
// All validations passed - collect TTC for all elements in this block
// Clear the TTC collector before collecting new values
ttcCollectorRef.current = {};
// Call each form's submit method to trigger TTC calculation
// The forms will call handleTtcCollect synchronously with their TTC values
block.elements.forEach((element) => {
const form = elementFormRefs.current.get(element.id);
if (form) {
form.requestSubmit();
}
});
// Collect TTC from the ref (populated synchronously by form submissions)
// Falls back to state for elements that may have TTC from user interactions
const blockTtc: TResponseTtc = {};
block.elements.forEach((element) => {
if (ttc[element.id] !== undefined) {
// Prefer freshly calculated TTC from form submission, fall back to state
if (ttcCollectorRef.current[element.id] !== undefined) {
blockTtc[element.id] = ttcCollectorRef.current[element.id];
} else if (ttc[element.id] !== undefined) {
blockTtc[element.id] = ttc[element.id];
}
});
@@ -225,6 +249,7 @@ export function BlockConditional({
elementFormRefs.current.delete(element.id);
}
}}
onTtcCollect={handleTtcCollect}
/>
</div>
);

View File

@@ -42,6 +42,7 @@ interface ElementConditionalProps {
onOpenExternalURL?: (url: string) => void | Promise<void>;
dir?: "ltr" | "rtl" | "auto";
formRef?: (ref: HTMLFormElement | null) => void; // Callback to expose the form element
onTtcCollect?: (elementId: string, ttc: number) => void; // Callback to collect TTC synchronously
}
export function ElementConditional({
@@ -60,6 +61,7 @@ export function ElementConditional({
onOpenExternalURL,
dir,
formRef,
onTtcCollect,
}: ElementConditionalProps) {
// Ref to the container div, used to find and expose the form element inside
const containerRef = useRef<HTMLDivElement>(null);
@@ -75,6 +77,16 @@ export function ElementConditional({
}
}, [formRef]);
// Wrap setTtc to also call onTtcCollect synchronously
// This allows the block to collect TTC values without waiting for async state updates
const wrappedSetTtc = (newTtc: TResponseTtc) => {
setTtc(newTtc);
// Extract this element's TTC and call the collector if provided
if (onTtcCollect && newTtc[element.id] !== undefined) {
onTtcCollect(element.id, newTtc[element.id]);
}
};
const getResponseValueForRankingElement = (value: string[], choices: TSurveyElementChoice[]): string[] => {
return value
.map((entry) => {
@@ -122,7 +134,7 @@ export function ElementConditional({
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
setTtc={wrappedSetTtc}
autoFocusEnabled={autoFocusEnabled}
currentElementId={currentElementId}
dir={dir}
@@ -137,7 +149,7 @@ export function ElementConditional({
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
setTtc={wrappedSetTtc}
autoFocusEnabled={autoFocusEnabled}
currentElementId={currentElementId}
dir={dir}
@@ -152,7 +164,7 @@ export function ElementConditional({
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
setTtc={wrappedSetTtc}
autoFocusEnabled={autoFocusEnabled}
currentElementId={currentElementId}
dir={dir}
@@ -167,7 +179,7 @@ export function ElementConditional({
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
setTtc={wrappedSetTtc}
autoFocusEnabled={autoFocusEnabled}
currentElementId={currentElementId}
dir={dir}
@@ -182,7 +194,7 @@ export function ElementConditional({
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
setTtc={wrappedSetTtc}
autoFocusEnabled={autoFocusEnabled}
currentElementId={currentElementId}
onOpenExternalURL={onOpenExternalURL}
@@ -197,7 +209,7 @@ export function ElementConditional({
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
setTtc={wrappedSetTtc}
autoFocusEnabled={autoFocusEnabled}
currentElementId={currentElementId}
dir={dir}
@@ -212,7 +224,7 @@ export function ElementConditional({
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
setTtc={wrappedSetTtc}
autoFocusEnabled={autoFocusEnabled}
currentElementId={currentElementId}
dir={dir}
@@ -227,7 +239,7 @@ export function ElementConditional({
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
setTtc={wrappedSetTtc}
autoFocusEnabled={autoFocusEnabled}
currentElementId={currentElementId}
/>
@@ -241,7 +253,7 @@ export function ElementConditional({
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
setTtc={wrappedSetTtc}
autoFocusEnabled={autoFocusEnabled}
currentElementId={currentElementId}
dir={dir}
@@ -258,7 +270,7 @@ export function ElementConditional({
onFileUpload={onFileUpload}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
setTtc={wrappedSetTtc}
autoFocusEnabled={autoFocusEnabled}
currentElementId={currentElementId}
/>
@@ -272,7 +284,7 @@ export function ElementConditional({
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
setTtc={wrappedSetTtc}
currentElementId={currentElementId}
/>
);
@@ -284,7 +296,7 @@ export function ElementConditional({
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
setTtc={wrappedSetTtc}
currentElementId={currentElementId}
/>
);
@@ -296,7 +308,7 @@ export function ElementConditional({
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
setTtc={wrappedSetTtc}
currentElementId={currentElementId}
autoFocusEnabled={autoFocusEnabled}
dir={dir}
@@ -310,7 +322,7 @@ export function ElementConditional({
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
setTtc={wrappedSetTtc}
autoFocusEnabled={autoFocusEnabled}
currentElementId={currentElementId}
/>
@@ -323,7 +335,7 @@ export function ElementConditional({
onChange={onChange}
languageCode={languageCode}
ttc={ttc}
setTtc={setTtc}
setTtc={wrappedSetTtc}
currentElementId={currentElementId}
autoFocusEnabled={autoFocusEnabled}
dir={dir}