feat(board): add widget preview, floating checklist and multi-select apps
- Show a live widget preview panel next to the grid in the add-widget modal, sizing from the widget default size and using matching integrations - Pin the board setup checklist below the app header with Affix - Allow selecting and adding multiple apps at once from the board
This commit is contained in:
7 files changed
+375
-173
No files matched your search
@@ -1,10 +1,6 @@
|
||||
.root {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
bottom: calc(var(--mantine-spacing-md) * var(--board-canvas-inverse-scale, 1));
|
||||
left: calc(var(--mantine-spacing-md) * var(--board-canvas-inverse-scale, 1));
|
||||
max-width: min(24rem, calc(100% - 2 * var(--mantine-spacing-md)));
|
||||
pointer-events: auto;
|
||||
max-width: min(24rem, calc(100vw - 2rem));
|
||||
}
|
||||
|
||||
.panel {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ActionIcon, Button, Group, Paper, Progress, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon, Affix, Button, Group, Paper, Progress, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
|
||||
import { useLocalStorage } from "@mantine/hooks";
|
||||
import { IconCheck, IconChevronDown, IconChecklist } from "@tabler/icons-react";
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useSetupAnalytics } from "~/components/create/setup-analytics";
|
||||
import { appShellHeaderHeight } from "~/components/layout/constants";
|
||||
import { getBoardSetupProgress } from "./board-setup-progress";
|
||||
import { useBoardAddActions } from "./use-board-add-actions";
|
||||
import classes from "./board-setup-checklist.module.css";
|
||||
@@ -34,7 +35,7 @@ export const BoardSetupChecklist = () => {
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Affix position={{ top: appShellHeaderHeight + 16, left: 16 }} className={classes.root} withinPortal zIndex={300}>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<IconChecklist size={18} />}
|
||||
@@ -45,12 +46,12 @@ export const BoardSetupChecklist = () => {
|
||||
>
|
||||
{t("resume", { completed: progress.completedCount, total: progress.totalCount })}
|
||||
</Button>
|
||||
</div>
|
||||
</Affix>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Affix position={{ top: appShellHeaderHeight + 16, left: 16 }} className={classes.root} withinPortal zIndex={300}>
|
||||
<Paper className={classes.panel} withBorder shadow="md" radius="md" p="md">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
@@ -93,7 +94,7 @@ export const BoardSetupChecklist = () => {
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</div>
|
||||
</Affix>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Avatar, Badge, Box, Button, Center, Divider, Group, Image, Select, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Divider,
|
||||
Flex,
|
||||
Group,
|
||||
Image,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IconApi } from "@tabler/icons-react";
|
||||
|
||||
@@ -31,6 +45,7 @@ import { WorkshopInstallButton } from "~/components/workshop/workshop-install-bu
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import { useSetupAnalytics } from "~/components/create/setup-analytics";
|
||||
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { WidgetPreviewPanel } from "./widget-preview-panel";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import type { WidgetConnectionStatus } from "./item-select-data";
|
||||
import {
|
||||
@@ -96,6 +111,7 @@ const ItemSelectModalContent = ({
|
||||
);
|
||||
const [targetSectionId, setTargetSectionId] = useState<string | null>(placementOptions[0]?.value ?? null);
|
||||
const [loadingSelection, setLoadingSelection] = useState<string | null>(null);
|
||||
const [previewKind, setPreviewKind] = useState<WidgetKind | null>(null);
|
||||
const selectionLock = useRef(false);
|
||||
const { createItem, removeItem } = useItemActions();
|
||||
|
||||
@@ -115,6 +131,15 @@ const ItemSelectModalContent = ({
|
||||
[integrationData],
|
||||
);
|
||||
|
||||
const previewIntegrationIds = useMemo(() => {
|
||||
if (!previewKind) return [];
|
||||
const supported = (widgetIntegrationSupport[previewKind] ?? []).filter((integration) => integration !== "mock");
|
||||
return (integrationData ?? [])
|
||||
.filter(({ permissions }) => permissions.hasUseAccess)
|
||||
.filter(({ kind }) => (supported as readonly string[]).includes(kind))
|
||||
.map(({ id }) => id);
|
||||
}, [integrationData, previewKind]);
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
widgetKinds
|
||||
@@ -364,100 +389,115 @@ const ItemSelectModalContent = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<SelectGridLayout
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder={`${t("item.create.search")}...`}
|
||||
ariaLabel={t("item.create.search")}
|
||||
onSearchKeyDown={(event) => {
|
||||
if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) {
|
||||
const [item] = filteredItems;
|
||||
if (item) void handleAdd(item.kind);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{placementOptions.length > 1 && (
|
||||
<Box style={{ gridColumn: "1 / -1" }}>
|
||||
<Select
|
||||
label={t("item.create.destination.label")}
|
||||
description={t("item.create.destination.description")}
|
||||
data={placementOptions}
|
||||
value={targetSectionId}
|
||||
onChange={setTargetSectionId}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{filteredItems.map((item) => (
|
||||
<WidgetItem
|
||||
key={item.kind}
|
||||
item={item}
|
||||
onSelect={() => void handleAdd(item.kind)}
|
||||
onIntent={() => {
|
||||
void loadWidgetDefinition(item.kind).catch(() => undefined);
|
||||
preloadWidgetEditModal();
|
||||
<Flex gap="md" align="stretch">
|
||||
<Box style={{ flex: "1 1 0%", minWidth: 0 }}>
|
||||
<SelectGridLayout
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder={`${t("item.create.search")}...`}
|
||||
ariaLabel={t("item.create.search")}
|
||||
onSearchKeyDown={(event) => {
|
||||
if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) {
|
||||
const [item] = filteredItems;
|
||||
if (item) void handleAdd(item.kind);
|
||||
}
|
||||
}}
|
||||
disabled={loadingSelection !== null}
|
||||
loading={loadingSelection === item.kind}
|
||||
connectionStatus={getWidgetConnectionStatus({
|
||||
supportedIntegrations: item.supportedIntegrations,
|
||||
availableKinds,
|
||||
connectionOptional: widgetKindsWithOptionalIntegrations.has(item.kind),
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Divider
|
||||
label={t("customWidget.page.list.title")}
|
||||
labelPosition="center"
|
||||
my="sm"
|
||||
style={{ gridColumn: "1 / -1" }}
|
||||
/>
|
||||
{filteredCustomWidgets.map((def) => (
|
||||
<CatalogItem
|
||||
key={def.id}
|
||||
height={selectGridCardHeight}
|
||||
label={def.name}
|
||||
status={t("item.create.connectionStatus.noConnectionRequired")}
|
||||
>
|
||||
{placementOptions.length > 1 && (
|
||||
<Box style={{ gridColumn: "1 / -1" }}>
|
||||
<Select
|
||||
label={t("item.create.destination.label")}
|
||||
description={t("item.create.destination.description")}
|
||||
data={placementOptions}
|
||||
value={targetSectionId}
|
||||
onChange={setTargetSectionId}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{filteredItems.map((item) => (
|
||||
<WidgetItem
|
||||
key={item.kind}
|
||||
item={item}
|
||||
onSelect={() => void handleAdd(item.kind)}
|
||||
onFocus={() => {
|
||||
void loadWidgetDefinition(item.kind).catch(() => undefined);
|
||||
preloadWidgetEditModal();
|
||||
setPreviewKind(item.kind);
|
||||
}}
|
||||
onPointerEnter={() => {
|
||||
void loadWidgetDefinition(item.kind).catch(() => undefined);
|
||||
preloadWidgetEditModal();
|
||||
setPreviewKind(item.kind);
|
||||
}}
|
||||
disabled={loadingSelection !== null}
|
||||
busy={loadingSelection === `custom:${def.id}`}
|
||||
onSelect={() => void handleAddCustomWidget(def)}
|
||||
onFocus={() => void loadWidgetDefinition("customApi").catch(() => undefined)}
|
||||
onPointerEnter={() => void loadWidgetDefinition("customApi").catch(() => undefined)}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
{def.iconUrl ? (
|
||||
<Image src={def.iconUrl} w={22} h={22} fit="contain" style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
) : (
|
||||
<IconApi size={22} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
)}
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
|
||||
{def.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
|
||||
{def.description ?? ""}
|
||||
</Text>
|
||||
<ConnectionStatusBadge status="noConnectionRequired" />
|
||||
</Stack>
|
||||
</CatalogItem>
|
||||
loading={loadingSelection === item.kind}
|
||||
connectionStatus={getWidgetConnectionStatus({
|
||||
supportedIntegrations: item.supportedIntegrations,
|
||||
availableKinds,
|
||||
connectionOptional: widgetKindsWithOptionalIntegrations.has(item.kind),
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Box style={{ gridColumn: "1 / -1" }}>
|
||||
<WorkshopInstallButton fullWidth>{t("workshop.installDialog")}</WorkshopInstallButton>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Divider
|
||||
label={t("customWidget.page.list.title")}
|
||||
labelPosition="center"
|
||||
my="sm"
|
||||
style={{ gridColumn: "1 / -1" }}
|
||||
/>
|
||||
{filteredCustomWidgets.map((def) => (
|
||||
<CatalogItem
|
||||
key={def.id}
|
||||
height={selectGridCardHeight}
|
||||
label={def.name}
|
||||
status={t("item.create.connectionStatus.noConnectionRequired")}
|
||||
disabled={loadingSelection !== null}
|
||||
busy={loadingSelection === `custom:${def.id}`}
|
||||
onSelect={() => void handleAddCustomWidget(def)}
|
||||
onFocus={() => void loadWidgetDefinition("customApi").catch(() => undefined)}
|
||||
onPointerEnter={() => void loadWidgetDefinition("customApi").catch(() => undefined)}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
{def.iconUrl ? (
|
||||
<Image src={def.iconUrl} w={22} h={22} fit="contain" style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
) : (
|
||||
<IconApi size={22} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
)}
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
|
||||
{def.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
|
||||
{def.description ?? ""}
|
||||
</Text>
|
||||
<ConnectionStatusBadge status="noConnectionRequired" />
|
||||
</Stack>
|
||||
</CatalogItem>
|
||||
))}
|
||||
|
||||
{filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && (
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("common.noResults")}</Text>
|
||||
</Center>
|
||||
<Box style={{ gridColumn: "1 / -1" }}>
|
||||
<WorkshopInstallButton fullWidth>{t("workshop.installDialog")}</WorkshopInstallButton>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && (
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("common.noResults")}</Text>
|
||||
</Center>
|
||||
)}
|
||||
</SelectGridLayout>
|
||||
</Box>
|
||||
{previewKind && (
|
||||
<Box w={320} style={{ flexShrink: 0 }}>
|
||||
<WidgetPreviewPanel kind={previewKind} integrationIds={previewIntegrationIds} />
|
||||
</Box>
|
||||
)}
|
||||
</SelectGridLayout>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
const ItemSelectModalFrame = ({
|
||||
@@ -509,7 +549,8 @@ export const ItemSelectModal = createModal<{
|
||||
const WidgetItem = ({
|
||||
item,
|
||||
onSelect,
|
||||
onIntent,
|
||||
onFocus,
|
||||
onPointerEnter,
|
||||
disabled,
|
||||
loading,
|
||||
connectionStatus,
|
||||
@@ -522,7 +563,8 @@ const WidgetItem = ({
|
||||
icon: TablerIcon;
|
||||
};
|
||||
onSelect: () => void;
|
||||
onIntent: () => void;
|
||||
onFocus: () => void;
|
||||
onPointerEnter: () => void;
|
||||
disabled: boolean;
|
||||
loading: boolean;
|
||||
connectionStatus: WidgetConnectionStatus;
|
||||
@@ -535,8 +577,8 @@ const WidgetItem = ({
|
||||
height={selectGridCardHeight}
|
||||
label={item.name}
|
||||
status={statusLabel}
|
||||
onFocus={onIntent}
|
||||
onPointerEnter={onIntent}
|
||||
onFocus={onFocus}
|
||||
onPointerEnter={onPointerEnter}
|
||||
onSelect={onSelect}
|
||||
disabled={disabled}
|
||||
busy={loading}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, use } from "react";
|
||||
import { Center, Loader, Paper, Stack, Text } from "@mantine/core";
|
||||
import { QueryErrorResetBoundary } from "@tanstack/react-query";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { widgetDefaultSizes } from "@homarr/definitions";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
const PREVIEW_CELL_SIZE = 56;
|
||||
const PREVIEW_MIN_HEIGHT = 120;
|
||||
const PREVIEW_MAX_HEIGHT = 260;
|
||||
|
||||
export const getPreviewSize = (kind: WidgetKind) => {
|
||||
const defaultSize = widgetDefaultSizes[kind] ?? { width: 1, height: 1 };
|
||||
return {
|
||||
width: Math.max(140, defaultSize.width * PREVIEW_CELL_SIZE),
|
||||
height: Math.min(PREVIEW_MAX_HEIGHT, Math.max(PREVIEW_MIN_HEIGHT, defaultSize.height * PREVIEW_CELL_SIZE)),
|
||||
};
|
||||
};
|
||||
|
||||
interface WidgetPreviewProps {
|
||||
kind: WidgetKind;
|
||||
integrationIds: string[];
|
||||
}
|
||||
|
||||
export const WidgetPreviewPanel = ({ kind, integrationIds }: WidgetPreviewProps) => {
|
||||
const t = useI18n();
|
||||
const { width, height } = getPreviewSize(kind);
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="md" p="sm" shadow="sm" h="100%" style={{ overflow: "hidden" }}>
|
||||
<Text size="xs" c="dimmed" mb="xs" fw={600} tt="uppercase">
|
||||
{t("item.create.preview.label")}
|
||||
</Text>
|
||||
<Center h={`calc(100% - 26px)`} style={{ pointerEvents: "none" }}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Center h={height}>
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
}
|
||||
>
|
||||
<WidgetPreviewContent kind={kind} integrationIds={integrationIds} width={width} height={height} />
|
||||
</Suspense>
|
||||
</Center>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const WidgetPreviewContent = ({
|
||||
kind,
|
||||
integrationIds,
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
kind: WidgetKind;
|
||||
integrationIds: string[];
|
||||
width: number;
|
||||
height: number;
|
||||
}) => {
|
||||
const settings = useSettings();
|
||||
const { definition, Component } = use(loadWidgetResources(kind));
|
||||
const options = reduceWidgetOptionsWithDefinition(definition, settings, {});
|
||||
|
||||
return (
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
fallbackRender={({ error, resetErrorBoundary }) => (
|
||||
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
)}
|
||||
>
|
||||
<Stack w={width} h={height} style={{ overflow: "hidden" }}>
|
||||
<Component
|
||||
options={options as never}
|
||||
integrationIds={integrationIds}
|
||||
width={width}
|
||||
height={height}
|
||||
isEditMode={false}
|
||||
boardId={undefined}
|
||||
itemId={undefined}
|
||||
setOptions={() => undefined}
|
||||
/>
|
||||
</Stack>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>
|
||||
);
|
||||
};
|
||||
@@ -26,7 +26,9 @@ export const useBoardAddActions = () => {
|
||||
const addApp = useCallback(
|
||||
() =>
|
||||
openAppSelectModal({
|
||||
onSelect: (app) => createItem({ kind: "app", options: { appId: app.id } }),
|
||||
onSelectMany: (apps) => {
|
||||
apps.forEach((app) => createItem({ kind: "app", options: { appId: app.id } }));
|
||||
},
|
||||
withCreate: canCreateApp,
|
||||
}),
|
||||
[canCreateApp, createItem, openAppSelectModal],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Center, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import { Button, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconCheck, IconPlus, IconApps } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -10,16 +10,21 @@ import { CatalogItem, SelectGridLayout, selectGridCardHeight } from "@homarr/ui"
|
||||
|
||||
import { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";
|
||||
|
||||
type SelectableApp = RouterOutputs["app"]["selectable"][number];
|
||||
|
||||
interface AppSelectModalProps {
|
||||
onSelect?: (app: RouterOutputs["app"]["selectable"][number]) => void;
|
||||
onSelect?: (app: SelectableApp) => void;
|
||||
onSelectMany?: (apps: SelectableApp[]) => void;
|
||||
withCreate: boolean;
|
||||
}
|
||||
|
||||
export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, innerProps }) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedAppIds, setSelectedAppIds] = useState<Set<string>>(new Set());
|
||||
const t = useI18n();
|
||||
const { data: apps = [], isPending } = clientApi.app.selectable.useQuery();
|
||||
const { openModal: openQuickAddAppModal } = useModalAction(QuickAddAppModal);
|
||||
const multiSelect = Boolean(innerProps.onSelectMany);
|
||||
|
||||
const filteredApps = useMemo(
|
||||
() =>
|
||||
@@ -29,14 +34,45 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
|
||||
[apps, search],
|
||||
);
|
||||
|
||||
const handleSelect = (app: RouterOutputs["app"]["selectable"][number]) => {
|
||||
const selectedApps = useMemo(
|
||||
() => filteredApps.filter((app) => selectedAppIds.has(app.id)),
|
||||
[filteredApps, selectedAppIds],
|
||||
);
|
||||
|
||||
const handleSelect = (app: SelectableApp) => {
|
||||
if (multiSelect) {
|
||||
setSelectedAppIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(app.id)) next.delete(app.id);
|
||||
else next.add(app.id);
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
innerProps.onSelect?.(app);
|
||||
actions.closeModal();
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (multiSelect) {
|
||||
innerProps.onSelectMany?.(selectedApps);
|
||||
actions.closeModal();
|
||||
return;
|
||||
}
|
||||
const [first] = filteredApps;
|
||||
if (first) {
|
||||
innerProps.onSelect?.(first);
|
||||
actions.closeModal();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddNewApp = () => {
|
||||
openQuickAddAppModal({
|
||||
onClose(app) {
|
||||
if (multiSelect) {
|
||||
setSelectedAppIds((current) => new Set(current).add(app.id));
|
||||
return;
|
||||
}
|
||||
innerProps.onSelect?.(app);
|
||||
actions.closeModal();
|
||||
},
|
||||
@@ -44,74 +80,97 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
|
||||
};
|
||||
|
||||
return (
|
||||
<SelectGridLayout
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder={`${t("app.action.select.search")}...`}
|
||||
ariaLabel={t("app.action.select.search")}
|
||||
onSearchKeyDown={(event) => {
|
||||
if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) {
|
||||
handleSelect(filteredApps[0]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{innerProps.withCreate && (
|
||||
<CatalogItem
|
||||
height={selectGridCardHeight}
|
||||
label={t("app.action.create.title")}
|
||||
status={t("app.action.create.action")}
|
||||
onSelect={handleAddNewApp}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<IconPlus size={22} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
|
||||
{t("app.action.create.title")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
|
||||
{t("app.action.create.description")}
|
||||
</Text>
|
||||
<Text size="xs" c="blue" fw={500} mt="auto">
|
||||
{t("app.action.create.action")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</CatalogItem>
|
||||
)}
|
||||
|
||||
{filteredApps.map((app) => (
|
||||
<CatalogItem
|
||||
key={app.id}
|
||||
height={selectGridCardHeight}
|
||||
label={app.name}
|
||||
status={t("app.action.select.action", { app: app.name })}
|
||||
onSelect={() => handleSelect(app)}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<img src={app.iconUrl} alt={app.name} width={22} height={22} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
|
||||
{app.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Tooltip label={app.description} multiline w={250} disabled={!app.description}>
|
||||
<>
|
||||
<SelectGridLayout
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder={`${t("app.action.select.search")}...`}
|
||||
ariaLabel={t("app.action.select.search")}
|
||||
onSearchKeyDown={(event) => {
|
||||
if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) {
|
||||
handleSelect(filteredApps[0]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{innerProps.withCreate && (
|
||||
<CatalogItem
|
||||
height={selectGridCardHeight}
|
||||
label={t("app.action.create.title")}
|
||||
status={t("app.action.create.action")}
|
||||
onSelect={handleAddNewApp}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<IconPlus size={22} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
|
||||
{t("app.action.create.title")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
|
||||
{app.description ?? ""}
|
||||
{t("app.action.create.description")}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<Text size="xs" c="blue" fw={500} mt="auto">
|
||||
{t("app.action.select.action", { app: app.name })}
|
||||
</Text>
|
||||
</Stack>
|
||||
</CatalogItem>
|
||||
))}
|
||||
<Text size="xs" c="blue" fw={500} mt="auto">
|
||||
{t("app.action.create.action")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</CatalogItem>
|
||||
)}
|
||||
|
||||
{filteredApps.length === 0 && !isPending && (
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("app.action.select.noResults")}</Text>
|
||||
</Center>
|
||||
{filteredApps.map((app) => (
|
||||
<CatalogItem
|
||||
key={app.id}
|
||||
height={selectGridCardHeight}
|
||||
label={app.name}
|
||||
status={
|
||||
multiSelect
|
||||
? selectedAppIds.has(app.id)
|
||||
? t("app.action.select.selected")
|
||||
: t("app.action.select.toggle")
|
||||
: t("app.action.select.action", { app: app.name })
|
||||
}
|
||||
selected={multiSelect ? selectedAppIds.has(app.id) : undefined}
|
||||
onSelect={() => handleSelect(app)}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<img src={app.iconUrl} alt={app.name} width={22} height={22} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
|
||||
{app.name}
|
||||
</Text>
|
||||
{multiSelect && selectedAppIds.has(app.id) && (
|
||||
<IconCheck size={18} color="var(--mantine-primary-color-filled)" />
|
||||
)}
|
||||
</Group>
|
||||
<Tooltip label={app.description} multiline w={250} disabled={!app.description}>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
|
||||
{app.description ?? ""}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<Text size="xs" c="blue" fw={500} mt="auto">
|
||||
{multiSelect
|
||||
? selectedAppIds.has(app.id)
|
||||
? t("app.action.select.selected")
|
||||
: t("app.action.select.toggle")
|
||||
: t("app.action.select.action", { app: app.name })}
|
||||
</Text>
|
||||
</Stack>
|
||||
</CatalogItem>
|
||||
))}
|
||||
|
||||
{filteredApps.length === 0 && !isPending && (
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("app.action.select.noResults")}</Text>
|
||||
</Center>
|
||||
)}
|
||||
</SelectGridLayout>
|
||||
{multiSelect && (
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button leftSection={<IconApps size={16} />} disabled={selectedAppIds.size === 0} onClick={handleSubmit}>
|
||||
{t("app.action.select.multiple", { count: String(selectedAppIds.size) })}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</SelectGridLayout>
|
||||
</>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => t("app.action.select.title"),
|
||||
|
||||
@@ -847,7 +847,10 @@
|
||||
"search": "Search for an app",
|
||||
"noResults": "No results",
|
||||
"action": "Select {app}",
|
||||
"title": "Select an app to add to this board"
|
||||
"title": "Select an app to add to this board",
|
||||
"toggle": "Click to select",
|
||||
"selected": "Selected",
|
||||
"multiple": "Add {count}"
|
||||
},
|
||||
"create": {
|
||||
"title": "Create new app",
|
||||
@@ -2746,6 +2749,9 @@
|
||||
"title": "Unable to add item",
|
||||
"message": "The item could not be added to this board"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"label": "Live preview"
|
||||
}
|
||||
},
|
||||
"moveResize": {
|
||||
|
||||
Reference in new issue
Block a user