"use client"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Accordion, ActionIcon, Button, Checkbox, Code, Divider, Fieldset, Group, Image, Loader, Select, Stack, Text, TextInput, Title, Tooltip, } from "@mantine/core"; import type { CheckboxProps } from "@mantine/core"; import type { FormErrors } from "@mantine/form"; import { useDebouncedValue } from "@mantine/hooks"; import { IconAlertTriangleFilled, IconBrandDocker, IconEdit, IconPlus, IconSquare, IconSquareCheck, IconTrash, IconTriangleFilled, } from "@tabler/icons-react"; import { escapeForRegEx } from "@tiptap/react"; import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; import { createId } from "@homarr/common"; import { getIconUrl } from "@homarr/definitions"; import type { IntegrationKind } from "@homarr/definitions"; import { findBestIconMatch, IconPicker } from "@homarr/forms-collection"; import { createModal, useModalAction } from "@homarr/modals"; import { useScopedI18n } from "@homarr/translation/client"; import { MaskedImage } from "@homarr/ui"; import type { ReleasesRepository, ReleasesVersionFilter } from "../releases/releases-repository"; import { WidgetIntegrationSelect } from "../widget-integration-select"; import type { IntegrationSelectOption } from "../widget-integration-select"; import type { CommonWidgetInputProps } from "./common"; import { useWidgetInputTranslation } from "./common"; import { useFormContext } from "./form"; interface FormValidation { hasErrors: boolean; errors: FormErrors; } interface Integration extends IntegrationSelectOption { iconUrl: string; } export const WidgetMultiReleasesRepositoriesInput = ({ property, kind, }: CommonWidgetInputProps<"multiReleasesRepositories">) => { const t = useWidgetInputTranslation(kind, property); const tRepository = useScopedI18n("widget.releases.option.repositories"); const form = useFormContext(); const repositories = form.values.options[property] as ReleasesRepository[]; const { openModal: openEditModal } = useModalAction(RepositoryEditModal); const { openModal: openImportModal } = useModalAction(RepositoryImportModal); const versionFilterPrecisionOptions = useMemo( () => [tRepository("versionFilter.precision.options.none"), "#", "#.#", "#.#.#", "#.#.#.#", "#.#.#.#.#"], [tRepository], ); const { data: session } = useSession(); const isAdmin = session?.user.permissions.includes("admin") ?? false; const integrationsApi = clientApi.integration.allOfGivenCategory.useQuery( { category: "releasesProvider", }, { refetchOnMount: false, refetchOnWindowFocus: false, refetchOnReconnect: false, }, ); const integrations = useMemo( () => integrationsApi.data?.reduce>((acc, integration) => { acc[integration.id] = { id: integration.id, name: integration.name, url: integration.url, kind: integration.kind, iconUrl: getIconUrl(integration.kind), }; return acc; }, {}) ?? {}, [integrationsApi], ); const onRepositorySave = useCallback( (repository: ReleasesRepository, index: number): FormValidation => { form.setFieldValue(`options.${property}.${index}.providerIntegrationId`, repository.providerIntegrationId); form.setFieldValue(`options.${property}.${index}.identifier`, repository.identifier); form.setFieldValue(`options.${property}.${index}.name`, repository.name); form.setFieldValue(`options.${property}.${index}.versionFilter`, repository.versionFilter); form.setFieldValue(`options.${property}.${index}.iconUrl`, repository.iconUrl); const formValidation = form.validate(); const fieldErrors: FormErrors = Object.entries(formValidation.errors).reduce((acc, [key, value]) => { if (key.startsWith(`options.${property}.${index}.`)) { acc[key] = value; } return acc; }, {} as FormErrors); return { hasErrors: Object.keys(fieldErrors).length > 0, errors: fieldErrors, }; }, [form, property], ); const addNewRepository = () => { const repository: ReleasesRepository = { id: createId(), identifier: "", }; form.setValues((previous) => { const previousValues = previous.options?.[property] as ReleasesRepository[]; return { ...previous, options: { ...previous.options, [property]: [...previousValues, repository], }, }; }); const index = repositories.length; openEditModal({ fieldPath: `options.${property}.${index}`, repository, onRepositorySave: (saved) => onRepositorySave(saved, index), onRepositoryCancel: () => onRepositoryRemove(index), versionFilterPrecisionOptions, integrations, }); }; const onRepositoryRemove = (index: number) => { form.setValues((previous) => { const previousValues = previous.options?.[property] as ReleasesRepository[]; return { ...previous, options: { ...previous.options, [property]: previousValues.filter((_, i) => i !== index), }, }; }); }; return (
{repositories.map((repository, index) => { const integration = repository.providerIntegrationId ? integrations[repository.providerIntegrationId] : undefined; return ( {integration?.name ?? ""} {/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */} {repository.name || repository.identifier} onRepositoryRemove(index)}> {Object.keys(form.errors).filter((key) => key.startsWith(`options.${property}.${index}.`)).length > 0 && ( {tRepository("invalid")} )} ); })}
); }; const formatVersionFilterRegex = (versionFilter: ReleasesVersionFilter | undefined) => { if (!versionFilter) return undefined; const escapedPrefix = versionFilter.prefix ? escapeForRegEx(versionFilter.prefix) : ""; const precision = "[0-9]+\\.".repeat(versionFilter.precision).slice(0, -2); const escapedSuffix = versionFilter.suffix ? escapeForRegEx(versionFilter.suffix) : ""; return `^${escapedPrefix}${precision}${escapedSuffix}$`; }; const formatIdentifierName = (identifier: string) => { const unformattedName = identifier.split("/").pop(); return unformattedName?.replace(/[-_]/g, " ").replace(/(?:^\w|[A-Z]|\b\w)/g, (char) => char.toUpperCase()) ?? ""; }; interface RepositoryEditProps { fieldPath: string; repository: ReleasesRepository; onRepositorySave: (repository: ReleasesRepository) => FormValidation; onRepositoryCancel?: () => void; versionFilterPrecisionOptions: string[]; integrations: Record; } const RepositoryEditModal = createModal(({ innerProps, actions }) => { const tRepository = useScopedI18n("widget.releases.option.repositories"); const [loading, setLoading] = useState(false); const [tempRepository, setTempRepository] = useState(() => ({ ...innerProps.repository })); const [formErrors, setFormErrors] = useState({}); const integrationSelectOptions: IntegrationSelectOption[] = useMemo( () => Object.values(innerProps.integrations), [innerProps.integrations], ); // Allows user to not select an icon by removing the url from the input, // will only try and get an icon if the name or identifier changes const [autoSetIcon, setAutoSetIcon] = useState(false); // Debounce the name value with 200ms delay const [debouncedName] = useDebouncedValue(tempRepository.name, 800); const handleConfirm = useCallback(() => { setLoading(true); const validation = innerProps.onRepositorySave(tempRepository); setFormErrors(validation.errors); if (!validation.hasErrors) { actions.closeModal(); } setLoading(false); }, [innerProps, tempRepository, actions]); const handleCancel = useCallback(() => { if (innerProps.onRepositoryCancel) { innerProps.onRepositoryCancel(); } actions.closeModal(); }, [innerProps, actions]); const handleChange = useCallback((changedValue: Partial) => { setTempRepository((prev) => ({ ...prev, ...changedValue })); }, []); // Auto-select icon based on identifier formatted name with debounced search const { data: iconsData } = clientApi.icon.findIcons.useQuery( { searchText: debouncedName, }, { enabled: autoSetIcon && (debouncedName?.length ?? 0) > 3, }, ); useEffect(() => { if (autoSetIcon && debouncedName && !tempRepository.iconUrl && iconsData?.icons) { const bestMatch = findBestIconMatch(debouncedName, iconsData.icons); if (bestMatch) { handleChange({ iconUrl: bestMatch }); } } }, [debouncedName, iconsData, tempRepository, handleChange, autoSetIcon]); return (
{ handleChange({ providerIntegrationId: value.length > 0 ? value.pop() : undefined }); }} />
{ const name = tempRepository.name === undefined || formatIdentifierName(tempRepository.identifier) === tempRepository.name ? formatIdentifierName(event.currentTarget.value) : tempRepository.name; handleChange({ identifier: event.currentTarget.value, name, }); if (event.currentTarget.value) setAutoSetIcon(true); }} error={formErrors[`${innerProps.fieldPath}.identifier`]} style={{ flex: 0.7 }} />
{ handleChange({ name: event.currentTarget.value }); if (event.currentTarget.value) setAutoSetIcon(true); }} error={formErrors[`${innerProps.fieldPath}.name`]} style={{ flex: 0.3 }} />
{ if (url === "") { setAutoSetIcon(false); handleChange({ iconUrl: undefined }); } else { handleChange({ iconUrl: url }); } }} error={formErrors[`${innerProps.fieldPath}.iconUrl`] as string} />
{ handleChange({ versionFilter: { ...(tempRepository.versionFilter ?? { precision: 0 }), prefix: event.currentTarget.value, }, }); }} error={formErrors[`${innerProps.fieldPath}.versionFilter.prefix`]} disabled={!tempRepository.versionFilter} />