Rewrite Person Detail Page to Server Components (#609)

* feat: migration /[personId] page to server side

* feat: decouple components in person page

* fix: ZDisplaysWithSurveyName now extends the ZDisplay type

* feat: drop custom service and use existing service for survey and response

* run pnpm format

* shift data fetching to component level but still server side

* rename event to action

* move special person services to activity service

* remove activityFeedItem type in ActivityFeed

* simplify TResponseWithSurvey

---------

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Shubham Palriwala
2023-08-06 13:23:37 +05:30
committed by GitHub
parent 5c9605f4af
commit fdb1aa2299
26 changed files with 796 additions and 356 deletions

View File

@@ -0,0 +1,42 @@
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
import { ActivityItemContent, ActivityItemIcon, ActivityItemPopover } from "./ActivityItemComponents";
import { TActivityFeedItem } from "@formbricks/types/v1/activity";
interface ActivityFeedProps {
activities: TActivityFeedItem[];
sortByDate: boolean;
environmentId: string;
}
export default function ActivityFeed({ activities, sortByDate, environmentId }: ActivityFeedProps) {
const sortedActivities: TActivityFeedItem[] = activities.sort((a, b) =>
sortByDate
? new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
: new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
return (
<>
{sortedActivities.length === 0 ? (
<EmptySpaceFiller type={"event"} environmentId={environmentId} />
) : (
<div>
{sortedActivities.map((activityItem) => (
<li key={activityItem.id} className="list-none">
<div className="relative pb-12">
<span className="absolute left-6 top-4 -ml-px h-full w-0.5 bg-slate-200" aria-hidden="true" />
<div className="relative">
<ActivityItemPopover activityItem={activityItem}>
<div className="flex space-x-3 text-left">
<ActivityItemIcon activityItem={activityItem} />
<ActivityItemContent activityItem={activityItem} />
</div>
</ActivityItemPopover>
</div>
</div>
</li>
))}
</div>
)}
</>
);
}

View File

@@ -1,5 +1,5 @@
import { capitalizeFirstLetter } from "@/lib/utils";
import { timeSince } from "@formbricks/lib/time";
import { TActivityFeedItem } from "@formbricks/types/v1/activity";
import { Label, Popover, PopoverContent, PopoverTrigger } from "@formbricks/ui";
import {
CodeBracketIcon,
@@ -9,9 +9,9 @@ import {
SparklesIcon,
TagIcon,
} from "@heroicons/react/24/solid";
import { ActivityFeedItem } from "./ActivityFeed"; // Import the ActivityFeedItem type from the main file
import { formatDistance } from "date-fns";
export const ActivityItemIcon = ({ activityItem }: { activityItem: ActivityFeedItem }) => (
export const ActivityItemIcon = ({ activityItem }: { activityItem: TActivityFeedItem }) => (
<div className="h-12 w-12 rounded-full bg-white p-3 text-slate-500 duration-100 ease-in-out group-hover:scale-110 group-hover:text-slate-600">
{activityItem.type === "attribute" ? (
<TagIcon />
@@ -19,9 +19,9 @@ export const ActivityItemIcon = ({ activityItem }: { activityItem: ActivityFeedI
<EyeIcon />
) : activityItem.type === "event" ? (
<div>
{activityItem.eventType === "code" && <CodeBracketIcon />}
{activityItem.eventType === "noCode" && <CursorArrowRaysIcon />}
{activityItem.eventType === "automatic" && <SparklesIcon />}
{activityItem.actionType === "code" && <CodeBracketIcon />}
{activityItem.actionType === "noCode" && <CursorArrowRaysIcon />}
{activityItem.actionType === "automatic" && <SparklesIcon />}
</div>
) : (
<QuestionMarkCircleIcon />
@@ -29,7 +29,7 @@ export const ActivityItemIcon = ({ activityItem }: { activityItem: ActivityFeedI
</div>
);
export const ActivityItemContent = ({ activityItem }: { activityItem: ActivityFeedItem }) => (
export const ActivityItemContent = ({ activityItem }: { activityItem: TActivityFeedItem }) => (
<div>
<div className="font-semibold text-slate-700">
{activityItem.type === "attribute" ? (
@@ -37,40 +37,36 @@ export const ActivityItemContent = ({ activityItem }: { activityItem: ActivityFe
) : activityItem.type === "display" ? (
<p>Seen survey</p>
) : activityItem.type === "event" ? (
<p>{activityItem.eventLabel} triggered</p>
<p>{activityItem.actionLabel} triggered</p>
) : (
<p>Unknown Activity</p>
)}
</div>
<div className="text-sm text-slate-400">
<time dateTime={timeSince(activityItem.createdAt)}>{timeSince(activityItem.createdAt)}</time>
<time
dateTime={formatDistance(activityItem.createdAt, new Date(), {
addSuffix: true,
})}>
{formatDistance(activityItem.createdAt, new Date(), {
addSuffix: true,
})}
</time>
</div>
</div>
);
export const ActivityItemPopover = ({
activityItem,
responses,
children,
}: {
activityItem: ActivityFeedItem;
responses: any[];
activityItem: TActivityFeedItem;
children: React.ReactNode;
}) => {
function findMatchingSurveyName(responses, surveyId) {
for (const response of responses) {
if (response.survey.id === surveyId) {
return response.survey.name;
}
return null; // Return null if no match is found
}
}
return (
<Popover>
<PopoverTrigger className="group">{children}</PopoverTrigger>
<PopoverContent className="bg-white">
<div className="">
<div>
{activityItem.type === "attribute" ? (
<div>
<Label className="font-normal text-slate-400">Attribute Label</Label>
@@ -81,26 +77,24 @@ export const ActivityItemPopover = ({
) : activityItem.type === "display" ? (
<div>
<Label className="font-normal text-slate-400">Survey Name</Label>
<p className=" mb-2 text-sm font-medium text-slate-900">
{findMatchingSurveyName(responses, activityItem.displaySurveyId)}
</p>
<p className=" mb-2 text-sm font-medium text-slate-900">{activityItem.displaySurveyName}</p>
</div>
) : activityItem.type === "event" ? (
<div>
<div>
<Label className="font-normal text-slate-400">Event Display Name</Label>
<p className=" mb-2 text-sm font-medium text-slate-900">{activityItem.eventLabel}</p>{" "}
<Label className="font-normal text-slate-400">Event Description</Label>
<Label className="font-normal text-slate-400">Action Display Name</Label>
<p className=" mb-2 text-sm font-medium text-slate-900">{activityItem.actionLabel}</p>{" "}
<Label className="font-normal text-slate-400">Action Description</Label>
<p className=" mb-2 text-sm font-medium text-slate-900">
{activityItem.eventDescription ? (
<span>{activityItem.eventDescription}</span>
{activityItem.actionDescription ? (
<span>{activityItem.actionDescription}</span>
) : (
<span>-</span>
)}
</p>
<Label className="font-normal text-slate-400">Event Type</Label>
<Label className="font-normal text-slate-400">Action Type</Label>
<p className="text-sm font-medium text-slate-900">
{capitalizeFirstLetter(activityItem.eventType)}
{capitalizeFirstLetter(activityItem.actionType)}
</p>
</div>
</div>

View File

@@ -0,0 +1,18 @@
import ActivityTimeline from "@/app/(app)/environments/[environmentId]/people/[personId]/(activitySection)/ActivityTimeline";
import { getActivityTimeline } from "@formbricks/lib/services/activity";
export default async function ActivitySection({
environmentId,
personId,
}: {
environmentId: string;
personId: string;
}) {
const activities = await getActivityTimeline(personId);
return (
<div className="md:col-span-1">
<ActivityTimeline environmentId={environmentId} activities={activities} />
</div>
);
}

View File

@@ -0,0 +1,36 @@
"use client";
import ActivityFeed from "@/app/(app)/environments/[environmentId]/people/[personId]/(activitySection)/ActivityFeed";
import { TActivityFeedItem } from "@formbricks/types/v1/activity";
import { ArrowsUpDownIcon } from "@heroicons/react/24/outline";
import { useState } from "react";
export default function ActivityTimeline({
environmentId,
activities,
}: {
environmentId: string;
activities: TActivityFeedItem[];
}) {
const [activityAscending, setActivityAscending] = useState(true);
const toggleSortActivity = () => {
setActivityAscending(!activityAscending);
};
return (
<>
<div className="flex items-center justify-between pb-6">
<h2 className="text-lg font-bold text-slate-700">Activity Timeline</h2>
<div className="text-right">
<button
onClick={toggleSortActivity}
className="hover:text-brand-dark flex items-center px-1 text-slate-800">
<ArrowsUpDownIcon className="inline h-4 w-4" />
</button>
</div>
</div>
<ActivityFeed activities={activities} sortByDate={activityAscending} environmentId={environmentId} />
</>
);
}

View File

@@ -0,0 +1,68 @@
export const revalidate = REVALIDATION_INTERVAL;
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { capitalizeFirstLetter } from "@/lib/utils";
import { getPerson } from "@formbricks/lib/services/person";
import { getResponsesByPersonId } from "@formbricks/lib/services/response";
import { getSessionCount } from "@formbricks/lib/services/session";
export default async function AttributesSection({ personId }: { personId: string }) {
const person = await getPerson(personId);
if (!person) {
throw new Error("No such person found");
}
const numberOfSessions = await getSessionCount(personId);
const responses = await getResponsesByPersonId(personId);
const numberOfResponses = responses?.length || 0;
return (
<div className="space-y-6">
<h2 className="text-lg font-bold text-slate-700">Attributes</h2>
<div>
<dt className="text-sm font-medium text-slate-500">Email</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{person.attributes.email ? (
<span>{person.attributes.email}</span>
) : (
<span className="text-slate-300">Not provided</span>
)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">User Id</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{person.attributes.userId ? (
<span>{person.attributes.userId}</span>
) : (
<span className="text-slate-300">Not provided</span>
)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">Formbricks Id (internal)</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">{person.id}</dd>
</div>
{Object.entries(person.attributes)
.filter(([key, _]) => key !== "email" && key !== "userId")
.map(([key, value]) => (
<div key={key}>
<dt className="text-sm font-medium text-slate-500">{capitalizeFirstLetter(key.toString())}</dt>
<dd className="mt-1 text-sm text-slate-900">{value}</dd>
</div>
))}
<hr />
<div>
<dt className="text-sm font-medium text-slate-500">Sessions</dt>
<dd className="mt-1 text-sm text-slate-900">{numberOfSessions}</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">Responses</dt>
<dd className="mt-1 text-sm text-slate-900">{numberOfResponses}</dd>
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import ResponseTimeline from "@/app/(app)/environments/[environmentId]/people/[personId]/(responseSection)/ResponseTimeline";
import { getResponsesByPersonId } from "@formbricks/lib/services/response";
import { getSurveys } from "@formbricks/lib/services/survey";
import { TResponseWithSurvey } from "@formbricks/types/v1/responses";
import { TSurvey } from "@formbricks/types/v1/surveys";
export default async function ResponseSection({
environmentId,
personId,
}: {
environmentId: string;
personId: string;
}) {
const responses = await getResponsesByPersonId(personId);
const surveyIds = responses?.map((response) => response.surveyId) || [];
const surveys: TSurvey[] = surveyIds.length === 0 ? [] : (await getSurveys(environmentId)) ?? [];
const responsesWithSurvey: TResponseWithSurvey[] =
responses?.reduce((acc: TResponseWithSurvey[], response) => {
const thisSurvey = surveys.find((survey) => survey?.id === response.surveyId);
if (thisSurvey) {
acc.push({
...response,
survey: thisSurvey,
});
}
return acc;
}, []) || [];
return <ResponseTimeline environmentId={environmentId} responses={responsesWithSurvey} />;
}

View File

@@ -0,0 +1,35 @@
"use client";
import ResponseFeed from "@/app/(app)/environments/[environmentId]/people/[personId]/(responseSection)/ResponsesFeed";
import { TResponseWithSurvey } from "@formbricks/types/v1/responses";
import { ArrowsUpDownIcon } from "@heroicons/react/24/outline";
import { useState } from "react";
export default function ResponseTimeline({
environmentId,
responses,
}: {
environmentId: string;
responses: TResponseWithSurvey[];
}) {
const [responsesAscending, setResponsesAscending] = useState(true);
const toggleSortResponses = () => {
setResponsesAscending(!responsesAscending);
};
return (
<div className="md:col-span-2">
<div className="flex items-center justify-between pb-6">
<h2 className="text-lg font-bold text-slate-700">Responses</h2>
<div className="text-right">
<button
onClick={toggleSortResponses}
className="hover:text-brand-dark flex items-center px-1 text-slate-800">
<ArrowsUpDownIcon className="inline h-4 w-4" />
</button>
</div>
</div>
<ResponseFeed responses={responses} sortByDate={responsesAscending} environmentId={environmentId} />
</div>
);
}

View File

@@ -1,26 +1,35 @@
import { formatDistance } from "date-fns";
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
import SurveyStatusIndicator from "@/components/shared/SurveyStatusIndicator";
import { timeSince } from "@formbricks/lib/time";
import { TResponseWithSurvey } from "@formbricks/types/v1/responses";
import Link from "next/link";
export default function ResponseFeed({ person, sortByDate, environmentId }) {
export default function ResponseFeed({
responses,
sortByDate,
environmentId,
}: {
responses: TResponseWithSurvey[];
sortByDate: boolean;
environmentId: string;
}) {
return (
<>
{person.responses.length === 0 ? (
{responses.length === 0 ? (
<EmptySpaceFiller type="response" environmentId={environmentId} />
) : (
<div>
{person.responses
{responses
.slice()
.sort((a, b) =>
sortByDate
? new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
: new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
)
.map((response, responseIdx) => (
<li key={response.createdAt} className="list-none">
.map((response: TResponseWithSurvey, responseIdx) => (
<li key={response.id} className="list-none">
<div className="relative pb-8">
{responseIdx !== person.responses.length - 1 ? (
{responseIdx !== responses.length - 1 ? (
<span
className="absolute left-4 top-4 -ml-px h-full w-0.5 bg-slate-200"
aria-hidden="true"
@@ -31,8 +40,14 @@ export default function ResponseFeed({ person, sortByDate, environmentId }) {
<div className="px-4 py-5 sm:p-6">
<div className="flex w-full justify-between">
<div className="text-sm text-slate-400">
<time className="text-slate-700" dateTime={timeSince(response.createdAt)}>
{timeSince(response.createdAt)}
<time
className="text-slate-700"
dateTime={formatDistance(response.createdAt, new Date(), {
addSuffix: true,
})}>
{formatDistance(response.createdAt, new Date(), {
addSuffix: true,
})}
</time>
</div>
<div className="flex items-center justify-center space-x-2 rounded-full bg-slate-50 px-3 py-1 text-sm text-slate-600">
@@ -52,8 +67,8 @@ export default function ResponseFeed({ person, sortByDate, environmentId }) {
<div key={question.id}>
<p className="text-sm text-slate-500">{question.headline}</p>
<p className="ph-no-capture my-1 text-lg font-semibold text-slate-700">
{response.data[question.id] instanceof Array
? response.data[question.id].join(", ")
{Array.isArray(response.data[question.id])
? (response.data[question.id] as string[]).join(", ")
: response.data[question.id]}
</p>
</div>

View File

@@ -1,114 +0,0 @@
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
import { useMemo } from "react";
import { ActivityItemContent, ActivityItemIcon, ActivityItemPopover } from "./ActivityItemComponents";
interface ActivityFeedProps {
sessions: any[];
attributes: any[];
displays: any[];
responses: any[];
sortByDate: boolean;
environmentId: string;
}
export type ActivityFeedItem = {
id: string;
type: "event" | "attribute" | "display";
createdAt: string;
updatedAt?: string;
attributeLabel?: string;
attributeValue?: string;
displaySurveyId?: string;
eventLabel?: string;
eventDescription?: string;
eventType?: string;
};
export default function ActivityFeed({
sessions,
attributes,
displays,
responses,
sortByDate,
environmentId,
}: ActivityFeedProps) {
// Convert Attributes into unified format
const unifiedAttributes = useMemo(() => {
if (attributes) {
return attributes.map((attribute) => ({
id: attribute.id,
type: "attribute",
createdAt: attribute.createdAt,
updatedAt: attribute.updatedAt,
attributeLabel: attribute.attributeClass.name,
attributeValue: attribute.value,
}));
}
return [];
}, [attributes]);
// Convert Displays into unified format
const unifiedDisplays = useMemo(() => {
if (displays) {
return displays.map((display) => ({
id: display.id,
type: "display",
createdAt: display.createdAt,
updatedAt: display.updatedAt,
displaySurveyId: display.surveyId,
}));
}
return [];
}, [displays]);
// Convert Events into unified format
const unifiedEvents = useMemo(() => {
if (sessions) {
return sessions.flatMap((session) =>
session.events.map((event) => ({
id: event.id,
type: "event",
eventType: event.eventClass.type,
createdAt: event.createdAt,
eventLabel: event.eventClass.name,
eventDescription: event.eventClass.description,
}))
);
}
return [];
}, [sessions]);
const unifiedList = useMemo<ActivityFeedItem[]>(() => {
return [...unifiedAttributes, ...unifiedDisplays, ...unifiedEvents].sort((a, b) =>
sortByDate
? new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
: new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
}, [unifiedAttributes, unifiedDisplays, unifiedEvents, sortByDate]);
return (
<>
{unifiedList.length === 0 ? (
<EmptySpaceFiller type={"event"} environmentId={environmentId} />
) : (
<div>
{unifiedList.map((activityItem) => (
<li key={activityItem.id} className="list-none">
<div className="relative pb-12">
<span className="absolute left-6 top-4 -ml-px h-full w-0.5 bg-slate-200" aria-hidden="true" />
<div className="relative">
<ActivityItemPopover activityItem={activityItem} responses={responses}>
<div className="flex space-x-3 text-left">
<ActivityItemIcon activityItem={activityItem} />
<ActivityItemContent activityItem={activityItem} />
</div>
</ActivityItemPopover>
</div>
</div>
</li>
))}
</div>
)}
</>
);
}

View File

@@ -0,0 +1,52 @@
"use client";
import DeleteDialog from "@/components/shared/DeleteDialog";
import GoBackButton from "@/components/shared/GoBackButton";
import { deletePersonAction } from "./actions";
import { TPerson } from "@formbricks/types/v1/people";
import { TrashIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
export default function HeadingSection({
environmentId,
person,
}: {
environmentId: string;
person: TPerson;
}) {
const router = useRouter();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const handleDeletePerson = async () => {
await deletePersonAction(person.id);
router.push(`/environments/${environmentId}/people`);
toast.success("Person deleted successfully.");
};
return (
<>
<GoBackButton />
<div className="flex items-baseline justify-between border-b border-slate-200 pb-6 pt-4">
<h1 className="ph-no-capture text-4xl font-bold tracking-tight text-slate-900">
<span>{person.attributes.email || person.id}</span>
</h1>
<div className="flex items-center space-x-3">
<button
onClick={() => {
setDeleteDialogOpen(true);
}}>
<TrashIcon className="h-5 w-5 text-slate-500 hover:text-red-700" />
</button>
</div>
</div>
<DeleteDialog
open={deleteDialogOpen}
setOpen={setDeleteDialogOpen}
deleteWhat="person"
onDelete={handleDeletePerson}
/>
</>
);
}

View File

@@ -1,183 +0,0 @@
"use client";
import DeleteDialog from "@/components/shared/DeleteDialog";
import GoBackButton from "@/components/shared/GoBackButton";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { deletePerson, usePerson } from "@/lib/people/people";
import { capitalizeFirstLetter } from "@/lib/utils";
import { ErrorComponent } from "@formbricks/ui";
import { ArrowsUpDownIcon, TrashIcon } from "@heroicons/react/24/outline";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import ActivityFeed from "./ActivityFeed";
import ResponseFeed from "./ResponsesFeed";
interface PersonDetailsProps {
environmentId: string;
personId: string;
}
export default function PersonDetails({ environmentId, personId }: PersonDetailsProps) {
const router = useRouter();
const { person, isLoadingPerson, isErrorPerson } = usePerson(environmentId, personId);
const [responsesAscending, setResponsesAscending] = useState(true);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [activityAscending, setActivityAscending] = useState(true);
const personEmail = useMemo(
() => person?.attributes?.find((attribute) => attribute.attributeClass.name === "email"),
[person]
);
const personUserId = useMemo(
() => person?.attributes?.find((attribute) => attribute.attributeClass.name === "userId"),
[person]
);
const otherAttributes = useMemo(
() =>
person?.attributes?.filter(
(attribute) =>
attribute.attributeClass.name !== "email" &&
attribute.attributeClass.name !== "userId" &&
!attribute.attributeClass.archived
) as any[],
[person]
);
const toggleSortResponses = () => {
setResponsesAscending(!responsesAscending);
};
const handleDeletePerson = async () => {
await deletePerson(environmentId, personId);
router.push(`/environments/${environmentId}/people`);
toast.success("Person deleted successfully.");
};
const toggleSortActivity = () => {
setActivityAscending(!activityAscending);
};
if (isLoadingPerson) {
return (
<div className="flex h-full w-full items-center justify-center">
<LoadingSpinner />
</div>
);
}
if (isErrorPerson) {
return <ErrorComponent />;
}
return (
<>
<GoBackButton />
<div className="flex items-baseline justify-between border-b border-slate-200 pb-6 pt-4">
<h1 className="ph-no-capture text-4xl font-bold tracking-tight text-slate-900">
{personEmail ? <span>{personEmail.value}</span> : <span>{person.id}</span>}
</h1>
<div className="flex items-center space-x-3">
<button
onClick={() => {
setDeleteDialogOpen(true);
}}>
<TrashIcon className="h-5 w-5 text-slate-500 hover:text-red-700" />
</button>
</div>
</div>
<section className="pb-24 pt-6">
<div className="grid grid-cols-1 gap-x-8 md:grid-cols-4">
<div className="space-y-6">
<h2 className="text-lg font-bold text-slate-700">Attributes</h2>
<div>
<dt className="text-sm font-medium text-slate-500">Email</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{personEmail ? (
<span>{personEmail?.value}</span>
) : (
<span className="text-slate-300">Not provided</span>
)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">User Id</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{personUserId ? (
<span>{personUserId?.value}</span>
) : (
<span className="text-slate-300">Not provided</span>
)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">Formbricks Id (internal)</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">{person.id}</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">Sessions</dt>
<dd className="mt-1 text-sm text-slate-900">{person.sessions.length}</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">Responses</dt>
<dd className="mt-1 text-sm text-slate-900">{person.responses.length}</dd>
</div>
{otherAttributes.map((attribute) => (
<div key={attribute.attributeClass.name}>
<dt className="text-sm font-medium text-slate-500">
{capitalizeFirstLetter(attribute.attributeClass.name)}
</dt>
<dd className="mt-1 text-sm text-slate-900">{attribute.value}</dd>
</div>
))}
</div>
<div className="md:col-span-2">
<div className="flex items-center justify-between pb-6">
<h2 className="text-lg font-bold text-slate-700">Responses</h2>
<div className="text-right">
<button
onClick={toggleSortResponses}
className="hover:text-brand-dark flex items-center px-1 text-slate-800">
<ArrowsUpDownIcon className="inline h-4 w-4" />
</button>
</div>
</div>
<ResponseFeed person={person} sortByDate={responsesAscending} environmentId={environmentId} />
</div>
<div className="md:col-span-1">
<div className="flex items-center justify-between pb-6">
<h2 className="text-lg font-bold text-slate-700">Activity Timeline</h2>
<div className="text-right">
<button
onClick={toggleSortActivity}
className="hover:text-brand-dark flex items-center px-1 text-slate-800">
<ArrowsUpDownIcon className="inline h-4 w-4" />
</button>
</div>
</div>
<ActivityFeed
sessions={person.sessions}
attributes={person.attributes}
displays={person.displays}
responses={person.responses}
sortByDate={activityAscending}
environmentId={environmentId}
/>
</div>
</div>
</section>
<DeleteDialog
open={deleteDialogOpen}
setOpen={setDeleteDialogOpen}
deleteWhat="person"
onDelete={handleDeletePerson}
/>
</>
);
}

View File

@@ -0,0 +1,7 @@
"use server";
import { deletePerson } from "@formbricks/lib/services/person";
export const deletePersonAction = async (personId: string) => {
await deletePerson(personId);
};

View File

@@ -0,0 +1,145 @@
import { ArrowsUpDownIcon, TrashIcon } from "@heroicons/react/24/outline";
import {
ActivityItemPopover,
ActivityItemIcon,
} from "@/app/(app)/environments/[environmentId]/people/[personId]/(activitySection)/ActivityItemComponents";
import { BackIcon } from "@formbricks/ui";
import { TActivityFeedItem } from "@formbricks/types/v1/activity";
export default function Loading() {
const unifiedList: TActivityFeedItem[] = [
{
id: "clk9o7gnu000kz8kw4nb26o21",
type: "event",
actionType: "noCode",
createdAt: new Date(),
actionLabel: "Loading User Acitivity",
updatedAt: null,
attributeLabel: null,
attributeValue: null,
actionDescription: null,
displaySurveyName: null,
},
{
id: "clk9o7fwc000iz8kw4s0ha0ql",
type: "event",
actionType: "automatic",
createdAt: new Date(),
actionLabel: "Loading User Session Info",
updatedAt: null,
attributeLabel: null,
attributeValue: null,
actionDescription: null,
displaySurveyName: null,
},
];
return (
<div>
<main className="mx-auto px-4 sm:px-6 lg:px-8">
<div className="pointer-events-none animate-pulse cursor-not-allowed select-none">
<button className="inline-flex pt-5 text-sm text-slate-500">
<BackIcon className="mr-2 h-5 w-5" />
Back
</button>
</div>
<div className="flex items-baseline justify-between border-b border-slate-200 pb-6 pt-4">
<h1 className="ph-no-capture text-4xl font-bold tracking-tight text-slate-900">
<span className="animate-pulse rounded-full">Fetching user</span>
</h1>
<div className="flex items-center space-x-3">
<button className="pointer-events-none animate-pulse cursor-not-allowed select-none">
<TrashIcon className="h-5 w-5 text-slate-500 hover:text-red-700" />
</button>
</div>
</div>
<section className="pb-24 pt-6">
<div className="grid grid-cols-1 gap-x-8 md:grid-cols-4">
<div className="space-y-6">
<h2 className="text-lg font-bold text-slate-700">Attributes</h2>
<div>
<dt className="text-sm font-medium text-slate-500">Email</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
<span className="animate-pulse text-slate-300">Loading</span>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">User Id</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
<span className="animate-pulse text-slate-300">Loading</span>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">Formbricks Id (internal)</dt>
<dd className="mt-1 animate-pulse text-sm text-slate-300">Loading</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">Sessions</dt>
<dd className="mt-1 animate-pulse text-sm text-slate-300">Loading</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">Responses</dt>
<dd className="mt-1 animate-pulse text-sm text-slate-300">Loading</dd>
</div>
</div>
<div className="md:col-span-2">
<div className="flex items-center justify-between pb-6">
<h2 className="text-lg font-bold text-slate-700">Responses</h2>
<div className="text-right">
<button className="hover:text-brand-dark pointer-events-none flex animate-pulse cursor-not-allowed select-none items-center px-1 text-slate-800">
<ArrowsUpDownIcon className="inline h-4 w-4" />
</button>
</div>
</div>
<div className="group space-y-4 rounded-lg bg-white p-6 ">
<div className="flex items-center space-x-4">
<div className="h-12 w-12 flex-shrink-0 rounded-full bg-slate-100"></div>
<div className=" h-6 w-full rounded-full bg-slate-100"></div>
</div>
<div className="space-y-4">
<div className="h-12 w-full rounded-full bg-slate-100"></div>
<div className=" flex h-12 w-full items-center justify-center rounded-full bg-slate-50 text-sm text-slate-500 hover:bg-slate-100">
<span className="animate-pulse text-center">Loading user responses</span>
</div>
<div className="h-12 w-full rounded-full bg-slate-50/50"></div>
</div>
</div>
</div>
<div className="md:col-span-1">
<div className="flex items-center justify-between pb-6">
<h2 className="text-lg font-bold text-slate-700">Activity Timeline</h2>
<div className="text-right">
<button className="hover:text-brand-dark pointer-events-none flex animate-pulse cursor-not-allowed select-none items-center px-1 text-slate-800">
<ArrowsUpDownIcon className="inline h-4 w-4" />
</button>
</div>
</div>
<div>
{unifiedList.map((activityItem) => (
<li key={activityItem.id} className="list-none">
<div className="relative pb-12">
<span
className="absolute left-6 top-4 -ml-px h-full w-0.5 bg-slate-200"
aria-hidden="true"
/>
<div className="relative animate-pulse cursor-not-allowed select-none">
<ActivityItemPopover activityItem={activityItem}>
<div className="flex cursor-not-allowed select-none items-center space-x-3">
<ActivityItemIcon activityItem={activityItem} />
<div className="font-semibold text-slate-700">Loading</div>
</div>
</ActivityItemPopover>
</div>
</div>
</li>
))}
</div>
</div>
</div>
</section>
</main>
</div>
);
}

View File

@@ -1,10 +1,31 @@
import PersonDetails from "./PersonDetails";
export const revalidate = REVALIDATION_INTERVAL;
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { getPerson } from "@formbricks/lib/services/person";
import AttributesSection from "@/app/(app)/environments/[environmentId]/people/[personId]/(attributeSection)/AttributesSection";
import ActivitySection from "@/app/(app)/environments/[environmentId]/people/[personId]/(activitySection)/ActivitySection";
import HeadingSection from "@/app/(app)/environments/[environmentId]/people/[personId]/HeadingSection";
import ResponseSection from "@/app/(app)/environments/[environmentId]/people/[personId]/(responseSection)/ResponseSection";
export default async function PersonPage({ params }) {
const person = await getPerson(params.personId);
if (!person) {
throw new Error("No such person found");
}
export default function PersonPage({ params }) {
return (
<div>
<main className="mx-auto px-4 sm:px-6 lg:px-8">
<PersonDetails personId={params.personId} environmentId={params.environmentId} />
<>
<HeadingSection environmentId={params.environmentId} person={person} />
<section className="pb-24 pt-6">
<div className="grid grid-cols-1 gap-x-8 md:grid-cols-4">
<AttributesSection personId={params.personId} />
<ResponseSection environmentId={params.environmentId} personId={params.personId} />
<ActivitySection environmentId={params.environmentId} personId={params.personId} />
</div>
</section>
</>
</main>
</div>
);

View File

@@ -3,7 +3,7 @@ import { responses } from "@/lib/api/response";
import { transformErrorToDetails } from "@/lib/api/validator";
import { prisma } from "@formbricks/database";
import { getActionClasses } from "@formbricks/lib/services/actionClass";
import { getPerson, select, transformPrismaPerson } from "@formbricks/lib/services/person";
import { getPerson, selectPerson, transformPrismaPerson } from "@formbricks/lib/services/person";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import { extendSession } from "@formbricks/lib/services/session";
import { TJsState, ZJsPeopleAttributeInput } from "@formbricks/types/v1/js";
@@ -94,7 +94,7 @@ export async function POST(req: Request, { params }): Promise<NextResponse> {
},
select: {
person: {
select,
select: selectPerson,
},
},
});

View File

@@ -3,7 +3,7 @@ import { responses } from "@/lib/api/response";
import { transformErrorToDetails } from "@/lib/api/validator";
import { prisma } from "@formbricks/database";
import { getActionClasses } from "@formbricks/lib/services/actionClass";
import { deletePerson, select, transformPrismaPerson } from "@formbricks/lib/services/person";
import { deletePerson, selectPerson, transformPrismaPerson } from "@formbricks/lib/services/person";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import { extendSession } from "@formbricks/lib/services/session";
import { TJsState, ZJsPeopleUserIdInput } from "@formbricks/types/v1/js";
@@ -45,7 +45,7 @@ export async function POST(req: Request, { params }): Promise<NextResponse> {
},
},
},
select,
select: selectPerson,
});
// if person exists, reconnect session and delete old user
if (existingPerson) {
@@ -87,7 +87,7 @@ export async function POST(req: Request, { params }): Promise<NextResponse> {
},
},
},
select,
select: selectPerson,
});
}

View File

@@ -1,4 +1,7 @@
export function capitalizeFirstLetter(string = "") {
export function capitalizeFirstLetter(string: string | null = "") {
if (string === null) {
return "";
}
return string.charAt(0).toUpperCase() + string.slice(1);
}

View File

@@ -0,0 +1,78 @@
import { prisma } from "@formbricks/database";
import { TActivityFeedItem } from "@formbricks/types/v1/activity";
export const getActivityTimeline = async (personId: string): Promise<TActivityFeedItem[]> => {
const person = await prisma.person.findUnique({
where: {
id: personId,
},
include: {
attributes: {
include: {
attributeClass: true,
},
},
displays: {
include: {
survey: true,
},
},
sessions: {
include: {
events: {
include: {
eventClass: true,
},
},
},
},
},
});
if (!person) {
throw new Error("No such person found");
}
const { attributes, displays, sessions } = person;
const unifiedAttributes: TActivityFeedItem[] = attributes.map((attribute) => ({
id: attribute.id,
type: "attribute",
createdAt: attribute.createdAt,
updatedAt: attribute.updatedAt,
attributeLabel: attribute.attributeClass.name,
attributeValue: attribute.value,
actionLabel: null,
actionDescription: null,
actionType: null,
displaySurveyName: null,
}));
const unifiedDisplays: TActivityFeedItem[] = displays.map((display) => ({
id: display.id,
type: "display",
createdAt: display.createdAt,
updatedAt: display.updatedAt,
attributeLabel: null,
attributeValue: null,
actionLabel: null,
actionDescription: null,
actionType: null,
displaySurveyName: display.survey.name,
}));
const unifiedEvents: TActivityFeedItem[] = sessions.flatMap((session) =>
session.events.map((event) => ({
id: event.id,
type: "event",
createdAt: event.createdAt,
updatedAt: null,
attributeLabel: null,
attributeValue: null,
actionLabel: event.eventClass?.name || null,
actionDescription: event.eventClass?.description || null,
actionType: event.eventClass?.type || null,
displaySurveyName: null,
}))
);
const unifiedList: TActivityFeedItem[] = [...unifiedAttributes, ...unifiedDisplays, ...unifiedEvents];
return unifiedList;
};

View File

@@ -1,5 +1,5 @@
import { prisma } from "@formbricks/database";
import { TDisplay, TDisplayInput } from "@formbricks/types/v1/displays";
import { TDisplay, TDisplayInput, TDisplaysWithSurveyName } from "@formbricks/types/v1/displays";
import { Prisma } from "@prisma/client";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/errors";
import { transformPrismaPerson } from "./person";
@@ -98,3 +98,52 @@ export const markDisplayResponded = async (displayId: string): Promise<TDisplay>
throw error;
}
};
export const getDisplaysOfPerson = async (personId: string): Promise<TDisplaysWithSurveyName[] | null> => {
try {
const displaysPrisma = await prisma.display.findMany({
where: {
personId: personId,
},
select: {
id: true,
createdAt: true,
updatedAt: true,
surveyId: true,
survey: {
select: {
name: true,
},
},
status: true,
},
});
if (!displaysPrisma) {
throw new ResourceNotFoundError("Display from PersonId", personId);
}
let displays: TDisplaysWithSurveyName[] = [];
displaysPrisma.forEach((displayPrisma) => {
const display: TDisplaysWithSurveyName = {
id: displayPrisma.id,
createdAt: displayPrisma.createdAt,
updatedAt: displayPrisma.updatedAt,
person: null,
status: displayPrisma.status,
surveyId: displayPrisma.surveyId,
surveyName: displayPrisma.survey.name,
};
displays.push(display);
});
return displays;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
};

View File

@@ -1,14 +1,21 @@
import "server-only";
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/errors";
import { TPerson } from "@formbricks/types/v1/people";
import { Prisma } from "@prisma/client";
import { cache } from "react";
export const select = {
export const selectPerson = {
id: true,
createdAt: true,
updatedAt: true,
attributes: {
where: {
attributeClass: {
archived: false,
},
},
select: {
value: true,
attributeClass: {
@@ -52,7 +59,7 @@ export const getPerson = async (personId: string): Promise<TPerson | null> => {
where: {
id: personId,
},
select,
select: selectPerson,
});
if (!personPrisma) {
@@ -77,7 +84,7 @@ export const getPeople = cache(async (environmentId: string): Promise<TPerson[]>
where: {
environmentId: environmentId,
},
select,
select: selectPerson,
});
if (!personsPrisma) {
throw new ResourceNotFoundError("Persons", "All Persons");
@@ -107,7 +114,7 @@ export const createPerson = async (environmentId: string): Promise<TPerson> => {
},
},
},
select,
select: selectPerson,
});
const person = transformPrismaPerson(personPrisma);

View File

@@ -1,7 +1,7 @@
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/errors";
import { TPerson } from "@formbricks/types/v1/people";
import { TResponse, TResponseInput, TResponseUpdateInput } from "@formbricks/types/v1/responses";
import { TPerson } from "@formbricks/types/v1/people";
import { TTag } from "@formbricks/types/v1/tags";
import { Prisma } from "@prisma/client";
import { cache } from "react";
@@ -63,6 +63,39 @@ const responseSelection = {
},
};
export const getResponsesByPersonId = async (personId: string): Promise<Array<TResponse> | null> => {
try {
const responsePrisma = await prisma.response.findMany({
where: {
personId,
},
select: responseSelection,
});
if (!responsePrisma) {
throw new ResourceNotFoundError("Response from PersonId", personId);
}
let responses: Array<TResponse> = [];
responsePrisma.forEach((response) => {
responses.push({
...response,
person: response.person ? transformPrismaPerson(response.person) : null,
tags: response.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
});
});
return responses;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
};
export const createResponse = async (responseInput: Partial<TResponseInput>): Promise<TResponse> => {
try {
let person: TPerson | null = null;

View File

@@ -1,7 +1,11 @@
"use server";
import "server-only";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/errors";
import { TSession } from "@formbricks/types/v1/sessions";
import { TSession, TSessionWithActions } from "@formbricks/types/v1/sessions";
import { Prisma } from "@prisma/client";
import { cache } from "react";
const select = {
id: true,
@@ -32,6 +36,58 @@ export const getSession = async (sessionId: string): Promise<TSession | null> =>
}
};
export const getSessionWithActionsOfPerson = async (
personId: string
): Promise<TSessionWithActions[] | null> => {
try {
const sessionsWithActionsForPerson = await prisma.session.findMany({
where: {
personId,
},
select: {
id: true,
events: {
select: {
id: true,
createdAt: true,
eventClass: {
select: {
name: true,
description: true,
type: true,
},
},
},
},
},
});
if (!sessionsWithActionsForPerson) return null;
return sessionsWithActionsForPerson;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
};
export const getSessionCount = cache(async (personId: string): Promise<number> => {
try {
const sessionCount = await prisma.session.count({
where: {
personId,
},
});
return sessionCount;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
});
export const createSession = async (personId: string): Promise<TSession> => {
try {
const session = await prisma.session.create({

View File

@@ -0,0 +1,16 @@
import { z } from "zod";
export const ZActivityFeedItem = z.object({
id: z.string().cuid2(),
type: z.enum(["event", "attribute", "display"]),
createdAt: z.date(),
updatedAt: z.date().nullable(),
attributeLabel: z.string().nullable(),
attributeValue: z.string().nullable(),
actionLabel: z.string().nullable(),
actionDescription: z.string().nullable(),
actionType: z.string().nullable(),
displaySurveyName: z.string().nullable(),
});
export type TActivityFeedItem = z.infer<typeof ZActivityFeedItem>;

View File

@@ -18,3 +18,9 @@ export const ZDisplayInput = z.object({
});
export type TDisplayInput = z.infer<typeof ZDisplayInput>;
export const ZDisplaysWithSurveyName = ZDisplay.extend({
surveyName: z.string(),
});
export type TDisplaysWithSurveyName = z.infer<typeof ZDisplaysWithSurveyName>;

View File

@@ -1,5 +1,6 @@
import { z } from "zod";
import { ZPersonAttributes } from "./people";
import { ZSurvey } from "./surveys";
import { ZTag } from "./tags";
export const ZResponseData = z.record(z.union([z.string(), z.number(), z.array(z.string())]));
@@ -88,3 +89,9 @@ export const ZResponseUpdateInput = z.object({
});
export type TResponseUpdateInput = z.infer<typeof ZResponseUpdateInput>;
export const ZResponseWithSurvey = ZResponse.extend({
survey: ZSurvey,
});
export type TResponseWithSurvey = z.infer<typeof ZResponseWithSurvey>;

View File

@@ -9,3 +9,22 @@ export const ZSession = z.object({
});
export type TSession = z.infer<typeof ZSession>;
export const ZSessionWithActions = z.object({
id: z.string().cuid2(),
events: z.array(
z.object({
id: z.string().cuid2(),
createdAt: z.date(),
eventClass: z
.object({
name: z.string(),
description: z.union([z.string(), z.null()]),
type: z.enum(["code", "noCode", "automatic"]),
})
.nullable(),
})
),
});
export type TSessionWithActions = z.infer<typeof ZSessionWithActions>;