feat: Add widget integration option (#14)

* wip: add widget integrations

* feat: Add integration option to widgets

* feat: Add translation for widget integration select

* fix: formatting issue

* chore: address pull request feedback
This commit is contained in:
Meier Lukas
2024-02-03 10:24:39 +01:00
committed by GitHub
parent 3a0f280984
commit 1740450648
22 changed files with 378 additions and 55 deletions

View File

@@ -0,0 +1,84 @@
"use client";
import { useState } from "react";
import type { WidgetOptionDefinition } from "node_modules/@homarr/widgets/src/options";
import type { IntegrationKind } from "@homarr/definitions";
import { ActionIcon, Affix, IconPencil } from "@homarr/ui";
import type { WidgetSort } from "@homarr/widgets";
import {
loadWidgetDynamic,
reduceWidgetOptionsWithDefaultValues,
widgetImports,
} from "@homarr/widgets";
import { modalEvents } from "../../modals";
interface WidgetPreviewPageContentProps {
sort: WidgetSort;
integrationData: {
id: string;
name: string;
url: string;
kind: IntegrationKind;
}[];
}
export const WidgetPreviewPageContent = ({
sort,
integrationData,
}: WidgetPreviewPageContentProps) => {
const currentDefinition = widgetImports[sort].definition;
const options = currentDefinition.options as Record<
string,
WidgetOptionDefinition
>;
const [state, setState] = useState<{
options: Record<string, unknown>;
integrations: string[];
}>({
options: reduceWidgetOptionsWithDefaultValues(options),
integrations: [],
});
const Comp = loadWidgetDynamic(sort);
return (
<>
<Comp
options={state.options as never}
integrations={state.integrations.map(
(id) => integrationData.find((x) => x.id === id)!,
)}
/>
<Affix bottom={12} right={72}>
<ActionIcon
size={48}
variant="default"
radius="xl"
onClick={() => {
return modalEvents.openManagedModal({
modal: "widgetEditModal",
innerProps: {
sort,
definition: currentDefinition.options,
state: [state, setState],
integrationData: integrationData.filter(
(integration) =>
"supportedIntegrations" in currentDefinition &&
currentDefinition.supportedIntegrations.some(
(kind) => kind === integration.kind,
),
),
integrationSupport:
"supportedIntegrations" in currentDefinition,
},
});
}}
>
<IconPencil size={24} />
</ActionIcon>
</Affix>
</>
);
};

View File

@@ -1,48 +1,34 @@
"use client";
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { useState } from "react";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { ActionIcon, Affix, Center, IconPencil } from "@homarr/ui"; import { db } from "@homarr/db";
import { Center } from "@homarr/ui";
import type { WidgetSort } from "@homarr/widgets"; import type { WidgetSort } from "@homarr/widgets";
import { loadWidgetDynamic, widgetImports } from "@homarr/widgets"; import { widgetImports } from "@homarr/widgets";
import { modalEvents } from "../../modals"; import { WidgetPreviewPageContent } from "./_content";
type Props = PropsWithChildren<{ params: { sort: string } }>; type Props = PropsWithChildren<{ params: { sort: string } }>;
export default function WidgetPreview(props: Props) { export default async function WidgetPreview(props: Props) {
const [options, setOptions] = useState<Record<string, unknown>>({});
if (!(props.params.sort in widgetImports)) { if (!(props.params.sort in widgetImports)) {
notFound(); notFound();
} }
const integrationData = await db.query.integrations.findMany({
columns: {
id: true,
name: true,
url: true,
kind: true,
},
});
const sort = props.params.sort as WidgetSort; const sort = props.params.sort as WidgetSort;
const Comp = loadWidgetDynamic(sort);
return ( return (
<Center h="100vh"> <Center h="100vh">
<Comp options={options as never} integrations={[]} /> <WidgetPreviewPageContent sort={sort} integrationData={integrationData} />
<Affix bottom={12} right={72}>
<ActionIcon
size={48}
variant="default"
radius="xl"
onClick={() => {
return modalEvents.openManagedModal({
modal: "widgetEditModal",
innerProps: {
sort,
definition: widgetImports[sort].definition.options,
state: [options, setOptions],
},
});
}}
>
<IconPencil size={24} />
</ActionIcon>
</Affix>
</Center> </Center>
); );
} }

View File

@@ -148,6 +148,9 @@ export default {
cancel: "Abbrechen", cancel: "Abbrechen",
confirm: "Bestätigen", confirm: "Bestätigen",
}, },
multiSelect: {
placeholder: "Wähle eine oder mehrere Optionen aus",
},
noResults: "Keine Ergebnisse gefunden", noResults: "Keine Ergebnisse gefunden",
search: { search: {
placeholder: "Suche nach etwas...", placeholder: "Suche nach etwas...",
@@ -155,17 +158,23 @@ export default {
}, },
}, },
widget: { widget: {
editModal: {
integrations: {
label: "Integrationen",
},
},
clock: { clock: {
option: { option: {
is24HourFormat: { is24HourFormat: {
label: "24-Stunden Format", label: "24-Stunden Format",
description: "Use 24-hour format instead of 12-hour format", description:
"Verwende das 24-Stunden Format anstelle des 12-Stunden Formats",
}, },
isLocaleTime: { isLocaleTime: {
label: "Use locale time", label: "Lokale Zeit verwenden",
}, },
timezone: { timezone: {
label: "Timezone", label: "Zeitzone",
}, },
}, },
}, },

View File

@@ -147,6 +147,9 @@ export default {
cancel: "Cancel", cancel: "Cancel",
confirm: "Confirm", confirm: "Confirm",
}, },
multiSelect: {
placeholder: "Pick one or more values",
},
search: { search: {
placeholder: "Search for anything...", placeholder: "Search for anything...",
nothingFound: "Nothing found", nothingFound: "Nothing found",
@@ -154,6 +157,11 @@ export default {
noResults: "No results found", noResults: "No results found",
}, },
widget: { widget: {
editModal: {
integrations: {
label: "Integrations",
},
},
clock: { clock: {
option: { option: {
is24HourFormat: { is24HourFormat: {

View File

@@ -33,10 +33,12 @@
}, },
"prettier": "@homarr/prettier-config", "prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@homarr/ui": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0", "@homarr/form": "workspace:^0.1.0",
"@homarr/notifications": "workspace:^0.1.0", "@homarr/notifications": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0" "@homarr/validation": "workspace:^0.1.0"
} }
} }

View File

@@ -2,5 +2,7 @@
import { createFormContext } from "@homarr/form"; import { createFormContext } from "@homarr/form";
import type { WidgetEditModalState } from "../modals/widget-edit-modal";
export const [FormProvider, useFormContext, useForm] = export const [FormProvider, useFormContext, useForm] =
createFormContext<Record<string, unknown>>(); createFormContext<WidgetEditModalState>();

View File

@@ -19,7 +19,7 @@ export const WidgetMultiSelectInput = ({
label={t("label")} label={t("label")}
data={options.options as unknown as string[]} data={options.options as unknown as string[]}
description={options.withDescription ? t("description") : undefined} description={options.withDescription ? t("description") : undefined}
{...form.getInputProps(property)} {...form.getInputProps(`options.${property}`)}
/> />
); );
}; };

View File

@@ -21,7 +21,7 @@ export const WidgetNumberInput = ({
min={options.validate.minValue ?? undefined} min={options.validate.minValue ?? undefined}
max={options.validate.maxValue ?? undefined} max={options.validate.maxValue ?? undefined}
step={options.step} step={options.step}
{...form.getInputProps(property)} {...form.getInputProps(`options.${property}`)}
/> />
); );
}; };

