mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-19 11:11:05 -05:00
Compare commits
17 Commits
person-tab
...
version-fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
435dbf2261 | ||
|
|
df74510756 | ||
|
|
57135d167b | ||
|
|
916e47c55d | ||
|
|
5faccf1063 | ||
|
|
ebbc5abd3c | ||
|
|
ad445d1915 | ||
|
|
756d0c84af | ||
|
|
8f6b356870 | ||
|
|
e0ba9cb998 | ||
|
|
c6f1e9e7d5 | ||
|
|
f025a70d20 | ||
|
|
ba1e478d68 | ||
|
|
8e57082376 | ||
|
|
2681d03e15 | ||
|
|
72e023c530 | ||
|
|
7088f9bd26 |
@@ -79,6 +79,27 @@ Promise<{ id: string }, NetworkError | Error>
|
|||||||
</CodeGroup>
|
</CodeGroup>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
|
- Update Display
|
||||||
|
|
||||||
|
<Col>
|
||||||
|
<CodeGroup title="Update Display">
|
||||||
|
|
||||||
|
```javascript {{ title: 'Update Display Method Call'}}
|
||||||
|
await api.client.display.update(
|
||||||
|
displayId: "<your-display-id>",
|
||||||
|
{
|
||||||
|
userId: "<your-user-id>", // optional
|
||||||
|
responseId: "<your-response-id>", // optional
|
||||||
|
},
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
```javascript {{ title: 'Update Display Method Return Type' }}
|
||||||
|
Promise<{ }, NetworkError | Error]>
|
||||||
|
```
|
||||||
|
|
||||||
|
</CodeGroup>
|
||||||
|
</Col>
|
||||||
|
|
||||||
## Responses
|
## Responses
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { getQuestionDefaults, questionTypes, universalQuestionPresets } from "@/app/lib/questions";
|
||||||
import { createId } from "@paralleldrive/cuid2";
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||||
import { PlusIcon } from "lucide-react";
|
import { PlusIcon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { cn } from "@formbricks/lib/cn";
|
import { cn } from "@formbricks/lib/cn";
|
||||||
import {
|
|
||||||
getQuestionDefaults,
|
|
||||||
questionTypes,
|
|
||||||
universalQuestionPresets,
|
|
||||||
} from "@formbricks/lib/utils/questions";
|
|
||||||
import { TProduct } from "@formbricks/types/product";
|
import { TProduct } from "@formbricks/types/product";
|
||||||
|
|
||||||
interface AddQuestionButtonProps {
|
interface AddQuestionButtonProps {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { QUESTIONS_ICON_MAP, QUESTIONS_NAME_MAP, getQuestionDefaults } from "@/app/lib/questions";
|
||||||
import { createId } from "@paralleldrive/cuid2";
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, EllipsisIcon, TrashIcon } from "lucide-react";
|
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, EllipsisIcon, TrashIcon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { cn } from "@formbricks/lib/cn";
|
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 { TProduct } from "@formbricks/types/product";
|
||||||
import {
|
import {
|
||||||
TSurvey,
|
TSurvey,
|
||||||
|
|||||||
@@ -219,7 +219,6 @@ export const MultipleChoiceQuestionForm = ({
|
|||||||
<Label htmlFor="choices">Options*</Label>
|
<Label htmlFor="choices">Options*</Label>
|
||||||
<div className="mt-2" id="choices">
|
<div className="mt-2" id="choices">
|
||||||
<DndContext
|
<DndContext
|
||||||
id="multi-choice-choices"
|
|
||||||
onDragEnd={(event) => {
|
onDragEnd={(event) => {
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
import { RankingQuestionForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RankingQuestionForm";
|
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 { 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 { useSortable } from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||||
import { ChevronDownIcon, ChevronRightIcon, GripIcon } from "lucide-react";
|
import { ChevronDownIcon, ChevronRightIcon, GripIcon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { cn } from "@formbricks/lib/cn";
|
import { cn } from "@formbricks/lib/cn";
|
||||||
import { QUESTIONS_ICON_MAP, getTSurveyQuestionTypeEnumName } from "@formbricks/lib/utils/questions";
|
|
||||||
import { recallToHeadline } from "@formbricks/lib/utils/recall";
|
import { recallToHeadline } from "@formbricks/lib/utils/recall";
|
||||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||||
import { TProduct } from "@formbricks/types/product";
|
import { TProduct } from "@formbricks/types/product";
|
||||||
|
|||||||
@@ -372,11 +372,7 @@ export const QuestionsView = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DndContext
|
<DndContext sensors={sensors} onDragEnd={onQuestionCardDragEnd} collisionDetection={closestCorners}>
|
||||||
id="questions"
|
|
||||||
sensors={sensors}
|
|
||||||
onDragEnd={onQuestionCardDragEnd}
|
|
||||||
collisionDetection={closestCorners}>
|
|
||||||
<QuestionsDroppable
|
<QuestionsDroppable
|
||||||
localSurvey={localSurvey}
|
localSurvey={localSurvey}
|
||||||
product={product}
|
product={product}
|
||||||
@@ -399,11 +395,7 @@ export const QuestionsView = ({
|
|||||||
<AddQuestionButton addQuestion={addQuestion} product={product} />
|
<AddQuestionButton addQuestion={addQuestion} product={product} />
|
||||||
<div className="mt-5 flex flex-col gap-5">
|
<div className="mt-5 flex flex-col gap-5">
|
||||||
<hr className="border-t border-dashed" />
|
<hr className="border-t border-dashed" />
|
||||||
<DndContext
|
<DndContext sensors={sensors} onDragEnd={onEndingCardDragEnd} collisionDetection={closestCorners}>
|
||||||
id="endings"
|
|
||||||
sensors={sensors}
|
|
||||||
onDragEnd={onEndingCardDragEnd}
|
|
||||||
collisionDetection={closestCorners}>
|
|
||||||
<SortableContext items={localSurvey.endings} strategy={verticalListSortingStrategy}>
|
<SortableContext items={localSurvey.endings} strategy={verticalListSortingStrategy}>
|
||||||
{localSurvey.endings.map((ending, index) => {
|
{localSurvey.endings.map((ending, index) => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -167,7 +167,6 @@ export const RankingQuestionForm = ({
|
|||||||
<Label htmlFor="choices">Options*</Label>
|
<Label htmlFor="choices">Options*</Label>
|
||||||
<div className="mt-2" id="choices">
|
<div className="mt-2" id="choices">
|
||||||
<DndContext
|
<DndContext
|
||||||
id="ranking-choices"
|
|
||||||
onDragEnd={(event) => {
|
onDragEnd={(event) => {
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
|
|
||||||
|
|||||||
@@ -86,13 +86,6 @@ export const SurveyMenuBar = ({
|
|||||||
};
|
};
|
||||||
}, [localSurvey, survey]);
|
}, [localSurvey, survey]);
|
||||||
|
|
||||||
const clearSurveyLocalStorage = () => {
|
|
||||||
if (typeof localStorage !== "undefined") {
|
|
||||||
localStorage.removeItem(`${localSurvey.id}-columnOrder`);
|
|
||||||
localStorage.removeItem(`${localSurvey.id}-columnVisibility`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const containsEmptyTriggers = useMemo(() => {
|
const containsEmptyTriggers = useMemo(() => {
|
||||||
if (localSurvey.type === "link") return false;
|
if (localSurvey.type === "link") return false;
|
||||||
|
|
||||||
@@ -240,7 +233,6 @@ export const SurveyMenuBar = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const segment = await handleSegmentUpdate();
|
const segment = await handleSegmentUpdate();
|
||||||
clearSurveyLocalStorage();
|
|
||||||
const updatedSurveyResponse = await updateSurveyAction({ ...localSurvey, segment });
|
const updatedSurveyResponse = await updateSurveyAction({ ...localSurvey, segment });
|
||||||
|
|
||||||
setIsSurveySaving(false);
|
setIsSurveySaving(false);
|
||||||
@@ -286,7 +278,6 @@ export const SurveyMenuBar = ({
|
|||||||
}
|
}
|
||||||
const status = localSurvey.runOnDate ? "scheduled" : "inProgress";
|
const status = localSurvey.runOnDate ? "scheduled" : "inProgress";
|
||||||
const segment = await handleSegmentUpdate();
|
const segment = await handleSegmentUpdate();
|
||||||
clearSurveyLocalStorage();
|
|
||||||
|
|
||||||
await updateSurveyAction({
|
await updateSurveyAction({
|
||||||
...localSurvey,
|
...localSurvey,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation";
|
import { PeopleSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PeopleSecondaryNavigation";
|
||||||
import { TagIcon } from "lucide-react";
|
import { TagIcon } from "lucide-react";
|
||||||
import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
|
import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
|
||||||
import { PageHeader } from "@formbricks/ui/PageHeader";
|
import { PageHeader } from "@formbricks/ui/PageHeader";
|
||||||
@@ -8,7 +8,7 @@ const Loading = () => {
|
|||||||
<>
|
<>
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
<PageHeader pageTitle="People">
|
<PageHeader pageTitle="People">
|
||||||
<PersonSecondaryNavigation activeId="attributes" loading />
|
<PeopleSecondaryNavigation activeId="attributes" loading />
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<div className="grid h-12 grid-cols-5 content-center border-b text-left text-sm font-semibold text-slate-900">
|
<div className="grid h-12 grid-cols-5 content-center border-b text-left text-sm font-semibold text-slate-900">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation";
|
import { PeopleSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PeopleSecondaryNavigation";
|
||||||
import { CircleHelpIcon } from "lucide-react";
|
import { CircleHelpIcon } from "lucide-react";
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
@@ -42,7 +42,7 @@ const Page = async ({ params }) => {
|
|||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
<PageHeader pageTitle="People" cta={HowToAddAttributesButton}>
|
<PageHeader pageTitle="People" cta={HowToAddAttributesButton}>
|
||||||
<PersonSecondaryNavigation activeId="attributes" environmentId={params.environmentId} />
|
<PeopleSecondaryNavigation activeId="attributes" environmentId={params.environmentId} />
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<AttributeClassesTable attributeClasses={attributeClasses} />
|
<AttributeClassesTable attributeClasses={attributeClasses} />
|
||||||
</PageContentWrapper>
|
</PageContentWrapper>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { deletePersonAction } from "@/app/(app)/environments/[environmentId]/(people)/people/[personId]/actions";
|
||||||
import { TrashIcon } from "lucide-react";
|
import { TrashIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
|
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
|
||||||
import { deletePersonAction } from "@formbricks/ui/DataTable/actions";
|
|
||||||
import { DeleteDialog } from "@formbricks/ui/DeleteDialog";
|
import { DeleteDialog } from "@formbricks/ui/DeleteDialog";
|
||||||
|
|
||||||
interface DeletePersonButtonProps {
|
interface DeletePersonButtonProps {
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
"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);
|
|
||||||
});
|
|
||||||
@@ -2,17 +2,17 @@ import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
|||||||
import { TProductConfigChannel } from "@formbricks/types/product";
|
import { TProductConfigChannel } from "@formbricks/types/product";
|
||||||
import { SecondaryNavigation } from "@formbricks/ui/SecondaryNavigation";
|
import { SecondaryNavigation } from "@formbricks/ui/SecondaryNavigation";
|
||||||
|
|
||||||
interface PersonSecondaryNavigationProps {
|
interface PeopleSegmentsTabsProps {
|
||||||
activeId: string;
|
activeId: string;
|
||||||
environmentId?: string;
|
environmentId?: string;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PersonSecondaryNavigation = async ({
|
export const PeopleSecondaryNavigation = async ({
|
||||||
activeId,
|
activeId,
|
||||||
environmentId,
|
environmentId,
|
||||||
loading,
|
loading,
|
||||||
}: PersonSecondaryNavigationProps) => {
|
}: PeopleSegmentsTabsProps) => {
|
||||||
let currentProductChannel: TProductConfigChannel = null;
|
let currentProductChannel: TProductConfigChannel = null;
|
||||||
|
|
||||||
if (!loading && environmentId) {
|
if (!loading && environmentId) {
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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 (
|
||||||
|
<Link
|
||||||
|
href={`/environments/${person.environmentId}/people/${person.id}`}
|
||||||
|
key={person.id}
|
||||||
|
className="w-full">
|
||||||
|
<div className="m-2 grid h-16 grid-cols-7 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100">
|
||||||
|
<div className="col-span-3 flex items-center pl-6 text-sm">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="ph-no-capture h-10 w-10 flex-shrink-0">
|
||||||
|
<PersonAvatar personId={person.id} />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4">
|
||||||
|
<div className="ph-no-capture font-medium text-slate-900">
|
||||||
|
<span>{getPersonIdentifier({ id: person.id, userId: person.userId }, attributes)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
|
||||||
|
<div className="ph-no-capture text-slate-900">{person.userId}</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
|
||||||
|
<div className="ph-no-capture text-slate-900">{attributes.email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { 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 { TPersonWithAttributes } from "@formbricks/types/people";
|
|
||||||
|
|
||||||
interface PersonDataViewProps {
|
|
||||||
environment: TEnvironment;
|
|
||||||
personCount: number;
|
|
||||||
itemsPerPage: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PersonDataView = ({ environment, personCount, itemsPerPage }: PersonDataViewProps) => {
|
|
||||||
const [persons, setPersons] = useState<TPersonWithAttributes[]>([]);
|
|
||||||
const [pageNumber, setPageNumber] = useState<number>(1);
|
|
||||||
const [totalPersons, setTotalPersons] = useState<number>(0);
|
|
||||||
const [isDataLoaded, setIsDataLoaded] = useState<boolean>(false);
|
|
||||||
const [hasMore, setHasMore] = useState<boolean>(false);
|
|
||||||
const [loadingNextPage, setLoadingNextPage] = useState<boolean>(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);
|
|
||||||
} finally {
|
|
||||||
setIsDataLoaded(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchData();
|
|
||||||
}, [pageNumber, personCount, itemsPerPage, environment.id]);
|
|
||||||
|
|
||||||
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((prevPersons) => prevPersons.filter((p) => !personIds.includes(p.id)));
|
|
||||||
};
|
|
||||||
|
|
||||||
const personTableData = persons.map((person) => ({
|
|
||||||
createdAt: person.createdAt,
|
|
||||||
personId: person.id,
|
|
||||||
userId: person.userId,
|
|
||||||
email: person.attributes.email,
|
|
||||||
attributes: person.attributes,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PersonTable
|
|
||||||
data={personTableData}
|
|
||||||
fetchNextPage={fetchNextPage}
|
|
||||||
hasMore={hasMore}
|
|
||||||
isDataLoaded={isDataLoaded}
|
|
||||||
deletePersons={deletePersons}
|
|
||||||
environmentId={environment.id}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
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 { getCommonPinningStyles } from "@formbricks/ui/DataTable/lib/utils";
|
|
||||||
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<VisibilityState>({});
|
|
||||||
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
|
||||||
const [isTableSettingsModalOpen, setIsTableSettingsModalOpen] = useState(false);
|
|
||||||
const [isExpanded, setIsExpanded] = useState<boolean | null>(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: () => (
|
|
||||||
<Skeleton className="w-full">
|
|
||||||
<div className="h-6"></div>
|
|
||||||
</Skeleton>
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
: 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 (
|
|
||||||
<div>
|
|
||||||
<DndContext
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
modifiers={[restrictToHorizontalAxis]}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
sensors={sensors}>
|
|
||||||
<DataTableToolbar
|
|
||||||
setIsExpanded={setIsExpanded}
|
|
||||||
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
|
|
||||||
isExpanded={isExpanded ?? false}
|
|
||||||
table={table}
|
|
||||||
deleteRows={deletePersons}
|
|
||||||
type="person"
|
|
||||||
/>
|
|
||||||
<div className="w-fit max-w-full overflow-hidden overflow-x-auto rounded-xl border border-slate-300">
|
|
||||||
<div className="w-full overflow-x-auto">
|
|
||||||
<Table style={{ width: table.getCenterTotalSize(), tableLayout: "fixed" }}>
|
|
||||||
<TableHeader>
|
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
|
||||||
<tr key={headerGroup.id}>
|
|
||||||
<SortableContext items={columnOrder} strategy={horizontalListSortingStrategy}>
|
|
||||||
{headerGroup.headers.map((header) => (
|
|
||||||
<DataTableHeader
|
|
||||||
key={header.id}
|
|
||||||
header={header}
|
|
||||||
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</SortableContext>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</TableHeader>
|
|
||||||
|
|
||||||
<TableBody>
|
|
||||||
{table.getRowModel().rows.map((row) => (
|
|
||||||
<TableRow
|
|
||||||
key={row.id}
|
|
||||||
data-state={row.getIsSelected() && "selected"}
|
|
||||||
className={"group cursor-pointer"}>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<TableCell
|
|
||||||
key={cell.id}
|
|
||||||
onClick={() => {
|
|
||||||
if (cell.column.id === "select") return;
|
|
||||||
router.push(`/environments/${environmentId}/people/${row.id}`);
|
|
||||||
}}
|
|
||||||
style={cell.column.id === "select" ? getCommonPinningStyles(cell.column) : {}}
|
|
||||||
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(),
|
|
||||||
}
|
|
||||||
)}>
|
|
||||||
<div
|
|
||||||
className={cn("flex flex-1 items-center truncate", isExpanded ? "h-full" : "h-10")}>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
{table.getRowModel().rows.length === 0 && (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
|
||||||
No results.
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{data && hasMore && data.length > 0 && (
|
|
||||||
<div className="mt-4 flex justify-center">
|
|
||||||
<Button onClick={fetchNextPage} className="bg-blue-500 text-white">
|
|
||||||
Load More
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DataTableSettingsModal
|
|
||||||
open={isTableSettingsModalOpen}
|
|
||||||
setOpen={setIsTableSettingsModalOpen}
|
|
||||||
table={table}
|
|
||||||
columnOrder={columnOrder}
|
|
||||||
handleDragEnd={handleDragEnd}
|
|
||||||
/>
|
|
||||||
</DndContext>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
"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<TPersonTableData>[] => {
|
|
||||||
const dateColumn: ColumnDef<TPersonTableData> = {
|
|
||||||
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 (
|
|
||||||
<div>
|
|
||||||
<p className="truncate text-slate-900">{formattedDate}</p>
|
|
||||||
<p className="truncate text-slate-900">{formattedTime}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const userColumn: ColumnDef<TPersonTableData> = {
|
|
||||||
accessorKey: "user",
|
|
||||||
header: "User",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const personId = row.original.personId;
|
|
||||||
return <p className="truncate text-slate-900">{personId}</p>;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const userIdColumn: ColumnDef<TPersonTableData> = {
|
|
||||||
accessorKey: "userId",
|
|
||||||
header: "User ID",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const userId = row.original.userId;
|
|
||||||
return <p className="truncate text-slate-900">{userId}</p>;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const emailColumn: ColumnDef<TPersonTableData> = {
|
|
||||||
accessorKey: "email",
|
|
||||||
header: "Email",
|
|
||||||
};
|
|
||||||
|
|
||||||
const attributesColumn: ColumnDef<TPersonTableData> = {
|
|
||||||
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 (
|
|
||||||
<div className={cn(!isExpanded && "flex space-x-2")}>
|
|
||||||
{Object.entries(attributes).map(([key, value]) => (
|
|
||||||
<div key={key} className="flex space-x-2">
|
|
||||||
<div className="font-semibold">{key}</div> : <div>{value}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return [getSelectionColumn(), dateColumn, userColumn, userIdColumn, emailColumn, attributesColumn];
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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 (
|
||||||
|
<nav aria-label="Page navigation" className="flex justify-center">
|
||||||
|
<ul className="mt-4 inline-flex -space-x-px text-sm">
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={previousPageLink}
|
||||||
|
className={`ml-0 flex h-8 items-center justify-center rounded-l-lg border border-slate-300 bg-white px-3 text-slate-500 ${
|
||||||
|
currentPage === 1
|
||||||
|
? "cursor-not-allowed opacity-50"
|
||||||
|
: "hover:bg-slate-100 hover:text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-white"
|
||||||
|
}`}>
|
||||||
|
Previous
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{Array.from({ length: totalPages }).map((_, idx) => {
|
||||||
|
const pageNum = idx + 1;
|
||||||
|
const pageLink = `/environments/${environmentId}/people?page=${pageNum}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={pageNum} className="hidden sm:block">
|
||||||
|
<a
|
||||||
|
href={pageNum === currentPage ? "#" : pageLink}
|
||||||
|
className={`flex h-8 items-center justify-center px-3 ${
|
||||||
|
pageNum === currentPage ? "bg-blue-50 text-green-500" : "bg-white text-slate-500"
|
||||||
|
} border border-slate-300 hover:bg-slate-100 hover:text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-white`}>
|
||||||
|
{pageNum}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={nextPageLink}
|
||||||
|
className={`ml-0 flex h-8 items-center justify-center rounded-r-lg border border-slate-300 bg-white px-3 text-slate-500 ${
|
||||||
|
currentPage === totalPages
|
||||||
|
? "cursor-not-allowed opacity-50"
|
||||||
|
: "hover:bg-slate-100 hover:text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-white"
|
||||||
|
}`}>
|
||||||
|
Next
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation";
|
import { PeopleSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PeopleSecondaryNavigation";
|
||||||
import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
|
import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
|
||||||
import { PageHeader } from "@formbricks/ui/PageHeader";
|
import { PageHeader } from "@formbricks/ui/PageHeader";
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ const Loading = () => {
|
|||||||
<>
|
<>
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
<PageHeader pageTitle="People">
|
<PageHeader pageTitle="People">
|
||||||
<PersonSecondaryNavigation activeId="people" loading />
|
<PeopleSecondaryNavigation activeId="people" loading />
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<div className="grid h-12 grid-cols-7 content-center border-b text-left text-sm font-semibold text-slate-900">
|
<div className="grid h-12 grid-cols-7 content-center border-b text-left text-sm font-semibold text-slate-900">
|
||||||
|
|||||||
@@ -1,20 +1,42 @@
|
|||||||
import { PersonDataView } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonDataView";
|
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 { CircleHelpIcon } from "lucide-react";
|
||||||
import { ITEMS_PER_PAGE } from "@formbricks/lib/constants";
|
import { ITEMS_PER_PAGE } from "@formbricks/lib/constants";
|
||||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||||
import { getPersonCount } from "@formbricks/lib/person/service";
|
import { getPeople, getPeopleCount } from "@formbricks/lib/person/service";
|
||||||
|
import { TPerson } from "@formbricks/types/people";
|
||||||
import { Button } from "@formbricks/ui/Button";
|
import { Button } from "@formbricks/ui/Button";
|
||||||
|
import { EmptySpaceFiller } from "@formbricks/ui/EmptySpaceFiller";
|
||||||
import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
|
import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
|
||||||
import { PageHeader } from "@formbricks/ui/PageHeader";
|
import { PageHeader } from "@formbricks/ui/PageHeader";
|
||||||
|
import { Pagination } from "@formbricks/ui/Pagination";
|
||||||
|
import { PersonCard } from "./components/PersonCard";
|
||||||
|
|
||||||
const Page = async ({ params }: { params: { environmentId: string } }) => {
|
const Page = async ({
|
||||||
const environment = await getEnvironment(params.environmentId);
|
params,
|
||||||
const personCount = await getPersonCount(params.environmentId);
|
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),
|
||||||
|
]);
|
||||||
if (!environment) {
|
if (!environment) {
|
||||||
throw new Error("Environment not found");
|
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 = (
|
const HowToAddPeopleButton = (
|
||||||
<Button
|
<Button
|
||||||
@@ -30,9 +52,35 @@ const Page = async ({ params }: { params: { environmentId: string } }) => {
|
|||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
<PageHeader pageTitle="People" cta={HowToAddPeopleButton}>
|
<PageHeader pageTitle="People" cta={HowToAddPeopleButton}>
|
||||||
<PersonSecondaryNavigation activeId="people" environmentId={params.environmentId} />
|
<PeopleSecondaryNavigation activeId="people" environmentId={params.environmentId} />
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<PersonDataView environment={environment} personCount={personCount} itemsPerPage={ITEMS_PER_PAGE} />
|
{people.length === 0 ? (
|
||||||
|
<EmptySpaceFiller
|
||||||
|
type="table"
|
||||||
|
environment={environment}
|
||||||
|
emptyMessage="Your users will appear here as soon as they use your app ⏲️"
|
||||||
|
noWidgetRequired={true}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
<div className="grid h-12 grid-cols-7 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
|
||||||
|
<div className="col-span-3 pl-6">User</div>
|
||||||
|
<div className="col-span-2 hidden text-center sm:block">User ID</div>
|
||||||
|
<div className="col-span-2 hidden text-center sm:block">Email</div>
|
||||||
|
</div>
|
||||||
|
{people.map((person) => (
|
||||||
|
<PersonCard person={person} key={person.id} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hidePagination ? null : (
|
||||||
|
<Pagination
|
||||||
|
baseUrl={`/environments/${params.environmentId}/people`}
|
||||||
|
currentPage={pageNumber}
|
||||||
|
totalItems={totalPeople}
|
||||||
|
itemsPerPage={ITEMS_PER_PAGE}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</PageContentWrapper>
|
</PageContentWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation";
|
import { PeopleSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PeopleSecondaryNavigation";
|
||||||
import { UsersIcon } from "lucide-react";
|
import { UsersIcon } from "lucide-react";
|
||||||
import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
|
import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
|
||||||
import { PageHeader } from "@formbricks/ui/PageHeader";
|
import { PageHeader } from "@formbricks/ui/PageHeader";
|
||||||
@@ -8,7 +8,7 @@ const Loading = () => {
|
|||||||
<>
|
<>
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
<PageHeader pageTitle="People">
|
<PageHeader pageTitle="People">
|
||||||
<PersonSecondaryNavigation activeId="segments" loading />
|
<PeopleSecondaryNavigation activeId="segments" loading />
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<div className="grid h-12 grid-cols-7 content-center border-b text-left text-sm font-semibold text-slate-900">
|
<div className="grid h-12 grid-cols-7 content-center border-b text-left text-sm font-semibold text-slate-900">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PersonSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PersonSecondaryNavigation";
|
import { PeopleSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PeopleSecondaryNavigation";
|
||||||
import { BasicCreateSegmentModal } from "@/app/(app)/environments/[environmentId]/(people)/segments/components/BasicCreateSegmentModal";
|
import { BasicCreateSegmentModal } from "@/app/(app)/environments/[environmentId]/(people)/segments/components/BasicCreateSegmentModal";
|
||||||
import { SegmentTable } from "@/app/(app)/environments/[environmentId]/(people)/segments/components/SegmentTable";
|
import { SegmentTable } from "@/app/(app)/environments/[environmentId]/(people)/segments/components/SegmentTable";
|
||||||
import { CreateSegmentModal } from "@formbricks/ee/advanced-targeting/components/create-segment-modal";
|
import { CreateSegmentModal } from "@formbricks/ee/advanced-targeting/components/create-segment-modal";
|
||||||
@@ -56,7 +56,7 @@ const Page = async ({ params }) => {
|
|||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
<PageHeader pageTitle="People" cta={renderCreateSegmentButton()}>
|
<PageHeader pageTitle="People" cta={renderCreateSegmentButton()}>
|
||||||
<PersonSecondaryNavigation activeId="segments" environmentId={params.environmentId} />
|
<PeopleSecondaryNavigation activeId="segments" environmentId={params.environmentId} />
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<SegmentTable
|
<SegmentTable
|
||||||
segments={filteredSegments}
|
segments={filteredSegments}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
TYPE_MAPPING,
|
TYPE_MAPPING,
|
||||||
UNSUPPORTED_TYPES_BY_NOTION,
|
UNSUPPORTED_TYPES_BY_NOTION,
|
||||||
} from "@/app/(app)/environments/[environmentId]/integrations/notion/constants";
|
} from "@/app/(app)/environments/[environmentId]/integrations/notion/constants";
|
||||||
|
import { questionTypes } from "@/app/lib/questions";
|
||||||
import NotionLogo from "@/images/notion.png";
|
import NotionLogo from "@/images/notion.png";
|
||||||
import { PlusIcon, XIcon } from "lucide-react";
|
import { PlusIcon, XIcon } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
@@ -12,7 +13,6 @@ import { useForm } from "react-hook-form";
|
|||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||||
import { questionTypes } from "@formbricks/lib/utils/questions";
|
|
||||||
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
|
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
|
||||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||||
import { TIntegrationInput } from "@formbricks/types/integration";
|
import { TIntegrationInput } from "@formbricks/types/integration";
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
import { Column } from "@tanstack/react-table";
|
import { Column } from "@tanstack/react-table";
|
||||||
import { EllipsisVerticalIcon, EyeOffIcon, SettingsIcon } from "lucide-react";
|
import { EllipsisVerticalIcon, EyeOffIcon, SettingsIcon } from "lucide-react";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../DropdownMenu";
|
import React from "react";
|
||||||
|
import { TResponseTableData } from "@formbricks/types/responses";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@formbricks/ui/DropdownMenu";
|
||||||
|
|
||||||
interface ColumnSettingsDropdownProps<T> {
|
interface ColumnSettingsDropdownProps {
|
||||||
column: Column<T>;
|
column: Column<TResponseTableData>;
|
||||||
setIsTableSettingsModalOpen: (isTableSettingsModalOpen: boolean) => void;
|
setIsTableSettingsModalOpen: (isTableSettingsModalOpen: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ColumnSettingsDropdown = <T,>({
|
export const ColumnSettingsDropdown = ({
|
||||||
column,
|
column,
|
||||||
setIsTableSettingsModalOpen,
|
setIsTableSettingsModalOpen,
|
||||||
}: ColumnSettingsDropdownProps<T>) => {
|
}: ColumnSettingsDropdownProps) => {
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -11,8 +11,8 @@ import { SingleResponseCard } from "@formbricks/ui/SingleResponseCard";
|
|||||||
|
|
||||||
interface ResponseCardModalProps {
|
interface ResponseCardModalProps {
|
||||||
responses: TResponse[];
|
responses: TResponse[];
|
||||||
selectedResponseId: string | null;
|
selectedResponse: TResponse | null;
|
||||||
setSelectedResponseId: (id: string | null) => void;
|
setSelectedResponse: (response: TResponse | null) => void;
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
environment: TEnvironment;
|
environment: TEnvironment;
|
||||||
user?: TUser;
|
user?: TUser;
|
||||||
@@ -26,8 +26,8 @@ interface ResponseCardModalProps {
|
|||||||
|
|
||||||
export const ResponseCardModal = ({
|
export const ResponseCardModal = ({
|
||||||
responses,
|
responses,
|
||||||
selectedResponseId,
|
selectedResponse,
|
||||||
setSelectedResponseId,
|
setSelectedResponse,
|
||||||
survey,
|
survey,
|
||||||
environment,
|
environment,
|
||||||
user,
|
user,
|
||||||
@@ -41,33 +41,33 @@ export const ResponseCardModal = ({
|
|||||||
const [currentIndex, setCurrentIndex] = useState<number | null>(null);
|
const [currentIndex, setCurrentIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedResponseId) {
|
if (selectedResponse) {
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
const index = responses.findIndex((response) => response.id === selectedResponseId);
|
const index = responses.findIndex((response) => response.id === selectedResponse.id);
|
||||||
setCurrentIndex(index);
|
setCurrentIndex(index);
|
||||||
} else {
|
} else {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}
|
}
|
||||||
}, [selectedResponseId, responses, setOpen]);
|
}, [selectedResponse, responses, setOpen]);
|
||||||
|
|
||||||
const handleNext = () => {
|
const handleNext = () => {
|
||||||
if (currentIndex !== null && currentIndex < responses.length - 1) {
|
if (currentIndex !== null && currentIndex < responses.length - 1) {
|
||||||
setSelectedResponseId(responses[currentIndex + 1].id);
|
setSelectedResponse(responses[currentIndex + 1]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
if (currentIndex !== null && currentIndex > 0) {
|
if (currentIndex !== null && currentIndex > 0) {
|
||||||
setSelectedResponseId(responses[currentIndex - 1].id);
|
setSelectedResponse(responses[currentIndex - 1]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setSelectedResponseId(null);
|
setSelectedResponse(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// If no response is selected or currentIndex is null, do not render the modal
|
// If no response is selected or currentIndex is null, do not render the modal
|
||||||
if (selectedResponseId === null || currentIndex === null) return null;
|
if (selectedResponse === null || currentIndex === null) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -100,7 +100,7 @@ export const ResponseCardModal = ({
|
|||||||
</div>
|
</div>
|
||||||
<SingleResponseCard
|
<SingleResponseCard
|
||||||
survey={survey}
|
survey={survey}
|
||||||
response={responses[currentIndex]}
|
response={selectedResponse}
|
||||||
user={user}
|
user={user}
|
||||||
pageType="response"
|
pageType="response"
|
||||||
environment={environment}
|
environment={environment}
|
||||||
@@ -108,7 +108,7 @@ export const ResponseCardModal = ({
|
|||||||
isViewer={isViewer}
|
isViewer={isViewer}
|
||||||
updateResponse={updateResponse}
|
updateResponse={updateResponse}
|
||||||
deleteResponses={deleteResponses}
|
deleteResponses={deleteResponses}
|
||||||
setSelectedResponseId={setSelectedResponseId}
|
setSelectedResponse={setSelectedResponse}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { ResponseCardModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal";
|
import { ResponseCardModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal";
|
||||||
import { ResponseTableCell } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell";
|
import { ResponseTableCell } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell";
|
||||||
import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
|
import { generateColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
|
||||||
|
import { ResponseTableHeader } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableHeader";
|
||||||
|
import { ResponseTableToolbar } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableToolbar";
|
||||||
|
import { TableSettingsModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/TableSettingsModal";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
type DragEndEvent,
|
type DragEndEvent,
|
||||||
@@ -21,7 +24,6 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
|||||||
import { TTag } from "@formbricks/types/tags";
|
import { TTag } from "@formbricks/types/tags";
|
||||||
import { TUser } from "@formbricks/types/user";
|
import { TUser } from "@formbricks/types/user";
|
||||||
import { Button } from "@formbricks/ui/Button";
|
import { Button } from "@formbricks/ui/Button";
|
||||||
import { DataTableHeader, DataTableSettingsModal, DataTableToolbar } from "@formbricks/ui/DataTable";
|
|
||||||
import { Skeleton } from "@formbricks/ui/Skeleton";
|
import { Skeleton } from "@formbricks/ui/Skeleton";
|
||||||
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@formbricks/ui/Table";
|
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@formbricks/ui/Table";
|
||||||
|
|
||||||
@@ -57,46 +59,12 @@ export const ResponseTable = ({
|
|||||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||||
const [rowSelection, setRowSelection] = useState({});
|
const [rowSelection, setRowSelection] = useState({});
|
||||||
const [isTableSettingsModalOpen, setIsTableSettingsModalOpen] = useState(false);
|
const [isTableSettingsModalOpen, setIsTableSettingsModalOpen] = useState(false);
|
||||||
const [selectedResponseId, setSelectedResponseId] = useState<string | null>(null);
|
const [selectedResponse, setSelectedResponse] = useState<TResponse | null>(null);
|
||||||
const selectedResponse = responses?.find((response) => response.id === selectedResponseId) ?? null;
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const [isExpanded, setIsExpanded] = useState<boolean | null>(null);
|
|
||||||
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
||||||
|
|
||||||
// Generate columns
|
// Generate columns
|
||||||
const columns = generateResponseTableColumns(survey, isExpanded ?? false, isViewer);
|
const columns = generateColumns(survey, isExpanded, isViewer);
|
||||||
|
|
||||||
// Load saved settings from localStorage
|
|
||||||
useEffect(() => {
|
|
||||||
const savedColumnOrder = localStorage.getItem(`${survey.id}-columnOrder`);
|
|
||||||
const savedColumnVisibility = localStorage.getItem(`${survey.id}-columnVisibility`);
|
|
||||||
const savedExpandedSettings = localStorage.getItem(`${survey.id}-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));
|
|
||||||
}
|
|
||||||
}, [survey.id]);
|
|
||||||
|
|
||||||
// Save settings to localStorage when they change
|
|
||||||
useEffect(() => {
|
|
||||||
if (columnOrder.length > 0) {
|
|
||||||
localStorage.setItem(`${survey.id}-columnOrder`, JSON.stringify(columnOrder));
|
|
||||||
}
|
|
||||||
if (Object.keys(columnVisibility).length > 0) {
|
|
||||||
localStorage.setItem(`${survey.id}-columnVisibility`, JSON.stringify(columnVisibility));
|
|
||||||
}
|
|
||||||
if (isExpanded !== null) {
|
|
||||||
localStorage.setItem(`${survey.id}-rowExpand`, JSON.stringify(isExpanded));
|
|
||||||
}
|
|
||||||
}, [columnOrder, columnVisibility, isExpanded, survey.id]);
|
|
||||||
|
|
||||||
// Initialize DnD sensors
|
// Initialize DnD sensors
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
@@ -148,6 +116,14 @@ export const ResponseTable = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Set initial column order
|
||||||
|
const setInitialColumnOrder = () => {
|
||||||
|
table.setColumnOrder(table.getAllLeafColumns().map((d) => d.id));
|
||||||
|
};
|
||||||
|
setInitialColumnOrder();
|
||||||
|
}, [table]);
|
||||||
|
|
||||||
// Handle column drag end
|
// Handle column drag end
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
@@ -163,70 +139,62 @@ export const ResponseTable = ({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<DndContext
|
<DndContext
|
||||||
id="response-table"
|
|
||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
modifiers={[restrictToHorizontalAxis]}
|
modifiers={[restrictToHorizontalAxis]}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
sensors={sensors}>
|
sensors={sensors}>
|
||||||
<DataTableToolbar
|
<ResponseTableToolbar
|
||||||
setIsExpanded={setIsExpanded}
|
setIsExpanded={setIsExpanded}
|
||||||
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
|
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
|
||||||
isExpanded={isExpanded ?? false}
|
isExpanded={isExpanded}
|
||||||
table={table}
|
table={table}
|
||||||
deleteRows={deleteResponses}
|
deleteResponses={deleteResponses}
|
||||||
type="response"
|
|
||||||
/>
|
/>
|
||||||
<div className="w-fit max-w-full overflow-hidden overflow-x-auto rounded-xl border border-slate-300">
|
<div>
|
||||||
<div className="w-full overflow-x-auto">
|
<Table style={{ width: table.getCenterTotalSize(), tableLayout: "fixed" }}>
|
||||||
<Table
|
<TableHeader>
|
||||||
style={{
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
width: table.getCenterTotalSize(),
|
<tr key={headerGroup.id}>
|
||||||
tableLayout: "fixed",
|
<SortableContext items={columnOrder} strategy={horizontalListSortingStrategy}>
|
||||||
}}>
|
{headerGroup.headers.map((header) => (
|
||||||
<TableHeader>
|
<ResponseTableHeader
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
key={header.id}
|
||||||
<tr key={headerGroup.id}>
|
header={header}
|
||||||
<SortableContext items={columnOrder} strategy={horizontalListSortingStrategy}>
|
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
|
||||||
{headerGroup.headers.map((header) => (
|
|
||||||
<DataTableHeader
|
|
||||||
key={header.id}
|
|
||||||
header={header}
|
|
||||||
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</SortableContext>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</TableHeader>
|
|
||||||
|
|
||||||
<TableBody>
|
|
||||||
{table.getRowModel().rows.map((row) => (
|
|
||||||
<TableRow
|
|
||||||
key={row.id}
|
|
||||||
data-state={row.getIsSelected() && "selected"}
|
|
||||||
className={"group cursor-pointer"}>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<ResponseTableCell
|
|
||||||
key={cell.id}
|
|
||||||
cell={cell}
|
|
||||||
row={row}
|
|
||||||
isExpanded={isExpanded ?? false}
|
|
||||||
setSelectedResponseId={setSelectedResponseId}
|
|
||||||
responses={responses}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</SortableContext>
|
||||||
))}
|
</tr>
|
||||||
{table.getRowModel().rows.length === 0 && (
|
))}
|
||||||
<TableRow>
|
</TableHeader>
|
||||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
|
||||||
No results.
|
<TableBody>
|
||||||
</TableCell>
|
{table.getRowModel().rows.map((row) => (
|
||||||
</TableRow>
|
<TableRow
|
||||||
)}
|
key={row.id}
|
||||||
</TableBody>
|
data-state={row.getIsSelected() && "selected"}
|
||||||
</Table>
|
className={"group cursor-pointer"}>
|
||||||
</div>
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<ResponseTableCell
|
||||||
|
key={cell.id}
|
||||||
|
cell={cell}
|
||||||
|
row={row}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
setSelectedResponseCard={setSelectedResponse}
|
||||||
|
responses={responses}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{table.getRowModel().rows.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||||
|
No results.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data && hasMore && data.length > 0 && (
|
{data && hasMore && data.length > 0 && (
|
||||||
@@ -237,7 +205,7 @@ export const ResponseTable = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DataTableSettingsModal
|
<TableSettingsModal
|
||||||
open={isTableSettingsModalOpen}
|
open={isTableSettingsModalOpen}
|
||||||
setOpen={setIsTableSettingsModalOpen}
|
setOpen={setIsTableSettingsModalOpen}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
@@ -256,12 +224,12 @@ export const ResponseTable = ({
|
|||||||
isViewer={isViewer}
|
isViewer={isViewer}
|
||||||
updateResponse={updateResponse}
|
updateResponse={updateResponse}
|
||||||
deleteResponses={deleteResponses}
|
deleteResponses={deleteResponses}
|
||||||
setSelectedResponseId={setSelectedResponseId}
|
setSelectedResponse={setSelectedResponse}
|
||||||
selectedResponseId={selectedResponseId}
|
selectedResponse={selectedResponse}
|
||||||
open={selectedResponse !== null}
|
open={selectedResponse !== null}
|
||||||
setOpen={(open) => {
|
setOpen={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setSelectedResponseId(null);
|
setSelectedResponse(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
|
import { getCommonPinningStyles } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableHeader";
|
||||||
import { Cell, Row, flexRender } from "@tanstack/react-table";
|
import { Cell, Row, flexRender } from "@tanstack/react-table";
|
||||||
import { Maximize2Icon } from "lucide-react";
|
import { Maximize2Icon } from "lucide-react";
|
||||||
import { cn } from "@formbricks/lib/cn";
|
import { cn } from "@formbricks/lib/cn";
|
||||||
import { TResponse, TResponseTableData } from "@formbricks/types/responses";
|
import { TResponse, TResponseTableData } from "@formbricks/types/responses";
|
||||||
import { getCommonPinningStyles } from "@formbricks/ui/DataTable/lib/utils";
|
|
||||||
import { TableCell } from "@formbricks/ui/Table";
|
import { TableCell } from "@formbricks/ui/Table";
|
||||||
|
|
||||||
interface ResponseTableCellProps {
|
interface ResponseTableCellProps {
|
||||||
cell: Cell<TResponseTableData, unknown>;
|
cell: Cell<TResponseTableData, unknown>;
|
||||||
row: Row<TResponseTableData>;
|
row: Row<TResponseTableData>;
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
setSelectedResponseId: (responseId: string | null) => void;
|
setSelectedResponseCard: (responseCard: TResponse) => void;
|
||||||
responses: TResponse[] | null;
|
responses: TResponse[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,14 +17,14 @@ export const ResponseTableCell = ({
|
|||||||
cell,
|
cell,
|
||||||
row,
|
row,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
setSelectedResponseId,
|
setSelectedResponseCard,
|
||||||
responses,
|
responses,
|
||||||
}: ResponseTableCellProps) => {
|
}: ResponseTableCellProps) => {
|
||||||
// Function to handle cell click
|
// Function to handle cell click
|
||||||
const handleCellClick = () => {
|
const handleCellClick = () => {
|
||||||
if (cell.column.id !== "select") {
|
if (cell.column.id !== "select") {
|
||||||
const response = responses?.find((response) => response.id === row.id);
|
const response = responses?.find((response) => response.id === row.id);
|
||||||
if (response) setSelectedResponseId(response.id);
|
if (response) setSelectedResponseCard(response);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -46,12 +46,8 @@ export const ResponseTableCell = ({
|
|||||||
<TableCell
|
<TableCell
|
||||||
key={cell.id}
|
key={cell.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-slate-300 bg-white shadow-none group-hover:bg-slate-100",
|
"border border-slate-300 bg-white shadow-none group-hover:bg-slate-100",
|
||||||
row.getIsSelected() && "bg-slate-100",
|
row.getIsSelected() && "bg-slate-100"
|
||||||
{
|
|
||||||
"border-r": !cell.column.getIsLastColumn(),
|
|
||||||
"border-l": !cell.column.getIsFirstColumn(),
|
|
||||||
}
|
|
||||||
)}
|
)}
|
||||||
style={cellStyles}
|
style={cellStyles}
|
||||||
onClick={handleCellClick}>
|
onClick={handleCellClick}>
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { QUESTIONS_ICON_MAP } from "@/app/lib/questions";
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
|
import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||||
import { getPersonIdentifier } from "@formbricks/lib/person/utils";
|
import { getPersonIdentifier } from "@formbricks/lib/person/utils";
|
||||||
import { processResponseData } from "@formbricks/lib/responses";
|
import { processResponseData } from "@formbricks/lib/responses";
|
||||||
import { QUESTIONS_ICON_MAP } from "@formbricks/lib/utils/questions";
|
|
||||||
import { recallToHeadline } from "@formbricks/lib/utils/recall";
|
import { recallToHeadline } from "@formbricks/lib/utils/recall";
|
||||||
import { TResponseTableData } from "@formbricks/types/responses";
|
import { TResponseTableData } from "@formbricks/types/responses";
|
||||||
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||||
import { getSelectionColumn } from "@formbricks/ui/DataTable";
|
import { Checkbox } from "@formbricks/ui/Checkbox";
|
||||||
import { ResponseBadges } from "@formbricks/ui/ResponseBadges";
|
import { ResponseBadges } from "@formbricks/ui/ResponseBadges";
|
||||||
import { RenderResponse } from "@formbricks/ui/SingleResponseCard/components/RenderResponse";
|
import { RenderResponse } from "@formbricks/ui/SingleResponseCard/components/RenderResponse";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip";
|
||||||
@@ -123,7 +123,7 @@ const getQuestionColumnsData = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const generateResponseTableColumns = (
|
export const generateColumns = (
|
||||||
survey: TSurvey,
|
survey: TSurvey,
|
||||||
isExpanded: boolean,
|
isExpanded: boolean,
|
||||||
isViewer: boolean
|
isViewer: boolean
|
||||||
@@ -131,6 +131,30 @@ export const generateResponseTableColumns = (
|
|||||||
const questionColumns = survey.questions.flatMap((question) =>
|
const questionColumns = survey.questions.flatMap((question) =>
|
||||||
getQuestionColumnsData(question, survey, isExpanded)
|
getQuestionColumnsData(question, survey, isExpanded)
|
||||||
);
|
);
|
||||||
|
const selectionColumn: ColumnDef<TResponseTableData> = {
|
||||||
|
accessorKey: "select",
|
||||||
|
size: 75,
|
||||||
|
enableResizing: false,
|
||||||
|
header: ({ table }) => (
|
||||||
|
<div className="flex w-full items-center justify-center pr-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={table.getIsAllPageRowsSelected()}
|
||||||
|
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||||
|
aria-label="Select all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex w-full items-center justify-center pr-4">
|
||||||
|
<Checkbox
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||||
|
aria-label="Select row"
|
||||||
|
className="mx-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
const dateColumn: ColumnDef<TResponseTableData> = {
|
const dateColumn: ColumnDef<TResponseTableData> = {
|
||||||
accessorKey: "createdAt",
|
accessorKey: "createdAt",
|
||||||
@@ -275,8 +299,8 @@ export const generateResponseTableColumns = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Combine the selection column with the dynamic question columns
|
// Combine the selection column with the dynamic question columns
|
||||||
|
return [
|
||||||
const baseColumns = [
|
...(isViewer ? [] : [selectionColumn]),
|
||||||
personColumn,
|
personColumn,
|
||||||
dateColumn,
|
dateColumn,
|
||||||
statusColumn,
|
statusColumn,
|
||||||
@@ -286,6 +310,4 @@ export const generateResponseTableColumns = (
|
|||||||
tagsColumn,
|
tagsColumn,
|
||||||
notesColumn,
|
notesColumn,
|
||||||
];
|
];
|
||||||
|
|
||||||
return isViewer ? baseColumns : [getSelectionColumn(), ...baseColumns];
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,19 +1,28 @@
|
|||||||
|
import { ColumnSettingsDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ColumnSettingsDropdown";
|
||||||
import { useSortable } from "@dnd-kit/sortable";
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { Header, flexRender } from "@tanstack/react-table";
|
import { Column, Header, flexRender } from "@tanstack/react-table";
|
||||||
import { GripVerticalIcon } from "lucide-react";
|
import { GripVerticalIcon } from "lucide-react";
|
||||||
import { CSSProperties } from "react";
|
import { CSSProperties } from "react";
|
||||||
import { cn } from "@formbricks/lib/cn";
|
import { cn } from "@formbricks/lib/cn";
|
||||||
import { TableHead } from "../../Table";
|
import { TResponseTableData } from "@formbricks/types/responses";
|
||||||
import { getCommonPinningStyles } from "../lib/utils";
|
import { TableHead } from "@formbricks/ui/Table";
|
||||||
import { ColumnSettingsDropdown } from "./ColumnSettingsDropdown";
|
|
||||||
|
|
||||||
interface DataTableHeaderProps<T> {
|
interface ResponseTableHeaderProps {
|
||||||
header: Header<T, unknown>;
|
header: Header<TResponseTableData, unknown>;
|
||||||
setIsTableSettingsModalOpen: (isTableSettingsModalOpen: boolean) => void;
|
setIsTableSettingsModalOpen: (isTableSettingsModalOpen: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DataTableHeader = <T,>({ header, setIsTableSettingsModalOpen }: DataTableHeaderProps<T>) => {
|
export const getCommonPinningStyles = (column: Column<TResponseTableData>): CSSProperties => {
|
||||||
|
return {
|
||||||
|
left: `${column.getStart("left") - 1}px`,
|
||||||
|
position: "sticky",
|
||||||
|
width: column.getSize(),
|
||||||
|
zIndex: 1,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ResponseTableHeader = ({ header, setIsTableSettingsModalOpen }: ResponseTableHeaderProps) => {
|
||||||
const { attributes, isDragging, listeners, setNodeRef, transform } = useSortable({
|
const { attributes, isDragging, listeners, setNodeRef, transform } = useSortable({
|
||||||
id: header.column.id,
|
id: header.column.id,
|
||||||
});
|
});
|
||||||
@@ -26,7 +35,6 @@ export const DataTableHeader = <T,>({ header, setIsTableSettingsModalOpen }: Dat
|
|||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
width: header.column.getSize(),
|
width: header.column.getSize(),
|
||||||
zIndex: isDragging ? 1 : 0,
|
zIndex: isDragging ? 1 : 0,
|
||||||
|
|
||||||
...(header.column.id === "select" ? getCommonPinningStyles(header.column) : {}),
|
...(header.column.id === "select" ? getCommonPinningStyles(header.column) : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -36,10 +44,7 @@ export const DataTableHeader = <T,>({ header, setIsTableSettingsModalOpen }: Dat
|
|||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
style={style}
|
||||||
key={header.id}
|
key={header.id}
|
||||||
className={cn("group relative h-10 border-b border-slate-300 bg-white px-4 text-center", {
|
className="group relative h-10 border border-slate-300 bg-slate-200 px-2 text-center">
|
||||||
"border-r": !header.column.getIsLastColumn(),
|
|
||||||
"border-l": !header.column.getIsFirstColumn(),
|
|
||||||
})}>
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="w-full truncate text-left font-semibold">
|
<div className="w-full truncate text-left font-semibold">
|
||||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
@@ -1,30 +1,29 @@
|
|||||||
|
import { SelectedResponseSettings } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/SelectedResponseSettings";
|
||||||
import { Table } from "@tanstack/react-table";
|
import { Table } from "@tanstack/react-table";
|
||||||
import { MoveVerticalIcon, SettingsIcon } from "lucide-react";
|
import { MoveVerticalIcon, SettingsIcon } from "lucide-react";
|
||||||
import { cn } from "@formbricks/lib/cn";
|
import { cn } from "@formbricks/lib/cn";
|
||||||
import { TooltipRenderer } from "../../Tooltip";
|
import { TResponseTableData } from "@formbricks/types/responses";
|
||||||
import { SelectedRowSettings } from "./SelectedRowSettings";
|
import { TooltipRenderer } from "@formbricks/ui/Tooltip";
|
||||||
|
|
||||||
interface DataTableToolbarProps<T> {
|
interface ResponseTableToolbarProps {
|
||||||
setIsTableSettingsModalOpen: (isTableSettingsModalOpen: boolean) => void;
|
setIsTableSettingsModalOpen: (isTableSettingsModalOpen: boolean) => void;
|
||||||
setIsExpanded: (isExpanded: boolean) => void;
|
setIsExpanded: (isExpanded: boolean) => void;
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
table: Table<T>;
|
table: Table<TResponseTableData>;
|
||||||
deleteRows: (rowIds: string[]) => void;
|
deleteResponses: (responseIds: string[]) => void;
|
||||||
type: "person" | "response";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DataTableToolbar = <T,>({
|
export const ResponseTableToolbar = ({
|
||||||
setIsExpanded,
|
setIsExpanded,
|
||||||
setIsTableSettingsModalOpen,
|
setIsTableSettingsModalOpen,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
table,
|
table,
|
||||||
deleteRows,
|
deleteResponses,
|
||||||
type,
|
}: ResponseTableToolbarProps) => {
|
||||||
}: DataTableToolbarProps<T>) => {
|
|
||||||
return (
|
return (
|
||||||
<div className="sticky top-12 z-30 my-2 flex w-full items-center justify-between bg-slate-50 py-2">
|
<div className="sticky top-12 z-30 my-2 flex w-full items-center justify-between bg-slate-50 py-2">
|
||||||
{table.getFilteredSelectedRowModel().rows.length > 0 ? (
|
{table.getFilteredSelectedRowModel().rows.length > 0 ? (
|
||||||
<SelectedRowSettings table={table} deleteRows={deleteRows} type={type} />
|
<SelectedResponseSettings table={table} deleteResponses={deleteResponses} />
|
||||||
) : (
|
) : (
|
||||||
<div></div>
|
<div></div>
|
||||||
)}
|
)}
|
||||||
@@ -2,18 +2,16 @@ import { Table } from "@tanstack/react-table";
|
|||||||
import { Trash2Icon } from "lucide-react";
|
import { Trash2Icon } from "lucide-react";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
|
import { TResponseTableData } from "@formbricks/types/responses";
|
||||||
import { DeleteDialog } from "../../DeleteDialog";
|
import { DeleteDialog } from "@formbricks/ui/DeleteDialog";
|
||||||
import { deleteResponseAction } from "../../SingleResponseCard/actions";
|
import { deleteResponseAction } from "@formbricks/ui/SingleResponseCard/actions";
|
||||||
import { deletePersonAction } from "../actions";
|
|
||||||
|
|
||||||
interface SelectedRowSettingsProps<T> {
|
interface SelectedResponseSettingsProps {
|
||||||
table: Table<T>;
|
table: Table<TResponseTableData>;
|
||||||
deleteRows: (rowId: string[]) => void;
|
deleteResponses: (responseIds: string[]) => void;
|
||||||
type: "response" | "person";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SelectedRowSettings = <T,>({ table, deleteRows, type }: SelectedRowSettingsProps<T>) => {
|
export const SelectedResponseSettings = ({ table, deleteResponses }: SelectedResponseSettingsProps) => {
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
@@ -27,26 +25,17 @@ export const SelectedRowSettings = <T,>({ table, deleteRows, type }: SelectedRow
|
|||||||
[table]
|
[table]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle deletion
|
// Handle deletion of responses
|
||||||
const handleDelete = async () => {
|
const handleDeleteResponses = async () => {
|
||||||
try {
|
try {
|
||||||
setIsDeleting(true);
|
setIsDeleting(true);
|
||||||
const rowsToBeDeleted = table.getFilteredSelectedRowModel().rows.map((row) => row.id);
|
const rowsToBeDeleted = table.getFilteredSelectedRowModel().rows.map((row) => row.id);
|
||||||
|
await Promise.all(rowsToBeDeleted.map((responseId) => deleteResponseAction({ responseId })));
|
||||||
|
|
||||||
if (type === "response") {
|
deleteResponses(rowsToBeDeleted);
|
||||||
await Promise.all(rowsToBeDeleted.map((responseId) => deleteResponseAction({ responseId })));
|
toast.success("Responses deleted successfully");
|
||||||
} else if (type === "person") {
|
|
||||||
await Promise.all(rowsToBeDeleted.map((personId) => deletePersonAction({ personId })));
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteRows(rowsToBeDeleted);
|
|
||||||
toast.success(`${capitalizeFirstLetter(type)}s deleted successfully`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
toast.error(error.message || "An error occurred while deleting responses");
|
||||||
toast.error(error.message);
|
|
||||||
} else {
|
|
||||||
toast.error(`An unknown error occurred while deleting ${type}s`);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsDeleting(false);
|
setIsDeleting(false);
|
||||||
setIsDeleteDialogOpen(false);
|
setIsDeleteDialogOpen(false);
|
||||||
@@ -65,9 +54,7 @@ export const SelectedRowSettings = <T,>({ table, deleteRows, type }: SelectedRow
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-x-2 rounded-md bg-slate-900 p-1 px-2 text-xs text-white">
|
<div className="flex items-center gap-x-2 rounded-md bg-slate-900 p-1 px-2 text-xs text-white">
|
||||||
<div>
|
<div>{selectedRowCount} responses selected</div>
|
||||||
{selectedRowCount} {type}s selected
|
|
||||||
</div>
|
|
||||||
<Separator />
|
<Separator />
|
||||||
<SelectableOption label="Select all" onClick={() => handleToggleAllRowsSelection(true)} />
|
<SelectableOption label="Select all" onClick={() => handleToggleAllRowsSelection(true)} />
|
||||||
<Separator />
|
<Separator />
|
||||||
@@ -81,8 +68,8 @@ export const SelectedRowSettings = <T,>({ table, deleteRows, type }: SelectedRow
|
|||||||
<DeleteDialog
|
<DeleteDialog
|
||||||
open={isDeleteDialogOpen}
|
open={isDeleteDialogOpen}
|
||||||
setOpen={setIsDeleteDialogOpen}
|
setOpen={setIsDeleteDialogOpen}
|
||||||
deleteWhat={type}
|
deleteWhat="response"
|
||||||
onDelete={handleDelete}
|
onDelete={handleDeleteResponses}
|
||||||
isDeleting={isDeleting}
|
isDeleting={isDeleting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { TableSettingsModalItem } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/TableSettingsModalItem";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
DragEndEvent,
|
DragEndEvent,
|
||||||
@@ -9,27 +10,27 @@ import {
|
|||||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||||
import { Table } from "@tanstack/react-table";
|
import { Table } from "@tanstack/react-table";
|
||||||
import { SettingsIcon } from "lucide-react";
|
import { SettingsIcon } from "lucide-react";
|
||||||
|
import { TResponseTableData } from "@formbricks/types/responses";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { Modal } from "../../Modal";
|
import { Modal } from "@formbricks/ui/Modal";
|
||||||
import { DataTableSettingsModalItem } from "./DataTableSettingsModalItem";
|
|
||||||
|
|
||||||
interface DataTableSettingsModalProps<T> {
|
interface TableSettingsModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
setOpen: (open: boolean) => void;
|
setOpen: (open: boolean) => void;
|
||||||
table: Table<T>;
|
survey: TSurvey;
|
||||||
|
table: Table<TResponseTableData>;
|
||||||
columnOrder: string[];
|
columnOrder: string[];
|
||||||
handleDragEnd: (event: DragEndEvent) => void;
|
handleDragEnd: (event: DragEndEvent) => void;
|
||||||
survey?: TSurvey;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DataTableSettingsModal = <T,>({
|
export const TableSettingsModal = ({
|
||||||
open,
|
open,
|
||||||
setOpen,
|
setOpen,
|
||||||
|
survey,
|
||||||
table,
|
table,
|
||||||
columnOrder,
|
columnOrder,
|
||||||
handleDragEnd,
|
handleDragEnd,
|
||||||
survey,
|
}: TableSettingsModalProps) => {
|
||||||
}: DataTableSettingsModalProps<T>) => {
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
activationConstraint: {
|
activationConstraint: {
|
||||||
@@ -55,17 +56,13 @@ export const DataTableSettingsModal = <T,>({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[75vh] space-y-2 overflow-auto p-8">
|
<div className="max-h-[75vh] space-y-2 overflow-auto p-8">
|
||||||
<DndContext
|
<DndContext sensors={sensors} onDragEnd={handleDragEnd} collisionDetection={closestCorners}>
|
||||||
id="table-settings"
|
|
||||||
sensors={sensors}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
collisionDetection={closestCorners}>
|
|
||||||
<SortableContext items={columnOrder} strategy={verticalListSortingStrategy}>
|
<SortableContext items={columnOrder} strategy={verticalListSortingStrategy}>
|
||||||
{columnOrder.map((columnId) => {
|
{columnOrder.map((columnId) => {
|
||||||
if (columnId === "select" || columnId === "createdAt") return;
|
if (columnId === "select") return;
|
||||||
const column = table.getAllColumns().find((column) => column.id === columnId);
|
const column = table.getAllColumns().find((column) => column.id === columnId);
|
||||||
if (!column) return null;
|
if (!column) return;
|
||||||
return <DataTableSettingsModalItem column={column} key={column.id} survey={survey} />;
|
return <TableSettingsModalItem column={column} key={column?.id} survey={survey} />;
|
||||||
})}
|
})}
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
@@ -1,19 +1,20 @@
|
|||||||
|
import { QUESTIONS_ICON_MAP } from "@/app/lib/questions";
|
||||||
import { useSortable } from "@dnd-kit/sortable";
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { Column } from "@tanstack/react-table";
|
import { Column } from "@tanstack/react-table";
|
||||||
import { capitalize } from "lodash";
|
import { capitalize } from "lodash";
|
||||||
import { GripVertical } from "lucide-react";
|
import { GripVertical } from "lucide-react";
|
||||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||||
import { QUESTIONS_ICON_MAP } from "@formbricks/lib/utils/questions";
|
import { TResponseTableData } from "@formbricks/types/responses";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { Switch } from "../../Switch";
|
import { Switch } from "@formbricks/ui/Switch";
|
||||||
|
|
||||||
interface DataTableSettingsModalItemProps<T> {
|
interface TableSettingsModalItemProps {
|
||||||
column: Column<T, unknown>;
|
column: Column<TResponseTableData, unknown>;
|
||||||
survey?: TSurvey;
|
survey: TSurvey;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DataTableSettingsModalItem = <T,>({ column, survey }: DataTableSettingsModalItemProps<T>) => {
|
export const TableSettingsModalItem = ({ column, survey }: TableSettingsModalItemProps) => {
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
id: column.id,
|
id: column.id,
|
||||||
});
|
});
|
||||||
@@ -39,14 +40,13 @@ export const DataTableSettingsModalItem = <T,>({ column, survey }: DataTableSett
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const question = survey?.questions.find((question) => question.id === column.id);
|
const question = survey.questions.find((question) => question.id === column.id);
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
transition: transition ?? "transform 100ms ease",
|
transition: transition ?? "transform 100ms ease",
|
||||||
transform: CSS.Translate.toString(transform),
|
transform: CSS.Translate.toString(transform),
|
||||||
zIndex: isDragging ? 10 : 1,
|
zIndex: isDragging ? 10 : 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={setNodeRef} style={style} id={column.id}>
|
<div ref={setNodeRef} style={style} id={column.id}>
|
||||||
<div {...listeners} {...attributes}>
|
<div {...listeners} {...attributes}>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import { questionTypes } from "@/app/lib/questions";
|
||||||
import { InboxIcon } from "lucide-react";
|
import { InboxIcon } from "lucide-react";
|
||||||
import { questionTypes } from "@formbricks/lib/utils/questions";
|
|
||||||
import { recallToHeadline } from "@formbricks/lib/utils/recall";
|
import { recallToHeadline } from "@formbricks/lib/utils/recall";
|
||||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||||
import { TSurvey, TSurveyQuestionSummary } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveyQuestionSummary } from "@formbricks/types/surveys/types";
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useState } from "react";
|
|||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { TUser } from "@formbricks/types/user";
|
import { TUser } from "@formbricks/types/user";
|
||||||
import { Badge } from "@formbricks/ui/Badge";
|
import { Badge } from "@formbricks/ui/Badge";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@formbricks/ui/Dialog";
|
import { Dialog, DialogContent } from "@formbricks/ui/Dialog";
|
||||||
import { ShareSurveyLink } from "@formbricks/ui/ShareSurveyLink";
|
import { ShareSurveyLink } from "@formbricks/ui/ShareSurveyLink";
|
||||||
import { EmbedView } from "./shareEmbedModal/EmbedView";
|
import { EmbedView } from "./shareEmbedModal/EmbedView";
|
||||||
import { PanelInfoView } from "./shareEmbedModal/PanelInfoView";
|
import { PanelInfoView } from "./shareEmbedModal/PanelInfoView";
|
||||||
@@ -55,10 +55,7 @@ export const ShareEmbedSurvey = ({ survey, open, setOpen, webAppUrl, user }: Sha
|
|||||||
{showView === "start" ? (
|
{showView === "start" ? (
|
||||||
<div className="h-full max-w-full overflow-hidden">
|
<div className="h-full max-w-full overflow-hidden">
|
||||||
<div className="flex h-[200px] w-full flex-col items-center justify-center space-y-6 p-8 text-center lg:h-2/5">
|
<div className="flex h-[200px] w-full flex-col items-center justify-center space-y-6 p-8 text-center lg:h-2/5">
|
||||||
<DialogTitle>
|
<p className="pt-2 text-xl font-semibold text-slate-800">Your survey is public 🎉</p>
|
||||||
<p className="pt-2 text-xl font-semibold text-slate-800">Your survey is public 🎉</p>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="hidden" />
|
|
||||||
<ShareSurveyLink
|
<ShareSurveyLink
|
||||||
survey={survey}
|
survey={survey}
|
||||||
webAppUrl={webAppUrl}
|
webAppUrl={webAppUrl}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { AlertCircleIcon, CheckCircle2Icon } from "lucide-react";
|
|||||||
import { Clipboard } from "lucide-react";
|
import { Clipboard } from "lucide-react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { Button } from "@formbricks/ui/Button";
|
import { Button } from "@formbricks/ui/Button";
|
||||||
import { Modal } from "@formbricks/ui/Modal";
|
import { Dialog, DialogContent } from "@formbricks/ui/Dialog";
|
||||||
|
|
||||||
interface ShareEmbedSurveyProps {
|
interface ShareEmbedSurveyProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -21,9 +21,13 @@ export const ShareSurveyResults = ({
|
|||||||
surveyUrl,
|
surveyUrl,
|
||||||
}: ShareEmbedSurveyProps) => {
|
}: ShareEmbedSurveyProps) => {
|
||||||
return (
|
return (
|
||||||
<Modal open={open} setOpen={setOpen} size="lg">
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setOpen(open);
|
||||||
|
}}>
|
||||||
{showPublishModal && surveyUrl ? (
|
{showPublishModal && surveyUrl ? (
|
||||||
<div className="flex flex-col rounded-2xl bg-white px-12 py-6">
|
<DialogContent className="flex flex-col rounded-2xl bg-white px-12 py-6">
|
||||||
<div className="flex flex-col items-center gap-y-6 text-center">
|
<div className="flex flex-col items-center gap-y-6 text-center">
|
||||||
<CheckCircle2Icon className="h-20 w-20 text-slate-300" />
|
<CheckCircle2Icon className="h-20 w-20 text-slate-300" />
|
||||||
<div>
|
<div>
|
||||||
@@ -33,6 +37,7 @@ export const ShareSurveyResults = ({
|
|||||||
by search engines.
|
by search engines.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="whitespace-nowrap rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-800">
|
<div className="whitespace-nowrap rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-800">
|
||||||
<span>{surveyUrl}</span>
|
<span>{surveyUrl}</span>
|
||||||
@@ -50,6 +55,7 @@ export const ShareSurveyResults = ({
|
|||||||
<Clipboard />
|
<Clipboard />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -58,14 +64,15 @@ export const ShareSurveyResults = ({
|
|||||||
onClick={() => handleUnpublish()}>
|
onClick={() => handleUnpublish()}>
|
||||||
Unpublish
|
Unpublish
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button className="text-center" href={surveyUrl} target="_blank">
|
<Button className="text-center" href={surveyUrl} target="_blank">
|
||||||
View site
|
View site
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DialogContent>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col rounded-2xl bg-white p-8">
|
<DialogContent className="flex flex-col rounded-2xl bg-white p-8">
|
||||||
<div className="flex flex-col items-center gap-y-6 text-center">
|
<div className="flex flex-col items-center gap-y-6 text-center">
|
||||||
<AlertCircleIcon className="h-20 w-20 text-slate-300" />
|
<AlertCircleIcon className="h-20 w-20 text-slate-300" />
|
||||||
<div>
|
<div>
|
||||||
@@ -81,8 +88,8 @@ export const ShareSurveyResults = ({
|
|||||||
Publish to public web
|
Publish to public web
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DialogContent>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// DEPRECATED
|
||||||
|
// Storing actions on the server is deprecated and no longer supported.
|
||||||
|
import { responses } from "@/app/lib/api/response";
|
||||||
|
|
||||||
|
export const OPTIONS = async (): Promise<Response> => {
|
||||||
|
return responses.successResponse({}, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST = async (): Promise<Response> => {
|
||||||
|
return responses.successResponse({}, true);
|
||||||
|
};
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// 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<Response> => {
|
||||||
|
return responses.successResponse({}, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST = async (req: Request, context: Context): Promise<Response> => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
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<Response> => {
|
||||||
|
return responses.successResponse({}, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST = async (req: Request, context: Context): Promise<Response> => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
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 (!environment) {
|
|
||||||
throw new ResourceNotFoundError("environment", environmentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!organization) {
|
|
||||||
throw new ResourceNotFoundError("organization", null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!product) {
|
|
||||||
throw new ResourceNotFoundError("product", null);
|
|
||||||
}
|
|
||||||
|
|
||||||
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),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
)();
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
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 { ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
import { ZJsSyncInput } from "@formbricks/types/js";
|
|
||||||
|
|
||||||
export const OPTIONS = async (): Promise<Response> => {
|
|
||||||
return responses.successResponse({}, true);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const GET = async (
|
|
||||||
_: NextRequest,
|
|
||||||
{
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: {
|
|
||||||
environmentId: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
): Promise<Response> => {
|
|
||||||
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) {
|
|
||||||
if (err instanceof ResourceNotFoundError) {
|
|
||||||
return responses.notFoundResponse(err.resourceType, err.resourceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
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),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
)();
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
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<string[]> =>
|
|
||||||
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)],
|
|
||||||
}
|
|
||||||
)();
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
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";
|
|
||||||
|
|
||||||
export const OPTIONS = async (): Promise<Response> => {
|
|
||||||
return responses.successResponse({}, true);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const GET = async (
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: { environmentId: string; userId: string } }
|
|
||||||
): Promise<Response> => {
|
|
||||||
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) {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
return responses.internalServerErrorResponse(`Unable to complete response: ${error.message}`, true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
import { replaceAttributeRecall } from "@/app/api/v1/client/[environmentId]/app/sync/lib/utils";
|
import {
|
||||||
|
replaceAttributeRecall,
|
||||||
|
replaceAttributeRecallInLegacySurveys,
|
||||||
|
} from "@/app/api/v1/client/[environmentId]/app/sync/lib/utils";
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
import { NextRequest, userAgent } from "next/server";
|
import { NextRequest, userAgent } from "next/server";
|
||||||
@@ -19,6 +22,8 @@ import {
|
|||||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||||
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
|
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
|
||||||
import { getSyncSurveys } from "@formbricks/lib/survey/service";
|
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 { TJsAppStateSync, ZJsPeopleUserIdInput } from "@formbricks/types/js";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
|
|
||||||
@@ -39,6 +44,7 @@ export const GET = async (
|
|||||||
): Promise<Response> => {
|
): Promise<Response> => {
|
||||||
try {
|
try {
|
||||||
const { device } = userAgent(request);
|
const { device } = userAgent(request);
|
||||||
|
const version = request.nextUrl.searchParams.get("version");
|
||||||
|
|
||||||
// validate using zod
|
// validate using zod
|
||||||
const inputValidation = ZJsPeopleUserIdInput.safeParse({
|
const inputValidation = ZJsPeopleUserIdInput.safeParse({
|
||||||
@@ -162,7 +168,9 @@ export const GET = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [surveys, actionClasses] = await Promise.all([
|
const [surveys, actionClasses] = await Promise.all([
|
||||||
getSyncSurveys(environmentId, person.id, device.type === "mobile" ? "phone" : "desktop"),
|
getSyncSurveys(environmentId, person.id, device.type === "mobile" ? "phone" : "desktop", {
|
||||||
|
version: version ?? undefined,
|
||||||
|
}),
|
||||||
getActionClasses(environmentId),
|
getActionClasses(environmentId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -179,6 +187,7 @@ export const GET = async (
|
|||||||
};
|
};
|
||||||
const attributes = await getAttributes(person.id);
|
const attributes = await getAttributes(person.id);
|
||||||
const language = attributes["language"];
|
const language = attributes["language"];
|
||||||
|
const noCodeActionClasses = actionClasses.filter((actionClass) => actionClass.type === "noCode");
|
||||||
|
|
||||||
// Scenario 1: Multi language and updated trigger action classes supported.
|
// Scenario 1: Multi language and updated trigger action classes supported.
|
||||||
// Use the surveys as they are.
|
// Use the surveys as they are.
|
||||||
@@ -194,6 +203,30 @@ export const GET = async (
|
|||||||
product: updatedProduct,
|
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);
|
return responses.successResponse({ ...state }, true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|||||||
@@ -53,3 +53,29 @@ export const replaceAttributeRecall = (survey: TSurvey, attributes: TAttributes)
|
|||||||
|
|
||||||
return surveyTemp;
|
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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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<Response> => {
|
||||||
|
return responses.successResponse({}, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PUT = async (request: Request, context: Context): Promise<Response> => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -3,7 +3,6 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { getAttributesByUserId, updateAttributes } from "@formbricks/lib/attribute/service";
|
import { getAttributesByUserId, updateAttributes } from "@formbricks/lib/attribute/service";
|
||||||
import { createPerson, getPersonByUserId } from "@formbricks/lib/person/service";
|
import { createPerson, getPersonByUserId } from "@formbricks/lib/person/service";
|
||||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
import { ZJsPeopleUpdateAttributeInput } from "@formbricks/types/js";
|
import { ZJsPeopleUpdateAttributeInput } from "@formbricks/types/js";
|
||||||
|
|
||||||
export const OPTIONS = async () => {
|
export const OPTIONS = async () => {
|
||||||
@@ -82,10 +81,6 @@ export const PUT = async (
|
|||||||
return responses.forbiddenResponse(err.message || "Forbidden", true, { ignore: true });
|
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);
|
return responses.internalServerErrorResponse("Something went wrong", true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
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 (!environment) {
|
|
||||||
throw new ResourceNotFoundError("environment", environmentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!organization) {
|
|
||||||
throw new ResourceNotFoundError("organization", null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!product) {
|
|
||||||
throw new ResourceNotFoundError("product", null);
|
|
||||||
}
|
|
||||||
|
|
||||||
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),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
)();
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
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";
|
|
||||||
|
|
||||||
export const OPTIONS = async (): Promise<Response> => {
|
|
||||||
return responses.successResponse({}, true);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const GET = async (
|
|
||||||
_: NextRequest,
|
|
||||||
{ params }: { params: { environmentId: string } }
|
|
||||||
): Promise<Response> => {
|
|
||||||
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) {
|
|
||||||
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) {
|
|
||||||
console.error(error);
|
|
||||||
return responses.internalServerErrorResponse(`Unable to complete response: ${error.message}`, true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
175
apps/web/app/api/v1/client/[environmentId]/website/sync/route.ts
Normal file
175
apps/web/app/api/v1/client/[environmentId]/website/sync/route.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
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<Response> => {
|
||||||
|
return responses.successResponse({}, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GET = async (
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { environmentId: string } }
|
||||||
|
): Promise<Response> => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -106,7 +106,7 @@ const methodNotAllowedResponse = (
|
|||||||
|
|
||||||
const notFoundResponse = (
|
const notFoundResponse = (
|
||||||
resourceType: string,
|
resourceType: string,
|
||||||
resourceId: string | null,
|
resourceId: string,
|
||||||
cors: boolean = false,
|
cors: boolean = false,
|
||||||
cache: string = "private, no-store"
|
cache: string = "private, no-store"
|
||||||
) => {
|
) => {
|
||||||
|
|||||||
@@ -229,12 +229,12 @@ export const questionTypes: TQuestion[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const QUESTIONS_ICON_MAP: Record<TSurveyQuestionTypeEnum, JSX.Element> = questionTypes.reduce(
|
export const QUESTIONS_ICON_MAP = questionTypes.reduce(
|
||||||
(prev, curr) => ({
|
(prev, curr) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[curr.id]: <curr.icon className="h-4 w-4" />,
|
[curr.id]: <curr.icon className="h-4 w-4" />,
|
||||||
}),
|
}),
|
||||||
{} as Record<TSurveyQuestionTypeEnum, JSX.Element>
|
{}
|
||||||
);
|
);
|
||||||
|
|
||||||
export const QUESTIONS_NAME_MAP = questionTypes.reduce(
|
export const QUESTIONS_NAME_MAP = questionTypes.reduce(
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||||
|
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||||
import { TProduct } from "@formbricks/types/product";
|
import { TProduct } from "@formbricks/types/product";
|
||||||
import { TSurveyQuestion } from "@formbricks/types/surveys/types";
|
import { TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||||
import { TTemplate } from "@formbricks/types/templates";
|
import { TTemplate } from "@formbricks/types/templates";
|
||||||
import { getLocalizedValue } from "../i18n/utils";
|
|
||||||
import { structuredClone } from "../pollyfills/structuredClone";
|
|
||||||
|
|
||||||
export const replaceQuestionPresetPlaceholders = (
|
export const replaceQuestionPresetPlaceholders = (
|
||||||
question: TSurveyQuestion,
|
question: TSurveyQuestion,
|
||||||
@@ -278,7 +278,6 @@ export const LinkSurvey = ({
|
|||||||
url: window.location.href,
|
url: window.location.href,
|
||||||
source: sourceParam || "",
|
source: sourceParam || "",
|
||||||
},
|
},
|
||||||
displayId: surveyState.displayId,
|
|
||||||
...(Object.keys(hiddenFieldsRecord).length > 0 && { hiddenFields: hiddenFieldsRecord }),
|
...(Object.keys(hiddenFieldsRecord).length > 0 && { hiddenFields: hiddenFieldsRecord }),
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -62,6 +62,18 @@ 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() {
|
async redirects() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ test.describe("JS Package Test", async () => {
|
|||||||
// Formbricks In App Sync has happened
|
// Formbricks In App Sync has happened
|
||||||
const syncApi = await page.waitForResponse(
|
const syncApi = await page.waitForResponse(
|
||||||
(response) => {
|
(response) => {
|
||||||
return response.url().includes("/app/environment");
|
return response.url().includes("/app/sync");
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
timeout: 120000,
|
timeout: 120000,
|
||||||
|
|||||||
@@ -37,6 +37,18 @@ 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: "<your-display-id>",
|
||||||
|
{
|
||||||
|
userId: "<your-user-id>", // optional
|
||||||
|
responseId: "<your-response-id>", // optional
|
||||||
|
},
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
### Response
|
### Response
|
||||||
|
|
||||||
- Create a Response
|
- Create a Response
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type TDisplayCreateInput } from "@formbricks/types/displays";
|
import { type TDisplayCreateInput, type TDisplayUpdateInput } from "@formbricks/types/displays";
|
||||||
import { type Result } from "@formbricks/types/error-handlers";
|
import { type Result } from "@formbricks/types/error-handlers";
|
||||||
import { type NetworkError } from "@formbricks/types/errors";
|
import { type NetworkError } from "@formbricks/types/errors";
|
||||||
import { makeRequest } from "../../utils/make-request";
|
import { makeRequest } from "../../utils/make-request";
|
||||||
@@ -17,4 +17,16 @@ export class DisplayAPI {
|
|||||||
): Promise<Result<{ id: string }, NetworkError | Error>> {
|
): Promise<Result<{ id: string }, NetworkError | Error>> {
|
||||||
return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/displays`, "POST", displayInput);
|
return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/displays`, "POST", displayInput);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
displayId: string,
|
||||||
|
displayInput: Omit<TDisplayUpdateInput, "environmentId">
|
||||||
|
): Promise<Result<object, NetworkError | Error>> {
|
||||||
|
return makeRequest(
|
||||||
|
this.apiHost,
|
||||||
|
`/api/v1/client/${this.environmentId}/displays/${displayId}`,
|
||||||
|
"PUT",
|
||||||
|
displayInput
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
/* eslint-disable no-console -- logging is allowed in migration scripts */
|
|
||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
const TRANSACTION_TIMEOUT = 30 * 60 * 1000; // 30 minutes in milliseconds
|
|
||||||
|
|
||||||
async function runMigration(): Promise<void> {
|
|
||||||
const startTime = Date.now();
|
|
||||||
console.log("Starting data migration...");
|
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
responseId: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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("Displays where the response was missing have been deleted.");
|
|
||||||
console.log("Data migration completed.");
|
|
||||||
console.log(`Affected rows: ${rawQueryResult + displayIdsToDelete.length}`);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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 {
|
|
||||||
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();
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
/*
|
|
||||||
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;
|
|
||||||
@@ -47,8 +47,7 @@
|
|||||||
"data-migration:fix-logic-end-destination": "ts-node ./data-migrations/20240806120500_fix-logic-end-destination/data-migration.ts",
|
"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: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: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": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.18.0",
|
"@prisma/client": "^5.18.0",
|
||||||
|
|||||||
@@ -132,8 +132,6 @@ model Response {
|
|||||||
// singleUseId, used to prevent multiple responses
|
// singleUseId, used to prevent multiple responses
|
||||||
singleUseId String?
|
singleUseId String?
|
||||||
language String?
|
language String?
|
||||||
displayId String? @unique
|
|
||||||
display Display? @relation(fields: [displayId], references: [id])
|
|
||||||
|
|
||||||
@@unique([surveyId, singleUseId])
|
@@unique([surveyId, singleUseId])
|
||||||
@@index([surveyId, createdAt]) // to determine monthly response count
|
@@index([surveyId, createdAt]) // to determine monthly response count
|
||||||
@@ -200,9 +198,8 @@ model Display {
|
|||||||
surveyId String
|
surveyId String
|
||||||
person Person? @relation(fields: [personId], references: [id], onDelete: Cascade)
|
person Person? @relation(fields: [personId], references: [id], onDelete: Cascade)
|
||||||
personId String?
|
personId String?
|
||||||
responseId String? @unique //deprecated
|
responseId String? @unique
|
||||||
status DisplayStatus?
|
status DisplayStatus?
|
||||||
response Response?
|
|
||||||
|
|
||||||
@@index([surveyId])
|
@@index([surveyId])
|
||||||
@@index([personId, createdAt])
|
@@index([personId, createdAt])
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { TJsTrackProperties } from "@formbricks/types/js";
|
import { TJsTrackProperties } from "@formbricks/types/js";
|
||||||
import { InvalidCodeError, NetworkError, Result, err, okVoid } from "../../shared/errors";
|
import { InvalidCodeError, NetworkError, Result, err, okVoid } from "../../shared/errors";
|
||||||
import { Logger } from "../../shared/logger";
|
import { Logger } from "../../shared/logger";
|
||||||
|
import { getIsDebug } from "../../shared/utils";
|
||||||
import { AppConfig } from "./config";
|
import { AppConfig } from "./config";
|
||||||
|
import { sync } from "./sync";
|
||||||
import { triggerSurvey } from "./widget";
|
import { triggerSurvey } from "./widget";
|
||||||
|
|
||||||
const logger = Logger.getInstance();
|
const logger = Logger.getInstance();
|
||||||
@@ -13,11 +15,31 @@ export const trackAction = async (
|
|||||||
properties?: TJsTrackProperties
|
properties?: TJsTrackProperties
|
||||||
): Promise<Result<void, NetworkError>> => {
|
): Promise<Result<void, NetworkError>> => {
|
||||||
const aliasName = alias || name;
|
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`);
|
logger.debug(`Formbricks: Action "${aliasName}" tracked`);
|
||||||
|
|
||||||
// get a list of surveys that are collecting insights
|
// get a list of surveys that are collecting insights
|
||||||
const activeSurveys = appConfig.get().filteredSurveys;
|
const activeSurveys = appConfig.get().state?.surveys;
|
||||||
|
|
||||||
if (!!activeSurveys && activeSurveys.length > 0) {
|
if (!!activeSurveys && activeSurveys.length > 0) {
|
||||||
for (const survey of activeSurveys) {
|
for (const survey of activeSurveys) {
|
||||||
@@ -38,7 +60,9 @@ export const trackCodeAction = (
|
|||||||
code: string,
|
code: string,
|
||||||
properties?: TJsTrackProperties
|
properties?: TJsTrackProperties
|
||||||
): Promise<Result<void, NetworkError>> | Result<void, InvalidCodeError> => {
|
): Promise<Result<void, NetworkError>> | Result<void, InvalidCodeError> => {
|
||||||
const actionClasses = appConfig.get().environmentState.data.actionClasses;
|
const {
|
||||||
|
state: { actionClasses = [] },
|
||||||
|
} = appConfig.get();
|
||||||
|
|
||||||
const codeActionClasses = actionClasses.filter((action) => action.type === "code");
|
const codeActionClasses = actionClasses.filter((action) => action.type === "code");
|
||||||
const action = codeActionClasses.find((action) => action.key === code);
|
const action = codeActionClasses.find((action) => action.key === code);
|
||||||
|
|||||||
@@ -2,37 +2,13 @@ import { FormbricksAPI } from "@formbricks/api";
|
|||||||
import { TAttributes } from "@formbricks/types/attributes";
|
import { TAttributes } from "@formbricks/types/attributes";
|
||||||
import { MissingPersonError, NetworkError, Result, err, ok, okVoid } from "../../shared/errors";
|
import { MissingPersonError, NetworkError, Result, err, ok, okVoid } from "../../shared/errors";
|
||||||
import { Logger } from "../../shared/logger";
|
import { Logger } from "../../shared/logger";
|
||||||
import { fetchPersonState } from "../../shared/personState";
|
|
||||||
import { filterSurveys } from "../../shared/utils";
|
|
||||||
import { AppConfig } from "./config";
|
import { AppConfig } from "./config";
|
||||||
|
|
||||||
const appConfig = AppConfig.getInstance();
|
const appConfig = AppConfig.getInstance();
|
||||||
const logger = Logger.getInstance();
|
const logger = Logger.getInstance();
|
||||||
|
|
||||||
export const updateAttribute = async (
|
export const updateAttribute = async (key: string, value: string): Promise<Result<void, NetworkError>> => {
|
||||||
key: string,
|
const { apiHost, environmentId, userId } = appConfig.get();
|
||||||
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({
|
const api = new FormbricksAPI({
|
||||||
apiHost,
|
apiHost,
|
||||||
@@ -45,13 +21,7 @@ export const updateAttribute = async (
|
|||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
if (res.error.details?.ignore) {
|
if (res.error.details?.ignore) {
|
||||||
logger.error(res.error.message ?? `Error updating person with userId ${userId}`);
|
logger.error(res.error.message ?? `Error updating person with userId ${userId}`);
|
||||||
return {
|
return okVoid();
|
||||||
ok: true,
|
|
||||||
value: {
|
|
||||||
changed: false,
|
|
||||||
message: res.error.message,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return err({
|
return err({
|
||||||
code: "network_error",
|
code: "network_error",
|
||||||
@@ -65,35 +35,23 @@ export const updateAttribute = async (
|
|||||||
|
|
||||||
if (res.data.changed) {
|
if (res.data.changed) {
|
||||||
logger.debug("Attribute updated in Formbricks");
|
logger.debug("Attribute updated in Formbricks");
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
value: {
|
|
||||||
changed: true,
|
|
||||||
message: "Attribute updated in Formbricks",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return okVoid();
|
||||||
ok: true,
|
|
||||||
value: {
|
|
||||||
changed: false,
|
|
||||||
message: "Attribute not updated in Formbricks",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateAttributes = async (
|
export const updateAttributes = async (
|
||||||
apiHost: string,
|
apiHost: string,
|
||||||
environmentId: string,
|
environmentId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
attributes: TAttributes
|
attributes: TAttributes,
|
||||||
|
appConfig: AppConfig
|
||||||
): Promise<Result<TAttributes, NetworkError>> => {
|
): Promise<Result<TAttributes, NetworkError>> => {
|
||||||
// clean attributes and remove existing attributes if config already exists
|
// clean attributes and remove existing attributes if config already exists
|
||||||
const updatedAttributes = { ...attributes };
|
const updatedAttributes = { ...attributes };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existingAttributes = appConfig.get().personState.data.attributes;
|
const existingAttributes = appConfig.get()?.state?.attributes;
|
||||||
if (existingAttributes) {
|
if (existingAttributes) {
|
||||||
for (const [key, value] of Object.entries(existingAttributes)) {
|
for (const [key, value] of Object.entries(existingAttributes)) {
|
||||||
if (updatedAttributes[key] === value) {
|
if (updatedAttributes[key] === value) {
|
||||||
@@ -139,11 +97,10 @@ export const updateAttributes = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isExistingAttribute = (key: string, value: string): boolean => {
|
export const isExistingAttribute = (key: string, value: string, appConfig: AppConfig): boolean => {
|
||||||
if (appConfig.get().personState.data.attributes[key] === value) {
|
if (appConfig.get().state.attributes[key] === value) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -156,18 +113,9 @@ export const setAttributeInApp = async (
|
|||||||
return okVoid();
|
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);
|
logger.debug("Setting attribute: " + key + " to value: " + value);
|
||||||
// check if attribute already exists with this value
|
// check if attribute already exists with this value
|
||||||
if (isExistingAttribute(key, value.toString())) {
|
if (isExistingAttribute(key, value.toString(), appConfig)) {
|
||||||
logger.debug("Attribute already set to this value. Skipping update.");
|
logger.debug("Attribute already set to this value. Skipping update.");
|
||||||
return okVoid();
|
return okVoid();
|
||||||
}
|
}
|
||||||
@@ -175,27 +123,22 @@ export const setAttributeInApp = async (
|
|||||||
const result = await updateAttribute(key, value.toString());
|
const result = await updateAttribute(key, value.toString());
|
||||||
|
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
if (result.value.changed) {
|
// udpdate attribute in config
|
||||||
const personState = await fetchPersonState(
|
appConfig.update({
|
||||||
{
|
environmentId: appConfig.get().environmentId,
|
||||||
apiHost: appConfig.get().apiHost,
|
apiHost: appConfig.get().apiHost,
|
||||||
environmentId: appConfig.get().environmentId,
|
userId: appConfig.get().userId,
|
||||||
userId,
|
state: {
|
||||||
|
...appConfig.get().state,
|
||||||
|
attributes: {
|
||||||
|
...appConfig.get().state.attributes,
|
||||||
|
[key]: value.toString(),
|
||||||
},
|
},
|
||||||
true
|
},
|
||||||
);
|
expiresAt: appConfig.get().expiresAt,
|
||||||
|
});
|
||||||
const filteredSurveys = filterSurveys(appConfig.get().environmentState, personState);
|
|
||||||
|
|
||||||
appConfig.update({
|
|
||||||
...appConfig.get(),
|
|
||||||
personState,
|
|
||||||
filteredSurveys,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return okVoid();
|
return okVoid();
|
||||||
}
|
}
|
||||||
|
|
||||||
return err(result.error as NetworkError);
|
return err(result.error);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,67 +1,89 @@
|
|||||||
import { TJsConfig, TJsConfigUpdateInput } from "@formbricks/types/js";
|
import { TJSAppConfig, TJsAppConfigUpdateInput } from "@formbricks/types/js";
|
||||||
import { APP_SURVEYS_LOCAL_STORAGE_KEY } from "../../shared/constants";
|
import { APP_SURVEYS_LOCAL_STORAGE_KEY } from "../../shared/constants";
|
||||||
import { Result, err, ok, wrapThrows } from "../../shared/errors";
|
import { Result, err, ok, wrapThrows } from "../../shared/errors";
|
||||||
|
|
||||||
export class AppConfig {
|
export interface StorageHandler {
|
||||||
private static instance: AppConfig | undefined;
|
getItem(key: string): Promise<string | null>;
|
||||||
private config: TJsConfig | null = null;
|
setItem(key: string, value: string): Promise<void>;
|
||||||
|
removeItem(key: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
private constructor() {
|
// LocalStorage implementation - default
|
||||||
const savedConfig = this.loadFromLocalStorage();
|
class LocalStorage implements StorageHandler {
|
||||||
|
async getItem(key: string): Promise<string | null> {
|
||||||
if (savedConfig.ok) {
|
return localStorage.getItem(key);
|
||||||
this.config = savedConfig.value;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static getInstance(): AppConfig {
|
async setItem(key: string, value: string): Promise<void> {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeItem(key: string): Promise<void> {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AppConfig {
|
||||||
|
private static instance: AppConfig | undefined;
|
||||||
|
private config: TJSAppConfig | null = null;
|
||||||
|
private storageHandler: StorageHandler;
|
||||||
|
private storageKey: string;
|
||||||
|
|
||||||
|
private constructor(
|
||||||
|
storageHandler: StorageHandler = new LocalStorage(),
|
||||||
|
storageKey: string = APP_SURVEYS_LOCAL_STORAGE_KEY
|
||||||
|
) {
|
||||||
|
this.storageHandler = storageHandler;
|
||||||
|
this.storageKey = storageKey;
|
||||||
|
|
||||||
|
this.loadFromStorage().then((res) => {
|
||||||
|
if (res.ok) {
|
||||||
|
this.config = res.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static getInstance(storageHandler?: StorageHandler, storageKey?: string): AppConfig {
|
||||||
if (!AppConfig.instance) {
|
if (!AppConfig.instance) {
|
||||||
AppConfig.instance = new AppConfig();
|
AppConfig.instance = new AppConfig(storageHandler, storageKey);
|
||||||
}
|
}
|
||||||
return AppConfig.instance;
|
return AppConfig.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
public update(newConfig: TJsConfigUpdateInput): void {
|
public update(newConfig: TJsAppConfigUpdateInput): void {
|
||||||
if (newConfig) {
|
if (newConfig) {
|
||||||
this.config = {
|
this.config = {
|
||||||
...this.config,
|
...this.config,
|
||||||
...newConfig,
|
...newConfig,
|
||||||
status: {
|
status: newConfig.status || "success",
|
||||||
value: newConfig.status?.value || "success",
|
|
||||||
expiresAt: newConfig.status?.expiresAt || null,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
this.saveToStorage();
|
this.saveToStorage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public get(): TJsConfig {
|
public get(): TJSAppConfig {
|
||||||
if (!this.config) {
|
if (!this.config) {
|
||||||
throw new Error("config is null, maybe the init function was not called?");
|
throw new Error("config is null, maybe the init function was not called?");
|
||||||
}
|
}
|
||||||
return this.config;
|
return this.config;
|
||||||
}
|
}
|
||||||
|
|
||||||
public loadFromLocalStorage(): Result<TJsConfig, Error> {
|
public async loadFromStorage(): Promise<Result<TJSAppConfig, Error>> {
|
||||||
if (typeof window !== "undefined") {
|
try {
|
||||||
const savedConfig = localStorage.getItem(APP_SURVEYS_LOCAL_STORAGE_KEY);
|
const savedConfig = await this.storageHandler.getItem(this.storageKey);
|
||||||
if (savedConfig) {
|
if (savedConfig) {
|
||||||
// TODO: validate config
|
const parsedConfig = JSON.parse(savedConfig) as TJSAppConfig;
|
||||||
// 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
|
// check if the config has expired
|
||||||
if (
|
if (parsedConfig.expiresAt && new Date(parsedConfig.expiresAt) <= new Date()) {
|
||||||
parsedConfig.environmentState?.expiresAt &&
|
|
||||||
new Date(parsedConfig.environmentState.expiresAt) <= new Date()
|
|
||||||
) {
|
|
||||||
return err(new Error("Config in local storage has expired"));
|
return err(new Error("Config in local storage has expired"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return ok(parsedConfig);
|
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"));
|
return err(new Error("No or invalid config in local storage"));
|
||||||
@@ -69,7 +91,7 @@ export class AppConfig {
|
|||||||
|
|
||||||
private async saveToStorage(): Promise<Result<Promise<void>, Error>> {
|
private async saveToStorage(): Promise<Result<Promise<void>, Error>> {
|
||||||
return wrapThrows(async () => {
|
return wrapThrows(async () => {
|
||||||
await localStorage.setItem(APP_SURVEYS_LOCAL_STORAGE_KEY, JSON.stringify(this.config));
|
await this.storageHandler.setItem(this.storageKey, JSON.stringify(this.config));
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,8 +100,9 @@ export class AppConfig {
|
|||||||
public async resetConfig(): Promise<Result<Promise<void>, Error>> {
|
public async resetConfig(): Promise<Result<Promise<void>, Error>> {
|
||||||
this.config = null;
|
this.config = null;
|
||||||
|
|
||||||
|
// return wrapThrows(() => localStorage.removeItem(IN_APP_LOCAL_STORAGE_KEY))();
|
||||||
return wrapThrows(async () => {
|
return wrapThrows(async () => {
|
||||||
localStorage.removeItem(APP_SURVEYS_LOCAL_STORAGE_KEY);
|
await this.storageHandler.removeItem(this.storageKey);
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,3 @@
|
|||||||
import {
|
|
||||||
addEnvironmentStateExpiryCheckListener,
|
|
||||||
clearEnvironmentStateExpiryCheckListener,
|
|
||||||
} from "../../shared/environmentState";
|
|
||||||
import {
|
|
||||||
addPersonStateExpiryCheckListener,
|
|
||||||
clearPersonStateExpiryCheckListener,
|
|
||||||
} from "../../shared/personState";
|
|
||||||
import {
|
import {
|
||||||
addClickEventListener,
|
addClickEventListener,
|
||||||
addExitIntentListener,
|
addExitIntentListener,
|
||||||
@@ -17,12 +9,13 @@ import {
|
|||||||
removeScrollDepthListener,
|
removeScrollDepthListener,
|
||||||
} from "../lib/noCodeActions";
|
} from "../lib/noCodeActions";
|
||||||
import { AppConfig } from "./config";
|
import { AppConfig } from "./config";
|
||||||
|
import { addExpiryCheckListener, removeExpiryCheckListener } from "./sync";
|
||||||
|
|
||||||
let areRemoveEventListenersAdded = false;
|
let areRemoveEventListenersAdded = false;
|
||||||
|
const appConfig = AppConfig.getInstance();
|
||||||
|
|
||||||
export const addEventListeners = (config: AppConfig): void => {
|
export const addEventListeners = (): void => {
|
||||||
addEnvironmentStateExpiryCheckListener("app", config);
|
addExpiryCheckListener(appConfig);
|
||||||
addPersonStateExpiryCheckListener(config);
|
|
||||||
addPageUrlEventListeners();
|
addPageUrlEventListeners();
|
||||||
addClickEventListener();
|
addClickEventListener();
|
||||||
addExitIntentListener();
|
addExitIntentListener();
|
||||||
@@ -32,8 +25,7 @@ export const addEventListeners = (config: AppConfig): void => {
|
|||||||
export const addCleanupEventListeners = (): void => {
|
export const addCleanupEventListeners = (): void => {
|
||||||
if (areRemoveEventListenersAdded) return;
|
if (areRemoveEventListenersAdded) return;
|
||||||
window.addEventListener("beforeunload", () => {
|
window.addEventListener("beforeunload", () => {
|
||||||
clearEnvironmentStateExpiryCheckListener();
|
removeExpiryCheckListener();
|
||||||
clearPersonStateExpiryCheckListener();
|
|
||||||
removePageUrlEventListeners();
|
removePageUrlEventListeners();
|
||||||
removeClickEventListener();
|
removeClickEventListener();
|
||||||
removeExitIntentListener();
|
removeExitIntentListener();
|
||||||
@@ -45,8 +37,7 @@ export const addCleanupEventListeners = (): void => {
|
|||||||
export const removeCleanupEventListeners = (): void => {
|
export const removeCleanupEventListeners = (): void => {
|
||||||
if (!areRemoveEventListenersAdded) return;
|
if (!areRemoveEventListenersAdded) return;
|
||||||
window.removeEventListener("beforeunload", () => {
|
window.removeEventListener("beforeunload", () => {
|
||||||
clearEnvironmentStateExpiryCheckListener();
|
removeExpiryCheckListener();
|
||||||
clearPersonStateExpiryCheckListener();
|
|
||||||
removePageUrlEventListeners();
|
removePageUrlEventListeners();
|
||||||
removeClickEventListener();
|
removeClickEventListener();
|
||||||
removeExitIntentListener();
|
removeExitIntentListener();
|
||||||
@@ -56,8 +47,7 @@ export const removeCleanupEventListeners = (): void => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const removeAllEventListeners = (): void => {
|
export const removeAllEventListeners = (): void => {
|
||||||
clearEnvironmentStateExpiryCheckListener();
|
removeExpiryCheckListener();
|
||||||
clearPersonStateExpiryCheckListener();
|
|
||||||
removePageUrlEventListeners();
|
removePageUrlEventListeners();
|
||||||
removeClickEventListener();
|
removeClickEventListener();
|
||||||
removeExitIntentListener();
|
removeExitIntentListener();
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { TAttributes } from "@formbricks/types/attributes";
|
import { TAttributes } from "@formbricks/types/attributes";
|
||||||
import type { TJsAppConfigInput, TJsConfig } from "@formbricks/types/js";
|
import type { TJSAppConfig, TJsAppConfigInput } from "@formbricks/types/js";
|
||||||
import { APP_SURVEYS_LOCAL_STORAGE_KEY } from "../../shared/constants";
|
import { APP_SURVEYS_LOCAL_STORAGE_KEY } from "../../shared/constants";
|
||||||
import { fetchEnvironmentState } from "../../shared/environmentState";
|
|
||||||
import {
|
import {
|
||||||
ErrorHandler,
|
ErrorHandler,
|
||||||
MissingFieldError,
|
MissingFieldError,
|
||||||
@@ -14,16 +13,16 @@ import {
|
|||||||
wrapThrows,
|
wrapThrows,
|
||||||
} from "../../shared/errors";
|
} from "../../shared/errors";
|
||||||
import { Logger } from "../../shared/logger";
|
import { Logger } from "../../shared/logger";
|
||||||
import { fetchPersonState } from "../../shared/personState";
|
import { getIsDebug } from "../../shared/utils";
|
||||||
import { filterSurveys, getIsDebug } from "../../shared/utils";
|
|
||||||
import { trackNoCodeAction } from "./actions";
|
import { trackNoCodeAction } from "./actions";
|
||||||
import { updateAttributes } from "./attributes";
|
import { updateAttributes } from "./attributes";
|
||||||
import { AppConfig } from "./config";
|
import { AppConfig } from "./config";
|
||||||
import { addCleanupEventListeners, addEventListeners, removeAllEventListeners } from "./eventListeners";
|
import { addCleanupEventListeners, addEventListeners, removeAllEventListeners } from "./eventListeners";
|
||||||
import { checkPageUrl } from "./noCodeActions";
|
import { checkPageUrl } from "./noCodeActions";
|
||||||
|
import { sync } from "./sync";
|
||||||
import { addWidgetContainer, removeWidgetContainer, setIsSurveyRunning } from "./widget";
|
import { addWidgetContainer, removeWidgetContainer, setIsSurveyRunning } from "./widget";
|
||||||
|
|
||||||
const appConfigGlobal = AppConfig.getInstance();
|
const appConfig = AppConfig.getInstance();
|
||||||
const logger = Logger.getInstance();
|
const logger = Logger.getInstance();
|
||||||
|
|
||||||
let isInitialized = false;
|
let isInitialized = false;
|
||||||
@@ -32,20 +31,6 @@ export const setIsInitialized = (value: boolean) => {
|
|||||||
isInitialized = value;
|
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 (
|
export const initialize = async (
|
||||||
configInput: TJsAppConfigInput
|
configInput: TJsAppConfigInput
|
||||||
): Promise<Result<void, MissingFieldError | NetworkError | MissingPersonError>> => {
|
): Promise<Result<void, MissingFieldError | NetworkError | MissingPersonError>> => {
|
||||||
@@ -54,46 +39,31 @@ export const initialize = async (
|
|||||||
logger.configure({ logLevel: "debug" });
|
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) {
|
if (isInitialized) {
|
||||||
logger.debug("Already initialized, skipping initialization.");
|
logger.debug("Already initialized, skipping initialization.");
|
||||||
return okVoid();
|
return okVoid();
|
||||||
}
|
}
|
||||||
|
|
||||||
let existingConfig: TJsConfig | undefined;
|
let existingConfig: TJSAppConfig | undefined;
|
||||||
try {
|
try {
|
||||||
existingConfig = appConfigGlobal.get();
|
existingConfig = appConfig.get();
|
||||||
logger.debug("Found existing configuration.");
|
logger.debug("Found existing configuration.");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.debug("No existing configuration found.");
|
logger.debug("No existing configuration found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// formbricks is in error state, skip initialization
|
// formbricks is in error state, skip initialization
|
||||||
if (existingConfig?.status?.value === "error") {
|
if (existingConfig?.status === "error") {
|
||||||
if (isDebug) {
|
if (isDebug) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Formbricks is in error state, but debug mode is active. Resetting config and continuing."
|
"Formbricks is in error state, but debug mode is active. Resetting config and continuing."
|
||||||
);
|
);
|
||||||
appConfigGlobal.resetConfig();
|
appConfig.resetConfig();
|
||||||
return okVoid();
|
return okVoid();
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug("Formbricks was set to an error state.");
|
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");
|
logger.debug("Error state is not expired, skipping initialization");
|
||||||
return okVoid();
|
return okVoid();
|
||||||
} else {
|
} else {
|
||||||
@@ -140,7 +110,8 @@ export const initialize = async (
|
|||||||
configInput.apiHost,
|
configInput.apiHost,
|
||||||
configInput.environmentId,
|
configInput.environmentId,
|
||||||
configInput.userId,
|
configInput.userId,
|
||||||
configInput.attributes
|
configInput.attributes,
|
||||||
|
appConfig
|
||||||
);
|
);
|
||||||
if (res.ok !== true) {
|
if (res.ok !== true) {
|
||||||
return err(res.error);
|
return err(res.error);
|
||||||
@@ -150,96 +121,50 @@ export const initialize = async (
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
existingConfig &&
|
existingConfig &&
|
||||||
existingConfig.environmentState &&
|
existingConfig.state &&
|
||||||
existingConfig.environmentId === configInput.environmentId &&
|
existingConfig.environmentId === configInput.environmentId &&
|
||||||
existingConfig.apiHost === configInput.apiHost &&
|
existingConfig.apiHost === configInput.apiHost &&
|
||||||
existingConfig.personState?.data?.userId === configInput.userId
|
existingConfig.userId === configInput.userId &&
|
||||||
|
existingConfig.expiresAt // only accept config when they follow new config version with expiresAt
|
||||||
) {
|
) {
|
||||||
logger.debug("Configuration fits init parameters.");
|
logger.debug("Configuration fits init parameters.");
|
||||||
let isEnvironmentStateExpired = false;
|
if (existingConfig.expiresAt < new Date()) {
|
||||||
let isPersonStateExpired = false;
|
logger.debug("Configuration expired.");
|
||||||
|
|
||||||
if (new Date(existingConfig.environmentState.expiresAt) < new Date()) {
|
try {
|
||||||
logger.debug("Environment state expired. Syncing.");
|
await sync(
|
||||||
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,
|
apiHost: configInput.apiHost,
|
||||||
environmentId: configInput.environmentId,
|
environmentId: configInput.environmentId,
|
||||||
userId: configInput.userId,
|
userId: configInput.userId,
|
||||||
})
|
},
|
||||||
: existingConfig.personState;
|
undefined,
|
||||||
|
appConfig
|
||||||
// filter the environment state wrt the person state
|
);
|
||||||
const filteredSurveys = filterSurveys(environmentState, personState);
|
} catch (e) {
|
||||||
|
putFormbricksInErrorState();
|
||||||
// update the appConfig with the new filtered surveys
|
}
|
||||||
appConfigGlobal.update({
|
} else {
|
||||||
...existingConfig,
|
logger.debug("Configuration not expired. Extending expiration.");
|
||||||
environmentState,
|
appConfig.update(existingConfig);
|
||||||
personState,
|
|
||||||
filteredSurveys,
|
|
||||||
});
|
|
||||||
|
|
||||||
const surveyNames = filteredSurveys.map((s) => s.name);
|
|
||||||
logger.debug("Fetched " + surveyNames.length + " surveys during sync: " + surveyNames.join(", "));
|
|
||||||
} catch (e) {
|
|
||||||
putFormbricksInErrorState(appConfig);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"No valid configuration found or it has been expired. Resetting config and creating new one."
|
"No valid configuration found or it has been expired. Resetting config and creating new one."
|
||||||
);
|
);
|
||||||
appConfigGlobal.resetConfig();
|
appConfig.resetConfig();
|
||||||
logger.debug("Syncing.");
|
logger.debug("Syncing.");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const environmentState = await fetchEnvironmentState(
|
await sync(
|
||||||
{
|
|
||||||
apiHost: configInput.apiHost,
|
|
||||||
environmentId: configInput.environmentId,
|
|
||||||
},
|
|
||||||
"app",
|
|
||||||
false
|
|
||||||
);
|
|
||||||
const personState = await fetchPersonState(
|
|
||||||
{
|
{
|
||||||
apiHost: configInput.apiHost,
|
apiHost: configInput.apiHost,
|
||||||
environmentId: configInput.environmentId,
|
environmentId: configInput.environmentId,
|
||||||
userId: configInput.userId,
|
userId: configInput.userId,
|
||||||
},
|
},
|
||||||
false
|
undefined,
|
||||||
|
appConfig
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredSurveys = filterSurveys(environmentState, personState);
|
|
||||||
|
|
||||||
appConfigGlobal.update({
|
|
||||||
apiHost: configInput.apiHost,
|
|
||||||
environmentId: configInput.environmentId,
|
|
||||||
personState,
|
|
||||||
environmentState,
|
|
||||||
filteredSurveys,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleErrorOnFirstInit();
|
handleErrorOnFirstInit();
|
||||||
}
|
}
|
||||||
@@ -247,26 +172,22 @@ export const initialize = async (
|
|||||||
// and track the new session event
|
// and track the new session event
|
||||||
await trackNoCodeAction("New Session");
|
await trackNoCodeAction("New Session");
|
||||||
}
|
}
|
||||||
|
|
||||||
// update attributes in config
|
// update attributes in config
|
||||||
if (updatedAttributes && Object.keys(updatedAttributes).length > 0) {
|
if (updatedAttributes && Object.keys(updatedAttributes).length > 0) {
|
||||||
appConfigGlobal.update({
|
appConfig.update({
|
||||||
...appConfigGlobal.get(),
|
environmentId: appConfig.get().environmentId,
|
||||||
personState: {
|
apiHost: appConfig.get().apiHost,
|
||||||
...appConfigGlobal.get().personState,
|
userId: appConfig.get().userId,
|
||||||
data: {
|
state: {
|
||||||
...appConfigGlobal.get().personState.data,
|
...appConfig.get().state,
|
||||||
attributes: {
|
attributes: { ...appConfig.get().state.attributes, ...configInput.attributes },
|
||||||
...appConfigGlobal.get().personState.data.attributes,
|
|
||||||
...updatedAttributes,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
expiresAt: appConfig.get().expiresAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug("Adding event listeners");
|
logger.debug("Adding event listeners");
|
||||||
addEventListeners(appConfigGlobal);
|
addEventListeners();
|
||||||
addCleanupEventListeners();
|
addCleanupEventListeners();
|
||||||
|
|
||||||
setIsInitialized(true);
|
setIsInitialized(true);
|
||||||
@@ -278,20 +199,17 @@ export const initialize = async (
|
|||||||
return okVoid();
|
return okVoid();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleErrorOnFirstInit = () => {
|
const handleErrorOnFirstInit = () => {
|
||||||
if (getIsDebug()) {
|
if (getIsDebug()) {
|
||||||
logger.debug("Not putting formbricks in error state because debug mode is active (no error state)");
|
logger.debug("Not putting formbricks in error state because debug mode is active (no error state)");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// put formbricks in error state (by creating a new config) and throw error
|
// put formbricks in error state (by creating a new config) and throw error
|
||||||
const initialErrorConfig: Partial<TJsConfig> = {
|
const initialErrorConfig: Partial<TJSAppConfig> = {
|
||||||
status: {
|
status: "error",
|
||||||
value: "error",
|
expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future
|
||||||
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
|
// can't use config.update here because the config is not yet initialized
|
||||||
wrapThrows(() => localStorage.setItem(APP_SURVEYS_LOCAL_STORAGE_KEY, JSON.stringify(initialErrorConfig)))();
|
wrapThrows(() => localStorage.setItem(APP_SURVEYS_LOCAL_STORAGE_KEY, JSON.stringify(initialErrorConfig)))();
|
||||||
throw new Error("Could not initialize formbricks");
|
throw new Error("Could not initialize formbricks");
|
||||||
@@ -317,7 +235,7 @@ export const deinitalize = (): void => {
|
|||||||
setIsInitialized(false);
|
setIsInitialized(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const putFormbricksInErrorState = (appConfig: AppConfig): void => {
|
export const putFormbricksInErrorState = (): void => {
|
||||||
if (getIsDebug()) {
|
if (getIsDebug()) {
|
||||||
logger.debug("Not putting formbricks in error state because debug mode is active (no error state)");
|
logger.debug("Not putting formbricks in error state because debug mode is active (no error state)");
|
||||||
return;
|
return;
|
||||||
@@ -326,11 +244,9 @@ export const putFormbricksInErrorState = (appConfig: AppConfig): void => {
|
|||||||
logger.debug("Putting formbricks in error state");
|
logger.debug("Putting formbricks in error state");
|
||||||
// change formbricks status to error
|
// change formbricks status to error
|
||||||
appConfig.update({
|
appConfig.update({
|
||||||
...appConfigGlobal.get(),
|
...appConfig.get(),
|
||||||
status: {
|
status: "error",
|
||||||
value: "error",
|
expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future
|
||||||
expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
deinitalize();
|
deinitalize();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { evaluateNoCodeConfigClick, handleUrlFilters } from "../../shared/utils"
|
|||||||
import { trackNoCodeAction } from "./actions";
|
import { trackNoCodeAction } from "./actions";
|
||||||
import { AppConfig } from "./config";
|
import { AppConfig } from "./config";
|
||||||
|
|
||||||
const appConfig = AppConfig.getInstance();
|
const inAppConfig = AppConfig.getInstance();
|
||||||
const logger = Logger.getInstance();
|
const logger = Logger.getInstance();
|
||||||
const errorHandler = ErrorHandler.getInstance();
|
const errorHandler = ErrorHandler.getInstance();
|
||||||
|
|
||||||
@@ -17,7 +17,8 @@ let arePageUrlEventListenersAdded = false;
|
|||||||
|
|
||||||
export const checkPageUrl = async (): Promise<Result<void, NetworkError>> => {
|
export const checkPageUrl = async (): Promise<Result<void, NetworkError>> => {
|
||||||
logger.debug(`Checking page url: ${window.location.href}`);
|
logger.debug(`Checking page url: ${window.location.href}`);
|
||||||
const actionClasses = appConfig.get().environmentState.data.actionClasses;
|
const { state } = inAppConfig.get();
|
||||||
|
const { actionClasses = [] } = state ?? {};
|
||||||
|
|
||||||
const noCodePageViewActionClasses = actionClasses.filter(
|
const noCodePageViewActionClasses = actionClasses.filter(
|
||||||
(action) => action.type === "noCode" && action.noCodeConfig?.type === "pageView"
|
(action) => action.type === "noCode" && action.noCodeConfig?.type === "pageView"
|
||||||
@@ -54,11 +55,10 @@ export const removePageUrlEventListeners = (): void => {
|
|||||||
let isClickEventListenerAdded = false;
|
let isClickEventListenerAdded = false;
|
||||||
|
|
||||||
const checkClickMatch = (event: MouseEvent) => {
|
const checkClickMatch = (event: MouseEvent) => {
|
||||||
const { environmentState } = appConfig.get();
|
const { state } = inAppConfig.get();
|
||||||
if (!environmentState) return;
|
if (!state) return;
|
||||||
|
|
||||||
const { actionClasses = [] } = environmentState.data;
|
|
||||||
|
|
||||||
|
const { actionClasses = [] } = state;
|
||||||
const noCodeClickActionClasses = actionClasses.filter(
|
const noCodeClickActionClasses = actionClasses.filter(
|
||||||
(action) => action.type === "noCode" && action.noCodeConfig?.type === "click"
|
(action) => action.type === "noCode" && action.noCodeConfig?.type === "click"
|
||||||
);
|
);
|
||||||
@@ -96,8 +96,8 @@ export const removeClickEventListener = (): void => {
|
|||||||
let isExitIntentListenerAdded = false;
|
let isExitIntentListenerAdded = false;
|
||||||
|
|
||||||
const checkExitIntent = async (e: MouseEvent) => {
|
const checkExitIntent = async (e: MouseEvent) => {
|
||||||
const { environmentState } = appConfig.get();
|
const { state } = inAppConfig.get();
|
||||||
const { actionClasses = [] } = environmentState.data ?? {};
|
const { actionClasses = [] } = state ?? {};
|
||||||
|
|
||||||
const noCodeExitIntentActionClasses = actionClasses.filter(
|
const noCodeExitIntentActionClasses = actionClasses.filter(
|
||||||
(action) => action.type === "noCode" && action.noCodeConfig?.type === "exitIntent"
|
(action) => action.type === "noCode" && action.noCodeConfig?.type === "exitIntent"
|
||||||
@@ -148,8 +148,8 @@ const checkScrollDepth = async () => {
|
|||||||
if (!scrollDepthTriggered && scrollPosition / (bodyHeight - windowSize) >= 0.5) {
|
if (!scrollDepthTriggered && scrollPosition / (bodyHeight - windowSize) >= 0.5) {
|
||||||
scrollDepthTriggered = true;
|
scrollDepthTriggered = true;
|
||||||
|
|
||||||
const { environmentState } = appConfig.get();
|
const { state } = inAppConfig.get();
|
||||||
const { actionClasses = [] } = environmentState.data ?? {};
|
const { actionClasses = [] } = state ?? {};
|
||||||
|
|
||||||
const noCodefiftyPercentScrollActionClasses = actionClasses.filter(
|
const noCodefiftyPercentScrollActionClasses = actionClasses.filter(
|
||||||
(action) => action.type === "noCode" && action.noCodeConfig?.type === "fiftyPercentScroll"
|
(action) => action.type === "noCode" && action.noCodeConfig?.type === "fiftyPercentScroll"
|
||||||
|
|||||||
@@ -15,23 +15,11 @@ export const logoutPerson = async (): Promise<void> => {
|
|||||||
export const resetPerson = async (): Promise<Result<void, NetworkError>> => {
|
export const resetPerson = async (): Promise<Result<void, NetworkError>> => {
|
||||||
logger.debug("Resetting state & getting new state from backend");
|
logger.debug("Resetting state & getting new state from backend");
|
||||||
closeSurvey();
|
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 = {
|
const syncParams = {
|
||||||
environmentId: appConfig.get().environmentId,
|
environmentId: appConfig.get().environmentId,
|
||||||
apiHost: appConfig.get().apiHost,
|
apiHost: appConfig.get().apiHost,
|
||||||
userId,
|
userId: appConfig.get().userId,
|
||||||
attributes: appConfig.get().personState.data.attributes,
|
attributes: appConfig.get().state.attributes,
|
||||||
};
|
};
|
||||||
await logoutPerson();
|
await logoutPerson();
|
||||||
try {
|
try {
|
||||||
|
|||||||
127
packages/js-core/src/app/lib/sync.ts
Normal file
127
packages/js-core/src/app/lib/sync.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
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<Result<TJsAppStateSync, NetworkError>> => {
|
||||||
|
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<void> => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -2,24 +2,27 @@ import { FormbricksAPI } from "@formbricks/api";
|
|||||||
import { ResponseQueue } from "@formbricks/lib/responseQueue";
|
import { ResponseQueue } from "@formbricks/lib/responseQueue";
|
||||||
import { SurveyState } from "@formbricks/lib/surveyState";
|
import { SurveyState } from "@formbricks/lib/surveyState";
|
||||||
import { getStyling } from "@formbricks/lib/utils/styling";
|
import { getStyling } from "@formbricks/lib/utils/styling";
|
||||||
import { TJsFileUploadParams, TJsPersonState, TJsTrackProperties } from "@formbricks/types/js";
|
import { TJsTrackProperties } from "@formbricks/types/js";
|
||||||
import { TResponseHiddenFieldValue, TResponseUpdate } from "@formbricks/types/responses";
|
import { TResponseHiddenFieldValue, TResponseUpdate } from "@formbricks/types/responses";
|
||||||
import { TUploadFileConfig } from "@formbricks/types/storage";
|
import { TUploadFileConfig } from "@formbricks/types/storage";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
|
import { ErrorHandler } from "../../shared/errors";
|
||||||
import { Logger } from "../../shared/logger";
|
import { Logger } from "../../shared/logger";
|
||||||
import {
|
import {
|
||||||
filterSurveys,
|
|
||||||
getDefaultLanguageCode,
|
getDefaultLanguageCode,
|
||||||
getLanguageCode,
|
getLanguageCode,
|
||||||
handleHiddenFields,
|
handleHiddenFields,
|
||||||
shouldDisplayBasedOnPercentage,
|
shouldDisplayBasedOnPercentage,
|
||||||
} from "../../shared/utils";
|
} from "../../shared/utils";
|
||||||
import { AppConfig } from "./config";
|
import { AppConfig } from "./config";
|
||||||
|
import { putFormbricksInErrorState } from "./initialize";
|
||||||
|
import { sync } from "./sync";
|
||||||
|
|
||||||
const containerId = "formbricks-app-container";
|
const containerId = "formbricks-app-container";
|
||||||
|
|
||||||
const appConfig = AppConfig.getInstance();
|
const appConfig = AppConfig.getInstance();
|
||||||
const logger = Logger.getInstance();
|
const logger = Logger.getInstance();
|
||||||
|
const errorHandler = ErrorHandler.getInstance();
|
||||||
let isSurveyRunning = false;
|
let isSurveyRunning = false;
|
||||||
let setIsError = (_: boolean) => {};
|
let setIsError = (_: boolean) => {};
|
||||||
let setIsResponseSendingFinished = (_: boolean) => {};
|
let setIsResponseSendingFinished = (_: boolean) => {};
|
||||||
@@ -65,8 +68,8 @@ const renderWidget = async (
|
|||||||
logger.debug(`Delaying survey "${survey.name}" by ${survey.delay} seconds.`);
|
logger.debug(`Delaying survey "${survey.name}" by ${survey.delay} seconds.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { product } = appConfig.get().environmentState.data ?? {};
|
const product = appConfig.get().state.product;
|
||||||
const { attributes } = appConfig.get().personState.data ?? {};
|
const attributes = appConfig.get().state.attributes;
|
||||||
|
|
||||||
const isMultiLanguageSurvey = survey.languages.length > 1;
|
const isMultiLanguageSurvey = survey.languages.length > 1;
|
||||||
let languageCode = "default";
|
let languageCode = "default";
|
||||||
@@ -82,7 +85,7 @@ const renderWidget = async (
|
|||||||
languageCode = displayLanguage;
|
languageCode = displayLanguage;
|
||||||
}
|
}
|
||||||
|
|
||||||
const surveyState = new SurveyState(survey.id, null, null, appConfig.get().personState.data.userId);
|
const surveyState = new SurveyState(survey.id, null, null, appConfig.get().userId);
|
||||||
|
|
||||||
const responseQueue = new ResponseQueue(
|
const responseQueue = new ResponseQueue(
|
||||||
{
|
{
|
||||||
@@ -121,12 +124,7 @@ const renderWidget = async (
|
|||||||
setIsResponseSendingFinished = f;
|
setIsResponseSendingFinished = f;
|
||||||
},
|
},
|
||||||
onDisplay: async () => {
|
onDisplay: async () => {
|
||||||
const { userId } = appConfig.get().personState.data;
|
const { userId } = appConfig.get();
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
logger.debug("User ID not found. Skipping.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const api = new FormbricksAPI({
|
const api = new FormbricksAPI({
|
||||||
apiHost: appConfig.get().apiHost,
|
apiHost: appConfig.get().apiHost,
|
||||||
@@ -146,39 +144,9 @@ const renderWidget = async (
|
|||||||
|
|
||||||
surveyState.updateDisplayId(id);
|
surveyState.updateDisplayId(id);
|
||||||
responseQueue.updateSurveyState(surveyState);
|
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) => {
|
onResponse: (responseUpdate: TResponseUpdate) => {
|
||||||
const { userId } = appConfig.get().personState.data;
|
const { userId } = appConfig.get();
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
logger.debug("User ID not found. Skipping.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isNewResponse = surveyState.responseId === null;
|
|
||||||
|
|
||||||
surveyState.updateUserId(userId);
|
surveyState.updateUserId(userId);
|
||||||
|
|
||||||
responseQueue.updateSurveyState(surveyState);
|
responseQueue.updateSurveyState(surveyState);
|
||||||
@@ -193,31 +161,13 @@ const renderWidget = async (
|
|||||||
action,
|
action,
|
||||||
},
|
},
|
||||||
hiddenFields,
|
hiddenFields,
|
||||||
displayId: surveyState.displayId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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,
|
onClose: closeSurvey,
|
||||||
onFileUpload: async (file: TJsFileUploadParams["file"], params: TUploadFileConfig) => {
|
onFileUpload: async (
|
||||||
|
file: { type: string; name: string; base64: string },
|
||||||
|
params: TUploadFileConfig
|
||||||
|
) => {
|
||||||
const api = new FormbricksAPI({
|
const api = new FormbricksAPI({
|
||||||
apiHost: appConfig.get().apiHost,
|
apiHost: appConfig.get().apiHost,
|
||||||
environmentId: appConfig.get().environmentId,
|
environmentId: appConfig.get().environmentId,
|
||||||
@@ -246,17 +196,23 @@ export const closeSurvey = async (): Promise<void> => {
|
|||||||
removeWidgetContainer();
|
removeWidgetContainer();
|
||||||
addWidgetContainer();
|
addWidgetContainer();
|
||||||
|
|
||||||
const { environmentState, personState } = appConfig.get();
|
// for identified users we sync to get the latest surveys
|
||||||
const filteredSurveys = filterSurveys(environmentState, personState);
|
try {
|
||||||
|
await sync(
|
||||||
appConfig.update({
|
{
|
||||||
...appConfig.get(),
|
apiHost: appConfig.get().apiHost,
|
||||||
environmentState,
|
environmentId: appConfig.get().environmentId,
|
||||||
personState,
|
userId: appConfig.get().userId,
|
||||||
filteredSurveys,
|
attributes: appConfig.get().state.attributes,
|
||||||
});
|
},
|
||||||
|
true,
|
||||||
setIsSurveyRunning(false);
|
appConfig
|
||||||
|
);
|
||||||
|
setIsSurveyRunning(false);
|
||||||
|
} catch (e: any) {
|
||||||
|
errorHandler.handle(e);
|
||||||
|
putFormbricksInErrorState();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const addWidgetContainer = (): void => {
|
export const addWidgetContainer = (): void => {
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
// 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<TJsEnvironmentState> => {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
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<TJsPersonState> => {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import { diffInDays } from "@formbricks/lib/utils/datetime";
|
|
||||||
import {
|
import {
|
||||||
TActionClass,
|
TActionClass,
|
||||||
TActionClassNoCodeConfig,
|
TActionClassNoCodeConfig,
|
||||||
TActionClassPageUrlRule,
|
TActionClassPageUrlRule,
|
||||||
} from "@formbricks/types/action-classes";
|
} from "@formbricks/types/action-classes";
|
||||||
import { TAttributes } from "@formbricks/types/attributes";
|
import { TAttributes } from "@formbricks/types/attributes";
|
||||||
import { TJsEnvironmentState, TJsPersonState, TJsTrackProperties } from "@formbricks/types/js";
|
import { TJsTrackProperties } from "@formbricks/types/js";
|
||||||
import { TResponseHiddenFieldValue } from "@formbricks/types/responses";
|
import { TResponseHiddenFieldValue } from "@formbricks/types/responses";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { Logger } from "./logger";
|
import { Logger } from "./logger";
|
||||||
@@ -108,6 +107,7 @@ export const handleHiddenFields = (
|
|||||||
|
|
||||||
return hiddenFieldsObject;
|
return hiddenFieldsObject;
|
||||||
};
|
};
|
||||||
|
export const getIsDebug = () => window.location.search.includes("formbricksDebug=true");
|
||||||
|
|
||||||
export const shouldDisplayBasedOnPercentage = (displayPercentage: number) => {
|
export const shouldDisplayBasedOnPercentage = (displayPercentage: number) => {
|
||||||
const randomNum = Math.floor(Math.random() * 10000) / 100;
|
const randomNum = Math.floor(Math.random() * 10000) / 100;
|
||||||
@@ -145,91 +145,3 @@ export const getDefaultLanguageCode = (survey: TSurvey) => {
|
|||||||
});
|
});
|
||||||
if (defaultSurveyLanguage) return defaultSurveyLanguage.language.code;
|
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);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const trackAction = async (
|
|||||||
logger.debug(`Formbricks: Action "${aliasName}" tracked`);
|
logger.debug(`Formbricks: Action "${aliasName}" tracked`);
|
||||||
|
|
||||||
// get a list of surveys that are collecting insights
|
// get a list of surveys that are collecting insights
|
||||||
const activeSurveys = websiteConfig.get().filteredSurveys;
|
const activeSurveys = websiteConfig.get().state?.surveys;
|
||||||
|
|
||||||
if (!!activeSurveys && activeSurveys.length > 0) {
|
if (!!activeSurveys && activeSurveys.length > 0) {
|
||||||
for (const survey of activeSurveys) {
|
for (const survey of activeSurveys) {
|
||||||
@@ -37,7 +37,9 @@ export const trackCodeAction = (
|
|||||||
code: string,
|
code: string,
|
||||||
properties?: TJsTrackProperties
|
properties?: TJsTrackProperties
|
||||||
): Promise<Result<void, NetworkError>> | Result<void, InvalidCodeError> => {
|
): Promise<Result<void, NetworkError>> | Result<void, InvalidCodeError> => {
|
||||||
const actionClasses = websiteConfig.get().environmentState.data.actionClasses;
|
const {
|
||||||
|
state: { actionClasses = [] },
|
||||||
|
} = websiteConfig.get();
|
||||||
|
|
||||||
const codeActionClasses = actionClasses.filter((action) => action.type === "code");
|
const codeActionClasses = actionClasses.filter((action) => action.type === "code");
|
||||||
const action = codeActionClasses.find((action) => action.key === code);
|
const action = codeActionClasses.find((action) => action.key === code);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { TJsConfig, TJsConfigUpdateInput } from "@formbricks/types/js";
|
import { TJsWebsiteConfig, TJsWebsiteConfigUpdateInput } from "@formbricks/types/js";
|
||||||
import { WEBSITE_SURVEYS_LOCAL_STORAGE_KEY } from "../../shared/constants";
|
import { WEBSITE_SURVEYS_LOCAL_STORAGE_KEY } from "../../shared/constants";
|
||||||
import { Result, err, ok, wrapThrows } from "../../shared/errors";
|
import { Result, err, ok, wrapThrows } from "../../shared/errors";
|
||||||
|
|
||||||
export class WebsiteConfig {
|
export class WebsiteConfig {
|
||||||
private static instance: WebsiteConfig | undefined;
|
private static instance: WebsiteConfig | undefined;
|
||||||
private config: TJsConfig | null = null;
|
private config: TJsWebsiteConfig | null = null;
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
const localConfig = this.loadFromLocalStorage();
|
const localConfig = this.loadFromLocalStorage();
|
||||||
@@ -21,49 +21,37 @@ export class WebsiteConfig {
|
|||||||
return WebsiteConfig.instance;
|
return WebsiteConfig.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
public update(newConfig: TJsConfigUpdateInput): void {
|
public update(newConfig: TJsWebsiteConfigUpdateInput): void {
|
||||||
if (newConfig) {
|
if (newConfig) {
|
||||||
this.config = {
|
this.config = {
|
||||||
...this.config,
|
...this.config,
|
||||||
...newConfig,
|
...newConfig,
|
||||||
status: {
|
status: newConfig.status || "success",
|
||||||
value: newConfig.status?.value || "success",
|
|
||||||
expiresAt: newConfig.status?.expiresAt || null,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
this.saveToLocalStorage();
|
this.saveToLocalStorage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public get(): TJsConfig {
|
public get(): TJsWebsiteConfig {
|
||||||
if (!this.config) {
|
if (!this.config) {
|
||||||
throw new Error("config is null, maybe the init function was not called?");
|
throw new Error("config is null, maybe the init function was not called?");
|
||||||
}
|
}
|
||||||
return this.config;
|
return this.config;
|
||||||
}
|
}
|
||||||
|
|
||||||
public loadFromLocalStorage(): Result<TJsConfig, Error> {
|
public loadFromLocalStorage(): Result<TJsWebsiteConfig, Error> {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
const savedConfig = localStorage.getItem(WEBSITE_SURVEYS_LOCAL_STORAGE_KEY);
|
const savedConfig = localStorage.getItem(WEBSITE_SURVEYS_LOCAL_STORAGE_KEY);
|
||||||
if (savedConfig) {
|
if (savedConfig) {
|
||||||
// TODO: validate config
|
const parsedConfig = JSON.parse(savedConfig) as TJsWebsiteConfig;
|
||||||
// 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
|
// 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 err(new Error("Config in local storage has expired"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return ok(parsedConfig);
|
return ok(JSON.parse(savedConfig) as TJsWebsiteConfig);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
import {
|
|
||||||
addEnvironmentStateExpiryCheckListener,
|
|
||||||
clearEnvironmentStateExpiryCheckListener,
|
|
||||||
} from "../../shared/environmentState";
|
|
||||||
import {
|
import {
|
||||||
addClickEventListener,
|
addClickEventListener,
|
||||||
addExitIntentListener,
|
addExitIntentListener,
|
||||||
@@ -12,13 +8,12 @@ import {
|
|||||||
removePageUrlEventListeners,
|
removePageUrlEventListeners,
|
||||||
removeScrollDepthListener,
|
removeScrollDepthListener,
|
||||||
} from "../lib/noCodeActions";
|
} from "../lib/noCodeActions";
|
||||||
import { WebsiteConfig } from "./config";
|
import { addExpiryCheckListener, removeExpiryCheckListener } from "./sync";
|
||||||
|
|
||||||
let areRemoveEventListenersAdded = false;
|
let areRemoveEventListenersAdded = false;
|
||||||
|
|
||||||
export const addEventListeners = (config: WebsiteConfig): void => {
|
export const addEventListeners = (): void => {
|
||||||
addEnvironmentStateExpiryCheckListener("website", config);
|
addExpiryCheckListener();
|
||||||
clearEnvironmentStateExpiryCheckListener();
|
|
||||||
addPageUrlEventListeners();
|
addPageUrlEventListeners();
|
||||||
addClickEventListener();
|
addClickEventListener();
|
||||||
addExitIntentListener();
|
addExitIntentListener();
|
||||||
@@ -28,7 +23,7 @@ export const addEventListeners = (config: WebsiteConfig): void => {
|
|||||||
export const addCleanupEventListeners = (): void => {
|
export const addCleanupEventListeners = (): void => {
|
||||||
if (areRemoveEventListenersAdded) return;
|
if (areRemoveEventListenersAdded) return;
|
||||||
window.addEventListener("beforeunload", () => {
|
window.addEventListener("beforeunload", () => {
|
||||||
clearEnvironmentStateExpiryCheckListener();
|
removeExpiryCheckListener();
|
||||||
removePageUrlEventListeners();
|
removePageUrlEventListeners();
|
||||||
removeClickEventListener();
|
removeClickEventListener();
|
||||||
removeExitIntentListener();
|
removeExitIntentListener();
|
||||||
@@ -40,7 +35,7 @@ export const addCleanupEventListeners = (): void => {
|
|||||||
export const removeCleanupEventListeners = (): void => {
|
export const removeCleanupEventListeners = (): void => {
|
||||||
if (!areRemoveEventListenersAdded) return;
|
if (!areRemoveEventListenersAdded) return;
|
||||||
window.removeEventListener("beforeunload", () => {
|
window.removeEventListener("beforeunload", () => {
|
||||||
clearEnvironmentStateExpiryCheckListener();
|
removeExpiryCheckListener();
|
||||||
removePageUrlEventListeners();
|
removePageUrlEventListeners();
|
||||||
removeClickEventListener();
|
removeClickEventListener();
|
||||||
removeExitIntentListener();
|
removeExitIntentListener();
|
||||||
@@ -50,7 +45,7 @@ export const removeCleanupEventListeners = (): void => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const removeAllEventListeners = (): void => {
|
export const removeAllEventListeners = (): void => {
|
||||||
clearEnvironmentStateExpiryCheckListener();
|
removeExpiryCheckListener();
|
||||||
removePageUrlEventListeners();
|
removePageUrlEventListeners();
|
||||||
removeClickEventListener();
|
removeClickEventListener();
|
||||||
removeExitIntentListener();
|
removeExitIntentListener();
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { TJsConfig, TJsWebsiteConfigInput, TJsWebsiteState } from "@formbricks/types/js";
|
import type { TJSAppConfig, TJsWebsiteConfig, TJsWebsiteConfigInput } from "@formbricks/types/js";
|
||||||
import { WEBSITE_SURVEYS_LOCAL_STORAGE_KEY } from "../../shared/constants";
|
import { WEBSITE_SURVEYS_LOCAL_STORAGE_KEY } from "../../shared/constants";
|
||||||
import { fetchEnvironmentState } from "../../shared/environmentState";
|
|
||||||
import {
|
import {
|
||||||
ErrorHandler,
|
ErrorHandler,
|
||||||
MissingFieldError,
|
MissingFieldError,
|
||||||
@@ -13,15 +12,15 @@ import {
|
|||||||
wrapThrows,
|
wrapThrows,
|
||||||
} from "../../shared/errors";
|
} from "../../shared/errors";
|
||||||
import { Logger } from "../../shared/logger";
|
import { Logger } from "../../shared/logger";
|
||||||
import { DEFAULT_PERSON_STATE_WEBSITE } from "../../shared/personState";
|
|
||||||
import { getIsDebug } from "../../shared/utils";
|
import { getIsDebug } from "../../shared/utils";
|
||||||
import { filterSurveys as filterPublicSurveys } from "../../shared/utils";
|
|
||||||
import { trackNoCodeAction } from "./actions";
|
import { trackNoCodeAction } from "./actions";
|
||||||
import { WebsiteConfig } from "./config";
|
import { WebsiteConfig } from "./config";
|
||||||
import { addCleanupEventListeners, addEventListeners, removeAllEventListeners } from "./eventListeners";
|
import { addCleanupEventListeners, addEventListeners, removeAllEventListeners } from "./eventListeners";
|
||||||
import { checkPageUrl } from "./noCodeActions";
|
import { checkPageUrl } from "./noCodeActions";
|
||||||
|
import { sync } from "./sync";
|
||||||
import { addWidgetContainer, removeWidgetContainer, setIsSurveyRunning } from "./widget";
|
import { addWidgetContainer, removeWidgetContainer, setIsSurveyRunning } from "./widget";
|
||||||
|
|
||||||
|
const websiteConfig = WebsiteConfig.getInstance();
|
||||||
const logger = Logger.getInstance();
|
const logger = Logger.getInstance();
|
||||||
|
|
||||||
let isInitialized = false;
|
let isInitialized = false;
|
||||||
@@ -30,76 +29,6 @@ export const setIsInitialized = (value: boolean) => {
|
|||||||
isInitialized = value;
|
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 (
|
export const initialize = async (
|
||||||
configInput: TJsWebsiteConfigInput
|
configInput: TJsWebsiteConfigInput
|
||||||
): Promise<Result<void, MissingFieldError | NetworkError | MissingPersonError>> => {
|
): Promise<Result<void, MissingFieldError | NetworkError | MissingPersonError>> => {
|
||||||
@@ -108,27 +37,12 @@ export const initialize = async (
|
|||||||
logger.configure({ logLevel: "debug" });
|
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) {
|
if (isInitialized) {
|
||||||
logger.debug("Already initialized, skipping initialization.");
|
logger.debug("Already initialized, skipping initialization.");
|
||||||
return okVoid();
|
return okVoid();
|
||||||
}
|
}
|
||||||
|
|
||||||
let existingConfig: TJsConfig | undefined;
|
let existingConfig: TJsWebsiteConfig | undefined;
|
||||||
try {
|
try {
|
||||||
existingConfig = websiteConfig.get();
|
existingConfig = websiteConfig.get();
|
||||||
logger.debug("Found existing configuration.");
|
logger.debug("Found existing configuration.");
|
||||||
@@ -137,7 +51,7 @@ export const initialize = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// formbricks is in error state, skip initialization
|
// formbricks is in error state, skip initialization
|
||||||
if (existingConfig?.status?.value === "error") {
|
if (existingConfig?.status === "error") {
|
||||||
if (isDebug) {
|
if (isDebug) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Formbricks is in error state, but debug mode is active. Resetting config and continuing."
|
"Formbricks is in error state, but debug mode is active. Resetting config and continuing."
|
||||||
@@ -147,8 +61,7 @@ export const initialize = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.debug("Formbricks was set to an error state.");
|
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");
|
logger.debug("Error state is not expired, skipping initialization");
|
||||||
return okVoid();
|
return okVoid();
|
||||||
} else {
|
} else {
|
||||||
@@ -182,42 +95,22 @@ export const initialize = async (
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
existingConfig &&
|
existingConfig &&
|
||||||
|
existingConfig.state &&
|
||||||
existingConfig.environmentId === configInput.environmentId &&
|
existingConfig.environmentId === configInput.environmentId &&
|
||||||
existingConfig.apiHost === configInput.apiHost &&
|
existingConfig.apiHost === configInput.apiHost &&
|
||||||
existingConfig.environmentState
|
existingConfig.expiresAt
|
||||||
) {
|
) {
|
||||||
logger.debug("Configuration fits init parameters.");
|
logger.debug("Configuration fits init parameters.");
|
||||||
if (existingConfig.environmentState.expiresAt < new Date()) {
|
if (existingConfig.expiresAt < new Date()) {
|
||||||
logger.debug("Configuration expired.");
|
logger.debug("Configuration expired.");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// fetch the environment state
|
await sync({
|
||||||
|
|
||||||
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,
|
apiHost: configInput.apiHost,
|
||||||
environmentId: configInput.environmentId,
|
environmentId: configInput.environmentId,
|
||||||
environmentState,
|
|
||||||
personState: DEFAULT_PERSON_STATE_WEBSITE,
|
|
||||||
filteredSurveys,
|
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
putFormbricksInErrorState(websiteConfig);
|
putFormbricksInErrorState();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.debug("Configuration not expired. Extending expiration.");
|
logger.debug("Configuration not expired. Extending expiration.");
|
||||||
@@ -231,22 +124,9 @@ export const initialize = async (
|
|||||||
logger.debug("Syncing.");
|
logger.debug("Syncing.");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const environmentState = await fetchEnvironmentState(
|
await sync({
|
||||||
{
|
|
||||||
apiHost: configInput.apiHost,
|
|
||||||
environmentId: configInput.environmentId,
|
|
||||||
},
|
|
||||||
"website"
|
|
||||||
);
|
|
||||||
|
|
||||||
const filteredSurveys = filterPublicSurveys(environmentState, DEFAULT_PERSON_STATE_WEBSITE, "website");
|
|
||||||
|
|
||||||
websiteConfig.update({
|
|
||||||
apiHost: configInput.apiHost,
|
apiHost: configInput.apiHost,
|
||||||
environmentId: configInput.environmentId,
|
environmentId: configInput.environmentId,
|
||||||
environmentState,
|
|
||||||
personState: DEFAULT_PERSON_STATE_WEBSITE,
|
|
||||||
filteredSurveys,
|
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleErrorOnFirstInit();
|
handleErrorOnFirstInit();
|
||||||
@@ -256,14 +136,13 @@ export const initialize = async (
|
|||||||
const currentWebsiteConfig = websiteConfig.get();
|
const currentWebsiteConfig = websiteConfig.get();
|
||||||
|
|
||||||
websiteConfig.update({
|
websiteConfig.update({
|
||||||
...currentWebsiteConfig,
|
environmentId: currentWebsiteConfig.environmentId,
|
||||||
personState: {
|
apiHost: currentWebsiteConfig.apiHost,
|
||||||
...currentWebsiteConfig.personState,
|
state: {
|
||||||
data: {
|
...websiteConfig.get().state,
|
||||||
...currentWebsiteConfig.personState.data,
|
attributes: { ...websiteConfig.get().state.attributes, ...configInput.attributes },
|
||||||
attributes: { ...currentWebsiteConfig.personState.data.attributes, ...configInput.attributes },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
expiresAt: websiteConfig.get().expiresAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,7 +151,7 @@ export const initialize = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.debug("Adding event listeners");
|
logger.debug("Adding event listeners");
|
||||||
addEventListeners(websiteConfig);
|
addEventListeners();
|
||||||
addCleanupEventListeners();
|
addCleanupEventListeners();
|
||||||
|
|
||||||
setIsInitialized(true);
|
setIsInitialized(true);
|
||||||
@@ -284,19 +163,17 @@ export const initialize = async (
|
|||||||
return okVoid();
|
return okVoid();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleErrorOnFirstInit = () => {
|
const handleErrorOnFirstInit = () => {
|
||||||
if (getIsDebug()) {
|
if (getIsDebug()) {
|
||||||
logger.debug("Not putting formbricks in error state because debug mode is active (no error state)");
|
logger.debug("Not putting formbricks in error state because debug mode is active (no error state)");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialErrorConfig: Partial<TJsConfig> = {
|
// put formbricks in error state (by creating a new config) and throw error
|
||||||
status: {
|
const initialErrorConfig: Partial<TJSAppConfig> = {
|
||||||
value: "error",
|
status: "error",
|
||||||
expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future
|
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
|
// can't use config.update here because the config is not yet initialized
|
||||||
wrapThrows(() =>
|
wrapThrows(() =>
|
||||||
localStorage.setItem(WEBSITE_SURVEYS_LOCAL_STORAGE_KEY, JSON.stringify(initialErrorConfig))
|
localStorage.setItem(WEBSITE_SURVEYS_LOCAL_STORAGE_KEY, JSON.stringify(initialErrorConfig))
|
||||||
@@ -324,7 +201,7 @@ export const deinitalize = (): void => {
|
|||||||
setIsInitialized(false);
|
setIsInitialized(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const putFormbricksInErrorState = (websiteConfig: WebsiteConfig): void => {
|
export const putFormbricksInErrorState = (): void => {
|
||||||
if (getIsDebug()) {
|
if (getIsDebug()) {
|
||||||
logger.debug("Not putting formbricks in error state because debug mode is active (no error state)");
|
logger.debug("Not putting formbricks in error state because debug mode is active (no error state)");
|
||||||
return;
|
return;
|
||||||
@@ -334,10 +211,8 @@ export const putFormbricksInErrorState = (websiteConfig: WebsiteConfig): void =>
|
|||||||
// change formbricks status to error
|
// change formbricks status to error
|
||||||
websiteConfig.update({
|
websiteConfig.update({
|
||||||
...websiteConfig.get(),
|
...websiteConfig.get(),
|
||||||
status: {
|
status: "error",
|
||||||
value: "error",
|
expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future
|
||||||
expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
deinitalize();
|
deinitalize();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ let arePageUrlEventListenersAdded = false;
|
|||||||
|
|
||||||
export const checkPageUrl = async (): Promise<Result<void, NetworkError>> => {
|
export const checkPageUrl = async (): Promise<Result<void, NetworkError>> => {
|
||||||
logger.debug(`Checking page url: ${window.location.href}`);
|
logger.debug(`Checking page url: ${window.location.href}`);
|
||||||
const actionClasses = websiteConfig.get().environmentState.data.actionClasses;
|
const { state } = websiteConfig.get();
|
||||||
|
const { actionClasses = [] } = state ?? {};
|
||||||
|
|
||||||
const noCodePageViewActionClasses = actionClasses.filter(
|
const noCodePageViewActionClasses = actionClasses.filter(
|
||||||
(action) => action.type === "noCode" && action.noCodeConfig?.type === "pageView"
|
(action) => action.type === "noCode" && action.noCodeConfig?.type === "pageView"
|
||||||
@@ -54,10 +55,10 @@ export const removePageUrlEventListeners = (): void => {
|
|||||||
let isClickEventListenerAdded = false;
|
let isClickEventListenerAdded = false;
|
||||||
|
|
||||||
const checkClickMatch = (event: MouseEvent) => {
|
const checkClickMatch = (event: MouseEvent) => {
|
||||||
const { environmentState } = websiteConfig.get();
|
const { state } = websiteConfig.get();
|
||||||
if (!environmentState) return;
|
if (!state) return;
|
||||||
|
|
||||||
const { actionClasses = [] } = environmentState.data;
|
const { actionClasses = [] } = state;
|
||||||
const noCodeClickActionClasses = actionClasses.filter(
|
const noCodeClickActionClasses = actionClasses.filter(
|
||||||
(action) => action.type === "noCode" && action.noCodeConfig?.type === "click"
|
(action) => action.type === "noCode" && action.noCodeConfig?.type === "click"
|
||||||
);
|
);
|
||||||
@@ -95,7 +96,8 @@ export const removeClickEventListener = (): void => {
|
|||||||
let isExitIntentListenerAdded = false;
|
let isExitIntentListenerAdded = false;
|
||||||
|
|
||||||
const checkExitIntent = async (e: MouseEvent) => {
|
const checkExitIntent = async (e: MouseEvent) => {
|
||||||
const actionClasses = websiteConfig.get().environmentState.data.actionClasses;
|
const { state } = websiteConfig.get();
|
||||||
|
const { actionClasses = [] } = state ?? {};
|
||||||
|
|
||||||
const noCodeExitIntentActionClasses = actionClasses.filter(
|
const noCodeExitIntentActionClasses = actionClasses.filter(
|
||||||
(action) => action.type === "noCode" && action.noCodeConfig?.type === "exitIntent"
|
(action) => action.type === "noCode" && action.noCodeConfig?.type === "exitIntent"
|
||||||
@@ -146,7 +148,8 @@ const checkScrollDepth = async () => {
|
|||||||
if (!scrollDepthTriggered && scrollPosition / (bodyHeight - windowSize) >= 0.5) {
|
if (!scrollDepthTriggered && scrollPosition / (bodyHeight - windowSize) >= 0.5) {
|
||||||
scrollDepthTriggered = true;
|
scrollDepthTriggered = true;
|
||||||
|
|
||||||
const actionClasses = websiteConfig.get().environmentState.data.actionClasses;
|
const { state } = websiteConfig.get();
|
||||||
|
const { actionClasses = [] } = state ?? {};
|
||||||
|
|
||||||
const noCodefiftyPercentScrollActionClasses = actionClasses.filter(
|
const noCodefiftyPercentScrollActionClasses = actionClasses.filter(
|
||||||
(action) => action.type === "noCode" && action.noCodeConfig?.type === "fiftyPercentScroll"
|
(action) => action.type === "noCode" && action.noCodeConfig?.type === "fiftyPercentScroll"
|
||||||
|
|||||||
189
packages/js-core/src/website/lib/sync.ts
Normal file
189
packages/js-core/src/website/lib/sync.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
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<Result<TJsWebsiteState, NetworkError>> => {
|
||||||
|
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<void> => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -2,14 +2,14 @@ import { FormbricksAPI } from "@formbricks/api";
|
|||||||
import { ResponseQueue } from "@formbricks/lib/responseQueue";
|
import { ResponseQueue } from "@formbricks/lib/responseQueue";
|
||||||
import { SurveyState } from "@formbricks/lib/surveyState";
|
import { SurveyState } from "@formbricks/lib/surveyState";
|
||||||
import { getStyling } from "@formbricks/lib/utils/styling";
|
import { getStyling } from "@formbricks/lib/utils/styling";
|
||||||
import { TJsPersonState, TJsTrackProperties } from "@formbricks/types/js";
|
import { TJSWebsiteStateDisplay, TJsTrackProperties } from "@formbricks/types/js";
|
||||||
import { TResponseHiddenFieldValue, TResponseUpdate } from "@formbricks/types/responses";
|
import { TResponseHiddenFieldValue, TResponseUpdate } from "@formbricks/types/responses";
|
||||||
import { TUploadFileConfig } from "@formbricks/types/storage";
|
import { TUploadFileConfig } from "@formbricks/types/storage";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { Logger } from "../../shared/logger";
|
import { Logger } from "../../shared/logger";
|
||||||
import { filterSurveys as filterPublicSurveys } from "../../shared/utils";
|
|
||||||
import { getDefaultLanguageCode, getLanguageCode, handleHiddenFields } from "../../shared/utils";
|
import { getDefaultLanguageCode, getLanguageCode, handleHiddenFields } from "../../shared/utils";
|
||||||
import { WebsiteConfig } from "./config";
|
import { WebsiteConfig } from "./config";
|
||||||
|
import { filterPublicSurveys } from "./sync";
|
||||||
|
|
||||||
const containerId = "formbricks-website-container";
|
const containerId = "formbricks-website-container";
|
||||||
|
|
||||||
@@ -66,8 +66,8 @@ const renderWidget = async (
|
|||||||
logger.debug(`Delaying survey by ${survey.delay} seconds.`);
|
logger.debug(`Delaying survey by ${survey.delay} seconds.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const product = websiteConfig.get().environmentState.data.product;
|
const product = websiteConfig.get().state.product;
|
||||||
const attributes = websiteConfig.get().personState.data.attributes;
|
const attributes = websiteConfig.get().state.attributes;
|
||||||
|
|
||||||
const isMultiLanguageSurvey = survey.languages.length > 1;
|
const isMultiLanguageSurvey = survey.languages.length > 1;
|
||||||
let languageCode = "default";
|
let languageCode = "default";
|
||||||
@@ -122,6 +122,26 @@ const renderWidget = async (
|
|||||||
setIsResponseSendingFinished = f;
|
setIsResponseSendingFinished = f;
|
||||||
},
|
},
|
||||||
onDisplay: async () => {
|
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({
|
const api = new FormbricksAPI({
|
||||||
apiHost: websiteConfig.get().apiHost,
|
apiHost: websiteConfig.get().apiHost,
|
||||||
environmentId: websiteConfig.get().environmentId,
|
environmentId: websiteConfig.get().environmentId,
|
||||||
@@ -136,44 +156,27 @@ const renderWidget = async (
|
|||||||
|
|
||||||
const { id } = res.data;
|
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);
|
surveyState.updateDisplayId(id);
|
||||||
responseQueue.updateSurveyState(surveyState);
|
responseQueue.updateSurveyState(surveyState);
|
||||||
},
|
},
|
||||||
onResponse: (responseUpdate: TResponseUpdate) => {
|
onResponse: (responseUpdate: TResponseUpdate) => {
|
||||||
const displays = websiteConfig.get().personState.data.displays;
|
const displays = websiteConfig.get().state.displays;
|
||||||
const lastDisplay = displays && displays[displays.length - 1];
|
const lastDisplay = displays && displays[displays.length - 1];
|
||||||
if (!lastDisplay) {
|
if (!lastDisplay) {
|
||||||
throw new Error("No lastDisplay found");
|
throw new Error("No lastDisplay found");
|
||||||
}
|
}
|
||||||
|
if (!lastDisplay.responded) {
|
||||||
const isNewResponse = surveyState.responseId === null;
|
lastDisplay.responded = true;
|
||||||
|
const previousConfig = websiteConfig.get();
|
||||||
|
let state = filterPublicSurveys({
|
||||||
|
...previousConfig.state,
|
||||||
|
displays,
|
||||||
|
});
|
||||||
|
websiteConfig.update({
|
||||||
|
...previousConfig,
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
responseQueue.updateSurveyState(surveyState);
|
responseQueue.updateSurveyState(surveyState);
|
||||||
|
|
||||||
@@ -188,32 +191,7 @@ const renderWidget = async (
|
|||||||
action,
|
action,
|
||||||
},
|
},
|
||||||
hiddenFields,
|
hiddenFields,
|
||||||
displayId: surveyState.displayId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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,
|
onClose: closeSurvey,
|
||||||
onFileUpload: async (
|
onFileUpload: async (
|
||||||
@@ -248,13 +226,11 @@ export const closeSurvey = async (): Promise<void> => {
|
|||||||
removeWidgetContainer();
|
removeWidgetContainer();
|
||||||
addWidgetContainer();
|
addWidgetContainer();
|
||||||
|
|
||||||
const { environmentState, personState } = websiteConfig.get();
|
const state = websiteConfig.get().state;
|
||||||
const filteredSurveys = filterPublicSurveys(environmentState, personState, "website");
|
const updatedState = filterPublicSurveys(state);
|
||||||
websiteConfig.update({
|
websiteConfig.update({
|
||||||
...websiteConfig.get(),
|
...websiteConfig.get(),
|
||||||
environmentState,
|
state: updatedState,
|
||||||
personState,
|
|
||||||
filteredSurveys,
|
|
||||||
});
|
});
|
||||||
setIsSurveyRunning(false);
|
setIsSurveyRunning(false);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
e.parentNode.insertBefore(t, e),
|
e.parentNode.insertBefore(t, e),
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
formbricks.init({
|
formbricks.init({
|
||||||
environmentId: "cm14wcs5m0005b3aezc4a6ejf",
|
environmentId: "cm020vmv0000cpq4xvxabpo8x",
|
||||||
userId: "RANDOM_USER_ID",
|
userId: "RANDOM_USER_ID",
|
||||||
apiHost: "http://localhost:3000",
|
apiHost: "http://localhost:3000",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ interface RevalidateProps {
|
|||||||
id?: string;
|
id?: string;
|
||||||
surveyId?: string;
|
surveyId?: string;
|
||||||
personId?: string | null;
|
personId?: string | null;
|
||||||
userId?: string;
|
|
||||||
environmentId?: string;
|
environmentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,18 +18,11 @@ export const displayCache = {
|
|||||||
byPersonId(personId: string) {
|
byPersonId(personId: string) {
|
||||||
return `people-${personId}-displays`;
|
return `people-${personId}-displays`;
|
||||||
},
|
},
|
||||||
byEnvironmentIdAndUserId(environmentId: string, userId: string) {
|
|
||||||
return `environments-${environmentId}-users-${userId}-displays`;
|
|
||||||
},
|
|
||||||
byEnvironmentId(environmentId: string) {
|
byEnvironmentId(environmentId: string) {
|
||||||
return `environments-${environmentId}-displays`;
|
return `environments-${environmentId}-displays`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
revalidate({ id, surveyId, personId, environmentId, userId }: RevalidateProps): void {
|
revalidate({ id, surveyId, personId, environmentId }: RevalidateProps): void {
|
||||||
if (environmentId && userId) {
|
|
||||||
revalidateTag(this.tag.byEnvironmentIdAndUserId(environmentId, userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
revalidateTag(this.tag.byId(id));
|
revalidateTag(this.tag.byId(id));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,18 @@ import "server-only";
|
|||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { cache as reactCache } from "react";
|
import { cache as reactCache } from "react";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { ZOptionalNumber, ZString } from "@formbricks/types/common";
|
import { ZOptionalNumber } from "@formbricks/types/common";
|
||||||
import { ZId } from "@formbricks/types/common";
|
import { ZId } from "@formbricks/types/common";
|
||||||
import {
|
import {
|
||||||
TDisplay,
|
TDisplay,
|
||||||
TDisplayCreateInput,
|
TDisplayCreateInput,
|
||||||
TDisplayFilters,
|
TDisplayFilters,
|
||||||
|
TDisplayUpdateInput,
|
||||||
ZDisplayCreateInput,
|
ZDisplayCreateInput,
|
||||||
|
ZDisplayUpdateInput,
|
||||||
} from "@formbricks/types/displays";
|
} from "@formbricks/types/displays";
|
||||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
|
import { TPerson } from "@formbricks/types/people";
|
||||||
import { cache } from "../cache";
|
import { cache } from "../cache";
|
||||||
import { ITEMS_PER_PAGE } from "../constants";
|
import { ITEMS_PER_PAGE } from "../constants";
|
||||||
import { createPerson, getPersonByUserId } from "../person/service";
|
import { createPerson, getPersonByUserId } from "../person/service";
|
||||||
@@ -22,6 +25,7 @@ export const selectDisplay = {
|
|||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
surveyId: true,
|
surveyId: true,
|
||||||
|
responseId: true,
|
||||||
personId: true,
|
personId: true,
|
||||||
status: true,
|
status: true,
|
||||||
};
|
};
|
||||||
@@ -56,6 +60,57 @@ export const getDisplay = reactCache(
|
|||||||
)()
|
)()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const updateDisplay = async (
|
||||||
|
displayId: string,
|
||||||
|
displayInput: TDisplayUpdateInput
|
||||||
|
): Promise<TDisplay> => {
|
||||||
|
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<TDisplay> => {
|
export const createDisplay = async (displayInput: TDisplayCreateInput): Promise<TDisplay> => {
|
||||||
validateInputs([displayInput, ZDisplayCreateInput]);
|
validateInputs([displayInput, ZDisplayCreateInput]);
|
||||||
|
|
||||||
@@ -90,8 +145,6 @@ export const createDisplay = async (displayInput: TDisplayCreateInput): Promise<
|
|||||||
id: display.id,
|
id: display.id,
|
||||||
personId: display.personId,
|
personId: display.personId,
|
||||||
surveyId: display.surveyId,
|
surveyId: display.surveyId,
|
||||||
userId,
|
|
||||||
environmentId,
|
|
||||||
});
|
});
|
||||||
return display;
|
return display;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -138,46 +191,33 @@ export const getDisplaysByPersonId = reactCache(
|
|||||||
)()
|
)()
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getDisplaysByUserId = reactCache(
|
export const deleteDisplayByResponseId = async (
|
||||||
async (environmentId: string, userId: string, page?: number): Promise<TDisplay[]> =>
|
responseId: string,
|
||||||
cache(
|
surveyId: string
|
||||||
async () => {
|
): Promise<TDisplay | null> => {
|
||||||
validateInputs([environmentId, ZId], [userId, ZString], [page, ZOptionalNumber]);
|
validateInputs([responseId, ZId], [surveyId, ZId]);
|
||||||
|
|
||||||
const person = await getPersonByUserId(environmentId, userId);
|
try {
|
||||||
|
const display = await prisma.display.delete({
|
||||||
if (!person) {
|
where: {
|
||||||
throw new ResourceNotFoundError("person", userId);
|
responseId,
|
||||||
}
|
|
||||||
|
|
||||||
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}`],
|
select: selectDisplay,
|
||||||
{
|
});
|
||||||
tags: [displayCache.tag.byEnvironmentIdAndUserId(environmentId, userId)],
|
|
||||||
}
|
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(
|
export const getDisplayCountBySurveyId = reactCache(
|
||||||
(surveyId: string, filters?: TDisplayFilters): Promise<number> =>
|
(surveyId: string, filters?: TDisplayFilters): Promise<number> =>
|
||||||
@@ -218,29 +258,3 @@ export const getDisplayCountBySurveyId = reactCache(
|
|||||||
}
|
}
|
||||||
)()
|
)()
|
||||||
);
|
);
|
||||||
|
|
||||||
export const deleteDisplay = async (displayId: string): Promise<TDisplay> => {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { selectDisplay } from "../../service";
|
||||||
|
|
||||||
export const mockEnvironmentId = "clqkr5961000108jyfnjmbjhi";
|
export const mockEnvironmentId = "clqkr5961000108jyfnjmbjhi";
|
||||||
export const mockSingleUseId = "qj57j3opsw8b5sxgea20fgcq";
|
export const mockSingleUseId = "qj57j3opsw8b5sxgea20fgcq";
|
||||||
@@ -49,13 +50,3 @@ export const mockDisplayUpdate = {
|
|||||||
userId: mockUserId,
|
userId: mockUserId,
|
||||||
responseId: mockResponseId,
|
responseId: mockResponseId,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mockEnvironment: TEnvironment = {
|
|
||||||
id: mockId,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
type: "production",
|
|
||||||
productId: mockId,
|
|
||||||
appSetupCompleted: false,
|
|
||||||
websiteSetupCompleted: false,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import {
|
|||||||
mockDisplay,
|
mockDisplay,
|
||||||
mockDisplayInput,
|
mockDisplayInput,
|
||||||
mockDisplayInputWithUserId,
|
mockDisplayInputWithUserId,
|
||||||
|
mockDisplayUpdate,
|
||||||
mockDisplayWithPersonId,
|
mockDisplayWithPersonId,
|
||||||
mockEnvironment,
|
mockDisplayWithResponseId,
|
||||||
|
mockResponseId,
|
||||||
mockSurveyId,
|
mockSurveyId,
|
||||||
} from "./__mocks__/data.mock";
|
} from "./__mocks__/data.mock";
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
@@ -14,10 +16,11 @@ import { testInputValidation } from "vitestSetup";
|
|||||||
import { DatabaseError } from "@formbricks/types/errors";
|
import { DatabaseError } from "@formbricks/types/errors";
|
||||||
import {
|
import {
|
||||||
createDisplay,
|
createDisplay,
|
||||||
deleteDisplay,
|
deleteDisplayByResponseId,
|
||||||
getDisplay,
|
getDisplay,
|
||||||
getDisplayCountBySurveyId,
|
getDisplayCountBySurveyId,
|
||||||
getDisplaysByPersonId,
|
getDisplaysByPersonId,
|
||||||
|
updateDisplay,
|
||||||
} from "../service";
|
} from "../service";
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -91,7 +94,6 @@ describe("Tests for getDisplay", () => {
|
|||||||
describe("Tests for createDisplay service", () => {
|
describe("Tests for createDisplay service", () => {
|
||||||
describe("Happy Path", () => {
|
describe("Happy Path", () => {
|
||||||
it("Creates a new display when a userId exists", async () => {
|
it("Creates a new display when a userId exists", async () => {
|
||||||
prisma.environment.findUnique.mockResolvedValue(mockEnvironment);
|
|
||||||
prisma.display.create.mockResolvedValue(mockDisplayWithPersonId);
|
prisma.display.create.mockResolvedValue(mockDisplayWithPersonId);
|
||||||
|
|
||||||
const display = await createDisplay(mockDisplayInputWithUserId);
|
const display = await createDisplay(mockDisplayInputWithUserId);
|
||||||
@@ -111,7 +113,6 @@ describe("Tests for createDisplay service", () => {
|
|||||||
|
|
||||||
it("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => {
|
it("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => {
|
||||||
const mockErrorMessage = "Mock error message";
|
const mockErrorMessage = "Mock error message";
|
||||||
prisma.environment.findUnique.mockResolvedValue(mockEnvironment);
|
|
||||||
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
||||||
code: "P2002",
|
code: "P2002",
|
||||||
clientVersion: "0.0.1",
|
clientVersion: "0.0.1",
|
||||||
@@ -131,13 +132,47 @@ describe("Tests for createDisplay service", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Tests for delete display service", () => {
|
describe("Tests for updateDisplay Service", () => {
|
||||||
describe("Happy Path", () => {
|
describe("Happy Path", () => {
|
||||||
it("Deletes a display", async () => {
|
it("Updates a display (responded)", async () => {
|
||||||
prisma.display.delete.mockResolvedValue(mockDisplay);
|
prisma.display.update.mockResolvedValue(mockDisplayWithResponseId);
|
||||||
|
|
||||||
const display = await deleteDisplay(mockDisplay.id);
|
const display = await updateDisplay(mockDisplay.id, mockDisplayUpdate);
|
||||||
expect(display).toEqual(mockDisplay);
|
expect(display).toEqual(mockDisplayWithResponseId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Sad Path", () => {
|
||||||
|
testInputValidation(updateDisplay, "123", "123");
|
||||||
|
|
||||||
|
it("Throws DatabaseError on PrismaClientKnownRequestError", async () => {
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe("Sad Path", () => {
|
describe("Sad Path", () => {
|
||||||
@@ -150,14 +185,14 @@ describe("Tests for delete display service", () => {
|
|||||||
|
|
||||||
prisma.display.delete.mockRejectedValue(errToThrow);
|
prisma.display.delete.mockRejectedValue(errToThrow);
|
||||||
|
|
||||||
await expect(deleteDisplay(mockDisplay.id)).rejects.toThrow(DatabaseError);
|
await expect(deleteDisplayByResponseId(mockResponseId, mockSurveyId)).rejects.toThrow(DatabaseError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Throws a generic Error for other exceptions", async () => {
|
it("Throws a generic Error for other exceptions", async () => {
|
||||||
const mockErrorMessage = "Mock error message";
|
const mockErrorMessage = "Mock error message";
|
||||||
prisma.display.delete.mockRejectedValue(new Error(mockErrorMessage));
|
prisma.display.delete.mockRejectedValue(new Error(mockErrorMessage));
|
||||||
|
|
||||||
await expect(deleteDisplay(mockDisplay.id)).rejects.toThrow(Error);
|
await expect(deleteDisplayByResponseId(mockResponseId, mockSurveyId)).rejects.toThrow(Error);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -514,3 +514,45 @@ export const mockLegacyThankYouCard = {
|
|||||||
subheader: "We appreciate your feedback.",
|
subheader: "We appreciate your feedback.",
|
||||||
buttonLabel: "Create your own Survey",
|
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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { mockLegacySurvey, mockTranslatedSurvey } from "./i18n.mock";
|
||||||
|
import { reverseTranslateSurvey } from "./reverseTranslation";
|
||||||
import { createI18nString } from "./utils";
|
import { createI18nString } from "./utils";
|
||||||
|
|
||||||
describe("createI18nString", () => {
|
describe("createI18nString", () => {
|
||||||
@@ -32,3 +34,10 @@ 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { TI18nString } from "@formbricks/types/surveys/types";
|
import { TI18nString, TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { structuredClone } from "../pollyfills/structuredClone";
|
import { structuredClone } from "../pollyfills/structuredClone";
|
||||||
import { isI18nObject } from "./utils";
|
import { getLocalizedValue, isI18nObject } from "./utils";
|
||||||
|
|
||||||
// Helper function to extract a regular string from an i18nString.
|
// Helper function to extract a regular string from an i18nString.
|
||||||
const extractStringFromI18n = (i18nString: TI18nString, languageCode: string): string => {
|
const extractStringFromI18n = (i18nString: TI18nString, languageCode: string): string => {
|
||||||
@@ -26,3 +26,45 @@ const reverseTranslateObject = <T extends Record<string, any>>(obj: T, languageC
|
|||||||
}
|
}
|
||||||
return clonedObj;
|
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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { cache as reactCache } from "react";
|
|||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { ZOptionalNumber, ZString } from "@formbricks/types/common";
|
import { ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||||
import { ZId } from "@formbricks/types/common";
|
import { ZId } from "@formbricks/types/common";
|
||||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
import { DatabaseError } from "@formbricks/types/errors";
|
||||||
import { TPerson, TPersonWithAttributes } from "@formbricks/types/people";
|
import { TPerson } from "@formbricks/types/people";
|
||||||
import { cache } from "../cache";
|
import { cache } from "../cache";
|
||||||
import { ITEMS_PER_PAGE } from "../constants";
|
import { ITEMS_PER_PAGE } from "../constants";
|
||||||
import { validateInputs } from "../utils/validate";
|
import { validateInputs } from "../utils/validate";
|
||||||
@@ -17,16 +17,6 @@ export const selectPerson = {
|
|||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
environmentId: true,
|
environmentId: true,
|
||||||
attributes: {
|
|
||||||
select: {
|
|
||||||
value: true,
|
|
||||||
attributeClass: {
|
|
||||||
select: {
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type TransformPersonInput = {
|
type TransformPersonInput = {
|
||||||
@@ -43,7 +33,7 @@ type TransformPersonInput = {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const transformPrismaPerson = (person: TransformPersonInput): TPersonWithAttributes => {
|
export const transformPrismaPerson = (person: TransformPersonInput): TPerson => {
|
||||||
const attributes = person.attributes.reduce(
|
const attributes = person.attributes.reduce(
|
||||||
(acc, attr) => {
|
(acc, attr) => {
|
||||||
acc[attr.attributeClass.name] = attr.value;
|
acc[attr.attributeClass.name] = attr.value;
|
||||||
@@ -59,7 +49,7 @@ export const transformPrismaPerson = (person: TransformPersonInput): TPersonWith
|
|||||||
environmentId: person.environmentId,
|
environmentId: person.environmentId,
|
||||||
createdAt: new Date(person.createdAt),
|
createdAt: new Date(person.createdAt),
|
||||||
updatedAt: new Date(person.updatedAt),
|
updatedAt: new Date(person.updatedAt),
|
||||||
} as TPersonWithAttributes;
|
} as TPerson;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getPerson = reactCache(
|
export const getPerson = reactCache(
|
||||||
@@ -91,13 +81,13 @@ export const getPerson = reactCache(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const getPeople = reactCache(
|
export const getPeople = reactCache(
|
||||||
(environmentId: string, page?: number): Promise<TPersonWithAttributes[]> =>
|
(environmentId: string, page?: number): Promise<TPerson[]> =>
|
||||||
cache(
|
cache(
|
||||||
async () => {
|
async () => {
|
||||||
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
|
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const persons = await prisma.person.findMany({
|
return await prisma.person.findMany({
|
||||||
where: {
|
where: {
|
||||||
environmentId: environmentId,
|
environmentId: environmentId,
|
||||||
},
|
},
|
||||||
@@ -105,8 +95,6 @@ export const getPeople = reactCache(
|
|||||||
take: page ? ITEMS_PER_PAGE : undefined,
|
take: page ? ITEMS_PER_PAGE : undefined,
|
||||||
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
|
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return persons.map((person) => transformPrismaPerson(person));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
throw new DatabaseError(error.message);
|
throw new DatabaseError(error.message);
|
||||||
@@ -122,7 +110,7 @@ export const getPeople = reactCache(
|
|||||||
)()
|
)()
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getPersonCount = reactCache(
|
export const getPeopleCount = reactCache(
|
||||||
(environmentId: string): Promise<number> =>
|
(environmentId: string): Promise<number> =>
|
||||||
cache(
|
cache(
|
||||||
async () => {
|
async () => {
|
||||||
@@ -142,7 +130,7 @@ export const getPersonCount = reactCache(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[`getPersonCount-${environmentId}`],
|
[`getPeopleCount-${environmentId}`],
|
||||||
{
|
{
|
||||||
tags: [personCache.tag.byEnvironmentId(environmentId)],
|
tags: [personCache.tag.byEnvironmentId(environmentId)],
|
||||||
}
|
}
|
||||||
@@ -230,16 +218,6 @@ export const getPersonByUserId = reactCache(
|
|||||||
async () => {
|
async () => {
|
||||||
validateInputs([environmentId, ZId], [userId, ZString]);
|
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
|
// check if userId exists as a column
|
||||||
const personWithUserId = await prisma.person.findFirst({
|
const personWithUserId = await prisma.person.findFirst({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ interface RevalidateProps {
|
|||||||
id?: string;
|
id?: string;
|
||||||
environmentId?: string;
|
environmentId?: string;
|
||||||
personId?: string;
|
personId?: string;
|
||||||
userId?: string;
|
|
||||||
singleUseId?: string;
|
singleUseId?: string;
|
||||||
surveyId?: string;
|
surveyId?: string;
|
||||||
}
|
}
|
||||||
@@ -20,9 +19,6 @@ export const responseCache = {
|
|||||||
byPersonId(personId: string) {
|
byPersonId(personId: string) {
|
||||||
return `people-${personId}-responses`;
|
return `people-${personId}-responses`;
|
||||||
},
|
},
|
||||||
byEnvironmentIdAndUserId(environmentId: string, userId: string) {
|
|
||||||
return `environments-${environmentId}-users-${userId}-responses`;
|
|
||||||
},
|
|
||||||
bySingleUseId(surveyId: string, singleUseId: string) {
|
bySingleUseId(surveyId: string, singleUseId: string) {
|
||||||
return `surveys-${surveyId}-singleUse-${singleUseId}-responses`;
|
return `surveys-${surveyId}-singleUse-${singleUseId}-responses`;
|
||||||
},
|
},
|
||||||
@@ -30,7 +26,7 @@ export const responseCache = {
|
|||||||
return `surveys-${surveyId}-responses`;
|
return `surveys-${surveyId}-responses`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
revalidate({ environmentId, personId, id, singleUseId, surveyId, userId }: RevalidateProps): void {
|
revalidate({ environmentId, personId, id, singleUseId, surveyId }: RevalidateProps): void {
|
||||||
if (id) {
|
if (id) {
|
||||||
revalidateTag(this.tag.byId(id));
|
revalidateTag(this.tag.byId(id));
|
||||||
}
|
}
|
||||||
@@ -47,10 +43,6 @@ export const responseCache = {
|
|||||||
revalidateTag(this.tag.byEnvironmentId(environmentId));
|
revalidateTag(this.tag.byEnvironmentId(environmentId));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (environmentId && userId) {
|
|
||||||
revalidateTag(this.tag.byEnvironmentIdAndUserId(environmentId, userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (surveyId && singleUseId) {
|
if (surveyId && singleUseId) {
|
||||||
revalidateTag(this.tag.bySingleUseId(surveyId, singleUseId));
|
revalidateTag(this.tag.bySingleUseId(surveyId, singleUseId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { getAttributes } from "../attribute/service";
|
|||||||
import { cache } from "../cache";
|
import { cache } from "../cache";
|
||||||
import { IS_FORMBRICKS_CLOUD, ITEMS_PER_PAGE, WEBAPP_URL } from "../constants";
|
import { IS_FORMBRICKS_CLOUD, ITEMS_PER_PAGE, WEBAPP_URL } from "../constants";
|
||||||
import { displayCache } from "../display/cache";
|
import { displayCache } from "../display/cache";
|
||||||
import { deleteDisplay, getDisplayCountBySurveyId } from "../display/service";
|
import { deleteDisplayByResponseId, getDisplayCountBySurveyId } from "../display/service";
|
||||||
import { getMonthlyOrganizationResponseCount, getOrganizationByEnvironmentId } from "../organization/service";
|
import { getMonthlyOrganizationResponseCount, getOrganizationByEnvironmentId } from "../organization/service";
|
||||||
import { createPerson, getPersonByUserId } from "../person/service";
|
import { createPerson, getPersonByUserId } from "../person/service";
|
||||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "../posthogServer";
|
import { sendPlanLimitsReachedEventToPosthogWeekly } from "../posthogServer";
|
||||||
@@ -62,7 +62,6 @@ export const responseSelection = {
|
|||||||
personAttributes: true,
|
personAttributes: true,
|
||||||
singleUseId: true,
|
singleUseId: true,
|
||||||
language: true,
|
language: true,
|
||||||
displayId: true,
|
|
||||||
person: {
|
person: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -98,7 +97,7 @@ export const responseSelection = {
|
|||||||
isEdited: true,
|
isEdited: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} satisfies Prisma.ResponseSelect;
|
};
|
||||||
|
|
||||||
export const getResponsesByPersonId = reactCache(
|
export const getResponsesByPersonId = reactCache(
|
||||||
(personId: string, page?: number): Promise<TResponse[] | null> =>
|
(personId: string, page?: number): Promise<TResponse[] | null> =>
|
||||||
@@ -152,61 +151,6 @@ export const getResponsesByPersonId = reactCache(
|
|||||||
)()
|
)()
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getResponsesByUserId = reactCache(
|
|
||||||
(environmentId: string, userId: string, page?: number): Promise<TResponse[] | null> =>
|
|
||||||
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(
|
export const getResponseBySingleUseId = reactCache(
|
||||||
(surveyId: string, singleUseId: string): Promise<TResponse | null> =>
|
(surveyId: string, singleUseId: string): Promise<TResponse | null> =>
|
||||||
cache(
|
cache(
|
||||||
@@ -255,7 +199,6 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
|
|||||||
language,
|
language,
|
||||||
userId,
|
userId,
|
||||||
surveyId,
|
surveyId,
|
||||||
displayId,
|
|
||||||
finished,
|
finished,
|
||||||
data,
|
data,
|
||||||
meta,
|
meta,
|
||||||
@@ -294,7 +237,6 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
|
|||||||
id: surveyId,
|
id: surveyId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
display: displayId ? { connect: { id: displayId } } : undefined,
|
|
||||||
finished: finished,
|
finished: finished,
|
||||||
data: data,
|
data: data,
|
||||||
language: language,
|
language: language,
|
||||||
@@ -308,7 +250,6 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
|
|||||||
}),
|
}),
|
||||||
...(meta && ({ meta } as Prisma.JsonObject)),
|
...(meta && ({ meta } as Prisma.JsonObject)),
|
||||||
singleUseId,
|
singleUseId,
|
||||||
|
|
||||||
ttc: ttc,
|
ttc: ttc,
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
@@ -328,7 +269,6 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
|
|||||||
environmentId: environmentId,
|
environmentId: environmentId,
|
||||||
id: response.id,
|
id: response.id,
|
||||||
personId: response.person?.id,
|
personId: response.person?.id,
|
||||||
userId: userId ?? undefined,
|
|
||||||
surveyId: response.surveyId,
|
surveyId: response.surveyId,
|
||||||
singleUseId: singleUseId ? singleUseId : undefined,
|
singleUseId: singleUseId ? singleUseId : undefined,
|
||||||
});
|
});
|
||||||
@@ -803,9 +743,8 @@ export const deleteResponse = async (responseId: string): Promise<TResponse> =>
|
|||||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (response.displayId) {
|
deleteDisplayByResponseId(responseId, response.surveyId);
|
||||||
deleteDisplay(response.displayId);
|
|
||||||
}
|
|
||||||
const survey = await getSurvey(response.surveyId);
|
const survey = await getSurvey(response.surveyId);
|
||||||
|
|
||||||
if (survey) {
|
if (survey) {
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
import { TEnvironment } from "@formbricks/types/environment";
|
|
||||||
|
|
||||||
export const mockId = "ars2tjk8hsi8oqk1uac00mo8";
|
|
||||||
|
|
||||||
export const constantsForTests = {
|
export const constantsForTests = {
|
||||||
uuid: "123e4567-e89b-12d3-a456-426614174000",
|
uuid: "123e4567-e89b-12d3-a456-426614174000",
|
||||||
browser: "Chrome",
|
browser: "Chrome",
|
||||||
@@ -10,13 +6,3 @@ export const constantsForTests = {
|
|||||||
text: "Abc12345",
|
text: "Abc12345",
|
||||||
fullName: "Pavitr Prabhakar",
|
fullName: "Pavitr Prabhakar",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mockEnvironment: TEnvironment = {
|
|
||||||
id: mockId,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
type: "production",
|
|
||||||
productId: mockId,
|
|
||||||
appSetupCompleted: false,
|
|
||||||
websiteSetupCompleted: false,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import {
|
|||||||
updateResponse,
|
updateResponse,
|
||||||
} from "../service";
|
} from "../service";
|
||||||
import { buildWhereClause } from "../utils";
|
import { buildWhereClause } from "../utils";
|
||||||
import { constantsForTests, mockEnvironment } from "./constants";
|
import { constantsForTests } from "./constants";
|
||||||
|
|
||||||
// vitest.mock("../../organization/service", async (methods) => {
|
// vitest.mock("../../organization/service", async (methods) => {
|
||||||
// return {
|
// return {
|
||||||
@@ -205,7 +205,6 @@ describe("Tests for createResponse service", () => {
|
|||||||
describe("Happy Path", () => {
|
describe("Happy Path", () => {
|
||||||
it("Creates a response linked to an existing user", async () => {
|
it("Creates a response linked to an existing user", async () => {
|
||||||
prisma.attribute.findMany.mockResolvedValue([]);
|
prisma.attribute.findMany.mockResolvedValue([]);
|
||||||
prisma.environment.findUnique.mockResolvedValue(mockEnvironment);
|
|
||||||
const response = await createResponse(mockResponseInputWithUserId);
|
const response = await createResponse(mockResponseInputWithUserId);
|
||||||
expect(response).toEqual(expectedResponseWithPerson);
|
expect(response).toEqual(expectedResponseWithPerson);
|
||||||
});
|
});
|
||||||
@@ -217,7 +216,6 @@ describe("Tests for createResponse service", () => {
|
|||||||
|
|
||||||
it("Creates a new person and response when the person does not exist", async () => {
|
it("Creates a new person and response when the person does not exist", async () => {
|
||||||
prisma.person.findFirst.mockResolvedValue(null);
|
prisma.person.findFirst.mockResolvedValue(null);
|
||||||
prisma.environment.findUnique.mockResolvedValue(mockEnvironment);
|
|
||||||
prisma.person.create.mockResolvedValue(mockPerson);
|
prisma.person.create.mockResolvedValue(mockPerson);
|
||||||
prisma.attribute.findMany.mockResolvedValue([]);
|
prisma.attribute.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
@@ -248,7 +246,6 @@ describe("Tests for createResponse service", () => {
|
|||||||
clientVersion: "0.0.1",
|
clientVersion: "0.0.1",
|
||||||
});
|
});
|
||||||
|
|
||||||
prisma.environment.findUnique.mockResolvedValue(mockEnvironment);
|
|
||||||
prisma.response.create.mockRejectedValue(errToThrow);
|
prisma.response.create.mockRejectedValue(errToThrow);
|
||||||
prisma.attribute.findMany.mockResolvedValue([]);
|
prisma.attribute.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
|||||||
@@ -87,11 +87,19 @@ export class ResponseQueue {
|
|||||||
userId: this.surveyState.userId || null,
|
userId: this.surveyState.userId || null,
|
||||||
singleUseId: this.surveyState.singleUseId || null,
|
singleUseId: this.surveyState.singleUseId || null,
|
||||||
data: { ...responseUpdate.data, ...responseUpdate.hiddenFields },
|
data: { ...responseUpdate.data, ...responseUpdate.hiddenFields },
|
||||||
displayId: this.surveyState.displayId,
|
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Could not create response");
|
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);
|
this.surveyState.updateResponseId(response.data.id);
|
||||||
if (this.config.setSurveyState) {
|
if (this.config.setSurveyState) {
|
||||||
this.config.setSurveyState(this.surveyState);
|
this.config.setSurveyState(this.surveyState);
|
||||||
|
|||||||
@@ -609,11 +609,6 @@ export const evaluateSegment = async (
|
|||||||
userData: TEvaluateSegmentUserData,
|
userData: TEvaluateSegmentUserData,
|
||||||
filters: TBaseFilters
|
filters: TBaseFilters
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
if (!filters.length) {
|
|
||||||
// if there are no filters, the segment will be evaluated as true
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
let resultPairs: ResultConnectorPair[] = [];
|
let resultPairs: ResultConnectorPair[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -557,3 +557,50 @@ export const isAdvancedSegment = (filters: TBaseFilters): boolean => {
|
|||||||
|
|
||||||
return false;
|
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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ import { capturePosthogEnvironmentEvent } from "../posthogServer";
|
|||||||
import { productCache } from "../product/cache";
|
import { productCache } from "../product/cache";
|
||||||
import { getProductByEnvironmentId } from "../product/service";
|
import { getProductByEnvironmentId } from "../product/service";
|
||||||
import { responseCache } from "../response/cache";
|
import { responseCache } from "../response/cache";
|
||||||
import { getResponsesByPersonId } from "../response/service";
|
|
||||||
import { segmentCache } from "../segment/cache";
|
import { segmentCache } from "../segment/cache";
|
||||||
import { createSegment, deleteSegment, evaluateSegment, getSegment, updateSegment } from "../segment/service";
|
import { createSegment, deleteSegment, evaluateSegment, getSegment, updateSegment } from "../segment/service";
|
||||||
|
import { transformSegmentFiltersToAttributeFilters } from "../segment/utils";
|
||||||
import { diffInDays } from "../utils/datetime";
|
import { diffInDays } from "../utils/datetime";
|
||||||
import { validateInputs } from "../utils/validate";
|
import { validateInputs } from "../utils/validate";
|
||||||
import { surveyCache } from "./cache";
|
import { surveyCache } from "./cache";
|
||||||
@@ -1096,7 +1096,10 @@ export const getSyncSurveys = reactCache(
|
|||||||
(
|
(
|
||||||
environmentId: string,
|
environmentId: string,
|
||||||
personId: string,
|
personId: string,
|
||||||
deviceType: "phone" | "desktop" = "desktop"
|
deviceType: "phone" | "desktop" = "desktop",
|
||||||
|
options?: {
|
||||||
|
version?: string;
|
||||||
|
}
|
||||||
): Promise<TSurvey[]> =>
|
): Promise<TSurvey[]> =>
|
||||||
cache(
|
cache(
|
||||||
async () => {
|
async () => {
|
||||||
@@ -1125,7 +1128,6 @@ export const getSyncSurveys = reactCache(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const displays = await getDisplaysByPersonId(person.id);
|
const displays = await getDisplaysByPersonId(person.id);
|
||||||
const responses = await getResponsesByPersonId(person.id);
|
|
||||||
|
|
||||||
// filter surveys that meet the displayOption criteria
|
// filter surveys that meet the displayOption criteria
|
||||||
surveys = surveys.filter((survey) => {
|
surveys = surveys.filter((survey) => {
|
||||||
@@ -1135,18 +1137,20 @@ export const getSyncSurveys = reactCache(
|
|||||||
case "displayOnce":
|
case "displayOnce":
|
||||||
return displays.filter((display) => display.surveyId === survey.id).length === 0;
|
return displays.filter((display) => display.surveyId === survey.id).length === 0;
|
||||||
case "displayMultiple":
|
case "displayMultiple":
|
||||||
if (!responses) return true;
|
return (
|
||||||
else {
|
displays
|
||||||
return responses.filter((response) => response.surveyId === survey.id).length === 0;
|
.filter((display) => display.surveyId === survey.id)
|
||||||
}
|
.filter((display) => display.responseId).length === 0
|
||||||
|
);
|
||||||
case "displaySome":
|
case "displaySome":
|
||||||
if (survey.displayLimit === null) {
|
if (survey.displayLimit === null) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
responses &&
|
displays
|
||||||
responses.filter((response) => response.surveyId === survey.id).length !== 0
|
.filter((display) => display.surveyId === survey.id)
|
||||||
|
.some((display) => display.responseId)
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1204,6 +1208,42 @@ export const getSyncSurveys = reactCache(
|
|||||||
return survey;
|
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
|
// Evaluate the segment filters
|
||||||
const result = await evaluateSegment(
|
const result = await evaluateSegment(
|
||||||
{
|
{
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user