Skip to content

Commit

Permalink
Rewrite Person Detail Page to Server Components (#609)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
ShubhamPalriwala and mattinannt committed Aug 6, 2023
1 parent 5c9605f commit fdb1aa2
Show file tree
Hide file tree
Showing 26 changed files with 796 additions and 356 deletions.
Original file line number Diff line number Diff line change
@@ -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>
)}
</>
);
}
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -9,68 +9,64 @@ 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 />
) : activityItem.type === "display" ? (
<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 />
)}
</div>
);

export const ActivityItemContent = ({ activityItem }: { activityItem: ActivityFeedItem }) => (
export const ActivityItemContent = ({ activityItem }: { activityItem: TActivityFeedItem }) => (
<div>
<div className="font-semibold text-slate-700">
{activityItem.type === "attribute" ? (
<p>{capitalizeFirstLetter(activityItem.attributeLabel)} added</p>
) : 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>
Expand All @@ -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>
Expand Down
Original file line number Diff line number Diff line change
@@ -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>
);
}
Original file line number Diff line number Diff line change
@@ -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} />
</>
);
}
Original file line number Diff line number Diff line change
@@ -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>
);
}
Original file line number Diff line number Diff line change
@@ -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} />;
}
Original file line number Diff line number Diff line change
@@ -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>
);
}
Loading

2 comments on commit fdb1aa2

@vercel
Copy link

@vercel vercel bot commented on fdb1aa2 Aug 6, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vercel
Copy link

@vercel vercel bot commented on fdb1aa2 Aug 6, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

formbricks-com – ./apps/formbricks-com

formbricks-com-git-main-formbricks.vercel.app
formbricks-com-formbricks.vercel.app
formbricks-com.vercel.app
formbricks.com
www.formbricks.com

Please sign in to comment.