Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

feat(boards): add responsive layout system #2271

Merged
merged 30 commits into from
Feb 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
a40d509
wip: add layouts
Meierschlumpf Jan 31, 2025
75abb36
wip: add board layouts
Meierschlumpf Feb 1, 2025
10a7334
wip: continue on responsive board layout
Meierschlumpf Feb 7, 2025
d8aea27
fix: temporary remove migration
Meierschlumpf Feb 7, 2025
c8b83e1
Merge branch 'dev' into add-layouts
Meierschlumpf Feb 7, 2025
12c3917
fix: merge issues
Meierschlumpf Feb 7, 2025
f77f831
wip: move dynamic and category actions to layout system
Meierschlumpf Feb 8, 2025
4d3ae96
Merge branch 'dev' into add-layouts
Meierschlumpf Feb 8, 2025
c2db9bc
wip: add migrations, add automatic grid-algorithm for creation and up…
Meierschlumpf Feb 8, 2025
f31f542
docs: add comment for graphical representation of grid-algorithm
Meierschlumpf Feb 8, 2025
d734d2c
fix: order to import wrong
Meierschlumpf Feb 16, 2025
08f7f65
fix: hydration issue for different layouts
Meierschlumpf Feb 18, 2025
e1df2f5
fix: no layout created during board creation
Meierschlumpf Feb 22, 2025
516daae
fix: duplicate item still using board column-count
Meierschlumpf Feb 22, 2025
4441642
feat: add layout remove button to board-settings
Meierschlumpf Feb 22, 2025
3a7ac90
fix: typecheck issue
Meierschlumpf Feb 22, 2025
2e38523
test: adjust board router tests
Meierschlumpf Feb 22, 2025
17c4f26
test: add save-layout unit-test for board-router
Meierschlumpf Feb 22, 2025
4193c22
refactor: rename layouts for items and sections
Meierschlumpf Feb 22, 2025
76686fe
chore: remove migrations temporary
Meierschlumpf Feb 22, 2025
46e6f30
chore: remove mysql migrations temporary
Meierschlumpf Feb 22, 2025
3308dbe
Merge branch 'dev' into add-layouts
Meierschlumpf Feb 22, 2025
78ba66e
feat: add migrations for added layout
Meierschlumpf Feb 22, 2025
08d1d2a
fix: make item board_id temporarily nullable
Meierschlumpf Feb 22, 2025
4b85cbd
fix: resizing should reinitialize gridstack for layout
Meierschlumpf Feb 22, 2025
ccf4275
fix: import boards without json file extension in name
Meierschlumpf Feb 22, 2025
de703ad
fix: format issues
Meierschlumpf Feb 22, 2025
c5904f5
fix: typecheck issues
Meierschlumpf Feb 22, 2025
55df370
fix: deepsource issue
Meierschlumpf Feb 22, 2025
153ee75
chore: address pull request feedback
Meierschlumpf Feb 23, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Box, LoadingOverlay, Stack } from "@mantine/core";

import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";

