Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0aa667b8cc | ||
|
|
d3192688c6 | ||
|
|
11626eda8a | ||
|
|
f58c0ac77c | ||
|
|
b3e24f0d34 | ||
|
|
7d742c9efa |
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>
|
||||
</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>
|
||||
<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>
|
||||
<img
|
||||
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>
|
||||
|
||||
As soon as you save your changes, the tile on the board will refresh and display your app.
|
||||
|
||||
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 144 KiB |
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { use, useCallback, useState } from "react";
|
||||
import { use, useCallback, useId, useState } from "react";
|
||||
import { ActionIcon, Affix, Card } from "@mantine/core";
|
||||
import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react";
|
||||
import { QueryErrorResetBoundary } from "@tanstack/react-query";
|
||||
@@ -32,6 +32,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
||||
const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal);
|
||||
const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal);
|
||||
const { definition: currentDefinition, Component } = use(loadWidgetResources(kind));
|
||||
const previewId = useId();
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [isEditorLoading, setIsEditorLoading] = useState(false);
|
||||
const [dimensions, setDimensions] = useState<Dimensions>({
|
||||
@@ -58,6 +59,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
||||
preloadWidgetEditModal();
|
||||
try {
|
||||
const integrationData = hasIntegrationSupport ? await utils.integration.all.ensureData() : [];
|
||||
const originalState = state;
|
||||
openWidgetEditModal(
|
||||
{
|
||||
kind,
|
||||
@@ -66,11 +68,14 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
||||
onSuccessfulEdit: (value) => {
|
||||
setState(value);
|
||||
},
|
||||
onPreviewChange: setState,
|
||||
onPreviewRestore: () => setState(originalState),
|
||||
integrationData: integrationData.filter((integration) =>
|
||||
(currentDefinition.supportedIntegrations ?? []).includes(integration.kind),
|
||||
),
|
||||
integrationSupport: hasIntegrationSupport,
|
||||
settings,
|
||||
focusTargetId: previewId,
|
||||
},
|
||||
{
|
||||
title(translate) {
|
||||
@@ -86,7 +91,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
||||
} finally {
|
||||
setIsEditorLoading(false);
|
||||
}
|
||||
}, [currentDefinition, kind, openWidgetEditModal, settings, state, t, utils]);
|
||||
}, [currentDefinition, kind, openWidgetEditModal, previewId, settings, state, t, utils]);
|
||||
|
||||
const toggleEditMode = useCallback(() => {
|
||||
setEditMode((editMode) => !editMode);
|
||||
@@ -110,7 +115,7 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps
|
||||
|
||||
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>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
|
||||
@@ -30,6 +30,13 @@ interface UpdateItemIntegrations {
|
||||
newIntegrations: string[];
|
||||
}
|
||||
|
||||
interface UpdateItemSettings {
|
||||
itemId: string;
|
||||
options: Record<string, unknown>;
|
||||
advancedOptions: BoardItemAdvancedOptions;
|
||||
integrationIds: string[];
|
||||
}
|
||||
|
||||
export const useItemActions = () => {
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
@@ -77,6 +84,20 @@ export const useItemActions = () => {
|
||||
[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(
|
||||
(input: Omit<MoveAndResizeItemInput, "layoutId">) => {
|
||||
updateBoard(moveAndResizeItemCallback({ ...input, layoutId: currentLayoutId }));
|
||||
@@ -105,6 +126,7 @@ export const useItemActions = () => {
|
||||
updateItemOptions,
|
||||
updateItemAdvancedOptions,
|
||||
updateItemIntegrations,
|
||||
updateItemSettings,
|
||||
duplicateItem,
|
||||
createItem,
|
||||
};
|
||||
|
||||
@@ -297,6 +297,7 @@ const LoadedBoardItemContent = ({
|
||||
role={isPreview ? "region" : undefined}
|
||||
aria-label={isPreview ? advancedViewLabel : undefined}
|
||||
data-advanced-focus-surface={isPreview || undefined}
|
||||
data-advanced-focus-item-id={isAdvanced ? item.id : undefined}
|
||||
data-grid-item-content
|
||||
radius={board.itemRadius}
|
||||
p={isAdvanced ? undefined : 0}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil } from "@tabler/icons-react";
|
||||
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { getWidgetName } from "@homarr/definitions";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
@@ -33,6 +34,7 @@ export const BoardItemMenu = (props: BoardItemMenuProps) => {
|
||||
};
|
||||
const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
|
||||
const { data: session } = useSession();
|
||||
const board = useRequiredBoard();
|
||||
const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
|
||||
const refResetErrorBoundaryOnNextRender = useRef(false);
|
||||
const tItem = useI18n("item");
|
||||
@@ -40,8 +42,7 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
const { openModal } = useModalAction(LazyWidgetEditModal);
|
||||
const openMoveModal = useOpenItemMoveModal();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
||||
useItemActions();
|
||||
const { updateItemSettings, duplicateItem, removeItem } = useItemActions();
|
||||
const { integrations: integrationData, section } = useSectionContext();
|
||||
const settings = useSettings();
|
||||
const label = item.advancedOptions.title?.trim() || getWidgetName(item.kind, t);
|
||||
@@ -55,6 +56,11 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
}, [item, resetErrorBoundary]);
|
||||
|
||||
const openEditModal = () => {
|
||||
const originalSettings = {
|
||||
options: item.options,
|
||||
advancedOptions: item.advancedOptions,
|
||||
integrationIds: item.integrationIds,
|
||||
};
|
||||
openModal(
|
||||
{
|
||||
kind: item.kind,
|
||||
@@ -64,19 +70,14 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
options: item.options,
|
||||
integrationIds: item.integrationIds,
|
||||
},
|
||||
onSuccessfulEdit: ({ options, integrationIds, advancedOptions }) => {
|
||||
updateItemOptions({
|
||||
itemId: item.id,
|
||||
newOptions: options,
|
||||
});
|
||||
updateItemAdvancedOptions({
|
||||
itemId: item.id,
|
||||
newAdvancedOptions: advancedOptions,
|
||||
});
|
||||
updateItemIntegrations({
|
||||
itemId: item.id,
|
||||
newIntegrations: integrationIds,
|
||||
});
|
||||
onPreviewChange: (value) => {
|
||||
updateItemSettings({ itemId: item.id, ...value });
|
||||
},
|
||||
onPreviewRestore: () => {
|
||||
updateItemSettings({ itemId: item.id, ...originalSettings });
|
||||
},
|
||||
onSuccessfulEdit: (value) => {
|
||||
updateItemSettings({ itemId: item.id, ...value });
|
||||
refResetErrorBoundaryOnNextRender.current = true;
|
||||
},
|
||||
onIntegrationSaved: resetErrorBoundary,
|
||||
@@ -89,6 +90,7 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
integrationSupport: "supportedIntegrations" in definition,
|
||||
settings,
|
||||
itemId: item.id,
|
||||
boardId: board.id,
|
||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -277,6 +277,7 @@ const ItemSelectModalContent = ({
|
||||
integrationData: [],
|
||||
integrationSupport: false,
|
||||
settings,
|
||||
boardId: board.id,
|
||||
},
|
||||
{
|
||||
title: (titleT) => `${titleT("item.edit.title")} - ${customWidgetDefinition.name}`,
|
||||
@@ -346,6 +347,7 @@ const ItemSelectModalContent = ({
|
||||
integrationData: availableIntegrations,
|
||||
integrationSupport: hasIntegrationSupport,
|
||||
settings,
|
||||
boardId: board.id,
|
||||
},
|
||||
{
|
||||
title: (titleT) => `${titleT("item.edit.title")} - ${getWidgetName(kind, titleT)}`,
|
||||
|
||||
@@ -157,6 +157,8 @@ export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>
|
||||
defaultTitle(t) {
|
||||
return t("item.edit.title");
|
||||
},
|
||||
presentation: "inspector",
|
||||
withOverlay: false,
|
||||
size: modalSizeForm,
|
||||
closeOnClickOutside: false,
|
||||
});
|
||||
@@ -58,7 +58,7 @@ export const WidgetContextMenu = ({
|
||||
const tCommon = useI18n("common.action");
|
||||
const settings = useSettings();
|
||||
const { openModal } = useModalAction(LazyWidgetEditModal);
|
||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
|
||||
const { updateItemSettings } = useItemActions();
|
||||
const hasSupportedIntegrations = (definition.supportedIntegrations?.length ?? 0) > 0;
|
||||
const { data: integrationData = [], isPending } = clientApi.integration.all.useQuery(undefined, {
|
||||
enabled: hasSupportedIntegrations,
|
||||
@@ -154,19 +154,27 @@ export const WidgetContextMenu = ({
|
||||
}) ?? [];
|
||||
|
||||
const openEditModal = useCallback(() => {
|
||||
const originalSettings = {
|
||||
options: item.options,
|
||||
advancedOptions: item.advancedOptions,
|
||||
integrationIds: item.integrationIds,
|
||||
};
|
||||
openModal(
|
||||
{
|
||||
kind: item.kind,
|
||||
definition,
|
||||
value: {
|
||||
advancedOptions: item.advancedOptions,
|
||||
options: item.options,
|
||||
options,
|
||||
integrationIds: item.integrationIds,
|
||||
},
|
||||
onPreviewChange: (value) => {
|
||||
updateItemSettings({ itemId: item.id, ...value });
|
||||
},
|
||||
onPreviewRestore: () => {
|
||||
updateItemSettings({ itemId: item.id, ...originalSettings });
|
||||
},
|
||||
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) => ({
|
||||
...previous,
|
||||
items: previous.items.map((boardItem) =>
|
||||
@@ -189,6 +197,8 @@ export const WidgetContextMenu = ({
|
||||
),
|
||||
integrationSupport: definition.supportedIntegrations !== undefined,
|
||||
settings,
|
||||
itemId: item.id,
|
||||
boardId: board.id,
|
||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -197,16 +207,16 @@ export const WidgetContextMenu = ({
|
||||
},
|
||||
);
|
||||
}, [
|
||||
board.id,
|
||||
definition,
|
||||
integrationData,
|
||||
item,
|
||||
handleRefetch,
|
||||
openModal,
|
||||
options,
|
||||
persistBoard,
|
||||
settings,
|
||||
updateItemAdvancedOptions,
|
||||
updateItemIntegrations,
|
||||
updateItemOptions,
|
||||
updateItemSettings,
|
||||
]);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
|
||||
|
After Width: | Height: | Size: 298 KiB |
|
After Width: | Height: | Size: 288 KiB |
|
After Width: | Height: | Size: 281 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 589 KiB |
|
After Width: | Height: | Size: 397 KiB |
|
After Width: | Height: | Size: 395 KiB |
|
After Width: | Height: | Size: 327 KiB |
|
After Width: | Height: | Size: 578 KiB |
|
After Width: | Height: | Size: 651 KiB |
@@ -7,9 +7,11 @@
|
||||
.inspectorContent {
|
||||
--modal-content-flex: 0 0 100%;
|
||||
--modal-content-max-height: min(88dvh, 52rem);
|
||||
--modal-size: 100%;
|
||||
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 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-height: 100dvh;
|
||||
--modal-content-max-height: 100dvh;
|
||||
--modal-size: min(34rem, 46vw);
|
||||
|
||||
width: min(34rem, 46vw);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.inspectorInner:has([data-widget-inspector-side="left"]) {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export type CreateModalOptions = Pick<
|
||||
| "centered"
|
||||
| "keepMounted"
|
||||
| "withCloseButton"
|
||||
| "withOverlay"
|
||||
| "zIndex"
|
||||
| "scrollAreaComponent"
|
||||
| "yOffset"
|
||||
|
||||
@@ -17,7 +17,10 @@ export const WidgetAdvancedOptionsModal = createModal<InnerProps>(({ actions, in
|
||||
const t = useI18n("item.edit");
|
||||
const theme = useMantineTheme();
|
||||
const form = useForm({
|
||||
initialValues: innerProps.advancedOptions,
|
||||
initialValues: {
|
||||
...innerProps.advancedOptions,
|
||||
title: innerProps.advancedOptions.title ?? "",
|
||||
},
|
||||
});
|
||||
const handleSubmit = (values: BoardItemAdvancedOptions) => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import type { ComponentType, FormEvent } from "react";
|
||||
import { Box, Button, Group, Stack, Tabs, Text } from "@mantine/core";
|
||||
import { schemaResolver } from "@mantine/form";
|
||||
@@ -11,7 +11,7 @@ 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, modalSizeForm, useModalAction } from "@homarr/modals";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
@@ -26,8 +26,10 @@ 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 { useWidgetEditFocus, WidgetEditFocusOverlay } from "./widget-edit-focus-overlay";
|
||||
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>;
|
||||
@@ -50,11 +52,14 @@ export interface WidgetEditModalProps<TSort extends WidgetKind> {
|
||||
definition: WidgetDefinition;
|
||||
value: WidgetEditModalState;
|
||||
onSuccessfulEdit: (value: WidgetEditModalState) => void;
|
||||
onPreviewChange?: (value: WidgetEditModalState) => void;
|
||||
onPreviewRestore?: () => void;
|
||||
integrationData: IntegrationSelectOption[];
|
||||
integrationSupport: boolean;
|
||||
settings: SettingsContextProps;
|
||||
itemId?: string;
|
||||
boardId?: string;
|
||||
focusTargetId?: string;
|
||||
appId?: string;
|
||||
integrationEditForm?: ComponentType<EmbeddedIntegrationEditFormProps>;
|
||||
onIntegrationSaved?: () => void;
|
||||
@@ -81,6 +86,18 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
const [mountedIntegrationIds, setMountedIntegrationIds] = useState<string[]>([]);
|
||||
const [activeIntegrationId, setActiveIntegrationId] = useState<string | null>(null);
|
||||
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({
|
||||
customError: zodErrorMap(t),
|
||||
@@ -112,25 +129,47 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
if (integrationsRequired) integrationIdsSchema = integrationIdsSchema.min(1);
|
||||
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({
|
||||
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 },
|
||||
),
|
||||
initialValues: {
|
||||
options: innerProps.value.options,
|
||||
integrationIds: innerProps.value.integrationIds,
|
||||
},
|
||||
validate: schemaResolver(widgetFormSchema, { sync: true }),
|
||||
validateInputOnBlur: true,
|
||||
validateInputOnChange: true,
|
||||
onValuesChange: (values) => {
|
||||
if (!widgetFormSchema.safeParse(values).success) return;
|
||||
|
||||
applyPreview({ ...values, advancedOptions });
|
||||
},
|
||||
});
|
||||
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 canConfigureWidget = innerProps.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
|
||||
const appId = innerProps.appId;
|
||||
@@ -210,6 +249,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
...values,
|
||||
advancedOptions,
|
||||
});
|
||||
hasSavedRef.current = true;
|
||||
actions.closeModal();
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
@@ -231,202 +271,202 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
}
|
||||
: 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 = (
|
||||
<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")}
|
||||
/>
|
||||
)}
|
||||
{canConfigureWidget &&
|
||||
Object.entries(options).map(([key, value]) => {
|
||||
const Input = getInputForType(value.type);
|
||||
<form id={widgetFormId} onSubmit={onFormSubmit}>
|
||||
<Stack className={classes.settings} gap="md">
|
||||
{canConfigureWidget && innerProps.integrationSupport && (
|
||||
<WidgetIntegrationSelect
|
||||
label={tItem("field.integrations.label")}
|
||||
data={innerProps.integrationData}
|
||||
canSelectMultiple={maxIntegrations > 1}
|
||||
withAsterisk={integrationsRequired}
|
||||
onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined}
|
||||
{...form.getInputProps("integrationIds")}
|
||||
/>
|
||||
)}
|
||||
{canConfigureWidget && switchOptions.length > 0 && (
|
||||
<Box className={classes.switchGrid}>
|
||||
{switchOptions.map(([key, value]) => {
|
||||
const Input = getInputForType(value.type);
|
||||
if (!Input) return null;
|
||||
|
||||
if (
|
||||
!Input ||
|
||||
value.shouldHide?.(
|
||||
form.values.options as never,
|
||||
innerProps.integrationData
|
||||
.filter(({ id }) => form.values.integrationIds.includes(id))
|
||||
.map(({ kind }) => kind),
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Box key={key} className={classes.switchOption}>
|
||||
<Input
|
||||
kind={innerProps.kind}
|
||||
property={key}
|
||||
options={value as never}
|
||||
initialOptions={innerProps.value.options}
|
||||
itemId={innerProps.itemId}
|
||||
boardId={innerProps.boardId ?? board?.id}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
{canConfigureWidget &&
|
||||
otherOptions.map(([key, value]) => {
|
||||
const Input = getInputForType(value.type);
|
||||
if (!Input) 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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{showResourceTabs ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openModal({
|
||||
advancedOptions,
|
||||
onSuccess: setAdvancedOptions,
|
||||
})
|
||||
}
|
||||
>
|
||||
{tItem("advancedOptions.label")}
|
||||
</Button>
|
||||
) : (
|
||||
<ModalFormFooter
|
||||
onCancel={actions.closeModal}
|
||||
leftSection={
|
||||
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>
|
||||
</form>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<WidgetEditFocusOverlay focusRect={focusRect} />
|
||||
<FormProvider form={form}>
|
||||
<Stack className={classes.panel} data-widget-inspector-side={inspectorSide} gap={0}>
|
||||
<Box pb="md">
|
||||
{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">
|
||||
{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
|
||||
variant="subtle"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openModal({
|
||||
advancedOptions,
|
||||
onSuccess: setAdvancedOptions,
|
||||
onSuccess: (value) => {
|
||||
setAdvancedOptions(value);
|
||||
applyPreview({ ...form.getValues(), advancedOptions: value });
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
{tItem("advancedOptions.label")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={onFormSubmit}>
|
||||
<FormProvider form={form}>
|
||||
{showResourceTabs ? (
|
||||
<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
|
||||
)}
|
||||
<Group className={classes.footerActions} gap="xs" justify="flex-end" wrap="wrap">
|
||||
<Button type="button" onClick={actions.closeModal} variant="subtle" color="gray">
|
||||
{tCommon("action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" form={widgetFormId} loading={isSubmitting}>
|
||||
{tCommon("action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</FormProvider>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}).withOptions({
|
||||
keepMounted: true,
|
||||
defaultTitle(t) {
|
||||
return t("item.edit.title");
|
||||
},
|
||||
presentation: "inspector",
|
||||
withOverlay: false,
|
||||
size: modalSizeForm,
|
||||
closeOnClickOutside: false,
|
||||
});
|
||||