fix(releases-widget): error display, decouple database repository object from responses and batch widget queries (#2891)

Co-authored-by: Manuel <30572287+manuel-rw@users.noreply.github.com>
Co-authored-by: Andre Silva <asilva01@acuitysso.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
Andre Silva
2025-05-09 12:24:37 +01:00
committed by GitHub
parent d6180ebc73
commit 347c6d1519
14 changed files with 414 additions and 191 deletions

View File

@@ -5,7 +5,7 @@ import { releasesRequestHandler } from "@homarr/request-handler/releases";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const formatVersionFilterRegex = (versionFilter: z.infer<typeof _releaseVersionFilterSchema> | undefined) => {
const formatVersionFilterRegex = (versionFilter: z.infer<typeof releaseVersionFilterSchema> | undefined) => {
if (!versionFilter) return undefined;
const escapedPrefix = versionFilter.prefix ? escapeForRegEx(versionFilter.prefix) : "";
@@ -15,7 +15,7 @@ const formatVersionFilterRegex = (versionFilter: z.infer<typeof _releaseVersionF
return `^${escapedPrefix}${precision}${escapedSuffix}$`;
};
const _releaseVersionFilterSchema = z.object({
const releaseVersionFilterSchema = z.object({
prefix: z.string().optional(),
precision: z.number(),
suffix: z.string().optional(),
@@ -29,7 +29,7 @@ export const releasesRouter = createTRPCRouter({
z.object({
providerKey: z.string(),
identifier: z.string(),
versionFilter: _releaseVersionFilterSchema.optional(),
versionFilter: releaseVersionFilterSchema.optional(),
}),
),
}),

View File

@@ -5,3 +5,11 @@ export const splitToNChunks = <T>(array: T[], chunks: number): T[][] => {
}
return result;
};
export const splitToChunksWithNItems = <T>(array: T[], itemCount: number): T[][] => {
const result: T[][] = [];
for (let i = 0; i < array.length; i += itemCount) {
result.push(array.slice(i, i + itemCount));
}
return result;
};

View File

@@ -0,0 +1,26 @@
import dayjs from "dayjs";
import type { UnitTypeShort } from "dayjs";
import isBetween from "dayjs/plugin/isBetween";
dayjs.extend(isBetween);
const validUnits = ["h", "d", "w", "M", "y"] as UnitTypeShort[];
export const isDateWithin = (date: Date, relativeDate: string): boolean => {
if (relativeDate.length < 2) {
throw new Error("Relative date must be at least 2 characters long");
}
const amount = parseInt(relativeDate.slice(0, -1), 10);
if (isNaN(amount) || amount <= 0) {
throw new Error("Relative date must be a number greater than 0");
}
const unit = relativeDate.slice(-1) as dayjs.UnitTypeShort;
if (!validUnits.includes(unit)) {
throw new Error("Invalid relative time unit");
}
const startDate = dayjs().subtract(amount, unit);
return dayjs(date).isBetween(startDate, dayjs(), null, "[]");
};

View File

@@ -2,6 +2,7 @@ export * from "./object";
export * from "./string";
export * from "./cookie";
export * from "./array";
export * from "./date";
export * from "./stopwatch";
export * from "./hooks";
export * from "./url";

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { splitToChunksWithNItems, splitToNChunks } from "../array";
describe("splitToNChunks", () => {
it("should split an array into the specified number of chunks", () => {
const array = [1, 2, 3, 4, 5];
const chunks = 3;
const result = splitToNChunks(array, chunks);
expect(result).toEqual([[1, 2], [3, 4], [5]]);
});
it("should handle an empty array", () => {
const array: number[] = [];
const chunks = 3;
const result = splitToNChunks(array, chunks);
expect(result).toEqual([[], [], []]);
});
it("should handle more chunks than elements", () => {
const array = [1, 2];
const chunks = 5;
const result = splitToNChunks(array, chunks);
expect(result).toEqual([[1], [2], [], [], []]);
});
});
describe("splitToChunksWithNItems", () => {
it("should split an array into chunks with the specified number of items", () => {
const array = [1, 2, 3, 4, 5];
const items = 2;
const result = splitToChunksWithNItems(array, items);
expect(result).toEqual([[1, 2], [3, 4], [5]]);
});
it("should handle an empty array", () => {
const array: number[] = [];
const items = 2;
const result = splitToChunksWithNItems(array, items);
expect(result).toEqual([]);
});
it("should handle more items per chunk than elements", () => {
const array = [1, 2];
const items = 5;
const result = splitToChunksWithNItems(array, items);
expect(result).toEqual([[1, 2]]);
});
});

View File

@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import { isDateWithin } from "../date";
describe("isDateWithin", () => {
it("should return true for a date within the specified hours", () => {
const date = new Date();
date.setHours(date.getHours() - 20);
expect(isDateWithin(date, "100h")).toBe(true);
});
it("should return false for a date outside the specified hours", () => {
const date = new Date();
date.setHours(date.getHours() - 101);
expect(isDateWithin(date, "100h")).toBe(false);
});
it("should return true for a date within the specified days", () => {
const date = new Date();
date.setDate(date.getDate() - 5);
expect(isDateWithin(date, "10d")).toBe(true);
});
it("should return false for a date outside the specified days", () => {
const date = new Date();
date.setDate(date.getDate() - 11);
expect(isDateWithin(date, "10d")).toBe(false);
});
it("should return true for a date within the specified weeks", () => {
const date = new Date();
date.setDate(date.getDate() - 10);
expect(isDateWithin(date, "7w")).toBe(true);
});
it("should return false for a date outside the specified weeks", () => {
const date = new Date();
date.setDate(date.getDate() - 50);
expect(isDateWithin(date, "7w")).toBe(false);
});
it("should return true for a date within the specified months", () => {
const date = new Date();
date.setMonth(date.getMonth() - 1);
expect(isDateWithin(date, "2M")).toBe(true);
});
it("should return false for a date outside the specified months", () => {
const date = new Date();
date.setMonth(date.getMonth() - 3);
expect(isDateWithin(date, "2M")).toBe(false);
});
it("should return true for a date within the specified years", () => {
const date = new Date();
date.setFullYear(date.getFullYear() - 1);
expect(isDateWithin(date, "2y")).toBe(true);
});
it("should return false for a date outside the specified years", () => {
const date = new Date();
date.setFullYear(date.getFullYear() - 3);
expect(isDateWithin(date, "2y")).toBe(false);
});
it("should return false for a date after the specified relative time", () => {
const date = new Date();
date.setDate(date.getDate() + 2);
expect(isDateWithin(date, "1d")).toBe(false);
});
it("should throw an error for an invalid unit", () => {
const date = new Date();
expect(() => isDateWithin(date, "2x")).toThrow("Invalid relative time unit");
});
it("should throw an error if relativeDate is less than 2 characters long", () => {
const date = new Date();
expect(() => isDateWithin(date, "h")).toThrow("Relative date must be at least 2 characters long");
});
it("should throw an error if relativeDate has an invalid number", () => {
const date = new Date();
expect(() => isDateWithin(date, "hh")).toThrow("Relative date must be a number greater than 0");
});
it("should throw an error if relativeDate is set to 0", () => {
const date = new Date();
expect(() => isDateWithin(date, "0y")).toThrow("Relative date must be a number greater than 0");
});
});

View File

@@ -41,12 +41,8 @@ export const Providers: ProvidersProps = {
.transform((resp) => ({
projectUrl: `https://hub.docker.com/r/${resp.namespace === "library" ? "_" : resp.namespace}/${resp.name}`,
projectDescription: resp.description,
isFork: false,
isArchived: false,
createdAt: resp.date_registered,
starsCount: resp.star_count,
openIssues: 0,
forksCount: 0,
}))
.safeParse(response);
},
@@ -67,12 +63,7 @@ export const Providers: ProvidersProps = {
),
})
.transform((resp) => {
return resp.results.map((release) => ({
...release,
releaseUrl: "",
releaseDescription: "",
isPreRelease: false,
}));
return resp.results;
})
.safeParse(response);
},
@@ -217,7 +208,6 @@ export const Providers: ProvidersProps = {
...release,
releaseUrl: `https://www.npmjs.com/package/${resp.name}/v/${release.latestRelease}`,
releaseDescription: resp.versions[release.latestRelease]?.description ?? "",
isPreRelease: false,
}));
})
.safeParse(response);
@@ -282,23 +272,31 @@ export const Providers: ProvidersProps = {
},
};
const _detailsSchema = z.object({
projectUrl: z.string(),
projectDescription: z.string(),
isFork: z.boolean(),
isArchived: z.boolean(),
createdAt: z.date(),
starsCount: z.number(),
openIssues: z.number(),
forksCount: z.number(),
});
const _detailsSchema = z
.object({
projectUrl: z.string().optional(),
projectDescription: z.string().optional(),
isFork: z.boolean().optional(),
isArchived: z.boolean().optional(),
createdAt: z.date().optional(),
starsCount: z.number().optional(),
openIssues: z.number().optional(),
forksCount: z.number().optional(),
})
.optional();
const _releasesSchema = z.object({
latestRelease: z.string(),
latestReleaseAt: z.date(),
releaseUrl: z.string(),
releaseDescription: z.string(),
isPreRelease: z.boolean(),
releaseUrl: z.string().optional(),
releaseDescription: z.string().optional(),
isPreRelease: z.boolean().optional(),
error: z
.object({
code: z.string().optional(),
message: z.string().optional(),
})
.optional(),
});
export type DetailsResponse = z.infer<typeof _detailsSchema>;

