chore(release): automatic release v1.8.0
This commit is contained in:
4
.github/renovate.json5
vendored
4
.github/renovate.json5
vendored
@@ -6,9 +6,9 @@
|
|||||||
matchPackagePatterns: ["^@homarr/"],
|
matchPackagePatterns: ["^@homarr/"],
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
// Disable Dockerode updates see https://github.com/apocas/dockerode/issues/787
|
// 15.2.0 crashes with turbopack error (panic)
|
||||||
{
|
{
|
||||||
matchPackagePatterns: ["^dockerode$"],
|
matchPackagePatterns: ["^next$", "^@next/eslint-plugin-next$"],
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
121
.github/workflows/deployment-docker-image.yml
vendored
121
.github/workflows/deployment-docker-image.yml
vendored
@@ -15,13 +15,14 @@ on:
|
|||||||
description: Send notifications
|
description: Send notifications
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write # Required to update package.json version
|
||||||
packages: write
|
packages: write # Required for pushing to GHCR
|
||||||
|
|
||||||
env:
|
env:
|
||||||
SKIP_ENV_VALIDATION: true
|
SKIP_ENV_VALIDATION: true
|
||||||
REGISTRY: ghcr.io
|
REGISTRY: ghcr.io
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
GHCR_REPO: ghcr.io/${{ github.repository }}
|
||||||
TURBO_TELEMETRY_DISABLED: 1
|
TURBO_TELEMETRY_DISABLED: 1
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
@@ -102,59 +103,123 @@ jobs:
|
|||||||
git pull origin dev
|
git pull origin dev
|
||||||
git rebase ${{ github.ref_name }}
|
git rebase ${{ github.ref_name }}
|
||||||
git push origin dev
|
git push origin dev
|
||||||
deploy:
|
build-amd64:
|
||||||
name: Deploy docker image
|
name: Build docker image for amd64
|
||||||
needs: release
|
needs: release
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
outputs:
|
||||||
NEXT_VERSION: ${{ needs.release.outputs.version }}
|
digest: ${{ steps.build.outputs.digest }}
|
||||||
DEPLOY_LATEST: ${{ github.ref_name == 'main' }}
|
|
||||||
DEPLOY_BETA: ${{ github.ref_name == 'beta' }}
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.release.outputs.git_ref }}
|
ref: ${{ needs.release.outputs.git_ref }}
|
||||||
- name: Discord notification
|
|
||||||
if: ${{ github.events.inputs.send-notifications != false }}
|
- name: Docker meta
|
||||||
env:
|
id: meta
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
uses: docker/metadata-action@v5
|
||||||
uses: Ilshidur/action-discord@master
|
|
||||||
with:
|
with:
|
||||||
args: "Deployment of an image for version '${{env.NEXT_VERSION}}' has been triggered: [run ${{ github.run_number }}](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>)"
|
images: "${{ env.GHCR_REPO }}"
|
||||||
|
|
||||||
- name: Log in to the Container registry
|
- name: Log in to the Container registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build and push by digest
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
network: host
|
||||||
|
platforms: linux/amd64
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
|
||||||
|
env:
|
||||||
|
SKIP_ENV_VALIDATION: true
|
||||||
|
|
||||||
|
build-arm64:
|
||||||
|
name: Build docker image for arm64
|
||||||
|
needs: release
|
||||||
|
runs-on: ubuntu-24.04-arm
|
||||||
|
outputs:
|
||||||
|
digest: ${{ steps.build.outputs.digest }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ needs.release.outputs.git_ref }}
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
|
images: "${{ env.GHCR_REPO }}"
|
||||||
tags: |
|
|
||||||
${{ env.DEPLOY_LATEST == 'true' && 'type=raw,value=latest' || null }}
|
- name: Log in to the Container registry
|
||||||
${{ env.DEPLOY_BETA == 'true' && 'type=raw,value=beta' || null }}
|
uses: docker/login-action@v3
|
||||||
type=raw,value=${{ env.NEXT_VERSION }}
|
with:
|
||||||
- name: Build and push
|
registry: ${{ env.REGISTRY }}
|
||||||
id: buildPushAction
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build and push by digest
|
||||||
|
id: build
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
context: .
|
context: .
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
network: host
|
network: host
|
||||||
|
platforms: linux/arm64
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
|
||||||
env:
|
env:
|
||||||
SKIP_ENV_VALIDATION: true
|
SKIP_ENV_VALIDATION: true
|
||||||
|
|
||||||
|
publish:
|
||||||
|
name: Complete deployment and notify
|
||||||
|
needs: [release, build-amd64, build-arm64]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
NEXT_VERSION: ${{ needs.release.outputs.version }}
|
||||||
|
DEPLOY_LATEST: ${{ github.ref_name == 'main' }}
|
||||||
|
DEPLOY_BETA: ${{ github.ref_name == 'beta' }}
|
||||||
|
steps:
|
||||||
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Publish beta
|
||||||
|
if: env.DEPLOY_BETA == 'true'
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create -t ${{ env.GHCR_REPO }}:beta \
|
||||||
|
${{ env.GHCR_REPO }}@${{ needs.build-amd64.outputs.digest }} \
|
||||||
|
${{ env.GHCR_REPO }}@${{ needs.build-arm64.outputs.digest }}
|
||||||
|
|
||||||
|
- name: Publish latest
|
||||||
|
if: env.DEPLOY_LATEST == 'true'
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create -t ${{ env.GHCR_REPO }}:latest \
|
||||||
|
${{ env.GHCR_REPO }}@${{ needs.build-amd64.outputs.digest }} \
|
||||||
|
${{ env.GHCR_REPO }}@${{ needs.build-arm64.outputs.digest }}
|
||||||
|
|
||||||
|
- name: Publish version
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create -t ${{ env.GHCR_REPO }}:${{ env.NEXT_VERSION }} \
|
||||||
|
${{ env.GHCR_REPO }}@${{ needs.build-amd64.outputs.digest }} \
|
||||||
|
${{ env.GHCR_REPO }}@${{ needs.build-arm64.outputs.digest }}
|
||||||
|
|
||||||
- name: Discord notification
|
- name: Discord notification
|
||||||
env:
|
env:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
uses: Ilshidur/action-discord@master
|
uses: Ilshidur/action-discord@master
|
||||||
with:
|
with:
|
||||||
args: "Deployment of image has completed for branch ${{ github.ref_name }}. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'."
|
args: "Successfully deployed images for branch **${{ github.ref_name }}**. Tagged as **${{env.NEXT_VERSION}}**."
|
||||||
|
|||||||
13
.run/typecheck.run.xml
Normal file
13
.run/typecheck.run.xml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="typecheck" type="js.build_tools.npm">
|
||||||
|
<package-json value="$PROJECT_DIR$/package.json" />
|
||||||
|
<command value="run" />
|
||||||
|
<scripts>
|
||||||
|
<script value="typecheck" />
|
||||||
|
</scripts>
|
||||||
|
<node-interpreter value="project" />
|
||||||
|
<package-manager value="pnpm" />
|
||||||
|
<envs />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
@@ -56,9 +56,9 @@
|
|||||||
"@mantine/tiptap": "^7.17.0",
|
"@mantine/tiptap": "^7.17.0",
|
||||||
"@million/lint": "1.0.14",
|
"@million/lint": "1.0.14",
|
||||||
"@tabler/icons-react": "^3.30.0",
|
"@tabler/icons-react": "^3.30.0",
|
||||||
"@tanstack/react-query": "^5.66.9",
|
"@tanstack/react-query": "^5.66.11",
|
||||||
"@tanstack/react-query-devtools": "^5.66.9",
|
"@tanstack/react-query-devtools": "^5.66.11",
|
||||||
"@tanstack/react-query-next-experimental": "^5.66.9",
|
"@tanstack/react-query-next-experimental": "^5.66.11",
|
||||||
"@trpc/client": "next",
|
"@trpc/client": "next",
|
||||||
"@trpc/next": "next",
|
"@trpc/next": "next",
|
||||||
"@trpc/react-query": "next",
|
"@trpc/react-query": "next",
|
||||||
@@ -81,9 +81,9 @@
|
|||||||
"react-dom": "19.0.0",
|
"react-dom": "19.0.0",
|
||||||
"react-error-boundary": "^5.0.0",
|
"react-error-boundary": "^5.0.0",
|
||||||
"react-simple-code-editor": "^0.14.1",
|
"react-simple-code-editor": "^0.14.1",
|
||||||
"sass": "^1.85.0",
|
"sass": "^1.85.1",
|
||||||
"superjson": "2.2.2",
|
"superjson": "2.2.2",
|
||||||
"swagger-ui-react": "^5.19.0",
|
"swagger-ui-react": "^5.20.0",
|
||||||
"use-deep-compare-effect": "^1.8.1",
|
"use-deep-compare-effect": "^1.8.1",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
@@ -92,15 +92,15 @@
|
|||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/chroma-js": "3.1.1",
|
"@types/chroma-js": "3.1.1",
|
||||||
"@types/node": "^22.13.4",
|
"@types/node": "^22.13.5",
|
||||||
"@types/prismjs": "^1.26.5",
|
"@types/prismjs": "^1.26.5",
|
||||||
"@types/react": "19.0.10",
|
"@types/react": "19.0.10",
|
||||||
"@types/react-dom": "19.0.4",
|
"@types/react-dom": "19.0.4",
|
||||||
"@types/swagger-ui-react": "^5.18.0",
|
"@types/swagger-ui-react": "^5.18.0",
|
||||||
"concurrently": "^9.1.2",
|
"concurrently": "^9.1.2",
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.21.0",
|
||||||
"node-loader": "^2.1.0",
|
"node-loader": "^2.1.0",
|
||||||
"prettier": "^3.5.1",
|
"prettier": "^3.5.2",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.8.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Box, LoadingOverlay, Stack } from "@mantine/core";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
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 { BoardCategorySection } from "~/components/board/sections/category-section";
|
||||||
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
||||||
@@ -43,6 +43,7 @@ export const useUpdateBoard = () => {
|
|||||||
|
|
||||||
export const ClientBoard = () => {
|
export const ClientBoard = () => {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
|
const currentLayoutId = useCurrentLayout();
|
||||||
const isReady = useIsBoardReady();
|
const isReady = useIsBoardReady();
|
||||||
|
|
||||||
const fullWidthSortedSections = board.sections
|
const fullWidthSortedSections = board.sections
|
||||||
@@ -63,9 +64,10 @@ export const ClientBoard = () => {
|
|||||||
<Stack ref={ref} h="100%" style={{ visibility: isReady ? "visible" : "hidden" }}>
|
<Stack ref={ref} h="100%" style={{ visibility: isReady ? "visible" : "hidden" }}>
|
||||||
{fullWidthSortedSections.map((section) =>
|
{fullWidthSortedSections.map((section) =>
|
||||||
section.kind === "empty" ? (
|
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>
|
</Stack>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { getI18n } from "@homarr/translation/server";
|
|||||||
import { createMetaTitle } from "~/metadata";
|
import { createMetaTitle } from "~/metadata";
|
||||||
import { createBoardLayout } from "../_layout-creator";
|
import { createBoardLayout } from "../_layout-creator";
|
||||||
import type { Board } from "../_types";
|
import type { Board } from "../_types";
|
||||||
import { ClientBoard } from "./_client";
|
import { DynamicClientBoard } from "./_dynamic-client";
|
||||||
import { BoardContentHeaderActions } from "./_header-actions";
|
import { BoardContentHeaderActions } from "./_header-actions";
|
||||||
|
|
||||||
export type Params = Record<string, unknown>;
|
export type Params = Record<string, unknown>;
|
||||||
@@ -37,13 +37,13 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<IntegrationProvider integrations={integrations}>
|
<IntegrationProvider integrations={integrations}>
|
||||||
<ClientBoard />
|
<DynamicClientBoard />
|
||||||
</IntegrationProvider>
|
</IntegrationProvider>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
generateMetadataAsync: async ({ params }: { params: TParams }): Promise<Metadata> => {
|
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
|
||||||
try {
|
try {
|
||||||
const board = await getInitialBoard(params);
|
const board = await getInitialBoard(await params);
|
||||||
const t = await getI18n();
|
const t = await getI18n();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
export const DynamicClientBoard = dynamic(() => import("./_client").then((mod) => mod.ClientBoard), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@ import type { MouseEvent } from "react";
|
|||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Group, Menu } from "@mantine/core";
|
import { Group, Menu, ScrollArea } from "@mantine/core";
|
||||||
import { useHotkeys } from "@mantine/hooks";
|
import { useHotkeys } from "@mantine/hooks";
|
||||||
import {
|
import {
|
||||||
IconBox,
|
IconBox,
|
||||||
@@ -168,16 +168,18 @@ const SelectBoardsMenu = () => {
|
|||||||
</HeaderButton>
|
</HeaderButton>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
|
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
|
||||||
{boards.map((board) => (
|
<ScrollArea.Autosize mah={300}>
|
||||||
<Menu.Item
|
{boards.map((board) => (
|
||||||
key={board.id}
|
<Menu.Item
|
||||||
component={Link}
|
key={board.id}
|
||||||
href={`/boards/${board.name}`}
|
component={Link}
|
||||||
leftSection={<IconLayoutBoard size={20} />}
|
href={`/boards/${board.name}`}
|
||||||
>
|
leftSection={<IconLayoutBoard size={20} />}
|
||||||
{board.name}
|
>
|
||||||
</Menu.Item>
|
{board.name}
|
||||||
))}
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</ScrollArea.Autosize>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,43 +1,109 @@
|
|||||||
"use client";
|
"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 { useZodForm } from "@homarr/form";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
import type { Board } from "../../_types";
|
import type { Board } from "../../_types";
|
||||||
import { useSavePartialSettingsMutation } from "./_shared";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
board: Board;
|
board: Board;
|
||||||
}
|
}
|
||||||
export const LayoutSettingsContent = ({ board }: Props) => {
|
export const LayoutSettingsContent = ({ board }: Props) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
|
const utils = clientApi.useUtils();
|
||||||
const form = useZodForm(validation.board.savePartialSettings.pick({ columnCount: true }).required(), {
|
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: {
|
initialValues: {
|
||||||
columnCount: board.columnCount,
|
layouts: board.layouts,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
onSubmit={form.onSubmit((values) => {
|
onSubmit={form.onSubmit((values) => {
|
||||||
savePartialSettings({
|
saveLayouts({
|
||||||
id: board.id,
|
id: board.id,
|
||||||
...values,
|
...values,
|
||||||
});
|
});
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Grid>
|
<Stack gap="sm">
|
||||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
<Group justify="space-between" align="center">
|
||||||
<Input.Wrapper label={t("board.field.columnCount.label")}>
|
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
|
||||||
<Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps("columnCount")} />
|
<Button
|
||||||
</Input.Wrapper>
|
variant="subtle"
|
||||||
</Grid.Col>
|
onClick={() => {
|
||||||
</Grid>
|
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">
|
<Group justify="end">
|
||||||
<Button type="submit" loading={isPending} color="teal">
|
<Button type="submit" loading={isPending} color="teal">
|
||||||
{t("common.action.saveChanges")}
|
{t("common.action.saveChanges")}
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ import type { WidgetKind } from "@homarr/definitions";
|
|||||||
|
|
||||||
export type Board = RouterOutputs["board"]["getHomeBoard"];
|
export type Board = RouterOutputs["board"]["getHomeBoard"];
|
||||||
export type Section = Board["sections"][number];
|
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 CategorySection = Extract<Section, { kind: "category" }>;
|
||||||
export type EmptySection = Extract<Section, { kind: "empty" }>;
|
export type EmptySection = Extract<Section, { kind: "empty" }>;
|
||||||
export type DynamicSection = Extract<Section, { kind: "dynamic" }>;
|
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 }>;
|
export type ItemOfKind<TKind extends WidgetKind> = Extract<Item, { kind: TKind }>;
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import { getBoardLayouts } from "@homarr/boards/context";
|
||||||
import type { Modify } from "@homarr/common/types";
|
import type { Modify } from "@homarr/common/types";
|
||||||
import { createId } from "@homarr/db/client";
|
import { createId } from "@homarr/db/client";
|
||||||
import type { WidgetKind } from "@homarr/definitions";
|
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 { getFirstEmptyPosition } from "./empty-position";
|
||||||
|
import { getSectionElements } from "./section-elements";
|
||||||
|
|
||||||
export interface CreateItemInput {
|
export interface CreateItemInput {
|
||||||
kind: WidgetKind;
|
kind: WidgetKind;
|
||||||
@@ -19,24 +21,11 @@ export const createItemCallback =
|
|||||||
|
|
||||||
if (!firstSection) return previous;
|
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 = {
|
const widget = {
|
||||||
id: createId(),
|
id: createId(),
|
||||||
kind,
|
kind,
|
||||||
options: {},
|
options: {},
|
||||||
width: 1,
|
layouts: createItemLayouts(previous, firstSection),
|
||||||
height: 1,
|
|
||||||
...emptyPosition,
|
|
||||||
integrationIds: [],
|
integrationIds: [],
|
||||||
advancedOptions: {
|
advancedOptions: {
|
||||||
customCssClasses: [],
|
customCssClasses: [],
|
||||||
@@ -50,13 +39,31 @@ export const createItemCallback =
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...previous,
|
...previous,
|
||||||
sections: previous.sections.map((section) => {
|
items: previous.items.concat(widget),
|
||||||
// Return same section if item is not in it
|
|
||||||
if (section.id !== firstSection.id) return section;
|
|
||||||
return {
|
|
||||||
...section,
|
|
||||||
items: section.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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createId } from "@homarr/db/client";
|
import { createId } from "@homarr/db/client";
|
||||||
|
|
||||||
import type { Board, DynamicSection, EmptySection } from "~/app/[locale]/boards/_types";
|
import type { Board, EmptySection, ItemLayout, Section } from "~/app/[locale]/boards/_types";
|
||||||
import { getFirstEmptyPosition } from "./empty-position";
|
import { getFirstEmptyPosition } from "./empty-position";
|
||||||
|
import { getSectionElements } from "./section-elements";
|
||||||
|
|
||||||
export interface DuplicateItemInput {
|
export interface DuplicateItemInput {
|
||||||
itemId: string;
|
itemId: string;
|
||||||
@@ -10,72 +11,78 @@ export interface DuplicateItemInput {
|
|||||||
export const duplicateItemCallback =
|
export const duplicateItemCallback =
|
||||||
({ itemId }: DuplicateItemInput) =>
|
({ itemId }: DuplicateItemInput) =>
|
||||||
(previous: Board): Board => {
|
(previous: Board): Board => {
|
||||||
const itemToDuplicate = previous.sections
|
const itemToDuplicate = previous.items.find((item) => item.id === itemId);
|
||||||
.flatMap((section) => section.items.map((item) => ({ ...item, sectionId: section.id })))
|
|
||||||
.find((item) => item.id === itemId);
|
|
||||||
if (!itemToDuplicate) return previous;
|
if (!itemToDuplicate) return previous;
|
||||||
|
|
||||||
const currentSection = previous.sections.find((section) => section.id === itemToDuplicate.sectionId);
|
const clonedItem = structuredClone(itemToDuplicate);
|
||||||
if (!currentSection) return previous;
|
|
||||||
|
|
||||||
const dynamicSectionsOfCurrentSection = previous.sections.filter(
|
return {
|
||||||
(section): section is DynamicSection =>
|
|
||||||
section.kind === "dynamic" && section.parentSectionId === currentSection.id,
|
|
||||||
);
|
|
||||||
const elements = [...currentSection.items, ...dynamicSectionsOfCurrentSection];
|
|
||||||
let sectionId = currentSection.id;
|
|
||||||
let emptyPosition = getFirstEmptyPosition(
|
|
||||||
elements,
|
|
||||||
currentSection.kind === "dynamic" ? currentSection.width : previous.columnCount,
|
|
||||||
currentSection.kind === "dynamic" ? currentSection.height : undefined,
|
|
||||||
{
|
|
||||||
width: itemToDuplicate.width,
|
|
||||||
height: itemToDuplicate.height,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!emptyPosition) {
|
|
||||||
const firstSection = previous.sections
|
|
||||||
.filter((section): section is EmptySection => section.kind === "empty")
|
|
||||||
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
|
||||||
.at(0);
|
|
||||||
|
|
||||||
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];
|
|
||||||
emptyPosition = getFirstEmptyPosition(elements, previous.columnCount, undefined, {
|
|
||||||
width: itemToDuplicate.width,
|
|
||||||
height: itemToDuplicate.height,
|
|
||||||
});
|
|
||||||
if (!emptyPosition) {
|
|
||||||
console.error("Your board is full");
|
|
||||||
return previous;
|
|
||||||
}
|
|
||||||
|
|
||||||
sectionId = firstSection.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const widget = structuredClone(itemToDuplicate);
|
|
||||||
widget.id = createId();
|
|
||||||
widget.xOffset = emptyPosition.xOffset;
|
|
||||||
widget.yOffset = emptyPosition.yOffset;
|
|
||||||
widget.sectionId = sectionId;
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
...previous,
|
...previous,
|
||||||
sections: previous.sections.map((section) => {
|
items: previous.items.concat({
|
||||||
// Return same section if item is not in it
|
...clonedItem,
|
||||||
if (section.id !== sectionId) return section;
|
id: createId(),
|
||||||
return {
|
layouts: clonedItem.layouts.map((layout) => ({
|
||||||
...section,
|
...layout,
|
||||||
items: section.items.concat(widget),
|
...getNextPosition(previous, layout),
|
||||||
};
|
})),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getNextPosition = (board: Board, layout: ItemLayout): { xOffset: number; yOffset: number; sectionId: string } => {
|
||||||
|
const currentSection = board.sections.find((section) => section.id === layout.sectionId);
|
||||||
|
if (currentSection) {
|
||||||
|
const emptySectionPosition = getEmptySectionPosition(board, layout, currentSection);
|
||||||
|
if (emptySectionPosition) {
|
||||||
|
return {
|
||||||
|
...emptySectionPosition,
|
||||||
|
sectionId: currentSection.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstSection = board.sections
|
||||||
|
.filter((section): section is EmptySection => section.kind === "empty")
|
||||||
|
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||||
|
.at(0);
|
||||||
|
|
||||||
|
if (!firstSection) {
|
||||||
|
throw new Error("Your board is full. reason='no first section'");
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptySectionPosition = getEmptySectionPosition(board, layout, firstSection);
|
||||||
|
|
||||||
|
if (!emptySectionPosition) {
|
||||||
|
throw new Error("Your board is full. reason='no empty positions'");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...emptySectionPosition,
|
||||||
|
sectionId: firstSection.id,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEmptySectionPosition = (
|
||||||
|
board: Board,
|
||||||
|
layout: ItemLayout,
|
||||||
|
section: Section,
|
||||||
|
): { xOffset: number; yOffset: number } | undefined => {
|
||||||
|
const boardLayout = board.layouts.find((boardLayout) => boardLayout.id === layout.layoutId);
|
||||||
|
if (!boardLayout) return;
|
||||||
|
|
||||||
|
const sectionElements = getSectionElements(board, { sectionId: layout.sectionId, layoutId: layout.layoutId });
|
||||||
|
if (section.kind !== "dynamic") {
|
||||||
|
return getFirstEmptyPosition(sectionElements, boardLayout.columnCount, undefined, {
|
||||||
|
width: layout.width,
|
||||||
|
height: layout.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionLayout = section.layouts.find((sectionLayout) => sectionLayout.layoutId === layout.layoutId);
|
||||||
|
if (!sectionLayout) return;
|
||||||
|
|
||||||
|
return getFirstEmptyPosition(sectionElements, sectionLayout.width, sectionLayout.height, {
|
||||||
|
width: layout.width,
|
||||||
|
height: layout.height,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Item } from "~/app/[locale]/boards/_types";
|
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||||
|
|
||||||
export const getFirstEmptyPosition = (
|
export const getFirstEmptyPosition = (
|
||||||
elements: Pick<Item, "yOffset" | "xOffset" | "width" | "height">[],
|
elements: Pick<SectionItem, "yOffset" | "xOffset" | "width" | "height">[],
|
||||||
columnCount: number,
|
columnCount: number,
|
||||||
rowCount = 9999,
|
rowCount = 9999,
|
||||||
size: { width: number; height: number } = { width: 1, height: 1 },
|
size: { width: number; height: number } = { width: 1, height: 1 },
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { getCurrentLayout } from "@homarr/boards/context";
|
||||||
|
|
||||||
|
import type { Board } from "~/app/[locale]/boards/_types";
|
||||||
|
|
||||||
|
export interface MoveAndResizeItemInput {
|
||||||
|
itemId: string;
|
||||||
|
xOffset: number;
|
||||||
|
yOffset: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const moveAndResizeItemCallback =
|
||||||
|
({ itemId, ...layoutInput }: MoveAndResizeItemInput) =>
|
||||||
|
(previous: Board): Board => {
|
||||||
|
const currentLayout = getCurrentLayout(previous);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...previous,
|
||||||
|
items: previous.items.map((item) =>
|
||||||
|
item.id !== itemId
|
||||||
|
? item
|
||||||
|
: {
|
||||||
|
...item,
|
||||||
|
layouts: item.layouts.map((layout) =>
|
||||||
|
layout.layoutId !== currentLayout
|
||||||
|
? layout
|
||||||
|
: {
|
||||||
|
...layout,
|
||||||
|
...layoutInput,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { getCurrentLayout } from "@homarr/boards/context";
|
||||||
|
|
||||||
|
import type { Board } from "~/app/[locale]/boards/_types";
|
||||||
|
|
||||||
|
export interface MoveItemToSectionInput {
|
||||||
|
itemId: string;
|
||||||
|
sectionId: string;
|
||||||
|
xOffset: number;
|
||||||
|
yOffset: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const moveItemToSectionCallback =
|
||||||
|
({ itemId, ...layoutInput }: MoveItemToSectionInput) =>
|
||||||
|
(board: Board): Board => {
|
||||||
|
const currentLayout = getCurrentLayout(board);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...board,
|
||||||
|
items: board.items.map((item) =>
|
||||||
|
item.id !== itemId
|
||||||
|
? item
|
||||||
|
: {
|
||||||
|
...item,
|
||||||
|
layouts: item.layouts.map((layout) =>
|
||||||
|
layout.layoutId !== currentLayout
|
||||||
|
? layout
|
||||||
|
: {
|
||||||
|
...layout,
|
||||||
|
...layoutInput,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Board } from "~/app/[locale]/boards/_types";
|
||||||
|
|
||||||
|
export interface RemoveItemInput {
|
||||||
|
itemId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const removeItemCallback =
|
||||||
|
({ itemId }: RemoveItemInput) =>
|
||||||
|
(board: Board): Board => ({
|
||||||
|
...board,
|
||||||
|
items: board.items.filter((item) => item.id !== itemId),
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Board } from "~/app/[locale]/boards/_types";
|
||||||
|
|
||||||
|
export const getSectionElements = (board: Board, { sectionId, layoutId }: { sectionId: string; layoutId: string }) => {
|
||||||
|
const dynamicSectionsOfFirstSection = board.sections
|
||||||
|
.filter((section) => section.kind === "dynamic")
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
.map(({ layouts, ...section }) => ({ ...section, ...layouts.find((layout) => layout.layoutId === layoutId)! }))
|
||||||
|
.filter((section) => section.parentSectionId === sectionId);
|
||||||
|
const items = board.items
|
||||||
|
.map(({ layouts, ...item }) => ({
|
||||||
|
...item,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
...layouts.find((layout) => layout.layoutId === layoutId)!,
|
||||||
|
}))
|
||||||
|
.filter((item) => item.sectionId === sectionId);
|
||||||
|
|
||||||
|
return [...items, ...dynamicSectionsOfFirstSection];
|
||||||
|
};
|
||||||
@@ -1,61 +1,109 @@
|
|||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import type { Board } from "~/app/[locale]/boards/_types";
|
import * as boardContext from "@homarr/boards/context";
|
||||||
|
|
||||||
import { createItemCallback } from "../create-item";
|
import { createItemCallback } from "../create-item";
|
||||||
import * as emptyPosition from "../empty-position";
|
import * as emptyPositionModule from "../empty-position";
|
||||||
import { createDynamicSection, createEmptySection, createItem } from "./shared";
|
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||||
|
import { DynamicSectionMockBuilder } from "./mocks/dynamic-section-mock";
|
||||||
|
import { ItemMockBuilder } from "./mocks/item-mock";
|
||||||
|
import { LayoutMockBuilder } from "./mocks/layout-mock";
|
||||||
|
|
||||||
describe("item actions create-item", () => {
|
describe("item actions create-item", () => {
|
||||||
test("should add it to first section", () => {
|
test("should add it to first section", () => {
|
||||||
const spy = vi.spyOn(emptyPosition, "getFirstEmptyPosition");
|
// Arrange
|
||||||
spy.mockReturnValue({ xOffset: 5, yOffset: 5 });
|
const itemKind = "clock";
|
||||||
const input = {
|
const emptyPosition = { xOffset: 5, yOffset: 5 };
|
||||||
sections: [createEmptySection("1", 2), createEmptySection("2", 0), createEmptySection("3", 1)],
|
const firstSectionId = "2";
|
||||||
columnCount: 4,
|
const layoutId = "1";
|
||||||
} satisfies Pick<Board, "sections" | "columnCount">;
|
|
||||||
|
|
||||||
|
const layout = new LayoutMockBuilder({ id: layoutId, columnCount: 4 }).build();
|
||||||
|
const board = new BoardMockBuilder()
|
||||||
|
.addLayout(layout)
|
||||||
|
.addLayout()
|
||||||
|
.addEmptySection({ id: "1", yOffset: 2 })
|
||||||
|
.addEmptySection({ id: firstSectionId, yOffset: 0 })
|
||||||
|
.addEmptySection({ id: "3", yOffset: 1 })
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const emptyPositionSpy = vi.spyOn(emptyPositionModule, "getFirstEmptyPosition");
|
||||||
|
emptyPositionSpy.mockReturnValue(emptyPosition);
|
||||||
|
const layoutsSpy = vi.spyOn(boardContext, "getBoardLayouts");
|
||||||
|
layoutsSpy.mockReturnValue([layoutId]);
|
||||||
|
|
||||||
|
// Act
|
||||||
const result = createItemCallback({
|
const result = createItemCallback({
|
||||||
kind: "clock",
|
kind: itemKind,
|
||||||
})(input as unknown as Board);
|
})(board);
|
||||||
|
|
||||||
const firstSection = result.sections.find((section) => section.id === "2");
|
// Assert
|
||||||
const item = firstSection?.items.at(0);
|
const item = result.items.at(0);
|
||||||
expect(item).toEqual(expect.objectContaining({ kind: "clock", xOffset: 5, yOffset: 5 }));
|
expect(item).toEqual(
|
||||||
expect(spy).toHaveBeenCalledWith([], input.columnCount);
|
expect.objectContaining({
|
||||||
|
kind: itemKind,
|
||||||
|
layouts: [
|
||||||
|
{
|
||||||
|
layoutId,
|
||||||
|
height: 1,
|
||||||
|
width: 1,
|
||||||
|
...emptyPosition,
|
||||||
|
sectionId: firstSectionId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount);
|
||||||
});
|
});
|
||||||
test("should correctly pass dynamic section and items to getFirstEmptyPosition", () => {
|
test("should correctly pass dynamic section and items to getFirstEmptyPosition", () => {
|
||||||
const spy = vi.spyOn(emptyPosition, "getFirstEmptyPosition");
|
// Arrange
|
||||||
spy.mockReturnValue({ xOffset: 5, yOffset: 5 });
|
const itemKind = "clock";
|
||||||
const firstSection = createEmptySection("2", 0);
|
const emptyPosition = { xOffset: 5, yOffset: 5 };
|
||||||
const expectedItem = createItem({ id: "12", xOffset: 1, yOffset: 2, width: 3, height: 2 });
|
const firstSectionId = "2";
|
||||||
firstSection.items.push(expectedItem);
|
const layoutId = "1";
|
||||||
const dynamicSectionInFirst = createDynamicSection({
|
const itemAndSectionPosition = { height: 2, width: 3, yOffset: 2, xOffset: 1 };
|
||||||
id: "4",
|
|
||||||
parentSectionId: "2",
|
|
||||||
yOffset: 0,
|
|
||||||
xOffset: 0,
|
|
||||||
width: 2,
|
|
||||||
height: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
const input = {
|
const layout = new LayoutMockBuilder({ id: layoutId, columnCount: 4 }).build();
|
||||||
sections: [
|
const dynamicSectionInFirstSection = new DynamicSectionMockBuilder({ id: "4" })
|
||||||
createEmptySection("1", 2),
|
.addLayout({ ...itemAndSectionPosition, layoutId, parentSectionId: firstSectionId })
|
||||||
firstSection,
|
.build();
|
||||||
createEmptySection("3", 1),
|
const itemInFirstSection = new ItemMockBuilder({ id: "12" })
|
||||||
dynamicSectionInFirst,
|
.addLayout({ ...itemAndSectionPosition, layoutId, sectionId: firstSectionId })
|
||||||
createDynamicSection({ id: "5", parentSectionId: "3", yOffset: 1 }),
|
.build();
|
||||||
],
|
const otherDynamicSection = new DynamicSectionMockBuilder({ id: "5" }).addLayout({ layoutId }).build();
|
||||||
columnCount: 4,
|
const otherItem = new ItemMockBuilder({ id: "13" }).addLayout({ layoutId }).build();
|
||||||
} satisfies Pick<Board, "sections" | "columnCount">;
|
const board = new BoardMockBuilder()
|
||||||
|
.addLayout(layout)
|
||||||
|
.addEmptySection({ id: "1", yOffset: 2 })
|
||||||
|
.addEmptySection({ id: firstSectionId, yOffset: 0 })
|
||||||
|
.addEmptySection({ id: "3", yOffset: 1 })
|
||||||
|
.addSection(dynamicSectionInFirstSection)
|
||||||
|
.addSection(otherDynamicSection)
|
||||||
|
.addItem(itemInFirstSection)
|
||||||
|
.addItem(otherItem)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const spy = vi.spyOn(emptyPositionModule, "getFirstEmptyPosition");
|
||||||
|
spy.mockReturnValue(emptyPosition);
|
||||||
|
const layoutsSpy = vi.spyOn(boardContext, "getBoardLayouts");
|
||||||
|
layoutsSpy.mockReturnValue([layoutId]);
|
||||||
|
|
||||||
|
// Act
|
||||||
const result = createItemCallback({
|
const result = createItemCallback({
|
||||||
kind: "clock",
|
kind: itemKind,
|
||||||
})(input as unknown as Board);
|
})(board);
|
||||||
|
|
||||||
const firstSectionResult = result.sections.find((section) => section.id === "2");
|
// Assert
|
||||||
const item = firstSectionResult?.items.find((item) => item.id !== "12");
|
expect(result.items.length).toBe(3);
|
||||||
expect(item).toEqual(expect.objectContaining({ kind: "clock", xOffset: 5, yOffset: 5 }));
|
const item = result.items.find((item) => item.id !== itemInFirstSection.id && item.id !== otherItem.id);
|
||||||
expect(spy).toHaveBeenCalledWith([expectedItem, dynamicSectionInFirst], input.columnCount);
|
expect(item).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: itemKind,
|
||||||
|
layouts: [{ ...emptyPosition, height: 1, width: 1, sectionId: firstSectionId, layoutId }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(spy).toHaveBeenCalledWith(
|
||||||
|
[expect.objectContaining(itemAndSectionPosition), expect.objectContaining(itemAndSectionPosition)],
|
||||||
|
layout.columnCount,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,51 +1,63 @@
|
|||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import type { Board } from "~/app/[locale]/boards/_types";
|
|
||||||
import { duplicateItemCallback } from "../duplicate-item";
|
import { duplicateItemCallback } from "../duplicate-item";
|
||||||
import * as emptyPosition from "../empty-position";
|
import * as emptyPositionModule from "../empty-position";
|
||||||
import { createEmptySection, createItem } from "./shared";
|
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||||
|
import { ItemMockBuilder } from "./mocks/item-mock";
|
||||||
|
import { LayoutMockBuilder } from "./mocks/layout-mock";
|
||||||
|
|
||||||
describe("item actions duplicate-item", () => {
|
describe("item actions duplicate-item", () => {
|
||||||
test("should copy it in the same section", () => {
|
test("should copy it in the same section", () => {
|
||||||
const spy = vi.spyOn(emptyPosition, "getFirstEmptyPosition");
|
// Arrange
|
||||||
spy.mockReturnValue({ xOffset: 5, yOffset: 5 });
|
const itemKind = "minecraftServerStatus";
|
||||||
const currentSection = createEmptySection("2", 1);
|
const emptyPosition = { xOffset: 5, yOffset: 5 };
|
||||||
const currentItem = createItem({
|
const currentSectionId = "2";
|
||||||
id: "1",
|
const layoutId = "1";
|
||||||
xOffset: 1,
|
const currentItemSize = { height: 2, width: 3 };
|
||||||
yOffset: 3,
|
|
||||||
width: 3,
|
const layout = new LayoutMockBuilder({ id: layoutId, columnCount: 4 }).build();
|
||||||
height: 2,
|
const currentItem = new ItemMockBuilder({
|
||||||
kind: "minecraftServerStatus",
|
kind: itemKind,
|
||||||
integrationIds: ["1"],
|
integrationIds: ["1"],
|
||||||
options: { address: "localhost" },
|
options: { address: "localhost" },
|
||||||
advancedOptions: { customCssClasses: ["test"] },
|
advancedOptions: { customCssClasses: ["test"] },
|
||||||
});
|
})
|
||||||
const otherItem = createItem({
|
.addLayout({ layoutId, sectionId: currentSectionId, ...currentItemSize })
|
||||||
id: "2",
|
.build();
|
||||||
});
|
const otherItem = new ItemMockBuilder({ id: "2" }).addLayout({ layoutId }).build();
|
||||||
currentSection.items.push(currentItem, otherItem);
|
|
||||||
const input = {
|
|
||||||
columnCount: 10,
|
|
||||||
sections: [createEmptySection("1", 0), currentSection, createEmptySection("3", 2)],
|
|
||||||
} satisfies Pick<Board, "sections" | "columnCount">;
|
|
||||||
|
|
||||||
const result = duplicateItemCallback({ itemId: currentItem.id })(input as unknown as Board);
|
const board = new BoardMockBuilder()
|
||||||
|
.addLayout(layout)
|
||||||
|
.addItem(currentItem)
|
||||||
|
.addItem(otherItem)
|
||||||
|
.addEmptySection({ id: "1", yOffset: 2 })
|
||||||
|
.addEmptySection({ id: currentSectionId, yOffset: 0 })
|
||||||
|
.addEmptySection({ id: "3", yOffset: 1 })
|
||||||
|
.build();
|
||||||
|
|
||||||
const section = result.sections.find((section) => section.id === "2");
|
const spy = vi.spyOn(emptyPositionModule, "getFirstEmptyPosition");
|
||||||
expect(section?.items.length).toBe(3);
|
spy.mockReturnValue(emptyPosition);
|
||||||
const duplicatedItem = section?.items.find((item) => item.id !== currentItem.id && item.id !== otherItem.id);
|
|
||||||
|
// Act
|
||||||
|
const result = duplicateItemCallback({ itemId: currentItem.id })(board);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result.items.length).toBe(3);
|
||||||
|
const duplicatedItem = result.items.find((item) => item.id !== currentItem.id && item.id !== otherItem.id);
|
||||||
|
|
||||||
expect(duplicatedItem).toEqual(
|
expect(duplicatedItem).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
kind: "minecraftServerStatus",
|
kind: itemKind,
|
||||||
xOffset: 5,
|
integrationIds: currentItem.integrationIds,
|
||||||
yOffset: 5,
|
options: currentItem.options,
|
||||||
width: 3,
|
advancedOptions: currentItem.advancedOptions,
|
||||||
height: 2,
|
layouts: [
|
||||||
integrationIds: ["1"],
|
expect.objectContaining({
|
||||||
options: { address: "localhost" },
|
...emptyPosition,
|
||||||
advancedOptions: { customCssClasses: ["test"] },
|
...currentItemSize,
|
||||||
|
sectionId: currentSectionId,
|
||||||
|
}),
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ describe("get first empty position", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const createElementsFromLayout = (layout: string[][]) => {
|
const createElementsFromLayout = (layout: string[][]) => {
|
||||||
const elements: (Pick<Item, "xOffset" | "yOffset" | "width" | "height"> & { char: string })[] = [];
|
const elements: (Pick<Item["layouts"][number], "xOffset" | "yOffset" | "width" | "height"> & { char: string })[] = [];
|
||||||
for (let yOffset = 0; yOffset < layout.length; yOffset++) {
|
for (let yOffset = 0; yOffset < layout.length; yOffset++) {
|
||||||
const row = layout[yOffset];
|
const row = layout[yOffset];
|
||||||
if (!row) continue;
|
if (!row) continue;
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { createId } from "@homarr/db";
|
||||||
|
|
||||||
|
import type { Board, DynamicSection, EmptySection, Item, Section } from "~/app/[locale]/boards/_types";
|
||||||
|
import { DynamicSectionMockBuilder } from "./dynamic-section-mock";
|
||||||
|
import { EmptySectionMockBuilder } from "./empty-section-mock";
|
||||||
|
import { ItemMockBuilder } from "./item-mock";
|
||||||
|
import { LayoutMockBuilder } from "./layout-mock";
|
||||||
|
|
||||||
|
export class BoardMockBuilder {
|
||||||
|
private readonly board: Board;
|
||||||
|
|
||||||
|
constructor(board?: Partial<Omit<Board, "groupPermissions" | "userPermissions" | "sections" | "items" | "layouts">>) {
|
||||||
|
this.board = {
|
||||||
|
id: createId(),
|
||||||
|
backgroundImageRepeat: "no-repeat",
|
||||||
|
backgroundImageAttachment: "scroll",
|
||||||
|
backgroundImageSize: "cover",
|
||||||
|
backgroundImageUrl: null,
|
||||||
|
primaryColor: "#ffffff",
|
||||||
|
secondaryColor: "#000000",
|
||||||
|
iconColor: null,
|
||||||
|
itemRadius: "lg",
|
||||||
|
pageTitle: "Board",
|
||||||
|
metaTitle: "Board",
|
||||||
|
logoImageUrl: null,
|
||||||
|
faviconImageUrl: null,
|
||||||
|
name: "board",
|
||||||
|
opacity: 100,
|
||||||
|
isPublic: true,
|
||||||
|
disableStatus: false,
|
||||||
|
customCss: "",
|
||||||
|
creatorId: createId(),
|
||||||
|
creator: {
|
||||||
|
id: createId(),
|
||||||
|
image: null,
|
||||||
|
name: "User",
|
||||||
|
},
|
||||||
|
groupPermissions: [],
|
||||||
|
userPermissions: [],
|
||||||
|
sections: [],
|
||||||
|
items: [],
|
||||||
|
layouts: [
|
||||||
|
{
|
||||||
|
id: createId(),
|
||||||
|
name: "Base",
|
||||||
|
columnCount: 12,
|
||||||
|
breakpoint: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
...board,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public addEmptySection(emptySection?: Partial<EmptySection>): BoardMockBuilder {
|
||||||
|
return this.addSection(new EmptySectionMockBuilder(emptySection).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
public addDynamicSection(dynamicSection?: Partial<DynamicSection>): BoardMockBuilder {
|
||||||
|
return this.addSection(new DynamicSectionMockBuilder(dynamicSection).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
public addSection(section: Section): BoardMockBuilder {
|
||||||
|
this.board.sections.push(section);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public addSections(sections: Section[]): BoardMockBuilder {
|
||||||
|
this.board.sections.push(...sections);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public addItem(item?: Partial<Item>): BoardMockBuilder {
|
||||||
|
this.board.items.push(new ItemMockBuilder(item).build());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public addItems(items: Item[]): BoardMockBuilder {
|
||||||
|
this.board.items.push(...items);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public addLayout(layout?: Partial<Board["layouts"][number]>): BoardMockBuilder {
|
||||||
|
this.board.layouts.push(new LayoutMockBuilder(layout).build());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public build(): Board {
|
||||||
|
return this.board;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { createId } from "@homarr/db";
|
||||||
|
|
||||||
|
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||||
|
|
||||||
|
export class CategorySectionMockBuilder {
|
||||||
|
private readonly section: CategorySection;
|
||||||
|
|
||||||
|
constructor(section?: Partial<CategorySection>) {
|
||||||
|
this.section = {
|
||||||
|
id: createId(),
|
||||||
|
kind: "category",
|
||||||
|
xOffset: 0,
|
||||||
|
yOffset: 0,
|
||||||
|
name: "Category",
|
||||||
|
collapsed: false,
|
||||||
|
...section,
|
||||||
|
} satisfies CategorySection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public build(): CategorySection {
|
||||||
|
return this.section;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { createId } from "@homarr/db";
|
||||||
|
|
||||||
|
import type { DynamicSection } from "~/app/[locale]/boards/_types";
|
||||||
|
|
||||||
|
export class DynamicSectionMockBuilder {
|
||||||
|
private readonly section: DynamicSection;
|
||||||
|
|
||||||
|
constructor(section?: Partial<DynamicSection>) {
|
||||||
|
this.section = {
|
||||||
|
id: createId(),
|
||||||
|
kind: "dynamic",
|
||||||
|
layouts: [],
|
||||||
|
...section,
|
||||||
|
} satisfies DynamicSection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public addLayout(layout?: Partial<DynamicSection["layouts"][0]>): DynamicSectionMockBuilder {
|
||||||
|
this.section.layouts.push({
|
||||||
|
layoutId: "1",
|
||||||
|
height: 1,
|
||||||
|
width: 1,
|
||||||
|
xOffset: 0,
|
||||||
|
yOffset: 0,
|
||||||
|
parentSectionId: "0",
|
||||||
|
...layout,
|
||||||
|
} satisfies DynamicSection["layouts"][0]);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public build(): DynamicSection {
|
||||||
|
return this.section;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { createId } from "@homarr/db";
|
||||||
|
|
||||||
|
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
||||||
|
|
||||||
|
export class EmptySectionMockBuilder {
|
||||||
|
private readonly section: EmptySection;
|
||||||
|
|
||||||
|
constructor(section?: Partial<EmptySection>) {
|
||||||
|
this.section = {
|
||||||
|
id: createId(),
|
||||||
|
kind: "empty",
|
||||||
|
xOffset: 0,
|
||||||
|
yOffset: 0,
|
||||||
|
...section,
|
||||||
|
} satisfies EmptySection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public build(): EmptySection {
|
||||||
|
return this.section;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { createId } from "@homarr/db";
|
||||||
|
|
||||||
|
import type { Item } from "~/app/[locale]/boards/_types";
|
||||||
|
|
||||||
|
export class ItemMockBuilder {
|
||||||
|
private readonly item: Item;
|
||||||
|
|
||||||
|
constructor(item?: Partial<Item>) {
|
||||||
|
this.item = {
|
||||||
|
id: createId(),
|
||||||
|
kind: "app",
|
||||||
|
options: {},
|
||||||
|
layouts: [],
|
||||||
|
integrationIds: [],
|
||||||
|
advancedOptions: {
|
||||||
|
customCssClasses: [],
|
||||||
|
},
|
||||||
|
...item,
|
||||||
|
} satisfies Item;
|
||||||
|
}
|
||||||
|
|
||||||
|
public addLayout(layout?: Partial<Item["layouts"][0]>): ItemMockBuilder {
|
||||||
|
this.item.layouts.push({
|
||||||
|
layoutId: "1",
|
||||||
|
height: 1,
|
||||||
|
width: 1,
|
||||||
|
xOffset: 0,
|
||||||
|
yOffset: 0,
|
||||||
|
sectionId: "0",
|
||||||
|
...layout,
|
||||||
|
} satisfies Item["layouts"][0]);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public build(): Item {
|
||||||
|
return this.item;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { createId } from "@homarr/db";
|
||||||
|
|
||||||
|
import type { Board } from "~/app/[locale]/boards/_types";
|
||||||
|
|
||||||
|
export class LayoutMockBuilder {
|
||||||
|
private readonly layout: Board["layouts"][number];
|
||||||
|
|
||||||
|
constructor(layout?: Partial<Board["layouts"][number]>) {
|
||||||
|
this.layout = {
|
||||||
|
id: createId(),
|
||||||
|
name: "Base",
|
||||||
|
columnCount: 12,
|
||||||
|
breakpoint: 0,
|
||||||
|
...layout,
|
||||||
|
} satisfies Board["layouts"][0];
|
||||||
|
}
|
||||||
|
|
||||||
|
public build(): Board["layouts"][0] {
|
||||||
|
return this.layout;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import * as boardContext from "@homarr/boards/context";
|
||||||
|
|
||||||
|
import { moveAndResizeItemCallback } from "../move-and-resize-item";
|
||||||
|
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||||
|
import { ItemMockBuilder } from "./mocks/item-mock";
|
||||||
|
|
||||||
|
describe("moveItemToSectionCallback should move item in section", () => {
|
||||||
|
test("should move item in section", () => {
|
||||||
|
// Arrange
|
||||||
|
const itemToMove = "2";
|
||||||
|
const layoutId = "1";
|
||||||
|
const spy = vi.spyOn(boardContext, "getCurrentLayout");
|
||||||
|
spy.mockReturnValue(layoutId);
|
||||||
|
const newPosition = {
|
||||||
|
xOffset: 20,
|
||||||
|
yOffset: 30,
|
||||||
|
width: 15,
|
||||||
|
height: 17,
|
||||||
|
};
|
||||||
|
const itemA = new ItemMockBuilder().addLayout({ layoutId }).build();
|
||||||
|
const itemB = new ItemMockBuilder({ id: itemToMove }).addLayout({ layoutId }).addLayout().build();
|
||||||
|
const itemC = new ItemMockBuilder().addLayout({ layoutId }).build();
|
||||||
|
const board = new BoardMockBuilder().addItem(itemA).addItem(itemB).addItem(itemC).build();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, ...newPosition })(board);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(updatedBoard.items).toHaveLength(3);
|
||||||
|
const movedItem = updatedBoard.items.find((item) => item.id === itemToMove);
|
||||||
|
expect(movedItem).not.toBeUndefined();
|
||||||
|
expect(movedItem?.layouts.find((layout) => layout.layoutId === layoutId)).toEqual(
|
||||||
|
expect.objectContaining(newPosition),
|
||||||
|
);
|
||||||
|
const otherItemLayouts = updatedBoard.items
|
||||||
|
.filter((item) => item.id !== itemToMove)
|
||||||
|
.flatMap((item) => item.layouts);
|
||||||
|
expect(otherItemLayouts).not.toContainEqual(expect.objectContaining(newPosition));
|
||||||
|
});
|
||||||
|
test("should not move item if item not found", () => {
|
||||||
|
// Arrange
|
||||||
|
const itemToMove = "2";
|
||||||
|
const layoutId = "1";
|
||||||
|
const spy = vi.spyOn(boardContext, "getCurrentLayout");
|
||||||
|
spy.mockReturnValue(layoutId);
|
||||||
|
const newPosition = {
|
||||||
|
xOffset: 20,
|
||||||
|
yOffset: 30,
|
||||||
|
width: 15,
|
||||||
|
height: 17,
|
||||||
|
};
|
||||||
|
const itemA = new ItemMockBuilder().addLayout({ layoutId }).build();
|
||||||
|
const itemC = new ItemMockBuilder().addLayout({ layoutId }).build();
|
||||||
|
const board = new BoardMockBuilder().addItem(itemA).addItem(itemC).build();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, ...newPosition })(board);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(updatedBoard.items).toHaveLength(2);
|
||||||
|
expect(updatedBoard.items.find((item) => item.layouts.at(0)?.yOffset === newPosition.yOffset)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import * as boardContext from "@homarr/boards/context";
|
||||||
|
|
||||||
|
import { moveItemToSectionCallback } from "../move-item-to-section";
|
||||||
|
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||||
|
import { ItemMockBuilder } from "./mocks/item-mock";
|
||||||
|
|
||||||
|
describe("moveItemToSectionCallback should move item to section", () => {
|
||||||
|
test("should move item to section", () => {
|
||||||
|
// Arrange
|
||||||
|
const itemToMove = "2";
|
||||||
|
const layoutId = "1";
|
||||||
|
const spy = vi.spyOn(boardContext, "getCurrentLayout");
|
||||||
|
spy.mockReturnValue(layoutId);
|
||||||
|
const newPosition = {
|
||||||
|
sectionId: "3",
|
||||||
|
xOffset: 20,
|
||||||
|
yOffset: 30,
|
||||||
|
width: 15,
|
||||||
|
height: 17,
|
||||||
|
};
|
||||||
|
const itemA = new ItemMockBuilder().addLayout({ layoutId }).build();
|
||||||
|
const itemB = new ItemMockBuilder({ id: itemToMove }).addLayout({ layoutId }).addLayout().build();
|
||||||
|
const itemC = new ItemMockBuilder().addLayout({ layoutId }).build();
|
||||||
|
const board = new BoardMockBuilder().addItem(itemA).addItem(itemB).addItem(itemC).build();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, ...newPosition })(board);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(updatedBoard.items).toHaveLength(3);
|
||||||
|
const movedItem = updatedBoard.items.find((item) => item.id === itemToMove);
|
||||||
|
expect(movedItem).not.toBeUndefined();
|
||||||
|
expect(movedItem?.layouts.find((layout) => layout.layoutId === layoutId)).toEqual(
|
||||||
|
expect.objectContaining(newPosition),
|
||||||
|
);
|
||||||
|
const otherItemLayouts = updatedBoard.items
|
||||||
|
.filter((item) => item.id !== itemToMove)
|
||||||
|
.flatMap((item) => item.layouts);
|
||||||
|
expect(otherItemLayouts).not.toContainEqual(expect.objectContaining(newPosition));
|
||||||
|
});
|
||||||
|
test("should not move item if item not found", () => {
|
||||||
|
// Arrange
|
||||||
|
const itemToMove = "2";
|
||||||
|
const layoutId = "1";
|
||||||
|
const spy = vi.spyOn(boardContext, "getCurrentLayout");
|
||||||
|
spy.mockReturnValue(layoutId);
|
||||||
|
const newPosition = {
|
||||||
|
sectionId: "3",
|
||||||
|
xOffset: 20,
|
||||||
|
yOffset: 30,
|
||||||
|
width: 15,
|
||||||
|
height: 17,
|
||||||
|
};
|
||||||
|
const itemA = new ItemMockBuilder().addLayout({ layoutId }).build();
|
||||||
|
const itemC = new ItemMockBuilder().addLayout({ layoutId }).build();
|
||||||
|
const board = new BoardMockBuilder().addItem(itemA).addItem(itemC).build();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, ...newPosition })(board);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(updatedBoard.items).toHaveLength(2);
|
||||||
|
expect(updatedBoard.items.find((item) => item.layouts.at(0)?.sectionId === newPosition.sectionId)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { removeItemCallback } from "../remove-item";
|
||||||
|
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||||
|
|
||||||
|
describe("removeItemCallback should remove item from board", () => {
|
||||||
|
test("should remove correct item from board", () => {
|
||||||
|
// Arrange
|
||||||
|
const itemIdToRemove = "2";
|
||||||
|
const board = new BoardMockBuilder()
|
||||||
|
.addItem({ id: "1" })
|
||||||
|
.addItem({ id: itemIdToRemove })
|
||||||
|
.addItem({ id: "3" })
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const updatedBoard = removeItemCallback({ itemId: itemIdToRemove })(board);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
const itemIds = updatedBoard.items.map((item) => item.id);
|
||||||
|
expect(itemIds).toHaveLength(2);
|
||||||
|
expect(itemIds).not.toContain(itemIdToRemove);
|
||||||
|
});
|
||||||
|
test("should not remove item if item not found", () => {
|
||||||
|
// Arrange
|
||||||
|
const itemIdToRemove = "2";
|
||||||
|
const board = new BoardMockBuilder().addItem({ id: "1" }).addItem({ id: "3" }).build();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const updatedBoard = removeItemCallback({ itemId: itemIdToRemove })(board);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
const itemIds = updatedBoard.items.map((item) => item.id);
|
||||||
|
expect(itemIds).toHaveLength(2);
|
||||||
|
expect(itemIds).not.toContain(itemIdToRemove);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import type { DynamicSection, EmptySection, Item } from "~/app/[locale]/boards/_types";
|
|
||||||
|
|
||||||
export const createEmptySection = (id: string, yOffset: number): EmptySection => ({
|
|
||||||
id,
|
|
||||||
kind: "empty",
|
|
||||||
yOffset,
|
|
||||||
xOffset: 0,
|
|
||||||
items: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const createDynamicSection = (section: Omit<Partial<DynamicSection>, "kind">): DynamicSection => ({
|
|
||||||
id: section.id ?? "0",
|
|
||||||
kind: "dynamic",
|
|
||||||
parentSectionId: section.parentSectionId ?? "0",
|
|
||||||
height: section.height ?? 1,
|
|
||||||
width: section.width ?? 1,
|
|
||||||
yOffset: section.yOffset ?? 0,
|
|
||||||
xOffset: section.xOffset ?? 0,
|
|
||||||
items: section.items ?? [],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const createItem = (item: Partial<Item>): Item => ({
|
|
||||||
id: item.id ?? "0",
|
|
||||||
width: item.width ?? 1,
|
|
||||||
height: item.height ?? 1,
|
|
||||||
yOffset: item.yOffset ?? 0,
|
|
||||||
xOffset: item.xOffset ?? 0,
|
|
||||||
kind: item.kind ?? "clock",
|
|
||||||
integrationIds: item.integrationIds ?? [],
|
|
||||||
options: item.options ?? {},
|
|
||||||
advancedOptions: item.advancedOptions ?? { customCssClasses: [] },
|
|
||||||
});
|
|
||||||
@@ -3,30 +3,16 @@ import { useCallback } from "react";
|
|||||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||||
import type { BoardItemAdvancedOptions } from "@homarr/validation";
|
import type { BoardItemAdvancedOptions } from "@homarr/validation";
|
||||||
|
|
||||||
import type { Item } from "~/app/[locale]/boards/_types";
|
|
||||||
import type { CreateItemInput } from "./actions/create-item";
|
import type { CreateItemInput } from "./actions/create-item";
|
||||||
import { createItemCallback } from "./actions/create-item";
|
import { createItemCallback } from "./actions/create-item";
|
||||||
import type { DuplicateItemInput } from "./actions/duplicate-item";
|
import type { DuplicateItemInput } from "./actions/duplicate-item";
|
||||||
import { duplicateItemCallback } from "./actions/duplicate-item";
|
import { duplicateItemCallback } from "./actions/duplicate-item";
|
||||||
|
import type { MoveAndResizeItemInput } from "./actions/move-and-resize-item";
|
||||||
interface MoveAndResizeItem {
|
import { moveAndResizeItemCallback } from "./actions/move-and-resize-item";
|
||||||
itemId: string;
|
import type { MoveItemToSectionInput } from "./actions/move-item-to-section";
|
||||||
xOffset: number;
|
import { moveItemToSectionCallback } from "./actions/move-item-to-section";
|
||||||
yOffset: number;
|
import type { RemoveItemInput } from "./actions/remove-item";
|
||||||
width: number;
|
import { removeItemCallback } from "./actions/remove-item";
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
interface MoveItemToSection {
|
|
||||||
itemId: string;
|
|
||||||
sectionId: string;
|
|
||||||
xOffset: number;
|
|
||||||
yOffset: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
interface RemoveItem {
|
|
||||||
itemId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UpdateItemOptions {
|
interface UpdateItemOptions {
|
||||||
itemId: string;
|
itemId: string;
|
||||||
@@ -62,164 +48,55 @@ export const useItemActions = () => {
|
|||||||
|
|
||||||
const updateItemOptions = useCallback(
|
const updateItemOptions = useCallback(
|
||||||
({ itemId, newOptions }: UpdateItemOptions) => {
|
({ itemId, newOptions }: UpdateItemOptions) => {
|
||||||
updateBoard((previous) => {
|
updateBoard((previous) => ({
|
||||||
return {
|
...previous,
|
||||||
...previous,
|
items: previous.items.map((item) => (item.id !== itemId ? item : { ...item, options: newOptions })),
|
||||||
sections: previous.sections.map((section) => {
|
}));
|
||||||
// Return same section if item is not in it
|
|
||||||
if (!section.items.some((item) => item.id === itemId)) return section;
|
|
||||||
return {
|
|
||||||
...section,
|
|
||||||
items: section.items.map((item) => {
|
|
||||||
// Return same item if item is not the one we're changing
|
|
||||||
if (item.id !== itemId) return item;
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
options: newOptions,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[updateBoard],
|
[updateBoard],
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateItemAdvancedOptions = useCallback(
|
const updateItemAdvancedOptions = useCallback(
|
||||||
({ itemId, newAdvancedOptions }: UpdateItemAdvancedOptions) => {
|
({ itemId, newAdvancedOptions }: UpdateItemAdvancedOptions) => {
|
||||||
updateBoard((previous) => {
|
updateBoard((previous) => ({
|
||||||
return {
|
...previous,
|
||||||
...previous,
|
items: previous.items.map((item) =>
|
||||||
sections: previous.sections.map((section) => {
|
item.id !== itemId ? item : { ...item, advancedOptions: newAdvancedOptions },
|
||||||
// Return same section if item is not in it
|
),
|
||||||
if (!section.items.some((item) => item.id === itemId)) return section;
|
}));
|
||||||
return {
|
|
||||||
...section,
|
|
||||||
items: section.items.map((item) => {
|
|
||||||
// Return same item if item is not the one we're changing
|
|
||||||
if (item.id !== itemId) return item;
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
advancedOptions: newAdvancedOptions,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[updateBoard],
|
[updateBoard],
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateItemIntegrations = useCallback(
|
const updateItemIntegrations = useCallback(
|
||||||
({ itemId, newIntegrations }: UpdateItemIntegrations) => {
|
({ itemId, newIntegrations }: UpdateItemIntegrations) => {
|
||||||
updateBoard((previous) => {
|
|
||||||
return {
|
|
||||||
...previous,
|
|
||||||
sections: previous.sections.map((section) => {
|
|
||||||
// Return same section if item is not in it
|
|
||||||
if (!section.items.some((item) => item.id === itemId)) return section;
|
|
||||||
return {
|
|
||||||
...section,
|
|
||||||
items: section.items.map((item) => {
|
|
||||||
// Return same item if item is not the one we're moving
|
|
||||||
if (item.id !== itemId) return item;
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
...("integrationIds" in item ? { integrationIds: newIntegrations } : {}),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[updateBoard],
|
|
||||||
);
|
|
||||||
|
|
||||||
const moveAndResizeItem = useCallback(
|
|
||||||
({ itemId, ...positionProps }: MoveAndResizeItem) => {
|
|
||||||
updateBoard((previous) => ({
|
updateBoard((previous) => ({
|
||||||
...previous,
|
...previous,
|
||||||
sections: previous.sections.map((section) => {
|
items: previous.items.map((item) =>
|
||||||
// Return same section if item is not in it
|
item.id !== itemId || !("integrationIds" in item) ? item : { ...item, integrationIds: newIntegrations },
|
||||||
if (!section.items.some((item) => item.id === itemId)) return section;
|
),
|
||||||
return {
|
|
||||||
...section,
|
|
||||||
items: section.items.map((item) => {
|
|
||||||
// Return same item if item is not the one we're moving
|
|
||||||
if (item.id !== itemId) return item;
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
...positionProps,
|
|
||||||
} satisfies Item;
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
[updateBoard],
|
[updateBoard],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const moveAndResizeItem = useCallback(
|
||||||
|
(input: MoveAndResizeItemInput) => {
|
||||||
|
updateBoard(moveAndResizeItemCallback(input));
|
||||||
|
},
|
||||||
|
[updateBoard],
|
||||||
|
);
|
||||||
|
|
||||||
const moveItemToSection = useCallback(
|
const moveItemToSection = useCallback(
|
||||||
({ itemId, sectionId, ...positionProps }: MoveItemToSection) => {
|
(input: MoveItemToSectionInput) => {
|
||||||
updateBoard((previous) => {
|
updateBoard(moveItemToSectionCallback(input));
|
||||||
const currentSection = previous.sections.find((section) => section.items.some((item) => item.id === itemId));
|
|
||||||
|
|
||||||
// If item is in the same section (on initial loading) don't do anything
|
|
||||||
if (!currentSection) {
|
|
||||||
return previous;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentItem = currentSection.items.find((item) => item.id === itemId);
|
|
||||||
if (!currentItem) {
|
|
||||||
return previous;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentSection.id === sectionId && currentItem.xOffset) {
|
|
||||||
return previous;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...previous,
|
|
||||||
sections: previous.sections.map((section) => {
|
|
||||||
// Return sections without item if not section where it is moved to
|
|
||||||
if (section.id !== sectionId)
|
|
||||||
return {
|
|
||||||
...section,
|
|
||||||
items: section.items.filter((item) => item.id !== itemId),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Return section and add item to it
|
|
||||||
return {
|
|
||||||
...section,
|
|
||||||
items: section.items
|
|
||||||
.filter((item) => item.id !== itemId)
|
|
||||||
.concat({
|
|
||||||
...currentItem,
|
|
||||||
...positionProps,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[updateBoard],
|
[updateBoard],
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeItem = useCallback(
|
const removeItem = useCallback(
|
||||||
({ itemId }: RemoveItem) => {
|
({ itemId }: RemoveItemInput) => {
|
||||||
updateBoard((previous) => {
|
updateBoard(removeItemCallback({ itemId }));
|
||||||
return {
|
|
||||||
...previous,
|
|
||||||
// Filter removed item out of items array
|
|
||||||
sections: previous.sections.map((section) => ({
|
|
||||||
...section,
|
|
||||||
items: section.items.filter((item) => item.id !== itemId),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[updateBoard],
|
[updateBoard],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ import { useSettings } from "@homarr/settings";
|
|||||||
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||||
import { WidgetError } from "@homarr/widgets/errors";
|
import { WidgetError } from "@homarr/widgets/errors";
|
||||||
|
|
||||||
import type { Item } from "~/app/[locale]/boards/_types";
|
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||||
import classes from "../sections/item.module.css";
|
import classes from "../sections/item.module.css";
|
||||||
import { useItemActions } from "./item-actions";
|
import { useItemActions } from "./item-actions";
|
||||||
import { BoardItemMenu } from "./item-menu";
|
import { BoardItemMenu } from "./item-menu";
|
||||||
|
|
||||||
interface BoardItemContentProps {
|
interface BoardItemContentProps {
|
||||||
item: Item;
|
item: SectionItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||||
@@ -50,7 +50,7 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface InnerContentProps {
|
interface InnerContentProps {
|
||||||
item: Item;
|
item: SectionItem;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
|||||||
import { widgetImports } from "@homarr/widgets";
|
import { widgetImports } from "@homarr/widgets";
|
||||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||||
|
|
||||||
import type { Item } from "~/app/[locale]/boards/_types";
|
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||||
import { useSectionContext } from "../sections/section-context";
|
import { useSectionContext } from "../sections/section-context";
|
||||||
import { useItemActions } from "./item-actions";
|
import { useItemActions } from "./item-actions";
|
||||||
import { ItemMoveModal } from "./item-move-modal";
|
import { ItemMoveModal } from "./item-move-modal";
|
||||||
@@ -21,7 +21,7 @@ export const BoardItemMenu = ({
|
|||||||
resetErrorBoundary,
|
resetErrorBoundary,
|
||||||
}: {
|
}: {
|
||||||
offset: number;
|
offset: number;
|
||||||
item: Item;
|
item: SectionItem;
|
||||||
resetErrorBoundary?: () => void;
|
resetErrorBoundary?: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const refResetErrorBoundaryOnNextRender = useRef(false);
|
const refResetErrorBoundaryOnNextRender = useRef(false);
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import type { GridStack } from "@homarr/gridstack";
|
|||||||
import { createModal } from "@homarr/modals";
|
import { createModal } from "@homarr/modals";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { Item } from "~/app/[locale]/boards/_types";
|
import type { Item, SectionItem } from "~/app/[locale]/boards/_types";
|
||||||
|
|
||||||
interface InnerProps {
|
interface InnerProps {
|
||||||
gridStack: GridStack;
|
gridStack: GridStack;
|
||||||
item: Pick<Item, "id" | "xOffset" | "yOffset" | "width" | "height">;
|
item: Pick<SectionItem, "id" | "width" | "height" | "xOffset" | "yOffset">;
|
||||||
columnCount: number;
|
columnCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(values: Omit<InnerProps["item"], "id">) => {
|
(values: Pick<Item["layouts"][number], "height" | "width" | "xOffset" | "yOffset">) => {
|
||||||
const gridItem = innerProps.gridStack
|
const gridItem = innerProps.gridStack
|
||||||
.getGridItems()
|
.getGridItems()
|
||||||
.find((item) => item.getAttribute("data-id") === innerProps.item.id);
|
.find((item) => item.getAttribute("data-id") === innerProps.item.id);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { Board, CategorySection, DynamicSection, EmptySection, Section } from "~/app/[locale]/boards/_types";
|
import { getBoardLayouts } from "@homarr/boards/context";
|
||||||
|
|
||||||
|
import type { Board, CategorySection, EmptySection, Section } from "~/app/[locale]/boards/_types";
|
||||||
|
|
||||||
export interface RemoveCategoryInput {
|
export interface RemoveCategoryInput {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -28,84 +30,121 @@ export const removeCategoryCallback =
|
|||||||
return previous;
|
return previous;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate the yOffset for the items in the currentCategory and removedWrapper to add them with the same offset to the aboveWrapper
|
const aboveYOffsets = getBoardLayouts(previous).map((layoutId) => {
|
||||||
const aboveYOffset = Math.max(
|
return {
|
||||||
calculateYHeightWithOffsetForItems(aboveSection),
|
layoutId,
|
||||||
calculateYHeightWithOffsetForDynamicSections(previous.sections, aboveSection.id),
|
yOffset: Math.max(
|
||||||
);
|
calculateYHeightWithOffsetForItemLayouts(previous, { sectionId: aboveSection.id, layoutId }),
|
||||||
const categoryYOffset = Math.max(
|
calculateYHeightWithOffsetForDynamicSectionLayouts(previous.sections, {
|
||||||
calculateYHeightWithOffsetForItems(currentCategory),
|
sectionId: aboveSection.id,
|
||||||
calculateYHeightWithOffsetForDynamicSections(previous.sections, currentCategory.id),
|
layoutId,
|
||||||
);
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const previousCategoryItems = currentCategory.items.map((item) => ({
|
const categoryYOffsets = getBoardLayouts(previous).map((layoutId) => {
|
||||||
...item,
|
return {
|
||||||
yOffset: item.yOffset + aboveYOffset,
|
layoutId,
|
||||||
}));
|
yOffset: Math.max(
|
||||||
const previousBelowWrapperItems = removedSection.items.map((item) => ({
|
calculateYHeightWithOffsetForItemLayouts(previous, { sectionId: currentCategory.id, layoutId }),
|
||||||
...item,
|
calculateYHeightWithOffsetForDynamicSectionLayouts(previous.sections, {
|
||||||
yOffset: item.yOffset + aboveYOffset + categoryYOffset,
|
sectionId: currentCategory.id,
|
||||||
}));
|
layoutId,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...previous,
|
...previous,
|
||||||
sections: [
|
sections: previous.sections
|
||||||
...previous.sections.filter((section) => section.yOffset < aboveSection.yOffset && section.kind !== "dynamic"),
|
.filter((section) => section.id !== currentCategory.id && section.id !== removedSection.id)
|
||||||
{
|
.map((section) =>
|
||||||
...aboveSection,
|
section.kind === "dynamic"
|
||||||
items: [...aboveSection.items, ...previousCategoryItems, ...previousBelowWrapperItems],
|
? {
|
||||||
},
|
...section,
|
||||||
...previous.sections
|
layouts: section.layouts.map((layout) => {
|
||||||
.filter(
|
const aboveYOffset = aboveYOffsets.find(({ layoutId }) => layout.layoutId === layoutId)?.yOffset ?? 0;
|
||||||
(section): section is CategorySection | EmptySection =>
|
const categoryYOffset =
|
||||||
section.yOffset > removedSection.yOffset && section.kind !== "dynamic",
|
categoryYOffsets.find(({ layoutId }) => layout.layoutId === layoutId)?.yOffset ?? 0;
|
||||||
)
|
|
||||||
.map((section) => ({
|
|
||||||
...section,
|
|
||||||
position: section.yOffset - 2,
|
|
||||||
})),
|
|
||||||
...previous.sections
|
|
||||||
.filter((section): section is DynamicSection => section.kind === "dynamic")
|
|
||||||
.map((dynamicSection) => {
|
|
||||||
// Move dynamic sections from removed section to above section with required yOffset
|
|
||||||
if (dynamicSection.parentSectionId === removedSection.id) {
|
|
||||||
return {
|
|
||||||
...dynamicSection,
|
|
||||||
yOffset: dynamicSection.yOffset + aboveYOffset + categoryYOffset,
|
|
||||||
parentSectionId: aboveSection.id,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move dynamic sections from category to above section with required yOffset
|
if (layout.parentSectionId === currentCategory.id) {
|
||||||
if (dynamicSection.parentSectionId === currentCategory.id) {
|
return {
|
||||||
return {
|
...layout,
|
||||||
...dynamicSection,
|
yOffset: layout.yOffset + aboveYOffset,
|
||||||
yOffset: dynamicSection.yOffset + aboveYOffset,
|
parentSectionId: aboveSection.id,
|
||||||
parentSectionId: aboveSection.id,
|
};
|
||||||
};
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return dynamicSection;
|
if (layout.parentSectionId === removedSection.id) {
|
||||||
}),
|
return {
|
||||||
],
|
...layout,
|
||||||
|
yOffset: layout.yOffset + aboveYOffset + categoryYOffset,
|
||||||
|
parentSectionId: aboveSection.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return layout;
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: section,
|
||||||
|
),
|
||||||
|
|
||||||
|
items: previous.items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
layouts: item.layouts.map((layout) => {
|
||||||
|
const aboveYOffset = aboveYOffsets.find(({ layoutId }) => layout.layoutId === layoutId)?.yOffset ?? 0;
|
||||||
|
const categoryYOffset = categoryYOffsets.find(({ layoutId }) => layout.layoutId === layoutId)?.yOffset ?? 0;
|
||||||
|
|
||||||
|
if (layout.sectionId === currentCategory.id) {
|
||||||
|
return {
|
||||||
|
...layout,
|
||||||
|
yOffset: layout.yOffset + aboveYOffset,
|
||||||
|
sectionId: aboveSection.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layout.sectionId === removedSection.id) {
|
||||||
|
return {
|
||||||
|
...layout,
|
||||||
|
yOffset: layout.yOffset + aboveYOffset + categoryYOffset,
|
||||||
|
sectionId: aboveSection.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return layout;
|
||||||
|
}),
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateYHeightWithOffsetForDynamicSections = (sections: Section[], sectionId: string) => {
|
const calculateYHeightWithOffsetForDynamicSectionLayouts = (
|
||||||
return sections.reduce((acc, section) => {
|
sections: Section[],
|
||||||
if (section.kind !== "dynamic" || section.parentSectionId !== sectionId) {
|
{ sectionId, layoutId }: { sectionId: string; layoutId: string },
|
||||||
|
) => {
|
||||||
|
return sections
|
||||||
|
.filter((section) => section.kind === "dynamic")
|
||||||
|
.map((section) => section.layouts.find((layout) => layout.layoutId === layoutId))
|
||||||
|
.filter((layout) => layout !== undefined)
|
||||||
|
.filter((layout) => layout.parentSectionId === sectionId)
|
||||||
|
.reduce((acc, layout) => {
|
||||||
|
const yHeightWithOffset = layout.yOffset + layout.height;
|
||||||
|
if (yHeightWithOffset > acc) return yHeightWithOffset;
|
||||||
return acc;
|
return acc;
|
||||||
}
|
}, 0);
|
||||||
|
|
||||||
const yHeightWithOffset = section.yOffset + section.height;
|
|
||||||
if (yHeightWithOffset > acc) return yHeightWithOffset;
|
|
||||||
return acc;
|
|
||||||
}, 0);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateYHeightWithOffsetForItems = (section: Section) =>
|
const calculateYHeightWithOffsetForItemLayouts = (
|
||||||
section.items.reduce((acc, item) => {
|
board: Board,
|
||||||
const yHeightWithOffset = item.yOffset + item.height;
|
{ sectionId, layoutId }: { sectionId: string; layoutId: string },
|
||||||
if (yHeightWithOffset > acc) return yHeightWithOffset;
|
) =>
|
||||||
return acc;
|
board.items
|
||||||
}, 0);
|
.map((item) => item.layouts.find((layout) => layout.layoutId === layoutId))
|
||||||
|
.filter((layout) => layout !== undefined)
|
||||||
|
.filter((layout) => layout.sectionId === sectionId)
|
||||||
|
.reduce((acc, layout) => {
|
||||||
|
const yHeightWithOffset = layout.yOffset + layout.height;
|
||||||
|
if (yHeightWithOffset > acc) return yHeightWithOffset;
|
||||||
|
return acc;
|
||||||
|
}, 0);
|
||||||
|
|||||||
@@ -73,5 +73,7 @@ const createSections = (categoryCount: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sortSections = (sections: Section[]) => {
|
const sortSections = (sections: Section[]) => {
|
||||||
return sections.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
|
return sections
|
||||||
|
.filter((section) => section.kind !== "dynamic")
|
||||||
|
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import type { DynamicSection, Item, Section } from "~/app/[locale]/boards/_types";
|
import * as boardContext from "@homarr/boards/context";
|
||||||
|
|
||||||
|
import type { DynamicSection, Section } from "~/app/[locale]/boards/_types";
|
||||||
|
import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
|
||||||
|
import { CategorySectionMockBuilder } from "~/components/board/items/actions/test/mocks/category-section-mock";
|
||||||
|
import { DynamicSectionMockBuilder } from "~/components/board/items/actions/test/mocks/dynamic-section-mock";
|
||||||
|
import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
|
||||||
|
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
|
||||||
import { removeCategoryCallback } from "../remove-category";
|
import { removeCategoryCallback } from "../remove-category";
|
||||||
|
|
||||||
describe("Remove Category", () => {
|
describe("Remove Category", () => {
|
||||||
@@ -13,114 +20,126 @@ describe("Remove Category", () => {
|
|||||||
])(
|
])(
|
||||||
"should remove category",
|
"should remove category",
|
||||||
(removeId, initialYOffsets, expectedYOffsets, expectedRemovals, expectedLocationOfItems) => {
|
(removeId, initialYOffsets, expectedYOffsets, expectedRemovals, expectedLocationOfItems) => {
|
||||||
const sections = createSections(initialYOffsets);
|
// Arrange
|
||||||
|
const layoutId = "1";
|
||||||
const input = removeId.toString();
|
const input = removeId.toString();
|
||||||
|
|
||||||
const result = removeCategoryCallback({ id: input })({ sections } as never);
|
const board = new BoardMockBuilder()
|
||||||
|
.addLayout({ id: layoutId })
|
||||||
|
.addSections(createSections(initialYOffsets))
|
||||||
|
.addItems(createSectionItems(initialYOffsets, layoutId))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
vi.spyOn(boardContext, "getBoardLayouts").mockReturnValue([layoutId]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = removeCategoryCallback({ id: input })(board);
|
||||||
|
|
||||||
|
// Assert
|
||||||
expect(result.sections.map((section) => parseInt(section.id, 10))).toEqual(expectedYOffsets);
|
expect(result.sections.map((section) => parseInt(section.id, 10))).toEqual(expectedYOffsets);
|
||||||
expectedRemovals.forEach((expectedRemoval) => {
|
expectedRemovals.forEach((expectedRemoval) => {
|
||||||
expect(result.sections.find((section) => section.id === expectedRemoval.toString())).toBeUndefined();
|
expect(result.sections.find((section) => section.id === expectedRemoval.toString())).toBeUndefined();
|
||||||
});
|
});
|
||||||
const aboveSection = result.sections.find((section) => section.id === expectedLocationOfItems.toString());
|
const aboveSectionItems = result.items.filter(
|
||||||
expect(aboveSection?.items.map((item) => parseInt(item.id, 10))).toEqual(
|
(item) => item.layouts[0]?.sectionId === expectedLocationOfItems.toString(),
|
||||||
expect.arrayContaining(expectedRemovals),
|
|
||||||
);
|
);
|
||||||
|
expect(aboveSectionItems.map((item) => parseInt(item.id, 10))).toEqual(expect.arrayContaining(expectedRemovals));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
test("should correctly move items to above empty section", () => {
|
test("should correctly move items to above empty section", () => {
|
||||||
|
// Arrange
|
||||||
|
const layoutId = "1";
|
||||||
|
const sectionIds = {
|
||||||
|
above: "2",
|
||||||
|
category: "3",
|
||||||
|
below: "4",
|
||||||
|
dynamic: "7",
|
||||||
|
};
|
||||||
const initialYOffsets = [0, 1, 2, 3, 4, 5, 6];
|
const initialYOffsets = [0, 1, 2, 3, 4, 5, 6];
|
||||||
const sections: Section[] = createSections(initialYOffsets);
|
|
||||||
const aboveSection = sections.find((section) => section.yOffset === 2)!;
|
|
||||||
aboveSection.items = [
|
|
||||||
createItem({ id: "above-1" }),
|
|
||||||
createItem({ id: "above-2", yOffset: 3, xOffset: 2, height: 2 }),
|
|
||||||
];
|
|
||||||
const removedCategory = sections.find((section) => section.yOffset === 3)!;
|
|
||||||
removedCategory.items = [
|
|
||||||
createItem({ id: "category-1" }),
|
|
||||||
createItem({ id: "category-2", yOffset: 2, xOffset: 4, width: 4 }),
|
|
||||||
];
|
|
||||||
const removedEmptySection = sections.find((section) => section.yOffset === 4)!;
|
|
||||||
removedEmptySection.items = [
|
|
||||||
createItem({ id: "below-1", xOffset: 5 }),
|
|
||||||
createItem({ id: "below-2", yOffset: 1, xOffset: 1, height: 2 }),
|
|
||||||
];
|
|
||||||
sections.push(
|
|
||||||
createDynamicSection({
|
|
||||||
id: "7",
|
|
||||||
parentSectionId: "3",
|
|
||||||
yOffset: 7,
|
|
||||||
height: 3,
|
|
||||||
items: [createItem({ id: "dynamic-1" })],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const input = "3";
|
const board = new BoardMockBuilder()
|
||||||
|
.addLayout({ id: layoutId })
|
||||||
|
.addSections(createSections(initialYOffsets))
|
||||||
|
.addItems(createSectionItems([0, 1, 5, 6], layoutId)) // Only add items to other sections
|
||||||
|
.addDynamicSection(
|
||||||
|
new DynamicSectionMockBuilder({ id: sectionIds.dynamic })
|
||||||
|
.addLayout({ layoutId, parentSectionId: sectionIds.category, yOffset: 7, height: 3 })
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.addItem(new ItemMockBuilder({ id: "above-1" }).addLayout({ layoutId, sectionId: sectionIds.above }).build())
|
||||||
|
.addItem(
|
||||||
|
new ItemMockBuilder({ id: "above-2" })
|
||||||
|
.addLayout({ layoutId, sectionId: sectionIds.above, yOffset: 3, xOffset: 2, height: 2 })
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.addItem(
|
||||||
|
new ItemMockBuilder({ id: "category-1" }).addLayout({ layoutId, sectionId: sectionIds.category }).build(),
|
||||||
|
)
|
||||||
|
.addItem(
|
||||||
|
new ItemMockBuilder({ id: "category-2" })
|
||||||
|
.addLayout({ layoutId, sectionId: sectionIds.category, yOffset: 2, xOffset: 4, width: 4 })
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.addItem(
|
||||||
|
new ItemMockBuilder({ id: "below-1" }).addLayout({ layoutId, sectionId: sectionIds.below, xOffset: 5 }).build(),
|
||||||
|
)
|
||||||
|
.addItem(
|
||||||
|
new ItemMockBuilder({ id: "below-2" })
|
||||||
|
.addLayout({ layoutId, sectionId: sectionIds.below, yOffset: 1, xOffset: 1, height: 2 })
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.addItem(new ItemMockBuilder({ id: "dynamic-1" }).addLayout({ layoutId, sectionId: sectionIds.dynamic }).build())
|
||||||
|
.build();
|
||||||
|
|
||||||
const result = removeCategoryCallback({ id: input })({ sections } as never);
|
vi.spyOn(boardContext, "getBoardLayouts").mockReturnValue([layoutId]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = removeCategoryCallback({ id: sectionIds.category })(board);
|
||||||
|
|
||||||
|
// Assert
|
||||||
expect(result.sections.map((section) => parseInt(section.id, 10))).toEqual([0, 1, 2, 5, 6, 7]);
|
expect(result.sections.map((section) => parseInt(section.id, 10))).toEqual([0, 1, 2, 5, 6, 7]);
|
||||||
const aboveSectionResult = result.sections.find((section) => section.id === "2")!;
|
const aboveSectionItems = result.items.filter((item) => item.layouts[0]?.sectionId === sectionIds.above);
|
||||||
expect(aboveSectionResult.items).toEqual(
|
expect(aboveSectionItems.length).toBe(6);
|
||||||
expect.arrayContaining([
|
|
||||||
createItem({ id: "above-1" }),
|
expect(
|
||||||
createItem({ id: "above-2", yOffset: 3, xOffset: 2, height: 2 }),
|
aboveSectionItems
|
||||||
createItem({ id: "category-1", yOffset: 5 }),
|
.map((item) => ({
|
||||||
createItem({ id: "category-2", yOffset: 7, xOffset: 4, width: 4 }),
|
...item,
|
||||||
createItem({ id: "below-1", yOffset: 15, xOffset: 5 }),
|
...item.layouts[0]!,
|
||||||
createItem({ id: "below-2", yOffset: 16, xOffset: 1, height: 2 }),
|
}))
|
||||||
]),
|
.sort((itemA, itemB) => itemA.yOffset - itemB.yOffset),
|
||||||
);
|
).toEqual([
|
||||||
|
expect.objectContaining({ id: "above-1", yOffset: 0, xOffset: 0 }),
|
||||||
|
expect.objectContaining({ id: "above-2", yOffset: 3, xOffset: 2, height: 2 }),
|
||||||
|
expect.objectContaining({ id: "category-1", yOffset: 5, xOffset: 0 }),
|
||||||
|
expect.objectContaining({ id: "category-2", yOffset: 7, xOffset: 4, width: 4 }),
|
||||||
|
expect.objectContaining({ id: "below-1", yOffset: 15, xOffset: 5 }),
|
||||||
|
expect.objectContaining({ id: "below-2", yOffset: 16, xOffset: 1, height: 2 }),
|
||||||
|
]);
|
||||||
|
|
||||||
const dynamicSection = result.sections.find((section): section is DynamicSection => section.id === "7")!;
|
const dynamicSection = result.sections.find((section): section is DynamicSection => section.id === "7")!;
|
||||||
expect(dynamicSection.yOffset).toBe(12);
|
expect(dynamicSection.layouts.at(0)?.yOffset).toBe(12);
|
||||||
expect(dynamicSection.parentSectionId).toBe("2");
|
expect(dynamicSection.layouts[0]?.parentSectionId).toBe("2");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const createItem = (item: Partial<{ id: string; width: number; height: number; yOffset: number; xOffset: number }>) => {
|
|
||||||
return {
|
|
||||||
id: item.id ?? "0",
|
|
||||||
kind: "app",
|
|
||||||
options: {},
|
|
||||||
advancedOptions: {
|
|
||||||
customCssClasses: [],
|
|
||||||
},
|
|
||||||
height: item.height ?? 1,
|
|
||||||
width: item.width ?? 1,
|
|
||||||
yOffset: item.yOffset ?? 0,
|
|
||||||
xOffset: item.xOffset ?? 0,
|
|
||||||
integrationIds: [],
|
|
||||||
} satisfies Item;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createDynamicSection = (
|
|
||||||
section: Partial<
|
|
||||||
Pick<DynamicSection, "id" | "height" | "width" | "yOffset" | "xOffset" | "parentSectionId" | "items">
|
|
||||||
>,
|
|
||||||
) => {
|
|
||||||
return {
|
|
||||||
id: section.id ?? "0",
|
|
||||||
kind: "dynamic",
|
|
||||||
height: section.height ?? 1,
|
|
||||||
width: section.width ?? 1,
|
|
||||||
yOffset: section.yOffset ?? 0,
|
|
||||||
xOffset: section.xOffset ?? 0,
|
|
||||||
parentSectionId: section.parentSectionId ?? "0",
|
|
||||||
items: section.items ?? [],
|
|
||||||
} satisfies DynamicSection;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createSections = (initialYOffsets: number[]) => {
|
const createSections = (initialYOffsets: number[]) => {
|
||||||
return initialYOffsets.map((yOffset, index) => ({
|
return initialYOffsets.map((yOffset, index) =>
|
||||||
id: yOffset.toString(),
|
index % 2 === 0
|
||||||
kind: index % 2 === 0 ? "empty" : "category",
|
? new EmptySectionMockBuilder({
|
||||||
name: "Category",
|
id: yOffset.toString(),
|
||||||
collapsed: false,
|
yOffset,
|
||||||
yOffset,
|
}).build()
|
||||||
xOffset: 0,
|
: new CategorySectionMockBuilder({
|
||||||
items: [createItem({ id: yOffset.toString() })],
|
id: yOffset.toString(),
|
||||||
})) satisfies Section[];
|
yOffset,
|
||||||
|
}).build(),
|
||||||
|
) satisfies Section[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const createSectionItems = (initialYOffsets: number[], layoutId: string) => {
|
||||||
|
return initialYOffsets.map((yOffset) =>
|
||||||
|
new ItemMockBuilder({ id: yOffset.toString() }).addLayout({ layoutId, sectionId: yOffset.toString() }).build(),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useCallback } from "react";
|
|||||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||||
import { createId } from "@homarr/db/client";
|
import { createId } from "@homarr/db/client";
|
||||||
|
|
||||||
import type { CategorySection, EmptySection } from "~/app/[locale]/boards/_types";
|
import type { CategorySection, EmptySection, Section } from "~/app/[locale]/boards/_types";
|
||||||
import type { MoveCategoryInput } from "./actions/move-category";
|
import type { MoveCategoryInput } from "./actions/move-category";
|
||||||
import { moveCategoryCallback } from "./actions/move-category";
|
import { moveCategoryCallback } from "./actions/move-category";
|
||||||
import type { RemoveCategoryInput } from "./actions/remove-category";
|
import type { RemoveCategoryInput } from "./actions/remove-category";
|
||||||
@@ -41,14 +41,12 @@ export const useCategoryActions = () => {
|
|||||||
yOffset,
|
yOffset,
|
||||||
xOffset: 0,
|
xOffset: 0,
|
||||||
collapsed: false,
|
collapsed: false,
|
||||||
items: [],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
kind: "empty",
|
kind: "empty",
|
||||||
yOffset: yOffset + 1,
|
yOffset: yOffset + 1,
|
||||||
xOffset: 0,
|
xOffset: 0,
|
||||||
items: [],
|
|
||||||
},
|
},
|
||||||
// Place sections after the new category
|
// Place sections after the new category
|
||||||
...previous.sections
|
...previous.sections
|
||||||
@@ -60,7 +58,7 @@ export const useCategoryActions = () => {
|
|||||||
...section,
|
...section,
|
||||||
yOffset: section.yOffset + 2,
|
yOffset: section.yOffset + 2,
|
||||||
})),
|
})),
|
||||||
],
|
] satisfies Section[],
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
[updateBoard],
|
[updateBoard],
|
||||||
@@ -91,16 +89,14 @@ export const useCategoryActions = () => {
|
|||||||
yOffset: lastYOffset + 1,
|
yOffset: lastYOffset + 1,
|
||||||
xOffset: 0,
|
xOffset: 0,
|
||||||
collapsed: false,
|
collapsed: false,
|
||||||
items: [],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
kind: "empty",
|
kind: "empty",
|
||||||
yOffset: lastYOffset + 2,
|
yOffset: lastYOffset + 2,
|
||||||
xOffset: 0,
|
xOffset: 0,
|
||||||
items: [],
|
|
||||||
},
|
},
|
||||||
],
|
] satisfies Section[],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
|
||||||
import { fetchApi } from "@homarr/api/client";
|
import { fetchApi } from "@homarr/api/client";
|
||||||
|
import { getCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||||
import { createId } from "@homarr/db/client";
|
import { createId } from "@homarr/db/client";
|
||||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||||
import { useSettings } from "@homarr/settings";
|
import { useSettings } from "@homarr/settings";
|
||||||
@@ -16,6 +17,7 @@ export const useCategoryMenuActions = (category: CategorySection) => {
|
|||||||
const { openConfirmModal } = useConfirmModal();
|
const { openConfirmModal } = useConfirmModal();
|
||||||
const { addCategory, moveCategory, removeCategory, renameCategory } = useCategoryActions();
|
const { addCategory, moveCategory, removeCategory, renameCategory } = useCategoryActions();
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
const board = useRequiredBoard();
|
||||||
|
|
||||||
const createCategoryAtYOffset = useCallback(
|
const createCategoryAtYOffset = useCallback(
|
||||||
(position: number) => {
|
(position: number) => {
|
||||||
@@ -102,7 +104,14 @@ export const useCategoryMenuActions = (category: CategorySection) => {
|
|||||||
|
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const openAllInNewTabs = useCallback(async () => {
|
const openAllInNewTabs = useCallback(async () => {
|
||||||
const appIds = filterByItemKind(category.items, settings, "app").map((item) => {
|
const currentLayoutId = getCurrentLayout(board);
|
||||||
|
const appIds = filterByItemKind(
|
||||||
|
board.items.filter(
|
||||||
|
(item) => item.layouts.find((layout) => layout.layoutId === currentLayoutId)?.sectionId === category.id,
|
||||||
|
),
|
||||||
|
settings,
|
||||||
|
"app",
|
||||||
|
).map((item) => {
|
||||||
return item.options.appId;
|
return item.options.appId;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -121,7 +130,7 @@ export const useCategoryMenuActions = (category: CategorySection) => {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}, [category, t, openConfirmModal, settings]);
|
}, [category, board, t, openConfirmModal, settings]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
addCategoryAbove,
|
addCategoryAbove,
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
import type { GridItemHTMLElement } from "@homarr/gridstack";
|
||||||
|
|
||||||
import type { DynamicSection, Item, Section } from "~/app/[locale]/boards/_types";
|
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||||
import { BoardItemContent } from "../items/item-content";
|
import { BoardItemContent } from "../items/item-content";
|
||||||
import { BoardDynamicSection } from "./dynamic-section";
|
import { BoardDynamicSection } from "./dynamic-section";
|
||||||
import { GridStackItem } from "./gridstack/gridstack-item";
|
import { GridStackItem } from "./gridstack/gridstack-item";
|
||||||
import { useSectionContext } from "./section-context";
|
import { useSectionContext } from "./section-context";
|
||||||
|
import { useSectionItems } from "./use-section-items";
|
||||||
|
|
||||||
export const SectionContent = () => {
|
export const SectionContent = () => {
|
||||||
const { section, innerSections, refs } = useSectionContext();
|
const { innerSections, items, refs } = useSectionContext();
|
||||||
const board = useRequiredBoard();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IMPORTANT: THE ORDER OF THE BELOW ITEMS HAS TO MATCH THE ORDER OF
|
* IMPORTANT: THE ORDER OF THE BELOW ITEMS HAS TO MATCH THE ORDER OF
|
||||||
@@ -18,41 +18,52 @@ export const SectionContent = () => {
|
|||||||
* @see https://github.com/homarr-labs/homarr/pull/1770
|
* @see https://github.com/homarr-labs/homarr/pull/1770
|
||||||
*/
|
*/
|
||||||
const sortedItems = useMemo(() => {
|
const sortedItems = useMemo(() => {
|
||||||
return [
|
return [...items, ...innerSections].sort((itemA, itemB) => {
|
||||||
...section.items.map((item) => ({ ...item, type: "item" as const })),
|
|
||||||
...innerSections.map((section) => ({ ...section, type: "section" as const })),
|
|
||||||
].sort((itemA, itemB) => {
|
|
||||||
if (itemA.yOffset === itemB.yOffset) {
|
if (itemA.yOffset === itemB.yOffset) {
|
||||||
return itemA.xOffset - itemB.xOffset;
|
return itemA.xOffset - itemB.xOffset;
|
||||||
}
|
}
|
||||||
|
|
||||||
return itemA.yOffset - itemB.yOffset;
|
return itemA.yOffset - itemB.yOffset;
|
||||||
});
|
});
|
||||||
}, [section.items, innerSections]);
|
}, [items, innerSections]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{sortedItems.map((item) => (
|
{sortedItems.map((item) => (
|
||||||
<GridStackItem
|
<Item key={item.id} item={item} innerRef={refs.items.current[item.id]} />
|
||||||
key={item.id}
|
|
||||||
innerRef={refs.items.current[item.id]}
|
|
||||||
width={item.width}
|
|
||||||
height={item.height}
|
|
||||||
xOffset={item.xOffset}
|
|
||||||
yOffset={item.yOffset}
|
|
||||||
kind={item.kind}
|
|
||||||
id={item.id}
|
|
||||||
type={item.type}
|
|
||||||
minWidth={item.type === "section" ? getMinSize("x", item.items, board.sections, item.id) : undefined}
|
|
||||||
minHeight={item.type === "section" ? getMinSize("y", item.items, board.sections, item.id) : undefined}
|
|
||||||
>
|
|
||||||
{item.type === "item" ? <BoardItemContent item={item} /> : <BoardDynamicSection section={item} />}
|
|
||||||
</GridStackItem>
|
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface ItemProps {
|
||||||
|
item: DynamicSectionItem | SectionItem;
|
||||||
|
innerRef: React.RefObject<GridItemHTMLElement | null> | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Item = ({ item, innerRef }: ItemProps) => {
|
||||||
|
const minWidth = useMinSize(item, "x");
|
||||||
|
const minHeight = useMinSize(item, "y");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GridStackItem
|
||||||
|
key={item.id}
|
||||||
|
innerRef={innerRef}
|
||||||
|
width={item.width}
|
||||||
|
height={item.height}
|
||||||
|
xOffset={item.xOffset}
|
||||||
|
yOffset={item.yOffset}
|
||||||
|
kind={item.kind}
|
||||||
|
id={item.id}
|
||||||
|
type={item.type}
|
||||||
|
minWidth={minWidth}
|
||||||
|
minHeight={minHeight}
|
||||||
|
>
|
||||||
|
{item.type === "item" ? <BoardItemContent item={item} /> : <BoardDynamicSection section={item} />}
|
||||||
|
</GridStackItem>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates the min width / height of a section by taking the maximum of
|
* Calculates the min width / height of a section by taking the maximum of
|
||||||
* the sum of the offset and size of all items and dynamic sections inside.
|
* the sum of the offset and size of all items and dynamic sections inside.
|
||||||
@@ -62,16 +73,13 @@ export const SectionContent = () => {
|
|||||||
* @param parentSectionId the id of the section we want to calculate the min size for
|
* @param parentSectionId the id of the section we want to calculate the min size for
|
||||||
* @returns the min size
|
* @returns the min size
|
||||||
*/
|
*/
|
||||||
const getMinSize = (direction: "x" | "y", items: Item[], sections: Section[], parentSectionId: string) => {
|
const useMinSize = (item: DynamicSectionItem | SectionItem, direction: "x" | "y") => {
|
||||||
|
const { items, innerSections } = useSectionItems(item.id);
|
||||||
|
if (item.type === "item") return undefined;
|
||||||
|
|
||||||
const size = direction === "x" ? "width" : "height";
|
const size = direction === "x" ? "width" : "height";
|
||||||
return Math.max(
|
return Math.max(
|
||||||
...items.map((item) => item[`${direction}Offset`] + item[size]),
|
...items.map((item) => item[`${direction}Offset`] + item[size]),
|
||||||
...sections
|
...innerSections.map((item) => item[`${direction}Offset`] + item[size]),
|
||||||
.filter(
|
|
||||||
(section): section is DynamicSection =>
|
|
||||||
section.kind === "dynamic" && section.parentSectionId === parentSectionId,
|
|
||||||
)
|
|
||||||
.map((item) => item[`${direction}Offset`] + item[size]),
|
|
||||||
1, // Minimum size
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import { Box, Card } from "@mantine/core";
|
import { Box, Card } from "@mantine/core";
|
||||||
|
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||||
|
|
||||||
import type { DynamicSection } from "~/app/[locale]/boards/_types";
|
import type { DynamicSectionItem } from "~/app/[locale]/boards/_types";
|
||||||
import { BoardDynamicSectionMenu } from "./dynamic/dynamic-menu";
|
import { BoardDynamicSectionMenu } from "./dynamic/dynamic-menu";
|
||||||
import { GridStack } from "./gridstack/gridstack";
|
import { GridStack } from "./gridstack/gridstack";
|
||||||
import classes from "./item.module.css";
|
import classes from "./item.module.css";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
section: DynamicSection;
|
section: DynamicSectionItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BoardDynamicSection = ({ section }: Props) => {
|
export const BoardDynamicSection = ({ section }: Props) => {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
|
const currentLayoutId = useCurrentLayout();
|
||||||
return (
|
return (
|
||||||
<Box className="grid-stack-item-content">
|
<Box className="grid-stack-item-content">
|
||||||
<Card
|
<Card
|
||||||
@@ -29,7 +30,8 @@ export const BoardDynamicSection = ({ section }: Props) => {
|
|||||||
radius={board.itemRadius}
|
radius={board.itemRadius}
|
||||||
p={0}
|
p={0}
|
||||||
>
|
>
|
||||||
<GridStack section={section} className="min-row" />
|
{/* Use unique key by layout to reinitialize gridstack */}
|
||||||
|
<GridStack key={`${currentLayoutId}-${section.id}`} section={section} className="min-row" />
|
||||||
</Card>
|
</Card>
|
||||||
<BoardDynamicSectionMenu section={section} />
|
<BoardDynamicSectionMenu section={section} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { getBoardLayouts } from "@homarr/boards/context";
|
||||||
|
import { createId } from "@homarr/db/client";
|
||||||
|
|
||||||
|
import type { Board, DynamicSection, DynamicSectionLayout, EmptySection } from "~/app/[locale]/boards/_types";
|
||||||
|
import { getFirstEmptyPosition } from "~/components/board/items/actions/empty-position";
|
||||||
|
import { getSectionElements } from "~/components/board/items/actions/section-elements";
|
||||||
|
|
||||||
|
export const addDynamicSectionCallback = () => (board: Board) => {
|
||||||
|
const firstSection = board.sections
|
||||||
|
.filter((section) => section.kind === "empty")
|
||||||
|
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||||
|
.at(0);
|
||||||
|
|
||||||
|
if (!firstSection) return board;
|
||||||
|
|
||||||
|
const newSection = {
|
||||||
|
id: createId(),
|
||||||
|
kind: "dynamic",
|
||||||
|
layouts: createDynamicSectionLayouts(board, firstSection),
|
||||||
|
} satisfies DynamicSection;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...board,
|
||||||
|
sections: board.sections.concat(newSection as unknown as DynamicSection),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createDynamicSectionLayouts = (board: Board, currentSection: EmptySection): DynamicSectionLayout[] => {
|
||||||
|
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,
|
||||||
|
parentSectionId: currentSection.id,
|
||||||
|
layoutId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { Board, DynamicSection } from "~/app/[locale]/boards/_types";
|
||||||
|
|
||||||
|
export interface RemoveDynamicSectionInput {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const removeDynamicSectionCallback =
|
||||||
|
({ id }: RemoveDynamicSectionInput) =>
|
||||||
|
(board: Board): Board => {
|
||||||
|
const sectionToRemove = board.sections.find(
|
||||||
|
(section): section is DynamicSection => section.id === id && section.kind === "dynamic",
|
||||||
|
);
|
||||||
|
if (!sectionToRemove) return board;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...board,
|
||||||
|
sections: board.sections
|
||||||
|
.filter((section) => section.id !== id)
|
||||||
|
.map((section) => {
|
||||||
|
if (section.kind !== "dynamic") return section;
|
||||||
|
|
||||||
|
// Change xOffset and yOffset of sections that were below the removed section and set parentSectionId to the parent of the removed section
|
||||||
|
return {
|
||||||
|
...section,
|
||||||
|
layouts: section.layouts.map((layout) => {
|
||||||
|
if (layout.parentSectionId !== sectionToRemove.id) return layout;
|
||||||
|
|
||||||
|
const removedSectionLayout = sectionToRemove.layouts.find(
|
||||||
|
(layoutToRemove) => layoutToRemove.layoutId === layout.layoutId,
|
||||||
|
);
|
||||||
|
if (!removedSectionLayout) throw new Error("Layout not found");
|
||||||
|
|
||||||
|
return {
|
||||||
|
...layout,
|
||||||
|
xOffset: layout.xOffset + removedSectionLayout.xOffset,
|
||||||
|
yOffset: layout.yOffset + removedSectionLayout.yOffset,
|
||||||
|
parentSectionId: removedSectionLayout.parentSectionId,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
// Move all items in dynamic section to parent of the removed section
|
||||||
|
items: board.items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
layouts: item.layouts.map((layout) => {
|
||||||
|
if (layout.sectionId !== sectionToRemove.id) return layout;
|
||||||
|
|
||||||
|
const removedSectionLayout = sectionToRemove.layouts.find(
|
||||||
|
(layoutToRemove) => layoutToRemove.layoutId === layout.layoutId,
|
||||||
|
);
|
||||||
|
if (!removedSectionLayout) throw new Error("Layout not found");
|
||||||
|
|
||||||
|
return {
|
||||||
|
...layout,
|
||||||
|
xOffset: layout.xOffset + removedSectionLayout.xOffset,
|
||||||
|
yOffset: layout.yOffset + removedSectionLayout.yOffset,
|
||||||
|
sectionId: removedSectionLayout.parentSectionId,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,83 +1,21 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
|
||||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||||
import { createId } from "@homarr/db/client";
|
|
||||||
|
|
||||||
import type { DynamicSection, EmptySection } from "~/app/[locale]/boards/_types";
|
import { addDynamicSectionCallback } from "./actions/add-dynamic-section";
|
||||||
|
import type { RemoveDynamicSectionInput } from "./actions/remove-dynamic-section";
|
||||||
interface RemoveDynamicSection {
|
import { removeDynamicSectionCallback } from "./actions/remove-dynamic-section";
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useDynamicSectionActions = () => {
|
export const useDynamicSectionActions = () => {
|
||||||
const { updateBoard } = useUpdateBoard();
|
const { updateBoard } = useUpdateBoard();
|
||||||
|
|
||||||
const addDynamicSection = useCallback(() => {
|
const addDynamicSection = useCallback(() => {
|
||||||
updateBoard((previous) => {
|
updateBoard(addDynamicSectionCallback());
|
||||||
const lastSection = previous.sections
|
|
||||||
.filter((section): section is EmptySection => section.kind === "empty")
|
|
||||||
.sort((sectionA, sectionB) => sectionB.yOffset - sectionA.yOffset)[0];
|
|
||||||
|
|
||||||
if (!lastSection) return previous;
|
|
||||||
|
|
||||||
const newSection = {
|
|
||||||
id: createId(),
|
|
||||||
kind: "dynamic",
|
|
||||||
height: 1,
|
|
||||||
width: 1,
|
|
||||||
items: [],
|
|
||||||
parentSectionId: lastSection.id,
|
|
||||||
// We omit xOffset and yOffset because gridstack will use the first available position
|
|
||||||
} satisfies Omit<DynamicSection, "xOffset" | "yOffset">;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...previous,
|
|
||||||
sections: previous.sections.concat(newSection as unknown as DynamicSection),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [updateBoard]);
|
}, [updateBoard]);
|
||||||
|
|
||||||
const removeDynamicSection = useCallback(
|
const removeDynamicSection = useCallback(
|
||||||
({ id }: RemoveDynamicSection) => {
|
(input: RemoveDynamicSectionInput) => {
|
||||||
updateBoard((previous) => {
|
updateBoard(removeDynamicSectionCallback(input));
|
||||||
const sectionToRemove = previous.sections.find(
|
|
||||||
(section): section is DynamicSection => section.id === id && section.kind === "dynamic",
|
|
||||||
);
|
|
||||||
if (!sectionToRemove) return previous;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...previous,
|
|
||||||
sections: previous.sections
|
|
||||||
.filter((section) => section.id !== id)
|
|
||||||
.map((section) => {
|
|
||||||
if (section.id === sectionToRemove.parentSectionId) {
|
|
||||||
return {
|
|
||||||
...section,
|
|
||||||
// Add items from the removed section to the parent section
|
|
||||||
items: section.items.concat(
|
|
||||||
sectionToRemove.items.map((item) => ({
|
|
||||||
...item,
|
|
||||||
xOffset: sectionToRemove.xOffset + item.xOffset,
|
|
||||||
yOffset: sectionToRemove.yOffset + item.yOffset,
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (section.kind === "dynamic" && section.parentSectionId === sectionToRemove.id) {
|
|
||||||
// Change xOffset and yOffset of sections that were below the removed section and set parentSectionId to the parent of the removed section
|
|
||||||
return {
|
|
||||||
...section,
|
|
||||||
parentSectionId: sectionToRemove.parentSectionId,
|
|
||||||
yOffset: section.yOffset + sectionToRemove.yOffset,
|
|
||||||
xOffset: section.xOffset + sectionToRemove.xOffset,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return section;
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[updateBoard],
|
[updateBoard],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { useEditMode } from "@homarr/boards/edit-mode";
|
|||||||
import { useConfirmModal } from "@homarr/modals";
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { DynamicSection } from "~/app/[locale]/boards/_types";
|
import type { DynamicSectionItem } from "~/app/[locale]/boards/_types";
|
||||||
import { useDynamicSectionActions } from "./dynamic-actions";
|
import { useDynamicSectionActions } from "./dynamic-actions";
|
||||||
|
|
||||||
export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSection }) => {
|
export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSectionItem }) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const tDynamic = useScopedI18n("section.dynamic");
|
const tDynamic = useScopedI18n("section.dynamic");
|
||||||
const { removeDynamicSection } = useDynamicSectionActions();
|
const { removeDynamicSection } = useDynamicSectionActions();
|
||||||
|
|||||||
@@ -11,14 +11,15 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const BoardEmptySection = ({ section }: Props) => {
|
export const BoardEmptySection = ({ section }: Props) => {
|
||||||
const { itemIds } = useSectionItems(section);
|
const { items, innerSections } = useSectionItems(section.id);
|
||||||
|
const totalLength = items.length + innerSections.length;
|
||||||
const [isEditMode] = useEditMode();
|
const [isEditMode] = useEditMode();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GridStack
|
<GridStack
|
||||||
section={section}
|
section={section}
|
||||||
style={{ transitionDuration: "0s" }}
|
style={{ transitionDuration: "0s" }}
|
||||||
className={combineClasses("min-row", itemIds.length > 0 || isEditMode ? undefined : "grid-stack-empty-wrapper")}
|
className={combineClasses("min-row", totalLength > 0 || isEditMode ? undefined : "grid-stack-empty-wrapper")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,23 +4,24 @@ import type { BoxProps } from "@mantine/core";
|
|||||||
import { Box } from "@mantine/core";
|
import { Box } from "@mantine/core";
|
||||||
import combineClasses from "clsx";
|
import combineClasses from "clsx";
|
||||||
|
|
||||||
import type { Section } from "~/app/[locale]/boards/_types";
|
import type { DynamicSectionItem, Section } from "~/app/[locale]/boards/_types";
|
||||||
import { SectionContent } from "../content";
|
import { SectionContent } from "../content";
|
||||||
import { SectionProvider } from "../section-context";
|
import { SectionProvider } from "../section-context";
|
||||||
import { useSectionItems } from "../use-section-items";
|
import { useSectionItems } from "../use-section-items";
|
||||||
import { useGridstack } from "./use-gridstack";
|
import { useGridstack } from "./use-gridstack";
|
||||||
|
|
||||||
interface Props extends BoxProps {
|
interface Props extends BoxProps {
|
||||||
section: Section;
|
section: Exclude<Section, { kind: "dynamic" }> | DynamicSectionItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GridStack = ({ section, ...props }: Props) => {
|
export const GridStack = ({ section, ...props }: Props) => {
|
||||||
const { itemIds, innerSections } = useSectionItems(section);
|
const { items, innerSections } = useSectionItems(section.id);
|
||||||
|
const itemIds = [...items, ...innerSections].map((item) => item.id);
|
||||||
|
|
||||||
const { refs } = useGridstack(section, itemIds);
|
const { refs } = useGridstack(section, itemIds);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionProvider value={{ section, innerSections, refs }}>
|
<SectionProvider value={{ section, items, innerSections, refs }}>
|
||||||
<Box
|
<Box
|
||||||
{...props}
|
{...props}
|
||||||
data-kind={section.kind}
|
data-kind={section.kind}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { RefObject } from "react";
|
|||||||
import { createRef, useCallback, useEffect, useRef } from "react";
|
import { createRef, useCallback, useEffect, useRef } from "react";
|
||||||
import { useElementSize } from "@mantine/hooks";
|
import { useElementSize } from "@mantine/hooks";
|
||||||
|
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||||
import type { GridHTMLElement, GridItemHTMLElement, GridStack, GridStackNode } from "@homarr/gridstack";
|
import type { GridHTMLElement, GridItemHTMLElement, GridStack, GridStackNode } from "@homarr/gridstack";
|
||||||
|
|
||||||
@@ -68,10 +68,13 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
|
|||||||
|
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
|
|
||||||
|
const currentLayoutId = useCurrentLayout();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId)!;
|
||||||
const columnCount =
|
const columnCount =
|
||||||
section.kind === "dynamic" && "width" in section && typeof section.width === "number"
|
section.kind === "dynamic" && "width" in section && typeof section.width === "number"
|
||||||
? section.width
|
? section.width
|
||||||
: board.columnCount;
|
: currentLayout.columnCount;
|
||||||
|
|
||||||
const itemRefKeys = Object.keys(itemRefs.current);
|
const itemRefKeys = Object.keys(itemRefs.current);
|
||||||
// define items in itemRefs for easy access and reference to items
|
// define items in itemRefs for easy access and reference to items
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
|
||||||
|
import { getCurrentLayout } from "@homarr/boards/context";
|
||||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||||
|
|
||||||
interface MoveAndResizeInnerSection {
|
interface MoveAndResizeInnerSection {
|
||||||
@@ -28,9 +29,19 @@ export const useSectionActions = () => {
|
|||||||
sections: previous.sections.map((section) => {
|
sections: previous.sections.map((section) => {
|
||||||
// Return same section if section is not the one we're moving
|
// Return same section if section is not the one we're moving
|
||||||
if (section.id !== innerSectionId) return section;
|
if (section.id !== innerSectionId) return section;
|
||||||
|
if (section.kind !== "dynamic") return section;
|
||||||
|
|
||||||
|
const currentLayout = getCurrentLayout(previous);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...section,
|
...section,
|
||||||
...positionProps,
|
layouts: section.layouts.map((layout) => {
|
||||||
|
if (layout.layoutId !== currentLayout) return layout;
|
||||||
|
return {
|
||||||
|
...layout,
|
||||||
|
...positionProps,
|
||||||
|
};
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
@@ -46,10 +57,20 @@ export const useSectionActions = () => {
|
|||||||
sections: previous.sections.map((section) => {
|
sections: previous.sections.map((section) => {
|
||||||
// Return section without changes when not the section we're moving
|
// Return section without changes when not the section we're moving
|
||||||
if (section.id !== innerSectionId) return section;
|
if (section.id !== innerSectionId) return section;
|
||||||
|
if (section.kind !== "dynamic") return section;
|
||||||
|
|
||||||
|
const currentLayout = getCurrentLayout(previous);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...section,
|
...section,
|
||||||
...positionProps,
|
layouts: section.layouts.map((layout) => {
|
||||||
parentSectionId: sectionId,
|
if (layout.layoutId !== currentLayout) return layout;
|
||||||
|
return {
|
||||||
|
...layout,
|
||||||
|
...positionProps,
|
||||||
|
parentSectionId: sectionId,
|
||||||
|
};
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { createContext, useContext } from "react";
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
import type { Section } from "~/app/[locale]/boards/_types";
|
import type { DynamicSectionItem, Section, SectionItem } from "~/app/[locale]/boards/_types";
|
||||||
import type { UseGridstackRefs } from "./gridstack/use-gridstack";
|
import type { UseGridstackRefs } from "./gridstack/use-gridstack";
|
||||||
|
|
||||||
interface SectionContextProps {
|
interface SectionContextProps {
|
||||||
section: Section;
|
section: Exclude<Section, { kind: "dynamic" }> | DynamicSectionItem;
|
||||||
innerSections: Exclude<Section, { kind: "category" } | { kind: "empty" }>[];
|
innerSections: DynamicSectionItem[];
|
||||||
|
items: SectionItem[];
|
||||||
refs: UseGridstackRefs;
|
refs: UseGridstackRefs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,53 @@
|
|||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import type { Section } from "~/app/[locale]/boards/_types";
|
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||||
|
|
||||||
export const useSectionItems = (section: Section) => {
|
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||||
|
|
||||||
|
export const useSectionItems = (sectionId: string): { innerSections: DynamicSectionItem[]; items: SectionItem[] } => {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const innerSections = board.sections.filter(
|
const currentLayoutId = useCurrentLayout();
|
||||||
(innerSection): innerSection is Exclude<Section, { kind: "category" } | { kind: "empty" }> =>
|
|
||||||
innerSection.kind === "dynamic" && innerSection.parentSectionId === section.id,
|
const innerSections = useMemo(
|
||||||
|
() =>
|
||||||
|
board.sections
|
||||||
|
.filter((innerSection) => innerSection.kind === "dynamic")
|
||||||
|
.map(({ layouts, ...innerSection }) => {
|
||||||
|
const layout = layouts.find((layout) => layout.layoutId === currentLayoutId);
|
||||||
|
|
||||||
|
if (!layout) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...layout,
|
||||||
|
...innerSection,
|
||||||
|
type: "section" as const,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item) => item !== null)
|
||||||
|
.filter((innerSection) => innerSection.parentSectionId === sectionId),
|
||||||
|
[board.sections, currentLayoutId, sectionId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = useMemo(
|
||||||
|
() =>
|
||||||
|
board.items
|
||||||
|
.map(({ layouts, ...item }) => {
|
||||||
|
const layout = layouts.find((layout) => layout.layoutId === currentLayoutId);
|
||||||
|
if (!layout) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...layout,
|
||||||
|
...item,
|
||||||
|
type: "item" as const,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item) => item !== null)
|
||||||
|
.filter((item) => item.sectionId === sectionId),
|
||||||
|
[board.items, currentLayoutId, sectionId],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
innerSections,
|
innerSections,
|
||||||
itemIds: section.items.map((item) => item.id).concat(innerSections.map((section) => section.id)),
|
items,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -38,17 +38,17 @@
|
|||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"superjson": "2.2.2",
|
"superjson": "2.2.2",
|
||||||
"undici": "7.3.0"
|
"undici": "7.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/node": "^22.13.4",
|
"@types/node": "^22.13.5",
|
||||||
"dotenv-cli": "^8.0.0",
|
"dotenv-cli": "^8.0.0",
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.21.0",
|
||||||
"prettier": "^3.5.1",
|
"prettier": "^3.5.2",
|
||||||
"tsx": "4.19.3",
|
"tsx": "4.19.3",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.8.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,8 +34,8 @@
|
|||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/ws": "^8.5.14",
|
"@types/ws": "^8.5.14",
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.21.0",
|
||||||
"prettier": "^3.5.1",
|
"prettier": "^3.5.2",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.8.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
package.json
18
package.json
@@ -38,22 +38,22 @@
|
|||||||
"@semantic-release/github": "^11.0.1",
|
"@semantic-release/github": "^11.0.1",
|
||||||
"@semantic-release/npm": "^12.0.1",
|
"@semantic-release/npm": "^12.0.1",
|
||||||
"@semantic-release/release-notes-generator": "^14.0.3",
|
"@semantic-release/release-notes-generator": "^14.0.3",
|
||||||
"@turbo/gen": "^2.4.2",
|
"@turbo/gen": "^2.4.4",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"@vitest/coverage-v8": "^3.0.6",
|
"@vitest/coverage-v8": "^3.0.7",
|
||||||
"@vitest/ui": "^3.0.6",
|
"@vitest/ui": "^3.0.7",
|
||||||
"conventional-changelog-conventionalcommits": "^8.0.0",
|
"conventional-changelog-conventionalcommits": "^8.0.0",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"jsdom": "^26.0.0",
|
"jsdom": "^26.0.0",
|
||||||
"prettier": "^3.5.1",
|
"prettier": "^3.5.2",
|
||||||
"semantic-release": "^24.2.3",
|
"semantic-release": "^24.2.3",
|
||||||
"testcontainers": "^10.18.0",
|
"testcontainers": "^10.18.0",
|
||||||
"turbo": "^2.4.2",
|
"turbo": "^2.4.4",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.8.2",
|
||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
"vitest": "^3.0.6"
|
"vitest": "^3.0.7"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.4.1",
|
"packageManager": "pnpm@10.5.2",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.14.0"
|
"node": ">=22.14.0"
|
||||||
},
|
},
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
],
|
],
|
||||||
"allowNonAppliedPatches": true,
|
"allowNonAppliedPatches": true,
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"proxmox-api>undici": "7.3.0"
|
"proxmox-api>undici": "7.4.0"
|
||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"pretty-print-error": "patches/pretty-print-error.patch"
|
"pretty-print-error": "patches/pretty-print-error.patch"
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.21.0",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.8.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,8 +56,8 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.21.0",
|
||||||
"prettier": "^3.5.1",
|
"prettier": "^3.5.2",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.8.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,15 +37,7 @@ export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
|
|||||||
userPermissions: true,
|
userPermissions: true,
|
||||||
items: {
|
items: {
|
||||||
with: {
|
with: {
|
||||||
item: {
|
item: true,
|
||||||
with: {
|
|
||||||
section: {
|
|
||||||
columns: {
|
|
||||||
boardId: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -107,15 +99,7 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
|
|||||||
secrets: true,
|
secrets: true,
|
||||||
items: {
|
items: {
|
||||||
with: {
|
with: {
|
||||||
item: {
|
item: true,
|
||||||
with: {
|
|
||||||
section: {
|
|
||||||
columns: {
|
|
||||||
boardId: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
userPermissions: true,
|
userPermissions: true,
|
||||||
|
|||||||
@@ -56,7 +56,11 @@ export const appRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
selectable: protectedProcedure
|
selectable: protectedProcedure
|
||||||
.input(z.void())
|
.input(z.void())
|
||||||
.output(z.array(selectAppSchema.pick({ id: true, name: true, iconUrl: true, href: true, description: true })))
|
.output(
|
||||||
|
z.array(
|
||||||
|
selectAppSchema.pick({ id: true, name: true, iconUrl: true, href: true, pingUrl: true, description: true }),
|
||||||
|
),
|
||||||
|
)
|
||||||
.meta({
|
.meta({
|
||||||
openapi: {
|
openapi: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@@ -73,6 +77,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
iconUrl: true,
|
iconUrl: true,
|
||||||
description: true,
|
description: true,
|
||||||
href: true,
|
href: true,
|
||||||
|
pingUrl: true,
|
||||||
},
|
},
|
||||||
orderBy: asc(apps.name),
|
orderBy: asc(apps.name),
|
||||||
});
|
});
|
||||||
@@ -121,6 +126,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
description: input.description,
|
description: input.description,
|
||||||
iconUrl: input.iconUrl,
|
iconUrl: input.iconUrl,
|
||||||
href: input.href,
|
href: input.href,
|
||||||
|
pingUrl: input.pingUrl === "" ? null : input.pingUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { appId: id };
|
return { appId: id };
|
||||||
@@ -164,6 +170,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
description: input.description,
|
description: input.description,
|
||||||
iconUrl: input.iconUrl,
|
iconUrl: input.iconUrl,
|
||||||
href: input.href,
|
href: input.href,
|
||||||
|
pingUrl: input.pingUrl === "" ? null : input.pingUrl,
|
||||||
})
|
})
|
||||||
.where(eq(apps.id, input.id));
|
.where(eq(apps.id, input.id));
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -21,21 +21,16 @@ const getAllAppIdsOnPublicBoardsAsync = async () => {
|
|||||||
const itemsWithApps = await db.query.items.findMany({
|
const itemsWithApps = await db.query.items.findMany({
|
||||||
where: or(eq(items.kind, "app"), eq(items.kind, "bookmarks")),
|
where: or(eq(items.kind, "app"), eq(items.kind, "bookmarks")),
|
||||||
with: {
|
with: {
|
||||||
section: {
|
board: {
|
||||||
columns: {}, // Nothing
|
columns: {
|
||||||
with: {
|
isPublic: true,
|
||||||
board: {
|
|
||||||
columns: {
|
|
||||||
isPublic: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return itemsWithApps
|
return itemsWithApps
|
||||||
.filter((item) => item.section.board.isPublic)
|
.filter((item) => item.board.isPublic)
|
||||||
.flatMap((item) => {
|
.flatMap((item) => {
|
||||||
if (item.kind === "app") {
|
if (item.kind === "app") {
|
||||||
const parsedOptions = SuperJSON.parse<WidgetComponentProps<"app">["options"]>(item.options);
|
const parsedOptions = SuperJSON.parse<WidgetComponentProps<"app">["options"]>(item.options);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
186
packages/api/src/router/board/grid-algorithm.ts
Normal file
186
packages/api/src/router/board/grid-algorithm.ts
Normal 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;
|
||||||
|
};
|
||||||
378
packages/api/src/router/board/test/grid-algorithm.spec.ts
Normal file
378
packages/api/src/router/board/test/grid-algorithm.spec.ts
Normal 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(" "));
|
||||||
|
};
|
||||||
@@ -158,6 +158,7 @@ describe("create should create a new app with all arguments", () => {
|
|||||||
description: "React components and hooks library",
|
description: "React components and hooks library",
|
||||||
iconUrl: "https://mantine.dev/favicon.svg",
|
iconUrl: "https://mantine.dev/favicon.svg",
|
||||||
href: "https://mantine.dev",
|
href: "https://mantine.dev",
|
||||||
|
pingUrl: "https://mantine.dev/a",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -170,6 +171,7 @@ describe("create should create a new app with all arguments", () => {
|
|||||||
expect(dbApp!.description).toBe(input.description);
|
expect(dbApp!.description).toBe(input.description);
|
||||||
expect(dbApp!.iconUrl).toBe(input.iconUrl);
|
expect(dbApp!.iconUrl).toBe(input.iconUrl);
|
||||||
expect(dbApp!.href).toBe(input.href);
|
expect(dbApp!.href).toBe(input.href);
|
||||||
|
expect(dbApp!.pingUrl).toBe(input.pingUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should create a new app only with required arguments", async () => {
|
test("should create a new app only with required arguments", async () => {
|
||||||
@@ -185,6 +187,7 @@ describe("create should create a new app with all arguments", () => {
|
|||||||
description: null,
|
description: null,
|
||||||
iconUrl: "https://mantine.dev/favicon.svg",
|
iconUrl: "https://mantine.dev/favicon.svg",
|
||||||
href: null,
|
href: null,
|
||||||
|
pingUrl: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -197,6 +200,7 @@ describe("create should create a new app with all arguments", () => {
|
|||||||
expect(dbApp!.description).toBe(input.description);
|
expect(dbApp!.description).toBe(input.description);
|
||||||
expect(dbApp!.iconUrl).toBe(input.iconUrl);
|
expect(dbApp!.iconUrl).toBe(input.iconUrl);
|
||||||
expect(dbApp!.href).toBe(input.href);
|
expect(dbApp!.href).toBe(input.href);
|
||||||
|
expect(dbApp!.pingUrl).toBe(null);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -225,6 +229,7 @@ describe("update should update an app", () => {
|
|||||||
description: "React components and hooks library",
|
description: "React components and hooks library",
|
||||||
iconUrl: "https://mantine.dev/favicon.svg2",
|
iconUrl: "https://mantine.dev/favicon.svg2",
|
||||||
href: "https://mantine.dev",
|
href: "https://mantine.dev",
|
||||||
|
pingUrl: "https://mantine.dev/a",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -257,6 +262,7 @@ describe("update should update an app", () => {
|
|||||||
iconUrl: "https://mantine.dev/favicon.svg",
|
iconUrl: "https://mantine.dev/favicon.svg",
|
||||||
description: null,
|
description: null,
|
||||||
href: null,
|
href: null,
|
||||||
|
pingUrl: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import SuperJSON from "superjson";
|
|||||||
import { describe, expect, it, test, vi } from "vitest";
|
import { describe, expect, it, test, vi } from "vitest";
|
||||||
|
|
||||||
import type { Session } from "@homarr/auth";
|
import type { Session } from "@homarr/auth";
|
||||||
import type { Database } from "@homarr/db";
|
import type { Database, InferInsertModel } from "@homarr/db";
|
||||||
import { createId, eq } from "@homarr/db";
|
import { and, createId, eq, not } from "@homarr/db";
|
||||||
import {
|
import {
|
||||||
boardGroupPermissions,
|
boardGroupPermissions,
|
||||||
boards,
|
boards,
|
||||||
@@ -13,7 +13,10 @@ import {
|
|||||||
groups,
|
groups,
|
||||||
integrationItems,
|
integrationItems,
|
||||||
integrations,
|
integrations,
|
||||||
|
itemLayouts,
|
||||||
items,
|
items,
|
||||||
|
layouts,
|
||||||
|
sectionLayouts,
|
||||||
sections,
|
sections,
|
||||||
serverSettings,
|
serverSettings,
|
||||||
users,
|
users,
|
||||||
@@ -304,17 +307,27 @@ describe("createBoard should create a new board", () => {
|
|||||||
await caller.createBoard({ name: "newBoard", columnCount: 24, isPublic: true });
|
await caller.createBoard({ name: "newBoard", columnCount: 24, isPublic: true });
|
||||||
|
|
||||||
// Assert
|
// 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).toBeDefined();
|
||||||
expect(dbBoard?.name).toBe("newBoard");
|
expect(dbBoard?.name).toBe("newBoard");
|
||||||
expect(dbBoard?.columnCount).toBe(24);
|
|
||||||
expect(dbBoard?.isPublic).toBe(true);
|
expect(dbBoard?.isPublic).toBe(true);
|
||||||
expect(dbBoard?.creatorId).toBe(defaultCreatorId);
|
expect(dbBoard?.creatorId).toBe(defaultCreatorId);
|
||||||
|
|
||||||
const dbSection = await db.query.sections.findFirst();
|
expect(dbBoard?.sections.length).toBe(1);
|
||||||
expect(dbSection).toBeDefined();
|
const firstSection = dbBoard?.sections.at(0);
|
||||||
expect(dbSection?.boardId).toBe(dbBoard?.id);
|
expect(firstSection?.kind).toBe("empty");
|
||||||
expect(dbSection?.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 () => {
|
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 newBackgroundImageSize = "cover";
|
||||||
const newBackgroundImageRepeat = "repeat";
|
const newBackgroundImageRepeat = "repeat";
|
||||||
const newBackgroundImageUrl = "http://background.image/url.png";
|
const newBackgroundImageUrl = "http://background.image/url.png";
|
||||||
const newColumnCount = 2;
|
|
||||||
const newCustomCss = "body { background-color: blue; }";
|
const newCustomCss = "body { background-color: blue; }";
|
||||||
const newOpacity = 0.8;
|
const newOpacity = 0.8;
|
||||||
const newPrimaryColor = "#0000ff";
|
const newPrimaryColor = "#0000ff";
|
||||||
@@ -605,7 +617,6 @@ describe("savePartialBoardSettings should save general settings", () => {
|
|||||||
backgroundImageRepeat: newBackgroundImageRepeat,
|
backgroundImageRepeat: newBackgroundImageRepeat,
|
||||||
backgroundImageSize: newBackgroundImageSize,
|
backgroundImageSize: newBackgroundImageSize,
|
||||||
backgroundImageUrl: newBackgroundImageUrl,
|
backgroundImageUrl: newBackgroundImageUrl,
|
||||||
columnCount: newColumnCount,
|
|
||||||
customCss: newCustomCss,
|
customCss: newCustomCss,
|
||||||
opacity: newOpacity,
|
opacity: newOpacity,
|
||||||
primaryColor: newPrimaryColor,
|
primaryColor: newPrimaryColor,
|
||||||
@@ -626,7 +637,6 @@ describe("savePartialBoardSettings should save general settings", () => {
|
|||||||
expect(dbBoard?.backgroundImageRepeat).toBe(newBackgroundImageRepeat);
|
expect(dbBoard?.backgroundImageRepeat).toBe(newBackgroundImageRepeat);
|
||||||
expect(dbBoard?.backgroundImageSize).toBe(newBackgroundImageSize);
|
expect(dbBoard?.backgroundImageSize).toBe(newBackgroundImageSize);
|
||||||
expect(dbBoard?.backgroundImageUrl).toBe(newBackgroundImageUrl);
|
expect(dbBoard?.backgroundImageUrl).toBe(newBackgroundImageUrl);
|
||||||
expect(dbBoard?.columnCount).toBe(newColumnCount);
|
|
||||||
expect(dbBoard?.customCss).toBe(newCustomCss);
|
expect(dbBoard?.customCss).toBe(newCustomCss);
|
||||||
expect(dbBoard?.opacity).toBe(newOpacity);
|
expect(dbBoard?.opacity).toBe(newOpacity);
|
||||||
expect(dbBoard?.primaryColor).toBe(newPrimaryColor);
|
expect(dbBoard?.primaryColor).toBe(newPrimaryColor);
|
||||||
@@ -668,9 +678,9 @@ describe("saveBoard should save full board", () => {
|
|||||||
kind: "empty",
|
kind: "empty",
|
||||||
yOffset: 0,
|
yOffset: 0,
|
||||||
xOffset: 0,
|
xOffset: 0,
|
||||||
items: [],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
items: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const board = await db.query.boards.findFirst({
|
const board = await db.query.boards.findFirst({
|
||||||
@@ -695,7 +705,7 @@ describe("saveBoard should save full board", () => {
|
|||||||
const db = createDb();
|
const db = createDb();
|
||||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
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({
|
await caller.saveBoard({
|
||||||
id: boardId,
|
id: boardId,
|
||||||
@@ -705,19 +715,25 @@ describe("saveBoard should save full board", () => {
|
|||||||
kind: "empty",
|
kind: "empty",
|
||||||
yOffset: 0,
|
yOffset: 0,
|
||||||
xOffset: 0,
|
xOffset: 0,
|
||||||
items: [
|
},
|
||||||
|
],
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: createId(),
|
||||||
|
kind: "clock",
|
||||||
|
options: { is24HourFormat: true },
|
||||||
|
integrationIds: [],
|
||||||
|
layouts: [
|
||||||
{
|
{
|
||||||
id: createId(),
|
layoutId,
|
||||||
kind: "clock",
|
sectionId,
|
||||||
options: { is24HourFormat: true },
|
|
||||||
integrationIds: [],
|
|
||||||
height: 1,
|
height: 1,
|
||||||
width: 1,
|
width: 1,
|
||||||
xOffset: 0,
|
xOffset: 0,
|
||||||
yOffset: 0,
|
yOffset: 0,
|
||||||
advancedOptions: {},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
advancedOptions: {},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -725,11 +741,8 @@ describe("saveBoard should save full board", () => {
|
|||||||
const board = await db.query.boards.findFirst({
|
const board = await db.query.boards.findFirst({
|
||||||
where: eq(boards.id, boardId),
|
where: eq(boards.id, boardId),
|
||||||
with: {
|
with: {
|
||||||
sections: {
|
sections: true,
|
||||||
with: {
|
items: true,
|
||||||
items: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -739,9 +752,8 @@ describe("saveBoard should save full board", () => {
|
|||||||
|
|
||||||
const definedBoard = expectToBeDefined(board);
|
const definedBoard = expectToBeDefined(board);
|
||||||
expect(definedBoard.sections.length).toBe(1);
|
expect(definedBoard.sections.length).toBe(1);
|
||||||
const firstSection = expectToBeDefined(definedBoard.sections[0]);
|
expect(definedBoard.items.length).toBe(1);
|
||||||
expect(firstSection.items.length).toBe(1);
|
expect(definedBoard.items[0]?.id).not.toBe(itemId);
|
||||||
expect(firstSection.items[0]?.id).not.toBe(itemId);
|
|
||||||
expect(item).toBeUndefined();
|
expect(item).toBeUndefined();
|
||||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
|
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
|
||||||
});
|
});
|
||||||
@@ -756,7 +768,7 @@ describe("saveBoard should save full board", () => {
|
|||||||
url: "http://localhost:3000",
|
url: "http://localhost:3000",
|
||||||
} as const;
|
} 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 db.insert(integrations).values(anotherIntegration);
|
||||||
|
|
||||||
await caller.saveBoard({
|
await caller.saveBoard({
|
||||||
@@ -767,19 +779,25 @@ describe("saveBoard should save full board", () => {
|
|||||||
kind: "empty",
|
kind: "empty",
|
||||||
xOffset: 0,
|
xOffset: 0,
|
||||||
yOffset: 0,
|
yOffset: 0,
|
||||||
items: [
|
},
|
||||||
|
],
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: itemId,
|
||||||
|
kind: "clock",
|
||||||
|
options: { is24HourFormat: true },
|
||||||
|
integrationIds: [anotherIntegration.id],
|
||||||
|
layouts: [
|
||||||
{
|
{
|
||||||
id: itemId,
|
layoutId,
|
||||||
kind: "clock",
|
sectionId,
|
||||||
options: { is24HourFormat: true },
|
|
||||||
integrationIds: [anotherIntegration.id],
|
|
||||||
height: 1,
|
height: 1,
|
||||||
width: 1,
|
width: 1,
|
||||||
xOffset: 0,
|
xOffset: 0,
|
||||||
yOffset: 0,
|
yOffset: 0,
|
||||||
advancedOptions: {},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
advancedOptions: {},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -787,13 +805,10 @@ describe("saveBoard should save full board", () => {
|
|||||||
const board = await db.query.boards.findFirst({
|
const board = await db.query.boards.findFirst({
|
||||||
where: eq(boards.id, boardId),
|
where: eq(boards.id, boardId),
|
||||||
with: {
|
with: {
|
||||||
sections: {
|
sections: true,
|
||||||
|
items: {
|
||||||
with: {
|
with: {
|
||||||
items: {
|
integrations: true,
|
||||||
with: {
|
|
||||||
integrations: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -805,9 +820,8 @@ describe("saveBoard should save full board", () => {
|
|||||||
|
|
||||||
const definedBoard = expectToBeDefined(board);
|
const definedBoard = expectToBeDefined(board);
|
||||||
expect(definedBoard.sections.length).toBe(1);
|
expect(definedBoard.sections.length).toBe(1);
|
||||||
const firstSection = expectToBeDefined(definedBoard.sections[0]);
|
expect(definedBoard.items.length).toBe(1);
|
||||||
expect(firstSection.items.length).toBe(1);
|
const firstItem = expectToBeDefined(definedBoard.items[0]);
|
||||||
const firstItem = expectToBeDefined(firstSection.items[0]);
|
|
||||||
expect(firstItem.integrations.length).toBe(1);
|
expect(firstItem.integrations.length).toBe(1);
|
||||||
expect(firstItem.integrations[0]?.integrationId).not.toBe(integrationId);
|
expect(firstItem.integrations[0]?.integrationId).not.toBe(integrationId);
|
||||||
expect(integration).toBeUndefined();
|
expect(integration).toBeUndefined();
|
||||||
@@ -830,7 +844,6 @@ describe("saveBoard should save full board", () => {
|
|||||||
id: newSectionId,
|
id: newSectionId,
|
||||||
xOffset: 0,
|
xOffset: 0,
|
||||||
yOffset: 1,
|
yOffset: 1,
|
||||||
items: [],
|
|
||||||
...partialSection,
|
...partialSection,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -838,9 +851,9 @@ describe("saveBoard should save full board", () => {
|
|||||||
kind: "empty",
|
kind: "empty",
|
||||||
xOffset: 0,
|
xOffset: 0,
|
||||||
yOffset: 0,
|
yOffset: 0,
|
||||||
items: [],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
items: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const board = await db.query.boards.findFirst({
|
const board = await db.query.boards.findFirst({
|
||||||
@@ -873,7 +886,7 @@ describe("saveBoard should save full board", () => {
|
|||||||
const db = createDb();
|
const db = createDb();
|
||||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
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();
|
const newItemId = createId();
|
||||||
await caller.saveBoard({
|
await caller.saveBoard({
|
||||||
@@ -884,19 +897,25 @@ describe("saveBoard should save full board", () => {
|
|||||||
kind: "empty",
|
kind: "empty",
|
||||||
yOffset: 0,
|
yOffset: 0,
|
||||||
xOffset: 0,
|
xOffset: 0,
|
||||||
items: [
|
},
|
||||||
|
],
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: newItemId,
|
||||||
|
kind: "clock",
|
||||||
|
options: { is24HourFormat: true },
|
||||||
|
integrationIds: [],
|
||||||
|
layouts: [
|
||||||
{
|
{
|
||||||
id: newItemId,
|
layoutId,
|
||||||
kind: "clock",
|
sectionId,
|
||||||
options: { is24HourFormat: true },
|
|
||||||
integrationIds: [],
|
|
||||||
height: 1,
|
height: 1,
|
||||||
width: 1,
|
width: 1,
|
||||||
xOffset: 3,
|
xOffset: 3,
|
||||||
yOffset: 2,
|
yOffset: 2,
|
||||||
advancedOptions: {},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
advancedOptions: {},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -904,9 +923,10 @@ describe("saveBoard should save full board", () => {
|
|||||||
const board = await db.query.boards.findFirst({
|
const board = await db.query.boards.findFirst({
|
||||||
where: eq(boards.id, boardId),
|
where: eq(boards.id, boardId),
|
||||||
with: {
|
with: {
|
||||||
sections: {
|
sections: true,
|
||||||
|
items: {
|
||||||
with: {
|
with: {
|
||||||
items: true,
|
layouts: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -918,17 +938,18 @@ describe("saveBoard should save full board", () => {
|
|||||||
|
|
||||||
const definedBoard = expectToBeDefined(board);
|
const definedBoard = expectToBeDefined(board);
|
||||||
expect(definedBoard.sections.length).toBe(1);
|
expect(definedBoard.sections.length).toBe(1);
|
||||||
const firstSection = expectToBeDefined(definedBoard.sections[0]);
|
expect(definedBoard.items.length).toBe(1);
|
||||||
expect(firstSection.items.length).toBe(1);
|
const addedItem = expectToBeDefined(definedBoard.items.find((item) => item.id === newItemId));
|
||||||
const addedItem = expectToBeDefined(firstSection.items.find((item) => item.id === newItemId));
|
|
||||||
expect(addedItem).toBeDefined();
|
expect(addedItem).toBeDefined();
|
||||||
expect(addedItem.id).toBe(newItemId);
|
expect(addedItem.id).toBe(newItemId);
|
||||||
expect(addedItem.kind).toBe("clock");
|
expect(addedItem.kind).toBe("clock");
|
||||||
expect(addedItem.options).toBe(SuperJSON.stringify({ is24HourFormat: true }));
|
expect(addedItem.options).toBe(SuperJSON.stringify({ is24HourFormat: true }));
|
||||||
expect(addedItem.height).toBe(1);
|
const firstLayout = expectToBeDefined(addedItem.layouts[0]);
|
||||||
expect(addedItem.width).toBe(1);
|
expect(firstLayout.sectionId).toBe(sectionId);
|
||||||
expect(addedItem.xOffset).toBe(3);
|
expect(firstLayout.height).toBe(1);
|
||||||
expect(addedItem.yOffset).toBe(2);
|
expect(firstLayout.width).toBe(1);
|
||||||
|
expect(firstLayout.xOffset).toBe(3);
|
||||||
|
expect(firstLayout.yOffset).toBe(2);
|
||||||
expect(item).toBeDefined();
|
expect(item).toBeDefined();
|
||||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
|
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
|
||||||
});
|
});
|
||||||
@@ -943,7 +964,7 @@ describe("saveBoard should save full board", () => {
|
|||||||
url: "http://plex.local",
|
url: "http://plex.local",
|
||||||
} as const;
|
} 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 db.insert(integrations).values(integration);
|
||||||
|
|
||||||
await caller.saveBoard({
|
await caller.saveBoard({
|
||||||
@@ -954,19 +975,25 @@ describe("saveBoard should save full board", () => {
|
|||||||
kind: "empty",
|
kind: "empty",
|
||||||
xOffset: 0,
|
xOffset: 0,
|
||||||
yOffset: 0,
|
yOffset: 0,
|
||||||
items: [
|
},
|
||||||
|
],
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: itemId,
|
||||||
|
kind: "clock",
|
||||||
|
options: { is24HourFormat: true },
|
||||||
|
integrationIds: [integration.id],
|
||||||
|
layouts: [
|
||||||
{
|
{
|
||||||
id: itemId,
|
sectionId,
|
||||||
kind: "clock",
|
layoutId,
|
||||||
options: { is24HourFormat: true },
|
|
||||||
integrationIds: [integration.id],
|
|
||||||
height: 1,
|
height: 1,
|
||||||
width: 1,
|
width: 1,
|
||||||
xOffset: 0,
|
xOffset: 0,
|
||||||
yOffset: 0,
|
yOffset: 0,
|
||||||
advancedOptions: {},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
advancedOptions: {},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -974,13 +1001,10 @@ describe("saveBoard should save full board", () => {
|
|||||||
const board = await db.query.boards.findFirst({
|
const board = await db.query.boards.findFirst({
|
||||||
where: eq(boards.id, boardId),
|
where: eq(boards.id, boardId),
|
||||||
with: {
|
with: {
|
||||||
sections: {
|
sections: true,
|
||||||
|
items: {
|
||||||
with: {
|
with: {
|
||||||
items: {
|
integrations: true,
|
||||||
with: {
|
|
||||||
integrations: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -992,9 +1016,7 @@ describe("saveBoard should save full board", () => {
|
|||||||
|
|
||||||
const definedBoard = expectToBeDefined(board);
|
const definedBoard = expectToBeDefined(board);
|
||||||
expect(definedBoard.sections.length).toBe(1);
|
expect(definedBoard.sections.length).toBe(1);
|
||||||
const firstSection = expectToBeDefined(definedBoard.sections[0]);
|
const firstItem = expectToBeDefined(definedBoard.items.find((item) => item.id === itemId));
|
||||||
expect(firstSection.items.length).toBe(1);
|
|
||||||
const firstItem = expectToBeDefined(firstSection.items.find((item) => item.id === itemId));
|
|
||||||
expect(firstItem.integrations.length).toBe(1);
|
expect(firstItem.integrations.length).toBe(1);
|
||||||
expect(firstItem.integrations[0]?.integrationId).toBe(integration.id);
|
expect(firstItem.integrations[0]?.integrationId).toBe(integration.id);
|
||||||
expect(integrationItem).toBeDefined();
|
expect(integrationItem).toBeDefined();
|
||||||
@@ -1025,7 +1047,6 @@ describe("saveBoard should save full board", () => {
|
|||||||
xOffset: 0,
|
xOffset: 0,
|
||||||
name: "Test",
|
name: "Test",
|
||||||
collapsed: true,
|
collapsed: true,
|
||||||
items: [],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: newSectionId,
|
id: newSectionId,
|
||||||
@@ -1034,9 +1055,9 @@ describe("saveBoard should save full board", () => {
|
|||||||
yOffset: 0,
|
yOffset: 0,
|
||||||
xOffset: 0,
|
xOffset: 0,
|
||||||
collapsed: false,
|
collapsed: false,
|
||||||
items: [],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
items: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const board = await db.query.boards.findFirst({
|
const board = await db.query.boards.findFirst({
|
||||||
@@ -1064,7 +1085,7 @@ describe("saveBoard should save full board", () => {
|
|||||||
const db = createDb();
|
const db = createDb();
|
||||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
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({
|
await caller.saveBoard({
|
||||||
id: boardId,
|
id: boardId,
|
||||||
@@ -1074,19 +1095,25 @@ describe("saveBoard should save full board", () => {
|
|||||||
kind: "empty",
|
kind: "empty",
|
||||||
yOffset: 0,
|
yOffset: 0,
|
||||||
xOffset: 0,
|
xOffset: 0,
|
||||||
items: [
|
},
|
||||||
|
],
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: itemId,
|
||||||
|
kind: "clock",
|
||||||
|
options: { is24HourFormat: false },
|
||||||
|
integrationIds: [],
|
||||||
|
layouts: [
|
||||||
{
|
{
|
||||||
id: itemId,
|
layoutId,
|
||||||
kind: "clock",
|
sectionId,
|
||||||
options: { is24HourFormat: false },
|
|
||||||
integrationIds: [],
|
|
||||||
height: 3,
|
height: 3,
|
||||||
width: 2,
|
width: 2,
|
||||||
xOffset: 7,
|
xOffset: 7,
|
||||||
yOffset: 5,
|
yOffset: 5,
|
||||||
advancedOptions: {},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
advancedOptions: {},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -1094,9 +1121,10 @@ describe("saveBoard should save full board", () => {
|
|||||||
const board = await db.query.boards.findFirst({
|
const board = await db.query.boards.findFirst({
|
||||||
where: eq(boards.id, boardId),
|
where: eq(boards.id, boardId),
|
||||||
with: {
|
with: {
|
||||||
sections: {
|
sections: true,
|
||||||
|
items: {
|
||||||
with: {
|
with: {
|
||||||
items: true,
|
layouts: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1104,16 +1132,17 @@ describe("saveBoard should save full board", () => {
|
|||||||
|
|
||||||
const definedBoard = expectToBeDefined(board);
|
const definedBoard = expectToBeDefined(board);
|
||||||
expect(definedBoard.sections.length).toBe(1);
|
expect(definedBoard.sections.length).toBe(1);
|
||||||
const firstSection = expectToBeDefined(definedBoard.sections[0]);
|
expect(definedBoard.items.length).toBe(1);
|
||||||
expect(firstSection.items.length).toBe(1);
|
const firstItem = expectToBeDefined(definedBoard.items.find((item) => item.id === itemId));
|
||||||
const firstItem = expectToBeDefined(firstSection.items.find((item) => item.id === itemId));
|
|
||||||
expect(firstItem.id).toBe(itemId);
|
expect(firstItem.id).toBe(itemId);
|
||||||
expect(firstItem.kind).toBe("clock");
|
expect(firstItem.kind).toBe("clock");
|
||||||
expect(SuperJSON.parse<{ is24HourFormat: boolean }>(firstItem.options).is24HourFormat).toBe(false);
|
expect(SuperJSON.parse<{ is24HourFormat: boolean }>(firstItem.options).is24HourFormat).toBe(false);
|
||||||
expect(firstItem.height).toBe(3);
|
const firstLayout = expectToBeDefined(firstItem.layouts[0]);
|
||||||
expect(firstItem.width).toBe(2);
|
expect(firstLayout.sectionId).toBe(sectionId);
|
||||||
expect(firstItem.xOffset).toBe(7);
|
expect(firstLayout.height).toBe(3);
|
||||||
expect(firstItem.yOffset).toBe(5);
|
expect(firstLayout.width).toBe(2);
|
||||||
|
expect(firstLayout.xOffset).toBe(7);
|
||||||
|
expect(firstLayout.yOffset).toBe(5);
|
||||||
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
|
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
|
||||||
});
|
});
|
||||||
it("should fail when board not found", async () => {
|
it("should fail when board not found", async () => {
|
||||||
@@ -1124,6 +1153,7 @@ describe("saveBoard should save full board", () => {
|
|||||||
await caller.saveBoard({
|
await caller.saveBoard({
|
||||||
id: "nonExistentBoardId",
|
id: "nonExistentBoardId",
|
||||||
sections: [],
|
sections: [],
|
||||||
|
items: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(actAsync()).rejects.toThrowError("Board not found");
|
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 = (
|
const expectInputToBeFullBoardWithName = (
|
||||||
input: RouterOutputs["board"]["getHomeBoard"],
|
input: RouterOutputs["board"]["getHomeBoard"],
|
||||||
props: { name: string } & Awaited<ReturnType<typeof createFullBoardAsync>>,
|
props: { name: string } & Awaited<ReturnType<typeof createFullBoardAsync>>,
|
||||||
@@ -1302,8 +1491,8 @@ const expectInputToBeFullBoardWithName = (
|
|||||||
expect(input.sections.length).toBe(1);
|
expect(input.sections.length).toBe(1);
|
||||||
const firstSection = expectToBeDefined(input.sections[0]);
|
const firstSection = expectToBeDefined(input.sections[0]);
|
||||||
expect(firstSection.id).toBe(props.sectionId);
|
expect(firstSection.id).toBe(props.sectionId);
|
||||||
expect(firstSection.items.length).toBe(1);
|
expect(input.items.length).toBe(1);
|
||||||
const firstItem = expectToBeDefined(firstSection.items[0]);
|
const firstItem = expectToBeDefined(input.items[0]);
|
||||||
expect(firstItem.id).toBe(props.itemId);
|
expect(firstItem.id).toBe(props.itemId);
|
||||||
expect(firstItem.kind).toBe("clock");
|
expect(firstItem.kind).toBe("clock");
|
||||||
if (firstItem.kind === "clock") {
|
if (firstItem.kind === "clock") {
|
||||||
@@ -1326,6 +1515,15 @@ const createFullBoardAsync = async (db: Database, name: string) => {
|
|||||||
creatorId: defaultCreatorId,
|
creatorId: defaultCreatorId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const layoutId = createId();
|
||||||
|
await db.insert(layouts).values({
|
||||||
|
id: layoutId,
|
||||||
|
name: "Base",
|
||||||
|
columnCount: 10,
|
||||||
|
breakpoint: 0,
|
||||||
|
boardId,
|
||||||
|
});
|
||||||
|
|
||||||
const sectionId = createId();
|
const sectionId = createId();
|
||||||
await db.insert(sections).values({
|
await db.insert(sections).values({
|
||||||
id: sectionId,
|
id: sectionId,
|
||||||
@@ -1339,12 +1537,18 @@ const createFullBoardAsync = async (db: Database, name: string) => {
|
|||||||
await db.insert(items).values({
|
await db.insert(items).values({
|
||||||
id: itemId,
|
id: itemId,
|
||||||
kind: "clock",
|
kind: "clock",
|
||||||
|
boardId,
|
||||||
|
options: SuperJSON.stringify({ is24HourFormat: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(itemLayouts).values({
|
||||||
height: 1,
|
height: 1,
|
||||||
width: 1,
|
width: 1,
|
||||||
xOffset: 0,
|
xOffset: 0,
|
||||||
yOffset: 0,
|
yOffset: 0,
|
||||||
sectionId,
|
sectionId,
|
||||||
options: SuperJSON.stringify({ is24HourFormat: true }),
|
itemId,
|
||||||
|
layoutId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const integrationId = createId();
|
const integrationId = createId();
|
||||||
@@ -1363,7 +1567,226 @@ const createFullBoardAsync = async (db: Database, name: string) => {
|
|||||||
return {
|
return {
|
||||||
boardId,
|
boardId,
|
||||||
sectionId,
|
sectionId,
|
||||||
|
layoutId,
|
||||||
itemId,
|
itemId,
|
||||||
integrationId,
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -19,16 +19,9 @@ export const notebookRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const item = await ctx.db.query.items.findFirst({
|
const item = await ctx.db.query.items.findFirst({
|
||||||
where: eq(items.id, input.itemId),
|
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({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "Specified item was not found",
|
message: "Specified item was not found",
|
||||||
|
|||||||
@@ -23,8 +23,8 @@
|
|||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config",
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/core": "^0.37.4",
|
"@auth/core": "^0.38.0",
|
||||||
"@auth/drizzle-adapter": "^1.7.4",
|
"@auth/drizzle-adapter": "^1.8.0",
|
||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
"@homarr/db": "workspace:^0.1.0",
|
"@homarr/db": "workspace:^0.1.0",
|
||||||
"@homarr/definitions": "workspace:^0.1.0",
|
"@homarr/definitions": "workspace:^0.1.0",
|
||||||
@@ -47,8 +47,8 @@
|
|||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/bcrypt": "5.0.2",
|
"@types/bcrypt": "5.0.2",
|
||||||
"@types/cookies": "0.9.0",
|
"@types/cookies": "0.9.0",
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.21.0",
|
||||||
"prettier": "^3.5.1",
|
"prettier": "^3.5.2",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.8.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,7 @@ interface Integration {
|
|||||||
id: string;
|
id: string;
|
||||||
items: {
|
items: {
|
||||||
item: {
|
item: {
|
||||||
section: {
|
boardId: string;
|
||||||
boardId: string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
}[];
|
}[];
|
||||||
userPermissions: {
|
userPermissions: {
|
||||||
@@ -56,7 +54,7 @@ export const hasQueryAccessToIntegrationsAsync = async (
|
|||||||
|
|
||||||
const integrationsWithBoardIds = integrationsWithoutUseAccessAndWithoutBoardViewAllAccess.map((integration) => ({
|
const integrationsWithBoardIds = integrationsWithoutUseAccessAndWithoutBoardViewAllAccess.map((integration) => ({
|
||||||
id: integration.id,
|
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({
|
const permissionsOfCurrentUserWhenPresent = await db.query.boardUserPermissions.findMany({
|
||||||
|
|||||||
@@ -29,13 +29,13 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
|
|||||||
const integrations = [
|
const integrations = [
|
||||||
{
|
{
|
||||||
id: "1",
|
id: "1",
|
||||||
items: [{ item: { section: { boardId: "1" } } }],
|
items: [{ item: { boardId: "1" } }],
|
||||||
userPermissions: [],
|
userPermissions: [],
|
||||||
groupPermissions: [],
|
groupPermissions: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "2",
|
id: "2",
|
||||||
items: [{ item: { section: { boardId: "2" } } }],
|
items: [{ item: { boardId: "2" } }],
|
||||||
userPermissions: [],
|
userPermissions: [],
|
||||||
groupPermissions: [],
|
groupPermissions: [],
|
||||||
},
|
},
|
||||||
@@ -63,7 +63,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
|
|||||||
const integrations = [
|
const integrations = [
|
||||||
{
|
{
|
||||||
id: "1",
|
id: "1",
|
||||||
items: [{ item: { section: { boardId: "1" } } }],
|
items: [{ item: { boardId: "1" } }],
|
||||||
userPermissions: [],
|
userPermissions: [],
|
||||||
groupPermissions: [],
|
groupPermissions: [],
|
||||||
},
|
},
|
||||||
@@ -131,9 +131,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
item: {
|
item: {
|
||||||
section: {
|
boardId: "1",
|
||||||
boardId: "1",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -145,16 +143,12 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
item: {
|
item: {
|
||||||
section: {
|
boardId: "1",
|
||||||
boardId: "1",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
item: {
|
item: {
|
||||||
section: {
|
boardId: "2",
|
||||||
boardId: "2",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -189,9 +183,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
item: {
|
item: {
|
||||||
section: {
|
boardId: "1",
|
||||||
boardId: "1",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -203,9 +195,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
item: {
|
item: {
|
||||||
section: {
|
boardId: "2",
|
||||||
boardId: "2",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -240,9 +230,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
item: {
|
item: {
|
||||||
section: {
|
boardId: "1",
|
||||||
boardId: "1",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -254,16 +242,12 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
item: {
|
item: {
|
||||||
section: {
|
boardId: "1",
|
||||||
boardId: "1",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
item: {
|
item: {
|
||||||
section: {
|
boardId: "2",
|
||||||
boardId: "2",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -300,9 +284,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
item: {
|
item: {
|
||||||
section: {
|
boardId: "1",
|
||||||
boardId: "1",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -314,9 +296,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
item: {
|
item: {
|
||||||
section: {
|
boardId: "2",
|
||||||
boardId: "2",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -353,9 +333,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
item: {
|
item: {
|
||||||
section: {
|
boardId: "1",
|
||||||
boardId: "1",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -367,9 +345,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
item: {
|
item: {
|
||||||
section: {
|
boardId: "2",
|
||||||
boardId: "2",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -401,9 +377,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
item: {
|
item: {
|
||||||
section: {
|
boardId: "1",
|
||||||
boardId: "1",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -415,16 +389,12 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
item: {
|
item: {
|
||||||
section: {
|
boardId: "2",
|
||||||
boardId: "2",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
item: {
|
item: {
|
||||||
section: {
|
boardId: "1",
|
||||||
boardId: "1",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -452,9 +422,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
item: {
|
item: {
|
||||||
section: {
|
boardId: "1",
|
||||||
boardId: "1",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -466,16 +434,12 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
item: {
|
item: {
|
||||||
section: {
|
boardId: "2",
|
||||||
boardId: "2",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
item: {
|
item: {
|
||||||
section: {
|
boardId: "1",
|
||||||
boardId: "1",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -502,9 +466,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
item: {
|
item: {
|
||||||
section: {
|
boardId: "1",
|
||||||
boardId: "1",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -516,9 +478,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
item: {
|
item: {
|
||||||
section: {
|
boardId: "2",
|
||||||
boardId: "2",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -543,9 +503,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
item: {
|
item: {
|
||||||
section: {
|
boardId: "1",
|
||||||
boardId: "1",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -557,9 +515,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
item: {
|
item: {
|
||||||
section: {
|
boardId: "2",
|
||||||
boardId: "2",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -585,9 +541,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
item: {
|
item: {
|
||||||
section: {
|
boardId: "1",
|
||||||
boardId: "1",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -599,16 +553,12 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
item: {
|
item: {
|
||||||
section: {
|
boardId: "2",
|
||||||
boardId: "2",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
item: {
|
item: {
|
||||||
section: {
|
boardId: "1",
|
||||||
boardId: "1",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.21.0",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.8.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
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 { usePathname } from "next/navigation";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
@@ -10,7 +10,7 @@ import { clientApi } from "@homarr/api/client";
|
|||||||
import { updateBoardName } from "./updater";
|
import { updateBoardName } from "./updater";
|
||||||
|
|
||||||
const BoardContext = createContext<{
|
const BoardContext = createContext<{
|
||||||
board: RouterOutputs["board"]["getHomeBoard"];
|
board: RouterOutputs["board"]["getBoardByName"];
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
export const BoardProvider = ({
|
export const BoardProvider = ({
|
||||||
@@ -68,3 +68,43 @@ export const useOptionalBoard = () => {
|
|||||||
|
|
||||||
return context?.board ?? null;
|
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);
|
||||||
|
};
|
||||||
|
|||||||
@@ -23,13 +23,13 @@
|
|||||||
"prettier": "@homarr/prettier-config",
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
"undici": "7.3.0"
|
"undici": "7.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.21.0",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.8.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.21.0",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.8.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,14 +33,14 @@
|
|||||||
"next": "15.1.7",
|
"next": "15.1.7",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0",
|
"react-dom": "19.0.0",
|
||||||
"undici": "7.3.0",
|
"undici": "7.4.0",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.21.0",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.8.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.21.0",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.8.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.21.0",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.8.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.21.0",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.8.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.21.0",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.8.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
|
import type { InferInsertModel } from "drizzle-orm";
|
||||||
|
|
||||||
import { objectEntries } from "@homarr/common";
|
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 = {
|
type TableKey = {
|
||||||
[K in keyof typeof schema]: (typeof schema)[K] extends { _: { brand: "Table" } } ? K : never;
|
[K in keyof typeof schema]: (typeof schema)[K] extends { _: { brand: "Table" } } ? K : never;
|
||||||
}[keyof typeof schema];
|
}[keyof typeof schema];
|
||||||
|
|
||||||
export const createDbInsertCollection = <TTableKey extends TableKey>(tablesInInsertOrder: TTableKey[]) => {
|
export const createDbInsertCollectionForTransaction = <TTableKey extends TableKey>(
|
||||||
|
tablesInInsertOrder: TTableKey[],
|
||||||
|
) => {
|
||||||
const context = tablesInInsertOrder.reduce(
|
const context = tablesInInsertOrder.reduce(
|
||||||
(acc, key) => {
|
(acc, key) => {
|
||||||
acc[key] = [];
|
acc[key] = [];
|
||||||
@@ -17,7 +23,7 @@ export const createDbInsertCollection = <TTableKey extends TableKey>(tablesInIns
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...context,
|
...context,
|
||||||
insertAll: (db: Database) => {
|
insertAll: (db: HomarrDatabase) => {
|
||||||
db.transaction((transaction) => {
|
db.transaction((transaction) => {
|
||||||
for (const [key, values] of objectEntries(context)) {
|
for (const [key, values] of objectEntries(context)) {
|
||||||
if (values.length >= 1) {
|
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
1
packages/db/migrations/mysql/0028_add_app_ping_url.sql
Normal file
1
packages/db/migrations/mysql/0028_add_app_ping_url.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `app` ADD `ping_url` text;
|
||||||
50
packages/db/migrations/mysql/0029_add_layouts.sql
Normal file
50
packages/db/migrations/mysql/0029_add_layouts.sql
Normal 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';
|
||||||
@@ -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';
|
||||||
1833
packages/db/migrations/mysql/meta/0028_snapshot.json
Normal file
1833
packages/db/migrations/mysql/meta/0028_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2012
packages/db/migrations/mysql/meta/0029_snapshot.json
Normal file
2012
packages/db/migrations/mysql/meta/0029_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2012
packages/db/migrations/mysql/meta/0030_snapshot.json
Normal file
2012
packages/db/migrations/mysql/meta/0030_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -197,6 +197,27 @@
|
|||||||
"when": 1739915526818,
|
"when": 1739915526818,
|
||||||
"tag": "0027_acoustic_karma",
|
"tag": "0027_acoustic_karma",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 28,
|
||||||
|
"version": "5",
|
||||||
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
1
packages/db/migrations/sqlite/0028_add_app_ping_url.sql
Normal file
1
packages/db/migrations/sqlite/0028_add_app_ping_url.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `app` ADD `ping_url` text;
|
||||||
42
packages/db/migrations/sqlite/0029_add_layouts.sql
Normal file
42
packages/db/migrations/sqlite/0029_add_layouts.sql
Normal 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';
|
||||||
@@ -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;
|
||||||
1758
packages/db/migrations/sqlite/meta/0028_snapshot.json
Normal file
1758
packages/db/migrations/sqlite/meta/0028_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1932
packages/db/migrations/sqlite/meta/0029_snapshot.json
Normal file
1932
packages/db/migrations/sqlite/meta/0029_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1932
packages/db/migrations/sqlite/meta/0030_snapshot.json
Normal file
1932
packages/db/migrations/sqlite/meta/0030_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -197,6 +197,27 @@
|
|||||||
"when": 1739915486467,
|
"when": 1739915486467,
|
||||||
"tag": "0027_wooden_blizzard",
|
"tag": "0027_wooden_blizzard",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 28,
|
||||||
|
"version": "6",
|
||||||
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.ts",
|
".": "./index.ts",
|
||||||
"./client": "./client.ts",
|
"./client": "./client.ts",
|
||||||
|
"./collection": "./collection.ts",
|
||||||
"./schema": "./schema/index.ts",
|
"./schema": "./schema/index.ts",
|
||||||
"./test": "./test/index.ts",
|
"./test": "./test/index.ts",
|
||||||
"./queries": "./queries/index.ts",
|
"./queries": "./queries/index.ts",
|
||||||
@@ -37,7 +38,7 @@
|
|||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config",
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/core": "^0.37.4",
|
"@auth/core": "^0.38.0",
|
||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
"@homarr/definitions": "workspace:^0.1.0",
|
"@homarr/definitions": "workspace:^0.1.0",
|
||||||
"@homarr/env": "workspace:^0.1.0",
|
"@homarr/env": "workspace:^0.1.0",
|
||||||
@@ -48,8 +49,8 @@
|
|||||||
"@testcontainers/mysql": "^10.18.0",
|
"@testcontainers/mysql": "^10.18.0",
|
||||||
"better-sqlite3": "^11.8.1",
|
"better-sqlite3": "^11.8.1",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"drizzle-kit": "^0.30.4",
|
"drizzle-kit": "^0.30.5",
|
||||||
"drizzle-orm": "^0.39.3",
|
"drizzle-orm": "^0.40.0",
|
||||||
"drizzle-zod": "^0.7.0",
|
"drizzle-zod": "^0.7.0",
|
||||||
"mysql2": "3.12.0"
|
"mysql2": "3.12.0"
|
||||||
},
|
},
|
||||||
@@ -59,9 +60,9 @@
|
|||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/better-sqlite3": "7.6.12",
|
"@types/better-sqlite3": "7.6.12",
|
||||||
"dotenv-cli": "^8.0.0",
|
"dotenv-cli": "^8.0.0",
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.21.0",
|
||||||
"prettier": "^3.5.1",
|
"prettier": "^3.5.2",
|
||||||
"tsx": "4.19.3",
|
"tsx": "4.19.3",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.8.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ export const {
|
|||||||
users,
|
users,
|
||||||
verificationTokens,
|
verificationTokens,
|
||||||
sectionCollapseStates,
|
sectionCollapseStates,
|
||||||
|
layouts,
|
||||||
|
itemLayouts,
|
||||||
|
sectionLayouts,
|
||||||
} = schema;
|
} = schema;
|
||||||
|
|
||||||
export type User = InferSelectModel<typeof schema.users>;
|
export type User = InferSelectModel<typeof schema.users>;
|
||||||
|
|||||||
@@ -280,7 +280,6 @@ export const boards = mysqlTable("board", {
|
|||||||
secondaryColor: text().default("#fd7e14").notNull(),
|
secondaryColor: text().default("#fd7e14").notNull(),
|
||||||
opacity: int().default(100).notNull(),
|
opacity: int().default(100).notNull(),
|
||||||
customCss: text(),
|
customCss: text(),
|
||||||
columnCount: int().default(10).notNull(),
|
|
||||||
iconColor: text(),
|
iconColor: text(),
|
||||||
itemRadius: text().$type<MantineSize>().default("lg").notNull(),
|
itemRadius: text().$type<MantineSize>().default("lg").notNull(),
|
||||||
disableStatus: boolean().default(false).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", {
|
export const sections = mysqlTable("section", {
|
||||||
id: varchar({ length: 64 }).notNull().primaryKey(),
|
id: varchar({ length: 64 }).notNull().primaryKey(),
|
||||||
boardId: varchar({ length: 64 })
|
boardId: varchar({ length: 64 })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => boards.id, { onDelete: "cascade" }),
|
.references(() => boards.id, { onDelete: "cascade" }),
|
||||||
kind: text().$type<SectionKind>().notNull(),
|
kind: text().$type<SectionKind>().notNull(),
|
||||||
xOffset: int().notNull(),
|
xOffset: int(),
|
||||||
yOffset: int().notNull(),
|
yOffset: int(),
|
||||||
width: int(),
|
|
||||||
height: int(),
|
|
||||||
name: text(),
|
name: text(),
|
||||||
parentSectionId: varchar({ length: 64 }).references((): AnyMySqlColumn => sections.id, {
|
|
||||||
onDelete: "cascade",
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sectionCollapseStates = mysqlTable(
|
export const sectionCollapseStates = mysqlTable(
|
||||||
@@ -358,14 +410,10 @@ export const sectionCollapseStates = mysqlTable(
|
|||||||
|
|
||||||
export const items = mysqlTable("item", {
|
export const items = mysqlTable("item", {
|
||||||
id: varchar({ length: 64 }).notNull().primaryKey(),
|
id: varchar({ length: 64 }).notNull().primaryKey(),
|
||||||
sectionId: varchar({ length: 64 })
|
boardId: varchar({ length: 64 })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => sections.id, { onDelete: "cascade" }),
|
.references(() => boards.id, { onDelete: "cascade" }),
|
||||||
kind: text().$type<WidgetKind>().notNull(),
|
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
|
options: text().default('{"json": {}}').notNull(), // empty superjson object
|
||||||
advancedOptions: text().default('{"json": {}}').notNull(), // empty superjson object
|
advancedOptions: text().default('{"json": {}}').notNull(), // empty superjson object
|
||||||
});
|
});
|
||||||
@@ -376,6 +424,7 @@ export const apps = mysqlTable("app", {
|
|||||||
description: text(),
|
description: text(),
|
||||||
iconUrl: text().notNull(),
|
iconUrl: text().notNull(),
|
||||||
href: text(),
|
href: text(),
|
||||||
|
pingUrl: text(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const integrationItems = mysqlTable(
|
export const integrationItems = mysqlTable(
|
||||||
@@ -589,12 +638,14 @@ export const integrationSecretRelations = relations(integrationSecrets, ({ one }
|
|||||||
|
|
||||||
export const boardRelations = relations(boards, ({ many, one }) => ({
|
export const boardRelations = relations(boards, ({ many, one }) => ({
|
||||||
sections: many(sections),
|
sections: many(sections),
|
||||||
|
items: many(items),
|
||||||
creator: one(users, {
|
creator: one(users, {
|
||||||
fields: [boards.creatorId],
|
fields: [boards.creatorId],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
}),
|
}),
|
||||||
userPermissions: many(boardUserPermissions),
|
userPermissions: many(boardUserPermissions),
|
||||||
groupPermissions: many(boardGroupPermissions),
|
groupPermissions: many(boardGroupPermissions),
|
||||||
|
layouts: many(layouts),
|
||||||
groupHomes: many(groups, {
|
groupHomes: many(groups, {
|
||||||
relationName: "groupRelations__board__homeBoardId",
|
relationName: "groupRelations__board__homeBoardId",
|
||||||
}),
|
}),
|
||||||
@@ -604,12 +655,17 @@ export const boardRelations = relations(boards, ({ many, one }) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
export const sectionRelations = relations(sections, ({ many, one }) => ({
|
export const sectionRelations = relations(sections, ({ many, one }) => ({
|
||||||
items: many(items),
|
|
||||||
board: one(boards, {
|
board: one(boards, {
|
||||||
fields: [sections.boardId],
|
fields: [sections.boardId],
|
||||||
references: [boards.id],
|
references: [boards.id],
|
||||||
}),
|
}),
|
||||||
collapseStates: many(sectionCollapseStates),
|
collapseStates: many(sectionCollapseStates),
|
||||||
|
layouts: many(sectionLayouts, {
|
||||||
|
relationName: "sectionLayoutRelations__section__sectionId",
|
||||||
|
}),
|
||||||
|
children: many(sectionLayouts, {
|
||||||
|
relationName: "sectionLayoutRelations__section__parentSectionId",
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const sectionCollapseStateRelations = relations(sectionCollapseStates, ({ one }) => ({
|
export const sectionCollapseStateRelations = relations(sectionCollapseStates, ({ one }) => ({
|
||||||
@@ -624,11 +680,12 @@ export const sectionCollapseStateRelations = relations(sectionCollapseStates, ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
export const itemRelations = relations(items, ({ one, many }) => ({
|
export const itemRelations = relations(items, ({ one, many }) => ({
|
||||||
section: one(sections, {
|
|
||||||
fields: [items.sectionId],
|
|
||||||
references: [sections.id],
|
|
||||||
}),
|
|
||||||
integrations: many(integrationItems),
|
integrations: many(integrationItems),
|
||||||
|
layouts: many(itemLayouts),
|
||||||
|
board: one(boards, {
|
||||||
|
fields: [items.boardId],
|
||||||
|
references: [boards.id],
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const integrationItemRelations = relations(integrationItems, ({ one }) => ({
|
export const integrationItemRelations = relations(integrationItems, ({ one }) => ({
|
||||||
@@ -649,3 +706,44 @@ export const searchEngineRelations = relations(searchEngines, ({ one, many }) =>
|
|||||||
}),
|
}),
|
||||||
usersWithDefault: many(users),
|
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],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|||||||
@@ -265,7 +265,6 @@ export const boards = sqliteTable("board", {
|
|||||||
secondaryColor: text().default("#fd7e14").notNull(),
|
secondaryColor: text().default("#fd7e14").notNull(),
|
||||||
opacity: int().default(100).notNull(),
|
opacity: int().default(100).notNull(),
|
||||||
customCss: text(),
|
customCss: text(),
|
||||||
columnCount: int().default(10).notNull(),
|
|
||||||
iconColor: text(),
|
iconColor: text(),
|
||||||
itemRadius: text().$type<MantineSize>().default("lg").notNull(),
|
itemRadius: text().$type<MantineSize>().default("lg").notNull(),
|
||||||
disableStatus: int({ mode: "boolean" }).default(false).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", {
|
export const sections = sqliteTable("section", {
|
||||||
id: text().notNull().primaryKey(),
|
id: text().notNull().primaryKey(),
|
||||||
boardId: text()
|
boardId: text()
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => boards.id, { onDelete: "cascade" }),
|
.references(() => boards.id, { onDelete: "cascade" }),
|
||||||
kind: text().$type<SectionKind>().notNull(),
|
kind: text().$type<SectionKind>().notNull(),
|
||||||
xOffset: int().notNull(),
|
xOffset: int(),
|
||||||
yOffset: int().notNull(),
|
yOffset: int(),
|
||||||
width: int(),
|
|
||||||
height: int(),
|
|
||||||
name: text(),
|
name: text(),
|
||||||
parentSectionId: text().references((): AnySQLiteColumn => sections.id, {
|
|
||||||
onDelete: "cascade",
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sectionCollapseStates = sqliteTable(
|
export const sectionCollapseStates = sqliteTable(
|
||||||
@@ -343,14 +395,10 @@ export const sectionCollapseStates = sqliteTable(
|
|||||||
|
|
||||||
export const items = sqliteTable("item", {
|
export const items = sqliteTable("item", {
|
||||||
id: text().notNull().primaryKey(),
|
id: text().notNull().primaryKey(),
|
||||||
sectionId: text()
|
boardId: text()
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => sections.id, { onDelete: "cascade" }),
|
.references(() => boards.id, { onDelete: "cascade" }),
|
||||||
kind: text().$type<WidgetKind>().notNull(),
|
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
|
options: text().default('{"json": {}}').notNull(), // empty superjson object
|
||||||
advancedOptions: text().default('{"json": {}}').notNull(), // empty superjson object
|
advancedOptions: text().default('{"json": {}}').notNull(), // empty superjson object
|
||||||
});
|
});
|
||||||
@@ -361,6 +409,7 @@ export const apps = sqliteTable("app", {
|
|||||||
description: text(),
|
description: text(),
|
||||||
iconUrl: text().notNull(),
|
iconUrl: text().notNull(),
|
||||||
href: text(),
|
href: text(),
|
||||||
|
pingUrl: text(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const integrationItems = sqliteTable(
|
export const integrationItems = sqliteTable(
|
||||||
@@ -575,12 +624,14 @@ export const integrationSecretRelations = relations(integrationSecrets, ({ one }
|
|||||||
|
|
||||||
export const boardRelations = relations(boards, ({ many, one }) => ({
|
export const boardRelations = relations(boards, ({ many, one }) => ({
|
||||||
sections: many(sections),
|
sections: many(sections),
|
||||||
|
items: many(items),
|
||||||
creator: one(users, {
|
creator: one(users, {
|
||||||
fields: [boards.creatorId],
|
fields: [boards.creatorId],
|
||||||
references: [users.id],
|
references: [users.id],
|
||||||
}),
|
}),
|
||||||
userPermissions: many(boardUserPermissions),
|
userPermissions: many(boardUserPermissions),
|
||||||
groupPermissions: many(boardGroupPermissions),
|
groupPermissions: many(boardGroupPermissions),
|
||||||
|
layouts: many(layouts),
|
||||||
groupHomes: many(groups, {
|
groupHomes: many(groups, {
|
||||||
relationName: "groupRelations__board__homeBoardId",
|
relationName: "groupRelations__board__homeBoardId",
|
||||||
}),
|
}),
|
||||||
@@ -590,12 +641,17 @@ export const boardRelations = relations(boards, ({ many, one }) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
export const sectionRelations = relations(sections, ({ many, one }) => ({
|
export const sectionRelations = relations(sections, ({ many, one }) => ({
|
||||||
items: many(items),
|
|
||||||
board: one(boards, {
|
board: one(boards, {
|
||||||
fields: [sections.boardId],
|
fields: [sections.boardId],
|
||||||
references: [boards.id],
|
references: [boards.id],
|
||||||
}),
|
}),
|
||||||
collapseStates: many(sectionCollapseStates),
|
collapseStates: many(sectionCollapseStates),
|
||||||
|
layouts: many(sectionLayouts, {
|
||||||
|
relationName: "sectionLayoutRelations__section__sectionId",
|
||||||
|
}),
|
||||||
|
children: many(sectionLayouts, {
|
||||||
|
relationName: "sectionLayoutRelations__section__parentSectionId",
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const sectionCollapseStateRelations = relations(sectionCollapseStates, ({ one }) => ({
|
export const sectionCollapseStateRelations = relations(sectionCollapseStates, ({ one }) => ({
|
||||||
@@ -610,11 +666,12 @@ export const sectionCollapseStateRelations = relations(sectionCollapseStates, ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
export const itemRelations = relations(items, ({ one, many }) => ({
|
export const itemRelations = relations(items, ({ one, many }) => ({
|
||||||
section: one(sections, {
|
|
||||||
fields: [items.sectionId],
|
|
||||||
references: [sections.id],
|
|
||||||
}),
|
|
||||||
integrations: many(integrationItems),
|
integrations: many(integrationItems),
|
||||||
|
layouts: many(itemLayouts),
|
||||||
|
board: one(boards, {
|
||||||
|
fields: [items.boardId],
|
||||||
|
references: [boards.id],
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const integrationItemRelations = relations(integrationItems, ({ one }) => ({
|
export const integrationItemRelations = relations(integrationItems, ({ one }) => ({
|
||||||
@@ -635,3 +692,44 @@ export const searchEngineRelations = relations(searchEngines, ({ one, many }) =>
|
|||||||
}),
|
}),
|
||||||
usersWithDefault: many(users),
|
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],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.21.0",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.8.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ export type HomarrDocumentationPath =
|
|||||||
| "/docs/tags/variables"
|
| "/docs/tags/variables"
|
||||||
| "/docs/tags/widgets"
|
| "/docs/tags/widgets"
|
||||||
| "/docs/advanced/command-line"
|
| "/docs/advanced/command-line"
|
||||||
|
| "/docs/advanced/command-line/fix-usernames"
|
||||||
| "/docs/advanced/command-line/password-recovery"
|
| "/docs/advanced/command-line/password-recovery"
|
||||||
| "/docs/advanced/development/getting-started"
|
| "/docs/advanced/development/getting-started"
|
||||||
| "/docs/advanced/environment-variables"
|
| "/docs/advanced/environment-variables"
|
||||||
|
|||||||
@@ -31,8 +31,8 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/dockerode": "^3.3.34",
|
"@types/dockerode": "^3.3.35",
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.21.0",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.8.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4
packages/env/package.json
vendored
4
packages/env/package.json
vendored
@@ -30,7 +30,7 @@
|
|||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"eslint": "^9.20.1",
|
"eslint": "^9.21.0",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.8.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user