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:
@@ -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(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
26
packages/common/src/date.ts
Normal file
26
packages/common/src/date.ts
Normal 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, "[]");
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
49
packages/common/src/test/array.spec.ts
Normal file
49
packages/common/src/test/array.spec.ts
Normal 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]]);
|
||||
});
|
||||
});
|
||||
91
packages/common/src/test/date.spec.ts
Normal file
91
packages/common/src/test/date.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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": {},
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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%" />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
1
pnpm-lock.yaml
generated
@@ -20538,3 +20538,4 @@ snapshots:
|
||||
zod@3.24.4: {}
|
||||
|
||||
zwitch@2.0.4: {}
|
||||
|
||||
Reference in New Issue
Block a user