feat(category): save collapse state for signed in users (#2134)
This commit is contained in:
@@ -2,6 +2,8 @@ import { Card, Collapse, Group, Stack, Title, UnstyledButton } from "@mantine/co
|
|||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
|
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
|
||||||
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||||
import { useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
|
import { useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
|
||||||
import { CategoryMenu } from "./category/category-menu";
|
import { CategoryMenu } from "./category/category-menu";
|
||||||
@@ -13,8 +15,16 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const BoardCategorySection = ({ section }: Props) => {
|
export const BoardCategorySection = ({ section }: Props) => {
|
||||||
const [opened, { toggle }] = useDisclosure(false);
|
const { mutate } = clientApi.section.changeCollapsed.useMutation();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
|
const [opened, { toggle }] = useDisclosure(section.collapsed, {
|
||||||
|
onOpen() {
|
||||||
|
mutate({ sectionId: section.id, collapsed: true });
|
||||||
|
},
|
||||||
|
onClose() {
|
||||||
|
mutate({ sectionId: section.id, collapsed: false });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card style={{ "--opacity": board.opacity / 100 }} withBorder p={0} className={classes.itemCard}>
|
<Card style={{ "--opacity": board.opacity / 100 }} withBorder p={0} className={classes.itemCard}>
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ const createSections = (categoryCount: number) => {
|
|||||||
name: `Category ${index}`,
|
name: `Category ${index}`,
|
||||||
yOffset: index,
|
yOffset: index,
|
||||||
xOffset: 0,
|
xOffset: 0,
|
||||||
|
collapsed: false,
|
||||||
items: [],
|
items: [],
|
||||||
})) satisfies Section[];
|
})) satisfies Section[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ const createSections = (initialYOffsets: number[]) => {
|
|||||||
id: yOffset.toString(),
|
id: yOffset.toString(),
|
||||||
kind: index % 2 === 0 ? "empty" : "category",
|
kind: index % 2 === 0 ? "empty" : "category",
|
||||||
name: "Category",
|
name: "Category",
|
||||||
|
collapsed: false,
|
||||||
yOffset,
|
yOffset,
|
||||||
xOffset: 0,
|
xOffset: 0,
|
||||||
items: [createItem({ id: yOffset.toString() })],
|
items: [createItem({ id: yOffset.toString() })],
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export const useCategoryActions = () => {
|
|||||||
kind: "category",
|
kind: "category",
|
||||||
yOffset,
|
yOffset,
|
||||||
xOffset: 0,
|
xOffset: 0,
|
||||||
|
collapsed: false,
|
||||||
items: [],
|
items: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -89,6 +90,7 @@ export const useCategoryActions = () => {
|
|||||||
kind: "category",
|
kind: "category",
|
||||||
yOffset: lastYOffset + 1,
|
yOffset: lastYOffset + 1,
|
||||||
xOffset: 0,
|
xOffset: 0,
|
||||||
|
collapsed: false,
|
||||||
items: [],
|
items: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { logRouter } from "./router/log";
|
|||||||
import { mediaRouter } from "./router/medias/media-router";
|
import { mediaRouter } from "./router/medias/media-router";
|
||||||
import { onboardRouter } from "./router/onboard/onboard-router";
|
import { onboardRouter } from "./router/onboard/onboard-router";
|
||||||
import { searchEngineRouter } from "./router/search-engine/search-engine-router";
|
import { searchEngineRouter } from "./router/search-engine/search-engine-router";
|
||||||
|
import { sectionRouter } from "./router/section/section-router";
|
||||||
import { serverSettingsRouter } from "./router/serverSettings";
|
import { serverSettingsRouter } from "./router/serverSettings";
|
||||||
import { updateCheckerRouter } from "./router/update-checker";
|
import { updateCheckerRouter } from "./router/update-checker";
|
||||||
import { userRouter } from "./router/user";
|
import { userRouter } from "./router/user";
|
||||||
@@ -27,6 +28,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
invite: inviteRouter,
|
invite: inviteRouter,
|
||||||
integration: integrationRouter,
|
integration: integrationRouter,
|
||||||
board: boardRouter,
|
board: boardRouter,
|
||||||
|
section: sectionRouter,
|
||||||
app: innerAppRouter,
|
app: innerAppRouter,
|
||||||
searchEngine: searchEngineRouter,
|
searchEngine: searchEngineRouter,
|
||||||
widget: widgetRouter,
|
widget: widgetRouter,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
integrationItems,
|
integrationItems,
|
||||||
integrationUserPermissions,
|
integrationUserPermissions,
|
||||||
items,
|
items,
|
||||||
|
sectionCollapseStates,
|
||||||
sections,
|
sections,
|
||||||
users,
|
users,
|
||||||
} from "@homarr/db/schema";
|
} from "@homarr/db/schema";
|
||||||
@@ -1025,6 +1026,9 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
|
|||||||
},
|
},
|
||||||
sections: {
|
sections: {
|
||||||
with: {
|
with: {
|
||||||
|
collapseStates: {
|
||||||
|
where: eq(sectionCollapseStates.userId, userId ?? ""),
|
||||||
|
},
|
||||||
items: {
|
items: {
|
||||||
with: {
|
with: {
|
||||||
integrations: {
|
integrations: {
|
||||||
@@ -1059,9 +1063,10 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...otherBoardProperties,
|
...otherBoardProperties,
|
||||||
sections: sections.map((section) =>
|
sections: sections.map(({ collapseStates, ...section }) =>
|
||||||
parseSection({
|
parseSection({
|
||||||
...section,
|
...section,
|
||||||
|
collapsed: collapseStates.at(0)?.collapsed ?? false,
|
||||||
items: section.items.map(({ integrations: itemIntegrations, ...item }) => ({
|
items: section.items.map(({ integrations: itemIntegrations, ...item }) => ({
|
||||||
...item,
|
...item,
|
||||||
integrationIds: itemIntegrations.map((item) => item.integration.id),
|
integrationIds: itemIntegrations.map((item) => item.integration.id),
|
||||||
|
|||||||
52
packages/api/src/router/section/section-router.ts
Normal file
52
packages/api/src/router/section/section-router.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { and, eq } from "@homarr/db";
|
||||||
|
import { sectionCollapseStates, sections } from "@homarr/db/schema";
|
||||||
|
|
||||||
|
import { createTRPCRouter, protectedProcedure } from "../../trpc";
|
||||||
|
|
||||||
|
export const sectionRouter = createTRPCRouter({
|
||||||
|
changeCollapsed: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
sectionId: z.string(),
|
||||||
|
collapsed: z.boolean(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const section = await ctx.db.query.sections.findFirst({
|
||||||
|
where: and(eq(sections.id, input.sectionId), eq(sections.kind, "category")),
|
||||||
|
with: {
|
||||||
|
collapseStates: {
|
||||||
|
where: eq(sectionCollapseStates.userId, ctx.session.user.id),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!section) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: `Section not found id=${input.sectionId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (section.collapseStates.length === 0) {
|
||||||
|
await ctx.db.insert(sectionCollapseStates).values({
|
||||||
|
sectionId: section.id,
|
||||||
|
userId: ctx.session.user.id,
|
||||||
|
collapsed: input.collapsed,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db
|
||||||
|
.update(sectionCollapseStates)
|
||||||
|
.set({
|
||||||
|
collapsed: input.collapsed,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(eq(sectionCollapseStates.sectionId, section.id), eq(sectionCollapseStates.userId, ctx.session.user.id)),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -812,7 +812,7 @@ describe("saveBoard should save full board", () => {
|
|||||||
expect(integration).toBeUndefined();
|
expect(integration).toBeUndefined();
|
||||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
|
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
|
||||||
});
|
});
|
||||||
it.each([[{ kind: "empty" as const }], [{ kind: "category" as const, name: "My first category" }]])(
|
it.each([[{ kind: "empty" as const }], [{ kind: "category" as const, collapsed: false, name: "My first category" }]])(
|
||||||
"should add section when present in input",
|
"should add section when present in input",
|
||||||
async (partialSection) => {
|
async (partialSection) => {
|
||||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||||
@@ -1023,6 +1023,7 @@ describe("saveBoard should save full board", () => {
|
|||||||
yOffset: 1,
|
yOffset: 1,
|
||||||
xOffset: 0,
|
xOffset: 0,
|
||||||
name: "Test",
|
name: "Test",
|
||||||
|
collapsed: true,
|
||||||
items: [],
|
items: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1031,6 +1032,7 @@ describe("saveBoard should save full board", () => {
|
|||||||
name: "After",
|
name: "After",
|
||||||
yOffset: 0,
|
yOffset: 0,
|
||||||
xOffset: 0,
|
xOffset: 0,
|
||||||
|
collapsed: false,
|
||||||
items: [],
|
items: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE `section_collapse_state` (
|
||||||
|
`user_id` varchar(64) NOT NULL,
|
||||||
|
`section_id` varchar(64) NOT NULL,
|
||||||
|
`collapsed` boolean NOT NULL DEFAULT false,
|
||||||
|
CONSTRAINT `section_collapse_state_user_id_section_id_pk` PRIMARY KEY(`user_id`,`section_id`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `section_collapse_state` ADD CONSTRAINT `section_collapse_state_user_id_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `section_collapse_state` ADD CONSTRAINT `section_collapse_state_section_id_section_id_fk` FOREIGN KEY (`section_id`) REFERENCES `section`(`id`) ON DELETE cascade ON UPDATE no action;
|
||||||
1764
packages/db/migrations/mysql/meta/0022_snapshot.json
Normal file
1764
packages/db/migrations/mysql/meta/0022_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -155,6 +155,13 @@
|
|||||||
"when": 1737883744729,
|
"when": 1737883744729,
|
||||||
"tag": "0021_fluffy_jocasta",
|
"tag": "0021_fluffy_jocasta",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 22,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1737927618711,
|
||||||
|
"tag": "0022_famous_otto_octavius",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
8
packages/db/migrations/sqlite/0022_modern_sunfire.sql
Normal file
8
packages/db/migrations/sqlite/0022_modern_sunfire.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
CREATE TABLE `section_collapse_state` (
|
||||||
|
`user_id` text NOT NULL,
|
||||||
|
`section_id` text NOT NULL,
|
||||||
|
`collapsed` integer DEFAULT false NOT NULL,
|
||||||
|
PRIMARY KEY(`user_id`, `section_id`),
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`section_id`) REFERENCES `section`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
1689
packages/db/migrations/sqlite/meta/0022_snapshot.json
Normal file
1689
packages/db/migrations/sqlite/meta/0022_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -155,6 +155,13 @@
|
|||||||
"when": 1737883733050,
|
"when": 1737883733050,
|
||||||
"tag": "0021_famous_bruce_banner",
|
"tag": "0021_famous_bruce_banner",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 22,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1737927609085,
|
||||||
|
"tag": "0022_modern_sunfire",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export const {
|
|||||||
sessions,
|
sessions,
|
||||||
users,
|
users,
|
||||||
verificationTokens,
|
verificationTokens,
|
||||||
|
sectionCollapseStates,
|
||||||
} = schema;
|
} = schema;
|
||||||
|
|
||||||
export type User = InferSelectModel<typeof schema.users>;
|
export type User = InferSelectModel<typeof schema.users>;
|
||||||
|
|||||||
@@ -326,6 +326,24 @@ export const sections = mysqlTable("section", {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const sectionCollapseStates = mysqlTable(
|
||||||
|
"section_collapse_state",
|
||||||
|
{
|
||||||
|
userId: varchar({ length: 64 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
sectionId: varchar({ length: 64 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => sections.id, { onDelete: "cascade" }),
|
||||||
|
collapsed: boolean().default(false).notNull(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
compoundKey: primaryKey({
|
||||||
|
columns: [table.userId, table.sectionId],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export const items = mysqlTable("item", {
|
export const items = mysqlTable("item", {
|
||||||
id: varchar({ length: 64 }).notNull().primaryKey(),
|
id: varchar({ length: 64 }).notNull().primaryKey(),
|
||||||
sectionId: varchar({ length: 64 })
|
sectionId: varchar({ length: 64 })
|
||||||
@@ -563,6 +581,18 @@ export const sectionRelations = relations(sections, ({ many, one }) => ({
|
|||||||
fields: [sections.boardId],
|
fields: [sections.boardId],
|
||||||
references: [boards.id],
|
references: [boards.id],
|
||||||
}),
|
}),
|
||||||
|
collapseStates: many(sectionCollapseStates),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const sectionCollapseStateRelations = relations(sectionCollapseStates, ({ one }) => ({
|
||||||
|
user: one(users, {
|
||||||
|
fields: [sectionCollapseStates.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
section: one(sections, {
|
||||||
|
fields: [sectionCollapseStates.sectionId],
|
||||||
|
references: [sections.id],
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const itemRelations = relations(items, ({ one, many }) => ({
|
export const itemRelations = relations(items, ({ one, many }) => ({
|
||||||
|
|||||||
@@ -312,6 +312,24 @@ export const sections = sqliteTable("section", {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const sectionCollapseStates = sqliteTable(
|
||||||
|
"section_collapse_state",
|
||||||
|
{
|
||||||
|
userId: text()
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
sectionId: text()
|
||||||
|
.notNull()
|
||||||
|
.references(() => sections.id, { onDelete: "cascade" }),
|
||||||
|
collapsed: int({ mode: "boolean" }).default(false).notNull(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
compoundKey: primaryKey({
|
||||||
|
columns: [table.userId, table.sectionId],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export const items = sqliteTable("item", {
|
export const items = sqliteTable("item", {
|
||||||
id: text().notNull().primaryKey(),
|
id: text().notNull().primaryKey(),
|
||||||
sectionId: text()
|
sectionId: text()
|
||||||
@@ -550,6 +568,18 @@ export const sectionRelations = relations(sections, ({ many, one }) => ({
|
|||||||
fields: [sections.boardId],
|
fields: [sections.boardId],
|
||||||
references: [boards.id],
|
references: [boards.id],
|
||||||
}),
|
}),
|
||||||
|
collapseStates: many(sectionCollapseStates),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const sectionCollapseStateRelations = relations(sectionCollapseStates, ({ one }) => ({
|
||||||
|
user: one(users, {
|
||||||
|
fields: [sectionCollapseStates.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
section: one(sections, {
|
||||||
|
fields: [sectionCollapseStates.sectionId],
|
||||||
|
references: [sections.id],
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const itemRelations = relations(items, ({ one, many }) => ({
|
export const itemRelations = relations(items, ({ one, many }) => ({
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const createCategorySchema = <TItemSchema extends z.ZodTypeAny>(itemSchema: TIte
|
|||||||
yOffset: z.number(),
|
yOffset: z.number(),
|
||||||
xOffset: z.number(),
|
xOffset: z.number(),
|
||||||
items: z.array(itemSchema),
|
items: z.array(itemSchema),
|
||||||
|
collapsed: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const createEmptySchema = <TItemSchema extends z.ZodTypeAny>(itemSchema: TItemSchema) =>
|
const createEmptySchema = <TItemSchema extends z.ZodTypeAny>(itemSchema: TItemSchema) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user