feat(custom-widgets): complete Custom JSX v2 (#6390)
This commit is contained in:
1 parent
3a1245cbc5
commit
acdfcbb18e
416 files changed
+37853
-18066
No files matched your search
@@ -1,21 +1,17 @@
|
||||
import type { ComponentType, MutableRefObject } from "react";
|
||||
import { Suspense, use, useRef } from "react";
|
||||
import type { CardProps } from "@mantine/core";
|
||||
import { Badge, Card, Center, Loader } from "@mantine/core";
|
||||
import type { MutableRefObject } from "react";
|
||||
import { useRef } from "react";
|
||||
import { Badge, Card } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
|
||||
import combineClasses from "clsx";
|
||||
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
|
||||
import type { FallbackProps } from "react-error-boundary";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
import { getWidgetQueryKeys } from "@homarr/widgets/definition";
|
||||
import type { WidgetComponentProps, WidgetDefinition } from "@homarr/widgets/definition";
|
||||
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import classes from "../sections/item.module.css";
|
||||
@@ -23,7 +19,6 @@ import { useItemActions } from "./item-actions";
|
||||
import itemContentClasses from "./item-content.module.css";
|
||||
import { BoardItemMenu } from "./item-menu";
|
||||
import { WidgetContextMenu } from "./widget-context-menu";
|
||||
import { removeWidgetDataQueries } from "./widget-query-recovery";
|
||||
|
||||
interface BoardItemContentProps {
|
||||
item: SectionItem;
|
||||
@@ -35,53 +30,6 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
type BoardItemCardProps = CardProps & {
|
||||
item: SectionItem;
|
||||
innerRef: (element: HTMLDivElement | null) => void;
|
||||
};
|
||||
|
||||
const BoardItemCard = ({ item, innerRef, children, ...cardProps }: BoardItemCardProps) => {
|
||||
const board = useRequiredBoard();
|
||||
|
||||
return (
|
||||
<Card
|
||||
{...cardProps}
|
||||
ref={innerRef}
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
`${item.kind}-wrapper`,
|
||||
"grid-stack-item-content",
|
||||
item.advancedOptions.customCssClasses.join(" "),
|
||||
)}
|
||||
radius={board.itemRadius}
|
||||
styles={{
|
||||
root: {
|
||||
"--opacity": board.opacity / 100,
|
||||
containerType: "size",
|
||||
overflow: getOverflowFromKind(item.kind),
|
||||
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
||||
},
|
||||
}}
|
||||
p={0}
|
||||
>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const WidgetDefinitionLoadError = ({
|
||||
error,
|
||||
resetErrorBoundary,
|
||||
item,
|
||||
innerRef,
|
||||
}: FallbackProps & Pick<BoardItemCardProps, "item" | "innerRef">) => (
|
||||
<BoardItemCard item={item} innerRef={innerRef}>
|
||||
<Center h="100%">
|
||||
<WidgetError error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
</Center>
|
||||
</BoardItemCard>
|
||||
);
|
||||
|
||||
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
const { ref, width, height } = useElementSize<HTMLDivElement>();
|
||||
const board = useRequiredBoard();
|
||||
@@ -89,28 +37,29 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<ErrorBoundary
|
||||
resetKeys={[item.kind]}
|
||||
fallbackRender={(fallbackProps) => <WidgetDefinitionLoadError {...fallbackProps} item={item} innerRef={ref} />}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<BoardItemCard item={item} innerRef={ref}>
|
||||
<Center h="100%">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
</BoardItemCard>
|
||||
}
|
||||
<WidgetContextMenu item={item} widgetStateRef={widgetStateRef}>
|
||||
<Card
|
||||
ref={ref}
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
`${item.kind}-wrapper`,
|
||||
"grid-stack-item-content",
|
||||
item.advancedOptions.customCssClasses.join(" "),
|
||||
)}
|
||||
radius={board.itemRadius}
|
||||
styles={{
|
||||
root: {
|
||||
"--opacity": board.opacity / 100,
|
||||
containerType: "size",
|
||||
overflow: getOverflowFromKind(item.kind),
|
||||
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
||||
},
|
||||
}}
|
||||
p={0}
|
||||
>
|
||||
<LoadedBoardItemContent
|
||||
item={item}
|
||||
width={width}
|
||||
height={height}
|
||||
widgetStateRef={widgetStateRef}
|
||||
innerRef={ref}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
<InnerContent item={item} width={width} height={height} widgetStateRef={widgetStateRef} />
|
||||
</Card>
|
||||
</WidgetContextMenu>
|
||||
{item.advancedOptions.title?.trim() && (
|
||||
<Badge
|
||||
pos="absolute"
|
||||
@@ -136,72 +85,40 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
interface LoadedBoardItemContentProps extends Omit<InnerContentProps, "definition" | "Component"> {
|
||||
innerRef: (element: HTMLDivElement | null) => void;
|
||||
}
|
||||
|
||||
const LoadedBoardItemContent = ({ item, innerRef, ...innerContentProps }: LoadedBoardItemContentProps) => {
|
||||
const { definition, Component } = use(loadWidgetResources(item.kind));
|
||||
|
||||
return (
|
||||
<WidgetContextMenu item={item} definition={definition} widgetStateRef={innerContentProps.widgetStateRef}>
|
||||
<BoardItemCard item={item} innerRef={innerRef}>
|
||||
<ErrorBoundary
|
||||
resetKeys={[item.kind]}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
<>
|
||||
<BoardItemMenu offset={4} item={item} definition={definition} resetErrorBoundary={resetErrorBoundary} />
|
||||
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<InnerContent item={item} definition={definition} Component={Component} {...innerContentProps} />
|
||||
</ErrorBoundary>
|
||||
</BoardItemCard>
|
||||
</WidgetContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
interface InnerContentProps {
|
||||
item: SectionItem;
|
||||
width: number;
|
||||
height: number;
|
||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||
definition: WidgetDefinition;
|
||||
Component: ComponentType<WidgetComponentProps<SectionItem["kind"]>>;
|
||||
}
|
||||
|
||||
const InnerContent = ({ item, definition, Component, ...dimensions }: InnerContentProps) => {
|
||||
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
const settings = useSettings();
|
||||
const board = useRequiredBoard();
|
||||
const [isEditMode] = useEditMode();
|
||||
const options = reduceWidgetOptionsWithDefinition(definition, settings, item.options);
|
||||
const Comp = loadWidgetDynamic(item.kind);
|
||||
const { definition } = widgetImports[item.kind];
|
||||
const options = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
|
||||
const newItem = { ...item, options };
|
||||
const { updateItemOptions } = useItemActions();
|
||||
const { removeItem, updateItemOptions } = useItemActions();
|
||||
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
||||
updateItemOptions({ itemId: item.id, newOptions });
|
||||
const widgetSupportsIntegrations =
|
||||
"supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) >= 1;
|
||||
"supportedIntegrations" in definition && definition.supportedIntegrations.length >= 1;
|
||||
const queryClient = useQueryClient();
|
||||
const widgetQueryKeys = getWidgetQueryKeys(definition, item.kind);
|
||||
|
||||
return (
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={() => {
|
||||
removeWidgetDataQueries(queryClient, widgetQueryKeys);
|
||||
void queryClient.invalidateQueries({ queryKey: [["widget"]] });
|
||||
reset();
|
||||
}}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
<>
|
||||
<BoardItemMenu
|
||||
offset={4}
|
||||
item={newItem}
|
||||
definition={definition}
|
||||
resetErrorBoundary={resetErrorBoundary}
|
||||
/>
|
||||
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
<BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />
|
||||
<WidgetError kind={item.kind} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
</>
|
||||
)}
|
||||
>
|
||||
@@ -213,25 +130,24 @@ const InnerContent = ({ item, definition, Component, ...dimensions }: InnerConte
|
||||
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false)
|
||||
}
|
||||
/>
|
||||
<BoardItemMenu offset={4} item={newItem} definition={definition} />
|
||||
<div data-homarr-widget-ready={item.kind} style={{ display: "contents" }}>
|
||||
<Component
|
||||
options={options as never}
|
||||
integrationIds={item.integrationIds}
|
||||
isEditMode={isEditMode}
|
||||
boardId={board.id}
|
||||
itemId={item.id}
|
||||
setOptions={(partialNewOptions) =>
|
||||
updateOptions({
|
||||
newOptions: {
|
||||
...options,
|
||||
...partialNewOptions.newOptions,
|
||||
},
|
||||
})
|
||||
}
|
||||
{...dimensions}
|
||||
/>
|
||||
</div>
|
||||
<BoardItemMenu offset={4} item={newItem} />
|
||||
<Comp
|
||||
options={options as never}
|
||||
integrationIds={item.integrationIds}
|
||||
isEditMode={isEditMode}
|
||||
boardId={board.id}
|
||||
itemId={item.id}
|
||||
removeItem={() => removeItem({ itemId: item.id })}
|
||||
setOptions={(partialNewOptions) =>
|
||||
updateOptions({
|
||||
newOptions: {
|
||||
...options,
|
||||
...partialNewOptions.newOptions,
|
||||
},
|
||||
})
|
||||
}
|
||||
{...dimensions}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>
|
||||
|
||||
@@ -1,65 +1,47 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconApi } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createId } from "@homarr/common";
|
||||
import { getIconUrl, getIntegrationName, widgetIntegrationSupport, widgetKinds } from "@homarr/definitions";
|
||||
import { createId, objectEntries } from "@homarr/common";
|
||||
import { getIconUrl, getIntegrationName } from "@homarr/definitions";
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
|
||||
import { WorkshopInstallButton } from "~/components/workshop/workshop-install-button";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import { resolveMatchingIntegrationsAsync, tryLockSelection, unlockSelection } from "./item-select-data";
|
||||
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
|
||||
import classes from "./item-select-modal.module.css";
|
||||
|
||||
interface ItemSelectModalContentProps {
|
||||
actions: { closeModal: () => void };
|
||||
innerProps: void;
|
||||
integrationData: RouterOutputs["integration"]["all"] | undefined;
|
||||
customWidgetDefs: RouterOutputs["customWidget"]["all"] | undefined;
|
||||
ensureIntegrationDataAsync: () => Promise<RouterOutputs["integration"]["all"]>;
|
||||
}
|
||||
|
||||
const ItemSelectModalContent = ({
|
||||
actions,
|
||||
integrationData,
|
||||
customWidgetDefs,
|
||||
ensureIntegrationDataAsync,
|
||||
}: ItemSelectModalContentProps) => {
|
||||
export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, innerProps }) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const [loadingSelection, setLoadingSelection] = useState<string | null>(null);
|
||||
const selectionLock = useRef(false);
|
||||
const t = useI18n();
|
||||
const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
|
||||
const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal);
|
||||
const { openModal: openEditModal } = useModalAction(WidgetEditModal);
|
||||
const { data: integrationData } = clientApi.integration.all.useQuery();
|
||||
const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery({ boardId: innerProps.boardId });
|
||||
const settings = useSettings();
|
||||
|
||||
const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]);
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
widgetKinds
|
||||
.filter((kind) => kind !== "customApi")
|
||||
.map((kind) => {
|
||||
return {
|
||||
kind,
|
||||
supportedIntegrations: (widgetIntegrationSupport[kind] ?? []).filter(
|
||||
(integration) => integration !== "mock",
|
||||
),
|
||||
icon: widgetCatalogIcons[kind],
|
||||
name: t(`widget.${kind}.name`),
|
||||
description: t(`widget.${kind}.description`),
|
||||
};
|
||||
})
|
||||
objectEntries(widgetImports)
|
||||
.filter(([kind]) => kind !== "customApi")
|
||||
.map(([kind, value]) => ({
|
||||
kind,
|
||||
supportedIntegrations:
|
||||
"supportedIntegrations" in value.definition
|
||||
? value.definition.supportedIntegrations.filter((integration) => integration !== "mock")
|
||||
: [],
|
||||
icon: value.definition.icon,
|
||||
name: t(`widget.${kind}.name`),
|
||||
description: t(`widget.${kind}.description`),
|
||||
}))
|
||||
.sort((itemA, itemB) => {
|
||||
if (itemA.kind === "app") return -1;
|
||||
if (itemB.kind === "app") return 1;
|
||||
@@ -80,86 +62,81 @@ const ItemSelectModalContent = ({
|
||||
}, [items, search]);
|
||||
|
||||
const filteredCustomWidgets = useMemo(
|
||||
() =>
|
||||
(customWidgetDefs ?? []).filter((def) => def.enabled && def.name.toLowerCase().includes(search.toLowerCase())),
|
||||
() => (customWidgetDefs ?? []).filter((def) => def.name.toLowerCase().includes(search.toLowerCase())),
|
||||
[customWidgetDefs, search],
|
||||
);
|
||||
|
||||
const notifyDefinitionLoadError = (error: unknown) => {
|
||||
showErrorNotification({
|
||||
title: t("common.error"),
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
const handleAddCustomWidget = (definition: NonNullable<typeof customWidgetDefs>[number]) => {
|
||||
const itemId = createId();
|
||||
const defaultOptions = reduceWidgetOptionsWithDefaultValues("customApi", settings);
|
||||
const options = {
|
||||
...defaultOptions,
|
||||
definitionId: definition.id,
|
||||
configuration: definition.defaultOptions,
|
||||
configurationVersion: definition.updatedAt.getTime(),
|
||||
};
|
||||
actions.closeModal();
|
||||
openEditModal(
|
||||
{
|
||||
kind: "customApi",
|
||||
value: {
|
||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||
options,
|
||||
integrationIds: [],
|
||||
},
|
||||
onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }) => {
|
||||
createItem({ id: itemId, kind: "customApi", integrationIds: [] });
|
||||
updateItemOptions({ itemId, newOptions: configuredOptions });
|
||||
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
|
||||
},
|
||||
integrationData: [],
|
||||
integrationSupport: false,
|
||||
settings,
|
||||
},
|
||||
{
|
||||
title: (titleT) => `${titleT("item.edit.title")} - ${definition.name}`,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddCustomWidget = async (definitionId: string) => {
|
||||
if (!tryLockSelection(selectionLock)) return;
|
||||
setLoadingSelection(`custom:${definitionId}`);
|
||||
try {
|
||||
const definition = await loadWidgetDefinition("customApi");
|
||||
const itemId = createId();
|
||||
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
|
||||
createItem({ id: itemId, kind: "customApi", integrationIds: [] });
|
||||
updateItemOptions({ itemId, newOptions: { ...defaultOptions, definitionId } });
|
||||
actions.closeModal();
|
||||
} catch (error) {
|
||||
notifyDefinitionLoadError(error);
|
||||
} finally {
|
||||
unlockSelection(selectionLock);
|
||||
setLoadingSelection(null);
|
||||
}
|
||||
};
|
||||
const handleAdd = (kind: WidgetKind) => {
|
||||
const definition = widgetImports[kind].definition;
|
||||
const hasIntegrationSupport = "supportedIntegrations" in definition;
|
||||
|
||||
const handleAdd = async (kind: WidgetKind) => {
|
||||
if (!tryLockSelection(selectionLock)) return;
|
||||
setLoadingSelection(kind);
|
||||
preloadWidgetEditModal();
|
||||
try {
|
||||
const definition = await loadWidgetDefinition(kind);
|
||||
const hasIntegrationSupport = "supportedIntegrations" in definition;
|
||||
const matchingIntegrations = hasIntegrationSupport
|
||||
? (integrationData ?? []).filter((integration) =>
|
||||
(definition.supportedIntegrations as string[]).includes(integration.kind),
|
||||
)
|
||||
: [];
|
||||
|
||||
const matchingIntegrations = await resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport,
|
||||
supportedIntegrations: definition.supportedIntegrations ?? [],
|
||||
currentData: integrationData,
|
||||
ensureDataAsync: ensureIntegrationDataAsync,
|
||||
});
|
||||
const integrationIds = matchingIntegrations.map((i) => i.id);
|
||||
const itemId = createId();
|
||||
const defaultOptions = reduceWidgetOptionsWithDefaultValues(kind, settings);
|
||||
|
||||
const integrationIds = matchingIntegrations.map((i) => i.id);
|
||||
const itemId = createId();
|
||||
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
|
||||
createItem({ id: itemId, kind, integrationIds });
|
||||
actions.closeModal();
|
||||
|
||||
createItem({ id: itemId, kind, integrationIds });
|
||||
actions.closeModal();
|
||||
|
||||
openEditModal(
|
||||
{
|
||||
kind,
|
||||
definition,
|
||||
value: {
|
||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||
options: defaultOptions,
|
||||
integrationIds,
|
||||
},
|
||||
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
|
||||
updateItemOptions({ itemId, newOptions: options });
|
||||
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
|
||||
updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds });
|
||||
},
|
||||
integrationData: matchingIntegrations,
|
||||
integrationSupport: hasIntegrationSupport,
|
||||
settings,
|
||||
openEditModal(
|
||||
{
|
||||
kind,
|
||||
value: {
|
||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||
options: defaultOptions,
|
||||
integrationIds,
|
||||
},
|
||||
{
|
||||
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
|
||||
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
|
||||
updateItemOptions({ itemId, newOptions: options });
|
||||
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
|
||||
updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds });
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
notifyDefinitionLoadError(error);
|
||||
} finally {
|
||||
unlockSelection(selectionLock);
|
||||
setLoadingSelection(null);
|
||||
}
|
||||
integrationData: matchingIntegrations,
|
||||
integrationSupport: hasIntegrationSupport,
|
||||
settings,
|
||||
},
|
||||
{
|
||||
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -168,9 +145,9 @@ const ItemSelectModalContent = ({
|
||||
onSearchChange={setSearch}
|
||||
placeholder={`${t("item.create.search")}...`}
|
||||
onSearchKeyDown={(event) => {
|
||||
if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) {
|
||||
const [item] = filteredItems;
|
||||
if (item) void handleAdd(item.kind);
|
||||
if (event.key === "Enter" && filteredItems.length === 1) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
handleAdd(filteredItems[0]!.kind);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -178,76 +155,64 @@ const ItemSelectModalContent = ({
|
||||
<WidgetItem
|
||||
key={item.kind}
|
||||
item={item}
|
||||
onSelect={() => void handleAdd(item.kind)}
|
||||
onIntent={() => {
|
||||
void loadWidgetDefinition(item.kind).catch(() => undefined);
|
||||
preloadWidgetEditModal();
|
||||
}}
|
||||
disabled={loadingSelection !== null}
|
||||
loading={loadingSelection === item.kind}
|
||||
onSelect={() => handleAdd(item.kind)}
|
||||
hasMatchingIntegration={item.supportedIntegrations.some((kind) => availableKinds.has(kind))}
|
||||
/>
|
||||
))}
|
||||
|
||||
{filteredCustomWidgets.length > 0 && (
|
||||
<>
|
||||
<Divider
|
||||
label={t("customWidget.page.list.title")}
|
||||
labelPosition="center"
|
||||
my="sm"
|
||||
style={{ gridColumn: "1 / -1" }}
|
||||
/>
|
||||
{filteredCustomWidgets.map((def) => (
|
||||
<Card
|
||||
key={def.id}
|
||||
className={classes.card}
|
||||
h={selectGridCardHeight}
|
||||
withBorder
|
||||
pos="relative"
|
||||
style={{ overflow: "hidden" }}
|
||||
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>
|
||||
</Stack>
|
||||
<Box
|
||||
className={classes.action}
|
||||
pos="absolute"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
p="xs"
|
||||
style={{
|
||||
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={() => void handleAddCustomWidget(def.id)}
|
||||
variant="light"
|
||||
size="xs"
|
||||
fullWidth
|
||||
disabled={loadingSelection !== null}
|
||||
loading={loadingSelection === `custom:${def.id}`}
|
||||
>
|
||||
{t("item.create.addToBoard")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<Divider
|
||||
label={t("customWidget.page.list.title")}
|
||||
labelPosition="center"
|
||||
my="sm"
|
||||
style={{ gridColumn: "1 / -1" }}
|
||||
/>
|
||||
{filteredCustomWidgets.map((def) => (
|
||||
<Card
|
||||
key={def.id}
|
||||
h={selectGridCardHeight}
|
||||
withBorder
|
||||
pos="relative"
|
||||
style={{ overflow: "hidden", "--_hover-opacity": "0" }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
|
||||
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
|
||||
>
|
||||
<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>
|
||||
</Stack>
|
||||
<Box
|
||||
pos="absolute"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
p="xs"
|
||||
style={{
|
||||
opacity: "var(--_hover-opacity)",
|
||||
transition: "opacity 150ms ease",
|
||||
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
|
||||
}}
|
||||
>
|
||||
<Button onClick={() => handleAddCustomWidget(def)} variant="light" size="xs" fullWidth>
|
||||
{t("item.create.addToBoard")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Box style={{ gridColumn: "1 / -1" }}>
|
||||
<WorkshopInstallButton fullWidth>{t("workshop.installDialog")}</WorkshopInstallButton>
|
||||
</Box>
|
||||
|
||||
{filteredItems.length === 0 && filteredCustomWidgets.length === 0 && (
|
||||
<Center p="xl">
|
||||
@@ -256,24 +221,7 @@ const ItemSelectModalContent = ({
|
||||
)}
|
||||
</SelectGridLayout>
|
||||
);
|
||||
};
|
||||
|
||||
const ItemSelectModalFrame = (props: Pick<ItemSelectModalContentProps, "actions" | "innerProps">) => {
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: integrationData } = clientApi.integration.all.useQuery();
|
||||
const { data: customWidgetDefs } = clientApi.customWidget.all.useQuery();
|
||||
|
||||
return (
|
||||
<ItemSelectModalContent
|
||||
{...props}
|
||||
integrationData={integrationData}
|
||||
customWidgetDefs={customWidgetDefs}
|
||||
ensureIntegrationDataAsync={() => utils.integration.all.ensureData()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ItemSelectModal = createModal<void>((props) => <ItemSelectModalFrame {...props} />).withOptions({
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => t("item.create.title"),
|
||||
size: modalSizeSelect,
|
||||
});
|
||||
@@ -281,9 +229,6 @@ export const ItemSelectModal = createModal<void>((props) => <ItemSelectModalFram
|
||||
const WidgetItem = ({
|
||||
item,
|
||||
onSelect,
|
||||
onIntent,
|
||||
disabled,
|
||||
loading,
|
||||
hasMatchingIntegration,
|
||||
}: {
|
||||
item: {
|
||||
@@ -294,27 +239,23 @@ const WidgetItem = ({
|
||||
icon: TablerIcon;
|
||||
};
|
||||
onSelect: () => void;
|
||||
onIntent: () => void;
|
||||
disabled: boolean;
|
||||
loading: boolean;
|
||||
hasMatchingIntegration: boolean;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={classes.card}
|
||||
h={selectGridCardHeight}
|
||||
withBorder
|
||||
pos="relative"
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
"--_hover-opacity": "0",
|
||||
borderColor: hasMatchingIntegration ? "var(--mantine-color-blue-6)" : undefined,
|
||||
borderWidth: hasMatchingIntegration ? 2 : undefined,
|
||||
}}
|
||||
onFocus={onIntent}
|
||||
onPointerEnter={onIntent}
|
||||
aria-busy={loading || undefined}
|
||||
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
|
||||
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
@@ -331,17 +272,18 @@ const WidgetItem = ({
|
||||
<SupportedIntegrations integrations={item.supportedIntegrations} />
|
||||
</Stack>
|
||||
<Box
|
||||
className={classes.action}
|
||||
pos="absolute"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
p="xs"
|
||||
style={{
|
||||
opacity: "var(--_hover-opacity)",
|
||||
transition: "opacity 150ms ease",
|
||||
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
|
||||
}}
|
||||
>
|
||||
<Button onClick={onSelect} variant="light" size="xs" fullWidth disabled={disabled} loading={loading}>
|
||||
<Button onClick={onSelect} variant="light" size="xs" fullWidth>
|
||||
{t(`item.create.addToBoard`)}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
Reference in new issue
Block a user