mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-08 06:41:45 -05:00
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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -138,5 +138,6 @@ export const getPreviewSurvey = (locale: string) => {
|
||||
languages: [],
|
||||
triggers: [],
|
||||
showLanguageSwitch: false,
|
||||
followUps: [],
|
||||
} as TSurvey;
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user