feat: refine mobile dashboard experience
This commit is contained in:
1 parent
9f0969ec22
commit
315520beca
116 files changed
+2499
-586
No files matched your search
@@ -1592,6 +1592,9 @@ const getHomeIdBoardAsync = async (
|
||||
user: InferSelectModel<typeof users> | null,
|
||||
deviceType: DeviceType,
|
||||
) => {
|
||||
// Mobile home-board selection remains device-based because the server does
|
||||
// not know the resolved CSS viewport. Tablet rendering is resolved against
|
||||
// 48em on the client and must not be guessed here.
|
||||
const settingKey = deviceType === "mobile" ? "mobileHomeBoardId" : "homeBoardId";
|
||||
|
||||
if (!user) {
|
||||
@@ -1613,8 +1616,7 @@ const getHomeIdBoardAsync = async (
|
||||
and(
|
||||
eq(groupMembers.userId, user.id),
|
||||
not(eq(groups.name, everyoneGroup)),
|
||||
not(isNull(groups[settingKey])),
|
||||
not(isNull(groups.homeBoardId)),
|
||||
or(not(isNull(groups[settingKey])), not(isNull(groups.homeBoardId))),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(groups.position))
|
||||
|
||||
@@ -516,6 +516,64 @@ describe("getHomeBoard should return home board", () => {
|
||||
});
|
||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "view");
|
||||
});
|
||||
test.each([
|
||||
{ deviceType: "mobile", expectedBoard: "mobile-home" },
|
||||
{ deviceType: "tablet", expectedBoard: "desktop-home" },
|
||||
] as const)(
|
||||
"should use the $expectedBoard preference for a $deviceType device",
|
||||
async ({ deviceType, expectedBoard }) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType, session: defaultSession });
|
||||
|
||||
const mobileBoardProps = await createFullBoardAsync(db, "mobile-home");
|
||||
const desktopBoardProps = await createFullBoardAsync(db, "desktop-home");
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
homeBoardId: desktopBoardProps.boardId,
|
||||
mobileHomeBoardId: mobileBoardProps.boardId,
|
||||
})
|
||||
.where(eq(users.id, defaultCreatorId));
|
||||
|
||||
// Act
|
||||
const result = await caller.getHomeBoard();
|
||||
|
||||
// Assert
|
||||
expectInputToBeFullBoardWithName(result, {
|
||||
...(expectedBoard === "mobile-home" ? mobileBoardProps : desktopBoardProps),
|
||||
name: expectedBoard,
|
||||
});
|
||||
},
|
||||
);
|
||||
test("should return a mobile-only group home board", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: "mobile", session: defaultSession });
|
||||
|
||||
const mobileBoardProps = await createFullBoardAsync(db, "group-mobile-home");
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "mobile group",
|
||||
position: 1,
|
||||
homeBoardId: null,
|
||||
mobileHomeBoardId: mobileBoardProps.boardId,
|
||||
});
|
||||
await db.insert(groupMembers).values({
|
||||
userId: defaultCreatorId,
|
||||
groupId,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await caller.getHomeBoard();
|
||||
|
||||
// Assert
|
||||
expectInputToBeFullBoardWithName(result, {
|
||||
name: "group-mobile-home",
|
||||
...mobileBoardProps,
|
||||
});
|
||||
});
|
||||
test("should return global home board when user doesn't have one", async () => {
|
||||
// Arrange
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
@@ -1427,9 +1485,12 @@ const expectInputToBeFullBoardWithName = (
|
||||
};
|
||||
|
||||
const createFullBoardAsync = async (db: Database, name: string) => {
|
||||
await db.insert(users).values({
|
||||
id: defaultCreatorId,
|
||||
});
|
||||
await db
|
||||
.insert(users)
|
||||
.values({
|
||||
id: defaultCreatorId,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
const boardId = createId();
|
||||
await db.insert(boards).values({
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { radarrReleaseTypes } from "@homarr/integrations/types";
|
||||
import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const logger = createLogger({ module: "calendarRouter" });
|
||||
|
||||
export const calendarRouter = createTRPCRouter({
|
||||
findAllEvents: publicProcedure
|
||||
.meta({
|
||||
@@ -30,8 +27,9 @@ export const calendarRouter = createTRPCRouter({
|
||||
)
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const settled = await Promise.allSettled(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
async (integration) => {
|
||||
const { integrationIds: _integrationIds, ...handlerInput } = input;
|
||||
const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput);
|
||||
const { data } = await innerHandler.getDataAsync();
|
||||
@@ -44,25 +42,8 @@ export const calendarRouter = createTRPCRouter({
|
||||
kind: integration.kind,
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
{ throwOnAllFailure: true },
|
||||
);
|
||||
|
||||
return settled.flatMap((result, index) => {
|
||||
if (result.status === "fulfilled") {
|
||||
return [result.value];
|
||||
}
|
||||
const integration = ctx.integrations[index];
|
||||
logger.warn(
|
||||
new ErrorWithMetadata(
|
||||
"Calendar integration request failed; skipping events for this integration",
|
||||
{
|
||||
integrationId: integration?.id,
|
||||
integrationKind: integration?.kind,
|
||||
},
|
||||
{ cause: result.reason },
|
||||
),
|
||||
);
|
||||
return [];
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -24,14 +24,18 @@ export const downloadsRouter = createTRPCRouter({
|
||||
.concat(createDownloadClientIntegrationMiddleware("query"))
|
||||
.input(z.object({ limitPerIntegration: z.number().default(50) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration });
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
return {
|
||||
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
|
||||
data,
|
||||
};
|
||||
});
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
async (integration) => {
|
||||
const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration });
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
return {
|
||||
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
|
||||
data,
|
||||
};
|
||||
},
|
||||
{ throwOnAllFailure: true },
|
||||
);
|
||||
}),
|
||||
pause: protectedProcedure
|
||||
.meta({
|
||||
|
||||
@@ -27,15 +27,19 @@ export const healthMonitoringRouter = createTRPCRouter({
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", ...healthMonitoringIntegrationKinds))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const { data, timestamp } = await systemInfoRequestHandler.handler(integration, {}).getDataAsync();
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
healthInfo: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
});
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
async (integration) => {
|
||||
const { data, timestamp } = await systemInfoRequestHandler.handler(integration, {}).getDataAsync();
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
healthInfo: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
},
|
||||
{ throwOnAllFailure: true },
|
||||
);
|
||||
}),
|
||||
listStorageVolumes: publicProcedure
|
||||
.meta({
|
||||
|
||||
@@ -9,20 +9,24 @@ export const mediaReleaseRouter = createTRPCRouter({
|
||||
getMediaReleases: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")))
|
||||
.query(async ({ ctx }) => {
|
||||
const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const innerHandler = mediaReleaseRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
const results = await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
async (integration) => {
|
||||
const innerHandler = mediaReleaseRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
|
||||
return {
|
||||
integration: {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
releases: data,
|
||||
};
|
||||
});
|
||||
return {
|
||||
integration: {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
releases: data,
|
||||
};
|
||||
},
|
||||
{ throwOnAllFailure: true },
|
||||
);
|
||||
return results.flatMap((result) =>
|
||||
result.releases.map((release) => ({
|
||||
...release,
|
||||
|
||||
@@ -25,10 +25,14 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
|
||||
.input(mediaRequestListInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const { data } = await mediaRequestListRequestHandler.handler(integration, input).getDataAsync();
|
||||
return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
|
||||
});
|
||||
const results = await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
async (integration) => {
|
||||
const { data } = await mediaRequestListRequestHandler.handler(integration, input).getDataAsync();
|
||||
return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
|
||||
},
|
||||
{ throwOnAllFailure: true },
|
||||
);
|
||||
return results
|
||||
.flatMap(({ data, integration }) =>
|
||||
data.map((request) => ({
|
||||
@@ -57,10 +61,14 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
|
||||
.query(async ({ ctx }) => {
|
||||
const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync();
|
||||
return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
|
||||
});
|
||||
const results = await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
async (integration) => {
|
||||
const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync();
|
||||
return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
|
||||
},
|
||||
{ throwOnAllFailure: true },
|
||||
);
|
||||
return {
|
||||
stats: results.flatMap((result) => result.data.stats),
|
||||
users: results
|
||||
|
||||
@@ -23,11 +23,15 @@ export const mediaServerRouter = createTRPCRouter({
|
||||
.concat(createMediaServerIntegrationMiddleware("query"))
|
||||
.input(z.object({ showOnlyPlaying: z.boolean() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const { data } = await mediaServerRequestHandler
|
||||
.handler(integration, { showOnlyPlaying: input.showOnlyPlaying })
|
||||
.getDataAsync();
|
||||
return { integrationId: integration.id, integrationKind: integration.kind, sessions: data };
|
||||
});
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
async (integration) => {
|
||||
const { data } = await mediaServerRequestHandler
|
||||
.handler(integration, { showOnlyPlaying: input.showOnlyPlaying })
|
||||
.getDataAsync();
|
||||
return { integrationId: integration.id, integrationKind: integration.kind, sessions: data };
|
||||
},
|
||||
{ throwOnAllFailure: true },
|
||||
);
|
||||
}),
|
||||
});
|
||||
@@ -13,19 +13,23 @@ export const notificationsRouter = createTRPCRouter({
|
||||
getNotifications: publicProcedure
|
||||
.unstable_concat(createNotificationsIntegrationMiddleware("query"))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const innerHandler = notificationsRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
async (integration) => {
|
||||
const innerHandler = notificationsRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
|
||||
return {
|
||||
integration: {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
data,
|
||||
};
|
||||
});
|
||||
return {
|
||||
integration: {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
data,
|
||||
};
|
||||
},
|
||||
{ throwOnAllFailure: true },
|
||||
);
|
||||
}),
|
||||
});
|
||||
@@ -8,17 +8,21 @@ export const uptimeKumaRouter = createTRPCRouter({
|
||||
getDashboard: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "uptimeKuma"))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const innerHandler = uptimeKumaRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
async (integration) => {
|
||||
const innerHandler = uptimeKumaRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
dashboard: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
});
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
dashboard: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
},
|
||||
{ throwOnAllFailure: true },
|
||||
);
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { settleIntegrationQueries } from "./settle-integrations";
|
||||
|
||||
const integrations = [
|
||||
{ id: "first", name: "First", kind: "mock" },
|
||||
{ id: "second", name: "Second", kind: "mock" },
|
||||
];
|
||||
|
||||
describe("settleIntegrationQueries", () => {
|
||||
it("retains successful integration results when another integration fails", async () => {
|
||||
const results = await settleIntegrationQueries(
|
||||
integrations,
|
||||
vi.fn(async (integration) => {
|
||||
if (integration.id === "first") throw new Error("offline");
|
||||
return integration.name;
|
||||
}),
|
||||
{ throwOnAllFailure: true },
|
||||
);
|
||||
|
||||
expect(results).toEqual(["Second"]);
|
||||
});
|
||||
|
||||
it("throws when every integration fails and throwOnAllFailure is enabled", async () => {
|
||||
await expect(
|
||||
settleIntegrationQueries(
|
||||
integrations,
|
||||
vi.fn(async () => {
|
||||
throw new Error("offline");
|
||||
}),
|
||||
{ throwOnAllFailure: true },
|
||||
),
|
||||
).rejects.toThrow("offline");
|
||||
});
|
||||
|
||||
it("does not treat an empty integration list as a failure", async () => {
|
||||
await expect(
|
||||
settleIntegrationQueries([], vi.fn(async () => "unused"), { throwOnAllFailure: true }),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ interface IntegrationLike {
|
||||
|
||||
interface Options<TIntegration extends IntegrationLike, TResult> {
|
||||
fallback?: (integration: TIntegration, error: unknown) => TResult;
|
||||
throwOnAllFailure?: boolean;
|
||||
}
|
||||
|
||||
export async function settleIntegrationQueries<TIntegration extends IntegrationLike, TResult>(
|
||||
@@ -19,7 +20,7 @@ export async function settleIntegrationQueries<TIntegration extends IntegrationL
|
||||
options?: Options<TIntegration, TResult>,
|
||||
): Promise<TResult[]> {
|
||||
const settled = await Promise.allSettled(integrations.map(async (integration) => fn(integration)));
|
||||
return settled.flatMap((result, index) => {
|
||||
const results = settled.flatMap((result, index) => {
|
||||
if (result.status === "fulfilled") return [result.value];
|
||||
const integration = integrations[index];
|
||||
logger.warn(
|
||||
@@ -32,4 +33,16 @@ export async function settleIntegrationQueries<TIntegration extends IntegrationL
|
||||
if (options?.fallback && integration) return [options.fallback(integration, result.reason)];
|
||||
return [];
|
||||
});
|
||||
|
||||
const firstFailure = settled.find((result) => result.status === "rejected");
|
||||
if (
|
||||
options?.throwOnAllFailure === true &&
|
||||
settled.length > 0 &&
|
||||
settled.every((result) => result.status === "rejected") &&
|
||||
firstFailure?.status === "rejected"
|
||||
) {
|
||||
throw firstFailure.reason;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
Reference in new issue
Block a user