"use client"; /* eslint-disable react/no-unstable-nested-components -- Widget modules and definition-bound fallbacks are loaded dynamically. */ import { useEffect, useId, useRef, useState } from "react"; import type { ComponentType, FormEvent, PropsWithChildren } from "react"; import { Alert, Badge, Box, Button, Center, Divider, Group, NumberInput, SimpleGrid, Stack, Tabs, Text, Tooltip, } from "@mantine/core"; import { schemaResolver } from "@mantine/form"; 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 { IntegrationProvider, useSession } from "@homarr/auth/client"; import { useOptionalBoard } from "@homarr/boards/context"; import type { WidgetKind } from "@homarr/definitions"; import { createModal, modalSizeForm, useModalAction } from "@homarr/modals"; import type { SettingsContextProps } from "@homarr/settings/creator"; import { SpotlightProvider } from "@homarr/spotlight"; import { useI18n } from "@homarr/translation/client"; import { IntegrationAvatar } from "@homarr/ui"; import { zodErrorMap } from "@homarr/validation/form/i18n"; import { getInputForType } from "../_inputs"; import { FormProvider, useForm } from "../_inputs/form"; import type { BoardItemAdvancedOptions } from "../../../validation/src/shared"; import type { OptionsBuilderResult } from "../options"; 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 { WidgetCardShell, WidgetTitleBadge } from "../widget-card-shell"; 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; integrationIds: string[]; advancedOptions: BoardItemAdvancedOptions; } export interface WidgetEditModalSize { width: number; height: number; } interface WidgetEditPreviewResizeOptions { initialSize: WidgetEditModalSize; maximumSize: WidgetEditModalSize; getDimensions?: (size: WidgetEditModalSize) => { width: number; height: number; scale?: number }; } export interface EmbeddedIntegrationEditFormHandle { submitIfDirty: () => Promise; } export interface EmbeddedIntegrationEditFormProps { integrationId: string; handleRef: React.Ref; onSuccess?: () => void; } export interface WidgetEditModalProps { kind: TSort; definition: WidgetDefinition; value: WidgetEditModalState; onSuccessfulEdit: (value: WidgetEditModalState, size?: WidgetEditModalSize) => void; integrationData: IntegrationSelectOption[]; integrationSupport: boolean; settings: SettingsContextProps; itemId?: string; boardId?: string; appId?: string; integrationEditForm?: ComponentType; onIntegrationSaved?: () => void; onOpenNewIntegration?: (onCreated?: (id: string) => void) => void; previewComponent?: ComponentType>; previewDimensions?: { width: number; height: number; scale?: number }; previewResize?: WidgetEditPreviewResizeOptions; previewWrapper?: ComponentType; } interface WidgetEditPreviewProps { kind: WidgetKind; Component: ComponentType>; definition: WidgetDefinition; state: WidgetEditModalState; itemId?: string; boardId?: string; dimensions?: { width: number; height: number; scale?: number }; integrationData: IntegrationSelectOption[]; onChangeOptions: (newOptions: Record) => void; resize?: { size: WidgetEditModalSize; maximumSize: WidgetEditModalSize; onChange: (size: WidgetEditModalSize) => void; }; PreviewWrapper?: ComponentType; } const PreviewRuntimeBoundary = ({ Wrapper, children, }: PropsWithChildren<{ Wrapper?: ComponentType }>) => { if (!Wrapper) return children; return {children}; }; const normalizePreviewSize = (size: WidgetEditModalSize, maximumSize: WidgetEditModalSize): WidgetEditModalSize => ({ width: Math.max(1, Math.min(Math.round(size.width), maximumSize.width)), height: Math.max(1, Math.min(Math.round(size.height), maximumSize.height)), }); const WidgetEditPreview = ({ kind, Component, definition, state, itemId, boardId, dimensions = { width: 400, height: 240 }, integrationData, onChangeOptions, resize, PreviewWrapper, }: WidgetEditPreviewProps) => { const t = useI18n(); const tItem = useI18n("item.edit"); const board = useOptionalBoard(); const generatedPreviewId = useId().replaceAll(":", ""); const { ref, width: availableWidth, height: availableHeight } = useElementSize(); const sourceWidth = Math.max(dimensions.width, 1); const sourceHeight = Math.max(dimensions.height, 1); let maximumScale = dimensions.scale ?? 0.9; if (!Number.isFinite(maximumScale) || maximumScale <= 0) maximumScale = 0.9; maximumScale = Math.min(maximumScale, 0.9); let previewScale = maximumScale; if (availableWidth > 0 && availableHeight > 0) { previewScale = Math.min(availableWidth / sourceWidth, availableHeight / sourceHeight, maximumScale); } const previewWidth = sourceWidth * previewScale; const previewHeight = sourceHeight * previewScale; const hasIntegrationSupport = "supportedIntegrations" in definition; const integrationRequired = hasIntegrationSupport && definition.integrationsRequired !== false; const isMissingIntegration = integrationRequired && state.integrationIds.length === 0; const previewIntegrations = integrationData .filter((integration) => state.integrationIds.includes(integration.id)) .map((integration) => ({ id: integration.id, permissions: integration.permissions ?? { hasFullAccess: false, hasInteractAccess: false, hasUseAccess: false, }, })); let componentItemId = itemId; if (kind === "assistant") componentItemId = `widget-preview-${generatedPreviewId}`; const isPendingCustomWidget = kind === "customApi" && !itemId; const previewOpacity = (board?.opacity ?? 100) / 100; const handleResizeValue = (dimension: keyof WidgetEditModalSize, value: string | number) => { if (typeof value !== "number" || !Number.isFinite(value) || !resize) return; resize.onChange( normalizePreviewSize( { ...resize.size, [dimension]: value, }, resize.maximumSize, ), ); }; return (
{resize && ( {tItem("preview.size")} handleResizeValue("width", value)} min={1} max={resize.maximumSize.width} step={1} allowDecimal={false} allowNegative={false} clampBehavior="strict" size="xs" leftSection="W" leftSectionPointerEvents="none" /> × handleResizeValue("height", value)} min={1} max={resize.maximumSize.height} step={1} allowDecimal={false} allowNegative={false} clampBehavior="strict" size="xs" leftSection="H" leftSectionPointerEvents="none" /> )} {Math.round(sourceWidth)} × {Math.round(sourceHeight)} {isMissingIntegration ? ( } maw={320}> {tItem("preview.integrationRequired")} ) : ( {({ reset }) => ( ( )} >
onChangeOptions(newOptions as Record)} />
)}
)}
); }; export const getSelectedWidgetIntegrations = ( integrationData: readonly IntegrationSelectOption[], selectedIds: readonly string[], ) => integrationData.filter((integration) => selectedIds.includes(integration.id)); export const WidgetEditModal = createModal>(({ actions, innerProps }) => { const t = useI18n(); const tItem = useI18n("item.edit"); const tCommon = useI18n("common"); const board = useOptionalBoard(); const { data: session } = useSession(); const [advancedOptions, setAdvancedOptions] = useState(innerProps.value.advancedOptions); const appEditRef = useRef(null); const integrationEditHandles = useRef(new Map()); const integrationEditRefCallbacks = useRef( new Map void>(), ); const [mountedIntegrationIds, setMountedIntegrationIds] = useState([]); const [activeIntegrationId, setActiveIntegrationId] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const [previewSize, setPreviewSize] = useState(() => { const resize = innerProps.previewResize; if (!resize) return null; return normalizePreviewSize(resize.initialSize, resize.maximumSize); }); const widgetFormId = useId(); z.config({ customError: zodErrorMap(t), }); const { definition } = innerProps; const integrationsRequired = innerProps.integrationSupport && (!("integrationsRequired" in definition) || definition.integrationsRequired !== false); const maxIntegrations = "maxIntegrations" in definition ? (definition.maxIntegrations ?? Infinity) : Infinity; const options = definition.createOptions(innerProps.settings) as Record; const optionsSuperRefine = (options as Record)[OPTIONS_SUPER_REFINE] as | ((data: Record, ctx: z.RefinementCtx) => void) | undefined; const optionsFieldSchema = objectEntries(options).reduce( (acc, [key, value]: [string, { type: string; validate?: z.ZodType }]) => { if (value.validate) { acc[key] = value.type === "multiText" ? z.array(value.validate).optional() : value.validate; } return acc; }, {} as Record>, ); const optionsSchema = optionsSuperRefine ? z.object(optionsFieldSchema).superRefine(optionsSuperRefine) : z.object(optionsFieldSchema); let integrationIdsSchema = z.array(z.string()); if (integrationsRequired) integrationIdsSchema = integrationIdsSchema.min(1); if (Number.isFinite(maxIntegrations)) integrationIdsSchema = integrationIdsSchema.max(maxIntegrations); const form = useForm({ mode: "controlled", initialValues: innerProps.value, validate: schemaResolver( z.object({ options: optionsSchema, integrationIds: integrationIdsSchema, advancedOptions: z.object({ customCssClasses: z.array(z.string()), borderColor: z.string(), }), }), { sync: true }, ), validateInputOnBlur: true, validateInputOnChange: true, }); const { openModal } = useModalAction(WidgetAdvancedOptionsModal); const canModifyApps = session?.user.permissions.includes("app-modify-all") ?? false; const canConfigureWidget = innerProps.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false); const appId = innerProps.appId; const showAppTab = innerProps.kind === "app" && canModifyApps && Boolean(appId); const canModifyAllIntegrations = session?.user.permissions.includes("integration-full-all") ?? false; const selectedIntegrations = getSelectedWidgetIntegrations(innerProps.integrationData, form.values.integrationIds); const editableIntegrationIds = selectedIntegrations .filter((integration) => canModifyAllIntegrations || integration.permissions?.hasFullAccess === true) .map((integration) => integration.id); let soleEditableIntegrationId: string | null = null; if (selectedIntegrations.length === 1) { soleEditableIntegrationId = editableIntegrationIds[0] ?? null; } let displayedIntegrationId = soleEditableIntegrationId; if (activeIntegrationId && editableIntegrationIds.includes(activeIntegrationId)) { displayedIntegrationId = activeIntegrationId; } const integrationIdsToRender = mountedIntegrationIds.filter((id) => editableIntegrationIds.includes(id)); if (displayedIntegrationId && !integrationIdsToRender.includes(displayedIntegrationId)) { integrationIdsToRender.push(displayedIntegrationId); } const IntegrationEditForm = innerProps.integrationEditForm; const showIntegrationTab = Boolean(IntegrationEditForm) && selectedIntegrations.length > 0; const showResourceTabs = showAppTab || showIntegrationTab; const [activeTab, setActiveTab] = useState("widget"); useEffect(() => { if (activeTab === "integration" && !showIntegrationTab) setActiveTab("widget"); if (activeTab === "app" && !showAppTab) setActiveTab("widget"); }, [activeTab, showAppTab, showIntegrationTab]); const getIntegrationEditRef = (integrationId: string) => { const existingCallback = integrationEditRefCallbacks.current.get(integrationId); if (existingCallback) { return existingCallback; } const callback = (handle: EmbeddedIntegrationEditFormHandle | null) => { if (handle) { integrationEditHandles.current.set(integrationId, handle); } else { integrationEditHandles.current.delete(integrationId); } }; integrationEditRefCallbacks.current.set(integrationId, callback); return callback; }; const beginEditingIntegration = (integrationId: string) => { setMountedIntegrationIds((current) => { if (current.includes(integrationId)) { return current; } return [...current, integrationId]; }); setActiveIntegrationId(integrationId); }; const handleSubmit = form.onSubmit(async (values) => { setIsSubmitting(true); try { const appSaved = showAppTab ? await (appEditRef.current?.submitIfDirty() ?? true) : true; if (!appSaved) { return; } for (const integrationEditHandle of integrationEditHandles.current.values()) { const integrationSaved = await integrationEditHandle.submitIfDirty(); if (!integrationSaved) { return; } } innerProps.onSuccessfulEdit( { ...values, advancedOptions, }, previewSize ?? undefined, ); actions.closeModal(); } finally { setIsSubmitting(false); } }); const onFormSubmit = (event: FormEvent) => { handleSubmit(event); }; const canCreateIntegration = maxIntegrations === 1 || form.values.integrationIds.length < maxIntegrations; const handleOpenNewIntegration = innerProps.onOpenNewIntegration ? () => { innerProps.onOpenNewIntegration?.((newId) => { if (newId) { form.setFieldValue("integrationIds", [...(maxIntegrations > 1 ? form.values.integrationIds : []), newId]); } }); } : undefined; const handlePreviewOptionsChange = (newOptions: Record) => { 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 hasWidgetSettings = canConfigureWidget && (innerProps.integrationSupport || visibleOptions.length > 0); const previewResize = innerProps.previewResize; let previewDimensions = innerProps.previewDimensions; let resizeControls: WidgetEditPreviewProps["resize"]; if (previewSize && previewResize?.getDimensions) { previewDimensions = previewResize.getDimensions(previewSize); resizeControls = { size: previewSize, maximumSize: previewResize.maximumSize, onChange: setPreviewSize, }; } const widgetFormContent = ( {innerProps.previewComponent && ( )} {innerProps.previewComponent && hasWidgetSettings && ( )} ); const footer = ( ); return ( {showResourceTabs ? ( {tItem("tab.widget")} {showAppTab && {tItem("tab.app")}} {showIntegrationTab && {tItem("tab.integration")}} {widgetFormContent} {showAppTab && ( {appId && } )} {showIntegrationTab && ( {displayedIntegrationId === null && ( <> {tItem("integration.description")} {selectedIntegrations.map((integration) => { const canEdit = editableIntegrationIds.includes(integration.id); return ( {integration.name} {integration.url} {canEdit ? ( ) : ( {tItem("integration.fullAccessRequired")} )} ); })} )} {displayedIntegrationId !== null && selectedIntegrations.length > 1 && ( )} {integrationIdsToRender.map((integrationId) => ( ))} )} ) : ( widgetFormContent )} {footer} ); }).withOptions({ keepMounted: true, defaultTitle(t) { return t("item.edit.title"); }, size: modalSizeForm, closeOnClickOutside: false, });