View File

@@ -19,7 +19,7 @@ export const WidgetSelectInput = ({
label={t("label")} label={t("label")}
data={options.options as unknown as string[]} data={options.options as unknown as string[]}
description={options.withDescription ? t("description") : undefined} description={options.withDescription ? t("description") : undefined}
{...form.getInputProps(property)} {...form.getInputProps(`options.${property}`)}
/> />
); );
}; };

View File

@@ -23,7 +23,7 @@ export const WidgetSliderInput = ({
min={options.validate.minValue ?? undefined} min={options.validate.minValue ?? undefined}
max={options.validate.maxValue ?? undefined} max={options.validate.maxValue ?? undefined}
step={options.step} step={options.step}
{...form.getInputProps(property)} {...form.getInputProps(`options.${property}`)}
/> />
</InputWrapper> </InputWrapper>
); );

View File

@@ -18,7 +18,7 @@ export const WidgetSwitchInput = ({
<Switch <Switch
label={t("label")} label={t("label")}
description={options.withDescription ? t("description") : undefined} description={options.withDescription ? t("description") : undefined}
{...form.getInputProps(property, { type: "checkbox" })} {...form.getInputProps(`options.${property}`, { type: "checkbox" })}
/> />
); );
}; };

View File

@@ -18,7 +18,7 @@ export const WidgetTextInput = ({
<TextInput <TextInput
label={t("label")} label={t("label")}
description={options.withDescription ? t("description") : undefined} description={options.withDescription ? t("description") : undefined}
{...form.getInputProps(property)} {...form.getInputProps(`options.${property}`)}
/> />
); );
}; };

