feat: add widget preview pages (#9)

* feat: add widget definition system

* fix: wrong typecheck command in turbo generator

* chore: fix formatting

* feat: add widget preview page

* chore: fix formatting and type errors

* chore: fix from widget edit modal and remove some never casts

* chore: address pull request feedback
This commit is contained in:
Meier Lukas
2024-01-02 15:36:59 +01:00
committed by GitHub
parent fa19966fcc
commit 782897527f
48 changed files with 1226 additions and 81 deletions

View File

@@ -0,0 +1,71 @@
"use client";
import type { Dispatch, SetStateAction } from "react";
import type { ManagedModal } from "mantine-modal-manager";
import { Button, Group, Stack } from "@homarr/ui";
import type { WidgetSort } from ".";
import { getInputForType } from "./_inputs";
import { FormProvider, useForm } from "./_inputs/form";
import type { WidgetOptionsRecordOf } from "./definition";
import type { WidgetOptionDefinition } from "./options";
interface ModalProps<TSort extends WidgetSort> {
sort: TSort;
state: [
Record<string, unknown>,
Dispatch<SetStateAction<Record<string, unknown>>>,
];
definition: WidgetOptionsRecordOf<TSort>;
}
export const WidgetEditModal: ManagedModal<ModalProps<WidgetSort>> = ({
actions,
innerProps,
}) => {
const [value, setValue] = innerProps.state;
const form = useForm({
initialValues: value,
});
return (
<form
onSubmit={form.onSubmit((v) => {
setValue(v);
actions.closeModal();
})}
>
<FormProvider form={form}>
<Stack>
{Object.entries(innerProps.definition).map(
([key, value]: [string, WidgetOptionDefinition]) => {
const Input = getInputForType(value.type);
if (!Input) {
return null;
}
return (
<Input
key={key}
sort={innerProps.sort}
property={key}
options={value as never}
/>
);
},
)}
<Group justify="right">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
Close
</Button>
<Button type="submit" color="teal">
Save
</Button>
</Group>
</Stack>
</FormProvider>
</form>
);
};

View File

@@ -0,0 +1,33 @@
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetSort } from "..";
import type { WidgetOptionOfType, WidgetOptionType } from "../options";
export interface CommonWidgetInputProps<TKey extends WidgetOptionType> {
sort: WidgetSort;
property: string;
options: Omit<WidgetOptionOfType<TKey>, "defaultValue" | "type">;
}
type UseWidgetInputTranslationReturnType = (
key: "label" | "description",
) => string;
/**
* Short description why as and unknown convertions are used below:
* Typescript was not smart enought to work with the generic of the WidgetSort to only allow properties that are relying within that specified sort.
* This does not mean, that the function useWidgetInputTranslation can be called with invalid arguments without type errors and rather means that the above widget.<sort>.option.<property> string
* is not recognized as valid argument for the scoped i18n hook. Because the typesafety should remain outside the usage of those methods I (Meierschlumpf) decided to provide this fully typesafe useWidgetInputTranslation method.
*
* Some notes about it:
* - The label translation can be used for every input, especially considering that all options should have defined a label for themself. The description translation should only be used when withDescription
* is defined for the option. The method does sadly not reconize issues with those definitions. So it does not yell at you when you somewhere show the label without having it defined in the translations.
*/
export const useWidgetInputTranslation = (
sort: WidgetSort,
property: string,
): UseWidgetInputTranslationReturnType => {
return useScopedI18n(
`widget.${sort}.option.${property}` as never, // Because the type is complex and not recognized by typescript, we need to cast it to never to make it work.
) as unknown as UseWidgetInputTranslationReturnType;
};

View File

@@ -0,0 +1,6 @@
"use client";
import { createFormContext } from "@homarr/form";
export const [FormProvider, useFormContext, useForm] =
createFormContext<Record<string, unknown>>();

View File

