fix: address board grid CI and review
This commit is contained in:
4 files changed
+128
-40
No files matched your search
@@ -6,7 +6,7 @@ import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { getRootSectionLane } from "@homarr/definitions";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { createModal, ModalFormFooter, modalSizeForm, useModalAction } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { Board, ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { getLayoutRowCount } from "../layout";
|
||||
@@ -53,7 +53,9 @@ type MoveTargetCandidate = Omit<MoveTarget, "label"> & {
|
||||
};
|
||||
|
||||
export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const tLandmark = useScopedI18n("board.landmark");
|
||||
const tContainer = useScopedI18n("section.container");
|
||||
const tMoveResize = useScopedI18n("item.moveResize");
|
||||
const { board, commitSectionGrids, currentLayoutId, entry, sourceSectionId } = innerProps;
|
||||
const minimumSize = useMemo(
|
||||
() => getEntryMinimumSize(board, currentLayoutId, entry),
|
||||
@@ -62,19 +64,19 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
|
||||
const targets = useMemo(
|
||||
() =>
|
||||
getMoveTargets(board, currentLayoutId, entry, {
|
||||
canvas: t("board.landmark.canvas"),
|
||||
container: t("section.container.action.create"),
|
||||
leftRail: t("board.landmark.leftRail"),
|
||||
rightRail: t("board.landmark.rightRail"),
|
||||
numbered: (name, index) => t("item.moveResize.target.numbered", { name, index: String(index) }),
|
||||
canvas: tLandmark("canvas"),
|
||||
container: tContainer("action.create"),
|
||||
leftRail: tLandmark("leftRail"),
|
||||
rightRail: tLandmark("rightRail"),
|
||||
numbered: (name, index) => tMoveResize("target.numbered", { name, index: String(index) }),
|
||||
located: (name, location, index) =>
|
||||
t("item.moveResize.target.located", {
|
||||
tMoveResize("target.located", {
|
||||
name,
|
||||
location,
|
||||
index: String(index),
|
||||
}),
|
||||
}),
|
||||
[board, currentLayoutId, entry, t],
|
||||
[board, currentLayoutId, entry, tContainer, tLandmark, tMoveResize],
|
||||
);
|
||||
const targetById = useMemo(() => new Map(targets.map((target) => [target.id, target])), [targets]);
|
||||
const initialTarget = targetById.get(sourceSectionId) ?? targets[0];
|
||||
@@ -127,7 +129,7 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
|
||||
target.columnCount,
|
||||
);
|
||||
if (target.maxRowCount !== null && getLayoutRowCount(resolvedTarget) > target.maxRowCount) {
|
||||
form.setFieldError("height", t("item.moveResize.keyboard.boundary"));
|
||||
form.setFieldError("height", tMoveResize("keyboard.boundary"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -149,7 +151,18 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
|
||||
actions.closeModal();
|
||||
focusMovedEntry(entry.id, target.id);
|
||||
},
|
||||
[actions, board, commitSectionGrids, currentLayoutId, entry, form, minimumSize, sourceSectionId, t, targetById],
|
||||
[
|
||||
actions,
|
||||
board,
|
||||
commitSectionGrids,
|
||||
currentLayoutId,
|
||||
entry,
|
||||
form,
|
||||
minimumSize,
|
||||
sourceSectionId,
|
||||
targetById,
|
||||
tMoveResize,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -157,7 +170,7 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
|
||||
<Stack>
|
||||
<Select
|
||||
data={targets.map((target) => ({ value: target.id, label: target.label }))}
|
||||
label={t("item.moveResize.field.section.label")}
|
||||
label={tMoveResize("field.section.label")}
|
||||
data-autofocus
|
||||
allowDeselect={false}
|
||||
searchable
|
||||
@@ -166,7 +179,7 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<NumberInput
|
||||
label={t("item.moveResize.field.xOffset.label")}
|
||||
label={tMoveResize("field.xOffset.label")}
|
||||
min={0}
|
||||
max={selectedTarget.columnCount - 1}
|
||||
{...form.getInputProps("xOffset")}
|
||||
@@ -175,7 +188,7 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<NumberInput
|
||||
label={t("item.moveResize.field.yOffset.label")}
|
||||
label={tMoveResize("field.yOffset.label")}
|
||||
min={0}
|
||||
max={selectedTarget.maxRowCount === null ? undefined : selectedTarget.maxRowCount - 1}
|
||||
{...form.getInputProps("yOffset")}
|
||||
@@ -184,7 +197,7 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<NumberInput
|
||||
label={t("item.moveResize.field.width.label")}
|
||||
label={tMoveResize("field.width.label")}
|
||||
min={minimumSize.width}
|
||||
max={selectedTarget.columnCount - Math.min(form.values.xOffset, selectedTarget.columnCount - 1)}
|
||||
{...form.getInputProps("width")}
|
||||
@@ -193,7 +206,7 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<NumberInput
|
||||
label={t("item.moveResize.field.height.label")}
|
||||
label={tMoveResize("field.height.label")}
|
||||
min={minimumSize.height}
|
||||
max={
|
||||
selectedTarget.maxRowCount === null
|
||||
|
||||
@@ -691,25 +691,12 @@ export const boardRouter = createTRPCRouter({
|
||||
saveLayouts: protectedProcedure.input(boardSaveLayoutsSchema).mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
|
||||
|
||||
let board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
|
||||
const missingGutterRoots = (["left", "right"] as const).filter(
|
||||
(lane) =>
|
||||
input.layouts.some((layout) => getBoardLaneColumnCount(layout, lane) > 0) &&
|
||||
!getRootSectionForLane(board, lane),
|
||||
const requiredGutterRoots = (["left", "right"] as const).filter((lane) =>
|
||||
input.layouts.some((layout) => getBoardLaneColumnCount(layout, lane) > 0),
|
||||
);
|
||||
if (missingGutterRoots.length > 0) {
|
||||
await ctx.db.insert(sections).values(
|
||||
missingGutterRoots.map((lane) => ({
|
||||
id: createId(),
|
||||
boardId: board.id,
|
||||
kind: "empty" as const,
|
||||
xOffset: rootSectionOffsets[lane],
|
||||
yOffset: 0,
|
||||
options: emptySuperJSON,
|
||||
})),
|
||||
);
|
||||
board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
|
||||
}
|
||||
await ensureGutterRootSectionsAsync(ctx.db, input.id, requiredGutterRoots);
|
||||
|
||||
const board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
|
||||
|
||||
const addedLayouts = filterAddedItems(input.layouts, board.layouts);
|
||||
|
||||
@@ -1784,12 +1771,77 @@ interface BoardLayoutGeometry {
|
||||
rightGutterColumnCount: number;
|
||||
}
|
||||
|
||||
const getRootSectionForLane = (board: Awaited<ReturnType<typeof getFullBoardWithWhereAsync>>, lane: BoardLane) => {
|
||||
const roots = board.sections.filter(
|
||||
(section) => section.kind === "empty" && getRootSectionLane(section.xOffset) === lane,
|
||||
);
|
||||
if (roots.length > 1) throw new Error(`Board "${board.id}" has multiple ${lane} canvas roots`);
|
||||
return roots[0];
|
||||
type GutterLane = Exclude<BoardLane, "main">;
|
||||
|
||||
const ensureGutterRootSectionsAsync = async (db: Database, boardId: string, lanes: readonly GutterLane[]) => {
|
||||
if (lanes.length === 0) return;
|
||||
|
||||
const getMissingLanes = (existingOffsets: readonly (number | null)[]) =>
|
||||
lanes.filter((lane) => {
|
||||
const rootCount = existingOffsets.filter((offset) => offset === rootSectionOffsets[lane]).length;
|
||||
if (rootCount > 1) throw new Error(`Board "${boardId}" has multiple ${lane} canvas roots`);
|
||||
return rootCount === 0;
|
||||
});
|
||||
const getRows = (missingLanes: readonly GutterLane[]) =>
|
||||
missingLanes.map((lane) => ({
|
||||
id: createId(),
|
||||
boardId,
|
||||
kind: "empty" as const,
|
||||
xOffset: rootSectionOffsets[lane],
|
||||
yOffset: 0,
|
||||
options: emptySuperJSON,
|
||||
}));
|
||||
|
||||
await handleTransactionsAsync(db, {
|
||||
async handleAsync(database, schema) {
|
||||
await database.transaction(async (transaction) => {
|
||||
await transaction
|
||||
.select({ id: schema.boards.id })
|
||||
.from(schema.boards)
|
||||
.where(eq(schema.boards.id, boardId))
|
||||
.for("update");
|
||||
|
||||
const existingRoots = await transaction
|
||||
.select({ xOffset: schema.sections.xOffset })
|
||||
.from(schema.sections)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.sections.boardId, boardId),
|
||||
eq(schema.sections.kind, "empty"),
|
||||
inArray(
|
||||
schema.sections.xOffset,
|
||||
lanes.map((lane) => rootSectionOffsets[lane]),
|
||||
),
|
||||
),
|
||||
);
|
||||
const rows = getRows(getMissingLanes(existingRoots.map(({ xOffset }) => xOffset)));
|
||||
if (rows.length > 0) await transaction.insert(schema.sections).values(rows);
|
||||
});
|
||||
},
|
||||
handleSync(database) {
|
||||
database.transaction(
|
||||
(transaction) => {
|
||||
const existingRoots = transaction
|
||||
.select({ xOffset: sections.xOffset })
|
||||
.from(sections)
|
||||
.where(
|
||||
and(
|
||||
eq(sections.boardId, boardId),
|
||||
eq(sections.kind, "empty"),
|
||||
inArray(
|
||||
sections.xOffset,
|
||||
lanes.map((lane) => rootSectionOffsets[lane]),
|
||||
),
|
||||
),
|
||||
)
|
||||
.all();
|
||||
const rows = getRows(getMissingLanes(existingRoots.map(({ xOffset }) => xOffset)));
|
||||
if (rows.length > 0) transaction.insert(sections).values(rows).run();
|
||||
},
|
||||
{ behavior: "immediate" },
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const getElementsForLayout = (board: Awaited<ReturnType<typeof getFullBoardWithWhereAsync>>, layoutId: string) => {
|
||||
|
||||
@@ -1694,6 +1694,27 @@ describe("saveLayouts should save layout changes", () => {
|
||||
);
|
||||
expect(returnedItem.sectionId).toBe(sectionId);
|
||||
});
|
||||
test("should create one gutter root when layouts are saved concurrently", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const { boardId, layoutId } = await createFullBoardAsync(db, "concurrent-gutters");
|
||||
const input = {
|
||||
id: boardId,
|
||||
layouts: [
|
||||
{
|
||||
...createExistingLayout(layoutId),
|
||||
leftGutterColumnCount: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await Promise.all([caller.saveLayouts(input), caller.saveLayouts(input)]);
|
||||
|
||||
const leftRoots = await db.query.sections.findMany({
|
||||
where: and(eq(sections.boardId, boardId), eq(sections.kind, "empty"), eq(sections.xOffset, -1)),
|
||||
});
|
||||
expect(leftRoots).toHaveLength(1);
|
||||
});
|
||||
test("should remove layout when not present in input", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
|
||||
@@ -148,6 +148,8 @@ const optionMapping: OptionMapping = {
|
||||
},
|
||||
mediaServer: {
|
||||
showOnlyPlaying: () => undefined,
|
||||
showBitrate: () => undefined,
|
||||
showLocation: () => undefined,
|
||||
},
|
||||
indexerManager: {
|
||||
openIndexerSiteInNewTab: (oldOptions) => oldOptions.openIndexerSiteInNewTab,
|
||||
|
||||
Reference in new issue
Block a user