diff --git a/packages/api/src/router/widgets/releases.ts b/packages/api/src/router/widgets/releases.ts index a8b797b44..5d90911bb 100644 --- a/packages/api/src/router/widgets/releases.ts +++ b/packages/api/src/router/widgets/releases.ts @@ -5,7 +5,7 @@ import { releasesRequestHandler } from "@homarr/request-handler/releases"; import { createTRPCRouter, publicProcedure } from "../../trpc"; -const formatVersionFilterRegex = (versionFilter: z.infer | undefined) => { +const formatVersionFilterRegex = (versionFilter: z.infer | undefined) => { if (!versionFilter) return undefined; const escapedPrefix = versionFilter.prefix ? escapeForRegEx(versionFilter.prefix) : ""; @@ -15,7 +15,7 @@ const formatVersionFilterRegex = (versionFilter: z.infer(array: T[], chunks: number): T[][] => { } return result; }; + +export const splitToChunksWithNItems = (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; +}; diff --git a/packages/common/src/date.ts b/packages/common/src/date.ts new file mode 100644 index 000000000..c7dc36913 --- /dev/null +++ b/packages/common/src/date.ts @@ -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, "[]"); +}; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index cbcec5e2a..fc69d4120 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -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"; diff --git a/packages/common/src/test/array.spec.ts b/packages/common/src/test/array.spec.ts new file mode 100644 index 000000000..ce0cb3640 --- /dev/null +++ b/packages/common/src/test/array.spec.ts @@ -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]]); + }); +}); diff --git a/packages/common/src/test/date.spec.ts b/packages/common/src/test/date.spec.ts new file mode 100644 index 000000000..355f0c997 --- /dev/null +++ b/packages/common/src/test/date.spec.ts @@ -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"); + }); +}); diff --git a/packages/request-handler/src/releases-providers.ts b/packages/request-handler/src/releases-providers.ts index 3ed3383ea..9e30fc8f1 100644 --- a/packages/request-handler/src/releases-providers.ts +++ b/packages/request-handler/src/releases-providers.ts @@ -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; diff --git a/packages/request-handler/src/releases.ts b/packages/request-handler/src/releases.ts index 17d7b924e..bb6ffac22 100644 --- a/packages/request-handler/src/releases.ts +++ b/packages/request-handler/src/releases.ts @@ -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; + 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; +export type ReleaseResponse = z.infer; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 515ff45b5..08f197944 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -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": {}, diff --git a/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx b/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx index ddd7123ef..466001e0c 100644 --- a/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx +++ b/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx @@ -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 (
diff --git a/packages/widgets/src/releases/component.tsx b/packages/widgets/src/releases/component.tsx index d6a465f7c..b8fa38fd3 100644 --- a/packages/widgets/src/releases/component.tsx +++ b/packages/widgets/src/releases/component.tsx @@ -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 ( - {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 ( toggleExpandedRepository(repository.identifier)} + onClick={() => toggleExpandedRepository(repository)} > {repository.identifier} - - - {repository.latestRelease ?? t("not-found")} + + + {hasError ? t("error.label") : (repository.latestRelease ?? t("not-found"))} @@ -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", })} - {(repository.isNewRelease || repository.isStaleRelease) && ( - + {!hasError ? ( + (repository.isNewRelease || repository.isStaleRelease) && ( + + ) + ) : ( + )} @@ -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 ( <> - toggleExpandedRepository(repository.identifier)} /> + toggleExpandedRepository(repository)} /> toggleExpandedRepository(repository.identifier)} + onClick={() => toggleExpandedRepository(repository)} > - + - + - + - + - + - + )} - - + {(repository.releaseUrl ?? repository.projectUrl) && ( + <> + + + + )} + {repository.error && ( + <> + + + {t("error.label")} + + + {repository.error.code ? t(`error.options.${repository.error.code}` as never) : repository.error.message} + + + )} {repository.releaseDescription && ( <> diff --git a/packages/widgets/src/releases/index.ts b/packages/widgets/src/releases/index.ts index 1afeabb1e..55e7414bc 100644 --- a/packages/widgets/src/releases/index.ts +++ b/packages/widgets/src/releases/index.ts @@ -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, diff --git a/packages/widgets/src/releases/releases-repository.ts b/packages/widgets/src/releases/releases-repository.ts index a6fe7f368..8697d42fa 100644 --- a/packages/widgets/src/releases/releases-repository.ts +++ b/packages/widgets/src/releases/releases-repository.ts @@ -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 }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07e1bdbe3..061450fbc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20538,3 +20538,4 @@ snapshots: zod@3.24.4: {} zwitch@2.0.4: {} + \ No newline at end of file