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:
84
apps/nextjs/src/app/[locale]/widgets/[sort]/_content.tsx
Normal file
84
apps/nextjs/src/app/[locale]/widgets/[sort]/_content.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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}`)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}`)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}`)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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" })}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}`)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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> =
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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>,
|
||||||
|
);
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
11
packages/widgets/src/widget-integration-select.module.css
Normal file
11
packages/widgets/src/widget-integration-select.module.css
Normal 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);
|
||||||
|
}
|
||||||
162
packages/widgets/src/widget-integration-select.tsx
Normal file
162
packages/widgets/src/widget-integration-select.tsx
Normal 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
6
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user