feat(custom-widgets): add extensible widget platform
This commit is contained in:
1 parent
6cca757493
commit
8f152f1929
317 files changed
+32326
-1788
No files matched your search
@@ -0,0 +1,360 @@
|
||||
"use client";
|
||||
|
||||
import type { Context, Dispatch, PropsWithChildren, SetStateAction } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { hashKey, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import type {
|
||||
WidgetCommand,
|
||||
WidgetHostContext,
|
||||
WidgetHostServices,
|
||||
WidgetStateStore,
|
||||
WidgetStorageScope,
|
||||
} from "./shared";
|
||||
|
||||
export type * from "./shared";
|
||||
export { parseLocalWidgetState, serializeLocalWidgetState, useLocalWidgetState } from "./local-state";
|
||||
|
||||
interface WidgetRuntime {
|
||||
context: WidgetHostContext;
|
||||
services: WidgetHostServices;
|
||||
options: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// The authorized module loader and Next route chunks must share one context even after HMR.
|
||||
// Compiled package modules can outlive the chunk that originally supplied their SDK hooks.
|
||||
const contextKey = Symbol.for("homarr.widget.context.v1");
|
||||
const contextRegistry = globalThis as typeof globalThis & { [contextKey]?: Context<WidgetRuntime | null> };
|
||||
const WidgetContext = (contextRegistry[contextKey] ??= createContext<WidgetRuntime | null>(null));
|
||||
|
||||
export function WidgetHostProvider({ children, ...runtime }: PropsWithChildren<WidgetRuntime>) {
|
||||
return <WidgetContext.Provider value={runtime}>{children}</WidgetContext.Provider>;
|
||||
}
|
||||
|
||||
/** Compose independently authored components without colliding state, storage, commands or query caches. */
|
||||
export function WidgetScope({
|
||||
name,
|
||||
handlerPrefix = "",
|
||||
options,
|
||||
children,
|
||||
}: PropsWithChildren<{
|
||||
name: string;
|
||||
handlerPrefix?: string;
|
||||
options?: Record<string, unknown>;
|
||||
}>) {
|
||||
const runtime = useWidgetRuntime();
|
||||
const parent = runtime.services;
|
||||
const services = useMemo<WidgetHostServices>(() => {
|
||||
const operation = (input: Parameters<WidgetHostServices["transport"]["query"]>[0]) => ({
|
||||
...input,
|
||||
name: input.name.startsWith("$") ? input.name : `${handlerPrefix}${input.name}`,
|
||||
});
|
||||
return {
|
||||
...parent,
|
||||
state: {
|
||||
get: (key) => parent.state.get(`${name}:${key}`),
|
||||
set: (key, value) => parent.state.set(`${name}:${key}`, value),
|
||||
subscribe: parent.state.subscribe,
|
||||
},
|
||||
registerCommands: (commands) =>
|
||||
parent.registerCommands(commands.map((command) => ({ ...command, id: `${name}:${command.id}` }))),
|
||||
transport: {
|
||||
query: (input, signal) => parent.transport.query(operation(input), signal),
|
||||
action: (input) => parent.transport.action(operation(input)),
|
||||
subscribe: (input, observer) => parent.transport.subscribe(operation(input), observer),
|
||||
storageGet: (input, signal) => parent.transport.storageGet({ ...input, key: `${name}:${input.key}` }, signal),
|
||||
storageSet: (input) => parent.transport.storageSet({ ...input, key: `${name}:${input.key}` }),
|
||||
},
|
||||
};
|
||||
}, [handlerPrefix, name, parent]);
|
||||
const scope = `${runtime.context.scope ?? ""}/${name}`;
|
||||
return (
|
||||
<WidgetContext.Provider
|
||||
value={{
|
||||
context: { ...runtime.context, scope, handlerPrefix: `${runtime.context.handlerPrefix ?? ""}${handlerPrefix}` },
|
||||
services,
|
||||
options: options ?? runtime.options,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</WidgetContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function useWidgetRuntime() {
|
||||
const runtime = useContext(WidgetContext);
|
||||
if (!runtime) throw new Error("Widget SDK hooks require a Homarr widget host");
|
||||
return runtime;
|
||||
}
|
||||
|
||||
export function useWidgetHost() {
|
||||
return useWidgetRuntime().context;
|
||||
}
|
||||
|
||||
export function useWidgetServices() {
|
||||
return useWidgetRuntime().services;
|
||||
}
|
||||
|
||||
export function useWidgetOptions<T extends Record<string, unknown> = Record<string, unknown>>() {
|
||||
return useWidgetRuntime().options as T;
|
||||
}
|
||||
|
||||
function queryScope(context: WidgetHostContext) {
|
||||
return ["custom-widget-sdk", context.revisionId, context.itemId ?? context.previewId, context.userId] as const;
|
||||
}
|
||||
|
||||
interface WidgetQueryOptions {
|
||||
enabled?: boolean;
|
||||
refetchInterval?: number | false;
|
||||
staleTime?: number;
|
||||
}
|
||||
|
||||
export function useWidgetQuery<T = unknown>(name: string, input?: unknown, options: WidgetQueryOptions = {}) {
|
||||
const { context, services } = useWidgetRuntime();
|
||||
const itemId = context.itemId ?? context.previewId;
|
||||
const query = useQuery<T>({
|
||||
queryKey: [...queryScope(context), "query", context.scope, name, input],
|
||||
queryFn: ({ signal }) => {
|
||||
if (!itemId) throw new Error("Save this widget to run its server queries");
|
||||
return services.transport.query({ itemId, name, input }, signal) as Promise<T>;
|
||||
},
|
||||
enabled: Boolean(itemId) && context.visible && options.enabled !== false,
|
||||
refetchInterval: options.refetchInterval ?? false,
|
||||
staleTime: options.staleTime ?? (typeof options.refetchInterval === "number" ? options.refetchInterval : 1_000),
|
||||
retry: false,
|
||||
});
|
||||
if (isAccessError(query.error)) return { ...query, data: undefined };
|
||||
return query;
|
||||
}
|
||||
|
||||
function isAccessError(error: unknown) {
|
||||
if (!error || typeof error !== "object" || !("data" in error)) return false;
|
||||
const data = error.data;
|
||||
if (!data || typeof data !== "object" || !("code" in data)) return false;
|
||||
return data.code === "FORBIDDEN" || data.code === "UNAUTHORIZED" || data.code === "NOT_FOUND";
|
||||
}
|
||||
|
||||
export function useWidgetAction<TOutput = unknown, TInput = unknown>(name: string) {
|
||||
const { context, services } = useWidgetRuntime();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<TOutput, Error, TInput>({
|
||||
mutationFn: (input) => {
|
||||
const itemId = context.itemId ?? context.previewId;
|
||||
if (!itemId) throw new Error("Save this widget to run its server actions");
|
||||
if (context.isEditMode || context.executionEnabled === false)
|
||||
throw new Error("Widget actions are currently paused");
|
||||
return services.transport.action({ itemId, name, input }) as Promise<TOutput>;
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: queryScope(context) }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Discover controls currently available to this viewer, including exact guest-approved inputs. */
|
||||
export function useWidgetCapabilities() {
|
||||
const host = useWidgetHost();
|
||||
const query = useWidgetQuery<{ actions: Record<string, { allowed: boolean; allowedInputs?: unknown[] }> }>(
|
||||
"$capabilities",
|
||||
);
|
||||
return {
|
||||
...query,
|
||||
canRun(name: string, input?: unknown) {
|
||||
if (host.isEditMode || host.executionEnabled === false || query.error) return false;
|
||||
const capability = query.data?.actions[`${host.handlerPrefix ?? ""}${name}`];
|
||||
if (!capability?.allowed) return false;
|
||||
if (!capability.allowedInputs) return true;
|
||||
let effectiveInput = input;
|
||||
if (effectiveInput === undefined) effectiveInput = {};
|
||||
return capability.allowedInputs.some((allowed) => hashKey([allowed]) === hashKey([effectiveInput]));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useWidgetSubscription<T = unknown>(
|
||||
name: string,
|
||||
input?: unknown,
|
||||
options: { enabled?: boolean; maximumEvents?: number } = {},
|
||||
) {
|
||||
const { context, services } = useWidgetRuntime();
|
||||
const itemId = context.itemId ?? context.previewId;
|
||||
const inputKey = hashKey([input]);
|
||||
const inputRef = useRef(input);
|
||||
inputRef.current = input;
|
||||
let maximumEvents = 60;
|
||||
if (options.maximumEvents !== undefined && Number.isFinite(options.maximumEvents))
|
||||
maximumEvents = Math.max(1, Math.floor(options.maximumEvents));
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const enabled = Boolean(itemId) && context.visible && options.enabled !== false;
|
||||
const [state, setState] = useWidgetState<{ data?: T; events: T[]; error?: Error; connected: boolean }>(
|
||||
`subscription:${hashKey([context.revisionId, context.userId, name, inputKey])}`,
|
||||
{ events: [], connected: false },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setState((current) => ({ ...current, error: undefined, connected: false }));
|
||||
if (!enabled || !itemId) return;
|
||||
let active = true;
|
||||
const unsubscribe = services.transport.subscribe(
|
||||
{ itemId, name, input: inputRef.current },
|
||||
{
|
||||
next(value) {
|
||||
if (!active) return;
|
||||
setState((current) => ({
|
||||
data: value as T,
|
||||
events: Object.is(current.data, value)
|
||||
? current.events
|
||||
: [...current.events, value as T].slice(-maximumEvents),
|
||||
connected: true,
|
||||
}));
|
||||
},
|
||||
error(error) {
|
||||
if (!active) return;
|
||||
if (isAccessError(error)) setState({ events: [], error, connected: false });
|
||||
else setState((current) => ({ ...current, error, connected: false }));
|
||||
},
|
||||
complete() {
|
||||
if (active) setState((current) => ({ ...current, connected: false }));
|
||||
},
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
active = false;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [
|
||||
attempt,
|
||||
itemId,
|
||||
context.revisionId,
|
||||
context.userId,
|
||||
enabled,
|
||||
inputKey,
|
||||
maximumEvents,
|
||||
name,
|
||||
services.transport,
|
||||
setState,
|
||||
]);
|
||||
|
||||
return { ...state, reconnect: () => setAttempt((current) => current + 1) };
|
||||
}
|
||||
|
||||
export function createWidgetStateStore(values: Record<string, unknown> = {}): WidgetStateStore {
|
||||
const listeners = new Set<() => void>();
|
||||
return {
|
||||
get: (key) => (Object.hasOwn(values, key) ? values[key] : undefined),
|
||||
set(key, value) {
|
||||
if (Object.hasOwn(values, key) && Object.is(values[key], value)) return;
|
||||
Object.defineProperty(values, key, { value, enumerable: true, configurable: true, writable: true });
|
||||
listeners.forEach((listener) => listener());
|
||||
},
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Instance state survives compact/advanced view changes without persisting secrets to a browser. */
|
||||
export function useWidgetState<T>(key: string, initialValue: T | (() => T)): [T, Dispatch<SetStateAction<T>>] {
|
||||
const { services } = useWidgetRuntime();
|
||||
const [initial] = useState(initialValue);
|
||||
const getSnapshot = useCallback(() => {
|
||||
const value = services.state.get(key);
|
||||
if (value === undefined) return initial;
|
||||
return value as T;
|
||||
}, [initial, key, services.state]);
|
||||
const value = useSyncExternalStore(services.state.subscribe, getSnapshot, () => initial);
|
||||
const setValue: Dispatch<SetStateAction<T>> = useCallback(
|
||||
(next) => {
|
||||
let nextValue = next;
|
||||
if (typeof next === "function") nextValue = (next as (previous: T) => T)(getSnapshot());
|
||||
services.state.set(key, nextValue);
|
||||
},
|
||||
[getSnapshot, key, services.state],
|
||||
);
|
||||
return [value, setValue];
|
||||
}
|
||||
|
||||
/** Shared storage remains server-authorized; previews use an isolated temporary store. */
|
||||
export function useWidgetStorage<T = unknown>(scope: WidgetStorageScope, key: string, initialValue: T) {
|
||||
const { context, services } = useWidgetRuntime();
|
||||
const itemId = context.itemId ?? context.previewId;
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = [...queryScope(context), "storage", context.scope, scope, key];
|
||||
const query = useQuery({
|
||||
queryKey,
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!itemId) return initialValue;
|
||||
const value = await services.transport.storageGet({ itemId, scope, key }, signal);
|
||||
if (value === undefined || value === null) return initialValue;
|
||||
return value as T;
|
||||
},
|
||||
enabled: Boolean(itemId) && context.executionEnabled !== false,
|
||||
retry: false,
|
||||
});
|
||||
const mutation = useMutation({
|
||||
mutationFn: (value: T) => {
|
||||
if (!itemId || context.isEditMode || context.executionEnabled === false) {
|
||||
throw new Error("Managed widget storage cannot be changed before saving or in board edit mode");
|
||||
}
|
||||
return services.transport.storageSet({ itemId, scope, key, value });
|
||||
},
|
||||
onSuccess: (_result, value) => queryClient.setQueryData(queryKey, value),
|
||||
});
|
||||
return {
|
||||
...query,
|
||||
data: isAccessError(query.error) ? initialValue : (query.data ?? initialValue),
|
||||
set: mutation.mutateAsync,
|
||||
saving: mutation.isPending,
|
||||
saveError: mutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useWidgetCommands(commands: readonly WidgetCommand[]) {
|
||||
const { services } = useWidgetRuntime();
|
||||
const registerCommands = services.registerCommands;
|
||||
const commandsRef = useRef(commands);
|
||||
commandsRef.current = commands;
|
||||
const descriptorKey = hashKey(commands.map(({ run: _run, ...command }) => command));
|
||||
useEffect(
|
||||
() =>
|
||||
registerCommands(
|
||||
commandsRef.current.map((command) => ({
|
||||
...command,
|
||||
run: () => commandsRef.current.find(({ id }) => id === command.id)?.run(),
|
||||
})),
|
||||
),
|
||||
[descriptorKey, registerCommands],
|
||||
);
|
||||
}
|
||||
|
||||
/** Shared native/custom clock, aligned to real time boundaries to avoid accumulating drift. */
|
||||
export function useWidgetNow(intervalMs = 1000, enabled = true) {
|
||||
const [now, setNow] = useState<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let interval = 1000;
|
||||
if (Number.isFinite(intervalMs)) interval = Math.max(16, intervalMs);
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const tick = () => {
|
||||
setNow(Date.now());
|
||||
timer = setTimeout(tick, interval - (Date.now() % interval));
|
||||
};
|
||||
setNow(Date.now());
|
||||
timer = setTimeout(tick, interval - (Date.now() % interval));
|
||||
return () => clearTimeout(timer);
|
||||
}, [enabled, intervalMs]);
|
||||
return now;
|
||||
}
|
||||
|
||||
export function useWidgetClock(intervalMs = 1000) {
|
||||
const { visible } = useWidgetHost();
|
||||
return useWidgetNow(intervalMs, visible);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import sqlite from "../examples/sqlite.json";
|
||||
import mqtt from "../examples/mqtt.json";
|
||||
import clock from "../examples/clock.json";
|
||||
import beszel from "../examples/beszel.json";
|
||||
import timer from "../examples/timer.json";
|
||||
import checklist from "../examples/checklist.json";
|
||||
import downloads from "../examples/downloads.json";
|
||||
import sonarr from "../examples/sonarr.json";
|
||||
import rack from "../examples/rack.json";
|
||||
import backup from "../examples/backup.json";
|
||||
import household from "../examples/household.json";
|
||||
import overview from "../examples/overview.json";
|
||||
import media from "../examples/media.json";
|
||||
|
||||
export const widgetReferencePackages = {
|
||||
sqlite,
|
||||
mqtt,
|
||||
clock,
|
||||
beszel,
|
||||
timer,
|
||||
checklist,
|
||||
downloads,
|
||||
sonarr,
|
||||
rack,
|
||||
backup,
|
||||
household,
|
||||
overview,
|
||||
media,
|
||||
};
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
/** Compatibility exports are supplied by Homarr's host; new widgets should use the regular SDK. */
|
||||
export declare const LegacyWidget: ComponentType<{ template: string; requestCapabilities?: unknown }>;
|
||||
export declare const LegacyConfiguration: ComponentType<{ schema: unknown; parameterNames?: Record<string, string[]> }>;
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface StoredWidgetState {
|
||||
version: number;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
interface UseLocalWidgetStateOptions<T> {
|
||||
key: string | undefined;
|
||||
version: number;
|
||||
defaultValue: T | (() => T);
|
||||
validate?: (value: unknown) => value is T;
|
||||
migrate?: (value: unknown, storedVersion: number) => T | undefined;
|
||||
}
|
||||
|
||||
const resolveDefaultValue = <T>(value: T | (() => T)): T =>
|
||||
typeof value === "function" ? (value as () => T)() : value;
|
||||
|
||||
export const serializeLocalWidgetState = <T>(version: number, value: T): string => JSON.stringify({ version, value });
|
||||
|
||||
export const parseLocalWidgetState = <T>(
|
||||
serialized: string,
|
||||
version: number,
|
||||
validate?: (value: unknown) => value is T,
|
||||
migrate?: (value: unknown, storedVersion: number) => T | undefined,
|
||||
): T | undefined => {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(serialized);
|
||||
if (typeof parsed !== "object" || parsed === null || !("version" in parsed) || !("value" in parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const stored = parsed as StoredWidgetState;
|
||||
if (typeof stored.version !== "number") return undefined;
|
||||
if (stored.version !== version) {
|
||||
const migrated = migrate?.(stored.value, stored.version);
|
||||
if (migrated === undefined || (validate && !validate(migrated))) return undefined;
|
||||
return migrated;
|
||||
}
|
||||
if (validate && !validate(stored.value)) return undefined;
|
||||
return stored.value as T;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const useLocalWidgetState = <T>({
|
||||
key,
|
||||
version,
|
||||
defaultValue,
|
||||
validate,
|
||||
migrate,
|
||||
}: UseLocalWidgetStateOptions<T>): [T, Dispatch<SetStateAction<T>>, () => void] => {
|
||||
const defaultValueRef = useRef(defaultValue);
|
||||
const validateRef = useRef(validate);
|
||||
const migrateRef = useRef(migrate);
|
||||
defaultValueRef.current = defaultValue;
|
||||
validateRef.current = validate;
|
||||
migrateRef.current = migrate;
|
||||
|
||||
const createDefaultValue = useCallback(() => resolveDefaultValue(defaultValueRef.current), []);
|
||||
const [value, setValueState] = useState<T>(createDefaultValue);
|
||||
const valueRef = useRef(value);
|
||||
|
||||
const setMemoryValue = useCallback((nextValue: T) => {
|
||||
valueRef.current = nextValue;
|
||||
setValueState(nextValue);
|
||||
}, []);
|
||||
|
||||
const removeStoredValue = useCallback(() => {
|
||||
if (!key) return;
|
||||
try {
|
||||
window.localStorage.removeItem(key);
|
||||
} catch {
|
||||
// Storage can be unavailable in private or restricted browser contexts.
|
||||
}
|
||||
}, [key]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!key) {
|
||||
setMemoryValue(createDefaultValue());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const serialized = window.localStorage.getItem(key);
|
||||
if (serialized === null) {
|
||||
setMemoryValue(createDefaultValue());
|
||||
return;
|
||||
}
|
||||
const parsed = parseLocalWidgetState(serialized, version, validateRef.current, migrateRef.current);
|
||||
if (parsed === undefined) {
|
||||
window.localStorage.removeItem(key);
|
||||
setMemoryValue(createDefaultValue());
|
||||
return;
|
||||
}
|
||||
setMemoryValue(parsed);
|
||||
} catch {
|
||||
setMemoryValue(createDefaultValue());
|
||||
}
|
||||
}, [createDefaultValue, key, setMemoryValue, version]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!key) return;
|
||||
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.storageArea !== window.localStorage || event.key !== key) return;
|
||||
if (event.newValue === null) {
|
||||
setMemoryValue(createDefaultValue());
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseLocalWidgetState(event.newValue, version, validateRef.current, migrateRef.current);
|
||||
if (parsed !== undefined) setMemoryValue(parsed);
|
||||
};
|
||||
window.addEventListener("storage", handleStorage);
|
||||
return () => window.removeEventListener("storage", handleStorage);
|
||||
}, [createDefaultValue, key, setMemoryValue, version]);
|
||||
|
||||
const setValue: Dispatch<SetStateAction<T>> = useCallback(
|
||||
(nextValue) => {
|
||||
const resolved =
|
||||
typeof nextValue === "function" ? (nextValue as (previous: T) => T)(valueRef.current) : nextValue;
|
||||
if (Object.is(resolved, valueRef.current)) return;
|
||||
setMemoryValue(resolved);
|
||||
if (key) {
|
||||
try {
|
||||
window.localStorage.setItem(key, serializeLocalWidgetState(version, resolved));
|
||||
} catch {
|
||||
// Keep the in-memory state usable when persistence is unavailable.
|
||||
}
|
||||
}
|
||||
},
|
||||
[key, setMemoryValue, version],
|
||||
);
|
||||
|
||||
const resetValue = useCallback(() => {
|
||||
removeStoredValue();
|
||||
setMemoryValue(createDefaultValue());
|
||||
}, [createDefaultValue, removeStoredValue, setMemoryValue]);
|
||||
|
||||
return [value, setValue, resetValue];
|
||||
};
|
||||
@@ -0,0 +1,173 @@
|
||||
export type { WidgetJson, WidgetStorageScope } from "./shared";
|
||||
|
||||
export interface WidgetServerContext {
|
||||
signal: AbortSignal;
|
||||
instanceId: string;
|
||||
boardId: string;
|
||||
userId?: string;
|
||||
sdk: {
|
||||
invoke(operation: string, input: unknown): Promise<unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export type WidgetServerHandler<TInput = unknown, TOutput = unknown> = (
|
||||
input: TInput,
|
||||
context: WidgetServerContext,
|
||||
) => TOutput | Promise<TOutput> | AsyncIterable<TOutput>;
|
||||
|
||||
/** The runtime invokes only manifest-declared handlers after authorizing the actor. */
|
||||
export function defineWidgetServer<T extends Record<string, WidgetServerHandler<never>>>(handlers: T): T {
|
||||
return handlers;
|
||||
}
|
||||
|
||||
export interface WidgetIntegrationInput {
|
||||
connection: string;
|
||||
capability: string;
|
||||
input?: unknown;
|
||||
}
|
||||
|
||||
export interface WidgetConnectionRequest {
|
||||
connection: string;
|
||||
path?: string;
|
||||
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD";
|
||||
query?: Record<string, string | number | boolean>;
|
||||
headers?: Record<string, string>;
|
||||
body?: unknown;
|
||||
responseType?: "json" | "text" | "base64";
|
||||
}
|
||||
|
||||
export interface WidgetConnectionResponse<T> {
|
||||
data: T;
|
||||
ok: boolean;
|
||||
status: number;
|
||||
contentType: string | null;
|
||||
durationMs: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Credentials, local DNS, timeout and TLS policy come from the named owner-bound connection. */
|
||||
export function fetchWidgetConnection<T = unknown>(context: WidgetServerContext, input: WidgetConnectionRequest) {
|
||||
return context.sdk.invoke("connection.fetch", input) as Promise<WidgetConnectionResponse<T>>;
|
||||
}
|
||||
|
||||
export interface WidgetBoundConnection<TSettings = Record<string, unknown>> {
|
||||
configuration: {
|
||||
kind: "http" | "integration" | "service";
|
||||
serviceType?: string;
|
||||
settings?: TSettings;
|
||||
baseUrl?: string;
|
||||
browserUrl?: string;
|
||||
timeoutMs: number;
|
||||
tls?: { rejectUnauthorized: boolean; ca?: string; cert?: string };
|
||||
[key: string]: unknown;
|
||||
};
|
||||
secrets: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Server handlers only. Use local connection settings and credentials with a Node library or native protocol. */
|
||||
export async function getWidgetConnection<TSettings = Record<string, unknown>>(
|
||||
context: WidgetServerContext,
|
||||
input: { connection: string; serviceType?: string },
|
||||
): Promise<WidgetBoundConnection<TSettings>> {
|
||||
const connection = (await context.sdk.invoke("connection.get", {
|
||||
connection: input.connection,
|
||||
})) as WidgetBoundConnection<TSettings>;
|
||||
if (
|
||||
input.serviceType &&
|
||||
(connection.configuration.kind !== "service" || connection.configuration.serviceType !== input.serviceType)
|
||||
)
|
||||
throw new Error(`Connection '${input.connection}' requires the '${input.serviceType}' service type`);
|
||||
return connection;
|
||||
}
|
||||
|
||||
export function getWidgetServerContext<TOptions extends Record<string, unknown> = Record<string, unknown>>(
|
||||
context: WidgetServerContext,
|
||||
) {
|
||||
return context.sdk.invoke("widget.context", {}) as Promise<{
|
||||
options: TOptions;
|
||||
installationId: string;
|
||||
bindingNames: string[];
|
||||
isPreview: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function callWidgetIntegration<T = unknown>(context: WidgetServerContext, input: WidgetIntegrationInput) {
|
||||
return context.sdk.invoke("integration.call", input) as Promise<T>;
|
||||
}
|
||||
|
||||
/** Bridges native streams across the worker boundary and releases them when the widget stops observing. */
|
||||
export async function* subscribeWidgetIntegration<T = unknown>(
|
||||
context: WidgetServerContext,
|
||||
input: WidgetIntegrationInput,
|
||||
): AsyncGenerator<T> {
|
||||
context.signal.throwIfAborted();
|
||||
const id = await context.sdk.invoke("integration.subscribe", input);
|
||||
if (typeof id !== "string") throw new Error("The integration subscription returned an invalid identifier");
|
||||
try {
|
||||
while (!context.signal.aborted) {
|
||||
const result = (await context.sdk.invoke("integration.next", { id })) as IteratorResult<T>;
|
||||
if (result.done) return;
|
||||
yield result.value;
|
||||
}
|
||||
} finally {
|
||||
// Cleanup is best effort after a worker cancellation; preserve the original stream result/error.
|
||||
await context.sdk.invoke("integration.unsubscribe", { id }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export interface WidgetRunnerInput {
|
||||
connection: string;
|
||||
handler: string;
|
||||
input?: unknown;
|
||||
}
|
||||
|
||||
async function runnerRequest<T>(context: WidgetServerContext, connection: string, path: string, body: unknown) {
|
||||
const response = await fetchWidgetConnection<T & { error?: string }>(context, {
|
||||
connection,
|
||||
path,
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
if (!response.ok || response.data.error)
|
||||
throw new Error(response.data.error ?? `Widget runner returned HTTP ${response.status}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** Run an owner-installed package handler on a named remote runner connection. */
|
||||
export async function callWidgetRunner<T = unknown>(context: WidgetServerContext, input: WidgetRunnerInput) {
|
||||
const result = await runnerRequest<{ value: T }>(context, input.connection, "/invoke", {
|
||||
handler: input.handler,
|
||||
input: input.input,
|
||||
instanceId: context.instanceId,
|
||||
boardId: context.boardId,
|
||||
userId: context.userId,
|
||||
});
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export async function* subscribeWidgetRunner<T = unknown>(
|
||||
context: WidgetServerContext,
|
||||
input: WidgetRunnerInput,
|
||||
): AsyncGenerator<T> {
|
||||
const { id } = await runnerRequest<{ id: string }>(context, input.connection, "/subscriptions/start", {
|
||||
handler: input.handler,
|
||||
input: input.input,
|
||||
instanceId: context.instanceId,
|
||||
boardId: context.boardId,
|
||||
userId: context.userId,
|
||||
});
|
||||
try {
|
||||
while (!context.signal.aborted) {
|
||||
const result = await runnerRequest<{ done: boolean; values: T[] }>(
|
||||
context,
|
||||
input.connection,
|
||||
"/subscriptions/next",
|
||||
{ id },
|
||||
);
|
||||
for (const value of result.values) yield value;
|
||||
if (result.done) return;
|
||||
}
|
||||
} finally {
|
||||
await runnerRequest(context, input.connection, "/subscriptions/cancel", { id }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type WidgetStorageScope = "user" | "instance" | "installation";
|
||||
export type WidgetDisplayMode = "compact" | "advanced";
|
||||
export type WidgetJson = null | boolean | number | string | WidgetJson[] | { [key: string]: WidgetJson };
|
||||
|
||||
export interface WidgetOperationInput {
|
||||
itemId: string;
|
||||
name: string;
|
||||
input: unknown;
|
||||
}
|
||||
|
||||
export interface WidgetStorageInput {
|
||||
itemId: string;
|
||||
scope: WidgetStorageScope;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface WidgetSubscriptionObserver {
|
||||
next(value: unknown): void;
|
||||
error(error: Error): void;
|
||||
complete(): void;
|
||||
}
|
||||
|
||||
export interface WidgetTransport {
|
||||
query(input: WidgetOperationInput, signal: AbortSignal): Promise<unknown>;
|
||||
action(input: WidgetOperationInput): Promise<unknown>;
|
||||
subscribe(input: WidgetOperationInput, observer: WidgetSubscriptionObserver): () => void;
|
||||
storageGet(input: WidgetStorageInput, signal: AbortSignal): Promise<unknown>;
|
||||
storageSet(input: WidgetStorageInput & { value: unknown }): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface WidgetCommand {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
hidden?: boolean;
|
||||
destructive?: boolean;
|
||||
run(): void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface WidgetHostContext {
|
||||
sdkVersion: "1";
|
||||
itemId?: string;
|
||||
previewId?: string;
|
||||
boardId?: string;
|
||||
installationId?: string;
|
||||
revisionId: string;
|
||||
scope?: string;
|
||||
handlerPrefix?: string;
|
||||
displayMode: WidgetDisplayMode;
|
||||
width: number;
|
||||
height: number;
|
||||
displayScale: number;
|
||||
visibleWidth: number;
|
||||
visibleHeight: number;
|
||||
isEditMode: boolean;
|
||||
isPreview: boolean;
|
||||
visible: boolean;
|
||||
/** False when an authoring preview preserves its last successful view without executing requests or controls. */
|
||||
executionEnabled?: boolean;
|
||||
/** Dashboard refresh setting; compatibility widgets use it for their declared load queries. */
|
||||
refreshIntervalSeconds?: number;
|
||||
locale: string;
|
||||
timeZone: string;
|
||||
colorScheme: "light" | "dark";
|
||||
reducedMotion: boolean;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface WidgetStateStore {
|
||||
get(key: string): unknown;
|
||||
set(key: string, value: unknown): void;
|
||||
subscribe(listener: () => void): () => void;
|
||||
}
|
||||
|
||||
export interface WidgetNotification {
|
||||
title: string;
|
||||
message?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface WidgetHostServices {
|
||||
transport: WidgetTransport;
|
||||
state: WidgetStateStore;
|
||||
registerCommands(commands: readonly WidgetCommand[]): () => void;
|
||||
notify(notification: WidgetNotification): void;
|
||||
navigate(href: string): void;
|
||||
openAdvanced(): void;
|
||||
closeAdvanced(): void;
|
||||
openDetail(detail: { title: string; content: ReactNode }): void;
|
||||
closeDetail(): void;
|
||||
confirm(input: { title: string; message: string; destructive?: boolean }): Promise<boolean>;
|
||||
updateOptions(options: Record<string, unknown>): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { HomarrDataTable } from "./table/homarr-data-table";
|
||||
export {
|
||||
parseColumnOrder,
|
||||
parseColumnWidths,
|
||||
usePersistedTableLayout,
|
||||
useTableLayoutPersistence,
|
||||
} from "./table/use-persisted-table-layout";
|
||||
export type { DataTableColumn, DataTableProps, DataTableSortStatus } from "mantine-datatable";
|
||||
@@ -0,0 +1,95 @@
|
||||
.homarr-data-table,
|
||||
.homarr-data-table .mantine-datatable-table,
|
||||
.homarr-data-table .mantine-datatable-table thead,
|
||||
.homarr-data-table .mantine-datatable-table tbody,
|
||||
.homarr-data-table .mantine-datatable-table tfoot,
|
||||
.homarr-data-table th,
|
||||
.homarr-data-table td {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.homarr-data-table .mantine-datatable-table tr {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.homarr-data-table .mantine-datatable-table {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
table-layout: fixed !important;
|
||||
}
|
||||
|
||||
.homarr-data-table .mantine-datatable-table tbody tr:hover {
|
||||
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
|
||||
}
|
||||
|
||||
.homarr-data-table .mantine-datatable-scroll-area-scrollbar[data-state="visible"]:hover {
|
||||
@mixin light {
|
||||
background-color: var(--mantine-color-gray-0);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background-color: var(--mantine-color-dark-8);
|
||||
}
|
||||
}
|
||||
|
||||
.homarr-data-table th {
|
||||
padding: 0;
|
||||
white-space: nowrap;
|
||||
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.homarr-data-table td {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Lets a cell's content (e.g. an oversized action icon) visually overflow its own box without
|
||||
growing it - used to make a per-row icon look bigger without inflating row height, since the
|
||||
icon is pulled out of flow via absolute positioning rather than actually being a bigger cell. */
|
||||
.homarr-data-table td.docker-actions-cell {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.homarr-data-table td .mantine-Text-root,
|
||||
.homarr-data-table td .mantine-Group-root {
|
||||
white-space: nowrap;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.homarr-data-table .mantine-datatable-header-cell-sortable-icon,
|
||||
.homarr-data-table .mantine-datatable-header-cell-draggable-action-icon {
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease-out;
|
||||
}
|
||||
|
||||
.homarr-data-table .mantine-datatable-header-cell-sortable-group {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.homarr-data-table .mantine-datatable-header-cell-draggable-action-icon {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.homarr-data-table .mantine-datatable-header-cell-sortable-unsorted-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.homarr-data-table .mantine-datatable-header-cell-draggable-action-icon svg,
|
||||
.homarr-data-table .mantine-datatable-header-cell-sortable-text svg,
|
||||
.homarr-data-table .mantine-datatable-header-cell-sortable-icon svg {
|
||||
width: var(--mantine-font-size-md);
|
||||
height: var(--mantine-font-size-md);
|
||||
}
|
||||
|
||||
.homarr-data-table th:hover .mantine-datatable-header-cell-sortable-icon,
|
||||
.homarr-data-table th:hover .mantine-datatable-header-cell-draggable-action-icon,
|
||||
.homarr-data-table .mantine-datatable-header-cell-draggable-action-icon:focus-visible,
|
||||
.homarr-data-table .mantine-datatable-header-cell-drag-over .mantine-datatable-header-cell-draggable-action-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.homarr-data-table-row-cursor-default .mantine-datatable-context-menu-cursor {
|
||||
cursor: default;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
declare const stylesheet: string;
|
||||
export default stylesheet;
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
// Shared CSS is required by both native and package table consumers.
|
||||
// eslint-disable-next-line import/no-unassigned-import
|
||||
import "./homarr-data-table.css";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { DataTableColumn, DataTableProps } from "mantine-datatable";
|
||||
import { DataTable } from "mantine-datatable";
|
||||
|
||||
import { scrollAreaDefaultProps } from "../ui-settings";
|
||||
|
||||
const fallbackColumnWidth = 60;
|
||||
|
||||
const getColumnWidth = (width: string | number | undefined): number => {
|
||||
if (typeof width === "number" && Number.isFinite(width) && width > 0) return width;
|
||||
if (typeof width === "string") {
|
||||
const parsedWidth = Number.parseFloat(width);
|
||||
if (Number.isFinite(parsedWidth) && parsedWidth > 0) return parsedWidth;
|
||||
}
|
||||
return fallbackColumnWidth;
|
||||
};
|
||||
|
||||
type HomarrDataTableProps<T> = DataTableProps<T> & {
|
||||
columns: DataTableColumn<T>[];
|
||||
groups?: never;
|
||||
isEditMode: boolean;
|
||||
cellPadding: string;
|
||||
rowCursor?: "auto" | "default";
|
||||
};
|
||||
|
||||
export function HomarrDataTable<T>({
|
||||
isEditMode,
|
||||
cellPadding,
|
||||
rowCursor = "auto",
|
||||
className,
|
||||
columns,
|
||||
defaultColumnProps,
|
||||
onMouseDownCapture,
|
||||
scrollAreaProps,
|
||||
style,
|
||||
...props
|
||||
}: HomarrDataTableProps<T>) {
|
||||
const [resizePreviewWidths, setResizePreviewWidths] = useState<Record<string, number>>();
|
||||
const removeResizeListeners = useRef<() => void>(() => undefined);
|
||||
useEffect(() => () => removeResizeListeners.current(), []);
|
||||
|
||||
const handleMouseDownCapture = useCallback(
|
||||
(event: React.MouseEvent<HTMLTableElement>) => {
|
||||
onMouseDownCapture?.(event);
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
|
||||
const handle = target.closest(".mantine-datatable-header-resizable-handle");
|
||||
const currentCell = handle?.closest<HTMLTableCellElement>("th[data-accessor]");
|
||||
const headerRow = currentCell?.parentElement;
|
||||
if (!currentCell || !headerRow) return;
|
||||
|
||||
const cells = Array.from(headerRow.querySelectorAll<HTMLTableCellElement>("th[data-accessor]"));
|
||||
const currentIndex = cells.indexOf(currentCell);
|
||||
const nextCell = cells[currentIndex + 1];
|
||||
const currentAccessor = currentCell.dataset.accessor;
|
||||
const nextAccessor = nextCell?.dataset.accessor;
|
||||
if (!nextCell || !currentAccessor || !nextAccessor) return;
|
||||
|
||||
const widths = Object.fromEntries(
|
||||
cells.flatMap((cell) => {
|
||||
const accessor = cell.dataset.accessor;
|
||||
if (!accessor) return [];
|
||||
return [[accessor, cell.getBoundingClientRect().width]];
|
||||
}),
|
||||
);
|
||||
const currentWidth = widths[currentAccessor];
|
||||
const nextWidth = widths[nextAccessor];
|
||||
if (currentWidth === undefined || nextWidth === undefined) return;
|
||||
|
||||
const visualScale = currentCell.getBoundingClientRect().width / currentCell.offsetWidth;
|
||||
const minimumWidth = fallbackColumnWidth * visualScale;
|
||||
const startX = event.clientX;
|
||||
|
||||
removeResizeListeners.current();
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const delta = Math.min(
|
||||
nextWidth - minimumWidth,
|
||||
Math.max(minimumWidth - currentWidth, moveEvent.clientX - startX),
|
||||
);
|
||||
setResizePreviewWidths({
|
||||
...widths,
|
||||
[currentAccessor]: currentWidth + delta,
|
||||
[nextAccessor]: nextWidth - delta,
|
||||
});
|
||||
};
|
||||
const handleResizeEnd = () => {
|
||||
removeResizeListeners.current();
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => setResizePreviewWidths(undefined)));
|
||||
};
|
||||
removeResizeListeners.current = () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleResizeEnd);
|
||||
window.removeEventListener("blur", handleResizeEnd);
|
||||
};
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleResizeEnd);
|
||||
window.addEventListener("blur", handleResizeEnd);
|
||||
},
|
||||
[onMouseDownCapture],
|
||||
);
|
||||
|
||||
const mergedClassName = [
|
||||
"homarr-data-table",
|
||||
rowCursor === "default" ? "homarr-data-table-row-cursor-default" : undefined,
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const fittedColumns = useMemo(() => {
|
||||
const visibleColumns = columns.filter(({ hidden }) => !hidden);
|
||||
const getRenderedWidth = (column: DataTableColumn<T>) =>
|
||||
resizePreviewWidths?.[String(column.accessor)] ?? getColumnWidth(column.width);
|
||||
const totalWidth = visibleColumns.reduce((total, column) => total + getRenderedWidth(column), 0);
|
||||
if (totalWidth <= 0) return columns;
|
||||
|
||||
return columns.map((column) => {
|
||||
if (column.hidden) return column;
|
||||
|
||||
const width = `${(getRenderedWidth(column) / totalWidth) * 100}%`;
|
||||
const fittedWidth = { width, minWidth: width, maxWidth: width };
|
||||
const cellsStyle = column.cellsStyle ?? defaultColumnProps?.cellsStyle;
|
||||
return {
|
||||
...column,
|
||||
titleStyle: [column.titleStyle, fittedWidth],
|
||||
cellsStyle: (record: T, index: number) => [cellsStyle?.(record, index), fittedWidth, { padding: cellPadding }],
|
||||
};
|
||||
});
|
||||
}, [cellPadding, columns, defaultColumnProps?.cellsStyle, resizePreviewWidths]);
|
||||
|
||||
return (
|
||||
<DataTable<T>
|
||||
withTableBorder={false}
|
||||
borderRadius={0}
|
||||
highlightOnHover
|
||||
highlightOnHoverColor={{ dark: "dark.5", light: "gray.1" }}
|
||||
verticalAlign="center"
|
||||
loaderBackgroundBlur={2}
|
||||
textSelectionDisabled
|
||||
height="100%"
|
||||
{...props}
|
||||
striped={false}
|
||||
columns={fittedColumns}
|
||||
onMouseDownCapture={handleMouseDownCapture}
|
||||
className={mergedClassName}
|
||||
style={[{ pointerEvents: isEditMode ? "none" : undefined }, style]}
|
||||
defaultColumnProps={{
|
||||
...defaultColumnProps,
|
||||
noWrap: defaultColumnProps?.noWrap ?? true,
|
||||
draggable: true,
|
||||
resizable: true,
|
||||
cellsStyle: defaultColumnProps?.cellsStyle ?? (() => ({ padding: cellPadding })),
|
||||
}}
|
||||
scrollAreaProps={{ ...scrollAreaDefaultProps, ...scrollAreaProps }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import type { DataTableColumn } from "mantine-datatable";
|
||||
import { useDataTableColumns } from "mantine-datatable";
|
||||
|
||||
import { isRecord } from "@homarr/common";
|
||||
|
||||
interface TableLayoutOptions extends Record<string, unknown> {
|
||||
columnOrder?: string;
|
||||
columnWidths?: string;
|
||||
}
|
||||
|
||||
interface PendingTableLayout {
|
||||
layout: TableLayoutOptions;
|
||||
onLayoutChange: (layout: TableLayoutOptions) => void;
|
||||
storeKey: string;
|
||||
}
|
||||
|
||||
interface UseTableLayoutPersistenceProps {
|
||||
boardId: string | undefined;
|
||||
hasChangeAccess: boolean;
|
||||
itemId: string | undefined;
|
||||
saveItemOptions: (input: { boardId: string; itemId: string; newOptions: TableLayoutOptions }) => void;
|
||||
setOptions: (input: { newOptions: TableLayoutOptions }) => void;
|
||||
}
|
||||
|
||||
interface UsePersistedTableLayoutProps<T> {
|
||||
columns: DataTableColumn<T>[];
|
||||
columnAccessors: readonly string[];
|
||||
columnOrder: string;
|
||||
columnWidths: string;
|
||||
itemId: string | undefined;
|
||||
storeKeyPrefix: string;
|
||||
onLayoutChange: (layout: TableLayoutOptions) => void;
|
||||
}
|
||||
|
||||
const layoutSaveDelay = 350;
|
||||
|
||||
export const useTableLayoutPersistence = ({
|
||||
boardId,
|
||||
hasChangeAccess,
|
||||
itemId,
|
||||
saveItemOptions,
|
||||
setOptions,
|
||||
}: UseTableLayoutPersistenceProps) =>
|
||||
useCallback(
|
||||
(newOptions: TableLayoutOptions) => {
|
||||
setOptions({ newOptions });
|
||||
if (hasChangeAccess && boardId && itemId) saveItemOptions({ boardId, itemId, newOptions });
|
||||
},
|
||||
[boardId, hasChangeAccess, itemId, saveItemOptions, setOptions],
|
||||
);
|
||||
|
||||
const toPixelWidth = (width: string | number): number | undefined => {
|
||||
if (typeof width === "number" && Number.isFinite(width) && width > 0) return width;
|
||||
if (typeof width !== "string" || !width.endsWith("px")) return undefined;
|
||||
|
||||
const parsedWidth = Number.parseInt(width, 10);
|
||||
return Number.isFinite(parsedWidth) && parsedWidth > 0 ? parsedWidth : undefined;
|
||||
};
|
||||
|
||||
const getColumnWidthMap = (
|
||||
columnsWidth: Record<string, string | number>[],
|
||||
visibleAccessorSet: ReadonlySet<string>,
|
||||
): Record<string, number> => {
|
||||
const widths: Record<string, number> = {};
|
||||
for (const entry of columnsWidth) {
|
||||
for (const [accessor, width] of Object.entries(entry)) {
|
||||
if (!visibleAccessorSet.has(accessor)) continue;
|
||||
const pixelWidth = toPixelWidth(width);
|
||||
if (pixelWidth !== undefined) widths[accessor] = pixelWidth;
|
||||
}
|
||||
}
|
||||
return widths;
|
||||
};
|
||||
|
||||
export const parseColumnOrder = (value: string, columnAccessors: readonly string[]): string[] => {
|
||||
if (!value) return [];
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
|
||||
const allowed = new Set(columnAccessors);
|
||||
return parsed.filter(
|
||||
(accessor, index): accessor is string =>
|
||||
typeof accessor === "string" && allowed.has(accessor) && parsed.indexOf(accessor) === index,
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const parseColumnWidths = (value: string, columnAccessors: readonly string[]): Record<string, number> => {
|
||||
if (!value) return {};
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (!isRecord(parsed)) return {};
|
||||
|
||||
const allowed = new Set(columnAccessors);
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed).filter(
|
||||
(entry): entry is [string, number] =>
|
||||
allowed.has(entry[0]) && typeof entry[1] === "number" && Number.isFinite(entry[1]) && entry[1] > 0,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export const usePersistedTableLayout = <T>({
|
||||
columns,
|
||||
columnAccessors,
|
||||
columnOrder,
|
||||
columnWidths,
|
||||
itemId,
|
||||
storeKeyPrefix,
|
||||
onLayoutChange,
|
||||
}: UsePersistedTableLayoutProps<T>) => {
|
||||
const savedOrder = useMemo(() => parseColumnOrder(columnOrder, columnAccessors), [columnAccessors, columnOrder]);
|
||||
const savedWidths = useMemo(() => parseColumnWidths(columnWidths, columnAccessors), [columnAccessors, columnWidths]);
|
||||
const visibleAccessors = useMemo(() => columns.map(({ accessor }) => String(accessor)), [columns]);
|
||||
const visibleAccessorSet = useMemo(() => new Set(visibleAccessors), [visibleAccessors]);
|
||||
const restoredOrder = useMemo(
|
||||
() => [
|
||||
...savedOrder.filter((accessor) => visibleAccessorSet.has(accessor)),
|
||||
...visibleAccessors.filter((accessor) => !savedOrder.includes(accessor)),
|
||||
],
|
||||
[savedOrder, visibleAccessors, visibleAccessorSet],
|
||||
);
|
||||
const storeKey = `${storeKeyPrefix}-${itemId ?? "preview"}-${[...visibleAccessors].toSorted().join(",")}`;
|
||||
const { effectiveColumns, columnsOrder, columnsWidth, setColumnsOrder, setMultipleColumnWidths } =
|
||||
useDataTableColumns<T>({
|
||||
key: storeKey,
|
||||
columns,
|
||||
});
|
||||
|
||||
const lastStoreKey = useRef(storeKey);
|
||||
const hydrated = useRef(false);
|
||||
const previousOrder = useRef(columnsOrder);
|
||||
const previousWidths = useRef(columnsWidth);
|
||||
useEffect(() => {
|
||||
if (lastStoreKey.current !== storeKey) {
|
||||
lastStoreKey.current = storeKey;
|
||||
hydrated.current = false;
|
||||
}
|
||||
if (hydrated.current) return;
|
||||
|
||||
const orderMatches =
|
||||
columnsOrder.length === restoredOrder.length &&
|
||||
columnsOrder.every((accessor, index) => accessor === restoredOrder[index]);
|
||||
const currentWidths = getColumnWidthMap(columnsWidth, visibleAccessorSet);
|
||||
const visibleWidths = Object.entries(savedWidths).filter(([accessor]) => visibleAccessorSet.has(accessor));
|
||||
const widthsMatch = visibleWidths.every(([accessor, width]) => currentWidths[accessor] === width);
|
||||
|
||||
if (orderMatches && widthsMatch) {
|
||||
previousOrder.current = columnsOrder;
|
||||
previousWidths.current = columnsWidth;
|
||||
hydrated.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!orderMatches) setColumnsOrder(restoredOrder);
|
||||
if (!widthsMatch) {
|
||||
setMultipleColumnWidths(visibleWidths.map(([accessor, width]) => ({ accessor, width })));
|
||||
}
|
||||
}, [
|
||||
columnsOrder,
|
||||
columnsWidth,
|
||||
restoredOrder,
|
||||
savedWidths,
|
||||
setColumnsOrder,
|
||||
setMultipleColumnWidths,
|
||||
storeKey,
|
||||
visibleAccessorSet,
|
||||
]);
|
||||
|
||||
const pendingLayout = useRef<PendingTableLayout | undefined>(undefined);
|
||||
const saveTimeout = useRef<number | undefined>(undefined);
|
||||
const flushPendingLayout = useCallback(() => {
|
||||
const pending = pendingLayout.current;
|
||||
pendingLayout.current = undefined;
|
||||
window.clearTimeout(saveTimeout.current);
|
||||
saveTimeout.current = undefined;
|
||||
if (pending) pending.onLayoutChange(pending.layout);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (pendingLayout.current && pendingLayout.current.storeKey !== storeKey) flushPendingLayout();
|
||||
}, [flushPendingLayout, storeKey]);
|
||||
useEffect(() => () => flushPendingLayout(), [flushPendingLayout]);
|
||||
useEffect(() => {
|
||||
if (!hydrated.current) return;
|
||||
|
||||
const orderChanged = JSON.stringify(columnsOrder) !== JSON.stringify(previousOrder.current);
|
||||
const widthsChanged = JSON.stringify(columnsWidth) !== JSON.stringify(previousWidths.current);
|
||||
previousOrder.current = columnsOrder;
|
||||
previousWidths.current = columnsWidth;
|
||||
if (!orderChanged && !widthsChanged) return;
|
||||
|
||||
const newLayout: TableLayoutOptions = {};
|
||||
if (orderChanged) {
|
||||
const fullOrder = [...savedOrder, ...columnAccessors.filter((accessor) => !savedOrder.includes(accessor))];
|
||||
const reorderedVisibleColumns = [...columnsOrder];
|
||||
const mergedOrder = fullOrder.map((accessor) =>
|
||||
visibleAccessorSet.has(accessor) ? (reorderedVisibleColumns.shift() ?? accessor) : accessor,
|
||||
);
|
||||
newLayout.columnOrder = JSON.stringify(mergedOrder);
|
||||
}
|
||||
if (widthsChanged) {
|
||||
const widthMap: Record<string, number> = { ...savedWidths };
|
||||
Object.assign(widthMap, getColumnWidthMap(columnsWidth, visibleAccessorSet));
|
||||
newLayout.columnWidths = JSON.stringify(widthMap);
|
||||
}
|
||||
|
||||
if (pendingLayout.current && pendingLayout.current.storeKey !== storeKey) flushPendingLayout();
|
||||
pendingLayout.current = pendingLayout.current
|
||||
? { ...pendingLayout.current, layout: { ...pendingLayout.current.layout, ...newLayout } }
|
||||
: { layout: newLayout, onLayoutChange, storeKey };
|
||||
window.clearTimeout(saveTimeout.current);
|
||||
saveTimeout.current = window.setTimeout(flushPendingLayout, layoutSaveDelay);
|
||||
}, [
|
||||
columnAccessors,
|
||||
columnsOrder,
|
||||
columnsWidth,
|
||||
flushPendingLayout,
|
||||
onLayoutChange,
|
||||
savedOrder,
|
||||
savedWidths,
|
||||
storeKey,
|
||||
visibleAccessorSet,
|
||||
]);
|
||||
|
||||
return { effectiveColumns, storeKey };
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import type { MRT_RowData, MRT_TableOptions } from "mantine-react-table";
|
||||
import { useMantineReactTable } from "mantine-react-table";
|
||||
|
||||
import type { SupportedLanguage } from "@homarr/translation";
|
||||
import { localeConfigurations } from "@homarr/translation";
|
||||
|
||||
export const useTranslatedMantineReactTable = <TData extends MRT_RowData>(
|
||||
tableOptions: Omit<MRT_TableOptions<TData>, "localization">,
|
||||
) => {
|
||||
const { locale } = useParams<{ locale: SupportedLanguage }>();
|
||||
const { data: mantineReactTable } = useSuspenseQuery({
|
||||
queryKey: ["mantine-react-table-locale", locale],
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
queryFn: async () => {
|
||||
return await localeConfigurations[locale].importMrtLocalization();
|
||||
},
|
||||
});
|
||||
|
||||
return useMantineReactTable<TData>({
|
||||
...tableOptions,
|
||||
localization: mantineReactTable,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export const scrollAreaDefaultProps = {
|
||||
type: "hover",
|
||||
scrollbarSize: 2,
|
||||
scrollHideDelay: 500,
|
||||
offsetScrollbars: true,
|
||||
} as const;
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren, ReactNode } from "react";
|
||||
import { Alert, Center, Loader, Stack, Text } from "@mantine/core";
|
||||
|
||||
export { ActionIcon, Badge, Box, Button, Card, Group, SimpleGrid, Stack, Table, Text, Title } from "@mantine/core";
|
||||
|
||||
export function WidgetEmptyState({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
{children}
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
export function WidgetLoading({ label }: { label?: string }) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Stack align="center" gap="xs">
|
||||
<Loader size="sm" />
|
||||
{label && <Text size="sm">{label}</Text>}
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
export function WidgetError({ children, title, action }: PropsWithChildren<{ title?: string; action?: ReactNode }>) {
|
||||
return (
|
||||
<Alert color="red" title={title}>
|
||||
{children}
|
||||
{action}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export { HomarrDataTable, usePersistedTableLayout, useTableLayoutPersistence } from "./table";
|
||||
export type { DataTableColumn, DataTableProps, DataTableSortStatus } from "./table";
|
||||
export { MantineReactTable } from "mantine-react-table";
|
||||
export type { MRT_ColumnDef, MRT_TableOptions } from "mantine-react-table";
|
||||
export { useTranslatedMantineReactTable } from "./table/use-translated-mantine-react-table";
|
||||
Reference in new issue
Block a user