This commit is contained in:
1 parent
e3a713d2df
commit
e842177d9b
163 files changed
+7390
-1240
No files matched your search
@@ -35,10 +35,10 @@ import { useSession } from "@homarr/auth/client";
|
||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||
import { useOptionalBoard } from "@homarr/boards/context";
|
||||
import { formatBytes, useTimeAgo } from "@homarr/common";
|
||||
import type { ContainerState } from "@homarr/docker";
|
||||
import type { ContainerState, DockerEndpointCapability } from "@homarr/docker";
|
||||
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
|
||||
import { AddDockerAppToHomarr, useDockerContainerRemovalConfirmation } from "@homarr/modals-collection";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
@@ -50,6 +50,7 @@ import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/us
|
||||
import { getDockerColumnVisibility, getDockerFooterVisibility } from "./layout";
|
||||
|
||||
type DockerContainer = RouterOutputs["docker"]["getContainers"]["containers"][number];
|
||||
type DockerEndpoint = RouterOutputs["docker"]["getContainers"]["endpoints"][number];
|
||||
type ContainerAction = "start" | "stop" | "restart" | "remove";
|
||||
|
||||
interface ContextMenuState {
|
||||
@@ -59,6 +60,7 @@ interface ContextMenuState {
|
||||
}
|
||||
|
||||
interface ContainerActionHandlers {
|
||||
canUse: (container: DockerContainer, capability: DockerEndpointCapability) => boolean;
|
||||
onAction: (action: ContainerAction, container: DockerContainer) => void;
|
||||
onAddToHomarr: (container: DockerContainer) => void;
|
||||
onOpenLogs: (container: DockerContainer) => void;
|
||||
@@ -67,8 +69,10 @@ interface ContainerActionHandlers {
|
||||
const columnAccessors = ["name", "state", "host", "cpuUsage", "memoryUsage", "actions"] as const;
|
||||
const containerMenuWidth = 240;
|
||||
|
||||
const createContainerLogsPath = (container: Pick<DockerContainer, "id" | "name">) =>
|
||||
`/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}`;
|
||||
const createContainerLogsPath = (container: Pick<DockerContainer, "endpointId" | "id" | "name">) =>
|
||||
`/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}&endpointId=${encodeURIComponent(container.endpointId)}`;
|
||||
|
||||
const getContainerTarget = ({ endpointId, id }: DockerContainer) => ({ endpointId, id });
|
||||
|
||||
const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
|
||||
const t = useScopedI18n("docker.field.state.option");
|
||||
@@ -209,10 +213,11 @@ const useContainerAction = (action: ContainerAction) => {
|
||||
async onSettled() {
|
||||
await utils.docker.getContainers.invalidate();
|
||||
},
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
title: t(`${action}.notification.success.title`),
|
||||
message: t(`${action}.notification.success.message`),
|
||||
onSuccess(results) {
|
||||
const failed = results.some((result) => !result.success);
|
||||
(failed ? showErrorNotification : showSuccessNotification)({
|
||||
title: t(`${action}.notification.${failed ? "error" : "success"}.title`),
|
||||
message: t(`${action}.notification.${failed ? "error" : "success"}.message`),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
@@ -236,14 +241,31 @@ export default function DockerWidget({
|
||||
const t = useScopedI18n("docker");
|
||||
const tWidget = useScopedI18n("widget.dockerContainers");
|
||||
const { openModal } = useModalAction(AddDockerAppToHomarr);
|
||||
const confirmRemoval = useDockerContainerRemovalConfirmation();
|
||||
const board = useOptionalBoard();
|
||||
const { data: session } = useSession();
|
||||
const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false;
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
|
||||
const utils = clientApi.useUtils();
|
||||
const containersQuery = clientApi.docker.getContainers.useQuery();
|
||||
const data = getUsableWidgetQueryData(containersQuery);
|
||||
const { refetch, isFetching } = containersQuery;
|
||||
const { isFetching } = containersQuery;
|
||||
const refreshInventory = clientApi.docker.refreshInventory.useMutation({
|
||||
async onSuccess() {
|
||||
await Promise.all([
|
||||
utils.docker.getContainers.invalidate(),
|
||||
utils.docker.reconcileServices.invalidate(),
|
||||
utils.docker.getServiceHealth.invalidate(),
|
||||
]);
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("action.refresh.notification.error.title"),
|
||||
message: t("action.refresh.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
const containers = useMemo(() => data?.containers ?? [], [data?.containers]);
|
||||
const timestamp = useMemo(() => data?.timestamp ?? new Date(), [data?.timestamp]);
|
||||
const relativeTime = useTimeAgo(timestamp);
|
||||
@@ -254,12 +276,13 @@ export default function DockerWidget({
|
||||
const { mutate: removeContainer } = useContainerAction("remove");
|
||||
const handleContainerAction = useCallback(
|
||||
(action: ContainerAction, container: DockerContainer) => {
|
||||
if (action === "start") startContainer({ ids: [container.id] });
|
||||
else if (action === "stop") stopContainer({ ids: [container.id] });
|
||||
else if (action === "restart") restartContainer({ ids: [container.id] });
|
||||
else removeContainer({ ids: [container.id] });
|
||||
const target = { targets: [getContainerTarget(container)] };
|
||||
if (action === "start") startContainer(target);
|
||||
else if (action === "stop") stopContainer(target);
|
||||
else if (action === "restart") restartContainer(target);
|
||||
else confirmRemoval([container], () => removeContainer(target));
|
||||
},
|
||||
[removeContainer, restartContainer, startContainer, stopContainer],
|
||||
[confirmRemoval, removeContainer, restartContainer, startContainer, stopContainer],
|
||||
);
|
||||
const handleOpenLogs = useCallback(
|
||||
(container: DockerContainer) => window.location.assign(createContainerLogsPath(container)),
|
||||
@@ -271,11 +294,16 @@ export default function DockerWidget({
|
||||
);
|
||||
const actionHandlers = useMemo<ContainerActionHandlers>(
|
||||
() => ({
|
||||
canUse: (container, capability) =>
|
||||
endpointHasCapability(
|
||||
data?.endpoints.find(({ id }) => id === container.endpointId),
|
||||
capability,
|
||||
),
|
||||
onAction: handleContainerAction,
|
||||
onAddToHomarr: handleAddToHomarr,
|
||||
onOpenLogs: handleOpenLogs,
|
||||
}),
|
||||
[handleAddToHomarr, handleContainerAction, handleOpenLogs],
|
||||
[data?.endpoints, handleAddToHomarr, handleContainerAction, handleOpenLogs],
|
||||
);
|
||||
|
||||
const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation({
|
||||
@@ -359,6 +387,14 @@ export default function DockerWidget({
|
||||
);
|
||||
}
|
||||
|
||||
if (containers.length === 0 && data?.endpoints.some(({ status }) => status === "unavailable")) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Text>{tWidget("error.endpointsUnavailable")}</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const footerVisibility = getDockerFooterVisibility(width, isAdvanced);
|
||||
|
||||
return (
|
||||
@@ -376,7 +412,7 @@ export default function DockerWidget({
|
||||
storeColumnsKey={storeKey}
|
||||
sortStatus={sortStatus}
|
||||
onSortStatusChange={(isAdvanced || options.enableRowSorting) && !isEditMode ? setSortStatus : undefined}
|
||||
idAccessor="id"
|
||||
idAccessor="resourceId"
|
||||
onRowContextMenu={isEditMode ? undefined : handleContextMenu}
|
||||
onScroll={() => {
|
||||
if (contextMenu) closeContextMenu();
|
||||
@@ -417,8 +453,8 @@ export default function DockerWidget({
|
||||
size="sm"
|
||||
variant="transparent"
|
||||
c="var(--mantine-color-text)"
|
||||
loading={isFetching}
|
||||
onClick={() => void refetch()}
|
||||
loading={isFetching || refreshInventory.isPending}
|
||||
onClick={() => refreshInventory.mutate()}
|
||||
aria-label={t("table.refresh.lastUpdated", { when: relativeTime })}
|
||||
>
|
||||
<IconRefresh size={16} />
|
||||
@@ -475,7 +511,6 @@ function ContainerActionItems({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const t = useScopedI18n("docker.action");
|
||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||
const stateAction = container.state === "running" ? "stop" : "start";
|
||||
const StateIcon = stateAction === "stop" ? IconPlayerStop : IconPlayerPlay;
|
||||
|
||||
@@ -491,6 +526,7 @@ function ContainerActionItems({
|
||||
</Menu.Label>
|
||||
<Menu.Item
|
||||
leftSection={<IconFileText size={14} />}
|
||||
disabled={!handlers.canUse(container, "logs")}
|
||||
onClick={() => {
|
||||
handlers.onOpenLogs(container);
|
||||
onClose();
|
||||
@@ -502,22 +538,27 @@ function ContainerActionItems({
|
||||
<Menu.Item
|
||||
color={stateAction === "start" ? "green" : "red"}
|
||||
leftSection={<StateIcon size={14} />}
|
||||
disabled={!handlers.canUse(container, "lifecycle")}
|
||||
onClick={() => invokeAction(stateAction)}
|
||||
>
|
||||
{t(`${stateAction}.label`)}
|
||||
</Menu.Item>
|
||||
<Menu.Item color="orange" leftSection={<IconRotateClockwise size={14} />} onClick={() => invokeAction("restart")}>
|
||||
<Menu.Item
|
||||
color="orange"
|
||||
leftSection={<IconRotateClockwise size={14} />}
|
||||
disabled={!handlers.canUse(container, "lifecycle")}
|
||||
onClick={() => invokeAction("restart")}
|
||||
>
|
||||
{t("restart.label")}
|
||||
</Menu.Item>
|
||||
{!confirmRemove ? (
|
||||
<Menu.Item color="red" leftSection={<IconTrash size={14} />} onClick={() => setConfirmRemove(true)}>
|
||||
{t("remove.label")}
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<Menu.Item color="red" leftSection={<IconTrash size={14} />} onClick={() => invokeAction("remove")}>
|
||||
{t("remove.confirm")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash size={14} />}
|
||||
disabled={!handlers.canUse(container, "remove")}
|
||||
onClick={() => invokeAction("remove")}
|
||||
>
|
||||
{t("remove.label")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<IconCategoryPlus size={14} />}
|
||||
@@ -532,6 +573,9 @@ function ContainerActionItems({
|
||||
);
|
||||
}
|
||||
|
||||
const endpointHasCapability = (endpoint: DockerEndpoint | undefined, capability: DockerEndpointCapability) =>
|
||||
endpoint && "capabilities" in endpoint ? endpoint.capabilities.includes(capability) : true;
|
||||
|
||||
function ContainerContextMenu({
|
||||
state,
|
||||
handlers,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { Button, Group, Stack, Tabs } from "@mantine/core";
|
||||
import { Box, Button, Group, Stack, Tabs, Text } from "@mantine/core";
|
||||
import { schemaResolver } from "@mantine/form";
|
||||
import { IconPencil } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { objectEntries } from "@homarr/common";
|
||||
@@ -13,6 +14,7 @@ import type { WidgetKind } from "@homarr/definitions";
|
||||
import { createModal, ModalFormFooter, modalSizeForm, useModalAction } from "@homarr/modals";
|
||||
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
import { zodErrorMap } from "@homarr/validation/form/i18n";
|
||||
|
||||
import { getInputForType } from "../_inputs";
|
||||
@@ -44,8 +46,15 @@ export interface WidgetEditModalProps<TSort extends WidgetKind> {
|
||||
itemId?: string;
|
||||
boardId?: string;
|
||||
appId?: string;
|
||||
onEditIntegration?: (integrationId: string) => void;
|
||||
onIntegrationSaved?: () => void;
|
||||
}
|
||||
|
||||
export const getSelectedWidgetIntegrations = (
|
||||
integrationData: readonly IntegrationSelectOption[],
|
||||
selectedIds: readonly string[],
|
||||
) => integrationData.filter((integration) => selectedIds.includes(integration.id));
|
||||
|
||||
export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const board = useOptionalBoard();
|
||||
@@ -61,6 +70,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
const integrationsRequired =
|
||||
innerProps.integrationSupport &&
|
||||
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false);
|
||||
const maxIntegrations = "maxIntegrations" in definition ? (definition.maxIntegrations ?? Infinity) : Infinity;
|
||||
const options = definition.createOptions(innerProps.settings) as Record<string, OptionsBuilderResult[string]>;
|
||||
const optionsSuperRefine = (options as Record<symbol, unknown>)[OPTIONS_SUPER_REFINE] as
|
||||
| ((data: Record<string, unknown>, ctx: z.RefinementCtx) => void)
|
||||
@@ -79,13 +89,17 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
? z.object(optionsFieldSchema).superRefine(optionsSuperRefine)
|
||||
: z.object(optionsFieldSchema);
|
||||
|
||||
let integrationIdsSchema = z.array(z.string());
|
||||
if (integrationsRequired) integrationIdsSchema = integrationIdsSchema.min(1);
|
||||
if (Number.isFinite(maxIntegrations)) integrationIdsSchema = integrationIdsSchema.max(maxIntegrations);
|
||||
|
||||
const form = useForm({
|
||||
mode: "controlled",
|
||||
initialValues: innerProps.value,
|
||||
validate: schemaResolver(
|
||||
z.object({
|
||||
options: optionsSchema,
|
||||
integrationIds: integrationsRequired ? z.array(z.string()).min(1) : z.array(z.string()),
|
||||
integrationIds: integrationIdsSchema,
|
||||
advancedOptions: z.object({
|
||||
customCssClasses: z.array(z.string()),
|
||||
borderColor: z.string(),
|
||||
@@ -102,6 +116,27 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
const canConfigureWidget = innerProps.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
|
||||
const appId = innerProps.appId;
|
||||
const showAppTab = innerProps.kind === "app" && canModifyApps && Boolean(appId);
|
||||
const canModifyAllIntegrations = session?.user.permissions.includes("integration-full-all") ?? false;
|
||||
const selectedIntegrations = getSelectedWidgetIntegrations(innerProps.integrationData, form.values.integrationIds);
|
||||
const showIntegrationTab = Boolean(innerProps.onEditIntegration) && selectedIntegrations.length > 0;
|
||||
const showResourceTabs = showAppTab || showIntegrationTab;
|
||||
const [activeTab, setActiveTab] = useState<string | null>("widget");
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "integration" && !showIntegrationTab) setActiveTab("widget");
|
||||
if (activeTab === "app" && !showAppTab) setActiveTab("widget");
|
||||
}, [activeTab, showAppTab, showIntegrationTab]);
|
||||
|
||||
const handleTabChange = (value: string | null) => {
|
||||
if (value === "integration" && selectedIntegrations.length === 1) {
|
||||
const integration = selectedIntegrations[0];
|
||||
if (integration) {
|
||||
innerProps.onEditIntegration?.(integration.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setActiveTab(value);
|
||||
};
|
||||
|
||||
const handleSubmit = form.onSubmit(async (values) => {
|
||||
setIsSubmitting(true);
|
||||
@@ -132,7 +167,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
<WidgetIntegrationSelect
|
||||
label={t("item.edit.field.integrations.label")}
|
||||
data={innerProps.integrationData}
|
||||
canSelectMultiple={((definition as { maxIntegrations?: number }).maxIntegrations ?? Infinity) > 1}
|
||||
canSelectMultiple={maxIntegrations > 1}
|
||||
withAsterisk={integrationsRequired}
|
||||
{...form.getInputProps("integrationIds")}
|
||||
/>
|
||||
@@ -165,20 +200,14 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{showAppTab ? (
|
||||
{showResourceTabs ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openModal({
|
||||
advancedOptions,
|
||||
onSuccess(options) {
|
||||
setAdvancedOptions(options);
|
||||
innerProps.onSuccessfulEdit({
|
||||
...innerProps.value,
|
||||
advancedOptions: options,
|
||||
});
|
||||
},
|
||||
onSuccess: setAdvancedOptions,
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -194,13 +223,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
onClick={() =>
|
||||
openModal({
|
||||
advancedOptions,
|
||||
onSuccess(options) {
|
||||
setAdvancedOptions(options);
|
||||
innerProps.onSuccessfulEdit({
|
||||
...innerProps.value,
|
||||
advancedOptions: options,
|
||||
});
|
||||
},
|
||||
onSuccess: setAdvancedOptions,
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -215,28 +238,86 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
return (
|
||||
<form onSubmit={onFormSubmit}>
|
||||
<FormProvider form={form}>
|
||||
{showAppTab ? (
|
||||
{showResourceTabs ? (
|
||||
<Stack>
|
||||
<Tabs defaultValue="widget">
|
||||
<Tabs value={activeTab} onChange={handleTabChange}>
|
||||
<Tabs.List grow>
|
||||
<Tabs.Tab value="widget">{t("item.edit.tab.widget")}</Tabs.Tab>
|
||||
<Tabs.Tab value="app">{t("item.edit.tab.app")}</Tabs.Tab>
|
||||
{showAppTab && <Tabs.Tab value="app">{t("item.edit.tab.app")}</Tabs.Tab>}
|
||||
{showIntegrationTab && <Tabs.Tab value="integration">{t("item.edit.tab.integration")}</Tabs.Tab>}
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="widget" pt="md">
|
||||
{widgetFormContent}
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="app" pt="md">
|
||||
{appId && <EmbeddedAppEditForm appId={appId} handleRef={appEditRef} />}
|
||||
</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>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("item.edit.integration.description")}
|
||||
</Text>
|
||||
{selectedIntegrations.map((integration) => {
|
||||
const canEdit = canModifyAllIntegrations || integration.permissions?.hasFullAccess === true;
|
||||
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={() => innerProps.onEditIntegration?.(integration.id)}
|
||||
aria-label={t("item.edit.integration.editLabel", { name: integration.name })}
|
||||
>
|
||||
{t("item.edit.integration.action")}
|
||||
</Button>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed" ta="end">
|
||||
{t("item.edit.integration.fullAccessRequired")}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
</Tabs>
|
||||
<Group justify="end">
|
||||
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
<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">
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
</Stack>
|
||||
) : (
|
||||
widgetFormContent
|
||||
@@ -250,4 +331,6 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
return t("item.edit.title");
|
||||
},
|
||||
size: modalSizeForm,
|
||||
presentation: "inspector",
|
||||
closeOnClickOutside: false,
|
||||
});
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
CloseButton,
|
||||
Combobox,
|
||||
Group,
|
||||
Input,
|
||||
Pill,
|
||||
PillsInput,
|
||||
Stack,
|
||||
@@ -38,6 +37,7 @@ export const WidgetIntegrationSelect = ({
|
||||
data,
|
||||
onChange,
|
||||
value: valueProp,
|
||||
label,
|
||||
canSelectMultiple = true,
|
||||
withAsterisk = false,
|
||||
...props
|
||||
@@ -79,6 +79,7 @@ export const WidgetIntegrationSelect = ({
|
||||
option={option}
|
||||
onRemove={() => handleValueRemove(item)}
|
||||
showRemoveButton={canSelectMultiple}
|
||||
removeLabel={t("item.edit.field.integrations.removeLabel", { name: option.name })}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -92,7 +93,7 @@ export const WidgetIntegrationSelect = ({
|
||||
<Avatar src={getIconUrl(item.kind)} size="sm" />
|
||||
<Stack gap={0}>
|
||||
<span>{item.name}</span>
|
||||
<Text size="xs" c="gray.6">
|
||||
<Text size="xs" c="dimmed">
|
||||
{item.url}
|
||||
</Text>
|
||||
</Stack>
|
||||
@@ -109,33 +110,42 @@ export const WidgetIntegrationSelect = ({
|
||||
inputWrapperOrder={["label", "input", "description", "error"]}
|
||||
description={
|
||||
<Text size="xs" span>
|
||||
{t.rich("widget.common.integration.description", {
|
||||
here: () => (
|
||||
<Anchor size="xs" component={Link} target="_blank" href="/manage/integrations">
|
||||
{t("common.here")}
|
||||
</Anchor>
|
||||
),
|
||||
})}
|
||||
<Anchor size="xs" component={Link} target="_blank" href="/manage/integrations">
|
||||
{t("widget.common.integration.manage")}
|
||||
</Anchor>
|
||||
</Text>
|
||||
}
|
||||
pointer
|
||||
onClick={() => combobox.toggleDropdown()}
|
||||
onClick={() => combobox.openDropdown()}
|
||||
label={label}
|
||||
withAsterisk={withAsterisk}
|
||||
{...props}
|
||||
>
|
||||
<Pill.Group>
|
||||
{values.length > 0 ? values : <Input.Placeholder>{t("common.multiSelect.placeholder")}</Input.Placeholder>}
|
||||
{values}
|
||||
|
||||
<Combobox.EventsTarget>
|
||||
<PillsInput.Field
|
||||
type="hidden"
|
||||
readOnly
|
||||
aria-label={label}
|
||||
placeholder={values.length === 0 ? t("common.multiSelect.placeholder") : undefined}
|
||||
onFocus={() => combobox.openDropdown()}
|
||||
onBlur={() => combobox.closeDropdown()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Backspace") return;
|
||||
|
||||
event.preventDefault();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
handleValueRemove(multiSelectValues[multiSelectValues.length - 1]!);
|
||||
if (["ArrowDown", "Enter", " "].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
combobox.openDropdown();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
combobox.closeDropdown();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Backspace" && canSelectMultiple && multiSelectValues.length > 0) {
|
||||
event.preventDefault();
|
||||
const lastValue = multiSelectValues.at(-1);
|
||||
if (lastValue) handleValueRemove(lastValue);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Combobox.EventsTarget>
|
||||
@@ -163,22 +173,38 @@ export interface IntegrationSelectOption {
|
||||
name: string;
|
||||
url: string;
|
||||
kind: IntegrationKind;
|
||||
permissions?: {
|
||||
hasUseAccess: boolean;
|
||||
hasInteractAccess: boolean;
|
||||
hasFullAccess: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface IntegrationPillProps {
|
||||
option: IntegrationSelectOption;
|
||||
onRemove: () => void;
|
||||
showRemoveButton: boolean;
|
||||
removeLabel: string;
|
||||
}
|
||||
|
||||
const IntegrationPill = ({ option, onRemove, showRemoveButton }: IntegrationPillProps) => (
|
||||
const IntegrationPill = ({ option, onRemove, showRemoveButton, removeLabel }: IntegrationPillProps) => (
|
||||
<Group align="center" wrap="nowrap" gap={0} className={classes.pill} mih={24} pr={!showRemoveButton ? 10 : undefined}>
|
||||
<Avatar src={getIconUrl(option.kind)} size={14} mr={6} />
|
||||
<Text span size="xs" lh={1} fw={500}>
|
||||
{option.name}
|
||||
</Text>
|
||||
{showRemoveButton && (
|
||||
<CloseButton onMouseDown={onRemove} variant="transparent" color="gray" size={22} iconSize={14} tabIndex={-1} />
|
||||
<CloseButton
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
aria-label={removeLabel}
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
size={24}
|
||||
iconSize={14}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
Reference in new issue
Block a user