feat: complete automatic mobile board UX

This commit is contained in:
ajnart committed 2026-08-14 11:32:09 +02:00
1 parent b217d8e026
commit e6bd1adfc2
50 files changed
+1279 -343

No files matched your search

+2 -4
View File
@@ -1913,9 +1913,6 @@ 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) {
@@ -1937,7 +1934,8 @@ const getHomeIdBoardAsync = async (
and(
eq(groupMembers.userId, user.id),
not(eq(groups.name, everyoneGroup)),
or(not(isNull(groups[settingKey])), not(isNull(groups.homeBoardId))),
not(isNull(groups[settingKey])),
not(isNull(groups.homeBoardId)),
),
)
.orderBy(asc(groups.position))
+14 -4
View File
@@ -19,6 +19,7 @@ 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"]>;
@@ -39,7 +40,7 @@ export const serverSettingsRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
"Get global board defaults, including desktop/mobile home board IDs and status behavior. Requires admin permission",
"Get global board defaults, including desktop/mobile home board IDs, automatic mobile layout mode, and status behavior. Requires admin permission",
},
})
.input(z.void())
@@ -54,7 +55,7 @@ export const serverSettingsRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
"Update global board defaults. Requires admin permission. Optional fields: homeBoardId, mobileHomeBoardId, enableStatusByDefault, forceDisableStatus. Home board IDs must reference public boards or be null",
"Update global board defaults. Requires admin permission. Optional fields: homeBoardId, mobileHomeBoardId, enableAutomaticMobileLayout, enableStatusByDefault, forceDisableStatus. Home board IDs must reference public boards or be null",
},
})
.input(boardServerSettingsUpdateSchema)
@@ -103,10 +104,19 @@ export const serverSettingsRouter = createTRPCRouter({
)
.mutation(async ({ ctx, input }) => {
const current = await getServerSettingByKeyAsync(ctx.db, input.settingsKey);
await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, {
const next = {
...current,
...input.value,
} as ServerSettings[keyof ServerSettings]);
} 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);
}
}),
initSettings: onboardingProcedure
.requiresStep("settings")
+3 -64
View File
@@ -549,64 +549,6 @@ 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");
@@ -2077,12 +2019,9 @@ const expectInputToBeFullBoardWithName = (
};
const createFullBoardAsync = async (db: Database, name: string) => {
await db
.insert(users)
.values({
id: defaultCreatorId,
})
.onConflictDoNothing();
await db.insert(users).values({
id: defaultCreatorId,
});
const boardId = createId();
await db.insert(boards).values({
@@ -56,6 +56,33 @@ 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({
@@ -103,6 +130,27 @@ 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({
@@ -145,6 +193,7 @@ describe("board settings API", () => {
await caller.updateBoardSettings({ homeBoardId: boardId });
const result = await caller.updateBoardSettings({
mobileHomeBoardId: boardId,
enableAutomaticMobileLayout: false,
enableStatusByDefault: false,
});
@@ -152,6 +201,7 @@ describe("board settings API", () => {
...defaultServerSettings.board,
homeBoardId: boardId,
mobileHomeBoardId: boardId,
enableAutomaticMobileLayout: false,
enableStatusByDefault: false,
});
const dbSettings = await db.select().from(serverSettings);
+17 -1
View File
@@ -2,7 +2,7 @@
import { describe, expect, it, vi } from "vitest";
import { settleIntegrationQueries } from "./settle-integrations";
import { getIntegrationQueryProvenance, settleIntegrationQueries } from "./settle-integrations";
const integrations = [
{ id: "first", name: "First", kind: "mock" },
@@ -76,3 +76,19 @@ 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,
});
});
});
+13
View File
@@ -20,6 +20,19 @@ interface Options<TIntegration extends IntegrationLike, TResult> {
throwOnAllFailures?: boolean;
}
export interface IntegrationQueryProvenance {
failedIntegrationCount: number;
staleIntegrationCount: number;
}
export const getIntegrationQueryProvenance = (
selectedIntegrationCount: number,
results: readonly { isStale?: boolean }[],
): IntegrationQueryProvenance => ({
failedIntegrationCount: Math.max(0, selectedIntegrationCount - results.length),
staleIntegrationCount: results.filter((result) => result.isStale === true).length,
});
export async function settleIntegrationQueries<TIntegration extends IntegrationLike, TResult>(
integrations: TIntegration[],
fn: (integration: TIntegration) => Promise<TResult>,
+1
View File
@@ -67,6 +67,7 @@ const MCP_TOOL_ALLOWLIST = {
"board_deleteBoard",
"board_duplicateBoard",
"board_renameBoard",
"board_resetLayout",
"board_savePartialBoardSettings",
"board_setHomeBoard",
"board_setMobileHomeBoard",