mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
feat(settings): carry category choices in the labs list layout
The list layout gains two choices that only the labs list reads: groupBy (none or category, default none) and groupsOpen (expanded, flagged or collapsed, default expanded). The resolver keeps a valid stored value and drops anything else to the default, the PUT merges both field by field, and the hook exposes a setter for each. A browser value from before the server column never held them, so the legacy parse fills them from the defaults.
This commit is contained in:
1 parent
2e14b2d8b5
commit
e2170115c7
10 files changed
+208
-13
No files matched your search
@@ -34117,6 +34117,17 @@ components:
|
||||
- alphaAsc
|
||||
- alphaDesc
|
||||
- manual
|
||||
groupBy:
|
||||
type: string
|
||||
enum:
|
||||
- none
|
||||
- category
|
||||
groupsOpen:
|
||||
type: string
|
||||
enum:
|
||||
- expanded
|
||||
- flagged
|
||||
- collapsed
|
||||
required:
|
||||
- version
|
||||
- list
|
||||
@@ -75011,6 +75022,22 @@ components:
|
||||
- alphaDesc
|
||||
- manual
|
||||
description: Sort of the labs list; `manual` follows `order`. Default "recentDesc". The other lists ignore it.
|
||||
groupBy:
|
||||
type: string
|
||||
enum:
|
||||
- none
|
||||
- category
|
||||
description: 'Whether the labs list shows its server-resolved categories (`category`) or one flat list (`none`). Default
|
||||
"none". The labs list stays flat while `GET /api/labs/overview` answers `grouped: false`. The other lists
|
||||
ignore it.'
|
||||
groupsOpen:
|
||||
type: string
|
||||
enum:
|
||||
- expanded
|
||||
- flagged
|
||||
- collapsed
|
||||
description: 'Which labs categories start open: every one (`expanded`), only those whose `status` is `above` or `below`
|
||||
(`flagged`), or none (`collapsed`). Default "expanded". The other lists ignore it.'
|
||||
saved:
|
||||
type: boolean
|
||||
description: True when the server holds a choice for this list; false means every field is a default.
|
||||
@@ -75018,6 +75045,8 @@ components:
|
||||
- view
|
||||
- order
|
||||
- sortDir
|
||||
- groupBy
|
||||
- groupsOpen
|
||||
- saved
|
||||
additionalProperties: false
|
||||
ModuleListLayoutSavedEnvelope:
|
||||
|
||||
@@ -65,7 +65,7 @@ export const PUT = apiHandler(async (request: Request) => {
|
||||
});
|
||||
if (!parsed.ok) return parsed.error;
|
||||
|
||||
const { list, view, order, sortDir } = parsed.data;
|
||||
const { list, view, order, sortDir, groupBy, groupsOpen } = parsed.data;
|
||||
// Preserve-when-absent: one stored read fills whichever field the body
|
||||
// left out, and every other list stays as stored. Only fields a person has
|
||||
// chosen are written, so a field never picked keeps following the default.
|
||||
@@ -77,6 +77,8 @@ export const PUT = apiHandler(async (request: Request) => {
|
||||
...(view ? { view } : {}),
|
||||
...(order ? { order: [...new Set(order)] } : {}),
|
||||
...(sortDir ? { sortDir } : {}),
|
||||
...(groupBy ? { groupBy } : {}),
|
||||
...(groupsOpen ? { groupsOpen } : {}),
|
||||
};
|
||||
const next = { ...stored, [list]: nextChoices };
|
||||
await tx.user.update({
|
||||
@@ -93,6 +95,8 @@ export const PUT = apiHandler(async (request: Request) => {
|
||||
list,
|
||||
view: layout.lists[list].view,
|
||||
sortDir: layout.lists[list].sortDir,
|
||||
groupBy: layout.lists[list].groupBy,
|
||||
groupsOpen: layout.lists[list].groupsOpen,
|
||||
orderLength: layout.lists[list].order.length,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -62,7 +62,13 @@ describe("parseModuleListPrefs", () => {
|
||||
JSON.stringify({ view: "grid", sortDir: "weird", order: ["a", "b"] }),
|
||||
defaults,
|
||||
),
|
||||
).toEqual({ view: "cards", sortDir: "recentDesc", order: ["a", "b"] });
|
||||
).toEqual({
|
||||
view: "cards",
|
||||
sortDir: "recentDesc",
|
||||
order: ["a", "b"],
|
||||
groupBy: "none",
|
||||
groupsOpen: "expanded",
|
||||
});
|
||||
});
|
||||
|
||||
it("applies the labs default (list view, #40) when nothing is stored", () => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
/**
|
||||
* Per-list presentation for the labs, illness and checkups lists: card or
|
||||
* list view, a manual order, and (labs only) the sort.
|
||||
* list view, a manual order, and (labs only) the sort and the categories.
|
||||
*
|
||||
* The choices live on the server (`GET/PUT /api/settings/module-list-layout`,
|
||||
* `User.moduleListLayoutJson`) so they follow a person to every device. Until
|
||||
@@ -39,11 +39,15 @@ import {
|
||||
type ModuleListKey,
|
||||
type ModuleListLayout,
|
||||
type ModuleListPrefs,
|
||||
type ModuleGroupBy,
|
||||
type ModuleGroupsOpen,
|
||||
type ModuleListView,
|
||||
type ModuleSortDir,
|
||||
} from "@/lib/settings/module-list-layout";
|
||||
|
||||
export type {
|
||||
ModuleGroupBy,
|
||||
ModuleGroupsOpen,
|
||||
ModuleListKey,
|
||||
ModuleListPrefs,
|
||||
ModuleListView,
|
||||
@@ -59,7 +63,8 @@ export function legacyModuleListStorageKey(list: ModuleListKey): string {
|
||||
|
||||
/**
|
||||
* Parse a browser-stored blob from before the server column. Anything
|
||||
* malformed collapses onto the defaults field by field.
|
||||
* malformed collapses onto the defaults field by field. The browser never
|
||||
* held the category fields, so those always come from the defaults.
|
||||
*/
|
||||
export function parseModuleListPrefs(
|
||||
raw: string | null,
|
||||
@@ -69,6 +74,7 @@ export function parseModuleListPrefs(
|
||||
try {
|
||||
const blob = JSON.parse(raw) as Record<string, unknown>;
|
||||
return {
|
||||
...defaults,
|
||||
view: isModuleListView(blob.view) ? blob.view : defaults.view,
|
||||
order: normaliseModuleListOrder(blob.order) ?? [],
|
||||
sortDir: isModuleSortDir(blob.sortDir) ? blob.sortDir : defaults.sortDir,
|
||||
@@ -220,7 +226,12 @@ export async function runSetModuleListPrefs(deps: {
|
||||
...patch,
|
||||
});
|
||||
if (saved) queryClient.setQueryData(key, saved);
|
||||
if (patch.view !== undefined || patch.sortDir !== undefined) {
|
||||
if (
|
||||
patch.view !== undefined ||
|
||||
patch.sortDir !== undefined ||
|
||||
patch.groupBy !== undefined ||
|
||||
patch.groupsOpen !== undefined
|
||||
) {
|
||||
toastWrittenOutcome("success", t("settings.saved"));
|
||||
}
|
||||
} catch (failure) {
|
||||
@@ -238,6 +249,8 @@ export function useModuleListPrefs(list: ModuleListKey): {
|
||||
setView: (view: ModuleListView) => void;
|
||||
setOrder: (order: string[]) => void;
|
||||
setSortDir: (sortDir: ModuleSortDir) => void;
|
||||
setGroupBy: (groupBy: ModuleGroupBy) => void;
|
||||
setGroupsOpen: (groupsOpen: ModuleGroupsOpen) => void;
|
||||
} {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
@@ -259,7 +272,13 @@ export function useModuleListPrefs(list: ModuleListKey): {
|
||||
const entry = data?.lists[list];
|
||||
const prefs = useMemo<ModuleListPrefs>(() => {
|
||||
if (entry) {
|
||||
return { view: entry.view, order: entry.order, sortDir: entry.sortDir };
|
||||
return {
|
||||
view: entry.view,
|
||||
order: entry.order,
|
||||
sortDir: entry.sortDir,
|
||||
groupBy: entry.groupBy,
|
||||
groupsOpen: entry.groupsOpen,
|
||||
};
|
||||
}
|
||||
const defaults = moduleListDefaults(list);
|
||||
if (!mounted) return defaults;
|
||||
@@ -282,5 +301,14 @@ export function useModuleListPrefs(list: ModuleListKey): {
|
||||
[write],
|
||||
);
|
||||
|
||||
return { prefs, setView, setOrder, setSortDir };
|
||||
const setGroupBy = useCallback(
|
||||
(groupBy: ModuleGroupBy) => write({ groupBy }),
|
||||
[write],
|
||||
);
|
||||
const setGroupsOpen = useCallback(
|
||||
(groupsOpen: ModuleGroupsOpen) => write({ groupsOpen }),
|
||||
[write],
|
||||
);
|
||||
|
||||
return { prefs, setView, setOrder, setSortDir, setGroupBy, setGroupsOpen };
|
||||
}
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
} from "./shared";
|
||||
import { WEBHOOK_PAYLOAD_FORMATS } from "@/lib/notifications/types";
|
||||
import {
|
||||
MODULE_GROUPS_OPEN,
|
||||
MODULE_GROUP_BYS,
|
||||
MODULE_LIST_VIEWS,
|
||||
MODULE_SORT_DIRS,
|
||||
} from "@/lib/settings/module-list-layout";
|
||||
@@ -47,6 +49,16 @@ const moduleListPrefsEntry = z
|
||||
.describe(
|
||||
'Sort of the labs list; `manual` follows `order`. Default "recentDesc". The other lists ignore it.',
|
||||
),
|
||||
groupBy: z
|
||||
.enum(MODULE_GROUP_BYS)
|
||||
.describe(
|
||||
'Whether the labs list shows its server-resolved categories (`category`) or one flat list (`none`). Default "none". The labs list stays flat while `GET /api/labs/overview` answers `grouped: false`. The other lists ignore it.',
|
||||
),
|
||||
groupsOpen: z
|
||||
.enum(MODULE_GROUPS_OPEN)
|
||||
.describe(
|
||||
'Which labs categories start open: every one (`expanded`), only those whose `status` is `above` or `below` (`flagged`), or none (`collapsed`). Default "expanded". The other lists ignore it.',
|
||||
),
|
||||
saved: z
|
||||
.boolean()
|
||||
.describe(
|
||||
|
||||
@@ -14,6 +14,8 @@ describe("resolveModuleListLayout", () => {
|
||||
view: "list",
|
||||
order: [],
|
||||
sortDir: "recentDesc",
|
||||
groupBy: "none",
|
||||
groupsOpen: "expanded",
|
||||
saved: false,
|
||||
});
|
||||
expect(layout.lists.illness.view).toBe("cards");
|
||||
@@ -30,6 +32,8 @@ describe("resolveModuleListLayout", () => {
|
||||
view: "cards",
|
||||
order: ["b", "a"],
|
||||
sortDir: "recentDesc",
|
||||
groupBy: "none",
|
||||
groupsOpen: "expanded",
|
||||
saved: true,
|
||||
});
|
||||
expect(layout.lists.labs.saved).toBe(false);
|
||||
@@ -44,12 +48,33 @@ describe("resolveModuleListLayout", () => {
|
||||
view: "list",
|
||||
order: [],
|
||||
sortDir: "recentDesc",
|
||||
groupBy: "none",
|
||||
groupsOpen: "expanded",
|
||||
saved: false,
|
||||
});
|
||||
expect(layout.lists.vorsorge.saved).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("category choices", () => {
|
||||
it("keeps valid category choices and drops unknown ones", () => {
|
||||
const layout = resolveModuleListLayout({
|
||||
labs: { groupBy: "category", groupsOpen: "flagged" },
|
||||
illness: { groupBy: "panel", groupsOpen: "all" },
|
||||
});
|
||||
expect(layout.lists.labs).toMatchObject({
|
||||
groupBy: "category",
|
||||
groupsOpen: "flagged",
|
||||
saved: true,
|
||||
});
|
||||
expect(layout.lists.illness).toMatchObject({
|
||||
groupBy: "none",
|
||||
groupsOpen: "expanded",
|
||||
saved: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("normaliseModuleListOrder", () => {
|
||||
it("keeps strings, drops duplicates, empties and over-long ids", () => {
|
||||
expect(
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
* block in the server's own order.
|
||||
* - `sortDir`: only the labs list reads it (recency, alphabetical, or the
|
||||
* manual `order`).
|
||||
* - `groupBy` / `groupsOpen`: only the labs list reads them. `groupBy` shows
|
||||
* the list under its server-resolved categories (`"category"`) or flat
|
||||
* (`"none"`, the default); `groupsOpen` says which categories start open:
|
||||
* all of them, only those with a value outside its range, or none.
|
||||
*
|
||||
* Null / missing column = the defaults below. The GET never lazy-writes, and
|
||||
* the PUT stores only the fields a person has actually chosen, so a default
|
||||
@@ -37,6 +41,12 @@ export const MODULE_SORT_DIRS = [
|
||||
] as const;
|
||||
export type ModuleSortDir = (typeof MODULE_SORT_DIRS)[number];
|
||||
|
||||
export const MODULE_GROUP_BYS = ["none", "category"] as const;
|
||||
export type ModuleGroupBy = (typeof MODULE_GROUP_BYS)[number];
|
||||
|
||||
export const MODULE_GROUPS_OPEN = ["expanded", "flagged", "collapsed"] as const;
|
||||
export type ModuleGroupsOpen = (typeof MODULE_GROUPS_OPEN)[number];
|
||||
|
||||
/** Longest order a list keeps; a longer one is cut, never refused. */
|
||||
export const MODULE_LIST_ORDER_MAX = 500;
|
||||
/** Longest id an order entry may carry (cuid and uuid fit comfortably). */
|
||||
@@ -46,6 +56,8 @@ export interface ModuleListPrefs {
|
||||
view: ModuleListView;
|
||||
order: string[];
|
||||
sortDir: ModuleSortDir;
|
||||
groupBy: ModuleGroupBy;
|
||||
groupsOpen: ModuleGroupsOpen;
|
||||
}
|
||||
|
||||
export interface ModuleListPrefsEntry extends ModuleListPrefs {
|
||||
@@ -62,6 +74,8 @@ const BASE_DEFAULTS: ModuleListPrefs = {
|
||||
view: "cards",
|
||||
order: [],
|
||||
sortDir: "recentDesc",
|
||||
groupBy: "none",
|
||||
groupsOpen: "expanded",
|
||||
};
|
||||
|
||||
/** Labs opens on the compact list, the dense reading table (#40). */
|
||||
@@ -90,6 +104,20 @@ export function isModuleSortDir(value: unknown): value is ModuleSortDir {
|
||||
);
|
||||
}
|
||||
|
||||
export function isModuleGroupBy(value: unknown): value is ModuleGroupBy {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(MODULE_GROUP_BYS as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
export function isModuleGroupsOpen(value: unknown): value is ModuleGroupsOpen {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(MODULE_GROUPS_OPEN as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
/** Keep string ids of a sane length, first occurrence wins, capped. */
|
||||
export function normaliseModuleListOrder(raw: unknown): string[] | null {
|
||||
if (!Array.isArray(raw)) return null;
|
||||
@@ -114,12 +142,22 @@ export function storedModuleListChoices(
|
||||
raw: unknown,
|
||||
): Partial<ModuleListPrefs> {
|
||||
if (raw === null || raw === undefined || typeof raw !== "object") return {};
|
||||
const blob = raw as { view?: unknown; order?: unknown; sortDir?: unknown };
|
||||
const blob = raw as {
|
||||
view?: unknown;
|
||||
order?: unknown;
|
||||
sortDir?: unknown;
|
||||
groupBy?: unknown;
|
||||
groupsOpen?: unknown;
|
||||
};
|
||||
const order = normaliseModuleListOrder(blob.order);
|
||||
return {
|
||||
...(isModuleListView(blob.view) ? { view: blob.view } : {}),
|
||||
...(order !== null ? { order } : {}),
|
||||
...(isModuleSortDir(blob.sortDir) ? { sortDir: blob.sortDir } : {}),
|
||||
...(isModuleGroupBy(blob.groupBy) ? { groupBy: blob.groupBy } : {}),
|
||||
...(isModuleGroupsOpen(blob.groupsOpen)
|
||||
? { groupsOpen: blob.groupsOpen }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
MODULE_GROUPS_OPEN,
|
||||
MODULE_GROUP_BYS,
|
||||
MODULE_LIST_ID_MAX,
|
||||
MODULE_LIST_KEYS,
|
||||
MODULE_LIST_ORDER_MAX,
|
||||
@@ -24,5 +26,7 @@ export const moduleListLayoutPutSchema = z
|
||||
.max(MODULE_LIST_ORDER_MAX)
|
||||
.optional(),
|
||||
sortDir: z.enum(MODULE_SORT_DIRS).optional(),
|
||||
groupBy: z.enum(MODULE_GROUP_BYS).optional(),
|
||||
groupsOpen: z.enum(MODULE_GROUPS_OPEN).optional(),
|
||||
})
|
||||
.strict();
|
||||
@@ -124,6 +124,8 @@ describe("/api/settings/module-list-layout — integration", () => {
|
||||
view: "list",
|
||||
order: ["m2", "m1"],
|
||||
sortDir: "manual",
|
||||
groupBy: "none",
|
||||
groupsOpen: "expanded",
|
||||
saved: true,
|
||||
});
|
||||
expect(body.data.lists.illness).toMatchObject({
|
||||
@@ -145,6 +147,49 @@ describe("/api/settings/module-list-layout — integration", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("stores the category choices and reads them back", async () => {
|
||||
const { userId } = await seedUser();
|
||||
const { GET, PUT } =
|
||||
await import("@/app/api/settings/module-list-layout/route");
|
||||
const put = await (PUT as Handler)(
|
||||
json("PUT", URL, {
|
||||
version: 1,
|
||||
list: "labs",
|
||||
groupBy: "category",
|
||||
groupsOpen: "flagged",
|
||||
}),
|
||||
);
|
||||
expect(put.status).toBe(200);
|
||||
// A later write of another field keeps them.
|
||||
await (PUT as Handler)(
|
||||
json("PUT", URL, { version: 1, list: "labs", view: "cards" }),
|
||||
);
|
||||
|
||||
const res = await (GET as Handler)(new Request(URL));
|
||||
const body = (await res.json()) as {
|
||||
data: { lists: Record<string, Record<string, unknown>> };
|
||||
};
|
||||
expect(body.data.lists.labs).toMatchObject({
|
||||
view: "cards",
|
||||
groupBy: "category",
|
||||
groupsOpen: "flagged",
|
||||
saved: true,
|
||||
});
|
||||
const row = await getPrismaClient().user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { moduleListLayoutJson: true },
|
||||
});
|
||||
expect(row?.moduleListLayoutJson).toEqual({
|
||||
version: 1,
|
||||
labs: { groupBy: "category", groupsOpen: "flagged", view: "cards" },
|
||||
});
|
||||
|
||||
const refused = await (PUT as Handler)(
|
||||
json("PUT", URL, { version: 1, list: "labs", groupsOpen: "all" }),
|
||||
);
|
||||
expect(refused.status).toBe(422);
|
||||
});
|
||||
|
||||
it("refuses unknown lists, values and keys with every issue named", async () => {
|
||||
await seedUser();
|
||||
const { PUT } = await import("@/app/api/settings/module-list-layout/route");
|
||||
|
||||
@@ -869,9 +869,11 @@ function entries(user: Pick<User, "id">): Entry[] {
|
||||
route: "/api/settings/module-list-layout",
|
||||
body: {
|
||||
version: 1,
|
||||
list: "illness",
|
||||
view: "list",
|
||||
list: "labs",
|
||||
view: "cards",
|
||||
sortDir: "alphaAsc",
|
||||
groupBy: "category",
|
||||
groupsOpen: "collapsed",
|
||||
},
|
||||
},
|
||||
read: {
|
||||
@@ -881,11 +883,13 @@ function entries(user: Pick<User, "id">): Entry[] {
|
||||
route: "/api/settings/module-list-layout",
|
||||
},
|
||||
pick: (res) => [
|
||||
at(res.data, "lists.illness.view"),
|
||||
at(res.data, "lists.illness.sortDir"),
|
||||
at(res.data, "lists.labs.view"),
|
||||
at(res.data, "lists.labs.sortDir"),
|
||||
at(res.data, "lists.labs.groupBy"),
|
||||
at(res.data, "lists.labs.groupsOpen"),
|
||||
],
|
||||
},
|
||||
expected: ["list", "alphaAsc"],
|
||||
expected: ["cards", "alphaAsc", "category", "collapsed"],
|
||||
},
|
||||
{
|
||||
columns: ["activityGoalsJson"],
|
||||
|
||||
Reference in new issue
Block a user