chore: moves setup checklist to react server components (#695)

* Chore: moves setup checklist to RSC

* fix other merge conflictsg

* made code refactors

* added TAction as return type for getActions

* fixed build issues

* fix environmentNotice component

* refactor EnvironmentNotice component

* fix js tests

---------

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Dhruwang Jariwala
2023-09-06 11:48:49 +05:30
committed by GitHub
parent b0d7bd8686
commit 0a1de196aa
18 changed files with 291 additions and 156 deletions

View File

@@ -1,18 +1,10 @@
{
"extends": "@formbricks/tsconfig/nextjs.json",
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"../../packages/types/*.d.ts"
],
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "../../packages/types/*.d.ts"],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": [
"./*"
]
"@/*": ["./*"]
},
"strictNullChecks": true
},

View File

@@ -5,22 +5,27 @@ import SettingsCard from "../SettingsCard";
import SettingsTitle from "../SettingsTitle";
import ApiKeyList from "./ApiKeyList";
import EnvironmentNotice from "@/components/shared/EnvironmentNotice";
import { getEnvironment } from "@formbricks/lib/services/environment";
export default async function ProfileSettingsPage({ params }) {
const environment = await getEnvironment(params.environmentId);
return (
<div>
<SettingsTitle title="API Keys" />
<EnvironmentNotice environmentId={params.environmentId} pageType="apiSettings" />
<SettingsCard
title="Development Env Keys"
description="Add and remove API keys for your Development environment.">
<ApiKeyList environmentId={params.environmentId} environmentType="development" />
</SettingsCard>
<SettingsCard
title="Production Env Keys"
description="Add and remove API keys for your Production environment.">
<ApiKeyList environmentId={params.environmentId} environmentType="production" />
</SettingsCard>
<EnvironmentNotice environment={environment} />
{environment.type === "development" ? (
<SettingsCard
title="Development Env Keys"
description="Add and remove API keys for your Development environment.">
<ApiKeyList environmentId={params.environmentId} environmentType="development" />
</SettingsCard>
) : (
<SettingsCard
title="Production Env Keys"
description="Add and remove API keys for your Production environment.">
<ApiKeyList environmentId={params.environmentId} environmentType="production" />
</SettingsCard>
)}
</div>
);
}

View File

