From c9b8ffa9ef55d8edc85414f191053a5582df5848 Mon Sep 17 00:00:00 2001 From: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com> Date: Mon, 16 Sep 2024 16:01:54 +0530 Subject: [PATCH 01/16] fix: billing tab (#3138) Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com> --- .../(organization)/components/OrganizationSettingsNavbar.tsx | 2 +- .../(analysis)/responses/components/ResponseTableColumns.tsx | 2 +- .../(analysis)/responses/components/ResponseTableHeader.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx index a5cf55893d..2d9c962115 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx @@ -35,7 +35,7 @@ export const OrganizationSettingsNavbar = ({ label: "Billing & Plan", href: `/environments/${environmentId}/settings/billing`, icon: , - hidden: !isFormbricksCloud || !isOwner, + hidden: !isFormbricksCloud || isPricingDisabled, current: pathname?.includes("/billing"), }, { diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.tsx index d40be4c10d..c3416ca0f3 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.tsx @@ -136,7 +136,7 @@ export const generateColumns = ( size: 75, enableResizing: false, header: ({ table }) => ( -
+
table.toggleAllPageRowsSelected(!!value)} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableHeader.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableHeader.tsx index 63afe40b1c..6689675ca0 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableHeader.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableHeader.tsx @@ -46,7 +46,7 @@ export const ResponseTableHeader = ({ header, setIsTableSettingsModalOpen }: Res key={header.id} className="group relative h-10 border border-slate-300 bg-slate-200 px-2 text-center">
-
+
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
From 152fbede909256c313a6b70fda030fb9b9833015 Mon Sep 17 00:00:00 2001 From: RajuGangitla Date: Tue, 17 Sep 2024 13:05:19 +0530 Subject: [PATCH 02/16] feat: info about new formbricks version (#3126) Co-authored-by: Johannes Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com> Co-authored-by: pandeymangg Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com> Co-authored-by: Piyush Gupta --- .../[environmentId]/actions/actions.ts | 33 ++++++++++++++- .../components/MainNavigation.tsx | 41 +++++++++++++++++-- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/actions.ts b/apps/web/app/(app)/environments/[environmentId]/actions/actions.ts index 3ff5965856..3c35933638 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/actions/actions.ts @@ -2,8 +2,9 @@ import { z } from "zod"; import { deleteActionClass, getActionClass, updateActionClass } from "@formbricks/lib/actionClass/service"; -import { authenticatedActionClient } from "@formbricks/lib/actionClient"; +import { actionClient, authenticatedActionClient } from "@formbricks/lib/actionClient"; import { checkAuthorization } from "@formbricks/lib/actionClient/utils"; +import { cache } from "@formbricks/lib/cache"; import { getOrganizationIdFromActionClassId } from "@formbricks/lib/organization/utils"; import { getSurveysByActionClassId } from "@formbricks/lib/survey/service"; import { ZActionClassInput } from "@formbricks/types/action-classes"; @@ -72,3 +73,33 @@ export const getActiveInactiveSurveysAction = authenticatedActionClient }; return response; }); + +const getLatestStableFbRelease = async (): Promise => + cache( + async () => { + try { + const res = await fetch("https://api.github.com/repos/formbricks/formbricks/releases"); + const releases = await res.json(); + + if (Array.isArray(releases)) { + const latestStableReleaseTag = releases.filter((release) => !release.prerelease)?.[0] + ?.tag_name as string; + if (latestStableReleaseTag) { + return latestStableReleaseTag; + } + } + + return null; + } catch (err) { + return null; + } + }, + ["latest-fb-release"], + { + revalidate: 60 * 60 * 24, // 24 hours + } + )(); + +export const getLatestStableFbReleaseAction = actionClient.action(async () => { + return await getLatestStableFbRelease(); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx index 90136ea077..a76dbd38cf 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx @@ -1,5 +1,6 @@ "use client"; +import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[environmentId]/actions/actions"; import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink"; import { formbricksLogout } from "@/app/lib/formbricks"; import FBLogo from "@/images/formbricks-wordmark.svg"; @@ -20,6 +21,7 @@ import { PanelLeftCloseIcon, PanelLeftOpenIcon, PlusIcon, + RocketIcon, UserCircleIcon, UserIcon, UsersIcon, @@ -54,6 +56,7 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@formbricks/ui/DropdownMenu"; +import { version } from "../../../../../package.json"; interface NavigationProps { environment: TEnvironment; @@ -61,9 +64,9 @@ interface NavigationProps { user: TUser; organization: TOrganization; products: TProduct[]; - isFormbricksCloud: boolean; - membershipRole?: TMembershipRole; isMultiOrgEnabled: boolean; + isFormbricksCloud?: boolean; + membershipRole?: TMembershipRole; } export const MainNavigation = ({ @@ -72,9 +75,9 @@ export const MainNavigation = ({ organization, user, products, - isFormbricksCloud, - membershipRole, isMultiOrgEnabled, + isFormbricksCloud = true, + membershipRole, }: NavigationProps) => { const router = useRouter(); const pathname = usePathname(); @@ -84,6 +87,7 @@ export const MainNavigation = ({ const [showCreateOrganizationModal, setShowCreateOrganizationModal] = useState(false); const [isCollapsed, setIsCollapsed] = useState(true); const [isTextVisible, setIsTextVisible] = useState(true); + const [latestVersion, setLatestVersion] = useState(""); const product = products.find((product) => product.id === environment.productId); const { isAdmin, isOwner, isViewer } = getAccessFlags(membershipRole); @@ -248,6 +252,21 @@ export const MainNavigation = ({ }, ]; + useEffect(() => { + async function loadReleases() { + const res = await getLatestStableFbReleaseAction(); + if (res?.data) { + const latestVersionTag = res.data; + const currentVersionTag = `v${version}`; + + if (currentVersionTag !== latestVersionTag) { + setLatestVersion(latestVersionTag); + } + } + } + if (isOwnerOrAdmin) loadReleases(); + }, [isOwnerOrAdmin]); + return ( <> {product && ( @@ -305,8 +324,22 @@ export const MainNavigation = ({ )}
+ {/* Product Switch */}
+ {/* New Version Available */} + {!isCollapsed && isOwnerOrAdmin && latestVersion && !isFormbricksCloud && ( + +

+ + Formbricks {latestVersion} is here. Upgrade now! +

+ + )} + Date: Tue, 17 Sep 2024 10:15:19 +0200 Subject: [PATCH 03/16] feat: Make app surveys scalable (#3024) Co-authored-by: pandeymangg Co-authored-by: Piyush Gupta --- .../client/[environmentId]/actions/route.ts | 11 - .../[environmentId]/people/[userId]/route.ts | 88 -------- .../people/[userId]/set-attribute/route.ts | 89 --------- .../app/environment/lib/environmentState.ts | 131 ++++++++++++ .../[environmentId]/app/environment/route.ts | 59 ++++++ .../app/people/[userId]/lib/personState.ts | 153 ++++++++++++++ .../app/people/[userId]/lib/segments.ts | 55 +++++ .../app/people/[userId]/route.ts | 63 ++++++ .../app/sync/[userId]/route.ts | 37 +--- .../[environmentId]/app/sync/lib/utils.ts | 26 --- .../environment/lib/environmentState.ts | 126 ++++++++++++ .../website/environment/route.ts | 53 +++++ .../[environmentId]/website/sync/route.ts | 175 ---------------- apps/web/playwright/js.spec.ts | 2 +- packages/js-core/src/app/lib/actions.ts | 28 +-- packages/js-core/src/app/lib/attributes.ts | 105 +++++++--- packages/js-core/src/app/lib/config.ts | 79 +++----- .../js-core/src/app/lib/eventListeners.ts | 24 ++- packages/js-core/src/app/lib/initialize.ts | 184 ++++++++++++----- packages/js-core/src/app/lib/noCodeActions.ts | 20 +- packages/js-core/src/app/lib/person.ts | 16 +- packages/js-core/src/app/lib/sync.ts | 127 ------------ packages/js-core/src/app/lib/widget.ts | 105 +++++++--- .../js-core/src/shared/environmentState.ts | 108 ++++++++++ packages/js-core/src/shared/personState.ts | 121 +++++++++++ packages/js-core/src/shared/utils.ts | 92 ++++++++- packages/js-core/src/website/lib/actions.ts | 6 +- packages/js-core/src/website/lib/config.ts | 30 ++- .../js-core/src/website/lib/eventListeners.ts | 17 +- .../js-core/src/website/lib/initialize.ts | 179 ++++++++++++++--- .../js-core/src/website/lib/noCodeActions.ts | 15 +- packages/js-core/src/website/lib/sync.ts | 189 ------------------ packages/js-core/src/website/lib/widget.ts | 103 ++++++---- packages/js/index.html | 2 +- packages/lib/display/cache.ts | 10 +- packages/lib/display/service.ts | 45 ++++- packages/lib/i18n/i18n.mock.ts | 42 ---- packages/lib/i18n/i18n.test.ts | 9 - packages/lib/i18n/reverseTranslation.ts | 46 +---- packages/lib/response/cache.ts | 10 +- packages/lib/response/service.ts | 58 +++++- packages/lib/segment/service.ts | 5 + packages/lib/segment/utils.ts | 47 ----- packages/lib/survey/service.ts | 42 +--- packages/lib/survey/utils.ts | 15 -- packages/lib/surveyState.ts | 4 +- packages/lib/utils/version.ts | 17 -- packages/react-native/package.json | 2 +- packages/react-native/src/lib/attributes.ts | 22 +- packages/react-native/src/lib/config.ts | 93 ++++++++- packages/react-native/src/lib/initialize.ts | 15 +- packages/react-native/src/lib/sync.ts | 129 ++++++++++++ packages/react-native/src/survey-web-view.tsx | 3 +- packages/types/js.ts | 170 ++++++++++------ 54 files changed, 2057 insertions(+), 1345 deletions(-) delete mode 100644 apps/web/app/api/v1/(legacy)/client/[environmentId]/actions/route.ts delete mode 100644 apps/web/app/api/v1/(legacy)/client/[environmentId]/people/[userId]/route.ts delete mode 100644 apps/web/app/api/v1/(legacy)/client/[environmentId]/people/[userId]/set-attribute/route.ts create mode 100644 apps/web/app/api/v1/client/[environmentId]/app/environment/lib/environmentState.ts create mode 100644 apps/web/app/api/v1/client/[environmentId]/app/environment/route.ts create mode 100644 apps/web/app/api/v1/client/[environmentId]/app/people/[userId]/lib/personState.ts create mode 100644 apps/web/app/api/v1/client/[environmentId]/app/people/[userId]/lib/segments.ts create mode 100644 apps/web/app/api/v1/client/[environmentId]/app/people/[userId]/route.ts create mode 100644 apps/web/app/api/v1/client/[environmentId]/website/environment/lib/environmentState.ts create mode 100644 apps/web/app/api/v1/client/[environmentId]/website/environment/route.ts delete mode 100644 apps/web/app/api/v1/client/[environmentId]/website/sync/route.ts delete mode 100644 packages/js-core/src/app/lib/sync.ts create mode 100644 packages/js-core/src/shared/environmentState.ts create mode 100644 packages/js-core/src/shared/personState.ts delete mode 100644 packages/js-core/src/website/lib/sync.ts delete mode 100644 packages/lib/utils/version.ts create mode 100644 packages/react-native/src/lib/sync.ts diff --git a/apps/web/app/api/v1/(legacy)/client/[environmentId]/actions/route.ts b/apps/web/app/api/v1/(legacy)/client/[environmentId]/actions/route.ts deleted file mode 100644 index 5cfa64904b..0000000000 --- a/apps/web/app/api/v1/(legacy)/client/[environmentId]/actions/route.ts +++ /dev/null @@ -1,11 +0,0 @@ -// DEPRECATED -// Storing actions on the server is deprecated and no longer supported. -import { responses } from "@/app/lib/api/response"; - -export const OPTIONS = async (): Promise => { - return responses.successResponse({}, true); -}; - -export const POST = async (): Promise => { - return responses.successResponse({}, true); -}; diff --git a/apps/web/app/api/v1/(legacy)/client/[environmentId]/people/[userId]/route.ts b/apps/web/app/api/v1/(legacy)/client/[environmentId]/people/[userId]/route.ts deleted file mode 100644 index d44fa709ff..0000000000 --- a/apps/web/app/api/v1/(legacy)/client/[environmentId]/people/[userId]/route.ts +++ /dev/null @@ -1,88 +0,0 @@ -// Deprecated since 2024-04-13 -// last supported js version 1.6.5 -import { responses } from "@/app/lib/api/response"; -import { transformErrorToDetails } from "@/app/lib/api/validator"; -import { z } from "zod"; -import { getAttributesByUserId, updateAttributes } from "@formbricks/lib/attribute/service"; -import { createPerson, getPersonByUserId } from "@formbricks/lib/person/service"; -import { ZAttributes } from "@formbricks/types/attributes"; - -interface Context { - params: { - userId: string; - environmentId: string; - }; -} - -export const OPTIONS = async (): Promise => { - return responses.successResponse({}, true); -}; - -export const POST = async (req: Request, context: Context): Promise => { - try { - const { userId, environmentId } = context.params; - const jsonInput = await req.json(); - - // transform all attributes to string if attributes are present - if (jsonInput.attributes) { - for (const key in jsonInput.attributes) { - jsonInput.attributes[key] = String(jsonInput.attributes[key]); - } - } - - // validate using zod - const inputValidation = z.object({ attributes: ZAttributes }).safeParse(jsonInput); - - if (!inputValidation.success) { - return responses.badRequestResponse( - "Fields are missing or incorrectly formatted", - transformErrorToDetails(inputValidation.error), - true - ); - } - - // remove userId from attributes because it is not allowed to be updated - const { userId: userIdAttr, ...updatedAttributes } = inputValidation.data.attributes; - - let person = await getPersonByUserId(environmentId, userId); - - if (!person) { - // return responses.notFoundResponse("PersonByUserId", userId, true); - // HOTFIX: create person if not found to work around caching issue - person = await createPerson(environmentId, userId); - } - - const oldAttributes = await getAttributesByUserId(environmentId, userId); - - let isUpToDate = true; - for (const key in updatedAttributes) { - if (updatedAttributes[key] !== oldAttributes[key]) { - isUpToDate = false; - break; - } - } - - if (isUpToDate) { - return responses.successResponse( - { - changed: false, - message: "No updates were necessary; the person is already up to date.", - }, - true - ); - } - - await updateAttributes(person.id, updatedAttributes); - - return responses.successResponse( - { - changed: true, - message: "The person was successfully updated.", - }, - true - ); - } catch (error) { - console.error(error); - return responses.internalServerErrorResponse(`Unable to complete request: ${error.message}`, true); - } -}; diff --git a/apps/web/app/api/v1/(legacy)/client/[environmentId]/people/[userId]/set-attribute/route.ts b/apps/web/app/api/v1/(legacy)/client/[environmentId]/people/[userId]/set-attribute/route.ts deleted file mode 100644 index dac2e26dd9..0000000000 --- a/apps/web/app/api/v1/(legacy)/client/[environmentId]/people/[userId]/set-attribute/route.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { responses } from "@/app/lib/api/response"; -import { transformErrorToDetails } from "@/app/lib/api/validator"; -import { getActionClasses } from "@formbricks/lib/actionClass/service"; -import { updateAttributes } from "@formbricks/lib/attribute/service"; -import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; -import { personCache } from "@formbricks/lib/person/cache"; -import { getPerson } from "@formbricks/lib/person/service"; -import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; -import { surveyCache } from "@formbricks/lib/survey/cache"; -import { getSyncSurveys } from "@formbricks/lib/survey/service"; -import { ZJsPeopleAttributeInput } from "@formbricks/types/js"; - -interface Context { - params: { - userId: string; - environmentId: string; - }; -} - -export const OPTIONS = async (): Promise => { - return responses.successResponse({}, true); -}; - -export const POST = async (req: Request, context: Context): Promise => { - try { - const { userId, environmentId } = context.params; - const personId = userId; // legacy workaround for formbricks-js 1.2.0 & 1.2.1 - const jsonInput = await req.json(); - - // validate using zod - const inputValidation = ZJsPeopleAttributeInput.safeParse(jsonInput); - - if (!inputValidation.success) { - return responses.badRequestResponse( - "Fields are missing or incorrectly formatted", - transformErrorToDetails(inputValidation.error), - true - ); - } - - const { key, value } = inputValidation.data; - - const person = await getPerson(personId); - - if (!person) { - return responses.notFoundResponse("Person", personId, true); - } - - await updateAttributes(personId, { [key]: value }); - - personCache.revalidate({ - id: personId, - environmentId, - }); - - surveyCache.revalidate({ - environmentId, - }); - - const organization = await getOrganizationByEnvironmentId(environmentId); - - if (!organization) { - throw new Error("Organization not found"); - } - - const [surveys, noCodeActionClasses, product] = await Promise.all([ - getSyncSurveys(environmentId, person.id), - getActionClasses(environmentId), - getProductByEnvironmentId(environmentId), - ]); - - if (!product) { - throw new Error("Product not found"); - } - - // return state - const state = { - person: { id: person.id, userId: person.userId }, - surveys, - noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"), - product, - }; - - return responses.successResponse({ ...state }, true); - } catch (error) { - console.error(error); - return responses.internalServerErrorResponse(`Unable to complete request: ${error.message}`, true); - } -}; diff --git a/apps/web/app/api/v1/client/[environmentId]/app/environment/lib/environmentState.ts b/apps/web/app/api/v1/client/[environmentId]/app/environment/lib/environmentState.ts new file mode 100644 index 0000000000..5a8d5e2e57 --- /dev/null +++ b/apps/web/app/api/v1/client/[environmentId]/app/environment/lib/environmentState.ts @@ -0,0 +1,131 @@ +import { prisma } from "@formbricks/database"; +import { actionClassCache } from "@formbricks/lib/actionClass/cache"; +import { getActionClasses } from "@formbricks/lib/actionClass/service"; +import { cache } from "@formbricks/lib/cache"; +import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; +import { environmentCache } from "@formbricks/lib/environment/cache"; +import { getEnvironment } from "@formbricks/lib/environment/service"; +import { organizationCache } from "@formbricks/lib/organization/cache"; +import { + getMonthlyOrganizationResponseCount, + getOrganizationByEnvironmentId, +} from "@formbricks/lib/organization/service"; +import { + capturePosthogEnvironmentEvent, + sendPlanLimitsReachedEventToPosthogWeekly, +} from "@formbricks/lib/posthogServer"; +import { productCache } from "@formbricks/lib/product/cache"; +import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; +import { surveyCache } from "@formbricks/lib/survey/cache"; +import { getSurveys } from "@formbricks/lib/survey/service"; +import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { TJsEnvironmentState } from "@formbricks/types/js"; + +/** + * + * @param environmentId + * @returns The environment state + * @throws ResourceNotFoundError if the environment or organization does not exist + * @throws InvalidInputError if the channel is not "app" + */ +export const getEnvironmentState = async ( + environmentId: string +): Promise<{ state: TJsEnvironmentState["data"]; revalidateEnvironment?: boolean }> => + cache( + async () => { + let revalidateEnvironment = false; + const [environment, organization, product] = await Promise.all([ + getEnvironment(environmentId), + getOrganizationByEnvironmentId(environmentId), + getProductByEnvironmentId(environmentId), + ]); + + if (!organization) { + throw new ResourceNotFoundError("organization", environmentId); + } + + if (!environment) { + throw new ResourceNotFoundError("environment", environmentId); + } + + if (!product) { + throw new ResourceNotFoundError("product", environmentId); + } + + if (product.config.channel && product.config.channel !== "app") { + throw new InvalidInputError("Invalid channel"); + } + + if (!environment.appSetupCompleted) { + await Promise.all([ + prisma.environment.update({ + where: { + id: environmentId, + }, + data: { appSetupCompleted: true }, + }), + capturePosthogEnvironmentEvent(environmentId, "app setup completed"), + ]); + + revalidateEnvironment = true; + } + + // check if MAU limit is reached + let isMonthlyResponsesLimitReached = false; + + if (IS_FORMBRICKS_CLOUD) { + const monthlyResponseLimit = organization.billing.limits.monthly.responses; + + const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id); + isMonthlyResponsesLimitReached = + monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit; + } + + if (isMonthlyResponsesLimitReached) { + try { + await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, { + plan: organization.billing.plan, + limits: { + monthly: { + miu: organization.billing.limits.monthly.miu, + responses: organization.billing.limits.monthly.responses, + }, + }, + }); + } catch (err) { + console.error(`Error sending plan limits reached event to Posthog: ${err}`); + } + } + + const [surveys, actionClasses] = await Promise.all([ + getSurveys(environmentId), + getActionClasses(environmentId), + ]); + + const filteredSurveys = surveys.filter( + (survey) => survey.type === "app" && survey.status === "inProgress" + ); + + const state: TJsEnvironmentState["data"] = { + surveys: !isMonthlyResponsesLimitReached ? filteredSurveys : [], + actionClasses, + product, + }; + + return { + state, + revalidateEnvironment, + }; + }, + [`environmentState-app-${environmentId}`], + { + ...(IS_FORMBRICKS_CLOUD && { revalidate: 24 * 60 * 60 }), + tags: [ + environmentCache.tag.byId(environmentId), + organizationCache.tag.byEnvironmentId(environmentId), + productCache.tag.byEnvironmentId(environmentId), + surveyCache.tag.byEnvironmentId(environmentId), + actionClassCache.tag.byEnvironmentId(environmentId), + ], + } + )(); diff --git a/apps/web/app/api/v1/client/[environmentId]/app/environment/route.ts b/apps/web/app/api/v1/client/[environmentId]/app/environment/route.ts new file mode 100644 index 0000000000..0b31b5fc41 --- /dev/null +++ b/apps/web/app/api/v1/client/[environmentId]/app/environment/route.ts @@ -0,0 +1,59 @@ +import { getEnvironmentState } from "@/app/api/v1/client/[environmentId]/app/environment/lib/environmentState"; +import { responses } from "@/app/lib/api/response"; +import { transformErrorToDetails } from "@/app/lib/api/validator"; +import { NextRequest } from "next/server"; +import { environmentCache } from "@formbricks/lib/environment/cache"; +import { ZJsSyncInput } from "@formbricks/types/js"; + +export const OPTIONS = async (): Promise => { + return responses.successResponse({}, true); +}; + +export const GET = async ( + _: NextRequest, + { + params, + }: { + params: { + environmentId: string; + }; + } +): Promise => { + try { + // validate using zod + const inputValidation = ZJsSyncInput.safeParse({ + environmentId: params.environmentId, + }); + + if (!inputValidation.success) { + return responses.badRequestResponse( + "Fields are missing or incorrectly formatted", + transformErrorToDetails(inputValidation.error), + true + ); + } + + try { + const environmentState = await getEnvironmentState(params.environmentId); + + if (environmentState.revalidateEnvironment) { + environmentCache.revalidate({ + id: inputValidation.data.environmentId, + productId: environmentState.state.product.id, + }); + } + + return responses.successResponse( + environmentState.state, + true, + "public, s-maxage=600, max-age=840, stale-while-revalidate=600, stale-if-error=600" + ); + } catch (err) { + console.error(err); + return responses.internalServerErrorResponse(err.message, true); + } + } catch (error) { + console.error(error); + return responses.internalServerErrorResponse("Unable to handle the request: " + error.message, true); + } +}; diff --git a/apps/web/app/api/v1/client/[environmentId]/app/people/[userId]/lib/personState.ts b/apps/web/app/api/v1/client/[environmentId]/app/people/[userId]/lib/personState.ts new file mode 100644 index 0000000000..937e103959 --- /dev/null +++ b/apps/web/app/api/v1/client/[environmentId]/app/people/[userId]/lib/personState.ts @@ -0,0 +1,153 @@ +import { getPersonSegmentIds } from "@/app/api/v1/client/[environmentId]/app/people/[userId]/lib/segments"; +import { prisma } from "@formbricks/database"; +import { attributeCache } from "@formbricks/lib/attribute/cache"; +import { getAttributesByUserId } from "@formbricks/lib/attribute/service"; +import { cache } from "@formbricks/lib/cache"; +import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; +import { displayCache } from "@formbricks/lib/display/cache"; +import { getDisplaysByUserId } from "@formbricks/lib/display/service"; +import { environmentCache } from "@formbricks/lib/environment/cache"; +import { getEnvironment } from "@formbricks/lib/environment/service"; +import { organizationCache } from "@formbricks/lib/organization/cache"; +import { + getMonthlyActiveOrganizationPeopleCount, + getOrganizationByEnvironmentId, +} from "@formbricks/lib/organization/service"; +import { personCache } from "@formbricks/lib/person/cache"; +import { getIsPersonMonthlyActive, getPersonByUserId } from "@formbricks/lib/person/service"; +import { sendPlanLimitsReachedEventToPosthogWeekly } from "@formbricks/lib/posthogServer"; +import { responseCache } from "@formbricks/lib/response/cache"; +import { getResponsesByUserId } from "@formbricks/lib/response/service"; +import { segmentCache } from "@formbricks/lib/segment/cache"; +import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { TJsPersonState } from "@formbricks/types/js"; + +/** + * + * @param environmentId - The environment id + * @param userId - The user id + * @param device - The device type + * @returns The person state + * @throws {ValidationError} - If the input is invalid + * @throws {ResourceNotFoundError} - If the environment or organization is not found + * @throws {OperationNotAllowedError} - If the MAU limit is reached and the person has not been active this month + */ +export const getPersonState = async ({ + environmentId, + userId, + device, +}: { + environmentId: string; + userId: string; + device: "phone" | "desktop"; +}): Promise<{ state: TJsPersonState["data"]; revalidateProps?: { personId: string; revalidate: boolean } }> => + cache( + async () => { + let revalidatePerson = false; + const environment = await getEnvironment(environmentId); + + if (!environment) { + throw new ResourceNotFoundError(`environment`, environmentId); + } + + const organization = await getOrganizationByEnvironmentId(environmentId); + + if (!organization) { + throw new ResourceNotFoundError(`organization`, environmentId); + } + + let isMauLimitReached = false; + if (IS_FORMBRICKS_CLOUD) { + const currentMau = await getMonthlyActiveOrganizationPeopleCount(organization.id); + const monthlyMiuLimit = organization.billing.limits.monthly.miu; + + isMauLimitReached = monthlyMiuLimit !== null && currentMau >= monthlyMiuLimit; + } + + let person = await getPersonByUserId(environmentId, userId); + + if (isMauLimitReached) { + // MAU limit reached: check if person has been active this month; only continue if person has been active + + try { + await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, { + plan: organization.billing.plan, + limits: { + monthly: { + miu: organization.billing.limits.monthly.miu, + responses: organization.billing.limits.monthly.responses, + }, + }, + }); + } catch (err) { + console.error(`Error sending plan limits reached event to Posthog: ${err}`); + } + + const errorMessage = `Monthly Active Users limit in the current plan is reached in ${environmentId}`; + if (!person) { + // if it's a new person and MAU limit is reached, throw an error + throw new OperationNotAllowedError(errorMessage); + } + + // check if person has been active this month + const isPersonMonthlyActive = await getIsPersonMonthlyActive(person.id); + if (!isPersonMonthlyActive) { + throw new OperationNotAllowedError(errorMessage); + } + } else { + // MAU limit not reached: create person if not exists + if (!person) { + person = await prisma.person.create({ + data: { + environment: { + connect: { + id: environmentId, + }, + }, + userId, + }, + }); + + revalidatePerson = true; + } + } + + const personResponses = await getResponsesByUserId(environmentId, userId); + const personDisplays = await getDisplaysByUserId(environmentId, userId); + const segments = await getPersonSegmentIds(environmentId, person, device); + const attributes = await getAttributesByUserId(environmentId, userId); + + // If the person exists, return the persons's state + const userState: TJsPersonState["data"] = { + userId: person.userId, + segments, + displays: + personDisplays?.map((display) => ({ surveyId: display.surveyId, createdAt: display.createdAt })) ?? + [], + responses: personResponses?.map((response) => response.surveyId) ?? [], + attributes, + lastDisplayAt: + personDisplays.length > 0 + ? personDisplays.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0].createdAt + : null, + }; + + return { + state: userState, + revalidateProps: revalidatePerson ? { personId: person.id, revalidate: true } : undefined, + }; + }, + [`personState-${environmentId}-${userId}-${device}`], + { + ...(IS_FORMBRICKS_CLOUD && { revalidate: 24 * 60 * 60 }), + tags: [ + environmentCache.tag.byId(environmentId), + organizationCache.tag.byEnvironmentId(environmentId), + personCache.tag.byEnvironmentIdAndUserId(environmentId, userId), + attributeCache.tag.byEnvironmentIdAndUserId(environmentId, userId), + displayCache.tag.byEnvironmentIdAndUserId(environmentId, userId), + responseCache.tag.byEnvironmentIdAndUserId(environmentId, userId), + segmentCache.tag.byEnvironmentId(environmentId), + ], + } + )(); diff --git a/apps/web/app/api/v1/client/[environmentId]/app/people/[userId]/lib/segments.ts b/apps/web/app/api/v1/client/[environmentId]/app/people/[userId]/lib/segments.ts new file mode 100644 index 0000000000..e07381db0f --- /dev/null +++ b/apps/web/app/api/v1/client/[environmentId]/app/people/[userId]/lib/segments.ts @@ -0,0 +1,55 @@ +import { attributeCache } from "@formbricks/lib/attribute/cache"; +import { getAttributes } from "@formbricks/lib/attribute/service"; +import { cache } from "@formbricks/lib/cache"; +import { segmentCache } from "@formbricks/lib/segment/cache"; +import { evaluateSegment, getSegments } from "@formbricks/lib/segment/service"; +import { validateInputs } from "@formbricks/lib/utils/validate"; +import { ZId } from "@formbricks/types/common"; +import { TPerson, ZPerson } from "@formbricks/types/people"; +import { TSegment } from "@formbricks/types/segment"; + +export const getPersonSegmentIds = ( + environmentId: string, + person: TPerson, + deviceType: "phone" | "desktop" +): Promise => + cache( + async () => { + validateInputs([environmentId, ZId], [person, ZPerson]); + + const segments = await getSegments(environmentId); + + // fast path; if there are no segments, return an empty array + if (!segments) { + return []; + } + + const attributes = await getAttributes(person.id); + + const personSegments: TSegment[] = []; + + for (const segment of segments) { + const isIncluded = await evaluateSegment( + { + attributes, + actionIds: [], + deviceType, + environmentId, + personId: person.id, + userId: person.userId, + }, + segment.filters + ); + + if (isIncluded) { + personSegments.push(segment); + } + } + + return personSegments.map((segment) => segment.id); + }, + [`getPersonSegmentIds-${environmentId}-${person.id}-${deviceType}`], + { + tags: [segmentCache.tag.byEnvironmentId(environmentId), attributeCache.tag.byPersonId(person.id)], + } + )(); diff --git a/apps/web/app/api/v1/client/[environmentId]/app/people/[userId]/route.ts b/apps/web/app/api/v1/client/[environmentId]/app/people/[userId]/route.ts new file mode 100644 index 0000000000..9453abd09b --- /dev/null +++ b/apps/web/app/api/v1/client/[environmentId]/app/people/[userId]/route.ts @@ -0,0 +1,63 @@ +import { responses } from "@/app/lib/api/response"; +import { transformErrorToDetails } from "@/app/lib/api/validator"; +import { NextRequest, userAgent } from "next/server"; +import { personCache } from "@formbricks/lib/person/cache"; +import { ZJsPersonIdentifyInput } from "@formbricks/types/js"; +import { getPersonState } from "./lib/personState"; + +export const OPTIONS = async (): Promise => { + return responses.successResponse({}, true); +}; + +export const GET = async ( + request: NextRequest, + { params }: { params: { environmentId: string; userId: string } } +): Promise => { + try { + const { environmentId, userId } = params; + + // Validate input + const syncInputValidation = ZJsPersonIdentifyInput.safeParse({ + environmentId, + userId, + }); + if (!syncInputValidation.success) { + return responses.badRequestResponse( + "Fields are missing or incorrectly formatted", + transformErrorToDetails(syncInputValidation.error), + true + ); + } + + const { device } = userAgent(request); + const deviceType = device ? "phone" : "desktop"; + + try { + const personState = await getPersonState({ + environmentId, + userId, + device: deviceType, + }); + + if (personState.revalidateProps?.revalidate) { + personCache.revalidate({ + environmentId, + userId, + id: personState.revalidateProps.personId, + }); + } + + return responses.successResponse( + personState.state, + true, + "public, s-maxage=600, max-age=840, stale-while-revalidate=600, stale-if-error=600" + ); + } catch (err) { + console.error(err); + return responses.internalServerErrorResponse(err.message ?? "Unable to fetch person state", true); + } + } catch (error) { + console.error(error); + return responses.internalServerErrorResponse(`Unable to complete response: ${error.message}`, true); + } +}; diff --git a/apps/web/app/api/v1/client/[environmentId]/app/sync/[userId]/route.ts b/apps/web/app/api/v1/client/[environmentId]/app/sync/[userId]/route.ts index 8791d322d5..c5296c8451 100644 --- a/apps/web/app/api/v1/client/[environmentId]/app/sync/[userId]/route.ts +++ b/apps/web/app/api/v1/client/[environmentId]/app/sync/[userId]/route.ts @@ -1,7 +1,4 @@ -import { - replaceAttributeRecall, - replaceAttributeRecallInLegacySurveys, -} from "@/app/api/v1/client/[environmentId]/app/sync/lib/utils"; +import { replaceAttributeRecall } from "@/app/api/v1/client/[environmentId]/app/sync/lib/utils"; import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; import { NextRequest, userAgent } from "next/server"; @@ -22,8 +19,6 @@ import { import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants"; import { getSyncSurveys } from "@formbricks/lib/survey/service"; -import { transformToLegacySurvey } from "@formbricks/lib/survey/utils"; -import { isVersionGreaterThanOrEqualTo } from "@formbricks/lib/utils/version"; import { TJsAppStateSync, ZJsPeopleUserIdInput } from "@formbricks/types/js"; import { TSurvey } from "@formbricks/types/surveys/types"; @@ -44,7 +39,6 @@ export const GET = async ( ): Promise => { try { const { device } = userAgent(request); - const version = request.nextUrl.searchParams.get("version"); // validate using zod const inputValidation = ZJsPeopleUserIdInput.safeParse({ @@ -168,9 +162,7 @@ export const GET = async ( } const [surveys, actionClasses] = await Promise.all([ - getSyncSurveys(environmentId, person.id, device.type === "mobile" ? "phone" : "desktop", { - version: version ?? undefined, - }), + getSyncSurveys(environmentId, person.id, device.type === "mobile" ? "phone" : "desktop"), getActionClasses(environmentId), ]); @@ -187,7 +179,6 @@ export const GET = async ( }; const attributes = await getAttributes(person.id); const language = attributes["language"]; - const noCodeActionClasses = actionClasses.filter((actionClass) => actionClass.type === "noCode"); // Scenario 1: Multi language and updated trigger action classes supported. // Use the surveys as they are. @@ -203,30 +194,6 @@ export const GET = async ( product: updatedProduct, }; - // Backwards compatibility for versions less than 2.0.0 (no multi-language support and updated trigger action classes). - if (!isVersionGreaterThanOrEqualTo(version ?? "", "2.0.0")) { - // Scenario 2: Multi language and updated trigger action classes not supported - // Convert to legacy surveys with default language - // convert triggers to array of actionClasses Names - transformedSurveys = await Promise.all( - surveys.map((survey) => { - const languageCode = "default"; - return transformToLegacySurvey(survey as TSurvey, languageCode); - }) - ); - - const legacyState: any = { - surveys: !isMonthlyResponsesLimitReached - ? transformedSurveys.map((survey) => replaceAttributeRecallInLegacySurveys(survey, attributes)) - : [], - person, - noCodeActionClasses, - language, - product: updatedProduct, - }; - return responses.successResponse({ ...legacyState }, true); - } - return responses.successResponse({ ...state }, true); } catch (error) { console.error(error); diff --git a/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/utils.ts b/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/utils.ts index 7f1a91292d..1823555cfe 100644 --- a/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/utils.ts +++ b/apps/web/app/api/v1/client/[environmentId]/app/sync/lib/utils.ts @@ -53,29 +53,3 @@ export const replaceAttributeRecall = (survey: TSurvey, attributes: TAttributes) return surveyTemp; }; - -export const replaceAttributeRecallInLegacySurveys = (survey: any, attributes: TAttributes): any => { - const surveyTemp = structuredClone(survey); - surveyTemp.questions.forEach((question) => { - if (question.headline.includes("recall:")) { - question.headline = parseRecallInfo(question.headline, attributes); - } - if (question.subheader && question.subheader.includes("recall:")) { - question.subheader = parseRecallInfo(question.subheader, attributes); - } - }); - if (surveyTemp.welcomeCard.enabled && surveyTemp.welcomeCard.headline) { - if (surveyTemp.welcomeCard.headline && surveyTemp.welcomeCard.headline.includes("recall:")) { - surveyTemp.welcomeCard.headline = parseRecallInfo(surveyTemp.welcomeCard.headline, attributes); - } - } - if (surveyTemp.thankYouCard.enabled && surveyTemp.thankYouCard.headline) { - if (surveyTemp.thankYouCard.headline && surveyTemp.thankYouCard.headline.includes("recall:")) { - surveyTemp.thankYouCard.headline = parseRecallInfo(surveyTemp.thankYouCard.headline, attributes); - if (surveyTemp.thankYouCard.subheader && surveyTemp.thankYouCard.subheader.includes("recall:")) { - surveyTemp.thankYouCard.subheader = parseRecallInfo(surveyTemp.thankYouCard.subheader, attributes); - } - } - } - return surveyTemp; -}; diff --git a/apps/web/app/api/v1/client/[environmentId]/website/environment/lib/environmentState.ts b/apps/web/app/api/v1/client/[environmentId]/website/environment/lib/environmentState.ts new file mode 100644 index 0000000000..6ff699ff00 --- /dev/null +++ b/apps/web/app/api/v1/client/[environmentId]/website/environment/lib/environmentState.ts @@ -0,0 +1,126 @@ +import { prisma } from "@formbricks/database"; +import { actionClassCache } from "@formbricks/lib/actionClass/cache"; +import { getActionClasses } from "@formbricks/lib/actionClass/service"; +import { cache } from "@formbricks/lib/cache"; +import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; +import { environmentCache } from "@formbricks/lib/environment/cache"; +import { getEnvironment } from "@formbricks/lib/environment/service"; +import { organizationCache } from "@formbricks/lib/organization/cache"; +import { + getMonthlyOrganizationResponseCount, + getOrganizationByEnvironmentId, +} from "@formbricks/lib/organization/service"; +import { + capturePosthogEnvironmentEvent, + sendPlanLimitsReachedEventToPosthogWeekly, +} from "@formbricks/lib/posthogServer"; +import { productCache } from "@formbricks/lib/product/cache"; +import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; +import { surveyCache } from "@formbricks/lib/survey/cache"; +import { getSurveys } from "@formbricks/lib/survey/service"; +import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { TJsEnvironmentState } from "@formbricks/types/js"; + +/** + * Get the environment state + * @param environmentId + * @returns The environment state + * @throws ResourceNotFoundError if the organization, environment or product is not found + * @throws InvalidInputError if the product channel is not website + */ +export const getEnvironmentState = async ( + environmentId: string +): Promise<{ state: TJsEnvironmentState["data"]; revalidateEnvironment?: boolean }> => + cache( + async () => { + let revalidateEnvironment = false; + const [environment, organization, product] = await Promise.all([ + getEnvironment(environmentId), + getOrganizationByEnvironmentId(environmentId), + getProductByEnvironmentId(environmentId), + ]); + + if (!organization) { + throw new ResourceNotFoundError("organization", environmentId); + } + + if (!environment) { + throw new ResourceNotFoundError("environment", environmentId); + } + + if (!product) { + throw new ResourceNotFoundError("product", environmentId); + } + + if (product.config.channel && product.config.channel !== "website") { + throw new InvalidInputError("Product channel is not website"); + } + + // check if response limit is reached + let isWebsiteSurveyResponseLimitReached = false; + if (IS_FORMBRICKS_CLOUD) { + const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id); + const monthlyResponseLimit = organization.billing.limits.monthly.responses; + + isWebsiteSurveyResponseLimitReached = + monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit; + + if (isWebsiteSurveyResponseLimitReached) { + try { + await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, { + plan: organization.billing.plan, + limits: { monthly: { responses: monthlyResponseLimit, miu: null } }, + }); + } catch (error) { + console.error(`Error sending plan limits reached event to Posthog: ${error}`); + } + } + } + + if (!environment?.websiteSetupCompleted) { + await Promise.all([ + await prisma.environment.update({ + where: { + id: environmentId, + }, + data: { websiteSetupCompleted: true }, + }), + capturePosthogEnvironmentEvent(environmentId, "website setup completed"), + ]); + + revalidateEnvironment = true; + } + + const [surveys, actionClasses] = await Promise.all([ + getSurveys(environmentId), + getActionClasses(environmentId), + ]); + + // Common filter condition for selecting surveys that are in progress, are of type 'website' and have no active segment filtering. + const filteredSurveys = surveys.filter( + (survey) => survey.status === "inProgress" && survey.type === "website" + ); + + const state: TJsEnvironmentState["data"] = { + surveys: filteredSurveys, + actionClasses, + product, + }; + + return { + state, + revalidateEnvironment, + }; + }, + [`environmentState-website-${environmentId}`], + { + ...(IS_FORMBRICKS_CLOUD && { revalidate: 24 * 60 * 60 }), + tags: [ + environmentCache.tag.byId(environmentId), + organizationCache.tag.byEnvironmentId(environmentId), + productCache.tag.byEnvironmentId(environmentId), + surveyCache.tag.byEnvironmentId(environmentId), + actionClassCache.tag.byEnvironmentId(environmentId), + ], + } + )(); diff --git a/apps/web/app/api/v1/client/[environmentId]/website/environment/route.ts b/apps/web/app/api/v1/client/[environmentId]/website/environment/route.ts new file mode 100644 index 0000000000..085b2091a7 --- /dev/null +++ b/apps/web/app/api/v1/client/[environmentId]/website/environment/route.ts @@ -0,0 +1,53 @@ +import { responses } from "@/app/lib/api/response"; +import { transformErrorToDetails } from "@/app/lib/api/validator"; +import { NextRequest } from "next/server"; +import { environmentCache } from "@formbricks/lib/environment/cache"; +import { ZJsSyncInput } from "@formbricks/types/js"; +import { getEnvironmentState } from "./lib/environmentState"; + +export const OPTIONS = async (): Promise => { + return responses.successResponse({}, true); +}; + +export const GET = async ( + _: NextRequest, + { params }: { params: { environmentId: string } } +): Promise => { + try { + const syncInputValidation = ZJsSyncInput.safeParse({ + environmentId: params.environmentId, + }); + + if (!syncInputValidation.success) { + return responses.badRequestResponse( + "Fields are missing or incorrectly formatted", + transformErrorToDetails(syncInputValidation.error), + true + ); + } + + const { environmentId } = syncInputValidation.data; + + try { + const environmentState = await getEnvironmentState(environmentId); + + if (environmentState.revalidateEnvironment) { + environmentCache.revalidate({ + id: syncInputValidation.data.environmentId, + productId: environmentState.state.product.id, + }); + } + + return responses.successResponse( + environmentState.state, + true, + "public, s-maxage=600, max-age=840, stale-while-revalidate=600, stale-if-error=600" + ); + } catch (err) { + return responses.internalServerErrorResponse(err.message ?? "Unable to complete response", true); + } + } catch (error) { + console.error(error); + return responses.internalServerErrorResponse(`Unable to complete response: ${error.message}`, true); + } +}; diff --git a/apps/web/app/api/v1/client/[environmentId]/website/sync/route.ts b/apps/web/app/api/v1/client/[environmentId]/website/sync/route.ts deleted file mode 100644 index f16c2fad28..0000000000 --- a/apps/web/app/api/v1/client/[environmentId]/website/sync/route.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { responses } from "@/app/lib/api/response"; -import { transformErrorToDetails } from "@/app/lib/api/validator"; -import { NextRequest } from "next/server"; -import { getActionClasses } from "@formbricks/lib/actionClass/service"; -import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; -import { getEnvironment, updateEnvironment } from "@formbricks/lib/environment/service"; -import { - getMonthlyOrganizationResponseCount, - getOrganizationByEnvironmentId, -} from "@formbricks/lib/organization/service"; -import { - capturePosthogEnvironmentEvent, - sendPlanLimitsReachedEventToPosthogWeekly, -} from "@formbricks/lib/posthogServer"; -import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; -import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants"; -import { getSurveys } from "@formbricks/lib/survey/service"; -import { transformToLegacySurvey } from "@formbricks/lib/survey/utils"; -import { isVersionGreaterThanOrEqualTo } from "@formbricks/lib/utils/version"; -import { TJsWebsiteStateSync, ZJsWebsiteSyncInput } from "@formbricks/types/js"; -import { TSurvey } from "@formbricks/types/surveys/types"; - -export const OPTIONS = async (): Promise => { - return responses.successResponse({}, true); -}; - -export const GET = async ( - request: NextRequest, - { params }: { params: { environmentId: string } } -): Promise => { - try { - const searchParams = request.nextUrl.searchParams; - const version = - searchParams.get("version") === "undefined" || searchParams.get("version") === null - ? undefined - : searchParams.get("version"); - const syncInputValidation = ZJsWebsiteSyncInput.safeParse({ - environmentId: params.environmentId, - }); - - if (!syncInputValidation.success) { - return responses.badRequestResponse( - "Fields are missing or incorrectly formatted", - transformErrorToDetails(syncInputValidation.error), - true - ); - } - - const { environmentId } = syncInputValidation.data; - - const [environment, organization, product] = await Promise.all([ - getEnvironment(environmentId), - getOrganizationByEnvironmentId(environmentId), - getProductByEnvironmentId(environmentId), - ]); - - if (!organization) { - throw new Error("Organization does not exist"); - } - - if (!environment) { - throw new Error("Environment does not exist"); - } - - if (!product) { - throw new Error("Product not found"); - } - - if (product.config.channel && product.config.channel !== "website") { - return responses.forbiddenResponse("Product channel is not website", true); - } - - // check if response limit is reached - let isWebsiteSurveyResponseLimitReached = false; - if (IS_FORMBRICKS_CLOUD) { - const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id); - const monthlyResponseLimit = organization.billing.limits.monthly.responses; - - isWebsiteSurveyResponseLimitReached = - monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit; - - if (isWebsiteSurveyResponseLimitReached) { - try { - await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, { - plan: organization.billing.plan, - limits: { monthly: { responses: monthlyResponseLimit, miu: null } }, - }); - } catch (error) { - console.error(`Error sending plan limits reached event to Posthog: ${error}`); - } - } - } - - // temporary remove the example survey creation to avoid caching issue with multiple example surveys - /* if (!environment?.websiteSetupCompleted) { - const exampleTrigger = await getActionClassByEnvironmentIdAndName(environmentId, "New Session"); - if (!exampleTrigger) { - throw new Error("Example trigger not found"); - } - const firstSurvey = getExampleWebsiteSurveyTemplate(WEBAPP_URL, exampleTrigger); - await createSurvey(environmentId, firstSurvey); - await updateEnvironment(environment.id, { websiteSetupCompleted: true }); - } */ - - if (!environment?.websiteSetupCompleted) { - await Promise.all([ - updateEnvironment(environment.id, { websiteSetupCompleted: true }), - capturePosthogEnvironmentEvent(environmentId, "website setup completed"), - ]); - } - - const [surveys, actionClasses] = await Promise.all([ - getSurveys(environmentId), - getActionClasses(environmentId), - ]); - - // Common filter condition for selecting surveys that are in progress, are of type 'website' and have no active segment filtering. - const filteredSurveys = surveys.filter( - (survey) => survey.status === "inProgress" && survey.type === "website" - // TODO: Find out if this required anymore. Most likely not. - // && (!survey.segment || survey.segment.filters.length === 0) - ); - - const updatedProduct: any = { - ...product, - brandColor: product.styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor, - ...(product.styling.highlightBorderColor?.light && { - highlightBorderColor: product.styling.highlightBorderColor.light, - }), - }; - - const noCodeActionClasses = actionClasses.filter((actionClass) => actionClass.type === "noCode"); - - // Define 'transformedSurveys' which can be an array of either TLegacySurvey or TSurvey. - let transformedSurveys: TSurvey[] = filteredSurveys; - let state: TJsWebsiteStateSync = { - surveys: !isWebsiteSurveyResponseLimitReached ? transformedSurveys : [], - actionClasses, - product: updatedProduct, - }; - - // Backwards compatibility for versions less than 2.0.0 (no multi-language support and updated trigger action classes). - if (!isVersionGreaterThanOrEqualTo(version ?? "", "2.0.0")) { - // Scenario 2: Multi language and updated trigger action classes not supported - // Convert to legacy surveys with default language - // convert triggers to array of actionClasses Names - transformedSurveys = await Promise.all( - filteredSurveys.map((survey) => { - const languageCode = "default"; - return transformToLegacySurvey(survey, languageCode); - }) - ); - - const legacyState: any = { - surveys: isWebsiteSurveyResponseLimitReached ? [] : transformedSurveys, - noCodeActionClasses, - product: updatedProduct, - }; - return responses.successResponse( - { ...legacyState }, - true, - "public, s-maxage=600, max-age=840, stale-while-revalidate=600, stale-if-error=600" - ); - } - - return responses.successResponse( - { ...state }, - true, - "public, s-maxage=600, max-age=840, stale-while-revalidate=600, stale-if-error=600" - ); - } catch (error) { - console.error(error); - return responses.internalServerErrorResponse(`Unable to complete response: ${error.message}`, true); - } -}; diff --git a/apps/web/playwright/js.spec.ts b/apps/web/playwright/js.spec.ts index 2188278d99..a23a19135c 100644 --- a/apps/web/playwright/js.spec.ts +++ b/apps/web/playwright/js.spec.ts @@ -59,7 +59,7 @@ test.describe("JS Package Test", async () => { // Formbricks In App Sync has happened const syncApi = await page.waitForResponse( (response) => { - return response.url().includes("/app/sync"); + return response.url().includes("/app/environment"); }, { timeout: 120000, diff --git a/packages/js-core/src/app/lib/actions.ts b/packages/js-core/src/app/lib/actions.ts index 5c756cc5bc..297196f9b5 100644 --- a/packages/js-core/src/app/lib/actions.ts +++ b/packages/js-core/src/app/lib/actions.ts @@ -1,9 +1,7 @@ import { TJsTrackProperties } from "@formbricks/types/js"; import { InvalidCodeError, NetworkError, Result, err, okVoid } from "../../shared/errors"; import { Logger } from "../../shared/logger"; -import { getIsDebug } from "../../shared/utils"; import { AppConfig } from "./config"; -import { sync } from "./sync"; import { triggerSurvey } from "./widget"; const logger = Logger.getInstance(); @@ -15,31 +13,11 @@ export const trackAction = async ( properties?: TJsTrackProperties ): Promise> => { const aliasName = alias || name; - const { userId } = appConfig.get(); - - if (userId) { - // we skip the resync on a new action since this leads to too many requests if the user has a lot of actions - // also this always leads to a second sync call on the `New Session` action - // when debug: sync after every action for testing purposes - if (getIsDebug()) { - logger.debug(`Resync after action "${aliasName} in debug mode"`); - await sync( - { - environmentId: appConfig.get().environmentId, - apiHost: appConfig.get().apiHost, - userId, - attributes: appConfig.get().state.attributes, - }, - true, - appConfig - ); - } - } logger.debug(`Formbricks: Action "${aliasName}" tracked`); // get a list of surveys that are collecting insights - const activeSurveys = appConfig.get().state?.surveys; + const activeSurveys = appConfig.get().filteredSurveys; if (!!activeSurveys && activeSurveys.length > 0) { for (const survey of activeSurveys) { @@ -60,9 +38,7 @@ export const trackCodeAction = ( code: string, properties?: TJsTrackProperties ): Promise> | Result => { - const { - state: { actionClasses = [] }, - } = appConfig.get(); + const actionClasses = appConfig.get().environmentState.data.actionClasses; const codeActionClasses = actionClasses.filter((action) => action.type === "code"); const action = codeActionClasses.find((action) => action.key === code); diff --git a/packages/js-core/src/app/lib/attributes.ts b/packages/js-core/src/app/lib/attributes.ts index d1f4836be8..d7f7660902 100644 --- a/packages/js-core/src/app/lib/attributes.ts +++ b/packages/js-core/src/app/lib/attributes.ts @@ -2,13 +2,37 @@ import { FormbricksAPI } from "@formbricks/api"; import { TAttributes } from "@formbricks/types/attributes"; import { MissingPersonError, NetworkError, Result, err, ok, okVoid } from "../../shared/errors"; import { Logger } from "../../shared/logger"; +import { fetchPersonState } from "../../shared/personState"; +import { filterSurveys } from "../../shared/utils"; import { AppConfig } from "./config"; const appConfig = AppConfig.getInstance(); const logger = Logger.getInstance(); -export const updateAttribute = async (key: string, value: string): Promise> => { - const { apiHost, environmentId, userId } = appConfig.get(); +export const updateAttribute = async ( + key: string, + value: string | number +): Promise< + Result< + { + changed: boolean; + message: string; + }, + Error | NetworkError + > +> => { + const { apiHost, environmentId } = appConfig.get(); + const userId = appConfig.get().personState.data.userId; + + if (!userId) { + return err({ + code: "network_error", + status: 500, + message: "Missing userId", + url: `${apiHost}/api/v1/client/${environmentId}/people/${userId}/attributes`, + responseMessage: "Missing userId", + }); + } const api = new FormbricksAPI({ apiHost, @@ -21,7 +45,13 @@ export const updateAttribute = async (key: string, value: string): Promise> => { // clean attributes and remove existing attributes if config already exists const updatedAttributes = { ...attributes }; try { - const existingAttributes = appConfig.get()?.state?.attributes; + const existingAttributes = appConfig.get().personState.data.attributes; if (existingAttributes) { for (const [key, value] of Object.entries(existingAttributes)) { if (updatedAttributes[key] === value) { @@ -97,10 +139,11 @@ export const updateAttributes = async ( } }; -export const isExistingAttribute = (key: string, value: string, appConfig: AppConfig): boolean => { - if (appConfig.get().state.attributes[key] === value) { +export const isExistingAttribute = (key: string, value: string): boolean => { + if (appConfig.get().personState.data.attributes[key] === value) { return true; } + return false; }; @@ -113,9 +156,18 @@ export const setAttributeInApp = async ( return okVoid(); } + const userId = appConfig.get().personState.data.userId; + + if (!userId) { + return err({ + code: "missing_person", + message: "Missing userId", + }); + } + logger.debug("Setting attribute: " + key + " to value: " + value); // check if attribute already exists with this value - if (isExistingAttribute(key, value.toString(), appConfig)) { + if (isExistingAttribute(key, value.toString())) { logger.debug("Attribute already set to this value. Skipping update."); return okVoid(); } @@ -123,22 +175,27 @@ export const setAttributeInApp = async ( const result = await updateAttribute(key, value.toString()); if (result.ok) { - // udpdate attribute in config - appConfig.update({ - environmentId: appConfig.get().environmentId, - apiHost: appConfig.get().apiHost, - userId: appConfig.get().userId, - state: { - ...appConfig.get().state, - attributes: { - ...appConfig.get().state.attributes, - [key]: value.toString(), + if (result.value.changed) { + const personState = await fetchPersonState( + { + apiHost: appConfig.get().apiHost, + environmentId: appConfig.get().environmentId, + userId, }, - }, - expiresAt: appConfig.get().expiresAt, - }); + true + ); + + const filteredSurveys = filterSurveys(appConfig.get().environmentState, personState); + + appConfig.update({ + ...appConfig.get(), + personState, + filteredSurveys, + }); + } + return okVoid(); } - return err(result.error); + return err(result.error as NetworkError); }; diff --git a/packages/js-core/src/app/lib/config.ts b/packages/js-core/src/app/lib/config.ts index cddd23c45b..63c5d4e659 100644 --- a/packages/js-core/src/app/lib/config.ts +++ b/packages/js-core/src/app/lib/config.ts @@ -1,89 +1,67 @@ -import { TJSAppConfig, TJsAppConfigUpdateInput } from "@formbricks/types/js"; +import { TJsConfig, TJsConfigUpdateInput } from "@formbricks/types/js"; import { APP_SURVEYS_LOCAL_STORAGE_KEY } from "../../shared/constants"; import { Result, err, ok, wrapThrows } from "../../shared/errors"; -export interface StorageHandler { - getItem(key: string): Promise; - setItem(key: string, value: string): Promise; - removeItem(key: string): Promise; -} - -// LocalStorage implementation - default -class LocalStorage implements StorageHandler { - async getItem(key: string): Promise { - return localStorage.getItem(key); - } - - async setItem(key: string, value: string): Promise { - localStorage.setItem(key, value); - } - - async removeItem(key: string): Promise { - localStorage.removeItem(key); - } -} - export class AppConfig { private static instance: AppConfig | undefined; - private config: TJSAppConfig | null = null; - private storageHandler: StorageHandler; - private storageKey: string; + private config: TJsConfig | null = null; - private constructor( - storageHandler: StorageHandler = new LocalStorage(), - storageKey: string = APP_SURVEYS_LOCAL_STORAGE_KEY - ) { - this.storageHandler = storageHandler; - this.storageKey = storageKey; + private constructor() { + const savedConfig = this.loadFromLocalStorage(); - this.loadFromStorage().then((res) => { - if (res.ok) { - this.config = res.value; - } - }); + if (savedConfig.ok) { + this.config = savedConfig.value; + } } - static getInstance(storageHandler?: StorageHandler, storageKey?: string): AppConfig { + static getInstance(): AppConfig { if (!AppConfig.instance) { - AppConfig.instance = new AppConfig(storageHandler, storageKey); + AppConfig.instance = new AppConfig(); } return AppConfig.instance; } - public update(newConfig: TJsAppConfigUpdateInput): void { + public update(newConfig: TJsConfigUpdateInput): void { if (newConfig) { this.config = { ...this.config, ...newConfig, - status: newConfig.status || "success", + status: { + value: newConfig.status?.value || "success", + expiresAt: newConfig.status?.expiresAt || null, + }, }; this.saveToStorage(); } } - public get(): TJSAppConfig { + public get(): TJsConfig { if (!this.config) { throw new Error("config is null, maybe the init function was not called?"); } return this.config; } - public async loadFromStorage(): Promise> { - try { - const savedConfig = await this.storageHandler.getItem(this.storageKey); + public loadFromLocalStorage(): Result { + if (typeof window !== "undefined") { + const savedConfig = localStorage.getItem(APP_SURVEYS_LOCAL_STORAGE_KEY); if (savedConfig) { - const parsedConfig = JSON.parse(savedConfig) as TJSAppConfig; + // TODO: validate config + // This is a hack to get around the fact that we don't have a proper + // way to validate the config yet. + const parsedConfig = JSON.parse(savedConfig) as TJsConfig; // check if the config has expired - if (parsedConfig.expiresAt && new Date(parsedConfig.expiresAt) <= new Date()) { + if ( + parsedConfig.environmentState?.expiresAt && + new Date(parsedConfig.environmentState.expiresAt) <= new Date() + ) { return err(new Error("Config in local storage has expired")); } return ok(parsedConfig); } - } catch (e) { - return err(new Error("No or invalid config in local storage")); } return err(new Error("No or invalid config in local storage")); @@ -91,7 +69,7 @@ export class AppConfig { private async saveToStorage(): Promise, Error>> { return wrapThrows(async () => { - await this.storageHandler.setItem(this.storageKey, JSON.stringify(this.config)); + await localStorage.setItem(APP_SURVEYS_LOCAL_STORAGE_KEY, JSON.stringify(this.config)); })(); } @@ -100,9 +78,8 @@ export class AppConfig { public async resetConfig(): Promise, Error>> { this.config = null; - // return wrapThrows(() => localStorage.removeItem(IN_APP_LOCAL_STORAGE_KEY))(); return wrapThrows(async () => { - await this.storageHandler.removeItem(this.storageKey); + localStorage.removeItem(APP_SURVEYS_LOCAL_STORAGE_KEY); })(); } } diff --git a/packages/js-core/src/app/lib/eventListeners.ts b/packages/js-core/src/app/lib/eventListeners.ts index e11df15255..0a1532b0b0 100644 --- a/packages/js-core/src/app/lib/eventListeners.ts +++ b/packages/js-core/src/app/lib/eventListeners.ts @@ -1,3 +1,11 @@ +import { + addEnvironmentStateExpiryCheckListener, + clearEnvironmentStateExpiryCheckListener, +} from "../../shared/environmentState"; +import { + addPersonStateExpiryCheckListener, + clearPersonStateExpiryCheckListener, +} from "../../shared/personState"; import { addClickEventListener, addExitIntentListener, @@ -9,13 +17,12 @@ import { removeScrollDepthListener, } from "../lib/noCodeActions"; import { AppConfig } from "./config"; -import { addExpiryCheckListener, removeExpiryCheckListener } from "./sync"; let areRemoveEventListenersAdded = false; -const appConfig = AppConfig.getInstance(); -export const addEventListeners = (): void => { - addExpiryCheckListener(appConfig); +export const addEventListeners = (config: AppConfig): void => { + addEnvironmentStateExpiryCheckListener("app", config); + addPersonStateExpiryCheckListener(config); addPageUrlEventListeners(); addClickEventListener(); addExitIntentListener(); @@ -25,7 +32,8 @@ export const addEventListeners = (): void => { export const addCleanupEventListeners = (): void => { if (areRemoveEventListenersAdded) return; window.addEventListener("beforeunload", () => { - removeExpiryCheckListener(); + clearEnvironmentStateExpiryCheckListener(); + clearPersonStateExpiryCheckListener(); removePageUrlEventListeners(); removeClickEventListener(); removeExitIntentListener(); @@ -37,7 +45,8 @@ export const addCleanupEventListeners = (): void => { export const removeCleanupEventListeners = (): void => { if (!areRemoveEventListenersAdded) return; window.removeEventListener("beforeunload", () => { - removeExpiryCheckListener(); + clearEnvironmentStateExpiryCheckListener(); + clearPersonStateExpiryCheckListener(); removePageUrlEventListeners(); removeClickEventListener(); removeExitIntentListener(); @@ -47,7 +56,8 @@ export const removeCleanupEventListeners = (): void => { }; export const removeAllEventListeners = (): void => { - removeExpiryCheckListener(); + clearEnvironmentStateExpiryCheckListener(); + clearPersonStateExpiryCheckListener(); removePageUrlEventListeners(); removeClickEventListener(); removeExitIntentListener(); diff --git a/packages/js-core/src/app/lib/initialize.ts b/packages/js-core/src/app/lib/initialize.ts index d58a6bea6e..c6366c1733 100644 --- a/packages/js-core/src/app/lib/initialize.ts +++ b/packages/js-core/src/app/lib/initialize.ts @@ -1,6 +1,7 @@ import { TAttributes } from "@formbricks/types/attributes"; -import type { TJSAppConfig, TJsAppConfigInput } from "@formbricks/types/js"; +import type { TJsAppConfigInput, TJsConfig } from "@formbricks/types/js"; import { APP_SURVEYS_LOCAL_STORAGE_KEY } from "../../shared/constants"; +import { fetchEnvironmentState } from "../../shared/environmentState"; import { ErrorHandler, MissingFieldError, @@ -13,16 +14,16 @@ import { wrapThrows, } from "../../shared/errors"; import { Logger } from "../../shared/logger"; -import { getIsDebug } from "../../shared/utils"; +import { fetchPersonState } from "../../shared/personState"; +import { filterSurveys, getIsDebug } from "../../shared/utils"; import { trackNoCodeAction } from "./actions"; import { updateAttributes } from "./attributes"; import { AppConfig } from "./config"; import { addCleanupEventListeners, addEventListeners, removeAllEventListeners } from "./eventListeners"; import { checkPageUrl } from "./noCodeActions"; -import { sync } from "./sync"; import { addWidgetContainer, removeWidgetContainer, setIsSurveyRunning } from "./widget"; -const appConfig = AppConfig.getInstance(); +const appConfigGlobal = AppConfig.getInstance(); const logger = Logger.getInstance(); let isInitialized = false; @@ -31,6 +32,20 @@ export const setIsInitialized = (value: boolean) => { isInitialized = value; }; +const checkForOlderLocalConfig = (): boolean => { + const oldConfig = localStorage.getItem(APP_SURVEYS_LOCAL_STORAGE_KEY); + + if (oldConfig) { + const parsedOldConfig = JSON.parse(oldConfig); + if (parsedOldConfig.state || parsedOldConfig.expiresAt) { + // local config follows old structure + return true; + } + } + + return false; +}; + export const initialize = async ( configInput: TJsAppConfigInput ): Promise> => { @@ -39,31 +54,46 @@ export const initialize = async ( logger.configure({ logLevel: "debug" }); } + const isLocalStorageOld = checkForOlderLocalConfig(); + + let appConfig = appConfigGlobal; + + if (isLocalStorageOld) { + logger.debug("Local config is of an older version"); + logger.debug("Resetting config"); + + appConfig.resetConfig(); + appConfig = AppConfig.getInstance(); + } + if (isInitialized) { logger.debug("Already initialized, skipping initialization."); return okVoid(); } - let existingConfig: TJSAppConfig | undefined; + let existingConfig: TJsConfig | undefined; try { - existingConfig = appConfig.get(); + existingConfig = appConfigGlobal.get(); logger.debug("Found existing configuration."); } catch (e) { logger.debug("No existing configuration found."); } // formbricks is in error state, skip initialization - if (existingConfig?.status === "error") { + if (existingConfig?.status?.value === "error") { if (isDebug) { logger.debug( "Formbricks is in error state, but debug mode is active. Resetting config and continuing." ); - appConfig.resetConfig(); + appConfigGlobal.resetConfig(); return okVoid(); } logger.debug("Formbricks was set to an error state."); - if (existingConfig?.expiresAt && new Date(existingConfig.expiresAt) > new Date()) { + + const expiresAt = existingConfig?.status?.expiresAt; + + if (expiresAt && new Date(expiresAt) > new Date()) { logger.debug("Error state is not expired, skipping initialization"); return okVoid(); } else { @@ -110,8 +140,7 @@ export const initialize = async ( configInput.apiHost, configInput.environmentId, configInput.userId, - configInput.attributes, - appConfig + configInput.attributes ); if (res.ok !== true) { return err(res.error); @@ -121,50 +150,96 @@ export const initialize = async ( if ( existingConfig && - existingConfig.state && + existingConfig.environmentState && existingConfig.environmentId === configInput.environmentId && existingConfig.apiHost === configInput.apiHost && - existingConfig.userId === configInput.userId && - existingConfig.expiresAt // only accept config when they follow new config version with expiresAt + existingConfig.personState?.data?.userId === configInput.userId ) { logger.debug("Configuration fits init parameters."); - if (existingConfig.expiresAt < new Date()) { - logger.debug("Configuration expired."); + let isEnvironmentStateExpired = false; + let isPersonStateExpired = false; - try { - await sync( - { + if (new Date(existingConfig.environmentState.expiresAt) < new Date()) { + logger.debug("Environment state expired. Syncing."); + isEnvironmentStateExpired = true; + } + + if (existingConfig.personState.expiresAt && new Date(existingConfig.personState.expiresAt) < new Date()) { + logger.debug("Person state expired. Syncing."); + isPersonStateExpired = true; + } + + try { + // fetch the environment state (if expired) + const environmentState = isEnvironmentStateExpired + ? await fetchEnvironmentState( + { + apiHost: configInput.apiHost, + environmentId: configInput.environmentId, + }, + "app" + ) + : existingConfig.environmentState; + + // fetch the person state (if expired) + const personState = isPersonStateExpired + ? await fetchPersonState({ apiHost: configInput.apiHost, environmentId: configInput.environmentId, userId: configInput.userId, - }, - undefined, - appConfig - ); - } catch (e) { - putFormbricksInErrorState(); - } - } else { - logger.debug("Configuration not expired. Extending expiration."); - appConfig.update(existingConfig); + }) + : existingConfig.personState; + + // filter the environment state wrt the person state + const filteredSurveys = filterSurveys(environmentState, personState); + + // update the appConfig with the new filtered surveys + appConfigGlobal.update({ + ...existingConfig, + environmentState, + personState, + filteredSurveys, + }); + + const surveyNames = filteredSurveys.map((s) => s.name); + logger.debug("Fetched " + surveyNames.length + " surveys during sync: " + surveyNames.join(", ")); + } catch (e) { + putFormbricksInErrorState(appConfig); } } else { logger.debug( "No valid configuration found or it has been expired. Resetting config and creating new one." ); - appConfig.resetConfig(); + appConfigGlobal.resetConfig(); logger.debug("Syncing."); try { - await sync( + const environmentState = await fetchEnvironmentState( + { + apiHost: configInput.apiHost, + environmentId: configInput.environmentId, + }, + "app", + false + ); + const personState = await fetchPersonState( { apiHost: configInput.apiHost, environmentId: configInput.environmentId, userId: configInput.userId, }, - undefined, - appConfig + false ); + + const filteredSurveys = filterSurveys(environmentState, personState); + + appConfigGlobal.update({ + apiHost: configInput.apiHost, + environmentId: configInput.environmentId, + personState, + environmentState, + filteredSurveys, + }); } catch (e) { handleErrorOnFirstInit(); } @@ -172,22 +247,26 @@ export const initialize = async ( // and track the new session event await trackNoCodeAction("New Session"); } + // update attributes in config if (updatedAttributes && Object.keys(updatedAttributes).length > 0) { - appConfig.update({ - environmentId: appConfig.get().environmentId, - apiHost: appConfig.get().apiHost, - userId: appConfig.get().userId, - state: { - ...appConfig.get().state, - attributes: { ...appConfig.get().state.attributes, ...configInput.attributes }, + appConfigGlobal.update({ + ...appConfigGlobal.get(), + personState: { + ...appConfigGlobal.get().personState, + data: { + ...appConfigGlobal.get().personState.data, + attributes: { + ...appConfigGlobal.get().personState.data.attributes, + ...updatedAttributes, + }, + }, }, - expiresAt: appConfig.get().expiresAt, }); } logger.debug("Adding event listeners"); - addEventListeners(); + addEventListeners(appConfigGlobal); addCleanupEventListeners(); setIsInitialized(true); @@ -199,17 +278,20 @@ export const initialize = async ( return okVoid(); }; -const handleErrorOnFirstInit = () => { +export const handleErrorOnFirstInit = () => { if (getIsDebug()) { logger.debug("Not putting formbricks in error state because debug mode is active (no error state)"); return; } // put formbricks in error state (by creating a new config) and throw error - const initialErrorConfig: Partial = { - status: "error", - expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future + const initialErrorConfig: Partial = { + status: { + value: "error", + expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future + }, }; + // can't use config.update here because the config is not yet initialized wrapThrows(() => localStorage.setItem(APP_SURVEYS_LOCAL_STORAGE_KEY, JSON.stringify(initialErrorConfig)))(); throw new Error("Could not initialize formbricks"); @@ -235,7 +317,7 @@ export const deinitalize = (): void => { setIsInitialized(false); }; -export const putFormbricksInErrorState = (): void => { +export const putFormbricksInErrorState = (appConfig: AppConfig): void => { if (getIsDebug()) { logger.debug("Not putting formbricks in error state because debug mode is active (no error state)"); return; @@ -244,9 +326,11 @@ export const putFormbricksInErrorState = (): void => { logger.debug("Putting formbricks in error state"); // change formbricks status to error appConfig.update({ - ...appConfig.get(), - status: "error", - expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future + ...appConfigGlobal.get(), + status: { + value: "error", + expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future + }, }); deinitalize(); }; diff --git a/packages/js-core/src/app/lib/noCodeActions.ts b/packages/js-core/src/app/lib/noCodeActions.ts index 4b66732300..87fba28f41 100644 --- a/packages/js-core/src/app/lib/noCodeActions.ts +++ b/packages/js-core/src/app/lib/noCodeActions.ts @@ -5,7 +5,7 @@ import { evaluateNoCodeConfigClick, handleUrlFilters } from "../../shared/utils" import { trackNoCodeAction } from "./actions"; import { AppConfig } from "./config"; -const inAppConfig = AppConfig.getInstance(); +const appConfig = AppConfig.getInstance(); const logger = Logger.getInstance(); const errorHandler = ErrorHandler.getInstance(); @@ -17,8 +17,7 @@ let arePageUrlEventListenersAdded = false; export const checkPageUrl = async (): Promise> => { logger.debug(`Checking page url: ${window.location.href}`); - const { state } = inAppConfig.get(); - const { actionClasses = [] } = state ?? {}; + const actionClasses = appConfig.get().environmentState.data.actionClasses; const noCodePageViewActionClasses = actionClasses.filter( (action) => action.type === "noCode" && action.noCodeConfig?.type === "pageView" @@ -55,10 +54,11 @@ export const removePageUrlEventListeners = (): void => { let isClickEventListenerAdded = false; const checkClickMatch = (event: MouseEvent) => { - const { state } = inAppConfig.get(); - if (!state) return; + const { environmentState } = appConfig.get(); + if (!environmentState) return; + + const { actionClasses = [] } = environmentState.data; - const { actionClasses = [] } = state; const noCodeClickActionClasses = actionClasses.filter( (action) => action.type === "noCode" && action.noCodeConfig?.type === "click" ); @@ -96,8 +96,8 @@ export const removeClickEventListener = (): void => { let isExitIntentListenerAdded = false; const checkExitIntent = async (e: MouseEvent) => { - const { state } = inAppConfig.get(); - const { actionClasses = [] } = state ?? {}; + const { environmentState } = appConfig.get(); + const { actionClasses = [] } = environmentState.data ?? {}; const noCodeExitIntentActionClasses = actionClasses.filter( (action) => action.type === "noCode" && action.noCodeConfig?.type === "exitIntent" @@ -148,8 +148,8 @@ const checkScrollDepth = async () => { if (!scrollDepthTriggered && scrollPosition / (bodyHeight - windowSize) >= 0.5) { scrollDepthTriggered = true; - const { state } = inAppConfig.get(); - const { actionClasses = [] } = state ?? {}; + const { environmentState } = appConfig.get(); + const { actionClasses = [] } = environmentState.data ?? {}; const noCodefiftyPercentScrollActionClasses = actionClasses.filter( (action) => action.type === "noCode" && action.noCodeConfig?.type === "fiftyPercentScroll" diff --git a/packages/js-core/src/app/lib/person.ts b/packages/js-core/src/app/lib/person.ts index 6b8bfada1e..b3c9b6b9e4 100644 --- a/packages/js-core/src/app/lib/person.ts +++ b/packages/js-core/src/app/lib/person.ts @@ -15,11 +15,23 @@ export const logoutPerson = async (): Promise => { export const resetPerson = async (): Promise> => { logger.debug("Resetting state & getting new state from backend"); closeSurvey(); + + const userId = appConfig.get().personState.data.userId; + if (!userId) { + return err({ + code: "network_error", + status: 500, + message: "Missing userId", + url: `${appConfig.get().apiHost}/api/v1/client/${appConfig.get().environmentId}/people/${userId}/attributes`, + responseMessage: "Missing userId", + }); + } + const syncParams = { environmentId: appConfig.get().environmentId, apiHost: appConfig.get().apiHost, - userId: appConfig.get().userId, - attributes: appConfig.get().state.attributes, + userId, + attributes: appConfig.get().personState.data.attributes, }; await logoutPerson(); try { diff --git a/packages/js-core/src/app/lib/sync.ts b/packages/js-core/src/app/lib/sync.ts deleted file mode 100644 index 43a064b313..0000000000 --- a/packages/js-core/src/app/lib/sync.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { TAttributes } from "@formbricks/types/attributes"; -import { TJsAppState, TJsAppStateSync, TJsAppSyncParams } from "@formbricks/types/js"; -import { TSurvey } from "@formbricks/types/surveys/types"; -import { NetworkError, Result, err, ok } from "../../shared/errors"; -import { Logger } from "../../shared/logger"; -import { AppConfig } from "./config"; - -const logger = Logger.getInstance(); - -let syncIntervalId: number | null = null; - -const syncWithBackend = async ( - { apiHost, environmentId, userId }: TJsAppSyncParams, - noCache: boolean -): Promise> => { - try { - let fetchOptions: RequestInit = {}; - - if (noCache) { - fetchOptions.cache = "no-cache"; - logger.debug("No cache option set for sync"); - } - logger.debug("syncing with backend"); - const url = `${apiHost}/api/v1/client/${environmentId}/app/sync/${userId}?version=2.0.0`; - - const response = await fetch(url, fetchOptions); - - if (!response.ok) { - const jsonRes = await response.json(); - - return err({ - code: "network_error", - status: response.status, - message: "Error syncing with backend", - url, - responseMessage: jsonRes.message, - }); - } - - const data = await response.json(); - const { data: state } = data; - - return ok(state as TJsAppStateSync); - } catch (e) { - return err(e as NetworkError); - } -}; - -export const sync = async ( - params: TJsAppSyncParams, - noCache = false, - appConfig: AppConfig -): Promise => { - try { - const syncResult = await syncWithBackend(params, noCache); - - if (syncResult?.ok !== true) { - throw syncResult.error; - } - - let attributes: TAttributes = params.attributes || {}; - - if (syncResult.value.language) { - attributes.language = syncResult.value.language; - } - - let state: TJsAppState = { - surveys: syncResult.value.surveys as TSurvey[], - actionClasses: syncResult.value.actionClasses, - product: syncResult.value.product, - attributes, - }; - - const surveyNames = state.surveys.map((s) => s.name); - logger.debug("Fetched " + surveyNames.length + " surveys during sync: " + surveyNames.join(", ")); - - appConfig.update({ - apiHost: params.apiHost, - environmentId: params.environmentId, - userId: params.userId, - state, - expiresAt: new Date(new Date().getTime() + 2 * 60000), // 2 minutes in the future - }); - } catch (error) { - console.error(`Error during sync: ${error}`); - throw error; - } -}; - -export const addExpiryCheckListener = (appConfig: AppConfig): void => { - const updateInterval = 1000 * 30; // every 30 seconds - // add event listener to check sync with backend on regular interval - if (typeof window !== "undefined" && syncIntervalId === null) { - syncIntervalId = window.setInterval(async () => { - try { - // check if the config has not expired yet - if (appConfig.get().expiresAt && new Date(appConfig.get().expiresAt) >= new Date()) { - return; - } - logger.debug("Config has expired. Starting sync."); - await sync( - { - apiHost: appConfig.get().apiHost, - environmentId: appConfig.get().environmentId, - userId: appConfig.get().userId, - attributes: appConfig.get().state.attributes, - }, - false, - appConfig - ); - } catch (e) { - console.error(`Error during expiry check: ${e}`); - logger.debug("Extending config and try again later."); - const existingConfig = appConfig.get(); - appConfig.update(existingConfig); - } - }, updateInterval); - } -}; - -export const removeExpiryCheckListener = (): void => { - if (typeof window !== "undefined" && syncIntervalId !== null) { - window.clearInterval(syncIntervalId); - - syncIntervalId = null; - } -}; diff --git a/packages/js-core/src/app/lib/widget.ts b/packages/js-core/src/app/lib/widget.ts index 658dab66bf..b7557ebcaf 100644 --- a/packages/js-core/src/app/lib/widget.ts +++ b/packages/js-core/src/app/lib/widget.ts @@ -2,27 +2,24 @@ import { FormbricksAPI } from "@formbricks/api"; import { ResponseQueue } from "@formbricks/lib/responseQueue"; import { SurveyState } from "@formbricks/lib/surveyState"; import { getStyling } from "@formbricks/lib/utils/styling"; -import { TJsTrackProperties } from "@formbricks/types/js"; +import { TJsFileUploadParams, TJsPersonState, TJsTrackProperties } from "@formbricks/types/js"; import { TResponseHiddenFieldValue, TResponseUpdate } from "@formbricks/types/responses"; import { TUploadFileConfig } from "@formbricks/types/storage"; import { TSurvey } from "@formbricks/types/surveys/types"; -import { ErrorHandler } from "../../shared/errors"; import { Logger } from "../../shared/logger"; import { + filterSurveys, getDefaultLanguageCode, getLanguageCode, handleHiddenFields, shouldDisplayBasedOnPercentage, } from "../../shared/utils"; import { AppConfig } from "./config"; -import { putFormbricksInErrorState } from "./initialize"; -import { sync } from "./sync"; const containerId = "formbricks-app-container"; const appConfig = AppConfig.getInstance(); const logger = Logger.getInstance(); -const errorHandler = ErrorHandler.getInstance(); let isSurveyRunning = false; let setIsError = (_: boolean) => {}; let setIsResponseSendingFinished = (_: boolean) => {}; @@ -68,8 +65,8 @@ const renderWidget = async ( logger.debug(`Delaying survey "${survey.name}" by ${survey.delay} seconds.`); } - const product = appConfig.get().state.product; - const attributes = appConfig.get().state.attributes; + const { product } = appConfig.get().environmentState.data ?? {}; + const { attributes } = appConfig.get().personState.data ?? {}; const isMultiLanguageSurvey = survey.languages.length > 1; let languageCode = "default"; @@ -85,7 +82,7 @@ const renderWidget = async ( languageCode = displayLanguage; } - const surveyState = new SurveyState(survey.id, null, null, appConfig.get().userId); + const surveyState = new SurveyState(survey.id, null, null, appConfig.get().personState.data.userId); const responseQueue = new ResponseQueue( { @@ -124,7 +121,12 @@ const renderWidget = async ( setIsResponseSendingFinished = f; }, onDisplay: async () => { - const { userId } = appConfig.get(); + const { userId } = appConfig.get().personState.data; + + if (!userId) { + logger.debug("User ID not found. Skipping."); + return; + } const api = new FormbricksAPI({ apiHost: appConfig.get().apiHost, @@ -144,9 +146,39 @@ const renderWidget = async ( surveyState.updateDisplayId(id); responseQueue.updateSurveyState(surveyState); + + const existingDisplays = appConfig.get().personState.data.displays; + const newDisplay = { surveyId: survey.id, createdAt: new Date() }; + const displays = existingDisplays ? [...existingDisplays, newDisplay] : [newDisplay]; + const previousConfig = appConfig.get(); + + const updatedPersonState: TJsPersonState = { + ...previousConfig.personState, + data: { + ...previousConfig.personState.data, + displays, + lastDisplayAt: new Date(), + }, + }; + + const filteredSurveys = filterSurveys(previousConfig.environmentState, updatedPersonState); + + appConfig.update({ + ...previousConfig, + personState: updatedPersonState, + filteredSurveys, + }); }, onResponse: (responseUpdate: TResponseUpdate) => { - const { userId } = appConfig.get(); + const { userId } = appConfig.get().personState.data; + + if (!userId) { + logger.debug("User ID not found. Skipping."); + return; + } + + const isNewResponse = surveyState.responseId === null; + surveyState.updateUserId(userId); responseQueue.updateSurveyState(surveyState); @@ -162,12 +194,29 @@ const renderWidget = async ( }, hiddenFields, }); + + if (isNewResponse) { + const responses = appConfig.get().personState.data.responses; + const newPersonState: TJsPersonState = { + ...appConfig.get().personState, + data: { + ...appConfig.get().personState.data, + responses: [...responses, surveyState.surveyId], + }, + }; + + const filteredSurveys = filterSurveys(appConfig.get().environmentState, newPersonState); + + appConfig.update({ + ...appConfig.get(), + environmentState: appConfig.get().environmentState, + personState: newPersonState, + filteredSurveys, + }); + } }, onClose: closeSurvey, - onFileUpload: async ( - file: { type: string; name: string; base64: string }, - params: TUploadFileConfig - ) => { + onFileUpload: async (file: TJsFileUploadParams["file"], params: TUploadFileConfig) => { const api = new FormbricksAPI({ apiHost: appConfig.get().apiHost, environmentId: appConfig.get().environmentId, @@ -196,23 +245,17 @@ export const closeSurvey = async (): Promise => { removeWidgetContainer(); addWidgetContainer(); - // for identified users we sync to get the latest surveys - try { - await sync( - { - apiHost: appConfig.get().apiHost, - environmentId: appConfig.get().environmentId, - userId: appConfig.get().userId, - attributes: appConfig.get().state.attributes, - }, - true, - appConfig - ); - setIsSurveyRunning(false); - } catch (e: any) { - errorHandler.handle(e); - putFormbricksInErrorState(); - } + const { environmentState, personState } = appConfig.get(); + const filteredSurveys = filterSurveys(environmentState, personState); + + appConfig.update({ + ...appConfig.get(), + environmentState, + personState, + filteredSurveys, + }); + + setIsSurveyRunning(false); }; export const addWidgetContainer = (): void => { diff --git a/packages/js-core/src/shared/environmentState.ts b/packages/js-core/src/shared/environmentState.ts new file mode 100644 index 0000000000..ce881a563a --- /dev/null +++ b/packages/js-core/src/shared/environmentState.ts @@ -0,0 +1,108 @@ +// shared functions for environment and person state(s) +import { TJsEnvironmentState, TJsEnvironmentSyncParams } from "@formbricks/types/js"; +import { AppConfig } from "../app/lib/config"; +import { WebsiteConfig } from "../website/lib/config"; +import { err } from "./errors"; +import { Logger } from "./logger"; +import { filterSurveys, getIsDebug } from "./utils"; + +const logger = Logger.getInstance(); +let environmentStateSyncIntervalId: number | null = null; + +/** + * Fetch the environment state from the backend + * @param apiHost - The API host + * @param environmentId - The environment ID + * @param noCache - Whether to skip the cache + * @returns The environment state + * @throws NetworkError + */ +export const fetchEnvironmentState = async ( + { apiHost, environmentId }: TJsEnvironmentSyncParams, + sdkType: "app" | "website", + noCache: boolean = false +): Promise => { + let fetchOptions: RequestInit = {}; + + if (noCache || getIsDebug()) { + fetchOptions.cache = "no-cache"; + logger.debug("No cache option set for sync"); + } + + const url = `${apiHost}/api/v1/client/${environmentId}/${sdkType}/environment`; + + const response = await fetch(url, fetchOptions); + + if (!response.ok) { + const jsonRes = await response.json(); + + const error = err({ + code: "network_error", + status: response.status, + message: "Error syncing with backend", + url: new URL(url), + responseMessage: jsonRes.message, + }); + + throw error; + } + + const data = await response.json(); + const { data: state } = data; + + return { + data: { ...(state as TJsEnvironmentState["data"]) }, + expiresAt: new Date(new Date().getTime() + 1000 * 60 * 30), // 30 minutes + }; +}; + +export const addEnvironmentStateExpiryCheckListener = ( + sdkType: "app" | "website", + config: AppConfig | WebsiteConfig +): void => { + let updateInterval = 1000 * 60; // every minute + if (typeof window !== "undefined" && environmentStateSyncIntervalId === null) { + environmentStateSyncIntervalId = window.setInterval(async () => { + const expiresAt = config.get().environmentState.expiresAt; + + try { + // check if the environmentState has not expired yet + if (expiresAt && new Date(expiresAt) >= new Date()) { + return; + } + + logger.debug("Environment State has expired. Starting sync."); + + const personState = config.get().personState; + const environmentState = await fetchEnvironmentState( + { + apiHost: config.get().apiHost, + environmentId: config.get().environmentId, + }, + sdkType, + true + ); + + const filteredSurveys = filterSurveys(environmentState, personState); + + config.update({ + ...config.get(), + environmentState, + filteredSurveys, + }); + } catch (e) { + console.error(`Error during expiry check: ${e}`); + logger.debug("Extending config and try again later."); + const existingConfig = config.get(); + config.update(existingConfig); + } + }, updateInterval); + } +}; + +export const clearEnvironmentStateExpiryCheckListener = (): void => { + if (environmentStateSyncIntervalId) { + clearInterval(environmentStateSyncIntervalId); + environmentStateSyncIntervalId = null; + } +}; diff --git a/packages/js-core/src/shared/personState.ts b/packages/js-core/src/shared/personState.ts new file mode 100644 index 0000000000..c575dda87e --- /dev/null +++ b/packages/js-core/src/shared/personState.ts @@ -0,0 +1,121 @@ +import { TJsPersonState, TJsPersonSyncParams } from "@formbricks/types/js"; +import { AppConfig } from "../app/lib/config"; +import { err } from "./errors"; +import { Logger } from "./logger"; +import { getIsDebug } from "./utils"; + +const logger = Logger.getInstance(); +let personStateSyncIntervalId: number | null = null; + +export const DEFAULT_PERSON_STATE_WEBSITE: TJsPersonState = { + expiresAt: null, + data: { + userId: null, + segments: [], + displays: [], + responses: [], + attributes: {}, + lastDisplayAt: null, + }, +} as const; + +/** + * Fetch the person state from the backend + * @param apiHost - The API host + * @param environmentId - The environment ID + * @param userId - The user ID + * @param noCache - Whether to skip the cache + * @returns The person state + * @throws NetworkError + */ +export const fetchPersonState = async ( + { apiHost, environmentId, userId }: TJsPersonSyncParams, + noCache: boolean = false +): Promise => { + let fetchOptions: RequestInit = {}; + + if (noCache || getIsDebug()) { + fetchOptions.cache = "no-cache"; + logger.debug("No cache option set for sync"); + } + + const url = `${apiHost}/api/v1/client/${environmentId}/app/people/${userId}`; + + const response = await fetch(url, fetchOptions); + + if (!response.ok) { + const jsonRes = await response.json(); + + const error = err({ + code: "network_error", + status: response.status, + message: "Error syncing with backend", + url: new URL(url), + responseMessage: jsonRes.message, + }); + + throw error; + } + + const data = await response.json(); + const { data: state } = data; + + const defaultPersonState: TJsPersonState = { + expiresAt: new Date(new Date().getTime() + 1000 * 60 * 30), // 30 minutes + data: { + userId, + segments: [], + displays: [], + responses: [], + attributes: {}, + lastDisplayAt: null, + }, + }; + + if (!Object.keys(state).length) { + return defaultPersonState; + } + + return { + data: { ...(state as TJsPersonState["data"]) }, + expiresAt: new Date(new Date().getTime() + 1000 * 60 * 30), // 30 minutes + }; +}; + +/** + * Add a listener to check if the person state has expired with a certain interval + * @param appConfig - The app config + */ +export const addPersonStateExpiryCheckListener = (appConfig: AppConfig): void => { + const updateInterval = 1000 * 60; // every 60 seconds + + if (typeof window !== "undefined" && personStateSyncIntervalId === null) { + personStateSyncIntervalId = window.setInterval(async () => { + const userId = appConfig.get().personState.data.userId; + + if (!userId) { + return; + } + + // extend the personState validity by 30 minutes: + + appConfig.update({ + ...appConfig.get(), + personState: { + ...appConfig.get().personState, + expiresAt: new Date(new Date().getTime() + 1000 * 60 * 30), // 30 minutes + }, + }); + }, updateInterval); + } +}; + +/** + * Clear the person state expiry check listener + */ +export const clearPersonStateExpiryCheckListener = (): void => { + if (personStateSyncIntervalId) { + clearInterval(personStateSyncIntervalId); + personStateSyncIntervalId = null; + } +}; diff --git a/packages/js-core/src/shared/utils.ts b/packages/js-core/src/shared/utils.ts index 3bcbef0944..4911566edc 100644 --- a/packages/js-core/src/shared/utils.ts +++ b/packages/js-core/src/shared/utils.ts @@ -1,10 +1,11 @@ +import { diffInDays } from "@formbricks/lib/utils/datetime"; import { TActionClass, TActionClassNoCodeConfig, TActionClassPageUrlRule, } from "@formbricks/types/action-classes"; import { TAttributes } from "@formbricks/types/attributes"; -import { TJsTrackProperties } from "@formbricks/types/js"; +import { TJsEnvironmentState, TJsPersonState, TJsTrackProperties } from "@formbricks/types/js"; import { TResponseHiddenFieldValue } from "@formbricks/types/responses"; import { TSurvey } from "@formbricks/types/surveys/types"; import { Logger } from "./logger"; @@ -107,7 +108,6 @@ export const handleHiddenFields = ( return hiddenFieldsObject; }; -export const getIsDebug = () => window.location.search.includes("formbricksDebug=true"); export const shouldDisplayBasedOnPercentage = (displayPercentage: number) => { const randomNum = Math.floor(Math.random() * 10000) / 100; @@ -145,3 +145,91 @@ export const getDefaultLanguageCode = (survey: TSurvey) => { }); if (defaultSurveyLanguage) return defaultSurveyLanguage.language.code; }; + +export const getIsDebug = () => window.location.search.includes("formbricksDebug=true"); + +/** + * Filters surveys based on the displayOption, recontactDays, and segments + * @param environmentSate The environment state + * @param personState The person state + * @returns The filtered surveys + */ + +// takes the environment and person state and returns the filtered surveys +export const filterSurveys = ( + environmentState: TJsEnvironmentState, + personState: TJsPersonState, + sdkType: "app" | "website" = "app" +): TSurvey[] => { + const { product, surveys } = environmentState.data; + const { displays, responses, lastDisplayAt, segments } = personState.data; + + if (!displays) { + return []; + } + + // Function to filter surveys based on displayOption criteria + let filteredSurveys = surveys.filter((survey: TSurvey) => { + switch (survey.displayOption) { + case "respondMultiple": + return true; + case "displayOnce": + return displays.filter((display) => display.surveyId === survey.id).length === 0; + case "displayMultiple": + return responses.filter((surveyId) => surveyId === survey.id).length === 0; + + case "displaySome": + if (survey.displayLimit === null) { + return true; + } + + // Check if survey response exists, if so, stop here + if (responses.filter((surveyId) => surveyId === survey.id).length) { + return false; + } + + // Otherwise, check if displays length is less than displayLimit + return displays.filter((display) => display.surveyId === survey.id).length < survey.displayLimit; + + default: + throw Error("Invalid displayOption"); + } + }); + + // filter surveys that meet the recontactDays criteria + filteredSurveys = filteredSurveys.filter((survey) => { + // if no survey was displayed yet, show the survey + if (!lastDisplayAt) { + return true; + } + // if survey has recontactDays, check if the last display was more than recontactDays ago + else if (survey.recontactDays !== null) { + const lastDisplaySurvey = displays.filter((display) => display.surveyId === survey.id)[0]; + if (!lastDisplaySurvey) { + return true; + } + return diffInDays(new Date(), new Date(lastDisplaySurvey.createdAt)) >= survey.recontactDays; + } + // use recontactDays of the product if survey does not have recontactDays + else if (product.recontactDays !== null) { + return diffInDays(new Date(), new Date(lastDisplayAt)) >= product.recontactDays; + } + // if no recontactDays is set, show the survey + else { + return true; + } + }); + + if (sdkType === "website") { + return filteredSurveys; + } + + if (!segments.length) { + return []; + } + + // filter surveys based on segments + return filteredSurveys.filter((survey) => { + return survey.segment?.id && segments.includes(survey.segment.id); + }); +}; diff --git a/packages/js-core/src/website/lib/actions.ts b/packages/js-core/src/website/lib/actions.ts index 72de26ea51..fef6d027b7 100644 --- a/packages/js-core/src/website/lib/actions.ts +++ b/packages/js-core/src/website/lib/actions.ts @@ -16,7 +16,7 @@ export const trackAction = async ( logger.debug(`Formbricks: Action "${aliasName}" tracked`); // get a list of surveys that are collecting insights - const activeSurveys = websiteConfig.get().state?.surveys; + const activeSurveys = websiteConfig.get().filteredSurveys; if (!!activeSurveys && activeSurveys.length > 0) { for (const survey of activeSurveys) { @@ -37,9 +37,7 @@ export const trackCodeAction = ( code: string, properties?: TJsTrackProperties ): Promise> | Result => { - const { - state: { actionClasses = [] }, - } = websiteConfig.get(); + const actionClasses = websiteConfig.get().environmentState.data.actionClasses; const codeActionClasses = actionClasses.filter((action) => action.type === "code"); const action = codeActionClasses.find((action) => action.key === code); diff --git a/packages/js-core/src/website/lib/config.ts b/packages/js-core/src/website/lib/config.ts index 418bada1e4..113eab2e2b 100644 --- a/packages/js-core/src/website/lib/config.ts +++ b/packages/js-core/src/website/lib/config.ts @@ -1,10 +1,10 @@ -import { TJsWebsiteConfig, TJsWebsiteConfigUpdateInput } from "@formbricks/types/js"; +import { TJsConfig, TJsConfigUpdateInput } from "@formbricks/types/js"; import { WEBSITE_SURVEYS_LOCAL_STORAGE_KEY } from "../../shared/constants"; import { Result, err, ok, wrapThrows } from "../../shared/errors"; export class WebsiteConfig { private static instance: WebsiteConfig | undefined; - private config: TJsWebsiteConfig | null = null; + private config: TJsConfig | null = null; private constructor() { const localConfig = this.loadFromLocalStorage(); @@ -21,37 +21,49 @@ export class WebsiteConfig { return WebsiteConfig.instance; } - public update(newConfig: TJsWebsiteConfigUpdateInput): void { + public update(newConfig: TJsConfigUpdateInput): void { if (newConfig) { this.config = { ...this.config, ...newConfig, - status: newConfig.status || "success", + status: { + value: newConfig.status?.value || "success", + expiresAt: newConfig.status?.expiresAt || null, + }, }; this.saveToLocalStorage(); } } - public get(): TJsWebsiteConfig { + public get(): TJsConfig { if (!this.config) { throw new Error("config is null, maybe the init function was not called?"); } return this.config; } - public loadFromLocalStorage(): Result { + public loadFromLocalStorage(): Result { if (typeof window !== "undefined") { const savedConfig = localStorage.getItem(WEBSITE_SURVEYS_LOCAL_STORAGE_KEY); if (savedConfig) { - const parsedConfig = JSON.parse(savedConfig) as TJsWebsiteConfig; + // TODO: validate config + // This is a hack to get around the fact that we don't have a proper + // way to validate the config yet. + const parsedConfig = JSON.parse(savedConfig) as TJsConfig; // check if the config has expired - if (parsedConfig.expiresAt && new Date(parsedConfig.expiresAt) <= new Date()) { + + // TODO: Figure out the expiration logic + if ( + parsedConfig.environmentState && + parsedConfig.environmentState.expiresAt && + new Date(parsedConfig.environmentState.expiresAt) <= new Date() + ) { return err(new Error("Config in local storage has expired")); } - return ok(JSON.parse(savedConfig) as TJsWebsiteConfig); + return ok(parsedConfig); } } diff --git a/packages/js-core/src/website/lib/eventListeners.ts b/packages/js-core/src/website/lib/eventListeners.ts index c4909e3b2b..bfe5b74d66 100644 --- a/packages/js-core/src/website/lib/eventListeners.ts +++ b/packages/js-core/src/website/lib/eventListeners.ts @@ -1,3 +1,7 @@ +import { + addEnvironmentStateExpiryCheckListener, + clearEnvironmentStateExpiryCheckListener, +} from "../../shared/environmentState"; import { addClickEventListener, addExitIntentListener, @@ -8,12 +12,13 @@ import { removePageUrlEventListeners, removeScrollDepthListener, } from "../lib/noCodeActions"; -import { addExpiryCheckListener, removeExpiryCheckListener } from "./sync"; +import { WebsiteConfig } from "./config"; let areRemoveEventListenersAdded = false; -export const addEventListeners = (): void => { - addExpiryCheckListener(); +export const addEventListeners = (config: WebsiteConfig): void => { + addEnvironmentStateExpiryCheckListener("website", config); + clearEnvironmentStateExpiryCheckListener(); addPageUrlEventListeners(); addClickEventListener(); addExitIntentListener(); @@ -23,7 +28,7 @@ export const addEventListeners = (): void => { export const addCleanupEventListeners = (): void => { if (areRemoveEventListenersAdded) return; window.addEventListener("beforeunload", () => { - removeExpiryCheckListener(); + clearEnvironmentStateExpiryCheckListener(); removePageUrlEventListeners(); removeClickEventListener(); removeExitIntentListener(); @@ -35,7 +40,7 @@ export const addCleanupEventListeners = (): void => { export const removeCleanupEventListeners = (): void => { if (!areRemoveEventListenersAdded) return; window.removeEventListener("beforeunload", () => { - removeExpiryCheckListener(); + clearEnvironmentStateExpiryCheckListener(); removePageUrlEventListeners(); removeClickEventListener(); removeExitIntentListener(); @@ -45,7 +50,7 @@ export const removeCleanupEventListeners = (): void => { }; export const removeAllEventListeners = (): void => { - removeExpiryCheckListener(); + clearEnvironmentStateExpiryCheckListener(); removePageUrlEventListeners(); removeClickEventListener(); removeExitIntentListener(); diff --git a/packages/js-core/src/website/lib/initialize.ts b/packages/js-core/src/website/lib/initialize.ts index 2bdf07df7c..4adfb713b7 100644 --- a/packages/js-core/src/website/lib/initialize.ts +++ b/packages/js-core/src/website/lib/initialize.ts @@ -1,5 +1,6 @@ -import type { TJSAppConfig, TJsWebsiteConfig, TJsWebsiteConfigInput } from "@formbricks/types/js"; +import type { TJsConfig, TJsWebsiteConfigInput, TJsWebsiteState } from "@formbricks/types/js"; import { WEBSITE_SURVEYS_LOCAL_STORAGE_KEY } from "../../shared/constants"; +import { fetchEnvironmentState } from "../../shared/environmentState"; import { ErrorHandler, MissingFieldError, @@ -12,15 +13,15 @@ import { wrapThrows, } from "../../shared/errors"; import { Logger } from "../../shared/logger"; +import { DEFAULT_PERSON_STATE_WEBSITE } from "../../shared/personState"; import { getIsDebug } from "../../shared/utils"; +import { filterSurveys as filterPublicSurveys } from "../../shared/utils"; import { trackNoCodeAction } from "./actions"; import { WebsiteConfig } from "./config"; import { addCleanupEventListeners, addEventListeners, removeAllEventListeners } from "./eventListeners"; import { checkPageUrl } from "./noCodeActions"; -import { sync } from "./sync"; import { addWidgetContainer, removeWidgetContainer, setIsSurveyRunning } from "./widget"; -const websiteConfig = WebsiteConfig.getInstance(); const logger = Logger.getInstance(); let isInitialized = false; @@ -29,6 +30,76 @@ export const setIsInitialized = (value: boolean) => { isInitialized = value; }; +const migrateLocalStorage = (): { changed: boolean; newState?: TJsConfig } => { + const oldConfig = localStorage.getItem(WEBSITE_SURVEYS_LOCAL_STORAGE_KEY); + + let newWebsiteConfig: TJsConfig; + if (oldConfig) { + const parsedOldConfig = JSON.parse(oldConfig); + // if the old config follows the older structure, we need to migrate it + if (parsedOldConfig.state || parsedOldConfig.expiresAt) { + logger.debug("Migrating local storage"); + const { apiHost, environmentId, state, expiresAt } = parsedOldConfig as { + apiHost: string; + environmentId: string; + state: TJsWebsiteState; + expiresAt: Date; + }; + const { displays: displaysState, actionClasses, product, surveys, attributes } = state; + + const responses = displaysState + .filter((display) => display.responded) + .map((display) => display.surveyId); + + const displays = displaysState.map((display) => ({ + surveyId: display.surveyId, + createdAt: display.createdAt, + })); + const lastDisplayAt = displaysState + ? displaysState.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0] + .createdAt + : null; + + newWebsiteConfig = { + apiHost, + environmentId, + environmentState: { + data: { + surveys, + actionClasses, + product, + }, + expiresAt, + }, + personState: { + expiresAt, + data: { + userId: null, + segments: [], + displays, + responses, + attributes: attributes ?? {}, + lastDisplayAt, + }, + }, + filteredSurveys: surveys, + status: { + value: "success", + expiresAt: null, + }, + }; + + logger.debug("Migrated local storage to new format"); + + return { changed: true, newState: newWebsiteConfig }; + } + + return { changed: false }; + } + + return { changed: false }; +}; + export const initialize = async ( configInput: TJsWebsiteConfigInput ): Promise> => { @@ -37,12 +108,27 @@ export const initialize = async ( logger.configure({ logLevel: "debug" }); } + const { changed, newState } = migrateLocalStorage(); + let websiteConfig = WebsiteConfig.getInstance(); + + // If the state was changed due to migration, reset and reinitialize the configuration + if (changed && newState) { + // The state exists in the local storage, so this should not fail + websiteConfig.resetConfig(); // Reset the configuration + + // Re-fetch a new instance of WebsiteConfig after resetting + websiteConfig = WebsiteConfig.getInstance(); + + // Update the new instance with the migrated state + websiteConfig.update(newState); + } + if (isInitialized) { logger.debug("Already initialized, skipping initialization."); return okVoid(); } - let existingConfig: TJsWebsiteConfig | undefined; + let existingConfig: TJsConfig | undefined; try { existingConfig = websiteConfig.get(); logger.debug("Found existing configuration."); @@ -51,7 +137,7 @@ export const initialize = async ( } // formbricks is in error state, skip initialization - if (existingConfig?.status === "error") { + if (existingConfig?.status?.value === "error") { if (isDebug) { logger.debug( "Formbricks is in error state, but debug mode is active. Resetting config and continuing." @@ -61,7 +147,8 @@ export const initialize = async ( } logger.debug("Formbricks was set to an error state."); - if (existingConfig?.expiresAt && new Date(existingConfig.expiresAt) > new Date()) { + + if (existingConfig?.status?.expiresAt && new Date(existingConfig?.status?.expiresAt) > new Date()) { logger.debug("Error state is not expired, skipping initialization"); return okVoid(); } else { @@ -95,22 +182,42 @@ export const initialize = async ( if ( existingConfig && - existingConfig.state && existingConfig.environmentId === configInput.environmentId && existingConfig.apiHost === configInput.apiHost && - existingConfig.expiresAt + existingConfig.environmentState ) { logger.debug("Configuration fits init parameters."); - if (existingConfig.expiresAt < new Date()) { + if (existingConfig.environmentState.expiresAt < new Date()) { logger.debug("Configuration expired."); try { - await sync({ + // fetch the environment state + + const environmentState = await fetchEnvironmentState( + { + apiHost: configInput.apiHost, + environmentId: configInput.environmentId, + }, + "website" + ); + + // filter the surveys with the default person state + + const filteredSurveys = filterPublicSurveys( + environmentState, + DEFAULT_PERSON_STATE_WEBSITE, + "website" + ); + + websiteConfig.update({ apiHost: configInput.apiHost, environmentId: configInput.environmentId, + environmentState, + personState: DEFAULT_PERSON_STATE_WEBSITE, + filteredSurveys, }); } catch (e) { - putFormbricksInErrorState(); + putFormbricksInErrorState(websiteConfig); } } else { logger.debug("Configuration not expired. Extending expiration."); @@ -124,9 +231,22 @@ export const initialize = async ( logger.debug("Syncing."); try { - await sync({ + const environmentState = await fetchEnvironmentState( + { + apiHost: configInput.apiHost, + environmentId: configInput.environmentId, + }, + "website" + ); + + const filteredSurveys = filterPublicSurveys(environmentState, DEFAULT_PERSON_STATE_WEBSITE, "website"); + + websiteConfig.update({ apiHost: configInput.apiHost, environmentId: configInput.environmentId, + environmentState, + personState: DEFAULT_PERSON_STATE_WEBSITE, + filteredSurveys, }); } catch (e) { handleErrorOnFirstInit(); @@ -136,13 +256,14 @@ export const initialize = async ( const currentWebsiteConfig = websiteConfig.get(); websiteConfig.update({ - environmentId: currentWebsiteConfig.environmentId, - apiHost: currentWebsiteConfig.apiHost, - state: { - ...websiteConfig.get().state, - attributes: { ...websiteConfig.get().state.attributes, ...configInput.attributes }, + ...currentWebsiteConfig, + personState: { + ...currentWebsiteConfig.personState, + data: { + ...currentWebsiteConfig.personState.data, + attributes: { ...currentWebsiteConfig.personState.data.attributes, ...configInput.attributes }, + }, }, - expiresAt: websiteConfig.get().expiresAt, }); } @@ -151,7 +272,7 @@ export const initialize = async ( } logger.debug("Adding event listeners"); - addEventListeners(); + addEventListeners(websiteConfig); addCleanupEventListeners(); setIsInitialized(true); @@ -163,17 +284,19 @@ export const initialize = async ( return okVoid(); }; -const handleErrorOnFirstInit = () => { +export const handleErrorOnFirstInit = () => { if (getIsDebug()) { logger.debug("Not putting formbricks in error state because debug mode is active (no error state)"); return; } - // put formbricks in error state (by creating a new config) and throw error - const initialErrorConfig: Partial = { - status: "error", - expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future + const initialErrorConfig: Partial = { + status: { + value: "error", + expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future + }, }; + // can't use config.update here because the config is not yet initialized wrapThrows(() => localStorage.setItem(WEBSITE_SURVEYS_LOCAL_STORAGE_KEY, JSON.stringify(initialErrorConfig)) @@ -201,7 +324,7 @@ export const deinitalize = (): void => { setIsInitialized(false); }; -export const putFormbricksInErrorState = (): void => { +export const putFormbricksInErrorState = (websiteConfig: WebsiteConfig): void => { if (getIsDebug()) { logger.debug("Not putting formbricks in error state because debug mode is active (no error state)"); return; @@ -211,8 +334,10 @@ export const putFormbricksInErrorState = (): void => { // change formbricks status to error websiteConfig.update({ ...websiteConfig.get(), - status: "error", - expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future + status: { + value: "error", + expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future + }, }); deinitalize(); }; diff --git a/packages/js-core/src/website/lib/noCodeActions.ts b/packages/js-core/src/website/lib/noCodeActions.ts index 085e021944..fc36941bcd 100644 --- a/packages/js-core/src/website/lib/noCodeActions.ts +++ b/packages/js-core/src/website/lib/noCodeActions.ts @@ -17,8 +17,7 @@ let arePageUrlEventListenersAdded = false; export const checkPageUrl = async (): Promise> => { logger.debug(`Checking page url: ${window.location.href}`); - const { state } = websiteConfig.get(); - const { actionClasses = [] } = state ?? {}; + const actionClasses = websiteConfig.get().environmentState.data.actionClasses; const noCodePageViewActionClasses = actionClasses.filter( (action) => action.type === "noCode" && action.noCodeConfig?.type === "pageView" @@ -55,10 +54,10 @@ export const removePageUrlEventListeners = (): void => { let isClickEventListenerAdded = false; const checkClickMatch = (event: MouseEvent) => { - const { state } = websiteConfig.get(); - if (!state) return; + const { environmentState } = websiteConfig.get(); + if (!environmentState) return; - const { actionClasses = [] } = state; + const { actionClasses = [] } = environmentState.data; const noCodeClickActionClasses = actionClasses.filter( (action) => action.type === "noCode" && action.noCodeConfig?.type === "click" ); @@ -96,8 +95,7 @@ export const removeClickEventListener = (): void => { let isExitIntentListenerAdded = false; const checkExitIntent = async (e: MouseEvent) => { - const { state } = websiteConfig.get(); - const { actionClasses = [] } = state ?? {}; + const actionClasses = websiteConfig.get().environmentState.data.actionClasses; const noCodeExitIntentActionClasses = actionClasses.filter( (action) => action.type === "noCode" && action.noCodeConfig?.type === "exitIntent" @@ -148,8 +146,7 @@ const checkScrollDepth = async () => { if (!scrollDepthTriggered && scrollPosition / (bodyHeight - windowSize) >= 0.5) { scrollDepthTriggered = true; - const { state } = websiteConfig.get(); - const { actionClasses = [] } = state ?? {}; + const actionClasses = websiteConfig.get().environmentState.data.actionClasses; const noCodefiftyPercentScrollActionClasses = actionClasses.filter( (action) => action.type === "noCode" && action.noCodeConfig?.type === "fiftyPercentScroll" diff --git a/packages/js-core/src/website/lib/sync.ts b/packages/js-core/src/website/lib/sync.ts deleted file mode 100644 index 1dbf280311..0000000000 --- a/packages/js-core/src/website/lib/sync.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { diffInDays } from "@formbricks/lib/utils/datetime"; -import { TJsWebsiteState, TJsWebsiteSyncParams } from "@formbricks/types/js"; -import { TSurvey } from "@formbricks/types/surveys/types"; -import { NetworkError, Result, err, ok } from "../../shared/errors"; -import { Logger } from "../../shared/logger"; -import { getIsDebug } from "../../shared/utils"; -import { WebsiteConfig } from "./config"; - -const websiteConfig = WebsiteConfig.getInstance(); -const logger = Logger.getInstance(); - -let syncIntervalId: number | null = null; - -const syncWithBackend = async ( - { apiHost, environmentId }: TJsWebsiteSyncParams, - noCache: boolean -): Promise> => { - try { - const baseUrl = `${apiHost}/api/v1/client/${environmentId}/website/sync`; - const urlSuffix = `?version=${import.meta.env.VERSION}`; - - let fetchOptions: RequestInit = {}; - - if (noCache || getIsDebug()) { - fetchOptions.cache = "no-cache"; - logger.debug("No cache option set for sync"); - } - - // if user id is not available - const url = baseUrl + urlSuffix; - // public survey - const response = await fetch(url, fetchOptions); - - if (!response.ok) { - const jsonRes = await response.json(); - - return err({ - code: "network_error", - status: response.status, - message: "Error syncing with backend", - url, - responseMessage: jsonRes.message, - }); - } - - return ok((await response.json()).data as TJsWebsiteState); - } catch (e) { - return err(e as NetworkError); - } -}; - -export const sync = async (params: TJsWebsiteSyncParams, noCache = false): Promise => { - try { - const syncResult = await syncWithBackend(params, noCache); - - if (syncResult?.ok !== true) { - throw syncResult.error; - } - - let oldState: TJsWebsiteState | undefined; - try { - oldState = websiteConfig.get().state; - } catch (e) { - // ignore error - } - - let state: TJsWebsiteState = { - surveys: syncResult.value.surveys as TSurvey[], - actionClasses: syncResult.value.actionClasses, - product: syncResult.value.product, - displays: oldState?.displays || [], - }; - - state = filterPublicSurveys(state); - - const surveyNames = state.surveys.map((s) => s.name); - logger.debug("Fetched " + surveyNames.length + " surveys during sync: " + surveyNames.join(", ")); - - websiteConfig.update({ - apiHost: params.apiHost, - environmentId: params.environmentId, - state, - expiresAt: new Date(new Date().getTime() + 2 * 60000), // 2 minutes in the future - }); - } catch (error) { - console.error(`Error during sync: ${error}`); - throw error; - } -}; - -export const filterPublicSurveys = (state: TJsWebsiteState): TJsWebsiteState => { - const { displays, product } = state; - - let { surveys } = state; - - if (!displays) { - return state; - } - - // Function to filter surveys based on displayOption criteria - let filteredSurveys = surveys.filter((survey: TSurvey) => { - switch (survey.displayOption) { - case "respondMultiple": - return true; - case "displayOnce": - return displays.filter((display) => display.surveyId === survey.id).length === 0; - case "displayMultiple": - return ( - displays.filter((display) => display.surveyId === survey.id).filter((display) => display.responded) - .length === 0 - ); - - case "displaySome": - if (survey.displayLimit === null) { - return true; - } - - // Check if any display has responded, if so, stop here - if ( - displays.filter((display) => display.surveyId === survey.id).some((display) => display.responded) - ) { - return false; - } - - // Otherwise, check if displays length is less than displayLimit - return displays.filter((display) => display.surveyId === survey.id).length < survey.displayLimit; - - default: - throw Error("Invalid displayOption"); - } - }); - - const latestDisplay = displays.length > 0 ? displays[displays.length - 1] : undefined; - - // filter surveys that meet the recontactDays criteria - filteredSurveys = filteredSurveys.filter((survey) => { - if (!latestDisplay) { - return true; - } else if (survey.recontactDays !== null) { - const lastDisplaySurvey = displays.filter((display) => display.surveyId === survey.id)[0]; - if (!lastDisplaySurvey) { - return true; - } - return diffInDays(new Date(), new Date(lastDisplaySurvey.createdAt)) >= survey.recontactDays; - } else if (product.recontactDays !== null) { - return diffInDays(new Date(), new Date(latestDisplay.createdAt)) >= product.recontactDays; - } else { - return true; - } - }); - - return { - ...state, - surveys: filteredSurveys, - }; -}; - -export const addExpiryCheckListener = (): void => { - const updateInterval = 1000 * 30; // every 30 seconds - // add event listener to check sync with backend on regular interval - if (typeof window !== "undefined" && syncIntervalId === null) { - syncIntervalId = window.setInterval(async () => { - try { - // check if the config has not expired yet - if (websiteConfig.get().expiresAt && new Date(websiteConfig.get().expiresAt) >= new Date()) { - return; - } - logger.debug("Config has expired. Starting sync."); - await sync({ - apiHost: websiteConfig.get().apiHost, - environmentId: websiteConfig.get().environmentId, - }); - } catch (e) { - console.error(`Error during expiry check: ${e}`); - logger.debug("Extending config and try again later."); - const existingConfig = websiteConfig.get(); - websiteConfig.update(existingConfig); - } - }, updateInterval); - } -}; - -export const removeExpiryCheckListener = (): void => { - if (typeof window !== "undefined" && syncIntervalId !== null) { - window.clearInterval(syncIntervalId); - - syncIntervalId = null; - } -}; diff --git a/packages/js-core/src/website/lib/widget.ts b/packages/js-core/src/website/lib/widget.ts index 2a33cc4eb4..e233004868 100644 --- a/packages/js-core/src/website/lib/widget.ts +++ b/packages/js-core/src/website/lib/widget.ts @@ -2,14 +2,14 @@ import { FormbricksAPI } from "@formbricks/api"; import { ResponseQueue } from "@formbricks/lib/responseQueue"; import { SurveyState } from "@formbricks/lib/surveyState"; import { getStyling } from "@formbricks/lib/utils/styling"; -import { TJSWebsiteStateDisplay, TJsTrackProperties } from "@formbricks/types/js"; +import { TJsPersonState, TJsTrackProperties } from "@formbricks/types/js"; import { TResponseHiddenFieldValue, TResponseUpdate } from "@formbricks/types/responses"; import { TUploadFileConfig } from "@formbricks/types/storage"; import { TSurvey } from "@formbricks/types/surveys/types"; import { Logger } from "../../shared/logger"; +import { filterSurveys as filterPublicSurveys } from "../../shared/utils"; import { getDefaultLanguageCode, getLanguageCode, handleHiddenFields } from "../../shared/utils"; import { WebsiteConfig } from "./config"; -import { filterPublicSurveys } from "./sync"; const containerId = "formbricks-website-container"; @@ -66,8 +66,8 @@ const renderWidget = async ( logger.debug(`Delaying survey by ${survey.delay} seconds.`); } - const product = websiteConfig.get().state.product; - const attributes = websiteConfig.get().state.attributes; + const product = websiteConfig.get().environmentState.data.product; + const attributes = websiteConfig.get().personState.data.attributes; const isMultiLanguageSurvey = survey.languages.length > 1; let languageCode = "default"; @@ -122,26 +122,6 @@ const renderWidget = async ( setIsResponseSendingFinished = f; }, onDisplay: async () => { - const localDisplay: TJSWebsiteStateDisplay = { - createdAt: new Date(), - surveyId: survey.id, - responded: false, - }; - - const existingDisplays = websiteConfig.get().state.displays; - const displays = existingDisplays ? [...existingDisplays, localDisplay] : [localDisplay]; - const previousConfig = websiteConfig.get(); - - let state = filterPublicSurveys({ - ...previousConfig.state, - displays, - }); - - websiteConfig.update({ - ...previousConfig, - state, - }); - const api = new FormbricksAPI({ apiHost: websiteConfig.get().apiHost, environmentId: websiteConfig.get().environmentId, @@ -156,27 +136,44 @@ const renderWidget = async ( const { id } = res.data; + const existingDisplays = websiteConfig.get().personState.data.displays; + const newDisplay = { surveyId: survey.id, createdAt: new Date() }; + const displays = existingDisplays ? [...existingDisplays, newDisplay] : [newDisplay]; + const previousConfig = websiteConfig.get(); + + const updatedPersonState: TJsPersonState = { + ...previousConfig.personState, + data: { + ...previousConfig.personState.data, + displays, + lastDisplayAt: new Date(), + }, + }; + + const filteredSurveys = filterPublicSurveys( + previousConfig.environmentState, + updatedPersonState, + "website" + ); + + websiteConfig.update({ + ...previousConfig, + environmentState: previousConfig.environmentState, + personState: updatedPersonState, + filteredSurveys, + }); + surveyState.updateDisplayId(id); responseQueue.updateSurveyState(surveyState); }, onResponse: (responseUpdate: TResponseUpdate) => { - const displays = websiteConfig.get().state.displays; + const displays = websiteConfig.get().personState.data.displays; const lastDisplay = displays && displays[displays.length - 1]; if (!lastDisplay) { throw new Error("No lastDisplay found"); } - if (!lastDisplay.responded) { - lastDisplay.responded = true; - const previousConfig = websiteConfig.get(); - let state = filterPublicSurveys({ - ...previousConfig.state, - displays, - }); - websiteConfig.update({ - ...previousConfig, - state, - }); - } + + const isNewResponse = surveyState.responseId === null; responseQueue.updateSurveyState(surveyState); @@ -192,6 +189,30 @@ const renderWidget = async ( }, hiddenFields, }); + + if (isNewResponse) { + const responses = websiteConfig.get().personState.data.responses; + const newPersonState: TJsPersonState = { + ...websiteConfig.get().personState, + data: { + ...websiteConfig.get().personState.data, + responses: [...responses, surveyState.surveyId], + }, + }; + + const filteredSurveys = filterPublicSurveys( + websiteConfig.get().environmentState, + newPersonState, + "website" + ); + + websiteConfig.update({ + ...websiteConfig.get(), + environmentState: websiteConfig.get().environmentState, + personState: newPersonState, + filteredSurveys, + }); + } }, onClose: closeSurvey, onFileUpload: async ( @@ -226,11 +247,13 @@ export const closeSurvey = async (): Promise => { removeWidgetContainer(); addWidgetContainer(); - const state = websiteConfig.get().state; - const updatedState = filterPublicSurveys(state); + const { environmentState, personState } = websiteConfig.get(); + const filteredSurveys = filterPublicSurveys(environmentState, personState, "website"); websiteConfig.update({ ...websiteConfig.get(), - state: updatedState, + environmentState, + personState, + filteredSurveys, }); setIsSurveyRunning(false); return; diff --git a/packages/js/index.html b/packages/js/index.html index c453f7dd1c..367569a253 100644 --- a/packages/js/index.html +++ b/packages/js/index.html @@ -7,7 +7,7 @@ e.parentNode.insertBefore(t, e), setTimeout(function () { formbricks.init({ - environmentId: "cm020vmv0000cpq4xvxabpo8x", + environmentId: "cm14wcs5m0005b3aezc4a6ejf", userId: "RANDOM_USER_ID", apiHost: "http://localhost:3000", }); diff --git a/packages/lib/display/cache.ts b/packages/lib/display/cache.ts index bb6b628ae8..a77e640e93 100644 --- a/packages/lib/display/cache.ts +++ b/packages/lib/display/cache.ts @@ -4,6 +4,7 @@ interface RevalidateProps { id?: string; surveyId?: string; personId?: string | null; + userId?: string; environmentId?: string; } @@ -18,11 +19,18 @@ export const displayCache = { byPersonId(personId: string) { return `people-${personId}-displays`; }, + byEnvironmentIdAndUserId(environmentId: string, userId: string) { + return `environments-${environmentId}-users-${userId}-displays`; + }, byEnvironmentId(environmentId: string) { return `environments-${environmentId}-displays`; }, }, - revalidate({ id, surveyId, personId, environmentId }: RevalidateProps): void { + revalidate({ id, surveyId, personId, environmentId, userId }: RevalidateProps): void { + if (environmentId && userId) { + revalidateTag(this.tag.byEnvironmentIdAndUserId(environmentId, userId)); + } + if (id) { revalidateTag(this.tag.byId(id)); } diff --git a/packages/lib/display/service.ts b/packages/lib/display/service.ts index a925bf61a0..3abbd8b265 100644 --- a/packages/lib/display/service.ts +++ b/packages/lib/display/service.ts @@ -2,7 +2,7 @@ import "server-only"; import { Prisma } from "@prisma/client"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; -import { ZOptionalNumber } from "@formbricks/types/common"; +import { ZOptionalNumber, ZString } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common"; import { TDisplay, @@ -145,6 +145,8 @@ export const createDisplay = async (displayInput: TDisplayCreateInput): Promise< id: display.id, personId: display.personId, surveyId: display.surveyId, + userId, + environmentId, }); return display; } catch (error) { @@ -191,6 +193,47 @@ export const getDisplaysByPersonId = reactCache( )() ); +export const getDisplaysByUserId = reactCache( + async (environmentId: string, userId: string, page?: number): Promise => + cache( + async () => { + validateInputs([environmentId, ZId], [userId, ZString], [page, ZOptionalNumber]); + + const person = await getPersonByUserId(environmentId, userId); + + if (!person) { + throw new ResourceNotFoundError("person", userId); + } + + try { + const displays = await prisma.display.findMany({ + where: { + personId: person.id, + }, + select: selectDisplay, + take: page ? ITEMS_PER_PAGE : undefined, + skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined, + orderBy: { + createdAt: "desc", + }, + }); + + return displays; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + }, + [`getDisplaysByUserId-${environmentId}-${userId}-${page}`], + { + tags: [displayCache.tag.byEnvironmentIdAndUserId(environmentId, userId)], + } + )() +); + export const deleteDisplayByResponseId = async ( responseId: string, surveyId: string diff --git a/packages/lib/i18n/i18n.mock.ts b/packages/lib/i18n/i18n.mock.ts index ff94c1712d..417bbe05b4 100644 --- a/packages/lib/i18n/i18n.mock.ts +++ b/packages/lib/i18n/i18n.mock.ts @@ -514,45 +514,3 @@ export const mockLegacyThankYouCard = { subheader: "We appreciate your feedback.", buttonLabel: "Create your own Survey", }; - -export const mockTranslatedSurvey = { - ...mockSurvey, - questions: [ - mockTranslatedOpenTextQuestion, - mockTranslatedSingleSelectQuestion, - mockTranslatedMultiSelectQuestion, - mockTranslatedPictureSelectQuestion, - mockTranslatedRatingQuestion, - mockTranslatedNpsQuestion, - mockTranslatedCtaQuestion, - mockTranslatedConsentQuestion, - mockTranslatedDateQuestion, - mockTranslatedFileUploadQuestion, - mockTranslatedCalQuestion, - ], - welcomeCard: mockTranslatedWelcomeCard, - endings: mockTranslatedEndings, -}; - -export const mockLegacySurvey = { - ...mockSurvey, - createdAt: new Date("2024-02-06T20:12:03.521Z"), - updatedAt: new Date("2024-02-06T20:12:03.521Z"), - questions: [ - mockLegacyOpenTextQuestion, - mockLegacySingleSelectQuestion, - mockLegacyMultiSelectQuestion, - mockLegacyPictureSelectQuestion, - mockLegacyRatingQuestion, - mockLegacyNpsQuestion, - mockLegacyCtaQuestion, - mockLegacyConsentQuestion, - mockLegacyDateQuestion, - mockLegacyFileUploadQuestion, - mockLegacyCalQuestion, - ], - welcomeCard: mockLegacyWelcomeCard, - thankYouCard: mockLegacyThankYouCard, - endings: undefined, - redirectUrl: null, -}; diff --git a/packages/lib/i18n/i18n.test.ts b/packages/lib/i18n/i18n.test.ts index e3b3301b78..28f19b4336 100644 --- a/packages/lib/i18n/i18n.test.ts +++ b/packages/lib/i18n/i18n.test.ts @@ -1,6 +1,4 @@ import { describe, expect, it } from "vitest"; -import { mockLegacySurvey, mockTranslatedSurvey } from "./i18n.mock"; -import { reverseTranslateSurvey } from "./reverseTranslation"; import { createI18nString } from "./utils"; describe("createI18nString", () => { @@ -34,10 +32,3 @@ describe("createI18nString", () => { }); }); }); - -describe("translate to Legacy Survey", () => { - it("should translate all questions of a normal survey to Legacy Survey", () => { - const translatedSurvey = reverseTranslateSurvey(mockTranslatedSurvey, "default"); - expect(translatedSurvey).toEqual(mockLegacySurvey); - }); -}); diff --git a/packages/lib/i18n/reverseTranslation.ts b/packages/lib/i18n/reverseTranslation.ts index d3da76279b..0a5502202c 100644 --- a/packages/lib/i18n/reverseTranslation.ts +++ b/packages/lib/i18n/reverseTranslation.ts @@ -1,7 +1,7 @@ import "server-only"; -import { TI18nString, TSurvey } from "@formbricks/types/surveys/types"; +import { TI18nString } from "@formbricks/types/surveys/types"; import { structuredClone } from "../pollyfills/structuredClone"; -import { getLocalizedValue, isI18nObject } from "./utils"; +import { isI18nObject } from "./utils"; // Helper function to extract a regular string from an i18nString. const extractStringFromI18n = (i18nString: TI18nString, languageCode: string): string => { @@ -26,45 +26,3 @@ const reverseTranslateObject = >(obj: T, languageC } return clonedObj; }; - -const reverseTranslateEndings = (survey: TSurvey, languageCode: string): any => { - const firstEndingCard = survey.endings[0]; - if (firstEndingCard && firstEndingCard.type === "endScreen") { - return { - headline: getLocalizedValue(firstEndingCard.headline, languageCode), - subheader: getLocalizedValue(firstEndingCard.subheader, languageCode), - buttonLabel: getLocalizedValue(firstEndingCard.buttonLabel, languageCode), - buttonLink: firstEndingCard.buttonLink, - enabled: true, - }; - } else { - return { enabled: false }; - } -}; - -export const reverseTranslateSurvey = (survey: TSurvey, languageCode: string = "default"): any => { - const reversedSurvey = structuredClone(survey); - reversedSurvey.questions = reversedSurvey.questions.map((question) => - reverseTranslateObject(question, languageCode) - ); - - // check if the headline is an empty object, if so, add a "default" key - // TODO: This is a temporary fix, should be handled propperly - if (reversedSurvey.welcomeCard.headline && Object.keys(reversedSurvey.welcomeCard.headline).length === 0) { - reversedSurvey.welcomeCard.headline = { default: "" }; - } - - reversedSurvey.welcomeCard = reverseTranslateObject(reversedSurvey.welcomeCard, languageCode); - // @ts-expect-error - reversedSurvey.thankYouCard = reverseTranslateEndings(reversedSurvey, languageCode); - const firstEndingCard = survey.endings[0]; - // @ts-expect-error - reversedSurvey.redirectUrl = null; - if (firstEndingCard?.type === "redirectToUrl") { - // @ts-expect-error - reversedSurvey.redirectUrl = firstEnabledEnding.url; - } - // @ts-expect-error - reversedSurvey.endings = undefined; - return reversedSurvey; -}; diff --git a/packages/lib/response/cache.ts b/packages/lib/response/cache.ts index a835cda683..818e2bfd8f 100644 --- a/packages/lib/response/cache.ts +++ b/packages/lib/response/cache.ts @@ -4,6 +4,7 @@ interface RevalidateProps { id?: string; environmentId?: string; personId?: string; + userId?: string; singleUseId?: string; surveyId?: string; } @@ -19,6 +20,9 @@ export const responseCache = { byPersonId(personId: string) { return `people-${personId}-responses`; }, + byEnvironmentIdAndUserId(environmentId: string, userId: string) { + return `environments-${environmentId}-users-${userId}-responses`; + }, bySingleUseId(surveyId: string, singleUseId: string) { return `surveys-${surveyId}-singleUse-${singleUseId}-responses`; }, @@ -26,7 +30,7 @@ export const responseCache = { return `surveys-${surveyId}-responses`; }, }, - revalidate({ environmentId, personId, id, singleUseId, surveyId }: RevalidateProps): void { + revalidate({ environmentId, personId, id, singleUseId, surveyId, userId }: RevalidateProps): void { if (id) { revalidateTag(this.tag.byId(id)); } @@ -43,6 +47,10 @@ export const responseCache = { revalidateTag(this.tag.byEnvironmentId(environmentId)); } + if (environmentId && userId) { + revalidateTag(this.tag.byEnvironmentIdAndUserId(environmentId, userId)); + } + if (surveyId && singleUseId) { revalidateTag(this.tag.bySingleUseId(surveyId, singleUseId)); } diff --git a/packages/lib/response/service.ts b/packages/lib/response/service.ts index 2ccfe3b524..7d7253e588 100644 --- a/packages/lib/response/service.ts +++ b/packages/lib/response/service.ts @@ -97,7 +97,7 @@ export const responseSelection = { isEdited: true, }, }, -}; +} satisfies Prisma.ResponseSelect; export const getResponsesByPersonId = reactCache( (personId: string, page?: number): Promise => @@ -151,6 +151,61 @@ export const getResponsesByPersonId = reactCache( )() ); +export const getResponsesByUserId = reactCache( + (environmentId: string, userId: string, page?: number): Promise => + cache( + async () => { + validateInputs([environmentId, ZId], [userId, ZString], [page, ZOptionalNumber]); + + const person = await getPersonByUserId(environmentId, userId); + + if (!person) { + throw new ResourceNotFoundError("Person", userId); + } + + try { + const responsePrisma = await prisma.response.findMany({ + where: { + personId: person.id, + }, + select: responseSelection, + take: page ? ITEMS_PER_PAGE : undefined, + skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined, + orderBy: { + createdAt: "desc", + }, + }); + + if (!responsePrisma) { + throw new ResourceNotFoundError("Response from PersonId", person.id); + } + + const responsePromises = responsePrisma.map(async (response) => { + const tags = response.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag); + + return { + ...response, + tags, + }; + }); + + const responses = await Promise.all(responsePromises); + return responses; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } + }, + [`getResponsesByUserId-${environmentId}-${userId}-${page}`], + { + tags: [responseCache.tag.byEnvironmentIdAndUserId(environmentId, userId)], + } + )() +); + export const getResponseBySingleUseId = reactCache( (surveyId: string, singleUseId: string): Promise => cache( @@ -269,6 +324,7 @@ export const createResponse = async (responseInput: TResponseInput): Promise => { + if (!filters.length) { + // if there are no filters, the segment will be evaluated as true + return true; + } + let resultPairs: ResultConnectorPair[] = []; try { diff --git a/packages/lib/segment/utils.ts b/packages/lib/segment/utils.ts index ee10015fd5..2f3fc5875c 100644 --- a/packages/lib/segment/utils.ts +++ b/packages/lib/segment/utils.ts @@ -557,50 +557,3 @@ export const isAdvancedSegment = (filters: TBaseFilters): boolean => { return false; }; - -type TAttributeFilter = { - attributeClassName: string; - operator: TAttributeOperator; - value: string; -}; - -export const transformSegmentFiltersToAttributeFilters = ( - filters: TBaseFilters -): TAttributeFilter[] | null => { - const attributeFilters: TAttributeFilter[] = []; - - for (let filter of filters) { - const { resource } = filter; - - if (isResourceFilter(resource)) { - const { root, qualifier, value } = resource; - const { type } = root; - - if (type === "attribute") { - const { attributeClassName } = root; - const { operator } = qualifier; - - attributeFilters.push({ - attributeClassName, - operator: operator as TAttributeOperator, - value: value.toString(), - }); - } - - if (type === "person") { - const { operator } = qualifier; - - attributeFilters.push({ - attributeClassName: "userId", - operator: operator as TAttributeOperator, - value: value.toString(), - }); - } - } else { - // the resource is a group, so we don't need to recurse, we know that this is an advanced segment - return null; - } - } - - return attributeFilters; -}; diff --git a/packages/lib/survey/service.ts b/packages/lib/survey/service.ts index 61c73abf38..4811142683 100644 --- a/packages/lib/survey/service.ts +++ b/packages/lib/survey/service.ts @@ -38,7 +38,6 @@ import { getProductByEnvironmentId } from "../product/service"; import { responseCache } from "../response/cache"; import { segmentCache } from "../segment/cache"; import { createSegment, deleteSegment, evaluateSegment, getSegment, updateSegment } from "../segment/service"; -import { transformSegmentFiltersToAttributeFilters } from "../segment/utils"; import { diffInDays } from "../utils/datetime"; import { validateInputs } from "../utils/validate"; import { surveyCache } from "./cache"; @@ -1096,10 +1095,7 @@ export const getSyncSurveys = reactCache( ( environmentId: string, personId: string, - deviceType: "phone" | "desktop" = "desktop", - options?: { - version?: string; - } + deviceType: "phone" | "desktop" = "desktop" ): Promise => cache( async () => { @@ -1208,42 +1204,6 @@ export const getSyncSurveys = reactCache( return survey; } - // backwards compatibility for older versions of the js package - // if the version is not provided, we will use the old method of evaluating the segment, which is attribute filters - // transform the segment filters to attribute filters and evaluate them - if (!options?.version) { - const attributeFilters = transformSegmentFiltersToAttributeFilters(segment.filters); - - // if the attribute filters are null, it means the segment filters don't match the expected format for attribute filters, so we skip this survey - if (attributeFilters === null) { - return null; - } - - // if there are no attribute filters, we return the survey - if (!attributeFilters.length) { - return survey; - } - - // we check if the person meets the attribute filters for all the attribute filters - const isEligible = attributeFilters.every((attributeFilter) => { - const personAttributeValue = attributes[attributeFilter.attributeClassName]; - if (!personAttributeValue) { - return false; - } - - if (attributeFilter.operator === "equals") { - return personAttributeValue === attributeFilter.value; - } else if (attributeFilter.operator === "notEquals") { - return personAttributeValue !== attributeFilter.value; - } else { - // if the operator is not equals or not equals, we skip the survey, this means that new segment filter options are being used - return false; - } - }); - - return isEligible ? survey : null; - } - // Evaluate the segment filters const result = await evaluateSegment( { diff --git a/packages/lib/survey/utils.ts b/packages/lib/survey/utils.ts index 6ffa786a99..e1106b233a 100644 --- a/packages/lib/survey/utils.ts +++ b/packages/lib/survey/utils.ts @@ -2,7 +2,6 @@ import "server-only"; import { Prisma } from "@prisma/client"; import { TSegment } from "@formbricks/types/segment"; import { TSurvey, TSurveyFilterCriteria } from "@formbricks/types/surveys/types"; -import { reverseTranslateSurvey } from "../i18n/reverseTranslation"; export const transformPrismaSurvey = (surveyPrisma: any): TSurvey => { let segment: TSegment | null = null; @@ -87,17 +86,3 @@ export const anySurveyHasFilters = (surveys: TSurvey[]): boolean => { return false; }); }; - -export const transformToLegacySurvey = async (survey: TSurvey, languageCode?: string): Promise => { - const targetLanguage = languageCode ?? "default"; - - // workaround to handle triggers for legacy surveys - // because we dont wanna do this in the `reverseTranslateSurvey` function - const surveyToTransform: any = { - ...structuredClone(survey), - triggers: survey.triggers.map((trigger) => trigger.actionClass.name), - }; - - const transformedSurvey = reverseTranslateSurvey(surveyToTransform as TSurvey, targetLanguage); - return transformedSurvey; -}; diff --git a/packages/lib/surveyState.ts b/packages/lib/surveyState.ts index 5a8d1d83a8..cb0e9fcce8 100644 --- a/packages/lib/surveyState.ts +++ b/packages/lib/surveyState.ts @@ -52,8 +52,8 @@ export class SurveyState { } /** - * Update the response ID after a successful response creation - * @param id - The response ID + * Update the display ID after a successful display creation + * @param id - The display ID */ updateDisplayId(id: string) { this.displayId = id; diff --git a/packages/lib/utils/version.ts b/packages/lib/utils/version.ts deleted file mode 100644 index 8e9ed3b39c..0000000000 --- a/packages/lib/utils/version.ts +++ /dev/null @@ -1,17 +0,0 @@ -export const isVersionGreaterThanOrEqualTo = (version: string, specificVersion: string) => { - // return true; // uncomment when testing in demo app - if (!version || !specificVersion) return false; - - const parts1 = version.split(".").map(Number); - const parts2 = specificVersion.split(".").map(Number); - - for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) { - const num1 = parts1[i] || 0; - const num2 = parts2[i] || 0; - - if (num1 > num2) return true; - if (num1 < num2) return false; - } - - return true; -}; diff --git a/packages/react-native/package.json b/packages/react-native/package.json index d8bd16e345..f142a61278 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -1,6 +1,6 @@ { "name": "@formbricks/react-native", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "description": "Formbricks React Native SDK allows you to connect your app to Formbricks, display surveys and trigger events.", "homepage": "https://formbricks.com", diff --git a/packages/react-native/src/lib/attributes.ts b/packages/react-native/src/lib/attributes.ts index 5d0aba8d8f..423ba58767 100644 --- a/packages/react-native/src/lib/attributes.ts +++ b/packages/react-native/src/lib/attributes.ts @@ -1,6 +1,4 @@ -/* eslint-disable @typescript-eslint/no-unsafe-member-access -- required */ - -/* eslint-disable @typescript-eslint/no-dynamic-delete -- required */ +/* eslint-disable @typescript-eslint/no-unnecessary-condition -- could be undefined */ import { FormbricksAPI } from "@formbricks/api"; import type { TAttributes } from "@formbricks/types/attributes"; import { type Result, err, ok } from "@formbricks/types/error-handlers"; @@ -20,10 +18,14 @@ export const updateAttributes = async ( const updatedAttributes = { ...attributes }; try { - const existingAttributes = appConfig.get().state.attributes; - for (const [key, value] of Object.entries(existingAttributes)) { - if (updatedAttributes[key] === value) { - delete updatedAttributes[key]; + const existingAttributes = appConfig.get()?.state?.attributes; + + if (existingAttributes) { + for (const [key, value] of Object.entries(existingAttributes)) { + if (updatedAttributes[key] === value) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- required + delete updatedAttributes[key]; + } } } } catch (e) { @@ -48,9 +50,11 @@ export const updateAttributes = async ( if (res.ok) { return ok(updatedAttributes); } - // @ts-expect-error -- required because we set ignore + + // @ts-expect-error -- details is not defined in the error type + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- required if (res.error.details?.ignore) { - logger.error(`Error updating person with userId ${userId}`); + logger.error(res.error.message ?? `Error updating person with userId ${userId}`); return ok(updatedAttributes); } diff --git a/packages/react-native/src/lib/config.ts b/packages/react-native/src/lib/config.ts index 78473bfe59..25696cc3fd 100644 --- a/packages/react-native/src/lib/config.ts +++ b/packages/react-native/src/lib/config.ts @@ -1,11 +1,90 @@ +/* eslint-disable no-console -- Required for error logging */ import AsyncStorage from "@react-native-async-storage/async-storage"; -import { AppConfig, type StorageHandler } from "../../../js-core/src/app/lib/config"; +import { type Result, err, ok, wrapThrowsAsync } from "@formbricks/types/error-handlers"; +import type { TJsAppConfigUpdateInput, TJsRNConfig } from "@formbricks/types/js"; import { RN_ASYNC_STORAGE_KEY } from "../../../js-core/src/shared/constants"; -const storageHandler: StorageHandler = { - getItem: async (key: string) => AsyncStorage.getItem(key), - setItem: async (key: string, value: string) => AsyncStorage.setItem(key, value), - removeItem: async (key: string) => AsyncStorage.removeItem(key), -}; +// LocalStorage implementation - default -export const appConfig = AppConfig.getInstance(storageHandler, RN_ASYNC_STORAGE_KEY); +export class RNConfig { + private static instance: RNConfig | undefined; + private config: TJsRNConfig | null = null; + + private constructor() { + // const localConfig = this.loadFromStorage(); + + this.loadFromStorage() + .then((localConfig) => { + if (localConfig.ok) { + this.config = localConfig.data; + } + }) + .catch((e: unknown) => { + console.error("Error loading config from storage", e); + }); + } + + static getInstance(): RNConfig { + if (!RNConfig.instance) { + RNConfig.instance = new RNConfig(); + } + return RNConfig.instance; + } + + public update(newConfig: TJsAppConfigUpdateInput): void { + this.config = { + ...this.config, + ...newConfig, + status: newConfig.status ?? "success", + }; + + void this.saveToStorage(); + } + + public get(): TJsRNConfig { + if (!this.config) { + throw new Error("config is null, maybe the init function was not called?"); + } + return this.config; + } + + public async loadFromStorage(): Promise> { + try { + // const savedConfig = await this.storageHandler.getItem(this.storageKey); + const savedConfig = await AsyncStorage.getItem(RN_ASYNC_STORAGE_KEY); + if (savedConfig) { + const parsedConfig = JSON.parse(savedConfig) as TJsRNConfig; + + // check if the config has expired + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- need to check if expiresAt is set + if (parsedConfig.expiresAt && new Date(parsedConfig.expiresAt) <= new Date()) { + return err(new Error("Config in local storage has expired")); + } + + return ok(parsedConfig); + } + } catch (e) { + return err(new Error("No or invalid config in local storage")); + } + + return err(new Error("No or invalid config in local storage")); + } + + private async saveToStorage(): Promise> { + return wrapThrowsAsync(async () => { + await AsyncStorage.setItem(RN_ASYNC_STORAGE_KEY, JSON.stringify(this.config)); + })(); + } + + // reset the config + + public async resetConfig(): Promise> { + this.config = null; + + return wrapThrowsAsync(async () => { + await AsyncStorage.removeItem(RN_ASYNC_STORAGE_KEY); + })(); + } +} + +export const appConfig = RNConfig.getInstance(); diff --git a/packages/react-native/src/lib/initialize.ts b/packages/react-native/src/lib/initialize.ts index da72c6c405..780d382e6b 100644 --- a/packages/react-native/src/lib/initialize.ts +++ b/packages/react-native/src/lib/initialize.ts @@ -1,6 +1,5 @@ import { type TAttributes } from "@formbricks/types/attributes"; -import { type TJSAppConfig, type TJsAppConfigInput } from "@formbricks/types/js"; -import { sync } from "../../../js-core/src/app/lib/sync"; +import { type TJsAppConfigInput, type TJsRNConfig } from "@formbricks/types/js"; import { ErrorHandler, type MissingFieldError, @@ -15,6 +14,7 @@ import { Logger } from "../../../js-core/src/shared/logger"; import { trackAction } from "./actions"; import { updateAttributes } from "./attributes"; import { appConfig } from "./config"; +import { sync } from "./sync"; let isInitialized = false; const logger = Logger.getInstance(); @@ -60,11 +60,10 @@ export const initialize = async ( if (!res.ok) { return err(res.error) as unknown as Result; } - updatedAttributes = res.data; } - let existingConfig: TJSAppConfig | undefined; + let existingConfig: TJsRNConfig | undefined; try { existingConfig = appConfig.get(); } catch (e) { @@ -88,8 +87,8 @@ export const initialize = async ( environmentId: c.environmentId, userId: c.userId, }, - true, - appConfig + appConfig, + true ); } else { logger.debug("Configuration not expired. Extending expiration."); @@ -105,8 +104,8 @@ export const initialize = async ( environmentId: c.environmentId, userId: c.userId, }, - true, - appConfig + appConfig, + true ); // and track the new session event diff --git a/packages/react-native/src/lib/sync.ts b/packages/react-native/src/lib/sync.ts new file mode 100644 index 0000000000..c0a465c186 --- /dev/null +++ b/packages/react-native/src/lib/sync.ts @@ -0,0 +1,129 @@ +/* eslint-disable @typescript-eslint/no-unnecessary-condition -- required */ + +/* eslint-disable no-console -- required for logging */ +import type { TAttributes } from "@formbricks/types/attributes"; +import { type Result, err, ok } from "@formbricks/types/error-handlers"; +import type { NetworkError } from "@formbricks/types/errors"; +import type { TJsAppState, TJsAppStateSync, TJsRNSyncParams } from "@formbricks/types/js"; +import { Logger } from "../../../js-core/src/shared/logger"; +import type { RNConfig } from "./config"; + +const logger = Logger.getInstance(); + +let syncIntervalId: number | null = null; + +const syncWithBackend = async ( + { apiHost, environmentId, userId }: TJsRNSyncParams, + noCache: boolean +): Promise> => { + try { + const fetchOptions: RequestInit = {}; + + if (noCache) { + fetchOptions.cache = "no-cache"; + logger.debug("No cache option set for sync"); + } + logger.debug("syncing with backend"); + const url = `${apiHost}/api/v1/client/${environmentId}/app/sync/${userId}`; + + const response = await fetch(url, fetchOptions); + + if (!response.ok) { + const jsonRes = (await response.json()) as { message: string }; + + return err({ + code: "network_error", + status: response.status, + message: "Error syncing with backend", + url, + responseMessage: jsonRes.message, + }) as Result; + } + + const data = (await response.json()) as { data: TJsAppStateSync }; + const { data: state } = data; + + return ok(state); + } catch (e) { + return err(e as NetworkError); + } +}; + +export const sync = async (params: TJsRNSyncParams, appConfig: RNConfig, noCache = false): Promise => { + try { + const syncResult = await syncWithBackend(params, noCache); + + if (!syncResult.ok) { + throw syncResult.error as unknown as Error; + } + + const attributes: TAttributes = params.attributes ?? {}; + + if (syncResult.data.language) { + attributes.language = syncResult.data.language; + } + + const state: TJsAppState = { + surveys: syncResult.data.surveys, + actionClasses: syncResult.data.actionClasses, + product: syncResult.data.product, + attributes, + }; + + const surveyNames = state.surveys.map((s) => s.name); + logger.debug(`Fetched ${surveyNames.length.toString()} surveys during sync: ${surveyNames.join(", ")}`); + + appConfig.update({ + apiHost: params.apiHost, + environmentId: params.environmentId, + userId: params.userId, + state, + expiresAt: new Date(new Date().getTime() + 2 * 60000), // 2 minutes in the future + }); + } catch (error) { + console.error(`Error during sync: ${error as string}`); + throw error; + } +}; + +export const addExpiryCheckListener = (appConfig: RNConfig): void => { + const updateInterval = 1000 * 30; // every 30 seconds + // add event listener to check sync with backend on regular interval + if (typeof window !== "undefined" && syncIntervalId === null) { + syncIntervalId = window.setInterval( + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- we want to run this function async + async () => { + try { + // check if the config has not expired yet + if (appConfig.get().expiresAt && new Date(appConfig.get().expiresAt) >= new Date()) { + return; + } + logger.debug("Config has expired. Starting sync."); + await sync( + { + apiHost: appConfig.get().apiHost, + environmentId: appConfig.get().environmentId, + userId: appConfig.get().userId, + attributes: appConfig.get().state.attributes, + }, + appConfig + ); + } catch (e) { + console.error(`Error during expiry check: ${e as string}`); + logger.debug("Extending config and try again later."); + const existingConfig = appConfig.get(); + appConfig.update(existingConfig); + } + }, + updateInterval + ); + } +}; + +export const removeExpiryCheckListener = (): void => { + if (typeof window !== "undefined" && syncIntervalId !== null) { + window.clearInterval(syncIntervalId); + + syncIntervalId = null; + } +}; diff --git a/packages/react-native/src/survey-web-view.tsx b/packages/react-native/src/survey-web-view.tsx index 17599e50b1..bb77a53beb 100644 --- a/packages/react-native/src/survey-web-view.tsx +++ b/packages/react-native/src/survey-web-view.tsx @@ -14,11 +14,11 @@ import type { TJsFileUploadParams } from "@formbricks/types/js"; import type { TResponseUpdate } from "@formbricks/types/responses"; import type { TUploadFileConfig } from "@formbricks/types/storage"; import type { TSurvey } from "@formbricks/types/surveys/types"; -import { sync } from "../../js-core/src/app/lib/sync"; import { Logger } from "../../js-core/src/shared/logger"; import { getDefaultLanguageCode, getLanguageCode } from "../../js-core/src/shared/utils"; import { appConfig } from "./lib/config"; import { SurveyStore } from "./lib/survey-store"; +import { sync } from "./lib/sync"; const logger = Logger.getInstance(); logger.configure({ logLevel: "debug" }); @@ -106,7 +106,6 @@ export function SurveyWebView({ survey }: SurveyWebViewProps): JSX.Element | und environmentId: appConfig.get().environmentId, userId: appConfig.get().userId, }, - false, appConfig ); surveyStore.resetSurvey(); diff --git a/packages/types/js.ts b/packages/types/js.ts index 4b532bc27b..9985345cc6 100644 --- a/packages/types/js.ts +++ b/packages/types/js.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { ZActionClass } from "./action-classes"; import { ZAttributes } from "./attributes"; +import { ZId } from "./common"; import { ZProduct } from "./product"; import { ZResponseHiddenFieldValue, ZResponseUpdate } from "./responses"; import { ZUploadFileConfig } from "./storage"; @@ -41,10 +42,6 @@ export const ZJsAppStateSync = z.object({ export type TJsAppStateSync = z.infer; -export const ZJsWebsiteStateSync = ZJsAppStateSync.omit({ person: true }); - -export type TJsWebsiteStateSync = z.infer; - export const ZJsAppState = z.object({ attributes: ZAttributes, surveys: z.array(ZSurvey), @@ -54,54 +51,6 @@ export const ZJsAppState = z.object({ export type TJsAppState = z.infer; -export const ZJsWebsiteState = z.object({ - surveys: z.array(ZSurvey), - actionClasses: z.array(ZActionClass), - product: ZProduct, - displays: z.array(ZJSWebsiteStateDisplay), - attributes: ZAttributes.optional(), -}); - -export type TJsWebsiteState = z.infer; - -export const ZJsWebsiteSyncInput = z.object({ - environmentId: z.string().cuid2(), - version: z.string().optional(), -}); - -export type TJsWebsiteSyncInput = z.infer; - -export const ZJsWebsiteConfig = z.object({ - environmentId: z.string().cuid2(), - apiHost: z.string(), - state: ZJsWebsiteState, - expiresAt: z.date(), - status: z.enum(["success", "error"]).optional(), -}); - -export type TJsWebsiteConfig = z.infer; - -export const ZJSAppConfig = z.object({ - environmentId: z.string().cuid2(), - apiHost: z.string(), - userId: z.string(), - state: ZJsAppState, - expiresAt: z.date(), - status: z.enum(["success", "error"]).optional(), -}); - -export type TJSAppConfig = z.infer; - -export const ZJsWebsiteConfigUpdateInput = z.object({ - environmentId: z.string().cuid2(), - apiHost: z.string(), - state: ZJsWebsiteState, - expiresAt: z.date(), - status: z.enum(["success", "error"]).optional(), -}); - -export type TJsWebsiteConfigUpdateInput = z.infer; - export const ZJsAppConfigUpdateInput = z.object({ environmentId: z.string().cuid2(), apiHost: z.string(), @@ -113,6 +62,109 @@ export const ZJsAppConfigUpdateInput = z.object({ export type TJsAppConfigUpdateInput = z.infer; +export const ZJsRNConfig = z.object({ + environmentId: z.string().cuid(), + apiHost: z.string(), + userId: z.string(), + state: ZJsAppState, + expiresAt: z.date(), + status: z.enum(["success", "error"]).optional(), +}); + +export type TJsRNConfig = z.infer; + +export const ZJsWebsiteStateSync = ZJsAppStateSync.omit({ person: true }); + +export type TJsWebsiteStateSync = z.infer; + +export const ZJsRNSyncParams = z.object({ + environmentId: z.string().cuid(), + apiHost: z.string(), + userId: z.string(), + attributes: ZAttributes.optional(), +}); + +export type TJsRNSyncParams = z.infer; + +export const ZJsWebsiteState = z.object({ + surveys: z.array(ZSurvey), + actionClasses: z.array(ZActionClass), + product: ZProduct, + displays: z.array(ZJSWebsiteStateDisplay), + attributes: ZAttributes.optional(), +}); + +export type TJsWebsiteState = z.infer; + +export const ZJsEnvironmentState = z.object({ + expiresAt: z.date(), + data: z.object({ + surveys: z.array(ZSurvey), + actionClasses: z.array(ZActionClass), + product: ZProduct, + }), +}); + +export type TJsEnvironmentState = z.infer; + +export const ZJsSyncInput = z.object({ + environmentId: z.string().cuid(), +}); + +export type TJsSyncInput = z.infer; + +export const ZJsPersonState = z.object({ + expiresAt: z.date().nullable(), + data: z.object({ + userId: z.string().nullable(), + segments: z.array(ZId), // segment ids the person belongs to + // displays: z.array(z.string()), // displayed survey ids + displays: z.array( + z.object({ + surveyId: ZId, + createdAt: z.date(), + }) + ), + responses: z.array(ZId), // responded survey ids + attributes: ZAttributes, + lastDisplayAt: z.date().nullable(), + }), +}); + +export type TJsPersonState = z.infer; + +export const ZJsPersonIdentifyInput = z.object({ + environmentId: z.string().cuid(), + userId: z.string().optional(), +}); + +export type TJsPersonIdentifyInput = z.infer; + +export const ZJsConfig = z.object({ + environmentId: z.string().cuid(), + apiHost: z.string(), + environmentState: ZJsEnvironmentState, + personState: ZJsPersonState, + filteredSurveys: z.array(ZSurvey).default([]), + status: z.object({ + value: z.enum(["success", "error"]), + expiresAt: z.date().nullable(), + }), +}); + +export type TJsConfig = z.infer; + +export const ZJsConfigUpdateInput = ZJsConfig.omit({ status: true }).extend({ + status: z + .object({ + value: z.enum(["success", "error"]), + expiresAt: z.date().nullable(), + }) + .optional(), +}); + +export type TJsConfigUpdateInput = z.infer; + export const ZJsWebsiteConfigInput = z.object({ environmentId: z.string().cuid2(), apiHost: z.string(), @@ -135,7 +187,6 @@ export type TJsAppConfigInput = z.infer; export const ZJsPeopleUserIdInput = z.object({ environmentId: z.string().cuid2(), userId: z.string().min(1).max(255), - version: z.string().optional(), }); export const ZJsPeopleUpdateAttributeInput = z.object({ @@ -165,16 +216,21 @@ export const ZJsWesbiteActionInput = ZJsActionInput.omit({ userId: true }); export type TJsWesbiteActionInput = z.infer; -export const ZJsAppSyncParams = z.object({ - environmentId: z.string().cuid2(), +export const ZJsEnvironmentSyncParams = z.object({ + environmentId: z.string().cuid(), apiHost: z.string(), +}); + +export type TJsEnvironmentSyncParams = z.infer; + +export const ZJsPersonSyncParams = ZJsEnvironmentSyncParams.extend({ userId: z.string(), attributes: ZAttributes.optional(), }); -export type TJsAppSyncParams = z.infer; +export type TJsPersonSyncParams = z.infer; -export const ZJsWebsiteSyncParams = ZJsAppSyncParams.omit({ userId: true }); +export const ZJsWebsiteSyncParams = ZJsPersonSyncParams.omit({ userId: true }); export type TJsWebsiteSyncParams = z.infer; From f13efc954e273c651010b5d49ac7afafb7c8d196 Mon Sep 17 00:00:00 2001 From: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com> Date: Tue, 17 Sep 2024 14:49:21 +0530 Subject: [PATCH 04/16] fix: removes rewrites from next config (#3149) --- apps/web/next.config.mjs | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 8d64fbe4c5..aee60ecda0 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -62,18 +62,6 @@ const nextConfig = { }, ], }, - async rewrites() { - return [ - { - source: "/api/v1/client/:environmentId/in-app/sync", - destination: "/api/v1/client/:environmentId/website/sync", - }, - { - source: "/api/v1/client/:environmentId/in-app/sync/:userId", - destination: "/api/v1/client/:environmentId/app/sync/:userId", - }, - ]; - }, async redirects() { return [ { From ebf35ea582d97bf269e9e540b5e6024f1eaf036f Mon Sep 17 00:00:00 2001 From: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com> Date: Tue, 17 Sep 2024 14:54:12 +0530 Subject: [PATCH 05/16] feat: adds auto animate to survey questions (#3147) Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com> --- .../components/MultipleChoiceQuestionForm.tsx | 1 + .../edit/components/QuestionsView.tsx | 12 +- .../edit/components/RankingQuestionForm.tsx | 1 + .../responses/components/ResponseTable.tsx | 1 + .../components/TableSettingsModal.tsx | 6 +- packages/surveys/package.json | 3 + .../src/components/general/FileInput.tsx | 5 +- .../components/questions/RankingQuestion.tsx | 6 +- pnpm-lock.yaml | 266 ++++++++++-------- 9 files changed, 172 insertions(+), 129 deletions(-) diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/MultipleChoiceQuestionForm.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/MultipleChoiceQuestionForm.tsx index 56b1824ed7..239aeec2df 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/MultipleChoiceQuestionForm.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/MultipleChoiceQuestionForm.tsx @@ -219,6 +219,7 @@ export const MultipleChoiceQuestionForm = ({
{ const { active, over } = event; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsView.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsView.tsx index 3989f75c8f..673e49d14e 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsView.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsView.tsx @@ -372,7 +372,11 @@ export const QuestionsView = ({ />
- +

- + {localSurvey.endings.map((ending, index) => { return ( diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RankingQuestionForm.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RankingQuestionForm.tsx index 104945beb3..b31eb16356 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RankingQuestionForm.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RankingQuestionForm.tsx @@ -167,6 +167,7 @@ export const RankingQuestionForm = ({
{ const { active, over } = event; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable.tsx index 6a8c77f52f..149040ce69 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable.tsx @@ -139,6 +139,7 @@ export const ResponseTable = ({ return (
- + {columnOrder.map((columnId) => { if (columnId === "select") return; diff --git a/packages/surveys/package.json b/packages/surveys/package.json index 94004f21c8..d746b966e4 100644 --- a/packages/surveys/package.json +++ b/packages/surveys/package.json @@ -54,5 +54,8 @@ "vite": "^5.4.1", "vite-plugin-dts": "^3.9.1", "vite-tsconfig-paths": "^5.0.1" + }, + "dependencies": { + "@formkit/auto-animate": "^0.8.2" } } diff --git a/packages/surveys/src/components/general/FileInput.tsx b/packages/surveys/src/components/general/FileInput.tsx index 29112910c4..333b4823c6 100644 --- a/packages/surveys/src/components/general/FileInput.tsx +++ b/packages/surveys/src/components/general/FileInput.tsx @@ -1,3 +1,4 @@ +import { useAutoAnimate } from "@formkit/auto-animate/react"; import { useMemo, useState } from "preact/hooks"; import { JSXInternal } from "preact/src/jsx"; import { getOriginalFileNameFromUrl } from "@formbricks/lib/storage/utils"; @@ -31,6 +32,8 @@ export const FileInput = ({ }: FileInputProps) => { const [selectedFiles, setSelectedFiles] = useState([]); const [isUploading, setIsUploading] = useState(false); + const [parent] = useAutoAnimate(); + const validateFileSize = async (file: File): Promise => { if (maxSizeInMB) { const fileBuffer = await file.arrayBuffer(); @@ -156,7 +159,7 @@ export const FileInput = ({ return (
-
+
{fileUrls?.map((fileUrl, index) => { const fileName = getOriginalFileNameFromUrl(fileUrl); return ( diff --git a/packages/surveys/src/components/questions/RankingQuestion.tsx b/packages/surveys/src/components/questions/RankingQuestion.tsx index e9632683a1..57476735ce 100644 --- a/packages/surveys/src/components/questions/RankingQuestion.tsx +++ b/packages/surveys/src/components/questions/RankingQuestion.tsx @@ -6,6 +6,7 @@ import { Subheader } from "@/components/general/Subheader"; import { ScrollableContainer } from "@/components/wrappers/ScrollableContainer"; import { getUpdatedTtc, useTtc } from "@/lib/ttc"; import { cn } from "@/lib/utils"; +import { useAutoAnimate } from "@formkit/auto-animate/react"; import { useCallback, useMemo, useState } from "preact/hooks"; import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import { TResponseData, TResponseTtc } from "@formbricks/types/responses"; @@ -50,6 +51,9 @@ export const RankingQuestion = ({ const [unsortedItems, setUnsortedItems] = useState( question.choices.filter((c) => !value.includes(c.id)) ); + + const [parent] = useAutoAnimate(); + const [error, setError] = useState(null); const isMediaAvailable = question.imageUrl || question.videoUrl; @@ -139,7 +143,7 @@ export const RankingQuestion = ({
Ranking Items -
+
{[...sortedItems, ...unsortedItems].map((item, idx) => { if (!item) return; const isSorted = sortedItems.includes(item); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 35107c32e4..5972819229 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,19 +130,19 @@ importers: version: 2.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@headlessui/tailwindcss': specifier: ^0.2.1 - version: 0.2.1(tailwindcss@3.4.7(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.11))(@types/node@22.3.0)(typescript@5.5.4))) + version: 0.2.1(tailwindcss@3.4.7(ts-node@10.9.2(typescript@5.5.4))) '@mapbox/rehype-prism': specifier: ^0.9.0 version: 0.9.0 '@mdx-js/loader': specifier: ^3.0.1 - version: 3.0.1(webpack@5.93.0(@swc/core@1.3.101(@swc/helpers@0.5.11))) + version: 3.0.1(webpack@5.93.0) '@mdx-js/react': specifier: ^3.0.1 version: 3.0.1(@types/react@18.3.3)(react@18.3.1) '@next/mdx': specifier: 14.2.5 - version: 14.2.5(@mdx-js/loader@3.0.1(webpack@5.93.0(@swc/core@1.3.101(@swc/helpers@0.5.11))))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1)) + version: 14.2.5(@mdx-js/loader@3.0.1(webpack@5.93.0))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1)) '@paralleldrive/cuid2': specifier: ^2.2.2 version: 2.2.2 @@ -151,7 +151,7 @@ importers: version: 2.2.1 '@tailwindcss/typography': specifier: ^0.5.13 - version: 0.5.13(tailwindcss@3.4.7(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.11))(@types/node@22.3.0)(typescript@5.5.4))) + version: 0.5.13(tailwindcss@3.4.7(ts-node@10.9.2(typescript@5.5.4))) acorn: specifier: ^8.12.1 version: 8.12.1 @@ -247,7 +247,7 @@ importers: version: 1.2.1 tailwindcss: specifier: ^3.4.7 - version: 3.4.7(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.11))(@types/node@22.3.0)(typescript@5.5.4)) + version: 3.4.7(ts-node@10.9.2(typescript@5.5.4)) unist-util-filter: specifier: ^5.0.1 version: 5.0.1 @@ -300,7 +300,7 @@ importers: version: 8.2.9(storybook@8.2.9(@babel/preset-env@7.24.7(@babel/core@7.25.2))) '@storybook/addon-interactions': specifier: ^8.2.9 - version: 8.2.9(storybook@8.2.9(@babel/preset-env@7.24.7(@babel/core@7.25.2)))(vitest@2.0.5(@types/node@22.3.0)(jsdom@24.1.1)(terser@5.31.6)) + version: 8.2.9(storybook@8.2.9(@babel/preset-env@7.24.7(@babel/core@7.25.2)))(vitest@2.0.5(@types/node@22.3.0)(terser@5.31.6)) '@storybook/addon-links': specifier: ^8.2.9 version: 8.2.9(react@18.3.1)(storybook@8.2.9(@babel/preset-env@7.24.7(@babel/core@7.25.2))) @@ -315,10 +315,10 @@ importers: version: 8.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.2.9(@babel/preset-env@7.24.7(@babel/core@7.25.2)))(typescript@5.5.4) '@storybook/react-vite': specifier: ^8.2.9 - version: 8.2.9(@preact/preset-vite@2.9.0(@babel/core@7.25.2)(preact@10.23.2)(vite@5.4.1(@types/node@22.3.0)(terser@5.31.6)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.19.1)(storybook@8.2.9(@babel/preset-env@7.24.7(@babel/core@7.25.2)))(typescript@5.5.4)(vite@5.4.1(@types/node@22.3.0)(terser@5.31.6)) + version: 8.2.9(@preact/preset-vite@2.9.0(@babel/core@7.25.2)(vite@5.4.1(@types/node@22.3.0)(terser@5.31.6)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.19.1)(storybook@8.2.9(@babel/preset-env@7.24.7(@babel/core@7.25.2)))(typescript@5.5.4)(vite@5.4.1(@types/node@22.3.0)(terser@5.31.6)) '@storybook/test': specifier: ^8.2.9 - version: 8.2.9(storybook@8.2.9(@babel/preset-env@7.24.7(@babel/core@7.25.2)))(vitest@2.0.5(@types/node@22.3.0)(jsdom@24.1.1)(terser@5.31.6)) + version: 8.2.9(storybook@8.2.9(@babel/preset-env@7.24.7(@babel/core@7.25.2)))(vitest@2.0.5(@types/node@22.3.0)(terser@5.31.6)) '@typescript-eslint/eslint-plugin': specifier: ^8.0.0 version: 8.0.0(@typescript-eslint/parser@8.0.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4) @@ -342,7 +342,7 @@ importers: version: 8.2.9(@babel/preset-env@7.24.7(@babel/core@7.25.2)) tsup: specifier: ^8.2.4 - version: 8.2.4(@microsoft/api-extractor@7.43.0(@types/node@22.3.0))(@swc/core@1.3.101(@swc/helpers@0.5.11))(jiti@1.21.6)(postcss@8.4.41)(tsx@4.16.5)(typescript@5.5.4)(yaml@2.4.5) + version: 8.2.4(@microsoft/api-extractor@7.43.0(@types/node@22.3.0))(@swc/core@1.3.101)(jiti@1.21.6)(postcss@8.4.41)(tsx@4.16.5)(typescript@5.5.4)(yaml@2.4.5) vite: specifier: ^5.4.1 version: 5.4.1(@types/node@22.3.0)(terser@5.31.6) @@ -411,7 +411,7 @@ importers: version: 0.0.22(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@sentry/nextjs': specifier: ^8.26.0 - version: 8.26.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.52.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.25.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.93.0) + version: 8.26.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.52.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.25.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@14.2.5(@babel/core@7.25.2)(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.93.0) '@tanstack/react-table': specifier: ^8.20.1 version: 8.20.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -420,7 +420,7 @@ importers: version: 0.6.2 '@vercel/speed-insights': specifier: ^1.0.12 - version: 1.0.12(next@14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 1.0.12(next@14.2.5(@babel/core@7.25.2)(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) bcryptjs: specifier: ^2.4.3 version: 2.4.3 @@ -456,10 +456,10 @@ importers: version: 4.0.4 next: specifier: 14.2.5 - version: 14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 14.2.5(@babel/core@7.25.2)(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-safe-action: specifier: ^7.6.2 - version: 7.7.0(next@14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(zod@3.23.8) + version: 7.7.0(next@14.2.5(@babel/core@7.25.2)(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(zod@3.23.8) optional: specifier: ^0.1.4 version: 0.1.4 @@ -511,7 +511,7 @@ importers: version: link:../../packages/config-eslint '@neshca/cache-handler': specifier: ^1.5.1 - version: 1.5.1(next@14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(redis@4.7.0) + version: 1.5.1(next@14.2.5(@babel/core@7.25.2)(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(redis@4.7.0) '@types/bcryptjs': specifier: ^2.4.6 version: 2.4.6 @@ -571,7 +571,7 @@ importers: version: 8.0.0(eslint@8.57.0)(typescript@5.5.4) '@vercel/style-guide': specifier: ^6.0.0 - version: 6.0.0(@next/eslint-plugin-next@14.2.5)(eslint@8.57.0)(prettier@3.3.3)(typescript@5.5.4)(vitest@2.0.5(@types/node@22.3.0)(jsdom@24.1.1)(terser@5.31.6)) + version: 6.0.0(@next/eslint-plugin-next@14.2.5)(eslint@8.57.0)(prettier@3.3.3)(typescript@5.5.4)(vitest@2.0.5) eslint-config-next: specifier: ^14.2.5 version: 14.2.5(eslint@8.57.0)(typescript@5.5.4) @@ -607,10 +607,10 @@ importers: devDependencies: '@tailwindcss/forms': specifier: ^0.5.7 - version: 0.5.7(tailwindcss@3.4.10(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.11))(@types/node@22.3.0)(typescript@5.5.4))) + version: 0.5.7(tailwindcss@3.4.10(ts-node@10.9.2)) '@tailwindcss/typography': specifier: ^0.5.14 - version: 0.5.14(tailwindcss@3.4.10(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.11))(@types/node@22.3.0)(typescript@5.5.4))) + version: 0.5.14(tailwindcss@3.4.10(ts-node@10.9.2)) autoprefixer: specifier: ^10.4.20 version: 10.4.20(postcss@8.4.41) @@ -619,7 +619,7 @@ importers: version: 8.4.41 tailwindcss: specifier: ^3.4.10 - version: 3.4.10(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.11))(@types/node@22.3.0)(typescript@5.5.4)) + version: 3.4.10(ts-node@10.9.2) packages/config-typescript: devDependencies: @@ -671,7 +671,7 @@ importers: version: 3.0.4(prisma@5.18.0)(typescript@5.5.4) ts-node: specifier: ^10.9.2 - version: 10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.11))(@types/node@22.3.0)(typescript@5.5.4) + version: 10.9.2(@swc/core@1.3.101)(@types/node@22.3.0)(typescript@5.5.4) zod: specifier: ^3.23.8 version: 3.23.8 @@ -771,7 +771,7 @@ importers: version: 6.9.14 react-email: specifier: ^2.1.6 - version: 2.1.6(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.11)(eslint@8.57.0)(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.11))(@types/node@22.3.0)(typescript@5.5.4)) + version: 2.1.6(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.11)(eslint@8.57.0)(ts-node@10.9.2) devDependencies: '@types/nodemailer': specifier: ^6.4.15 @@ -921,7 +921,7 @@ importers: version: 16.4.5 ts-node: specifier: ^10.9.2 - version: 10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.11))(@types/node@22.3.0)(typescript@5.5.4) + version: 10.9.2(@swc/core@1.3.101)(@types/node@22.3.0)(typescript@5.5.4) vitest: specifier: ^2.0.5 version: 2.0.5(@types/node@22.3.0)(jsdom@24.1.1)(terser@5.31.6) @@ -970,6 +970,10 @@ importers: version: 3.9.1(@types/node@22.3.0)(rollup@4.19.1)(typescript@5.5.4)(vite@5.4.1(@types/node@22.3.0)(terser@5.31.6)) packages/surveys: + dependencies: + '@formkit/auto-animate': + specifier: ^0.8.2 + version: 0.8.2 devDependencies: '@calcom/embed-snippet': specifier: 1.3.0 @@ -1012,7 +1016,7 @@ importers: version: 14.2.3 tailwindcss: specifier: ^3.4.10 - version: 3.4.10(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.11))(@types/node@22.3.0)(typescript@5.5.4)) + version: 3.4.10(ts-node@10.9.2) terser: specifier: ^5.31.6 version: 5.31.6 @@ -2936,6 +2940,9 @@ packages: '@floating-ui/utils@0.2.2': resolution: {integrity: sha512-J4yDIIthosAsRZ5CPYP/jQvUAQtlZTTD/4suA08/FEnlxqW3sKS9iAhgsa9VYLZ6vDHn/ixJgIqRQPotoBjxIw==} + '@formkit/auto-animate@0.8.2': + resolution: {integrity: sha512-SwPWfeRa5veb1hOIBMdzI+73te5puUBHmqqaF1Bu7FjvxlYSz/kJcZKSa9Cg60zL0uRNeJL2SbRxV6Jp6Q1nFQ==} + '@graphql-typed-document-node/core@3.2.0': resolution: {integrity: sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==} peerDependencies: @@ -16096,6 +16103,8 @@ snapshots: '@floating-ui/utils@0.2.2': {} + '@formkit/auto-animate@0.8.2': {} + '@graphql-typed-document-node/core@3.2.0(graphql@15.8.0)': dependencies: graphql: 15.8.0 @@ -16115,9 +16124,9 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@headlessui/tailwindcss@0.2.1(tailwindcss@3.4.7(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.11))(@types/node@22.3.0)(typescript@5.5.4)))': + '@headlessui/tailwindcss@0.2.1(tailwindcss@3.4.7(ts-node@10.9.2(typescript@5.5.4)))': dependencies: - tailwindcss: 3.4.7(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.11))(@types/node@22.3.0)(typescript@5.5.4)) + tailwindcss: 3.4.7(ts-node@10.9.2(typescript@5.5.4)) '@hookform/resolvers@3.9.0(react-hook-form@7.52.2(react@18.3.1))': dependencies: @@ -16491,11 +16500,11 @@ snapshots: refractor: 3.6.0 unist-util-visit: 2.0.3 - '@mdx-js/loader@3.0.1(webpack@5.93.0(@swc/core@1.3.101(@swc/helpers@0.5.11)))': + '@mdx-js/loader@3.0.1(webpack@5.93.0)': dependencies: '@mdx-js/mdx': 3.0.1 source-map: 0.7.4 - webpack: 5.93.0(@swc/core@1.3.101(@swc/helpers@0.5.11)) + webpack: 5.93.0 transitivePeerDependencies: - supports-color @@ -16568,11 +16577,11 @@ snapshots: '@microsoft/tsdoc@0.14.2': {} - '@neshca/cache-handler@1.5.1(next@14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(redis@4.7.0)': + '@neshca/cache-handler@1.5.1(next@14.2.5(@babel/core@7.25.2)(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(redis@4.7.0)': dependencies: cluster-key-slot: 1.1.2 lru-cache: 10.3.0 - next: 14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 14.2.5(@babel/core@7.25.2)(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) redis: 4.7.0 '@next/env@13.5.6': {} @@ -16587,11 +16596,11 @@ snapshots: dependencies: glob: 10.3.10 - '@next/mdx@14.2.5(@mdx-js/loader@3.0.1(webpack@5.93.0(@swc/core@1.3.101(@swc/helpers@0.5.11))))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))': + '@next/mdx@14.2.5(@mdx-js/loader@3.0.1(webpack@5.93.0))(@mdx-js/react@3.0.1(@types/react@18.3.3)(react@18.3.1))': dependencies: source-map: 0.7.4 optionalDependencies: - '@mdx-js/loader': 3.0.1(webpack@5.93.0(@swc/core@1.3.101(@swc/helpers@0.5.11))) + '@mdx-js/loader': 3.0.1(webpack@5.93.0) '@mdx-js/react': 3.0.1(@types/react@18.3.3)(react@18.3.1) '@next/swc-darwin-arm64@14.1.4': @@ -18922,7 +18931,7 @@ snapshots: '@sentry/types': 8.26.0 '@sentry/utils': 8.26.0 - '@sentry/nextjs@8.26.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.52.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.25.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.93.0)': + '@sentry/nextjs@8.26.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.52.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.25.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@14.2.5(@babel/core@7.25.2)(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.93.0)': dependencies: '@opentelemetry/instrumentation-http': 0.52.1(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.25.1 @@ -18936,7 +18945,7 @@ snapshots: '@sentry/vercel-edge': 8.26.0 '@sentry/webpack-plugin': 2.20.1(encoding@0.1.13)(webpack@5.93.0) chalk: 3.0.0 - next: 14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 14.2.5(@babel/core@7.25.2)(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) resolve: 1.22.8 rollup: 3.29.4 stacktrace-parser: 0.1.10 @@ -19465,11 +19474,11 @@ snapshots: '@storybook/global': 5.0.0 storybook: 8.2.9(@babel/preset-env@7.24.7(@babel/core@7.25.2)) - '@storybook/addon-interactions@8.2.9(storybook@8.2.9(@babel/preset-env@7.24.7(@babel/core@7.25.2)))(vitest@2.0.5(@types/node@22.3.0)(jsdom@24.1.1)(terser@5.31.6))': + '@storybook/addon-interactions@8.2.9(storybook@8.2.9(@babel/preset-env@7.24.7(@babel/core@7.25.2)))(vitest@2.0.5(@types/node@22.3.0)(terser@5.31.6))': dependencies: '@storybook/global': 5.0.0 '@storybook/instrumenter': 8.2.9(storybook@8.2.9(@babel/preset-env@7.24.7(@babel/core@7.25.2))) - '@storybook/test': 8.2.9(storybook@8.2.9(@babel/preset-env@7.24.7(@babel/core@7.25.2)))(vitest@2.0.5(@types/node@22.3.0)(jsdom@24.1.1)(terser@5.31.6)) + '@storybook/test': 8.2.9(storybook@8.2.9(@babel/preset-env@7.24.7(@babel/core@7.25.2)))(vitest@2.0.5(@types/node@22.3.0)(terser@5.31.6)) polished: 4.3.1 storybook: 8.2.9(@babel/preset-env@7.24.7(@babel/core@7.25.2)) ts-dedent: 2.2.0 @@ -19538,7 +19547,7 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@storybook/builder-vite@8.2.9(@preact/preset-vite@2.9.0(@babel/core@7.25.2)(preact@10.23.2)(vite@5.4.1(@types/node@22.3.0)(terser@5.31.6)))(storybook@8.2.9(@babel/preset-env@7.24.7(@babel/core@7.25.2)))(typescript@5.5.4)(vite@5.4.1(@types/node@22.3.0)(terser@5.31.6))': + '@storybook/builder-vite@8.2.9(@preact/preset-vite@2.9.0(@babel/core@7.25.2)(vite@5.4.1(@types/node@22.3.0)(terser@5.31.6)))(storybook@8.2.9(@babel/preset-env@7.24.7(@babel/core@7.25.2)))(typescript@5.5.4)(vite@5.4.1(@types/node@22.3.0)(terser@5.31.6))': dependencies: '@storybook/csf-plugin': 8.2.9(storybook@8.2.9(@babel/preset-env@7.24.7(@babel/core@7.25.2))) '@types/find-cache-dir': 3.2.1 @@ -19640,11 +19649,11 @@ snapshots: react-dom: 18.3.1(react@18.3.1) storybook: 8.2.9(@babel/preset-env@7.24.7(@babel/core@7.25.2)) - '@storybook/react-vite@8.2.9(@preact/preset-vite@2.9.0(@babel/core@7.25.2)(preact@10.23.2)(vite@5.4.1(@types/node@22.3.0)(terser@5.31.6)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.19.1)(storybook@8.2.9(@babel/preset-env@7.24.7(@babel/core@7.25.2)))(typescript@5.5.4)(vite@5.4.1(@types/node@22.3.0)(terser@5.31.6))': + '@storybook/react-vite@8.2.9(@preact/preset-vite@2.9.0(@babel/core@7.25.2)(vite@5.4.1(@types/node@22.3.0)(terser@5.31.6)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.19.1)(storybook@8.2.9(@babel/preset-env@7.24.7(@babel/core@7.25.2)))(typescript@5.5.4)(vite@5.4.1(@types/node@22.3.0)(terser@5.31.6))': dependencies: '@joshwooding/vite-plugin-react-docgen-typescript': 0.3.1(typescript@5.5.4)(vite@5.4.1(@types/node@22.3.0)(terser@5.31.6)) '@rollup/pluginutils': 5.1.0(rollup@4.19.1) - '@storybook/builder-vite': 8.2.9(@preact/preset-vite@2.9.0(@babel/core@7.25.2)(preact@10.23.2)(vite@5.4.1(@types/node@22.3.0)(terser@5.31.6)))(storybook@8.2.9(@babel/preset-env@7.24.7(@babel/core@7.25.2)))(typescript@5.5.4)(vite@5.4.1(@types/node@22.3.0)(terser@5.31.6)) + '@storybook/builder-vite': 8.2.9(@preact/preset-vite@2.9.0(@babel/core@7.25.2)(vite@5.4.1(@types/node@22.3.0)(terser@5.31.6)))(storybook@8.2.9(@babel/preset-env@7.24.7(@babel/core@7.25.2)))(typescript@5.5.4)(vite@5.4.1(@types/node@22.3.0)(terser@5.31.6)) '@storybook/react': 8.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.2.9(@babel/preset-env@7.24.7(@babel/core@7.25.2)))(typescript@5.5.4) find-up: 5.0.0 magic-string: 0.30.10 @@ -19691,12 +19700,12 @@ snapshots: optionalDependencies: typescript: 5.5.4 - '@storybook/test@8.2.9(storybook@8.2.9(@babel/preset-env@7.24.7(@babel/core@7.25.2)))(vitest@2.0.5(@types/node@22.3.0)(jsdom@24.1.1)(terser@5.31.6))': + '@storybook/test@8.2.9(storybook@8.2.9(@babel/preset-env@7.24.7(@babel/core@7.25.2)))(vitest@2.0.5(@types/node@22.3.0)(terser@5.31.6))': dependencies: '@storybook/csf': 0.1.11 '@storybook/instrumenter': 8.2.9(storybook@8.2.9(@babel/preset-env@7.24.7(@babel/core@7.25.2))) '@testing-library/dom': 10.1.0 - '@testing-library/jest-dom': 6.4.5(vitest@2.0.5(@types/node@22.3.0)(jsdom@24.1.1)(terser@5.31.6)) + '@testing-library/jest-dom': 6.4.5(vitest@2.0.5(@types/node@22.3.0)(terser@5.31.6)) '@testing-library/user-event': 14.5.2(@testing-library/dom@10.1.0) '@vitest/expect': 1.6.0 '@vitest/spy': 1.6.0 @@ -19794,26 +19803,26 @@ snapshots: optionalDependencies: typescript: 5.5.4 - '@tailwindcss/forms@0.5.7(tailwindcss@3.4.10(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.11))(@types/node@22.3.0)(typescript@5.5.4)))': + '@tailwindcss/forms@0.5.7(tailwindcss@3.4.10(ts-node@10.9.2))': dependencies: mini-svg-data-uri: 1.4.4 - tailwindcss: 3.4.10(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.11))(@types/node@22.3.0)(typescript@5.5.4)) + tailwindcss: 3.4.10(ts-node@10.9.2) - '@tailwindcss/typography@0.5.13(tailwindcss@3.4.7(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.11))(@types/node@22.3.0)(typescript@5.5.4)))': + '@tailwindcss/typography@0.5.13(tailwindcss@3.4.7(ts-node@10.9.2(typescript@5.5.4)))': dependencies: lodash.castarray: 4.4.0 lodash.isplainobject: 4.0.6 lodash.merge: 4.6.2 postcss-selector-parser: 6.0.10 - tailwindcss: 3.4.7(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.11))(@types/node@22.3.0)(typescript@5.5.4)) + tailwindcss: 3.4.7(ts-node@10.9.2(typescript@5.5.4)) - '@tailwindcss/typography@0.5.14(tailwindcss@3.4.10(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.11))(@types/node@22.3.0)(typescript@5.5.4)))': + '@tailwindcss/typography@0.5.14(tailwindcss@3.4.10(ts-node@10.9.2))': dependencies: lodash.castarray: 4.4.0 lodash.isplainobject: 4.0.6 lodash.merge: 4.6.2 postcss-selector-parser: 6.0.10 - tailwindcss: 3.4.10(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.11))(@types/node@22.3.0)(typescript@5.5.4)) + tailwindcss: 3.4.10(ts-node@10.9.2) '@tanstack/react-table@8.20.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: @@ -19842,7 +19851,7 @@ snapshots: lz-string: 1.5.0 pretty-format: 27.5.1 - '@testing-library/jest-dom@6.4.5(vitest@2.0.5(@types/node@22.3.0)(jsdom@24.1.1)(terser@5.31.6))': + '@testing-library/jest-dom@6.4.5(vitest@2.0.5(@types/node@22.3.0)(terser@5.31.6))': dependencies: '@adobe/css-tools': 4.4.0 '@babel/runtime': 7.24.7 @@ -20459,12 +20468,12 @@ snapshots: satori: 0.10.9 yoga-wasm-web: 0.3.3 - '@vercel/speed-insights@1.0.12(next@14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + '@vercel/speed-insights@1.0.12(next@14.2.5(@babel/core@7.25.2)(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': optionalDependencies: - next: 14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 14.2.5(@babel/core@7.25.2)(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 - '@vercel/style-guide@6.0.0(@next/eslint-plugin-next@14.2.5)(eslint@8.57.0)(prettier@3.3.3)(typescript@5.5.4)(vitest@2.0.5(@types/node@22.3.0)(jsdom@24.1.1)(terser@5.31.6))': + '@vercel/style-guide@6.0.0(@next/eslint-plugin-next@14.2.5)(eslint@8.57.0)(prettier@3.3.3)(typescript@5.5.4)(vitest@2.0.5)': dependencies: '@babel/core': 7.24.7 '@babel/eslint-parser': 7.24.7(@babel/core@7.24.7)(eslint@8.57.0) @@ -20484,7 +20493,7 @@ snapshots: eslint-plugin-testing-library: 6.2.2(eslint@8.57.0)(typescript@5.5.4) eslint-plugin-tsdoc: 0.2.17 eslint-plugin-unicorn: 51.0.1(eslint@8.57.0) - eslint-plugin-vitest: 0.3.26(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4)(vitest@2.0.5(@types/node@22.3.0)(jsdom@24.1.1)(terser@5.31.6)) + eslint-plugin-vitest: 0.3.26(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4)(vitest@2.0.5) prettier-plugin-packagejson: 2.5.0(prettier@3.3.3) optionalDependencies: '@next/eslint-plugin-next': 14.2.5 @@ -22537,7 +22546,7 @@ snapshots: debug: 4.3.5 enhanced-resolve: 5.17.0 eslint: 8.57.0 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) fast-glob: 3.3.2 get-tsconfig: 4.7.5 @@ -22577,16 +22586,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.8.1(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0): - dependencies: - debug: 3.2.7 - optionalDependencies: - '@typescript-eslint/parser': 7.18.0(eslint@8.57.0)(typescript@5.5.4) - eslint: 8.57.0 - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@8.57.0) - transitivePeerDependencies: - - supports-color - eslint-module-utils@2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0): dependencies: debug: 3.2.7 @@ -22791,13 +22790,13 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-vitest@0.3.26(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4)(vitest@2.0.5(@types/node@22.3.0)(jsdom@24.1.1)(terser@5.31.6)): + eslint-plugin-vitest@0.3.26(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4)(vitest@2.0.5): dependencies: '@typescript-eslint/utils': 7.18.0(eslint@8.57.0)(typescript@5.5.4) eslint: 8.57.0 optionalDependencies: '@typescript-eslint/eslint-plugin': 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4) - vitest: 2.0.5(@types/node@22.3.0)(jsdom@24.1.1)(terser@5.31.6) + vitest: 2.0.5 transitivePeerDependencies: - supports-color - typescript @@ -25748,9 +25747,9 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - next-safe-action@7.7.0(next@14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(zod@3.23.8): + next-safe-action@7.7.0(next@14.2.5(@babel/core@7.25.2)(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(zod@3.23.8): dependencies: - next: 14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 14.2.5(@babel/core@7.25.2)(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: @@ -25801,6 +25800,33 @@ snapshots: - '@babel/core' - babel-plugin-macros + next@14.2.5(@babel/core@7.25.2)(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@next/env': 14.2.5 + '@swc/helpers': 0.5.5 + busboy: 1.6.0 + caniuse-lite: 1.0.30001636 + graceful-fs: 4.2.11 + postcss: 8.4.31 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + styled-jsx: 5.1.1(@babel/core@7.25.2)(react@18.3.1) + optionalDependencies: + '@next/swc-darwin-arm64': 14.2.5 + '@next/swc-darwin-x64': 14.2.5 + '@next/swc-linux-arm64-gnu': 14.2.5 + '@next/swc-linux-arm64-musl': 14.2.5 + '@next/swc-linux-x64-gnu': 14.2.5 + '@next/swc-linux-x64-musl': 14.2.5 + '@next/swc-win32-arm64-msvc': 14.2.5 + '@next/swc-win32-ia32-msvc': 14.2.5 + '@next/swc-win32-x64-msvc': 14.2.5 + '@opentelemetry/api': 1.9.0 + '@playwright/test': 1.45.3 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + next@14.2.5(@opentelemetry/api@1.9.0)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@next/env': 14.2.5 @@ -25811,7 +25837,7 @@ snapshots: postcss: 8.4.31 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - styled-jsx: 5.1.1(react@18.3.1) + styled-jsx: 5.1.1(@babel/core@7.24.5)(react@18.3.1) optionalDependencies: '@next/swc-darwin-arm64': 14.2.5 '@next/swc-darwin-x64': 14.2.5 @@ -26471,21 +26497,21 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.4.41 - postcss-load-config@4.0.2(postcss@8.4.40)(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.11))(@types/node@22.3.0)(typescript@5.5.4)): + postcss-load-config@4.0.2(postcss@8.4.40)(ts-node@10.9.2(typescript@5.5.4)): dependencies: lilconfig: 3.1.2 yaml: 2.4.5 optionalDependencies: postcss: 8.4.40 - ts-node: 10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.11))(@types/node@22.3.0)(typescript@5.5.4) + ts-node: 10.9.2(@swc/core@1.3.101)(@types/node@22.3.0)(typescript@5.5.4) - postcss-load-config@4.0.2(postcss@8.4.41)(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.11))(@types/node@22.3.0)(typescript@5.5.4)): + postcss-load-config@4.0.2(postcss@8.4.41)(ts-node@10.9.2): dependencies: lilconfig: 3.1.2 yaml: 2.4.5 optionalDependencies: postcss: 8.4.41 - ts-node: 10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.11))(@types/node@22.3.0)(typescript@5.5.4) + ts-node: 10.9.2(@swc/core@1.3.101)(@types/node@22.3.0)(typescript@5.5.4) postcss-load-config@6.0.1(jiti@1.21.6)(postcss@8.4.41)(tsx@4.16.5)(yaml@2.4.5): dependencies: @@ -26879,7 +26905,7 @@ snapshots: react-dom: 18.3.1(react@18.3.1) react-is: 18.1.0 - react-email@2.1.6(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.11)(eslint@8.57.0)(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.11))(@types/node@22.3.0)(typescript@5.5.4)): + react-email@2.1.6(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.11)(eslint@8.57.0)(ts-node@10.9.2): dependencies: '@babel/core': 7.24.5 '@babel/parser': 7.24.5 @@ -26919,7 +26945,7 @@ snapshots: source-map-js: 1.0.2 stacktrace-parser: 0.1.10 tailwind-merge: 2.2.0 - tailwindcss: 3.4.0(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.11))(@types/node@22.3.0)(typescript@5.5.4)) + tailwindcss: 3.4.0(ts-node@10.9.2) typescript: 5.1.6 transitivePeerDependencies: - '@opentelemetry/api' @@ -28245,10 +28271,12 @@ snapshots: optionalDependencies: '@babel/core': 7.24.5 - styled-jsx@5.1.1(react@18.3.1): + styled-jsx@5.1.1(@babel/core@7.25.2)(react@18.3.1): dependencies: client-only: 0.0.1 react: 18.3.1 + optionalDependencies: + '@babel/core': 7.25.2 styled-jsx@5.1.1(react@19.0.0-rc-935180c7e0-20240524): dependencies: @@ -28326,7 +28354,7 @@ snapshots: tailwind-merge@2.5.2: {} - tailwindcss@3.4.0(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.11))(@types/node@22.3.0)(typescript@5.5.4)): + tailwindcss@3.4.0(ts-node@10.9.2): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -28345,7 +28373,7 @@ snapshots: postcss: 8.4.41 postcss-import: 15.1.0(postcss@8.4.41) postcss-js: 4.0.1(postcss@8.4.41) - postcss-load-config: 4.0.2(postcss@8.4.41)(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.11))(@types/node@22.3.0)(typescript@5.5.4)) + postcss-load-config: 4.0.2(postcss@8.4.41)(ts-node@10.9.2) postcss-nested: 6.0.1(postcss@8.4.41) postcss-selector-parser: 6.1.0 resolve: 1.22.8 @@ -28353,7 +28381,7 @@ snapshots: transitivePeerDependencies: - ts-node - tailwindcss@3.4.10(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.11))(@types/node@22.3.0)(typescript@5.5.4)): + tailwindcss@3.4.10(ts-node@10.9.2): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -28372,7 +28400,7 @@ snapshots: postcss: 8.4.41 postcss-import: 15.1.0(postcss@8.4.41) postcss-js: 4.0.1(postcss@8.4.41) - postcss-load-config: 4.0.2(postcss@8.4.41)(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.11))(@types/node@22.3.0)(typescript@5.5.4)) + postcss-load-config: 4.0.2(postcss@8.4.41)(ts-node@10.9.2) postcss-nested: 6.0.1(postcss@8.4.41) postcss-selector-parser: 6.1.0 resolve: 1.22.8 @@ -28380,7 +28408,7 @@ snapshots: transitivePeerDependencies: - ts-node - tailwindcss@3.4.7(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.11))(@types/node@22.3.0)(typescript@5.5.4)): + tailwindcss@3.4.7(ts-node@10.9.2(typescript@5.5.4)): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -28399,7 +28427,7 @@ snapshots: postcss: 8.4.40 postcss-import: 15.1.0(postcss@8.4.40) postcss-js: 4.0.1(postcss@8.4.40) - postcss-load-config: 4.0.2(postcss@8.4.40)(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.11))(@types/node@22.3.0)(typescript@5.5.4)) + postcss-load-config: 4.0.2(postcss@8.4.40)(ts-node@10.9.2(typescript@5.5.4)) postcss-nested: 6.0.1(postcss@8.4.40) postcss-selector-parser: 6.1.0 resolve: 1.22.8 @@ -28496,17 +28524,6 @@ snapshots: '@swc/core': 1.3.101(@swc/helpers@0.5.11) esbuild: 0.19.11 - terser-webpack-plugin@5.3.10(@swc/core@1.3.101(@swc/helpers@0.5.11))(webpack@5.93.0(@swc/core@1.3.101(@swc/helpers@0.5.11))): - dependencies: - '@jridgewell/trace-mapping': 0.3.25 - jest-worker: 27.5.1 - schema-utils: 3.3.0 - serialize-javascript: 6.0.2 - terser: 5.31.6 - webpack: 5.93.0(@swc/core@1.3.101(@swc/helpers@0.5.11)) - optionalDependencies: - '@swc/core': 1.3.101(@swc/helpers@0.5.11) - terser-webpack-plugin@5.3.10(webpack@5.93.0): dependencies: '@jridgewell/trace-mapping': 0.3.25 @@ -28632,7 +28649,7 @@ snapshots: '@ts-morph/common': 0.12.3 code-block-writer: 11.0.3 - ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.11))(@types/node@22.3.0)(typescript@5.5.4): + ts-node@10.9.2(@swc/core@1.3.101)(@types/node@22.3.0)(typescript@5.5.4): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 @@ -28677,7 +28694,7 @@ snapshots: tslib@2.6.3: {} - tsup@8.2.4(@microsoft/api-extractor@7.43.0(@types/node@22.3.0))(@swc/core@1.3.101(@swc/helpers@0.5.11))(jiti@1.21.6)(postcss@8.4.41)(tsx@4.16.5)(typescript@5.5.4)(yaml@2.4.5): + tsup@8.2.4(@microsoft/api-extractor@7.43.0(@types/node@22.3.0))(@swc/core@1.3.101)(jiti@1.21.6)(postcss@8.4.41)(tsx@4.16.5)(typescript@5.5.4)(yaml@2.4.5): dependencies: bundle-require: 5.0.0(esbuild@0.23.0) cac: 6.7.14 @@ -29175,6 +29192,38 @@ snapshots: typescript: 5.5.4 vitest: 2.0.5(@types/node@22.3.0)(jsdom@24.1.1)(terser@5.31.6) + vitest@2.0.5: + dependencies: + '@ampproject/remapping': 2.3.0 + '@vitest/expect': 2.0.5 + '@vitest/pretty-format': 2.0.5 + '@vitest/runner': 2.0.5 + '@vitest/snapshot': 2.0.5 + '@vitest/spy': 2.0.5 + '@vitest/utils': 2.0.5 + chai: 5.1.1 + debug: 4.3.5 + execa: 8.0.1 + magic-string: 0.30.10 + pathe: 1.1.2 + std-env: 3.7.0 + tinybench: 2.8.0 + tinypool: 1.0.0 + tinyrainbow: 1.2.0 + vite: 5.4.1(@types/node@22.3.0)(terser@5.31.6) + vite-node: 2.0.5(@types/node@22.3.0)(terser@5.31.6) + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + optional: true + vitest@2.0.5(@types/node@22.3.0)(jsdom@24.1.1)(terser@5.31.6): dependencies: '@ampproject/remapping': 2.3.0 @@ -29301,37 +29350,6 @@ snapshots: - esbuild - uglify-js - webpack@5.93.0(@swc/core@1.3.101(@swc/helpers@0.5.11)): - dependencies: - '@types/eslint-scope': 3.7.7 - '@types/estree': 1.0.5 - '@webassemblyjs/ast': 1.12.1 - '@webassemblyjs/wasm-edit': 1.12.1 - '@webassemblyjs/wasm-parser': 1.12.1 - acorn: 8.12.1 - acorn-import-attributes: 1.9.5(acorn@8.12.1) - browserslist: 4.23.1 - chrome-trace-event: 1.0.4 - enhanced-resolve: 5.17.0 - es-module-lexer: 1.5.3 - eslint-scope: 5.1.1 - events: 3.3.0 - glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 - json-parse-even-better-errors: 2.3.1 - loader-runner: 4.3.0 - mime-types: 2.1.35 - neo-async: 2.6.2 - schema-utils: 3.3.0 - tapable: 2.2.1 - terser-webpack-plugin: 5.3.10(@swc/core@1.3.101(@swc/helpers@0.5.11))(webpack@5.93.0(@swc/core@1.3.101(@swc/helpers@0.5.11))) - watchpack: 2.4.1 - webpack-sources: 3.2.3 - transitivePeerDependencies: - - '@swc/core' - - esbuild - - uglify-js - webpack@5.93.0(@swc/core@1.3.101(@swc/helpers@0.5.11))(esbuild@0.19.11): dependencies: '@types/eslint-scope': 3.7.7 @@ -29627,4 +29645,4 @@ snapshots: '@types/react': 18.3.3 react: 18.3.1 - zwitch@2.0.4: {} \ No newline at end of file + zwitch@2.0.4: {} From 774c6f19a519d1053480b673eb82d9b036428dba Mon Sep 17 00:00:00 2001 From: mdm317 Date: Wed, 18 Sep 2024 18:37:36 +0900 Subject: [PATCH 06/16] fix: notes not appearing (#3131) Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com> --- .../components/ResponseCardModal.tsx | 26 +++++++++---------- .../responses/components/ResponseTable.tsx | 11 ++++---- .../components/ResponseTableCell.tsx | 6 ++--- packages/ui/SingleResponseCard/index.tsx | 6 ++--- 4 files changed, 25 insertions(+), 24 deletions(-) diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal.tsx index c2550f87f0..f21785ffd1 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal.tsx @@ -11,8 +11,8 @@ import { SingleResponseCard } from "@formbricks/ui/SingleResponseCard"; interface ResponseCardModalProps { responses: TResponse[]; - selectedResponse: TResponse | null; - setSelectedResponse: (response: TResponse | null) => void; + selectedResponseId: string | null; + setSelectedResponseId: (id: string | null) => void; survey: TSurvey; environment: TEnvironment; user?: TUser; @@ -26,8 +26,8 @@ interface ResponseCardModalProps { export const ResponseCardModal = ({ responses, - selectedResponse, - setSelectedResponse, + selectedResponseId, + setSelectedResponseId, survey, environment, user, @@ -41,33 +41,33 @@ export const ResponseCardModal = ({ const [currentIndex, setCurrentIndex] = useState(null); useEffect(() => { - if (selectedResponse) { + if (selectedResponseId) { setOpen(true); - const index = responses.findIndex((response) => response.id === selectedResponse.id); + const index = responses.findIndex((response) => response.id === selectedResponseId); setCurrentIndex(index); } else { setOpen(false); } - }, [selectedResponse, responses, setOpen]); + }, [selectedResponseId, responses, setOpen]); const handleNext = () => { if (currentIndex !== null && currentIndex < responses.length - 1) { - setSelectedResponse(responses[currentIndex + 1]); + setSelectedResponseId(responses[currentIndex + 1].id); } }; const handleBack = () => { if (currentIndex !== null && currentIndex > 0) { - setSelectedResponse(responses[currentIndex - 1]); + setSelectedResponseId(responses[currentIndex - 1].id); } }; const handleClose = () => { - setSelectedResponse(null); + setSelectedResponseId(null); }; // If no response is selected or currentIndex is null, do not render the modal - if (selectedResponse === null || currentIndex === null) return null; + if (selectedResponseId === null || currentIndex === null) return null; return (
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable.tsx index 149040ce69..685869370d 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable.tsx @@ -59,7 +59,8 @@ export const ResponseTable = ({ const [columnVisibility, setColumnVisibility] = useState({}); const [rowSelection, setRowSelection] = useState({}); const [isTableSettingsModalOpen, setIsTableSettingsModalOpen] = useState(false); - const [selectedResponse, setSelectedResponse] = useState(null); + const [selectedResponseId, setSelectedResponseId] = useState(null); + const selectedResponse = responses?.find((response) => response.id === selectedResponseId) ?? null; const [isExpanded, setIsExpanded] = useState(false); const [columnOrder, setColumnOrder] = useState([]); @@ -181,7 +182,7 @@ export const ResponseTable = ({ cell={cell} row={row} isExpanded={isExpanded} - setSelectedResponseCard={setSelectedResponse} + setSelectedResponseId={setSelectedResponseId} responses={responses} /> ))} @@ -225,12 +226,12 @@ export const ResponseTable = ({ isViewer={isViewer} updateResponse={updateResponse} deleteResponses={deleteResponses} - setSelectedResponse={setSelectedResponse} - selectedResponse={selectedResponse} + setSelectedResponseId={setSelectedResponseId} + selectedResponseId={selectedResponseId} open={selectedResponse !== null} setOpen={(open) => { if (!open) { - setSelectedResponse(null); + setSelectedResponseId(null); } }} /> diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell.tsx index de058a07ee..ca9ce7279d 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell.tsx @@ -9,7 +9,7 @@ interface ResponseTableCellProps { cell: Cell; row: Row; isExpanded: boolean; - setSelectedResponseCard: (responseCard: TResponse) => void; + setSelectedResponseId: (responseId: string | null) => void; responses: TResponse[] | null; } @@ -17,14 +17,14 @@ export const ResponseTableCell = ({ cell, row, isExpanded, - setSelectedResponseCard, + setSelectedResponseId, responses, }: ResponseTableCellProps) => { // Function to handle cell click const handleCellClick = () => { if (cell.column.id !== "select") { const response = responses?.find((response) => response.id === row.id); - if (response) setSelectedResponseCard(response); + if (response) setSelectedResponseId(response.id); } }; diff --git a/packages/ui/SingleResponseCard/index.tsx b/packages/ui/SingleResponseCard/index.tsx index 8114a15c4d..82f900bae6 100644 --- a/packages/ui/SingleResponseCard/index.tsx +++ b/packages/ui/SingleResponseCard/index.tsx @@ -28,7 +28,7 @@ interface SingleResponseCardProps { updateResponse?: (responseId: string, responses: TResponse) => void; deleteResponses?: (responseIds: string[]) => void; isViewer: boolean; - setSelectedResponse?: (response: TResponse | null) => void; + setSelectedResponseId?: (responseId: string | null) => void; } export const SingleResponseCard = ({ @@ -41,7 +41,7 @@ export const SingleResponseCard = ({ updateResponse, deleteResponses, isViewer, - setSelectedResponse, + setSelectedResponseId, }: SingleResponseCardProps) => { const environmentId = survey.environmentId; const router = useRouter(); @@ -95,7 +95,7 @@ export const SingleResponseCard = ({ await deleteResponseAction({ responseId: response.id }); deleteResponses?.([response.id]); router.refresh(); - if (setSelectedResponse) setSelectedResponse(null); + if (setSelectedResponseId) setSelectedResponseId(null); toast.success("Response deleted successfully."); setDeleteDialogOpen(false); } catch (error) { From 10255aa1025c0db1647bf306b8305f9396ae04b5 Mon Sep 17 00:00:00 2001 From: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com> Date: Wed, 18 Sep 2024 15:22:05 +0530 Subject: [PATCH 07/16] fix: api errors (#3150) Co-authored-by: Piyush Gupta --- .../app/environment/lib/environmentState.ts | 10 +++++----- .../[environmentId]/app/environment/route.ts | 5 +++++ .../[environmentId]/app/people/[userId]/route.ts | 5 +++++ .../people/[userId]/attributes/route.ts | 5 +++++ .../website/environment/lib/environmentState.ts | 10 +++++----- .../[environmentId]/website/environment/route.ts | 6 ++++++ apps/web/app/lib/api/response.ts | 2 +- packages/lib/display/tests/__mocks__/data.mock.ts | 13 +++++++++++-- packages/lib/display/tests/display.test.ts | 7 ++++++- packages/lib/person/service.ts | 12 +++++++++++- packages/lib/response/tests/constants.ts | 14 ++++++++++++++ packages/lib/response/tests/response.test.ts | 5 ++++- packages/types/errors.ts | 9 +++++++-- 13 files changed, 85 insertions(+), 18 deletions(-) diff --git a/apps/web/app/api/v1/client/[environmentId]/app/environment/lib/environmentState.ts b/apps/web/app/api/v1/client/[environmentId]/app/environment/lib/environmentState.ts index 5a8d5e2e57..fb34f8a81a 100644 --- a/apps/web/app/api/v1/client/[environmentId]/app/environment/lib/environmentState.ts +++ b/apps/web/app/api/v1/client/[environmentId]/app/environment/lib/environmentState.ts @@ -40,16 +40,16 @@ export const getEnvironmentState = async ( getProductByEnvironmentId(environmentId), ]); - if (!organization) { - throw new ResourceNotFoundError("organization", environmentId); - } - if (!environment) { throw new ResourceNotFoundError("environment", environmentId); } + if (!organization) { + throw new ResourceNotFoundError("organization", null); + } + if (!product) { - throw new ResourceNotFoundError("product", environmentId); + throw new ResourceNotFoundError("product", null); } if (product.config.channel && product.config.channel !== "app") { diff --git a/apps/web/app/api/v1/client/[environmentId]/app/environment/route.ts b/apps/web/app/api/v1/client/[environmentId]/app/environment/route.ts index 0b31b5fc41..919fc350f9 100644 --- a/apps/web/app/api/v1/client/[environmentId]/app/environment/route.ts +++ b/apps/web/app/api/v1/client/[environmentId]/app/environment/route.ts @@ -3,6 +3,7 @@ import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; import { NextRequest } from "next/server"; import { environmentCache } from "@formbricks/lib/environment/cache"; +import { ResourceNotFoundError } from "@formbricks/types/errors"; import { ZJsSyncInput } from "@formbricks/types/js"; export const OPTIONS = async (): Promise => { @@ -49,6 +50,10 @@ export const GET = async ( "public, s-maxage=600, max-age=840, stale-while-revalidate=600, stale-if-error=600" ); } catch (err) { + if (err instanceof ResourceNotFoundError) { + return responses.notFoundResponse(err.resourceType, err.resourceId); + } + console.error(err); return responses.internalServerErrorResponse(err.message, true); } diff --git a/apps/web/app/api/v1/client/[environmentId]/app/people/[userId]/route.ts b/apps/web/app/api/v1/client/[environmentId]/app/people/[userId]/route.ts index 9453abd09b..46998cab3a 100644 --- a/apps/web/app/api/v1/client/[environmentId]/app/people/[userId]/route.ts +++ b/apps/web/app/api/v1/client/[environmentId]/app/people/[userId]/route.ts @@ -2,6 +2,7 @@ import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; import { NextRequest, userAgent } from "next/server"; import { personCache } from "@formbricks/lib/person/cache"; +import { ResourceNotFoundError } from "@formbricks/types/errors"; import { ZJsPersonIdentifyInput } from "@formbricks/types/js"; import { getPersonState } from "./lib/personState"; @@ -53,6 +54,10 @@ export const GET = async ( "public, s-maxage=600, max-age=840, stale-while-revalidate=600, stale-if-error=600" ); } catch (err) { + if (err instanceof ResourceNotFoundError) { + return responses.notFoundResponse(err.resourceType, err.resourceId); + } + console.error(err); return responses.internalServerErrorResponse(err.message ?? "Unable to fetch person state", true); } diff --git a/apps/web/app/api/v1/client/[environmentId]/people/[userId]/attributes/route.ts b/apps/web/app/api/v1/client/[environmentId]/people/[userId]/attributes/route.ts index 4b894af552..a26b059293 100644 --- a/apps/web/app/api/v1/client/[environmentId]/people/[userId]/attributes/route.ts +++ b/apps/web/app/api/v1/client/[environmentId]/people/[userId]/attributes/route.ts @@ -3,6 +3,7 @@ import { transformErrorToDetails } from "@/app/lib/api/validator"; import { NextRequest } from "next/server"; import { getAttributesByUserId, updateAttributes } from "@formbricks/lib/attribute/service"; import { createPerson, getPersonByUserId } from "@formbricks/lib/person/service"; +import { ResourceNotFoundError } from "@formbricks/types/errors"; import { ZJsPeopleUpdateAttributeInput } from "@formbricks/types/js"; export const OPTIONS = async () => { @@ -81,6 +82,10 @@ export const PUT = async ( return responses.forbiddenResponse(err.message || "Forbidden", true, { ignore: true }); } + if (err instanceof ResourceNotFoundError) { + return responses.notFoundResponse(err.resourceType, err.resourceId, true); + } + return responses.internalServerErrorResponse("Something went wrong", true); } }; diff --git a/apps/web/app/api/v1/client/[environmentId]/website/environment/lib/environmentState.ts b/apps/web/app/api/v1/client/[environmentId]/website/environment/lib/environmentState.ts index 6ff699ff00..0e1d2d73cf 100644 --- a/apps/web/app/api/v1/client/[environmentId]/website/environment/lib/environmentState.ts +++ b/apps/web/app/api/v1/client/[environmentId]/website/environment/lib/environmentState.ts @@ -40,16 +40,16 @@ export const getEnvironmentState = async ( getProductByEnvironmentId(environmentId), ]); - if (!organization) { - throw new ResourceNotFoundError("organization", environmentId); - } - if (!environment) { throw new ResourceNotFoundError("environment", environmentId); } + if (!organization) { + throw new ResourceNotFoundError("organization", null); + } + if (!product) { - throw new ResourceNotFoundError("product", environmentId); + throw new ResourceNotFoundError("product", null); } if (product.config.channel && product.config.channel !== "website") { diff --git a/apps/web/app/api/v1/client/[environmentId]/website/environment/route.ts b/apps/web/app/api/v1/client/[environmentId]/website/environment/route.ts index 085b2091a7..e5b20a0bf4 100644 --- a/apps/web/app/api/v1/client/[environmentId]/website/environment/route.ts +++ b/apps/web/app/api/v1/client/[environmentId]/website/environment/route.ts @@ -2,6 +2,7 @@ import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; import { NextRequest } from "next/server"; import { environmentCache } from "@formbricks/lib/environment/cache"; +import { ResourceNotFoundError } from "@formbricks/types/errors"; import { ZJsSyncInput } from "@formbricks/types/js"; import { getEnvironmentState } from "./lib/environmentState"; @@ -44,6 +45,11 @@ export const GET = async ( "public, s-maxage=600, max-age=840, stale-while-revalidate=600, stale-if-error=600" ); } catch (err) { + if (err instanceof ResourceNotFoundError) { + return responses.notFoundResponse(err.resourceType, err.resourceId); + } + + console.error(err); return responses.internalServerErrorResponse(err.message ?? "Unable to complete response", true); } } catch (error) { diff --git a/apps/web/app/lib/api/response.ts b/apps/web/app/lib/api/response.ts index 767e1e191d..ac4b9c3f93 100644 --- a/apps/web/app/lib/api/response.ts +++ b/apps/web/app/lib/api/response.ts @@ -106,7 +106,7 @@ const methodNotAllowedResponse = ( const notFoundResponse = ( resourceType: string, - resourceId: string, + resourceId: string | null, cors: boolean = false, cache: string = "private, no-store" ) => { diff --git a/packages/lib/display/tests/__mocks__/data.mock.ts b/packages/lib/display/tests/__mocks__/data.mock.ts index dd42b28c2f..22942d4bce 100644 --- a/packages/lib/display/tests/__mocks__/data.mock.ts +++ b/packages/lib/display/tests/__mocks__/data.mock.ts @@ -1,5 +1,4 @@ -import { Prisma } from "@prisma/client"; -import { selectDisplay } from "../../service"; +import { TEnvironment } from "@formbricks/types/environment"; export const mockEnvironmentId = "clqkr5961000108jyfnjmbjhi"; export const mockSingleUseId = "qj57j3opsw8b5sxgea20fgcq"; @@ -50,3 +49,13 @@ export const mockDisplayUpdate = { userId: mockUserId, responseId: mockResponseId, }; + +export const mockEnvironment: TEnvironment = { + id: mockId, + createdAt: new Date(), + updatedAt: new Date(), + type: "production", + productId: mockId, + appSetupCompleted: false, + websiteSetupCompleted: false, +}; diff --git a/packages/lib/display/tests/display.test.ts b/packages/lib/display/tests/display.test.ts index ed0f21bbd1..8a334117d5 100644 --- a/packages/lib/display/tests/display.test.ts +++ b/packages/lib/display/tests/display.test.ts @@ -7,13 +7,14 @@ import { mockDisplayUpdate, mockDisplayWithPersonId, mockDisplayWithResponseId, + mockEnvironment, mockResponseId, mockSurveyId, } from "./__mocks__/data.mock"; import { Prisma } from "@prisma/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { testInputValidation } from "vitestSetup"; -import { DatabaseError } from "@formbricks/types/errors"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; import { createDisplay, deleteDisplayByResponseId, @@ -94,6 +95,7 @@ describe("Tests for getDisplay", () => { describe("Tests for createDisplay service", () => { describe("Happy Path", () => { it("Creates a new display when a userId exists", async () => { + prisma.environment.findUnique.mockResolvedValue(mockEnvironment); prisma.display.create.mockResolvedValue(mockDisplayWithPersonId); const display = await createDisplay(mockDisplayInputWithUserId); @@ -113,6 +115,7 @@ describe("Tests for createDisplay service", () => { it("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => { const mockErrorMessage = "Mock error message"; + prisma.environment.findUnique.mockResolvedValue(mockEnvironment); const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, { code: "P2002", clientVersion: "0.0.1", @@ -136,6 +139,7 @@ describe("Tests for updateDisplay Service", () => { describe("Happy Path", () => { it("Updates a display (responded)", async () => { prisma.display.update.mockResolvedValue(mockDisplayWithResponseId); + prisma.environment.findUnique.mockResolvedValue(mockEnvironment); const display = await updateDisplay(mockDisplay.id, mockDisplayUpdate); expect(display).toEqual(mockDisplayWithResponseId); @@ -146,6 +150,7 @@ describe("Tests for updateDisplay Service", () => { testInputValidation(updateDisplay, "123", "123"); it("Throws DatabaseError on PrismaClientKnownRequestError", async () => { + prisma.environment.findUnique.mockResolvedValue(mockEnvironment); const mockErrorMessage = "Mock error message"; const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, { code: "P2002", diff --git a/packages/lib/person/service.ts b/packages/lib/person/service.ts index 3ad71cf4d5..8a7516470b 100644 --- a/packages/lib/person/service.ts +++ b/packages/lib/person/service.ts @@ -4,7 +4,7 @@ import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; import { ZOptionalNumber, ZString } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common"; -import { DatabaseError } from "@formbricks/types/errors"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; import { TPerson } from "@formbricks/types/people"; import { cache } from "../cache"; import { ITEMS_PER_PAGE } from "../constants"; @@ -218,6 +218,16 @@ export const getPersonByUserId = reactCache( async () => { validateInputs([environmentId, ZId], [userId, ZString]); + const environment = await prisma.environment.findUnique({ + where: { + id: environmentId, + }, + }); + + if (!environment) { + throw new ResourceNotFoundError("environment", environmentId); + } + // check if userId exists as a column const personWithUserId = await prisma.person.findFirst({ where: { diff --git a/packages/lib/response/tests/constants.ts b/packages/lib/response/tests/constants.ts index 90a2b3833f..99d151cf76 100644 --- a/packages/lib/response/tests/constants.ts +++ b/packages/lib/response/tests/constants.ts @@ -1,3 +1,7 @@ +import { TEnvironment } from "@formbricks/types/environment"; + +export const mockId = "ars2tjk8hsi8oqk1uac00mo8"; + export const constantsForTests = { uuid: "123e4567-e89b-12d3-a456-426614174000", browser: "Chrome", @@ -6,3 +10,13 @@ export const constantsForTests = { text: "Abc12345", fullName: "Pavitr Prabhakar", }; + +export const mockEnvironment: TEnvironment = { + id: mockId, + createdAt: new Date(), + updatedAt: new Date(), + type: "production", + productId: mockId, + appSetupCompleted: false, + websiteSetupCompleted: false, +}; diff --git a/packages/lib/response/tests/response.test.ts b/packages/lib/response/tests/response.test.ts index 19fc925dce..8a81872273 100644 --- a/packages/lib/response/tests/response.test.ts +++ b/packages/lib/response/tests/response.test.ts @@ -43,7 +43,7 @@ import { updateResponse, } from "../service"; import { buildWhereClause } from "../utils"; -import { constantsForTests } from "./constants"; +import { constantsForTests, mockEnvironment } from "./constants"; // vitest.mock("../../organization/service", async (methods) => { // return { @@ -205,6 +205,7 @@ describe("Tests for createResponse service", () => { describe("Happy Path", () => { it("Creates a response linked to an existing user", async () => { prisma.attribute.findMany.mockResolvedValue([]); + prisma.environment.findUnique.mockResolvedValue(mockEnvironment); const response = await createResponse(mockResponseInputWithUserId); expect(response).toEqual(expectedResponseWithPerson); }); @@ -216,6 +217,7 @@ describe("Tests for createResponse service", () => { it("Creates a new person and response when the person does not exist", async () => { prisma.person.findFirst.mockResolvedValue(null); + prisma.environment.findUnique.mockResolvedValue(mockEnvironment); prisma.person.create.mockResolvedValue(mockPerson); prisma.attribute.findMany.mockResolvedValue([]); @@ -246,6 +248,7 @@ describe("Tests for createResponse service", () => { clientVersion: "0.0.1", }); + prisma.environment.findUnique.mockResolvedValue(mockEnvironment); prisma.response.create.mockRejectedValue(errToThrow); prisma.attribute.findMany.mockResolvedValue([]); diff --git a/packages/types/errors.ts b/packages/types/errors.ts index ed9d854224..b427083850 100644 --- a/packages/types/errors.ts +++ b/packages/types/errors.ts @@ -2,9 +2,14 @@ import { z } from "zod"; class ResourceNotFoundError extends Error { statusCode = 404; - constructor(resource: string, id: string) { - super(`${resource} with ID ${id} not found`); + resourceId: string | null; + resourceType: string; + + constructor(resource: string, id: string | null) { + super(id ? `${resource} with ID ${id} not found` : `${resource} not found`); this.name = "ResourceNotFoundError"; + this.resourceType = resource; + this.resourceId = id; } } From b1ed61c2470362520e3b9edc6eb8cbec2387609b Mon Sep 17 00:00:00 2001 From: Sai Suhas Sawant <92092643+SaiSawant1@users.noreply.github.com> Date: Wed, 18 Sep 2024 15:23:50 +0530 Subject: [PATCH 08/16] fix: Added DialogTitle and DialogDescrtiption components to the dialog. (#3146) Co-authored-by: Dhruwang --- .../summary/components/ShareEmbedSurvey.tsx | 7 +++++-- .../summary/components/ShareSurveyResults.tsx | 21 +++++++------------ packages/ui/Modal/index.tsx | 5 ++++- .../components/SurveyLinkDisplay.tsx | 2 +- .../SurveysList/components/CopySurveyForm.tsx | 1 + 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey.tsx index 1beba61ba3..28cfea013b 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey.tsx @@ -7,7 +7,7 @@ import { useState } from "react"; import { TSurvey } from "@formbricks/types/surveys/types"; import { TUser } from "@formbricks/types/user"; import { Badge } from "@formbricks/ui/Badge"; -import { Dialog, DialogContent } from "@formbricks/ui/Dialog"; +import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@formbricks/ui/Dialog"; import { ShareSurveyLink } from "@formbricks/ui/ShareSurveyLink"; import { EmbedView } from "./shareEmbedModal/EmbedView"; import { PanelInfoView } from "./shareEmbedModal/PanelInfoView"; @@ -55,7 +55,10 @@ export const ShareEmbedSurvey = ({ survey, open, setOpen, webAppUrl, user }: Sha {showView === "start" ? (
-

Your survey is public 🎉

+ +

Your survey is public 🎉

+
+ { return ( - { - setOpen(open); - }}> + {showPublishModal && surveyUrl ? ( - +
@@ -37,7 +33,6 @@ export const ShareSurveyResults = ({ by search engines.

-
{surveyUrl} @@ -55,7 +50,6 @@ export const ShareSurveyResults = ({
-
-
- +
) : ( - +
@@ -88,8 +81,8 @@ export const ShareSurveyResults = ({ Publish to public web
- +
)} -
+ ); }; diff --git a/packages/ui/Modal/index.tsx b/packages/ui/Modal/index.tsx index 7f6734cc0c..725596b69b 100644 --- a/packages/ui/Modal/index.tsx +++ b/packages/ui/Modal/index.tsx @@ -71,7 +71,10 @@ const DialogContent = React.forwardRef< e.preventDefault(); } }}> - {title &&

{title}

} + + {title &&

{title}

} +
+ {children} { ); }; diff --git a/packages/ui/SurveysList/components/CopySurveyForm.tsx b/packages/ui/SurveysList/components/CopySurveyForm.tsx index b944760def..de293fd9d5 100644 --- a/packages/ui/SurveysList/components/CopySurveyForm.tsx +++ b/packages/ui/SurveysList/components/CopySurveyForm.tsx @@ -91,6 +91,7 @@ export const CopySurveyForm = ({ {product?.environments.map((environment) => { return ( { From e4009d5951c83d57eace6915dd83d88aca400227 Mon Sep 17 00:00:00 2001 From: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com> Date: Wed, 18 Sep 2024 16:25:46 +0530 Subject: [PATCH 09/16] chore: Refactor display response relationship (#3100) Co-authored-by: Piyush Gupta --- apps/docs/app/developer-docs/api-sdk/page.mdx | 21 ---- .../displays/[displayId]/route.ts | 40 ------- .../s/[surveyId]/components/LinkSurvey.tsx | 1 + packages/api/README.md | 12 -- packages/api/src/api/client/display.ts | 14 +-- .../data-migration.ts | 96 +++++++++++++++ .../migration.sql | 14 +++ packages/database/package.json | 3 +- packages/database/schema.prisma | 5 +- packages/js-core/src/app/lib/widget.ts | 1 + packages/js-core/src/website/lib/widget.ts | 1 + packages/lib/display/service.ts | 109 +++++------------- packages/lib/display/tests/display.test.ts | 58 ++-------- packages/lib/response/service.ts | 11 +- packages/lib/responseQueue.ts | 10 +- packages/lib/survey/service.ts | 16 +-- packages/lib/survey/tests/survey.test.ts | 4 + packages/lib/surveyState.ts | 1 + packages/react-native/src/survey-web-view.tsx | 1 + packages/types/displays.ts | 13 +-- packages/types/responses.ts | 3 + 21 files changed, 183 insertions(+), 251 deletions(-) delete mode 100644 apps/web/app/api/v1/client/[environmentId]/displays/[displayId]/route.ts create mode 100644 packages/database/data-migrations/20240905120500_refactor_display_response_relationship/data-migration.ts create mode 100644 packages/database/migrations/20240917112456_add_display_id_to_response/migration.sql diff --git a/apps/docs/app/developer-docs/api-sdk/page.mdx b/apps/docs/app/developer-docs/api-sdk/page.mdx index 5811a2c894..292423075e 100644 --- a/apps/docs/app/developer-docs/api-sdk/page.mdx +++ b/apps/docs/app/developer-docs/api-sdk/page.mdx @@ -79,27 +79,6 @@ Promise<{ id: string }, NetworkError | Error> -- Update Display - - - - -```javascript {{ title: 'Update Display Method Call'}} -await api.client.display.update( - displayId: "", - { - userId: "", // optional - responseId: "", // optional - }, -); -``` - -```javascript {{ title: 'Update Display Method Return Type' }} -Promise<{ }, NetworkError | Error]> -``` - - - ## Responses diff --git a/apps/web/app/api/v1/client/[environmentId]/displays/[displayId]/route.ts b/apps/web/app/api/v1/client/[environmentId]/displays/[displayId]/route.ts deleted file mode 100644 index e9fcdcfcf2..0000000000 --- a/apps/web/app/api/v1/client/[environmentId]/displays/[displayId]/route.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { responses } from "@/app/lib/api/response"; -import { transformErrorToDetails } from "@/app/lib/api/validator"; -import { updateDisplay } from "@formbricks/lib/display/service"; -import { ZDisplayUpdateInput } from "@formbricks/types/displays"; - -interface Context { - params: { - displayId: string; - environmentId: string; - }; -} - -export const OPTIONS = async (): Promise => { - return responses.successResponse({}, true); -}; - -export const PUT = async (request: Request, context: Context): Promise => { - const { displayId, environmentId } = context.params; - const jsonInput = await request.json(); - const inputValidation = ZDisplayUpdateInput.safeParse({ - ...jsonInput, - environmentId, - }); - - if (!inputValidation.success) { - return responses.badRequestResponse( - "Fields are missing or incorrectly formatted", - transformErrorToDetails(inputValidation.error), - true - ); - } - - try { - await updateDisplay(displayId, inputValidation.data); - return responses.successResponse({}, true); - } catch (error) { - console.error(error); - return responses.internalServerErrorResponse(error.message, true); - } -}; diff --git a/apps/web/app/s/[surveyId]/components/LinkSurvey.tsx b/apps/web/app/s/[surveyId]/components/LinkSurvey.tsx index e7c4017786..117b59fe62 100644 --- a/apps/web/app/s/[surveyId]/components/LinkSurvey.tsx +++ b/apps/web/app/s/[surveyId]/components/LinkSurvey.tsx @@ -278,6 +278,7 @@ export const LinkSurvey = ({ url: window.location.href, source: sourceParam || "", }, + displayId: surveyState.displayId, ...(Object.keys(hiddenFieldsRecord).length > 0 && { hiddenFields: hiddenFieldsRecord }), }); }} diff --git a/packages/api/README.md b/packages/api/README.md index c546b4e968..a283d79e25 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -37,18 +37,6 @@ The API client is now ready to be used across your project. It can be used to in }); ``` -- Update a Display - - ```ts - await api.client.display.update( - displayId: "", - { - userId: "", // optional - responseId: "", // optional - }, - ); - ``` - ### Response - Create a Response diff --git a/packages/api/src/api/client/display.ts b/packages/api/src/api/client/display.ts index 1b06a95524..a01da13d37 100644 --- a/packages/api/src/api/client/display.ts +++ b/packages/api/src/api/client/display.ts @@ -1,4 +1,4 @@ -import { type TDisplayCreateInput, type TDisplayUpdateInput } from "@formbricks/types/displays"; +import { type TDisplayCreateInput } from "@formbricks/types/displays"; import { type Result } from "@formbricks/types/error-handlers"; import { type NetworkError } from "@formbricks/types/errors"; import { makeRequest } from "../../utils/make-request"; @@ -17,16 +17,4 @@ export class DisplayAPI { ): Promise> { return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/displays`, "POST", displayInput); } - - async update( - displayId: string, - displayInput: Omit - ): Promise> { - return makeRequest( - this.apiHost, - `/api/v1/client/${this.environmentId}/displays/${displayId}`, - "PUT", - displayInput - ); - } } diff --git a/packages/database/data-migrations/20240905120500_refactor_display_response_relationship/data-migration.ts b/packages/database/data-migrations/20240905120500_refactor_display_response_relationship/data-migration.ts new file mode 100644 index 0000000000..315025402f --- /dev/null +++ b/packages/database/data-migrations/20240905120500_refactor_display_response_relationship/data-migration.ts @@ -0,0 +1,96 @@ +/* eslint-disable no-console -- logging is allowed in migration scripts */ +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +async function runMigration(): Promise { + await prisma.$transaction( + async (tx) => { + const startTime = Date.now(); + console.log("Starting data migration..."); + + // Fetch all displays + const displays = await tx.display.findMany({ + where: { + responseId: { + not: null, + }, + }, + select: { + id: true, + responseId: true, + }, + }); + + if (displays.length === 0) { + // Stop the migration if there are no Displays + console.log("No Displays found"); + return; + } + + console.log(`Total displays with responseId: ${displays.length.toString()}`); + + let totalResponseTransformed = 0; + let totalDisplaysDeleted = 0; + await Promise.all( + displays.map(async (display) => { + if (!display.responseId) { + return Promise.resolve(); + } + + const response = await tx.response.findUnique({ + where: { id: display.responseId }, + select: { id: true }, + }); + + if (response) { + totalResponseTransformed++; + return Promise.all([ + tx.response.update({ + where: { id: response.id }, + data: { display: { connect: { id: display.id } } }, + }), + tx.display.update({ + where: { id: display.id }, + data: { responseId: null }, + }), + ]); + } + + totalDisplaysDeleted++; + return tx.display.delete({ + where: { id: display.id }, + }); + }) + ); + + console.log(`${totalResponseTransformed.toString()} responses transformed`); + console.log(`${totalDisplaysDeleted.toString()} displays deleted`); + const endTime = Date.now(); + console.log(`Data migration completed. Total time: ${((endTime - startTime) / 1000).toString()}s`); + }, + { + timeout: 300000, // 5 minutes + } + ); +} + +function handleError(error: unknown): void { + console.error("An error occurred during migration:", error); + process.exit(1); +} + +function handleDisconnectError(): void { + console.error("Failed to disconnect Prisma client"); + process.exit(1); +} + +function main(): void { + runMigration() + .catch(handleError) + .finally(() => { + prisma.$disconnect().catch(handleDisconnectError); + }); +} + +main(); diff --git a/packages/database/migrations/20240917112456_add_display_id_to_response/migration.sql b/packages/database/migrations/20240917112456_add_display_id_to_response/migration.sql new file mode 100644 index 0000000000..d5d4decd5b --- /dev/null +++ b/packages/database/migrations/20240917112456_add_display_id_to_response/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - A unique constraint covering the columns `[displayId]` on the table `Response` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "Response" ADD COLUMN "displayId" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "Response_displayId_key" ON "Response"("displayId"); + +-- AddForeignKey +ALTER TABLE "Response" ADD CONSTRAINT "Response_displayId_fkey" FOREIGN KEY ("displayId") REFERENCES "Display"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/database/package.json b/packages/database/package.json index fac4e4898e..5e9917e268 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -47,7 +47,8 @@ "data-migration:fix-logic-end-destination": "ts-node ./data-migrations/20240806120500_fix-logic-end-destination/data-migration.ts", "data-migration:v2.4": "pnpm data-migration:segments-cleanup && pnpm data-migration:multiple-endings && pnpm data-migration:simplified-email-verification && pnpm data-migration:fix-logic-end-destination", "data-migration:remove-dismissed-value-inconsistency": "ts-node ./data-migrations/20240807120500_cta_consent_dismissed_inconsistency/data-migration.ts", - "data-migration:v2.5": "pnpm data-migration:remove-dismissed-value-inconsistency" + "data-migration:v2.5": "pnpm data-migration:remove-dismissed-value-inconsistency", + "data-migration:add-display-id-to-response": "ts-node ./data-migrations/20240905120500_refactor_display_response_relationship/data-migration.ts" }, "dependencies": { "@prisma/client": "^5.18.0", diff --git a/packages/database/schema.prisma b/packages/database/schema.prisma index fd3860c52e..268cb3371b 100644 --- a/packages/database/schema.prisma +++ b/packages/database/schema.prisma @@ -132,6 +132,8 @@ model Response { // singleUseId, used to prevent multiple responses singleUseId String? language String? + displayId String? @unique + display Display? @relation(fields: [displayId], references: [id]) @@unique([surveyId, singleUseId]) @@index([surveyId, createdAt]) // to determine monthly response count @@ -198,8 +200,9 @@ model Display { surveyId String person Person? @relation(fields: [personId], references: [id], onDelete: Cascade) personId String? - responseId String? @unique + responseId String? @unique //deprecated status DisplayStatus? + response Response? @@index([surveyId]) @@index([personId, createdAt]) diff --git a/packages/js-core/src/app/lib/widget.ts b/packages/js-core/src/app/lib/widget.ts index b7557ebcaf..79a0395959 100644 --- a/packages/js-core/src/app/lib/widget.ts +++ b/packages/js-core/src/app/lib/widget.ts @@ -193,6 +193,7 @@ const renderWidget = async ( action, }, hiddenFields, + displayId: surveyState.displayId, }); if (isNewResponse) { diff --git a/packages/js-core/src/website/lib/widget.ts b/packages/js-core/src/website/lib/widget.ts index e233004868..85b91be500 100644 --- a/packages/js-core/src/website/lib/widget.ts +++ b/packages/js-core/src/website/lib/widget.ts @@ -188,6 +188,7 @@ const renderWidget = async ( action, }, hiddenFields, + displayId: surveyState.displayId, }); if (isNewResponse) { diff --git a/packages/lib/display/service.ts b/packages/lib/display/service.ts index 3abbd8b265..66cccded77 100644 --- a/packages/lib/display/service.ts +++ b/packages/lib/display/service.ts @@ -8,12 +8,9 @@ import { TDisplay, TDisplayCreateInput, TDisplayFilters, - TDisplayUpdateInput, ZDisplayCreateInput, - ZDisplayUpdateInput, } from "@formbricks/types/displays"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; -import { TPerson } from "@formbricks/types/people"; import { cache } from "../cache"; import { ITEMS_PER_PAGE } from "../constants"; import { createPerson, getPersonByUserId } from "../person/service"; @@ -25,7 +22,6 @@ export const selectDisplay = { createdAt: true, updatedAt: true, surveyId: true, - responseId: true, personId: true, status: true, }; @@ -60,57 +56,6 @@ export const getDisplay = reactCache( )() ); -export const updateDisplay = async ( - displayId: string, - displayInput: TDisplayUpdateInput -): Promise => { - validateInputs([displayInput, ZDisplayUpdateInput.partial()]); - - let person: TPerson | null = null; - if (displayInput.userId) { - person = await getPersonByUserId(displayInput.environmentId, displayInput.userId); - if (!person) { - throw new ResourceNotFoundError("Person", displayInput.userId); - } - } - - try { - const data = { - ...(person?.id && { - person: { - connect: { - id: person.id, - }, - }, - }), - ...(displayInput.responseId && { - responseId: displayInput.responseId, - }), - }; - const display = await prisma.display.update({ - where: { - id: displayId, - }, - data, - select: selectDisplay, - }); - - displayCache.revalidate({ - id: display.id, - surveyId: display.surveyId, - }); - - return display; - } catch (error) { - console.error(error); - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - - throw error; - } -}; - export const createDisplay = async (displayInput: TDisplayCreateInput): Promise => { validateInputs([displayInput, ZDisplayCreateInput]); @@ -234,34 +179,6 @@ export const getDisplaysByUserId = reactCache( )() ); -export const deleteDisplayByResponseId = async ( - responseId: string, - surveyId: string -): Promise => { - validateInputs([responseId, ZId], [surveyId, ZId]); - - try { - const display = await prisma.display.delete({ - where: { - responseId, - }, - select: selectDisplay, - }); - - displayCache.revalidate({ - id: display.id, - personId: display.personId, - surveyId, - }); - return display; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - throw new DatabaseError(error.message); - } - throw error; - } -}; - export const getDisplayCountBySurveyId = reactCache( (surveyId: string, filters?: TDisplayFilters): Promise => cache( @@ -301,3 +218,29 @@ export const getDisplayCountBySurveyId = reactCache( } )() ); + +export const deleteDisplay = async (displayId: string): Promise => { + validateInputs([displayId, ZId]); + try { + const display = await prisma.display.delete({ + where: { + id: displayId, + }, + select: selectDisplay, + }); + + displayCache.revalidate({ + id: display.id, + personId: display.personId, + surveyId: display.surveyId, + }); + + return display; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + + throw error; + } +}; diff --git a/packages/lib/display/tests/display.test.ts b/packages/lib/display/tests/display.test.ts index 8a334117d5..0aed38ac48 100644 --- a/packages/lib/display/tests/display.test.ts +++ b/packages/lib/display/tests/display.test.ts @@ -4,24 +4,20 @@ import { mockDisplay, mockDisplayInput, mockDisplayInputWithUserId, - mockDisplayUpdate, mockDisplayWithPersonId, - mockDisplayWithResponseId, mockEnvironment, - mockResponseId, mockSurveyId, } from "./__mocks__/data.mock"; import { Prisma } from "@prisma/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { testInputValidation } from "vitestSetup"; -import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { DatabaseError } from "@formbricks/types/errors"; import { createDisplay, - deleteDisplayByResponseId, + deleteDisplay, getDisplay, getDisplayCountBySurveyId, getDisplaysByPersonId, - updateDisplay, } from "../service"; beforeEach(() => { @@ -135,49 +131,13 @@ describe("Tests for createDisplay service", () => { }); }); -describe("Tests for updateDisplay Service", () => { +describe("Tests for delete display service", () => { describe("Happy Path", () => { - it("Updates a display (responded)", async () => { - prisma.display.update.mockResolvedValue(mockDisplayWithResponseId); - prisma.environment.findUnique.mockResolvedValue(mockEnvironment); + it("Deletes a display", async () => { + prisma.display.delete.mockResolvedValue(mockDisplay); - const display = await updateDisplay(mockDisplay.id, mockDisplayUpdate); - expect(display).toEqual(mockDisplayWithResponseId); - }); - }); - - describe("Sad Path", () => { - testInputValidation(updateDisplay, "123", "123"); - - it("Throws DatabaseError on PrismaClientKnownRequestError", async () => { - prisma.environment.findUnique.mockResolvedValue(mockEnvironment); - const mockErrorMessage = "Mock error message"; - const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, { - code: "P2002", - clientVersion: "0.0.1", - }); - - prisma.display.update.mockRejectedValue(errToThrow); - - await expect(updateDisplay(mockDisplay.id, mockDisplayUpdate)).rejects.toThrow(DatabaseError); - }); - - it("Throws a generic Error for other unexpected issues", async () => { - const mockErrorMessage = "Mock error message"; - prisma.display.update.mockRejectedValue(new Error(mockErrorMessage)); - - await expect(updateDisplay(mockDisplay.id, mockDisplayUpdate)).rejects.toThrow(Error); - }); - }); -}); - -describe("Tests for deleteDisplayByResponseId service", () => { - describe("Happy Path", () => { - it("Deletes a display when a response associated to it is deleted", async () => { - prisma.display.delete.mockResolvedValue(mockDisplayWithResponseId); - - const display = await deleteDisplayByResponseId(mockResponseId, mockSurveyId); - expect(display).toEqual(mockDisplayWithResponseId); + const display = await deleteDisplay(mockDisplay.id); + expect(display).toEqual(mockDisplay); }); }); describe("Sad Path", () => { @@ -190,14 +150,14 @@ describe("Tests for deleteDisplayByResponseId service", () => { prisma.display.delete.mockRejectedValue(errToThrow); - await expect(deleteDisplayByResponseId(mockResponseId, mockSurveyId)).rejects.toThrow(DatabaseError); + await expect(deleteDisplay(mockDisplay.id)).rejects.toThrow(DatabaseError); }); it("Throws a generic Error for other exceptions", async () => { const mockErrorMessage = "Mock error message"; prisma.display.delete.mockRejectedValue(new Error(mockErrorMessage)); - await expect(deleteDisplayByResponseId(mockResponseId, mockSurveyId)).rejects.toThrow(Error); + await expect(deleteDisplay(mockDisplay.id)).rejects.toThrow(Error); }); }); }); diff --git a/packages/lib/response/service.ts b/packages/lib/response/service.ts index 7d7253e588..e2e800668a 100644 --- a/packages/lib/response/service.ts +++ b/packages/lib/response/service.ts @@ -22,7 +22,7 @@ import { getAttributes } from "../attribute/service"; import { cache } from "../cache"; import { IS_FORMBRICKS_CLOUD, ITEMS_PER_PAGE, WEBAPP_URL } from "../constants"; import { displayCache } from "../display/cache"; -import { deleteDisplayByResponseId, getDisplayCountBySurveyId } from "../display/service"; +import { deleteDisplay, getDisplayCountBySurveyId } from "../display/service"; import { getMonthlyOrganizationResponseCount, getOrganizationByEnvironmentId } from "../organization/service"; import { createPerson, getPersonByUserId } from "../person/service"; import { sendPlanLimitsReachedEventToPosthogWeekly } from "../posthogServer"; @@ -62,6 +62,7 @@ export const responseSelection = { personAttributes: true, singleUseId: true, language: true, + displayId: true, person: { select: { id: true, @@ -254,6 +255,7 @@ export const createResponse = async (responseInput: TResponseInput): Promise => tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag), }; - deleteDisplayByResponseId(responseId, response.surveyId); - + if (response.displayId) { + deleteDisplay(response.displayId); + } const survey = await getSurvey(response.surveyId); if (survey) { diff --git a/packages/lib/responseQueue.ts b/packages/lib/responseQueue.ts index 21c2df8465..bcb4f8f834 100644 --- a/packages/lib/responseQueue.ts +++ b/packages/lib/responseQueue.ts @@ -87,19 +87,11 @@ export class ResponseQueue { userId: this.surveyState.userId || null, singleUseId: this.surveyState.singleUseId || null, data: { ...responseUpdate.data, ...responseUpdate.hiddenFields }, + displayId: this.surveyState.displayId, }); if (!response.ok) { throw new Error("Could not create response"); } - if (this.surveyState.displayId) { - try { - await this.api.client.display.update(this.surveyState.displayId, { - responseId: response.data.id, - }); - } catch (error) { - console.error(`Failed to update display, proceeding with the response. ${error}`); - } - } this.surveyState.updateResponseId(response.data.id); if (this.config.setSurveyState) { this.config.setSurveyState(this.surveyState); diff --git a/packages/lib/survey/service.ts b/packages/lib/survey/service.ts index 4811142683..e6b9045fc2 100644 --- a/packages/lib/survey/service.ts +++ b/packages/lib/survey/service.ts @@ -36,6 +36,7 @@ import { capturePosthogEnvironmentEvent } from "../posthogServer"; import { productCache } from "../product/cache"; import { getProductByEnvironmentId } from "../product/service"; import { responseCache } from "../response/cache"; +import { getResponsesByPersonId } from "../response/service"; import { segmentCache } from "../segment/cache"; import { createSegment, deleteSegment, evaluateSegment, getSegment, updateSegment } from "../segment/service"; import { diffInDays } from "../utils/datetime"; @@ -1124,6 +1125,7 @@ export const getSyncSurveys = reactCache( } const displays = await getDisplaysByPersonId(person.id); + const responses = await getResponsesByPersonId(person.id); // filter surveys that meet the displayOption criteria surveys = surveys.filter((survey) => { @@ -1133,20 +1135,18 @@ export const getSyncSurveys = reactCache( case "displayOnce": return displays.filter((display) => display.surveyId === survey.id).length === 0; case "displayMultiple": - return ( - displays - .filter((display) => display.surveyId === survey.id) - .filter((display) => display.responseId).length === 0 - ); + if (!responses) return true; + else { + return responses.filter((response) => response.surveyId === survey.id).length === 0; + } case "displaySome": if (survey.displayLimit === null) { return true; } if ( - displays - .filter((display) => display.surveyId === survey.id) - .some((display) => display.responseId) + responses && + responses.filter((response) => response.surveyId === survey.id).length !== 0 ) { return false; } diff --git a/packages/lib/survey/tests/survey.test.ts b/packages/lib/survey/tests/survey.test.ts index 216af08b99..06da286338 100644 --- a/packages/lib/survey/tests/survey.test.ts +++ b/packages/lib/survey/tests/survey.test.ts @@ -1,4 +1,5 @@ import { prisma } from "../../__mocks__/database"; +import { mockResponseNote, mockResponseWithMockPerson } from "../../response/tests/__mocks__/data.mock"; import { Prisma } from "@prisma/client"; import { beforeEach, describe, expect, it } from "vitest"; import { testInputValidation } from "vitestSetup"; @@ -303,6 +304,9 @@ describe("Tests for getSyncSurveys", () => { it("Returns synced surveys", async () => { prisma.survey.findMany.mockResolvedValueOnce([mockSyncSurveyOutput]); prisma.person.findUnique.mockResolvedValueOnce(mockPrismaPerson); + prisma.response.findMany.mockResolvedValue([mockResponseWithMockPerson]); + prisma.responseNote.findMany.mockResolvedValue([mockResponseNote]); + const surveys = await getSyncSurveys(mockId, mockPrismaPerson.id, "desktop", { version: "1.7.0", }); diff --git a/packages/lib/surveyState.ts b/packages/lib/surveyState.ts index cb0e9fcce8..16443882bc 100644 --- a/packages/lib/surveyState.ts +++ b/packages/lib/surveyState.ts @@ -76,6 +76,7 @@ export class SurveyState { finished: responseUpdate.finished, ttc: responseUpdate.ttc, data: { ...this.responseAcc.data, ...responseUpdate.data }, + displayId: responseUpdate.displayId, }; } diff --git a/packages/react-native/src/survey-web-view.tsx b/packages/react-native/src/survey-web-view.tsx index bb77a53beb..3028080bc7 100644 --- a/packages/react-native/src/survey-web-view.tsx +++ b/packages/react-native/src/survey-web-view.tsx @@ -96,6 +96,7 @@ export function SurveyWebView({ survey }: SurveyWebViewProps): JSX.Element | und finished: responseUpdate.finished, language: responseUpdate.language === "default" ? getDefaultLanguageCode(survey) : responseUpdate.language, + displayId: surveyState.displayId, }); }; diff --git a/packages/types/displays.ts b/packages/types/displays.ts index 9943e99ff5..d9157b8e21 100644 --- a/packages/types/displays.ts +++ b/packages/types/displays.ts @@ -4,9 +4,8 @@ export const ZDisplay = z.object({ id: z.string().cuid2(), createdAt: z.date(), updatedAt: z.date(), - personId: z.string().cuid2().nullable(), - surveyId: z.string().cuid2(), - responseId: z.string().cuid2().nullable(), + personId: z.string().cuid().nullable(), + surveyId: z.string().cuid(), status: z.enum(["seen", "responded"]).nullable(), }); @@ -21,14 +20,6 @@ export const ZDisplayCreateInput = z.object({ export type TDisplayCreateInput = z.infer; -export const ZDisplayUpdateInput = z.object({ - environmentId: z.string().cuid2(), - userId: z.string().optional(), - responseId: z.string().cuid2().optional(), -}); - -export type TDisplayUpdateInput = z.infer; - export const ZDisplaysWithSurveyName = ZDisplay.extend({ surveyName: z.string(), }); diff --git a/packages/types/responses.ts b/packages/types/responses.ts index 5586053b1b..df81229770 100644 --- a/packages/types/responses.ts +++ b/packages/types/responses.ts @@ -226,6 +226,7 @@ export const ZResponse = z.object({ createdAt: z.date(), updatedAt: z.date(), surveyId: z.string().cuid2(), + displayId: z.string().nullish(), person: ZResponsePerson.nullable(), personAttributes: ZResponsePersonAttributes, finished: z.boolean(), @@ -246,6 +247,7 @@ export const ZResponseInput = z.object({ environmentId: z.string().cuid2(), surveyId: z.string().cuid2(), userId: z.string().nullish(), + displayId: z.string().nullish(), singleUseId: z.string().nullable().optional(), finished: z.boolean(), language: z.string().optional(), @@ -301,6 +303,7 @@ export const ZResponseUpdate = z.object({ }) .optional(), hiddenFields: ZResponseHiddenFieldValue.optional(), + displayId: z.string().nullish(), }); export type TResponseUpdate = z.infer; From fe9746ba6786faac56b87de6cb8134927d1feb98 Mon Sep 17 00:00:00 2001 From: Matti Nannt Date: Thu, 19 Sep 2024 13:00:39 +0200 Subject: [PATCH 10/16] fix: update displays data migration run out of memory on many displays (#3157) Co-authored-by: pandeymangg --- .../data-migration.ts | 121 ++++++++++-------- 1 file changed, 70 insertions(+), 51 deletions(-) diff --git a/packages/database/data-migrations/20240905120500_refactor_display_response_relationship/data-migration.ts b/packages/database/data-migrations/20240905120500_refactor_display_response_relationship/data-migration.ts index 315025402f..2dad514b4b 100644 --- a/packages/database/data-migrations/20240905120500_refactor_display_response_relationship/data-migration.ts +++ b/packages/database/data-migrations/20240905120500_refactor_display_response_relationship/data-migration.ts @@ -2,15 +2,37 @@ import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); +const TRANSACTION_TIMEOUT = 30 * 60 * 1000; // 30 minutes in milliseconds async function runMigration(): Promise { - await prisma.$transaction( - async (tx) => { - const startTime = Date.now(); - console.log("Starting data migration..."); + const startTime = Date.now(); + console.log("Starting data migration..."); - // Fetch all displays - const displays = await tx.display.findMany({ + await prisma.$transaction( + async (transactionPrisma) => { + // Step 1: Use raw SQL to bulk update responses where responseId is not null in displays + console.log("Running bulk update for responses with valid responseId..."); + + const rawQueryResult = await transactionPrisma.$executeRaw` + WITH updated_displays AS ( + UPDATE public."Response" r + SET "displayId" = d.id + FROM public."Display" d + WHERE r.id = d."responseId" + RETURNING d.id + ) + UPDATE public."Display" + SET "responseId" = NULL + WHERE id IN (SELECT id FROM updated_displays); + `; + + console.log("Bulk update completed!"); + + // Step 2: Handle the case where a display has a responseId but the corresponding response does not exist + console.log("Handling displays where the responseId exists but the response is missing..."); + + // Find displays where responseId is not null but the corresponding response does not exist + const displaysWithMissingResponses = await transactionPrisma.display.findMany({ where: { responseId: { not: null, @@ -22,57 +44,54 @@ async function runMigration(): Promise { }, }); - if (displays.length === 0) { - // Stop the migration if there are no Displays - console.log("No Displays found"); - return; + const responseIds = displaysWithMissingResponses + .map((display) => display.responseId) + .filter((id): id is string => id !== null); + + // Check which of the responseIds actually exist in the responses table + const existingResponses = await transactionPrisma.response.findMany({ + where: { + id: { + in: responseIds, + }, + }, + select: { + id: true, + }, + }); + + const existingResponseIds = new Set(existingResponses.map((response) => response.id)); + + // Find displays where the responseId does not exist in the responses table + const displayIdsToDelete = displaysWithMissingResponses + .filter((display) => !existingResponseIds.has(display.responseId as unknown as string)) + .map((display) => display.id); + + if (displayIdsToDelete.length > 0) { + console.log( + `Deleting ${displayIdsToDelete.length.toString()} displays where the response is missing...` + ); + + await transactionPrisma.display.deleteMany({ + where: { + id: { + in: displayIdsToDelete, + }, + }, + }); } - console.log(`Total displays with responseId: ${displays.length.toString()}`); - - let totalResponseTransformed = 0; - let totalDisplaysDeleted = 0; - await Promise.all( - displays.map(async (display) => { - if (!display.responseId) { - return Promise.resolve(); - } - - const response = await tx.response.findUnique({ - where: { id: display.responseId }, - select: { id: true }, - }); - - if (response) { - totalResponseTransformed++; - return Promise.all([ - tx.response.update({ - where: { id: response.id }, - data: { display: { connect: { id: display.id } } }, - }), - tx.display.update({ - where: { id: display.id }, - data: { responseId: null }, - }), - ]); - } - - totalDisplaysDeleted++; - return tx.display.delete({ - where: { id: display.id }, - }); - }) - ); - - console.log(`${totalResponseTransformed.toString()} responses transformed`); - console.log(`${totalDisplaysDeleted.toString()} displays deleted`); - const endTime = Date.now(); - console.log(`Data migration completed. Total time: ${((endTime - startTime) / 1000).toString()}s`); + console.log("Displays where the response was missing have been deleted."); + console.log("Data migration completed."); + console.log(`Affected rows: ${rawQueryResult + displayIdsToDelete.length}`); }, { - timeout: 300000, // 5 minutes + timeout: TRANSACTION_TIMEOUT, } ); + + const endTime = Date.now(); + console.log(`Data migration completed. Total time: ${((endTime - startTime) / 1000).toFixed(2)}s`); } function handleError(error: unknown): void { From 6b64367d99d08222f4dd49bcbefc1dbaaa8f80b5 Mon Sep 17 00:00:00 2001 From: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com> Date: Thu, 19 Sep 2024 19:04:25 +0530 Subject: [PATCH 11/16] feat: Data table for persons (#3154) --- .../edit/components/AddQuestionButton.tsx | 6 +- .../edit/components/EditorCardMenu.tsx | 2 +- .../edit/components/QuestionCard.tsx | 2 +- .../edit/components/SurveyMenuBar.tsx | 9 + .../(people)/attributes/loading.tsx | 4 +- .../(people)/attributes/page.tsx | 4 +- .../components/DeletePersonButton.tsx | 2 +- .../(people)/people/actions.ts | 43 ++++ .../(people)/people/components/PersonCard.tsx | 38 --- .../people/components/PersonDataView.tsx | 107 ++++++++ ...tion.tsx => PersonSecondaryNavigation.tsx} | 6 +- .../people/components/PersonTable.tsx | 238 ++++++++++++++++++ .../people/components/PersonTableColumn.tsx | 83 ++++++ .../(people)/people/components/pagination.tsx | 55 ---- .../(people)/people/loading.tsx | 4 +- .../[environmentId]/(people)/people/page.tsx | 66 +---- .../(people)/segments/loading.tsx | 4 +- .../(people)/segments/page.tsx | 4 +- .../notion/components/AddIntegrationModal.tsx | 2 +- .../responses/components/ResponseTable.tsx | 152 ++++++----- .../components/ResponseTableCell.tsx | 10 +- .../components/ResponseTableColumns.tsx | 36 +-- .../components/QuestionSummaryHeader.tsx | 2 +- packages/lib/person/service.ts | 4 +- .../lib => packages/lib/utils}/questions.tsx | 4 +- .../lib => packages/lib/utils}/templates.ts | 4 +- packages/types/people.ts | 10 + .../ui/DataTable}/actions.ts | 0 .../components/ColumnSettingsDropdown.tsx | 17 +- .../DataTable/components/DataTableHeader.tsx | 29 +-- .../components/DataTableSettingsModal.tsx | 23 +- .../components/DataTableSettingsModalItem.tsx | 16 +- .../DataTable/components/DataTableToolbar.tsx | 21 +- .../components/SelectedRowSettings.tsx | 45 ++-- .../DataTable/components/SelectionColumn.tsx | 31 +++ packages/ui/DataTable/index.tsx | 6 + packages/ui/DataTable/lib/utils.ts | 11 + packages/ui/Table/index.tsx | 2 +- 38 files changed, 758 insertions(+), 344 deletions(-) create mode 100644 apps/web/app/(app)/environments/[environmentId]/(people)/people/actions.ts delete mode 100644 apps/web/app/(app)/environments/[environmentId]/(people)/people/components/PersonCard.tsx create mode 100644 apps/web/app/(app)/environments/[environmentId]/(people)/people/components/PersonDataView.tsx rename apps/web/app/(app)/environments/[environmentId]/(people)/people/components/{PeopleSecondaryNavigation.tsx => PersonSecondaryNavigation.tsx} (90%) create mode 100644 apps/web/app/(app)/environments/[environmentId]/(people)/people/components/PersonTable.tsx create mode 100644 apps/web/app/(app)/environments/[environmentId]/(people)/people/components/PersonTableColumn.tsx delete mode 100644 apps/web/app/(app)/environments/[environmentId]/(people)/people/components/pagination.tsx rename {apps/web/app/lib => packages/lib/utils}/questions.tsx (98%) rename {apps/web/app/lib => packages/lib/utils}/templates.ts (90%) rename {apps/web/app/(app)/environments/[environmentId]/(people)/people/[personId] => packages/ui/DataTable}/actions.ts (100%) rename {apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses => packages/ui/DataTable}/components/ColumnSettingsDropdown.tsx (76%) rename apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableHeader.tsx => packages/ui/DataTable/components/DataTableHeader.tsx (72%) rename apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/TableSettingsModal.tsx => packages/ui/DataTable/components/DataTableSettingsModal.tsx (76%) rename apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/TableSettingsModalItem.tsx => packages/ui/DataTable/components/DataTableSettingsModalItem.tsx (83%) rename apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableToolbar.tsx => packages/ui/DataTable/components/DataTableToolbar.tsx (71%) rename apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/SelectedResponseSettings.tsx => packages/ui/DataTable/components/SelectedRowSettings.tsx (60%) create mode 100644 packages/ui/DataTable/components/SelectionColumn.tsx create mode 100644 packages/ui/DataTable/index.tsx create mode 100644 packages/ui/DataTable/lib/utils.ts diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddQuestionButton.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddQuestionButton.tsx index ad446619a6..1a6d8ea474 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddQuestionButton.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddQuestionButton.tsx @@ -1,11 +1,15 @@ "use client"; -import { getQuestionDefaults, questionTypes, universalQuestionPresets } from "@/app/lib/questions"; import { createId } from "@paralleldrive/cuid2"; import * as Collapsible from "@radix-ui/react-collapsible"; import { PlusIcon } from "lucide-react"; import { useState } from "react"; import { cn } from "@formbricks/lib/cn"; +import { + getQuestionDefaults, + questionTypes, + universalQuestionPresets, +} from "@formbricks/lib/utils/questions"; import { TProduct } from "@formbricks/types/product"; interface AddQuestionButtonProps { diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditorCardMenu.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditorCardMenu.tsx index 032588216d..0ddea6ac1d 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditorCardMenu.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditorCardMenu.tsx @@ -1,10 +1,10 @@ "use client"; -import { QUESTIONS_ICON_MAP, QUESTIONS_NAME_MAP, getQuestionDefaults } from "@/app/lib/questions"; import { createId } from "@paralleldrive/cuid2"; import { ArrowDownIcon, ArrowUpIcon, CopyIcon, EllipsisIcon, TrashIcon } from "lucide-react"; import { useState } from "react"; import { cn } from "@formbricks/lib/cn"; +import { QUESTIONS_ICON_MAP, QUESTIONS_NAME_MAP, getQuestionDefaults } from "@formbricks/lib/utils/questions"; import { TProduct } from "@formbricks/types/product"; import { TSurvey, diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionCard.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionCard.tsx index 7f841c117a..8fc5c21de8 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionCard.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionCard.tsx @@ -2,13 +2,13 @@ import { RankingQuestionForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RankingQuestionForm"; import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/util"; -import { QUESTIONS_ICON_MAP, getTSurveyQuestionTypeEnumName } from "@/app/lib/questions"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import * as Collapsible from "@radix-ui/react-collapsible"; import { ChevronDownIcon, ChevronRightIcon, GripIcon } from "lucide-react"; import { useState } from "react"; import { cn } from "@formbricks/lib/cn"; +import { QUESTIONS_ICON_MAP, getTSurveyQuestionTypeEnumName } from "@formbricks/lib/utils/questions"; import { recallToHeadline } from "@formbricks/lib/utils/recall"; import { TAttributeClass } from "@formbricks/types/attribute-classes"; import { TProduct } from "@formbricks/types/product"; diff --git a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyMenuBar.tsx b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyMenuBar.tsx index 66699dd914..a55efa2da1 100644 --- a/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyMenuBar.tsx +++ b/apps/web/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyMenuBar.tsx @@ -86,6 +86,13 @@ export const SurveyMenuBar = ({ }; }, [localSurvey, survey]); + const clearSurveyLocalStorage = () => { + if (typeof localStorage !== "undefined") { + localStorage.removeItem(`${localSurvey.id}-columnOrder`); + localStorage.removeItem(`${localSurvey.id}-columnVisibility`); + } + }; + const containsEmptyTriggers = useMemo(() => { if (localSurvey.type === "link") return false; @@ -233,6 +240,7 @@ export const SurveyMenuBar = ({ } const segment = await handleSegmentUpdate(); + clearSurveyLocalStorage(); const updatedSurveyResponse = await updateSurveyAction({ ...localSurvey, segment }); setIsSurveySaving(false); @@ -278,6 +286,7 @@ export const SurveyMenuBar = ({ } const status = localSurvey.runOnDate ? "scheduled" : "inProgress"; const segment = await handleSegmentUpdate(); + clearSurveyLocalStorage(); await updateSurveyAction({ ...localSurvey, diff --git a/apps/web/app/(app)/environments/[environmentId]/(people)/attributes/loading.tsx b/apps/web/app/(app)/environments/[environmentId]/(people)/attributes/loading.tsx index 987ddec86d..33d0fe85b1 100644 --- a/apps/web/app/(app)/environments/[environmentId]/(people)/attributes/loading.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/(people)/attributes/loading.tsx @@ -1,4 +1,4 @@ -import { PeopleSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PeopleSecondaryNavigation"; +import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation"; import { TagIcon } from "lucide-react"; import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper"; import { PageHeader } from "@formbricks/ui/PageHeader"; @@ -8,7 +8,7 @@ const Loading = () => { <> - +
diff --git a/apps/web/app/(app)/environments/[environmentId]/(people)/attributes/page.tsx b/apps/web/app/(app)/environments/[environmentId]/(people)/attributes/page.tsx index b012f5263e..3ed401747f 100644 --- a/apps/web/app/(app)/environments/[environmentId]/(people)/attributes/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/(people)/attributes/page.tsx @@ -1,4 +1,4 @@ -import { PeopleSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PeopleSecondaryNavigation"; +import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation"; import { CircleHelpIcon } from "lucide-react"; import { Metadata } from "next"; import { notFound } from "next/navigation"; @@ -42,7 +42,7 @@ const Page = async ({ params }) => { return ( - + diff --git a/apps/web/app/(app)/environments/[environmentId]/(people)/people/[personId]/components/DeletePersonButton.tsx b/apps/web/app/(app)/environments/[environmentId]/(people)/people/[personId]/components/DeletePersonButton.tsx index 0899fdd803..a8a3ee7025 100644 --- a/apps/web/app/(app)/environments/[environmentId]/(people)/people/[personId]/components/DeletePersonButton.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/(people)/people/[personId]/components/DeletePersonButton.tsx @@ -1,11 +1,11 @@ "use client"; -import { deletePersonAction } from "@/app/(app)/environments/[environmentId]/(people)/people/[personId]/actions"; import { TrashIcon } from "lucide-react"; import { useRouter } from "next/navigation"; import { useState } from "react"; import toast from "react-hot-toast"; import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper"; +import { deletePersonAction } from "@formbricks/ui/DataTable/actions"; import { DeleteDialog } from "@formbricks/ui/DeleteDialog"; interface DeletePersonButtonProps { diff --git a/apps/web/app/(app)/environments/[environmentId]/(people)/people/actions.ts b/apps/web/app/(app)/environments/[environmentId]/(people)/people/actions.ts new file mode 100644 index 0000000000..47c9215ca0 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/(people)/people/actions.ts @@ -0,0 +1,43 @@ +"use server"; + +import { z } from "zod"; +import { authenticatedActionClient } from "@formbricks/lib/actionClient"; +import { checkAuthorization } from "@formbricks/lib/actionClient/utils"; +import { getAttributes } from "@formbricks/lib/attribute/service"; +import { getOrganizationIdFromEnvironmentId } from "@formbricks/lib/organization/utils"; +import { getPeople } from "@formbricks/lib/person/service"; +import { ZId } from "@formbricks/types/common"; + +const ZGetPersonsAction = z.object({ + environmentId: ZId, + page: z.number(), +}); + +export const getPersonsAction = authenticatedActionClient + .schema(ZGetPersonsAction) + .action(async ({ ctx, parsedInput }) => { + await checkAuthorization({ + userId: ctx.user.id, + organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId), + rules: ["environment", "read"], + }); + + return getPeople(parsedInput.environmentId, parsedInput.page); + }); + +const ZGetPersonAttributesAction = z.object({ + environmentId: ZId, + personId: ZId, +}); + +export const getPersonAttributesAction = authenticatedActionClient + .schema(ZGetPersonAttributesAction) + .action(async ({ ctx, parsedInput }) => { + await checkAuthorization({ + userId: ctx.user.id, + organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId), + rules: ["environment", "read"], + }); + + return getAttributes(parsedInput.personId); + }); diff --git a/apps/web/app/(app)/environments/[environmentId]/(people)/people/components/PersonCard.tsx b/apps/web/app/(app)/environments/[environmentId]/(people)/people/components/PersonCard.tsx deleted file mode 100644 index 3f927f88ee..0000000000 --- a/apps/web/app/(app)/environments/[environmentId]/(people)/people/components/PersonCard.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import Link from "next/link"; -import React from "react"; -import { getAttributes } from "@formbricks/lib/attribute/service"; -import { getPersonIdentifier } from "@formbricks/lib/person/utils"; -import { TPerson } from "@formbricks/types/people"; -import { PersonAvatar } from "@formbricks/ui/Avatars"; - -export const PersonCard = async ({ person }: { person: TPerson }) => { - const attributes = await getAttributes(person.id); - - return ( - -
-
-
-
- -
-
-
- {getPersonIdentifier({ id: person.id, userId: person.userId }, attributes)} -
-
-
-
-
-
{person.userId}
-
-
-
{attributes.email}
-
-
- - ); -}; diff --git a/apps/web/app/(app)/environments/[environmentId]/(people)/people/components/PersonDataView.tsx b/apps/web/app/(app)/environments/[environmentId]/(people)/people/components/PersonDataView.tsx new file mode 100644 index 0000000000..300521a021 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/(people)/people/components/PersonDataView.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { + getPersonAttributesAction, + getPersonsAction, +} from "@/app/(app)/environments/[environmentId]/(people)/people/actions"; +import { PersonTable } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonTable"; +import { useEffect, useState } from "react"; +import React from "react"; +import { TEnvironment } from "@formbricks/types/environment"; +import { TPerson, TPersonTableData } from "@formbricks/types/people"; + +interface PersonDataViewProps { + environment: TEnvironment; + personCount: number; + itemsPerPage: number; +} + +export const PersonDataView = ({ environment, personCount, itemsPerPage }: PersonDataViewProps) => { + const [persons, setPersons] = useState([]); + const [personTableData, setPersonTableData] = useState([]); + const [pageNumber, setPageNumber] = useState(1); + const [totalPersons, setTotalPersons] = useState(0); + const [isDataLoaded, setIsDataLoaded] = useState(false); + const [hasMore, setHasMore] = useState(false); + const [loadingNextPage, setLoadingNextPage] = useState(false); + + useEffect(() => { + setTotalPersons(personCount); + setHasMore(pageNumber < Math.ceil(personCount / itemsPerPage)); + + const fetchData = async () => { + try { + const getPersonActionData = await getPersonsAction({ + environmentId: environment.id, + page: pageNumber, + }); + if (getPersonActionData?.data) { + setPersons(getPersonActionData.data); + } + } catch (error) { + console.error("Error fetching people data:", error); + } + }; + + fetchData(); + }, [pageNumber]); + + // Fetch additional person attributes and update table data + useEffect(() => { + const fetchAttributes = async () => { + const updatedPersonTableData = await Promise.all( + persons.map(async (person) => { + const attributes = await getPersonAttributesAction({ + environmentId: environment.id, + personId: person.id, + }); + return { + createdAt: person.createdAt, + personId: person.id, + userId: person.userId, + email: attributes?.data?.email ?? "", + attributes: attributes?.data ?? {}, + }; + }) + ); + setPersonTableData(updatedPersonTableData); + setIsDataLoaded(true); + }; + + if (persons.length > 0) { + fetchAttributes(); + } + }, [persons]); + + const fetchNextPage = async () => { + if (hasMore && !loadingNextPage) { + setLoadingNextPage(true); + const getPersonsActionData = await getPersonsAction({ + environmentId: environment.id, + page: pageNumber, + }); + if (getPersonsActionData?.data) { + const newData = getPersonsActionData.data; + setPersons((prevPersonsData) => [...prevPersonsData, ...newData]); + } + setPageNumber((prevPage) => prevPage + 1); + setHasMore(pageNumber + 1 < Math.ceil(totalPersons / itemsPerPage)); + setLoadingNextPage(false); + } + }; + + const deletePersons = (personIds: string[]) => { + setPersons(persons.filter((p) => !personIds.includes(p.id))); + }; + + return ( + + ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/(people)/people/components/PeopleSecondaryNavigation.tsx b/apps/web/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation.tsx similarity index 90% rename from apps/web/app/(app)/environments/[environmentId]/(people)/people/components/PeopleSecondaryNavigation.tsx rename to apps/web/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation.tsx index 3df8dd3dfe..cfe21a81b3 100644 --- a/apps/web/app/(app)/environments/[environmentId]/(people)/people/components/PeopleSecondaryNavigation.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation.tsx @@ -2,17 +2,17 @@ import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; import { TProductConfigChannel } from "@formbricks/types/product"; import { SecondaryNavigation } from "@formbricks/ui/SecondaryNavigation"; -interface PeopleSegmentsTabsProps { +interface PersonSecondaryNavigationProps { activeId: string; environmentId?: string; loading?: boolean; } -export const PeopleSecondaryNavigation = async ({ +export const PersonSecondaryNavigation = async ({ activeId, environmentId, loading, -}: PeopleSegmentsTabsProps) => { +}: PersonSecondaryNavigationProps) => { let currentProductChannel: TProductConfigChannel = null; if (!loading && environmentId) { diff --git a/apps/web/app/(app)/environments/[environmentId]/(people)/people/components/PersonTable.tsx b/apps/web/app/(app)/environments/[environmentId]/(people)/people/components/PersonTable.tsx new file mode 100644 index 0000000000..0848f81aef --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/(people)/people/components/PersonTable.tsx @@ -0,0 +1,238 @@ +import { generatePersonTableColumns } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonTableColumn"; +import { + DndContext, + type DragEndEvent, + KeyboardSensor, + MouseSensor, + TouchSensor, + closestCenter, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { restrictToHorizontalAxis } from "@dnd-kit/modifiers"; +import { SortableContext, arrayMove, horizontalListSortingStrategy } from "@dnd-kit/sortable"; +import { VisibilityState, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"; +import { useRouter } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; +import { cn } from "@formbricks/lib/cn"; +import { TPersonTableData } from "@formbricks/types/people"; +import { Button } from "@formbricks/ui/Button"; +import { DataTableHeader, DataTableSettingsModal, DataTableToolbar } from "@formbricks/ui/DataTable"; +import { Skeleton } from "@formbricks/ui/Skeleton"; +import { Table, TableBody, TableCell, TableHeader, TableRow } from "@formbricks/ui/Table"; + +interface PersonTableProps { + data: TPersonTableData[]; + fetchNextPage: () => void; + hasMore: boolean; + deletePersons: (personIds: string[]) => void; + isDataLoaded: boolean; + environmentId: string; +} + +export const PersonTable = ({ + data, + fetchNextPage, + hasMore, + deletePersons, + isDataLoaded, + environmentId, +}: PersonTableProps) => { + const [columnVisibility, setColumnVisibility] = useState({}); + const [columnOrder, setColumnOrder] = useState([]); + const [isTableSettingsModalOpen, setIsTableSettingsModalOpen] = useState(false); + const [isExpanded, setIsExpanded] = useState(null); + const [rowSelection, setRowSelection] = useState({}); + const router = useRouter(); + // Generate columns + const columns = useMemo(() => generatePersonTableColumns(isExpanded ?? false), [isExpanded]); + + // Load saved settings from localStorage + useEffect(() => { + const savedColumnOrder = localStorage.getItem(`${environmentId}-columnOrder`); + const savedColumnVisibility = localStorage.getItem(`${environmentId}-columnVisibility`); + const savedExpandedSettings = localStorage.getItem(`${environmentId}-rowExpand`); + if (savedColumnOrder && JSON.parse(savedColumnOrder).length > 0) { + setColumnOrder(JSON.parse(savedColumnOrder)); + } else { + setColumnOrder(table.getAllLeafColumns().map((d) => d.id)); + } + + if (savedColumnVisibility) { + setColumnVisibility(JSON.parse(savedColumnVisibility)); + } + if (savedExpandedSettings !== null) { + setIsExpanded(JSON.parse(savedExpandedSettings)); + } + }, [environmentId]); + + // Save settings to localStorage when they change + useEffect(() => { + if (columnOrder.length > 0) { + localStorage.setItem(`${environmentId}-columnOrder`, JSON.stringify(columnOrder)); + } + if (Object.keys(columnVisibility).length > 0) { + localStorage.setItem(`${environmentId}-columnVisibility`, JSON.stringify(columnVisibility)); + } + + if (isExpanded !== null) { + localStorage.setItem(`${environmentId}-rowExpand`, JSON.stringify(isExpanded)); + } + }, [columnOrder, columnVisibility, isExpanded, environmentId]); + + // Initialize DnD sensors + const sensors = useSensors( + useSensor(MouseSensor, {}), + useSensor(TouchSensor, {}), + useSensor(KeyboardSensor, {}) + ); + + // Memoize table data and columns + const tableData: TPersonTableData[] = useMemo( + () => (!isDataLoaded ? Array(10).fill({}) : data), + [data, isDataLoaded] + ); + const tableColumns = useMemo( + () => + !isDataLoaded + ? columns.map((column) => ({ + ...column, + cell: () => ( + +
+
+ ), + })) + : columns, + [columns, data] + ); + + // React Table instance + const table = useReactTable({ + data: tableData, + columns: tableColumns, + getRowId: (originalRow) => originalRow.personId, + getCoreRowModel: getCoreRowModel(), + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + onColumnOrderChange: setColumnOrder, + columnResizeMode: "onChange", + columnResizeDirection: "ltr", + manualPagination: true, + defaultColumn: { size: 300 }, + state: { + columnOrder, + columnVisibility, + rowSelection, + columnPinning: { + left: ["select", "createdAt"], + }, + }, + }); + + // Handle column drag end + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (active && over && active.id !== over.id) { + setColumnOrder((prevOrder) => { + const oldIndex = prevOrder.indexOf(active.id as string); + const newIndex = prevOrder.indexOf(over.id as string); + return arrayMove(prevOrder, oldIndex, newIndex); + }); + } + }; + + return ( +
+ + +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + + {headerGroup.headers.map((header) => ( + + ))} + + + ))} + + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + { + if (cell.column.id === "select") return; + router.push(`/environments/${environmentId}/people/${row.id}`); + }} + className={cn( + "border-slate-300 bg-white shadow-none group-hover:bg-slate-100", + row.getIsSelected() && "bg-slate-100", + { + "border-r": !cell.column.getIsLastColumn(), + "border-l": !cell.column.getIsFirstColumn(), + } + )}> +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ ))} +
+ ))} + {table.getRowModel().rows.length === 0 && ( + + + No results. + + + )} +
+
+
+
+ + {data && hasMore && data.length > 0 && ( +
+ +
+ )} + + +
+
+ ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/(people)/people/components/PersonTableColumn.tsx b/apps/web/app/(app)/environments/[environmentId]/(people)/people/components/PersonTableColumn.tsx new file mode 100644 index 0000000000..51efcd1f22 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/(people)/people/components/PersonTableColumn.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { cn } from "@formbricks/lib/cn"; +import { TPersonTableData } from "@formbricks/types/people"; +import { getSelectionColumn } from "@formbricks/ui/DataTable"; + +export const generatePersonTableColumns = (isExpanded: boolean): ColumnDef[] => { + const dateColumn: ColumnDef = { + accessorKey: "createdAt", + header: () => "Date", + size: 200, + cell: ({ row }) => { + const isoDateString = row.original.createdAt; + const date = new Date(isoDateString); + + const formattedDate = date.toLocaleString(undefined, { + year: "numeric", + month: "long", + day: "numeric", + }); + + const formattedTime = date.toLocaleString(undefined, { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + + return ( +
+

{formattedDate}

+

{formattedTime}

+
+ ); + }, + }; + + const userColumn: ColumnDef = { + accessorKey: "user", + header: "User", + cell: ({ row }) => { + const personId = row.original.personId; + return

{personId}

; + }, + }; + + const userIdColumn: ColumnDef = { + accessorKey: "userId", + header: "User ID", + cell: ({ row }) => { + const userId = row.original.userId; + return

{userId}

; + }, + }; + + const emailColumn: ColumnDef = { + accessorKey: "email", + header: "Email", + }; + + const attributesColumn: ColumnDef = { + accessorKey: "attributes", + header: "Attributes", + cell: ({ row }) => { + const attributes = row.original.attributes; + + // Handle cases where attributes are missing or empty + if (!attributes || Object.keys(attributes).length === 0) return null; + + return ( +
+ {Object.entries(attributes).map(([key, value]) => ( +
+
{key}
:
{value}
+
+ ))} +
+ ); + }, + }; + + return [getSelectionColumn(), dateColumn, userColumn, userIdColumn, emailColumn, attributesColumn]; +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/(people)/people/components/pagination.tsx b/apps/web/app/(app)/environments/[environmentId]/(people)/people/components/pagination.tsx deleted file mode 100644 index e4241f2d8a..0000000000 --- a/apps/web/app/(app)/environments/[environmentId]/(people)/people/components/pagination.tsx +++ /dev/null @@ -1,55 +0,0 @@ -export const Pagination = ({ environmentId, currentPage, totalItems, itemsPerPage }) => { - const totalPages = Math.ceil(totalItems / itemsPerPage); - - const previousPageLink = - currentPage === 1 ? "#" : `/environments/${environmentId}/people?page=${currentPage - 1}`; - const nextPageLink = - currentPage === totalPages ? "#" : `/environments/${environmentId}/people?page=${currentPage + 1}`; - - return ( - - ); -}; diff --git a/apps/web/app/(app)/environments/[environmentId]/(people)/people/loading.tsx b/apps/web/app/(app)/environments/[environmentId]/(people)/people/loading.tsx index 84d10b5a8b..9bd184d536 100644 --- a/apps/web/app/(app)/environments/[environmentId]/(people)/people/loading.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/(people)/people/loading.tsx @@ -1,4 +1,4 @@ -import { PeopleSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PeopleSecondaryNavigation"; +import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation"; import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper"; import { PageHeader } from "@formbricks/ui/PageHeader"; @@ -7,7 +7,7 @@ const Loading = () => { <> - +
diff --git a/apps/web/app/(app)/environments/[environmentId]/(people)/people/page.tsx b/apps/web/app/(app)/environments/[environmentId]/(people)/people/page.tsx index 2a4ff2eadd..f0204b65f6 100644 --- a/apps/web/app/(app)/environments/[environmentId]/(people)/people/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/(people)/people/page.tsx @@ -1,42 +1,20 @@ -import { PeopleSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PeopleSecondaryNavigation"; +import { PersonDataView } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonDataView"; +import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation"; import { CircleHelpIcon } from "lucide-react"; import { ITEMS_PER_PAGE } from "@formbricks/lib/constants"; import { getEnvironment } from "@formbricks/lib/environment/service"; -import { getPeople, getPeopleCount } from "@formbricks/lib/person/service"; -import { TPerson } from "@formbricks/types/people"; +import { getPersonCount } from "@formbricks/lib/person/service"; import { Button } from "@formbricks/ui/Button"; -import { EmptySpaceFiller } from "@formbricks/ui/EmptySpaceFiller"; import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper"; import { PageHeader } from "@formbricks/ui/PageHeader"; -import { Pagination } from "@formbricks/ui/Pagination"; -import { PersonCard } from "./components/PersonCard"; -const Page = async ({ - params, - searchParams, -}: { - params: { environmentId: string }; - searchParams: { [key: string]: string | string[] | undefined }; -}) => { - const pageNumber = searchParams.page ? parseInt(searchParams.page as string) : 1; - const [environment, totalPeople] = await Promise.all([ - getEnvironment(params.environmentId), - getPeopleCount(params.environmentId), - ]); +const Page = async ({ params }: { params: { environmentId: string } }) => { + const environment = await getEnvironment(params.environmentId); + const personCount = await getPersonCount(params.environmentId); + if (!environment) { throw new Error("Environment not found"); } - const maxPageNumber = Math.ceil(totalPeople / ITEMS_PER_PAGE); - let hidePagination = false; - - let people: TPerson[] = []; - - if (pageNumber < 1 || pageNumber > maxPageNumber) { - people = []; - hidePagination = true; - } else { - people = await getPeople(params.environmentId, pageNumber); - } const HowToAddPeopleButton = (