This commit is contained in:
1 parent
e3a713d2df
commit
e842177d9b
163 files changed
+7390
-1240
No files matched your search
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { createModal, modalSizeForm } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
|
||||
import { EditIntegrationForm } from "~/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form";
|
||||
|
||||
export interface EmbeddedIntegrationEditModalProps {
|
||||
integrationId: string;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export const EmbeddedIntegrationEditModal = createModal<EmbeddedIntegrationEditModalProps>(
|
||||
({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const {
|
||||
data: integration,
|
||||
isPending,
|
||||
isError,
|
||||
refetch,
|
||||
} = clientApi.integration.byId.useQuery({
|
||||
id: innerProps.integrationId,
|
||||
});
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<Stack gap="sm" aria-busy="true" aria-label={t("item.edit.integration.loading")}>
|
||||
<Skeleton height={36} width="60%" />
|
||||
<Skeleton height={60} />
|
||||
<Skeleton height={120} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !integration) {
|
||||
return (
|
||||
<Alert color="red" title={t("common.error")}>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{t("item.edit.integration.loadError")}</Text>
|
||||
<Button variant="light" color="red" onClick={() => void refetch()}>
|
||||
{t("common.action.tryAgain")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<IntegrationAvatar kind={integration.kind} size="sm" />
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text fw={600} truncate>
|
||||
{integration.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{getIntegrationName(integration.kind)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
||||
{t("item.edit.integration.propagationNotice")}
|
||||
</Alert>
|
||||
<EditIntegrationForm
|
||||
integration={integration}
|
||||
embedded
|
||||
onSuccess={() => {
|
||||
innerProps.onSuccess?.();
|
||||
actions.closeModal();
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
).withOptions({
|
||||
defaultTitle: (t) => t("item.edit.tab.integration"),
|
||||
size: modalSizeForm,
|
||||
presentation: "inspector",
|
||||
closeOnClickOutside: true,
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import { ActionIcon, Button, Divider, Group, List, Stack, Text, ThemeIcon, Title, Tooltip } from "@mantine/core";
|
||||
import { useLocalStorage } from "@mantine/hooks";
|
||||
import { IconAppWindow, IconCheck, IconLayoutDashboard, IconPlugConnected, IconX } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||
import { useOptionalBoard } from "@homarr/boards/context";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { getIntegrationName, getWidgetKindsForIntegration } from "@homarr/definitions";
|
||||
import { createModal, modalSizeForm, useModalAction } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { CreatedIntegrationResult } from "~/app/[locale]/manage/integrations/new/_integration-new-form";
|
||||
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
||||
import {
|
||||
getBoardRecipeDismissalKey,
|
||||
getBoardRecipeRecommendations,
|
||||
} from "~/components/board/items/board-recipe-recommendations";
|
||||
import { useSetupAnalytics } from "~/components/create/setup-analytics";
|
||||
|
||||
interface IntegrationCompletionModalProps {
|
||||
result: CreatedIntegrationResult;
|
||||
boardId?: string;
|
||||
}
|
||||
|
||||
export const IntegrationCompletionModal = createModal<IntegrationCompletionModalProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const board = useOptionalBoard();
|
||||
const { data: session } = useSession();
|
||||
const { data: integrations = [] } = clientApi.integration.all.useQuery();
|
||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||
const trackSetup = useSetupAnalytics();
|
||||
const compatibleWidgets = getWidgetKindsForIntegration(innerProps.result.integration.kind);
|
||||
const boardMatchesContext = board !== null && board.id === innerProps.boardId;
|
||||
const canModifyBoard = boardMatchesContext && constructBoardPermissions(board, session).hasChangeAccess;
|
||||
const dismissalKey = getBoardRecipeDismissalKey(
|
||||
innerProps.boardId ?? "no-board-context",
|
||||
innerProps.result.integration.kind,
|
||||
);
|
||||
const [recommendationsDismissed, setRecommendationsDismissed] = useLocalStorage({
|
||||
key: dismissalKey,
|
||||
defaultValue: false,
|
||||
});
|
||||
const configuredIntegrationKinds = [
|
||||
...new Set<IntegrationKind>([
|
||||
...integrations.map((integration) => integration.kind),
|
||||
innerProps.result.integration.kind,
|
||||
]),
|
||||
];
|
||||
const recommendations = boardMatchesContext
|
||||
? getBoardRecipeRecommendations({
|
||||
configuredIntegrationKinds,
|
||||
existingItemKinds: board.items.map((item) => item.kind),
|
||||
preferredIntegrationKind: innerProps.result.integration.kind,
|
||||
})
|
||||
: [];
|
||||
const showRecommendations = canModifyBoard && !recommendationsDismissed && recommendations.length > 0;
|
||||
|
||||
const addWidget = () => {
|
||||
if (!innerProps.boardId) return;
|
||||
actions.closeModal();
|
||||
openItemSelectModal({
|
||||
boardId: innerProps.boardId,
|
||||
initialIntegrationKind: innerProps.result.integration.kind,
|
||||
});
|
||||
};
|
||||
|
||||
const addRecommendedWidget = (widgetKind: (typeof recommendations)[number]["widgetKind"]) => {
|
||||
if (!innerProps.boardId) return;
|
||||
trackSetup("completion-recipe-selected", {
|
||||
entryPoint: "board",
|
||||
intent: "add-compatible-widget",
|
||||
outcome: "continued",
|
||||
hasBoardContext: true,
|
||||
});
|
||||
actions.closeModal();
|
||||
openItemSelectModal({ boardId: innerProps.boardId, initialWidgetKind: widgetKind });
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon color="green" variant="light" size="xl" radius="xl">
|
||||
<IconCheck size={22} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
<Title order={3} size="h4">
|
||||
{t("integration.completion.title", { name: innerProps.result.integration.name })}
|
||||
</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("integration.completion.description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<List
|
||||
spacing="sm"
|
||||
icon={
|
||||
<ThemeIcon color="green" size="sm" radius="xl">
|
||||
<IconCheck size={12} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
>
|
||||
<List.Item>
|
||||
<Group gap="xs">
|
||||
<IconPlugConnected size={17} />
|
||||
<Text size="sm">{t("integration.completion.connectionReady")}</Text>
|
||||
</Group>
|
||||
</List.Item>
|
||||
{innerProps.result.appId && (
|
||||
<List.Item>
|
||||
<Group gap="xs">
|
||||
<IconAppWindow size={17} />
|
||||
<Text size="sm">{t("integration.completion.appReady")}</Text>
|
||||
</Group>
|
||||
</List.Item>
|
||||
)}
|
||||
<List.Item>
|
||||
<Group gap="xs">
|
||||
<IconLayoutDashboard size={17} />
|
||||
<Text size="sm">
|
||||
{t("integration.completion.compatibleWidgets", { count: String(compatibleWidgets.length) })}
|
||||
</Text>
|
||||
</Group>
|
||||
</List.Item>
|
||||
</List>
|
||||
|
||||
{showRecommendations && (
|
||||
<Stack gap="xs">
|
||||
<Divider />
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text fw={600} size="sm">
|
||||
{t("integration.completion.recipes.title")}
|
||||
</Text>
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("integration.completion.recipes.description")}
|
||||
</Text>
|
||||
</div>
|
||||
<Tooltip label={t("integration.completion.recipes.dismiss")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("integration.completion.recipes.dismiss")}
|
||||
onClick={() => setRecommendationsDismissed(true)}
|
||||
>
|
||||
<IconX size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
{recommendations.map((recommendation) => (
|
||||
<Group key={recommendation.widgetKind} justify="space-between" wrap="nowrap" gap="sm">
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{t(`widget.${recommendation.widgetKind}.name`)}
|
||||
</Text>
|
||||
<Text c="dimmed" size="xs" truncate>
|
||||
{t(
|
||||
recommendation.isNewlyAvailable
|
||||
? "integration.completion.recipes.newConnectionReason"
|
||||
: "integration.completion.recipes.existingConnectionReason",
|
||||
{
|
||||
integration: recommendation.isNewlyAvailable
|
||||
? innerProps.result.integration.name
|
||||
: getIntegrationName(recommendation.integrationKind),
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Button variant="light" size="compact-xs" onClick={() => addRecommendedWidget(recommendation.widgetKind)}>
|
||||
{t("integration.completion.recipes.add")}
|
||||
</Button>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group justify="end">
|
||||
<Button variant="default" onClick={actions.closeModal}>
|
||||
{t("common.action.close")}
|
||||
</Button>
|
||||
{innerProps.boardId && compatibleWidgets.length > 0 && (
|
||||
<Button onClick={addWidget}>{t("integration.completion.chooseWidget")}</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => t("integration.completion.modalTitle"),
|
||||
size: modalSizeForm,
|
||||
});
|
||||
@@ -6,20 +6,25 @@ import { IconArrowLeft } from "@tabler/icons-react";
|
||||
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { createModal, modalSizeSelect } from "@homarr/modals";
|
||||
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
|
||||
import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form";
|
||||
import type { CreatedIntegrationResult } from "~/app/[locale]/manage/integrations/new/_integration-new-form";
|
||||
import { IntegrationSelectGrid } from "./integration-select-grid";
|
||||
import { IntegrationCompletionModal } from "./integration-completion-modal";
|
||||
|
||||
interface IntegrationSelectModalProps {
|
||||
onSuccess?: () => void;
|
||||
onSuccess?: (result?: CreatedIntegrationResult) => void;
|
||||
enableMockIntegration?: boolean;
|
||||
allowedKinds?: readonly IntegrationKind[];
|
||||
completionBoardId?: string;
|
||||
}
|
||||
|
||||
export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>(({ actions, innerProps }) => {
|
||||
const [step, setStep] = useState<"select" | "form">("select");
|
||||
const [selectedKind, setSelectedKind] = useState<IntegrationKind | null>(null);
|
||||
const { openModal: openCompletionModal } = useModalAction(IntegrationCompletionModal);
|
||||
|
||||
const handleSelect = (kind: IntegrationKind) => {
|
||||
setSelectedKind(kind);
|
||||
@@ -31,9 +36,13 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
|
||||
setSelectedKind(null);
|
||||
};
|
||||
|
||||
const handleSuccess = () => {
|
||||
innerProps.onSuccess?.();
|
||||
const handleSuccess = (result?: CreatedIntegrationResult) => {
|
||||
actions.closeModal();
|
||||
if (innerProps.onSuccess) {
|
||||
innerProps.onSuccess(result);
|
||||
} else if (result) {
|
||||
openCompletionModal({ result, boardId: innerProps.completionBoardId });
|
||||
}
|
||||
};
|
||||
|
||||
if (step === "form" && selectedKind) {
|
||||
@@ -49,7 +58,13 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
|
||||
);
|
||||
}
|
||||
|
||||
return <IntegrationSelectGrid onSelect={handleSelect} enableMockIntegration={innerProps.enableMockIntegration} />;
|
||||
return (
|
||||
<IntegrationSelectGrid
|
||||
onSelect={handleSelect}
|
||||
enableMockIntegration={innerProps.enableMockIntegration}
|
||||
allowedKinds={innerProps.allowedKinds}
|
||||
/>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => t("integration.action.create"),
|
||||
size: modalSizeSelect,
|
||||
|
||||
Reference in new issue
Block a user