@@ -4,11 +4,11 @@ import CodeBlock from "@/components/shared/CodeBlock";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { TabBar } from "@formbricks/ui";
import Link from "next/link";
import Prism from "prismjs";
import "prismjs/themes/prism.css";
import { useEffect, useState } from "react";
import { useState } from "react";
import { IoLogoHtml5, IoLogoNpm } from "react-icons/io5";
import packageJson from "@/package.json";
import { WEBAPP_URL } from "@formbricks/lib/constants";
const tabs = [
{ id: "npm", label: "NPM", icon: <IoLogoNpm /> },
@@ -18,10 +18,6 @@ const tabs = [
export default function SetupInstructions({ environmentId }) {
const [activeTab, setActiveTab] = useState(tabs[0].id);
useEffect(() => {
Prism.highlightAll();
}, [activeTab]);
return (
<div>
<TabBar tabs={tabs} activeId={activeTab} setActiveId={setActiveTab} />
@@ -37,10 +33,8 @@ export default function SetupInstructions({ environmentId }) {
if (typeof window !== "undefined") {
formbricks.init({
environmentId: "${environmentId}",
apiHost: "${typeof window !== "undefined" && window.location.protocol}//${
typeof window !== "undefined" && window.location.host
}",
debug: true, // remove when in production
apiHost: "${WEBAPP_URL}",
debug: true, // remove when in production
});
}`}</CodeBlock>

View File

@@ -0,0 +1,8 @@
"use server";
import { updateEnvironment } from "@formbricks/lib/services/environment";
import { TEnvironment, TEnvironmentUpdateInput } from "@formbricks/types/v1/environment";
export async function updateEnvironmentAction(environmentId: string, data: Partial<TEnvironmentUpdateInput>): Promise<TEnvironment> {
return await updateEnvironment(environmentId, data);
}

View File

@@ -0,0 +1,50 @@
function LoadingCard({ title, description, skeletonLines }) {
return (
<div className="my-4 rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-100 px-6 py-5 text-left text-slate-900">
<h3 className="text-lg font-medium leading-6">{title}</h3>
<p className="mt-1 text-sm text-slate-500">{description}</p>
</div>
<div className="w-full">
<div className="rounded-lg px-6 py-5 hover:bg-slate-100">
{skeletonLines.map((line, index) => (
<div key={index} className="mt-4">
<div className={`animate-pulse bg-gray-200 ${line.classes}`}></div>
</div>
))}
</div>
</div>
</div>
);
}
export default function Loading() {
const cards = [
{
title: "Widget Status",
description: "Check if the Formbricks widget is alive and kicking.",
skeletonLines: [{ classes: "h-32 max-w-full rounded-md" }],
},
{
title: "How to setup",
description: "Follow these steps to setup the Formbricks widget within your app",
skeletonLines: [
{ classes: "h-6 w-24 rounded-full" },
{ classes: "h-4 w-60 rounded-full" },
{ classes: "h-4 w-60 rounded-full" },
{ classes: "h-6 w-24 rounded-full" },
{ classes: "h-4 w-60 rounded-full" },
{ classes: "h-4 w-60 rounded-full" },
],
},
];
return (
<div>
<h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">Setup Checklist</h2>
{cards.map((card, index) => (
<LoadingCard key={index} {...card} />
))}
</div>
);
}

View File

@@ -1,24 +1,51 @@
export const revalidate = REVALIDATION_INTERVAL;
import { updateEnvironmentAction } from "@/app/(app)/environments/[environmentId]/settings/setup/actions";
import EnvironmentNotice from "@/components/shared/EnvironmentNotice";
import WidgetStatusIndicator from "@/components/shared/WidgetStatusIndicator";
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { getActionsByEnvironmentId } from "@formbricks/lib/services/actions";
import { getEnvironment } from "@formbricks/lib/services/environment";
import { ErrorComponent } from "@formbricks/ui";
import SettingsCard from "../SettingsCard";
import SettingsTitle from "../SettingsTitle";
import EnvironmentNotice from "../../../../../../components/shared/EnvironmentNotice";
import SetupInstructions from "./SetupInstructions";
export default function ProfileSettingsPage({ params }) {
return (
<div className="space-y-4">
<SettingsTitle title="Setup Checklist" />
<SettingsCard title="Widget Status" description="Check if the Formbricks widget is alive and kicking.">
<WidgetStatusIndicator environmentId={params.environmentId} type="large" />
</SettingsCard>
export default async function ProfileSettingsPage({ params }) {
const [environment, actions] = await Promise.all([
await getEnvironment(params.environmentId),
getActionsByEnvironmentId(params.environmentId),
]);
<EnvironmentNotice environmentId={params.environmentId} pageType="setupChecklist" />
<SettingsCard
title="How to setup"
description="Follow these steps to setup the Formbricks widget within your app"
noPadding>
<SetupInstructions environmentId={params.environmentId} />
</SettingsCard>
</div>
if (!environment) {
return <ErrorComponent />;
}
return (
<>
{environment && (
<div className="space-y-4">
<SettingsTitle title="Setup Checklist" />
<EnvironmentNotice environment={environment} />
<SettingsCard
title="Widget Status"
description="Check if the Formbricks widget is alive and kicking.">
<WidgetStatusIndicator
environment={environment}
actions={actions}
type="large"
updateEnvironmentAction={updateEnvironmentAction}
/>
</SettingsCard>
<SettingsCard
title="How to setup"
description="Follow these steps to setup the Formbricks widget within your app"
noPadding>
<SetupInstructions environmentId={params.environmentId} />
</SettingsCard>
</div>
)}
</>
);
}

View File

@@ -88,7 +88,7 @@ export default async function SurveysList({ environmentId }: { environmentId: st
key={`survey-${survey.id}`}
environmentId={environmentId}
environment={environment}
otherEnvironment={otherEnvironment}
otherEnvironment={otherEnvironment!}
/>
</div>
</div>

View File

@@ -1,20 +1,35 @@
export const revalidate = REVALIDATION_INTERVAL;
import { updateEnvironmentAction } from "@/app/(app)/environments/[environmentId]/settings/setup/actions";
import ContentWrapper from "@/components/shared/ContentWrapper";
import WidgetStatusIndicator from "@/components/shared/WidgetStatusIndicator";
import SurveysList from "./SurveyList";
import { Metadata } from "next";
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { getActionsByEnvironmentId } from "@formbricks/lib/services/actions";
import { getEnvironment } from "@formbricks/lib/services/environment";
import { Metadata } from "next";
import SurveysList from "./SurveyList";
export const metadata: Metadata = {
title: "Your Surveys",
};
export default async function SurveysPage({ params }) {
const [environment, actions] = await Promise.all([
getEnvironment(params.environmentId),
getActionsByEnvironmentId(params.environmentId),
]);
return (
<ContentWrapper className="flex h-full flex-col justify-between">
<SurveysList environmentId={params.environmentId} />
<WidgetStatusIndicator environmentId={params.environmentId} type="mini" />
{environment && (
<WidgetStatusIndicator
environment={environment}
actions={actions}
type="mini"
updateEnvironmentAction={updateEnvironmentAction}
/>
)}
</ContentWrapper>
);
}

View File

@@ -1,69 +1,39 @@
"use client";
import { getEnvironments } from "@formbricks/lib/services/environment";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { LightBulbIcon } from "@heroicons/react/24/outline";
import { headers } from "next/headers";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useEnvironment } from "@/lib/environments/environments";
import { ErrorComponent } from "@formbricks/ui";
import { LightBulbIcon } from "@heroicons/react/24/solid";
import { useRouter } from "next/navigation";
interface EnvironmentNoticeProps {
environment: TEnvironment;
}
export default function EnvironmentNotice({
environmentId,
pageType,
}: {
environmentId: string;
pageType: string;
}) {
const { environment, isErrorEnvironment, isLoadingEnvironment } = useEnvironment(environmentId);
const router = useRouter();
export default async function EnvironmentNotice({ environment }: EnvironmentNoticeProps) {
const headersList = headers();
const currentUrl = headersList.get("x-invoke-path") || "";
const environments = await getEnvironments(environment.productId);
const otherEnvironmentId = environments.find((e) => e.id !== environment.id)?.id || "";
const changeEnvironment = (environmentType: string) => {
const newEnvironmentId = environment.product.environments.find((e) => e.type === environmentType)?.id;
router.push(`/environments/${newEnvironmentId}/`);
const replaceEnvironmentId = (url: string, newId: string): string => {
const regex = /environments\/([a-zA-Z0-9]+)/;
if (regex.test(url)) {
return url.replace(regex, `environments/${newId}`);
}
return url;
};
if (isLoadingEnvironment) {
return <LoadingSpinner />;
}
if (isErrorEnvironment) {
return <ErrorComponent />;
}
if (pageType === "apiSettings") {
return (
<div>
<div className="flex items-center space-y-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-sm md:space-y-0 md:text-base">
<LightBulbIcon className="mr-3 h-8 w-8 text-blue-400" />
<p>
{environment.type === "production"
? "You're currently in the production environment, so you can only create production API keys. "
: "You're currently in the development environment, so you can only create development API keys. "}
<a
onClick={() =>
changeEnvironment(environment.type === "production" ? "development" : "production")
}
className="ml-1 cursor-pointer underline">
Switch to {environment.type === "production" ? "Development" : "Production"} now.
</a>
</p>
</div>
return (
<div>
<div className="flex items-center space-y-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-sm md:space-y-0 md:text-base">
<LightBulbIcon className="mr-3 h-4 w-4 text-blue-400" />
<p>
{`You're currently in the ${environment.type} environment.`}
<a
href={replaceEnvironmentId(currentUrl, otherEnvironmentId)}
className="ml-1 cursor-pointer text-sm underline">
Switch to {environment.type === "production" ? "Development" : "Production"} now.
</a>
</p>
</div>
);
}
if (pageType === "setupChecklist")
return (
<div>
{environment.type === "production" && !environment.widgetSetupCompleted && (
<div className="flex items-center space-y-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-sm md:space-y-0 md:text-base">
<LightBulbIcon className="mr-3 h-6 w-6 text-blue-400" />
<p>
You&apos;re currently in the Production environment.
<a onClick={() => changeEnvironment("development")} className="ml-1 cursor-pointer underline">
Switch to Development environment.
</a>
</p>
</div>
)}
</div>
);
</div>
);
}

