feat!: release v2 (#6545)

Co-authored-by: Artur <145776561+arturict@users.noreply.github.com>
Co-authored-by: arturict <arturict@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Co-authored-by: ajnart <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
Co-authored-by: chenjun <chenjun@foxmail.com>
Co-authored-by: Wikibear <wikibear@users.noreply.github.com>
Co-authored-by: Bergasha <bergasha@gmail.com>
Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com>
Co-authored-by: Lucas Araujo <lucas.lra@gmail.com>
Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com>
Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social>
Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com>
Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com>
Co-authored-by: thrawnx <tight.bulb9384@fastmail.com>
Co-authored-by: Chris <58918656+nordada@users.noreply.github.com>
Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com>
Co-authored-by: Yanis <naamaneyanis@gmail.com>
Co-authored-by: Wikibear <me@sej.de>
Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
This commit is contained in:
3286 files changed
+284386 -139176

No files matched your search

@@ -20,7 +20,10 @@ interface EmbeddedAppEditFormProps {
}
export const EmbeddedAppEditForm = ({ appId, handleRef }: EmbeddedAppEditFormProps) => {
const t = useI18n();
const tCommon = useI18n("common");
const tAppEdit = useI18n("app.page.edit");
const tAppSelect = useI18n("app.action.select");
const tItemApp = useI18n("item.edit.app");
const utils = clientApi.useUtils();
const appFormRef = useRef<AppFormHandle>(null);
const { data: app, isPending: isLoadingApp, isError, refetch } = clientApi.app.byId.useQuery({ id: appId });
@@ -29,14 +32,14 @@ export const EmbeddedAppEditForm = ({ appId, handleRef }: EmbeddedAppEditFormPro
onSuccess: async () => {
await utils.app.invalidate();
showSuccessNotification({
title: t("app.page.edit.notification.success.title"),
message: t("app.page.edit.notification.success.message"),
title: tCommon("notification.update.success"),
message: tAppEdit("notification.success.message"),
});
},
onError: () => {
showErrorNotification({
title: t("app.page.edit.notification.error.title"),
message: t("app.page.edit.notification.error.message"),
title: tCommon("notification.update.error"),
message: tAppEdit("notification.error.message"),
});
},
});
@@ -66,9 +69,9 @@ export const EmbeddedAppEditForm = ({ appId, handleRef }: EmbeddedAppEditFormPro
if (isError || !app) {
return (
<Stack align="center" gap="md" py="xl">
<Text c="dimmed">{t("app.action.select.notFound")}</Text>
<Text c="dimmed">{tAppSelect("notFound")}</Text>
<Button variant="light" onClick={() => void refetch()}>
{t("common.action.tryAgain")}
{tCommon("action.tryAgain")}
</Button>
</Stack>
);
@@ -76,8 +79,8 @@ export const EmbeddedAppEditForm = ({ appId, handleRef }: EmbeddedAppEditFormPro
return (
<Stack>
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
{t("item.edit.app.propagationNotice")}
<Alert icon={<IconInfoCircle size="var(--mantine-font-size-md)" />} color="blue" variant="light">
{tItemApp("propagationNotice")}
</Alert>
<AppForm
formRef={appFormRef}
@@ -1,69 +0,0 @@
"use client";
import { CloseButton, ColorInput, Input, Stack, TextInput, useMantineTheme } from "@mantine/core";
import { useForm } from "@homarr/form";
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { TextMultiSelect } from "@homarr/ui";
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
interface InnerProps {
advancedOptions: BoardItemAdvancedOptions;
onSuccess: (options: BoardItemAdvancedOptions) => void;
}
export const WidgetAdvancedOptionsModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n();
const theme = useMantineTheme();
const form = useForm({
initialValues: innerProps.advancedOptions,
});
const handleSubmit = (values: BoardItemAdvancedOptions) => {
innerProps.onSuccess({
...values,
// we want to fallback to null if the title is empty
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
title: values.title?.trim() || null,
});
actions.closeModal();
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
label={t("item.edit.field.title.label")}
data-autofocus
{...form.getInputProps("title")}
rightSection={<Input.ClearButton onClick={() => form.setFieldValue("title", "")} />}
/>
<TextMultiSelect
label={t("item.edit.field.customCssClasses.label")}
{...form.getInputProps("customCssClasses")}
/>
<ColorInput
label={t("item.edit.field.borderColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
rightSection={
<CloseButton
onClick={() => form.setFieldValue("borderColor", "")}
style={{ display: form.getInputProps("borderColor").value ? undefined : "none" }}
/>
}
{...form.getInputProps("borderColor")}
/>
<ModalFormFooter onCancel={actions.closeModal} />
</Stack>
</form>
);
}).withOptions({
defaultTitle(t) {
return t("item.edit.advancedOptions.title");
},
size: modalSizeForm,
transitionProps: {
duration: 0,
},
});
@@ -0,0 +1,185 @@
.workspace {
display: grid;
grid-template-columns: 1fr;
gap: var(--mantine-spacing-lg);
align-items: start;
}
.workspace[data-with-preview][data-with-settings] {
grid-template-columns: minmax(0, 3fr) auto minmax(34rem, 2fr);
}
.previewPanel {
position: sticky;
top: 0;
width: 100%;
max-width: 52rem;
min-width: 0;
justify-self: center;
}
.previewDimensions {
position: absolute;
z-index: 2;
top: var(--mantine-spacing-xs);
right: var(--mantine-spacing-xs);
pointer-events: none;
}
.previewSizeControls {
position: absolute;
z-index: 3;
top: var(--mantine-spacing-xs);
left: var(--mantine-spacing-xs);
padding: 0.25rem 0.375rem;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-sm);
background: color-mix(in srgb, var(--mantine-color-body) 92%, transparent);
box-shadow: var(--mantine-shadow-xs);
backdrop-filter: blur(8px);
}
.previewSizeInput {
width: 5rem;
font-variant-numeric: tabular-nums;
}
.previewCanvas {
position: relative;
width: 100%;
height: clamp(24rem, calc(100dvh - 17rem), 44rem);
padding: var(--mantine-spacing-lg);
overflow: hidden;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-8));
background-image:
linear-gradient(to right, color-mix(in srgb, var(--mantine-color-dimmed) 14%, transparent) 1px, transparent 1px),
linear-gradient(to bottom, color-mix(in srgb, var(--mantine-color-dimmed) 14%, transparent) 1px, transparent 1px);
background-size: 2rem 2rem;
}
.previewDivider {
height: auto;
align-self: stretch;
}
.previewViewport {
position: relative;
flex: 0 0 auto;
animation: dock-widget 260ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.previewWidget {
position: absolute;
top: 0;
left: 0;
transform-origin: top left;
}
.previewContent {
width: 100%;
height: 100%;
pointer-events: none;
user-select: none;
}
.settingsPanel {
min-width: 0;
max-height: none;
overflow: visible;
}
.settingsForm {
min-width: 0;
}
.fullWidthField {
grid-column: 1 / -1;
}
.switchOption {
display: flex;
cursor: pointer;
height: 4.5rem;
align-items: center;
padding: 0.625rem 0.75rem;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-sm);
background: var(--mantine-color-body);
}
.switchOption > *,
.switchOption :global(.mantine-Switch-root),
.switchOption :global(.mantine-Switch-body) {
width: 100%;
}
.switchOption :global(.mantine-Switch-body) {
flex-direction: row-reverse;
align-items: center;
justify-content: space-between;
gap: var(--mantine-spacing-md);
}
.switchOption :global(.mantine-Switch-labelWrapper) {
min-width: 0;
flex: 1;
}
.switchOption :global(.mantine-Switch-description) {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.footer {
position: sticky;
z-index: 3;
bottom: calc(var(--mantine-spacing-md) * -1);
margin-inline: calc(var(--mantine-spacing-md) * -1);
margin-bottom: calc(var(--mantine-spacing-md) * -1);
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
padding-bottom: max(var(--mantine-spacing-sm), env(safe-area-inset-bottom));
border-top: 1px solid var(--mantine-color-default-border);
background: color-mix(in srgb, var(--mantine-color-body) 94%, transparent);
backdrop-filter: blur(10px);
}
@keyframes dock-widget {
from {
opacity: 0;
transform: translate(-1.25rem, 1rem) scale(0.92);
}
to {
opacity: 1;
transform: none;
}
}
@media (max-width: 68em) {
.workspace[data-with-preview][data-with-settings] {
grid-template-columns: 1fr;
}
.previewPanel {
position: static;
max-width: 44rem;
}
.previewDivider {
display: none;
}
.previewCanvas {
height: 24rem;
}
}
@media (prefers-reduced-motion: reduce) {
.previewViewport {
animation: none;
}
}
+721 -116
View File
@@ -1,30 +1,61 @@
"use client";
import { useRef, useState } from "react";
import type { FormEvent } from "react";
import { Button, Group, Stack, Tabs } from "@mantine/core";
/* eslint-disable react/no-unstable-nested-components -- Widget modules and definition-bound fallbacks are loaded dynamically. */
import { useEffect, useId, useRef, useState } from "react";
import type { ComponentType, CSSProperties, FormEvent, PropsWithChildren } from "react";
import {
Accordion,
Alert,
Badge,
Box,
Button,
Center,
CloseButton,
ColorInput,
Divider,
Group,
Input as MantineInput,
NumberInput,
SimpleGrid,
Stack,
Tabs,
Text,
TextInput,
Tooltip,
useMantineTheme,
} from "@mantine/core";
import { schemaResolver } from "@mantine/form";
import { useElementSize } from "@mantine/hooks";
import { IconArrowLeft, IconEye, IconPencil, IconSettings } from "@tabler/icons-react";
import { QueryErrorResetBoundary } from "@tanstack/react-query";
import { ErrorBoundary } from "react-error-boundary";
import { z } from "zod/v4";
import { objectEntries } from "@homarr/common";
import { useSession } from "@homarr/auth/client";
import { IntegrationProvider, useSession } from "@homarr/auth/client";
import { useOptionalBoard } from "@homarr/boards/context";
import type { WidgetKind } from "@homarr/definitions";
import { createModal, ModalFormFooter, modalSizeForm, useModalAction } from "@homarr/modals";
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
import type { SettingsContextProps } from "@homarr/settings/creator";
import { SpotlightProvider } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar, TextMultiSelect } from "@homarr/ui";
import { zodErrorMap } from "@homarr/validation/form/i18n";
import { getInputForType } from "../_inputs";
import { FormProvider, useForm } from "../_inputs/form";
import type { BoardItemAdvancedOptions } from "../../../validation/src/shared";
import type { OptionsBuilderResult } from "../options";
import type { WidgetDefinition } from "../definition";
import type { WidgetComponentProps, WidgetDefinition } from "../definition";
import { WidgetError } from "../errors/component";
import { OPTIONS_SUPER_REFINE } from "../options";
import type { IntegrationSelectOption } from "../widget-integration-select";
import { WidgetIntegrationSelect } from "../widget-integration-select";
import { WidgetAdvancedOptionsModal } from "./widget-advanced-options-modal";
import { WidgetCardShell, WidgetTitleBadge } from "../widget-card-shell";
import type { EmbeddedAppEditFormHandle } from "./embedded-app-edit-form";
import { EmbeddedAppEditForm } from "./embedded-app-edit-form";
import classes from "./widget-edit-modal.module.css";
export interface WidgetEditModalState {
options: Record<string, unknown>;
@@ -32,29 +63,356 @@ export interface WidgetEditModalState {
advancedOptions: BoardItemAdvancedOptions;
}
export interface WidgetEditModalSize {
width: number;
height: number;
}
export interface WidgetPreviewDimensions extends WidgetEditModalSize {
scale?: number;
}
interface WidgetEditPreviewResizeOptions {
initialSize: WidgetEditModalSize;
maximumSize: WidgetEditModalSize;
getDimensions?: (size: WidgetEditModalSize) => WidgetPreviewDimensions;
}
export interface EmbeddedIntegrationEditFormHandle {
submitIfDirty: () => Promise<boolean>;
}
export interface EmbeddedIntegrationEditFormProps {
integrationId: string;
handleRef: React.Ref<EmbeddedIntegrationEditFormHandle>;
onSuccess?: () => void;
}
export interface WidgetEditModalProps<TSort extends WidgetKind> {
kind: TSort;
definition: WidgetDefinition;
value: WidgetEditModalState;
onSuccessfulEdit: (value: WidgetEditModalState) => void;
onSuccessfulEdit: (value: WidgetEditModalState, size?: WidgetEditModalSize) => void;
integrationData: IntegrationSelectOption[];
integrationSupport: boolean;
settings: SettingsContextProps;
itemId?: string;
boardId?: string;
appId?: string;
integrationEditForm?: ComponentType<EmbeddedIntegrationEditFormProps>;
onIntegrationSaved?: () => void;
onOpenNewIntegration?: (onCreated?: (id: string) => void) => void;
previewComponent?: ComponentType<WidgetComponentProps<TSort>>;
previewDimensions?: WidgetPreviewDimensions;
previewSectionId?: string;
previewResize?: WidgetEditPreviewResizeOptions;
previewWrapper?: ComponentType<PropsWithChildren>;
}
interface WidgetEditPreviewProps {
kind: WidgetKind;
Component: ComponentType<WidgetComponentProps<WidgetKind>>;
definition: WidgetDefinition;
state: WidgetEditModalState;
itemId?: string;
boardId?: string;
dimensions?: WidgetPreviewDimensions;
sectionId?: string;
integrationData: IntegrationSelectOption[];
onChangeOptions: (newOptions: Record<string, unknown>) => void;
resize?: {
size: WidgetEditModalSize;
maximumSize: WidgetEditModalSize;
onChange: (size: WidgetEditModalSize) => void;
};
PreviewWrapper?: ComponentType<PropsWithChildren>;
}
const PreviewRuntimeBoundary = ({
Wrapper,
children,
}: PropsWithChildren<{ Wrapper?: ComponentType<PropsWithChildren> }>) => {
if (!Wrapper) return children;
return <Wrapper>{children}</Wrapper>;
};
const normalizePreviewSize = (size: WidgetEditModalSize, maximumSize: WidgetEditModalSize): WidgetEditModalSize => ({
width: Math.max(1, Math.min(Math.round(size.width), maximumSize.width)),
height: Math.max(1, Math.min(Math.round(size.height), maximumSize.height)),
});
const WidgetEditPreview = ({
kind,
Component,
definition,
state,
itemId,
boardId,
dimensions = { width: 400, height: 240 },
sectionId,
integrationData,
onChangeOptions,
resize,
PreviewWrapper,
}: WidgetEditPreviewProps) => {
const t = useI18n();
const tItem = useI18n("item.edit");
const board = useOptionalBoard();
const generatedPreviewId = useId().replaceAll(":", "");
const { ref, width: availableWidth, height: availableHeight } = useElementSize<HTMLDivElement>();
const { ref: cardRef, width: contentWidth, height: contentHeight } = useElementSize<HTMLDivElement>();
const [boardAppearance, setBoardAppearance] = useState<{
scale: number;
width?: number;
height?: number;
inset: number;
style: CSSProperties;
}>();
useEffect(() => {
let item: HTMLElement | null = null;
if (itemId) {
item = document.querySelector<HTMLElement>(
`[data-grid-item-id="${CSS.escape(itemId)}"] [data-grid-item-content]`,
);
}
const canvas = document.querySelector<HTMLElement>("[data-board-canvas-content]");
let section: HTMLElement | null = null;
if (sectionId) section = document.querySelector<HTMLElement>(`[data-grid-section-id="${CSS.escape(sectionId)}"]`);
section ??= item?.closest<HTMLElement>("[data-grid-section-id]") ?? null;
const source = item ?? section ?? canvas;
if (!source) return;
const updateAppearance = () => {
const computed = getComputedStyle(source);
// Inherit board sizing, not the existing item's custom CSS overrides.
const inherited = getComputedStyle(section ?? canvas ?? source);
const style = Object.fromEntries(
Array.from(inherited)
.filter((property) =>
/^--(?:mantine-(?:scale$|spacing-|font-size-|radius-|h\d-font-size)|board-canvas-)/.test(property),
)
.map((property) => [property, inherited.getPropertyValue(property)]),
) as CSSProperties;
setBoardAppearance({
scale: source.getBoundingClientRect().width / Number.parseFloat(computed.width),
width: item ? Number.parseFloat(computed.width) : undefined,
height: item ? Number.parseFloat(computed.height) : undefined,
// SectionGrid uses a fixed 10px visual inset on each side of new widgets.
inset: item ? 0 : 10 * (Number.parseFloat(inherited.getPropertyValue("--board-canvas-inverse-scale")) || 1),
style,
});
};
updateAppearance();
const observer = new ResizeObserver(updateAppearance);
observer.observe(source);
if (canvas && canvas !== source) observer.observe(canvas);
const mutationObserver = new MutationObserver(updateAppearance);
if (canvas) mutationObserver.observe(canvas, { attributes: true, attributeFilter: ["style", "class"] });
if (section) mutationObserver.observe(section, { attributes: true, attributeFilter: ["style", "class"] });
return () => {
mutationObserver.disconnect();
observer.disconnect();
};
}, [itemId, sectionId, board?.id]);
const sourceWidth = Math.max(boardAppearance?.width ?? dimensions.width - 2 * (boardAppearance?.inset ?? 0), 1);
const sourceHeight = Math.max(boardAppearance?.height ?? dimensions.height - 2 * (boardAppearance?.inset ?? 0), 1);
let displayScale = boardAppearance?.scale ?? dimensions.scale ?? 1;
if (!Number.isFinite(displayScale) || displayScale <= 0) displayScale = 1;
let previewScale = displayScale;
if (availableWidth > 0 && availableHeight > 0) {
previewScale = Math.min(availableWidth / sourceWidth, availableHeight / sourceHeight, displayScale);
}
const previewWidth = sourceWidth * previewScale;
const previewHeight = sourceHeight * previewScale;
const hasIntegrationSupport = "supportedIntegrations" in definition;
const integrationRequired = hasIntegrationSupport && definition.integrationsRequired !== false;
const isMissingIntegration = integrationRequired && state.integrationIds.length === 0;
const previewIntegrations = integrationData
.filter((integration) => state.integrationIds.includes(integration.id))
.map((integration) => ({
id: integration.id,
permissions: integration.permissions ?? {
hasFullAccess: false,
hasInteractAccess: false,
hasUseAccess: false,
},
}));
let componentItemId = itemId;
if (kind === "assistant" || kind === "timer") {
componentItemId = `widget-preview-${generatedPreviewId}`;
}
const isPendingCustomWidget = kind === "customApi" && !itemId;
const previewOpacity = (board?.opacity ?? 100) / 100;
const handleResizeValue = (dimension: keyof WidgetEditModalSize, value: string | number) => {
if (typeof value !== "number" || !Number.isFinite(value) || !resize) return;
resize.onChange(
normalizePreviewSize(
{
...resize.size,
[dimension]: value,
},
resize.maximumSize,
),
);
};
return (
<Stack className={classes.previewPanel} gap={0}>
<Center ref={ref} className={classes.previewCanvas}>
{resize && (
<Group className={classes.previewSizeControls} gap={6} wrap="nowrap">
<Text size="xs" fw={600} c="dimmed">
{tItem("preview.size")}
</Text>
<Tooltip label={t("item.moveResize.field.width.label")}>
<NumberInput
className={classes.previewSizeInput}
aria-label={t("item.moveResize.field.width.label")}
value={resize.size.width}
onChange={(value) => handleResizeValue("width", value)}
min={1}
max={resize.maximumSize.width}
step={1}
allowDecimal={false}
allowNegative={false}
clampBehavior="strict"
size="xs"
leftSection={t("item.moveResize.field.width.shortLabel")}
leftSectionPointerEvents="none"
/>
</Tooltip>
<Text size="xs" c="dimmed" aria-hidden>
×
</Text>
<Tooltip label={t("item.moveResize.field.height.label")}>
<NumberInput
className={classes.previewSizeInput}
aria-label={t("item.moveResize.field.height.label")}
value={resize.size.height}
onChange={(value) => handleResizeValue("height", value)}
min={1}
max={resize.maximumSize.height}
step={1}
allowDecimal={false}
allowNegative={false}
clampBehavior="strict"
size="xs"
leftSection={t("item.moveResize.field.height.shortLabel")}
leftSectionPointerEvents="none"
/>
</Tooltip>
</Group>
)}
<Badge className={classes.previewDimensions} size="xs" variant="light" color="gray">
{Math.round(sourceWidth)} × {Math.round(sourceHeight)}
</Badge>
{isMissingIntegration ? (
<Alert color="gray" variant="light" icon={<IconEye size={18} />} maw={320}>
{tItem("preview.integrationRequired")}
</Alert>
) : (
<Box
className={classes.previewViewport}
style={{ ...boardAppearance?.style, width: previewWidth, height: previewHeight }}
>
<WidgetCardShell
innerRef={cardRef}
className={classes.previewWidget}
kind={kind}
advancedOptions={state.advancedOptions}
opacity={previewOpacity}
radius={board?.itemRadius}
p={0}
data-grid-item-content
style={{
width: sourceWidth,
height: sourceHeight,
transform: `scale(${previewScale})`,
}}
>
<WidgetTitleBadge
advancedOptions={state.advancedOptions}
opacity={previewOpacity}
radius={board?.itemRadius}
/>
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
resetKeys={[state.options, state.integrationIds]}
fallbackRender={({ resetErrorBoundary, error }) => (
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
)}
>
<PreviewRuntimeBoundary Wrapper={PreviewWrapper}>
<SpotlightProvider>
<IntegrationProvider integrations={previewIntegrations}>
<div className={classes.previewContent} inert>
<Component
options={state.options as never}
integrationIds={state.integrationIds}
width={contentWidth || dimensions.width}
height={contentHeight || dimensions.height}
displayScale={displayScale}
isEditMode={isPendingCustomWidget}
displayMode="compact"
boardId={boardId}
itemId={componentItemId}
setOptions={({ newOptions }) => onChangeOptions(newOptions as Record<string, unknown>)}
/>
</div>
</IntegrationProvider>
</SpotlightProvider>
</PreviewRuntimeBoundary>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
</WidgetCardShell>
</Box>
)}
</Center>
</Stack>
);
};
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 tItem = useI18n("item.edit");
const tCommon = useI18n("common");
const board = useOptionalBoard();
const { data: session } = useSession();
const [advancedOptions, setAdvancedOptions] = useState<BoardItemAdvancedOptions>(innerProps.value.advancedOptions);
const theme = useMantineTheme();
const appEditRef = useRef<EmbeddedAppEditFormHandle>(null);
const integrationEditHandles = useRef(new Map<string, EmbeddedIntegrationEditFormHandle>());
const integrationEditRefCallbacks = useRef(
new Map<string, (handle: EmbeddedIntegrationEditFormHandle | null) => void>(),
);
const [mountedIntegrationIds, setMountedIntegrationIds] = useState<string[]>([]);
const [activeIntegrationId, setActiveIntegrationId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [previewSize, setPreviewSize] = useState<WidgetEditModalSize | null>(() => {
const resize = innerProps.previewResize;
if (!resize) return null;
return normalizePreviewSize(resize.initialSize, resize.maximumSize);
});
const widgetFormId = useId();
z.config({
customError: zodErrorMap(t),
});
const { definition } = innerProps;
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)
@@ -73,14 +431,19 @@ 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: z.array(z.string()),
integrationIds: integrationIdsSchema,
advancedOptions: z.object({
title: z.string().max(64).nullable(),
customCssClasses: z.array(z.string()),
borderColor: z.string(),
}),
@@ -90,11 +453,65 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
validateInputOnBlur: true,
validateInputOnChange: true,
});
const { openModal } = useModalAction(WidgetAdvancedOptionsModal);
const canModifyApps = session?.user.permissions.includes("app-modify-all") ?? false;
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 editableIntegrationIds = selectedIntegrations
.filter((integration) => canModifyAllIntegrations || integration.permissions?.hasFullAccess === true)
.map((integration) => integration.id);
let soleEditableIntegrationId: string | null = null;
if (selectedIntegrations.length === 1) {
soleEditableIntegrationId = editableIntegrationIds[0] ?? null;
}
let displayedIntegrationId = soleEditableIntegrationId;
if (activeIntegrationId && editableIntegrationIds.includes(activeIntegrationId)) {
displayedIntegrationId = activeIntegrationId;
}
const integrationIdsToRender = mountedIntegrationIds.filter((id) => editableIntegrationIds.includes(id));
if (displayedIntegrationId && !integrationIdsToRender.includes(displayedIntegrationId)) {
integrationIdsToRender.push(displayedIntegrationId);
}
const IntegrationEditForm = innerProps.integrationEditForm;
const showIntegrationTab = Boolean(IntegrationEditForm) && 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 getIntegrationEditRef = (integrationId: string) => {
const existingCallback = integrationEditRefCallbacks.current.get(integrationId);
if (existingCallback) {
return existingCallback;
}
const callback = (handle: EmbeddedIntegrationEditFormHandle | null) => {
if (handle) {
integrationEditHandles.current.set(integrationId, handle);
} else {
integrationEditHandles.current.delete(integrationId);
}
};
integrationEditRefCallbacks.current.set(integrationId, callback);
return callback;
};
const beginEditingIntegration = (integrationId: string) => {
setMountedIntegrationIds((current) => {
if (current.includes(integrationId)) {
return current;
}
return [...current, integrationId];
});
setActiveIntegrationId(integrationId);
};
const handleSubmit = form.onSubmit(async (values) => {
setIsSubmitting(true);
@@ -105,10 +522,23 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
return;
}
innerProps.onSuccessfulEdit({
...values,
advancedOptions,
});
for (const integrationEditHandle of integrationEditHandles.current.values()) {
const integrationSaved = await integrationEditHandle.submitIfDirty();
if (!integrationSaved) {
return;
}
}
innerProps.onSuccessfulEdit(
{
...values,
advancedOptions: {
...values.advancedOptions,
title: values.advancedOptions.title?.trim() || null,
},
},
previewSize ?? undefined,
);
actions.closeModal();
} finally {
setIsSubmitting(false);
@@ -119,120 +549,294 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
handleSubmit(event);
};
const canCreateIntegration = maxIntegrations === 1 || form.values.integrationIds.length < maxIntegrations;
const handleOpenNewIntegration = innerProps.onOpenNewIntegration
? () => {
innerProps.onOpenNewIntegration?.((newId) => {
if (newId) {
form.setFieldValue("integrationIds", [...(maxIntegrations > 1 ? form.values.integrationIds : []), newId]);
}
});
}
: undefined;
const handlePreviewOptionsChange = (newOptions: Record<string, unknown>) => {
form.setFieldValue("options", {
...form.values.options,
...newOptions,
});
};
const selectedIntegrationKinds = innerProps.integrationData
.filter(({ id }) => form.values.integrationIds.includes(id))
.map(({ kind }) => kind);
const visibleOptions = Object.entries(options).filter(([, value]) => {
const Input = getInputForType(value.type);
if (!Input) {
return false;
}
return !value.shouldHide?.(form.values.options as never, selectedIntegrationKinds);
});
const switchOptions = visibleOptions.filter(([, value]) => value.type === "switch");
const otherOptions = visibleOptions.filter(([, value]) => value.type !== "switch");
const hasWidgetSettings = canConfigureWidget && (innerProps.integrationSupport || visibleOptions.length > 0);
const previewResize = innerProps.previewResize;
let previewDimensions = innerProps.previewDimensions;
let resizeControls: WidgetEditPreviewProps["resize"];
if (previewSize && previewResize?.getDimensions) {
previewDimensions = previewResize.getDimensions(previewSize);
resizeControls = {
size: previewSize,
maximumSize: previewResize.maximumSize,
onChange: setPreviewSize,
};
}
const widgetFormContent = (
<Stack>
{innerProps.integrationSupport && (
<WidgetIntegrationSelect
label={t("item.edit.field.integrations.label")}
data={innerProps.integrationData}
canSelectMultiple={((definition as { maxIntegrations?: number }).maxIntegrations ?? Infinity) > 1}
{...form.getInputProps("integrationIds")}
<Box
className={classes.workspace}
data-with-preview={innerProps.previewComponent ? true : undefined}
data-with-settings
>
{innerProps.previewComponent && (
<WidgetEditPreview
kind={innerProps.kind}
Component={innerProps.previewComponent}
definition={definition}
state={form.values}
itemId={innerProps.itemId}
boardId={innerProps.boardId ?? board?.id}
dimensions={previewDimensions}
sectionId={innerProps.previewSectionId}
integrationData={innerProps.integrationData}
onChangeOptions={handlePreviewOptionsChange}
resize={resizeControls}
PreviewWrapper={innerProps.previewWrapper}
/>
)}
{Object.entries(options).map(([key, value]) => {
const Input = getInputForType(value.type);
{innerProps.previewComponent && <Divider orientation="vertical" className={classes.previewDivider} />}
<form id={widgetFormId} className={classes.settingsForm} onSubmit={onFormSubmit}>
<Stack className={classes.settingsPanel} gap="sm">
{hasWidgetSettings && (
<>
<Group gap="xs" wrap="nowrap">
<IconSettings size={16} />
<Text fw={600} size="sm">
{tItem("settings.label")}
</Text>
</Group>
{canConfigureWidget && innerProps.integrationSupport && (
<Box className={classes.fullWidthField}>
<WidgetIntegrationSelect
label={tItem("field.integrations.label")}
data={innerProps.integrationData}
canSelectMultiple={maxIntegrations > 1}
withAsterisk={integrationsRequired}
onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined}
{...form.getInputProps("integrationIds")}
/>
</Box>
)}
{canConfigureWidget && (
<Stack gap="sm">
{switchOptions.length > 0 && (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm" verticalSpacing="sm">
{switchOptions.map(([key, value]) => {
const Input = getInputForType(value.type);
if (!Input) {
return null;
}
if (
!Input ||
value.shouldHide?.(
form.values.options as never,
innerProps.integrationData
.filter(({ id }) => form.values.integrationIds.includes(id))
.map(({ kind }) => kind),
)
) {
return null;
}
return (
<Box
key={key}
className={classes.switchOption}
data-option-type={value.type}
onClick={(event) => {
const target = event.target;
if (target instanceof Element && target.closest("label, input")) return;
const input = event.currentTarget.querySelector<HTMLInputElement>("input[type=checkbox]");
if (!input?.disabled) input?.click();
}}
>
<Input
kind={innerProps.kind}
property={key}
options={value as never}
initialOptions={innerProps.value.options}
itemId={innerProps.itemId}
boardId={innerProps.boardId ?? board?.id}
/>
</Box>
);
})}
</SimpleGrid>
)}
{otherOptions.map(([key, value]) => {
const Input = getInputForType(value.type);
if (!Input) {
return null;
}
return (
<Input
key={key}
kind={innerProps.kind}
property={key}
options={value as never}
initialOptions={innerProps.value.options}
itemId={innerProps.itemId}
/>
);
})}
{showAppTab ? (
<Button
variant="subtle"
type="button"
onClick={() =>
openModal({
advancedOptions,
onSuccess(options) {
setAdvancedOptions(options);
innerProps.onSuccessfulEdit({
...innerProps.value,
advancedOptions: options,
});
},
})
}
>
{t("item.edit.advancedOptions.label")}
</Button>
) : (
<ModalFormFooter
onCancel={actions.closeModal}
leftSection={
<Button
variant="subtle"
type="button"
onClick={() =>
openModal({
advancedOptions,
onSuccess(options) {
setAdvancedOptions(options);
innerProps.onSuccessfulEdit({
...innerProps.value,
advancedOptions: options,
});
},
})
}
>
{t("item.edit.advancedOptions.label")}
</Button>
}
/>
)}
</Stack>
return (
<Box key={key} data-option-type={value.type}>
<Input
kind={innerProps.kind}
property={key}
options={value as never}
initialOptions={innerProps.value.options}
itemId={innerProps.itemId}
boardId={innerProps.boardId ?? board?.id}
/>
</Box>
);
})}
</Stack>
)}
</>
)}
<Accordion variant="contained">
<Accordion.Item value="advanced-options">
<Accordion.Control>{tItem("advancedOptions.label")}</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<TextInput
label={tItem("field.title.label")}
rightSection={
<MantineInput.ClearButton
onClick={() => form.setFieldValue("advancedOptions.title", "")}
disabled={!form.values.advancedOptions.title}
/>
}
{...form.getInputProps("advancedOptions.title")}
value={form.values.advancedOptions.title ?? ""}
/>
<TextMultiSelect
label={tItem("field.customCssClasses.label")}
{...form.getInputProps("advancedOptions.customCssClasses")}
/>
<ColorInput
label={tItem("field.borderColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
rightSection={
<CloseButton
aria-label={tCommon("action.remove")}
onClick={() => form.setFieldValue("advancedOptions.borderColor", "")}
style={{ display: form.values.advancedOptions.borderColor ? undefined : "none" }}
/>
}
{...form.getInputProps("advancedOptions.borderColor")}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
</form>
</Box>
);
const footer = (
<Box className={classes.footer}>
<ModalFormFooter onCancel={actions.closeModal} form={widgetFormId} loading={isSubmitting} />
</Box>
);
return (
<form onSubmit={onFormSubmit}>
<FormProvider form={form}>
{showAppTab ? (
<Stack>
<Tabs defaultValue="widget">
<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>
</Tabs.List>
<Tabs.Panel value="widget" pt="md">
{widgetFormContent}
</Tabs.Panel>
<Tabs.Panel value="app" pt="md">
<FormProvider form={form}>
<Stack gap="sm">
{showResourceTabs ? (
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List grow>
<Tabs.Tab value="widget">{tItem("tab.widget")}</Tabs.Tab>
{showAppTab && <Tabs.Tab value="app">{tItem("tab.app")}</Tabs.Tab>}
{showIntegrationTab && <Tabs.Tab value="integration">{tItem("tab.integration")}</Tabs.Tab>}
</Tabs.List>
<Tabs.Panel value="widget" pt="sm">
{widgetFormContent}
</Tabs.Panel>
{showAppTab && (
<Tabs.Panel value="app" pt="sm">
{appId && <EmbeddedAppEditForm appId={appId} handleRef={appEditRef} />}
</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>
</Stack>
)}
{showIntegrationTab && (
<Tabs.Panel value="integration" pt="sm">
<Stack>
{displayedIntegrationId === null && (
<>
<Text size="sm" c="dimmed">
{tItem("integration.description")}
</Text>
{selectedIntegrations.map((integration) => {
const canEdit = editableIntegrationIds.includes(integration.id);
return (
<Group key={integration.id} justify="space-between" wrap="nowrap" gap="sm">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<IntegrationAvatar kind={integration.kind} size="sm" />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={600} size="sm" truncate>
{integration.name}
</Text>
<Text size="xs" c="dimmed" truncate>
{integration.url}
</Text>
</Stack>
</Group>
{canEdit ? (
<Button
type="button"
variant="light"
leftSection={<IconPencil size={16} />}
onClick={() => beginEditingIntegration(integration.id)}
aria-label={tItem("integration.editLabel", { name: integration.name })}
>
{tItem("integration.action")}
</Button>
) : (
<Text size="xs" c="dimmed" ta="end">
{tItem("integration.fullAccessRequired")}
</Text>
)}
</Group>
);
})}
</>
)}
{displayedIntegrationId !== null && selectedIntegrations.length > 1 && (
<Button
type="button"
variant="subtle"
leftSection={<IconArrowLeft size={16} />}
onClick={() => setActiveIntegrationId(null)}
style={{ alignSelf: "start" }}
>
{tCommon("action.previous")}
</Button>
)}
{integrationIdsToRender.map((integrationId) => (
<Box key={integrationId} hidden={displayedIntegrationId !== integrationId}>
{IntegrationEditForm && (
<IntegrationEditForm
integrationId={integrationId}
handleRef={getIntegrationEditRef(integrationId)}
onSuccess={innerProps.onIntegrationSaved}
/>
)}
</Box>
))}
</Stack>
</Tabs.Panel>
)}
</Tabs>
) : (
widgetFormContent
)}
</FormProvider>
</form>
{footer}
</Stack>
</FormProvider>
);
}).withOptions({
keepMounted: true,
@@ -240,4 +844,5 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
return t("item.edit.title");
},
size: modalSizeForm,
closeOnClickOutside: false,
});