feat: rework widget settings modal

This commit is contained in:
ajnart committed 2026-08-23 01:19:08 +02:00
1 parent 1b843817a4
commit 5188ed89d6
8 files changed
+419 -97

No files matched your search

@@ -198,7 +198,9 @@ In the modal you can edit the app that you want to display:
<figure>
<img src={editItemCurrent} alt="Current Edit item modal for an App tile" style={{ maxWidth: "620px" }} />
<figcaption>Use the Edit item modal to choose the app and configure how its tile is displayed.</figcaption>
<figcaption>
The Edit item workspace previews option changes as you configure the tile. Save changes to apply them to the board.
</figcaption>
</figure>
As soon as you save your changes, the tile on the board will refresh and display your app.
@@ -1,5 +1,7 @@
"use client";
/* eslint-disable react/no-unstable-nested-components -- Widget modules and definition-bound fallbacks are loaded dynamically. */
import { use, useCallback, useState } from "react";
import { ActionIcon, Affix, Card } from "@mantine/core";
import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react";
@@ -71,6 +73,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
),
integrationSupport: hasIntegrationSupport,
settings,
previewDimensions: dimensions,
},
{
title(translate) {
@@ -86,10 +89,10 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
} finally {
setIsEditorLoading(false);
}
}, [currentDefinition, kind, openWidgetEditModal, settings, state, t, utils]);
}, [currentDefinition, dimensions, kind, openWidgetEditModal, settings, state, t, utils]);
const toggleEditMode = useCallback(() => {
setEditMode((editMode) => !editMode);
setEditMode((currentEditMode) => !currentEditMode);
showSuccessNotification({
message: editMode ? t("widgetPreview.toggle.disabled") : t("widgetPreview.toggle.enabled"),
});
@@ -89,6 +89,7 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
integrationSupport: "supportedIntegrations" in definition,
settings,
itemId: item.id,
previewDimensions: { width: item.width * 200, height: item.height * 200 },
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
},
{
@@ -7,10 +7,11 @@ import type { FallbackProps } from "react-error-boundary";
import { clientApi } from "@homarr/api/client";
import type { WidgetKind } from "@homarr/definitions";
import { createModal, modalSizeForm, useModalAction } from "@homarr/modals";
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
import { WidgetError } from "@homarr/widgets/errors";
import type * as WidgetModalsModule from "@homarr/widgets/modals";
import type { WidgetEditModalProps } from "@homarr/widgets/modals";
import { loadWidgetComponent } from "@homarr/widgets/manifest";
import type { IntegrationSelectOption } from "@homarr/widgets/widget-integration-select";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
@@ -83,6 +84,7 @@ interface LazyWidgetEditModalContentProps {
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
const { WidgetEditModal } = use(loadWidgetEditModal());
const { default: PreviewComponent } = use(loadWidgetComponent(props.innerProps.kind));
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
const Component = WidgetEditModal.component;
const utils = clientApi.useUtils();
@@ -122,6 +124,7 @@ const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
...props.innerProps,
integrationData: combinedIntegrationData,
integrationEditForm: LazyIntegrationEditForm,
previewComponent: PreviewComponent,
onOpenNewIntegration: supportedKinds.length > 0 ? handleOpenNewIntegration : undefined,
}}
/>
@@ -157,6 +160,10 @@ export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>
defaultTitle(t) {
return t("item.edit.title");
},
size: modalSizeForm,
size: modalSizeSelect,
transitionProps: {
transition: "pop",
duration: 180,
},
closeOnClickOutside: false,
});
@@ -189,6 +189,9 @@ export const WidgetContextMenu = ({
),
integrationSupport: definition.supportedIntegrations !== undefined,
settings,
itemId: item.id,
boardId: board.id,
previewDimensions: { width: item.width * 200, height: item.height * 200 },
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -197,6 +200,7 @@ export const WidgetContextMenu = ({
},
);
}, [
board.id,
definition,
integrationData,
item,
+6
View File
@@ -2115,6 +2115,12 @@
"loading": "Loading integration",
"loadError": "This integration could not be loaded. Try again before changing its connection settings."
},
"preview": {
"integrationRequired": "Choose a connection to preview this widget."
},
"settings": {
"label": "Widget settings"
},
"advancedOptions": {
"label": "Advanced options",
"title": "Advanced item options"
@@ -0,0 +1,160 @@
.workspace {
display: grid;
grid-template-columns: 1fr;
gap: var(--mantine-spacing-lg);
align-items: start;
}
.workspace[data-with-preview] {
grid-template-columns: minmax(0, 0.95fr) minmax(24rem, 1.05fr);
}
.previewPanel {
position: sticky;
top: 0;
min-width: 0;
padding: var(--mantine-spacing-md);
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
}
.previewDimensions {
position: absolute;
z-index: 2;
top: calc(var(--mantine-spacing-md) + var(--mantine-spacing-xs));
right: calc(var(--mantine-spacing-md) + var(--mantine-spacing-xs));
pointer-events: none;
}
.previewCanvas {
min-height: 23rem;
padding: var(--mantine-spacing-lg);
overflow: hidden;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-8));
background-image:
linear-gradient(to right, color-mix(in srgb, var(--mantine-color-dimmed) 14%, transparent) 1px, transparent 1px),
linear-gradient(to bottom, color-mix(in srgb, var(--mantine-color-dimmed) 14%, transparent) 1px, transparent 1px);
background-size: 2rem 2rem;
}
.previewWidget {
position: relative;
flex: 0 0 auto;
overflow: hidden;
box-shadow: var(--mantine-shadow-lg);
animation: dock-widget 260ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.previewTitle {
position: absolute;
z-index: 2;
top: var(--mantine-spacing-xs);
left: var(--mantine-spacing-xs);
max-width: calc(100% - var(--mantine-spacing-md));
}
.previewContent {
width: 100%;
height: 100%;
pointer-events: none;
user-select: none;
}
.settingsPanel {
min-width: 0;
max-height: calc(100dvh - 13rem);
padding-right: 0.25rem;
overflow-y: auto;
scrollbar-gutter: stable;
}
.fullWidthField {
grid-column: 1 / -1;
}
.switchOption {
display: flex;
height: 4.5rem;
align-items: center;
padding: 0.625rem 0.75rem;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-sm);
background: var(--mantine-color-body);
}
.switchOption > *,
.switchOption :global(.mantine-Switch-root),
.switchOption :global(.mantine-Switch-body) {
width: 100%;
}
.switchOption :global(.mantine-Switch-body) {
flex-direction: row-reverse;
align-items: center;
justify-content: space-between;
gap: var(--mantine-spacing-md);
}
.switchOption :global(.mantine-Switch-labelWrapper) {
min-width: 0;
flex: 1;
}
.switchOption :global(.mantine-Switch-description) {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.footer {
position: sticky;
z-index: 3;
bottom: calc(var(--mantine-spacing-md) * -1);
margin-inline: calc(var(--mantine-spacing-md) * -1);
margin-bottom: calc(var(--mantine-spacing-md) * -1);
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border-top: 1px solid var(--mantine-color-default-border);
background: color-mix(in srgb, var(--mantine-color-body) 94%, transparent);
backdrop-filter: blur(10px);
}
@keyframes dock-widget {
from {
opacity: 0;
transform: translate(-1.25rem, 1rem) scale(0.92);
}
to {
opacity: 1;
transform: none;
}
}
@media (max-width: 62em) {
.workspace[data-with-preview] {
grid-template-columns: 1fr;
}
.previewPanel {
position: static;
}
.previewCanvas {
min-height: 18rem;
}
.settingsPanel {
max-height: none;
overflow: visible;
}
}
@media (prefers-reduced-motion: reduce) {
.previewWidget {
animation: none;
}
}
+231 -92
View File
@@ -1,17 +1,22 @@
"use client";
/* eslint-disable react/no-unstable-nested-components -- Widget modules and definition-bound fallbacks are loaded dynamically. */
import { useEffect, useRef, useState } from "react";
import type { ComponentType, FormEvent } from "react";
import { Box, Button, Group, Stack, Tabs, Text } from "@mantine/core";
import { Alert, Badge, Box, Button, Card, Center, Group, SimpleGrid, Stack, Tabs, Text } from "@mantine/core";
import { schemaResolver } from "@mantine/form";
import { IconArrowLeft, IconPencil } from "@tabler/icons-react";
import { useElementSize } from "@mantine/hooks";
import { IconArrowLeft, IconEye, IconPencil, IconSettings } from "@tabler/icons-react";
import { QueryErrorResetBoundary } from "@tanstack/react-query";
import { ErrorBoundary } from "react-error-boundary";
import { z } from "zod/v4";
import { objectEntries } from "@homarr/common";
import { useSession } from "@homarr/auth/client";
import { useOptionalBoard } from "@homarr/boards/context";
import type { WidgetKind } from "@homarr/definitions";
import { createModal, ModalFormFooter, modalSizeForm, useModalAction } from "@homarr/modals";
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
import type { SettingsContextProps } from "@homarr/settings/creator";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "@homarr/ui";
@@ -21,13 +26,15 @@ import { getInputForType } from "../_inputs";
import { FormProvider, useForm } from "../_inputs/form";
import type { BoardItemAdvancedOptions } from "../../../validation/src/shared";
import type { OptionsBuilderResult } from "../options";
import type { WidgetDefinition } from "../definition";
import type { WidgetComponentProps, WidgetDefinition } from "../definition";
import { WidgetError } from "../errors/component";
import { OPTIONS_SUPER_REFINE } from "../options";
import type { IntegrationSelectOption } from "../widget-integration-select";
import { WidgetIntegrationSelect } from "../widget-integration-select";
import { WidgetAdvancedOptionsModal } from "./widget-advanced-options-modal";
import type { EmbeddedAppEditFormHandle } from "./embedded-app-edit-form";
import { EmbeddedAppEditForm } from "./embedded-app-edit-form";
import classes from "./widget-edit-modal.module.css";
export interface WidgetEditModalState {
options: Record<string, unknown>;
@@ -59,8 +66,101 @@ export interface WidgetEditModalProps<TSort extends WidgetKind> {
integrationEditForm?: ComponentType<EmbeddedIntegrationEditFormProps>;
onIntegrationSaved?: () => void;
onOpenNewIntegration?: (onCreated?: (id: string) => void) => void;
previewComponent?: ComponentType<WidgetComponentProps<TSort>>;
previewDimensions?: { width: number; height: number };
}
interface WidgetEditPreviewProps {
Component: ComponentType<WidgetComponentProps<WidgetKind>>;
definition: WidgetDefinition;
state: WidgetEditModalState;
itemId?: string;
boardId?: string;
dimensions?: { width: number; height: number };
onChangeOptions: (newOptions: Record<string, unknown>) => void;
}
const WidgetEditPreview = ({
Component,
definition,
state,
itemId,
boardId,
dimensions = { width: 400, height: 240 },
onChangeOptions,
}: WidgetEditPreviewProps) => {
const tItem = useI18n("item.edit");
const { ref, width: availableWidth } = useElementSize<HTMLDivElement>();
const sourceWidth = Math.max(dimensions.width, 1);
const sourceHeight = Math.max(dimensions.height, 1);
const aspectRatio = sourceWidth / sourceHeight;
const maximumWidth = Math.max(availableWidth, 280);
let previewWidth = maximumWidth;
let previewHeight = previewWidth / aspectRatio;
if (previewHeight > 320) {
previewHeight = 320;
previewWidth = previewHeight * aspectRatio;
}
const hasIntegrationSupport = "supportedIntegrations" in definition;
const integrationRequired = hasIntegrationSupport && definition.integrationsRequired !== false;
const isMissingIntegration = integrationRequired && state.integrationIds.length === 0;
return (
<Stack className={classes.previewPanel} gap="sm">
<Badge className={classes.previewDimensions} size="xs" variant="light" color="gray">
{Math.round(sourceWidth)} × {Math.round(sourceHeight)}
</Badge>
<Center ref={ref} className={classes.previewCanvas}>
{isMissingIntegration ? (
<Alert color="gray" variant="light" icon={<IconEye size={18} />} maw={320}>
{tItem("preview.integrationRequired")}
</Alert>
) : (
<Card
className={classes.previewWidget}
w={previewWidth}
h={previewHeight}
p={previewHeight >= 96 ? undefined : 4}
style={{ borderColor: state.advancedOptions.borderColor || undefined }}
>
{state.advancedOptions.title && (
<Badge className={classes.previewTitle} size="sm" variant="default">
{state.advancedOptions.title}
</Badge>
)}
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
resetKeys={[state.options, state.integrationIds]}
fallbackRender={({ resetErrorBoundary, error }) => (
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
)}
>
<div className={classes.previewContent} inert>
<Component
options={state.options as never}
integrationIds={state.integrationIds}
width={previewWidth}
height={previewHeight}
isEditMode={false}
boardId={boardId}
itemId={itemId}
setOptions={({ newOptions }) => onChangeOptions(newOptions as Record<string, unknown>)}
/>
</div>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
</Card>
)}
</Center>
</Stack>
);
};
export const getSelectedWidgetIntegrations = (
integrationData: readonly IntegrationSelectOption[],
selectedIds: readonly string[],
@@ -231,47 +331,112 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
}
: undefined;
const handlePreviewOptionsChange = (newOptions: Record<string, unknown>) => {
form.setFieldValue("options", {
...form.values.options,
...newOptions,
});
};
const selectedIntegrationKinds = innerProps.integrationData
.filter(({ id }) => form.values.integrationIds.includes(id))
.map(({ kind }) => kind);
const visibleOptions = Object.entries(options).filter(([, value]) => {
const Input = getInputForType(value.type);
if (!Input) {
return false;
}
return !value.shouldHide?.(form.values.options as never, selectedIntegrationKinds);
});
const switchOptions = visibleOptions.filter(([, value]) => value.type === "switch");
const otherOptions = visibleOptions.filter(([, value]) => value.type !== "switch");
const widgetFormContent = (
<Stack>
{canConfigureWidget && innerProps.integrationSupport && (
<WidgetIntegrationSelect
label={tItem("field.integrations.label")}
data={innerProps.integrationData}
canSelectMultiple={maxIntegrations > 1}
withAsterisk={integrationsRequired}
onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined}
{...form.getInputProps("integrationIds")}
<Box className={classes.workspace} data-with-preview={innerProps.previewComponent ? true : undefined}>
{innerProps.previewComponent && (
<WidgetEditPreview
Component={innerProps.previewComponent}
definition={definition}
state={{ ...form.values, advancedOptions }}
itemId={innerProps.itemId}
boardId={innerProps.boardId ?? board?.id}
dimensions={innerProps.previewDimensions}
onChangeOptions={handlePreviewOptionsChange}
/>
)}
{canConfigureWidget &&
Object.entries(options).map(([key, value]) => {
const Input = getInputForType(value.type);
if (
!Input ||
value.shouldHide?.(
form.values.options as never,
innerProps.integrationData
.filter(({ id }) => form.values.integrationIds.includes(id))
.map(({ kind }) => kind),
)
) {
return null;
}
return (
<Input
key={key}
kind={innerProps.kind}
property={key}
options={value as never}
initialOptions={innerProps.value.options}
itemId={innerProps.itemId}
boardId={innerProps.boardId ?? board?.id}
<Stack className={classes.settingsPanel} gap="sm">
<Group gap="xs" wrap="nowrap">
<IconSettings size={16} />
<Text fw={600} size="sm">
{tItem("settings.label")}
</Text>
</Group>
{canConfigureWidget && innerProps.integrationSupport && (
<Box className={classes.fullWidthField}>
<WidgetIntegrationSelect
label={tItem("field.integrations.label")}
data={innerProps.integrationData}
canSelectMultiple={maxIntegrations > 1}
withAsterisk={integrationsRequired}
onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined}
{...form.getInputProps("integrationIds")}
/>
);
})}
{showResourceTabs ? (
</Box>
)}
{canConfigureWidget && (
<Stack gap="sm">
{switchOptions.length > 0 && (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm" verticalSpacing="sm">
{switchOptions.map(([key, value]) => {
const Input = getInputForType(value.type);
if (!Input) {
return null;
}
return (
<Box key={key} className={classes.switchOption} data-option-type={value.type}>
<Input
kind={innerProps.kind}
property={key}
options={value as never}
initialOptions={innerProps.value.options}
itemId={innerProps.itemId}
boardId={innerProps.boardId ?? board?.id}
/>
</Box>
);
})}
</SimpleGrid>
)}
{otherOptions.map(([key, value]) => {
const Input = getInputForType(value.type);
if (!Input) {
return null;
}
return (
<Box key={key} data-option-type={value.type}>
<Input
kind={innerProps.kind}
property={key}
options={value as never}
initialOptions={innerProps.value.options}
itemId={innerProps.itemId}
boardId={innerProps.boardId ?? board?.id}
/>
</Box>
);
})}
</Stack>
)}
</Stack>
</Box>
);
const footer = (
<Box className={classes.footer}>
<Group justify="space-between" wrap="nowrap">
<Button
variant="subtle"
type="button"
@@ -284,49 +449,39 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
>
{tItem("advancedOptions.label")}
</Button>
) : (
<ModalFormFooter
onCancel={actions.closeModal}
leftSection={
<Button
variant="subtle"
type="button"
onClick={() =>
openModal({
advancedOptions,
onSuccess: setAdvancedOptions,
})
}
>
{tItem("advancedOptions.label")}
</Button>
}
/>
)}
</Stack>
<Group gap="xs" wrap="nowrap">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={isSubmitting}>
{tCommon("action.saveChanges")}
</Button>
</Group>
</Group>
</Box>
);
return (
<form onSubmit={onFormSubmit}>
<FormProvider form={form}>
{showResourceTabs ? (
<Stack>
<Stack gap="sm">
{showResourceTabs ? (
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List grow>
<Tabs.Tab value="widget">{tItem("tab.widget")}</Tabs.Tab>
{showAppTab && <Tabs.Tab value="app">{tItem("tab.app")}</Tabs.Tab>}
{showIntegrationTab && <Tabs.Tab value="integration">{tItem("tab.integration")}</Tabs.Tab>}
</Tabs.List>
<Tabs.Panel value="widget" pt="md">
<Tabs.Panel value="widget" pt="sm">
{widgetFormContent}
</Tabs.Panel>
{showAppTab && (
<Tabs.Panel value="app" pt="md">
<Tabs.Panel value="app" pt="sm">
{appId && <EmbeddedAppEditForm appId={appId} handleRef={appEditRef} />}
</Tabs.Panel>
)}
{showIntegrationTab && (
<Tabs.Panel value="integration" pt="md">
<Tabs.Panel value="integration" pt="sm">
<Stack>
{displayedIntegrationId === null && (
<>
@@ -394,31 +549,11 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
</Tabs.Panel>
)}
</Tabs>
<Box
pos="sticky"
bottom={0}
style={{
marginInline: "calc(var(--mantine-spacing-md) * -1)",
marginBottom: "calc(var(--mantine-spacing-md) * -1)",
paddingInline: "var(--mantine-spacing-md)",
paddingBlock: "var(--mantine-spacing-sm)",
background: "var(--mantine-color-body)",
borderTop: "1px solid var(--mantine-color-default-border)",
}}
>
<Group justify="end">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={isSubmitting}>
{tCommon("action.saveChanges")}
</Button>
</Group>
</Box>
</Stack>
) : (
widgetFormContent
)}
) : (
widgetFormContent
)}
{footer}
</Stack>
</FormProvider>
</form>
);
@@ -427,6 +562,10 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
defaultTitle(t) {
return t("item.edit.title");
},
size: modalSizeForm,
size: modalSizeSelect,
transitionProps: {
transition: "pop",
duration: 180,
},
closeOnClickOutside: false,
});