import { BoardCategorySection } from "~/components/board/sections/category-section";
import { BoardEmptySection } from "~/components/board/sections/empty-section";
Expand Down Expand Up @@ -43,6 +43,7 @@ export const useUpdateBoard = () => {

export const ClientBoard = () => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const isReady = useIsBoardReady();

const fullWidthSortedSections = board.sections
Expand All @@ -63,9 +64,10 @@ export const ClientBoard = () => {
<Stack ref={ref} h="100%" style={{ visibility: isReady ? "visible" : "hidden" }}>
{fullWidthSortedSections.map((section) =>
section.kind === "empty" ? (
<BoardEmptySection key={section.id} section={section} />
// Unique keys per layout to always reinitialize the gridstack
<BoardEmptySection key={`${currentLayoutId}-${section.id}`} section={section} />
) : (
<BoardCategorySection key={section.id} section={section} />
<BoardCategorySection key={`${currentLayoutId}-${section.id}`} section={section} />
),
)}
</Stack>
Expand Down
4 changes: 2 additions & 2 deletions apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { getI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata";
import { createBoardLayout } from "../_layout-creator";
import type { Board } from "../_types";
import { ClientBoard } from "./_client";
import { DynamicClientBoard } from "./_dynamic-client";
import { BoardContentHeaderActions } from "./_header-actions";

export type Params = Record<string, unknown>;
Expand All @@ -37,7 +37,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(

return (
<IntegrationProvider integrations={integrations}>
<ClientBoard />
<DynamicClientBoard />
</IntegrationProvider>
);
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"use client";

import dynamic from "next/dynamic";

export const DynamicClientBoard = dynamic(() => import("./_client").then((mod) => mod.ClientBoard), {
ssr: false,
});
92 changes: 79 additions & 13 deletions apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,109 @@
"use client";

import { Button, Grid, Group, Input, Slider, Stack } from "@mantine/core";
import { Button, Fieldset, Grid, Group, Input, NumberInput, Slider, Stack, Text, TextInput } from "@mantine/core";

import { clientApi } from "@homarr/api/client";
import { createId } from "@homarr/db/client";
import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";

import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared";

interface Props {
board: Board;
}
export const LayoutSettingsContent = ({ board }: Props) => {
const t = useI18n();
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
const form = useZodForm(validation.board.savePartialSettings.pick({ columnCount: true }).required(), {
const utils = clientApi.useUtils();
const { mutate: saveLayouts, isPending } = clientApi.board.saveLayouts.useMutation({
onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getHomeBoard.invalidate();
},
});
const form = useZodForm(validation.board.saveLayouts.omit({ id: true }).required(), {
initialValues: {
columnCount: board.columnCount,
layouts: board.layouts,
},
});

return (
<form
onSubmit={form.onSubmit((values) => {
savePartialSettings({
saveLayouts({
id: board.id,
...values,
});
})}
>
<Stack>
<Grid>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Input.Wrapper label={t("board.field.columnCount.label")}>
<Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps("columnCount")} />
</Input.Wrapper>
</Grid.Col>
</Grid>
<Stack gap="sm">
<Group justify="space-between" align="center">
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
<Button
variant="subtle"
onClick={() => {
form.setValues({
layouts: [
...form.values.layouts,
{
id: createId(),
name: "",
columnCount: 10,
breakpoint: 0,
},
],
});
}}
>
{t("board.setting.section.layout.responsive.action.add")}
</Button>
</Group>

{form.values.layouts.map((layout, index) => (
<Fieldset key={layout.id} legend={layout.name} bg="transparent">
<Grid>
<Grid.Col span={{ sm: 12, md: 6 }}>
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={t("layout.field.name.label")} />
</Grid.Col>

<Grid.Col span={{ sm: 12, md: 6 }}>
<Input.Wrapper label={t("layout.field.columnCount.label")}>
<Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps(`layouts.${index}.columnCount`)} />
</Input.Wrapper>
</Grid.Col>

<Grid.Col span={{ sm: 12, md: 6 }}>
<NumberInput
{...form.getInputProps(`layouts.${index}.breakpoint`)}
label={t("layout.field.breakpoint.label")}
description={t("layout.field.breakpoint.description")}
/>
</Grid.Col>
</Grid>
{form.values.layouts.length >= 2 && (
<Group justify="end">
<Button
variant="subtle"
onClick={() => {
form.setValues((previous) =>
previous.layouts !== undefined && previous.layouts.length >= 2
? {
layouts: form.values.layouts.filter((filteredLayout) => filteredLayout.id !== layout.id),
}
: previous,
);
}}
>
{t("common.action.remove")}
</Button>
</Group>
)}
</Fieldset>
))}
</Stack>

<Group justify="end">
<Button type="submit" loading={isPending} color="teal">
{t("common.action.saveChanges")}
Expand Down
6 changes: 5 additions & 1 deletion apps/nextjs/src/app/[locale]/boards/_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@ import type { WidgetKind } from "@homarr/definitions";

export type Board = RouterOutputs["board"]["getHomeBoard"];
export type Section = Board["sections"][number];
export type Item = Section["items"][number];
export type Item = Board["items"][number];
export type ItemLayout = Item["layouts"][number];
export type SectionItem = Omit<Item, "layouts"> & ItemLayout & { type: "item" };

export type CategorySection = Extract<Section, { kind: "category" }>;
export type EmptySection = Extract<Section, { kind: "empty" }>;
export type DynamicSection = Extract<Section, { kind: "dynamic" }>;
export type DynamicSectionLayout = DynamicSection["layouts"][number];
export type DynamicSectionItem = Omit<DynamicSection, "layouts"> & DynamicSectionLayout & { type: "section" };

export type ItemOfKind<TKind extends WidgetKind> = Extract<Item, { kind: TKind }>;
53 changes: 30 additions & 23 deletions apps/nextjs/src/components/board/items/actions/create-item.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { getBoardLayouts } from "@homarr/boards/context";
import type { Modify } from "@homarr/common/types";
import { createId } from "@homarr/db/client";
import type { WidgetKind } from "@homarr/definitions";

import type { Board, DynamicSection, EmptySection, Item } from "~/app/[locale]/boards/_types";
import type { Board, EmptySection, Item, ItemLayout } from "~/app/[locale]/boards/_types";
import { getFirstEmptyPosition } from "./empty-position";
import { getSectionElements } from "./section-elements";

export interface CreateItemInput {
kind: WidgetKind;
Expand All @@ -19,24 +21,11 @@ export const createItemCallback =

if (!firstSection) return previous;

const dynamicSectionsOfFirstSection = previous.sections.filter(
(section): section is DynamicSection => section.kind === "dynamic" && section.parentSectionId === firstSection.id,
);
const elements = [...firstSection.items, ...dynamicSectionsOfFirstSection];
const emptyPosition = getFirstEmptyPosition(elements, previous.columnCount);

if (!emptyPosition) {
console.error("Your board is full");
return previous;
}

const widget = {
id: createId(),
kind,
options: {},
width: 1,
height: 1,
...emptyPosition,
layouts: createItemLayouts(previous, firstSection),
integrationIds: [],
advancedOptions: {
customCssClasses: [],
Expand All @@ -50,13 +39,31 @@ export const createItemCallback =

return {
...previous,
sections: previous.sections.map((section) => {
// Return same section if item is not in it
if (section.id !== firstSection.id) return section;
return {
...section,
items: section.items.concat(widget),
};
}),
items: previous.items.concat(widget),
};
};

const createItemLayouts = (board: Board, currentSection: EmptySection): ItemLayout[] => {
const layouts = getBoardLayouts(board);

return layouts.map((layoutId) => {
const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId });

const emptyPosition = boardLayout
? getFirstEmptyPosition(elements, boardLayout.columnCount)
: { xOffset: 0, yOffset: 0 };

if (!emptyPosition) {
throw new Error("Your board is full");
}

return {
width: 1,
height: 1,
...emptyPosition,
sectionId: currentSection.id,
layoutId,
};
});
};
Loading