View File

@@ -1,32 +1,34 @@
"use client";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useEnvironment } from "@/lib/environments/environments";
import { useEnvironmentMutation } from "@/lib/environments/mutateEnvironments";
import { useEvents } from "@/lib/events/events";
import { timeSince } from "@formbricks/lib/time";
import { Confetti, ErrorComponent } from "@formbricks/ui";
import { TAction } from "@formbricks/types/v1/actions";
import { TEnvironment, TEnvironmentUpdateInput } from "@formbricks/types/v1/environment";
import { Confetti } from "@formbricks/ui";
import { ArrowDownIcon, CheckIcon, ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import clsx from "clsx";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
interface WidgetStatusIndicatorProps {
environmentId: string;
environment: TEnvironment;
type: "large" | "mini";
actions: TAction[];
updateEnvironmentAction: (environmentId: string, data: Partial<TEnvironmentUpdateInput>) => Promise<TEnvironment>;
}
export default function WidgetStatusIndicator({ environmentId, type }: WidgetStatusIndicatorProps) {
const { events, isLoadingEvents, isErrorEvents } = useEvents(environmentId);
const { triggerEnvironmentMutate } = useEnvironmentMutation(environmentId);
const { environment, isErrorEnvironment, isLoadingEnvironment } = useEnvironment(environmentId);
export default function WidgetStatusIndicator({
environment,
type,
actions,
updateEnvironmentAction,
}: WidgetStatusIndicatorProps) {
const [confetti, setConfetti] = useState(false);
useEffect(() => {
if (!environment?.widgetSetupCompleted && events && events.length > 0) {
triggerEnvironmentMutate({ widgetSetupCompleted: true });
if (!environment?.widgetSetupCompleted && actions && actions.length > 0) {
updateEnvironmentAction(environment.id, { widgetSetupCompleted: true });
}
}, [environment, triggerEnvironmentMutate, events]);
}, [environment, actions]);
const stati = {
notImplemented: {
@@ -45,8 +47,8 @@ export default function WidgetStatusIndicator({ environmentId, type }: WidgetSta
};
const status = useMemo(() => {
if (events && events.length > 0) {
const lastEvent = events[0];
if (actions && actions.length > 0) {
const lastEvent = actions[0];
const currentTime = new Date();
const lastEventTime = new Date(lastEvent.createdAt);
const timeDifference = currentTime.getTime() - lastEventTime.getTime();
@@ -60,22 +62,10 @@ export default function WidgetStatusIndicator({ environmentId, type }: WidgetSta
} else {
return "notImplemented";
}
}, [events]);
}, [actions]);
const currentStatus = stati[status];
if (isLoadingEvents || isLoadingEnvironment) {
return (
<div className="flex h-full w-full items-center justify-center">
<LoadingSpinner />
</div>
);
}
if (isErrorEvents || isErrorEnvironment) {
return <ErrorComponent />;
}
if (type === "large") {
return (
<div
@@ -97,7 +87,7 @@ export default function WidgetStatusIndicator({ environmentId, type }: WidgetSta
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
<p className="text-sm text-slate-700">
{currentStatus.subtitle}{" "}
{status !== "notImplemented" && <span>{timeSince(events[0].createdAt)}</span>}
{status !== "notImplemented" && <span>{timeSince(actions[0].createdAt.toISOString())}</span>}
</p>
{confetti && <Confetti />}
</div>
@@ -105,12 +95,12 @@ export default function WidgetStatusIndicator({ environmentId, type }: WidgetSta
}
if (type === "mini") {
return (
<Link href={`/environments/${environmentId}/settings/setup`}>
<Link href={`/environments/${environment.id}/settings/setup`}>
<div className="group my-4 flex justify-center">
<div className=" flex rounded-full bg-slate-100 px-2 py-1">
<p className="mr-2 text-sm text-slate-400 group-hover:underline">
{currentStatus.subtitle}{" "}
{status !== "notImplemented" && <span>{timeSince(events[0].createdAt)}</span>}
{status !== "notImplemented" && <span>{timeSince(actions[0].createdAt.toISOString())}</span>}
</p>
<div
className={clsx(

View File

@@ -214,7 +214,7 @@ export const mockRegisterRouteChangeResponse = () => {
console.log("Checking page url: http://localhost/");
};
export const mockLogoutResponse = () => {
export const mockResetResponse = () => {
fetchMock.mockResponseOnce(
JSON.stringify({
data: {

View File

@@ -6,7 +6,7 @@ import formbricks from "../src/index";
import {
mockEventTrackResponse,
mockInitResponse,
mockLogoutResponse,
mockResetResponse,
mockRegisterRouteChangeResponse,
mockSetCustomAttributeResponse,
mockSetEmailIdResponse,
@@ -145,9 +145,9 @@ test("Formbricks should register for route change", async () => {
expect(consoleLogMock).toHaveBeenCalledWith(expect.stringMatching(/Checking page url/));
});
test("Formbricks should logout", async () => {
mockLogoutResponse();
await formbricks.logout();
test("Formbricks should reset", async () => {
mockResetResponse();
await formbricks.reset();
const currentStatePerson = formbricks.getPerson();
const currentStatePersonAttributes = currentStatePerson.attributes;

View File

@@ -0,0 +1,45 @@
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/errors";
import { TAction } from "@formbricks/types/v1/actions";
import { Prisma } from "@prisma/client";
import { cache } from "react";
import "server-only";
export const getActionsByEnvironmentId = cache(
async (environmentId: string, limit?: number): Promise<TAction[]> => {
try {
const actionsPrisma = await prisma.event.findMany({
where: {
eventClass: {
environmentId: environmentId,
},
},
orderBy: {
createdAt: "desc",
},
take: limit ? limit : 20,
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;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
}
);

View File

@@ -4,10 +4,10 @@ import { z } from "zod";
import { Prisma } from "@prisma/client";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/errors";
import { ZEnvironment } from "@formbricks/types/v1/environment";
import type { TEnvironment } from "@formbricks/types/v1/environment";
import type { TEnvironment, TEnvironmentUpdateInput } from "@formbricks/types/v1/environment";
import { cache } from "react";
export const getEnvironment = cache(async (environmentId: string): Promise<TEnvironment | null> => {
export const getEnvironment = cache(async (environmentId: string): Promise<TEnvironment> => {
let environmentPrisma;
try {
environmentPrisma = await prisma.environment.findUnique({
@@ -75,3 +75,22 @@ export const getEnvironments = cache(async (productId: string): Promise<TEnviron
throw new ValidationError("Data validation of environments array failed");
}
});
export const updateEnvironment = async (environmentId: string, data: Partial<TEnvironmentUpdateInput>): Promise<TEnvironment> => {
const newData = { ...data, updatedAt: new Date() };
let updatedEnvironment;
try {
updatedEnvironment = await prisma.environment.update({
where: {
id: environmentId,
},
data: newData,
});
return updatedEnvironment;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
};

View File

@@ -18,4 +18,4 @@
},
"include": ["src", "next-env.d.ts"],
"exclude": ["node_modules"]
}
}

View File

@@ -0,0 +1,14 @@
import { z } from "zod";
import { ZActionClass } from "./actionClasses";
export const ZAction = z.object({
id: z.string(),
createdAt: z.date(),
sessionId: z.string(),
properties: z.record(z.string()),
actionClass: ZActionClass.nullable(),
});
export type TAction = z.infer<typeof ZAction>;

View File

@@ -1,6 +1,6 @@
import { z } from "zod";
export const ZEnvironment: any = z.object({
export const ZEnvironment = z.object({
id: z.string().cuid2(),
createdAt: z.date(),
updatedAt: z.date(),
@@ -10,3 +10,11 @@ export const ZEnvironment: any = z.object({
});
export type TEnvironment = z.infer<typeof ZEnvironment>;
export const ZEnvironmentUpdateInput = z.object({
type: z.enum(["development", "production"]),
productId: z.string(),
widgetSetupCompleted: z.boolean(),
});
export type TEnvironmentUpdateInput = z.infer<typeof ZEnvironmentUpdateInput>;

View File

@@ -13,9 +13,7 @@
"@formbricks/demo#go": {
"cache": false,
"persistent": true,
"dependsOn": [
"@formbricks/js#build"
]
"dependsOn": ["@formbricks/js#build"]
},
"@formbricks/api#build": {
"outputs": ["dist/**"],