@@ -0,0 +1,24 @@
import type { WidgetOptionType } from "../options";
import { WidgetMultiSelectInput } from "./widget-multiselect-input";
import { WidgetNumberInput } from "./widget-number-input";
import { WidgetSelectInput } from "./widget-select-input";
import { WidgetSliderInput } from "./widget-slider-input";
import { WidgetSwitchInput } from "./widget-switch-input";
import { WidgetTextInput } from "./widget-text-input";
const mapping = {
text: WidgetTextInput,
location: () => null,
multiSelect: WidgetMultiSelectInput,
multiText: () => null,
number: WidgetNumberInput,
select: WidgetSelectInput,
slider: WidgetSliderInput,
switch: WidgetSwitchInput,
} satisfies Record<WidgetOptionType, unknown>;
export const getInputForType = <TType extends WidgetOptionType>(
type: TType,
) => {
return mapping[type];
};

View File

@@ -0,0 +1,25 @@
"use client";
import { MultiSelect } from "@homarr/ui";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetMultiSelectInput = ({
property,
sort,
options,
}: CommonWidgetInputProps<"multiSelect">) => {
const t = useWidgetInputTranslation(sort, property);
const form = useFormContext();
return (
<MultiSelect
label={t("label")}
data={options.options as unknown as string[]}
description={options.withDescription ? t("description") : undefined}
{...form.getInputProps(property)}
/>
);
};

View File

@@ -0,0 +1,27 @@
"use client";
import { NumberInput } from "@homarr/ui";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetNumberInput = ({
property,
sort,
options,
}: CommonWidgetInputProps<"number">) => {
const t = useWidgetInputTranslation(sort, property);
const form = useFormContext();
return (
<NumberInput
label={t("label")}
description={options.withDescription ? t("description") : undefined}
min={options.validate.minValue ?? undefined}
max={options.validate.maxValue ?? undefined}
step={options.step}
{...form.getInputProps(property)}
/>
);
};

View File

@@ -0,0 +1,25 @@
"use client";
import { Select } from "@homarr/ui";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetSelectInput = ({
property,
sort,
options,
}: CommonWidgetInputProps<"select">) => {
const t = useWidgetInputTranslation(sort, property);
const form = useFormContext();
return (
<Select
label={t("label")}
data={options.options as unknown as string[]}
description={options.withDescription ? t("description") : undefined}
{...form.getInputProps(property)}
/>
);
};

View File

@@ -0,0 +1,30 @@
"use client";
import { InputWrapper, Slider } from "@homarr/ui";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetSliderInput = ({
property,
sort,
options,
}: CommonWidgetInputProps<"slider">) => {
const t = useWidgetInputTranslation(sort, property);
const form = useFormContext();
return (
<InputWrapper
description={options.withDescription ? t("description") : undefined}
>
<Slider
label={t("label")}
min={options.validate.minValue ?? undefined}
max={options.validate.maxValue ?? undefined}
step={options.step}
{...form.getInputProps(property)}
/>
</InputWrapper>
);
};

View File

@@ -0,0 +1,24 @@
"use client";
import { Switch } from "@homarr/ui";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetSwitchInput = ({
property,
sort,
options,
}: CommonWidgetInputProps<"switch">) => {
const t = useWidgetInputTranslation(sort, property);
const form = useFormContext();
return (
<Switch
label={t("label")}
description={options.withDescription ? t("description") : undefined}
{...form.getInputProps(property, { type: "checkbox" })}
/>
);
};

View File

@@ -0,0 +1,24 @@
"use client";
import { TextInput } from "@homarr/ui";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetTextInput = ({
property,
sort: widgetSort,
options,
}: CommonWidgetInputProps<"text">) => {
const t = useWidgetInputTranslation(widgetSort, property);
const form = useFormContext();
return (
<TextInput
label={t("label")}
description={options.withDescription ? t("description") : undefined}
{...form.getInputProps(property)}
/>
);
};

View File

@@ -0,0 +1,7 @@
import type { WidgetComponentProps } from "../definition";
export default function ClockWidget({
options,
}: WidgetComponentProps<"clock">) {
return <pre>{JSON.stringify(options)}</pre>;
}

