Compare commits

...
31 changed files with 526 additions and 219 deletions

No files matched your search

@@ -194,11 +194,15 @@ To configure this, click on the small 3 dots at the top of the item in the edit
<figcaption>The tile menu contains the item editor and the separate move-and-resize tools.</figcaption> <figcaption>The tile menu contains the item editor and the separate move-and-resize tools.</figcaption>
</figure> </figure>
In the modal you can edit the app that you want to display: The side editor keeps the selected tile visible while you choose the app and adjust its display settings:
<figure> <figure>
<img src={editItemCurrent} alt="Current Edit item modal for an App tile" style={{ maxWidth: "620px" }} /> <img
<figcaption>Use the Edit item modal to choose the app and configure how its tile is displayed.</figcaption> src={editItemCurrent}
alt="App tile highlighted beside the Edit item side editor"
style={{ maxWidth: "620px" }}
/>
<figcaption>The selected tile stays highlighted while the rest of the board is subdued.</figcaption>
</figure> </figure>
As soon as you save your changes, the tile on the board will refresh and display your app. As soon as you save your changes, the tile on the board will refresh and display your app.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 144 KiB

@@ -1,6 +1,6 @@
"use client"; "use client";
import { use, useCallback, useState } from "react"; import { use, useCallback, useId, useState } from "react";
import { ActionIcon, Affix, Card } from "@mantine/core"; import { ActionIcon, Affix, Card } from "@mantine/core";
import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react"; import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react";
import { QueryErrorResetBoundary } from "@tanstack/react-query"; import { QueryErrorResetBoundary } from "@tanstack/react-query";
@@ -32,6 +32,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal); const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal);
const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal); const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal);
const { definition: currentDefinition, Component } = use(loadWidgetResources(kind)); const { definition: currentDefinition, Component } = use(loadWidgetResources(kind));
const previewId = useId();
const [editMode, setEditMode] = useState(false); const [editMode, setEditMode] = useState(false);
const [isEditorLoading, setIsEditorLoading] = useState(false); const [isEditorLoading, setIsEditorLoading] = useState(false);
const [dimensions, setDimensions] = useState<Dimensions>({ const [dimensions, setDimensions] = useState<Dimensions>({
@@ -58,6 +59,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
preloadWidgetEditModal(); preloadWidgetEditModal();
try { try {
const integrationData = hasIntegrationSupport ? await utils.integration.all.ensureData() : []; const integrationData = hasIntegrationSupport ? await utils.integration.all.ensureData() : [];
const originalState = state;
openWidgetEditModal( openWidgetEditModal(
{ {
kind, kind,
@@ -66,11 +68,14 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
onSuccessfulEdit: (value) => { onSuccessfulEdit: (value) => {
setState(value); setState(value);
}, },
onPreviewChange: setState,
onPreviewRestore: () => setState(originalState),
integrationData: integrationData.filter((integration) => integrationData: integrationData.filter((integration) =>
(currentDefinition.supportedIntegrations ?? []).includes(integration.kind), (currentDefinition.supportedIntegrations ?? []).includes(integration.kind),
), ),
integrationSupport: hasIntegrationSupport, integrationSupport: hasIntegrationSupport,
settings, settings,
focusTargetId: previewId,
}, },
{ {
title(translate) { title(translate) {
@@ -86,7 +91,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
} finally { } finally {
setIsEditorLoading(false); setIsEditorLoading(false);
} }
}, [currentDefinition, kind, openWidgetEditModal, settings, state, t, utils]); }, [currentDefinition, kind, openWidgetEditModal, previewId, settings, state, t, utils]);
const toggleEditMode = useCallback(() => { const toggleEditMode = useCallback(() => {
setEditMode((editMode) => !editMode); setEditMode((editMode) => !editMode);
@@ -110,7 +115,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
return ( return (
<> <>
<Card w={dimensions.width} h={dimensions.height} p={dimensions.height >= 96 ? undefined : 4}> <Card id={previewId} w={dimensions.width} h={dimensions.height} p={dimensions.height >= 96 ? undefined : 4}>
<QueryErrorResetBoundary> <QueryErrorResetBoundary>
{({ reset }) => ( {({ reset }) => (
<ErrorBoundary <ErrorBoundary
@@ -30,6 +30,13 @@ interface UpdateItemIntegrations {
newIntegrations: string[]; newIntegrations: string[];
} }
interface UpdateItemSettings {
itemId: string;
options: Record<string, unknown>;
advancedOptions: BoardItemAdvancedOptions;
integrationIds: string[];
}
export const useItemActions = () => { export const useItemActions = () => {
const { updateBoard } = useUpdateBoard(); const { updateBoard } = useUpdateBoard();
const currentLayoutId = useCurrentLayout(); const currentLayoutId = useCurrentLayout();
@@ -77,6 +84,20 @@ export const useItemActions = () => {
[updateBoard], [updateBoard],
); );
const updateItemSettings = useCallback(
({ itemId, options, advancedOptions, integrationIds }: UpdateItemSettings) => {
updateBoard((previous) => ({
...previous,
items: previous.items.map((item) => {
if (item.id !== itemId || !("integrationIds" in item)) return item;
return { ...item, options, advancedOptions, integrationIds };
}),
}));
},
[updateBoard],
);
const moveAndResizeItem = useCallback( const moveAndResizeItem = useCallback(
(input: Omit<MoveAndResizeItemInput, "layoutId">) => { (input: Omit<MoveAndResizeItemInput, "layoutId">) => {
updateBoard(moveAndResizeItemCallback({ ...input, layoutId: currentLayoutId })); updateBoard(moveAndResizeItemCallback({ ...input, layoutId: currentLayoutId }));
@@ -105,6 +126,7 @@ export const useItemActions = () => {
updateItemOptions, updateItemOptions,
updateItemAdvancedOptions, updateItemAdvancedOptions,
updateItemIntegrations, updateItemIntegrations,
updateItemSettings,
duplicateItem, duplicateItem,
createItem, createItem,
}; };
@@ -297,6 +297,7 @@ const LoadedBoardItemContent = ({
role={isPreview ? "region" : undefined} role={isPreview ? "region" : undefined}
aria-label={isPreview ? advancedViewLabel : undefined} aria-label={isPreview ? advancedViewLabel : undefined}
data-advanced-focus-surface={isPreview || undefined} data-advanced-focus-surface={isPreview || undefined}
data-advanced-focus-item-id={isAdvanced ? item.id : undefined}
data-grid-item-content data-grid-item-content
radius={board.itemRadius} radius={board.itemRadius}
p={isAdvanced ? undefined : 0} p={isAdvanced ? undefined : 0}
@@ -3,6 +3,7 @@ import { ActionIcon, Menu } from "@mantine/core";
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil } from "@tabler/icons-react"; import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode"; import { useEditMode } from "@homarr/boards/edit-mode";
import { getWidgetName } from "@homarr/definitions"; import { getWidgetName } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
@@ -33,6 +34,7 @@ export const BoardItemMenu = (props: BoardItemMenuProps) => {
}; };
const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemMenuProps) => { const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
const { data: session } = useSession(); const { data: session } = useSession();
const board = useRequiredBoard();
const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false); const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
const refResetErrorBoundaryOnNextRender = useRef(false); const refResetErrorBoundaryOnNextRender = useRef(false);
const tItem = useI18n("item"); const tItem = useI18n("item");
@@ -40,8 +42,7 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
const { openModal } = useModalAction(LazyWidgetEditModal); const { openModal } = useModalAction(LazyWidgetEditModal);
const openMoveModal = useOpenItemMoveModal(); const openMoveModal = useOpenItemMoveModal();
const [isMenuOpen, setIsMenuOpen] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false);
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } = const { updateItemSettings, duplicateItem, removeItem } = useItemActions();
useItemActions();
const { integrations: integrationData, section } = useSectionContext(); const { integrations: integrationData, section } = useSectionContext();
const settings = useSettings(); const settings = useSettings();
const label = item.advancedOptions.title?.trim() || getWidgetName(item.kind, t); const label = item.advancedOptions.title?.trim() || getWidgetName(item.kind, t);
@@ -55,6 +56,11 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
}, [item, resetErrorBoundary]); }, [item, resetErrorBoundary]);
const openEditModal = () => { const openEditModal = () => {
const originalSettings = {
options: item.options,
advancedOptions: item.advancedOptions,
integrationIds: item.integrationIds,
};
openModal( openModal(
{ {
kind: item.kind, kind: item.kind,
@@ -64,19 +70,14 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
options: item.options, options: item.options,
integrationIds: item.integrationIds, integrationIds: item.integrationIds,
}, },
onSuccessfulEdit: ({ options, integrationIds, advancedOptions }) => { onPreviewChange: (value) => {
updateItemOptions({ updateItemSettings({ itemId: item.id, ...value });
itemId: item.id, },
newOptions: options, onPreviewRestore: () => {
}); updateItemSettings({ itemId: item.id, ...originalSettings });
updateItemAdvancedOptions({ },
itemId: item.id, onSuccessfulEdit: (value) => {
newAdvancedOptions: advancedOptions, updateItemSettings({ itemId: item.id, ...value });
});
updateItemIntegrations({
itemId: item.id,
newIntegrations: integrationIds,
});
refResetErrorBoundaryOnNextRender.current = true; refResetErrorBoundaryOnNextRender.current = true;
}, },
onIntegrationSaved: resetErrorBoundary, onIntegrationSaved: resetErrorBoundary,
@@ -89,6 +90,7 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
integrationSupport: "supportedIntegrations" in definition, integrationSupport: "supportedIntegrations" in definition,
settings, settings,
itemId: item.id, itemId: item.id,
boardId: board.id,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined, appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
}, },
{ {
@@ -277,6 +277,7 @@ const ItemSelectModalContent = ({
integrationData: [], integrationData: [],
integrationSupport: false, integrationSupport: false,
settings, settings,
boardId: board.id,
}, },
{ {
title: (titleT) => `${titleT("item.edit.title")} - ${customWidgetDefinition.name}`, title: (titleT) => `${titleT("item.edit.title")} - ${customWidgetDefinition.name}`,
@@ -346,6 +347,7 @@ const ItemSelectModalContent = ({
integrationData: availableIntegrations, integrationData: availableIntegrations,
integrationSupport: hasIntegrationSupport, integrationSupport: hasIntegrationSupport,
settings, settings,
boardId: board.id,
}, },
{ {
title: (titleT) => `${titleT("item.edit.title")} - ${getWidgetName(kind, titleT)}`, title: (titleT) => `${titleT("item.edit.title")} - ${getWidgetName(kind, titleT)}`,
@@ -157,6 +157,8 @@ export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>
defaultTitle(t) { defaultTitle(t) {
return t("item.edit.title"); return t("item.edit.title");
}, },
presentation: "inspector",
withOverlay: false,
size: modalSizeForm, size: modalSizeForm,
closeOnClickOutside: false, closeOnClickOutside: false,
}); });
@@ -58,7 +58,7 @@ export const WidgetContextMenu = ({
const tCommon = useI18n("common.action"); const tCommon = useI18n("common.action");
const settings = useSettings(); const settings = useSettings();
const { openModal } = useModalAction(LazyWidgetEditModal); const { openModal } = useModalAction(LazyWidgetEditModal);
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions(); const { updateItemSettings } = useItemActions();
const hasSupportedIntegrations = (definition.supportedIntegrations?.length ?? 0) > 0; const hasSupportedIntegrations = (definition.supportedIntegrations?.length ?? 0) > 0;
const { data: integrationData = [], isPending } = clientApi.integration.all.useQuery(undefined, { const { data: integrationData = [], isPending } = clientApi.integration.all.useQuery(undefined, {
enabled: hasSupportedIntegrations, enabled: hasSupportedIntegrations,
@@ -154,19 +154,27 @@ export const WidgetContextMenu = ({
}) ?? []; }) ?? [];
const openEditModal = useCallback(() => { const openEditModal = useCallback(() => {
const originalSettings = {
options: item.options,
advancedOptions: item.advancedOptions,
integrationIds: item.integrationIds,
};
openModal( openModal(
{ {
kind: item.kind, kind: item.kind,
definition, definition,
value: { value: {
advancedOptions: item.advancedOptions, advancedOptions: item.advancedOptions,
options: item.options, options,
integrationIds: item.integrationIds, integrationIds: item.integrationIds,
}, },
onPreviewChange: (value) => {
updateItemSettings({ itemId: item.id, ...value });
},
onPreviewRestore: () => {
updateItemSettings({ itemId: item.id, ...originalSettings });
},
onSuccessfulEdit: (editResult) => { onSuccessfulEdit: (editResult) => {
updateItemOptions({ itemId: item.id, newOptions: editResult.options });
updateItemAdvancedOptions({ itemId: item.id, newAdvancedOptions: editResult.advancedOptions });
updateItemIntegrations({ itemId: item.id, newIntegrations: editResult.integrationIds });
persistBoard((previous) => ({ persistBoard((previous) => ({
...previous, ...previous,
items: previous.items.map((boardItem) => items: previous.items.map((boardItem) =>
@@ -189,6 +197,8 @@ export const WidgetContextMenu = ({
), ),
integrationSupport: definition.supportedIntegrations !== undefined, integrationSupport: definition.supportedIntegrations !== undefined,
settings, settings,
itemId: item.id,
boardId: board.id,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined, appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
}, },
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -197,16 +207,16 @@ export const WidgetContextMenu = ({
}, },
); );
}, [ }, [
board.id,
definition, definition,
integrationData, integrationData,
item, item,
handleRefetch, handleRefetch,
openModal, openModal,
options,
persistBoard, persistBoard,
settings, settings,
updateItemAdvancedOptions, updateItemSettings,
updateItemIntegrations,
updateItemOptions,
]); ]);
const handleToggle = useCallback( const handleToggle = useCallback(
Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 651 KiB

@@ -7,9 +7,11 @@
.inspectorContent { .inspectorContent {
--modal-content-flex: 0 0 100%; --modal-content-flex: 0 0 100%;
--modal-content-max-height: min(88dvh, 52rem); --modal-content-max-height: min(88dvh, 52rem);
--modal-size: 100%;
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
min-width: 0;
border-radius: var(--mantine-radius-lg) var(--mantine-radius-lg) 0 0; border-radius: var(--mantine-radius-lg) var(--mantine-radius-lg) 0 0;
} }
@@ -23,8 +25,13 @@
--modal-content-flex: 0 0 min(34rem, 46vw); --modal-content-flex: 0 0 min(34rem, 46vw);
--modal-content-height: 100dvh; --modal-content-height: 100dvh;
--modal-content-max-height: 100dvh; --modal-content-max-height: 100dvh;
--modal-size: min(34rem, 46vw);
width: min(34rem, 46vw); width: min(34rem, 46vw);
border-radius: 0; border-radius: 0;
} }
.inspectorInner:has([data-widget-inspector-side="left"]) {
justify-content: flex-start;
}
} }
+1
View File
@@ -19,6 +19,7 @@ export type CreateModalOptions = Pick<
| "centered" | "centered"
| "keepMounted" | "keepMounted"
| "withCloseButton" | "withCloseButton"
| "withOverlay"
| "zIndex" | "zIndex"
| "scrollAreaComponent" | "scrollAreaComponent"
| "yOffset" | "yOffset"
@@ -17,7 +17,10 @@ export const WidgetAdvancedOptionsModal = createModal<InnerProps>(({ actions, in
const t = useI18n("item.edit"); const t = useI18n("item.edit");
const theme = useMantineTheme(); const theme = useMantineTheme();
const form = useForm({ const form = useForm({
initialValues: innerProps.advancedOptions, initialValues: {
...innerProps.advancedOptions,
title: innerProps.advancedOptions.title ?? "",
},
}); });
const handleSubmit = (values: BoardItemAdvancedOptions) => { const handleSubmit = (values: BoardItemAdvancedOptions) => {
innerProps.onSuccess({ innerProps.onSuccess({
@@ -0,0 +1,118 @@
"use client";
import { useState } from "react";
import { Box, getDefaultZIndex, Overlay, Portal } from "@mantine/core";
import { useIsomorphicEffect } from "@mantine/hooks";
import classes from "./widget-edit-modal.module.css";
interface FocusRect {
top: number;
left: number;
width: number;
height: number;
}
export type WidgetInspectorSide = "left" | "right";
const focusGap = 6;
const getFocusRect = (element: HTMLElement): FocusRect | null => {
const rect = element.getBoundingClientRect();
const top = Math.max(4, rect.top - focusGap);
const left = Math.max(4, rect.left - focusGap);
const right = Math.min(window.innerWidth - 4, rect.right + focusGap);
const bottom = Math.min(window.innerHeight - 4, rect.bottom + focusGap);
if (right <= left || bottom <= top) return null;
return {
top,
left,
width: right - left,
height: bottom - top,
};
};
const focusRectsMatch = (current: FocusRect | null, next: FocusRect | null) => {
if (current === next) return true;
if (!current || !next) return false;
return (
current.top === next.top &&
current.left === next.left &&
current.width === next.width &&
current.height === next.height
);
};
export const useWidgetEditFocus = (itemId: string | undefined, focusTargetId: string | undefined) => {
const [focusRect, setFocusRect] = useState<FocusRect | null>(null);
useIsomorphicEffect(() => {
let element: HTMLElement | null = null;
if (focusTargetId) {
element = document.getElementById(focusTargetId);
} else if (itemId) {
element = document.querySelector<HTMLElement>(`[data-advanced-focus-item-id="${CSS.escape(itemId)}"]`);
element ??= document.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(itemId)}"]`);
}
if (!element) {
setFocusRect(null);
return;
}
let animationFrame = 0;
const update = () => {
animationFrame = 0;
const nextRect = getFocusRect(element);
setFocusRect((current) => (focusRectsMatch(current, nextRect) ? current : nextRect));
};
const scheduleUpdate = () => {
if (animationFrame !== 0) return;
animationFrame = window.requestAnimationFrame(update);
};
update();
const resizeObserver = new ResizeObserver(scheduleUpdate);
resizeObserver.observe(element);
window.addEventListener("resize", scheduleUpdate);
window.addEventListener("scroll", scheduleUpdate, true);
return () => {
resizeObserver.disconnect();
window.removeEventListener("resize", scheduleUpdate);
window.removeEventListener("scroll", scheduleUpdate, true);
window.cancelAnimationFrame(animationFrame);
};
}, [focusTargetId, itemId]);
const inspectorSide: WidgetInspectorSide =
focusRect && focusRect.left + focusRect.width / 2 > window.innerWidth / 2 ? "left" : "right";
return { focusRect, inspectorSide };
};
export const WidgetEditFocusOverlay = ({ focusRect }: { focusRect: FocusRect | null }) => (
<Portal>
{focusRect ? (
<>
<Overlay fixed backgroundOpacity={0} zIndex={getDefaultZIndex("modal")} />
<Box
aria-hidden
className={classes.focusFrame}
style={{
top: focusRect.top,
left: focusRect.left,
width: focusRect.width,
height: focusRect.height,
zIndex: getDefaultZIndex("modal"),
}}
/>
</>
) : (
<Overlay fixed color="#000" backgroundOpacity={0.58} blur={1} zIndex={getDefaultZIndex("modal")} />
)}
</Portal>
);
@@ -0,0 +1,90 @@
.panel {
min-width: 0;
}
.settings {
min-width: 0;
container-type: inline-size;
}
.switchGrid {
display: grid;
grid-template-columns: minmax(0, 1fr);
align-items: stretch;
gap: var(--mantine-spacing-sm);
}
.switchOption {
display: flex;
height: 100%;
min-height: 4.25rem;
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-sm);
}
.switchOption :global(.mantine-Switch-labelWrapper) {
min-width: 0;
flex: 1;
}
.footer {
position: sticky;
z-index: 2;
bottom: 0;
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) 96%, transparent);
backdrop-filter: blur(10px);
}
.footerActions {
margin-inline-start: auto;
}
@container (min-width: 30rem) {
.switchGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: 1fr;
}
}
.focusFrame {
position: fixed;
pointer-events: none;
border: 2px solid var(--mantine-primary-color-filled);
border-radius: var(--mantine-radius-md);
box-shadow:
0 0 0 2px color-mix(in srgb, var(--mantine-primary-color-filled) 32%, transparent),
0 0 2.5rem color-mix(in srgb, var(--mantine-primary-color-filled) 28%, transparent),
0 0 0 100vmax rgb(0 0 0 / 0.58);
clip-path: inset(-100vmax);
transition:
top 160ms ease,
left 160ms ease,
width 160ms ease,
height 160ms ease;
}
@media (prefers-reduced-motion: reduce) {
.focusFrame {
transition: none;
}
}
+229 -189
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useId, useRef, useState } from "react";
import type { ComponentType, FormEvent } from "react"; import type { ComponentType, FormEvent } from "react";
import { Box, Button, Group, Stack, Tabs, Text } from "@mantine/core"; import { Box, Button, Group, Stack, Tabs, Text } from "@mantine/core";
import { schemaResolver } from "@mantine/form"; import { schemaResolver } from "@mantine/form";
@@ -11,7 +11,7 @@ import { objectEntries } from "@homarr/common";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import { useOptionalBoard } from "@homarr/boards/context"; import { useOptionalBoard } from "@homarr/boards/context";
import type { WidgetKind } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions";
import { createModal, ModalFormFooter, modalSizeForm, useModalAction } from "@homarr/modals"; import { createModal, modalSizeForm, useModalAction } from "@homarr/modals";
import type { SettingsContextProps } from "@homarr/settings/creator"; import type { SettingsContextProps } from "@homarr/settings/creator";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "@homarr/ui"; import { IntegrationAvatar } from "@homarr/ui";
@@ -26,8 +26,10 @@ import { OPTIONS_SUPER_REFINE } from "../options";
import type { IntegrationSelectOption } from "../widget-integration-select"; import type { IntegrationSelectOption } from "../widget-integration-select";
import { WidgetIntegrationSelect } from "../widget-integration-select"; import { WidgetIntegrationSelect } from "../widget-integration-select";
import { WidgetAdvancedOptionsModal } from "./widget-advanced-options-modal"; import { WidgetAdvancedOptionsModal } from "./widget-advanced-options-modal";
import { useWidgetEditFocus, WidgetEditFocusOverlay } from "./widget-edit-focus-overlay";
import type { EmbeddedAppEditFormHandle } from "./embedded-app-edit-form"; import type { EmbeddedAppEditFormHandle } from "./embedded-app-edit-form";
import { EmbeddedAppEditForm } from "./embedded-app-edit-form"; import { EmbeddedAppEditForm } from "./embedded-app-edit-form";
import classes from "./widget-edit-modal.module.css";
export interface WidgetEditModalState { export interface WidgetEditModalState {
options: Record<string, unknown>; options: Record<string, unknown>;
@@ -50,11 +52,14 @@ export interface WidgetEditModalProps<TSort extends WidgetKind> {
definition: WidgetDefinition; definition: WidgetDefinition;
value: WidgetEditModalState; value: WidgetEditModalState;
onSuccessfulEdit: (value: WidgetEditModalState) => void; onSuccessfulEdit: (value: WidgetEditModalState) => void;
onPreviewChange?: (value: WidgetEditModalState) => void;
onPreviewRestore?: () => void;
integrationData: IntegrationSelectOption[]; integrationData: IntegrationSelectOption[];
integrationSupport: boolean; integrationSupport: boolean;
settings: SettingsContextProps; settings: SettingsContextProps;
itemId?: string; itemId?: string;
boardId?: string; boardId?: string;
focusTargetId?: string;
appId?: string; appId?: string;
integrationEditForm?: ComponentType<EmbeddedIntegrationEditFormProps>; integrationEditForm?: ComponentType<EmbeddedIntegrationEditFormProps>;
onIntegrationSaved?: () => void; onIntegrationSaved?: () => void;
@@ -81,6 +86,18 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
const [mountedIntegrationIds, setMountedIntegrationIds] = useState<string[]>([]); const [mountedIntegrationIds, setMountedIntegrationIds] = useState<string[]>([]);
const [activeIntegrationId, setActiveIntegrationId] = useState<string | null>(null); const [activeIntegrationId, setActiveIntegrationId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const hasPreviewedRef = useRef(false);
const hasSavedRef = useRef(false);
const previewCallbacksRef = useRef({
onPreviewChange: innerProps.onPreviewChange,
onPreviewRestore: innerProps.onPreviewRestore,
});
previewCallbacksRef.current = {
onPreviewChange: innerProps.onPreviewChange,
onPreviewRestore: innerProps.onPreviewRestore,
};
const widgetFormId = useId();
const { focusRect, inspectorSide } = useWidgetEditFocus(innerProps.itemId, innerProps.focusTargetId);
z.config({ z.config({
customError: zodErrorMap(t), customError: zodErrorMap(t),
@@ -112,25 +129,47 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
if (integrationsRequired) integrationIdsSchema = integrationIdsSchema.min(1); if (integrationsRequired) integrationIdsSchema = integrationIdsSchema.min(1);
if (Number.isFinite(maxIntegrations)) integrationIdsSchema = integrationIdsSchema.max(maxIntegrations); if (Number.isFinite(maxIntegrations)) integrationIdsSchema = integrationIdsSchema.max(maxIntegrations);
const widgetFormSchema = z.object({
options: optionsSchema,
integrationIds: integrationIdsSchema,
});
const applyPreview = (value: WidgetEditModalState) => {
if (!innerProps.onPreviewChange) return;
hasPreviewedRef.current = true;
innerProps.onPreviewChange(value);
};
const form = useForm({ const form = useForm({
mode: "controlled", mode: "controlled",
initialValues: innerProps.value, initialValues: {
validate: schemaResolver( options: innerProps.value.options,
z.object({ integrationIds: innerProps.value.integrationIds,
options: optionsSchema, },
integrationIds: integrationIdsSchema, validate: schemaResolver(widgetFormSchema, { sync: true }),
advancedOptions: z.object({
customCssClasses: z.array(z.string()),
borderColor: z.string(),
}),
}),
{ sync: true },
),
validateInputOnBlur: true, validateInputOnBlur: true,
validateInputOnChange: true, validateInputOnChange: true,
onValuesChange: (values) => {
if (!widgetFormSchema.safeParse(values).success) return;
applyPreview({ ...values, advancedOptions });
},
}); });
const { openModal } = useModalAction(WidgetAdvancedOptionsModal); const { openModal } = useModalAction(WidgetAdvancedOptionsModal);
useEffect(
() => () => {
if (!hasPreviewedRef.current || hasSavedRef.current) return;
const { onPreviewChange, onPreviewRestore } = previewCallbacksRef.current;
if (onPreviewRestore) {
onPreviewRestore();
return;
}
onPreviewChange?.(innerProps.value);
},
[innerProps.value],
);
const canModifyApps = session?.user.permissions.includes("app-modify-all") ?? false; const canModifyApps = session?.user.permissions.includes("app-modify-all") ?? false;
const canConfigureWidget = innerProps.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false); const canConfigureWidget = innerProps.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
const appId = innerProps.appId; const appId = innerProps.appId;
@@ -210,6 +249,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
...values, ...values,
advancedOptions, advancedOptions,
}); });
hasSavedRef.current = true;
actions.closeModal(); actions.closeModal();
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
@@ -231,202 +271,202 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
} }
: undefined; : undefined;
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 = ( const widgetFormContent = (
<Stack> <form id={widgetFormId} onSubmit={onFormSubmit}>
{canConfigureWidget && innerProps.integrationSupport && ( <Stack className={classes.settings} gap="md">
<WidgetIntegrationSelect {canConfigureWidget && innerProps.integrationSupport && (
label={tItem("field.integrations.label")} <WidgetIntegrationSelect
data={innerProps.integrationData} label={tItem("field.integrations.label")}
canSelectMultiple={maxIntegrations > 1} data={innerProps.integrationData}
withAsterisk={integrationsRequired} canSelectMultiple={maxIntegrations > 1}
onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined} withAsterisk={integrationsRequired}
{...form.getInputProps("integrationIds")} onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined}
/> {...form.getInputProps("integrationIds")}
)} />
{canConfigureWidget && )}
Object.entries(options).map(([key, value]) => { {canConfigureWidget && switchOptions.length > 0 && (
const Input = getInputForType(value.type); <Box className={classes.switchGrid}>
{switchOptions.map(([key, value]) => {
const Input = getInputForType(value.type);
if (!Input) return null;
if ( return (
!Input || <Box key={key} className={classes.switchOption}>
value.shouldHide?.( <Input
form.values.options as never, kind={innerProps.kind}
innerProps.integrationData property={key}
.filter(({ id }) => form.values.integrationIds.includes(id)) options={value as never}
.map(({ kind }) => kind), initialOptions={innerProps.value.options}
) itemId={innerProps.itemId}
) { boardId={innerProps.boardId ?? board?.id}
return null; />
} </Box>
);
})}
</Box>
)}
{canConfigureWidget &&
otherOptions.map(([key, value]) => {
const Input = getInputForType(value.type);
if (!Input) return null;
return ( return (
<Input <Input
key={key} key={key}
kind={innerProps.kind} kind={innerProps.kind}
property={key} property={key}
options={value as never} options={value as never}
initialOptions={innerProps.value.options} initialOptions={innerProps.value.options}
itemId={innerProps.itemId} itemId={innerProps.itemId}
boardId={innerProps.boardId ?? board?.id} boardId={innerProps.boardId ?? board?.id}
/> />
); );
})} })}
{showResourceTabs ? ( </Stack>
<Button </form>
variant="subtle" );
type="button"
onClick={() => return (
openModal({ <>
advancedOptions, <WidgetEditFocusOverlay focusRect={focusRect} />
onSuccess: setAdvancedOptions, <FormProvider form={form}>
}) <Stack className={classes.panel} data-widget-inspector-side={inspectorSide} gap={0}>
} <Box pb="md">
> {showResourceTabs ? (
{tItem("advancedOptions.label")} <Tabs value={activeTab} onChange={setActiveTab}>
</Button> <Tabs.List grow>
) : ( <Tabs.Tab value="widget">{tItem("tab.widget")}</Tabs.Tab>
<ModalFormFooter {showAppTab && <Tabs.Tab value="app">{tItem("tab.app")}</Tabs.Tab>}
onCancel={actions.closeModal} {showIntegrationTab && <Tabs.Tab value="integration">{tItem("tab.integration")}</Tabs.Tab>}
leftSection={ </Tabs.List>
<Tabs.Panel value="widget" pt="md">
{widgetFormContent}
</Tabs.Panel>
{showAppTab && (
<Tabs.Panel value="app" pt="md">
{appId && <EmbeddedAppEditForm appId={appId} handleRef={appEditRef} />}
</Tabs.Panel>
)}
{showIntegrationTab && (
<Tabs.Panel value="integration" pt="md">
<Stack>
{displayedIntegrationId === null && (
<>
<Text size="sm" c="dimmed">
{tItem("integration.description")}
</Text>
{selectedIntegrations.map((integration) => {
const canEdit = editableIntegrationIds.includes(integration.id);
return (
<Group key={integration.id} justify="space-between" wrap="nowrap" gap="sm">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<IntegrationAvatar kind={integration.kind} size="sm" />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={600} size="sm" truncate>
{integration.name}
</Text>
<Text size="xs" c="dimmed" truncate>
{integration.url}
</Text>
</Stack>
</Group>
{canEdit ? (
<Button
type="button"
variant="light"
leftSection={<IconPencil size={16} />}
onClick={() => beginEditingIntegration(integration.id)}
aria-label={tItem("integration.editLabel", { name: integration.name })}
>
{tItem("integration.action")}
</Button>
) : (
<Text size="xs" c="dimmed" ta="end">
{tItem("integration.fullAccessRequired")}
</Text>
)}
</Group>
);
})}
</>
)}
{displayedIntegrationId !== null && selectedIntegrations.length > 1 && (
<Button
type="button"
variant="subtle"
leftSection={<IconArrowLeft size={16} />}
onClick={() => setActiveIntegrationId(null)}
style={{ alignSelf: "start" }}
>
{tCommon("action.previous")}
</Button>
)}
{integrationIdsToRender.map((integrationId) => (
<Box key={integrationId} hidden={displayedIntegrationId !== integrationId}>
{IntegrationEditForm && (
<IntegrationEditForm
integrationId={integrationId}
handleRef={getIntegrationEditRef(integrationId)}
onSuccess={innerProps.onIntegrationSaved}
/>
)}
</Box>
))}
</Stack>
</Tabs.Panel>
)}
</Tabs>
) : (
widgetFormContent
)}
</Box>
<Group className={classes.footer} justify="space-between" wrap="wrap">
<Button <Button
variant="subtle" variant="subtle"
type="button" type="button"
onClick={() => onClick={() =>
openModal({ openModal({
advancedOptions, advancedOptions,
onSuccess: setAdvancedOptions, onSuccess: (value) => {
setAdvancedOptions(value);
applyPreview({ ...form.getValues(), advancedOptions: value });
},
}) })
} }
> >
{tItem("advancedOptions.label")} {tItem("advancedOptions.label")}
</Button> </Button>
} <Group className={classes.footerActions} gap="xs" justify="flex-end" wrap="wrap">
/> <Button type="button" onClick={actions.closeModal} variant="subtle" color="gray">
)} {tCommon("action.cancel")}
</Stack> </Button>
); <Button type="submit" form={widgetFormId} loading={isSubmitting}>
{tCommon("action.saveChanges")}
return ( </Button>
<form onSubmit={onFormSubmit}> </Group>
<FormProvider form={form}> </Group>
{showResourceTabs ? ( </Stack>
<Stack>
<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">
{widgetFormContent}
</Tabs.Panel>
{showAppTab && (
<Tabs.Panel value="app" pt="md">
{appId && <EmbeddedAppEditForm appId={appId} handleRef={appEditRef} />}
</Tabs.Panel>
)}
{showIntegrationTab && (
<Tabs.Panel value="integration" pt="md">
<Stack>
{displayedIntegrationId === null && (
<>
<Text size="sm" c="dimmed">
{tItem("integration.description")}
</Text>
{selectedIntegrations.map((integration) => {
const canEdit = editableIntegrationIds.includes(integration.id);
return (
<Group key={integration.id} justify="space-between" wrap="nowrap" gap="sm">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<IntegrationAvatar kind={integration.kind} size="sm" />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={600} size="sm" truncate>
{integration.name}
</Text>
<Text size="xs" c="dimmed" truncate>
{integration.url}
</Text>
</Stack>
</Group>
{canEdit ? (
<Button
type="button"
variant="light"
leftSection={<IconPencil size={16} />}
onClick={() => beginEditingIntegration(integration.id)}
aria-label={tItem("integration.editLabel", { name: integration.name })}
>
{tItem("integration.action")}
</Button>
) : (
<Text size="xs" c="dimmed" ta="end">
{tItem("integration.fullAccessRequired")}
</Text>
)}
</Group>
);
})}
</>
)}
{displayedIntegrationId !== null && selectedIntegrations.length > 1 && (
<Button
type="button"
variant="subtle"
leftSection={<IconArrowLeft size={16} />}
onClick={() => setActiveIntegrationId(null)}
style={{ alignSelf: "start" }}
>
{tCommon("action.previous")}
</Button>
)}
{integrationIdsToRender.map((integrationId) => (
<Box key={integrationId} hidden={displayedIntegrationId !== integrationId}>
{IntegrationEditForm && (
<IntegrationEditForm
integrationId={integrationId}
handleRef={getIntegrationEditRef(integrationId)}
onSuccess={innerProps.onIntegrationSaved}
/>
)}
</Box>
))}
</Stack>
</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
)}
</FormProvider> </FormProvider>
</form> </>
); );
}).withOptions({ }).withOptions({
keepMounted: true, keepMounted: true,
defaultTitle(t) { defaultTitle(t) {
return t("item.edit.title"); return t("item.edit.title");
}, },
presentation: "inspector",
withOverlay: false,
size: modalSizeForm, size: modalSizeForm,
closeOnClickOutside: false, closeOnClickOutside: false,
}); });