From 5acdf018d36df58b46837a98a0e6af5fbc640c25 Mon Sep 17 00:00:00 2001 From: Sebastian Goscinski Date: Tue, 27 Feb 2024 11:53:00 +0100 Subject: [PATCH 01/11] feat: custom S3 endpoint to use third party storage services (#2133) Co-authored-by: Matthias Nannt --- .env.example | 13 +++++++++++++ packages/lib/constants.ts | 1 + packages/lib/env.ts | 2 ++ packages/lib/storage/service.ts | 29 ++++++++++++++++++++++------- turbo.json | 1 + 5 files changed, 39 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 2f722a67d2..e67e91976f 100644 --- a/.env.example +++ b/.env.example @@ -56,6 +56,19 @@ SMTP_PASSWORD=smtpPassword # Uncomment the variables you would like to use and customize the values. +############## +# S3 STORAGE # +############## + +# S3 Storage is required for the file uplaod in serverless environments like Vercel +S3_ACCESS_KEY= +S3_SECRET_KEY= +S3_REGION= +S3_BUCKET_NAME= +# Configure a third party S3 compatible storage service endpoint like StorJ leave empty if you use Amazon S3 +# e.g., https://gateway.storjshare.io +S3_ENDPOINT_URL= + ##################### # Disable Features # ##################### diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 96810611d2..c19a465076 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -87,6 +87,7 @@ export const ONBOARDING_DISABLED = env.ONBOARDING_DISABLED; export const S3_ACCESS_KEY = env.S3_ACCESS_KEY; export const S3_SECRET_KEY = env.S3_SECRET_KEY; export const S3_REGION = env.S3_REGION; +export const S3_ENDPOINT_URL = env.S3_ENDPOINT_URL; export const S3_BUCKET_NAME = env.S3_BUCKET_NAME; export const UPLOADS_DIR = "./uploads"; export const MAX_SIZES = { diff --git a/packages/lib/env.ts b/packages/lib/env.ts index 00d1e7107a..04e3a3a0e5 100644 --- a/packages/lib/env.ts +++ b/packages/lib/env.ts @@ -61,6 +61,7 @@ export const env = createEnv({ S3_BUCKET_NAME: z.string().optional(), S3_REGION: z.string().optional(), S3_SECRET_KEY: z.string().optional(), + S3_ENDPOINT_URL: z.string().optional(), SHORT_URL_BASE: z.string().url().optional().or(z.string().length(0)), SIGNUP_DISABLED: z.enum(["1", "0"]).optional(), SMTP_HOST: z.string().min(1).optional(), @@ -156,6 +157,7 @@ export const env = createEnv({ S3_BUCKET_NAME: process.env.S3_BUCKET_NAME, S3_REGION: process.env.S3_REGION, S3_SECRET_KEY: process.env.S3_SECRET_KEY, + S3_ENDPOINT_URL: process.env.S3_ENDPOINT_URL, SHORT_URL_BASE: process.env.SHORT_URL_BASE, SIGNUP_DISABLED: process.env.SIGNUP_DISABLED, SMTP_HOST: process.env.SMTP_HOST, diff --git a/packages/lib/storage/service.ts b/packages/lib/storage/service.ts index 97cebcdfb9..317f514395 100644 --- a/packages/lib/storage/service.ts +++ b/packages/lib/storage/service.ts @@ -20,8 +20,11 @@ import { TAccessType } from "@formbricks/types/storage"; import { IS_S3_CONFIGURED, MAX_SIZES, + S3_ACCESS_KEY, S3_BUCKET_NAME, + S3_ENDPOINT_URL, S3_REGION, + S3_SECRET_KEY, UPLOADS_DIR, WEBAPP_URL, } from "../constants"; @@ -30,14 +33,21 @@ import { env } from "../env"; import { storageCache } from "./cache"; // S3Client Singleton +let s3ClientInstance: S3Client | null = null; -export const s3Client = new S3Client({ - credentials: { - accessKeyId: env.S3_ACCESS_KEY!, - secretAccessKey: env.S3_SECRET_KEY!, - }, - region: S3_REGION!, -}); +export const getS3Client = () => { + if (!s3ClientInstance) { + s3ClientInstance = new S3Client({ + credentials: { + accessKeyId: S3_ACCESS_KEY!, + secretAccessKey: S3_SECRET_KEY!, + }, + region: S3_REGION, + endpoint: S3_ENDPOINT_URL, + }); + } + return s3ClientInstance; +}; const ensureDirectoryExists = async (dirPath: string) => { try { @@ -85,6 +95,7 @@ const getS3SignedUrl = async (fileKey: string): Promise => { }); try { + const s3Client = getS3Client(); return await getSignedUrl(s3Client, getObjectCommand, { expiresIn }); } catch (err) { throw err; @@ -239,6 +250,7 @@ export const getS3UploadSignedUrl = async ( const postConditions: PresignedPostOptions["Conditions"] = [["content-length-range", 0, maxSize]]; try { + const s3Client = getS3Client(); const { fields, url } = await createPresignedPost(s3Client, { Expires: 10 * 60, // 10 minutes Bucket: env.S3_BUCKET_NAME!, @@ -309,6 +321,7 @@ export const putFile = async ( }; const command = new PutObjectCommand(input); + const s3Client = getS3Client(); await s3Client.send(command); return { success: true, message: "File uploaded" }; } @@ -358,6 +371,7 @@ export const deleteS3File = async (fileKey: string) => { }); try { + const s3Client = getS3Client(); await s3Client.send(deleteObjectCommand); } catch (err) { throw err; @@ -367,6 +381,7 @@ export const deleteS3File = async (fileKey: string) => { export const deleteS3FilesByEnvironmentId = async (environmentId: string) => { try { // List all objects in the bucket with the prefix of environmentId + const s3Client = getS3Client(); const listObjectsOutput = await s3Client.send( new ListObjectsCommand({ Bucket: S3_BUCKET_NAME, diff --git a/turbo.json b/turbo.json index 51cfba1ce9..ab63374c6a 100644 --- a/turbo.json +++ b/turbo.json @@ -119,6 +119,7 @@ "S3_SECRET_KEY", "S3_REGION", "S3_BUCKET_NAME", + "S3_ENDPOINT_URL", "SENTRY_DSN", "SHORT_URL_BASE", "SIGNUP_DISABLED", From 21787f2af53cbcc93780222d17a56f2c5cac352a Mon Sep 17 00:00:00 2001 From: Shubham Palriwala Date: Tue, 27 Feb 2024 16:36:01 +0530 Subject: [PATCH 02/11] fix: react console errors & warnings in dashboard & survey editor (#2113) Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com> Co-authored-by: Johannes Co-authored-by: Matti Nannt --- .../surveys/components/Modal.tsx | 2 +- .../surveys/components/PreviewSurvey.tsx | 7 +- packages/ui/Input/index.tsx | 4 +- packages/ui/QuestionFormInput/index.tsx | 557 +++++++++--------- packages/ui/SurveysList/index.tsx | 10 +- 5 files changed, 296 insertions(+), 284 deletions(-) diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/components/Modal.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/components/Modal.tsx index 7f66533c6e..11f5374014 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/components/Modal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/components/Modal.tsx @@ -52,7 +52,7 @@ export default function Modal({ return { transform: `scale(${scaleValue})`, - "transform-origin": placementClass, + transformOrigin: placementClass, }; }; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/components/PreviewSurvey.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/components/PreviewSurvey.tsx index aba7be092f..06c2b98698 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/components/PreviewSurvey.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/components/PreviewSurvey.tsx @@ -241,8 +241,9 @@ export default function PreviewSurvey({
-

- {previewType === "modal" ? "Your web app" : "Preview"} +

+

{previewType === "modal" ? "Your web app" : "Preview"}

+
{isFullScreenPreview ? (
-

+
{previewType === "modal" ? ( diff --git a/packages/ui/Input/index.tsx b/packages/ui/Input/index.tsx index 62d08787ea..58d0170ad3 100644 --- a/packages/ui/Input/index.tsx +++ b/packages/ui/Input/index.tsx @@ -11,13 +11,13 @@ export interface InputProps isInvalid?: boolean; } -const Input = React.forwardRef(({ className, ...props }, ref) => { +const Input = React.forwardRef(({ className, isInvalid, ...props }, ref) => { return ( ; } -const QuestionFormInput = ({ - localSurvey, - questionId, - questionIdx, - updateQuestion, - updateSurvey, - isInvalid, - environmentId, - type, -}: QuestionFormInputProps) => { - const isThankYouCard = questionId === "end"; - const question = useMemo(() => { - return isThankYouCard - ? localSurvey.thankYouCard - : localSurvey.questions.find((question) => question.id === questionId)!; - }, [isThankYouCard, localSurvey, questionId]); +const QuestionFormInput = React.forwardRef( + ( + { + localSurvey, + questionId, + questionIdx, + updateQuestion, + updateSurvey, + isInvalid, + environmentId, + type, + }: QuestionFormInputProps, + ref + ) => { + const isThankYouCard = questionId === "end"; + const question = useMemo(() => { + return isThankYouCard + ? localSurvey.thankYouCard + : localSurvey.questions.find((question) => question.id === questionId)!; + }, [isThankYouCard, localSurvey, questionId]); - const getQuestionTextBasedOnType = (): string => { - return question[type as keyof typeof question] || ""; - }; + const getQuestionTextBasedOnType = (): string => { + return question[type as keyof typeof question] || ""; + }; - const [text, setText] = useState(getQuestionTextBasedOnType() ?? ""); - const [renderedText, setRenderedText] = useState(); + const [text, setText] = useState(getQuestionTextBasedOnType() ?? ""); + const [renderedText, setRenderedText] = useState(); - const highlightContainerRef = useRef(null); - const fallbackInputRef = useRef(null); - const inputRef = useRef(null); - const [showImageUploader, setShowImageUploader] = useState( - questionId === "end" - ? localSurvey.thankYouCard.imageUrl - ? true - : false - : !!(question as TSurveyQuestion).imageUrl - ); - const [showQuestionSelect, setShowQuestionSelect] = useState(false); - const [showFallbackInput, setShowFallbackInput] = useState(false); - const [recallQuestions, setRecallQuestions] = useState( - text.includes("#recall:") ? getRecallQuestions(text, localSurvey) : [] - ); - const filteredRecallQuestions = Array.from(new Set(recallQuestions.map((q) => q.id))).map((id) => { - return recallQuestions.find((q) => q.id === id); - }); - const [fallbacks, setFallbacks] = useState<{ [type: string]: string }>( - text.includes("/fallback:") ? getFallbackValues(text) : {} - ); - - // Hook to synchronize the horizontal scroll position of highlightContainerRef and inputRef. - useSyncScroll(highlightContainerRef, inputRef, text); - - useEffect(() => { - // Generates an array of headlines from recallQuestions, replacing nested recall questions with '___' . - const recallQuestionHeadlines = recallQuestions.flatMap((recallQuestion) => { - if (!recallQuestion.headline.includes("#recall:")) { - return [recallQuestion.headline]; - } - const recallQuestionText = (recallQuestion[type as keyof typeof recallQuestion] as string) || ""; - const recallInfo = extractRecallInfo(recallQuestionText); - - if (recallInfo) { - const recallQuestionId = extractId(recallInfo); - const recallQuestion = localSurvey.questions.find((question) => question.id === recallQuestionId); - - if (recallQuestion) { - return [recallQuestionText.replace(recallInfo, `___`)]; - } - } - return []; + const highlightContainerRef = useRef(null); + const fallbackInputRef = useRef(null); + const inputRef = useRef(null); + const [showImageUploader, setShowImageUploader] = useState( + questionId === "end" + ? localSurvey.thankYouCard.imageUrl + ? true + : false + : !!(question as TSurveyQuestion).imageUrl + ); + const [showQuestionSelect, setShowQuestionSelect] = useState(false); + const [showFallbackInput, setShowFallbackInput] = useState(false); + const [recallQuestions, setRecallQuestions] = useState( + text.includes("#recall:") ? getRecallQuestions(text, localSurvey) : [] + ); + const filteredRecallQuestions = Array.from(new Set(recallQuestions.map((q) => q.id))).map((id) => { + return recallQuestions.find((q) => q.id === id); }); + const [fallbacks, setFallbacks] = useState<{ [type: string]: string }>( + text.includes("/fallback:") ? getFallbackValues(text) : {} + ); - // Constructs an array of JSX elements representing segmented parts of text, interspersed with special formatted spans for recall headlines. - const processInput = (): JSX.Element[] => { - const parts: JSX.Element[] = []; - let remainingText: string = text ?? ""; - remainingText = recallToHeadline(remainingText, localSurvey, false); - filterRecallQuestions(remainingText); - recallQuestionHeadlines.forEach((headline) => { - const index = remainingText.indexOf("@" + headline); - if (index !== -1) { - if (index > 0) { + // Hook to synchronize the horizontal scroll position of highlightContainerRef and inputRef. + useSyncScroll(highlightContainerRef, inputRef, text); + + useEffect(() => { + // Generates an array of headlines from recallQuestions, replacing nested recall questions with '___' . + const recallQuestionHeadlines = recallQuestions.flatMap((recallQuestion) => { + if (!recallQuestion.headline.includes("#recall:")) { + return [recallQuestion.headline]; + } + const recallQuestionText = (recallQuestion[type as keyof typeof recallQuestion] as string) || ""; + const recallInfo = extractRecallInfo(recallQuestionText); + + if (recallInfo) { + const recallQuestionId = extractId(recallInfo); + const recallQuestion = localSurvey.questions.find((question) => question.id === recallQuestionId); + + if (recallQuestion) { + return [recallQuestionText.replace(recallInfo, `___`)]; + } + } + return []; + }); + + // Constructs an array of JSX elements representing segmented parts of text, interspersed with special formatted spans for recall headlines. + const processInput = (): JSX.Element[] => { + const parts: JSX.Element[] = []; + let remainingText: string = text ?? ""; + remainingText = recallToHeadline(remainingText, localSurvey, false); + filterRecallQuestions(remainingText); + recallQuestionHeadlines.forEach((headline) => { + const index = remainingText.indexOf("@" + headline); + if (index !== -1) { + if (index > 0) { + parts.push( + + {remainingText.substring(0, index)} + + ); + } parts.push( - - {remainingText.substring(0, index)} + + {"@" + headline} ); + remainingText = remainingText.substring(index + headline.length + 1); } + }); + if (remainingText.length) { parts.push( - - {"@" + headline} + + {remainingText} ); - remainingText = remainingText.substring(index + headline.length + 1); + } + return parts; + }; + setRenderedText(processInput()); + }, [text]); + + useEffect(() => { + if (fallbackInputRef.current) { + fallbackInputRef.current.focus(); + } + }, [showFallbackInput]); + + const checkForRecallSymbol = () => { + const pattern = /(^|\s)@(\s|$)/; + if (pattern.test(text)) { + setShowQuestionSelect(true); + } else { + setShowQuestionSelect(false); + } + }; + + // Adds a new recall question to the recallQuestions array, updates fallbacks, modifies the text with recall details. + const addRecallQuestion = (recallQuestion: TSurveyQuestion) => { + let recallQuestionTemp = { ...recallQuestion }; + recallQuestionTemp = replaceRecallInfoWithUnderline(recallQuestionTemp); + setRecallQuestions((prevQuestions) => { + const updatedQuestions = [...prevQuestions, recallQuestionTemp]; + return updatedQuestions; + }); + if (!Object.keys(fallbacks).includes(recallQuestion.id)) { + setFallbacks((prevFallbacks) => ({ + ...prevFallbacks, + [recallQuestion.id]: "", + })); + } + setShowQuestionSelect(false); + const modifiedHeadlineWithId = getQuestionTextBasedOnType().replace( + "@", + `#recall:${recallQuestion.id}/fallback:# ` + ); + updateQuestionDetails(modifiedHeadlineWithId); + + const modifiedHeadlineWithName = recallToHeadline(modifiedHeadlineWithId, localSurvey, false); + setText(modifiedHeadlineWithName); + setShowFallbackInput(true); + }; + + // Filters and updates the list of recall questions based on their presence in the given text, also managing related text and fallback states. + const filterRecallQuestions = (text: string) => { + let includedQuestions: TSurveyQuestion[] = []; + recallQuestions.forEach((recallQuestion) => { + if (text.includes(`@${recallQuestion.headline}`)) { + includedQuestions.push(recallQuestion); + } else { + const questionToRemove = recallQuestion.headline.slice(0, -1); + const newText = text.replace(`@${questionToRemove}`, ""); + setText(newText); + updateQuestionDetails(newText); + let updatedFallback = { ...fallbacks }; + delete updatedFallback[recallQuestion.id]; + setFallbacks(updatedFallback); } }); - if (remainingText.length) { - parts.push( - - {remainingText} - - ); - } - return parts; + setRecallQuestions(includedQuestions); }; - setRenderedText(processInput()); - }, [text]); - useEffect(() => { - if (fallbackInputRef.current) { - fallbackInputRef.current.focus(); - } - }, [showFallbackInput]); + const addFallback = () => { + let headlineWithFallback = getQuestionTextBasedOnType(); + filteredRecallQuestions.forEach((recallQuestion) => { + if (recallQuestion) { + const recallInfo = findRecallInfoById(getQuestionTextBasedOnType(), recallQuestion!.id); + if (recallInfo) { + let fallBackValue = fallbacks[recallQuestion.id].trim(); + fallBackValue = fallBackValue.replace(/ /g, "nbsp"); + let updatedFallback = { ...fallbacks }; + updatedFallback[recallQuestion.id] = fallBackValue; + setFallbacks(updatedFallback); + headlineWithFallback = headlineWithFallback.replace( + recallInfo, + `#recall:${recallQuestion?.id}/fallback:${fallBackValue}#` + ); + updateQuestionDetails(headlineWithFallback); + } + } + }); + setShowFallbackInput(false); + inputRef.current?.focus(); + }; - const checkForRecallSymbol = () => { - const pattern = /(^|\s)@(\s|$)/; - if (pattern.test(text)) { - setShowQuestionSelect(true); - } else { - setShowQuestionSelect(false); - } - }; + useEffect(() => { + checkForRecallSymbol(); + }, [text]); - // Adds a new recall question to the recallQuestions array, updates fallbacks, modifies the text with recall details. - const addRecallQuestion = (recallQuestion: TSurveyQuestion) => { - let recallQuestionTemp = { ...recallQuestion }; - recallQuestionTemp = replaceRecallInfoWithUnderline(recallQuestionTemp); - setRecallQuestions((prevQuestions) => { - const updatedQuestions = [...prevQuestions, recallQuestionTemp]; - return updatedQuestions; - }); - if (!Object.keys(fallbacks).includes(recallQuestion.id)) { - setFallbacks((prevFallbacks) => ({ - ...prevFallbacks, - [recallQuestion.id]: "", - })); - } - setShowQuestionSelect(false); - const modifiedHeadlineWithId = getQuestionTextBasedOnType().replace( - "@", - `#recall:${recallQuestion.id}/fallback:# ` - ); - updateQuestionDetails(modifiedHeadlineWithId); - - const modifiedHeadlineWithName = recallToHeadline(modifiedHeadlineWithId, localSurvey, false); - setText(modifiedHeadlineWithName); - setShowFallbackInput(true); - }; - - // Filters and updates the list of recall questions based on their presence in the given text, also managing related text and fallback states. - const filterRecallQuestions = (text: string) => { - let includedQuestions: TSurveyQuestion[] = []; - recallQuestions.forEach((recallQuestion) => { - if (text.includes(`@${recallQuestion.headline}`)) { - includedQuestions.push(recallQuestion); + // updation of questions and Thank You Card is done in a different manner, so for question we use updateQuestion and for ThankYouCard we use updateSurvey + const updateQuestionDetails = (updatedText: string) => { + if (isThankYouCard) { + if (updateSurvey) { + updateSurvey({ [type]: updatedText }); + } } else { - const questionToRemove = recallQuestion.headline.slice(0, -1); - const newText = text.replace(`@${questionToRemove}`, ""); - setText(newText); - updateQuestionDetails(newText); - let updatedFallback = { ...fallbacks }; - delete updatedFallback[recallQuestion.id]; - setFallbacks(updatedFallback); - } - }); - setRecallQuestions(includedQuestions); - }; - - const addFallback = () => { - let headlineWithFallback = getQuestionTextBasedOnType(); - filteredRecallQuestions.forEach((recallQuestion) => { - if (recallQuestion) { - const recallInfo = findRecallInfoById(getQuestionTextBasedOnType(), recallQuestion!.id); - if (recallInfo) { - let fallBackValue = fallbacks[recallQuestion.id].trim(); - fallBackValue = fallBackValue.replace(/ /g, "nbsp"); - let updatedFallback = { ...fallbacks }; - updatedFallback[recallQuestion.id] = fallBackValue; - setFallbacks(updatedFallback); - headlineWithFallback = headlineWithFallback.replace( - recallInfo, - `#recall:${recallQuestion?.id}/fallback:${fallBackValue}#` - ); - updateQuestionDetails(headlineWithFallback); + if (updateQuestion) { + updateQuestion(questionIdx, { + [type]: updatedText, + }); } } - }); - setShowFallbackInput(false); - inputRef.current?.focus(); - }; + }; - useEffect(() => { - checkForRecallSymbol(); - }, [text]); - - // updation of questions and Thank You Card is done in a different manner, so for question we use updateQuestion and for ThankYouCard we use updateSurvey - const updateQuestionDetails = (updatedText: string) => { - if (isThankYouCard) { - if (updateSurvey) { - updateSurvey({ [type]: updatedText }); - } - } else { - if (updateQuestion) { - updateQuestion(questionIdx, { - [type]: updatedText, - }); - } - } - }; - - return ( -
- -
- {showImageUploader && type === "headline" && ( - { - if (isThankYouCard && updateSurvey && url) { - updateSurvey({ imageUrl: url[0] }); - } else if (updateQuestion && url) { - updateQuestion(questionIdx, { imageUrl: url[0] }); - } - }} - fileUrl={ - isThankYouCard ? localSurvey.thankYouCard.imageUrl : (question as TSurveyQuestion).imageUrl - } - /> - )} -
-
-
-
- {renderedText} -
- {getQuestionTextBasedOnType().includes("recall:") && ( - - )} - { - setText(recallToHeadline(e.target.value ?? "", localSurvey, false)); - updateQuestionDetails(headlineToRecall(e.target.value, recallQuestions, fallbacks)); + return ( +
+ +
+ {showImageUploader && type === "headline" && ( + { + if (isThankYouCard && updateSurvey && url) { + updateSurvey({ imageUrl: url[0] }); + } else if (updateQuestion && url) { + updateQuestion(questionIdx, { imageUrl: url[0] }); + } }} - isInvalid={isInvalid && text.trim() === ""} + fileUrl={ + isThankYouCard ? localSurvey.thankYouCard.imageUrl : (question as TSurveyQuestion).imageUrl + } /> - {!showQuestionSelect && showFallbackInput && recallQuestions.length > 0 && ( - +
+
+
+ {renderedText} +
+ {getQuestionTextBasedOnType().includes("recall:") && ( + + )} + | undefined} + id={type} + name={type} + aria-label={type === "headline" ? "Question" : "Description"} + autoComplete={showQuestionSelect ? "off" : "on"} + value={recallToHeadline(text ?? "", localSurvey, false)} + onChange={(e) => { + setText(recallToHeadline(e.target.value ?? "", localSurvey, false)); + updateQuestionDetails(headlineToRecall(e.target.value, recallQuestions, fallbacks)); + }} + isInvalid={isInvalid && text.trim() === ""} + /> + {!showQuestionSelect && showFallbackInput && recallQuestions.length > 0 && ( + + )} +
+ {type === "headline" && ( + setShowImageUploader((prev) => !prev)} /> )}
- {type === "headline" && ( - setShowImageUploader((prev) => !prev)} - /> - )}
+ {showQuestionSelect && ( + + )}
- {showQuestionSelect && ( - - )} -
- ); -}; + ); + } +); +QuestionFormInput.displayName = "QuestionFormInput"; + export default QuestionFormInput; diff --git a/packages/ui/SurveysList/index.tsx b/packages/ui/SurveysList/index.tsx index a9188ce1dc..01b20fafa2 100644 --- a/packages/ui/SurveysList/index.tsx +++ b/packages/ui/SurveysList/index.tsx @@ -28,12 +28,16 @@ export default function SurveysList({ userId, }: SurveysListProps) { const [filteredSurveys, setFilteredSurveys] = useState(surveys); - // Initialize orientation state from localStorage or default to 'grid' - const [orientation, setOrientation] = useState(() => localStorage.getItem("surveyOrientation") || "grid"); + // Initialize orientation state with a function that checks if window is defined + const [orientation, setOrientation] = useState(() => + typeof window !== "undefined" ? localStorage.getItem("surveyOrientation") || "grid" : "grid" + ); // Save orientation to localStorage useEffect(() => { - localStorage.setItem("surveyOrientation", orientation); + if (typeof window !== "undefined") { + localStorage.setItem("surveyOrientation", orientation); + } }, [orientation]); return ( From 8064e1ecf6918c1e47aeeaa1cb82eb6d64c82592 Mon Sep 17 00:00:00 2001 From: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com> Date: Tue, 27 Feb 2024 17:05:52 +0530 Subject: [PATCH 03/11] =?UTF-8?q?fix:=20Check=20for=20complete=20submissio?= =?UTF-8?q?n=20before=20displaying=20CTA=20on=20Thank=20You=E2=80=A6=20(#1?= =?UTF-8?q?979)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Matthias Nannt --- .../s/[surveyId]/components/LinkSurvey.tsx | 8 +++++ packages/js/src/lib/widget.ts | 8 ++++- packages/lib/responseQueue.ts | 4 +++ .../surveys/src/components/general/Survey.tsx | 15 +++++++- .../src/components/general/SurveyModal.tsx | 2 ++ .../src/components/general/ThankYouCard.tsx | 8 +++-- packages/types/formbricksSurveys.ts | 1 + packages/ui/Survey/index.tsx | 35 ++----------------- 8 files changed, 44 insertions(+), 37 deletions(-) diff --git a/apps/web/app/s/[surveyId]/components/LinkSurvey.tsx b/apps/web/app/s/[surveyId]/components/LinkSurvey.tsx index 6d6136f414..0257c27572 100644 --- a/apps/web/app/s/[surveyId]/components/LinkSurvey.tsx +++ b/apps/web/app/s/[surveyId]/components/LinkSurvey.tsx @@ -18,6 +18,7 @@ import ContentWrapper from "@formbricks/ui/ContentWrapper"; import { SurveyInline } from "@formbricks/ui/Survey"; let setIsError = (_: boolean) => {}; +let setIsResponseSendingFinished = (_: boolean) => {}; interface LinkSurveyProps { survey: TSurvey; @@ -72,6 +73,10 @@ export default function LinkSurvey({ onResponseSendingFailed: () => { setIsError(true); }, + onResponseSendingFinished: () => { + // when response of current question is processed successfully + setIsResponseSendingFinished(true); + }, setSurveyState: setSurveyState, }, surveyState @@ -157,6 +162,9 @@ export default function LinkSurvey({ getSetIsError={(f: (value: boolean) => void) => { setIsError = f; }} + getSetIsResponseSendingFinished={(f: (value: boolean) => void) => { + setIsResponseSendingFinished = f; + }} onRetry={() => { setIsError(false); responseQueue.processQueue(); diff --git a/packages/js/src/lib/widget.ts b/packages/js/src/lib/widget.ts index 054ab65ee4..dd4b47ab78 100644 --- a/packages/js/src/lib/widget.ts +++ b/packages/js/src/lib/widget.ts @@ -16,6 +16,7 @@ const logger = Logger.getInstance(); const errorHandler = ErrorHandler.getInstance(); let surveyRunning = false; let setIsError = (_: boolean) => {}; +let setIsResponseSendingFinished = (_: boolean) => {}; export const renderWidget = async (survey: TSurvey) => { if (surveyRunning) { @@ -40,6 +41,9 @@ export const renderWidget = async (survey: TSurvey) => { onResponseSendingFailed: () => { setIsError(true); }, + onResponseSendingFinished: () => { + setIsResponseSendingFinished(true); + }, }, surveyState ); @@ -51,7 +55,6 @@ export const renderWidget = async (survey: TSurvey) => { const darkOverlay = productOverwrites.darkOverlay ?? product.darkOverlay; const placement = productOverwrites.placement ?? product.placement; const isBrandingEnabled = product.inAppSurveyBranding; - const formbricksSurveys = await loadFormbricksSurveysExternally(); setTimeout(() => { @@ -66,6 +69,9 @@ export const renderWidget = async (survey: TSurvey) => { getSetIsError: (f: (value: boolean) => void) => { setIsError = f; }, + getSetIsResponseSendingFinished: (f: (value: boolean) => void) => { + setIsResponseSendingFinished = f; + }, onDisplay: async () => { const { userId } = config.get(); // if config does not have a person, we store the displays in local storage diff --git a/packages/lib/responseQueue.ts b/packages/lib/responseQueue.ts index ed2b96bb03..f142027d7c 100644 --- a/packages/lib/responseQueue.ts +++ b/packages/lib/responseQueue.ts @@ -9,6 +9,7 @@ interface QueueConfig { environmentId: string; retryAttempts: number; onResponseSendingFailed?: (responseUpdate: TResponseUpdate) => void; + onResponseSendingFinished?: () => void; setSurveyState?: (state: SurveyState) => void; } @@ -68,6 +69,9 @@ export class ResponseQueue { } this.isRequestInProgress = false; } else { + if (responseUpdate.finished && this.config.onResponseSendingFinished) { + this.config.onResponseSendingFinished(); + } this.isRequestInProgress = false; this.processQueue(); // process the next item in the queue if any } diff --git a/packages/surveys/src/components/general/Survey.tsx b/packages/surveys/src/components/general/Survey.tsx index ae752ace7d..e9b207b7bb 100644 --- a/packages/surveys/src/components/general/Survey.tsx +++ b/packages/surveys/src/components/general/Survey.tsx @@ -29,6 +29,7 @@ export function Survey({ isRedirectDisabled = false, prefillResponseData, getSetIsError, + getSetIsResponseSendingFinished, onFileUpload, responseCount, }: SurveyBaseProps) { @@ -36,6 +37,9 @@ export function Survey({ activeQuestionId || (survey.welcomeCard.enabled ? "start" : survey?.questions[0]?.id) ); const [showError, setShowError] = useState(false); + // flag state to store whether response processing has been completed or not + const [isResponseSendingFinished, setIsResponseSendingFinished] = useState(false); + const [loadingElement, setLoadingElement] = useState(false); const [history, setHistory] = useState([]); const [responseData, setResponseData] = useState({}); @@ -85,7 +89,15 @@ export function Survey({ setShowError(value); }); } - }); + }, [getSetIsError]); + + useEffect(() => { + if (getSetIsResponseSendingFinished) { + getSetIsResponseSendingFinished((value: boolean) => { + setIsResponseSendingFinished(value); + }); + } + }, [getSetIsResponseSendingFinished]); let currIdxTemp = currentQuestionIndex; let currQuesTemp = currentQuestion; @@ -216,6 +228,7 @@ export function Survey({ } else if (questionId === "end" && survey.thankYouCard.enabled) { return ( { - if (!buttonLink) return; + if (!buttonLink || !isResponseSendingFinished) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Enter") { window.top?.location.replace(buttonLink); @@ -35,7 +37,7 @@ export default function ThankYouCard({ return () => { window.removeEventListener("keydown", handleKeyDown); }; - }, [buttonLink]); + }, [buttonLink, isResponseSendingFinished]); return (
@@ -66,7 +68,7 @@ export default function ThankYouCard({ - {buttonLabel && ( + {buttonLabel && isResponseSendingFinished && (