refactor: Migrate activity service (#1471)

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Dhruwang Jariwala
2023-10-30 17:46:20 +05:30
committed by GitHub
parent c42d48e242
commit 2e2c22a1db
9 changed files with 152 additions and 271 deletions

View File

@@ -1,43 +0,0 @@
import EmptySpaceFiller from "@formbricks/ui/EmptySpaceFiller";
import { ActivityItemContent, ActivityItemIcon, ActivityItemPopover } from "./ActivityItemComponents";
import { TActivityFeedItem } from "@formbricks/types/activity";
import { TEnvironment } from "@formbricks/types/environment";
interface ActivityFeedProps {
activities: TActivityFeedItem[];
sortByDate: boolean;
environment: TEnvironment;
}
export default function ActivityFeed({ activities, sortByDate, environment }: 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"} environment={environment} />
) : (
<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,54 +1,30 @@
import { capitalizeFirstLetter } from "@/app/lib/utils";
import { TActivityFeedItem } from "@formbricks/types/activity";
import { Popover, PopoverContent, PopoverTrigger } from "@formbricks/ui/Popover";
import { Label } from "@formbricks/ui/Label";
import {
CodeBracketIcon,
CursorArrowRaysIcon,
EyeIcon,
QuestionMarkCircleIcon,
SparklesIcon,
TagIcon,
} from "@heroicons/react/24/solid";
import { formatDistance } from "date-fns";
import { TAction } from "@formbricks/types/actions";
import { CodeBracketIcon, CursorArrowRaysIcon, SparklesIcon } from "@heroicons/react/24/solid";
export const ActivityItemIcon = ({ activityItem }: { activityItem: TActivityFeedItem }) => (
export const ActivityItemIcon = ({ actionItem }: { actionItem: TAction }) => (
<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.actionType === "code" && <CodeBracketIcon />}
{activityItem.actionType === "noCode" && <CursorArrowRaysIcon />}
{activityItem.actionType === "automatic" && <SparklesIcon />}
</div>
) : (
<QuestionMarkCircleIcon />
)}
<div>
{actionItem.actionClass?.type === "code" && <CodeBracketIcon />}
{actionItem.actionClass?.type === "noCode" && <CursorArrowRaysIcon />}
{actionItem.actionClass?.type === "automatic" && <SparklesIcon />}
</div>
</div>
);
export const ActivityItemContent = ({ activityItem }: { activityItem: TActivityFeedItem }) => (
export const ActivityItemContent = ({ actionItem }: { actionItem: TAction }) => (
<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.actionLabel} triggered</p>
) : (
<p>Unknown Activity</p>
)}
{actionItem.actionClass ? <p>{actionItem.actionClass.name}</p> : <p>Unknown Activity</p>}
</div>
<div className="text-sm text-slate-400">
<time
dateTime={formatDistance(activityItem.createdAt, new Date(), {
dateTime={formatDistance(actionItem.createdAt, new Date(), {
addSuffix: true,
})}>
{formatDistance(activityItem.createdAt, new Date(), {
{formatDistance(actionItem.createdAt, new Date(), {
addSuffix: true,
})}
</time>
@@ -57,10 +33,10 @@ export const ActivityItemContent = ({ activityItem }: { activityItem: TActivityF
);
export const ActivityItemPopover = ({
activityItem,
actionItem,
children,
}: {
activityItem: TActivityFeedItem;
actionItem: TAction;
children: React.ReactNode;
}) => {
return (
@@ -68,39 +44,15 @@ export const ActivityItemPopover = ({
<PopoverTrigger className="group">{children}</PopoverTrigger>
<PopoverContent className="bg-white">
<div>
{activityItem.type === "attribute" ? (
{actionItem && (
<div>
<Label className="font-normal text-slate-400">Attribute Label</Label>
<p className=" mb-2 text-sm font-medium text-slate-900">{activityItem.attributeLabel}</p>
<Label className="font-normal text-slate-400">Attribute Value</Label>
<p className="text-sm font-medium text-slate-900">{activityItem.attributeValue}</p>
<Label className="font-normal text-slate-400">Action Label</Label>
<p className=" mb-2 text-sm font-medium text-slate-900">{actionItem.actionClass!.name}</p>
<Label className="font-normal text-slate-400">Action Description</Label>
<p className="text-sm font-medium text-slate-900">{actionItem.actionClass!.description}</p>
<Label className="font-normal text-slate-400">Action Type</Label>
<p className="text-sm font-medium text-slate-900">{actionItem.actionClass!.type}</p>
</div>
) : 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">{activityItem.displaySurveyName}</p>
</div>
) : activityItem.type === "event" ? (
<div>
<div>
<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.actionDescription ? (
<span>{activityItem.actionDescription}</span>
) : (
<span>-</span>
)}
</p>
<Label className="font-normal text-slate-400">Action Type</Label>
<p className="text-sm font-medium text-slate-900">
{capitalizeFirstLetter(activityItem.actionType)}
</p>
</div>
</div>
) : (
<QuestionMarkCircleIcon />
)}
</div>
</PopoverContent>

View File

@@ -1,5 +1,5 @@
import ActivityTimeline from "@/app/(app)/environments/[environmentId]/people/[personId]/components/ActivityTimeline";
import { getActivityTimeline } from "@formbricks/lib/activity/service";
import { getActionsByPersonId } from "@formbricks/lib/action/service";
import { getEnvironment } from "@formbricks/lib/environment/service";
export default async function ActivitySection({
@@ -9,17 +9,18 @@ export default async function ActivitySection({
environmentId: string;
personId: string;
}) {
const [activities, environment] = await Promise.all([
getActivityTimeline(personId),
const [environment, actions] = await Promise.all([
getEnvironment(environmentId),
getActionsByPersonId(personId, 1),
]);
if (!environment) {
throw new Error("Environment not found");
}
return (
<div className="md:col-span-1">
<ActivityTimeline environment={environment} activities={activities} />
<ActivityTimeline environment={environment} actions={actions.slice(0, 10)} />
</div>
);
}

View File

@@ -1,37 +1,57 @@
"use client";
import ActivityFeed from "@/app/(app)/environments/[environmentId]/people/[personId]/components/ActivityFeed";
import { TActivityFeedItem } from "@formbricks/types/activity";
import { TAction } from "@formbricks/types/actions";
import { TEnvironment } from "@formbricks/types/environment";
import { ArrowsUpDownIcon } from "@heroicons/react/24/outline";
import { useState } from "react";
import EmptySpaceFiller from "@formbricks/ui/EmptySpaceFiller";
import { ActivityItemContent, ActivityItemIcon, ActivityItemPopover } from "./ActivityItemComponents";
export default function ActivityTimeline({
environment,
activities,
actions,
}: {
environment: TEnvironment;
activities: TActivityFeedItem[];
actions: TAction[];
}) {
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>
<h2 className="text-lg font-bold text-slate-700">Actions Timeline</h2>
</div>
<ActivityFeed activities={activities} sortByDate={activityAscending} environment={environment} />
<div className="relative">
{actions.length === 0 ? (
<EmptySpaceFiller type={"event"} environment={environment} />
) : (
<div>
{actions.map(
(actionItem, index) =>
actionItem && (
<li key={actionItem.id} className="list-none">
<div className="relative pb-12">
{index !== actions.length - 1 && (
<span
className="absolute left-6 top-4 -ml-px h-full w-0.5 bg-slate-200"
aria-hidden="true"
/>
)}
<div className="relative">
<ActivityItemPopover actionItem={actionItem}>
<div className="flex space-x-3 text-left">
<ActivityItemIcon actionItem={actionItem} />
<ActivityItemContent actionItem={actionItem} />
</div>
</ActivityItemPopover>
</div>
</div>
</li>
)
)}
<div className="relative">
{actions.length === 10 && (
<div className="absolute bottom-0 flex h-56 w-full items-end justify-center bg-gradient-to-t from-slate-50 to-transparent"></div>
)}
</div>
</div>
)}
</div>
</>
);
}

View File

@@ -2,36 +2,44 @@ import {
ActivityItemIcon,
ActivityItemPopover,
} from "@/app/(app)/environments/[environmentId]/people/[personId]/components/ActivityItemComponents";
import { TActivityFeedItem } from "@formbricks/types/activity";
import { TAction } from "@formbricks/types/actions";
import { BackIcon } from "@formbricks/ui/icons";
import { ArrowsUpDownIcon } from "@heroicons/react/24/outline";
import { TrashIcon } from "lucide-react";
export default function Loading() {
const unifiedList: TActivityFeedItem[] = [
const actionItemList: TAction[] = [
{
id: "clk9o7gnu000kz8kw4nb26o21",
type: "event",
actionType: "noCode",
id: "demoId1",
createdAt: new Date(),
actionLabel: "Loading User Acitivity",
updatedAt: null,
attributeLabel: null,
attributeValue: null,
actionDescription: null,
displaySurveyName: null,
sessionId: "",
properties: {},
actionClass: {
id: "demoId1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Loading User Acitivity",
description: null,
type: "automatic",
noCodeConfig: null,
environmentId: "testEnvironment",
},
},
{
id: "clk9o7fwc000iz8kw4s0ha0ql",
type: "event",
actionType: "automatic",
id: "demoId2",
createdAt: new Date(),
actionLabel: "Loading User Session Info",
updatedAt: null,
attributeLabel: null,
attributeValue: null,
actionDescription: null,
displaySurveyName: null,
sessionId: "",
properties: {},
actionClass: {
id: "demoId2",
createdAt: new Date(),
updatedAt: new Date(),
name: "Loading User Acitivity",
description: null,
type: "automatic",
noCodeConfig: null,
environmentId: "testEnvironment",
},
},
];
return (
@@ -118,17 +126,17 @@ export default function Loading() {
</div>
</div>
<div>
{unifiedList.map((activityItem) => (
<li key={activityItem.id} className="list-none">
{actionItemList.map((actionItem) => (
<li key={actionItem.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}>
<ActivityItemPopover actionItem={actionItem}>
<div className="flex cursor-not-allowed select-none items-center space-x-3">
<ActivityItemIcon activityItem={activityItem} />
<ActivityItemIcon actionItem={actionItem} />
<div className="font-semibold text-slate-700">Loading</div>
</div>
</ActivityItemPopover>

View File

@@ -2,6 +2,7 @@ import { revalidateTag } from "next/cache";
interface RevalidateProps {
environmentId?: string;
personId?: string;
}
export const actionCache = {
@@ -9,6 +10,9 @@ export const actionCache = {
byEnvironmentId(environmentId: string): string {
return `environments-${environmentId}-actions`;
},
byPersonId(personId: string): string {
return `environments-${personId}-actions`;
},
},
revalidate({ environmentId }: RevalidateProps): void {
if (environmentId) {

View File

@@ -70,6 +70,54 @@ export const getLatestActionByEnvironmentId = async (environmentId: string): Pro
: action;
};
export const getActionsByPersonId = async (personId: string, page?: number): Promise<TAction[]> => {
const actions = await unstable_cache(
async () => {
validateInputs([personId, ZId], [page, ZOptionalNumber]);
const actionsPrisma = await prisma.event.findMany({
where: {
session: {
personId: personId,
},
},
orderBy: {
createdAt: "desc",
},
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
include: {
eventClass: true,
},
});
const actions: TAction[] = [];
// transforming response to type TAction[]
actionsPrisma.forEach((action) => {
actions.push({
id: action.id,
createdAt: action.createdAt,
sessionId: action.sessionId,
properties: action.properties,
actionClass: action.eventClass,
});
});
return actions;
},
[`getActionsByPersonId-${personId}-${page}`],
{
tags: [actionCache.tag.byPersonId(personId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
// Deserialize dates if caching does not support deserialization
return actions.map((action) => ({
...action,
createdAt: new Date(action.createdAt),
}));
};
export const getActionsByEnvironmentId = async (environmentId: string, page?: number): Promise<TAction[]> => {
const actions = await unstable_cache(
async () => {

View File

@@ -1,98 +0,0 @@
import "server-only";
import { prisma } from "@formbricks/database";
import { TActivityFeedItem } from "@formbricks/types/activity";
import { validateInputs } from "../utils/validate";
import { ZId } from "@formbricks/types/environment";
import { unstable_cache } from "next/cache";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { personCache } from "../person/cache";
import { formatActivityFeedItemDateFields } from "./util";
import { SERVICES_REVALIDATION_INTERVAL } from "../constants";
export const getActivityTimeline = async (personId: string): Promise<TActivityFeedItem[]> => {
const activityFeedItem = await unstable_cache(
async () => {
validateInputs([personId, ZId]);
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 ResourceNotFoundError("Person", personId);
}
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;
},
[`getActivityTimeline-${personId}`],
{ tags: [personCache.tag.byId(personId)], revalidate: SERVICES_REVALIDATION_INTERVAL }
)();
return formatActivityFeedItemDateFields(activityFeedItem);
};

View File

@@ -1,11 +0,0 @@
import { TActivityFeedItem } from "@formbricks/types/activity";
export const formatActivityFeedItemDateFields = (
activityFeedItem: TActivityFeedItem[]
): TActivityFeedItem[] => {
return activityFeedItem.map((item) => ({
...item,
createdAt: new Date(item.createdAt),
updatedAt: item.updatedAt ? new Date(item.updatedAt) : item.updatedAt,
}));
};