mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-21 18:18:48 -06:00
feat: advanced follow ups (#5340)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com> Co-authored-by: Johannes <johannes@formbricks.com>
This commit is contained in:
@@ -112,7 +112,7 @@ export const LandingSidebar = ({
|
||||
{/* Dropdown Items */}
|
||||
|
||||
{dropdownNavigation.map((link) => (
|
||||
<Link href={link.href} target={link.target} className="flex w-full items-center">
|
||||
<Link id={link.href} href={link.href} target={link.target} className="flex w-full items-center">
|
||||
<DropdownMenuItem>
|
||||
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||
{link.label}
|
||||
|
||||
@@ -12,9 +12,10 @@ type FollowUpResult = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const evaluateFollowUp = async (
|
||||
export const evaluateFollowUp = async (
|
||||
followUpId: string,
|
||||
followUpAction: TSurveyFollowUpAction,
|
||||
survey: TSurvey,
|
||||
response: TResponse,
|
||||
organization: TOrganization
|
||||
): Promise<void> => {
|
||||
@@ -22,6 +23,25 @@ const evaluateFollowUp = async (
|
||||
const { to, subject, body, replyTo } = properties;
|
||||
const toValueFromResponse = response.data[to];
|
||||
const logoUrl = organization.whitelabel?.logoUrl || "";
|
||||
|
||||
// Check if 'to' is a direct email address (team member or user email)
|
||||
const parsedEmailTo = z.string().email().safeParse(to);
|
||||
if (parsedEmailTo.success) {
|
||||
// 'to' is a valid email address, send email directly
|
||||
await sendFollowUpEmail({
|
||||
html: body,
|
||||
subject,
|
||||
to: parsedEmailTo.data,
|
||||
replyTo,
|
||||
survey,
|
||||
response,
|
||||
attachResponseData: properties.attachResponseData,
|
||||
logoUrl,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If not a direct email, check if it's a question ID or hidden field ID
|
||||
if (!toValueFromResponse) {
|
||||
throw new Error(`"To" value not found in response data for followup: ${followUpId}`);
|
||||
}
|
||||
@@ -31,7 +51,16 @@ const evaluateFollowUp = async (
|
||||
const parsedResult = z.string().email().safeParse(toValueFromResponse);
|
||||
if (parsedResult.data) {
|
||||
// send email to this email address
|
||||
await sendFollowUpEmail(body, subject, parsedResult.data, replyTo, logoUrl);
|
||||
await sendFollowUpEmail({
|
||||
html: body,
|
||||
subject,
|
||||
to: parsedResult.data,
|
||||
replyTo,
|
||||
logoUrl,
|
||||
survey,
|
||||
response,
|
||||
attachResponseData: properties.attachResponseData,
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Email address is not valid for followup: ${followUpId}`);
|
||||
}
|
||||
@@ -42,7 +71,16 @@ const evaluateFollowUp = async (
|
||||
}
|
||||
const parsedResult = z.string().email().safeParse(emailAddress);
|
||||
if (parsedResult.data) {
|
||||
await sendFollowUpEmail(body, subject, parsedResult.data, replyTo, logoUrl);
|
||||
await sendFollowUpEmail({
|
||||
html: body,
|
||||
subject,
|
||||
to: parsedResult.data,
|
||||
replyTo,
|
||||
logoUrl,
|
||||
survey,
|
||||
response,
|
||||
attachResponseData: properties.attachResponseData,
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Email address is not valid for followup: ${followUpId}`);
|
||||
}
|
||||
@@ -53,7 +91,7 @@ export const sendSurveyFollowUps = async (
|
||||
survey: TSurvey,
|
||||
response: TResponse,
|
||||
organization: TOrganization
|
||||
) => {
|
||||
): Promise<FollowUpResult[]> => {
|
||||
const followUpPromises = survey.followUps.map(async (followUp): Promise<FollowUpResult> => {
|
||||
const { trigger } = followUp;
|
||||
|
||||
@@ -70,7 +108,7 @@ export const sendSurveyFollowUps = async (
|
||||
}
|
||||
}
|
||||
|
||||
return evaluateFollowUp(followUp.id, followUp.action, response, organization)
|
||||
return evaluateFollowUp(followUp.id, followUp.action, survey, response, organization)
|
||||
.then(() => ({
|
||||
followUpId: followUp.id,
|
||||
status: "success" as const,
|
||||
@@ -92,4 +130,6 @@ export const sendSurveyFollowUps = async (
|
||||
if (errors.length > 0) {
|
||||
logger.error(errors, "Follow-up processing errors");
|
||||
}
|
||||
|
||||
return followUpResults;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyContactInfoQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
|
||||
export const mockEndingId1 = "mpkt4n5krsv2ulqetle7b9e7";
|
||||
export const mockEndingId2 = "ge0h63htnmgq6kwx1suh9cyi";
|
||||
|
||||
export const mockResponseEmailFollowUp: TSurvey["followUps"][number] = {
|
||||
id: "cm9gpuazd0002192z67olbfdt",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
surveyId: "cm9gptbhg0000192zceq9ayuc",
|
||||
name: "nice follow up",
|
||||
trigger: {
|
||||
type: "response",
|
||||
properties: null,
|
||||
},
|
||||
action: {
|
||||
type: "send-email",
|
||||
properties: {
|
||||
to: "vjniuob08ggl8dewl0hwed41",
|
||||
body: '<p class="fb-editor-paragraph"><span>Hey 👋</span><br><br><span>Thanks for taking the time to respond, we will be in touch shortly.</span><br><br><span>Have a great day!</span></p>',
|
||||
from: "noreply@example.com",
|
||||
replyTo: ["test@user.com"],
|
||||
subject: "Thanks for your answers!",
|
||||
attachResponseData: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const mockEndingFollowUp: TSurvey["followUps"][number] = {
|
||||
id: "j0g23cue6eih6xs5m0m4cj50",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
surveyId: "cm9gptbhg0000192zceq9ayuc",
|
||||
name: "nice follow up",
|
||||
trigger: {
|
||||
type: "endings",
|
||||
properties: {
|
||||
endingIds: [mockEndingId1],
|
||||
},
|
||||
},
|
||||
action: {
|
||||
type: "send-email",
|
||||
properties: {
|
||||
to: "vjniuob08ggl8dewl0hwed41",
|
||||
body: '<p class="fb-editor-paragraph"><span>Hey 👋</span><br><br><span>Thanks for taking the time to respond, we will be in touch shortly.</span><br><br><span>Have a great day!</span></p>',
|
||||
from: "noreply@example.com",
|
||||
replyTo: ["test@user.com"],
|
||||
subject: "Thanks for your answers!",
|
||||
attachResponseData: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const mockDirectEmailFollowUp: TSurvey["followUps"][number] = {
|
||||
id: "yyc5sq1fqofrsyw4viuypeku",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
surveyId: "cm9gptbhg0000192zceq9ayuc",
|
||||
name: "nice follow up 1",
|
||||
trigger: {
|
||||
type: "response",
|
||||
properties: null,
|
||||
},
|
||||
action: {
|
||||
type: "send-email",
|
||||
properties: {
|
||||
to: "direct@email.com",
|
||||
body: '<p class="fb-editor-paragraph"><span>Hey 👋</span><br><br><span>Thanks for taking the time to respond, we will be in touch shortly.</span><br><br><span>Have a great day!</span></p>',
|
||||
from: "noreply@example.com",
|
||||
replyTo: ["test@user.com"],
|
||||
subject: "Thanks for your answers!",
|
||||
attachResponseData: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const mockFollowUps: TSurvey["followUps"] = [mockDirectEmailFollowUp, mockResponseEmailFollowUp];
|
||||
|
||||
export const mockSurvey: TSurvey = {
|
||||
id: "cm9gptbhg0000192zceq9ayuc",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Start from scratch",
|
||||
type: "link",
|
||||
environmentId: "cm98djl8e000919hpzi6a80zp",
|
||||
createdBy: "cm98dg3xm000019hpubj39vfi",
|
||||
status: "inProgress",
|
||||
welcomeCard: {
|
||||
html: {
|
||||
default: "Thanks for providing your feedback - let's go!",
|
||||
},
|
||||
enabled: false,
|
||||
headline: {
|
||||
default: "Welcome!",
|
||||
},
|
||||
buttonLabel: {
|
||||
default: "Next",
|
||||
},
|
||||
timeToFinish: false,
|
||||
showResponseCount: false,
|
||||
},
|
||||
questions: [
|
||||
{
|
||||
id: "vjniuob08ggl8dewl0hwed41",
|
||||
type: "openText" as TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: {
|
||||
default: "What would you like to know?",
|
||||
},
|
||||
required: true,
|
||||
charLimit: {},
|
||||
inputType: "email",
|
||||
longAnswer: false,
|
||||
buttonLabel: {
|
||||
default: "Next",
|
||||
},
|
||||
placeholder: {
|
||||
default: "example@email.com",
|
||||
},
|
||||
},
|
||||
],
|
||||
endings: [
|
||||
{
|
||||
id: "gt1yoaeb5a3istszxqbl08mk",
|
||||
type: "endScreen",
|
||||
headline: {
|
||||
default: "Thank you!",
|
||||
},
|
||||
subheader: {
|
||||
default: "We appreciate your feedback.",
|
||||
},
|
||||
buttonLink: "https://formbricks.com",
|
||||
buttonLabel: {
|
||||
default: "Create your own Survey",
|
||||
},
|
||||
},
|
||||
],
|
||||
hiddenFields: {
|
||||
enabled: true,
|
||||
fieldIds: [],
|
||||
},
|
||||
variables: [],
|
||||
displayOption: "displayOnce",
|
||||
recontactDays: null,
|
||||
displayLimit: null,
|
||||
autoClose: null,
|
||||
runOnDate: null,
|
||||
closeOnDate: null,
|
||||
delay: 0,
|
||||
displayPercentage: null,
|
||||
autoComplete: null,
|
||||
isVerifyEmailEnabled: false,
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
isBackButtonHidden: false,
|
||||
projectOverwrites: null,
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
singleUse: {
|
||||
enabled: false,
|
||||
isEncrypted: true,
|
||||
},
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
showLanguageSwitch: null,
|
||||
languages: [],
|
||||
triggers: [],
|
||||
segment: null,
|
||||
followUps: mockFollowUps,
|
||||
};
|
||||
|
||||
export const mockContactQuestion: TSurveyContactInfoQuestion = {
|
||||
id: "zyoobxyolyqj17bt1i4ofr37",
|
||||
type: TSurveyQuestionTypeEnum.ContactInfo,
|
||||
email: {
|
||||
show: true,
|
||||
required: true,
|
||||
placeholder: {
|
||||
default: "Email",
|
||||
},
|
||||
},
|
||||
phone: {
|
||||
show: true,
|
||||
required: true,
|
||||
placeholder: {
|
||||
default: "Phone",
|
||||
},
|
||||
},
|
||||
company: {
|
||||
show: true,
|
||||
required: true,
|
||||
placeholder: {
|
||||
default: "Company",
|
||||
},
|
||||
},
|
||||
headline: {
|
||||
default: "Contact Question",
|
||||
},
|
||||
lastName: {
|
||||
show: true,
|
||||
required: true,
|
||||
placeholder: {
|
||||
default: "Last Name",
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
firstName: {
|
||||
show: true,
|
||||
required: true,
|
||||
placeholder: {
|
||||
default: "First Name",
|
||||
},
|
||||
},
|
||||
buttonLabel: {
|
||||
default: "Next",
|
||||
},
|
||||
backButtonLabel: {
|
||||
default: "Back",
|
||||
},
|
||||
};
|
||||
|
||||
export const mockContactEmailFollowUp: TSurvey["followUps"][number] = {
|
||||
...mockResponseEmailFollowUp,
|
||||
action: {
|
||||
...mockResponseEmailFollowUp.action,
|
||||
properties: {
|
||||
...mockResponseEmailFollowUp.action.properties,
|
||||
to: mockContactQuestion.id,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const mockSurveyWithContactQuestion: TSurvey = {
|
||||
...mockSurvey,
|
||||
questions: [mockContactQuestion],
|
||||
followUps: [mockContactEmailFollowUp],
|
||||
};
|
||||
|
||||
export const mockResponse: TResponse = {
|
||||
id: "response1",
|
||||
surveyId: "survey1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
variables: {},
|
||||
language: "en",
|
||||
data: {
|
||||
["vjniuob08ggl8dewl0hwed41"]: "test@example.com",
|
||||
},
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
meta: {},
|
||||
finished: true,
|
||||
notes: [],
|
||||
singleUseId: null,
|
||||
tags: [],
|
||||
displayId: null,
|
||||
};
|
||||
|
||||
export const mockResponseWithContactQuestion: TResponse = {
|
||||
...mockResponse,
|
||||
data: {
|
||||
zyoobxyolyqj17bt1i4ofr37: ["test", "user1", "test@user1.com", "99999999999", "sampleCompany"],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,235 @@
|
||||
import {
|
||||
mockContactEmailFollowUp,
|
||||
mockDirectEmailFollowUp,
|
||||
mockEndingFollowUp,
|
||||
mockEndingId2,
|
||||
mockResponse,
|
||||
mockResponseEmailFollowUp,
|
||||
mockResponseWithContactQuestion,
|
||||
mockSurvey,
|
||||
mockSurveyWithContactQuestion,
|
||||
} from "@/app/api/(internal)/pipeline/lib/tests/__mocks__/survey-follow-up.mock";
|
||||
import { sendFollowUpEmail } from "@/modules/email";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { evaluateFollowUp, sendSurveyFollowUps } from "../survey-follow-up";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/modules/email", () => ({
|
||||
sendFollowUpEmail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Survey Follow Up", () => {
|
||||
const mockOrganization: Partial<TOrganization> = {
|
||||
id: "org1",
|
||||
name: "Test Org",
|
||||
whitelabel: {
|
||||
logoUrl: "https://example.com/logo.png",
|
||||
},
|
||||
};
|
||||
|
||||
describe("evaluateFollowUp", () => {
|
||||
test("sends email when to is a direct email address", async () => {
|
||||
const followUpId = mockDirectEmailFollowUp.id;
|
||||
const followUpAction = mockDirectEmailFollowUp.action;
|
||||
|
||||
await evaluateFollowUp(
|
||||
followUpId,
|
||||
followUpAction,
|
||||
mockSurvey,
|
||||
mockResponse,
|
||||
mockOrganization as TOrganization
|
||||
);
|
||||
|
||||
expect(sendFollowUpEmail).toHaveBeenCalledWith({
|
||||
html: mockDirectEmailFollowUp.action.properties.body,
|
||||
subject: mockDirectEmailFollowUp.action.properties.subject,
|
||||
to: mockDirectEmailFollowUp.action.properties.to,
|
||||
replyTo: mockDirectEmailFollowUp.action.properties.replyTo,
|
||||
survey: mockSurvey,
|
||||
response: mockResponse,
|
||||
attachResponseData: true,
|
||||
logoUrl: "https://example.com/logo.png",
|
||||
});
|
||||
});
|
||||
|
||||
test("sends email when to is a question ID with valid email", async () => {
|
||||
const followUpId = mockResponseEmailFollowUp.id;
|
||||
const followUpAction = mockResponseEmailFollowUp.action;
|
||||
|
||||
await evaluateFollowUp(
|
||||
followUpId,
|
||||
followUpAction,
|
||||
mockSurvey as TSurvey,
|
||||
mockResponse as TResponse,
|
||||
mockOrganization as TOrganization
|
||||
);
|
||||
|
||||
expect(sendFollowUpEmail).toHaveBeenCalledWith({
|
||||
html: mockResponseEmailFollowUp.action.properties.body,
|
||||
subject: mockResponseEmailFollowUp.action.properties.subject,
|
||||
to: mockResponse.data[mockResponseEmailFollowUp.action.properties.to],
|
||||
replyTo: mockResponseEmailFollowUp.action.properties.replyTo,
|
||||
survey: mockSurvey,
|
||||
response: mockResponse,
|
||||
attachResponseData: true,
|
||||
logoUrl: "https://example.com/logo.png",
|
||||
});
|
||||
});
|
||||
|
||||
test("sends email when to is a question ID with valid email in array", async () => {
|
||||
const followUpId = mockContactEmailFollowUp.id;
|
||||
const followUpAction = mockContactEmailFollowUp.action;
|
||||
|
||||
await evaluateFollowUp(
|
||||
followUpId,
|
||||
followUpAction,
|
||||
mockSurveyWithContactQuestion,
|
||||
mockResponseWithContactQuestion,
|
||||
mockOrganization as TOrganization
|
||||
);
|
||||
|
||||
expect(sendFollowUpEmail).toHaveBeenCalledWith({
|
||||
html: mockContactEmailFollowUp.action.properties.body,
|
||||
subject: mockContactEmailFollowUp.action.properties.subject,
|
||||
to: mockResponseWithContactQuestion.data[mockContactEmailFollowUp.action.properties.to][2],
|
||||
replyTo: mockContactEmailFollowUp.action.properties.replyTo,
|
||||
survey: mockSurveyWithContactQuestion,
|
||||
response: mockResponseWithContactQuestion,
|
||||
attachResponseData: true,
|
||||
logoUrl: "https://example.com/logo.png",
|
||||
});
|
||||
});
|
||||
|
||||
test("throws error when to value is not found in response data", async () => {
|
||||
const followUpId = "followup1";
|
||||
const followUpAction = {
|
||||
...mockSurvey.followUps![0].action,
|
||||
properties: {
|
||||
...mockSurvey.followUps![0].action.properties,
|
||||
to: "nonExistentField",
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
evaluateFollowUp(
|
||||
followUpId,
|
||||
followUpAction,
|
||||
mockSurvey as TSurvey,
|
||||
mockResponse as TResponse,
|
||||
mockOrganization as TOrganization
|
||||
)
|
||||
).rejects.toThrow(`"To" value not found in response data for followup: ${followUpId}`);
|
||||
});
|
||||
|
||||
test("throws error when email address is invalid", async () => {
|
||||
const followUpId = mockResponseEmailFollowUp.id;
|
||||
const followUpAction = mockResponseEmailFollowUp.action;
|
||||
|
||||
const invalidResponse = {
|
||||
...mockResponse,
|
||||
data: {
|
||||
[mockResponseEmailFollowUp.action.properties.to]: "invalid-email",
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
evaluateFollowUp(
|
||||
followUpId,
|
||||
followUpAction,
|
||||
mockSurvey,
|
||||
invalidResponse,
|
||||
mockOrganization as TOrganization
|
||||
)
|
||||
).rejects.toThrow(`Email address is not valid for followup: ${followUpId}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendSurveyFollowUps", () => {
|
||||
test("skips follow-up when ending Id doesn't match", async () => {
|
||||
const responseWithDifferentEnding = {
|
||||
...mockResponse,
|
||||
endingId: mockEndingId2,
|
||||
};
|
||||
|
||||
const mockSurveyWithEndingFollowUp: TSurvey = {
|
||||
...mockSurvey,
|
||||
followUps: [mockEndingFollowUp],
|
||||
};
|
||||
|
||||
const results = await sendSurveyFollowUps(
|
||||
mockSurveyWithEndingFollowUp,
|
||||
responseWithDifferentEnding as TResponse,
|
||||
mockOrganization as TOrganization
|
||||
);
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
followUpId: mockEndingFollowUp.id,
|
||||
status: "skipped",
|
||||
},
|
||||
]);
|
||||
expect(sendFollowUpEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("processes follow-ups and log errors", async () => {
|
||||
const error = new Error("Test error");
|
||||
vi.mocked(sendFollowUpEmail).mockRejectedValueOnce(error);
|
||||
|
||||
const mockSurveyWithFollowUps: TSurvey = {
|
||||
...mockSurvey,
|
||||
followUps: [mockResponseEmailFollowUp],
|
||||
};
|
||||
|
||||
const results = await sendSurveyFollowUps(
|
||||
mockSurveyWithFollowUps,
|
||||
mockResponse,
|
||||
mockOrganization as TOrganization
|
||||
);
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
followUpId: mockResponseEmailFollowUp.id,
|
||||
status: "error",
|
||||
error: "Test error",
|
||||
},
|
||||
]);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
[`FollowUp ${mockResponseEmailFollowUp.id} failed: Test error`],
|
||||
"Follow-up processing errors"
|
||||
);
|
||||
});
|
||||
|
||||
test("successfully processes follow-ups", async () => {
|
||||
vi.mocked(sendFollowUpEmail).mockResolvedValueOnce(undefined);
|
||||
|
||||
const mockSurveyWithFollowUp: TSurvey = {
|
||||
...mockSurvey,
|
||||
followUps: [mockDirectEmailFollowUp],
|
||||
};
|
||||
|
||||
const results = await sendSurveyFollowUps(
|
||||
mockSurveyWithFollowUp,
|
||||
mockResponse,
|
||||
mockOrganization as TOrganization
|
||||
);
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
followUpId: mockDirectEmailFollowUp.id,
|
||||
status: "success",
|
||||
},
|
||||
]);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ import { convertDatesInObject } from "@formbricks/lib/time";
|
||||
import { getPromptText } from "@formbricks/lib/utils/ai";
|
||||
import { parseRecallInfo } from "@formbricks/lib/utils/recall";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { handleIntegrations } from "./lib/handleIntegrations";
|
||||
|
||||
export const POST = async (request: Request) => {
|
||||
@@ -50,7 +51,7 @@ export const POST = async (request: Request) => {
|
||||
|
||||
const organization = await getOrganizationByEnvironmentId(environmentId);
|
||||
if (!organization) {
|
||||
throw new Error("Organization not found");
|
||||
throw new ResourceNotFoundError("Organization", "Organization not found");
|
||||
}
|
||||
|
||||
// Fetch webhooks
|
||||
|
||||
@@ -42,7 +42,7 @@ const enforceHttps = (request: NextRequest): Response | null => {
|
||||
details: [
|
||||
{
|
||||
field: "",
|
||||
issue: "Only HTTPS connections are allowed on the management and contacts bulk endpoints.",
|
||||
issue: "Only HTTPS connections are allowed on the management endpoints.",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Logo } from "@/modules/ui/components/logo";
|
||||
import Link from "next/link";
|
||||
|
||||
interface FormWrapperProps {
|
||||
children: React.ReactNode;
|
||||
@@ -9,7 +10,9 @@ export const FormWrapper = ({ children }: FormWrapperProps) => {
|
||||
<div className="mx-auto flex flex-1 flex-col justify-center px-4 py-12 sm:px-6 lg:flex-none lg:px-20 xl:px-24">
|
||||
<div className="mx-auto w-full max-w-sm rounded-xl bg-white p-8 shadow-xl lg:w-96">
|
||||
<div className="mb-8 text-center">
|
||||
<Logo className="mx-auto w-3/4" />
|
||||
<Link target="_blank" href="https://formbricks.com?utm_source=ce" rel="noopener noreferrer">
|
||||
<Logo className="mx-auto w-3/4" />
|
||||
</Link>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -46,7 +46,13 @@ export async function EmailTemplate({
|
||||
</Container>
|
||||
|
||||
<Section className="mt-4 text-center text-sm">
|
||||
<Text className="m-0 font-normal text-slate-500">{t("emails.email_template_text_1")}</Text>
|
||||
<Link
|
||||
className="m-0 font-normal text-slate-500"
|
||||
href="https://formbricks.com/?utm_source=email_header&utm_medium=email"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
{t("emails.email_template_text_1")}
|
||||
</Link>
|
||||
{IMPRINT_ADDRESS && (
|
||||
<Text className="m-0 font-normal text-slate-500 opacity-50">{IMPRINT_ADDRESS}</Text>
|
||||
)}
|
||||
@@ -56,7 +62,7 @@ export async function EmailTemplate({
|
||||
{t("emails.imprint")}
|
||||
</Link>
|
||||
)}
|
||||
{IMPRINT_URL && PRIVACY_URL && "•"}
|
||||
{IMPRINT_URL && PRIVACY_URL && " • "}
|
||||
{PRIVACY_URL && (
|
||||
<Link href={PRIVACY_URL} target="_blank" rel="noopener noreferrer" className="text-slate-500">
|
||||
{t("emails.privacy_policy")}
|
||||
|
||||
259
apps/web/modules/email/emails/lib/tests/utils.test.tsx
Normal file
259
apps/web/modules/email/emails/lib/tests/utils.test.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { TFnType, TranslationKey } from "@tolgee/react";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { renderEmailResponseValue } from "../utils";
|
||||
|
||||
// Mock the components from @react-email/components to avoid dependency issues
|
||||
vi.mock("@react-email/components", () => ({
|
||||
Text: ({ children, className }) => <p className={className}>{children}</p>,
|
||||
Container: ({ children }) => <div>{children}</div>,
|
||||
Row: ({ children, className }) => <div className={className}>{children}</div>,
|
||||
Column: ({ children, className }) => <div className={className}>{children}</div>,
|
||||
Link: ({ children, href }) => <a href={href}>{children}</a>,
|
||||
Img: ({ src, alt, className }) => <img src={src} alt={alt} className={className} />,
|
||||
}));
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@formbricks/lib/storage/utils", () => ({
|
||||
getOriginalFileNameFromUrl: (url: string) => {
|
||||
// Extract filename from the URL for testing purposes
|
||||
const parts = url.split("/");
|
||||
return parts[parts.length - 1];
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock translation function
|
||||
const mockTranslate = (key: TranslationKey) => key;
|
||||
|
||||
describe("renderEmailResponseValue", () => {
|
||||
describe("FileUpload question type", () => {
|
||||
test("renders clickable file upload links with file icons and truncated file names when overrideFileUploadResponse is false", async () => {
|
||||
// Arrange
|
||||
const fileUrls = [
|
||||
"https://example.com/uploads/file1.pdf",
|
||||
"https://example.com/uploads/very-long-filename-that-should-be-truncated.docx",
|
||||
];
|
||||
|
||||
// Act
|
||||
const result = await renderEmailResponseValue(
|
||||
fileUrls,
|
||||
TSurveyQuestionTypeEnum.FileUpload,
|
||||
mockTranslate as unknown as TFnType,
|
||||
false
|
||||
);
|
||||
|
||||
render(result);
|
||||
|
||||
// Assert
|
||||
// Check if we have the correct number of links
|
||||
const links = screen.getAllByRole("link");
|
||||
expect(links).toHaveLength(2);
|
||||
|
||||
// Check if links have correct hrefs
|
||||
expect(links[0]).toHaveAttribute("href", fileUrls[0]);
|
||||
expect(links[1]).toHaveAttribute("href", fileUrls[1]);
|
||||
|
||||
// Check if file names are displayed
|
||||
expect(screen.getByText("file1.pdf")).toBeInTheDocument();
|
||||
expect(screen.getByText("very-long-filename-that-should-be-truncated.docx")).toBeInTheDocument();
|
||||
|
||||
// Check for SVG icons (file icons)
|
||||
const svgElements = document.querySelectorAll("svg");
|
||||
expect(svgElements.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("renders a message when overrideFileUploadResponse is true", async () => {
|
||||
// Arrange
|
||||
const fileUrls = ["https://example.com/uploads/file1.pdf"];
|
||||
const expectedMessage = "emails.render_email_response_value_file_upload_response_link_not_included";
|
||||
|
||||
// Act
|
||||
const result = await renderEmailResponseValue(
|
||||
fileUrls,
|
||||
TSurveyQuestionTypeEnum.FileUpload,
|
||||
mockTranslate as unknown as TFnType,
|
||||
true
|
||||
);
|
||||
|
||||
render(result);
|
||||
|
||||
// Assert
|
||||
// Check that the override message is displayed
|
||||
expect(screen.getByText(expectedMessage)).toBeInTheDocument();
|
||||
expect(screen.getByText(expectedMessage)).toHaveClass(
|
||||
"mt-0",
|
||||
"font-bold",
|
||||
"break-words",
|
||||
"whitespace-pre-wrap",
|
||||
"italic"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PictureSelection question type", () => {
|
||||
test("renders images with appropriate alt text and styling", async () => {
|
||||
// Arrange
|
||||
const imageUrls = [
|
||||
"https://example.com/images/sunset.jpg",
|
||||
"https://example.com/images/mountain.png",
|
||||
"https://example.com/images/beach.webp",
|
||||
];
|
||||
|
||||
// Act
|
||||
const result = await renderEmailResponseValue(
|
||||
imageUrls,
|
||||
TSurveyQuestionTypeEnum.PictureSelection,
|
||||
mockTranslate as unknown as TFnType
|
||||
);
|
||||
|
||||
render(result);
|
||||
|
||||
// Assert
|
||||
// Check if we have the correct number of images
|
||||
const images = screen.getAllByRole("img");
|
||||
expect(images).toHaveLength(3);
|
||||
|
||||
// Check if images have correct src attributes
|
||||
expect(images[0]).toHaveAttribute("src", imageUrls[0]);
|
||||
expect(images[1]).toHaveAttribute("src", imageUrls[1]);
|
||||
expect(images[2]).toHaveAttribute("src", imageUrls[2]);
|
||||
|
||||
// Check if images have correct alt text (extracted from URL)
|
||||
expect(images[0]).toHaveAttribute("alt", "sunset.jpg");
|
||||
expect(images[1]).toHaveAttribute("alt", "mountain.png");
|
||||
expect(images[2]).toHaveAttribute("alt", "beach.webp");
|
||||
|
||||
// Check if images have the expected styling class
|
||||
expect(images[0]).toHaveAttribute("class", "m-2 h-28");
|
||||
expect(images[1]).toHaveAttribute("class", "m-2 h-28");
|
||||
expect(images[2]).toHaveAttribute("class", "m-2 h-28");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ranking question type", () => {
|
||||
test("renders ranking responses with proper numbering and styling", async () => {
|
||||
// Arrange
|
||||
const rankingItems = ["First Choice", "Second Choice", "Third Choice"];
|
||||
|
||||
// Act
|
||||
const result = await renderEmailResponseValue(
|
||||
rankingItems,
|
||||
TSurveyQuestionTypeEnum.Ranking,
|
||||
mockTranslate as unknown as TFnType
|
||||
);
|
||||
|
||||
render(result);
|
||||
|
||||
// Assert
|
||||
// Check if we have the correct number of ranking items
|
||||
const rankingElements = document.querySelectorAll(".mb-1");
|
||||
expect(rankingElements).toHaveLength(3);
|
||||
|
||||
// Check if each item has the correct number and styling
|
||||
rankingItems.forEach((item, index) => {
|
||||
const itemElement = screen.getByText(item);
|
||||
expect(itemElement).toBeInTheDocument();
|
||||
expect(itemElement).toHaveClass("rounded", "bg-slate-100", "px-2", "py-1");
|
||||
|
||||
// Check if the ranking number is present
|
||||
const rankNumber = screen.getByText(`#${index + 1}`);
|
||||
expect(rankNumber).toBeInTheDocument();
|
||||
expect(rankNumber).toHaveClass("text-slate-400");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("handling long text responses", () => {
|
||||
test("properly formats extremely long text responses with line breaks", async () => {
|
||||
// Arrange
|
||||
// Create a very long text response with multiple paragraphs and long words
|
||||
const longTextResponse = `This is the first paragraph of a very long response that might be submitted by a user in an open text question. It contains detailed information and feedback.
|
||||
|
||||
This is the second paragraph with an extremely long word: ${"supercalifragilisticexpialidocious".repeat(5)}
|
||||
|
||||
And here's a third paragraph with more text and some line
|
||||
breaks within the paragraph itself to test if they are preserved properly.
|
||||
|
||||
${"This is a very long sentence that should wrap properly within the email layout and not break the formatting. ".repeat(10)}`;
|
||||
|
||||
// Act
|
||||
const result = await renderEmailResponseValue(
|
||||
longTextResponse,
|
||||
TSurveyQuestionTypeEnum.OpenText,
|
||||
mockTranslate as unknown as TFnType
|
||||
);
|
||||
|
||||
render(result);
|
||||
|
||||
// Assert
|
||||
// Check if the text is rendered
|
||||
const textElement = screen.getByText(/This is the first paragraph/);
|
||||
expect(textElement).toBeInTheDocument();
|
||||
|
||||
// Check if the extremely long word is rendered without breaking the layout
|
||||
expect(screen.getByText(/supercalifragilisticexpialidocious/)).toBeInTheDocument();
|
||||
|
||||
// Verify the text element has the proper CSS classes for handling long text
|
||||
expect(textElement).toHaveClass("break-words");
|
||||
expect(textElement).toHaveClass("whitespace-pre-wrap");
|
||||
|
||||
// Verify the content is preserved exactly as provided
|
||||
expect(textElement.textContent).toBe(longTextResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Default case (unmatched question type)", () => {
|
||||
test("renders the response as plain text when the question type does not match any specific case", async () => {
|
||||
// Arrange
|
||||
const response = "This is a plain text response";
|
||||
// Using a question type that doesn't match any specific case in the switch statement
|
||||
const questionType = "CustomQuestionType" as any;
|
||||
|
||||
// Act
|
||||
const result = await renderEmailResponseValue(
|
||||
response,
|
||||
questionType,
|
||||
mockTranslate as unknown as TFnType
|
||||
);
|
||||
|
||||
render(result);
|
||||
|
||||
// Assert
|
||||
// Check if the response text is rendered
|
||||
expect(screen.getByText(response)).toBeInTheDocument();
|
||||
|
||||
// Check if the text has the expected styling classes
|
||||
const textElement = screen.getByText(response);
|
||||
expect(textElement).toHaveClass("mt-0", "font-bold", "break-words", "whitespace-pre-wrap");
|
||||
});
|
||||
|
||||
test("handles array responses in the default case by rendering them as text", async () => {
|
||||
// Arrange
|
||||
const response = ["Item 1", "Item 2", "Item 3"];
|
||||
const questionType = "AnotherCustomType" as any;
|
||||
|
||||
// Act
|
||||
const result = await renderEmailResponseValue(
|
||||
response,
|
||||
questionType,
|
||||
mockTranslate as unknown as TFnType
|
||||
);
|
||||
|
||||
// Create a fresh container for this test to avoid conflicts with previous renders
|
||||
const container = document.createElement("div");
|
||||
render(result, { container });
|
||||
|
||||
// Assert
|
||||
// Check if the text element contains all items from the response array
|
||||
const textElement = container.querySelector("p");
|
||||
expect(textElement).not.toBeNull();
|
||||
expect(textElement).toHaveClass("mt-0", "font-bold", "break-words", "whitespace-pre-wrap");
|
||||
|
||||
// Verify each item is present in the text content
|
||||
response.forEach((item) => {
|
||||
expect(textElement?.textContent).toContain(item);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
71
apps/web/modules/email/emails/lib/utils.tsx
Normal file
71
apps/web/modules/email/emails/lib/utils.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Column, Container, Img, Link, Row, Text } from "@react-email/components";
|
||||
import { TFnType } from "@tolgee/react";
|
||||
import { FileIcon } from "lucide-react";
|
||||
import { getOriginalFileNameFromUrl } from "@formbricks/lib/storage/utils";
|
||||
import { TSurveyQuestionType, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const renderEmailResponseValue = async (
|
||||
response: string | string[],
|
||||
questionType: TSurveyQuestionType,
|
||||
t: TFnType,
|
||||
overrideFileUploadResponse = false
|
||||
): Promise<React.JSX.Element> => {
|
||||
switch (questionType) {
|
||||
case TSurveyQuestionTypeEnum.FileUpload:
|
||||
return (
|
||||
<Container>
|
||||
{overrideFileUploadResponse ? (
|
||||
<Text className="mt-0 font-bold break-words whitespace-pre-wrap italic">
|
||||
{t("emails.render_email_response_value_file_upload_response_link_not_included")}
|
||||
</Text>
|
||||
) : (
|
||||
Array.isArray(response) &&
|
||||
response.map((responseItem) => (
|
||||
<Link
|
||||
className="mt-2 flex flex-col items-center justify-center rounded-lg bg-slate-200 p-2 text-black shadow-sm"
|
||||
href={responseItem}
|
||||
key={responseItem}>
|
||||
<FileIcon />
|
||||
<Text className="mx-auto mb-0 truncate">{getOriginalFileNameFromUrl(responseItem)}</Text>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
|
||||
case TSurveyQuestionTypeEnum.PictureSelection:
|
||||
return (
|
||||
<Container>
|
||||
<Row>
|
||||
{Array.isArray(response) &&
|
||||
response.map((responseItem) => (
|
||||
<Column key={responseItem}>
|
||||
<Img alt={responseItem.split("/").pop()} className="m-2 h-28" src={responseItem} />
|
||||
</Column>
|
||||
))}
|
||||
</Row>
|
||||
</Container>
|
||||
);
|
||||
|
||||
case TSurveyQuestionTypeEnum.Ranking:
|
||||
return (
|
||||
<Container>
|
||||
<Row className="my-1 font-semibold text-slate-700" dir="auto">
|
||||
{Array.isArray(response) &&
|
||||
response.map(
|
||||
(item, index) =>
|
||||
item && (
|
||||
<Row key={item} className="mb-1 flex items-center">
|
||||
<Column className="w-6 text-slate-400">#{index + 1}</Column>
|
||||
<Column className="rounded bg-slate-100 px-2 py-1">{item}</Column>
|
||||
</Row>
|
||||
)
|
||||
)}
|
||||
</Row>
|
||||
</Container>
|
||||
);
|
||||
|
||||
default:
|
||||
return <Text className="mt-0 font-bold break-words whitespace-pre-wrap">{response}</Text>;
|
||||
}
|
||||
};
|
||||
@@ -3,6 +3,8 @@ import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { DefaultParamType, TFnType, TranslationKey } from "@tolgee/react/server";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { FollowUpEmail } from "./follow-up";
|
||||
|
||||
vi.mock("@formbricks/lib/constants", () => ({
|
||||
@@ -17,9 +19,41 @@ vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/email/emails/lib/utils", () => ({
|
||||
renderEmailResponseValue: vi.fn(() => <p data-testid="response-value">user@example.com</p>),
|
||||
}));
|
||||
|
||||
const defaultProps = {
|
||||
html: "<p>Test HTML Content</p>",
|
||||
logoUrl: "https://example.com/custom-logo.png",
|
||||
attachResponseData: false,
|
||||
survey: {
|
||||
questions: [
|
||||
{
|
||||
id: "vjniuob08ggl8dewl0hwed41",
|
||||
type: "openText" as TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: {
|
||||
default: "What would you like to know?",
|
||||
},
|
||||
required: true,
|
||||
charLimit: {},
|
||||
inputType: "email",
|
||||
longAnswer: false,
|
||||
buttonLabel: {
|
||||
default: "Next",
|
||||
},
|
||||
placeholder: {
|
||||
default: "example@email.com",
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as TSurvey,
|
||||
response: {
|
||||
data: {
|
||||
vjniuob08ggl8dewl0hwed41: "user@example.com",
|
||||
},
|
||||
language: null,
|
||||
} as unknown as TResponse,
|
||||
};
|
||||
|
||||
describe("FollowUpEmail", () => {
|
||||
@@ -86,7 +120,18 @@ describe("FollowUpEmail", () => {
|
||||
|
||||
render(followUpEmailElement);
|
||||
|
||||
expect(screen.getByText("emails.powered_by_formbricks")).toBeInTheDocument();
|
||||
expect(screen.getByText("emails.email_template_text_1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Imprint Address")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders the response data if attachResponseData is true", async () => {
|
||||
const followUpEmailElement = await FollowUpEmail({
|
||||
...defaultProps,
|
||||
attachResponseData: true,
|
||||
});
|
||||
|
||||
render(followUpEmailElement);
|
||||
|
||||
expect(screen.getByTestId("response-value")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,44 @@
|
||||
import { renderEmailResponseValue } from "@/modules/email/emails/lib/utils";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { Body, Container, Html, Img, Link, Section, Tailwind, Text } from "@react-email/components";
|
||||
import {
|
||||
Body,
|
||||
Column,
|
||||
Container,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Row,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import dompurify from "isomorphic-dompurify";
|
||||
import React from "react";
|
||||
import { FB_LOGO_URL, IMPRINT_ADDRESS, IMPRINT_URL, PRIVACY_URL } from "@formbricks/lib/constants";
|
||||
import { getQuestionResponseMapping } from "@formbricks/lib/responses";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
const fbLogoUrl = FB_LOGO_URL;
|
||||
const logoLink = "https://formbricks.com?utm_source=email_header&utm_medium=email";
|
||||
|
||||
interface FollowUpEmailProps {
|
||||
readonly html: string;
|
||||
readonly logoUrl?: string;
|
||||
html: string;
|
||||
logoUrl?: string;
|
||||
attachResponseData: boolean;
|
||||
survey: TSurvey;
|
||||
response: TResponse;
|
||||
}
|
||||
|
||||
export async function FollowUpEmail({ html, logoUrl }: FollowUpEmailProps): Promise<React.JSX.Element> {
|
||||
export async function FollowUpEmail({
|
||||
html,
|
||||
logoUrl,
|
||||
attachResponseData,
|
||||
survey,
|
||||
response,
|
||||
}: FollowUpEmailProps): Promise<React.JSX.Element> {
|
||||
const questions = attachResponseData ? getQuestionResponseMapping(survey, response) : [];
|
||||
const t = await getTranslate();
|
||||
const isDefaultLogo = !logoUrl || logoUrl === fbLogoUrl;
|
||||
|
||||
@@ -20,20 +46,20 @@ export async function FollowUpEmail({ html, logoUrl }: FollowUpEmailProps): Prom
|
||||
<Html>
|
||||
<Tailwind>
|
||||
<Body
|
||||
className="m-0 h-full w-full justify-center bg-slate-50 p-6 text-center text-base font-medium text-slate-800"
|
||||
className="m-0 h-full w-full justify-center bg-slate-50 p-6 text-center text-slate-800"
|
||||
style={{
|
||||
fontFamily: "'Jost', 'Helvetica Neue', 'Segoe UI', 'Helvetica', 'sans-serif'",
|
||||
}}>
|
||||
<Section>
|
||||
{isDefaultLogo ? (
|
||||
<Link href={logoLink} target="_blank">
|
||||
<Img alt="Logo" className="mx-auto w-80" src={fbLogoUrl} />
|
||||
<Img alt="Logo" className="mx-auto w-60" src={fbLogoUrl} />
|
||||
</Link>
|
||||
) : (
|
||||
<Img alt="Logo" className="mx-auto max-h-[100px] w-80 object-contain" src={logoUrl} />
|
||||
<Img alt="Logo" className="mx-auto max-h-[100px] w-60 object-contain" src={logoUrl} />
|
||||
)}
|
||||
</Section>
|
||||
<Container className="mx-auto my-8 max-w-xl rounded-md bg-white p-4 text-left">
|
||||
<Container className="mx-auto my-8 max-w-xl rounded-md bg-white p-4 text-left text-sm">
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: dompurify.sanitize(html, {
|
||||
@@ -44,11 +70,30 @@ export async function FollowUpEmail({ html, logoUrl }: FollowUpEmailProps): Prom
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
|
||||
{questions.length > 0 ? <Hr /> : null}
|
||||
|
||||
{questions.map((question) => {
|
||||
if (!question.response) return;
|
||||
return (
|
||||
<Row key={question.question}>
|
||||
<Column className="w-full">
|
||||
<Text className="mb-2 font-medium">{question.question}</Text>
|
||||
{renderEmailResponseValue(question.response, question.type, t, true)}
|
||||
</Column>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</Container>
|
||||
|
||||
<Section className="mt-4 text-center text-sm">
|
||||
<Text className="m-0 font-normal text-slate-500">{t("emails.powered_by_formbricks")}</Text>
|
||||
|
||||
<Link
|
||||
className="m-0 font-normal text-slate-500"
|
||||
href="https://formbricks.com/?utm_source=email_header&utm_medium=email"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
{t("emails.email_template_text_1")}
|
||||
</Link>
|
||||
{IMPRINT_ADDRESS && (
|
||||
<Text className="m-0 font-normal text-slate-500 opacity-50">{IMPRINT_ADDRESS}</Text>
|
||||
)}
|
||||
@@ -58,7 +103,7 @@ export async function FollowUpEmail({ html, logoUrl }: FollowUpEmailProps): Prom
|
||||
{t("emails.imprint")}
|
||||
</Link>
|
||||
)}
|
||||
{IMPRINT_URL && PRIVACY_URL && "•"}
|
||||
{IMPRINT_URL && PRIVACY_URL && " • "}
|
||||
{PRIVACY_URL && (
|
||||
<Link href={PRIVACY_URL} target="_blank" rel="noopener noreferrer" className="text-slate-500">
|
||||
{t("emails.privacy_policy")}
|
||||
|
||||
@@ -1,76 +1,14 @@
|
||||
import { renderEmailResponseValue } from "@/modules/email/emails/lib/utils";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { Column, Container, Hr, Img, Link, Row, Section, Text } from "@react-email/components";
|
||||
import { Column, Container, Hr, Link, Row, Section, Text } from "@react-email/components";
|
||||
import { FileDigitIcon, FileType2Icon } from "lucide-react";
|
||||
import { getQuestionResponseMapping } from "@formbricks/lib/responses";
|
||||
import { getOriginalFileNameFromUrl } from "@formbricks/lib/storage/utils";
|
||||
import type { TOrganization } from "@formbricks/types/organizations";
|
||||
import type { TResponse } from "@formbricks/types/responses";
|
||||
import {
|
||||
type TSurvey,
|
||||
type TSurveyQuestionType,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { type TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { EmailButton } from "../../components/email-button";
|
||||
import { EmailTemplate } from "../../components/email-template";
|
||||
|
||||
export const renderEmailResponseValue = async (
|
||||
response: string | string[],
|
||||
questionType: TSurveyQuestionType
|
||||
): Promise<React.JSX.Element> => {
|
||||
switch (questionType) {
|
||||
case TSurveyQuestionTypeEnum.FileUpload:
|
||||
return (
|
||||
<Container>
|
||||
{Array.isArray(response) &&
|
||||
response.map((responseItem) => (
|
||||
<Link
|
||||
className="mt-2 flex flex-col items-center justify-center rounded-lg bg-slate-200 p-2 text-black shadow-sm"
|
||||
href={responseItem}
|
||||
key={responseItem}>
|
||||
<FileIcon />
|
||||
<Text className="mx-auto mb-0 truncate">{getOriginalFileNameFromUrl(responseItem)}</Text>
|
||||
</Link>
|
||||
))}
|
||||
</Container>
|
||||
);
|
||||
|
||||
case TSurveyQuestionTypeEnum.PictureSelection:
|
||||
return (
|
||||
<Container>
|
||||
<Row>
|
||||
{Array.isArray(response) &&
|
||||
response.map((responseItem) => (
|
||||
<Column key={responseItem}>
|
||||
<Img alt={responseItem.split("/").pop()} className="m-2 h-28" src={responseItem} />
|
||||
</Column>
|
||||
))}
|
||||
</Row>
|
||||
</Container>
|
||||
);
|
||||
|
||||
case TSurveyQuestionTypeEnum.Ranking:
|
||||
return (
|
||||
<Container>
|
||||
<Row className="my-1 font-semibold text-slate-700" dir="auto">
|
||||
{Array.isArray(response) &&
|
||||
response.map(
|
||||
(item, index) =>
|
||||
item && (
|
||||
<Row key={item} className="mb-1 flex items-center">
|
||||
<Column className="w-6 text-slate-400">#{index + 1}</Column>
|
||||
<Column className="rounded bg-slate-100 px-2 py-1">{item}</Column>
|
||||
</Row>
|
||||
)
|
||||
)}
|
||||
</Row>
|
||||
</Container>
|
||||
);
|
||||
|
||||
default:
|
||||
return <Text className="mt-0 whitespace-pre-wrap break-words font-bold">{response}</Text>;
|
||||
}
|
||||
};
|
||||
|
||||
interface ResponseFinishedEmailProps {
|
||||
survey: TSurvey;
|
||||
responseCount: number;
|
||||
@@ -109,7 +47,7 @@ export async function ResponseFinishedEmail({
|
||||
<Row key={question.question}>
|
||||
<Column className="w-full">
|
||||
<Text className="mb-2 font-medium">{question.question}</Text>
|
||||
{renderEmailResponseValue(question.response, question.type)}
|
||||
{renderEmailResponseValue(question.response, question.type, t)}
|
||||
</Column>
|
||||
</Row>
|
||||
);
|
||||
@@ -128,7 +66,7 @@ export async function ResponseFinishedEmail({
|
||||
)}
|
||||
{variable.name}
|
||||
</Text>
|
||||
<Text className="mt-0 whitespace-pre-wrap break-words font-bold">
|
||||
<Text className="mt-0 font-bold break-words whitespace-pre-wrap">
|
||||
{variableResponse}
|
||||
</Text>
|
||||
</Column>
|
||||
@@ -146,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 whitespace-pre-wrap break-words font-bold">
|
||||
<Text className="mt-0 font-bold break-words whitespace-pre-wrap">
|
||||
{hiddenFieldResponse}
|
||||
</Text>
|
||||
</Column>
|
||||
@@ -192,25 +130,6 @@ export async function ResponseFinishedEmail({
|
||||
);
|
||||
}
|
||||
|
||||
function FileIcon(): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
className="lucide lucide-file"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function EyeOffIcon(): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { renderEmailResponseValue } from "@/modules/email/emails/lib/utils";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { Container, Hr, Link, Tailwind, Text } from "@react-email/components";
|
||||
import { TFnType } from "@tolgee/react";
|
||||
@@ -9,7 +10,6 @@ import type {
|
||||
TWeeklySummarySurveyResponseData,
|
||||
} from "@formbricks/types/weekly-summary";
|
||||
import { EmailButton } from "../../components/email-button";
|
||||
import { renderEmailResponseValue } from "../survey/response-finished-email";
|
||||
|
||||
const getButtonLabel = (count: number, t: TFnType): string => {
|
||||
if (count === 1) {
|
||||
@@ -63,7 +63,7 @@ export async function LiveSurveyNotification({
|
||||
surveyFields.push(
|
||||
<Container className="mt-4" key={`${index.toString()}-${surveyResponse.headline}`}>
|
||||
<Text className="m-0">{surveyResponse.headline}</Text>
|
||||
{renderEmailResponseValue(surveyResponse.responseValue, surveyResponse.questionType)}
|
||||
{renderEmailResponseValue(surveyResponse.responseValue, surveyResponse.questionType, t)}
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -103,7 +103,7 @@ export async function LiveSurveyNotification({
|
||||
createSurveyFields(survey.responses)
|
||||
)}
|
||||
{survey.responseCount > 0 && (
|
||||
<Container className="mt-4 block">
|
||||
<Container className="mt-4 block text-sm">
|
||||
<EmailButton
|
||||
href={`${WEBAPP_URL}/environments/${environmentId}/surveys/${survey.id}/responses?utm_source=weekly&utm_medium=email&utm_content=ViewResponsesCTA`}
|
||||
label={
|
||||
|
||||
@@ -352,17 +352,32 @@ export const sendNoLiveSurveyNotificationEmail = async (
|
||||
});
|
||||
};
|
||||
|
||||
export const sendFollowUpEmail = async (
|
||||
html: string,
|
||||
subject: string,
|
||||
to: string,
|
||||
replyTo: string[],
|
||||
logoUrl?: string
|
||||
): Promise<void> => {
|
||||
export const sendFollowUpEmail = async ({
|
||||
html,
|
||||
replyTo,
|
||||
subject,
|
||||
to,
|
||||
survey,
|
||||
response,
|
||||
attachResponseData = false,
|
||||
logoUrl,
|
||||
}: {
|
||||
html: string;
|
||||
subject: string;
|
||||
to: string;
|
||||
replyTo: string[];
|
||||
attachResponseData: boolean;
|
||||
survey: TSurvey;
|
||||
response: TResponse;
|
||||
logoUrl?: string;
|
||||
}): Promise<void> => {
|
||||
const emailHtmlBody = await render(
|
||||
await FollowUpEmail({
|
||||
html,
|
||||
logoUrl,
|
||||
attachResponseData,
|
||||
survey,
|
||||
response,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SettingsView } from "@/modules/survey/editor/components/settings-view";
|
||||
import { StylingView } from "@/modules/survey/editor/components/styling-view";
|
||||
import { SurveyEditorTabs } from "@/modules/survey/editor/components/survey-editor-tabs";
|
||||
import { SurveyMenuBar } from "@/modules/survey/editor/components/survey-menu-bar";
|
||||
import { TFollowUpEmailToUser } from "@/modules/survey/editor/types/survey-follow-up";
|
||||
import { FollowUpsView } from "@/modules/survey/follow-ups/components/follow-ups-view";
|
||||
import { PreviewSurvey } from "@/modules/ui/components/preview-survey";
|
||||
import { ActionClass, Environment, Language, OrganizationRole, Project } from "@prisma/client";
|
||||
@@ -43,6 +44,7 @@ interface SurveyEditorProps {
|
||||
projectLanguages: Language[];
|
||||
isSurveyFollowUpsAllowed: boolean;
|
||||
userEmail: string;
|
||||
teamMemberDetails: TFollowUpEmailToUser[];
|
||||
}
|
||||
|
||||
export const SurveyEditor = ({
|
||||
@@ -67,6 +69,7 @@ export const SurveyEditor = ({
|
||||
mailFrom,
|
||||
isSurveyFollowUpsAllowed = false,
|
||||
userEmail,
|
||||
teamMemberDetails,
|
||||
}: SurveyEditorProps) => {
|
||||
const [activeView, setActiveView] = useState<TSurveyEditorTabs>("questions");
|
||||
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
|
||||
@@ -230,6 +233,7 @@ export const SurveyEditor = ({
|
||||
mailFrom={mailFrom}
|
||||
isSurveyFollowUpsAllowed={isSurveyFollowUpsAllowed}
|
||||
userEmail={userEmail}
|
||||
teamMemberDetails={teamMemberDetails}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
|
||||
53
apps/web/modules/survey/editor/lib/team.ts
Normal file
53
apps/web/modules/survey/editor/lib/team.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { teamCache } from "@/lib/cache/team";
|
||||
import { TFollowUpEmailToUser } from "@/modules/survey/editor/types/survey-follow-up";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
|
||||
export const getTeamMemberDetails = reactCache(async (teamIds: string[]): Promise<TFollowUpEmailToUser[]> => {
|
||||
const cacheTags = teamIds.map((teamId) => teamCache.tag.byId(teamId));
|
||||
|
||||
return cache(
|
||||
async () => {
|
||||
if (teamIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const memberDetails: TFollowUpEmailToUser[] = [];
|
||||
|
||||
for (const teamId of teamIds) {
|
||||
const teamMembers = await prisma.teamUser.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
const userEmailAndNames = await prisma.user.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: teamMembers.map((member) => member.userId),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
memberDetails.push(...userEmailAndNames);
|
||||
}
|
||||
|
||||
const uniqueMemberDetailsMap = new Map(memberDetails.map((member) => [member.email, member]));
|
||||
const uniqueMemberDetails = Array.from(uniqueMemberDetailsMap.values()).map((member) => ({
|
||||
email: member.email,
|
||||
name: member.name,
|
||||
}));
|
||||
|
||||
return uniqueMemberDetails;
|
||||
},
|
||||
[`getTeamMemberDetails-${teamIds.join(",")}`],
|
||||
{
|
||||
tags: [...cacheTags],
|
||||
}
|
||||
)();
|
||||
});
|
||||
@@ -3,10 +3,11 @@ import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
import { getIsContactsEnabled, getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { getProjectLanguages } from "@/modules/survey/editor/lib/project";
|
||||
import { getTeamMemberDetails } from "@/modules/survey/editor/lib/team";
|
||||
import { getUserEmail } from "@/modules/survey/editor/lib/user";
|
||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||
import { getActionClasses } from "@/modules/survey/lib/action-class";
|
||||
import { getProjectByEnvironmentId } from "@/modules/survey/lib/project";
|
||||
import { getProjectWithTeamIdsByEnvironmentId } from "@/modules/survey/lib/project";
|
||||
import { getResponseCountBySurveyId } from "@/modules/survey/lib/response";
|
||||
import { getOrganizationBilling, getSurvey } from "@/modules/survey/lib/survey";
|
||||
import { ErrorComponent } from "@/modules/ui/components/error-component";
|
||||
@@ -37,20 +38,21 @@ export const SurveyEditorPage = async (props) => {
|
||||
await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const t = await getTranslate();
|
||||
const [survey, project, actionClasses, contactAttributeKeys, responseCount, segments] = await Promise.all([
|
||||
getSurvey(params.surveyId),
|
||||
getProjectByEnvironmentId(params.environmentId),
|
||||
getActionClasses(params.environmentId),
|
||||
getContactAttributeKeys(params.environmentId),
|
||||
getResponseCountBySurveyId(params.surveyId),
|
||||
getSegments(params.environmentId),
|
||||
]);
|
||||
const [survey, projectWithTeamIds, actionClasses, contactAttributeKeys, responseCount, segments] =
|
||||
await Promise.all([
|
||||
getSurvey(params.surveyId),
|
||||
getProjectWithTeamIdsByEnvironmentId(params.environmentId),
|
||||
getActionClasses(params.environmentId),
|
||||
getContactAttributeKeys(params.environmentId),
|
||||
getResponseCountBySurveyId(params.surveyId),
|
||||
getSegments(params.environmentId),
|
||||
]);
|
||||
|
||||
if (!project) {
|
||||
if (!projectWithTeamIds) {
|
||||
throw new Error(t("common.project_not_found"));
|
||||
}
|
||||
|
||||
const organizationBilling = await getOrganizationBilling(project.organizationId);
|
||||
const organizationBilling = await getOrganizationBilling(projectWithTeamIds.organizationId);
|
||||
if (!organizationBilling) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
@@ -63,15 +65,16 @@ export const SurveyEditorPage = async (props) => {
|
||||
const isSurveyFollowUpsAllowed = await getSurveyFollowUpsPermission(organizationBilling.plan);
|
||||
|
||||
const userEmail = await getUserEmail(session.user.id);
|
||||
const projectLanguages = await getProjectLanguages(projectWithTeamIds.id);
|
||||
|
||||
const projectLanguages = await getProjectLanguages(project.id);
|
||||
const teamMemberDetails = await getTeamMemberDetails(projectWithTeamIds.teamIds);
|
||||
|
||||
if (
|
||||
!survey ||
|
||||
!environment ||
|
||||
!actionClasses ||
|
||||
!contactAttributeKeys ||
|
||||
!project ||
|
||||
!projectWithTeamIds ||
|
||||
!userEmail ||
|
||||
isSurveyCreationDeletionDisabled
|
||||
) {
|
||||
@@ -83,7 +86,7 @@ export const SurveyEditorPage = async (props) => {
|
||||
return (
|
||||
<SurveyEditor
|
||||
survey={survey}
|
||||
project={project}
|
||||
project={projectWithTeamIds}
|
||||
environment={environment}
|
||||
actionClasses={actionClasses}
|
||||
contactAttributeKeys={contactAttributeKeys}
|
||||
@@ -103,6 +106,7 @@ export const SurveyEditorPage = async (props) => {
|
||||
mailFrom={MAIL_FROM ?? "hola@formbricks.com"}
|
||||
isSurveyFollowUpsAllowed={isSurveyFollowUpsAllowed}
|
||||
userEmail={userEmail}
|
||||
teamMemberDetails={teamMemberDetails}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,6 +8,12 @@ export const ZCreateSurveyFollowUpFormSchema = z.object({
|
||||
replyTo: z.array(z.string().email()).min(1, "Replies must have at least one email"),
|
||||
subject: z.string().trim().min(1, "Subject is required"),
|
||||
body: z.string().trim().min(1, "Body is required"),
|
||||
attachResponseData: z.boolean(),
|
||||
});
|
||||
|
||||
export type TCreateSurveyFollowUpForm = z.infer<typeof ZCreateSurveyFollowUpFormSchema>;
|
||||
|
||||
export type TFollowUpEmailToUser = {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,982 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { FollowUpItem } from "./follow-up-item";
|
||||
|
||||
// mock Data:
|
||||
|
||||
const mockSurveyId = "lgfd7jlhdp8cekkiopihi4ye";
|
||||
const mockEnvironmentId = "q7v06o64ml9nw0o4x53dqzr1";
|
||||
const mockQuestion1Id = "bgx8r8594elcml4m937u79d9";
|
||||
const mockQuestion2Id = "ebl0o7cye38p8r0g9cf6nvbg";
|
||||
const mockQuestion3Id = "lyz9v4dj1nta4yucklxepwms";
|
||||
const mockFollowUp1Id = "j4jyvddxbwswuw9nqdzicjn8";
|
||||
const mockFollowUp2Id = "c76dooqu448d49gtu6qv1vge";
|
||||
|
||||
// Mock the useTranslate hook
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the FollowUpModal component to verify it's opened
|
||||
const mockFollowUpModal = vi.fn();
|
||||
vi.mock("./follow-up-modal", () => ({
|
||||
FollowUpModal: (props) => {
|
||||
mockFollowUpModal(props);
|
||||
return <div data-testid="follow-up-modal" />;
|
||||
},
|
||||
}));
|
||||
|
||||
describe("FollowUpItem", () => {
|
||||
// Clean up after each test
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Common test data
|
||||
const userEmail = "user@example.com";
|
||||
const teamMemberEmails = [
|
||||
{ email: "team1@example.com", name: "team 1" },
|
||||
{ email: "team2@example.com", name: "team 2" },
|
||||
];
|
||||
|
||||
const mockSurvey = {
|
||||
id: mockSurveyId,
|
||||
environmentId: mockEnvironmentId,
|
||||
name: "Test Survey",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: "draft",
|
||||
questions: [
|
||||
{
|
||||
id: mockQuestion1Id,
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: {
|
||||
default: "What would you like to know?",
|
||||
},
|
||||
required: true,
|
||||
charLimit: {},
|
||||
inputType: "email",
|
||||
longAnswer: false,
|
||||
buttonLabel: {
|
||||
default: "Next",
|
||||
},
|
||||
placeholder: {
|
||||
default: "example@email.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: mockQuestion2Id,
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: {
|
||||
default: "What would you like to know?",
|
||||
},
|
||||
required: true,
|
||||
charLimit: {},
|
||||
inputType: "text",
|
||||
longAnswer: false,
|
||||
buttonLabel: {
|
||||
default: "Next",
|
||||
},
|
||||
placeholder: {
|
||||
default: "example@email.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: mockQuestion3Id,
|
||||
type: TSurveyQuestionTypeEnum.ContactInfo,
|
||||
email: {
|
||||
show: true,
|
||||
required: true,
|
||||
placeholder: {
|
||||
default: "Email",
|
||||
},
|
||||
},
|
||||
phone: {
|
||||
show: true,
|
||||
required: true,
|
||||
placeholder: {
|
||||
default: "Phone",
|
||||
},
|
||||
},
|
||||
company: {
|
||||
show: true,
|
||||
required: true,
|
||||
placeholder: {
|
||||
default: "Company",
|
||||
},
|
||||
},
|
||||
headline: {
|
||||
default: "Contact Question",
|
||||
},
|
||||
lastName: {
|
||||
show: true,
|
||||
required: true,
|
||||
placeholder: {
|
||||
default: "Last Name",
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
firstName: {
|
||||
show: true,
|
||||
required: true,
|
||||
placeholder: {
|
||||
default: "First Name",
|
||||
},
|
||||
},
|
||||
buttonLabel: {
|
||||
default: "Next",
|
||||
},
|
||||
backButtonLabel: {
|
||||
default: "Back",
|
||||
},
|
||||
},
|
||||
],
|
||||
hiddenFields: {
|
||||
enabled: true,
|
||||
fieldIds: ["hidden1", "hidden2"],
|
||||
},
|
||||
endings: [],
|
||||
welcomeCard: {
|
||||
html: {
|
||||
default: "Thanks for providing your feedback - let's go!",
|
||||
},
|
||||
enabled: false,
|
||||
headline: {
|
||||
default: "Welcome!",
|
||||
},
|
||||
buttonLabel: {
|
||||
default: "Next",
|
||||
},
|
||||
timeToFinish: false,
|
||||
showResponseCount: false,
|
||||
},
|
||||
displayPercentage: null,
|
||||
followUps: [],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const createMockFollowUp = (to: string): TSurveyFollowUp => ({
|
||||
id: "followup-1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
surveyId: "survey-1",
|
||||
name: "Test Follow-up",
|
||||
trigger: {
|
||||
type: "response",
|
||||
properties: null,
|
||||
},
|
||||
action: {
|
||||
type: "send-email",
|
||||
properties: {
|
||||
to,
|
||||
from: "noreply@example.com",
|
||||
replyTo: [userEmail],
|
||||
subject: "Follow-up Subject",
|
||||
body: "Follow-up Body",
|
||||
attachResponseData: false, // Add the missing property
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const setLocalSurvey = vi.fn();
|
||||
|
||||
test("marks email as invalid if 'to' does not match any valid question, hidden field, or email", () => {
|
||||
// Create a follow-up with an invalid 'to' value
|
||||
const invalidFollowUp = createMockFollowUp("invalid@example.com");
|
||||
|
||||
render(
|
||||
<FollowUpItem
|
||||
followUp={invalidFollowUp}
|
||||
localSurvey={mockSurvey}
|
||||
mailFrom="noreply@example.com"
|
||||
selectedLanguageCode="default"
|
||||
userEmail={userEmail}
|
||||
teamMemberDetails={teamMemberEmails}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
locale="en-US"
|
||||
/>
|
||||
);
|
||||
|
||||
// Check if the warning badge is displayed
|
||||
const warningBadge = screen.getByText("environments.surveys.edit.follow_ups_item_issue_detected_tag");
|
||||
expect(warningBadge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does not mark email as invalid if 'to' matches a valid question ID", () => {
|
||||
// Create a follow-up with a valid question ID (q1 is an OpenText with email inputType)
|
||||
const validFollowUp = createMockFollowUp(mockQuestion1Id);
|
||||
|
||||
render(
|
||||
<FollowUpItem
|
||||
followUp={validFollowUp}
|
||||
localSurvey={mockSurvey}
|
||||
mailFrom="noreply@example.com"
|
||||
selectedLanguageCode="default"
|
||||
userEmail={userEmail}
|
||||
teamMemberDetails={teamMemberEmails}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
locale="en-US"
|
||||
/>
|
||||
);
|
||||
|
||||
// Check that the warning badge is not displayed
|
||||
const warningBadges = screen.queryByText("environments.surveys.edit.follow_ups_item_issue_detected_tag");
|
||||
expect(warningBadges).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does not mark email as invalid if 'to' matches a valid hidden field ID", () => {
|
||||
// Create a follow-up with a valid hidden field ID
|
||||
const validFollowUp = createMockFollowUp("hidden1");
|
||||
|
||||
render(
|
||||
<FollowUpItem
|
||||
followUp={validFollowUp}
|
||||
localSurvey={mockSurvey}
|
||||
mailFrom="noreply@example.com"
|
||||
selectedLanguageCode="default"
|
||||
userEmail={userEmail}
|
||||
teamMemberDetails={teamMemberEmails}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
locale="en-US"
|
||||
/>
|
||||
);
|
||||
|
||||
// Check that the warning badge is not displayed
|
||||
const warningBadges = screen.queryByText("environments.surveys.edit.follow_ups_item_issue_detected_tag");
|
||||
expect(warningBadges).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does not mark email as invalid if 'to' matches a team member email", () => {
|
||||
// Create a follow-up with a valid team member email
|
||||
const validFollowUp = createMockFollowUp("team1@example.com");
|
||||
|
||||
render(
|
||||
<FollowUpItem
|
||||
followUp={validFollowUp}
|
||||
localSurvey={mockSurvey}
|
||||
mailFrom="noreply@example.com"
|
||||
selectedLanguageCode="default"
|
||||
userEmail={userEmail}
|
||||
teamMemberDetails={teamMemberEmails}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
locale="en-US"
|
||||
/>
|
||||
);
|
||||
|
||||
// Check that the warning badge is not displayed
|
||||
const warningBadges = screen.queryByText("environments.surveys.edit.follow_ups_item_issue_detected_tag");
|
||||
expect(warningBadges).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does not mark email as invalid if 'to' matches the user email", () => {
|
||||
// Create a follow-up with the user's email
|
||||
const validFollowUp = createMockFollowUp(userEmail);
|
||||
|
||||
render(
|
||||
<FollowUpItem
|
||||
followUp={validFollowUp}
|
||||
localSurvey={mockSurvey}
|
||||
mailFrom="noreply@example.com"
|
||||
selectedLanguageCode="default"
|
||||
userEmail={userEmail}
|
||||
teamMemberDetails={teamMemberEmails}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
locale="en-US"
|
||||
/>
|
||||
);
|
||||
|
||||
// Check that the warning badge is not displayed
|
||||
const warningBadges = screen.queryByText("environments.surveys.edit.follow_ups_item_issue_detected_tag");
|
||||
expect(warningBadges).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does mark email as invalid if 'to' matches a question with incorrect type", () => {
|
||||
// Create a follow-up with a question ID that is not OpenText with email inputType or ContactInfo
|
||||
const invalidFollowUp = createMockFollowUp(mockQuestion2Id); // q2 is OpenText but inputType is text, not email
|
||||
|
||||
render(
|
||||
<FollowUpItem
|
||||
followUp={invalidFollowUp}
|
||||
localSurvey={mockSurvey}
|
||||
mailFrom="noreply@example.com"
|
||||
selectedLanguageCode="default"
|
||||
userEmail={userEmail}
|
||||
teamMemberDetails={teamMemberEmails}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
locale="en-US"
|
||||
/>
|
||||
);
|
||||
|
||||
// Check if the warning badge is displayed
|
||||
const warningBadge = screen.queryByText("environments.surveys.edit.follow_ups_item_issue_detected_tag");
|
||||
expect(warningBadge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens the edit modal when the item is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Create a follow-up with a valid question ID
|
||||
const validFollowUp = createMockFollowUp(mockQuestion1Id);
|
||||
|
||||
// Render the component
|
||||
render(
|
||||
<FollowUpItem
|
||||
followUp={validFollowUp}
|
||||
localSurvey={mockSurvey}
|
||||
mailFrom="noreply@example.com"
|
||||
selectedLanguageCode="default"
|
||||
userEmail={userEmail}
|
||||
teamMemberDetails={teamMemberEmails}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
locale="en-US"
|
||||
/>
|
||||
);
|
||||
|
||||
// Find the clickable area
|
||||
const clickableArea = screen.getByText("Test Follow-up").closest("div");
|
||||
expect(clickableArea).toBeInTheDocument();
|
||||
|
||||
// Simulate a click on the clickable area
|
||||
if (clickableArea) {
|
||||
await user.click(clickableArea);
|
||||
}
|
||||
|
||||
// Wait for state updates to propagate
|
||||
await vi.waitFor(() => {
|
||||
expect(mockFollowUpModal).toHaveBeenCalledWith(expect.objectContaining({ open: true }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("FollowUpItem - Ending Validation", () => {
|
||||
// Clean up after each test
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Common test data
|
||||
const userEmail = "user@example.com";
|
||||
const teamMemberEmails = [
|
||||
{ email: "team1@example.com", name: "team 1" },
|
||||
{
|
||||
email: "team2@example.com",
|
||||
name: "team 2",
|
||||
},
|
||||
];
|
||||
|
||||
const mockSurvey = {
|
||||
id: mockSurveyId,
|
||||
environmentId: mockEnvironmentId,
|
||||
name: "Test Survey",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: "draft",
|
||||
questions: [
|
||||
{
|
||||
id: mockQuestion1Id,
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: {
|
||||
default: "What would you like to know?",
|
||||
},
|
||||
required: true,
|
||||
charLimit: {},
|
||||
inputType: "email",
|
||||
longAnswer: false,
|
||||
buttonLabel: {
|
||||
default: "Next",
|
||||
},
|
||||
placeholder: {
|
||||
default: "example@email.com",
|
||||
},
|
||||
},
|
||||
],
|
||||
hiddenFields: {
|
||||
enabled: true,
|
||||
fieldIds: ["hidden1"],
|
||||
},
|
||||
endings: [
|
||||
{
|
||||
id: "ending-1",
|
||||
type: "endScreen",
|
||||
headline: { default: "Thank you!" },
|
||||
},
|
||||
{
|
||||
id: "ending-2",
|
||||
type: "redirectToUrl",
|
||||
url: "https://example.com",
|
||||
label: "Redirect Ending",
|
||||
},
|
||||
],
|
||||
followUps: [],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const createMockFollowUp = (
|
||||
triggerType: "response" | "endings",
|
||||
endingIds?: string[]
|
||||
): TSurveyFollowUp => ({
|
||||
id: "followup-1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
surveyId: "survey-1",
|
||||
name: "Test Follow-up",
|
||||
trigger: {
|
||||
type: triggerType,
|
||||
properties: triggerType === "endings" ? { endingIds: endingIds || [] } : null,
|
||||
},
|
||||
action: {
|
||||
type: "send-email",
|
||||
properties: {
|
||||
to: mockQuestion1Id,
|
||||
from: "noreply@example.com",
|
||||
replyTo: [userEmail],
|
||||
subject: "Follow-up Subject",
|
||||
body: "Follow-up Body",
|
||||
attachResponseData: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const setLocalSurvey = vi.fn();
|
||||
|
||||
test("marks ending as invalid if trigger.type is 'endings' and no endingIds are provided", () => {
|
||||
// Create a follow-up with trigger type "endings" but no endingIds
|
||||
const invalidFollowUp = createMockFollowUp("endings", []);
|
||||
|
||||
render(
|
||||
<FollowUpItem
|
||||
followUp={invalidFollowUp}
|
||||
localSurvey={mockSurvey}
|
||||
mailFrom="noreply@example.com"
|
||||
selectedLanguageCode="default"
|
||||
userEmail={userEmail}
|
||||
teamMemberDetails={teamMemberEmails}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
locale="en-US"
|
||||
/>
|
||||
);
|
||||
|
||||
// Check if the warning badge is displayed
|
||||
const warningBadge = screen.getByText("environments.surveys.edit.follow_ups_item_issue_detected_tag");
|
||||
expect(warningBadge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does not mark ending as invalid if trigger.type is 'endings' and endingIds are provided", () => {
|
||||
// Create a follow-up with trigger type "endings" and valid endingIds
|
||||
const validFollowUp = createMockFollowUp("endings", ["ending-1", "ending-2"]);
|
||||
|
||||
render(
|
||||
<FollowUpItem
|
||||
followUp={validFollowUp}
|
||||
localSurvey={mockSurvey}
|
||||
mailFrom="noreply@example.com"
|
||||
selectedLanguageCode="default"
|
||||
userEmail={userEmail}
|
||||
teamMemberDetails={teamMemberEmails}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
locale="en-US"
|
||||
/>
|
||||
);
|
||||
|
||||
// Check that the warning badge is not displayed
|
||||
const warningBadges = screen.queryByText("environments.surveys.edit.follow_ups_item_issue_detected_tag");
|
||||
expect(warningBadges).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does not mark ending as invalid if trigger.type is 'response'", () => {
|
||||
// Create a follow-up with trigger type "response"
|
||||
const responseFollowUp = createMockFollowUp("response");
|
||||
|
||||
render(
|
||||
<FollowUpItem
|
||||
followUp={responseFollowUp}
|
||||
localSurvey={mockSurvey}
|
||||
mailFrom="noreply@example.com"
|
||||
selectedLanguageCode="default"
|
||||
userEmail={userEmail}
|
||||
teamMemberDetails={teamMemberEmails}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
locale="en-US"
|
||||
/>
|
||||
);
|
||||
|
||||
// Check that the warning badge is not displayed
|
||||
const warningBadges = screen.queryByText("environments.surveys.edit.follow_ups_item_issue_detected_tag");
|
||||
expect(warningBadges).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FollowUpItem - Endings Validation", () => {
|
||||
// Clean up after each test
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Common test data
|
||||
const userEmail = "user@example.com";
|
||||
const teamMemberEmails = [
|
||||
{ email: "team1@example.com", name: "team 1" },
|
||||
{
|
||||
email: "team2@example.com",
|
||||
name: "team 2",
|
||||
},
|
||||
];
|
||||
|
||||
// Create a mock survey with endings
|
||||
const mockSurveyWithEndings = {
|
||||
id: mockSurveyId,
|
||||
environmentId: mockEnvironmentId,
|
||||
name: "Test Survey",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: "draft",
|
||||
questions: [
|
||||
{
|
||||
id: mockQuestion1Id,
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: {
|
||||
default: "What would you like to know?",
|
||||
},
|
||||
required: true,
|
||||
charLimit: {},
|
||||
inputType: "email",
|
||||
longAnswer: false,
|
||||
buttonLabel: {
|
||||
default: "Next",
|
||||
},
|
||||
placeholder: {
|
||||
default: "example@email.com",
|
||||
},
|
||||
},
|
||||
],
|
||||
hiddenFields: {
|
||||
enabled: true,
|
||||
fieldIds: ["hidden1"],
|
||||
},
|
||||
endings: [
|
||||
{
|
||||
id: "ending-1",
|
||||
type: "endScreen",
|
||||
headline: { default: "Thank you!" },
|
||||
},
|
||||
{
|
||||
id: "ending-2",
|
||||
type: "endScreen",
|
||||
headline: { default: "Completed!" },
|
||||
},
|
||||
],
|
||||
followUps: [],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
// Create a follow-up with empty endingIds
|
||||
const createEmptyEndingFollowUp = (): TSurveyFollowUp => ({
|
||||
id: mockFollowUp1Id,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
surveyId: mockSurveyId,
|
||||
name: "Test Follow-up",
|
||||
trigger: {
|
||||
type: "endings",
|
||||
properties: {
|
||||
endingIds: [], // Empty array will trigger the warning
|
||||
},
|
||||
},
|
||||
action: {
|
||||
type: "send-email",
|
||||
properties: {
|
||||
to: mockQuestion1Id, // Valid question ID
|
||||
from: "noreply@example.com",
|
||||
replyTo: [userEmail],
|
||||
subject: "Follow-up Subject",
|
||||
body: "Follow-up Body",
|
||||
attachResponseData: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create a follow-up with valid endingIds
|
||||
const createValidEndingFollowUp = (): TSurveyFollowUp => ({
|
||||
id: mockFollowUp2Id,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
surveyId: mockSurveyId,
|
||||
name: "Test Follow-up",
|
||||
trigger: {
|
||||
type: "endings",
|
||||
properties: {
|
||||
endingIds: ["ending-1", "ending-2"], // Valid ending IDs
|
||||
},
|
||||
},
|
||||
action: {
|
||||
type: "send-email",
|
||||
properties: {
|
||||
to: mockQuestion1Id, // Valid question ID
|
||||
from: "noreply@example.com",
|
||||
replyTo: [userEmail],
|
||||
subject: "Follow-up Subject",
|
||||
body: "Follow-up Body",
|
||||
attachResponseData: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const setLocalSurvey = vi.fn();
|
||||
|
||||
test("displays a warning when followUp.trigger.type is 'endings' but endingIds array is empty", () => {
|
||||
// Create a follow-up with empty endingIds
|
||||
const emptyEndingFollowUp = createEmptyEndingFollowUp();
|
||||
|
||||
render(
|
||||
<FollowUpItem
|
||||
followUp={emptyEndingFollowUp}
|
||||
localSurvey={mockSurveyWithEndings}
|
||||
mailFrom="noreply@example.com"
|
||||
selectedLanguageCode="default"
|
||||
userEmail={userEmail}
|
||||
teamMemberDetails={teamMemberEmails}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
locale="en-US"
|
||||
/>
|
||||
);
|
||||
|
||||
// Check if the warning badge is displayed
|
||||
const warningBadge = screen.getByText("environments.surveys.edit.follow_ups_item_issue_detected_tag");
|
||||
expect(warningBadge).toBeInTheDocument();
|
||||
|
||||
// Also verify that the ending tag is displayed
|
||||
const endingTag = screen.getByText("environments.surveys.edit.follow_ups_item_ending_tag");
|
||||
expect(endingTag).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does not display a warning when followUp.trigger.type is 'endings' and endingIds array is not empty", () => {
|
||||
// Create a follow-up with valid endingIds
|
||||
const validEndingFollowUp = createValidEndingFollowUp();
|
||||
|
||||
render(
|
||||
<FollowUpItem
|
||||
followUp={validEndingFollowUp}
|
||||
localSurvey={mockSurveyWithEndings}
|
||||
mailFrom="noreply@example.com"
|
||||
selectedLanguageCode="default"
|
||||
userEmail={userEmail}
|
||||
teamMemberDetails={teamMemberEmails}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
locale="en-US"
|
||||
/>
|
||||
);
|
||||
|
||||
// Check that the warning badge is not displayed
|
||||
const warningBadge = screen.queryByText("environments.surveys.edit.follow_ups_item_issue_detected_tag");
|
||||
expect(warningBadge).not.toBeInTheDocument();
|
||||
|
||||
// Verify that the ending tag is displayed
|
||||
const endingTag = screen.getByText("environments.surveys.edit.follow_ups_item_ending_tag");
|
||||
expect(endingTag).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FollowUpItem - Deletion Tests", () => {
|
||||
// Clean up after each test
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Common test data
|
||||
const userEmail = "user@example.com";
|
||||
const teamMemberEmails = [
|
||||
{ email: "team1@example.com", name: "team 1" },
|
||||
{
|
||||
email: "team2@example.com",
|
||||
name: "team 2",
|
||||
},
|
||||
];
|
||||
|
||||
const mockSurvey = {
|
||||
id: mockSurveyId,
|
||||
environmentId: mockEnvironmentId,
|
||||
name: "Test Survey",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: "draft",
|
||||
questions: [
|
||||
{
|
||||
id: mockQuestion1Id,
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: {
|
||||
default: "What would you like to know?",
|
||||
},
|
||||
required: true,
|
||||
charLimit: {},
|
||||
inputType: "email",
|
||||
longAnswer: false,
|
||||
buttonLabel: {
|
||||
default: "Next",
|
||||
},
|
||||
placeholder: {
|
||||
default: "example@email.com",
|
||||
},
|
||||
},
|
||||
],
|
||||
hiddenFields: {
|
||||
enabled: true,
|
||||
fieldIds: ["hidden1"],
|
||||
},
|
||||
endings: [],
|
||||
followUps: [],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const createMockFollowUp = (): TSurveyFollowUp => ({
|
||||
id: mockFollowUp1Id,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
surveyId: mockSurveyId,
|
||||
name: "Test Follow-up",
|
||||
trigger: {
|
||||
type: "response",
|
||||
properties: null,
|
||||
},
|
||||
action: {
|
||||
type: "send-email",
|
||||
properties: {
|
||||
to: mockQuestion1Id,
|
||||
from: "noreply@example.com",
|
||||
replyTo: [userEmail],
|
||||
subject: "Follow-up Subject",
|
||||
body: "Follow-up Body",
|
||||
attachResponseData: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
test("opens delete confirmation modal when delete button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const followUp = createMockFollowUp();
|
||||
const setLocalSurvey = vi.fn();
|
||||
|
||||
render(
|
||||
<FollowUpItem
|
||||
followUp={followUp}
|
||||
localSurvey={mockSurvey}
|
||||
mailFrom="noreply@example.com"
|
||||
selectedLanguageCode="default"
|
||||
userEmail={userEmail}
|
||||
teamMemberDetails={teamMemberEmails}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
locale="en-US"
|
||||
/>
|
||||
);
|
||||
|
||||
// Find and click the delete button using the trash icon
|
||||
const deleteButton = screen.getByRole("button", { name: "common.delete" });
|
||||
await user.click(deleteButton);
|
||||
|
||||
// Check if the confirmation modal is displayed
|
||||
const confirmationModal = screen.getByText("environments.surveys.edit.follow_ups_delete_modal_title");
|
||||
expect(confirmationModal).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("marks follow-up as deleted when confirmed in delete modal", async () => {
|
||||
const user = userEvent.setup();
|
||||
const followUp = createMockFollowUp();
|
||||
const setLocalSurvey = vi.fn();
|
||||
|
||||
render(
|
||||
<FollowUpItem
|
||||
followUp={followUp}
|
||||
localSurvey={mockSurvey}
|
||||
mailFrom="noreply@example.com"
|
||||
selectedLanguageCode="default"
|
||||
userEmail={userEmail}
|
||||
teamMemberDetails={teamMemberEmails}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
locale="en-US"
|
||||
/>
|
||||
);
|
||||
|
||||
// Click delete button to open modal
|
||||
const deleteButton = screen.getByRole("button", { name: "common.delete" });
|
||||
await user.click(deleteButton);
|
||||
|
||||
// Click confirm button in modal
|
||||
const confirmButton = screen.getByRole("button", { name: "common.delete" });
|
||||
await user.click(confirmButton);
|
||||
|
||||
// Verify that setLocalSurvey was called with a function that updates the state correctly
|
||||
expect(setLocalSurvey).toHaveBeenCalledWith(expect.any(Function));
|
||||
|
||||
// Get the function that was passed to setLocalSurvey
|
||||
const updateFunction = setLocalSurvey.mock.calls[0][0];
|
||||
|
||||
// Call the function with a mock previous state
|
||||
const updatedState = updateFunction({
|
||||
...mockSurvey,
|
||||
followUps: [followUp],
|
||||
});
|
||||
|
||||
// Verify the updated state
|
||||
expect(updatedState.followUps).toEqual([
|
||||
{
|
||||
...followUp,
|
||||
deleted: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not mark follow-up as deleted when delete is cancelled", async () => {
|
||||
const user = userEvent.setup();
|
||||
const followUp = createMockFollowUp();
|
||||
const setLocalSurvey = vi.fn();
|
||||
|
||||
render(
|
||||
<FollowUpItem
|
||||
followUp={followUp}
|
||||
localSurvey={mockSurvey}
|
||||
mailFrom="noreply@example.com"
|
||||
selectedLanguageCode="default"
|
||||
userEmail={userEmail}
|
||||
teamMemberDetails={teamMemberEmails}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
locale="en-US"
|
||||
/>
|
||||
);
|
||||
|
||||
// Click delete button to open modal
|
||||
const deleteButton = screen.getByRole("button", { name: "common.delete" });
|
||||
await user.click(deleteButton);
|
||||
|
||||
// Click cancel button in modal
|
||||
const cancelButton = screen.getByRole("button", { name: "common.cancel" });
|
||||
await user.click(cancelButton);
|
||||
|
||||
// Verify that setLocalSurvey was not called
|
||||
expect(setLocalSurvey).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FollowUpItem - Duplicate Tests", () => {
|
||||
// Clean up after each test
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Common test data
|
||||
const userEmail = "user@example.com";
|
||||
const teamMemberEmails = [
|
||||
{ email: "team1@example.com", name: "team 1" },
|
||||
{
|
||||
email: "team2@example.com",
|
||||
name: "team 2",
|
||||
},
|
||||
];
|
||||
|
||||
const mockSurvey = {
|
||||
id: mockSurveyId,
|
||||
environmentId: mockEnvironmentId,
|
||||
name: "Test Survey",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
status: "draft",
|
||||
questions: [
|
||||
{
|
||||
id: mockQuestion1Id,
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: {
|
||||
default: "What would you like to know?",
|
||||
},
|
||||
required: true,
|
||||
charLimit: {},
|
||||
inputType: "email",
|
||||
longAnswer: false,
|
||||
buttonLabel: {
|
||||
default: "Next",
|
||||
},
|
||||
placeholder: {
|
||||
default: "example@email.com",
|
||||
},
|
||||
},
|
||||
],
|
||||
hiddenFields: {
|
||||
enabled: true,
|
||||
fieldIds: ["hidden1"],
|
||||
},
|
||||
endings: [],
|
||||
followUps: [],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const createMockFollowUp = (): TSurveyFollowUp => ({
|
||||
id: mockFollowUp1Id,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
surveyId: mockSurveyId,
|
||||
name: "Test Follow-up",
|
||||
trigger: {
|
||||
type: "response",
|
||||
properties: null,
|
||||
},
|
||||
action: {
|
||||
type: "send-email",
|
||||
properties: {
|
||||
to: mockQuestion1Id,
|
||||
from: "noreply@example.com",
|
||||
replyTo: [userEmail],
|
||||
subject: "Follow-up Subject",
|
||||
body: "Follow-up Body",
|
||||
attachResponseData: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
test("duplicates the follow-up when duplicate button is clicked", async () => {
|
||||
const followUp = createMockFollowUp();
|
||||
const setLocalSurvey = vi.fn();
|
||||
const localSurvey = {
|
||||
...mockSurvey,
|
||||
followUps: [followUp],
|
||||
};
|
||||
const newFollowUp = {
|
||||
...followUp,
|
||||
id: "new-followup-id",
|
||||
name: "Test Follow-up (copy)",
|
||||
};
|
||||
|
||||
setLocalSurvey.mockImplementation((updateFn) => {
|
||||
const updatedSurvey = updateFn(localSurvey);
|
||||
return updatedSurvey;
|
||||
});
|
||||
|
||||
render(
|
||||
<FollowUpItem
|
||||
followUp={followUp}
|
||||
localSurvey={mockSurvey}
|
||||
mailFrom="noreply@example.com"
|
||||
selectedLanguageCode="default"
|
||||
userEmail={userEmail}
|
||||
teamMemberDetails={teamMemberEmails}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
locale="en-US"
|
||||
/>
|
||||
);
|
||||
|
||||
// Click the duplicate button
|
||||
const duplicateButton = screen.getByRole("button", { name: "common.duplicate" });
|
||||
await userEvent.click(duplicateButton);
|
||||
// Check if setLocalSurvey was called with the correct arguments
|
||||
expect(setLocalSurvey).toHaveBeenCalledWith(expect.any(Function));
|
||||
// Get the function that was passed to setLocalSurvey
|
||||
const updateFunction = setLocalSurvey.mock.calls[0][0];
|
||||
// Call the function with a mock previous state
|
||||
const updatedState = updateFunction(localSurvey);
|
||||
// Verify the updated state
|
||||
expect(updatedState.followUps).toEqual([
|
||||
...localSurvey.followUps,
|
||||
{
|
||||
...newFollowUp, // New follow-up with updated ID and name
|
||||
id: expect.any(String), // ID should be a new unique ID
|
||||
name: "Test Follow-up (copy)",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { TFollowUpEmailToUser } from "@/modules/survey/editor/types/survey-follow-up";
|
||||
import { FollowUpModal } from "@/modules/survey/follow-ups/components/follow-up-modal";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { CopyPlusIcon, TrashIcon } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
@@ -18,6 +20,7 @@ interface FollowUpItemProps {
|
||||
selectedLanguageCode: string;
|
||||
mailFrom: string;
|
||||
userEmail: string;
|
||||
teamMemberDetails: TFollowUpEmailToUser[];
|
||||
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
@@ -28,6 +31,7 @@ export const FollowUpItem = ({
|
||||
mailFrom,
|
||||
selectedLanguageCode,
|
||||
userEmail,
|
||||
teamMemberDetails,
|
||||
setLocalSurvey,
|
||||
locale,
|
||||
}: FollowUpItemProps) => {
|
||||
@@ -43,7 +47,25 @@ export const FollowUpItem = ({
|
||||
const matchedQuestion = localSurvey.questions.find((question) => question.id === to);
|
||||
const matchedHiddenField = (localSurvey.hiddenFields?.fieldIds ?? []).find((fieldId) => fieldId === to);
|
||||
|
||||
if (!matchedQuestion && !matchedHiddenField) return true;
|
||||
const updatedTeamMemberDetails = teamMemberDetails.map((teamMemberDetail) => {
|
||||
if (teamMemberDetail.email === userEmail) {
|
||||
return { name: "Yourself", email: userEmail };
|
||||
}
|
||||
|
||||
return teamMemberDetail;
|
||||
});
|
||||
|
||||
const isUserEmailInTeamMemberDetails = updatedTeamMemberDetails.some(
|
||||
(teamMemberDetail) => teamMemberDetail.email === userEmail
|
||||
);
|
||||
|
||||
const updatedTeamMembers = isUserEmailInTeamMemberDetails
|
||||
? updatedTeamMemberDetails
|
||||
: [...updatedTeamMemberDetails, { email: userEmail, name: "Yourself" }];
|
||||
|
||||
const matchedEmail = updatedTeamMembers.find((detail) => detail.email === to);
|
||||
|
||||
if (!matchedQuestion && !matchedHiddenField && !matchedEmail) return true;
|
||||
|
||||
if (matchedQuestion) {
|
||||
if (
|
||||
@@ -63,12 +85,31 @@ export const FollowUpItem = ({
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [followUp.action.properties, localSurvey.hiddenFields?.fieldIds, localSurvey.questions]);
|
||||
}, [
|
||||
followUp.action.properties,
|
||||
localSurvey.hiddenFields?.fieldIds,
|
||||
localSurvey.questions,
|
||||
teamMemberDetails,
|
||||
userEmail,
|
||||
]);
|
||||
|
||||
const isEndingInvalid = useMemo(() => {
|
||||
return followUp.trigger.type === "endings" && !followUp.trigger.properties?.endingIds?.length;
|
||||
}, [followUp.trigger.properties?.endingIds?.length, followUp.trigger.type]);
|
||||
|
||||
const duplicateFollowUp = useCallback(() => {
|
||||
const newFollowUp = {
|
||||
...followUp,
|
||||
id: createId(),
|
||||
name: `${followUp.name} (copy)`,
|
||||
};
|
||||
|
||||
setLocalSurvey((prev) => ({
|
||||
...prev,
|
||||
followUps: [...prev.followUps, newFollowUp],
|
||||
}));
|
||||
}, [followUp, setLocalSurvey]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative cursor-pointer rounded-lg border border-slate-300 bg-white p-4 hover:bg-slate-50">
|
||||
@@ -105,7 +146,7 @@ export const FollowUpItem = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute right-4 top-4">
|
||||
<div className="absolute top-4 right-4 flex items-center">
|
||||
<TooltipRenderer tooltipContent={t("common.delete")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -113,10 +154,24 @@ export const FollowUpItem = ({
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteFollowUpModalOpen(true);
|
||||
}}>
|
||||
}}
|
||||
aria-label={t("common.delete")}>
|
||||
<TrashIcon className="h-4 w-4 text-slate-500" />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
|
||||
<TooltipRenderer tooltipContent={t("common.duplicate")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
duplicateFollowUp();
|
||||
}}
|
||||
aria-label={t("common.duplicate")}>
|
||||
<CopyPlusIcon className="h-4 w-4 text-slate-500" />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -136,8 +191,10 @@ export const FollowUpItem = ({
|
||||
body: followUp.action.properties.body,
|
||||
emailTo: followUp.action.properties.to,
|
||||
replyTo: followUp.action.properties.replyTo,
|
||||
attachResponseData: followUp.action.properties.attachResponseData,
|
||||
}}
|
||||
mode="edit"
|
||||
teamMemberDetails={teamMemberDetails}
|
||||
userEmail={userEmail}
|
||||
locale={locale}
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { getSurveyFollowUpActionDefaultBody } from "@/modules/survey/editor/lib/utils";
|
||||
import {
|
||||
TCreateSurveyFollowUpForm,
|
||||
TFollowUpEmailToUser,
|
||||
ZCreateSurveyFollowUpFormSchema,
|
||||
} from "@/modules/survey/editor/types/survey-follow-up";
|
||||
import FollowUpActionMultiEmailInput from "@/modules/survey/follow-ups/components/follow-up-action-multi-email-input";
|
||||
@@ -34,7 +35,15 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import DOMpurify from "isomorphic-dompurify";
|
||||
import { ArrowDownIcon, EyeOffIcon, HandshakeIcon, MailIcon, TriangleAlertIcon, ZapIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
EyeOffIcon,
|
||||
HandshakeIcon,
|
||||
MailIcon,
|
||||
TriangleAlertIcon,
|
||||
UserIcon,
|
||||
ZapIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -53,12 +62,13 @@ interface AddFollowUpModalProps {
|
||||
defaultValues?: Partial<TCreateSurveyFollowUpForm & { surveyFollowUpId: string }>;
|
||||
mode?: "create" | "edit";
|
||||
userEmail: string;
|
||||
teamMemberDetails: TFollowUpEmailToUser[];
|
||||
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
type EmailSendToOption = {
|
||||
type: "openTextQuestion" | "contactInfoQuestion" | "hiddenField";
|
||||
type: "openTextQuestion" | "contactInfoQuestion" | "hiddenField" | "user";
|
||||
label: string;
|
||||
id: string;
|
||||
};
|
||||
@@ -72,6 +82,7 @@ export const FollowUpModal = ({
|
||||
defaultValues,
|
||||
mode = "create",
|
||||
userEmail,
|
||||
teamMemberDetails,
|
||||
setLocalSurvey,
|
||||
locale,
|
||||
}: AddFollowUpModalProps) => {
|
||||
@@ -104,6 +115,22 @@ export const FollowUpModal = ({
|
||||
? { fieldIds: localSurvey.hiddenFields.fieldIds }
|
||||
: { fieldIds: [] };
|
||||
|
||||
const updatedTeamMemberDetails = teamMemberDetails.map((teamMemberDetail) => {
|
||||
if (teamMemberDetail.email === userEmail) {
|
||||
return { name: "Yourself", email: userEmail };
|
||||
}
|
||||
|
||||
return teamMemberDetail;
|
||||
});
|
||||
|
||||
const isUserEmailInTeamMemberDetails = updatedTeamMemberDetails.some(
|
||||
(teamMemberDetail) => teamMemberDetail.email === userEmail
|
||||
);
|
||||
|
||||
const updatedTeamMembers = isUserEmailInTeamMemberDetails
|
||||
? updatedTeamMemberDetails
|
||||
: [...updatedTeamMemberDetails, { email: userEmail, name: "Yourself" }];
|
||||
|
||||
return [
|
||||
...openTextAndContactQuestions.map((question) => ({
|
||||
label: recallToHeadline(question.headline, localSurvey, false, selectedLanguageCode)[
|
||||
@@ -121,8 +148,14 @@ export const FollowUpModal = ({
|
||||
id: fieldId,
|
||||
type: "hiddenField" as EmailSendToOption["type"],
|
||||
})),
|
||||
|
||||
...updatedTeamMembers.map((member) => ({
|
||||
label: `${member.name} (${member.email})`,
|
||||
id: member.email,
|
||||
type: "user" as EmailSendToOption["type"],
|
||||
})),
|
||||
];
|
||||
}, [localSurvey, selectedLanguageCode]);
|
||||
}, [localSurvey, selectedLanguageCode, teamMemberDetails, userEmail]);
|
||||
|
||||
const form = useForm<TCreateSurveyFollowUpForm>({
|
||||
defaultValues: {
|
||||
@@ -133,6 +166,7 @@ export const FollowUpModal = ({
|
||||
replyTo: defaultValues?.replyTo ?? [userEmail],
|
||||
subject: defaultValues?.subject ?? t("environments.surveys.edit.follow_ups_modal_action_subject"),
|
||||
body: defaultValues?.body ?? getSurveyFollowUpActionDefaultBody(t),
|
||||
attachResponseData: defaultValues?.attachResponseData ?? false,
|
||||
},
|
||||
resolver: zodResolver(ZCreateSurveyFollowUpFormSchema),
|
||||
mode: "onChange",
|
||||
@@ -217,6 +251,7 @@ export const FollowUpModal = ({
|
||||
replyTo: data.replyTo,
|
||||
subject: data.subject,
|
||||
body: sanitizedBody,
|
||||
attachResponseData: data.attachResponseData,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -263,6 +298,7 @@ export const FollowUpModal = ({
|
||||
replyTo: data.replyTo,
|
||||
subject: data.subject,
|
||||
body: sanitizedBody,
|
||||
attachResponseData: data.attachResponseData,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -317,15 +353,47 @@ export const FollowUpModal = ({
|
||||
replyTo: defaultValues?.replyTo ?? [userEmail],
|
||||
subject: defaultValues?.subject ?? "Thanks for your answers!",
|
||||
body: defaultValues?.body ?? getSurveyFollowUpActionDefaultBody(t),
|
||||
attachResponseData: defaultValues?.attachResponseData ?? false,
|
||||
});
|
||||
}
|
||||
}, [open, defaultValues, emailSendToOptions, form, userEmail, locale]);
|
||||
}, [open, defaultValues, emailSendToOptions, form, userEmail, locale, t]);
|
||||
|
||||
const handleModalClose = () => {
|
||||
form.reset();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const emailSendToQuestionOptions = emailSendToOptions.filter(
|
||||
(option) => option.type === "openTextQuestion" || option.type === "contactInfoQuestion"
|
||||
);
|
||||
const emailSendToHiddenFieldOptions = emailSendToOptions.filter((option) => option.type === "hiddenField");
|
||||
const userSendToEmailOptions = emailSendToOptions.filter((option) => option.type === "user");
|
||||
|
||||
const renderSelectItem = (option: EmailSendToOption) => {
|
||||
return (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
{option.type === "hiddenField" ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<EyeOffIcon className="h-4 w-4" />
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
) : option.type === "user" ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<UserIcon className="h-4 w-4" />
|
||||
<span className="overflow-hidden text-ellipsis whitespace-nowrap">{option.label}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-4 w-4">
|
||||
{QUESTIONS_ICON_MAP[option.type === "openTextQuestion" ? "openText" : "contactInfo"]}
|
||||
</div>
|
||||
<span className="overflow-hidden text-ellipsis whitespace-nowrap">{option.label}</span>
|
||||
</div>
|
||||
)}
|
||||
</SelectItem>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={handleModalClose} noPadding size="md">
|
||||
<div className="flex h-full flex-col rounded-lg">
|
||||
@@ -381,7 +449,6 @@ export const FollowUpModal = ({
|
||||
</div>
|
||||
|
||||
{/* Trigger */}
|
||||
|
||||
<div className="flex flex-col rounded-lg border border-slate-300">
|
||||
<div className="flex items-center gap-x-2 rounded-t-lg border-b border-slate-300 bg-slate-100 px-4 py-2">
|
||||
<div className="rounded-full border border-slate-300 bg-white p-1">
|
||||
@@ -504,13 +571,13 @@ export const FollowUpModal = ({
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<div className="flex items-center justify-center">
|
||||
<ArrowDownIcon className="h-4 w-4 text-slate-500" />
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
|
||||
<div className="flex flex-col rounded-lg border border-slate-300">
|
||||
<div className="flex items-center gap-x-2 rounded-t-lg border-b border-slate-300 bg-slate-100 px-4 py-2">
|
||||
<div className="rounded-full border border-slate-300 bg-white p-1">
|
||||
@@ -559,7 +626,7 @@ export const FollowUpModal = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{emailSendToOptions.length > 0 && (
|
||||
{emailSendToOptions.length > 0 ? (
|
||||
<div className="max-w-80">
|
||||
<FormControl>
|
||||
<Select
|
||||
@@ -577,38 +644,44 @@ export const FollowUpModal = ({
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
{emailSendToOptions.map((option) => {
|
||||
return (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
{option.type !== "hiddenField" ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-4 w-4">
|
||||
{
|
||||
QUESTIONS_ICON_MAP[
|
||||
option.type === "openTextQuestion"
|
||||
? "openText"
|
||||
: "contactInfo"
|
||||
]
|
||||
}
|
||||
</div>
|
||||
<span className="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{option.label}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<EyeOffIcon className="h-4 w-4" />
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
)}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
{emailSendToQuestionOptions.length > 0 ? (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center space-x-2 p-2">
|
||||
<p className="text-sm text-slate-500">Questions</p>
|
||||
</div>
|
||||
|
||||
{emailSendToQuestionOptions.map((option) =>
|
||||
renderSelectItem(option)
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{emailSendToHiddenFieldOptions.length > 0 ? (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex space-x-2 p-2">
|
||||
<p className="text-sm text-slate-500">Hidden Fields</p>
|
||||
</div>
|
||||
|
||||
{emailSendToHiddenFieldOptions.map((option) =>
|
||||
renderSelectItem(option)
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{userSendToEmailOptions.length > 0 ? (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex space-x-2 p-2">
|
||||
<p className="text-sm text-slate-500">Users</p>
|
||||
</div>
|
||||
|
||||
{userSendToEmailOptions.map((option) => renderSelectItem(option))}
|
||||
</div>
|
||||
) : null}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
@@ -746,6 +819,38 @@ export const FollowUpModal = ({
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="attachResponseData"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="attachResponseData"
|
||||
checked={field.value}
|
||||
defaultChecked={defaultValues?.attachResponseData ?? false}
|
||||
onCheckedChange={(checked) => field.onChange(checked)}
|
||||
/>
|
||||
<FormLabel htmlFor="attachResponseData" className="font-medium">
|
||||
{t(
|
||||
"environments.surveys.edit.follow_ups_modal_action_attach_response_data_label"
|
||||
)}
|
||||
</FormLabel>
|
||||
</div>
|
||||
|
||||
<FormDescription className="text-sm text-slate-500">
|
||||
{t(
|
||||
"environments.surveys.edit.follow_ups_modal_action_attach_response_data_description"
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { TFollowUpEmailToUser } from "@/modules/survey/editor/types/survey-follow-up";
|
||||
import { FollowUpItem } from "@/modules/survey/follow-ups/components/follow-up-item";
|
||||
import { FollowUpModal } from "@/modules/survey/follow-ups/components/follow-up-modal";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -17,6 +18,7 @@ interface FollowUpsViewProps {
|
||||
mailFrom: string;
|
||||
isSurveyFollowUpsAllowed: boolean;
|
||||
userEmail: string;
|
||||
teamMemberDetails: TFollowUpEmailToUser[];
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
@@ -27,6 +29,7 @@ export const FollowUpsView = ({
|
||||
mailFrom,
|
||||
isSurveyFollowUpsAllowed,
|
||||
userEmail,
|
||||
teamMemberDetails,
|
||||
locale,
|
||||
}: FollowUpsViewProps) => {
|
||||
const { t } = useTranslate();
|
||||
@@ -110,6 +113,7 @@ export const FollowUpsView = ({
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
mailFrom={mailFrom}
|
||||
userEmail={userEmail}
|
||||
teamMemberDetails={teamMemberDetails}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
@@ -124,6 +128,7 @@ export const FollowUpsView = ({
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
mailFrom={mailFrom}
|
||||
userEmail={userEmail}
|
||||
teamMemberDetails={teamMemberDetails}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -8,11 +8,17 @@ import { projectCache } from "@formbricks/lib/project/cache";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
|
||||
export const getProjectByEnvironmentId = reactCache(
|
||||
async (environmentId: string): Promise<Project | null> =>
|
||||
type ProjectWithTeam = Project & {
|
||||
teamIds: string[];
|
||||
};
|
||||
|
||||
export const getProjectWithTeamIdsByEnvironmentId = reactCache(
|
||||
async (environmentId: string): Promise<ProjectWithTeam | null> =>
|
||||
cache(
|
||||
async () => {
|
||||
let projectPrisma;
|
||||
let projectPrisma: Prisma.ProjectGetPayload<{
|
||||
include: { projectTeams: { select: { teamId: true } } };
|
||||
}> | null = null;
|
||||
|
||||
try {
|
||||
projectPrisma = await prisma.project.findFirst({
|
||||
@@ -23,9 +29,25 @@ export const getProjectByEnvironmentId = reactCache(
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
projectTeams: {
|
||||
select: {
|
||||
teamId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return projectPrisma;
|
||||
if (!projectPrisma) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const teamIds = projectPrisma.projectTeams.map((projectTeam) => projectTeam.teamId);
|
||||
|
||||
return {
|
||||
...projectPrisma,
|
||||
teamIds,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error, "Error fetching project by environment id");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { TemplateList } from "@/modules/survey/components/template-list";
|
||||
import { getProjectByEnvironmentId } from "@/modules/survey/lib/project";
|
||||
import { getProjectWithTeamIdsByEnvironmentId } from "@/modules/survey/lib/project";
|
||||
import { SurveysList } from "@/modules/survey/list/components/survey-list";
|
||||
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -38,7 +38,7 @@ export const SurveysPage = async ({
|
||||
const params = await paramsProps;
|
||||
const t = await getTranslate();
|
||||
|
||||
const project = await getProjectByEnvironmentId(params.environmentId);
|
||||
const project = await getProjectWithTeamIdsByEnvironmentId(params.environmentId);
|
||||
|
||||
if (!project) {
|
||||
throw new Error(t("common.project_not_found"));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { getProjectByEnvironmentId } from "@/modules/survey/lib/project";
|
||||
import { getProjectWithTeamIdsByEnvironmentId } from "@/modules/survey/lib/project";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { TProjectConfigChannel, TProjectConfigIndustry } from "@formbricks/types/project";
|
||||
@@ -25,7 +25,7 @@ export const SurveyTemplatesPage = async (props: SurveyTemplateProps) => {
|
||||
|
||||
const { session, environment, isReadOnly } = await getEnvironmentAuth(environmentId);
|
||||
|
||||
const project = await getProjectByEnvironmentId(environmentId);
|
||||
const project = await getProjectWithTeamIdsByEnvironmentId(environmentId);
|
||||
|
||||
if (!project) {
|
||||
throw new Error(t("common.project_not_found"));
|
||||
|
||||
@@ -28,6 +28,7 @@ export default defineConfig({
|
||||
"modules/ee/sso/lib/**/*.ts",
|
||||
"modules/email/components/email-template.tsx",
|
||||
"modules/email/emails/survey/follow-up.tsx",
|
||||
"modules/email/emails/lib/*.tsx",
|
||||
"modules/environments/lib/**/*.ts",
|
||||
"modules/ui/components/post-hog-client/*.tsx",
|
||||
"modules/ee/role-management/components/*.tsx",
|
||||
@@ -49,6 +50,7 @@ export default defineConfig({
|
||||
"modules/ee/sso/lib/**/*.ts",
|
||||
"app/lib/**/*.ts",
|
||||
"app/api/(internal)/insights/lib/**/*.ts",
|
||||
"app/api/(internal)/pipeline/lib/survey-follow-up.ts",
|
||||
"modules/ee/role-management/*.ts",
|
||||
"modules/organization/settings/teams/actions.ts",
|
||||
"modules/organization/settings/api-keys/lib/**/*.ts",
|
||||
@@ -60,6 +62,7 @@ export default defineConfig({
|
||||
"modules/survey/lib/client-utils.ts",
|
||||
"modules/survey/list/components/survey-card.tsx",
|
||||
"modules/survey/list/components/survey-dropdown-menu.tsx",
|
||||
"modules/survey/follow-ups/components/follow-up-item.tsx",
|
||||
"modules/ee/contacts/segments/lib/**/*.ts",
|
||||
"modules/ee/contacts/segments/components/segment-settings.tsx",
|
||||
"modules/ee/contacts/api/v2/management/contacts/bulk/lib/contact.ts",
|
||||
|
||||
@@ -39,6 +39,7 @@ export const ZSurveyFollowUpAction = z.object({
|
||||
replyTo: z.array(z.string().email()),
|
||||
subject: z.string(),
|
||||
body: z.string(),
|
||||
attachResponseData: z.boolean(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -481,6 +481,7 @@
|
||||
"powered_by_formbricks": "Unterstützt von Formbricks",
|
||||
"privacy_policy": "Datenschutzerklärung",
|
||||
"reject": "Ablehnen",
|
||||
"render_email_response_value_file_upload_response_link_not_included": "Link zur hochgeladenen Datei ist aus Datenschutzgründen nicht enthalten",
|
||||
"response_finished_email_subject": "Eine Antwort für {surveyName} wurde abgeschlossen ✅",
|
||||
"response_finished_email_subject_with_email": "{personEmail} hat deine Umfrage {surveyName} abgeschlossen ✅",
|
||||
"schedule_your_meeting": "Termin planen",
|
||||
@@ -1423,6 +1424,8 @@
|
||||
"follow_ups_item_issue_detected_tag": "Problem erkannt",
|
||||
"follow_ups_item_response_tag": "Jede Antwort",
|
||||
"follow_ups_item_send_email_tag": "E-Mail senden",
|
||||
"follow_ups_modal_action_attach_response_data_description": "Füge die Daten der Umfrageantwort zur Nachverfolgung hinzu",
|
||||
"follow_ups_modal_action_attach_response_data_label": "Antwortdaten anhängen",
|
||||
"follow_ups_modal_action_body_label": "Inhalt",
|
||||
"follow_ups_modal_action_body_placeholder": "Inhalt der E-Mail",
|
||||
"follow_ups_modal_action_email_content": "E-Mail Inhalt",
|
||||
|
||||
@@ -481,6 +481,7 @@
|
||||
"powered_by_formbricks": "Powered by Formbricks",
|
||||
"privacy_policy": "Privacy Policy",
|
||||
"reject": "Reject",
|
||||
"render_email_response_value_file_upload_response_link_not_included": "Link to uploaded file is not included for data privacy reasons",
|
||||
"response_finished_email_subject": "A response for {surveyName} was completed ✅",
|
||||
"response_finished_email_subject_with_email": "{personEmail} just completed your {surveyName} survey ✅",
|
||||
"schedule_your_meeting": "Schedule your meeting",
|
||||
@@ -1423,6 +1424,8 @@
|
||||
"follow_ups_item_issue_detected_tag": "Issue detected",
|
||||
"follow_ups_item_response_tag": "Any response",
|
||||
"follow_ups_item_send_email_tag": "Send email",
|
||||
"follow_ups_modal_action_attach_response_data_description": "Add the data of the survey response to the follow-up",
|
||||
"follow_ups_modal_action_attach_response_data_label": "Attach response data",
|
||||
"follow_ups_modal_action_body_label": "Body",
|
||||
"follow_ups_modal_action_body_placeholder": "Body of the email",
|
||||
"follow_ups_modal_action_email_content": "Email content",
|
||||
|
||||
@@ -481,6 +481,7 @@
|
||||
"powered_by_formbricks": "Propulsé par Formbricks",
|
||||
"privacy_policy": "Politique de confidentialité",
|
||||
"reject": "Rejeter",
|
||||
"render_email_response_value_file_upload_response_link_not_included": "Le lien vers le fichier téléchargé n'est pas inclus pour des raisons de confidentialité des données",
|
||||
"response_finished_email_subject": "Une réponse pour {surveyName} a été complétée ✅",
|
||||
"response_finished_email_subject_with_email": "{personEmail} vient de compléter votre enquête {surveyName} ✅",
|
||||
"schedule_your_meeting": "Planifier votre rendez-vous",
|
||||
@@ -1423,6 +1424,8 @@
|
||||
"follow_ups_item_issue_detected_tag": "Problème détecté",
|
||||
"follow_ups_item_response_tag": "Une réponse quelconque",
|
||||
"follow_ups_item_send_email_tag": "Envoyer un e-mail",
|
||||
"follow_ups_modal_action_attach_response_data_description": "Ajouter les données de la réponse à l'enquête au suivi",
|
||||
"follow_ups_modal_action_attach_response_data_label": "Joindre les données de réponse",
|
||||
"follow_ups_modal_action_body_label": "Corps",
|
||||
"follow_ups_modal_action_body_placeholder": "Corps de l'email",
|
||||
"follow_ups_modal_action_email_content": "Contenu de l'email",
|
||||
|
||||
@@ -481,6 +481,7 @@
|
||||
"powered_by_formbricks": "Desenvolvido por Formbricks",
|
||||
"privacy_policy": "Política de Privacidade",
|
||||
"reject": "Rejeitar",
|
||||
"render_email_response_value_file_upload_response_link_not_included": "O link para o arquivo enviado não está incluído por motivos de privacidade de dados",
|
||||
"response_finished_email_subject": "Uma resposta para {surveyName} foi concluída ✅",
|
||||
"response_finished_email_subject_with_email": "{personEmail} acabou de completar sua pesquisa {surveyName} ✅",
|
||||
"schedule_your_meeting": "Agendar sua reunião",
|
||||
@@ -1423,6 +1424,8 @@
|
||||
"follow_ups_item_issue_detected_tag": "Problema detectado",
|
||||
"follow_ups_item_response_tag": "Qualquer resposta",
|
||||
"follow_ups_item_send_email_tag": "Enviar e-mail",
|
||||
"follow_ups_modal_action_attach_response_data_description": "Adicionar os dados da resposta da pesquisa ao acompanhamento",
|
||||
"follow_ups_modal_action_attach_response_data_label": "Anexar dados da resposta",
|
||||
"follow_ups_modal_action_body_label": "Corpo",
|
||||
"follow_ups_modal_action_body_placeholder": "Corpo do e-mail",
|
||||
"follow_ups_modal_action_email_content": "Conteúdo do e-mail",
|
||||
|
||||
@@ -481,6 +481,7 @@
|
||||
"powered_by_formbricks": "Desenvolvido por Formbricks",
|
||||
"privacy_policy": "Política de Privacidade",
|
||||
"reject": "Rejeitar",
|
||||
"render_email_response_value_file_upload_response_link_not_included": "O link para o ficheiro carregado não está incluído por razões de privacidade de dados",
|
||||
"response_finished_email_subject": "Uma resposta para {surveyName} foi concluída ✅",
|
||||
"response_finished_email_subject_with_email": "{personEmail} acabou de completar o seu inquérito {surveyName} ✅",
|
||||
"schedule_your_meeting": "Agende a sua reunião",
|
||||
@@ -1423,6 +1424,8 @@
|
||||
"follow_ups_item_issue_detected_tag": "Problema detetado",
|
||||
"follow_ups_item_response_tag": "Qualquer resposta",
|
||||
"follow_ups_item_send_email_tag": "Enviar email",
|
||||
"follow_ups_modal_action_attach_response_data_description": "Adicionar os dados da resposta do inquérito ao acompanhamento",
|
||||
"follow_ups_modal_action_attach_response_data_label": "Anexar dados de resposta",
|
||||
"follow_ups_modal_action_body_label": "Corpo",
|
||||
"follow_ups_modal_action_body_placeholder": "Corpo do email",
|
||||
"follow_ups_modal_action_email_content": "Conteúdo do email",
|
||||
|
||||
@@ -481,6 +481,7 @@
|
||||
"powered_by_formbricks": "由 Formbricks 提供技術支援",
|
||||
"privacy_policy": "隱私權政策",
|
||||
"reject": "拒絕",
|
||||
"render_email_response_value_file_upload_response_link_not_included": "由於資料隱私原因,未包含上傳檔案的連結",
|
||||
"response_finished_email_subject": "{surveyName} 的回應已完成 ✅",
|
||||
"response_finished_email_subject_with_email": "{personEmail} 剛剛完成了您的 {surveyName} 調查 ✅",
|
||||
"schedule_your_meeting": "安排你的會議",
|
||||
@@ -1423,6 +1424,8 @@
|
||||
"follow_ups_item_issue_detected_tag": "偵測到問題",
|
||||
"follow_ups_item_response_tag": "任何回應",
|
||||
"follow_ups_item_send_email_tag": "發送電子郵件",
|
||||
"follow_ups_modal_action_attach_response_data_description": "將調查回應的數據添加到後續",
|
||||
"follow_ups_modal_action_attach_response_data_label": "附加 response data",
|
||||
"follow_ups_modal_action_body_label": "內文",
|
||||
"follow_ups_modal_action_body_placeholder": "電子郵件內文",
|
||||
"follow_ups_modal_action_email_content": "電子郵件內容",
|
||||
|
||||
@@ -43,7 +43,10 @@ export const getQuestionResponseMapping = (
|
||||
const answer = response.data[question.id];
|
||||
|
||||
questionResponseMapping.push({
|
||||
question: parseRecallInfo(getLocalizedValue(question.headline, "default"), response.data),
|
||||
question: parseRecallInfo(
|
||||
getLocalizedValue(question.headline, response.language ?? "default"),
|
||||
response.data
|
||||
),
|
||||
response: convertResponseValue(answer, question),
|
||||
type: question.type,
|
||||
});
|
||||
|
||||
@@ -1321,11 +1321,15 @@ export const ZSurvey = z
|
||||
];
|
||||
|
||||
if (validOptions.findIndex((option) => option === followUp.action.properties.to) === -1) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `The action in follow up ${String(index + 1)} has an invalid email field`,
|
||||
path: ["followUps"],
|
||||
});
|
||||
// not from a valid option within the survey, but it could be a correct email from the team member emails or the user's email:
|
||||
const parsedEmailTo = z.string().email().safeParse(followUp.action.properties.to);
|
||||
if (!parsedEmailTo.success) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `The action in follow up ${String(index + 1)} has an invalid email field`,
|
||||
path: ["followUps"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (followUp.trigger.type === "endings") {
|
||||
|
||||
Reference in New Issue
Block a user