From c6686209beeb9b534d29b5d9a047333b0b024e86 Mon Sep 17 00:00:00 2001 From: Shubham Palriwala Date: Fri, 11 Aug 2023 20:34:31 +0530 Subject: [PATCH] Move Look & Feel Settings to React Server Components (#672) * feat: migrate look and feel to serverside component with loading screen * fix: use existing product type instead of creating a custom type * fix: make improvements as Matti suggested * change attributes order in updateProduct function * run pnpm format --------- Co-authored-by: Matthias Nannt --- .../settings/lookandfeel/EditBrandColor.tsx | 41 +++ .../lookandfeel/EditHighlightBorder.tsx | 95 +++++ .../settings/lookandfeel/EditPlacement.tsx | 117 ++++++ .../settings/lookandfeel/EditSignature.tsx | 49 +++ .../settings/lookandfeel/actions.ts | 8 + .../settings/lookandfeel/editLookAndFeel.tsx | 339 ------------------ .../settings/lookandfeel/loading.tsx | 114 ++++++ .../settings/lookandfeel/page.tsx | 25 +- apps/web/components/preview/Modal.tsx | 3 +- packages/lib/services/product.ts | 35 +- packages/types/v1/product.ts | 8 + 11 files changed, 480 insertions(+), 354 deletions(-) create mode 100644 apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/EditBrandColor.tsx create mode 100644 apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/EditHighlightBorder.tsx create mode 100644 apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/EditPlacement.tsx create mode 100644 apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/EditSignature.tsx create mode 100644 apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/actions.ts delete mode 100644 apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/editLookAndFeel.tsx create mode 100644 apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/loading.tsx diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/EditBrandColor.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/EditBrandColor.tsx new file mode 100644 index 0000000000..07ab9a9875 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/EditBrandColor.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { TProduct, TProductUpdateInput } from "@formbricks/types/v1/product"; +import { Button, ColorPicker, Label } from "@formbricks/ui"; +import { useState } from "react"; +import toast from "react-hot-toast"; +import { updateProductAction } from "./actions"; + +interface EditBrandColorProps { + product: TProduct; +} + +export function EditBrandColor({ product }: EditBrandColorProps) { + const [color, setColor] = useState(product.brandColor); + const [updatingColor, setUpdatingColor] = useState(false); + + const handleUpdateBrandColor = async () => { + try { + setUpdatingColor(true); + let inputProduct: Partial = { + brandColor: color, + }; + await updateProductAction(product.id, inputProduct); + toast.success("Brand color updated successfully."); + } catch (error) { + toast.error(`Error: ${error.message}`); + } finally { + setUpdatingColor(false); + } + }; + + return ( +
+ + + +
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/EditHighlightBorder.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/EditHighlightBorder.tsx new file mode 100644 index 0000000000..ad41b63c3b --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/EditHighlightBorder.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { cn } from "@formbricks/lib/cn"; +import { Button, ColorPicker, Label, Switch } from "@formbricks/ui"; +import { useState } from "react"; +import toast from "react-hot-toast"; +import { DEFAULT_BRAND_COLOR } from "@formbricks/lib/constants"; +import { TProduct, TProductUpdateInput } from "@formbricks/types/v1/product"; +import { updateProductAction } from "./actions"; + +interface EditHighlightBorderProps { + product: TProduct; +} + +export const EditHighlightBorder = ({ product }: EditHighlightBorderProps) => { + const [showHighlightBorder, setShowHighlightBorder] = useState(product.highlightBorderColor ? true : false); + const [color, setColor] = useState(product.highlightBorderColor || DEFAULT_BRAND_COLOR); + const [updatingBorder, setUpdatingBorder] = useState(false); + + const handleUpdateHighlightBorder = async () => { + try { + setUpdatingBorder(true); + let inputProduct: Partial = { + highlightBorderColor: color, + }; + await updateProductAction(product.id, inputProduct); + toast.success("Border color updated successfully."); + } catch (error) { + toast.error(`Error: ${error.message}`); + } finally { + setUpdatingBorder(false); + } + }; + + const handleSwitch = (checked: boolean) => { + if (checked) { + if (!color) { + setColor(DEFAULT_BRAND_COLOR); + setShowHighlightBorder(true); + } else { + setShowHighlightBorder(true); + } + } else { + setShowHighlightBorder(false); + setColor(null); + } + }; + + return ( +
+
+
+ +

Show highlight border

+
+ + {showHighlightBorder && color ? ( + <> + + + + ) : null} + + +
+ +
+

Preview

+
+

How easy was it for you to do this?

+
+ {[1, 2, 3, 4, 5].map((num) => ( +
+ {num} +
+ ))} +
+
+
+
+ ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/EditPlacement.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/EditPlacement.tsx new file mode 100644 index 0000000000..f14b4529df --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/EditPlacement.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { cn } from "@formbricks/lib/cn"; +import { Button, Label, RadioGroup, RadioGroupItem } from "@formbricks/ui"; +import { useState } from "react"; +import toast from "react-hot-toast"; +import { getPlacementStyle } from "@/lib/preview"; +import { PlacementType } from "@formbricks/types/js"; +import { TProduct, TProductUpdateInput } from "@formbricks/types/v1/product"; +import { updateProductAction } from "./actions"; + +const placements = [ + { name: "Bottom Right", value: "bottomRight", disabled: false }, + { name: "Top Right", value: "topRight", disabled: false }, + { name: "Top Left", value: "topLeft", disabled: false }, + { name: "Bottom Left", value: "bottomLeft", disabled: false }, + { name: "Centered Modal", value: "center", disabled: false }, +]; + +interface EditPlacementProps { + product: TProduct; +} + +export function EditPlacement({ product }: EditPlacementProps) { + const [currentPlacement, setCurrentPlacement] = useState(product.placement); + const [overlay, setOverlay] = useState(product.darkOverlay ? "darkOverlay" : "lightOverlay"); + const [clickOutside, setClickOutside] = useState(product.clickOutsideClose ? "allow" : "disallow"); + const [updatingPlacement, setUpdatingPlacement] = useState(false); + + const handleUpdatePlacement = async () => { + try { + setUpdatingPlacement(true); + let inputProduct: Partial = { + placement: currentPlacement, + darkOverlay: overlay === "darkOverlay", + clickOutsideClose: clickOutside === "allow", + }; + await updateProductAction(product.id, inputProduct); + toast.success("Placement updated successfully."); + } catch (error) { + toast.error(`Error: ${error.message}`); + } finally { + setUpdatingPlacement(false); + } + }; + + return ( +
+
+ setCurrentPlacement(e as PlacementType)} value={currentPlacement}> + {placements.map((placement) => ( +
+ + +
+ ))} +
+
+
+
+
+ + {currentPlacement === "center" && ( + <> +
+ + setOverlay(e)} value={overlay} className="flex space-x-4"> +
+ + +
+
+ + +
+
+
+
+ + setClickOutside(e)} + value={clickOutside} + className="flex space-x-4"> +
+ + +
+
+ + +
+
+
+ + )} + +
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/EditSignature.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/EditSignature.tsx new file mode 100644 index 0000000000..bbe7483e90 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/EditSignature.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { updateProductAction } from "./actions"; +import { TProduct, TProductUpdateInput } from "@formbricks/types/v1/product"; +import { Label, Switch } from "@formbricks/ui"; +import { useState } from "react"; +import toast from "react-hot-toast"; + +interface EditSignatureProps { + product: TProduct; +} + +export function EditFormbricksSignature({ product }: EditSignatureProps) { + const [formbricksSignature, setFormbricksSignature] = useState(product.formbricksSignature); + const [updatingSignature, setUpdatingSignature] = useState(false); + + const toggleSignature = async () => { + try { + setUpdatingSignature(true); + const newSignatureState = !formbricksSignature; + setFormbricksSignature(newSignatureState); + let inputProduct: Partial = { + formbricksSignature: newSignatureState, + }; + await updateProductAction(product.id, inputProduct); + toast.success( + newSignatureState ? "Formbricks signature will be shown." : "Formbricks signature will now be hidden." + ); + } catch (error) { + toast.error(`Error: ${error.message}`); + } finally { + setUpdatingSignature(false); + } + }; + + return ( +
+
+ + +
+
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/actions.ts b/apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/actions.ts new file mode 100644 index 0000000000..46da36abac --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/actions.ts @@ -0,0 +1,8 @@ +"use server"; + +import { updateProduct } from "@formbricks/lib/services/product"; +import { TProductUpdateInput } from "@formbricks/types/v1/product"; + +export async function updateProductAction(productId: string, inputProduct: Partial) { + return await updateProduct(productId, inputProduct); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/editLookAndFeel.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/editLookAndFeel.tsx deleted file mode 100644 index 27219cea00..0000000000 --- a/apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/editLookAndFeel.tsx +++ /dev/null @@ -1,339 +0,0 @@ -"use client"; - -import { cn } from "@formbricks/lib/cn"; -import LoadingSpinner from "@/components/shared/LoadingSpinner"; -import { useEnvironment } from "@/lib/environments/environments"; -import { useProductMutation } from "@/lib/products/mutateProducts"; -import { useProduct } from "@/lib/products/products"; -import { - Button, - ColorPicker, - ErrorComponent, - Label, - RadioGroup, - RadioGroupItem, - Switch, -} from "@formbricks/ui"; -import { useEffect, useState } from "react"; -import toast from "react-hot-toast"; -import { getPlacementStyle } from "@/lib/preview"; -import { PlacementType } from "@formbricks/types/js"; -import { DEFAULT_BRAND_COLOR } from "@formbricks/lib/constants"; - -export function EditBrandColor({ environmentId }) { - const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId); - const { triggerProductMutate, isMutatingProduct } = useProductMutation(environmentId); - - const [color, setColor] = useState(""); - - useEffect(() => { - if (product) setColor(product.brandColor); - }, [product]); - - if (isLoadingProduct) { - return ; - } - if (isErrorProduct) { - return
Error
; - } - - return ( -
- - - -
- ); -} - -export function EditPlacement({ environmentId }) { - const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId); - const { triggerProductMutate, isMutatingProduct } = useProductMutation(environmentId); - - const [currentPlacement, setCurrentPlacement] = useState("bottomRight"); - const [overlay, setOverlay] = useState(""); - const [clickOutside, setClickOutside] = useState(""); - - useEffect(() => { - if (product) { - setCurrentPlacement(product.placement); - setOverlay(product.darkOverlay ? "darkOverlay" : "lightOverlay"); - setClickOutside(product.clickOutsideClose ? "allow" : "disallow"); - } - }, [product]); - - if (isLoadingProduct) { - return ; - } - if (isErrorProduct) { - return ; - } - - const placements = [ - { name: "Bottom Right", value: "bottomRight", disabled: false }, - { name: "Top Right", value: "topRight", disabled: false }, - { name: "Top Left", value: "topLeft", disabled: false }, - { name: "Bottom Left", value: "bottomLeft", disabled: false }, - { name: "Centered Modal", value: "center", disabled: false }, - ]; - - return ( -
-
- setCurrentPlacement(e as PlacementType)} value={currentPlacement}> - {placements.map((placement) => ( -
- - -
- ))} -
-
-
-
-
- - {currentPlacement === "center" && ( - <> -
- - setOverlay(e)} value={overlay} className="flex space-x-4"> -
- - -
-
- - -
-
-
-
- - setClickOutside(e)} - value={clickOutside} - className="flex space-x-4"> -
- - -
-
- - -
-
-
- - )} - -
- ); -} - -export const EditHighlightBorder: React.FC<{ environmentId: string }> = ({ environmentId }) => { - const { product, isLoadingProduct, isErrorProduct, mutateProduct } = useProduct(environmentId); - const { triggerProductMutate, isMutatingProduct } = useProductMutation(environmentId); - - const [showHighlightBorder, setShowHighlightBorder] = useState(false); - const [color, setColor] = useState(DEFAULT_BRAND_COLOR); - - // Sync product state with local state - // not a good pattern, we should find a better way to do this - useEffect(() => { - if (product) { - setShowHighlightBorder(product.highlightBorderColor ? true : false); - setColor(product.highlightBorderColor); - } - }, [product]); - - const handleSave = () => { - triggerProductMutate( - { highlightBorderColor: color }, - { - onSuccess: () => { - toast.success("Settings updated successfully."); - // refetch product to update data - mutateProduct(); - }, - onError: () => { - toast.error("Something went wrong!"); - }, - } - ); - }; - - const handleSwitch = (checked: boolean) => { - if (checked) { - if (!color) { - setColor(DEFAULT_BRAND_COLOR); - setShowHighlightBorder(true); - } else { - setShowHighlightBorder(true); - } - } else { - setShowHighlightBorder(false); - setColor(null); - } - }; - - if (isLoadingProduct) { - return ; - } - - if (isErrorProduct) { - return
Error
; - } - - return ( -
-
-
- -

Show highlight border

-
- - {showHighlightBorder && color ? ( - <> - - - - ) : null} - - -
- -
-

Preview

-
-

How easy was it for you to do this?

-
- {[1, 2, 3, 4, 5].map((num) => ( -
- {num} -
- ))} -
-
-
-
- ); -}; - -export function EditFormbricksSignature({ environmentId }) { - const { isLoadingEnvironment, isErrorEnvironment } = useEnvironment(environmentId); - const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId); - const { triggerProductMutate, isMutatingProduct } = useProductMutation(environmentId); - - const [formbricksSignature, setFormbricksSignature] = useState(false); - - useEffect(() => { - if (product) { - setFormbricksSignature(product.formbricksSignature); - } - }, [product]); - - const toggleSignature = () => { - const newSignatureState = !formbricksSignature; - setFormbricksSignature(newSignatureState); - triggerProductMutate({ formbricksSignature: newSignatureState }) - .then(() => { - toast.success(newSignatureState ? "Formbricks signature shown." : "Formbricks signature hidden."); - }) - .catch((error) => { - toast.error(`Error: ${error.message}`); - }); - }; - - if (isLoadingEnvironment || isLoadingProduct) { - return ; - } - - if (isErrorEnvironment || isErrorProduct) { - return ; - } - - if (formbricksSignature !== null) { - return ( -
-
- - -
-
- ); - } - - return null; -} diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/loading.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/loading.tsx new file mode 100644 index 0000000000..3e4ced40df --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/loading.tsx @@ -0,0 +1,114 @@ +import SettingsCard from "@/app/(app)/environments/[environmentId]/settings/SettingsCard"; +import SettingsTitle from "@/app/(app)/environments/[environmentId]/settings/SettingsTitle"; +import { cn } from "@formbricks/lib/cn"; +import { Button, Label, RadioGroup, RadioGroupItem, Switch } from "@formbricks/ui"; + +const placements = [ + { name: "Bottom Right", value: "bottomRight", disabled: false }, + { name: "Top Right", value: "topRight", disabled: false }, + { name: "Top Left", value: "topLeft", disabled: false }, + { name: "Bottom Left", value: "bottomLeft", disabled: false }, + { name: "Centered Modal", value: "center", disabled: false }, +]; + +export default function Loading() { + return ( +
+ + +
+ +
+
+
+
+
+ +
+
+ +
+
+ + {placements.map((placement) => ( +
+ + +
+ ))} +
+
+
+
+
+ +
+
+ +
+
+
+ +

Show highlight border

+
+ + +
+ +
+

Preview

+
+

How easy was it for you to do this?

+
+ {[1, 2, 3, 4, 5].map((num) => ( +
+ {num} +
+ ))} +
+
+
+
+
+ +
+
+ + +
+
+
+
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/page.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/page.tsx index b2da13e889..00042b4512 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/lookandfeel/page.tsx @@ -1,34 +1,37 @@ +export const revalidate = REVALIDATION_INTERVAL; + +import { getProductByEnvironmentId } from "@formbricks/lib/services/product"; +import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants"; import SettingsCard from "../SettingsCard"; import SettingsTitle from "../SettingsTitle"; -import { - EditBrandColor, - EditPlacement, - EditFormbricksSignature, - EditHighlightBorder, -} from "./editLookAndFeel"; +import { EditFormbricksSignature } from "./EditSignature"; +import { EditBrandColor } from "./EditBrandColor"; +import { EditPlacement } from "./EditPlacement"; +import { EditHighlightBorder } from "./EditHighlightBorder"; -export default function ProfileSettingsPage({ params }: { params: { environmentId: string } }) { +export default async function ProfileSettingsPage({ params }: { params: { environmentId: string } }) { + const product = await getProductByEnvironmentId(params.environmentId); return (
- + - + - + - +
); diff --git a/apps/web/components/preview/Modal.tsx b/apps/web/components/preview/Modal.tsx index ba42e3cfc1..488b210ac0 100644 --- a/apps/web/components/preview/Modal.tsx +++ b/apps/web/components/preview/Modal.tsx @@ -56,11 +56,10 @@ export default function Modal({ ref={modalRef} style={highlightBorderColorStyle} className={cn( - "pointer-events-auto absolute max-h-[90%] w-full h-fit max-w-sm overflow-y-auto rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5 transition-all duration-500 ease-in-out", + "pointer-events-auto absolute h-fit max-h-[90%] w-full max-w-sm overflow-y-auto rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5 transition-all duration-500 ease-in-out", previewMode === "desktop" ? getPlacementStyle(placement) : "max-w-full ", slidingAnimationClass )}> - {children} diff --git a/packages/lib/services/product.ts b/packages/lib/services/product.ts index 9f55d8cc19..22fb2047e3 100644 --- a/packages/lib/services/product.ts +++ b/packages/lib/services/product.ts @@ -1,11 +1,10 @@ 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 type { TProduct, TProductUpdateInput } from "@formbricks/types/v1/product"; import { cache } from "react"; const selectProduct = { @@ -57,3 +56,35 @@ export const getProductByEnvironmentId = cache(async (environmentId: string): Pr throw new ValidationError("Data validation of product failed"); } }); + +export const updateProduct = async ( + productId: string, + inputProduct: Partial +): Promise => { + let updatedProduct; + try { + updatedProduct = await prisma.product.update({ + where: { + id: productId, + }, + data: { + ...inputProduct, + }, + select: selectProduct, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError("Database operation failed"); + } + throw error; + } + try { + const product = ZProduct.parse(updatedProduct); + 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"); + } +}; diff --git a/packages/types/v1/product.ts b/packages/types/v1/product.ts index 9383e22f7c..c6d990ba7b 100644 --- a/packages/types/v1/product.ts +++ b/packages/types/v1/product.ts @@ -19,3 +19,11 @@ export const ZProduct = z.object({ }); export type TProduct = z.infer; + +export const ZProductUpdateInput = ZProduct.omit({ + id: true, + createdAt: true, + updatedAt: true, +}); + +export type TProductUpdateInput = z.infer;