merge: origin/dev (widget-only integration refactor)

Resolve conflicts keeping our simplified request handlers (with TTL
cache) and adopting dev's new release-provider + timetable direct
implementations. Remove per-query staleTime from timetable widget.
This commit is contained in:
Thomas Camlong committed 2026-07-02 18:19:46 +02:00
commit 356fadb9d6
91 files changed
+2306 -2545

No files matched your search

+1
View File
@@ -8,6 +8,7 @@ export interface CommonWidgetInputProps<TKey extends WidgetOptionType> {
property: string;
options: Omit<WidgetOptionOfType<TKey>, "defaultValue" | "type">;
initialOptions: Record<string, unknown>;
itemId?: string;
}
type UseWidgetInputTranslationReturnType = (key: "label" | "description") => string;
@@ -24,7 +24,11 @@ export const WidgetDynamicSelectInput = ({ property, kind, options }: CommonWidg
const inputProps = form.getInputProps(`options.${property}`);
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 300);
const { isPending, options: selectOptions } = options.useOptions(debouncedSearch, form.values.integrationIds);
const { isPending, options: selectOptions } = options.useOptions(
debouncedSearch,
form.values.integrationIds,
form.values.options,
);
const currentOption = inputProps.value as DynamicSelectOption | null;
const onChange = inputProps.onChange as (value: DynamicSelectOption | null) => void;
@@ -12,6 +12,7 @@ import {
Group,
Image,
Loader,
PasswordInput,
Select,
Stack,
Text,
@@ -27,6 +28,7 @@ import {
IconCopy,
IconCopyCheckFilled,
IconEdit,
IconKey,
IconPackageImport,
IconPlus,
IconTrash,
@@ -38,16 +40,20 @@ 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 type { ReleaseProviderKind } from "@homarr/definitions";
import {
getReleaseProviderDefaultUrl,
getReleaseProviderIconUrl,
getReleaseProviderName,
normalizeReleaseProviderIdentifier,
releaseProviderKinds,
} 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";
@@ -57,13 +63,10 @@ interface FormValidation {
errors: FormErrors;
}
interface Integration extends IntegrationSelectOption {
iconUrl: string;
}
export const WidgetMultiReleasesRepositoriesInput = ({
property,
kind,
itemId,
}: CommonWidgetInputProps<"multiReleasesRepositories">) => {
const t = useWidgetInputTranslation(kind, property);
const tRepository = useScopedI18n("widget.releases.option.repositories");
@@ -78,36 +81,19 @@ export const WidgetMultiReleasesRepositoriesInput = ({
const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const integrationsApi = clientApi.integration.allOfGivenCategory.useQuery(
{
category: "releasesProvider",
},
{
refetchOnMount: false,
},
);
const integrations = useMemo(
() =>
integrationsApi.data?.reduce<Record<string, Integration>>((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 providerOptions = useMemo(
() => releaseProviderKinds.map((provider) => ({ value: provider, label: getReleaseProviderName(provider) })),
[],
);
const onRepositorySave = useCallback(
(repository: ReleasesRepository, index: number): FormValidation => {
form.setFieldValue(`options.${property}.${index}.providerIntegrationId`, repository.providerIntegrationId);
form.setFieldValue(`options.${property}.${index}.provider`, repository.provider);
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);
form.setFieldValue(`options.${property}.${index}.providerUrl`, repository.providerUrl);
const formValidation = form.validate();
const fieldErrors: FormErrors = Object.entries(formValidation.errors).reduce((acc, [key, value]) => {
@@ -150,7 +136,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({
onRepositorySave: (saved) => onRepositorySave(saved, index),
onRepositoryCancel: () => onRepositoryRemove(index),
versionFilterPrecisionOptions,
integrations,
providerOptions,
});
};
@@ -181,7 +167,6 @@ export const WidgetMultiReleasesRepositoriesInput = ({
onClick={() =>
openImportModal({
repositories,
integrations,
versionFilterPrecisionOptions,
onConfirm: (selectedRepositories) => {
if (!selectedRepositories.length) return;
@@ -208,22 +193,22 @@ export const WidgetMultiReleasesRepositoriesInput = ({
<Divider my="sm" />
{repositories.map((repository, index) => {
const integration = repository.providerIntegrationId
? integrations[repository.providerIntegrationId]
: undefined;
const providerName = repository.provider ? getReleaseProviderName(repository.provider) : "";
return (
<Stack key={repository.id} gap={5}>
<Group align="center" gap="xs">
<Image
src={repository.iconUrl ?? integration?.iconUrl ?? null}
src={
repository.iconUrl ?? (repository.provider ? getReleaseProviderIconUrl(repository.provider) : null)
}
style={{
height: "1.2em",
width: "1.2em",
}}
/>
<Text c="dimmed" fw={100} size="xs">
{integration?.name ?? ""}
<Text c="dimmed" size="xs">
{providerName}
</Text>
<Group justify="space-between" align="center" style={{ flex: 1 }} gap={5}>
@@ -240,7 +225,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({
repository,
onRepositorySave: (saved) => onRepositorySave(saved, index),
versionFilterPrecisionOptions,
integrations,
providerOptions,
})
}
variant="light"
@@ -267,10 +252,125 @@ export const WidgetMultiReleasesRepositoriesInput = ({
);
})}
</Stack>
{itemId && <ProviderTokensSection itemId={itemId} repositories={repositories} />}
</Fieldset>
);
};
const providersWithAuth: ReleaseProviderKind[] = [
"github",
"gitHubContainerRegistry",
"dockerHub",
"gitlab",
"npm",
"codeberg",
];
const ProviderTokensSection = ({ itemId, repositories }: { itemId: string; repositories: ReleasesRepository[] }) => {
const tRepository = useScopedI18n("widget.releases.option.repositories");
const { data: configuredKinds = [], refetch } = clientApi.widget.secrets.getConfiguredKinds.useQuery({ itemId });
const setSecret = clientApi.widget.secrets.setSecret.useMutation({ onSuccess: () => refetch() });
const deleteSecret = clientApi.widget.secrets.deleteSecret.useMutation({ onSuccess: () => refetch() });
const authProviders = useMemo(() => {
const usedProviders = repositories.map((r) => r.provider).filter((p): p is ReleaseProviderKind => p !== undefined);
const configuredProviders = configuredKinds.filter((k): k is ReleaseProviderKind =>
providersWithAuth.includes(k as ReleaseProviderKind),
);
return [...new Set([...usedProviders, ...configuredProviders])].filter((p) => providersWithAuth.includes(p));
}, [repositories, configuredKinds]);
if (authProviders.length === 0) return null;
return (
<>
<Divider my="sm" label={tRepository("tokens.label")} labelPosition="center" />
<Stack gap="xs">
{authProviders.map((provider) => (
<ProviderTokenInput
key={provider}
provider={provider}
hasToken={configuredKinds.includes(provider)}
onSave={async (value) => {
await setSecret.mutateAsync({ itemId, kind: provider, value });
}}
onDelete={async () => {
await deleteSecret.mutateAsync({ itemId, kind: provider });
}}
/>
))}
</Stack>
</>
);
};
const ProviderTokenInput = ({
provider,
hasToken,
onSave,
onDelete,
}: {
provider: ReleaseProviderKind;
hasToken: boolean;
onSave: (value: string) => Promise<void>;
onDelete: () => Promise<void>;
}) => {
const tRepository = useScopedI18n("widget.releases.option.repositories");
const [value, setValue] = useState("");
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const handleSave = async () => {
if (!value.trim()) return;
setSaving(true);
try {
await onSave(value.trim());
setValue("");
setEditing(false);
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
setSaving(true);
try {
await onDelete();
setValue("");
setEditing(false);
} finally {
setSaving(false);
}
};
return (
<Group gap="xs" align="end">
<PasswordInput
label={`${getReleaseProviderName(provider)} ${tRepository("tokens.token")}`}
placeholder={hasToken ? tRepository("tokens.configured") : tRepository("tokens.notConfigured")}
value={value}
onChange={(e) => {
setValue(e.currentTarget.value);
setEditing(true);
}}
style={{ flex: 1 }}
size="xs"
leftSection={<IconKey size={14} />}
/>
{editing && value.trim() && (
<Button size="xs" onClick={handleSave} loading={saving}>
{tRepository("tokens.save")}
</Button>
)}
{hasToken && !editing && (
<ActionIcon variant="light" color="red" size="sm" onClick={handleDelete} loading={saving}>
<IconTrash size={14} />
</ActionIcon>
)}
</Group>
);
};
const formatVersionFilterRegex = (versionFilter: ReleasesVersionFilter | undefined) => {
if (!versionFilter) return undefined;
@@ -292,7 +392,7 @@ interface RepositoryEditProps {
onRepositorySave: (repository: ReleasesRepository) => FormValidation;
onRepositoryCancel?: () => void;
versionFilterPrecisionOptions: string[];
integrations: Record<string, Integration>;
providerOptions: { value: ReleaseProviderKind; label: string }[];
}
const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, actions }) => {
@@ -300,11 +400,6 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
const [loading, setLoading] = useState(false);
const [tempRepository, setTempRepository] = useState(() => ({ ...innerProps.repository }));
const [formErrors, setFormErrors] = useState<FormErrors>({});
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);
@@ -359,15 +454,14 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
<Stack>
<Group align="start" wrap="nowrap" grow preventGrowOverflow={false}>
<div style={{ flex: 0.3 }}>
<WidgetIntegrationSelect
canSelectMultiple={false}
<Select
withAsterisk
label={tRepository("provider.label")}
data={integrationSelectOptions}
value={tempRepository.providerIntegrationId ? [tempRepository.providerIntegrationId] : []}
error={formErrors[`${innerProps.fieldPath}.providerIntegrationId`] as string}
data={innerProps.providerOptions}
value={tempRepository.provider ?? null}
error={formErrors[`${innerProps.fieldPath}.provider`] as string}
onChange={(value) => {
handleChange({ providerIntegrationId: value.length > 0 ? value.pop() : undefined });
handleChange({ provider: value ? (value as ReleaseProviderKind) : undefined });
}}
/>
</div>
@@ -395,6 +489,16 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
/>
</Group>
<TextInput
label={tRepository("providerUrl.label")}
placeholder={tempRepository.provider ? getReleaseProviderDefaultUrl(tempRepository.provider) : undefined}
value={tempRepository.providerUrl ?? ""}
onChange={(event) => {
handleChange({ providerUrl: event.currentTarget.value || undefined });
}}
error={formErrors[`${innerProps.fieldPath}.providerUrl`]}
/>
<Group align="center" wrap="nowrap" grow preventGrowOverflow={false}>
<TextInput
label={tRepository("name.label")}
@@ -511,7 +615,6 @@ interface ReleasesRepositoryImport extends ReleasesRepository {
interface ImportRepositorySelectProps {
repository: ReleasesRepositoryImport;
checked: boolean;
integration?: Integration;
versionFilterPrecisionOptions: string[];
disabled: boolean;
onImageSelectionChanged?: (isSelected: boolean) => void;
@@ -520,12 +623,12 @@ interface ImportRepositorySelectProps {
const ImportRepositorySelect = ({
repository,
checked,
integration,
versionFilterPrecisionOptions,
disabled = false,
onImageSelectionChanged = undefined,
}: ImportRepositorySelectProps) => {
const tRepository = useScopedI18n("widget.releases.option.repositories");
const provider = repository.provider;
return (
<Group gap="xl" justify="space-between">
@@ -568,12 +671,12 @@ const ImportRepositorySelect = ({
)}
</Group>
<Tooltip label={tRepository("noProvider.tooltip")} disabled={integration !== undefined} withArrow>
<Tooltip label={tRepository("noProvider.tooltip")} disabled={provider !== undefined} withArrow>
<Group>
{integration ? (
{provider ? (
<MaskedImage
color="dimmed"
imageUrl={integration.iconUrl}
imageUrl={getReleaseProviderIconUrl(provider)}
style={{
height: "1em",
width: "1em",
@@ -584,7 +687,7 @@ const ImportRepositorySelect = ({
)}
<Text ff="monospace" c="dimmed" size="sm">
{integration?.name ?? tRepository("noProvider.label")}
{provider ? getReleaseProviderName(provider) : tRepository("noProvider.label")}
</Text>
</Group>
</Tooltip>
@@ -594,7 +697,6 @@ const ImportRepositorySelect = ({
interface RepositoryImportProps {
repositories: ReleasesRepository[];
integrations: Record<string, Integration>;
versionFilterPrecisionOptions: string[];
onConfirm: (selectedRepositories: ReleasesRepositoryImport[]) => void;
isAdmin: boolean;
@@ -621,28 +723,25 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
if (!identifier) return acc;
const providerKind = containerImageToProviderKind[source] ?? "dockerHub";
const integrationId = Object.values(innerProps.integrations).find(
(integration) => integration.kind === providerKind,
)?.id;
const provider = containerImageToProviderKind[source] ?? "dockerHub";
const normalizedIdentifier = normalizeReleaseProviderIdentifier(provider, identifier);
if (acc.some((item) => item.providerIntegrationId === integrationId && item.identifier === identifier))
return acc;
if (acc.some((item) => item.provider === provider && item.identifier === normalizedIdentifier)) return acc;
acc.push({
id: createId(),
providerIntegrationId: integrationId,
identifier,
provider,
identifier: normalizedIdentifier,
iconUrl: container.iconUrl ?? undefined,
name: formatIdentifierName(identifier),
name: formatIdentifierName(normalizedIdentifier),
versionFilter: version ? parseImageVersionToVersionFilter(version) : undefined,
alreadyImported: innerProps.repositories.some(
(item) => item.providerIntegrationId === integrationId && item.identifier === identifier,
(item) => item.provider === provider && item.identifier === normalizedIdentifier,
),
});
return acc;
}, []) ?? [],
[docker.data, innerProps.repositories, innerProps.integrations],
[docker.data, innerProps.repositories],
);
const handleConfirm = useCallback(() => {
@@ -718,27 +817,20 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
{importRepositories
.filter((repository) => !repository.alreadyImported)
.map((repository) => {
const integration = repository.providerIntegrationId
? innerProps.integrations[repository.providerIntegrationId]
: undefined;
return (
<ImportRepositorySelect
key={repository.id}
repository={repository}
checked={selectedImages.includes(repository)}
integration={integration}
versionFilterPrecisionOptions={innerProps.versionFilterPrecisionOptions}
disabled={false}
onImageSelectionChanged={(isSelected) =>
isSelected
? setSelectedImages([...selectedImages, repository])
: setSelectedImages(selectedImages.filter((img) => img !== repository))
}
/>
);
})}
.map((repository) => (
<ImportRepositorySelect
key={repository.id}
repository={repository}
checked={selectedImages.includes(repository)}
versionFilterPrecisionOptions={innerProps.versionFilterPrecisionOptions}
disabled={false}
onImageSelectionChanged={(isSelected) =>
isSelected
? setSelectedImages([...selectedImages, repository])
: setSelectedImages(selectedImages.filter((img) => img !== repository))
}
/>
))}
</Stack>
)}
</Accordion.Panel>
@@ -752,22 +844,15 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
<Stack justify="center" gap="xs">
{importRepositories
.filter((repository) => repository.alreadyImported)
.map((repository) => {
const integration = repository.providerIntegrationId
? innerProps.integrations[repository.providerIntegrationId]
: undefined;
return (
<ImportRepositorySelect
key={repository.id}
repository={repository}
integration={integration}
versionFilterPrecisionOptions={innerProps.versionFilterPrecisionOptions}
checked
disabled
/>
);
})}
.map((repository) => (
<ImportRepositorySelect
key={repository.id}
repository={repository}
versionFilterPrecisionOptions={innerProps.versionFilterPrecisionOptions}
checked
disabled
/>
))}
</Stack>
)}
</Accordion.Panel>
@@ -794,8 +879,8 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
size: "xl",
});
const containerImageToProviderKind: Record<string, IntegrationKind> = {
"ghcr.io": "github",
const containerImageToProviderKind: Record<string, ReleaseProviderKind> = {
"ghcr.io": "gitHubContainerRegistry",
"docker.io": "dockerHub",
"lscr.io": "linuxServerIO",
"quay.io": "quay",
@@ -1,7 +1,7 @@
"use client";
import type { Dispatch, SetStateAction } from "react";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Box, Center, Group, Image, Stack, Text } from "@mantine/core";
import { IconAlertCircle, IconCalendar } from "@tabler/icons-react";
@@ -26,6 +26,15 @@ export default function ImmichAlbumCarouselWidget({
{ enabled: Boolean(options.albumId) },
);
const photoAssets = useMemo(() => {
const assets = album?.assets.filter((asset) => asset.type === "IMAGE") ?? [];
return options.randomizePhotos ? shuffle(assets) : assets;
}, [album?.assets, options.randomizePhotos]);
useEffect(() => {
setCurrentPhotoIndex(0);
}, [photoAssets]);
if (!options.albumId) {
return <NoAlbumSelected />;
}
@@ -36,8 +45,6 @@ export default function ImmichAlbumCarouselWidget({
return <NoPhotosInAlbum />;
}
const photoAssets = album.assets.filter((asset) => asset.type === "IMAGE");
if (photoAssets.length === 0) {
return <NoPhotosInAlbum />;
}
@@ -53,6 +60,19 @@ export default function ImmichAlbumCarouselWidget({
);
}
function shuffle<T>(items: T[]) {
const shuffled = [...items];
for (let index = shuffled.length - 1; index > 0; index--) {
const randomIndex = Math.floor(Math.random() * (index + 1));
const current = shuffled[index];
const random = shuffled[randomIndex];
if (current === undefined || random === undefined) continue;
shuffled[index] = random;
shuffled[randomIndex] = current;
}
return shuffled;
}
interface CarouselProps {
assets: {
id: string;
@@ -1,6 +1,8 @@
import { IconPhoto } from "@tabler/icons-react";
import z from "zod";
import { clientApi } from "@homarr/api/client";
import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options";
@@ -10,9 +12,20 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-al
integrationsRequired: true,
createOptions() {
return optionsBuilder.from((factory) => ({
albumId: factory.text({
defaultValue: "",
albumId: factory.integrationSelect({
withDescription: true,
clearable: true,
useOptions: (integrationIds: string[]) => {
const {
data = [],
isPending,
isError,
} = clientApi.widget.immich.getAlbums.useQuery(
{ integrationId: integrationIds[0] ?? "" },
{ enabled: integrationIds.length > 0, staleTime: 15 * 60 * 1000 },
);
return { data: data.map((album) => ({ value: album.id, label: album.albumName })), isPending, isError };
},
}),
rotationIntervalSeconds: factory.number({
defaultValue: 5,
@@ -23,6 +36,10 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-al
defaultValue: false,
withDescription: true,
}),
randomizePhotos: factory.switch({
defaultValue: false,
withDescription: true,
}),
}));
},
}).withDynamicImport(() => import("./component"));
@@ -38,6 +38,7 @@ interface ModalProps<TSort extends WidgetKind> {
integrationData: IntegrationSelectOption[];
integrationSupport: boolean;
settings: SettingsContextProps;
itemId?: string;
appId?: string;
}
@@ -146,6 +147,7 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
property={key}
options={value as never}
initialOptions={innerProps.value.options}
itemId={innerProps.itemId}
/>
);
})}
+1
View File
@@ -55,6 +55,7 @@ interface DynamicSelectInput extends CommonInput<DynamicSelectOption | null> {
useOptions: (
query: string,
integrationIds: string[],
options: Record<string, unknown>,
) => {
isPending: boolean;
options: DynamicSelectOption[];
+37 -33
View File
@@ -20,8 +20,9 @@ import ReactMarkdown from "react-markdown";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { isDateWithin, isNullOrWhitespace, splitToChunksWithNItems } from "@homarr/common";
import { getIconUrl } from "@homarr/definitions";
import { createId, isDateWithin, isNullOrWhitespace, splitToChunksWithNItems } from "@homarr/common";
import type { ReleaseProviderKind } from "@homarr/definitions";
import { getReleaseProviderIconUrl, getReleaseProviderName } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
@@ -35,7 +36,7 @@ const formatRelativeDate = (value: string): string => {
return isMonths ? value.toUpperCase() : isOtherUnits ? value.toLowerCase() : value;
};
export default function ReleasesWidget({ options }: WidgetComponentProps<"releases">) {
export default function ReleasesWidget({ options, itemId }: WidgetComponentProps<"releases">) {
const t = useScopedI18n("widget.releases");
const now = useNow();
const formatter = useFormatter();
@@ -55,11 +56,16 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
[options.newReleaseWithin, options.staleReleaseWithin],
);
// Group repositories by integration
const normalizedRepositories = useMemo(
() => options.repositories.map((repo) => (repo.id ? repo : { ...repo, id: createId() })),
[options.repositories],
);
// Group repositories by provider
const groupedRepositories = useMemo(() => {
return options.repositories.reduce(
return normalizedRepositories.reduce(
(acc, repo) => {
const key = repo.providerIntegrationId;
const key = repo.provider;
if (!key) return acc;
acc[key] ??= [];
@@ -67,28 +73,30 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
return acc;
},
{} as Record<string, ReleasesRepository[]>,
{} as Partial<Record<ReleaseProviderKind, ReleasesRepository[]>>,
);
}, [options.repositories]);
}, [normalizedRepositories]);
// For each group, split into chunks of 5
const batchedRepositories = useMemo(() => {
return Object.entries(groupedRepositories).flatMap(([integrationId, group]) =>
return Object.entries(groupedRepositories).flatMap(([provider, group]) =>
splitToChunksWithNItems(group, 5).map((chunk) => ({
integrationId,
provider: provider as ReleaseProviderKind,
repositories: chunk,
})),
);
}, [groupedRepositories]);
const queryResults = clientApi.useQueries((t) =>
batchedRepositories.flatMap(({ integrationId, repositories }) =>
t.widget.releases.getLatest({
integrationId,
const queryResults = clientApi.useQueries((trpc) =>
batchedRepositories.flatMap(({ provider, repositories }) =>
trpc.widget.releases.getLatest({
itemId,
repositories: repositories.map((repository) => ({
id: repository.id,
provider,
identifier: repository.identifier,
versionFilter: repository.versionFilter,
providerUrl: repository.providerUrl,
})),
}),
),
@@ -100,15 +108,15 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
);
const repositories = useMemo(() => {
const formattedResults = options.repositories
const formattedResults = normalizedRepositories
.map((repository) => {
if (!repository.providerIntegrationId) return { ...repository, error: { code: "noProviderSeleceted" } };
if (!repository.provider) return { ...repository, error: { code: "noProviderSelected" } };
const repositoryResult = results.flat().find(({ id }) => id === repository.id);
if (!repositoryResult) return { ...repository, error: { code: "noProviderResponse" } };
if (!repositoryResult.success) return { ...repository, error: repositoryResult.error };
const { data: release, integration } = repositoryResult;
const { data: release, provider } = repositoryResult;
const isReleaseWithin = (relativeDate: string) =>
Boolean(relativeDate) && isDateWithin(release.latestReleaseAt, relativeDate);
@@ -116,7 +124,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
return {
...repository,
...release,
integration: { name: integration.name, iconUrl: getIconUrl(integration.kind) },
providerMetadata: { name: getReleaseProviderName(provider), iconUrl: getReleaseProviderIconUrl(provider) },
isNewRelease: isReleaseWithin(relativeDateOptions.newReleaseWithin),
isStaleRelease: !isReleaseWithin(relativeDateOptions.staleReleaseWithin),
viewed: releasesViewedList[repository.id] === release.latestRelease,
@@ -139,7 +147,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
return formattedResults;
}, [
results,
options.repositories,
normalizedRepositories,
options.showOnlyHighlighted,
options.topReleases,
relativeDateOptions.newReleaseWithin,
@@ -173,7 +181,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
className={combineClasses(
"releases-repository",
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
`releases-repository-${repository.integration?.name ?? "error"}-${repository.name || repository.identifier.replace(/[^a-zA-Z0-9]/g, "_")}`,
`releases-repository-${repository.providerMetadata?.name ?? "error"}-${repository.name || repository.identifier.replace(/[^a-zA-Z0-9]/g, "_")}`,
classes.releasesRepository,
)}
gap={0}
@@ -187,7 +195,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
>
<MaskedOrNormalImage
className="releases-repository-header-icon"
imageUrl={repository.iconUrl ?? repository.integration?.iconUrl}
imageUrl={repository.iconUrl ?? repository.providerMetadata?.iconUrl}
hasColor={hasIconColor}
style={{
width: "1em",
@@ -529,28 +537,23 @@ const ExpandedDisplay = ({
p={10}
>
<Group className="releases-repository-expanded-header" justify="space-between" align="center" gap="xs">
<Text className="releases-repository-expanded-header-identifier" size="xs" c="iconColor" ff="monospace">
<Text className="releases-repository-expanded-header-identifier" size="xs" c="dimmed" ff="monospace">
{repository.identifier}
</Text>
{repository.integration && (
{repository.providerMetadata && (
<Group className="releases-repository-expanded-header-provider-wrapper" gap={5} align="center">
<MaskedOrNormalImage
className="releases-repository-expanded-header-provider-icon"
imageUrl={repository.integration.iconUrl}
imageUrl={repository.providerMetadata.iconUrl}
hasColor={hasIconColor}
style={{
width: "1em",
aspectRatio: "1/1",
}}
/>
<Text
className="releases-repository-expanded-header-provider-name"
size="xs"
c="iconColor"
ff="monospace"
>
{repository.integration.name}
<Text className="releases-repository-expanded-header-provider-name" size="xs" c="dimmed" ff="monospace">
{repository.providerMetadata.name}
</Text>
</Group>
)}
@@ -614,7 +617,7 @@ const ExpandedDisplay = ({
{repository.error && (
<>
<Divider className="releases-repository-expanded-error-divider" mx="30%" />
<Title className="releases-repository-expanded-error-title" order={4} ta="center">
<Title className="releases-repository-expanded-error-title" order={4} ta="center" c="red">
{t("error.label")}
</Title>
<Text
@@ -624,7 +627,8 @@ const ExpandedDisplay = ({
c="red"
style={{ whiteSpace: "pre-wrap" }}
>
{repository.error.code ? t(`error.messages.${repository.error.code}` as never) : repository.error.message}
{repository.error.message ??
(repository.error.code ? t(`error.messages.${repository.error.code}` as never) : null)}
</Text>
</>
)}
+16 -2
View File
@@ -1,6 +1,9 @@
import { IconRocket } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { releaseProviderKinds } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
@@ -39,10 +42,20 @@ export const { definition, componentLoader } = createWidgetDefinition("releases"
validate: z.number().min(0),
}),
repositories: factory.multiReleasesRepositories({
defaultValue: [],
defaultValue: [
{
id: createId(),
provider: "github" as const,
identifier: "homarr-labs/homarr",
name: "Homarr",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/homarr.png",
versionFilter: { prefix: "v", precision: 3 },
},
],
validate: z.array(
z.object({
providerIntegrationId: z.string().optional(),
id: z.string().default(() => createId()),
provider: z.enum(releaseProviderKinds).optional(),
identifier: z.string().min(1),
name: z.string().optional(),
versionFilter: z
@@ -53,6 +66,7 @@ export const { definition, componentLoader } = createWidgetDefinition("releases"
})
.optional(),
iconUrl: z.string().optional(),
providerUrl: z.string().url().optional(),
}),
),
}),
@@ -1,3 +1,5 @@
import type { ReleaseProviderKind } from "@homarr/definitions";
export interface ReleasesVersionFilter {
prefix?: string;
precision: number;
@@ -6,11 +8,12 @@ export interface ReleasesVersionFilter {
export interface ReleasesRepository {
id: string;
providerIntegrationId?: string;
provider?: ReleaseProviderKind;
identifier: string;
name?: string;
versionFilter?: ReleasesVersionFilter;
iconUrl?: string;
providerUrl?: string;
}
export interface ReleasesRepositoryResponse extends ReleasesRepository {
@@ -32,7 +35,7 @@ export interface ReleasesRepositoryResponse extends ReleasesRepository {
forksCount?: number;
openIssues?: number;
integration?: {
providerMetadata?: {
name: string;
iconUrl?: string;
};
+5 -8
View File
@@ -7,27 +7,24 @@ import { useScopedI18n } from "@homarr/translation/client";
import type { DynamicSelectOption } from "../_inputs/widget-dynamic-select-input";
import type { WidgetComponentProps } from "../definition";
export default function TimetableWidget({ options, integrationIds }: WidgetComponentProps<"timetable">) {
// It will always have at least one integration as otherwise the NoIntegrationSelectedError would be thrown in item-content.tsx
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const integrationId = integrationIds[0]!;
export default function TimetableWidget({ options }: WidgetComponentProps<"timetable">) {
const t = useScopedI18n("widget.timetable");
if (!options.station) {
return <Center h="100%">{t("noStation")}</Center>;
}
return <TimetableWidgetInner station={options.station} integrationId={integrationId} />;
return <TimetableWidgetInner station={options.station} baseUrl={options.baseUrl} />;
}
interface TimetableWidgetInnerProps {
station: DynamicSelectOption;
integrationId: string;
baseUrl: string;
}
const TimetableWidgetInner = ({ station, integrationId }: TimetableWidgetInnerProps) => {
const TimetableWidgetInner = ({ station, baseUrl }: TimetableWidgetInnerProps) => {
const { data: timetable } = clientApi.widget.timetable.getTimetable.useQuery({
integrationId,
baseUrl,
stationId: station.value,
limit: 10,
});
+10 -11
View File
@@ -1,4 +1,5 @@
import { IconBusStop } from "@tabler/icons-react";
import { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
@@ -7,20 +8,18 @@ import { optionsBuilder } from "../options";
export const { componentLoader, definition } = createWidgetDefinition("timetable", {
icon: IconBusStop,
supportedIntegrations: ["searchCh"],
createOptions() {
return optionsBuilder.from((factory) => ({
baseUrl: factory.text({
defaultValue: "https://search.ch",
validate: z.string().url(),
}),
station: factory.dynamicSelect({
useOptions(query, integrationIds) {
const { data: stations, isPending } = clientApi.widget.timetable.searchStations.useQuery(
{
integrationId: integrationIds[0] ?? "",
query,
},
{
enabled: integrationIds.length > 0,
},
);
useOptions(query, _integrationIds, options) {
const { data: stations, isPending } = clientApi.widget.timetable.searchStations.useQuery({
baseUrl: typeof options.baseUrl === "string" ? options.baseUrl : "https://search.ch",
query,
});
return {
isPending,