feat: expand advanced widget experiences

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 11:07:09 +02:00
1 parent 9437d211c8
commit c099a8eef6
119 files changed
+3624 -844

No files matched your search

+19 -13
View File
@@ -37,9 +37,13 @@ import { getReleaseProviderIconUrl, getReleaseProviderName } from "@homarr/defin
import { useScopedI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.scss";
import { selectReleaseRepositoriesForDisplay } from "./display";
import type { ReleasesRepository, ReleasesRepositoryResponse } from "./releases-repository";
import { getReleasesQueryStaleTimeMs } from "./query-options";
@@ -49,7 +53,7 @@ const formatRelativeDate = (value: string): string => {
return isMonths ? value.toUpperCase() : isOtherUnits ? value.toLowerCase() : value;
};
export default function ReleasesWidget({ options, itemId, width }: WidgetComponentProps<"releases">) {
export default function ReleasesWidget({ options, itemId, width, displayMode }: WidgetComponentProps<"releases">) {
const t = useScopedI18n("widget.releases");
const now = useNow();
const formatter = useFormatter();
@@ -60,6 +64,7 @@ export default function ReleasesWidget({ options, itemId, width }: WidgetCompone
key: "releases-viewed-versions",
defaultValue: {},
});
const isAdvanced = displayMode === "advanced";
const columns = Math.max(1, Math.floor(width / 420));
const relativeDateOptions = useMemo(
@@ -123,6 +128,7 @@ export default function ReleasesWidget({ options, itemId, width }: WidgetCompone
() => queryResults.map((q) => q.data).filter((d): d is NonNullable<typeof d> => d != null),
[queryResults],
);
const isInitialLoading = queryResults.some(isInitialWidgetQueryPending);
const repositories = useMemo(() => {
const formattedResults = normalizedRepositories
@@ -147,22 +153,19 @@ export default function ReleasesWidget({ options, itemId, width }: WidgetCompone
viewed: releasesViewedList[repository.id] === release.latestRelease,
};
})
.filter(
(repository) =>
"error" in repository || !options.showOnlyHighlighted || repository.isNewRelease || repository.isStaleRelease,
)
.toSorted((repoA, repoB) => {
if ("error" in repoA) return -1;
if ("error" in repoB) return 1;
return repoA.latestReleaseAt > repoB.latestReleaseAt ? -1 : 1;
}) as ReleasesRepositoryResponse[];
if (typeof options.topReleases !== "string" && options.topReleases > 0) {
return formattedResults.slice(0, options.topReleases);
}
return formattedResults;
return selectReleaseRepositoriesForDisplay(formattedResults, {
displayMode,
showOnlyHighlighted: options.showOnlyHighlighted,
topReleases: options.topReleases,
});
}, [
displayMode,
results,
normalizedRepositories,
options.showOnlyHighlighted,
@@ -186,6 +189,9 @@ export default function ReleasesWidget({ options, itemId, width }: WidgetCompone
[setReleasesViewedList],
);
if (isInitialLoading) return <WidgetQueryLoadingState />;
if (repositories.length === 0) return <WidgetEmptyState />;
return (
<ScrollArea h="100%" className="releases">
<SimpleGrid cols={columns} spacing={columns > 1 ? "sm" : 0} p={columns > 1 ? "xs" : 0}>
@@ -241,7 +247,7 @@ export default function ReleasesWidget({ options, itemId, width }: WidgetCompone
miw={0}
style={{ flex: 1 }}
>
{!options.showOnlyIcon && (
{(isAdvanced || !options.showOnlyIcon) && (
<Text className="releases-repository-header-name" size="xs">
{/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */}
{repository.name || repository.identifier}
@@ -321,7 +327,7 @@ export default function ReleasesWidget({ options, itemId, width }: WidgetCompone
</Group>
</Group>
</UnstyledButton>
{options.showDetails && (
{(isAdvanced || options.showDetails) && (
<DetailsDisplay
repository={repository}
isExpanded={isActive}
@@ -0,0 +1,32 @@
import { describe, expect, test } from "vitest";
import { selectReleaseRepositoriesForDisplay } from "./display";
const repositories = [
{ id: "ordinary", isNewRelease: false, isStaleRelease: false },
{ id: "new", isNewRelease: true, isStaleRelease: false },
{ id: "stale", isNewRelease: false, isStaleRelease: true },
{ id: "error", error: { code: "provider" } },
];
describe("selectReleaseRepositoriesForDisplay", () => {
test("applies highlight and top limits in compact mode", () => {
const result = selectReleaseRepositoriesForDisplay(repositories, {
displayMode: "compact",
showOnlyHighlighted: true,
topReleases: 2,
});
expect(result.map(({ id }) => id)).toEqual(["new", "stale"]);
});
test("keeps every configured repository in advanced mode", () => {
const result = selectReleaseRepositoriesForDisplay(repositories, {
displayMode: "advanced",
showOnlyHighlighted: true,
topReleases: 1,
});
expect(result.map(({ id }) => id)).toEqual(["ordinary", "new", "stale", "error"]);
});
});
+30
View File
@@ -0,0 +1,30 @@
interface ReleasePresentationItem {
error?: unknown;
isNewRelease?: boolean;
isStaleRelease?: boolean;
}
interface ReleasePresentationOptions {
displayMode?: "compact" | "advanced";
showOnlyHighlighted: boolean;
topReleases: number | string;
}
export const selectReleaseRepositoriesForDisplay = <T extends ReleasePresentationItem>(
repositories: readonly T[],
options: ReleasePresentationOptions,
): T[] => {
const visibleRepositories =
options.displayMode === "advanced" || !options.showOnlyHighlighted
? [...repositories]
: repositories.filter(
(repository) =>
repository.error !== undefined || repository.isNewRelease === true || repository.isStaleRelease === true,
);
if (options.displayMode === "advanced" || typeof options.topReleases === "string" || options.topReleases <= 0) {
return visibleRepositories;
}
return visibleRepositories.slice(0, options.topReleases);
};
+1 -1
View File
@@ -13,7 +13,7 @@ const relativeDateSchema = z
.or(z.literal(""));
export const { definition, componentLoader } = createWidgetDefinition("releases", {
supportsAdvancedFocus: false,
supportsAdvancedFocus: true,
icon: IconRocket,
queryKey: [["widget", "releases", "getLatest"]],
queryMatcher: ({ input }, scope) => widgetQueryInputMatches(input, { itemId: scope.itemId }),