View File

@@ -0,0 +1,26 @@
import { IconClock } from "@homarr/ui";
import { createWidgetDefinition } from "../definition";
import { opt } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("clock", {
icon: IconClock,
options: opt.from(
(fac) => ({
is24HourFormat: fac.switch({
defaultValue: true,
withDescription: true,
}),
isLocaleTime: fac.switch({ defaultValue: true }),
timezone: fac.select({
options: ["Europe/Berlin", "Europe/London", "Europe/Moscow"] as const,
defaultValue: "Europe/Berlin",
}),
}),
{
timezone: {
shouldHide: (options) => options.isLocaleTime,
},
},
),
}).withDynamicImport(() => import("./component"));

View File

@@ -0,0 +1,40 @@
import type { LoaderComponent } from "next/dynamic";
import type { TablerIconsProps } from "@homarr/ui";
import type { WidgetImports, WidgetSort } from ".";
import type {
inferOptionsFromDefinition,
WidgetOptionsRecord,
} from "./options";
export const createWidgetDefinition = <
TSort extends WidgetSort,
TDefinition extends Definition,
>(
sort: TSort,
definition: TDefinition,
) => ({
withDynamicImport: (
componentLoader: () => LoaderComponent<WidgetComponentProps<TSort>>,
) => ({
definition: {
sort,
...definition,
},
componentLoader,
}),
});
interface Definition {
icon: (props: TablerIconsProps) => JSX.Element;
options: WidgetOptionsRecord;
}
export interface WidgetComponentProps<TSort extends WidgetSort> {
options: inferOptionsFromDefinition<WidgetOptionsRecordOf<TSort>>;
integrations: unknown[];
}
export type WidgetOptionsRecordOf<TSort extends WidgetSort> =
WidgetImports[TSort]["definition"]["options"];

View File

@@ -0,0 +1,5 @@
import type { WidgetSort } from ".";
export type WidgetImportRecord = {
[K in WidgetSort]: unknown;
};

View File

@@ -0,0 +1,30 @@
import dynamic from "next/dynamic";
import type { Loader } from "next/dynamic";
import { Loader as UiLoader } from "@homarr/ui";
import * as clock from "./clock";
import type { WidgetComponentProps } from "./definition";
import type { WidgetImportRecord } from "./import";
import * as weather from "./weather";
export { WidgetEditModal } from "./WidgetEditModal";
export const widgetSorts = ["clock", "weather"] as const;
export const widgetImports = {
clock,
weather,
} satisfies WidgetImportRecord;
export type WidgetSort = (typeof widgetSorts)[number];
export type WidgetImports = typeof widgetImports;
export type WidgetImportKey = keyof WidgetImports;
export const loadWidgetDynamic = <TSort extends WidgetSort>(sort: TSort) =>
dynamic<WidgetComponentProps<TSort>>(
widgetImports[sort].componentLoader as Loader<WidgetComponentProps<TSort>>,
{
loading: () => <UiLoader />,
},
);

View File

