From 975f9123dd4d509e2b777d8af8d74f38ebc59aad Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Thu, 8 Feb 2024 07:00:00 +0100 Subject: [PATCH] feat: add widget server loader (#16) * wip: Add gridstack board * wip: Centralize board pages, Add board settings page * fix: remove cyclic dependency and rename widget-sort to kind * improve: Add header actions as parallel route * feat: add item select modal, add category edit modal, * feat: add edit item modal * feat: add remove item modal * wip: add category actions * feat: add saving of board, wip: add app widget * Merge branch 'main' into add-board * chore: update turbo dependencies * chore: update mantine dependencies * chore: fix typescript errors, lint and format * feat: add confirm modal to category removal, move items of removed category to above wrapper * feat: remove app widget to continue in another branch * feat: add loading spinner until board is initialized * fix: issue with cellheight of gridstack items * feat: add translations for board * fix: issue with translation for settings page * feat: add widget server loader * fix: typing issue * chore: address pull request feedback * fix: formatting --- .../src/app/[locale]/boards/_creator.tsx | 24 ++--- .../src/components/board/sections/content.tsx | 11 ++- packages/widgets/src/clock/component.tsx | 1 + packages/widgets/src/clock/index.ts | 45 +++++----- packages/widgets/src/clock/serverData.ts | 10 +++ packages/widgets/src/definition.ts | 85 +++++++++++++++--- packages/widgets/src/index.tsx | 2 + packages/widgets/src/options.ts | 2 +- packages/widgets/src/server/client.tsx | 13 +++ packages/widgets/src/server/provider.tsx | 89 +++++++++++++++++++ packages/widgets/src/server/runner.tsx | 43 +++++++++ packages/widgets/src/weather/index.ts | 8 +- 12 files changed, 286 insertions(+), 47 deletions(-) create mode 100644 packages/widgets/src/clock/serverData.ts create mode 100644 packages/widgets/src/server/client.tsx create mode 100644 packages/widgets/src/server/provider.tsx create mode 100644 packages/widgets/src/server/runner.tsx diff --git a/apps/nextjs/src/app/[locale]/boards/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/_creator.tsx index ffb9b61ff..ec2ef4f97 100644 --- a/apps/nextjs/src/app/[locale]/boards/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/_creator.tsx @@ -13,6 +13,8 @@ import type { Board } from "./_types"; // This is placed here because it's used in the layout and the page and because it's here it's not needed to load it everywhere import "../../../styles/gridstack.scss"; +import { GlobalItemServerDataRunner } from "@homarr/widgets"; + type Params = Record; interface Props { @@ -31,16 +33,18 @@ export const createBoardPage = >({ const initialBoard = await getInitialBoard(params); return ( - - - } - actions={headeractions} - hasNavigation={false} - /> - {children} - - + + + + } + actions={headeractions} + hasNavigation={false} + /> + {children} + + + ); }, page: () => { diff --git a/apps/nextjs/src/components/board/sections/content.tsx b/apps/nextjs/src/components/board/sections/content.tsx index 006c2c2fd..bc6d21af7 100644 --- a/apps/nextjs/src/components/board/sections/content.tsx +++ b/apps/nextjs/src/components/board/sections/content.tsx @@ -17,6 +17,7 @@ import { import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, + useServerDataFor, } from "@homarr/widgets"; import type { Item } from "~/app/[locale]/boards/_types"; @@ -62,13 +63,21 @@ interface ItemProps { } const BoardItem = ({ item }: ItemProps) => { + const serverData = useServerDataFor(item.id); const Comp = loadWidgetDynamic(item.kind); const options = reduceWidgetOptionsWithDefaultValues(item.kind, item.options); const newItem = { ...item, options }; + + if (!serverData?.isReady) return null; + return ( <> - + ); }; diff --git a/packages/widgets/src/clock/component.tsx b/packages/widgets/src/clock/component.tsx index c8f3b7887..c1fab8aa0 100644 --- a/packages/widgets/src/clock/component.tsx +++ b/packages/widgets/src/clock/component.tsx @@ -3,6 +3,7 @@ import type { WidgetComponentProps } from "../definition"; export default function ClockWidget({ options: _options, integrations: _integrations, + serverData: _serverData, }: WidgetComponentProps<"clock">) { return
CLOCK
; } diff --git a/packages/widgets/src/clock/index.ts b/packages/widgets/src/clock/index.ts index 09337d32f..6892e5ef4 100644 --- a/packages/widgets/src/clock/index.ts +++ b/packages/widgets/src/clock/index.ts @@ -1,27 +1,30 @@ import { IconClock } from "@homarr/ui"; import { createWidgetDefinition } from "../definition"; -import { opt } from "../options"; +import { optionsBuilder } from "../options"; -export const { definition, componentLoader } = createWidgetDefinition("clock", { - icon: IconClock, - supportedIntegrations: ["adGuardHome", "piHole"], - options: opt.from( - (fac) => ({ - is24HourFormat: fac.switch({ - defaultValue: true, - withDescription: true, +export const { definition, componentLoader, serverDataLoader } = + createWidgetDefinition("clock", { + icon: IconClock, + supportedIntegrations: ["adGuardHome", "piHole"], + options: optionsBuilder.from( + (factory) => ({ + is24HourFormat: factory.switch({ + defaultValue: true, + withDescription: true, + }), + isLocaleTime: factory.switch({ defaultValue: true }), + timezone: factory.select({ + options: ["Europe/Berlin", "Europe/London", "Europe/Moscow"] as const, + defaultValue: "Europe/Berlin", + }), }), - isLocaleTime: fac.switch({ defaultValue: true }), - timezone: fac.select({ - options: ["Europe/Berlin", "Europe/London", "Europe/Moscow"] as const, - defaultValue: "Europe/Berlin", - }), - }), - { - timezone: { - shouldHide: (options) => options.isLocaleTime, + { + timezone: { + shouldHide: (options) => options.isLocaleTime, + }, }, - }, - ), -}).withDynamicImport(() => import("./component")); + ), + }) + .withServerData(() => import("./serverData")) + .withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/clock/serverData.ts b/packages/widgets/src/clock/serverData.ts new file mode 100644 index 000000000..e73580db1 --- /dev/null +++ b/packages/widgets/src/clock/serverData.ts @@ -0,0 +1,10 @@ +"use server"; + +import { db } from "../../../db"; +import type { WidgetProps } from "../definition"; + +export default async function getServerData(_item: WidgetProps<"clock">) { + const randomUuid = crypto.randomUUID(); + const data = await db.query.items.findMany(); + return { data, count: data.length, randomUuid }; +} diff --git a/packages/widgets/src/definition.ts b/packages/widgets/src/definition.ts index b7e5811e8..ad4554f96 100644 --- a/packages/widgets/src/definition.ts +++ b/packages/widgets/src/definition.ts @@ -10,6 +10,62 @@ import type { } from "./options"; import type { IntegrationSelectOption } from "./widget-integration-select"; +type ServerDataLoader = () => Promise<{ + default: (props: WidgetProps) => Promise>; +}>; + +const createWithDynamicImport = + < + TKind extends WidgetKind, + TDefinition extends WidgetDefinition, + TServerDataLoader extends ServerDataLoader | undefined, + >( + kind: TKind, + definition: TDefinition, + serverDataLoader: TServerDataLoader, + ) => + ( + componentLoader: () => LoaderComponent< + WidgetComponentProps & + (TServerDataLoader extends ServerDataLoader + ? { + serverData: Awaited< + ReturnType>["default"]> + >; + } + : never) + >, + ) => ({ + definition: { + ...definition, + kind, + }, + kind, + serverDataLoader, + componentLoader, + }); + +const createWithServerData = + ( + kind: TKind, + definition: TDefinition, + ) => + >( + serverDataLoader: TServerDataLoader, + ) => ({ + definition: { + ...definition, + kind, + }, + kind, + serverDataLoader, + withDynamicImport: createWithDynamicImport( + kind, + definition, + serverDataLoader, + ), + }); + export const createWidgetDefinition = < TKind extends WidgetKind, TDefinition extends WidgetDefinition, @@ -17,15 +73,8 @@ export const createWidgetDefinition = < kind: TKind, definition: TDefinition, ) => ({ - withDynamicImport: ( - componentLoader: () => LoaderComponent>, - ) => ({ - definition: { - kind, - ...definition, - }, - componentLoader, - }), + withServerData: createWithServerData(kind, definition), + withDynamicImport: createWithDynamicImport(kind, definition, undefined), }); export interface WidgetDefinition { @@ -34,13 +83,29 @@ export interface WidgetDefinition { options: WidgetOptionsRecord; } -export interface WidgetComponentProps { +export interface WidgetProps { options: inferOptionsFromDefinition>; integrations: inferIntegrationsFromDefinition< WidgetImports[TKind]["definition"] >; } +type inferServerDataForKind = + WidgetImports[TKind] extends { serverDataLoader: ServerDataLoader } + ? Awaited< + ReturnType< + Awaited< + ReturnType + >["default"] + > + > + : undefined; + +export type WidgetComponentProps = + WidgetProps & { + serverData?: inferServerDataForKind; + }; + type inferIntegrationsFromDefinition = TDefinition extends { supportedIntegrations: infer TSupportedIntegrations; diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index e72dcc2aa..0dd573f38 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -13,6 +13,8 @@ import * as weather from "./weather"; export { reduceWidgetOptionsWithDefaultValues } from "./options"; export { WidgetEditModal } from "./modals/widget-edit-modal"; +export { GlobalItemServerDataRunner } from "./server/runner"; +export { useServerDataFor } from "./server/provider"; export const widgetImports = { clock, diff --git a/packages/widgets/src/options.ts b/packages/widgets/src/options.ts index 94ab03db2..31ed0305a 100644 --- a/packages/widgets/src/options.ts +++ b/packages/widgets/src/options.ts @@ -141,7 +141,7 @@ const createOptions = ( }; }; -export const opt = { +export const optionsBuilder = { from: createOptions, }; diff --git a/packages/widgets/src/server/client.tsx b/packages/widgets/src/server/client.tsx new file mode 100644 index 000000000..ceb43c313 --- /dev/null +++ b/packages/widgets/src/server/client.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { useServerDataInitializer } from "./provider"; + +interface Props { + id: string; + serverData: Record | undefined; +} + +export const ClientServerDataInitalizer = ({ id, serverData }: Props) => { + useServerDataInitializer(id, serverData); + return null; +}; diff --git a/packages/widgets/src/server/provider.tsx b/packages/widgets/src/server/provider.tsx new file mode 100644 index 000000000..4516559e9 --- /dev/null +++ b/packages/widgets/src/server/provider.tsx @@ -0,0 +1,89 @@ +"use client"; + +import type { PropsWithChildren } from "react"; +import { createContext, useContext, useEffect, useState } from "react"; + +type Data = Record< + string, + { + data: Record | undefined; + isReady: boolean; + } +>; + +interface GlobalItemServerDataContext { + setItemServerData: ( + id: string, + data: Record | undefined, + ) => void; + data: Data; + initalItemIds: string[]; +} + +const GlobalItemServerDataContext = + createContext(null); + +interface Props { + initalItemIds: string[]; +} + +export const GlobalItemServerDataProvider = ({ + children, + initalItemIds, +}: PropsWithChildren) => { + const [data, setData] = useState({}); + + const setItemServerData = ( + id: string, + itemData: Record | undefined, + ) => { + setData((prev) => ({ + ...prev, + [id]: { + data: itemData, + isReady: true, + }, + })); + }; + + return ( + + {children} + + ); +}; + +export const useServerDataFor = (id: string) => { + const context = useContext(GlobalItemServerDataContext); + + if (!context) { + throw new Error("GlobalItemServerDataProvider is required"); + } + + // When the item is not in the initial list, it means the data can not come from the server + if (!context.initalItemIds.includes(id)) { + return { + data: undefined, + isReady: true, + }; + } + + return context.data[id]; +}; + +export const useServerDataInitializer = ( + id: string, + serverData: Record | undefined, +) => { + const context = useContext(GlobalItemServerDataContext); + + if (!context) { + throw new Error("GlobalItemServerDataProvider is required"); + } + + useEffect(() => { + context.setItemServerData(id, serverData); + }, []); +}; diff --git a/packages/widgets/src/server/runner.tsx b/packages/widgets/src/server/runner.tsx new file mode 100644 index 000000000..090c8b934 --- /dev/null +++ b/packages/widgets/src/server/runner.tsx @@ -0,0 +1,43 @@ +import type { PropsWithChildren } from "react"; +import { Suspense } from "react"; + +import type { RouterOutputs } from "@homarr/api"; + +import { widgetImports } from ".."; +import { ClientServerDataInitalizer } from "./client"; +import { GlobalItemServerDataProvider } from "./provider"; + +type Board = RouterOutputs["board"]["default"]; + +type Props = PropsWithChildren<{ + board: Board; +}>; + +export const GlobalItemServerDataRunner = ({ board, children }: Props) => { + const allItems = board.sections.flatMap((section) => section.items); + + return ( + id)}> + {allItems.map((item) => ( + + + + ))} + {children} + + ); +}; + +interface ItemDataLoaderProps { + item: Board["sections"][number]["items"][number]; +} + +const ItemDataLoader = async ({ item }: ItemDataLoaderProps) => { + const widgetImport = widgetImports[item.kind]; + if (!("serverDataLoader" in widgetImport)) { + return ; + } + const loader = await widgetImport.serverDataLoader(); + const data = await loader.default(item as never); + return ; +}; diff --git a/packages/widgets/src/weather/index.ts b/packages/widgets/src/weather/index.ts index 39fbfcdb7..dacb27ace 100644 --- a/packages/widgets/src/weather/index.ts +++ b/packages/widgets/src/weather/index.ts @@ -1,15 +1,15 @@ import { IconCloud } from "@homarr/ui"; import { createWidgetDefinition } from "../definition"; -import { opt } from "../options"; +import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition( "weather", { icon: IconCloud, - options: opt.from((fac) => ({ - location: fac.location(), - showCity: fac.switch(), + options: optionsBuilder.from((factory) => ({ + location: factory.location(), + showCity: factory.switch(), })), }, ).withDynamicImport(() => import("./component"));