View File

@@ -8,22 +8,49 @@ import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-ha
import { Providers } from "./releases-providers";
import type { DetailsResponse } from "./releases-providers";
const errorSchema = z.object({
code: z.string().optional(),
message: z.string().optional(),
});
type ReleasesError = z.infer<typeof errorSchema>;
const _reponseSchema = z.object({
identifier: z.string(),
providerKey: z.string(),
latestRelease: z.string(),
latestReleaseAt: z.date(),
releaseUrl: z.string(),
releaseDescription: z.string(),
isPreRelease: z.boolean(),
projectUrl: z.string(),
projectDescription: z.string(),
isFork: z.boolean(),
isArchived: z.boolean(),
createdAt: z.date(),
starsCount: z.number(),
openIssues: z.number(),
forksCount: z.number(),
latestRelease: z.string().optional(),
latestReleaseAt: z.date().optional(),
releaseUrl: z.string().optional(),
releaseDescription: z.string().optional(),
isPreRelease: z.boolean().optional(),
projectUrl: z.string().optional(),
projectDescription: z.string().optional(),
isFork: z.boolean().optional(),
isArchived: z.boolean().optional(),
createdAt: z.date().optional(),
starsCount: z.number().optional(),
openIssues: z.number().optional(),
forksCount: z.number().optional(),
error: errorSchema.optional(),
});
const formatErrorRelease = (identifier: string, providerKey: string, error: ReleasesError) => ({
identifier,
providerKey,
latestRelease: undefined,
latestReleaseAt: undefined,
releaseUrl: undefined,
releaseDescription: undefined,
isPreRelease: undefined,
projectUrl: undefined,
projectDescription: undefined,
isFork: undefined,
isArchived: undefined,
createdAt: undefined,
starsCount: undefined,
openIssues: undefined,
forksCount: undefined,
error,
});
export const releasesRequestHandler = createCachedWidgetRequestHandler({
@@ -34,17 +61,7 @@ export const releasesRequestHandler = createCachedWidgetRequestHandler({
if (!provider) return undefined;
let detailsResult: DetailsResponse = {
projectUrl: "",
projectDescription: "",
isFork: false,
isArchived: false,
createdAt: new Date(0),
starsCount: 0,
openIssues: 0,
forksCount: 0,
};
let detailsResult: DetailsResponse;
const detailsUrl = provider.getDetailsUrl(input.identifier);
if (detailsUrl !== undefined) {
const detailsResponse = await fetchWithTimeout(detailsUrl);
@@ -53,6 +70,7 @@ export const releasesRequestHandler = createCachedWidgetRequestHandler({
if (parsedDetails?.success) {
detailsResult = parsedDetails.data;
} else {
detailsResult = undefined;
logger.warn("Failed to parse details response", {
provider: input.providerKey,
identifier: input.identifier,
@@ -63,43 +81,42 @@ export const releasesRequestHandler = createCachedWidgetRequestHandler({
}
const releasesResponse = await fetchWithTimeout(provider.getReleasesUrl(input.identifier));
const releasesResult = provider.parseReleasesResponse(await releasesResponse.json());
const releasesResponseJson: unknown = await releasesResponse.json();
const releasesResult = provider.parseReleasesResponse(releasesResponseJson);
if (!releasesResult.success) return undefined;
const latest: ResponseResponse = releasesResult.data
.filter((result) => (input.versionRegex ? new RegExp(input.versionRegex).test(result.latestRelease) : true))
.reduce(
(latest, result) => {
return {
...detailsResult,
...(result.latestReleaseAt > latest.latestReleaseAt ? result : latest),
identifier: input.identifier,
providerKey: input.providerKey,
};
},
{
identifier: "",
providerKey: "",
latestRelease: "",
latestReleaseAt: new Date(0),
releaseUrl: "",
releaseDescription: "",
isPreRelease: false,
projectUrl: "",
projectDescription: "",
isFork: false,
isArchived: false,
createdAt: new Date(0),
starsCount: 0,
openIssues: 0,
forksCount: 0,
},
if (!releasesResult.success) {
return formatErrorRelease(input.identifier, input.providerKey, {
message: releasesResponseJson ? JSON.stringify(releasesResponseJson, null, 2) : releasesResult.error.message,
});
} else {
const releases = releasesResult.data.filter((result) =>
input.versionRegex && result.latestRelease ? new RegExp(input.versionRegex).test(result.latestRelease) : true,
);
return latest;
const latest =
releases.length === 0
? formatErrorRelease(input.identifier, input.providerKey, { code: "noMatchingVersion" })
: releases.reduce(
(latest, result) => {
return {
...detailsResult,
...(result.latestReleaseAt > latest.latestReleaseAt ? result : latest),
identifier: input.identifier,
providerKey: input.providerKey,
};
},
{
identifier: "",
providerKey: "",
latestRelease: "",
latestReleaseAt: new Date(0),
},
);
return latest;
}
},
cacheDuration: dayjs.duration(5, "minutes"),
});
export type ResponseResponse = z.infer<typeof _reponseSchema>;
export type ReleaseResponse = z.infer<typeof _reponseSchema>;

View File

@@ -2059,11 +2059,11 @@
"option": {
"newReleaseWithin": {
"label": "New Release Within",
"description": "Usage example: 1w (1 week), 10m (10 months). Accepted unit types h (hours), d (days), w (weeks), m (months), y (years). Leave empty for no highlighting of new releases."
"description": "Usage example: 1w (1 week), 10M (10 months). Accepted unit types h (hours), d (days), w (weeks), M (months), y (years). Leave empty for no highlighting of new releases."
},
"staleReleaseWithin": {
"label": "Stale Release Within",
"description": "Usage example: 1w (1 week), 10m (10 months). Accepted unit types h (hours), d (days), w (weeks), m (months), y (years). Leave empty for no highlighting of stale releases."
"description": "Usage example: 1w (1 week), 10M (10 months). Accepted unit types h (hours), d (days), w (weeks), M (months), y (years). Leave empty for no highlighting of stale releases."
},
"showOnlyHighlighted": {
"label": "Show Only Highlighted",
@@ -2130,7 +2130,13 @@
"openProjectPage": "Open Project Page",
"openReleasePage": "Open Release Page",
"releaseDescription": "Release Description",
"created": "Created"
"created": "Created",
"error": {
"label": "Error",
"options": {
"noMatchingVersion": "No matching version found"
}
}
},
"networkControllerSummary": {
"option": {},

View File

@@ -63,7 +63,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({
const item = {
providerKey: "DockerHub",
identifier: "",
} as ReleasesRepository;
};
form.setValues((previous) => {
const previousValues = previous.options?.[property] as ReleasesRepository[];
@@ -98,7 +98,6 @@ export const WidgetMultiReleasesRepositoriesInput = ({
};
});
};
return (
<Fieldset legend={t("label")}>
<Stack gap="5">

View File

@@ -10,6 +10,7 @@ import {
IconGitFork,
IconProgressCheck,
IconStar,
IconTriangleFilled,
} from "@tabler/icons-react";
import combineClasses from "clsx";
import { useFormatter, useNow } from "next-intl";
@@ -17,118 +18,116 @@ import ReactMarkdown from "react-markdown";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { isDateWithin, splitToChunksWithNItems } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.scss";
import { Providers } from "./releases-providers";
import type { ReleasesRepository } from "./releases-repository";
import type { ReleasesRepositoryResponse } from "./releases-repository";
function isDateWithin(date: Date, relativeDate: string): boolean {
const amount = parseInt(relativeDate.slice(0, -1), 10);
const unit = relativeDate.slice(-1);
const startTime = new Date().getTime();
const endTime = new Date(date).getTime();
const diffTime = Math.abs(endTime - startTime);
const diffHours = Math.ceil(diffTime / (1000 * 60 * 60));
switch (unit) {
case "h":
return diffHours < amount;
case "d":
return diffHours / 24 < amount;
case "w":
return diffHours / (24 * 7) < amount;
case "m":
return diffHours / (24 * 30) < amount;
case "y":
return diffHours / (24 * 365) < amount;
default:
throw new Error("Invalid unit");
}
}
const formatRelativeDate = (value: string): string => {
const isMonths = /\d+m/g.test(value);
const isOtherUnits = /\d+[HDWY]/g.test(value);
return isMonths ? value.toUpperCase() : isOtherUnits ? value.toLowerCase() : value;
};
export default function ReleasesWidget({ options }: WidgetComponentProps<"releases">) {
const t = useScopedI18n("widget.releases");
const now = useNow();
const formatter = useFormatter();
const board = useRequiredBoard();
const [expandedRepository, setExpandedRepository] = useState("");
const [expandedRepository, setExpandedRepository] = useState({ providerKey: "", identifier: "" });
const hasIconColor = useMemo(() => board.iconColor !== null, [board.iconColor]);
const relativeDateOptions = useMemo(
() => ({
newReleaseWithin: formatRelativeDate(options.newReleaseWithin),
staleReleaseWithin: formatRelativeDate(options.staleReleaseWithin),
}),
[options.newReleaseWithin, options.staleReleaseWithin],
);
const [results] = clientApi.widget.releases.getLatest.useSuspenseQuery(
{
repositories: options.repositories.map((repository) => ({
providerKey: repository.providerKey,
identifier: repository.identifier,
versionFilter: repository.versionFilter,
})),
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
const batchedRepositories = useMemo(() => splitToChunksWithNItems(options.repositories, 5), [options.repositories]);
const [results] = clientApi.useSuspenseQueries((t) =>
batchedRepositories.flatMap((chunk) =>
t.widget.releases.getLatest({
repositories: chunk.map((repository) => ({
providerKey: repository.providerKey,
identifier: repository.identifier,
versionFilter: repository.versionFilter,
})),
}),
),
);
const repositories = useMemo(() => {
return results
.flat()
.map(({ data }) => {
if (data === undefined) return undefined;
const repository = options.repositories.find(
(repository: ReleasesRepository) =>
repository.providerKey === data.providerKey && repository.identifier === data.identifier,
(repository) => repository.providerKey === data.providerKey && repository.identifier === data.identifier,
);
if (repository === undefined) return undefined;
return {
...repository,
...data,
iconUrl: repository.iconUrl,
isNewRelease:
options.newReleaseWithin !== "" ? isDateWithin(data.latestReleaseAt, options.newReleaseWithin) : false,
relativeDateOptions.newReleaseWithin !== "" && data.latestReleaseAt
? isDateWithin(data.latestReleaseAt, relativeDateOptions.newReleaseWithin)
: false,
isStaleRelease:
options.staleReleaseWithin !== "" ? !isDateWithin(data.latestReleaseAt, options.staleReleaseWithin) : false,
relativeDateOptions.staleReleaseWithin !== "" && data.latestReleaseAt
? !isDateWithin(data.latestReleaseAt, relativeDateOptions.staleReleaseWithin)
: false,
};
})
.filter(
(repository) =>
repository !== undefined &&
(!options.showOnlyHighlighted || repository.isNewRelease || repository.isStaleRelease),
(repository.error !== undefined ||
!options.showOnlyHighlighted ||
repository.isNewRelease ||
repository.isStaleRelease),
)
.sort((repoA, repoB) => {
if (repoA?.latestReleaseAt === undefined) return 1;
if (repoB?.latestReleaseAt === undefined) return -1;
return repoA.latestReleaseAt > repoB.latestReleaseAt ? -1 : 1;
}) as ReleasesRepository[];
}) as ReleasesRepositoryResponse[];
}, [
results,
options.repositories,
options.showOnlyHighlighted,
options.newReleaseWithin,
options.staleReleaseWithin,
relativeDateOptions.newReleaseWithin,
relativeDateOptions.staleReleaseWithin,
]);
const toggleExpandedRepository = useCallback(
(identifier: string) => {
setExpandedRepository(expandedRepository === identifier ? "" : identifier);
(repository: ReleasesRepositoryResponse) => {
if (
expandedRepository.providerKey === repository.providerKey &&
expandedRepository.identifier === repository.identifier
) {
setExpandedRepository({ providerKey: "", identifier: "" });
} else {
setExpandedRepository({ providerKey: repository.providerKey, identifier: repository.identifier });
}
},
[expandedRepository],
);
return (
<Stack gap={0}>
{repositories.map((repository: ReleasesRepository) => {
const isActive = expandedRepository === repository.identifier;
{repositories.map((repository: ReleasesRepositoryResponse) => {
const isActive =
expandedRepository.providerKey === repository.providerKey &&
expandedRepository.identifier === repository.identifier;
const hasError = repository.error !== undefined;
return (
<Stack
@@ -141,7 +140,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
[classes.active ?? ""]: isActive,
})}
p="xs"
onClick={() => toggleExpandedRepository(repository.identifier)}
onClick={() => toggleExpandedRepository(repository)}
>
<MaskedOrNormalImage
imageUrl={repository.iconUrl ?? Providers[repository.providerKey]?.iconUrl}
@@ -155,9 +154,14 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
<Group gap={5} justify="space-between" style={{ flex: 1 }}>
<Text size="xs">{repository.identifier}</Text>
<Tooltip label={repository.latestRelease ?? t("not-found")}>
<Text size="xs" fw={700} truncate="end" style={{ flexShrink: 1 }}>
{repository.latestRelease ?? t("not-found")}
<Tooltip
withArrow
arrowSize={5}
label={repository.latestRelease}
events={{ hover: repository.latestRelease !== undefined, focus: false, touch: false }}
>
<Text size="xs" fw={700} truncate="end" c={hasError ? "red" : "text"} style={{ flexShrink: 1 }}>
{hasError ? t("error.label") : (repository.latestRelease ?? t("not-found"))}
</Text>
</Tooltip>
</Group>
@@ -168,20 +172,25 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
c={repository.isNewRelease ? "primaryColor" : repository.isStaleRelease ? "secondaryColor" : "dimmed"}
>
{repository.latestReleaseAt &&
!hasError &&
formatter.relativeTime(repository.latestReleaseAt, {
now,
style: "narrow",
})}
</Text>
{(repository.isNewRelease || repository.isStaleRelease) && (
<IconCircleFilled
size={10}
color={
repository.isNewRelease
? "var(--mantine-color-primaryColor-filled)"
: "var(--mantine-color-secondaryColor-filled)"
}
/>
{!hasError ? (
(repository.isNewRelease || repository.isStaleRelease) && (
<IconCircleFilled
size={10}
color={
repository.isNewRelease
? "var(--mantine-color-primaryColor-filled)"
: "var(--mantine-color-secondaryColor-filled)"
}
/>
)
) : (
<IconTriangleFilled size={10} color={"var(--mantine-color-red-filled)"} />
)}
</Group>
</Group>
@@ -198,8 +207,8 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
}
interface DetailsDisplayProps {
repository: ReleasesRepository;
toggleExpandedRepository: (identifier: string) => void;
repository: ReleasesRepositoryResponse;
toggleExpandedRepository: (repository: ReleasesRepositoryResponse) => void;
}
const DetailsDisplay = ({ repository, toggleExpandedRepository }: DetailsDisplayProps) => {
@@ -208,15 +217,15 @@ const DetailsDisplay = ({ repository, toggleExpandedRepository }: DetailsDisplay
return (
<>
<Divider onClick={() => toggleExpandedRepository(repository.identifier)} />
<Divider onClick={() => toggleExpandedRepository(repository)} />
<Group
className={classes.releasesRepositoryDetails}
justify="space-between"
p={5}
onClick={() => toggleExpandedRepository(repository.identifier)}
onClick={() => toggleExpandedRepository(repository)}
>
<Group>
<Tooltip label={t("pre-release")}>
<Tooltip label={t("pre-release")} withArrow arrowSize={5}>
<IconProgressCheck
size={13}
color={
@@ -225,14 +234,14 @@ const DetailsDisplay = ({ repository, toggleExpandedRepository }: DetailsDisplay
/>
</Tooltip>
<Tooltip label={t("archived")}>
<Tooltip label={t("archived")} withArrow arrowSize={5}>
<IconArchive
size={13}
color={repository.isArchived ? "var(--mantine-color-secondaryColor-text)" : "var(--mantine-color-dimmed)"}
/>
</Tooltip>
<Tooltip label={t("forked")}>
<Tooltip label={t("forked")} withArrow arrowSize={5}>
<IconGitFork
size={13}
color={repository.isFork ? "var(--mantine-color-secondaryColor-text)" : "var(--mantine-color-dimmed)"}
@@ -240,7 +249,7 @@ const DetailsDisplay = ({ repository, toggleExpandedRepository }: DetailsDisplay
</Tooltip>
</Group>
<Group>
<Tooltip label={t("starsCount")}>
<Tooltip label={t("starsCount")} withArrow arrowSize={5}>
<Group gap={5}>
<IconStar
size={12}
@@ -257,7 +266,7 @@ const DetailsDisplay = ({ repository, toggleExpandedRepository }: DetailsDisplay
</Group>
</Tooltip>
<Tooltip label={t("forksCount")}>
<Tooltip label={t("forksCount")} withArrow arrowSize={5}>
<Group gap={5}>
<IconGitFork
size={12}
@@ -274,7 +283,7 @@ const DetailsDisplay = ({ repository, toggleExpandedRepository }: DetailsDisplay
</Group>
</Tooltip>
<Tooltip label={t("issuesCount")}>
<Tooltip label={t("issuesCount")} withArrow arrowSize={5}>
<Group gap={5}>
<IconCircleDot
size={12}
@@ -297,7 +306,7 @@ const DetailsDisplay = ({ repository, toggleExpandedRepository }: DetailsDisplay
};
interface ExtendedDisplayProps {
repository: ReleasesRepository;
repository: ReleasesRepositoryResponse;
hasIconColor: boolean;
}
@@ -337,17 +346,32 @@ const ExpandedDisplay = ({ repository, hasIconColor }: ExtendedDisplayProps) =>
</Text>
)}
</Group>
<Divider my={10} mx="30%" />
<Button
variant="light"
component="a"
href={repository.releaseUrl ?? repository.projectUrl}
target="_blank"
rel="noreferrer"
>
<IconExternalLink />
{repository.releaseUrl ? t("openReleasePage") : t("openProjectPage")}
</Button>
{(repository.releaseUrl ?? repository.projectUrl) && (
<>
<Divider my={10} mx="30%" />
<Button
variant="light"
component="a"
href={repository.releaseUrl ?? repository.projectUrl}
target="_blank"
rel="noreferrer"
>
<IconExternalLink />
{repository.releaseUrl ? t("openReleasePage") : t("openProjectPage")}
</Button>
</>
)}
{repository.error && (
<>
<Divider my={10} mx="30%" />
<Title order={4} ta="center">
{t("error.label")}
</Title>
<Text size="xs" ff="monospace" c="red" style={{ whiteSpace: "pre-wrap" }}>
{repository.error.code ? t(`error.options.${repository.error.code}` as never) : repository.error.message}
</Text>
</>
)}
{repository.releaseDescription && (
<>
<Divider my={10} mx="30%" />

View File

@@ -4,6 +4,11 @@ import { z } from "zod";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
const relativeDateSchema = z
.string()
.regex(/^\d+[hdwmyHDWMY]$/)
.or(z.literal(""));
export const { definition, componentLoader } = createWidgetDefinition("releases", {
icon: IconRocket,
createOptions() {
@@ -11,18 +16,12 @@ export const { definition, componentLoader } = createWidgetDefinition("releases"
newReleaseWithin: factory.text({
defaultValue: "1w",
withDescription: true,
validate: z
.string()
.regex(/^\d+[hdwmy]$/)
.or(z.literal("")),
validate: relativeDateSchema,
}),
staleReleaseWithin: factory.text({
defaultValue: "6m",
defaultValue: "6M",
withDescription: true,
validate: z
.string()
.regex(/^\d+[hdwmy]$/)
.or(z.literal("")),
validate: relativeDateSchema,
}),
showOnlyHighlighted: factory.switch({
withDescription: true,

View File

@@ -9,7 +9,9 @@ export interface ReleasesRepository {
identifier: string;
versionFilter?: ReleasesVersionFilter;
iconUrl?: string;
}
export interface ReleasesRepositoryResponse extends ReleasesRepository {
latestRelease?: string;
latestReleaseAt?: Date;
isNewRelease: boolean;
@@ -27,4 +29,6 @@ export interface ReleasesRepository {
starsCount?: number;
forksCount?: number;
openIssues?: number;
error?: { code?: string; message?: string };
}

1
pnpm-lock.yaml generated
View File

@@ -20538,3 +20538,4 @@ snapshots:
zod@3.24.4: {}
zwitch@2.0.4: {}