feat(boards): add responsive layout system (#2271)

This commit is contained in:
Meier Lukas
2025-02-23 17:34:56 +01:00
committed by GitHub
parent 2085b5ece2
commit 7761dc29c8
98 changed files with 11770 additions and 1694 deletions

View File

@@ -37,15 +37,7 @@ export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
userPermissions: true,
items: {
with: {
item: {
with: {
section: {
columns: {
boardId: true,
},
},
},
},
item: true,
},
},
},
@@ -107,15 +99,7 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
secrets: true,
items: {
with: {
item: {
with: {
section: {
columns: {
boardId: true,
},
},
},
},
item: true,
},
},
userPermissions: true,

View File

@@ -21,21 +21,16 @@ const getAllAppIdsOnPublicBoardsAsync = async () => {
const itemsWithApps = await db.query.items.findMany({
where: or(eq(items.kind, "app"), eq(items.kind, "bookmarks")),
with: {
section: {
columns: {}, // Nothing
with: {
board: {
columns: {
isPublic: true,
},
},
board: {
columns: {
isPublic: true,
},
},
},
});
return itemsWithApps
.filter((item) => item.section.board.isPublic)
.filter((item) => item.board.isPublic)
.flatMap((item) => {
if (item.kind === "app") {
const parsedOptions = SuperJSON.parse<WidgetComponentProps<"app">["options"]>(item.options);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,186 @@
export interface GridAlgorithmItem {
id: string;
type: "item" | "section";
width: number;
height: number;
xOffset: number;
yOffset: number;
sectionId: string;
}
interface GridAlgorithmInput {
items: GridAlgorithmItem[];
width: number;
previousWidth: number;
sectionId: string;
}
interface GridAlgorithmOutput {
height: number;
items: GridAlgorithmItem[];
}
export const generateResponsiveGridFor = ({
items,
previousWidth,
width,
sectionId,
}: GridAlgorithmInput): GridAlgorithmOutput => {
const itemsOfCurrentSection = items
.filter((item) => item.sectionId === sectionId)
.sort((itemA, itemB) =>
itemA.yOffset === itemB.yOffset ? itemA.xOffset - itemB.xOffset : itemA.yOffset - itemB.yOffset,
);
const normalizedItems = normalizeItems(itemsOfCurrentSection, width);
if (itemsOfCurrentSection.length === 0) {
return {
height: 0,
items: [],
};
}
const newItems: GridAlgorithmItem[] = [];
// Fix height of dynamic sections
const dynamicSectionHeightMap = new Map<string, number>();
const dynamicSectionsOfCurrentSection = normalizedItems.filter((item) => item.type === "section");
for (const dynamicSection of dynamicSectionsOfCurrentSection) {
const result = generateResponsiveGridFor({
items,
previousWidth: dynamicSection.previousWidth,
width: dynamicSection.width,
sectionId: dynamicSection.id,
});
newItems.push(...result.items);
dynamicSectionHeightMap.set(dynamicSection.id, result.height);
}
// Return same positions for items in the current section
if (width >= previousWidth) {
return {
height: Math.max(...itemsOfCurrentSection.map((item) => item.yOffset + item.height)),
items: newItems.concat(normalizedItems),
};
}
const occupied2d: boolean[][] = [];
for (const item of normalizedItems) {
const itemWithHeight = {
...item,
height: item.type === "section" ? Math.max(dynamicSectionHeightMap.get(item.id) ?? 1, item.height) : item.height,
};
const position = nextFreeSpot(occupied2d, itemWithHeight, width);
if (!position) throw new Error("No free spot available");
addItemToOccupied(occupied2d, itemWithHeight, position, width);
newItems.push({
...itemWithHeight,
xOffset: position.x,
yOffset: position.y,
});
}
return {
height: occupied2d.length,
items: newItems,
};
};
/**
* Reduces the width of the items to fit the new column count.
* @param items items to normalize
* @param columnCount new column count
*/
const normalizeItems = (items: GridAlgorithmItem[], columnCount: number) => {
return items.map((item) => ({ ...item, previousWidth: item.width, width: Math.min(columnCount, item.width) }));
};
/**
* Adds the item to the occupied spots.
* @param occupied2d array of occupied spots
* @param item item to place
* @param position position to place the item
*/
const addItemToOccupied = (
occupied2d: boolean[][],
item: GridAlgorithmItem,
position: { x: number; y: number },
columnCount: number,
) => {
for (let yOffset = 0; yOffset < item.height; yOffset++) {
let row = occupied2d[position.y + yOffset];
if (!row) {
addRow(occupied2d, columnCount);
// After adding it, it must exist
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
row = occupied2d[position.y + yOffset]!;
}
for (let xOffset = 0; xOffset < item.width; xOffset++) {
row[position.x + xOffset] = true;
}
}
};
/**
* Adds a new row to the grid.
* @param occupied2d array of occupied spots
* @param columnCount column count of section
*/
const addRow = (occupied2d: boolean[][], columnCount: number) => {
occupied2d.push(new Array<boolean>(columnCount).fill(false));
};
/**
* Searches for the next free spot in the grid.
* @param occupied2d array of occupied spots
* @param item item to place
* @param columnCount column count of section
* @returns the position of the next free spot or null if no spot is available
*/
const nextFreeSpot = (occupied2d: boolean[][], item: GridAlgorithmItem, columnCount: number) => {
for (let offsetY = 0; offsetY < 99999; offsetY++) {
for (let offsetX = 0; offsetX < columnCount; offsetX++) {
if (hasHorizontalSpace(columnCount, item, offsetX) && isFree(occupied2d, item, { x: offsetX, y: offsetY })) {
return { x: offsetX, y: offsetY };
}
}
}
return null;
};
/**
* Check if the item fits into the grid horizontally.
* @param columnCount available width
* @param item item to place
* @param offsetX current x position
* @returns true if the item fits horizontally
*/
const hasHorizontalSpace = (columnCount: number, item: GridAlgorithmItem, offsetX: number) => {
return offsetX + item.width <= columnCount;
};
/**
* Check if the spot is free.
* @param occupied2d array of occupied spots
* @param item item to place
* @param position position to check
* @returns true if the spot is free
*/
const isFree = (occupied2d: boolean[][], item: GridAlgorithmItem, position: { x: number; y: number }) => {
for (let yOffset = 0; yOffset < item.height; yOffset++) {
const row = occupied2d[position.y + yOffset];
if (!row) return true; // Empty row is free
for (let xOffset = 0; xOffset < item.width; xOffset++) {
if (row[position.x + xOffset]) {
return false;
}
}
}
return true;
};

View File

@@ -0,0 +1,378 @@
import { createId } from "@paralleldrive/cuid2";
import { describe, expect, test } from "vitest";
import type { GridAlgorithmItem } from "../grid-algorithm";
import { generateResponsiveGridFor } from "../grid-algorithm";
const ROOT_SECTION_ID = "section";
/**
* If you want to see how the layouts progress between the different layouts, you can find images here:
* https://github.com/homarr-labs/architecture-documentation/tree/main/grid-algorithm#graphical-representation-of-the-algorithm
*/
describe("Grid Algorithm", () => {
test.each(itemTests)("should convert a grid with %i columns to a grid with %i columns", (_, _ignored, item) => {
const input = generateInputFromText(item.input);
const result = generateResponsiveGridFor({
items: input,
width: item.outputColumnCount,
previousWidth: item.inputColumnCount,
sectionId: ROOT_SECTION_ID,
});
const output = generateOutputText(result.items, item.outputColumnCount);
expect(output).toBe(item.output);
});
test.each(dynamicSectionTests)(
"should convert a grid with dynamic sections from 16 columns to %i columns",
(_, testInput) => {
const outerDynamicSectionId = "b";
const innerDynamicSectionId = "f";
const items = [
algoItem({ id: "a", width: 2, height: 2 }),
algoItem({ id: outerDynamicSectionId, type: "section", width: 12, height: 3, yOffset: 2 }),
algoItem({ id: "a", width: 2, sectionId: outerDynamicSectionId }),
algoItem({ id: "b", width: 4, sectionId: outerDynamicSectionId, xOffset: 2 }),
algoItem({ id: "c", width: 2, sectionId: outerDynamicSectionId, xOffset: 6 }),
algoItem({ id: "d", width: 1, sectionId: outerDynamicSectionId, xOffset: 8 }),
algoItem({ id: "e", width: 3, sectionId: outerDynamicSectionId, xOffset: 9 }),
algoItem({
id: innerDynamicSectionId,
type: "section",
width: 8,
height: 2,
yOffset: 1,
sectionId: outerDynamicSectionId,
}),
algoItem({ id: "a", width: 2, sectionId: innerDynamicSectionId }),
algoItem({ id: "b", width: 5, xOffset: 2, sectionId: innerDynamicSectionId }),
algoItem({ id: "c", width: 1, height: 2, xOffset: 7, sectionId: innerDynamicSectionId }),
algoItem({ id: "d", width: 7, yOffset: 1, sectionId: innerDynamicSectionId }),
algoItem({ id: "g", width: 4, yOffset: 1, sectionId: outerDynamicSectionId, xOffset: 8 }),
algoItem({ id: "h", width: 3, yOffset: 2, sectionId: outerDynamicSectionId, xOffset: 8 }),
algoItem({ id: "i", width: 1, yOffset: 2, sectionId: outerDynamicSectionId, xOffset: 11 }),
algoItem({ id: "c", width: 5, yOffset: 5 }),
];
const newItems = generateResponsiveGridFor({
items,
width: testInput.outputColumns,
previousWidth: 16,
sectionId: ROOT_SECTION_ID,
});
const rootItems = newItems.items.filter((item) => item.sectionId === ROOT_SECTION_ID);
const outerSection = items.find((item) => item.id === outerDynamicSectionId);
const outerItems = newItems.items.filter((item) => item.sectionId === outerDynamicSectionId);
const innerSection = items.find((item) => item.id === innerDynamicSectionId);
const innerItems = newItems.items.filter((item) => item.sectionId === innerDynamicSectionId);
expect(generateOutputText(rootItems, testInput.outputColumns)).toBe(testInput.root);
expect(generateOutputText(outerItems, Math.min(testInput.outputColumns, outerSection?.width ?? 999))).toBe(
testInput.outer,
);
expect(generateOutputText(innerItems, Math.min(testInput.outputColumns, innerSection?.width ?? 999))).toBe(
testInput.inner,
);
},
);
});
const algoItem = (item: Partial<GridAlgorithmItem>): GridAlgorithmItem => ({
id: createId(),
type: "item",
width: 1,
height: 1,
xOffset: 0,
yOffset: 0,
sectionId: ROOT_SECTION_ID,
...item,
});
const sixteenColumns = `
abbccccddddeeefg
hbbccccddddeeeij
klllmmmmmnneeeop
qlllmmmmmnnrrrst
ulllmmmmmnnrrrvw
xyz äö`;
// Just add two empty columns to the right
const eighteenColumns = sixteenColumns
.split("\n")
.map((line, index) => (index === 0 ? line : `${line} `))
.join("\n");
const tenColumns = `
abbcccceee
fbbcccceee
ddddghieee
ddddjklllo
mmmmmplllq
mmmmmslllt
mmmmmnnrrr
uvwxynnrrr
zäö nn `;
const sixColumns = `
abbfgh
ibbjko
ccccnn
ccccnn
ddddnn
ddddpq
eeelll
eeelll
eeelll
mmmmms
mmmmmt
mmmmmu
rrrvwx
rrryzä
ö `;
const threeColumns = `
abb
fbb
ccc
ccc
ddd
ddd
eee
eee
eee
ghi
jko
lll
lll
lll
mmm
mmm
mmm
nnp
nnq
nns
rrr
rrr
tuv
wxy
zäö`;
const itemTests = [
{
input: sixteenColumns,
inputColumnCount: 16,
output: sixteenColumns,
outputColumnCount: 16,
},
{
input: sixteenColumns,
inputColumnCount: 16,
output: eighteenColumns,
outputColumnCount: 18,
},
{
input: sixteenColumns,
inputColumnCount: 16,
output: tenColumns,
outputColumnCount: 10,
},
{
input: sixteenColumns,
inputColumnCount: 16,
output: sixColumns,
outputColumnCount: 6,
},
{
input: sixteenColumns,
inputColumnCount: 16,
output: threeColumns,
outputColumnCount: 3,
},
].map((item) => [item.inputColumnCount, item.outputColumnCount, item] as const);
const dynamicSectionTests = [
{
outputColumns: 16,
root: `
aa
aa
bbbbbbbbbbbb
bbbbbbbbbbbb
bbbbbbbbbbbb
ccccc `,
outer: `
aabbbbccdeee
ffffffffgggg
ffffffffhhhi`,
inner: `
aabbbbbc
dddddddc`,
},
{
outputColumns: 10,
root: `
aaccccc
aa
bbbbbbbbbb
bbbbbbbbbb
bbbbbbbbbb
bbbbbbbbbb`,
outer: `
aabbbbccdi
eeegggghhh
ffffffff
ffffffff `,
inner: `
aabbbbbc
dddddddc`,
},
{
outputColumns: 6,
root: `
aa
aa
bbbbbb
bbbbbb
bbbbbb
bbbbbb
bbbbbb
bbbbbb
bbbbbb
ccccc `,
outer: `
aabbbb
ccdeee
ffffff
ffffff
ffffff
ggggi
hhh `,
inner: `
aa c
bbbbbc
dddddd`,
},
{
outputColumns: 3,
root: `
aa
aa
bbb
bbb
bbb
bbb
bbb
bbb
bbb
bbb
bbb
bbb
bbb
ccc`,
outer: `
aad
bbb
cci
eee
fff
fff
fff
fff
fff
ggg
hhh`,
inner: `
aa
bbb
c
c
ddd`,
},
].map((item) => [item.outputColumns, item] as const);
const generateInputFromText = (text: string) => {
const lines = text.split("\n").slice(1); // Remove first empty row
const items: GridAlgorithmItem[] = [];
for (let yOffset = 0; yOffset < lines.length; yOffset++) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const line = lines[yOffset]!;
for (let xOffset = 0; xOffset < line.length; xOffset++) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const char = line[xOffset]!;
if (char === " ") continue;
if (items.some((item) => item.id === char)) continue;
items.push({
id: char,
type: "item",
width: getWidth(line, xOffset, char),
height: getHeight(lines, { x: xOffset, y: yOffset }, char),
xOffset,
yOffset,
sectionId: ROOT_SECTION_ID,
});
}
}
return items;
};
const generateOutputText = (items: GridAlgorithmItem[], columnCount: number) => {
const occupied2d: string[][] = [];
for (const item of items) {
addItemToOccupied(occupied2d, item, { x: item.xOffset, y: item.yOffset }, columnCount);
}
return `\n${occupied2d.map((row) => row.join("")).join("\n")}`;
};
const getWidth = (line: string, offset: number, char: string) => {
const row = line.split("");
let width = 1;
for (let xOffset = offset + 1; xOffset < row.length; xOffset++) {
if (row[xOffset] === char) {
width++;
} else {
break;
}
}
return width;
};
const getHeight = (lines: string[], position: { x: number; y: number }, char: string) => {
let height = 1;
for (let yOffset = position.y + 1; yOffset < lines.length; yOffset++) {
if (lines[yOffset]?.[position.x] === char) {
height++;
} else {
break;
}
}
return height;
};
const addItemToOccupied = (
occupied2d: string[][],
item: GridAlgorithmItem,
position: { x: number; y: number },
columnCount: number,
) => {
for (let yOffset = 0; yOffset < item.height; yOffset++) {
let row = occupied2d[position.y + yOffset];
if (!row) {
addRow(occupied2d, columnCount);
// After adding it, it must exist
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
row = occupied2d[position.y + yOffset]!;
}
for (let xOffset = 0; xOffset < item.width; xOffset++) {
row[position.x + xOffset] = item.id;
}
}
};
const addRow = (occupied2d: string[][], columnCount: number) => {
occupied2d.push(new Array<string>(columnCount).fill(" "));
};

View File

@@ -2,8 +2,8 @@ import SuperJSON from "superjson";
import { describe, expect, it, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import type { Database } from "@homarr/db";
import { createId, eq } from "@homarr/db";
import type { Database, InferInsertModel } from "@homarr/db";
import { and, createId, eq, not } from "@homarr/db";
import {
boardGroupPermissions,
boards,
@@ -13,7 +13,10 @@ import {
groups,
integrationItems,
integrations,
itemLayouts,
items,
layouts,
sectionLayouts,
sections,
serverSettings,
users,
@@ -304,17 +307,27 @@ describe("createBoard should create a new board", () => {
await caller.createBoard({ name: "newBoard", columnCount: 24, isPublic: true });
// Assert
const dbBoard = await db.query.boards.findFirst();
const dbBoard = await db.query.boards.findFirst({
with: {
sections: true,
layouts: true,
},
});
expect(dbBoard).toBeDefined();
expect(dbBoard?.name).toBe("newBoard");
expect(dbBoard?.columnCount).toBe(24);
expect(dbBoard?.isPublic).toBe(true);
expect(dbBoard?.creatorId).toBe(defaultCreatorId);
const dbSection = await db.query.sections.findFirst();
expect(dbSection).toBeDefined();
expect(dbSection?.boardId).toBe(dbBoard?.id);
expect(dbSection?.kind).toBe("empty");
expect(dbBoard?.sections.length).toBe(1);
const firstSection = dbBoard?.sections.at(0);
expect(firstSection?.kind).toBe("empty");
expect(firstSection?.xOffset).toBe(0);
expect(firstSection?.yOffset).toBe(0);
expect(dbBoard?.layouts.length).toBe(1);
const firstLayout = dbBoard?.layouts.at(0);
expect(firstLayout?.columnCount).toBe(24);
expect(firstLayout?.breakpoint).toBe(0);
});
test("should throw error when user has no board-create permission", async () => {
@@ -587,7 +600,6 @@ describe("savePartialBoardSettings should save general settings", () => {
const newBackgroundImageSize = "cover";
const newBackgroundImageRepeat = "repeat";
const newBackgroundImageUrl = "http://background.image/url.png";
const newColumnCount = 2;
const newCustomCss = "body { background-color: blue; }";
const newOpacity = 0.8;
const newPrimaryColor = "#0000ff";
@@ -605,7 +617,6 @@ describe("savePartialBoardSettings should save general settings", () => {
backgroundImageRepeat: newBackgroundImageRepeat,
backgroundImageSize: newBackgroundImageSize,
backgroundImageUrl: newBackgroundImageUrl,
columnCount: newColumnCount,
customCss: newCustomCss,
opacity: newOpacity,
primaryColor: newPrimaryColor,
@@ -626,7 +637,6 @@ describe("savePartialBoardSettings should save general settings", () => {
expect(dbBoard?.backgroundImageRepeat).toBe(newBackgroundImageRepeat);
expect(dbBoard?.backgroundImageSize).toBe(newBackgroundImageSize);
expect(dbBoard?.backgroundImageUrl).toBe(newBackgroundImageUrl);
expect(dbBoard?.columnCount).toBe(newColumnCount);
expect(dbBoard?.customCss).toBe(newCustomCss);
expect(dbBoard?.opacity).toBe(newOpacity);
expect(dbBoard?.primaryColor).toBe(newPrimaryColor);
@@ -668,9 +678,9 @@ describe("saveBoard should save full board", () => {
kind: "empty",
yOffset: 0,
xOffset: 0,
items: [],
},
],
items: [],
});
const board = await db.query.boards.findFirst({
@@ -695,7 +705,7 @@ describe("saveBoard should save full board", () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, itemId, sectionId } = await createFullBoardAsync(db, "default");
const { boardId, itemId, sectionId, layoutId } = await createFullBoardAsync(db, "default");
await caller.saveBoard({
id: boardId,
@@ -705,19 +715,25 @@ describe("saveBoard should save full board", () => {
kind: "empty",
yOffset: 0,
xOffset: 0,
items: [
},
],
items: [
{
id: createId(),
kind: "clock",
options: { is24HourFormat: true },
integrationIds: [],
layouts: [
{
id: createId(),
kind: "clock",
options: { is24HourFormat: true },
integrationIds: [],
layoutId,
sectionId,
height: 1,
width: 1,
xOffset: 0,
yOffset: 0,
advancedOptions: {},
},
],
advancedOptions: {},
},
],
});
@@ -725,11 +741,8 @@ describe("saveBoard should save full board", () => {
const board = await db.query.boards.findFirst({
where: eq(boards.id, boardId),
with: {
sections: {
with: {
items: true,
},
},
sections: true,
items: true,
},
});
@@ -739,9 +752,8 @@ describe("saveBoard should save full board", () => {
const definedBoard = expectToBeDefined(board);
expect(definedBoard.sections.length).toBe(1);
const firstSection = expectToBeDefined(definedBoard.sections[0]);
expect(firstSection.items.length).toBe(1);
expect(firstSection.items[0]?.id).not.toBe(itemId);
expect(definedBoard.items.length).toBe(1);
expect(definedBoard.items[0]?.id).not.toBe(itemId);
expect(item).toBeUndefined();
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
});
@@ -756,7 +768,7 @@ describe("saveBoard should save full board", () => {
url: "http://localhost:3000",
} as const;
const { boardId, itemId, integrationId, sectionId } = await createFullBoardAsync(db, "default");
const { boardId, itemId, integrationId, sectionId, layoutId } = await createFullBoardAsync(db, "default");
await db.insert(integrations).values(anotherIntegration);
await caller.saveBoard({
@@ -767,19 +779,25 @@ describe("saveBoard should save full board", () => {
kind: "empty",
xOffset: 0,
yOffset: 0,
items: [
},
],
items: [
{
id: itemId,
kind: "clock",
options: { is24HourFormat: true },
integrationIds: [anotherIntegration.id],
layouts: [
{
id: itemId,
kind: "clock",
options: { is24HourFormat: true },
integrationIds: [anotherIntegration.id],
layoutId,
sectionId,
height: 1,
width: 1,
xOffset: 0,
yOffset: 0,
advancedOptions: {},
},
],
advancedOptions: {},
},
],
});
@@ -787,13 +805,10 @@ describe("saveBoard should save full board", () => {
const board = await db.query.boards.findFirst({
where: eq(boards.id, boardId),
with: {
sections: {
sections: true,
items: {
with: {
items: {
with: {
integrations: true,
},
},
integrations: true,
},
},
},
@@ -805,9 +820,8 @@ describe("saveBoard should save full board", () => {
const definedBoard = expectToBeDefined(board);
expect(definedBoard.sections.length).toBe(1);
const firstSection = expectToBeDefined(definedBoard.sections[0]);
expect(firstSection.items.length).toBe(1);
const firstItem = expectToBeDefined(firstSection.items[0]);
expect(definedBoard.items.length).toBe(1);
const firstItem = expectToBeDefined(definedBoard.items[0]);
expect(firstItem.integrations.length).toBe(1);
expect(firstItem.integrations[0]?.integrationId).not.toBe(integrationId);
expect(integration).toBeUndefined();
@@ -830,7 +844,6 @@ describe("saveBoard should save full board", () => {
id: newSectionId,
xOffset: 0,
yOffset: 1,
items: [],
...partialSection,
},
{
@@ -838,9 +851,9 @@ describe("saveBoard should save full board", () => {
kind: "empty",
xOffset: 0,
yOffset: 0,
items: [],
},
],
items: [],
});
const board = await db.query.boards.findFirst({
@@ -873,7 +886,7 @@ describe("saveBoard should save full board", () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
const { boardId, sectionId, layoutId } = await createFullBoardAsync(db, "default");
const newItemId = createId();
await caller.saveBoard({
@@ -884,19 +897,25 @@ describe("saveBoard should save full board", () => {
kind: "empty",
yOffset: 0,
xOffset: 0,
items: [
},
],
items: [
{
id: newItemId,
kind: "clock",
options: { is24HourFormat: true },
integrationIds: [],
layouts: [
{
id: newItemId,
kind: "clock",
options: { is24HourFormat: true },
integrationIds: [],
layoutId,
sectionId,
height: 1,
width: 1,
xOffset: 3,
yOffset: 2,
advancedOptions: {},
},
],
advancedOptions: {},
},
],
});
@@ -904,9 +923,10 @@ describe("saveBoard should save full board", () => {
const board = await db.query.boards.findFirst({
where: eq(boards.id, boardId),
with: {
sections: {
sections: true,
items: {
with: {
items: true,
layouts: true,
},
},
},
@@ -918,17 +938,18 @@ describe("saveBoard should save full board", () => {
const definedBoard = expectToBeDefined(board);
expect(definedBoard.sections.length).toBe(1);
const firstSection = expectToBeDefined(definedBoard.sections[0]);
expect(firstSection.items.length).toBe(1);
const addedItem = expectToBeDefined(firstSection.items.find((item) => item.id === newItemId));
expect(definedBoard.items.length).toBe(1);
const addedItem = expectToBeDefined(definedBoard.items.find((item) => item.id === newItemId));
expect(addedItem).toBeDefined();
expect(addedItem.id).toBe(newItemId);
expect(addedItem.kind).toBe("clock");
expect(addedItem.options).toBe(SuperJSON.stringify({ is24HourFormat: true }));
expect(addedItem.height).toBe(1);
expect(addedItem.width).toBe(1);
expect(addedItem.xOffset).toBe(3);
expect(addedItem.yOffset).toBe(2);
const firstLayout = expectToBeDefined(addedItem.layouts[0]);
expect(firstLayout.sectionId).toBe(sectionId);
expect(firstLayout.height).toBe(1);
expect(firstLayout.width).toBe(1);
expect(firstLayout.xOffset).toBe(3);
expect(firstLayout.yOffset).toBe(2);
expect(item).toBeDefined();
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
});
@@ -943,7 +964,7 @@ describe("saveBoard should save full board", () => {
url: "http://plex.local",
} as const;
const { boardId, itemId, sectionId } = await createFullBoardAsync(db, "default");
const { boardId, itemId, sectionId, layoutId } = await createFullBoardAsync(db, "default");
await db.insert(integrations).values(integration);
await caller.saveBoard({
@@ -954,19 +975,25 @@ describe("saveBoard should save full board", () => {
kind: "empty",
xOffset: 0,
yOffset: 0,
items: [
},
],
items: [
{
id: itemId,
kind: "clock",
options: { is24HourFormat: true },
integrationIds: [integration.id],
layouts: [
{
id: itemId,
kind: "clock",
options: { is24HourFormat: true },
integrationIds: [integration.id],
sectionId,
layoutId,
height: 1,
width: 1,
xOffset: 0,
yOffset: 0,
advancedOptions: {},
},
],
advancedOptions: {},
},
],
});
@@ -974,13 +1001,10 @@ describe("saveBoard should save full board", () => {
const board = await db.query.boards.findFirst({
where: eq(boards.id, boardId),
with: {
sections: {
sections: true,
items: {
with: {
items: {
with: {
integrations: true,
},
},
integrations: true,
},
},
},
@@ -992,9 +1016,7 @@ describe("saveBoard should save full board", () => {
const definedBoard = expectToBeDefined(board);
expect(definedBoard.sections.length).toBe(1);
const firstSection = expectToBeDefined(definedBoard.sections[0]);
expect(firstSection.items.length).toBe(1);
const firstItem = expectToBeDefined(firstSection.items.find((item) => item.id === itemId));
const firstItem = expectToBeDefined(definedBoard.items.find((item) => item.id === itemId));
expect(firstItem.integrations.length).toBe(1);
expect(firstItem.integrations[0]?.integrationId).toBe(integration.id);
expect(integrationItem).toBeDefined();
@@ -1025,7 +1047,6 @@ describe("saveBoard should save full board", () => {
xOffset: 0,
name: "Test",
collapsed: true,
items: [],
},
{
id: newSectionId,
@@ -1034,9 +1055,9 @@ describe("saveBoard should save full board", () => {
yOffset: 0,
xOffset: 0,
collapsed: false,
items: [],
},
],
items: [],
});
const board = await db.query.boards.findFirst({
@@ -1064,7 +1085,7 @@ describe("saveBoard should save full board", () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, itemId, sectionId } = await createFullBoardAsync(db, "default");
const { boardId, itemId, sectionId, layoutId } = await createFullBoardAsync(db, "default");
await caller.saveBoard({
id: boardId,
@@ -1074,19 +1095,25 @@ describe("saveBoard should save full board", () => {
kind: "empty",
yOffset: 0,
xOffset: 0,
items: [
},
],
items: [
{
id: itemId,
kind: "clock",
options: { is24HourFormat: false },
integrationIds: [],
layouts: [
{
id: itemId,
kind: "clock",
options: { is24HourFormat: false },
integrationIds: [],
layoutId,
sectionId,
height: 3,
width: 2,
xOffset: 7,
yOffset: 5,
advancedOptions: {},
},
],
advancedOptions: {},
},
],
});
@@ -1094,9 +1121,10 @@ describe("saveBoard should save full board", () => {
const board = await db.query.boards.findFirst({
where: eq(boards.id, boardId),
with: {
sections: {
sections: true,
items: {
with: {
items: true,
layouts: true,
},
},
},
@@ -1104,16 +1132,17 @@ describe("saveBoard should save full board", () => {
const definedBoard = expectToBeDefined(board);
expect(definedBoard.sections.length).toBe(1);
const firstSection = expectToBeDefined(definedBoard.sections[0]);
expect(firstSection.items.length).toBe(1);
const firstItem = expectToBeDefined(firstSection.items.find((item) => item.id === itemId));
expect(definedBoard.items.length).toBe(1);
const firstItem = expectToBeDefined(definedBoard.items.find((item) => item.id === itemId));
expect(firstItem.id).toBe(itemId);
expect(firstItem.kind).toBe("clock");
expect(SuperJSON.parse<{ is24HourFormat: boolean }>(firstItem.options).is24HourFormat).toBe(false);
expect(firstItem.height).toBe(3);
expect(firstItem.width).toBe(2);
expect(firstItem.xOffset).toBe(7);
expect(firstItem.yOffset).toBe(5);
const firstLayout = expectToBeDefined(firstItem.layouts[0]);
expect(firstLayout.sectionId).toBe(sectionId);
expect(firstLayout.height).toBe(3);
expect(firstLayout.width).toBe(2);
expect(firstLayout.xOffset).toBe(7);
expect(firstLayout.yOffset).toBe(5);
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
});
it("should fail when board not found", async () => {
@@ -1124,6 +1153,7 @@ describe("saveBoard should save full board", () => {
await caller.saveBoard({
id: "nonExistentBoardId",
sections: [],
items: [],
});
await expect(actAsync()).rejects.toThrowError("Board not found");
@@ -1293,6 +1323,165 @@ 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 save layout changes", () => {
test("should add layout when not present in database", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
const newLayout = createNewLayout(12);
// Act
await caller.saveLayouts({
id: boardId,
layouts: [createExistingLayout(layoutId), newLayout],
});
// Assert
const layout = await db.query.layouts.findFirst({
where: not(eq(layouts.id, layoutId)),
});
const definedLayout = expectToBeDefined(layout);
expect(definedLayout.name).toBe(newLayout.name);
expect(definedLayout.columnCount).toBe(newLayout.columnCount);
expect(definedLayout.breakpoint).toBe(newLayout.breakpoint);
});
test("should add items and dynamic sections generated from grid-algorithm when new layout is added", async () => {
// Arrange
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);
// Act
await caller.saveLayouts({
id: boardId,
layouts: [createExistingLayout(layoutId), newLayout],
});
// Assert
const layout = await db.query.layouts.findFirst({
where: not(eq(layouts.id, layoutId)),
});
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await expectLayoutForRootLayoutAsync(db, sectionId, layout!.id, {
...assignments.inRoot,
a: itemId,
});
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layout!.id, assignments.inDynamicSection);
});
test("should update layout when present in input", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
const updatedLayout = createExistingLayout(layoutId);
updatedLayout.breakpoint = 1400;
updatedLayout.name = "Updated layout";
// Act
await caller.saveLayouts({
id: boardId,
layouts: [updatedLayout],
});
// Assert
const layout = await db.query.layouts.findFirst({
where: eq(layouts.id, layoutId),
});
const definedLayout = expectToBeDefined(layout);
expect(definedLayout.name).toBe(updatedLayout.name);
expect(definedLayout.columnCount).toBe(updatedLayout.columnCount);
expect(definedLayout.breakpoint).toBe(updatedLayout.breakpoint);
});
test("should update position of items when column count changes", async () => {
// Arrange
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 updatedLayout = createExistingLayout(layoutId);
updatedLayout.columnCount = 3;
// Act
await caller.saveLayouts({
id: boardId,
layouts: [updatedLayout],
});
// Assert
await expectLayoutForRootLayoutAsync(db, sectionId, layoutId, {
...assignments.inRoot,
a: itemId,
});
await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layoutId, assignments.inDynamicSection);
});
test("should remove layout when not present in input", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
// Act
await caller.saveLayouts({
id: boardId,
layouts: [createNewLayout(12)],
});
// Assert
const layout = await db.query.layouts.findFirst({
where: eq(layouts.id, layoutId),
});
expect(layout).toBeUndefined();
});
test("should fail when board not found", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { layoutId } = await createFullBoardAsync(db, "default");
// Act
const actAsync = async () =>
await caller.saveLayouts({
id: createId(),
layouts: [createExistingLayout(layoutId)],
});
// Assert
await expect(actAsync()).rejects.toThrowError("Board not found");
});
});
const expectInputToBeFullBoardWithName = (
input: RouterOutputs["board"]["getHomeBoard"],
props: { name: string } & Awaited<ReturnType<typeof createFullBoardAsync>>,
@@ -1302,8 +1491,8 @@ const expectInputToBeFullBoardWithName = (
expect(input.sections.length).toBe(1);
const firstSection = expectToBeDefined(input.sections[0]);
expect(firstSection.id).toBe(props.sectionId);
expect(firstSection.items.length).toBe(1);
const firstItem = expectToBeDefined(firstSection.items[0]);
expect(input.items.length).toBe(1);
const firstItem = expectToBeDefined(input.items[0]);
expect(firstItem.id).toBe(props.itemId);
expect(firstItem.kind).toBe("clock");
if (firstItem.kind === "clock") {
@@ -1326,6 +1515,15 @@ const createFullBoardAsync = async (db: Database, name: string) => {
creatorId: defaultCreatorId,
});
const layoutId = createId();
await db.insert(layouts).values({
id: layoutId,
name: "Base",
columnCount: 10,
breakpoint: 0,
boardId,
});
const sectionId = createId();
await db.insert(sections).values({
id: sectionId,
@@ -1339,12 +1537,18 @@ const createFullBoardAsync = async (db: Database, name: string) => {
await db.insert(items).values({
id: itemId,
kind: "clock",
boardId,
options: SuperJSON.stringify({ is24HourFormat: true }),
});
await db.insert(itemLayouts).values({
height: 1,
width: 1,
xOffset: 0,
yOffset: 0,
sectionId,
options: SuperJSON.stringify({ is24HourFormat: true }),
itemId,
layoutId,
});
const integrationId = createId();
@@ -1363,7 +1567,226 @@ const createFullBoardAsync = async (db: Database, name: string) => {
return {
boardId,
sectionId,
layoutId,
itemId,
integrationId,
};
};
const addItemAsync = async (
db: Database,
item: Partial<Pick<InferInsertModel<typeof itemLayouts>, "height" | "width" | "xOffset" | "yOffset">> & {
sectionId: string;
layoutId: string;
boardId: string;
},
) => {
const itemId = createId();
await db.insert(items).values({
id: itemId,
kind: "clock",
boardId: item.boardId,
options: SuperJSON.stringify({ is24HourFormat: true }),
});
await db.insert(itemLayouts).values({
itemId,
layoutId: item.layoutId,
sectionId: item.sectionId,
height: item.height ?? 1,
width: item.width ?? 1,
xOffset: item.xOffset ?? 0,
yOffset: item.yOffset ?? 0,
});
return itemId;
};
const addDynamicSectionAsync = async (
db: Database,
section: Partial<Pick<InferInsertModel<typeof sectionLayouts>, "xOffset" | "yOffset" | "width" | "height">> & {
parentSectionId: string;
boardId: string;
layoutId: string;
},
) => {
const sectionId = createId();
await db.insert(sections).values({
id: sectionId,
kind: "dynamic",
boardId: section.boardId,
});
await db.insert(sectionLayouts).values({
parentSectionId: section.parentSectionId,
layoutId: section.layoutId,
sectionId,
xOffset: section.xOffset ?? 0,
yOffset: section.yOffset ?? 0,
width: section.width ?? 1,
height: section.height ?? 1,
});
return sectionId;
};
const createItemsAndSectionsAsync = async (
db: Database,
options: { boardId: string; sectionId: string; layoutId: string },
) => {
const { boardId, layoutId, sectionId } = options;
// From:
// abbbbbccdd
// efffffccdd
// efffffggdd
// efffffgg
// To:
// a
// bbb
// cce
// cce
// dde
// dd
// dd
// fff
// fff
// fff
// fff
// gg
// gg
const itemB = await addItemAsync(db, { boardId, layoutId, sectionId, xOffset: 1, width: 5 });
const itemC = await addItemAsync(db, { boardId, layoutId, sectionId, xOffset: 6, width: 2, height: 2 });
const itemD = await addItemAsync(db, { boardId, layoutId, sectionId, xOffset: 8, width: 2, height: 3 });
const itemE = await addItemAsync(db, { boardId, layoutId, sectionId, yOffset: 1, height: 3 });
const sectionF = await addDynamicSectionAsync(db, {
yOffset: 1,
xOffset: 1,
width: 5,
height: 3,
parentSectionId: sectionId,
boardId,
layoutId,
});
const sectionG = await addDynamicSectionAsync(db, {
yOffset: 2,
xOffset: 6,
width: 2,
height: 2,
parentSectionId: sectionId,
boardId,
layoutId,
});
// From:
// hhhhh
// iiijj
// iii
// To:
// hhh
// iii
// iii
// jj
const itemH = await addItemAsync(db, { boardId, layoutId, sectionId: sectionF, width: 5 });
const itemI = await addItemAsync(db, { boardId, layoutId, sectionId: sectionF, width: 3, height: 2, yOffset: 1 });
const itemJ = await addItemAsync(db, { boardId, layoutId, sectionId: sectionF, width: 2, yOffset: 1, xOffset: 2 });
return {
inRoot: {
b: itemB,
c: itemC,
d: itemD,
e: itemE,
f: sectionF,
g: sectionG,
},
inDynamicSection: {
h: itemH,
i: itemI,
j: itemJ,
},
};
};
const expectLayoutForRootLayoutAsync = async (
db: Database,
sectionId: string,
layoutId: string,
assignments: Record<string, string>,
) => {
await expectLayoutInSectionAsync(
db,
sectionId,
layoutId,
`
a
bbb
cce
cce
dde
dd
dd
fff
fff
fff
fff
gg
gg`,
assignments,
);
};
const expectLayoutForDynamicSectionAsync = async (
db: Database,
sectionId: string,
layoutId: string,
assignments: Record<string, string>,
) => {
await expectLayoutInSectionAsync(
db,
sectionId,
layoutId,
`
hhh
iii
iii
jj`,
assignments,
);
};
const expectLayoutInSectionAsync = async (
db: Database,
sectionId: string,
layoutId: string,
layout: string,
assignments: Record<string, string>,
) => {
const itemsInSection = await db.query.itemLayouts.findMany({
where: and(eq(itemLayouts.sectionId, sectionId), eq(itemLayouts.layoutId, layoutId)),
});
const sectionsInSection = await db.query.sectionLayouts.findMany({
where: and(eq(sectionLayouts.parentSectionId, sectionId), eq(sectionLayouts.layoutId, layoutId)),
});
const entries = [...itemsInSection, ...sectionsInSection];
const lines = layout.split("\n").slice(1);
const keys = Object.keys(assignments);
const positions: Record<string, { x: number; y: number; w: number; h: number }> = {};
for (let yOffset = 0; yOffset < lines.length; yOffset++) {
const line = lines[yOffset];
if (!line) continue;
for (let xOffset = 0; xOffset < line.length; xOffset++) {
const char = line[xOffset];
if (!char) continue;
if (!keys.includes(char)) continue;
if (char in positions) continue;
const width = line.split("").filter((lineChar) => lineChar === char).length;
const height = lines.slice(yOffset).filter((line) => line.substring(xOffset).startsWith(char)).length;
positions[char] = { x: xOffset, y: yOffset, w: width, h: height };
}
}
for (const [key, { x, y, w, h }] of Object.entries(positions)) {
const entry = entries.find((entry) => ("itemId" in entry ? entry.itemId : entry.sectionId) === assignments[key]);
expect(entry, `Expect entry for ${key} to be defined in assignments=${JSON.stringify(assignments)}`).toBeDefined();
expect(entry?.xOffset, `Expect xOffset of entry for ${key} to be ${x} for entry=${JSON.stringify(entry)}`).toBe(x);
expect(entry?.yOffset, `Expect yOffset of entry for ${key} to be ${y} for entry=${JSON.stringify(entry)}`).toBe(y);
expect(entry?.width, `Expect width of entry for ${key} to be ${w} for entry=${JSON.stringify(entry)}`).toBe(w);
expect(entry?.height, `Expect height of entry for ${key} to be ${h} for entry=${JSON.stringify(entry)}`).toBe(h);
}
};

View File

@@ -19,16 +19,9 @@ export const notebookRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
const item = await ctx.db.query.items.findFirst({
where: eq(items.id, input.itemId),
with: {
section: {
columns: {
boardId: true,
},
},
},
});
if (!item || item.section.boardId !== input.boardId) {
if (!item || item.boardId !== input.boardId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Specified item was not found",

View File

@@ -11,9 +11,7 @@ interface Integration {
id: string;
items: {
item: {
section: {
boardId: string;
};
boardId: string;
};
}[];
userPermissions: {
@@ -56,7 +54,7 @@ export const hasQueryAccessToIntegrationsAsync = async (
const integrationsWithBoardIds = integrationsWithoutUseAccessAndWithoutBoardViewAllAccess.map((integration) => ({
id: integration.id,
anyOfBoardIds: integration.items.map(({ item }) => item.section.boardId),
anyOfBoardIds: integration.items.map(({ item }) => item.boardId),
}));
const permissionsOfCurrentUserWhenPresent = await db.query.boardUserPermissions.findMany({

View File

@@ -29,13 +29,13 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
const integrations = [
{
id: "1",
items: [{ item: { section: { boardId: "1" } } }],
items: [{ item: { boardId: "1" } }],
userPermissions: [],
groupPermissions: [],
},
{
id: "2",
items: [{ item: { section: { boardId: "2" } } }],
items: [{ item: { boardId: "2" } }],
userPermissions: [],
groupPermissions: [],
},
@@ -63,7 +63,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
const integrations = [
{
id: "1",
items: [{ item: { section: { boardId: "1" } } }],
items: [{ item: { boardId: "1" } }],
userPermissions: [],
groupPermissions: [],
},
@@ -131,9 +131,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
section: {
boardId: "1",
},
boardId: "1",
},
},
],
@@ -145,16 +143,12 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
section: {
boardId: "1",
},
boardId: "1",
},
},
{
item: {
section: {
boardId: "2",
},
boardId: "2",
},
},
],
@@ -189,9 +183,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
section: {
boardId: "1",
},
boardId: "1",
},
},
],
@@ -203,9 +195,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
section: {
boardId: "2",
},
boardId: "2",
},
},
],
@@ -240,9 +230,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
section: {
boardId: "1",
},
boardId: "1",
},
},
],
@@ -254,16 +242,12 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
section: {
boardId: "1",
},
boardId: "1",
},
},
{
item: {
section: {
boardId: "2",
},
boardId: "2",
},
},
],
@@ -300,9 +284,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
section: {
boardId: "1",
},
boardId: "1",
},
},
],
@@ -314,9 +296,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
section: {
boardId: "2",
},
boardId: "2",
},
},
],
@@ -353,9 +333,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
section: {
boardId: "1",
},
boardId: "1",
},
},
],
@@ -367,9 +345,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
section: {
boardId: "2",
},
boardId: "2",
},
},
],
@@ -401,9 +377,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
section: {
boardId: "1",
},
boardId: "1",
},
},
],
@@ -415,16 +389,12 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
section: {
boardId: "2",
},
boardId: "2",
},
},
{
item: {
section: {
boardId: "1",
},
boardId: "1",
},
},
],
@@ -452,9 +422,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
section: {
boardId: "1",
},
boardId: "1",
},
},
],
@@ -466,16 +434,12 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
section: {
boardId: "2",
},
boardId: "2",
},
},
{
item: {
section: {
boardId: "1",
},
boardId: "1",
},
},
],
@@ -502,9 +466,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
section: {
boardId: "1",
},
boardId: "1",
},
},
],
@@ -516,9 +478,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
section: {
boardId: "2",
},
boardId: "2",
},
},
],
@@ -543,9 +503,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
section: {
boardId: "1",
},
boardId: "1",
},
},
],
@@ -557,9 +515,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
section: {
boardId: "2",
},
boardId: "2",
},
},
],
@@ -585,9 +541,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
section: {
boardId: "1",
},
boardId: "1",
},
},
],
@@ -599,16 +553,12 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
section: {
boardId: "2",
},
boardId: "2",
},
},
{
item: {
section: {
boardId: "1",
},
boardId: "1",
},
},
],

View File

@@ -1,7 +1,7 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useContext, useEffect } from "react";
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import type { RouterOutputs } from "@homarr/api";
@@ -10,7 +10,7 @@ import { clientApi } from "@homarr/api/client";
import { updateBoardName } from "./updater";
const BoardContext = createContext<{
board: RouterOutputs["board"]["getHomeBoard"];
board: RouterOutputs["board"]["getBoardByName"];
} | null>(null);
export const BoardProvider = ({
@@ -68,3 +68,43 @@ export const useOptionalBoard = () => {
return context?.board ?? null;
};
export const getCurrentLayout = (board: RouterOutputs["board"]["getBoardByName"]) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (typeof window === "undefined") return board.layouts.at(0)!.id;
const sortedLayouts = board.layouts.sort((layoutA, layoutB) => layoutB.breakpoint - layoutA.breakpoint);
// Fallback to smallest if none exists with breakpoint smaller than window width
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return sortedLayouts.find((layout) => layout.breakpoint <= window.innerWidth)?.id ?? sortedLayouts.at(0)!.id;
};
export const useCurrentLayout = () => {
const board = useRequiredBoard();
const [currentLayout, setCurrentLayout] = useState(getCurrentLayout(board));
const onResize = useCallback(() => {
setCurrentLayout(getCurrentLayout(board));
}, [board]);
useEffect(() => {
if (typeof window === "undefined") return;
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
};
}, [onResize]);
return currentLayout;
};
export const getBoardLayouts = (board: RouterOutputs["board"]["getBoardByName"]) =>
board.layouts.map((layout) => layout.id);
export const useLayouts = () => {
const board = useRequiredBoard();
return getBoardLayouts(board);
};

View File

@@ -1,12 +1,18 @@
import type { InferInsertModel } from "drizzle-orm";
import { objectEntries } from "@homarr/common";
import type { Database, HomarrDatabaseMysql, InferInsertModel } from "@homarr/db";
import * as schema from "@homarr/db/schema";
import type { HomarrDatabase, HomarrDatabaseMysql } from "./driver";
import { env } from "./env";
import * as schema from "./schema";
type TableKey = {
[K in keyof typeof schema]: (typeof schema)[K] extends { _: { brand: "Table" } } ? K : never;
}[keyof typeof schema];
export const createDbInsertCollection = <TTableKey extends TableKey>(tablesInInsertOrder: TTableKey[]) => {
export const createDbInsertCollectionForTransaction = <TTableKey extends TableKey>(
tablesInInsertOrder: TTableKey[],
) => {
const context = tablesInInsertOrder.reduce(
(acc, key) => {
acc[key] = [];
@@ -17,7 +23,7 @@ export const createDbInsertCollection = <TTableKey extends TableKey>(tablesInIns
return {
...context,
insertAll: (db: Database) => {
insertAll: (db: HomarrDatabase) => {
db.transaction((transaction) => {
for (const [key, values] of objectEntries(context)) {
if (values.length >= 1) {
@@ -41,3 +47,21 @@ export const createDbInsertCollection = <TTableKey extends TableKey>(tablesInIns
},
};
};
export const createDbInsertCollectionWithoutTransaction = <TTableKey extends TableKey>(
tablesInInsertOrder: TTableKey[],
) => {
const { insertAll, insertAllAsync, ...collection } = createDbInsertCollectionForTransaction(tablesInInsertOrder);
return {
...collection,
insertAllAsync: async (db: HomarrDatabase) => {
if (env.DB_DRIVER !== "mysql2") {
insertAll(db);
return;
}
await insertAllAsync(db as unknown as HomarrDatabaseMysql);
},
};
};

View File

@@ -0,0 +1,50 @@
CREATE TABLE `item_layout` (
`item_id` varchar(64) NOT NULL,
`section_id` varchar(64) NOT NULL,
`layout_id` varchar(64) NOT NULL,
`x_offset` int NOT NULL,
`y_offset` int NOT NULL,
`width` int NOT NULL,
`height` int NOT NULL,
CONSTRAINT `item_layout_item_id_section_id_layout_id_pk` PRIMARY KEY(`item_id`,`section_id`,`layout_id`)
);
--> statement-breakpoint
CREATE TABLE `layout` (
`id` varchar(64) NOT NULL,
`name` varchar(32) NOT NULL,
`board_id` varchar(64) NOT NULL,
`column_count` tinyint NOT NULL,
`breakpoint` smallint NOT NULL DEFAULT 0,
CONSTRAINT `layout_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `section_layout` (
`section_id` varchar(64) NOT NULL,
`layout_id` varchar(64) NOT NULL,
`parent_section_id` varchar(64),
`x_offset` int NOT NULL,
`y_offset` int NOT NULL,
`width` int NOT NULL,
`height` int NOT NULL,
CONSTRAINT `section_layout_section_id_layout_id_pk` PRIMARY KEY(`section_id`,`layout_id`)
);
--> statement-breakpoint
ALTER TABLE `item_layout` ADD CONSTRAINT `item_layout_item_id_item_id_fk` FOREIGN KEY (`item_id`) REFERENCES `item`(`id`) ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE `item_layout` ADD CONSTRAINT `item_layout_section_id_section_id_fk` FOREIGN KEY (`section_id`) REFERENCES `section`(`id`) ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE `item_layout` ADD CONSTRAINT `item_layout_layout_id_layout_id_fk` FOREIGN KEY (`layout_id`) REFERENCES `layout`(`id`) ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE `layout` ADD CONSTRAINT `layout_board_id_board_id_fk` FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE `section_layout` ADD CONSTRAINT `section_layout_section_id_section_id_fk` FOREIGN KEY (`section_id`) REFERENCES `section`(`id`) ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE `section_layout` ADD CONSTRAINT `section_layout_layout_id_layout_id_fk` FOREIGN KEY (`layout_id`) REFERENCES `layout`(`id`) ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE `section_layout` ADD CONSTRAINT `section_layout_parent_section_id_section_id_fk` FOREIGN KEY (`parent_section_id`) REFERENCES `section`(`id`) ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
INSERT INTO `layout`(`id`, `name`, `board_id`, `column_count`) SELECT `id`, 'Base', `id`, `column_count` FROM `board`;
--> statement-breakpoint
INSERT INTO `item_layout`(`item_id`, `section_id`, `layout_id`, `x_offset`, `y_offset`, `width`, `height`) SELECT `item`.`id`, `section`.`id`, `board`.`id`, `item`.`x_offset`, `item`.`y_offset`, `item`.`width`, `item`.`height` FROM `board` LEFT JOIN `section` ON `section`.`board_id`=`board`.`id` LEFT JOIN `item` ON `item`.`section_id`=`section`.`id` WHERE `item`.`id` IS NOT NULL;
--> statement-breakpoint
INSERT INTO `section_layout`(`section_id`, `layout_id`, `parent_section_id`, `x_offset`, `y_offset`, `width`, `height`) SELECT `section`.`id`, `board`.`id`, `section`.`parent_section_id`, `section`.`x_offset`, `section`.`y_offset`, `section`.`width`, `section`.`height` FROM `board` LEFT JOIN `section` ON `section`.`board_id`=`board`.`id` WHERE `section`.`id` IS NOT NULL AND `section`.`kind` = 'dynamic';

View File

@@ -0,0 +1,36 @@
-- Custom SQL migration file, put your code below! --
ALTER TABLE `item` DROP FOREIGN KEY `item_section_id_section_id_fk`;
--> statement-breakpoint
ALTER TABLE `section` DROP FOREIGN KEY `section_parent_section_id_section_id_fk`;
--> statement-breakpoint
ALTER TABLE `section` MODIFY COLUMN `x_offset` int;
--> statement-breakpoint
ALTER TABLE `section` MODIFY COLUMN `y_offset` int;
--> statement-breakpoint
ALTER TABLE `item` ADD `board_id` varchar(64);
--> statement-breakpoint
ALTER TABLE `item` ADD CONSTRAINT `item_board_id_board_id_fk` FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
UPDATE `item` JOIN `section` ON `item`.`section_id`=`section`.`id` SET `item`.`board_id` = `section`.`board_id`;
--> statement-breakpoint
ALTER TABLE `item` MODIFY COLUMN `board_id` varchar(64) NOT NULL;
--> statement-breakpoint
ALTER TABLE `board` DROP COLUMN `column_count`;
--> statement-breakpoint
ALTER TABLE `item` DROP COLUMN `section_id`;
--> statement-breakpoint
ALTER TABLE `item` DROP COLUMN `x_offset`;
--> statement-breakpoint
ALTER TABLE `item` DROP COLUMN `y_offset`;
--> statement-breakpoint
ALTER TABLE `item` DROP COLUMN `width`;
--> statement-breakpoint
ALTER TABLE `item` DROP COLUMN `height`;
--> statement-breakpoint
ALTER TABLE `section` DROP COLUMN `width`;
--> statement-breakpoint
ALTER TABLE `section` DROP COLUMN `height`;
--> statement-breakpoint
ALTER TABLE `section` DROP COLUMN `parent_section_id`;
--> statement-breakpoint
UPDATE `section` SET `x_offset` = NULL, `y_offset` = NULL WHERE `kind` = 'dynamic';

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -204,6 +204,20 @@
"when": 1740086765989,
"tag": "0028_add_app_ping_url",
"breakpoints": true
},
{
"idx": 29,
"version": "5",
"when": 1740255915876,
"tag": "0029_add_layouts",
"breakpoints": true
},
{
"idx": 30,
"version": "5",
"when": 1740256006328,
"tag": "0030_migrate_item_and_section_for_layouts",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,42 @@
CREATE TABLE `item_layout` (
`item_id` text NOT NULL,
`section_id` text NOT NULL,
`layout_id` text NOT NULL,
`x_offset` integer NOT NULL,
`y_offset` integer NOT NULL,
`width` integer NOT NULL,
`height` integer NOT NULL,
PRIMARY KEY(`item_id`, `section_id`, `layout_id`),
FOREIGN KEY (`item_id`) REFERENCES `item`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`section_id`) REFERENCES `section`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`layout_id`) REFERENCES `layout`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `layout` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`board_id` text NOT NULL,
`column_count` integer NOT NULL,
`breakpoint` integer DEFAULT 0 NOT NULL,
FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `section_layout` (
`section_id` text NOT NULL,
`layout_id` text NOT NULL,
`parent_section_id` text,
`x_offset` integer NOT NULL,
`y_offset` integer NOT NULL,
`width` integer NOT NULL,
`height` integer NOT NULL,
PRIMARY KEY(`section_id`, `layout_id`),
FOREIGN KEY (`section_id`) REFERENCES `section`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`layout_id`) REFERENCES `layout`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`parent_section_id`) REFERENCES `section`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO "layout"("id", "name", "board_id", "column_count") SELECT id, 'Base', id, column_count FROM board;
--> statement-breakpoint
INSERT INTO "item_layout"("item_id", "section_id", "layout_id", "x_offset", "y_offset", "width", "height") SELECT item.id, section.id, board.id, item.x_offset, item.y_offset, item.width, item.height FROM board LEFT JOIN section ON section.board_id=board.id LEFT JOIN item ON item.section_id=section.id WHERE item.id IS NOT NULL;
--> statement-breakpoint
INSERT INTO "section_layout"("section_id", "layout_id", "parent_section_id", "x_offset", "y_offset", "width", "height") SELECT section.id, board.id, section.parent_section_id, section.x_offset, section.y_offset, section.width, section.height FROM board LEFT JOIN section ON section.board_id=board.id WHERE section.id IS NOT NULL AND section.kind = 'dynamic';

View File

@@ -0,0 +1,47 @@
-- Custom SQL migration file, put your code below! --
COMMIT TRANSACTION;
--> statement-breakpoint
PRAGMA foreign_keys=OFF;
--> statement-breakpoint
BEGIN TRANSACTION;
--> statement-breakpoint
CREATE TABLE `__new_item` (
`id` text PRIMARY KEY NOT NULL,
`board_id` text NOT NULL,
`kind` text NOT NULL,
`options` text DEFAULT '{"json": {}}' NOT NULL,
`advanced_options` text DEFAULT '{"json": {}}' NOT NULL,
FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_item`("id", "board_id", "kind", "options", "advanced_options") SELECT "item"."id", "section"."board_id", "item"."kind", "item"."options", "item"."advanced_options" FROM `item` LEFT JOIN `section` ON section.id=item.section_id;
--> statement-breakpoint
DROP TABLE `item`;
--> statement-breakpoint
ALTER TABLE `__new_item` RENAME TO `item`;
--> statement-breakpoint
CREATE TABLE `__new_section` (
`id` text PRIMARY KEY NOT NULL,
`board_id` text NOT NULL,
`kind` text NOT NULL,
`x_offset` integer,
`y_offset` integer,
`name` text,
FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_section`("id", "board_id", "kind", "x_offset", "y_offset", "name") SELECT "id", "board_id", "kind", "x_offset", "y_offset", "name" FROM `section`;
--> statement-breakpoint
DROP TABLE `section`;
--> statement-breakpoint
ALTER TABLE `__new_section` RENAME TO `section`;
--> statement-breakpoint
UPDATE `section` SET `x_offset` = NULL, `y_offset` = NULL WHERE `kind` = 'dynamic';
--> statement-breakpoint
ALTER TABLE `board` DROP COLUMN `column_count`;
--> statement-breakpoint
COMMIT TRANSACTION;
--> statement-breakpoint
PRAGMA foreign_keys=ON;
--> statement-breakpoint
BEGIN TRANSACTION;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -204,6 +204,20 @@
"when": 1740086746417,
"tag": "0028_add_app_ping_url",
"breakpoints": true
},
{
"idx": 29,
"version": "6",
"when": 1740255687392,
"tag": "0029_add_layouts",
"breakpoints": true
},
{
"idx": 30,
"version": "6",
"when": 1740255968549,
"tag": "0030_migrate_item_and_section_for_layouts",
"breakpoints": true
}
]
}

View File

@@ -7,6 +7,7 @@
"exports": {
".": "./index.ts",
"./client": "./client.ts",
"./collection": "./collection.ts",
"./schema": "./schema/index.ts",
"./test": "./test/index.ts",
"./queries": "./queries/index.ts",

View File

@@ -36,6 +36,9 @@ export const {
users,
verificationTokens,
sectionCollapseStates,
layouts,
itemLayouts,
sectionLayouts,
} = schema;
export type User = InferSelectModel<typeof schema.users>;

View File

@@ -280,7 +280,6 @@ export const boards = mysqlTable("board", {
secondaryColor: text().default("#fd7e14").notNull(),
opacity: int().default(100).notNull(),
customCss: text(),
columnCount: int().default(10).notNull(),
iconColor: text(),
itemRadius: text().$type<MantineSize>().default("lg").notNull(),
disableStatus: boolean().default(false).notNull(),
@@ -322,20 +321,73 @@ export const boardGroupPermissions = mysqlTable(
}),
);
export const layouts = mysqlTable("layout", {
id: varchar({ length: 64 }).notNull().primaryKey(),
name: varchar({ length: 32 }).notNull(),
boardId: varchar({ length: 64 })
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
columnCount: tinyint().notNull(),
breakpoint: smallint().notNull().default(0),
});
export const itemLayouts = mysqlTable(
"item_layout",
{
itemId: varchar({ length: 64 })
.notNull()
.references(() => items.id, { onDelete: "cascade" }),
sectionId: varchar({ length: 64 })
.notNull()
.references(() => sections.id, { onDelete: "cascade" }),
layoutId: varchar({ length: 64 })
.notNull()
.references(() => layouts.id, { onDelete: "cascade" }),
xOffset: int().notNull(),
yOffset: int().notNull(),
width: int().notNull(),
height: int().notNull(),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.itemId, table.sectionId, table.layoutId],
}),
}),
);
export const sectionLayouts = mysqlTable(
"section_layout",
{
sectionId: varchar({ length: 64 })
.notNull()
.references(() => sections.id, { onDelete: "cascade" }),
layoutId: varchar({ length: 64 })
.notNull()
.references(() => layouts.id, { onDelete: "cascade" }),
parentSectionId: varchar({ length: 64 }).references((): AnyMySqlColumn => sections.id, {
onDelete: "cascade",
}),
xOffset: int().notNull(),
yOffset: int().notNull(),
width: int().notNull(),
height: int().notNull(),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.sectionId, table.layoutId],
}),
}),
);
export const sections = mysqlTable("section", {
id: varchar({ length: 64 }).notNull().primaryKey(),
boardId: varchar({ length: 64 })
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
kind: text().$type<SectionKind>().notNull(),
xOffset: int().notNull(),
yOffset: int().notNull(),
width: int(),
height: int(),
xOffset: int(),
yOffset: int(),
name: text(),
parentSectionId: varchar({ length: 64 }).references((): AnyMySqlColumn => sections.id, {
onDelete: "cascade",
}),
});
export const sectionCollapseStates = mysqlTable(
@@ -358,14 +410,10 @@ export const sectionCollapseStates = mysqlTable(
export const items = mysqlTable("item", {
id: varchar({ length: 64 }).notNull().primaryKey(),
sectionId: varchar({ length: 64 })
boardId: varchar({ length: 64 })
.notNull()
.references(() => sections.id, { onDelete: "cascade" }),
.references(() => boards.id, { onDelete: "cascade" }),
kind: text().$type<WidgetKind>().notNull(),
xOffset: int().notNull(),
yOffset: int().notNull(),
width: int().notNull(),
height: int().notNull(),
options: text().default('{"json": {}}').notNull(), // empty superjson object
advancedOptions: text().default('{"json": {}}').notNull(), // empty superjson object
});
@@ -590,12 +638,14 @@ export const integrationSecretRelations = relations(integrationSecrets, ({ one }
export const boardRelations = relations(boards, ({ many, one }) => ({
sections: many(sections),
items: many(items),
creator: one(users, {
fields: [boards.creatorId],
references: [users.id],
}),
userPermissions: many(boardUserPermissions),
groupPermissions: many(boardGroupPermissions),
layouts: many(layouts),
groupHomes: many(groups, {
relationName: "groupRelations__board__homeBoardId",
}),
@@ -605,12 +655,17 @@ export const boardRelations = relations(boards, ({ many, one }) => ({
}));
export const sectionRelations = relations(sections, ({ many, one }) => ({
items: many(items),
board: one(boards, {
fields: [sections.boardId],
references: [boards.id],
}),
collapseStates: many(sectionCollapseStates),
layouts: many(sectionLayouts, {
relationName: "sectionLayoutRelations__section__sectionId",
}),
children: many(sectionLayouts, {
relationName: "sectionLayoutRelations__section__parentSectionId",
}),
}));
export const sectionCollapseStateRelations = relations(sectionCollapseStates, ({ one }) => ({
@@ -625,11 +680,12 @@ export const sectionCollapseStateRelations = relations(sectionCollapseStates, ({
}));
export const itemRelations = relations(items, ({ one, many }) => ({
section: one(sections, {
fields: [items.sectionId],
references: [sections.id],
}),
integrations: many(integrationItems),
layouts: many(itemLayouts),
board: one(boards, {
fields: [items.boardId],
references: [boards.id],
}),
}));
export const integrationItemRelations = relations(integrationItems, ({ one }) => ({
@@ -650,3 +706,44 @@ export const searchEngineRelations = relations(searchEngines, ({ one, many }) =>
}),
usersWithDefault: many(users),
}));
export const itemLayoutRelations = relations(itemLayouts, ({ one }) => ({
item: one(items, {
fields: [itemLayouts.itemId],
references: [items.id],
}),
section: one(sections, {
fields: [itemLayouts.sectionId],
references: [sections.id],
}),
layout: one(layouts, {
fields: [itemLayouts.layoutId],
references: [layouts.id],
}),
}));
export const sectionLayoutRelations = relations(sectionLayouts, ({ one }) => ({
section: one(sections, {
fields: [sectionLayouts.sectionId],
references: [sections.id],
relationName: "sectionLayoutRelations__section__sectionId",
}),
layout: one(layouts, {
fields: [sectionLayouts.layoutId],
references: [layouts.id],
}),
parentSection: one(sections, {
fields: [sectionLayouts.parentSectionId],
references: [sections.id],
relationName: "sectionLayoutRelations__section__parentSectionId",
}),
}));
export const layoutRelations = relations(layouts, ({ one, many }) => ({
items: many(itemLayouts),
sections: many(sectionLayouts),
board: one(boards, {
fields: [layouts.boardId],
references: [boards.id],
}),
}));

View File

@@ -265,7 +265,6 @@ export const boards = sqliteTable("board", {
secondaryColor: text().default("#fd7e14").notNull(),
opacity: int().default(100).notNull(),
customCss: text(),
columnCount: int().default(10).notNull(),
iconColor: text(),
itemRadius: text().$type<MantineSize>().default("lg").notNull(),
disableStatus: int({ mode: "boolean" }).default(false).notNull(),
@@ -307,20 +306,73 @@ export const boardGroupPermissions = sqliteTable(
}),
);
export const layouts = sqliteTable("layout", {
id: text().notNull().primaryKey(),
name: text().notNull(),
boardId: text()
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
columnCount: int().notNull(),
breakpoint: int().notNull().default(0),
});
export const itemLayouts = sqliteTable(
"item_layout",
{
itemId: text()
.notNull()
.references(() => items.id, { onDelete: "cascade" }),
sectionId: text()
.notNull()
.references(() => sections.id, { onDelete: "cascade" }),
layoutId: text()
.notNull()
.references(() => layouts.id, { onDelete: "cascade" }),
xOffset: int().notNull(),
yOffset: int().notNull(),
width: int().notNull(),
height: int().notNull(),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.itemId, table.sectionId, table.layoutId],
}),
}),
);
export const sectionLayouts = sqliteTable(
"section_layout",
{
sectionId: text()
.notNull()
.references(() => sections.id, { onDelete: "cascade" }),
layoutId: text()
.notNull()
.references(() => layouts.id, { onDelete: "cascade" }),
parentSectionId: text().references((): AnySQLiteColumn => sections.id, {
onDelete: "cascade",
}),
xOffset: int().notNull(),
yOffset: int().notNull(),
width: int().notNull(),
height: int().notNull(),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.sectionId, table.layoutId],
}),
}),
);
export const sections = sqliteTable("section", {
id: text().notNull().primaryKey(),
boardId: text()
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
kind: text().$type<SectionKind>().notNull(),
xOffset: int().notNull(),
yOffset: int().notNull(),
width: int(),
height: int(),
xOffset: int(),
yOffset: int(),
name: text(),
parentSectionId: text().references((): AnySQLiteColumn => sections.id, {
onDelete: "cascade",
}),
});
export const sectionCollapseStates = sqliteTable(
@@ -343,14 +395,10 @@ export const sectionCollapseStates = sqliteTable(
export const items = sqliteTable("item", {
id: text().notNull().primaryKey(),
sectionId: text()
boardId: text()
.notNull()
.references(() => sections.id, { onDelete: "cascade" }),
.references(() => boards.id, { onDelete: "cascade" }),
kind: text().$type<WidgetKind>().notNull(),
xOffset: int().notNull(),
yOffset: int().notNull(),
width: int().notNull(),
height: int().notNull(),
options: text().default('{"json": {}}').notNull(), // empty superjson object
advancedOptions: text().default('{"json": {}}').notNull(), // empty superjson object
});
@@ -576,12 +624,14 @@ export const integrationSecretRelations = relations(integrationSecrets, ({ one }
export const boardRelations = relations(boards, ({ many, one }) => ({
sections: many(sections),
items: many(items),
creator: one(users, {
fields: [boards.creatorId],
references: [users.id],
}),
userPermissions: many(boardUserPermissions),
groupPermissions: many(boardGroupPermissions),
layouts: many(layouts),
groupHomes: many(groups, {
relationName: "groupRelations__board__homeBoardId",
}),
@@ -591,12 +641,17 @@ export const boardRelations = relations(boards, ({ many, one }) => ({
}));
export const sectionRelations = relations(sections, ({ many, one }) => ({
items: many(items),
board: one(boards, {
fields: [sections.boardId],
references: [boards.id],
}),
collapseStates: many(sectionCollapseStates),
layouts: many(sectionLayouts, {
relationName: "sectionLayoutRelations__section__sectionId",
}),
children: many(sectionLayouts, {
relationName: "sectionLayoutRelations__section__parentSectionId",
}),
}));
export const sectionCollapseStateRelations = relations(sectionCollapseStates, ({ one }) => ({
@@ -611,11 +666,12 @@ export const sectionCollapseStateRelations = relations(sectionCollapseStates, ({
}));
export const itemRelations = relations(items, ({ one, many }) => ({
section: one(sections, {
fields: [items.sectionId],
references: [sections.id],
}),
integrations: many(integrationItems),
layouts: many(itemLayouts),
board: one(boards, {
fields: [items.boardId],
references: [boards.id],
}),
}));
export const integrationItemRelations = relations(integrationItems, ({ one }) => ({
@@ -636,3 +692,44 @@ export const searchEngineRelations = relations(searchEngines, ({ one, many }) =>
}),
usersWithDefault: many(users),
}));
export const itemLayoutRelations = relations(itemLayouts, ({ one }) => ({
item: one(items, {
fields: [itemLayouts.itemId],
references: [items.id],
}),
section: one(sections, {
fields: [itemLayouts.sectionId],
references: [sections.id],
}),
layout: one(layouts, {
fields: [itemLayouts.layoutId],
references: [layouts.id],
}),
}));
export const sectionLayoutRelations = relations(sectionLayouts, ({ one }) => ({
section: one(sections, {
fields: [sectionLayouts.sectionId],
references: [sections.id],
relationName: "sectionLayoutRelations__section__sectionId",
}),
layout: one(layouts, {
fields: [sectionLayouts.layoutId],
references: [layouts.id],
}),
parentSection: one(sections, {
fields: [sectionLayouts.parentSectionId],
references: [sections.id],
relationName: "sectionLayoutRelations__section__parentSectionId",
}),
}));
export const layoutRelations = relations(layouts, ({ one, many }) => ({
items: many(itemLayouts),
sections: many(sectionLayouts),
board: one(boards, {
fields: [layouts.boardId],
references: [boards.id],
}),
}));

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { Button, FileInput, Group, Radio, Stack, TextInput } from "@mantine/core";
import { Button, FileInput, Group, Stack, TextInput } from "@mantine/core";
import { IconFileUpload } from "@tabler/icons-react";
import { z } from "zod";
@@ -12,13 +12,12 @@ import { OldmarrImportAppsSettings, SidebarBehaviourSelect } from "@homarr/old-i
import type { OldmarrImportConfiguration } from "@homarr/old-import/shared";
import { oldmarrImportConfigurationSchema, superRefineJsonImportFile } from "@homarr/old-import/shared";
import { oldmarrConfigSchema } from "@homarr/old-schema";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useScopedI18n } from "@homarr/translation/client";
import { useBoardNameStatus } from "./add-board-modal";
export const ImportBoardModal = createModal(({ actions }) => {
const tOldImport = useScopedI18n("board.action.oldImport");
const t = useI18n();
const tCommon = useScopedI18n("common");
const [fileValid, setFileValid] = useState(true);
const form = useZodForm(
@@ -33,7 +32,6 @@ export const ImportBoardModal = createModal(({ actions }) => {
file: null!,
configuration: {
onlyImportApps: false,
screenSize: "lg",
sidebarBehaviour: "last-section",
name: "",
},
@@ -136,19 +134,6 @@ export const ImportBoardModal = createModal(({ actions }) => {
{...form.getInputProps("configuration.name")}
/>
<Radio.Group
withAsterisk
label={tOldImport("form.screenSize.label")}
description={t("board.action.oldImport.form.screenSize.description")}
{...form.getInputProps("configuration.screenSize")}
>
<Group mt="xs">
<Radio value="sm" label={t("board.action.oldImport.form.screenSize.option.sm")} />
<Radio value="md" label={t("board.action.oldImport.form.screenSize.option.md")} />
<Radio value="lg" label={t("board.action.oldImport.form.screenSize.option.lg")} />
</Group>
</Radio.Group>
<SidebarBehaviourSelect {...form.getInputProps("configuration.sidebarBehaviour")} />
<Group justify="end">

View File

@@ -29,7 +29,7 @@ export const analyseOldmarrImportAsync = async (file: File) => {
}
return {
name: entry.name,
name: entry.name.replace(".json", ""),
config: result.data ?? null,
isError: !result.success,
};

View File

@@ -4,7 +4,6 @@ import SuperJSON from "superjson";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useModalAction } from "@homarr/modals";
import { boardSizes } from "@homarr/old-schema";
// We don't have access to the API client here, so we need to import it from the API package
// In the future we should consider having the used router also in this package
@@ -13,7 +12,6 @@ import type { AnalyseResult } from "../analyse/analyse-oldmarr-import";
import { prepareMultipleImports } from "../prepare/prepare-multiple";
import type { InitialOldmarrImportSettings } from "../settings";
import { defaultSidebarBehaviour } from "../settings";
import type { BoardSelectionMap, BoardSizeRecord } from "./initial/board-selection-card";
import { BoardSelectionCard } from "./initial/board-selection-card";
import { ImportSettingsCard } from "./initial/import-settings-card";
import { ImportSummaryCard } from "./initial/import-summary-card";
@@ -25,8 +23,8 @@ interface InitialOldmarrImportProps {
}
export const InitialOldmarrImport = ({ file, analyseResult }: InitialOldmarrImportProps) => {
const [boardSelections, setBoardSelections] = useState<BoardSelectionMap>(
new Map(createDefaultSelections(analyseResult.configs)),
const [boardSelections, setBoardSelections] = useState<Map<string, boolean>>(
new Map(analyseResult.configs.filter(({ config }) => config !== null).map(({ name }) => [name, true])),
);
const [settings, setSettings] = useState<InitialOldmarrImportSettings>({
onlyImportApps: false,
@@ -94,19 +92,3 @@ export const InitialOldmarrImport = ({ file, analyseResult }: InitialOldmarrImpo
</Stack>
);
};
const createDefaultSelections = (configs: AnalyseResult["configs"]) => {
return configs
.map(({ name, config }) => {
if (!config) return null;
const shapes = config.apps.flatMap((app) => app.shape).concat(config.widgets.flatMap((widget) => widget.shape));
const boardSizeRecord = boardSizes.reduce<BoardSizeRecord>((acc, size) => {
const allInclude = shapes.every((shape) => Boolean(shape[size]));
acc[size] = allInclude ? true : null;
return acc;
}, {} as BoardSizeRecord);
return [name, boardSizeRecord];
})
.filter((selection): selection is [string, BoardSizeRecord] => Boolean(selection));
};

View File

@@ -1,14 +1,9 @@
import type { ChangeEvent } from "react";
import { Anchor, Card, Checkbox, Group, Stack, Text } from "@mantine/core";
import { objectEntries, objectKeys } from "@homarr/common";
import { boardSizes } from "@homarr/old-schema";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
type BoardSize = (typeof boardSizes)[number];
export type BoardSizeRecord = Record<BoardSize, boolean | null>;
export type BoardSelectionMap = Map<string, BoardSizeRecord>;
export type BoardSelectionMap = Map<string, boolean>;
interface BoardSelectionCardProps {
selections: BoardSelectionMap;
@@ -16,12 +11,9 @@ interface BoardSelectionCardProps {
}
const allChecked = (map: BoardSelectionMap) => {
return [...map.values()].every((selection) => groupChecked(selection));
return [...map.values()].every((selection) => selection);
};
const groupChecked = (selection: BoardSizeRecord) =>
objectEntries(selection).every(([_, value]) => value === true || value === null);
export const BoardSelectionCard = ({ selections, updateSelections }: BoardSelectionCardProps) => {
const tBoardSelection = useScopedI18n("init.step.import.boardSelection");
const t = useI18n();
@@ -29,50 +21,14 @@ export const BoardSelectionCard = ({ selections, updateSelections }: BoardSelect
const handleToggleAll = () => {
updateSelections((selections) => {
const updated = new Map(selections);
[...selections.entries()].forEach(([name, selection]) => {
objectKeys(selection).forEach((size) => {
if (selection[size] === null) return;
selection[size] = !areAllChecked;
});
updated.set(name, selection);
});
return updated;
return new Map([...selections.keys()].map((name) => [name, !areAllChecked] as const));
});
};
const registerToggleGroup = (name: string) => (event: ChangeEvent<HTMLInputElement>) => {
const registerToggle = (name: string) => (event: ChangeEvent<HTMLInputElement>) => {
updateSelections((selections) => {
const updated = new Map(selections);
const selection = selections.get(name);
if (!selection) return updated;
objectKeys(selection).forEach((size) => {
if (selection[size] === null) return;
selection[size] = event.target.checked;
});
updated.set(name, selection);
return updated;
});
};
const registerToggle = (name: string, size: BoardSize) => (event: ChangeEvent<HTMLInputElement>) => {
updateSelections((selections) => {
const updated = new Map(selections);
const selection = selections.get(name);
if (!selection) return updated;
selection[size] = event.target.checked;
updated.set(name, selection);
updated.set(name, event.target.checked);
return updated;
});
};
@@ -100,53 +56,17 @@ export const BoardSelectionCard = ({ selections, updateSelections }: BoardSelect
</Stack>
<Stack gap="sm">
{[...selections.entries()].map(([name, selection]) => (
{[...selections.entries()].map(([name, selected]) => (
<Card key={name} withBorder>
<Group justify="space-between" align="center" visibleFrom="md">
<Checkbox
checked={groupChecked(selection)}
onChange={registerToggleGroup(name)}
label={
<Text fw={500} size="sm">
{name}
</Text>
}
/>
<Group>
{boardSizes.map((size) => (
<Checkbox
key={size}
disabled={selection[size] === null}
checked={selection[size] ?? undefined}
onChange={registerToggle(name, size)}
label={t(`board.action.oldImport.form.screenSize.option.${size}`)}
/>
))}
</Group>
</Group>
<Stack hiddenFrom="md">
<Checkbox
checked={groupChecked(selection)}
onChange={registerToggleGroup(name)}
label={
<Text fw={500} size="sm">
{name}
</Text>
}
/>
<Stack gap="sm" ps="sm">
{objectEntries(selection)
.filter(([_, value]) => value !== null)
.map(([size, value]) => (
<Checkbox
key={size}
checked={value ?? undefined}
onChange={registerToggle(name, size)}
label={`screenSize.${size}`}
/>
))}
</Stack>
</Stack>
<Checkbox
checked={selected}
onChange={registerToggle(name)}
label={
<Text fw={500} size="sm">
{name}
</Text>
}
/>
</Card>
))}
</Stack>

View File

@@ -1,160 +0,0 @@
import { createId, inArray } from "@homarr/db";
import type { Database, InferInsertModel, InferSelectModel } from "@homarr/db";
import { apps as appsTable } from "@homarr/db/schema";
import { logger } from "@homarr/log";
import type { OldmarrApp } from "@homarr/old-schema";
import type { BookmarkApp } from "./widgets/definitions/bookmark";
type DbAppWithoutId = Omit<InferSelectModel<typeof appsTable>, "id">;
interface AppMapping extends DbAppWithoutId {
ids: string[];
newId: string;
exists: boolean;
}
export const insertAppsAsync = async (
db: Database,
apps: OldmarrApp[],
bookmarkApps: BookmarkApp[],
distinctAppsByHref: boolean,
configName: string,
) => {
logger.info(
`Importing old homarr apps configuration=${configName} distinctAppsByHref=${distinctAppsByHref} apps=${apps.length}`,
);
const existingAppsWithHref = distinctAppsByHref
? await db.query.apps.findMany({
where: inArray(appsTable.href, [
...new Set(apps.map((app) => app.url).concat(bookmarkApps.map((app) => app.href))),
]),
})
: [];
logger.debug(`Found existing apps with href count=${existingAppsWithHref.length}`);
// Generate mappings for all apps from old to new ids
const appMappings: AppMapping[] = [];
addMappingFor(apps, appMappings, existingAppsWithHref, convertApp);
addMappingFor(bookmarkApps, appMappings, existingAppsWithHref, convertBookmarkApp);
logger.debug(`Mapping apps count=${appMappings.length}`);
const appsToCreate = appMappings
.filter((app) => !app.exists)
.map(
(app) =>
({
id: app.newId,
name: app.name,
iconUrl: app.iconUrl,
href: app.href,
description: app.description,
}) satisfies InferInsertModel<typeof appsTable>,
);
logger.debug(`Creating apps count=${appsToCreate.length}`);
if (appsToCreate.length > 0) {
await db.insert(appsTable).values(appsToCreate);
}
logger.info(`Imported apps count=${appsToCreate.length}`);
// Generates a map from old key to new key for all apps
return new Map(
appMappings
.map((app) => app.ids.map((id) => ({ id, newId: app.newId })))
.flat()
.map(({ id, newId }) => [id, newId]),
);
};
/**
* Creates a callback to be used in a find method that compares the old app with the new app
* @param app either an oldmarr app or a bookmark app
* @param convertApp a function that converts the app to a new app
* @returns a callback that compares the old app with the new app and returns true if they are the same
*/
const createFindCallback = <TApp extends OldmarrApp | BookmarkApp>(
app: TApp,
convertApp: (app: TApp) => DbAppWithoutId,
) => {
const oldApp = convertApp(app);
return (dbApp: DbAppWithoutId) =>
oldApp.href === dbApp.href &&
oldApp.name === dbApp.name &&
oldApp.iconUrl === dbApp.iconUrl &&
oldApp.description === dbApp.description;
};
/**
* Adds mappings for the given apps to the appMappings array
* @param apps apps to add mappings for
* @param appMappings existing app mappings
* @param existingAppsWithHref existing apps with href
* @param convertApp a function that converts the app to a new app
*/
const addMappingFor = <TApp extends OldmarrApp | BookmarkApp>(
apps: TApp[],
appMappings: AppMapping[],
existingAppsWithHref: InferSelectModel<typeof appsTable>[],
convertApp: (app: TApp) => DbAppWithoutId,
) => {
for (const app of apps) {
const previous = appMappings.find(createFindCallback(app, convertApp));
if (previous) {
previous.ids.push(app.id);
continue;
}
const existing = existingAppsWithHref.find(createFindCallback(app, convertApp));
if (existing) {
appMappings.push({
ids: [app.id],
newId: existing.id,
name: existing.name,
href: existing.href,
iconUrl: existing.iconUrl,
description: existing.description,
pingUrl: existing.pingUrl,
exists: true,
});
continue;
}
appMappings.push({
ids: [app.id],
newId: createId(),
...convertApp(app),
exists: false,
});
}
};
/**
* Converts an oldmarr app to a new app
* @param app oldmarr app
* @returns new app
*/
const convertApp = (app: OldmarrApp): DbAppWithoutId => ({
name: app.name,
href: app.behaviour.externalUrl === "" ? app.url : app.behaviour.externalUrl,
iconUrl: app.appearance.iconUrl,
description: app.behaviour.tooltipDescription ?? null,
pingUrl: app.url.length > 0 ? app.url : null,
});
/**
* Converts a bookmark app to a new app
* @param app bookmark app
* @returns new app
*/
const convertBookmarkApp = (app: BookmarkApp): DbAppWithoutId => ({
...app,
description: null,
pingUrl: null,
});

View File

@@ -1,35 +0,0 @@
import type { Database } from "@homarr/db";
import { createId } from "@homarr/db";
import { boards } from "@homarr/db/schema";
import { logger } from "@homarr/log";
import type { OldmarrConfig } from "@homarr/old-schema";
import { mapColor } from "./mappers/map-colors";
import { mapColumnCount } from "./mappers/map-column-count";
import type { OldmarrImportConfiguration } from "./settings";
export const insertBoardAsync = async (db: Database, old: OldmarrConfig, configuration: OldmarrImportConfiguration) => {
logger.info(`Importing old homarr board configuration=${old.configProperties.name}`);
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: configuration.name,
backgroundImageAttachment: old.settings.customization.backgroundImageAttachment,
backgroundImageUrl: old.settings.customization.backgroundImageUrl,
backgroundImageRepeat: old.settings.customization.backgroundImageRepeat,
backgroundImageSize: old.settings.customization.backgroundImageSize,
columnCount: mapColumnCount(old, configuration.screenSize),
faviconImageUrl: old.settings.customization.faviconUrl,
isPublic: old.settings.access.allowGuests,
logoImageUrl: old.settings.customization.logoImageUrl,
pageTitle: old.settings.customization.pageTitle,
metaTitle: old.settings.customization.metaTitle,
opacity: old.settings.customization.appOpacity,
primaryColor: mapColor(old.settings.customization.colors.primary, "#fa5252"),
secondaryColor: mapColor(old.settings.customization.colors.secondary, "#fd7e14"),
});
logger.info(`Imported board id=${boardId}`);
return boardId;
};

View File

@@ -1,6 +1,4 @@
import type { OldmarrConfig } from "@homarr/old-schema";
import type { OldmarrImportConfiguration } from "./settings";
import type { BoardSize, OldmarrConfig } from "@homarr/old-schema";
export class OldHomarrImportError extends Error {
constructor(oldConfig: OldmarrConfig, cause: unknown) {
@@ -11,7 +9,7 @@ export class OldHomarrImportError extends Error {
}
export class OldHomarrScreenSizeError extends Error {
constructor(type: "app" | "widget", id: string, screenSize: OldmarrImportConfiguration["screenSize"]) {
constructor(type: "app" | "widget", id: string, screenSize: BoardSize) {
super(`Screen size not found for type=${type} id=${id} screenSize=${screenSize}`);
}
}

View File

@@ -1,101 +0,0 @@
import SuperJSON from "superjson";
import type { Database } from "@homarr/db";
import { createId } from "@homarr/db";
import { items } from "@homarr/db/schema";
import { logger } from "@homarr/log";
import type { OldmarrApp, OldmarrWidget } from "@homarr/old-schema";
import type { WidgetComponentProps } from "../../widgets/src/definition";
import { OldHomarrScreenSizeError } from "./import-error";
import type { OldmarrImportConfiguration } from "./settings";
import { mapKind } from "./widgets/definitions";
import { mapOptions } from "./widgets/options";
export const insertItemsAsync = async (
db: Database,
widgets: OldmarrWidget[],
apps: OldmarrApp[],
appsMap: Map<string, string>,
sectionIdMaps: Map<string, string>,
configuration: OldmarrImportConfiguration,
) => {
logger.info(`Importing old homarr items widgets=${widgets.length} apps=${apps.length}`);
for (const widget of widgets) {
// All items should have been moved to the last wrapper
if (widget.area.type === "sidebar") {
continue;
}
const kind = mapKind(widget.type);
logger.debug(`Mapped widget kind id=${widget.id} previous=${widget.type} current=${kind}`);
if (!kind) {
logger.error(`Widget has no kind id=${widget.id} type=${widget.type}`);
continue;
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const sectionId = sectionIdMaps.get(widget.area.properties.id)!;
logger.debug(`Inserting widget id=${widget.id} sectionId=${sectionId}`);
const screenSizeShape = widget.shape[configuration.screenSize];
if (!screenSizeShape) {
throw new OldHomarrScreenSizeError("widget", widget.id, configuration.screenSize);
}
await db.insert(items).values({
id: createId(),
sectionId,
height: screenSizeShape.size.height,
width: screenSizeShape.size.width,
xOffset: screenSizeShape.location.x,
yOffset: screenSizeShape.location.y,
kind,
options: SuperJSON.stringify(mapOptions(widget.type, widget.properties, appsMap)),
});
logger.debug(`Inserted widget id=${widget.id} sectionId=${sectionId}`);
}
for (const app of apps) {
// All items should have been moved to the last wrapper
if (app.area.type === "sidebar") {
continue;
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const sectionId = sectionIdMaps.get(app.area.properties.id)!;
logger.debug(`Inserting app name=${app.name} sectionId=${sectionId}`);
const screenSizeShape = app.shape[configuration.screenSize];
if (!screenSizeShape) {
throw new OldHomarrScreenSizeError("app", app.id, configuration.screenSize);
}
await db.insert(items).values({
id: createId(),
sectionId,
height: screenSizeShape.size.height,
width: screenSizeShape.size.width,
xOffset: screenSizeShape.location.x,
yOffset: screenSizeShape.location.y,
kind: "app",
options: SuperJSON.stringify({
// it's safe to assume that the app exists in the map
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
appId: appsMap.get(app.id)!,
openInNewTab: app.behaviour.isOpeningNewTab,
pingEnabled: app.network.enabledStatusChecker,
showDescriptionTooltip: app.behaviour.tooltipDescription !== "",
showTitle: app.appearance.appNameStatus === "normal",
} satisfies WidgetComponentProps<"app">["options"]),
});
logger.debug(`Inserted app name=${app.name} sectionId=${sectionId}`);
}
};

View File

@@ -1,20 +1,31 @@
import { createId } from "@homarr/db";
import { createDbInsertCollectionForTransaction } from "@homarr/db/collection";
import { logger } from "@homarr/log";
import type { BoardSize } from "@homarr/old-schema";
import { boardSizes, getBoardSizeName } from "@homarr/old-schema";
import { fixSectionIssues } from "../../fix-section-issues";
import { mapBoard } from "../../mappers/map-board";
import { mapBreakpoint } from "../../mappers/map-breakpoint";
import { mapColumnCount } from "../../mappers/map-column-count";
import { moveWidgetsAndAppsIfMerge } from "../../move-widgets-and-apps-merge";
import { prepareItems } from "../../prepare/prepare-items";
import type { prepareMultipleImports } from "../../prepare/prepare-multiple";
import { prepareSections } from "../../prepare/prepare-sections";
import type { InitialOldmarrImportSettings } from "../../settings";
import { createDbInsertCollection } from "./common";
export const createBoardInsertCollection = (
{ preparedApps, preparedBoards }: Omit<ReturnType<typeof prepareMultipleImports>, "preparedIntegrations">,
settings: InitialOldmarrImportSettings,
) => {
const insertCollection = createDbInsertCollection(["apps", "boards", "sections", "items"]);
const insertCollection = createDbInsertCollectionForTransaction([
"apps",
"boards",
"layouts",
"sections",
"items",
"itemLayouts",
]);
logger.info("Preparing boards for insert collection");
const appsMap = new Map(
@@ -49,7 +60,6 @@ export const createBoardInsertCollection = (
const { wrappers, categories, wrapperIdsToMerge } = fixSectionIssues(board.config);
const { apps, widgets } = moveWidgetsAndAppsIfMerge(board.config, wrapperIdsToMerge, {
...settings,
screenSize: board.size,
name: board.name,
});
@@ -58,6 +68,25 @@ export const createBoardInsertCollection = (
const mappedBoard = mapBoard(board);
logger.debug(`Mapped board fileName=${board.name} boardId=${mappedBoard.id}`);
insertCollection.boards.push(mappedBoard);
const layoutMapping = boardSizes.reduce(
(acc, size) => {
acc[size] = createId();
return acc;
},
{} as Record<BoardSize, string>,
);
insertCollection.layouts.push(
...boardSizes.map((size) => ({
id: layoutMapping[size],
boardId: mappedBoard.id,
columnCount: mapColumnCount(board.config, size),
breakpoint: mapBreakpoint(size),
name: getBoardSizeName(size),
})),
);
const preparedSections = prepareSections(mappedBoard.id, { wrappers, categories });
for (const section of preparedSections.values()) {
@@ -65,8 +94,11 @@ export const createBoardInsertCollection = (
}
logger.debug(`Added sections to board insert collection count=${insertCollection.sections.length}`);
const preparedItems = prepareItems({ apps, widgets }, board.size, appsMap, preparedSections);
preparedItems.forEach((item) => insertCollection.items.push(item));
const preparedItems = prepareItems({ apps, widgets }, appsMap, preparedSections, layoutMapping, mappedBoard.id);
preparedItems.forEach(({ layouts, ...item }) => {
insertCollection.items.push(item);
insertCollection.itemLayouts.push(...layouts);
});
logger.debug(`Added items to board insert collection count=${insertCollection.items.length}`);
});

View File

@@ -1,15 +1,15 @@
import { encryptSecret } from "@homarr/common/server";
import { createDbInsertCollectionForTransaction } from "@homarr/db/collection";
import { logger } from "@homarr/log";
import { mapAndDecryptIntegrations } from "../../mappers/map-integration";
import type { PreparedIntegration } from "../../prepare/prepare-integrations";
import { createDbInsertCollection } from "./common";
export const createIntegrationInsertCollection = (
preparedIntegrations: PreparedIntegration[],
encryptionToken: string | null | undefined,
) => {
const insertCollection = createDbInsertCollection(["integrations", "integrationSecrets"]);
const insertCollection = createDbInsertCollectionForTransaction(["integrations", "integrationSecrets"]);
if (preparedIntegrations.length === 0) {
return insertCollection;

View File

@@ -1,16 +1,21 @@
import { createId } from "@homarr/db";
import { createDbInsertCollectionForTransaction } from "@homarr/db/collection";
import { credentialsAdminGroup } from "@homarr/definitions";
import { logger } from "@homarr/log";
import { mapAndDecryptUsers } from "../../mappers/map-user";
import type { OldmarrImportUser } from "../../user-schema";
import { createDbInsertCollection } from "./common";
export const createUserInsertCollection = (
importUsers: OldmarrImportUser[],
encryptionToken: string | null | undefined,
) => {
const insertCollection = createDbInsertCollection(["users", "groups", "groupMembers", "groupPermissions"]);
const insertCollection = createDbInsertCollectionForTransaction([
"users",
"groups",
"groupMembers",
"groupPermissions",
]);
if (importUsers.length === 0) {
return insertCollection;

View File

@@ -4,14 +4,7 @@ import { zfd } from "zod-form-data";
import { initialOldmarrImportSettings } from "../settings";
const boardSelectionMapSchema = z.map(
z.string(),
z.object({
sm: z.boolean().nullable(),
md: z.boolean().nullable(),
lg: z.boolean().nullable(),
}),
);
const boardSelectionMapSchema = z.map(z.string(), z.boolean());
export const importInitialOldmarrInputSchema = zfd.formData({
file: zfd.file(),

View File

@@ -4,7 +4,6 @@ import type { boards } from "@homarr/db/schema";
import type { prepareMultipleImports } from "../prepare/prepare-multiple";
import { mapColor } from "./map-colors";
import { mapColumnCount } from "./map-column-count";
type PreparedBoard = ReturnType<typeof prepareMultipleImports>["preparedBoards"][number];
@@ -15,7 +14,6 @@ export const mapBoard = (preparedBoard: PreparedBoard): InferInsertModel<typeof
backgroundImageUrl: preparedBoard.config.settings.customization.backgroundImageUrl,
backgroundImageRepeat: preparedBoard.config.settings.customization.backgroundImageRepeat,
backgroundImageSize: preparedBoard.config.settings.customization.backgroundImageSize,
columnCount: mapColumnCount(preparedBoard.config, preparedBoard.size),
faviconImageUrl: preparedBoard.config.settings.customization.faviconUrl,
isPublic: preparedBoard.config.settings.access.allowGuests,
logoImageUrl: preparedBoard.config.settings.customization.logoImageUrl,

View File

@@ -0,0 +1,18 @@
import type { BoardSize } from "@homarr/old-schema";
/**
* Copied from https://github.com/ajnart/homarr/blob/274eaa92084a8be4d04a69a87f9920860a229128/src/components/Dashboard/Wrappers/gridstack/store.tsx#L21-L30
* @param screenSize board size
* @returns layout breakpoint for the board
*/
export const mapBreakpoint = (screenSize: BoardSize) => {
switch (screenSize) {
case "lg":
return 1400;
case "md":
return 800;
case "sm":
default:
return 0;
}
};

View File

@@ -1,8 +1,6 @@
import type { OldmarrConfig } from "@homarr/old-schema";
import type { BoardSize, OldmarrConfig } from "@homarr/old-schema";
import type { OldmarrImportConfiguration } from "../settings";
export const mapColumnCount = (old: OldmarrConfig, screenSize: OldmarrImportConfiguration["screenSize"]) => {
export const mapColumnCount = (old: OldmarrConfig, screenSize: BoardSize) => {
switch (screenSize) {
case "lg":
return old.settings.customization.gridstack.columnCountLarge;

View File

@@ -2,9 +2,10 @@ import SuperJSON from "superjson";
import type { InferInsertModel } from "@homarr/db";
import { createId } from "@homarr/db";
import type { items } from "@homarr/db/schema";
import type { itemLayouts, items } from "@homarr/db/schema";
import { logger } from "@homarr/log";
import type { BoardSize, OldmarrApp, OldmarrWidget } from "@homarr/old-schema";
import { boardSizes } from "@homarr/old-schema";
import type { WidgetComponentProps } from "../../../widgets/src/definition";
import { mapKind } from "../widgets/definitions";
@@ -12,30 +13,23 @@ import { mapOptions } from "../widgets/options";
export const mapApp = (
app: OldmarrApp,
boardSize: BoardSize,
appsMap: Map<string, { id: string }>,
sectionMap: Map<string, { id: string }>,
): InferInsertModel<typeof items> | null => {
layoutMap: Record<BoardSize, string>,
boardId: string,
): (InferInsertModel<typeof items> & { layouts: InferInsertModel<typeof itemLayouts>[] }) | null => {
if (app.area.type === "sidebar") throw new Error("Mapping app in sidebar is not supported");
const shapeForSize = app.shape[boardSize];
if (!shapeForSize) {
throw new Error(`Failed to find a shape for appId='${app.id}' screenSize='${boardSize}'`);
}
const sectionId = sectionMap.get(app.area.properties.id)?.id;
if (!sectionId) {
logger.warn(`Failed to find section for app appId='${app.id}' sectionId='${app.area.properties.id}'. Removing app`);
return null;
}
const itemId = createId();
return {
id: createId(),
sectionId,
height: shapeForSize.size.height,
width: shapeForSize.size.width,
xOffset: shapeForSize.location.x,
yOffset: shapeForSize.location.y,
id: itemId,
boardId,
kind: "app",
options: SuperJSON.stringify({
// it's safe to assume that the app exists in the map
@@ -46,22 +40,34 @@ export const mapApp = (
showDescriptionTooltip: app.behaviour.tooltipDescription !== "",
showTitle: app.appearance.appNameStatus === "normal",
} satisfies WidgetComponentProps<"app">["options"]),
layouts: boardSizes.map((size) => {
const shapeForSize = app.shape[size];
if (!shapeForSize) {
throw new Error(`Failed to find a shape for appId='${app.id}' screenSize='${size}'`);
}
return {
itemId,
height: shapeForSize.size.height,
width: shapeForSize.size.width,
xOffset: shapeForSize.location.x,
yOffset: shapeForSize.location.y,
sectionId,
layoutId: layoutMap[size],
};
}),
};
};
export const mapWidget = (
widget: OldmarrWidget,
boardSize: BoardSize,
appsMap: Map<string, { id: string }>,
sectionMap: Map<string, { id: string }>,
): InferInsertModel<typeof items> | null => {
layoutMap: Record<BoardSize, string>,
boardId: string,
): (InferInsertModel<typeof items> & { layouts: InferInsertModel<typeof itemLayouts>[] }) | null => {
if (widget.area.type === "sidebar") throw new Error("Mapping widget in sidebar is not supported");
const shapeForSize = widget.shape[boardSize];
if (!shapeForSize) {
throw new Error(`Failed to find a shape for widgetId='${widget.id}' screenSize='${boardSize}'`);
}
const kind = mapKind(widget.type);
if (!kind) {
logger.warn(`Failed to map widget type='${widget.type}'. It's no longer supported`);
@@ -76,13 +82,10 @@ export const mapWidget = (
return null;
}
const itemId = createId();
return {
id: createId(),
sectionId,
height: shapeForSize.size.height,
width: shapeForSize.size.width,
xOffset: shapeForSize.location.x,
yOffset: shapeForSize.location.y,
id: itemId,
boardId,
kind,
options: SuperJSON.stringify(
mapOptions(
@@ -91,5 +94,21 @@ export const mapWidget = (
new Map([...appsMap.entries()].map(([key, value]) => [key, value.id])),
),
),
layouts: boardSizes.map((size) => {
const shapeForSize = widget.shape[size];
if (!shapeForSize) {
throw new Error(`Failed to find a shape for widgetId='${widget.id}' screenSize='${size}'`);
}
return {
itemId,
height: shapeForSize.size.height,
width: shapeForSize.size.width,
xOffset: shapeForSize.location.x,
yOffset: shapeForSize.location.y,
sectionId,
layoutId: layoutMap[size],
};
}),
};
};

View File

@@ -1,6 +1,7 @@
import { objectEntries } from "@homarr/common";
import { logger } from "@homarr/log";
import type { OldmarrApp, OldmarrConfig, OldmarrWidget } from "@homarr/old-schema";
import type { BoardSize, OldmarrApp, OldmarrConfig, OldmarrWidget } from "@homarr/old-schema";
import { boardSizes } from "@homarr/old-schema";
import { OldHomarrScreenSizeError } from "./import-error";
import { mapColumnCount } from "./mappers/map-column-count";
@@ -28,9 +29,21 @@ export const moveWidgetsAndAppsIfMerge = (
logger.debug(`Merging wrappers at the end of the board count=${wrapperIdsToMerge.length}`);
let offset = 0;
const offsets = boardSizes.reduce(
(previous, screenSize) => {
previous[screenSize] = 0;
return previous;
},
{} as Record<BoardSize, number>,
);
for (const id of wrapperIdsToMerge) {
let requiredHeight = 0;
const requiredHeights = boardSizes.reduce(
(previous, screenSize) => {
previous[screenSize] = 0;
return previous;
},
{} as Record<BoardSize, number>,
);
const affected = affectedMap.get(id);
if (!affected) {
continue;
@@ -44,18 +57,20 @@ export const moveWidgetsAndAppsIfMerge = (
// Move item to first wrapper
app.area.properties.id = firstId;
const screenSizeShape = app.shape[configuration.screenSize];
if (!screenSizeShape) {
throw new OldHomarrScreenSizeError("app", app.id, configuration.screenSize);
}
for (const screenSize of boardSizes) {
const screenSizeShape = app.shape[screenSize];
if (!screenSizeShape) {
throw new OldHomarrScreenSizeError("app", app.id, screenSize);
}
// Find the highest widget in the wrapper to increase the offset accordingly
if (screenSizeShape.location.y + screenSizeShape.size.height > requiredHeight) {
requiredHeight = screenSizeShape.location.y + screenSizeShape.size.height;
}
// Find the highest widget in the wrapper to increase the offset accordingly
if (screenSizeShape.location.y + screenSizeShape.size.height > requiredHeights[screenSize]) {
requiredHeights[screenSize] = screenSizeShape.location.y + screenSizeShape.size.height;
}
// Move item down as much as needed to not overlap with other items
screenSizeShape.location.y += offset;
// Move item down as much as needed to not overlap with other items
screenSizeShape.location.y += offsets[screenSize];
}
}
for (const widget of widgets) {
@@ -63,21 +78,25 @@ export const moveWidgetsAndAppsIfMerge = (
// Move item to first wrapper
widget.area.properties.id = firstId;
const screenSizeShape = widget.shape[configuration.screenSize];
if (!screenSizeShape) {
throw new OldHomarrScreenSizeError("widget", widget.id, configuration.screenSize);
}
for (const screenSize of boardSizes) {
const screenSizeShape = widget.shape[screenSize];
if (!screenSizeShape) {
throw new OldHomarrScreenSizeError("widget", widget.id, screenSize);
}
// Find the highest widget in the wrapper to increase the offset accordingly
if (screenSizeShape.location.y + screenSizeShape.size.height > requiredHeight) {
requiredHeight = screenSizeShape.location.y + screenSizeShape.size.height;
}
// Find the highest widget in the wrapper to increase the offset accordingly
if (screenSizeShape.location.y + screenSizeShape.size.height > requiredHeights[screenSize]) {
requiredHeights[screenSize] = screenSizeShape.location.y + screenSizeShape.size.height;
}
// Move item down as much as needed to not overlap with other items
screenSizeShape.location.y += offset;
// Move item down as much as needed to not overlap with other items
screenSizeShape.location.y += offsets[screenSize];
}
}
offset += requiredHeight;
for (const screenSize of boardSizes) {
offsets[screenSize] += requiredHeights[screenSize];
}
}
if (configuration.sidebarBehaviour === "last-section") {
@@ -86,14 +105,18 @@ export const moveWidgetsAndAppsIfMerge = (
old.settings.customization.layout.enabledLeftSidebar ||
areas.some((area) => area.type === "sidebar" && area.properties.location === "left")
) {
offset = moveWidgetsAndAppsInLeftSidebar(old, firstId, offset, configuration.screenSize);
for (const screenSize of boardSizes) {
offsets[screenSize] = moveWidgetsAndAppsInLeftSidebar(old, firstId, offsets[screenSize], screenSize);
}
}
if (
old.settings.customization.layout.enabledRightSidebar ||
areas.some((area) => area.type === "sidebar" && area.properties.location === "right")
) {
moveWidgetsAndAppsInRightSidebar(old, firstId, offset, configuration.screenSize);
for (const screenSize of boardSizes) {
moveWidgetsAndAppsInRightSidebar(old, firstId, offsets[screenSize], screenSize);
}
}
} else {
// Remove all widgets and apps in the sidebar
@@ -110,7 +133,7 @@ const moveWidgetsAndAppsInLeftSidebar = (
old: OldmarrConfig,
firstId: string,
offset: number,
screenSize: OldmarrImportConfiguration["screenSize"],
screenSize: BoardSize,
) => {
const columnCount = mapColumnCount(old, screenSize);
let requiredHeight = updateItems({
@@ -186,7 +209,7 @@ const moveWidgetsAndAppsInRightSidebar = (
old: OldmarrConfig,
firstId: string,
offset: number,
screenSize: OldmarrImportConfiguration["screenSize"],
screenSize: BoardSize,
) => {
const columnCount = mapColumnCount(old, screenSize);
const xOffsetDelta = Math.max(columnCount - 2, 0);
@@ -255,10 +278,7 @@ const moveWidgetsAndAppsInRightSidebar = (
});
};
const createItemSnapshot = (
item: OldmarrApp | OldmarrWidget,
screenSize: OldmarrImportConfiguration["screenSize"],
) => ({
const createItemSnapshot = (item: OldmarrApp | OldmarrWidget, screenSize: BoardSize) => ({
x: item.shape[screenSize]?.location.x,
y: item.shape[screenSize]?.location.y,
height: item.shape[screenSize]?.size.height,
@@ -285,7 +305,7 @@ const updateItems = (options: {
items: (OldmarrApp | OldmarrWidget)[];
filter: (item: OldmarrApp | OldmarrWidget) => boolean;
update: (item: OldmarrApp | OldmarrWidget) => void;
screenSize: OldmarrImportConfiguration["screenSize"];
screenSize: BoardSize;
}) => {
const items = options.items.filter(options.filter);
let requiredHeight = 0;

View File

@@ -1,34 +1,6 @@
import { objectEntries } from "@homarr/common";
import type { BoardSize } from "@homarr/old-schema";
import type { ValidAnalyseConfig } from "../analyse/types";
import type { BoardSelectionMap } from "../components/initial/board-selection-card";
const boardSizeSuffix: Record<BoardSize, string> = {
lg: "large",
md: "medium",
sm: "small",
};
export const createBoardName = (fileName: string, boardSize: BoardSize) => {
return `${fileName.replace(".json", "")}-${boardSizeSuffix[boardSize]}`;
};
export const prepareBoards = (analyseConfigs: ValidAnalyseConfig[], selections: BoardSelectionMap) => {
return analyseConfigs.flatMap(({ name, config }) => {
const selectedSizes = selections.get(name);
if (!selectedSizes) return [];
return objectEntries(selectedSizes)
.map(([size, selected]) => {
if (!selected) return null;
return {
name: createBoardName(name, size),
size,
config,
};
})
.filter((board) => board !== null);
});
return analyseConfigs.filter(({ name }) => selections.get(name));
};

View File

@@ -4,11 +4,12 @@ import { mapApp, mapWidget } from "../mappers/map-item";
export const prepareItems = (
{ apps, widgets }: Pick<OldmarrConfig, "apps" | "widgets">,
boardSize: BoardSize,
appsMap: Map<string, { id: string }>,
sectionMap: Map<string, { id: string }>,
layoutMap: Record<BoardSize, string>,
boardId: string,
) =>
widgets
.map((widget) => mapWidget(widget, boardSize, appsMap, sectionMap))
.concat(apps.map((app) => mapApp(app, boardSize, appsMap, sectionMap)))
.map((widget) => mapWidget(widget, appsMap, sectionMap, layoutMap, boardId))
.concat(apps.map((app) => mapApp(app, appsMap, sectionMap, layoutMap, boardId)))
.filter((widget) => widget !== null);

View File

@@ -8,14 +8,6 @@ export const prepareSingleImport = (config: OldmarrConfig, settings: OldmarrImpo
return {
preparedApps: prepareApps(validAnalyseConfigs),
preparedBoards: settings.onlyImportApps
? []
: [
{
name: settings.name,
size: settings.screenSize,
config,
},
],
preparedBoards: settings.onlyImportApps ? [] : validAnalyseConfigs,
};
};

View File

@@ -1,8 +1,7 @@
import { z } from "zod";
import { zfd } from "zod-form-data";
import { boardSizes } from "@homarr/old-schema";
import { validation, zodEnumFromArray } from "@homarr/validation";
import { validation } from "@homarr/validation";
import { createCustomErrorParams } from "@homarr/validation/form";
export const sidebarBehaviours = ["remove-items", "last-section"] as const;
@@ -12,7 +11,6 @@ export type SidebarBehaviour = (typeof sidebarBehaviours)[number];
export const oldmarrImportConfigurationSchema = z.object({
name: validation.board.name,
onlyImportApps: z.boolean().default(false),
screenSize: zodEnumFromArray(boardSizes).default("lg"),
sidebarBehaviour: z.enum(sidebarBehaviours).default(defaultSidebarBehaviour),
});

View File

@@ -3,5 +3,5 @@ export { oldmarrConfigSchema } from "./config";
export type { OldmarrApp, OldmarrIntegrationType } from "./app";
export type { OldmarrWidget, OldmarrWidgetKind } from "./widget";
export { oldmarrWidgetKinds } from "./widget";
export { boardSizes } from "./tile";
export { boardSizes, getBoardSizeName } from "./tile";
export type { BoardSize } from "./tile";

View File

@@ -58,3 +58,15 @@ export const tileBaseSchema = z.object({
export const boardSizes = objectKeys(shapeSchema._def.shape());
export type BoardSize = (typeof boardSizes)[number];
export const getBoardSizeName = (size: BoardSize) => {
switch (size) {
case "md":
return "medium";
case "sm":
return "small";
case "lg":
default:
return "large";
}
};

View File

@@ -2142,7 +2142,13 @@
"unrecognizedLink": "The provided link is not recognized and won't preview, it might still work."
},
"layout": {
"title": "Layout"
"title": "Layout",
"responsive": {
"title": "Responsive layouts",
"action": {
"add": "Add layout"
}
}
},
"background": {
"title": "Background"
@@ -2250,6 +2256,20 @@
}
}
},
"layout": {
"field": {
"name": {
"label": "Name"
},
"columnCount": {
"label": "Column count"
},
"breakpoint": {
"label": "Breakpoint",
"description": "Layout will be used on all screens larger than this breakpoint until the next bigger breakpoint."
}
}
},
"management": {
"metaTitle": "Management",
"title": {

View File

@@ -9,7 +9,7 @@ import {
import { zodEnumFromArray } from "./enums";
import { createSavePermissionsSchema } from "./permissions";
import { commonItemSchema, createSectionSchema } from "./shared";
import { commonItemSchema, sectionSchema } from "./shared";
const hexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/);
@@ -62,16 +62,28 @@ const savePartialSettingsSchema = z
secondaryColor: hexColorSchema,
opacity: z.number().min(0).max(100),
customCss: z.string().max(16384),
columnCount: z.number().min(1).max(24),
iconColor: hexColorNullableSchema,
itemRadius: z.union([z.literal("xs"), z.literal("sm"), z.literal("md"), z.literal("lg"), z.literal("xl")]),
disableStatus: z.boolean(),
})
.partial();
const saveLayoutsSchema = z.object({
id: z.string(),
layouts: z.array(
z.object({
id: z.string(),
name: z.string().trim().nonempty().max(32),
columnCount: z.number().min(1).max(24),
breakpoint: z.number().min(0).max(32767),
}),
),
});
const saveSchema = z.object({
id: z.string(),
sections: z.array(createSectionSchema(commonItemSchema)),
sections: z.array(sectionSchema),
items: z.array(commonItemSchema),
});
const createSchema = z.object({ name: boardNameSchema, columnCount: z.number().min(1).max(24), isPublic: z.boolean() });
@@ -96,6 +108,7 @@ export const boardSchemas = {
name: boardNameSchema,
byName: byNameSchema,
savePartialSettings: savePartialSettingsSchema,
saveLayouts: saveLayoutsSchema,
save: saveSchema,
create: createSchema,
duplicate: duplicateSchema,

View File

@@ -29,7 +29,7 @@ export const validation = {
};
export {
createSectionSchema,
sectionSchema,
itemAdvancedOptionsSchema,
sharedItemSchema,
type BoardItemAdvancedOptions,

View File

@@ -21,10 +21,16 @@ export type BoardItemAdvancedOptions = z.infer<typeof itemAdvancedOptionsSchema>
export const sharedItemSchema = z.object({
id: z.string(),
xOffset: z.number(),
yOffset: z.number(),
height: z.number(),
width: z.number(),
layouts: z.array(
z.object({
layoutId: z.string(),
yOffset: z.number(),
xOffset: z.number(),
width: z.number(),
height: z.number(),
sectionId: z.string(),
}),
),
integrationIds: z.array(z.string()),
advancedOptions: itemAdvancedOptionsSchema,
});
@@ -36,37 +42,35 @@ export const commonItemSchema = z
})
.and(sharedItemSchema);
const createCategorySchema = <TItemSchema extends z.ZodTypeAny>(itemSchema: TItemSchema) =>
z.object({
id: z.string(),
name: z.string(),
kind: z.literal("category"),
yOffset: z.number(),
xOffset: z.number(),
items: z.array(itemSchema),
collapsed: z.boolean(),
});
const categorySectionSchema = z.object({
id: z.string(),
name: z.string(),
kind: z.literal("category"),
yOffset: z.number(),
xOffset: z.number(),
collapsed: z.boolean(),
});
const createEmptySchema = <TItemSchema extends z.ZodTypeAny>(itemSchema: TItemSchema) =>
z.object({
id: z.string(),
kind: z.literal("empty"),
yOffset: z.number(),
xOffset: z.number(),
items: z.array(itemSchema),
});
const emptySectionSchema = z.object({
id: z.string(),
kind: z.literal("empty"),
yOffset: z.number(),
xOffset: z.number(),
});
const createDynamicSchema = <TItemSchema extends z.ZodTypeAny>(itemSchema: TItemSchema) =>
z.object({
id: z.string(),
kind: z.literal("dynamic"),
yOffset: z.number(),
xOffset: z.number(),
width: z.number(),
height: z.number(),
items: z.array(itemSchema),
parentSectionId: z.string(),
});
const dynamicSectionSchema = z.object({
id: z.string(),
kind: z.literal("dynamic"),
layouts: z.array(
z.object({
layoutId: z.string(),
yOffset: z.number(),
xOffset: z.number(),
width: z.number(),
height: z.number(),
parentSectionId: z.string(),
}),
),
});
export const createSectionSchema = <TItemSchema extends z.ZodTypeAny>(itemSchema: TItemSchema) =>
z.union([createCategorySchema(itemSchema), createEmptySchema(itemSchema), createDynamicSchema(itemSchema)]);
export const sectionSchema = z.union([categorySectionSchema, emptySectionSchema, dynamicSectionSchema]);