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>
|
<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.
|
||||||
|
|||||||
|
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(
|
||||||
|
|||||||
|
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 {
|
.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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||