feat: survey follow ups (#4247)

Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Anshuman Pandey
2024-11-21 12:20:37 +05:30
committed by GitHub
parent f0a4fad878
commit 37ef6be4c3
56 changed files with 2260 additions and 608 deletions
+2
View File
@@ -23,6 +23,7 @@ export class ResponseAPI {
async update({
responseId,
finished,
endingId,
data,
ttc,
variables,
@@ -30,6 +31,7 @@ export class ResponseAPI {
}: TResponseUpdateInputWithResponseId): Promise<Result<object, NetworkError | Error>> {
return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/responses/${responseId}`, "PUT", {
finished,
endingId,
data,
ttc,
variables,
+13 -12
View File
@@ -1,14 +1,12 @@
/* eslint-disable import/no-relative-packages -- required for importing types */
/* eslint-disable @typescript-eslint/no-namespace -- using namespaces is required for prisma-json-types-generator */
import { type TActionClassNoCodeConfig } from "@formbricks/types/action-classes";
import { type TIntegrationConfig } from "@formbricks/types/integration";
import { type TOrganizationBilling } from "@formbricks/types/organizations";
import { type TProductConfig, type TProductStyling } from "@formbricks/types/product";
import {
type TResponseData,
type TResponseMeta,
type TResponsePersonAttributes,
} from "@formbricks/types/responses";
import { type TBaseFilters } from "@formbricks/types/segment";
import { type TActionClassNoCodeConfig } from "../types/action-classes";
import { type TIntegrationConfig } from "../types/integration";
import { type TOrganizationBilling } from "../types/organizations";
import { type TProductConfig, type TProductStyling } from "../types/product";
import { type TResponseData, type TResponseMeta, type TResponsePersonAttributes } from "../types/responses";
import { type TBaseFilters } from "../types/segment";
import {
type TSurveyClosedMessage,
type TSurveyEnding,
@@ -19,8 +17,9 @@ import {
type TSurveyStyling,
type TSurveyVariables,
type TSurveyWelcomeCard,
} from "@formbricks/types/surveys/types";
import { type TUserLocale, type TUserNotificationSettings } from "@formbricks/types/user";
} from "../types/surveys/types";
import { type TUserLocale, type TUserNotificationSettings } from "../types/user";
import type { TSurveyFollowUpAction, TSurveyFollowUpTrigger } from "./types/survey-follow-up";
declare global {
namespace PrismaJson {
@@ -45,5 +44,7 @@ declare global {
export type SegmentFilter = TBaseFilters;
export type Styling = TProductStyling;
export type Locale = TUserLocale;
export type SurveyFollowUpTrigger = TSurveyFollowUpTrigger;
export type SurveyFollowUpAction = TSurveyFollowUpAction;
}
}
@@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "SurveyFollowUp" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"surveyId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"trigger" JSONB NOT NULL,
"action" JSONB NOT NULL,
CONSTRAINT "SurveyFollowUp_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "SurveyFollowUp" ADD CONSTRAINT "SurveyFollowUp_surveyId_fkey" FOREIGN KEY ("surveyId") REFERENCES "Survey"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Response" ADD COLUMN "endingId" TEXT;
+1 -2
View File
@@ -65,9 +65,8 @@
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@formbricks/types": "workspace:*",
"@paralleldrive/cuid2": "2.2.2",
"@formbricks/eslint-config": "workspace:*",
"@paralleldrive/cuid2": "2.2.2",
"prisma": "5.20.0",
"prisma-dbml-generator": "0.12.0",
"prisma-json-types-generator": "3.1.1",
+17
View File
@@ -112,6 +112,7 @@ model Response {
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
finished Boolean @default(false)
endingId String?
survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade)
surveyId String
person Person? @relation(fields: [personId], references: [id], onDelete: Cascade)
@@ -333,11 +334,27 @@ model Survey {
languages SurveyLanguage[]
showLanguageSwitch Boolean?
documents Document[]
followUps SurveyFollowUp[]
@@index([environmentId, updatedAt])
@@index([segmentId])
}
model SurveyFollowUp {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade)
surveyId String
name String
/// [SurveyFollowUpTrigger]
/// @zod.custom(imports.ZSurveyFollowUpTrigger)
trigger Json
/// [SurveyFollowUpAction]
/// @zod.custom(imports.ZSurveyFollowUpAction)
action Json
}
enum ActionType {
code
noCode
@@ -0,0 +1,57 @@
import { z } from "zod";
export const ZSurveyFollowUpTrigger = z
.object({
type: z.enum(["response", "endings"]),
properties: z
.object({
endingIds: z.array(z.string().cuid2()),
})
.nullable(),
})
.superRefine((trigger, ctx) => {
if (trigger.type === "response") {
if (trigger.properties) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Properties should be null for response type",
});
}
}
if (trigger.type === "endings") {
if (!trigger.properties) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Properties must be defined for endings type",
});
}
}
});
export type TSurveyFollowUpTrigger = z.infer<typeof ZSurveyFollowUpTrigger>;
export const ZSurveyFollowUpAction = z.object({
type: z.literal("send-email"),
properties: z.object({
to: z.string(),
from: z.string().email(),
replyTo: z.array(z.string().email()),
subject: z.string(),
body: z.string(),
}),
});
export type TSurveyFollowUpAction = z.infer<typeof ZSurveyFollowUpAction>;
export const ZSurveyFollowUp = z.object({
id: z.string().cuid2(),
createdAt: z.date(),
updatedAt: z.date(),
name: z.string(),
trigger: ZSurveyFollowUpTrigger,
action: ZSurveyFollowUpAction,
surveyId: z.string().cuid2(),
});
export type TSurveyFollowUp = z.infer<typeof ZSurveyFollowUp>;
+10 -12
View File
@@ -1,15 +1,11 @@
/* eslint-disable import/no-relative-packages -- required for importing types */
import { z } from "zod";
export const ZActionProperties = z.record(z.string());
export { ZActionClassNoCodeConfig } from "@formbricks/types/action-classes";
export { ZIntegrationConfig } from "@formbricks/types/integration";
export { ZActionClassNoCodeConfig } from "../types/action-classes";
export { ZIntegrationConfig } from "../types/integration";
export {
ZResponseData,
ZResponsePersonAttributes,
ZResponseMeta,
ZResponseTtc,
} from "@formbricks/types/responses";
export { ZResponseData, ZResponsePersonAttributes, ZResponseMeta, ZResponseTtc } from "../types/responses";
export {
ZSurveyWelcomeCard,
@@ -22,8 +18,10 @@ export {
ZSurveySingleUse,
ZSurveyInlineTriggers,
ZSurveyEnding,
} from "@formbricks/types/surveys/types";
} from "../types/surveys/types";
export { ZSegmentFilters } from "@formbricks/types/segment";
export { ZOrganizationBilling } from "@formbricks/types/organizations";
export { ZUserNotificationSettings } from "@formbricks/types/user";
export { ZSurveyFollowUpAction, ZSurveyFollowUpTrigger } from "./types/survey-follow-up";
export { ZSegmentFilters } from "../types/segment";
export { ZOrganizationBilling } from "../types/organizations";
export { ZUserNotificationSettings } from "../types/user";
-421
View File
@@ -1,421 +0,0 @@
export const Permissions = {
owner: {
environment: {
read: true,
},
product: {
create: true,
read: true,
update: true,
delete: true,
},
organization: {
read: true,
update: true,
delete: true,
},
membership: {
create: true,
update: true,
delete: true,
},
person: {
read: true,
delete: true,
},
response: {
read: true,
update: true,
delete: true,
},
survey: {
create: true,
read: true,
update: true,
delete: true,
},
tag: {
create: true,
update: true,
delete: true,
},
responseNote: {
create: true,
update: true,
delete: true,
},
segment: {
create: true,
read: true,
update: true,
delete: true,
},
actionClass: {
create: true,
delete: true,
},
integration: {
create: true,
update: true,
delete: true,
},
webhook: {
create: true,
update: true,
delete: true,
},
apiKey: {
create: true,
delete: true,
},
subscription: {
create: true,
read: true,
update: true,
delete: true,
},
invite: {
create: true,
read: true,
update: true,
delete: true,
},
language: {
create: true,
update: true,
delete: true,
},
team: {
create: true,
read: true,
update: true,
delete: true,
},
teamMembership: {
create: true,
read: true,
update: true,
delete: true,
},
productTeam: {
create: true,
update: true,
delete: true,
},
},
manager: {
environment: {
read: true,
},
product: {
create: true,
read: true,
update: true,
delete: true,
},
organization: {
read: true,
update: true,
delete: false,
},
membership: {
create: true,
update: true,
delete: true,
},
person: {
read: true,
delete: true,
},
response: {
read: true,
update: true,
delete: true,
},
survey: {
create: true,
read: true,
update: true,
delete: true,
},
tag: {
create: true,
update: true,
delete: true,
},
responseNote: {
create: true,
update: true,
delete: true,
},
segment: {
create: true,
read: true,
update: true,
delete: true,
},
actionClass: {
create: true,
delete: true,
},
integration: {
create: true,
update: true,
delete: true,
},
webhook: {
create: true,
update: true,
delete: true,
},
apiKey: {
create: true,
delete: true,
},
subscription: {
create: true,
read: true,
update: true,
delete: true,
},
invite: {
create: true,
read: true,
update: true,
delete: true,
},
language: {
create: true,
update: true,
delete: true,
},
team: {
create: true,
read: true,
update: true,
delete: true,
},
teamMembership: {
create: true,
read: true,
update: true,
delete: true,
},
productTeam: {
create: true,
update: true,
delete: true,
},
},
billing: {
environment: {
read: true,
},
product: {
create: false,
read: true,
update: false,
delete: false,
},
organization: {
read: true,
update: false,
delete: false,
},
membership: {
create: false,
update: false,
delete: false,
},
person: {
read: false,
delete: false,
},
response: {
read: false,
update: false,
delete: false,
},
survey: {
create: false,
read: false,
update: false,
delete: false,
},
tag: {
create: false,
update: false,
delete: false,
},
responseNote: {
create: false,
update: false,
delete: false,
},
segment: {
create: false,
read: false,
update: false,
delete: false,
},
actionClass: {
create: false,
delete: false,
},
integration: {
create: false,
update: false,
delete: false,
},
webhook: {
create: false,
update: false,
delete: false,
},
apiKey: {
create: false,
delete: false,
},
subscription: {
create: true,
read: true,
update: true,
delete: true,
},
invite: {
create: false,
read: false,
update: false,
delete: false,
},
language: {
create: false,
update: false,
delete: false,
},
team: {
create: false,
read: true,
update: false,
delete: false,
},
teamMembership: {
create: false,
read: false,
update: false,
delete: false,
},
productTeam: {
create: false,
update: false,
delete: false,
},
},
member: {
environment: {
read: true,
},
product: {
create: false,
read: true,
update: true,
delete: false,
},
organization: {
read: false,
update: false,
delete: false,
},
membership: {
create: false,
update: false,
delete: false,
},
person: {
read: true,
delete: true,
},
response: {
read: true,
update: true,
delete: true,
},
survey: {
create: true,
read: true,
update: true,
delete: true,
},
tag: {
create: true,
update: true,
delete: true,
},
responseNote: {
create: true,
update: true,
delete: true,
},
segment: {
create: true,
read: true,
update: true,
delete: true,
},
actionClass: {
create: true,
delete: true,
},
integration: {
create: true,
update: true,
delete: true,
},
webhook: {
create: true,
update: true,
delete: true,
},
apiKey: {
create: true,
delete: true,
},
subscription: {
create: false,
read: false,
update: false,
delete: false,
},
invite: {
create: false,
read: false,
update: false,
delete: false,
},
language: {
create: true,
update: true,
delete: true,
},
team: {
create: false,
read: true,
update: false,
delete: false,
},
teamMembership: {
create: true,
read: true,
update: true,
delete: true,
},
productTeam: {
create: false,
update: false,
delete: false,
},
},
};
+44 -1
View File
@@ -221,7 +221,7 @@
"invite_them": "Lade sie ein",
"join_discord": "Discord beitreten",
"key": "Schlüssel",
"label": "Etikett",
"label": "Bezeichnung",
"language": "Sprache",
"languages": "Sprachen",
"license": "Lizenz",
@@ -1376,6 +1376,48 @@
"field_name_eg_score_price": "Feldname z.B. Punktzahl, Preis",
"first_name": "Vorname",
"five_points_recommended": "5 Punkte (empfohlen)",
"follow_ups": "Follow-ups",
"follow_ups_delete_modal_text": "Bist du sicher, dass du dieses Follow-up löschen möchtest?",
"follow_ups_delete_modal_title": "Follow-up löschen?",
"follow_ups_empty_description": "Sende Nachrichten an Teilnehmer der Umfrage, dich selbst oder Teammitglieder.",
"follow_ups_empty_heading": "Automatische Follow-ups versenden",
"follow_ups_ending_card_delete_modal_text": "Dieser Abschluss wird in Follow-ups verwendet. Wenn Sie ihn löschen, wird er aus allen Follow-ups entfernt. Sind Sie sicher, dass Sie ihn löschen möchten?",
"follow_ups_ending_card_delete_modal_title": "Abschlusskarte löschen?",
"follow_ups_hidden_field_error": "Verstecktes Feld wird in einem Follow-up verwendet. Bitte entfernen Sie es zuerst aus dem Follow-up.",
"follow_ups_item_ending_tag": "Abschluss",
"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_body_label": "Inhalt",
"follow_ups_modal_action_body_placeholder": "Inhalt der E-Mail",
"follow_ups_modal_action_email_content": "E-Mail Inhalt",
"follow_ups_modal_action_email_settings": "E-Mail Einstellungen",
"follow_ups_modal_action_from_description": "Absender E-Mail",
"follow_ups_modal_action_from_label": "Von",
"follow_ups_modal_action_label": "Aktion",
"follow_ups_modal_action_replyTo_description": "Wenn der Empfänger antwortet, geht die Antwort an diese E-Mail-Adresse",
"follow_ups_modal_action_replyTo_label": "Antwort an",
"follow_ups_modal_action_replyTo_placeholder": "E-Mail-Adresse eingeben & Leertaste drücken",
"follow_ups_modal_action_subject": "Danke für deine Antworten!",
"follow_ups_modal_action_subject_label": "Betreff",
"follow_ups_modal_action_subject_placeholder": "Betreff der E-Mail",
"follow_ups_modal_action_to_description": "Empfänger-E-Mail-Adresse",
"follow_ups_modal_action_to_label": "An",
"follow_ups_modal_action_to_warning": "Kein E-Mail-Feld in der Umfrage gefunden.",
"follow_ups_modal_create_heading": "Neues Follow-up erstellen",
"follow_ups_modal_edit_heading": "Follow-up bearbeiten",
"follow_ups_modal_edit_no_id": "Keine Survey Follow-up-ID angegeben, das Survey-Follow-up kann nicht aktualisiert werden",
"follow_ups_modal_name_label": "Name des Follow-ups",
"follow_ups_modal_name_placeholder": "Benenne dein Follow-up",
"follow_ups_modal_subheading": "Sende Nachrichten an Teilnehmer, dich selbst oder Teammitglieder",
"follow_ups_modal_trigger_description": "Wann soll dieses Follow-up ausgelöst werden?",
"follow_ups_modal_trigger_label": "Auslöser",
"follow_ups_modal_trigger_type_ending": "Teilnehmer sieht einen bestimmten Abschluss",
"follow_ups_modal_trigger_type_ending_select": "Abschlüsse auswählen: ",
"follow_ups_modal_trigger_type_ending_warning": "Keine Abschlüsse in der Umfrage gefunden!",
"follow_ups_modal_trigger_type_response": "Teilnehmer schließt Umfrage ab",
"follow_ups_new": "Neues Follow-up",
"follow_ups_upgrade_button_text": "Upgrade, um Follow-ups zu aktivieren",
"for_advanced_targeting_please": "Für fortgeschrittenes Targeting, bitte",
"form_styling": "Umfrage Styling",
"formbricks_ai_description": "Beschreibe deine Umfrage und lass Formbricks KI die Umfrage für Dich erstellen",
@@ -2320,6 +2362,7 @@
"file_upload_description": "Ermögliche es den Befragten, Dokumente, Bilder oder andere Dateien hochzuladen",
"file_upload_headline": "Datei hochladen",
"finish": "Fertigstellen",
"follow_ups_modal_action_body": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span style=\"white-space: pre-wrap;\">Hey 👋</span><br><br><span style=\"white-space: pre-wrap;\">Danke, dass du dir die Zeit genommen hast zu antworten. Wir melden uns bald bei dir.</span><br><br><span style=\"white-space: pre-wrap;\">Hab noch einen schönen Tag!</span></p>",
"free_text": "Freitext",
"free_text_description": "Sammle offenes Feedback",
"free_text_headline": "Was möchtest Du mit uns teilen?",
+45 -2
View File
@@ -1376,8 +1376,50 @@
"field_name_eg_score_price": "Field name e.g, score, price",
"first_name": "First Name",
"five_points_recommended": "5 points (recommended)",
"follow_ups": "Follow-ups",
"follow_ups_delete_modal_text": "Are you sure you want to delete this follow-up?",
"follow_ups_delete_modal_title": "Delete follow-up?",
"follow_ups_empty_description": "Send messages to respondents, yourself or team mates.",
"follow_ups_empty_heading": "Send automatic follow-ups",
"follow_ups_ending_card_delete_modal_text": "This ending card is used in follow-ups. Deleting it will remove it from all follow-ups. Are you sure you want to delete it?",
"follow_ups_ending_card_delete_modal_title": "Delete ending card?",
"follow_ups_hidden_field_error": "Hidden field is used in a follow-up. Please remove it from follow-up first.",
"follow_ups_item_ending_tag": "Ending(s)",
"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_body_label": "Body",
"follow_ups_modal_action_body_placeholder": "Body of the email",
"follow_ups_modal_action_email_content": "Email content",
"follow_ups_modal_action_email_settings": "Email settings",
"follow_ups_modal_action_from_description": "Email address to send the email from",
"follow_ups_modal_action_from_label": "From",
"follow_ups_modal_action_label": "Action",
"follow_ups_modal_action_replyTo_description": "If the recipient hits reply, the following email address will receive it",
"follow_ups_modal_action_replyTo_label": "Reply To",
"follow_ups_modal_action_replyTo_placeholder": "Write an email address & press space bar",
"follow_ups_modal_action_subject": "Thanks for your answers!",
"follow_ups_modal_action_subject_label": "Subject",
"follow_ups_modal_action_subject_placeholder": "Subject of the email",
"follow_ups_modal_action_to_description": "Email address to send the email to",
"follow_ups_modal_action_to_label": "To",
"follow_ups_modal_action_to_warning": "No email field detected in the survey",
"follow_ups_modal_create_heading": "Create a new follow-up",
"follow_ups_modal_edit_heading": "Edit this follow-up",
"follow_ups_modal_edit_no_id": "No survey follow up id provided, can't update the survey follow up",
"follow_ups_modal_name_label": "Follow-up name",
"follow_ups_modal_name_placeholder": "Name your follow-up",
"follow_ups_modal_subheading": "Send messages to respondents, yourself or team mates",
"follow_ups_modal_trigger_description": "When should this follow-up be triggered?",
"follow_ups_modal_trigger_label": "Trigger",
"follow_ups_modal_trigger_type_ending": "Respondent sees a specific ending",
"follow_ups_modal_trigger_type_ending_select": "Select endings: ",
"follow_ups_modal_trigger_type_ending_warning": "No endings found in the survey!",
"follow_ups_modal_trigger_type_response": "Respondent completes survey",
"follow_ups_new": "New follow-up",
"follow_ups_upgrade_button_text": "Upgrade to enable follow-ups",
"for_advanced_targeting_please": "For advanced targeting, please",
"form_styling": "Form Styling",
"form_styling": "Form styling",
"formbricks_ai_description": "Describe your survey and let Formbricks AI create the survey for you",
"formbricks_ai_generate": "Generate",
"formbricks_ai_prompt_placeholder": "Enter survey information (e.g. key topics to cover)",
@@ -1385,7 +1427,7 @@
"four_points": "4 points",
"heading": "Heading",
"hidden_field_added_successfully": "Hidden field added successfully",
"hide_advanced_settings": "Hide Advanced settings",
"hide_advanced_settings": "Hide advanced settings",
"hide_logo": "Hide logo",
"hide_progress_bar": "Hide progress bar",
"hide_the_logo_in_this_specific_survey": "Hide the logo in this specific survey",
@@ -2320,6 +2362,7 @@
"file_upload_description": "Enable respondents to upload documents, images, or other files",
"file_upload_headline": "File Upload",
"finish": "Finish",
"follow_ups_modal_action_body": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span style=\"white-space: pre-wrap;\">Hey 👋</span><br><br><span style=\"white-space: pre-wrap;\">Thanks for taking the time to respond, we will be in touch shortly.</span><br><br><span style=\"white-space: pre-wrap;\">Have a great day!</span></p>",
"free_text": "Free text",
"free_text_description": "Collect open-ended feedback",
"free_text_headline": "Who let the dogs out?",
+43
View File
@@ -1376,6 +1376,48 @@
"field_name_eg_score_price": "Nome do campo, por exemplo, pontuação, preço",
"first_name": "Primeiro Nome",
"five_points_recommended": "5 pontos (recomendado)",
"follow_ups": "Acompanhamentos",
"follow_ups_delete_modal_text": "Tem certeza de que deseja excluir este acompanhamento?",
"follow_ups_delete_modal_title": "Excluir acompanhamento?",
"follow_ups_empty_description": "Envie mensagens para os entrevistados, para você mesmo ou para os colegas de equipe.",
"follow_ups_empty_heading": "Enviar acompanhamentos automáticos",
"follow_ups_ending_card_delete_modal_text": "Este final é usado em acompanhamentos. Excluí-lo o removerá de todos os acompanhamentos. Tem certeza de que deseja excluí-lo?",
"follow_ups_ending_card_delete_modal_title": "Excluir cartão de final?",
"follow_ups_hidden_field_error": "O campo oculto está sendo usado em um acompanhamento. Por favor, remova-o do acompanhamento primeiro.",
"follow_ups_item_ending_tag": "Final(is)",
"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_body_label": "Corpo",
"follow_ups_modal_action_body_placeholder": "Corpo do e-mail",
"follow_ups_modal_action_email_content": "Conteúdo do e-mail",
"follow_ups_modal_action_email_settings": "Configuração de e-mail",
"follow_ups_modal_action_from_description": "Endereço de e-mail de onde o e-mail será enviado",
"follow_ups_modal_action_from_label": "De",
"follow_ups_modal_action_label": "Ação",
"follow_ups_modal_action_replyTo_description": "Se o destinatário responder, o seguinte endereço de e-mail receberá a resposta",
"follow_ups_modal_action_replyTo_label": "Responder para",
"follow_ups_modal_action_replyTo_placeholder": "Escreva um endereço de e-mail e pressione a barra de espaço",
"follow_ups_modal_action_subject": "Valeu pelas respostas!",
"follow_ups_modal_action_subject_label": "Assunto",
"follow_ups_modal_action_subject_placeholder": "Assunto do e-mail",
"follow_ups_modal_action_to_description": "Endereço de e-mail para enviar o e-mail para",
"follow_ups_modal_action_to_label": "Para",
"follow_ups_modal_action_to_warning": "Nenhum campo de e-mail detectado na pesquisa",
"follow_ups_modal_create_heading": "Criar um novo acompanhamento",
"follow_ups_modal_edit_heading": "Editar este acompanhamento",
"follow_ups_modal_edit_no_id": "Nenhum ID de acompanhamento da pesquisa fornecido, não é possível atualizar o acompanhamento da pesquisa",
"follow_ups_modal_name_label": "Nome do acompanhamento",
"follow_ups_modal_name_placeholder": "Nomeie seu acompanhamento",
"follow_ups_modal_subheading": "Envie mensagens para os entrevistados, para você mesmo ou para os colegas de equipe",
"follow_ups_modal_trigger_description": "Quando este acompanhamento deve ser acionado?",
"follow_ups_modal_trigger_label": "Gatilho",
"follow_ups_modal_trigger_type_ending": "Respondente vê um final específico",
"follow_ups_modal_trigger_type_ending_select": "Selecione os finais: ",
"follow_ups_modal_trigger_type_ending_warning": "Nenhum final encontrado na pesquisa!",
"follow_ups_modal_trigger_type_response": "Respondente completa a pesquisa",
"follow_ups_new": "Novo acompanhamento",
"follow_ups_upgrade_button_text": "Atualize para habilitar os Acompanhamentos",
"for_advanced_targeting_please": "Para uma segmentação avançada, por favor",
"form_styling": "Estilização de Formulários",
"formbricks_ai_description": "Descreva sua pesquisa e deixe a Formbricks AI criar a pesquisa pra você",
@@ -2320,6 +2362,7 @@
"file_upload_description": "Permitir que os respondentes façam upload de documentos, imagens ou outros arquivos",
"file_upload_headline": "Enviar Arquivo",
"finish": "Terminar",
"follow_ups_modal_action_body": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span style=\"white-space: pre-wrap;\">Oi 👋</span><br><br><span style=\"white-space: pre-wrap;\">Valeu por tirar um tempinho pra responder. A gente vai entrar em contato em breve.</span><br><br><span style=\"white-space: pre-wrap;\">Tenha um ótimo dia!</span></p>",
"free_text": "Texto livre",
"free_text_description": "Coletar feedback aberto",
"free_text_headline": "Quem deixou os cachorros saírem?",
+5 -1
View File
@@ -51,6 +51,7 @@ export const responseSelection = {
updatedAt: true,
surveyId: true,
finished: true,
endingId: true,
data: true,
meta: true,
ttc: true,
@@ -253,6 +254,7 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
surveyId,
displayId,
finished,
endingId,
data,
meta,
singleUseId,
@@ -292,7 +294,8 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
},
},
display: displayId ? { connect: { id: displayId } } : undefined,
finished: finished,
finished,
endingId,
data: data,
language: language,
...(person?.id && {
@@ -673,6 +676,7 @@ export const updateResponse = async (
},
data: {
finished: responseInput.finished,
endingId: responseInput.endingId,
data,
ttc,
language,
+1
View File
@@ -138,5 +138,6 @@ export const getPreviewSurvey = (locale: string) => {
languages: [],
triggers: [],
showLanguageSwitch: false,
followUps: [],
} as TSurvey;
};
+73 -1
View File
@@ -140,6 +140,7 @@ export const selectSurvey = {
},
},
},
followUps: true,
} satisfies Prisma.SurveySelect;
const checkTriggersValidity = (triggers: TSurvey["triggers"], actionClasses: TActionClass[]) => {
@@ -391,6 +392,7 @@ export const getInProgressSurveyCount = reactCache(
export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> => {
validateInputs([updatedSurvey, ZSurvey]);
try {
const surveyId = updatedSurvey.id;
let data: any = {};
@@ -402,7 +404,8 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
throw new ResourceNotFoundError("Survey", surveyId);
}
const { triggers, environmentId, segment, questions, languages, type, ...surveyData } = updatedSurvey;
const { triggers, environmentId, segment, questions, languages, type, followUps, ...surveyData } =
updatedSurvey;
if (languages) {
// Process languages update logic here
@@ -545,6 +548,53 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
}
}
if (followUps) {
// Separate follow-ups into categories based on deletion flag
const deletedFollowUps = followUps.filter((followUp) => followUp.deleted);
const nonDeletedFollowUps = followUps.filter((followUp) => !followUp.deleted);
// Get set of existing follow-up IDs from currentSurvey
const existingFollowUpIds = new Set(currentSurvey.followUps.map((f) => f.id));
// Separate non-deleted follow-ups into new and existing
const existingFollowUps = nonDeletedFollowUps.filter((followUp) =>
existingFollowUpIds.has(followUp.id)
);
const newFollowUps = nonDeletedFollowUps.filter((followUp) => !existingFollowUpIds.has(followUp.id));
data.followUps = {
// Update existing follow-ups
updateMany: existingFollowUps.map((followUp) => ({
where: {
id: followUp.id,
},
data: {
name: followUp.name,
trigger: followUp.trigger,
action: followUp.action,
},
})),
// Create new follow-ups
createMany:
newFollowUps.length > 0
? {
data: newFollowUps.map((followUp) => ({
name: followUp.name,
trigger: followUp.trigger,
action: followUp.action,
})),
}
: undefined,
// Delete follow-ups marked as deleted, regardless of whether they exist in DB
deleteMany:
deletedFollowUps.length > 0
? deletedFollowUps.map((followUp) => ({
id: followUp.id,
}))
: undefined,
};
}
data.questions = questions.map((question) => {
const { isDraft, ...rest } = question;
return rest;
@@ -812,6 +862,19 @@ export const createSurvey = async (
}
}
// Survey follow-ups
if (restSurveyBody.followUps?.length) {
data.followUps = {
create: restSurveyBody.followUps.map((followUp) => ({
name: followUp.name,
trigger: followUp.trigger,
action: followUp.action,
})),
};
} else {
delete data.followUps;
}
const survey = await prisma.survey.create({
data: {
...data,
@@ -1038,6 +1101,15 @@ export const copySurveyToOtherEnvironment = async (
: Prisma.JsonNull,
styling: existingSurvey.styling ? structuredClone(existingSurvey.styling) : Prisma.JsonNull,
segment: undefined,
followUps: {
createMany: {
data: existingSurvey.followUps.map((followUp) => ({
name: followUp.name,
trigger: followUp.trigger,
action: followUp.action,
})),
},
},
};
// Handle segment
@@ -251,6 +251,7 @@ export const mockSurveyOutput: SurveyMock = {
resultShareKey: null,
inlineTriggers: null,
languages: mockSurveyLanguages,
followUps: [],
...baseSurveyProperties,
};
@@ -277,6 +278,7 @@ export const updateSurveyInput: TSurvey = {
languages: [],
showLanguageSwitch: null,
variables: [],
followUps: [],
...commonMockProperties,
...baseSurveyProperties,
};
@@ -286,6 +286,10 @@ export const Survey = ({
nextQuestionId === undefined ||
!localSurvey.questions.map((question) => question.id).includes(nextQuestionId);
const endingId = nextQuestionId
? localSurvey.endings.find((ending) => ending.id === nextQuestionId)?.id
: undefined;
onChange(responseData);
onChangeVariables(calculatedVariables);
onResponse({
@@ -294,6 +298,7 @@ export const Survey = ({
finished,
variables: calculatedVariables,
language: selectedLanguage,
endingId,
});
if (finished) {
// Post a message to the parent window indicating that the survey is completed.
+2 -1
View File
@@ -9,7 +9,8 @@
"clean": "rimraf node_modules .turbo"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*"
"@formbricks/config-typescript": "workspace:*",
"@formbricks/database": "workspace:*"
},
"dependencies": {
"zod": "3.23.8"
+4
View File
@@ -259,6 +259,7 @@ export const ZResponse = z.object({
person: ZResponsePerson.nullable(),
personAttributes: ZResponsePersonAttributes,
finished: z.boolean(),
endingId: z.string().nullish(),
data: ZResponseData,
variables: ZResponseVariables,
ttc: ZResponseTtc.optional(),
@@ -280,6 +281,7 @@ export const ZResponseInput = z.object({
displayId: z.string().nullish(),
singleUseId: z.string().nullable().optional(),
finished: z.boolean(),
endingId: z.string().nullish(),
language: z.string().optional(),
data: ZResponseData,
variables: ZResponseVariables.optional(),
@@ -305,6 +307,7 @@ export type TResponseInput = z.infer<typeof ZResponseInput>;
export const ZResponseUpdateInput = z.object({
finished: z.boolean(),
endingId: z.string().nullish(),
data: ZResponseData,
variables: ZResponseVariables.optional(),
ttc: ZResponseTtc.optional(),
@@ -337,6 +340,7 @@ export const ZResponseUpdate = z.object({
.optional(),
hiddenFields: ZResponseHiddenFieldValue.optional(),
displayId: z.string().nullish(),
endingId: z.string().nullish(),
});
export type TResponseUpdate = z.infer<typeof ZResponseUpdate>;
+68 -2
View File
@@ -1,4 +1,5 @@
import { type ZodIssue, z } from "zod";
import { ZSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
import { ZActionClass, ZActionClassNoCodeConfig } from "../action-classes";
import { ZAttributes } from "../attributes";
import { ZAllowedFileExtension, ZColor, ZId, ZPlacement } from "../common";
@@ -769,6 +770,11 @@ export const ZSurvey = z
});
}
}),
followUps: z.array(
ZSurveyFollowUp.extend({
deleted: z.boolean().optional(),
})
),
delay: z.number(),
autoComplete: z.number().min(1, { message: "Response limit must be greater than 0" }).nullable(),
runOnDate: z.date().nullable(),
@@ -1156,6 +1162,52 @@ export const ZSurvey = z
}
}
});
if (survey.followUps.length) {
survey.followUps
.filter((followUp) => !followUp.deleted)
.forEach((followUp, index) => {
if (followUp.action.properties.to) {
const validOptions = [
...survey.questions
.filter((q) => {
if (q.type === TSurveyQuestionTypeEnum.OpenText) {
if (q.inputType === "email") {
return true;
}
}
if (q.type === TSurveyQuestionTypeEnum.ContactInfo) {
return true;
}
return false;
})
.map((q) => q.id),
...(survey.hiddenFields.fieldIds ?? []),
];
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"],
});
}
if (followUp.trigger.type === "endings") {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- endingIds is always defined
if (!followUp.trigger.properties?.endingIds?.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `The trigger in follow up ${String(index + 1)} has no ending selected`,
path: ["followUps"],
});
}
}
}
});
}
});
const isInvalidOperatorsForQuestionType = (
@@ -2111,7 +2163,19 @@ const validateLogic = (survey: TSurvey, questionIndex: number, logic: TSurveyLog
// ZSurvey is a refinement, so to extend it to ZSurveyUpdateInput, we need to transform the innerType and then apply the same refinements.
export const ZSurveyUpdateInput = ZSurvey.innerType()
.omit({ createdAt: true, updatedAt: true })
.omit({ createdAt: true, updatedAt: true, followUps: true })
.extend({
followUps: z
.array(
ZSurveyFollowUp.omit({ createdAt: true, updatedAt: true }).and(
z.object({
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
})
)
)
.default([]),
})
.and(
z.object({
createdAt: z.coerce.date(),
@@ -2136,6 +2200,7 @@ export const ZSurveyCreateInput = makeSchemaOptional(ZSurvey.innerType())
updatedAt: true,
productOverwrites: true,
languages: true,
followUps: true,
})
.extend({
name: z.string(), // Keep name required
@@ -2146,6 +2211,7 @@ export const ZSurveyCreateInput = makeSchemaOptional(ZSurvey.innerType())
}),
endings: ZSurveyEndings.default([]),
type: ZSurveyType.default("link"),
followUps: z.array(ZSurveyFollowUp.omit({ createdAt: true, updatedAt: true })).default([]),
})
.superRefine(ZSurvey._def.effect.type === "refinement" ? ZSurvey._def.effect.refinement : () => null);
@@ -2160,7 +2226,7 @@ export interface TSurveyDates {
export type TSurveyCreateInput = z.input<typeof ZSurveyCreateInput>;
export type TSurveyEditorTabs = "questions" | "settings" | "styling";
export type TSurveyEditorTabs = "questions" | "settings" | "styling" | "followUps";
export const ZSurveyQuestionSummaryOpenText = z.object({
type: z.literal("openText"),
@@ -1,3 +1,4 @@
import { useTranslations } from "next-intl";
import React from "react";
import { Button } from "../Button";
import { Modal } from "../Modal";
@@ -29,6 +30,7 @@ export const ConfirmationModal = ({
closeOnOutsideClick = true,
hideCloseButton,
}: ConfirmationModalProps) => {
const t = useTranslations();
const handleButtonAction = async () => {
if (isButtonDisabled) return;
await onConfirm();
@@ -47,7 +49,7 @@ export const ConfirmationModal = ({
<div className="mt-4 space-x-2 text-right">
<Button variant="minimal" onClick={() => setOpen(false)}>
Cancel
{t("common.cancel")}
</Button>
<Button
loading={buttonLoading}
@@ -11,13 +11,14 @@ import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPl
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
import { HeadingNode, QuoteNode } from "@lexical/rich-text";
import { TableCellNode, TableNode, TableRowNode } from "@lexical/table";
import type { Dispatch, SetStateAction } from "react";
import { type Dispatch, type SetStateAction, useRef } from "react";
import { cn } from "@formbricks/lib/cn";
import { PlaygroundAutoLinkPlugin as AutoLinkPlugin } from "../components/AutoLinkPlugin";
import { ToolbarPlugin } from "../components/ToolbarPlugin";
import { exampleTheme } from "../lib/ExampleTheme";
import "../stylesEditor.css";
import "../stylesEditorFrontend.css";
import { PlaygroundAutoLinkPlugin as AutoLinkPlugin } from "./AutoLinkPlugin";
import { EditorContentChecker } from "./EditorContentChecker";
import { ToolbarPlugin } from "./ToolbarPlugin";
/*
Detault toolbar items:
@@ -38,6 +39,8 @@ export type TextEditorProps = {
firstRender?: boolean;
setFirstRender?: Dispatch<SetStateAction<boolean>>;
editable?: boolean;
onEmptyChange?: (isEmpty: boolean) => void;
isInvalid?: boolean;
};
const editorConfig = {
@@ -63,11 +66,14 @@ const editorConfig = {
export const Editor = (props: TextEditorProps) => {
const editable = props.editable ?? true;
const editorContainerRef = useRef<HTMLDivElement>(null);
return (
<div className="editor cursor-text rounded-md">
<LexicalComposer initialConfig={{ ...editorConfig, editable }}>
<div className="editor-container rounded-md p-0">
<div
ref={editorContainerRef}
className={cn("editor-container rounded-md p-0", props.isInvalid && "!border !border-red-500")}>
<ToolbarPlugin
getText={props.getText}
setText={props.setText}
@@ -77,14 +83,16 @@ export const Editor = (props: TextEditorProps) => {
updateTemplate={props.updateTemplate}
firstRender={props.firstRender}
setFirstRender={props.setFirstRender}
container={editorContainerRef.current}
/>
{props.onEmptyChange ? <EditorContentChecker onEmptyChange={props.onEmptyChange} /> : null}
<div
className={cn("editor-inner scroll-bar", !editable && "bg-muted")}
style={{ height: props.height }}>
<RichTextPlugin
contentEditable={<ContentEditable style={{ height: props.height }} className="editor-input" />}
placeholder={
<div className="text-muted -mt-11 cursor-text p-3 text-sm">{props.placeholder || ""}</div>
<div className="-mt-11 cursor-text p-3 text-sm text-slate-400">{props.placeholder || ""}</div>
}
ErrorBoundary={LexicalErrorBoundary}
/>
@@ -0,0 +1,25 @@
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { $getRoot } from "lexical";
import { useEffect } from "react";
export const EditorContentChecker = ({ onEmptyChange }: { onEmptyChange: (isEmpty: boolean) => void }) => {
const [editor] = useLexicalComposerContext();
useEffect(() => {
const checkIfEmpty = () => {
editor.update(() => {
const root = $getRoot();
const isEmpty = root.getChildren().length === 0 || root.getTextContent().trim() === "";
onEmptyChange(isEmpty);
});
};
// Check initially and subscribe to editor updates
checkIfEmpty();
const unregister = editor.registerUpdateListener(() => checkIfEmpty());
return () => unregister();
}, [editor, onEmptyChange]);
return null;
};
@@ -222,7 +222,7 @@ const getSelectedNode = (selection: RangeSelection) => {
}
};
export const ToolbarPlugin = (props: TextEditorProps) => {
export const ToolbarPlugin = (props: TextEditorProps & { container: HTMLElement | null }) => {
const [editor] = useLexicalComposerContext();
const toolbarRef = useRef(null);
@@ -450,6 +450,7 @@ export const ToolbarPlugin = (props: TextEditorProps) => {
}, [editor]);
if (!props.editable) return <></>;
return (
<div className="toolbar flex" ref={toolbarRef}>
<>
@@ -525,7 +526,8 @@ export const ToolbarPlugin = (props: TextEditorProps) => {
onClick={insertLink}
className={isLink ? "bg-subtle active-button" : "inactive-button"}
/>
{isLink && createPortal(<FloatingLinkEditor editor={editor} />, document.body)}{" "}
{isLink &&
createPortal(<FloatingLinkEditor editor={editor} />, props.container ?? document.body)}{" "}
</>
)}
</>
@@ -215,8 +215,8 @@ pre::-webkit-scrollbar-thumb {
.link-editor {
position: absolute;
z-index: 100;
top: -10000px;
left: -10000px;
top: 10px !important;
left: 10px !important;
margin-left: 80px;
margin-top: -6px;
max-width: 300px;
+8 -1
View File
@@ -84,7 +84,14 @@ const FormLabel = React.forwardRef<
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return <Label ref={ref} className={cn(error && "text-error", className)} htmlFor={formItemId} {...props} />;
return (
<Label
ref={ref}
className={cn(error ? "text-red-500" : "text-slate-800", className)}
htmlFor={formItemId}
{...props}
/>
);
});
FormLabel.displayName = "FormLabel";
@@ -0,0 +1,9 @@
import { CrownIcon } from "lucide-react";
export const ProBadge = () => {
return (
<div className="ml-2 flex items-center justify-center rounded-lg border border-slate-200 bg-slate-100 p-0.5 text-slate-500">
<CrownIcon className="h-3 w-3" />
</div>
);
};