feat(settings): add simple-ping settings (#2118)

This commit is contained in:
Meier Lukas
2025-02-07 22:10:35 +01:00
committed by GitHub
parent c04c42dc8a
commit dff6cb9d31
88 changed files with 4489 additions and 582 deletions

View File

@@ -7,6 +7,8 @@ import { IconLoader } from "@tabler/icons-react";
import combineClasses from "clsx";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useSettings } from "@homarr/settings";
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
@@ -17,6 +19,8 @@ import { PingIndicator } from "./ping/ping-indicator";
export default function AppWidget({ options, isEditMode }: WidgetComponentProps<"app">) {
const t = useI18n();
const settings = useSettings();
const board = useRequiredBoard();
const [app] = clientApi.app.byId.useSuspenseQuery(
{
id: options.appId,
@@ -81,7 +85,7 @@ export default function AppWidget({ options, isEditMode }: WidgetComponentProps<
<img src={app.iconUrl} alt={app.name} className={combineClasses(classes.appIcon, "app-icon")} />
</Flex>
</Tooltip.Floating>
{options.pingEnabled && app.href ? (
{options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? (
<Suspense fallback={<PingDot icon={IconLoader} color="blue" tooltip={`${t("common.action.loading")}`} />}>
<PingIndicator href={app.href} />
</Suspense>

View File

@@ -5,13 +5,24 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("app", {
icon: IconApps,
options: optionsBuilder.from((factory) => ({
appId: factory.app(),
openInNewTab: factory.switch({ defaultValue: true }),
showTitle: factory.switch({ defaultValue: true }),
showDescriptionTooltip: factory.switch({ defaultValue: false }),
pingEnabled: factory.switch({ defaultValue: false }),
})),
createOptions(settings) {
return optionsBuilder.from(
(factory) => ({
appId: factory.app(),
openInNewTab: factory.switch({ defaultValue: true }),
showTitle: factory.switch({ defaultValue: true }),
showDescriptionTooltip: factory.switch({ defaultValue: false }),
pingEnabled: factory.switch({ defaultValue: settings.enableStatusByDefault }),
}),
{
pingEnabled: {
shouldHide() {
return settings.forceDisableStatus;
},
},
},
);
},
errors: {
NOT_FOUND: {
icon: IconDeviceDesktopX,

View File

@@ -10,50 +10,52 @@ import { BookmarkAddButton } from "./add-button";
export const { definition, componentLoader } = createWidgetDefinition("bookmarks", {
icon: IconClock,
options: optionsBuilder.from((factory) => ({
title: factory.text(),
layout: factory.select({
options: (["grid", "row", "column"] as const).map((value) => ({
value,
label: (t) => t(`widget.bookmarks.option.layout.option.${value}.label`),
})),
defaultValue: "column",
}),
hideIcon: factory.switch({ defaultValue: false }),
hideHostname: factory.switch({ defaultValue: false }),
openNewTab: factory.switch({ defaultValue: true }),
items: factory.sortableItemList<RouterOutputs["app"]["all"][number], string>({
ItemComponent: ({ item, handle: Handle, removeItem, rootAttributes }) => {
return (
<Group {...rootAttributes} tabIndex={0} justify="space-between" wrap="nowrap">
<Group wrap="nowrap">
<Handle />
createOptions() {
return optionsBuilder.from((factory) => ({
title: factory.text(),
layout: factory.select({
options: (["grid", "row", "column"] as const).map((value) => ({
value,
label: (t) => t(`widget.bookmarks.option.layout.option.${value}.label`),
})),
defaultValue: "column",
}),
hideIcon: factory.switch({ defaultValue: false }),
hideHostname: factory.switch({ defaultValue: false }),
openNewTab: factory.switch({ defaultValue: true }),
items: factory.sortableItemList<RouterOutputs["app"]["all"][number], string>({
ItemComponent: ({ item, handle: Handle, removeItem, rootAttributes }) => {
return (
<Group {...rootAttributes} tabIndex={0} justify="space-between" wrap="nowrap">
<Group wrap="nowrap">
<Handle />
<Group>
<Avatar src={item.iconUrl} alt={item.name} />
<Stack gap={0}>
<Text>{item.name}</Text>
</Stack>
<Group>
<Avatar src={item.iconUrl} alt={item.name} />
<Stack gap={0}>
<Text>{item.name}</Text>
</Stack>
</Group>
</Group>
<ActionIcon variant="transparent" color="red" onClick={removeItem}>
<IconX size={20} />
</ActionIcon>
</Group>
);
},
AddButton: BookmarkAddButton,
uniqueIdentifier: (item) => item.id,
useData: (initialIds) => {
const { data, error, isLoading } = clientApi.app.byIds.useQuery(initialIds);
<ActionIcon variant="transparent" color="red" onClick={removeItem}>
<IconX size={20} />
</ActionIcon>
</Group>
);
},
AddButton: BookmarkAddButton,
uniqueIdentifier: (item) => item.id,
useData: (initialIds) => {
const { data, error, isLoading } = clientApi.app.byIds.useQuery(initialIds);
return {
data,
error,
isLoading,
};
},
}),
})),
return {
data,
error,
isLoading,
};
},
}),
}));
},
}).withDynamicImport(() => import("./component"));

View File

@@ -9,23 +9,25 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("calendar", {
icon: IconCalendar,
options: optionsBuilder.from((factory) => ({
releaseType: factory.multiSelect({
defaultValue: ["inCinemas", "digitalRelease"],
options: radarrReleaseTypes.map((value) => ({
value,
label: (t) => t(`widget.calendar.option.releaseType.options.${value}`),
})),
}),
filterPastMonths: factory.number({
validate: z.number().min(2).max(9999),
defaultValue: 2,
}),
filterFutureMonths: factory.number({
validate: z.number().min(2).max(9999),
defaultValue: 2,
}),
})),
createOptions() {
return optionsBuilder.from((factory) => ({
releaseType: factory.multiSelect({
defaultValue: ["inCinemas", "digitalRelease"],
options: radarrReleaseTypes.map((value) => ({
value,
label: (t) => t(`widget.calendar.option.releaseType.options.${value}`),
})),
}),
filterPastMonths: factory.number({
validate: z.number().min(2).max(9999),
defaultValue: 2,
}),
filterFutureMonths: factory.number({
validate: z.number().min(2).max(9999),
defaultValue: 2,
}),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("calendar"),
integrationsRequired: false,
}).withDynamicImport(() => import("./component"));

View File

@@ -6,65 +6,67 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("clock", {
icon: IconClock,
options: optionsBuilder.from(
(factory) => ({
customTitleToggle: factory.switch({
defaultValue: false,
withDescription: true,
createOptions() {
return optionsBuilder.from(
(factory) => ({
customTitleToggle: factory.switch({
defaultValue: false,
withDescription: true,
}),
customTitle: factory.text({
defaultValue: "",
}),
is24HourFormat: factory.switch({
defaultValue: true,
withDescription: true,
}),
showSeconds: factory.switch({
defaultValue: false,
}),
useCustomTimezone: factory.switch({ defaultValue: false }),
timezone: factory.select({
options: Intl.supportedValuesOf("timeZone").map((value) => value),
defaultValue: "Europe/London",
searchable: true,
withDescription: true,
}),
showDate: factory.switch({
defaultValue: true,
}),
dateFormat: factory.select({
options: [
{ value: "dddd, MMMM D", label: dayjs().format("dddd, MMMM D") },
{ value: "dddd, D MMMM", label: dayjs().format("dddd, D MMMM") },
{ value: "MMM D", label: dayjs().format("MMM D") },
{ value: "D MMM", label: dayjs().format("D MMM") },
{ value: "DD/MM/YYYY", label: dayjs().format("DD/MM/YYYY") },
{ value: "MM/DD/YYYY", label: dayjs().format("MM/DD/YYYY") },
{ value: "DD/MM", label: dayjs().format("DD/MM") },
{ value: "MM/DD", label: dayjs().format("MM/DD") },
],
defaultValue: "dddd, MMMM D",
withDescription: true,
}),
customTimeFormat: factory.text({
defaultValue: "",
withDescription: true,
}),
customDateFormat: factory.text({
defaultValue: "",
withDescription: true,
}),
}),
customTitle: factory.text({
defaultValue: "",
}),
is24HourFormat: factory.switch({
defaultValue: true,
withDescription: true,
}),
showSeconds: factory.switch({
defaultValue: false,
}),
useCustomTimezone: factory.switch({ defaultValue: false }),
timezone: factory.select({
options: Intl.supportedValuesOf("timeZone").map((value) => value),
defaultValue: "Europe/London",
searchable: true,
withDescription: true,
}),
showDate: factory.switch({
defaultValue: true,
}),
dateFormat: factory.select({
options: [
{ value: "dddd, MMMM D", label: dayjs().format("dddd, MMMM D") },
{ value: "dddd, D MMMM", label: dayjs().format("dddd, D MMMM") },
{ value: "MMM D", label: dayjs().format("MMM D") },
{ value: "D MMM", label: dayjs().format("D MMM") },
{ value: "DD/MM/YYYY", label: dayjs().format("DD/MM/YYYY") },
{ value: "MM/DD/YYYY", label: dayjs().format("MM/DD/YYYY") },
{ value: "DD/MM", label: dayjs().format("DD/MM") },
{ value: "MM/DD", label: dayjs().format("MM/DD") },
],
defaultValue: "dddd, MMMM D",
withDescription: true,
}),
customTimeFormat: factory.text({
defaultValue: "",
withDescription: true,
}),
customDateFormat: factory.text({
defaultValue: "",
withDescription: true,
}),
}),
{
customTitle: {
shouldHide: (options) => !options.customTitleToggle,
{
customTitle: {
shouldHide: (options) => !options.customTitleToggle,
},
timezone: {
shouldHide: (options) => !options.useCustomTimezone,
},
dateFormat: {
shouldHide: (options) => !options.showDate,
},
},
timezone: {
shouldHide: (options) => !options.useCustomTimezone,
},
dateFormat: {
shouldHide: (options) => !options.showDate,
},
},
),
);
},
}).withDynamicImport(() => import("./component"));

View File

@@ -2,11 +2,13 @@ import type { LoaderComponent } from "next/dynamic";
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 { stringOrTranslation } from "@homarr/translation";
import type { TablerIcon } from "@homarr/ui";
import type { WidgetImports } from ".";
import type { inferOptionsFromDefinition, WidgetOptionsRecord } from "./options";
import type { inferOptionsFromCreator, WidgetOptionsRecord } from "./options";
const createWithDynamicImport =
<TKind extends WidgetKind, TDefinition extends WidgetDefinition>(kind: TKind, definition: TDefinition) =>
@@ -30,7 +32,7 @@ export interface WidgetDefinition {
icon: TablerIcon;
supportedIntegrations?: IntegrationKind[];
integrationsRequired?: boolean;
options: WidgetOptionsRecord;
createOptions: (settings: SettingsContextProps) => WidgetOptionsRecord;
errors?: Partial<
Record<
DefaultErrorData["code"],
@@ -44,7 +46,7 @@ export interface WidgetDefinition {
}
export interface WidgetProps<TKind extends WidgetKind> {
options: inferOptionsFromDefinition<WidgetOptionsRecordOf<TKind>>;
options: inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>;
integrationIds: string[];
itemId: string | undefined; // undefined when in preview mode
}
@@ -52,13 +54,19 @@ export interface WidgetProps<TKind extends WidgetKind> {
export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind> & {
boardId: string | undefined; // undefined when in preview mode
isEditMode: boolean;
setOptions: ({
newOptions,
}: {
newOptions: Partial<inferOptionsFromDefinition<WidgetOptionsRecordOf<TKind>>>;
}) => void;
setOptions: ({ newOptions }: { newOptions: Partial<inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>> }) => void;
width: number;
height: number;
};
export type WidgetOptionsRecordOf<TKind extends WidgetKind> = WidgetImports[TKind]["definition"]["options"];
export type WidgetOptionsRecordOf<TKind extends WidgetKind> = WidgetImports[TKind]["definition"]["createOptions"];
/**
* The following type should only include values that can be available for user (including anonymous).
* Because they need to be provided to the client to for example set certain default values.
*/
export interface WidgetOptionsSettings {
server: {
board: Pick<ServerSettings["board"], "enableStatusByDefault" | "forceDisableStatus">;
};
}

View File

@@ -9,11 +9,13 @@ export const widgetKind = "dnsHoleControls";
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
icon: IconDeviceGamepad,
options: optionsBuilder.from((factory) => ({
showToggleAllButtons: factory.switch({
defaultValue: true,
}),
})),
createOptions() {
return optionsBuilder.from((factory) => ({
showToggleAllButtons: factory.switch({
defaultValue: true,
}),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("dnsHole"),
errors: {
INTERNAL_SERVER_ERROR: {

View File

@@ -9,18 +9,20 @@ export const widgetKind = "dnsHoleSummary";
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
icon: IconAd,
options: optionsBuilder.from((factory) => ({
usePiHoleColors: factory.switch({
defaultValue: true,
}),
layout: factory.select({
options: (["grid", "row", "column"] as const).map((value) => ({
value,
label: (t) => t(`widget.dnsHoleSummary.option.layout.option.${value}.label`),
})),
defaultValue: "grid",
}),
})),
createOptions() {
return optionsBuilder.from((factory) => ({
usePiHoleColors: factory.switch({
defaultValue: true,
}),
layout: factory.select({
options: (["grid", "row", "column"] as const).map((value) => ({
value,
label: (t) => t(`widget.dnsHoleSummary.option.layout.option.${value}.label`),
})),
defaultValue: "grid",
}),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("dnsHole"),
errors: {
INTERNAL_SERVER_ERROR: {

View File

@@ -33,76 +33,78 @@ const columnsSort = columnsList.filter((column) =>
export const { definition, componentLoader } = createWidgetDefinition("downloads", {
icon: IconDownload,
options: optionsBuilder.from(
(factory) => ({
columns: factory.multiSelect({
defaultValue: ["integration", "name", "progress", "time", "actions"],
options: columnsList.map((value) => ({
value,
label: (t) => t(`widget.downloads.items.${value}.columnTitle`),
})),
searchable: true,
createOptions() {
return optionsBuilder.from(
(factory) => ({
columns: factory.multiSelect({
defaultValue: ["integration", "name", "progress", "time", "actions"],
options: columnsList.map((value) => ({
value,
label: (t) => t(`widget.downloads.items.${value}.columnTitle`),
})),
searchable: true,
}),
enableRowSorting: factory.switch({
defaultValue: false,
}),
defaultSort: factory.select({
defaultValue: "type",
options: columnsSort.map((value) => ({
value,
label: (t) => t(`widget.downloads.items.${value}.columnTitle`),
})),
}),
descendingDefaultSort: factory.switch({
defaultValue: false,
}),
showCompletedUsenet: factory.switch({
defaultValue: true,
}),
showCompletedTorrent: factory.switch({
defaultValue: true,
}),
activeTorrentThreshold: factory.number({
//in KiB/s
validate: z.number().min(0),
defaultValue: 0,
step: 1,
}),
categoryFilter: factory.multiText({
defaultValue: [] as string[],
validate: z.string(),
}),
filterIsWhitelist: factory.switch({
defaultValue: false,
}),
applyFilterToRatio: factory.switch({
defaultValue: true,
}),
}),
enableRowSorting: factory.switch({
defaultValue: false,
}),
defaultSort: factory.select({
defaultValue: "type",
options: columnsSort.map((value) => ({
value,
label: (t) => t(`widget.downloads.items.${value}.columnTitle`),
})),
}),
descendingDefaultSort: factory.switch({
defaultValue: false,
}),
showCompletedUsenet: factory.switch({
defaultValue: true,
}),
showCompletedTorrent: factory.switch({
defaultValue: true,
}),
activeTorrentThreshold: factory.number({
//in KiB/s
validate: z.number().min(0),
defaultValue: 0,
step: 1,
}),
categoryFilter: factory.multiText({
defaultValue: [] as string[],
validate: z.string(),
}),
filterIsWhitelist: factory.switch({
defaultValue: false,
}),
applyFilterToRatio: factory.switch({
defaultValue: true,
}),
}),
{
defaultSort: {
shouldHide: (options) => !options.enableRowSorting,
{
defaultSort: {
shouldHide: (options) => !options.enableRowSorting,
},
descendingDefaultSort: {
shouldHide: (options) => !options.enableRowSorting,
},
showCompletedUsenet: {
shouldHide: (_, integrationKinds) =>
!getIntegrationKindsByCategory("usenet").some((kinds) => integrationKinds.includes(kinds)),
},
showCompletedTorrent: {
shouldHide: (_, integrationKinds) =>
!getIntegrationKindsByCategory("torrent").some((kinds) => integrationKinds.includes(kinds)),
},
activeTorrentThreshold: {
shouldHide: (_, integrationKinds) =>
!getIntegrationKindsByCategory("torrent").some((kinds) => integrationKinds.includes(kinds)),
},
applyFilterToRatio: {
shouldHide: (_, integrationKinds) =>
!getIntegrationKindsByCategory("torrent").some((kinds) => integrationKinds.includes(kinds)),
},
},
descendingDefaultSort: {
shouldHide: (options) => !options.enableRowSorting,
},
showCompletedUsenet: {
shouldHide: (_, integrationKinds) =>
!getIntegrationKindsByCategory("usenet").some((kinds) => integrationKinds.includes(kinds)),
},
showCompletedTorrent: {
shouldHide: (_, integrationKinds) =>
!getIntegrationKindsByCategory("torrent").some((kinds) => integrationKinds.includes(kinds)),
},
activeTorrentThreshold: {
shouldHide: (_, integrationKinds) =>
!getIntegrationKindsByCategory("torrent").some((kinds) => integrationKinds.includes(kinds)),
},
applyFilterToRatio: {
shouldHide: (_, integrationKinds) =>
!getIntegrationKindsByCategory("torrent").some((kinds) => integrationKinds.includes(kinds)),
},
},
),
);
},
supportedIntegrations: getIntegrationKindsByCategory("downloadClient"),
}).withDynamicImport(() => import("./component"));

View File

@@ -7,34 +7,36 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("healthMonitoring", {
icon: IconHeartRateMonitor,
options: optionsBuilder.from((factory) => ({
fahrenheit: factory.switch({
defaultValue: false,
}),
cpu: factory.switch({
defaultValue: true,
}),
memory: factory.switch({
defaultValue: true,
}),
fileSystem: factory.switch({
defaultValue: true,
}),
defaultTab: factory.select({
defaultValue: "system",
options: [
{ value: "system", label: "System" },
{ value: "cluster", label: "Cluster" },
] as const,
}),
sectionIndicatorRequirement: factory.select({
defaultValue: "all",
options: [
{ value: "all", label: "All active" },
{ value: "any", label: "Any active" },
] as const,
}),
})),
createOptions() {
return optionsBuilder.from((factory) => ({
fahrenheit: factory.switch({
defaultValue: false,
}),
cpu: factory.switch({
defaultValue: true,
}),
memory: factory.switch({
defaultValue: true,
}),
fileSystem: factory.switch({
defaultValue: true,
}),
defaultTab: factory.select({
defaultValue: "system",
options: [
{ value: "system", label: "System" },
{ value: "cluster", label: "Cluster" },
] as const,
}),
sectionIndicatorRequirement: factory.select({
defaultValue: "all",
options: [
{ value: "all", label: "All active" },
{ value: "any", label: "Any active" },
] as const,
}),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring"),
errors: {
INTERNAL_SERVER_ERROR: {

View File

@@ -5,17 +5,19 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("iframe", {
icon: IconBrowser,
options: optionsBuilder.from((factory) => ({
embedUrl: factory.text(),
allowFullScreen: factory.switch(),
allowScrolling: factory.switch({
defaultValue: true,
}),
allowTransparency: factory.switch(),
allowPayment: factory.switch(),
allowAutoPlay: factory.switch(),
allowMicrophone: factory.switch(),
allowCamera: factory.switch(),
allowGeolocation: factory.switch(),
})),
createOptions() {
return optionsBuilder.from((factory) => ({
embedUrl: factory.text(),
allowFullScreen: factory.switch(),
allowScrolling: factory.switch({
defaultValue: true,
}),
allowTransparency: factory.switch(),
allowPayment: factory.switch(),
allowAutoPlay: factory.switch(),
allowMicrophone: factory.switch(),
allowCamera: factory.switch(),
allowGeolocation: factory.switch(),
}));
},
}).withDynamicImport(() => import("./component"));

View File

@@ -5,6 +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 * as app from "./app";
import * as bookmarks from "./bookmarks";
@@ -31,7 +32,7 @@ import * as smartHomeExecuteAutomation from "./smart-home/execute-automation";
import * as video from "./video";
import * as weather from "./weather";
export type { WidgetDefinition } from "./definition";
export type { WidgetDefinition, WidgetOptionsSettings } from "./definition";
export type { WidgetComponentProps };
export const widgetImports = {
@@ -94,9 +95,13 @@ export type inferSupportedIntegrationsStrict<TKind extends WidgetKind> = (Widget
? WidgetImports[TKind]["definition"]["supportedIntegrations"]
: never[])[number];
export const reduceWidgetOptionsWithDefaultValues = (kind: WidgetKind, currentValue: Record<string, unknown> = {}) => {
export const reduceWidgetOptionsWithDefaultValues = (
kind: WidgetKind,
settings: SettingsContextProps,
currentValue: Record<string, unknown> = {},
) => {
const definition = widgetImports[kind].definition;
const options = definition.options as Record<string, WidgetOptionDefinition>;
const options = definition.createOptions(settings) as Record<string, WidgetOptionDefinition>;
return objectEntries(options).reduce(
(prev, [key, value]) => ({
...prev,

View File

@@ -7,11 +7,13 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("indexerManager", {
icon: IconReportSearch,
options: optionsBuilder.from((factory) => ({
openIndexerSiteInNewTab: factory.switch({
defaultValue: true,
}),
})),
createOptions() {
return optionsBuilder.from((factory) => ({
openIndexerSiteInNewTab: factory.switch({
defaultValue: true,
}),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("indexerManager"),
errors: {
INTERNAL_SERVER_ERROR: {

View File

@@ -7,10 +7,12 @@ import { optionsBuilder } from "../../options";
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestList", {
icon: IconZoomQuestion,
options: optionsBuilder.from((factory) => ({
linksTargetNewTab: factory.switch({
defaultValue: true,
}),
})),
createOptions() {
return optionsBuilder.from((factory) => ({
linksTargetNewTab: factory.switch({
defaultValue: true,
}),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"),
}).withDynamicImport(() => import("./component"));

View File

@@ -6,6 +6,8 @@ import { createWidgetDefinition } from "../../definition";
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestStats", {
icon: IconChartBar,
options: {},
createOptions() {
return {};
},
supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"),
}).withDynamicImport(() => import("./component"));

View File

@@ -4,6 +4,8 @@ import { createWidgetDefinition } from "../definition";
export const { componentLoader, definition } = createWidgetDefinition("mediaServer", {
icon: IconVideo,
options: {},
createOptions() {
return {};
},
supportedIntegrations: ["jellyfin", "plex"],
}).withDynamicImport(() => import("./component"));

View File

@@ -6,16 +6,18 @@ import { optionsBuilder } from "../options";
export const { componentLoader, definition } = createWidgetDefinition("mediaTranscoding", {
icon: IconTransform,
options: optionsBuilder.from((factory) => ({
defaultView: factory.select({
defaultValue: "statistics",
options: [
{ label: "Workers", value: "workers" },
{ label: "Queue", value: "queue" },
{ label: "Statistics", value: "statistics" },
],
}),
queuePageSize: factory.number({ defaultValue: 10, validate: z.number().min(1).max(30) }),
})),
createOptions() {
return optionsBuilder.from((factory) => ({
defaultView: factory.select({
defaultValue: "statistics",
options: [
{ label: "Workers", value: "workers" },
{ label: "Queue", value: "queue" },
{ label: "Statistics", value: "statistics" },
],
}),
queuePageSize: factory.number({ defaultValue: 10, validate: z.number().min(1).max(30) }),
}));
},
supportedIntegrations: ["tdarr"],
}).withDynamicImport(() => import("./component"));

View File

@@ -6,9 +6,11 @@ import { optionsBuilder } from "../../options";
export const { componentLoader, definition } = createWidgetDefinition("minecraftServerStatus", {
icon: IconBrandMinecraft,
options: optionsBuilder.from((factory) => ({
title: factory.text({ defaultValue: "" }),
domain: factory.text({ defaultValue: "hypixel.net", validate: z.string().nonempty() }),
isBedrockServer: factory.switch({ defaultValue: false }),
})),
createOptions() {
return optionsBuilder.from((factory) => ({
title: factory.text({ defaultValue: "" }),
domain: factory.text({ defaultValue: "hypixel.net", validate: z.string().nonempty() }),
isBedrockServer: factory.switch({ defaultValue: false }),
}));
},
}).withDynamicImport(() => import("./component"));

View File

@@ -8,6 +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 { useI18n } from "@homarr/translation/client";
import { zodErrorMap } from "@homarr/validation/form";
@@ -32,6 +33,7 @@ interface ModalProps<TSort extends WidgetKind> {
onSuccessfulEdit: (value: WidgetEditModalState) => void;
integrationData: IntegrationSelectOption[];
integrationSupport: boolean;
settings: SettingsContextProps;
}
export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, innerProps }) => {
@@ -40,13 +42,16 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
// Translate the error messages
z.setErrorMap(zodErrorMap(t));
const { definition } = widgetImports[innerProps.kind];
const options = definition.createOptions(innerProps.settings) as Record<string, OptionsBuilderResult[string]>;
const form = useForm({
mode: "controlled",
initialValues: innerProps.value,
validate: zodResolver(
z.object({
options: z.object(
objectEntries(widgetImports[innerProps.kind].definition.options).reduce(
objectEntries(options).reduce(
(acc, [key, value]: [string, { type: string; validate?: z.ZodType<unknown> }]) => {
if (value.validate) {
acc[key] = value.type === "multiText" ? z.array(value.validate).optional() : value.validate;
@@ -68,8 +73,6 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
});
const { openModal } = useModalAction(WidgetAdvancedOptionsModal);
const { definition } = widgetImports[innerProps.kind];
return (
<form
onSubmit={form.onSubmit((values) => {
@@ -89,7 +92,7 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
{...form.getInputProps("integrationIds")}
/>
)}
{Object.entries(definition.options).map(([key, value]: [string, OptionsBuilderResult[string]]) => {
{Object.entries(options).map(([key, value]) => {
const Input = getInputForType(value.type);
if (

View File

@@ -6,22 +6,24 @@ import { defaultContent } from "./default-content";
export const { definition, componentLoader } = createWidgetDefinition("notebook", {
icon: IconNotes,
options: optionsBuilder.from(
(factory) => ({
showToolbar: factory.switch({
defaultValue: true,
createOptions() {
return optionsBuilder.from(
(factory) => ({
showToolbar: factory.switch({
defaultValue: true,
}),
allowReadOnlyCheck: factory.switch({
defaultValue: true,
}),
content: factory.text({
defaultValue: defaultContent,
}),
}),
allowReadOnlyCheck: factory.switch({
defaultValue: true,
}),
content: factory.text({
defaultValue: defaultContent,
}),
}),
{
content: {
shouldHide: () => true, // Hide the content option as it can be modified in the editor
{
content: {
shouldHide: () => true, // Hide the content option as it can be modified in the editor
},
},
},
),
);
},
}).withDynamicImport(() => import("./component"));

View File

@@ -149,6 +149,10 @@ export type WidgetOptionType = WidgetOptionDefinition["type"];
export type WidgetOptionOfType<TType extends WidgetOptionType> = Extract<WidgetOptionDefinition, { type: TType }>;
type inferOptionFromDefinition<TDefinition extends WidgetOptionDefinition> = TDefinition["defaultValue"];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type inferOptionsFromCreator<TOptions extends (settings: any) => WidgetOptionsRecord> =
inferOptionsFromDefinition<ReturnType<TOptions>>;
export type inferOptionsFromDefinition<TOptions extends WidgetOptionsRecord> = {
[key in keyof TOptions]: inferOptionFromDefinition<TOptions[key]>;
};

View File

@@ -12,21 +12,23 @@ import { optionsBuilder } from "../options";
*/
export const { definition, componentLoader } = createWidgetDefinition("rssFeed", {
icon: IconRss,
options: optionsBuilder.from((factory) => ({
feedUrls: factory.multiText({
defaultValue: [],
validate: z.string().url(),
}),
enableRtl: factory.switch({
defaultValue: false,
}),
textLinesClamp: factory.number({
defaultValue: 5,
validate: z.number().min(1).max(50),
}),
maximumAmountPosts: factory.number({
defaultValue: 100,
validate: z.number().min(1).max(9999),
}),
})),
createOptions() {
return optionsBuilder.from((factory) => ({
feedUrls: factory.multiText({
defaultValue: [],
validate: z.string().url(),
}),
enableRtl: factory.switch({
defaultValue: false,
}),
textLinesClamp: factory.number({
defaultValue: 5,
validate: z.number().min(1).max(50),
}),
maximumAmountPosts: factory.number({
defaultValue: 100,
validate: z.number().min(1).max(9999),
}),
}));
},
}).withDynamicImport(() => import("./component"));

View File

@@ -7,15 +7,17 @@ import { optionsBuilder } from "../../options";
export const { definition, componentLoader } = createWidgetDefinition("smartHome-entityState", {
icon: IconBinaryTree,
options: optionsBuilder.from((factory) => ({
entityId: factory.text({
defaultValue: "sun.sun",
}),
displayName: factory.text({
defaultValue: "Sun",
}),
entityUnit: factory.text(),
clickable: factory.switch(),
})),
createOptions() {
return optionsBuilder.from((factory) => ({
entityId: factory.text({
defaultValue: "sun.sun",
}),
displayName: factory.text({
defaultValue: "Sun",
}),
entityUnit: factory.text(),
clickable: factory.switch(),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("smartHomeServer"),
}).withDynamicImport(() => import("./component"));

View File

@@ -7,9 +7,11 @@ import { optionsBuilder } from "../../options";
export const { definition, componentLoader } = createWidgetDefinition("smartHome-executeAutomation", {
icon: IconBinaryTree,
options: optionsBuilder.from((factory) => ({
displayName: factory.text(),
automationId: factory.text(),
})),
createOptions() {
return optionsBuilder.from((factory) => ({
displayName: factory.text(),
automationId: factory.text(),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("smartHomeServer"),
}).withDynamicImport(() => import("./component"));

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { objectEntries } from "@homarr/common";
import type { SettingsContextProps } from "@homarr/settings";
import { createLanguageMapping } from "@homarr/translation";
import { widgetImports } from "..";
@@ -8,26 +9,25 @@ import { widgetImports } from "..";
describe("Widget properties with description should have matching translations", async () => {
const enTranslation = await createLanguageMapping().en();
objectEntries(widgetImports).forEach(([key, value]) => {
Object.entries(value.definition.options).forEach(
([optionKey, optionValue]: [string, { withDescription?: boolean }]) => {
it(`should have matching translations for ${optionKey} option description of ${key} widget`, () => {
const option = enTranslation.default.widget[key].option;
if (!(optionKey in option)) {
throw new Error(`Option ${optionKey} not found in translation`);
}
const value = option[optionKey as keyof typeof option];
Object.entries(value.definition.createOptions({} as SettingsContextProps)).forEach(([optionKey, optionValue_]) => {
const optionValue = optionValue_ as { withDescription: boolean };
it(`should have matching translations for ${optionKey} option description of ${key} widget`, () => {
const option = enTranslation.default.widget[key].option;
if (!(optionKey in option)) {
throw new Error(`Option ${optionKey} not found in translation`);
}
const value = option[optionKey as keyof typeof option];
expect("description" in value).toBe(optionValue.withDescription);
});
},
);
expect("description" in value).toBe(optionValue.withDescription);
});
});
});
});
describe("Widget properties should have matching name translations", async () => {
const enTranslation = await createLanguageMapping().en();
objectEntries(widgetImports).forEach(([key, value]) => {
Object.keys(value.definition.options).forEach((optionKey) => {
Object.keys(value.definition.createOptions({} as SettingsContextProps)).forEach((optionKey) => {
it(`should have matching translations for ${optionKey} option name of ${key} widget`, () => {
const option = enTranslation.default.widget[key].option;
if (!(optionKey in option)) {

View File

@@ -5,16 +5,18 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("video", {
icon: IconDeviceCctv,
options: optionsBuilder.from((factory) => ({
feedUrl: factory.text({
defaultValue: "",
}),
hasAutoPlay: factory.switch({
withDescription: true,
}),
isMuted: factory.switch({
defaultValue: true,
}),
hasControls: factory.switch(),
})),
createOptions() {
return optionsBuilder.from((factory) => ({
feedUrl: factory.text({
defaultValue: "",
}),
hasAutoPlay: factory.switch({
withDescription: true,
}),
isMuted: factory.switch({
defaultValue: true,
}),
hasControls: factory.switch(),
}));
},
}).withDynamicImport(() => import("./component"));

View File

@@ -7,47 +7,49 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("weather", {
icon: IconCloud,
options: optionsBuilder.from(
(factory) => ({
isFormatFahrenheit: factory.switch(),
disableTemperatureDecimals: factory.switch(),
showCurrentWindSpeed: factory.switch({ withDescription: true }),
location: factory.location({
defaultValue: {
name: "Paris",
latitude: 48.85341,
longitude: 2.3488,
},
createOptions() {
return optionsBuilder.from(
(factory) => ({
isFormatFahrenheit: factory.switch(),
disableTemperatureDecimals: factory.switch(),
showCurrentWindSpeed: factory.switch({ withDescription: true }),
location: factory.location({
defaultValue: {
name: "Paris",
latitude: 48.85341,
longitude: 2.3488,
},
}),
dateFormat: factory.select({
options: [
{ value: "dddd, MMMM D", label: dayjs().format("dddd, MMMM D") },
{ value: "dddd, D MMMM", label: dayjs().format("dddd, D MMMM") },
{ value: "MMM D", label: dayjs().format("MMM D") },
{ value: "D MMM", label: dayjs().format("D MMM") },
{ value: "DD/MM/YYYY", label: dayjs().format("DD/MM/YYYY") },
{ value: "MM/DD/YYYY", label: dayjs().format("MM/DD/YYYY") },
{ value: "DD/MM", label: dayjs().format("DD/MM") },
{ value: "MM/DD", label: dayjs().format("MM/DD") },
],
defaultValue: "dddd, MMMM D",
withDescription: true,
}),
showCity: factory.switch(),
hasForecast: factory.switch(),
forecastDayCount: factory.slider({
defaultValue: 5,
validate: z.number().min(1).max(7),
step: 1,
withDescription: true,
}),
}),
dateFormat: factory.select({
options: [
{ value: "dddd, MMMM D", label: dayjs().format("dddd, MMMM D") },
{ value: "dddd, D MMMM", label: dayjs().format("dddd, D MMMM") },
{ value: "MMM D", label: dayjs().format("MMM D") },
{ value: "D MMM", label: dayjs().format("D MMM") },
{ value: "DD/MM/YYYY", label: dayjs().format("DD/MM/YYYY") },
{ value: "MM/DD/YYYY", label: dayjs().format("MM/DD/YYYY") },
{ value: "DD/MM", label: dayjs().format("DD/MM") },
{ value: "MM/DD", label: dayjs().format("MM/DD") },
],
defaultValue: "dddd, MMMM D",
withDescription: true,
}),
showCity: factory.switch(),
hasForecast: factory.switch(),
forecastDayCount: factory.slider({
defaultValue: 5,
validate: z.number().min(1).max(7),
step: 1,
withDescription: true,
}),
}),
{
forecastDayCount: {
shouldHide({ hasForecast }) {
return !hasForecast;
{
forecastDayCount: {
shouldHide({ hasForecast }) {
return !hasForecast;
},
},
},
},
),
);
},
}).withDynamicImport(() => import("./component"));