View File

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

View File

@@ -5,6 +5,7 @@ import { opt } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("clock", { export const { definition, componentLoader } = createWidgetDefinition("clock", {
icon: IconClock, icon: IconClock,
supportedIntegrations: ["adGuardHome", "piHole"],
options: opt.from( options: opt.from(
(fac) => ({ (fac) => ({
is24HourFormat: fac.switch({ is24HourFormat: fac.switch({

View File

@@ -1,5 +1,6 @@
import type { LoaderComponent } from "next/dynamic"; import type { LoaderComponent } from "next/dynamic";
import type { IntegrationKind } from "@homarr/definitions";
import type { TablerIconsProps } from "@homarr/ui"; import type { TablerIconsProps } from "@homarr/ui";
import type { WidgetImports, WidgetSort } from "."; import type { WidgetImports, WidgetSort } from ".";
@@ -7,6 +8,7 @@ import type {
inferOptionsFromDefinition, inferOptionsFromDefinition,
WidgetOptionsRecord, WidgetOptionsRecord,
} from "./options"; } from "./options";
import type { IntegrationSelectOption } from "./widget-integration-select";
export const createWidgetDefinition = < export const createWidgetDefinition = <
TSort extends WidgetSort, TSort extends WidgetSort,
@@ -28,12 +30,31 @@ export const createWidgetDefinition = <
interface Definition { interface Definition {
icon: (props: TablerIconsProps) => JSX.Element; icon: (props: TablerIconsProps) => JSX.Element;
supportedIntegrations?: IntegrationKind[];
options: WidgetOptionsRecord; options: WidgetOptionsRecord;
} }
export interface WidgetComponentProps<TSort extends WidgetSort> { export interface WidgetComponentProps<TSort extends WidgetSort> {
options: inferOptionsFromDefinition<WidgetOptionsRecordOf<TSort>>; options: inferOptionsFromDefinition<WidgetOptionsRecordOf<TSort>>;
integrations: unknown[]; integrations: inferIntegrationsFromDefinition<
WidgetImports[TSort]["definition"]
>;
}
type inferIntegrationsFromDefinition<TDefinition extends Definition> =
TDefinition extends {
supportedIntegrations: infer TSupportedIntegrations;
} // check if definition has supportedIntegrations
? TSupportedIntegrations extends IntegrationKind[] // check if supportedIntegrations is an array of IntegrationKind
? IntegrationSelectOptionFor<TSupportedIntegrations[number]>[] // if so, return an array of IntegrationSelectOptionFor
: IntegrationSelectOption[] // otherwise, return an array of IntegrationSelectOption without specifying the kind
: IntegrationSelectOption[];
interface IntegrationSelectOptionFor<TIntegration extends IntegrationKind> {
id: string;
name: string;
url: string;
kind: TIntegration[number];
} }
export type WidgetOptionsRecordOf<TSort extends WidgetSort> = export type WidgetOptionsRecordOf<TSort extends WidgetSort> =

View File

@@ -8,7 +8,9 @@ import type { WidgetComponentProps } from "./definition";
import type { WidgetImportRecord } from "./import"; import type { WidgetImportRecord } from "./import";
import * as weather from "./weather"; import * as weather from "./weather";
export { WidgetEditModal } from "./WidgetEditModal"; export { reduceWidgetOptionsWithDefaultValues } from "./options";
export { WidgetEditModal } from "./modals/widget-edit-modal";
export const widgetSorts = ["clock", "weather"] as const; export const widgetSorts = ["clock", "weather"] as const;

View File

@@ -3,27 +3,35 @@
import type { Dispatch, SetStateAction } from "react"; import type { Dispatch, SetStateAction } from "react";
import type { ManagedModal } from "mantine-modal-manager"; import type { ManagedModal } from "mantine-modal-manager";
import { useScopedI18n } from "@homarr/translation/client";
import { Button, Group, Stack } from "@homarr/ui"; import { Button, Group, Stack } from "@homarr/ui";
import type { WidgetSort } from "."; import type { WidgetSort } from "..";
import { getInputForType } from "./_inputs"; import { getInputForType } from "../_inputs";
import { FormProvider, useForm } from "./_inputs/form"; import { FormProvider, useForm } from "../_inputs/form";
import type { WidgetOptionsRecordOf } from "./definition"; import type { WidgetOptionsRecordOf } from "../definition";
import type { WidgetOptionDefinition } from "./options"; import type { WidgetOptionDefinition } from "../options";
import { WidgetIntegrationSelect } from "../widget-integration-select";
import type { IntegrationSelectOption } from "../widget-integration-select";
export interface WidgetEditModalState {
options: Record<string, unknown>;
integrations: string[];
}
interface ModalProps<TSort extends WidgetSort> { interface ModalProps<TSort extends WidgetSort> {
sort: TSort; sort: TSort;
state: [ state: [WidgetEditModalState, Dispatch<SetStateAction<WidgetEditModalState>>];
Record<string, unknown>,
Dispatch<SetStateAction<Record<string, unknown>>>,
];
definition: WidgetOptionsRecordOf<TSort>; definition: WidgetOptionsRecordOf<TSort>;
integrationData: IntegrationSelectOption[];
integrationSupport: boolean;
} }
export const WidgetEditModal: ManagedModal<ModalProps<WidgetSort>> = ({ export const WidgetEditModal: ManagedModal<ModalProps<WidgetSort>> = ({
actions, actions,
innerProps, innerProps,
}) => { }) => {
const t = useScopedI18n("widget.editModal");
const [value, setValue] = innerProps.state; const [value, setValue] = innerProps.state;
const form = useForm({ const form = useForm({
initialValues: value, initialValues: value,
@@ -38,6 +46,13 @@ export const WidgetEditModal: ManagedModal<ModalProps<WidgetSort>> = ({
> >
<FormProvider form={form}> <FormProvider form={form}>
<Stack> <Stack>
{innerProps.integrationSupport && (
<WidgetIntegrationSelect
label={t("integrations.label")}
data={innerProps.integrationData}
{...form.getInputProps("integrations")}
/>
)}
{Object.entries(innerProps.definition).map( {Object.entries(innerProps.definition).map(
([key, value]: [string, WidgetOptionDefinition]) => { ([key, value]: [string, WidgetOptionDefinition]) => {
const Input = getInputForType(value.type); const Input = getInputForType(value.type);

View File

@@ -1,3 +1,4 @@
import { objectEntries } from "@homarr/common";
import type { z } from "@homarr/validation"; import type { z } from "@homarr/validation";
interface CommonInput<TType> { interface CommonInput<TType> {
@@ -140,3 +141,15 @@ const createOptions = <TOptions extends WidgetOptionsRecord>(
export const opt = { export const opt = {
from: createOptions, from: createOptions,
}; };
export const reduceWidgetOptionsWithDefaultValues = (
optionsDefinition: Record<string, WidgetOptionDefinition>,
currentValue: Record<string, unknown> = {},
) =>
objectEntries(optionsDefinition).reduce(
(prev, [key, value]) => ({
...prev,
[key]: currentValue[key] ?? value.defaultValue,
}),
{} as Record<string, unknown>,
);

View File

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

View File

@@ -0,0 +1,11 @@
.pill {
cursor: default;
background-color: light-dark(
var(--mantine-color-white),
var(--mantine-color-dark-7)
);
border: rem(1px) solid
light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-7));
padding-left: var(--mantine-spacing-xs);
border-radius: var(--mantine-radius-xl);
}

View File

@@ -0,0 +1,162 @@
"use client";
import type { FocusEventHandler } from "react";
import type { IntegrationKind } from "@homarr/definitions";
import { getIconUrl } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import {
Avatar,
CheckIcon,
CloseButton,
Combobox,
Group,
Input,
Pill,
PillsInput,
Stack,
Text,
useCombobox,
} from "@homarr/ui";
import classes from "./widget-integration-select.module.css";
interface WidgetIntegrationSelectProps {
label: string;
onChange: (value: string[]) => void;
value?: string[];
error?: string;
onFocus?: FocusEventHandler<HTMLInputElement>;
onBlur?: FocusEventHandler<HTMLInputElement>;
data: IntegrationSelectOption[];
}
export const WidgetIntegrationSelect = ({
data,
onChange,
value: valueProp,
...props
}: WidgetIntegrationSelectProps) => {
const t = useI18n();
const multiSelectValues = valueProp ?? [];
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"),
});
const handleValueSelect = (selectedValue: string) =>
onChange(
multiSelectValues.includes(selectedValue)
? multiSelectValues.filter((v) => v !== selectedValue)
: [...multiSelectValues, selectedValue],
);
const handleValueRemove = (val: string) =>
onChange(multiSelectValues.filter((v) => v !== val));
const values = multiSelectValues.map((item) => (
<IntegrationPill
key={item}
option={data.find((i) => i.id === item)!}
onRemove={() => handleValueRemove(item)}
/>
));
const options = data.map((item) => {
return (
<Combobox.Option
value={item.id}
key={item.id}
active={multiSelectValues.includes(item.id)}
>
<Group gap="sm" align="center">
{multiSelectValues.includes(item.id) ? <CheckIcon size={12} /> : null}
<Group gap={7} align="center">
<Avatar src={getIconUrl(item.kind)} size="sm" />
<Stack gap={0}>
<span>{item.name}</span>
<Text size="xs" c="gray.6">
{item.url}
</Text>
</Stack>
</Group>
</Group>
</Combobox.Option>
);
});
return (
<Combobox
store={combobox}
onOptionSubmit={handleValueSelect}
withinPortal={false}
>
<Combobox.DropdownTarget>
<PillsInput
pointer
onClick={() => combobox.toggleDropdown()}
{...props}
>
<Pill.Group>
{values.length > 0 ? (
values
) : (
<Input.Placeholder>
{t("common.multiSelect.placeholder")}
</Input.Placeholder>
)}
<Combobox.EventsTarget>
<PillsInput.Field
type="hidden"
onBlur={() => combobox.closeDropdown()}
onKeyDown={(event) => {
if (event.key !== "Backspace") return;
event.preventDefault();
handleValueRemove(
multiSelectValues[multiSelectValues.length - 1]!,
);
}}
/>
</Combobox.EventsTarget>
</Pill.Group>
</PillsInput>
</Combobox.DropdownTarget>
<Combobox.Dropdown>
<Combobox.Options>{options}</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
);
};
export interface IntegrationSelectOption {
id: string;
name: string;
url: string;
kind: IntegrationKind;
}
interface IntegrationPillProps {
option: IntegrationSelectOption;
onRemove: () => void;
}
const IntegrationPill = ({ option, onRemove }: IntegrationPillProps) => (
<Group align="center" wrap="nowrap" gap={0} className={classes.pill}>
<Avatar src={getIconUrl(option.kind)} size={14} mr={6} />
<Text span size="xs" lh={1} fw={500}>
{option.name}
</Text>
<CloseButton
onMouseDown={onRemove}
variant="transparent"
color="gray"
size={22}
iconSize={14}
tabIndex={-1}
/>
</Group>
);

6
pnpm-lock.yaml generated
View File

@@ -502,6 +502,12 @@ importers:
packages/widgets: packages/widgets:
dependencies: dependencies:
'@homarr/common':
specifier: workspace:^0.1.0
version: link:../common
'@homarr/definitions':
specifier: workspace:^0.1.0
version: link:../definitions
'@homarr/form': '@homarr/form':
specifier: workspace:^0.1.0 specifier: workspace:^0.1.0
version: link:../form version: link:../form