Revert "feat: complete automatic mobile board UX"
This reverts commit 7959c20d88.
This commit is contained in:
1 parent
dba9a250ff
commit
b612fb9a89
85 files changed
+565
-2867
No files matched your search
@@ -49,7 +49,6 @@ import {
|
||||
boardDuplicateSchema,
|
||||
boardRenameSchema,
|
||||
boardSaveLayoutSchema,
|
||||
boardSaveLayoutsSchema,
|
||||
boardSavePartialSettingsSchema,
|
||||
boardSavePermissionsSchema,
|
||||
boardSaveSchema,
|
||||
@@ -688,7 +687,7 @@ export const boardRouter = createTRPCRouter({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Set a board's desktop column count while preserving saved responsive layouts. Requires modify permission. REQUIRED: id (board ID), columnCount (1-24)",
|
||||
"Set a board's desktop column count and remove legacy responsive layouts. Requires modify permission. REQUIRED: id (board ID), columnCount (1-24)",
|
||||
},
|
||||
})
|
||||
.input(boardSaveLayoutSchema)
|
||||
@@ -762,8 +761,12 @@ export const boardRouter = createTRPCRouter({
|
||||
|
||||
await transaction
|
||||
.update(schema.layouts)
|
||||
.set({ columnCount: input.columnCount })
|
||||
.set({ name: "Base", columnCount: input.columnCount, breakpoint: 0 })
|
||||
.where(eq(schema.layouts.id, desktopLayout.id));
|
||||
|
||||
await transaction
|
||||
.delete(schema.layouts)
|
||||
.where(and(eq(schema.layouts.boardId, board.id), not(eq(schema.layouts.id, desktopLayout.id))));
|
||||
});
|
||||
},
|
||||
handleSync(db) {
|
||||
@@ -808,230 +811,18 @@ export const boardRouter = createTRPCRouter({
|
||||
|
||||
transaction
|
||||
.update(layouts)
|
||||
.set({ columnCount: input.columnCount })
|
||||
.set({ name: "Base", columnCount: input.columnCount, breakpoint: 0 })
|
||||
.where(eq(layouts.id, desktopLayout.id))
|
||||
.run();
|
||||
|
||||
transaction
|
||||
.delete(layouts)
|
||||
.where(and(eq(layouts.boardId, board.id), not(eq(layouts.id, desktopLayout.id))))
|
||||
.run();
|
||||
});
|
||||
},
|
||||
});
|
||||
}),
|
||||
saveLayouts: protectedProcedure
|
||||
.input(boardSaveLayoutsSchema)
|
||||
.output(boardSaveLayoutsSchema.shape.layouts)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
|
||||
|
||||
const board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
|
||||
const addedLayouts = filterAddedItems(input.layouts, board.layouts);
|
||||
const layoutsToInsert: InferInsertModel<typeof layouts>[] = [];
|
||||
const itemSectionLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = [];
|
||||
const sectionLayoutsToInsert: InferInsertModel<typeof sectionLayouts>[] = [];
|
||||
const savedLayoutIds = new Map<string, string>();
|
||||
|
||||
for (const addedLayout of addedLayouts) {
|
||||
const layoutId = createId();
|
||||
savedLayoutIds.set(addedLayout.id, layoutId);
|
||||
|
||||
layoutsToInsert.push({
|
||||
id: layoutId,
|
||||
name: addedLayout.name,
|
||||
columnCount: addedLayout.columnCount,
|
||||
breakpoint: addedLayout.breakpoint,
|
||||
boardId: board.id,
|
||||
});
|
||||
|
||||
const sortedLayouts = board.layouts.toSorted((layoutA, layoutB) => layoutA.columnCount - layoutB.columnCount);
|
||||
const layoutToClone =
|
||||
sortedLayouts.find((layout) => layout.columnCount >= addedLayout.columnCount) ?? sortedLayouts.at(-1);
|
||||
|
||||
if (!layoutToClone) {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Board must have a layout" });
|
||||
}
|
||||
|
||||
const updatedBoardLayout = getUpdatedBoardLayout(board, {
|
||||
previous: {
|
||||
layoutId: layoutToClone.id,
|
||||
columnCount: layoutToClone.columnCount,
|
||||
},
|
||||
current: {
|
||||
layoutId,
|
||||
columnCount: addedLayout.columnCount,
|
||||
},
|
||||
});
|
||||
|
||||
itemSectionLayoutsToInsert.push(...updatedBoardLayout.itemSectionLayouts);
|
||||
sectionLayoutsToInsert.push(...updatedBoardLayout.sectionLayouts);
|
||||
}
|
||||
|
||||
const updatedLayouts = filterUpdatedItems(input.layouts, board.layouts);
|
||||
const itemSectionLayoutsToUpdate: InferInsertModel<typeof itemLayouts>[] = [];
|
||||
const sectionLayoutsToUpdate: InferInsertModel<typeof sectionLayouts>[] = [];
|
||||
const layoutsToUpdate: typeof input.layouts = [];
|
||||
|
||||
for (const updatedLayout of updatedLayouts) {
|
||||
const dbLayout = board.layouts.find((layout) => layout.id === updatedLayout.id);
|
||||
if (!dbLayout) continue;
|
||||
|
||||
if (dbLayout.columnCount !== updatedLayout.columnCount) {
|
||||
const updatedBoardLayout = getUpdatedBoardLayout(board, {
|
||||
previous: {
|
||||
layoutId: dbLayout.id,
|
||||
columnCount: dbLayout.columnCount,
|
||||
},
|
||||
current: {
|
||||
layoutId: dbLayout.id,
|
||||
columnCount: updatedLayout.columnCount,
|
||||
},
|
||||
});
|
||||
|
||||
itemSectionLayoutsToUpdate.push(...updatedBoardLayout.itemSectionLayouts);
|
||||
sectionLayoutsToUpdate.push(...updatedBoardLayout.sectionLayouts);
|
||||
}
|
||||
|
||||
layoutsToUpdate.push(updatedLayout);
|
||||
}
|
||||
|
||||
const removedLayoutIds = filterRemovedItems(input.layouts, board.layouts).map((layout) => layout.id);
|
||||
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
async handleAsync(db, schema) {
|
||||
await db.transaction(async (transaction) => {
|
||||
if (layoutsToInsert.length > 0) {
|
||||
await transaction.insert(schema.layouts).values(layoutsToInsert);
|
||||
}
|
||||
if (itemSectionLayoutsToInsert.length > 0) {
|
||||
await transaction.insert(schema.itemLayouts).values(itemSectionLayoutsToInsert);
|
||||
}
|
||||
if (sectionLayoutsToInsert.length > 0) {
|
||||
await transaction.insert(schema.sectionLayouts).values(sectionLayoutsToInsert);
|
||||
}
|
||||
|
||||
for (const itemSectionLayout of itemSectionLayoutsToUpdate) {
|
||||
await transaction
|
||||
.update(schema.itemLayouts)
|
||||
.set({
|
||||
height: itemSectionLayout.height,
|
||||
width: itemSectionLayout.width,
|
||||
xOffset: itemSectionLayout.xOffset,
|
||||
yOffset: itemSectionLayout.yOffset,
|
||||
sectionId: itemSectionLayout.sectionId,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(schema.itemLayouts.itemId, itemSectionLayout.itemId),
|
||||
eq(schema.itemLayouts.layoutId, itemSectionLayout.layoutId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (const sectionLayout of sectionLayoutsToUpdate) {
|
||||
await transaction
|
||||
.update(schema.sectionLayouts)
|
||||
.set({
|
||||
height: sectionLayout.height,
|
||||
width: sectionLayout.width,
|
||||
xOffset: sectionLayout.xOffset,
|
||||
yOffset: sectionLayout.yOffset,
|
||||
parentSectionId: sectionLayout.parentSectionId,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(schema.sectionLayouts.sectionId, sectionLayout.sectionId),
|
||||
eq(schema.sectionLayouts.layoutId, sectionLayout.layoutId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (const layout of layoutsToUpdate) {
|
||||
await transaction
|
||||
.update(schema.layouts)
|
||||
.set({
|
||||
name: layout.name,
|
||||
columnCount: layout.columnCount,
|
||||
breakpoint: layout.breakpoint,
|
||||
})
|
||||
.where(eq(schema.layouts.id, layout.id));
|
||||
}
|
||||
|
||||
if (removedLayoutIds.length > 0) {
|
||||
await transaction.delete(schema.layouts).where(inArray(schema.layouts.id, removedLayoutIds));
|
||||
}
|
||||
});
|
||||
},
|
||||
handleSync(db) {
|
||||
db.transaction((transaction) => {
|
||||
if (layoutsToInsert.length > 0) {
|
||||
transaction.insert(layouts).values(layoutsToInsert).run();
|
||||
}
|
||||
if (itemSectionLayoutsToInsert.length > 0) {
|
||||
transaction.insert(itemLayouts).values(itemSectionLayoutsToInsert).run();
|
||||
}
|
||||
if (sectionLayoutsToInsert.length > 0) {
|
||||
transaction.insert(sectionLayouts).values(sectionLayoutsToInsert).run();
|
||||
}
|
||||
|
||||
for (const itemSectionLayout of itemSectionLayoutsToUpdate) {
|
||||
transaction
|
||||
.update(itemLayouts)
|
||||
.set({
|
||||
height: itemSectionLayout.height,
|
||||
width: itemSectionLayout.width,
|
||||
xOffset: itemSectionLayout.xOffset,
|
||||
yOffset: itemSectionLayout.yOffset,
|
||||
sectionId: itemSectionLayout.sectionId,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(itemLayouts.itemId, itemSectionLayout.itemId),
|
||||
eq(itemLayouts.layoutId, itemSectionLayout.layoutId),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
for (const sectionLayout of sectionLayoutsToUpdate) {
|
||||
transaction
|
||||
.update(sectionLayouts)
|
||||
.set({
|
||||
height: sectionLayout.height,
|
||||
width: sectionLayout.width,
|
||||
xOffset: sectionLayout.xOffset,
|
||||
yOffset: sectionLayout.yOffset,
|
||||
parentSectionId: sectionLayout.parentSectionId,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(sectionLayouts.sectionId, sectionLayout.sectionId),
|
||||
eq(sectionLayouts.layoutId, sectionLayout.layoutId),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
for (const layout of layoutsToUpdate) {
|
||||
transaction
|
||||
.update(layouts)
|
||||
.set({
|
||||
name: layout.name,
|
||||
columnCount: layout.columnCount,
|
||||
breakpoint: layout.breakpoint,
|
||||
})
|
||||
.where(eq(layouts.id, layout.id))
|
||||
.run();
|
||||
}
|
||||
|
||||
if (removedLayoutIds.length > 0) {
|
||||
transaction.delete(layouts).where(inArray(layouts.id, removedLayoutIds)).run();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return input.layouts.map((layout) => ({
|
||||
...layout,
|
||||
id: savedLayoutIds.get(layout.id) ?? layout.id,
|
||||
}));
|
||||
}),
|
||||
savePartialBoardSettings: protectedProcedure
|
||||
.meta({
|
||||
openapi: { method: "PATCH", path: "/api/boards/{id}/settings", tags: ["boards"], protect: true },
|
||||
@@ -1700,16 +1491,16 @@ export const boardRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
const itemId = createId();
|
||||
const itemRow = {
|
||||
|
||||
await ctx.db.insert(items).values({
|
||||
id: itemId,
|
||||
boardId: input.boardId,
|
||||
kind: input.kind,
|
||||
options: superjson.stringify(input.options),
|
||||
advancedOptions: emptySuperJSON,
|
||||
};
|
||||
});
|
||||
|
||||
const layoutRows: (typeof itemLayouts.$inferInsert)[] = [];
|
||||
const defaultSize = widgetDefaultSizes[input.kind as WidgetKind] ?? { width: 1, height: 1 };
|
||||
|
||||
for (const layout of board.layouts) {
|
||||
const existingInSection = board.items
|
||||
@@ -1728,17 +1519,14 @@ export const boardRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
|
||||
const layoutItemSize = {
|
||||
...defaultSize,
|
||||
width: Math.min(defaultSize.width, layout.columnCount),
|
||||
};
|
||||
const defaultSize = widgetDefaultSizes[input.kind as WidgetKind] ?? { width: 1, height: 1 };
|
||||
|
||||
const fitsAt = (x: number, y: number) => {
|
||||
if (x + layoutItemSize.width > layout.columnCount) return false;
|
||||
for (let dy = 0; dy < layoutItemSize.height; dy++) {
|
||||
if (x + defaultSize.width > layout.columnCount) return false;
|
||||
for (let dy = 0; dy < defaultSize.height; dy++) {
|
||||
const row = occupied[y + dy];
|
||||
if (!row) continue;
|
||||
for (let dx = 0; dx < layoutItemSize.width; dx++) {
|
||||
for (let dx = 0; dx < defaultSize.width; dx++) {
|
||||
if (row[x + dx]) return false;
|
||||
}
|
||||
}
|
||||
@@ -1756,8 +1544,8 @@ export const boardRouter = createTRPCRouter({
|
||||
layoutId: layout.id,
|
||||
xOffset: x,
|
||||
yOffset: y,
|
||||
width: layoutItemSize.width,
|
||||
height: layoutItemSize.height,
|
||||
width: defaultSize.width,
|
||||
height: defaultSize.height,
|
||||
});
|
||||
placed = true;
|
||||
}
|
||||
@@ -1772,32 +1560,15 @@ export const boardRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
|
||||
const integrationRows = input.integrationIds.map((integrationId) => ({ itemId, integrationId }));
|
||||
if (layoutRows.length > 0) {
|
||||
await ctx.db.insert(itemLayouts).values(layoutRows);
|
||||
}
|
||||
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
async handleAsync(db, schema) {
|
||||
await db.transaction(async (transaction) => {
|
||||
await transaction.insert(schema.items).values(itemRow);
|
||||
if (layoutRows.length > 0) {
|
||||
await transaction.insert(schema.itemLayouts).values(layoutRows);
|
||||
}
|
||||
if (integrationRows.length > 0) {
|
||||
await transaction.insert(schema.integrationItems).values(integrationRows);
|
||||
}
|
||||
});
|
||||
},
|
||||
handleSync(db) {
|
||||
db.transaction((transaction) => {
|
||||
transaction.insert(items).values(itemRow).run();
|
||||
if (layoutRows.length > 0) {
|
||||
transaction.insert(itemLayouts).values(layoutRows).run();
|
||||
}
|
||||
if (integrationRows.length > 0) {
|
||||
transaction.insert(integrationItems).values(integrationRows).run();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
if (input.integrationIds.length > 0) {
|
||||
await ctx.db
|
||||
.insert(integrationItems)
|
||||
.values(input.integrationIds.map((integrationId) => ({ itemId, integrationId })));
|
||||
}
|
||||
|
||||
return { itemId };
|
||||
}),
|
||||
|
||||
@@ -19,7 +19,6 @@ import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
|
||||
const boardServerSettingsSchema = z.object({
|
||||
homeBoardId: z.string().nullable(),
|
||||
mobileHomeBoardId: z.string().nullable(),
|
||||
enableAutomaticMobileLayout: z.boolean(),
|
||||
enableStatusByDefault: z.boolean(),
|
||||
forceDisableStatus: z.boolean(),
|
||||
}) satisfies z.ZodType<ServerSettings["board"]>;
|
||||
@@ -40,7 +39,7 @@ export const serverSettingsRouter = createTRPCRouter({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get global board defaults, including desktop/mobile home board IDs, automatic mobile layout mode, and status behavior. Requires admin permission",
|
||||
"Get global board defaults, including desktop/mobile home board IDs and status behavior. Requires admin permission",
|
||||
},
|
||||
})
|
||||
.input(z.void())
|
||||
@@ -55,7 +54,7 @@ export const serverSettingsRouter = createTRPCRouter({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Update global board defaults. Requires admin permission. Optional fields: homeBoardId, mobileHomeBoardId, enableAutomaticMobileLayout, enableStatusByDefault, forceDisableStatus. Home board IDs must reference public boards or be null",
|
||||
"Update global board defaults. Requires admin permission. Optional fields: homeBoardId, mobileHomeBoardId, enableStatusByDefault, forceDisableStatus. Home board IDs must reference public boards or be null",
|
||||
},
|
||||
})
|
||||
.input(boardServerSettingsUpdateSchema)
|
||||
@@ -104,19 +103,10 @@ export const serverSettingsRouter = createTRPCRouter({
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const current = await getServerSettingByKeyAsync(ctx.db, input.settingsKey);
|
||||
const next = {
|
||||
await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, {
|
||||
...current,
|
||||
...input.value,
|
||||
} as ServerSettings[keyof ServerSettings];
|
||||
const existing = await ctx.db.query.serverSettings.findFirst({
|
||||
where: eq(serverSettings.settingKey, input.settingsKey),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, next);
|
||||
} else {
|
||||
await insertServerSettingByKeyAsync(ctx.db, input.settingsKey, next);
|
||||
}
|
||||
} as ServerSettings[keyof ServerSettings]);
|
||||
}),
|
||||
initSettings: onboardingProcedure
|
||||
.requiresStep("settings")
|
||||
|
||||
@@ -4,7 +4,7 @@ import { describe, expect, it, test, vi } from "vitest";
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import type { Database, InferInsertModel } from "@homarr/db";
|
||||
import { and, eq, not } from "@homarr/db";
|
||||
import { and, eq } from "@homarr/db";
|
||||
import {
|
||||
boardGroupPermissions,
|
||||
boards,
|
||||
@@ -1382,187 +1382,6 @@ describe("saveGroupBoardPermissions should save group board permissions", () =>
|
||||
);
|
||||
});
|
||||
|
||||
const createExistingLayout = (id: string) => ({
|
||||
id,
|
||||
name: "Base",
|
||||
columnCount: 10,
|
||||
breakpoint: 0,
|
||||
});
|
||||
|
||||
const createNewLayout = (columnCount: number) => ({
|
||||
id: createId(),
|
||||
name: "New layout",
|
||||
columnCount,
|
||||
breakpoint: 1400,
|
||||
});
|
||||
|
||||
describe("saveLayouts should preserve legacy responsive layout editing", () => {
|
||||
test("should add a layout and clone item and dynamic-section placements", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default");
|
||||
const assignments = await createItemsAndSectionsAsync(db, {
|
||||
boardId,
|
||||
layoutId,
|
||||
sectionId,
|
||||
});
|
||||
const newLayout = createNewLayout(3);
|
||||
|
||||
const savedLayouts = await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [createExistingLayout(layoutId), newLayout],
|
||||
});
|
||||
|
||||
const layout = expectToBeDefined(
|
||||
await db.query.layouts.findFirst({
|
||||
where: not(eq(layouts.id, layoutId)),
|
||||
}),
|
||||
);
|
||||
expect(layout).toMatchObject({
|
||||
name: newLayout.name,
|
||||
columnCount: newLayout.columnCount,
|
||||
breakpoint: newLayout.breakpoint,
|
||||
});
|
||||
expect(savedLayouts).toEqual([createExistingLayout(layoutId), { ...newLayout, id: layout.id }]);
|
||||
await expectLayoutForRootLayoutAsync(db, sectionId, layout.id, {
|
||||
...assignments.inRoot,
|
||||
a: itemId,
|
||||
});
|
||||
await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layout.id, assignments.inDynamicSection);
|
||||
|
||||
await caller.saveLayouts({ id: boardId, layouts: savedLayouts });
|
||||
await expect(db.query.layouts.findMany({ where: eq(layouts.boardId, boardId) })).resolves.toHaveLength(2);
|
||||
});
|
||||
|
||||
test("should update layout metadata and positions", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default");
|
||||
const assignments = await createItemsAndSectionsAsync(db, {
|
||||
boardId,
|
||||
layoutId,
|
||||
sectionId,
|
||||
});
|
||||
|
||||
await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [
|
||||
{
|
||||
id: layoutId,
|
||||
name: "Phone",
|
||||
columnCount: 3,
|
||||
breakpoint: 480,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(db.query.layouts.findFirst({ where: eq(layouts.id, layoutId) })).resolves.toMatchObject({
|
||||
name: "Phone",
|
||||
columnCount: 3,
|
||||
breakpoint: 480,
|
||||
});
|
||||
await expectLayoutForRootLayoutAsync(db, sectionId, layoutId, {
|
||||
...assignments.inRoot,
|
||||
a: itemId,
|
||||
});
|
||||
await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layoutId, assignments.inDynamicSection);
|
||||
});
|
||||
|
||||
test("should remove layouts omitted from the saved responsive configuration", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
|
||||
|
||||
await caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [createNewLayout(12)],
|
||||
});
|
||||
|
||||
await expect(db.query.layouts.findFirst({ where: eq(layouts.id, layoutId) })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("should reject duplicate responsive breakpoints", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
|
||||
|
||||
await expect(
|
||||
caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [
|
||||
createExistingLayout(layoutId),
|
||||
{
|
||||
...createNewLayout(4),
|
||||
breakpoint: 0,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
test("should reject duplicate responsive layout IDs", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
|
||||
const existingLayout = createExistingLayout(layoutId);
|
||||
|
||||
await expect(
|
||||
caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [existingLayout, { ...existingLayout, name: "Phone", breakpoint: 480 }],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
test("should reject fractional responsive layout values", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
|
||||
|
||||
await expect(
|
||||
caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [{ ...createExistingLayout(layoutId), breakpoint: 480.5 }],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
|
||||
await expect(
|
||||
caller.saveLayouts({
|
||||
id: boardId,
|
||||
layouts: [{ ...createExistingLayout(layoutId), columnCount: 3.5 }],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("addItem should preserve every saved layout", () => {
|
||||
test("should clamp wide widgets to a retained narrow layout", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
|
||||
const mobileLayoutId = createId();
|
||||
await db.insert(layouts).values({
|
||||
id: mobileLayoutId,
|
||||
name: "Mobile",
|
||||
columnCount: 1,
|
||||
breakpoint: 480,
|
||||
boardId,
|
||||
});
|
||||
|
||||
const { itemId } = await caller.addItem({
|
||||
boardId,
|
||||
kind: "mediaMissing",
|
||||
});
|
||||
|
||||
const savedItemLayouts = await db.query.itemLayouts.findMany({
|
||||
where: eq(itemLayouts.itemId, itemId),
|
||||
});
|
||||
expect(savedItemLayouts).toHaveLength(2);
|
||||
expect(savedItemLayouts).toContainEqual(expect.objectContaining({ layoutId, width: 4 }));
|
||||
expect(savedItemLayouts).toContainEqual(expect.objectContaining({ layoutId: mobileLayoutId, width: 1 }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveLayout should save the desktop layout", () => {
|
||||
test("should update position of items when column count changes", async () => {
|
||||
// Arrange
|
||||
@@ -1588,7 +1407,7 @@ describe("saveLayout should save the desktop layout", () => {
|
||||
});
|
||||
await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layoutId, assignments.inDynamicSection);
|
||||
});
|
||||
test("should preserve legacy layouts and desktop metadata", async () => {
|
||||
test("should keep the desktop layout and remove legacy responsive layouts", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
@@ -1615,18 +1434,15 @@ describe("saveLayout should save the desktop layout", () => {
|
||||
// Act
|
||||
await caller.saveLayout({
|
||||
id: boardId,
|
||||
columnCount: 14,
|
||||
columnCount: 16,
|
||||
});
|
||||
|
||||
// Assert
|
||||
const savedLayouts = await db.query.layouts.findMany({ where: eq(layouts.boardId, boardId) });
|
||||
expect(savedLayouts).toHaveLength(2);
|
||||
expect(savedLayouts).toContainEqual(
|
||||
expect.objectContaining({ id: layoutId, name: "Base", columnCount: 10, breakpoint: 0 }),
|
||||
);
|
||||
expect(savedLayouts).toContainEqual(
|
||||
expect.objectContaining({ id: desktopLayoutId, name: "Large", columnCount: 14, breakpoint: 1200 }),
|
||||
);
|
||||
expect(savedLayouts).toEqual([
|
||||
expect.objectContaining({ id: desktopLayoutId, name: "Base", columnCount: 16, breakpoint: 0 }),
|
||||
]);
|
||||
expect(savedLayouts.some((layout) => layout.id === layoutId)).toBe(false);
|
||||
});
|
||||
test("should fail when board not found", async () => {
|
||||
// Arrange
|
||||
|
||||
@@ -56,33 +56,6 @@ describe("getAll server settings", () => {
|
||||
});
|
||||
|
||||
describe("saveSettings", () => {
|
||||
test("saveSettings should persist settings when defaults are not seeded", async () => {
|
||||
const db = createDb();
|
||||
const caller = serverSettingsRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
await caller.saveSettings({
|
||||
settingsKey: "board",
|
||||
value: {
|
||||
enableAutomaticMobileLayout: false,
|
||||
},
|
||||
});
|
||||
|
||||
const dbSettings = await db.select().from(serverSettings);
|
||||
expect(dbSettings).toStrictEqual([
|
||||
{
|
||||
settingKey: "board",
|
||||
value: stringify({
|
||||
...defaultServerSettings.board,
|
||||
enableAutomaticMobileLayout: false,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("saveSettings should update settings and return true when it updated only one", async () => {
|
||||
const db = createDb();
|
||||
const caller = serverSettingsRouter.createCaller({
|
||||
@@ -130,27 +103,6 @@ describe("board settings API", () => {
|
||||
await expect(caller.getBoardSettings()).resolves.toStrictEqual(defaultServerSettings.board);
|
||||
});
|
||||
|
||||
test("getBoardSettings should backfill the automatic layout default for legacy values", async () => {
|
||||
const db = createDb();
|
||||
const caller = serverSettingsRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session: defaultSession,
|
||||
});
|
||||
const legacyBoardSettings = {
|
||||
homeBoardId: defaultServerSettings.board.homeBoardId,
|
||||
mobileHomeBoardId: defaultServerSettings.board.mobileHomeBoardId,
|
||||
enableStatusByDefault: defaultServerSettings.board.enableStatusByDefault,
|
||||
forceDisableStatus: defaultServerSettings.board.forceDisableStatus,
|
||||
};
|
||||
await db.insert(serverSettings).values({
|
||||
settingKey: "board",
|
||||
value: stringify(legacyBoardSettings),
|
||||
});
|
||||
|
||||
await expect(caller.getBoardSettings()).resolves.toStrictEqual(defaultServerSettings.board);
|
||||
});
|
||||
|
||||
test("updateBoardSettings should insert settings when defaults are not persisted", async () => {
|
||||
const db = createDb();
|
||||
const caller = serverSettingsRouter.createCaller({
|
||||
@@ -193,7 +145,6 @@ describe("board settings API", () => {
|
||||
await caller.updateBoardSettings({ homeBoardId: boardId });
|
||||
const result = await caller.updateBoardSettings({
|
||||
mobileHomeBoardId: boardId,
|
||||
enableAutomaticMobileLayout: false,
|
||||
enableStatusByDefault: false,
|
||||
});
|
||||
|
||||
@@ -201,7 +152,6 @@ describe("board settings API", () => {
|
||||
...defaultServerSettings.board,
|
||||
homeBoardId: boardId,
|
||||
mobileHomeBoardId: boardId,
|
||||
enableAutomaticMobileLayout: false,
|
||||
enableStatusByDefault: false,
|
||||
});
|
||||
const dbSettings = await db.select().from(serverSettings);
|
||||
|
||||
@@ -2,53 +2,36 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { mediaReleaseRequestHandler } from "@homarr/request-handler/media-release";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { getIntegrationQueryProvenance, settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const mediaReleasesProcedure = publicProcedure.concat(
|
||||
createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")),
|
||||
);
|
||||
|
||||
type MediaReleaseIntegration = Parameters<typeof mediaReleaseRequestHandler.handler>[0];
|
||||
|
||||
const getMediaReleasesWithProvenanceAsync = async (integrations: MediaReleaseIntegration[]) => {
|
||||
const results = await settleIntegrationQueries(
|
||||
integrations,
|
||||
async (integration) => {
|
||||
const innerHandler = mediaReleaseRequestHandler.handler(integration, {});
|
||||
const { data, timestamp, isStale } = await innerHandler.getDataWithProvenanceAsync();
|
||||
|
||||
return {
|
||||
integration: {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
releases: data,
|
||||
isStale,
|
||||
};
|
||||
},
|
||||
{ throwOnAllFailure: true },
|
||||
);
|
||||
const items = results.flatMap((result) =>
|
||||
result.releases.map((release) => ({
|
||||
...release,
|
||||
integration: result.integration,
|
||||
})),
|
||||
);
|
||||
|
||||
return {
|
||||
items,
|
||||
...getIntegrationQueryProvenance(integrations.length, results),
|
||||
};
|
||||
};
|
||||
|
||||
export const mediaReleaseRouter = createTRPCRouter({
|
||||
getMediaReleases: mediaReleasesProcedure.query(async ({ ctx }) => {
|
||||
return (await getMediaReleasesWithProvenanceAsync(ctx.integrations)).items;
|
||||
}),
|
||||
getMediaReleasesWithProvenance: mediaReleasesProcedure.query(async ({ ctx }) => {
|
||||
return await getMediaReleasesWithProvenanceAsync(ctx.integrations);
|
||||
}),
|
||||
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();
|
||||
|
||||
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,
|
||||
integration: result.integration,
|
||||
})),
|
||||
);
|
||||
}),
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import { z } from "zod/v4";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { mediaRequestStatusConfiguration } from "@homarr/integrations/types";
|
||||
import type { MediaRequestListInput } from "@homarr/request-handler/media-request-list";
|
||||
import {
|
||||
mediaRequestListInputSchema,
|
||||
mediaRequestListRequestHandler,
|
||||
@@ -11,93 +10,11 @@ import {
|
||||
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
|
||||
|
||||
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { getIntegrationQueryProvenance, settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
const latestRequestsProcedure = publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
|
||||
.input(mediaRequestListInputSchema);
|
||||
const requestStatsProcedure = publicProcedure.concat(
|
||||
createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")),
|
||||
);
|
||||
|
||||
type MediaRequestIntegration = Parameters<typeof mediaRequestListRequestHandler.handler>[0];
|
||||
type MediaRequestStatsIntegration = Parameters<typeof mediaRequestStatsRequestHandler.handler>[0];
|
||||
|
||||
const getLatestRequestsWithProvenanceAsync = async (
|
||||
integrations: MediaRequestIntegration[],
|
||||
input: MediaRequestListInput,
|
||||
) => {
|
||||
const results = await settleIntegrationQueries(
|
||||
integrations,
|
||||
async (integration) => {
|
||||
const { data, isStale } = await mediaRequestListRequestHandler
|
||||
.handler(integration, input)
|
||||
.getDataWithProvenanceAsync();
|
||||
return {
|
||||
integration: { id: integration.id, name: integration.name, kind: integration.kind },
|
||||
data,
|
||||
isStale,
|
||||
};
|
||||
},
|
||||
{ throwOnAllFailure: true },
|
||||
);
|
||||
const items = results
|
||||
.flatMap(({ data, integration }) =>
|
||||
data.map((request) => ({
|
||||
...request,
|
||||
integrationId: integration.id,
|
||||
})),
|
||||
)
|
||||
.toSorted((dataA, dataB) => {
|
||||
if (dataA.status === dataB.status) {
|
||||
return dataB.createdAt.getTime() - dataA.createdAt.getTime();
|
||||
}
|
||||
|
||||
return (
|
||||
mediaRequestStatusConfiguration[dataA.status].position - mediaRequestStatusConfiguration[dataB.status].position
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
items,
|
||||
...getIntegrationQueryProvenance(integrations.length, results),
|
||||
};
|
||||
};
|
||||
|
||||
const getRequestStatsWithProvenanceAsync = async (integrations: MediaRequestStatsIntegration[]) => {
|
||||
const results = await settleIntegrationQueries(
|
||||
integrations,
|
||||
async (integration) => {
|
||||
const { data, isStale } = await mediaRequestStatsRequestHandler
|
||||
.handler(integration, {})
|
||||
.getDataWithProvenanceAsync();
|
||||
return {
|
||||
integration: { id: integration.id, name: integration.name, kind: integration.kind },
|
||||
data,
|
||||
isStale,
|
||||
};
|
||||
},
|
||||
{ throwOnAllFailure: true },
|
||||
);
|
||||
|
||||
return {
|
||||
stats: results.flatMap((result) => result.data.stats),
|
||||
users: results
|
||||
.flatMap((result) =>
|
||||
result.data.users.map((user) => ({
|
||||
...user,
|
||||
integration: result.integration,
|
||||
})),
|
||||
)
|
||||
.toSorted(({ requestCount: countA }, { requestCount: countB }) => countB - countA),
|
||||
integrations: results.map((result) => result.integration),
|
||||
...getIntegrationQueryProvenance(integrations.length, results),
|
||||
};
|
||||
};
|
||||
|
||||
export const mediaRequestsRouter = createTRPCRouter({
|
||||
getLatestRequests: latestRequestsProcedure
|
||||
getLatestRequests: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
@@ -105,13 +22,36 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
"Get latest media requests from Overseerr/Jellyseerr with their status (pending, approved, declined, failed, completed). REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all). OPTIONAL: statuses (array of statuses to include, must be non-empty) and recentDays (number 0-365; 0 disables the time filter).",
|
||||
},
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
|
||||
.input(mediaRequestListInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return (await getLatestRequestsWithProvenanceAsync(ctx.integrations, input)).items;
|
||||
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) => ({
|
||||
...request,
|
||||
integrationId: integration.id,
|
||||
})),
|
||||
)
|
||||
.toSorted((dataA, dataB) => {
|
||||
if (dataA.status === dataB.status) {
|
||||
return dataB.createdAt.getTime() - dataA.createdAt.getTime();
|
||||
}
|
||||
|
||||
return (
|
||||
mediaRequestStatusConfiguration[dataA.status].position -
|
||||
mediaRequestStatusConfiguration[dataB.status].position
|
||||
);
|
||||
});
|
||||
}),
|
||||
getLatestRequestsWithProvenance: latestRequestsProcedure.query(async ({ ctx, input }) => {
|
||||
return await getLatestRequestsWithProvenanceAsync(ctx.integrations, input);
|
||||
}),
|
||||
getStats: requestStatsProcedure
|
||||
getStats: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
@@ -119,17 +59,30 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
"Get media request statistics including total counts and top requesters. REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all)",
|
||||
},
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
|
||||
.query(async ({ ctx }) => {
|
||||
const result = await getRequestStatsWithProvenanceAsync(ctx.integrations);
|
||||
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: result.stats,
|
||||
users: result.users,
|
||||
integrations: result.integrations,
|
||||
stats: results.flatMap((result) => result.data.stats),
|
||||
users: results
|
||||
.map((result) =>
|
||||
result.data.users.map((user) => ({
|
||||
...user,
|
||||
integration: result.integration,
|
||||
})),
|
||||
)
|
||||
.flat()
|
||||
.toSorted(({ requestCount: countA }, { requestCount: countB }) => countB - countA),
|
||||
integrations: results.map((result) => result.integration),
|
||||
};
|
||||
}),
|
||||
getStatsWithProvenance: requestStatsProcedure.query(async ({ ctx }) => {
|
||||
return await getRequestStatsWithProvenanceAsync(ctx.integrations);
|
||||
}),
|
||||
answerRequest: protectedProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getIntegrationQueryProvenance, settleIntegrationQueries } from "./settle-integrations";
|
||||
import { settleIntegrationQueries } from "./settle-integrations";
|
||||
|
||||
const integrations = [
|
||||
{ id: "first", name: "First", kind: "mock" },
|
||||
@@ -76,19 +76,3 @@ describe("settleIntegrationQueries", () => {
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIntegrationQueryProvenance", () => {
|
||||
it("counts dropped and stale integration results independently", () => {
|
||||
expect(getIntegrationQueryProvenance(4, [{ isStale: false }, { isStale: true }, { isStale: true }])).toEqual({
|
||||
failedIntegrationCount: 1,
|
||||
staleIntegrationCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not report failures for complete current results", () => {
|
||||
expect(getIntegrationQueryProvenance(2, [{ isStale: false }, {}])).toEqual({
|
||||
failedIntegrationCount: 0,
|
||||
staleIntegrationCount: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -33,7 +33,6 @@ test("MCP tools should contain expected procedures", () => {
|
||||
expect(toolNames).toContain("app_byId");
|
||||
expect(toolNames).toContain("app_create");
|
||||
expect(toolNames).toContain("board_savePartialBoardSettings");
|
||||
expect(toolNames).toContain("board_saveLayout");
|
||||
expect(toolNames).toContain("board_duplicateBoard");
|
||||
expect(toolNames).toContain("info_getInfo");
|
||||
expect(toolNames).toContain("invite_getAll");
|
||||
|
||||
Reference in new issue
Block a user