Move Templates Page to server components and new services (#488)

* [ADD] types

* [ADD] methods

* [Rearrange] with-server-component

* [FIX]server-methods,[ADD]preview-survey-server

* [FIX] zod-types-normalized

* pnpm format

* [FIX] build-error-due-to-cascade

* [RM] PreviewSurveyServer

* [TMP]

* [CHANGE] file-name-hack

---------

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Ankur Datta
2023-07-12 16:14:19 +05:30
committed by GitHub
parent 170ed85712
commit ec3a20b183
12 changed files with 232 additions and 85 deletions

View File

@@ -1,14 +1,15 @@
"use client";
import FormbricksSignature from "@/components/preview/FormbricksSignature";
import Modal from "@/components/preview/Modal";
import Progress from "@/components/preview/Progress";
import QuestionConditional from "@/components/preview/QuestionConditional";
import ThankYouCard from "@/components/preview/ThankYouCard";
import { useEnvironment } from "@/lib/environments/environments";
import { useProduct } from "@/lib/products/products";
import type { Logic, Question } from "@formbricks/types/questions";
import { Survey } from "@formbricks/types/surveys";
import { useEffect, useRef, useState } from "react";
import type { TProduct } from "@formbricks/types/v1/product";
import type { TEnvironment } from "@formbricks/types/v1/environment";
interface PreviewSurveyProps {
setActiveQuestionId: (id: string | null) => void;
activeQuestionId?: string | null;
@@ -19,6 +20,8 @@ interface PreviewSurveyProps {
thankYouCard: Survey["thankYouCard"];
autoClose: Survey["autoClose"];
previewType?: "modal" | "fullwidth" | "email";
product: TProduct;
environment: TEnvironment;
}
export default function PreviewSurvey({
@@ -26,15 +29,13 @@ export default function PreviewSurvey({
activeQuestionId,
questions,
brandColor,
environmentId,
surveyType,
thankYouCard,
autoClose,
previewType,
product,
environment,
}: PreviewSurveyProps) {
const { environment } = useEnvironment(environmentId);
const { product } = useProduct(environmentId);
const [isModalOpen, setIsModalOpen] = useState(true);
const [progress, setProgress] = useState(0); // [0, 1]
const [widgetSetupCompleted, setWidgetSetupCompleted] = useState(false);

View File

@@ -32,12 +32,17 @@ import toast from "react-hot-toast";
import TemplateList from "./templates/TemplateList";
import { useEffect } from "react";
import { changeEnvironment } from "@/lib/environments/changeEnvironments";
import { TProduct } from "@formbricks/types/v1/product";
export default function SurveysList({ environmentId }) {
interface SurveyListProps {
environmentId: string;
product: TProduct;
}
export default function SurveysList({ environmentId, product }: SurveyListProps) {
const router = useRouter();
const { surveys, mutateSurveys, isLoadingSurveys, isErrorSurveys } = useSurveys(environmentId);
const { environment, isErrorEnvironment, isLoadingEnvironment } = useEnvironment(environmentId);
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isCreateSurveyLoading, setIsCreateSurveyLoading] = useState(false);
@@ -133,6 +138,8 @@ export default function SurveysList({ environmentId }) {
onTemplateClick={(template) => {
newSurveyFromTemplate(template);
}}
environment={environment}
product={product}
/>
</>
)}

View File

@@ -11,6 +11,7 @@ import SettingsView from "./SettingsView";
import QuestionsAudienceTabs from "./QuestionsAudienceTabs";
import QuestionsView from "./QuestionsView";
import SurveyMenuBar from "./SurveyMenuBar";
import { useEnvironment } from "@/lib/environments/environments";
interface SurveyEditorProps {
environmentId: string;
@@ -24,6 +25,7 @@ export default function SurveyEditor({ environmentId, surveyId }: SurveyEditorPr
const { survey, isLoadingSurvey, isErrorSurvey } = useSurvey(environmentId, surveyId);
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
const { environment, isLoadingEnvironment, isErrorEnvironment } = useEnvironment(environmentId);
useEffect(() => {
if (survey) {
@@ -37,11 +39,11 @@ export default function SurveyEditor({ environmentId, surveyId }: SurveyEditorPr
}
}, [survey]);
if (isLoadingSurvey || isLoadingProduct || !localSurvey) {
if (isLoadingSurvey || isLoadingProduct || isLoadingEnvironment || !localSurvey) {
return <LoadingSpinner />;
}
if (isErrorSurvey || isErrorProduct) {
if (isErrorSurvey || isErrorProduct || isErrorEnvironment) {
return <ErrorComponent />;
}
@@ -81,6 +83,8 @@ export default function SurveyEditor({ environmentId, surveyId }: SurveyEditorPr
questions={localSurvey.questions}
brandColor={product.brandColor}
environmentId={environmentId}
product={product}
environment={environment}
surveyType={localSurvey.type}
thankYouCard={localSurvey.thankYouCard}
previewType={localSurvey.type === "web" ? "modal" : "fullwidth"}

View File

@@ -1,11 +1,15 @@
import ContentWrapper from "@/components/shared/ContentWrapper";
import WidgetStatusIndicator from "@/components/shared/WidgetStatusIndicator";
import SurveysList from "./SurveyList";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
export default async function SurveysPage({ params }) {
const environmentId = params.environmentId;
const product = await getProductByEnvironmentId(environmentId);
return (
<ContentWrapper className="flex h-full flex-col justify-between">
<SurveysList environmentId={params.environmentId} />
<SurveysList environmentId={params.environmentId} product={product} />
<WidgetStatusIndicator environmentId={params.environmentId} type="mini" />
</ContentWrapper>
);

View File

@@ -0,0 +1,72 @@
"use client";
import { useState } from "react";
import type { Template } from "@formbricks/types/templates";
import { useEffect } from "react";
import { replacePresetPlaceholders } from "@/lib/templates";
import { templates } from "./templates";
import PreviewSurvey from "../PreviewSurvey";
import TemplateList from "./TemplateList";
import type { TProduct } from "@formbricks/types/v1/product";
import type { TEnvironment } from "@formbricks/types/v1/environment";
type TemplateContainerWithPreviewProps = {
environmentId: string;
product: TProduct;
environment: TEnvironment;
};
export default function TemplateContainerWithPreview({
environmentId,
product,
environment,
}: TemplateContainerWithPreviewProps) {
const [activeTemplate, setActiveTemplate] = useState<Template | null>(null);
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
useEffect(() => {
if (product && templates?.length) {
const newTemplate = replacePresetPlaceholders(templates[0], product);
setActiveTemplate(newTemplate);
setActiveQuestionId(newTemplate.preset.questions[0].id);
}
}, [product]);
return (
<div className="flex h-full flex-col ">
<div className="relative z-0 flex flex-1 overflow-hidden">
<div className="flex-1 flex-col overflow-auto bg-slate-50">
<h1 className="ml-6 mt-6 text-2xl font-bold text-slate-800">Create a new survey</h1>
<TemplateList
environmentId={environmentId}
environment={environment}
product={product}
onTemplateClick={(template) => {
setActiveQuestionId(template.preset.questions[0].id);
setActiveTemplate(template);
}}
/>
</div>
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-100 bg-slate-50 md:flex md:flex-col">
{activeTemplate && (
<div className="my-6 flex h-full w-full flex-col items-center justify-center">
<p className="pb-2 text-center text-sm font-normal text-slate-400">Preview</p>
<PreviewSurvey
activeQuestionId={activeQuestionId}
questions={activeTemplate.preset.questions}
brandColor={product.brandColor}
product={product}
environment={environment}
setActiveQuestionId={setActiveQuestionId}
environmentId={environmentId}
surveyType={environment?.widgetSetupCompleted ? "web" : "link"}
thankYouCard={{ enabled: true }}
autoClose={null}
/>
</div>
)}
</aside>
</div>
</div>
);
}

View File

@@ -1,9 +1,5 @@
"use client";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useEnvironment } from "@/lib/environments/environments";
import { useProduct } from "@/lib/products/products";
import { useProfile } from "@/lib/profile";
import { createSurvey } from "@/lib/surveys/surveys";
import { replacePresetPlaceholders } from "@/lib/templates";
import { cn } from "@formbricks/lib/cn";
@@ -16,24 +12,26 @@ import { useEffect, useState } from "react";
import { customSurvey, templates } from "./templates";
import { SplitIcon } from "lucide-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui";
import type { TEnvironment } from "@formbricks/types/v1/environment";
import type { TProduct } from "@formbricks/types/v1/product";
import { useProfile } from "@/lib/profile";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
type TemplateList = {
environmentId: string;
onTemplateClick: (template: Template) => void;
environment: TEnvironment;
product: TProduct;
};
const ALL_CATEGORY_NAME = "All";
const RECOMMENDED_CATEGORY_NAME = "For you";
export default function TemplateList({ environmentId, onTemplateClick }: TemplateList) {
export default function TemplateList({ environmentId, onTemplateClick, product, environment }: TemplateList) {
const router = useRouter();
const [activeTemplate, setActiveTemplate] = useState<Template | null>(null);
const [loading, setLoading] = useState(false);
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
const { profile, isLoadingProfile, isErrorProfile } = useProfile();
const { environment } = useEnvironment(environmentId);
const [selectedFilter, setSelectedFilter] = useState(RECOMMENDED_CATEGORY_NAME);
const { profile, isLoadingProfile, isErrorProfile } = useProfile();
const [categories, setCategories] = useState<Array<string>>([]);
@@ -65,8 +63,8 @@ export default function TemplateList({ environmentId, onTemplateClick }: Templat
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`);
};
if (isLoadingProduct || isLoadingProfile) return <LoadingSpinner />;
if (isErrorProduct || isErrorProfile) return <ErrorComponent />;
if (isLoadingProfile) return <LoadingSpinner />;
if (isErrorProfile) return <ErrorComponent />;
return (
<main className="relative z-0 flex-1 overflow-y-auto px-6 pb-6 pt-3 focus:outline-none">

View File

@@ -0,0 +1,5 @@
import LoadingSpinner from "@/components/shared/LoadingSpinner";
export default function LoadingPage() {
return <LoadingSpinner />;
}

View File

@@ -1,67 +1,13 @@
"use client";
import TemplateContainerWithPreview from "./TemplateContainer";
import { getEnvironment } from "@formbricks/lib/services/environment";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useEnvironment } from "@/lib/environments/environments";
import { useProduct } from "@/lib/products/products";
import { replacePresetPlaceholders } from "@/lib/templates";
import type { Template } from "@formbricks/types/templates";
import { ErrorComponent } from "@formbricks/ui";
import { useEffect, useState } from "react";
import PreviewSurvey from "../PreviewSurvey";
import TemplateList from "./TemplateList";
import { templates } from "./templates";
export default function SurveyTemplatesPage({ params }) {
export default async function SurveyTemplatesPage({ params }) {
const environmentId = params.environmentId;
const [activeTemplate, setActiveTemplate] = useState<Template | null>(null);
const { environment, isLoadingEnvironment, isErrorEnvironment } = useEnvironment(environmentId);
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
useEffect(() => {
if (product && templates?.length) {
const newTemplate = replacePresetPlaceholders(templates[0], product);
setActiveTemplate(newTemplate);
setActiveQuestionId(newTemplate.preset.questions[0].id);
}
}, [product]);
if (isLoadingProduct || isLoadingEnvironment) return <LoadingSpinner />;
if (isErrorProduct || isErrorEnvironment) return <ErrorComponent />;
const environment = await getEnvironment(environmentId);
const product = await getProductByEnvironmentId(environmentId);
return (
<div className="flex h-full flex-col ">
<div className="relative z-0 flex flex-1 overflow-hidden">
<div className="flex-1 flex-col overflow-auto bg-slate-50">
<h1 className="ml-6 mt-6 text-2xl font-bold text-slate-800">Create a new survey</h1>
<TemplateList
environmentId={environmentId}
onTemplateClick={(template) => {
setActiveQuestionId(template.preset.questions[0].id);
setActiveTemplate(template);
}}
/>
</div>
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-100 bg-slate-50 md:flex md:flex-col">
{activeTemplate && (
<div className="my-6 flex h-full w-full flex-col items-center justify-center">
<p className="pb-2 text-center text-sm font-normal text-slate-400">Preview</p>
<PreviewSurvey
activeQuestionId={activeQuestionId}
questions={activeTemplate.preset.questions}
brandColor={product.brandColor}
setActiveQuestionId={setActiveQuestionId}
environmentId={environmentId}
surveyType={environment?.widgetSetupCompleted ? "web" : "link"}
thankYouCard={{ enabled: true }}
autoClose={null}
/>
</div>
)}
</aside>
</div>
</div>
<TemplateContainerWithPreview environmentId={environmentId} environment={environment} product={product} />
);
}

View File

@@ -0,0 +1,38 @@
import "server-only";
import { prisma } from "@formbricks/database";
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 { cache } from "react";
export const getEnvironment = cache(async (environmentId: string): Promise<TEnvironment | null> => {
let environmentPrisma;
try {
environmentPrisma = await prisma.environment.findUnique({
where: {
id: environmentId,
},
});
if (!environmentPrisma) {
throw new ResourceNotFoundError("Environment", environmentId);
}
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
try {
const environment = ZEnvironment.parse(environmentPrisma);
return environment;
} catch (error) {
if (error instanceof z.ZodError) {
console.error(JSON.stringify(error.errors, null, 2));
}
throw new ValidationError("Data validation of environment failed");
}
});

View File

@@ -0,0 +1,43 @@
import "server-only";
import { prisma } from "@formbricks/database";
import { z } from "zod";
import { Prisma } from "@prisma/client";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/errors";
import { ZProduct } from "@formbricks/types/v1/product";
import type { TProduct } from "@formbricks/types/v1/product";
import { cache } from "react";
export const getProductByEnvironmentId = cache(async (environmentId: string): Promise<TProduct> => {
let productPrisma;
try {
productPrisma = await prisma.product.findFirst({
where: {
environments: {
some: {
id: environmentId,
},
},
},
});
if (!productPrisma) {
throw new ResourceNotFoundError("Product for Environment", environmentId);
}
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
try {
const product = ZProduct.parse(productPrisma);
return product;
} catch (error) {
if (error instanceof z.ZodError) {
console.error(JSON.stringify(error.errors, null, 2));
}
throw new ValidationError("Data validation of product failed");
}
});

View File

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

View File

@@ -0,0 +1,17 @@
import { z } from "zod";
export const ZProduct = z.object({
id: z.string().cuid2(),
createdAt: z.date(),
updatedAt: z.date(),
name: z.string(),
teamId: z.string(),
brandColor: z.string().regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/),
recontactDays: z.number().int(),
formbricksSignature: z.boolean(),
placement: z.enum(["bottomLeft", "bottomRight", "topLeft", "topRight", "center"]),
clickOutsideClose: z.boolean(),
darkOverlay: z.boolean(),
});
export type TProduct = z.infer<typeof ZProduct>;