feat(widget): add prefetch for apps and bookmarks (#2895)
This commit is contained in:
@@ -41,9 +41,11 @@
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@kubernetes/client-node": "^1.1.2",
|
||||
"@tanstack/react-query": "^5.75.1",
|
||||
"@trpc/client": "^11.1.2",
|
||||
"@trpc/react-query": "^11.1.2",
|
||||
"@trpc/server": "^11.1.2",
|
||||
"@trpc/tanstack-react-query": "^11.1.2",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"next": "15.3.1",
|
||||
"react": "19.1.0",
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { cache } from "react";
|
||||
import { headers } from "next/headers";
|
||||
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
|
||||
|
||||
import { createCaller, createTRPCContext } from "@homarr/api";
|
||||
import { appRouter, createCaller, createTRPCContext } from "@homarr/api";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
|
||||
import { makeQueryClient } from "./shared";
|
||||
|
||||
/**
|
||||
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
|
||||
* handling a tRPC call from a React Server Component.
|
||||
@@ -19,3 +22,12 @@ const createContext = cache(async () => {
|
||||
});
|
||||
|
||||
export const api = createCaller(createContext);
|
||||
|
||||
// IMPORTANT: Create a stable getter for the query client that
|
||||
// will return the same client during the same request.
|
||||
export const getQueryClient = cache(makeQueryClient);
|
||||
export const trpc = createTRPCOptionsProxy({
|
||||
ctx: createContext,
|
||||
router: appRouter,
|
||||
queryClient: getQueryClient,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { defaultShouldDehydrateQuery, QueryClient } from "@tanstack/react-query";
|
||||
|
||||
/**
|
||||
* Creates a headers callback for a given source
|
||||
* It will set the x-trpc-source header and cookies if needed
|
||||
@@ -51,3 +53,16 @@ export const trpcPath = "/api/trpc";
|
||||
export function getTrpcUrl() {
|
||||
return `${getBaseUrl()}${trpcPath}`;
|
||||
}
|
||||
|
||||
export const makeQueryClient = () => {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 1000,
|
||||
},
|
||||
dehydrate: {
|
||||
shouldDehydrateQuery: (query) => defaultShouldDehydrateQuery(query) || query.state.status === "pending",
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.ts"
|
||||
".": "./index.ts",
|
||||
"./creator": "./src/creator.ts"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
|
||||
@@ -2,30 +2,9 @@
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
import type { DayOfWeek } from "@mantine/dates";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import type { User } from "@homarr/db/schema";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
|
||||
export type SettingsContextProps = Pick<
|
||||
User,
|
||||
| "firstDayOfWeek"
|
||||
| "defaultSearchEngineId"
|
||||
| "homeBoardId"
|
||||
| "mobileHomeBoardId"
|
||||
| "openSearchInNewTab"
|
||||
| "pingIconsEnabled"
|
||||
> &
|
||||
Pick<ServerSettings["board"], "enableStatusByDefault" | "forceDisableStatus">;
|
||||
|
||||
interface PublicServerSettings {
|
||||
search: Pick<ServerSettings["search"], "defaultSearchEngineId">;
|
||||
board: Pick<
|
||||
ServerSettings["board"],
|
||||
"homeBoardId" | "mobileHomeBoardId" | "enableStatusByDefault" | "forceDisableStatus"
|
||||
>;
|
||||
}
|
||||
import type { PublicServerSettings, SettingsContextProps, UserSettings } from "./creator";
|
||||
import { createSettings } from "./creator";
|
||||
|
||||
const SettingsContext = createContext<SettingsContextProps | null>(null);
|
||||
|
||||
@@ -33,22 +12,9 @@ export const SettingsProvider = ({
|
||||
user,
|
||||
serverSettings,
|
||||
children,
|
||||
}: PropsWithChildren<{ user: RouterOutputs["user"]["getById"] | null; serverSettings: PublicServerSettings }>) => {
|
||||
}: PropsWithChildren<{ user: UserSettings | null; serverSettings: PublicServerSettings }>) => {
|
||||
return (
|
||||
<SettingsContext.Provider
|
||||
value={{
|
||||
defaultSearchEngineId: user?.defaultSearchEngineId ?? serverSettings.search.defaultSearchEngineId,
|
||||
openSearchInNewTab: user?.openSearchInNewTab ?? true,
|
||||
firstDayOfWeek: (user?.firstDayOfWeek as DayOfWeek | undefined) ?? (1 as const),
|
||||
homeBoardId: user?.homeBoardId ?? serverSettings.board.homeBoardId,
|
||||
mobileHomeBoardId: user?.mobileHomeBoardId ?? serverSettings.board.mobileHomeBoardId,
|
||||
pingIconsEnabled: user?.pingIconsEnabled ?? false,
|
||||
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
|
||||
forceDisableStatus: serverSettings.board.forceDisableStatus,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SettingsContext.Provider>
|
||||
<SettingsContext.Provider value={createSettings({ user, serverSettings })}>{children}</SettingsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
48
packages/settings/src/creator.ts
Normal file
48
packages/settings/src/creator.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { User } from "@homarr/db/schema";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
|
||||
export type SettingsContextProps = Pick<
|
||||
User,
|
||||
| "firstDayOfWeek"
|
||||
| "defaultSearchEngineId"
|
||||
| "homeBoardId"
|
||||
| "mobileHomeBoardId"
|
||||
| "openSearchInNewTab"
|
||||
| "pingIconsEnabled"
|
||||
> &
|
||||
Pick<ServerSettings["board"], "enableStatusByDefault" | "forceDisableStatus">;
|
||||
|
||||
export interface PublicServerSettings {
|
||||
search: Pick<ServerSettings["search"], "defaultSearchEngineId">;
|
||||
board: Pick<
|
||||
ServerSettings["board"],
|
||||
"homeBoardId" | "mobileHomeBoardId" | "enableStatusByDefault" | "forceDisableStatus"
|
||||
>;
|
||||
}
|
||||
|
||||
export type UserSettings = Pick<
|
||||
User,
|
||||
| "firstDayOfWeek"
|
||||
| "defaultSearchEngineId"
|
||||
| "homeBoardId"
|
||||
| "mobileHomeBoardId"
|
||||
| "openSearchInNewTab"
|
||||
| "pingIconsEnabled"
|
||||
>;
|
||||
|
||||
export const createSettings = ({
|
||||
user,
|
||||
serverSettings,
|
||||
}: {
|
||||
user: UserSettings | null;
|
||||
serverSettings: PublicServerSettings;
|
||||
}) => ({
|
||||
defaultSearchEngineId: user?.defaultSearchEngineId ?? serverSettings.search.defaultSearchEngineId,
|
||||
openSearchInNewTab: user?.openSearchInNewTab ?? true,
|
||||
firstDayOfWeek: user?.firstDayOfWeek ?? (1 as const),
|
||||
homeBoardId: user?.homeBoardId ?? serverSettings.board.homeBoardId,
|
||||
mobileHomeBoardId: user?.mobileHomeBoardId ?? serverSettings.board.mobileHomeBoardId,
|
||||
pingIconsEnabled: user?.pingIconsEnabled ?? false,
|
||||
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
|
||||
forceDisableStatus: serverSettings.board.forceDisableStatus,
|
||||
});
|
||||
@@ -7,7 +7,8 @@
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./errors": "./src/errors/component.tsx",
|
||||
"./modals": "./src/modals/index.ts"
|
||||
"./modals": "./src/modals/index.ts",
|
||||
"./prefetch": "./src/prefetch.ts"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
@@ -35,6 +36,7 @@
|
||||
"@homarr/form": "workspace:^0.1.0",
|
||||
"@homarr/forms-collection": "workspace:^0.1.0",
|
||||
"@homarr/integrations": "workspace:^0.1.0",
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
"@homarr/modals": "workspace:^0.1.0",
|
||||
"@homarr/modals-collection": "workspace:^0.1.0",
|
||||
"@homarr/notifications": "workspace:^0.1.0",
|
||||
|
||||
23
packages/widgets/src/app/prefetch.ts
Normal file
23
packages/widgets/src/app/prefetch.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { trpc } from "@homarr/api/server";
|
||||
import { db, inArray } from "@homarr/db";
|
||||
import { apps } from "@homarr/db/schema";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import type { Prefetch } from "../definition";
|
||||
|
||||
const prefetchAllAsync: Prefetch<"app"> = async (queryClient, items) => {
|
||||
const appIds = items.map((item) => item.options.appId);
|
||||
const distinctAppIds = [...new Set(appIds)];
|
||||
|
||||
const dbApps = await db.query.apps.findMany({
|
||||
where: inArray(apps.id, distinctAppIds),
|
||||
});
|
||||
|
||||
for (const app of dbApps) {
|
||||
queryClient.setQueryData(trpc.app.byId.queryKey({ id: app.id }), app);
|
||||
}
|
||||
|
||||
logger.info(`Successfully prefetched ${dbApps.length} apps for app widget`);
|
||||
};
|
||||
|
||||
export default prefetchAllAsync;
|
||||
30
packages/widgets/src/bookmarks/prefetch.ts
Normal file
30
packages/widgets/src/bookmarks/prefetch.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { trpc } from "@homarr/api/server";
|
||||
import { db, inArray } from "@homarr/db";
|
||||
import { apps } from "@homarr/db/schema";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import type { Prefetch } from "../definition";
|
||||
|
||||
const prefetchAllAsync: Prefetch<"bookmarks"> = async (queryClient, items) => {
|
||||
const appIds = items.flatMap((item) => item.options.items);
|
||||
const distinctAppIds = [...new Set(appIds)];
|
||||
|
||||
const dbApps = await db.query.apps.findMany({
|
||||
where: inArray(apps.id, distinctAppIds),
|
||||
});
|
||||
|
||||
for (const item of items) {
|
||||
if (item.options.items.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
queryClient.setQueryData(
|
||||
trpc.app.byIds.queryKey(item.options.items),
|
||||
dbApps.filter((app) => item.options.items.includes(app.id)),
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`Successfully prefetched ${dbApps.length} apps for bookmarks`);
|
||||
};
|
||||
|
||||
export default prefetchAllAsync;
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { LoaderComponent } from "next/dynamic";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
|
||||
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import type { SettingsContextProps } from "@homarr/settings";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
import type { stringOrTranslation } from "@homarr/translation";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
@@ -21,6 +22,15 @@ const createWithDynamicImport =
|
||||
componentLoader,
|
||||
});
|
||||
|
||||
export type PrefetchLoader<TKind extends WidgetKind> = () => Promise<{ default: Prefetch<TKind> }>;
|
||||
export type Prefetch<TKind extends WidgetKind> = (
|
||||
queryClient: QueryClient,
|
||||
items: {
|
||||
options: inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>;
|
||||
integrationIds: string[];
|
||||
}[],
|
||||
) => Promise<void>;
|
||||
|
||||
export const createWidgetDefinition = <TKind extends WidgetKind, TDefinition extends WidgetDefinition>(
|
||||
kind: TKind,
|
||||
definition: TDefinition,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Center, Loader as UiLoader } from "@mantine/core";
|
||||
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import type { SettingsContextProps } from "@homarr/settings";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
|
||||
import * as app from "./app";
|
||||
import * as bookmarks from "./bookmarks";
|
||||
|
||||
@@ -8,7 +8,7 @@ import { objectEntries } from "@homarr/common";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { zodResolver } from "@homarr/form";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import type { SettingsContextProps } from "@homarr/settings";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { zodErrorMap } from "@homarr/validation/form/i18n";
|
||||
|
||||
|
||||
45
packages/widgets/src/prefetch.ts
Normal file
45
packages/widgets/src/prefetch.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { cache } from "react";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { db } from "@homarr/db";
|
||||
import { getServerSettingsAsync } from "@homarr/db/queries";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { createSettings } from "@homarr/settings/creator";
|
||||
|
||||
import { reduceWidgetOptionsWithDefaultValues } from ".";
|
||||
import prefetchForApps from "./app/prefetch";
|
||||
import prefetchForBookmarks from "./bookmarks/prefetch";
|
||||
import type { Prefetch, WidgetOptionsRecordOf } from "./definition";
|
||||
import type { inferOptionsFromCreator } from "./options";
|
||||
|
||||
const cachedGetServerSettingsAsync = cache(getServerSettingsAsync);
|
||||
|
||||
const prefetchCallbacks: Partial<{
|
||||
[TKind in WidgetKind]: Prefetch<TKind>;
|
||||
}> = {
|
||||
bookmarks: prefetchForBookmarks,
|
||||
app: prefetchForApps,
|
||||
};
|
||||
|
||||
export const prefetchForKindAsync = async <TKind extends WidgetKind>(
|
||||
kind: TKind,
|
||||
queryClient: QueryClient,
|
||||
items: {
|
||||
options: inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>;
|
||||
integrationIds: string[];
|
||||
}[],
|
||||
) => {
|
||||
const callback = prefetchCallbacks[kind];
|
||||
if (!callback) {
|
||||
return;
|
||||
}
|
||||
|
||||
const serverSettings = await cachedGetServerSettingsAsync(db);
|
||||
|
||||
const itemsWithDefaultOptions = items.map((item) => ({
|
||||
...item,
|
||||
options: reduceWidgetOptionsWithDefaultValues(kind, createSettings({ user: null, serverSettings }), item.options),
|
||||
}));
|
||||
|
||||
await callback(queryClient, itemsWithDefaultOptions as never[]);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { SettingsContextProps } from "@homarr/settings";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
import { createLanguageMapping } from "@homarr/translation";
|
||||
|
||||
import { widgetImports } from "..";
|
||||
|
||||
Reference in New Issue
Block a user