@@ -0,0 +1,142 @@
import type { z } from "@homarr/validation";
interface CommonInput<TType> {
defaultValue?: TType;
withDescription?: boolean;
}
interface TextInput extends CommonInput<string> {
validate: z.ZodType<string>;
}
interface MultiSelectInput<TOptions extends string[]>
extends CommonInput<TOptions[number][]> {
options: TOptions;
}
interface SelectInput<TOptions extends readonly [string, ...string[]]>
extends CommonInput<TOptions[number]> {
options: TOptions;
}
interface NumberInput extends CommonInput<number | ""> {
validate: z.ZodNumber;
step?: number;
}
interface SliderInput extends CommonInput<number> {
validate: z.ZodNumber;
step?: number;
}
interface OptLocation {
name: string;
latitude: number;
longitude: number;
}
const optionsFactory = {
switch: (input?: CommonInput<boolean>) => ({
type: "switch" as const,
defaultValue: input?.defaultValue ?? false,
withDescription: input?.withDescription ?? false,
}),
text: (input?: TextInput) => ({
type: "text" as const,
defaultValue: input?.defaultValue ?? "",
withDescription: input?.withDescription ?? false,
validate: input?.validate,
}),
multiSelect: <TOptions extends string[]>(
input: MultiSelectInput<TOptions>,
) => ({
type: "multiSelect" as const,
defaultValue: input.defaultValue ?? [],
options: input.options,
withDescription: input.withDescription ?? false,
}),
select: <TOptions extends readonly [string, ...string[]]>(
input: SelectInput<TOptions>,
) => ({
type: "select" as const,
defaultValue: input.defaultValue ?? input.options[0],
options: input.options,
withDescription: input.withDescription ?? false,
}),
number: (input: NumberInput) => ({
type: "number" as const,
defaultValue: input.defaultValue ?? ("" as const),
step: input.step,
withDescription: input.withDescription ?? false,
validate: input.validate,
}),
slider: (input: SliderInput) => ({
type: "slider" as const,
defaultValue: input.defaultValue ?? input.validate.minValue ?? 0,
step: input.step,
withDescription: input.withDescription ?? false,
validate: input.validate,
}),
location: (input?: CommonInput<OptLocation>) => ({
type: "location" as const,
defaultValue: input?.defaultValue ?? {
name: "",
latitude: 0,
longitude: 0,
},
withDescription: input?.withDescription ?? false,
}),
multiText: (input?: CommonInput<string[]>) => ({
type: "multiText" as const,
defaultValue: input?.defaultValue ?? [],
withDescription: input?.withDescription ?? false,
}),
};
type WidgetOptionFactory = typeof optionsFactory;
export type WidgetOptionDefinition = ReturnType<
WidgetOptionFactory[keyof WidgetOptionFactory]
>;
export type WidgetOptionsRecord = Record<string, WidgetOptionDefinition>;
export type WidgetOptionType = WidgetOptionDefinition["type"];
export type WidgetOptionOfType<TType extends WidgetOptionType> = Extract<
WidgetOptionDefinition,
{ type: TType }
>;
type inferOptionFromDefinition<TDefinition extends WidgetOptionDefinition> =
TDefinition["defaultValue"];
export type inferOptionsFromDefinition<TOptions extends WidgetOptionsRecord> = {
[key in keyof TOptions]: inferOptionFromDefinition<TOptions[key]>;
};
interface FieldConfiguration<TOptions extends WidgetOptionsRecord> {
shouldHide: (options: inferOptionsFromDefinition<TOptions>) => boolean;
}
type ConfigurationInput<TOptions extends WidgetOptionsRecord> = Partial<
Record<keyof TOptions, FieldConfiguration<TOptions>>
>;
const createOptions = <TOptions extends WidgetOptionsRecord>(
optionsCallback: (factory: WidgetOptionFactory) => TOptions,
configuration?: ConfigurationInput<TOptions>,
) => {
const obj = {} as Record<keyof TOptions, unknown>;
const options = optionsCallback(optionsFactory);
for (const key in options) {
obj[key] = {
...configuration?.[key],
...options[key],
};
}
return obj as {
[key in keyof TOptions]: TOptions[key] & FieldConfiguration<TOptions>;
};
};
export const opt = {
from: createOptions,
};

View File

@@ -0,0 +1,7 @@
import type { WidgetComponentProps } from "../definition";
export default function WeatherWidget({
options,
}: WidgetComponentProps<"weather">) {
return <pre>{JSON.stringify(options)}</pre>;
}

View File

@@ -0,0 +1,15 @@
import { IconCloud } from "@homarr/ui";
import { createWidgetDefinition } from "../definition";
import { opt } from "../options";
export const { definition, componentLoader } = createWidgetDefinition(
"weather",
{
icon: IconCloud,
options: opt.from((fac) => ({
location: fac.location(),
showCity: fac.switch(),
})),
},
).withDynamicImport(() => import("./component"));