Compare commits

..
Author SHA1 Message Date
Manuel 3498fdaebb feat: implement cacheable lookup 2025-02-23 15:48:56 +01:00
265 changed files with 6932 additions and 20152 deletions

No files matched your search

-3
View File
@@ -31,9 +31,6 @@ body:
label: Version
description: What version of Homarr are you running?
options:
- 1.9.0
- 1.8.0
- 1.7.0
- 1.6.0
- 1.5.0
- 1.4.0
+2 -2
View File
@@ -6,9 +6,9 @@
matchPackagePatterns: ["^@homarr/"],
enabled: false,
},
// 15.2.0 crashes with turbopack error (panic)
// Disable Dockerode updates see https://github.com/apocas/dockerode/issues/787
{
matchPackagePatterns: ["^next$", "^@next/eslint-plugin-next$"],
matchPackagePatterns: ["^dockerode$"],
enabled: false,
},
{
+40 -105
View File
@@ -15,14 +15,13 @@ on:
description: Send notifications
permissions:
contents: write # Required to update package.json version
packages: write # Required for pushing to GHCR
contents: write
packages: write
env:
SKIP_ENV_VALIDATION: true
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
GHCR_REPO: ghcr.io/${{ github.repository }}
TURBO_TELEMETRY_DISABLED: 1
concurrency:
@@ -103,123 +102,59 @@ jobs:
git pull origin dev
git rebase ${{ github.ref_name }}
git push origin dev
build-amd64:
name: Build docker image for amd64
deploy:
name: Deploy docker image
needs: release
runs-on: ubuntu-latest
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.release.outputs.git_ref }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: "${{ env.GHCR_REPO }}"
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
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
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
id: meta
uses: docker/metadata-action@v5
with:
images: "${{ env.GHCR_REPO }}"
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
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
with:
context: .
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:
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:
- uses: actions/checkout@v4
with:
ref: ${{ needs.release.outputs.git_ref }}
- name: Discord notification
if: ${{ github.events.inputs.send-notifications != false }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
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 }}>)"
- 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: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
tags: |
${{ env.DEPLOY_LATEST == 'true' && 'type=raw,value=latest' || null }}
${{ env.DEPLOY_BETA == 'true' && 'type=raw,value=beta' || null }}
type=raw,value=${{ env.NEXT_VERSION }}
- name: Build and push
id: buildPushAction
uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm64
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
network: host
env:
SKIP_ENV_VALIDATION: true
- name: Discord notification
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
args: "Successfully deployed images for branch **${{ github.ref_name }}**. Tagged as **${{env.NEXT_VERSION}}**."
args: "Deployment of image has completed for branch ${{ github.ref_name }}. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'."
-73
View File
@@ -1,76 +1,3 @@
## [1.10.0](https://github.com/homarr-labs/homarr/compare/v1.9.0...v1.10.0) (2025-03-07)
### Features
* remove cqmin system ([#2407](https://github.com/homarr-labs/homarr/issues/2407)) ([37d4714](https://github.com/homarr-labs/homarr/commit/37d471457a0a2b10112b67c02923f63de479e617))
### Bug Fixes
* **deps:** update dependency eslint-config-prettier to ^10.1.1 ([#2524](https://github.com/homarr-labs/homarr/issues/2524)) ([4721a97](https://github.com/homarr-labs/homarr/commit/4721a97094ae1181216923c7fef7a29c343f882b))
* **deps:** update dependency ioredis to v5.6.0 ([#2509](https://github.com/homarr-labs/homarr/issues/2509)) ([e4a3922](https://github.com/homarr-labs/homarr/commit/e4a39223a33d4bfccb1e9b4e2c30de1376879a1d))
* **deps:** update dependency mysql2 to v3.13.0 ([#2516](https://github.com/homarr-labs/homarr/issues/2516)) ([67c739c](https://github.com/homarr-labs/homarr/commit/67c739c35bcb2d537a349f0ebff080eac67e93b5))
* **deps:** update tanstack-query monorepo to ^5.67.2 ([#2525](https://github.com/homarr-labs/homarr/issues/2525)) ([a572aae](https://github.com/homarr-labs/homarr/commit/a572aae887142a7e601f70dcc601bf6335e37c17))
* **pi-hole:** wrong fetch used in factory ([#2527](https://github.com/homarr-labs/homarr/issues/2527)) ([9881577](https://github.com/homarr-labs/homarr/commit/9881577f47fbeacf861dd564d57fc6d7c469d27b))
## [1.9.0](https://github.com/homarr-labs/homarr/compare/v1.8.0...v1.9.0) (2025-03-05)
### Features
* add sigterm support ([#2472](https://github.com/homarr-labs/homarr/issues/2472)) ([a5e0e8c](https://github.com/homarr-labs/homarr/commit/a5e0e8c1296ebfbe46c88fbb152b5d0049db349d))
* added bordercolor option for dynamic section ([#2334](https://github.com/homarr-labs/homarr/issues/2334)) ([7dfb108](https://github.com/homarr-labs/homarr/commit/7dfb108a56e38c32e712b82d1e1bd65a5c15722b))
* **integration:** add emby integration ([#2466](https://github.com/homarr-labs/homarr/issues/2466)) ([7dfc364](https://github.com/homarr-labs/homarr/commit/7dfc3646b709e6bb1fc7ec11336024c7eff1f342))
* **media-request:** show request status ([#2465](https://github.com/homarr-labs/homarr/issues/2465)) ([80c2040](https://github.com/homarr-labs/homarr/commit/80c2040b1345b0a60030aa8879442b937863216f))
* **pihole:** add support for v6 ([#2448](https://github.com/homarr-labs/homarr/issues/2448)) ([3804d53](https://github.com/homarr-labs/homarr/commit/3804d530ec28c90b6ab1fa0f709468e2075afa28))
* use fetch with trusted certificates for oidc ([#2412](https://github.com/homarr-labs/homarr/issues/2412)) ([6fad48f](https://github.com/homarr-labs/homarr/commit/6fad48f65ad7cbb927fd80de49930a8256110331))
### Bug Fixes
* affix hides pagination on mobile ([#2491](https://github.com/homarr-labs/homarr/issues/2491)) ([604a05f](https://github.com/homarr-labs/homarr/commit/604a05f9d528b7d89b09f4f7982921963ba0fb90))
* **boards:** case-sensitive board names in url ([#2454](https://github.com/homarr-labs/homarr/issues/2454)) ([c0e3544](https://github.com/homarr-labs/homarr/commit/c0e354428af2c69bf7638911320888f049ec0659))
* **deps:** update dependency @tabler/icons-react to ^3.31.0 ([#2502](https://github.com/homarr-labs/homarr/issues/2502)) ([478aae6](https://github.com/homarr-labs/homarr/commit/478aae6cd496ef81e3036d8f2a5b6959b7fea12e))
* **deps:** update dependency prettier to ^3.5.3 ([#2485](https://github.com/homarr-labs/homarr/issues/2485)) ([49b0f96](https://github.com/homarr-labs/homarr/commit/49b0f962e0b316bbed599d77d97be927aac252da))
* **deps:** update dependency typescript-eslint to ^8.26.0 ([#2488](https://github.com/homarr-labs/homarr/issues/2488)) ([21f3a5a](https://github.com/homarr-labs/homarr/commit/21f3a5a408ab45dbf7d92162703e74b9e60aff10))
* **deps:** update mantine monorepo to ^7.17.1 ([#2462](https://github.com/homarr-labs/homarr/issues/2462)) ([d9ef1b0](https://github.com/homarr-labs/homarr/commit/d9ef1b04706a3435d4500db2f2966a28db900d9d))
* **deps:** update tanstack-query monorepo to ^5.67.0 ([#2486](https://github.com/homarr-labs/homarr/issues/2486)) ([bad07cb](https://github.com/homarr-labs/homarr/commit/bad07cbf251ab64afe99390bcb687d2f58e8fdc7))
* **deps:** update tanstack-query monorepo to ^5.67.1 ([#2487](https://github.com/homarr-labs/homarr/issues/2487)) ([6135b59](https://github.com/homarr-labs/homarr/commit/6135b59b7d24029eaf24ae2a2b94b5cbc822242a))
* docker add to homarr layout overflow ([#2441](https://github.com/homarr-labs/homarr/issues/2441)) ([a3999bc](https://github.com/homarr-labs/homarr/commit/a3999bcf521d4d339282701b4734b6cd352c9582))
* **integration:** store omv session in redis ([#2467](https://github.com/homarr-labs/homarr/issues/2467)) ([b3e4f30](https://github.com/homarr-labs/homarr/commit/b3e4f3031296ffc69d914f542f95895a449ce2c7))
* **logs:** add special case for axios error to prevent huge error messages ([#2468](https://github.com/homarr-labs/homarr/issues/2468)) ([9eb7663](https://github.com/homarr-labs/homarr/commit/9eb76634d07b7bf5ff28062714c44e150591d75d))
* **media-request:** inconsistent sorting and reordering ([#2464](https://github.com/homarr-labs/homarr/issues/2464)) ([43619e0](https://github.com/homarr-labs/homarr/commit/43619e0cc0311ca8f26a4fdbc0ccb7fae79c3514))
* **old-import:** default logo and favicon not replaced ([#2456](https://github.com/homarr-labs/homarr/issues/2456)) ([788304a](https://github.com/homarr-labs/homarr/commit/788304adfbe3c6c9696758e9ad42bca9d9b86290))
## [1.8.0](https://github.com/homarr-labs/homarr/compare/v1.7.0...v1.8.0) (2025-02-28)
### Features
* add app ping url ([#2380](https://github.com/homarr-labs/homarr/issues/2380)) ([fb467ac](https://github.com/homarr-labs/homarr/commit/fb467ac16553d3c498770a531cf77b9f2c7c0a9e))
* **boards:** add responsive layout system ([#2271](https://github.com/homarr-labs/homarr/issues/2271)) ([7761dc2](https://github.com/homarr-labs/homarr/commit/7761dc29c809b28cc8275dcabf77c453cea3621c))
### Bug Fixes
* await params for board metadata ([#2406](https://github.com/homarr-labs/homarr/issues/2406)) ([2085b5e](https://github.com/homarr-labs/homarr/commit/2085b5ece24f661086c4896a6ecfbff61de1b7d4))
* **boards:** add scrollarea to board-select ([#2409](https://github.com/homarr-labs/homarr/issues/2409)) ([508f9af](https://github.com/homarr-labs/homarr/commit/508f9afeb542d09d06fc34f06ac68cf732011620))
* chinese traditional locale ([#2440](https://github.com/homarr-labs/homarr/issues/2440)) ([50367eb](https://github.com/homarr-labs/homarr/commit/50367eb2f5ae9cb6b736197692b3f555e07aca59))
* **deps:** downgrade to next 15.1.7 cause of turbopack crash with 15.2.0 and above ([#2438](https://github.com/homarr-labs/homarr/issues/2438)) ([fdd91f5](https://github.com/homarr-labs/homarr/commit/fdd91f585d1c0c66a6c28c6d1e374bdd9fdcb787))
* **deps:** update dependency @auth/core to ^0.38.0 ([#2434](https://github.com/homarr-labs/homarr/issues/2434)) ([8c1308c](https://github.com/homarr-labs/homarr/commit/8c1308c97f011336c2a813e92e7cefa195277994))
* **deps:** update dependency @auth/drizzle-adapter to ^1.8.0 ([#2435](https://github.com/homarr-labs/homarr/issues/2435)) ([b2cc97e](https://github.com/homarr-labs/homarr/commit/b2cc97e316976e74df8136a7da53a3fae78f815b))
* **deps:** update dependency drizzle-kit to ^0.30.5 ([#2423](https://github.com/homarr-labs/homarr/issues/2423)) ([3b830ae](https://github.com/homarr-labs/homarr/commit/3b830ae098c290aaa15356cae5ec2fe8cb618d9f))
* **deps:** update dependency drizzle-orm to ^0.40.0 ([#2425](https://github.com/homarr-labs/homarr/issues/2425)) ([faf7322](https://github.com/homarr-labs/homarr/commit/faf7322bcb94a61dbb2b3295cc4a4eca52484d74))
* **deps:** update dependency eslint-config-prettier to ^10.0.2 ([#2427](https://github.com/homarr-labs/homarr/issues/2427)) ([c2d6e2e](https://github.com/homarr-labs/homarr/commit/c2d6e2efcf06e97c04bddc2d821c77ad034426b8))
* **deps:** update dependency eslint-plugin-react-hooks to ^5.2.0 ([#2449](https://github.com/homarr-labs/homarr/issues/2449)) ([a88c1ba](https://github.com/homarr-labs/homarr/commit/a88c1ba4401bbe6317ccde11cff9a057bfa53e26))
* **deps:** update dependency prettier to ^3.5.2 ([#2399](https://github.com/homarr-labs/homarr/issues/2399)) ([86e778d](https://github.com/homarr-labs/homarr/commit/86e778db1ee081288b4174f6b52fcc44a0bbc1e0))
* **deps:** update dependency sass to ^1.85.1 ([#2418](https://github.com/homarr-labs/homarr/issues/2418)) ([a9a37af](https://github.com/homarr-labs/homarr/commit/a9a37afd441ad148a42432c24a9c7e6acf804c89))
* **deps:** update dependency swagger-ui-react to ^5.20.0 ([#2436](https://github.com/homarr-labs/homarr/issues/2436)) ([add9170](https://github.com/homarr-labs/homarr/commit/add91701de30eaeb61efe3295307756e998703a9))
* **deps:** update dependency typescript-eslint to ^8.25.0 ([#2413](https://github.com/homarr-labs/homarr/issues/2413)) ([593c50b](https://github.com/homarr-labs/homarr/commit/593c50bb8f803ecbda8e6d0c4a082e019cc56352))
* **deps:** update dependency undici to v7.4.0 ([#2445](https://github.com/homarr-labs/homarr/issues/2445)) ([06ba0f0](https://github.com/homarr-labs/homarr/commit/06ba0f0e9f6da9c22ef531e41811a89c8c5039a8))
* **deps:** update nextjs monorepo to ^15.2.0 ([#2428](https://github.com/homarr-labs/homarr/issues/2428)) ([d6939e4](https://github.com/homarr-labs/homarr/commit/d6939e4a6daf7fbe0095ae4028d33189c5866fb2))
* **deps:** update tanstack-query monorepo to ^5.66.10 ([#2443](https://github.com/homarr-labs/homarr/issues/2443)) ([4483dc8](https://github.com/homarr-labs/homarr/commit/4483dc8bcd062f531badd94ba872cf42a17491df))
* **deps:** update tanstack-query monorepo to ^5.66.11 ([#2447](https://github.com/homarr-labs/homarr/issues/2447)) ([5537f95](https://github.com/homarr-labs/homarr/commit/5537f95b85b1ae56a35d7b529907ebe22d0b1ae6))
* **deps:** update turbo monorepo to ^2.4.3 ([#2422](https://github.com/homarr-labs/homarr/issues/2422)) ([874414f](https://github.com/homarr-labs/homarr/commit/874414fc4fc1130502abc267116ad109dab11efe))
* **deps:** update turbo monorepo to ^2.4.4 ([#2424](https://github.com/homarr-labs/homarr/issues/2424)) ([a6f9199](https://github.com/homarr-labs/homarr/commit/a6f91998c3a8192eaa93b57a74b6dfc8f3fdc13a))
* omv invalid temperature when not available ([#2322](https://github.com/homarr-labs/homarr/issues/2322)) ([5dc5d9e](https://github.com/homarr-labs/homarr/commit/5dc5d9e04ab747655276494d70557b45fed87b2c))
* update checker logs incorrectly ([#2402](https://github.com/homarr-labs/homarr/issues/2402)) ([c5107b2](https://github.com/homarr-labs/homarr/commit/c5107b2b67cad73808dcbae6bde633ac1a7eb2b5))
## [1.7.0](https://github.com/homarr-labs/homarr/compare/v1.6.0...v1.7.0) (2025-02-21)
### Features
+15 -15
View File
@@ -48,17 +48,17 @@
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^7.17.1",
"@mantine/core": "^7.17.1",
"@mantine/dropzone": "^7.17.1",
"@mantine/hooks": "^7.17.1",
"@mantine/modals": "^7.17.1",
"@mantine/tiptap": "^7.17.1",
"@mantine/colors-generator": "^7.17.0",
"@mantine/core": "^7.17.0",
"@mantine/dropzone": "^7.17.0",
"@mantine/hooks": "^7.17.0",
"@mantine/modals": "^7.17.0",
"@mantine/tiptap": "^7.17.0",
"@million/lint": "1.0.14",
"@tabler/icons-react": "^3.31.0",
"@tanstack/react-query": "^5.67.2",
"@tanstack/react-query-devtools": "^5.67.2",
"@tanstack/react-query-next-experimental": "^5.67.2",
"@tabler/icons-react": "^3.30.0",
"@tanstack/react-query": "^5.66.9",
"@tanstack/react-query-devtools": "^5.66.9",
"@tanstack/react-query-next-experimental": "^5.66.9",
"@trpc/client": "next",
"@trpc/next": "next",
"@trpc/react-query": "next",
@@ -81,9 +81,9 @@
"react-dom": "19.0.0",
"react-error-boundary": "^5.0.0",
"react-simple-code-editor": "^0.14.1",
"sass": "^1.85.1",
"sass": "^1.85.0",
"superjson": "2.2.2",
"swagger-ui-react": "^5.20.0",
"swagger-ui-react": "^5.19.0",
"use-deep-compare-effect": "^1.8.1",
"zod": "^3.24.2"
},
@@ -92,7 +92,7 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "3.1.1",
"@types/node": "^22.13.9",
"@types/node": "^22.13.5",
"@types/prismjs": "^1.26.5",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
@@ -100,7 +100,7 @@
"concurrently": "^9.1.2",
"eslint": "^9.21.0",
"node-loader": "^2.1.0",
"prettier": "^3.5.3",
"typescript": "^5.8.2"
"prettier": "^3.5.2",
"typescript": "^5.7.3"
}
}
@@ -5,7 +5,7 @@ import { Box, LoadingOverlay, Stack } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useRequiredBoard } from "@homarr/boards/context";
import { BoardCategorySection } from "~/components/board/sections/category-section";
import { BoardEmptySection } from "~/components/board/sections/empty-section";
@@ -43,7 +43,6 @@ export const useUpdateBoard = () => {
export const ClientBoard = () => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const isReady = useIsBoardReady();
const fullWidthSortedSections = board.sections
@@ -64,10 +63,9 @@ export const ClientBoard = () => {
<Stack ref={ref} h="100%" style={{ visibility: isReady ? "visible" : "hidden" }}>
{fullWidthSortedSections.map((section) =>
section.kind === "empty" ? (
// Unique keys per layout to always reinitialize the gridstack
<BoardEmptySection key={`${currentLayoutId}-${section.id}`} section={section} />
<BoardEmptySection key={section.id} section={section} />
) : (
<BoardCategorySection key={`${currentLayoutId}-${section.id}`} section={section} />
<BoardCategorySection key={section.id} section={section} />
),
)}
</Stack>
@@ -13,7 +13,7 @@ import { getI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata";
import { createBoardLayout } from "../_layout-creator";
import type { Board } from "../_types";
import { DynamicClientBoard } from "./_dynamic-client";
import { ClientBoard } from "./_client";
import { BoardContentHeaderActions } from "./_header-actions";
export type Params = Record<string, unknown>;
@@ -37,13 +37,13 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
return (
<IntegrationProvider integrations={integrations}>
<DynamicClientBoard />
<ClientBoard />
</IntegrationProvider>
);
},
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
generateMetadataAsync: async ({ params }: { params: TParams }): Promise<Metadata> => {
try {
const board = await getInitialBoard(await params);
const board = await getInitialBoard(params);
const t = await getI18n();
return {
@@ -1,7 +0,0 @@
"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 Link from "next/link";
import { useRouter } from "next/navigation";
import { Group, Menu, ScrollArea } from "@mantine/core";
import { Group, Menu } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
import {
IconBox,
@@ -168,18 +168,16 @@ const SelectBoardsMenu = () => {
</HeaderButton>
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
<ScrollArea.Autosize mah={300}>
{boards.map((board) => (
<Menu.Item
key={board.id}
component={Link}
href={`/boards/${board.name}`}
leftSection={<IconLayoutBoard size={20} />}
>
{board.name}
</Menu.Item>
))}
</ScrollArea.Autosize>
{boards.map((board) => (
<Menu.Item
key={board.id}
component={Link}
href={`/boards/${board.name}`}
leftSection={<IconLayoutBoard size={20} />}
>
{board.name}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
);
@@ -1,109 +1,43 @@
"use client";
import { Button, Fieldset, Grid, Group, Input, NumberInput, Slider, Stack, Text, TextInput } from "@mantine/core";
import { Button, Grid, Group, Input, Slider, Stack } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { createId } from "@homarr/db/client";
import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared";
interface Props {
board: Board;
}
export const LayoutSettingsContent = ({ board }: Props) => {
const t = useI18n();
const utils = clientApi.useUtils();
const { mutate: saveLayouts, isPending } = clientApi.board.saveLayouts.useMutation({
onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getHomeBoard.invalidate();
},
});
const form = useZodForm(validation.board.saveLayouts.omit({ id: true }).required(), {
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
const form = useZodForm(validation.board.savePartialSettings.pick({ columnCount: true }).required(), {
initialValues: {
layouts: board.layouts,
columnCount: board.columnCount,
},
});
return (
<form
onSubmit={form.onSubmit((values) => {
saveLayouts({
savePartialSettings({
id: board.id,
...values,
});
})}
>
<Stack>
<Stack gap="sm">
<Group justify="space-between" align="center">
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
<Button
variant="subtle"
onClick={() => {
form.setValues({
layouts: [
...form.values.layouts,
{
id: createId(),
name: "",
columnCount: 10,
breakpoint: 0,
},
],
});
}}
>
{t("board.setting.section.layout.responsive.action.add")}
</Button>
</Group>
{form.values.layouts.map((layout, index) => (
<Fieldset key={layout.id} legend={layout.name} bg="transparent">
<Grid>
<Grid.Col span={{ sm: 12, md: 6 }}>
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={t("layout.field.name.label")} />
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Input.Wrapper label={t("layout.field.columnCount.label")}>
<Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps(`layouts.${index}.columnCount`)} />
</Input.Wrapper>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<NumberInput
{...form.getInputProps(`layouts.${index}.breakpoint`)}
label={t("layout.field.breakpoint.label")}
description={t("layout.field.breakpoint.description")}
/>
</Grid.Col>
</Grid>
{form.values.layouts.length >= 2 && (
<Group justify="end">
<Button
variant="subtle"
onClick={() => {
form.setValues((previous) =>
previous.layouts !== undefined && previous.layouts.length >= 2
? {
layouts: form.values.layouts.filter((filteredLayout) => filteredLayout.id !== layout.id),
}
: previous,
);
}}
>
{t("common.action.remove")}
</Button>
</Group>
)}
</Fieldset>
))}
</Stack>
<Grid>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Input.Wrapper label={t("board.field.columnCount.label")}>
<Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps("columnCount")} />
</Input.Wrapper>
</Grid.Col>
</Grid>
<Group justify="end">
<Button type="submit" loading={isPending} color="teal">
{t("common.action.saveChanges")}
@@ -3,14 +3,10 @@ import type { WidgetKind } from "@homarr/definitions";
export type Board = RouterOutputs["board"]["getHomeBoard"];
export type Section = Board["sections"][number];
export type Item = Board["items"][number];
export type ItemLayout = Item["layouts"][number];
export type SectionItem = Omit<Item, "layouts"> & ItemLayout & { type: "item" };
export type Item = Section["items"][number];
export type CategorySection = Extract<Section, { kind: "category" }>;
export type EmptySection = Extract<Section, { kind: "empty" }>;
export type DynamicSection = Extract<Section, { kind: "dynamic" }>;
export type DynamicSectionLayout = DynamicSection["layouts"][number];
export type DynamicSectionItem = Omit<DynamicSection, "layouts"> & DynamicSectionLayout & { type: "section" };
export type ItemOfKind<TKind extends WidgetKind> = Extract<Item, { kind: TKind }>;
@@ -61,8 +61,7 @@ export default async function AppsPage(props: AppsPageProps) {
</Stack>
)}
{/* Added margin to not hide pagination behind affix-button */}
<Group justify="end" mb={48}>
<Group justify="end">
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
</Group>
</Stack>
@@ -92,8 +92,7 @@ export default async function GroupsListPage(props: MediaListPageProps) {
</TableTbody>
</Table>
{/* Added margin to not hide pagination behind affix-button */}
<Group justify="end" mb={48}>
<Group justify="end">
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
</Group>
</Stack>
@@ -61,8 +61,7 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
</Stack>
)}
{/* Added margin to not hide pagination behind affix-button */}
<Group justify="end" mb={48}>
<Group justify="end">
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
</Group>
</Stack>
@@ -35,13 +35,7 @@ const createColumns = (
Cell({ renderedCellValue, row }) {
return (
<Group gap="xs">
<Avatar
variant="outline"
radius="lg"
size="md"
styles={{ image: { objectFit: "contain" } }}
src={row.original.iconUrl}
>
<Avatar variant="outline" radius="lg" size="md" src={row.original.iconUrl}>
{row.original.name.at(0)?.toUpperCase()}
</Avatar>
<Text>{renderedCellValue}</Text>
@@ -1,11 +1,9 @@
import { getBoardLayouts } from "@homarr/boards/context";
import type { Modify } from "@homarr/common/types";
import { createId } from "@homarr/db/client";
import type { WidgetKind } from "@homarr/definitions";
import type { Board, EmptySection, Item, ItemLayout } from "~/app/[locale]/boards/_types";
import type { Board, DynamicSection, EmptySection, Item } from "~/app/[locale]/boards/_types";
import { getFirstEmptyPosition } from "./empty-position";
import { getSectionElements } from "./section-elements";
export interface CreateItemInput {
kind: WidgetKind;
@@ -21,11 +19,24 @@ export const createItemCallback =
if (!firstSection) return previous;
const dynamicSectionsOfFirstSection = previous.sections.filter(
(section): section is DynamicSection => section.kind === "dynamic" && section.parentSectionId === firstSection.id,
);
const elements = [...firstSection.items, ...dynamicSectionsOfFirstSection];
const emptyPosition = getFirstEmptyPosition(elements, previous.columnCount);
if (!emptyPosition) {
console.error("Your board is full");
return previous;
}
const widget = {
id: createId(),
kind,
options: {},
layouts: createItemLayouts(previous, firstSection),
width: 1,
height: 1,
...emptyPosition,
integrationIds: [],
advancedOptions: {
customCssClasses: [],
@@ -39,31 +50,13 @@ export const createItemCallback =
return {
...previous,
items: previous.items.concat(widget),
sections: previous.sections.map((section) => {
// Return same section if item is not in it
if (section.id !== firstSection.id) return section;
return {
...section,
items: section.items.concat(widget),
};
}),
};
};
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,8 +1,7 @@
import { createId } from "@homarr/db/client";
import type { Board, EmptySection, ItemLayout, Section } from "~/app/[locale]/boards/_types";
import type { Board, DynamicSection, EmptySection } from "~/app/[locale]/boards/_types";
import { getFirstEmptyPosition } from "./empty-position";
import { getSectionElements } from "./section-elements";
export interface DuplicateItemInput {
itemId: string;
@@ -11,78 +10,72 @@ export interface DuplicateItemInput {
export const duplicateItemCallback =
({ itemId }: DuplicateItemInput) =>
(previous: Board): Board => {
const itemToDuplicate = previous.items.find((item) => item.id === itemId);
const itemToDuplicate = previous.sections
.flatMap((section) => section.items.map((item) => ({ ...item, sectionId: section.id })))
.find((item) => item.id === itemId);
if (!itemToDuplicate) return previous;
const clonedItem = structuredClone(itemToDuplicate);
const currentSection = previous.sections.find((section) => section.id === itemToDuplicate.sectionId);
if (!currentSection) return previous;
return {
const dynamicSectionsOfCurrentSection = previous.sections.filter(
(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,
items: previous.items.concat({
...clonedItem,
id: createId(),
layouts: clonedItem.layouts.map((layout) => ({
...layout,
...getNextPosition(previous, layout),
})),
sections: previous.sections.map((section) => {
// Return same section if item is not in it
if (section.id !== sectionId) return section;
return {
...section,
items: section.items.concat(widget),
};
}),
};
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 { SectionItem } from "~/app/[locale]/boards/_types";
import type { Item } from "~/app/[locale]/boards/_types";
export const getFirstEmptyPosition = (
elements: Pick<SectionItem, "yOffset" | "xOffset" | "width" | "height">[],
elements: Pick<Item, "yOffset" | "xOffset" | "width" | "height">[],
columnCount: number,
rowCount = 9999,
size: { width: number; height: number } = { width: 1, height: 1 },
@@ -1,36 +0,0 @@
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,
},
),
},
),
};
};
@@ -1,37 +0,0 @@
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,
},
),
},
),
};
};
@@ -1,12 +0,0 @@
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),
});
@@ -1,18 +0,0 @@
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,109 +1,61 @@
import { describe, expect, test, vi } from "vitest";
import * as boardContext from "@homarr/boards/context";
import type { Board } from "~/app/[locale]/boards/_types";
import { createItemCallback } from "../create-item";
import * as emptyPositionModule from "../empty-position";
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";
import * as emptyPosition from "../empty-position";
import { createDynamicSection, createEmptySection, createItem } from "./shared";
describe("item actions create-item", () => {
test("should add it to first section", () => {
// Arrange
const itemKind = "clock";
const emptyPosition = { xOffset: 5, yOffset: 5 };
const firstSectionId = "2";
const layoutId = "1";
const spy = vi.spyOn(emptyPosition, "getFirstEmptyPosition");
spy.mockReturnValue({ xOffset: 5, yOffset: 5 });
const input = {
sections: [createEmptySection("1", 2), createEmptySection("2", 0), createEmptySection("3", 1)],
columnCount: 4,
} 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({
kind: itemKind,
})(board);
kind: "clock",
})(input as unknown as Board);
// Assert
const item = result.items.at(0);
expect(item).toEqual(
expect.objectContaining({
kind: itemKind,
layouts: [
{
layoutId,
height: 1,
width: 1,
...emptyPosition,
sectionId: firstSectionId,
},
],
}),
);
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount);
const firstSection = result.sections.find((section) => section.id === "2");
const item = firstSection?.items.at(0);
expect(item).toEqual(expect.objectContaining({ kind: "clock", xOffset: 5, yOffset: 5 }));
expect(spy).toHaveBeenCalledWith([], input.columnCount);
});
test("should correctly pass dynamic section and items to getFirstEmptyPosition", () => {
// Arrange
const itemKind = "clock";
const emptyPosition = { xOffset: 5, yOffset: 5 };
const firstSectionId = "2";
const layoutId = "1";
const itemAndSectionPosition = { height: 2, width: 3, yOffset: 2, xOffset: 1 };
const spy = vi.spyOn(emptyPosition, "getFirstEmptyPosition");
spy.mockReturnValue({ xOffset: 5, yOffset: 5 });
const firstSection = createEmptySection("2", 0);
const expectedItem = createItem({ id: "12", xOffset: 1, yOffset: 2, width: 3, height: 2 });
firstSection.items.push(expectedItem);
const dynamicSectionInFirst = createDynamicSection({
id: "4",
parentSectionId: "2",
yOffset: 0,
xOffset: 0,
width: 2,
height: 2,
});
const layout = new LayoutMockBuilder({ id: layoutId, columnCount: 4 }).build();
const dynamicSectionInFirstSection = new DynamicSectionMockBuilder({ id: "4" })
.addLayout({ ...itemAndSectionPosition, layoutId, parentSectionId: firstSectionId })
.build();
const itemInFirstSection = new ItemMockBuilder({ id: "12" })
.addLayout({ ...itemAndSectionPosition, layoutId, sectionId: firstSectionId })
.build();
const otherDynamicSection = new DynamicSectionMockBuilder({ id: "5" }).addLayout({ layoutId }).build();
const otherItem = new ItemMockBuilder({ id: "13" }).addLayout({ layoutId }).build();
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 input = {
sections: [
createEmptySection("1", 2),
firstSection,
createEmptySection("3", 1),
dynamicSectionInFirst,
createDynamicSection({ id: "5", parentSectionId: "3", yOffset: 1 }),
],
columnCount: 4,
} satisfies Pick<Board, "sections" | "columnCount">;
const spy = vi.spyOn(emptyPositionModule, "getFirstEmptyPosition");
spy.mockReturnValue(emptyPosition);
const layoutsSpy = vi.spyOn(boardContext, "getBoardLayouts");
layoutsSpy.mockReturnValue([layoutId]);
// Act
const result = createItemCallback({
kind: itemKind,
})(board);
kind: "clock",
})(input as unknown as Board);
// Assert
expect(result.items.length).toBe(3);
const item = result.items.find((item) => item.id !== itemInFirstSection.id && item.id !== otherItem.id);
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,
);
const firstSectionResult = result.sections.find((section) => section.id === "2");
const item = firstSectionResult?.items.find((item) => item.id !== "12");
expect(item).toEqual(expect.objectContaining({ kind: "clock", xOffset: 5, yOffset: 5 }));
expect(spy).toHaveBeenCalledWith([expectedItem, dynamicSectionInFirst], input.columnCount);
});
});
@@ -1,63 +1,51 @@
import { describe, expect, test, vi } from "vitest";
import type { Board } from "~/app/[locale]/boards/_types";
import { duplicateItemCallback } from "../duplicate-item";
import * as emptyPositionModule from "../empty-position";
import { BoardMockBuilder } from "./mocks/board-mock";
import { ItemMockBuilder } from "./mocks/item-mock";
import { LayoutMockBuilder } from "./mocks/layout-mock";
import * as emptyPosition from "../empty-position";
import { createEmptySection, createItem } from "./shared";
describe("item actions duplicate-item", () => {
test("should copy it in the same section", () => {
// Arrange
const itemKind = "minecraftServerStatus";
const emptyPosition = { xOffset: 5, yOffset: 5 };
const currentSectionId = "2";
const layoutId = "1";
const currentItemSize = { height: 2, width: 3 };
const layout = new LayoutMockBuilder({ id: layoutId, columnCount: 4 }).build();
const currentItem = new ItemMockBuilder({
kind: itemKind,
const spy = vi.spyOn(emptyPosition, "getFirstEmptyPosition");
spy.mockReturnValue({ xOffset: 5, yOffset: 5 });
const currentSection = createEmptySection("2", 1);
const currentItem = createItem({
id: "1",
xOffset: 1,
yOffset: 3,
width: 3,
height: 2,
kind: "minecraftServerStatus",
integrationIds: ["1"],
options: { address: "localhost" },
advancedOptions: { customCssClasses: ["test"] },
})
.addLayout({ layoutId, sectionId: currentSectionId, ...currentItemSize })
.build();
const otherItem = new ItemMockBuilder({ id: "2" }).addLayout({ layoutId }).build();
});
const otherItem = createItem({
id: "2",
});
currentSection.items.push(currentItem, otherItem);
const input = {
columnCount: 10,
sections: [createEmptySection("1", 0), currentSection, createEmptySection("3", 2)],
} satisfies Pick<Board, "sections" | "columnCount">;
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 result = duplicateItemCallback({ itemId: currentItem.id })(input as unknown as Board);
const spy = vi.spyOn(emptyPositionModule, "getFirstEmptyPosition");
spy.mockReturnValue(emptyPosition);
// 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);
const section = result.sections.find((section) => section.id === "2");
expect(section?.items.length).toBe(3);
const duplicatedItem = section?.items.find((item) => item.id !== currentItem.id && item.id !== otherItem.id);
expect(duplicatedItem).toEqual(
expect.objectContaining({
kind: itemKind,
integrationIds: currentItem.integrationIds,
options: currentItem.options,
advancedOptions: currentItem.advancedOptions,
layouts: [
expect.objectContaining({
...emptyPosition,
...currentItemSize,
sectionId: currentSectionId,
}),
],
kind: "minecraftServerStatus",
xOffset: 5,
yOffset: 5,
width: 3,
height: 2,
integrationIds: ["1"],
options: { address: "localhost" },
advancedOptions: { customCssClasses: ["test"] },
}),
);
});
@@ -109,7 +109,7 @@ describe("get first empty position", () => {
});
const createElementsFromLayout = (layout: string[][]) => {
const elements: (Pick<Item["layouts"][number], "xOffset" | "yOffset" | "width" | "height"> & { char: string })[] = [];
const elements: (Pick<Item, "xOffset" | "yOffset" | "width" | "height"> & { char: string })[] = [];
for (let yOffset = 0; yOffset < layout.length; yOffset++) {
const row = layout[yOffset];
if (!row) continue;
@@ -1,90 +0,0 @@
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;
}
}
@@ -1,23 +0,0 @@
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;
}
}
@@ -1,36 +0,0 @@
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",
options: {
borderColor: "",
},
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;
}
}
@@ -1,21 +0,0 @@
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;
}
}
@@ -1,38 +0,0 @@
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;
}
}
@@ -1,21 +0,0 @@
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;
}
}
@@ -1,65 +0,0 @@
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();
});
});
@@ -1,67 +0,0 @@
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();
});
});
@@ -1,37 +0,0 @@
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);
});
});
@@ -0,0 +1,32 @@
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,16 +3,30 @@ import { useCallback } from "react";
import { useUpdateBoard } from "@homarr/boards/updater";
import type { BoardItemAdvancedOptions } from "@homarr/validation";
import type { Item } from "~/app/[locale]/boards/_types";
import type { CreateItemInput } from "./actions/create-item";
import { createItemCallback } from "./actions/create-item";
import type { DuplicateItemInput } from "./actions/duplicate-item";
import { duplicateItemCallback } from "./actions/duplicate-item";
import type { MoveAndResizeItemInput } from "./actions/move-and-resize-item";
import { moveAndResizeItemCallback } from "./actions/move-and-resize-item";
import type { MoveItemToSectionInput } from "./actions/move-item-to-section";
import { moveItemToSectionCallback } from "./actions/move-item-to-section";
import type { RemoveItemInput } from "./actions/remove-item";
import { removeItemCallback } from "./actions/remove-item";
interface MoveAndResizeItem {
itemId: string;
xOffset: number;
yOffset: number;
width: number;
height: number;
}
interface MoveItemToSection {
itemId: string;
sectionId: string;
xOffset: number;
yOffset: number;
width: number;
height: number;
}
interface RemoveItem {
itemId: string;
}
interface UpdateItemOptions {
itemId: string;
@@ -48,55 +62,164 @@ export const useItemActions = () => {
const updateItemOptions = useCallback(
({ itemId, newOptions }: UpdateItemOptions) => {
updateBoard((previous) => ({
...previous,
items: previous.items.map((item) => (item.id !== itemId ? item : { ...item, options: newOptions })),
}));
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 changing
if (item.id !== itemId) return item;
return {
...item,
options: newOptions,
};
}),
};
}),
};
});
},
[updateBoard],
);
const updateItemAdvancedOptions = useCallback(
({ itemId, newAdvancedOptions }: UpdateItemAdvancedOptions) => {
updateBoard((previous) => ({
...previous,
items: previous.items.map((item) =>
item.id !== itemId ? item : { ...item, advancedOptions: newAdvancedOptions },
),
}));
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 changing
if (item.id !== itemId) return item;
return {
...item,
advancedOptions: newAdvancedOptions,
};
}),
};
}),
};
});
},
[updateBoard],
);
const updateItemIntegrations = useCallback(
({ itemId, newIntegrations }: UpdateItemIntegrations) => {
updateBoard((previous) => ({
...previous,
items: previous.items.map((item) =>
item.id !== itemId || !("integrationIds" in item) ? item : { ...item, integrationIds: newIntegrations },
),
}));
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(
(input: MoveAndResizeItemInput) => {
updateBoard(moveAndResizeItemCallback(input));
({ itemId, ...positionProps }: MoveAndResizeItem) => {
updateBoard((previous) => ({
...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,
...positionProps,
} satisfies Item;
}),
};
}),
}));
},
[updateBoard],
);
const moveItemToSection = useCallback(
(input: MoveItemToSectionInput) => {
updateBoard(moveItemToSectionCallback(input));
({ itemId, sectionId, ...positionProps }: MoveItemToSection) => {
updateBoard((previous) => {
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],
);
const removeItem = useCallback(
({ itemId }: RemoveItemInput) => {
updateBoard(removeItemCallback({ itemId }));
({ itemId }: RemoveItem) => {
updateBoard((previous) => {
return {
...previous,
// Filter removed item out of items array
sections: previous.sections.map((section) => ({
...section,
items: section.items.filter((item) => item.id !== itemId),
})),
};
});
},
[updateBoard],
);
@@ -11,13 +11,13 @@ import { useSettings } from "@homarr/settings";
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { WidgetError } from "@homarr/widgets/errors";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import type { Item } from "~/app/[locale]/boards/_types";
import classes from "../sections/item.module.css";
import { useItemActions } from "./item-actions";
import { BoardItemMenu } from "./item-menu";
interface BoardItemContentProps {
item: SectionItem;
item: Item;
}
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
@@ -50,7 +50,7 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
};
interface InnerContentProps {
item: SectionItem;
item: Item;
width: number;
height: number;
}
@@ -10,7 +10,7 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { widgetImports } from "@homarr/widgets";
import { WidgetEditModal } from "@homarr/widgets/modals";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import type { Item } from "~/app/[locale]/boards/_types";
import { useSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions";
import { ItemMoveModal } from "./item-move-modal";
@@ -21,7 +21,7 @@ export const BoardItemMenu = ({
resetErrorBoundary,
}: {
offset: number;
item: SectionItem;
item: Item;
resetErrorBoundary?: () => void;
}) => {
const refResetErrorBoundaryOnNextRender = useRef(false);
@@ -7,11 +7,11 @@ import type { GridStack } from "@homarr/gridstack";
import { createModal } from "@homarr/modals";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { Item, SectionItem } from "~/app/[locale]/boards/_types";
import type { Item } from "~/app/[locale]/boards/_types";
interface InnerProps {
gridStack: GridStack;
item: Pick<SectionItem, "id" | "width" | "height" | "xOffset" | "yOffset">;
item: Pick<Item, "id" | "xOffset" | "yOffset" | "width" | "height">;
columnCount: number;
}
@@ -47,7 +47,7 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
);
const handleSubmit = useCallback(
(values: Pick<Item["layouts"][number], "height" | "width" | "xOffset" | "yOffset">) => {
(values: Omit<InnerProps["item"], "id">) => {
const gridItem = innerProps.gridStack
.getGridItems()
.find((item) => item.getAttribute("data-id") === innerProps.item.id);
@@ -1,6 +1,4 @@
import { getBoardLayouts } from "@homarr/boards/context";
import type { Board, CategorySection, EmptySection, Section } from "~/app/[locale]/boards/_types";
import type { Board, CategorySection, DynamicSection, EmptySection, Section } from "~/app/[locale]/boards/_types";
export interface RemoveCategoryInput {
id: string;
@@ -30,121 +28,84 @@ export const removeCategoryCallback =
return previous;
}
const aboveYOffsets = getBoardLayouts(previous).map((layoutId) => {
return {
layoutId,
yOffset: Math.max(
calculateYHeightWithOffsetForItemLayouts(previous, { sectionId: aboveSection.id, layoutId }),
calculateYHeightWithOffsetForDynamicSectionLayouts(previous.sections, {
sectionId: aboveSection.id,
layoutId,
}),
),
};
});
// Calculate the yOffset for the items in the currentCategory and removedWrapper to add them with the same offset to the aboveWrapper
const aboveYOffset = Math.max(
calculateYHeightWithOffsetForItems(aboveSection),
calculateYHeightWithOffsetForDynamicSections(previous.sections, aboveSection.id),
);
const categoryYOffset = Math.max(
calculateYHeightWithOffsetForItems(currentCategory),
calculateYHeightWithOffsetForDynamicSections(previous.sections, currentCategory.id),
);
const categoryYOffsets = getBoardLayouts(previous).map((layoutId) => {
return {
layoutId,
yOffset: Math.max(
calculateYHeightWithOffsetForItemLayouts(previous, { sectionId: currentCategory.id, layoutId }),
calculateYHeightWithOffsetForDynamicSectionLayouts(previous.sections, {
sectionId: currentCategory.id,
layoutId,
}),
),
};
});
const previousCategoryItems = currentCategory.items.map((item) => ({
...item,
yOffset: item.yOffset + aboveYOffset,
}));
const previousBelowWrapperItems = removedSection.items.map((item) => ({
...item,
yOffset: item.yOffset + aboveYOffset + categoryYOffset,
}));
return {
...previous,
sections: previous.sections
.filter((section) => section.id !== currentCategory.id && section.id !== removedSection.id)
.map((section) =>
section.kind === "dynamic"
? {
...section,
layouts: section.layouts.map((layout) => {
const aboveYOffset = aboveYOffsets.find(({ layoutId }) => layout.layoutId === layoutId)?.yOffset ?? 0;
const categoryYOffset =
categoryYOffsets.find(({ layoutId }) => layout.layoutId === layoutId)?.yOffset ?? 0;
sections: [
...previous.sections.filter((section) => section.yOffset < aboveSection.yOffset && section.kind !== "dynamic"),
{
...aboveSection,
items: [...aboveSection.items, ...previousCategoryItems, ...previousBelowWrapperItems],
},
...previous.sections
.filter(
(section): section is CategorySection | EmptySection =>
section.yOffset > removedSection.yOffset && section.kind !== "dynamic",
)
.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,
};
}
if (layout.parentSectionId === currentCategory.id) {
return {
...layout,
yOffset: layout.yOffset + aboveYOffset,
parentSectionId: aboveSection.id,
};
}
// Move dynamic sections from category to above section with required yOffset
if (dynamicSection.parentSectionId === currentCategory.id) {
return {
...dynamicSection,
yOffset: dynamicSection.yOffset + aboveYOffset,
parentSectionId: aboveSection.id,
};
}
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;
}),
})),
return dynamicSection;
}),
],
};
};
const calculateYHeightWithOffsetForDynamicSectionLayouts = (
sections: Section[],
{ 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;
const calculateYHeightWithOffsetForDynamicSections = (sections: Section[], sectionId: string) => {
return sections.reduce((acc, section) => {
if (section.kind !== "dynamic" || section.parentSectionId !== sectionId) {
return acc;
}, 0);
}
const yHeightWithOffset = section.yOffset + section.height;
if (yHeightWithOffset > acc) return yHeightWithOffset;
return acc;
}, 0);
};
const calculateYHeightWithOffsetForItemLayouts = (
board: Board,
{ sectionId, layoutId }: { sectionId: string; layoutId: string },
) =>
board.items
.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);
const calculateYHeightWithOffsetForItems = (section: Section) =>
section.items.reduce((acc, item) => {
const yHeightWithOffset = item.yOffset + item.height;
if (yHeightWithOffset > acc) return yHeightWithOffset;
return acc;
}, 0);
@@ -73,7 +73,5 @@ const createSections = (categoryCount: number) => {
};
const sortSections = (sections: Section[]) => {
return sections
.filter((section) => section.kind !== "dynamic")
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
return sections.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
};
@@ -1,14 +1,7 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { describe, expect, test, vi } from "vitest";
import { describe, expect, test } from "vitest";
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 type { DynamicSection, Item, Section } from "~/app/[locale]/boards/_types";
import { removeCategoryCallback } from "../remove-category";
describe("Remove Category", () => {
@@ -20,126 +13,114 @@ describe("Remove Category", () => {
])(
"should remove category",
(removeId, initialYOffsets, expectedYOffsets, expectedRemovals, expectedLocationOfItems) => {
// Arrange
const layoutId = "1";
const sections = createSections(initialYOffsets);
const input = removeId.toString();
const board = new BoardMockBuilder()
.addLayout({ id: layoutId })
.addSections(createSections(initialYOffsets))
.addItems(createSectionItems(initialYOffsets, layoutId))
.build();
const result = removeCategoryCallback({ id: input })({ sections } as never);
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);
expectedRemovals.forEach((expectedRemoval) => {
expect(result.sections.find((section) => section.id === expectedRemoval.toString())).toBeUndefined();
});
const aboveSectionItems = result.items.filter(
(item) => item.layouts[0]?.sectionId === expectedLocationOfItems.toString(),
const aboveSection = result.sections.find((section) => section.id === expectedLocationOfItems.toString());
expect(aboveSection?.items.map((item) => parseInt(item.id, 10))).toEqual(
expect.arrayContaining(expectedRemovals),
);
expect(aboveSectionItems.map((item) => parseInt(item.id, 10))).toEqual(expect.arrayContaining(expectedRemovals));
},
);
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 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 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 input = "3";
vi.spyOn(boardContext, "getBoardLayouts").mockReturnValue([layoutId]);
const result = removeCategoryCallback({ id: input })({ sections } as never);
// 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]);
const aboveSectionItems = result.items.filter((item) => item.layouts[0]?.sectionId === sectionIds.above);
expect(aboveSectionItems.length).toBe(6);
expect(
aboveSectionItems
.map((item) => ({
...item,
...item.layouts[0]!,
}))
.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 aboveSectionResult = result.sections.find((section) => section.id === "2")!;
expect(aboveSectionResult.items).toEqual(
expect.arrayContaining([
createItem({ id: "above-1" }),
createItem({ id: "above-2", yOffset: 3, xOffset: 2, height: 2 }),
createItem({ id: "category-1", yOffset: 5 }),
createItem({ id: "category-2", yOffset: 7, xOffset: 4, width: 4 }),
createItem({ id: "below-1", yOffset: 15, xOffset: 5 }),
createItem({ id: "below-2", yOffset: 16, xOffset: 1, height: 2 }),
]),
);
const dynamicSection = result.sections.find((section): section is DynamicSection => section.id === "7")!;
expect(dynamicSection.layouts.at(0)?.yOffset).toBe(12);
expect(dynamicSection.layouts[0]?.parentSectionId).toBe("2");
expect(dynamicSection.yOffset).toBe(12);
expect(dynamicSection.parentSectionId).toBe("2");
});
});
const createSections = (initialYOffsets: number[]) => {
return initialYOffsets.map((yOffset, index) =>
index % 2 === 0
? new EmptySectionMockBuilder({
id: yOffset.toString(),
yOffset,
}).build()
: new CategorySectionMockBuilder({
id: yOffset.toString(),
yOffset,
}).build(),
) satisfies Section[];
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 createSectionItems = (initialYOffsets: number[], layoutId: string) => {
return initialYOffsets.map((yOffset) =>
new ItemMockBuilder({ id: yOffset.toString() }).addLayout({ layoutId, sectionId: yOffset.toString() }).build(),
);
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[]) => {
return initialYOffsets.map((yOffset, index) => ({
id: yOffset.toString(),
kind: index % 2 === 0 ? "empty" : "category",
name: "Category",
collapsed: false,
yOffset,
xOffset: 0,
items: [createItem({ id: yOffset.toString() })],
})) satisfies Section[];
};
@@ -3,7 +3,7 @@ import { useCallback } from "react";
import { useUpdateBoard } from "@homarr/boards/updater";
import { createId } from "@homarr/db/client";
import type { CategorySection, EmptySection, Section } from "~/app/[locale]/boards/_types";
import type { CategorySection, EmptySection } from "~/app/[locale]/boards/_types";
import type { MoveCategoryInput } from "./actions/move-category";
import { moveCategoryCallback } from "./actions/move-category";
import type { RemoveCategoryInput } from "./actions/remove-category";
@@ -41,12 +41,14 @@ export const useCategoryActions = () => {
yOffset,
xOffset: 0,
collapsed: false,
items: [],
},
{
id: createId(),
kind: "empty",
yOffset: yOffset + 1,
xOffset: 0,
items: [],
},
// Place sections after the new category
...previous.sections
@@ -58,7 +60,7 @@ export const useCategoryActions = () => {
...section,
yOffset: section.yOffset + 2,
})),
] satisfies Section[],
],
}));
},
[updateBoard],
@@ -89,14 +91,16 @@ export const useCategoryActions = () => {
yOffset: lastYOffset + 1,
xOffset: 0,
collapsed: false,
items: [],
},
{
id: createId(),
kind: "empty",
yOffset: lastYOffset + 2,
xOffset: 0,
items: [],
},
] satisfies Section[],
],
};
});
},
@@ -1,7 +1,6 @@
import { useCallback } from "react";
import { fetchApi } from "@homarr/api/client";
import { getCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { createId } from "@homarr/db/client";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
@@ -17,7 +16,6 @@ export const useCategoryMenuActions = (category: CategorySection) => {
const { openConfirmModal } = useConfirmModal();
const { addCategory, moveCategory, removeCategory, renameCategory } = useCategoryActions();
const t = useI18n();
const board = useRequiredBoard();
const createCategoryAtYOffset = useCallback(
(position: number) => {
@@ -104,14 +102,7 @@ export const useCategoryMenuActions = (category: CategorySection) => {
const settings = useSettings();
const openAllInNewTabs = useCallback(async () => {
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) => {
const appIds = filterByItemKind(category.items, settings, "app").map((item) => {
return item.options.appId;
});
@@ -130,7 +121,7 @@ export const useCategoryMenuActions = (category: CategorySection) => {
});
break;
}
}, [category, board, t, openConfirmModal, settings]);
}, [category, t, openConfirmModal, settings]);
return {
addCategoryAbove,
@@ -1,16 +1,16 @@
import { useMemo } from "react";
import type { GridItemHTMLElement } from "@homarr/gridstack";
import { useRequiredBoard } from "@homarr/boards/context";
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
import type { DynamicSection, Item, Section } from "~/app/[locale]/boards/_types";
import { BoardItemContent } from "../items/item-content";
import { BoardDynamicSection } from "./dynamic-section";
import { GridStackItem } from "./gridstack/gridstack-item";
import { useSectionContext } from "./section-context";
import { useSectionItems } from "./use-section-items";
export const SectionContent = () => {
const { innerSections, items, refs } = useSectionContext();
const { section, innerSections, refs } = useSectionContext();
const board = useRequiredBoard();
/**
* IMPORTANT: THE ORDER OF THE BELOW ITEMS HAS TO MATCH THE ORDER OF
@@ -18,52 +18,41 @@ export const SectionContent = () => {
* @see https://github.com/homarr-labs/homarr/pull/1770
*/
const sortedItems = useMemo(() => {
return [...items, ...innerSections].sort((itemA, itemB) => {
return [
...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) {
return itemA.xOffset - itemB.xOffset;
}
return itemA.yOffset - itemB.yOffset;
});
}, [items, innerSections]);
}, [section.items, innerSections]);
return (
<>
{sortedItems.map((item) => (
<Item key={item.id} item={item} innerRef={refs.items.current[item.id]} />
<GridStackItem
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
* the sum of the offset and size of all items and dynamic sections inside.
@@ -73,13 +62,16 @@ const Item = ({ item, innerRef }: ItemProps) => {
* @param parentSectionId the id of the section we want to calculate the min size for
* @returns the min size
*/
const useMinSize = (item: DynamicSectionItem | SectionItem, direction: "x" | "y") => {
const { items, innerSections } = useSectionItems(item.id);
if (item.type === "item") return undefined;
const getMinSize = (direction: "x" | "y", items: Item[], sections: Section[], parentSectionId: string) => {
const size = direction === "x" ? "width" : "height";
return Math.max(
...items.map((item) => item[`${direction}Offset`] + item[size]),
...innerSections.map((item) => item[`${direction}Offset`] + item[size]),
...sections
.filter(
(section): section is DynamicSection =>
section.kind === "dynamic" && section.parentSectionId === parentSectionId,
)
.map((item) => item[`${direction}Offset`] + item[size]),
1, // Minimum size
);
};
@@ -1,21 +1,18 @@
import { Box, Card } from "@mantine/core";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useRequiredBoard } from "@homarr/boards/context";
import type { DynamicSectionItem } from "~/app/[locale]/boards/_types";
import type { DynamicSection } from "~/app/[locale]/boards/_types";
import { BoardDynamicSectionMenu } from "./dynamic/dynamic-menu";
import { GridStack } from "./gridstack/gridstack";
import classes from "./item.module.css";
interface Props {
section: DynamicSectionItem;
section: DynamicSection;
}
export const BoardDynamicSection = ({ section }: Props) => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const options = section.options;
return (
<Box className="grid-stack-item-content">
<Card
@@ -27,14 +24,12 @@ export const BoardDynamicSection = ({ section }: Props) => {
root: {
"--opacity": board.opacity / 100,
overflow: "hidden",
"--border-color": options.borderColor !== "" ? options.borderColor : undefined,
},
}}
radius={board.itemRadius}
p={0}
>
{/* Use unique key by layout to reinitialize gridstack */}
<GridStack key={`${currentLayoutId}-${section.id}`} section={section} className="min-row" />
<GridStack section={section} className="min-row" />
</Card>
<BoardDynamicSectionMenu section={section} />
</Box>
@@ -1,54 +0,0 @@
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",
options: {
borderColor: "",
},
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,
};
});
};
@@ -1,62 +0,0 @@
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,45 +1,89 @@
import { useCallback } from "react";
import type { z } from "zod";
import { useUpdateBoard } from "@homarr/boards/updater";
import type { dynamicSectionOptionsSchema } from "@homarr/validation";
import { createId } from "@homarr/db/client";
import { addDynamicSectionCallback } from "./actions/add-dynamic-section";
import type { RemoveDynamicSectionInput } from "./actions/remove-dynamic-section";
import { removeDynamicSectionCallback } from "./actions/remove-dynamic-section";
import type { DynamicSection, EmptySection } from "~/app/[locale]/boards/_types";
interface UpdateDynamicOptions {
itemId: string;
newOptions: z.infer<typeof dynamicSectionOptionsSchema>;
interface RemoveDynamicSection {
id: string;
}
export const useDynamicSectionActions = () => {
const { updateBoard } = useUpdateBoard();
const addDynamicSection = useCallback(() => {
updateBoard(addDynamicSectionCallback());
updateBoard((previous) => {
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]);
const updateDynamicSection = useCallback(
({ itemId, newOptions }: UpdateDynamicOptions) => {
updateBoard((previous) => ({
...previous,
sections: previous.sections.map((item) => (item.id !== itemId ? item : { ...item, options: newOptions })),
}));
},
[updateBoard],
);
const removeDynamicSection = useCallback(
(input: RemoveDynamicSectionInput) => {
updateBoard(removeDynamicSectionCallback(input));
({ id }: RemoveDynamicSection) => {
updateBoard((previous) => {
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],
);
return {
addDynamicSection,
updateDynamicSection,
removeDynamicSection,
};
};
@@ -1,63 +0,0 @@
"use client";
import { Button, CloseButton, ColorInput, Group, Stack, useMantineTheme } from "@mantine/core";
import type { z } from "zod";
import { useZodForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { dynamicSectionOptionsSchema } from "@homarr/validation";
interface ModalProps {
value: z.infer<typeof dynamicSectionOptionsSchema>;
onSuccessfulEdit: (value: z.infer<typeof dynamicSectionOptionsSchema>) => void;
}
export const DynamicSectionEditModal = createModal<ModalProps>(({ actions, innerProps }) => {
const t = useI18n();
const theme = useMantineTheme();
const form = useZodForm(dynamicSectionOptionsSchema, {
mode: "controlled",
initialValues: { ...innerProps.value },
});
return (
<form
onSubmit={form.onSubmit((values) => {
innerProps.onSuccessfulEdit(values);
actions.closeModal();
})}
>
<Stack>
<ColorInput
label={t("section.dynamic.option.borderColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
rightSection={
<CloseButton
onClick={() => form.setFieldValue("borderColor", "")}
style={{ display: form.getInputProps("borderColor").value ? undefined : "none" }}
/>
}
{...form.getInputProps("borderColor")}
/>
<Group justify="end">
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button type="submit" color="teal">
{t("common.action.saveChanges")}
</Button>
</Group>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle(t) {
return t("item.edit.title");
},
size: "lg",
});
@@ -1,37 +1,22 @@
import { ActionIcon, Menu } from "@mantine/core";
import { IconDotsVertical, IconPencil, IconTrash } from "@tabler/icons-react";
import { IconDotsVertical, IconTrash } from "@tabler/icons-react";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useConfirmModal } from "@homarr/modals";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { DynamicSectionItem } from "~/app/[locale]/boards/_types";
import type { DynamicSection } from "~/app/[locale]/boards/_types";
import { useDynamicSectionActions } from "./dynamic-actions";
import { DynamicSectionEditModal } from "./dynamic-edit-modal";
export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSectionItem }) => {
export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSection }) => {
const t = useI18n();
const tDynamic = useScopedI18n("section.dynamic");
const tItem = useScopedI18n("item");
const { openModal } = useModalAction(DynamicSectionEditModal);
const { updateDynamicSection, removeDynamicSection } = useDynamicSectionActions();
const { removeDynamicSection } = useDynamicSectionActions();
const { openConfirmModal } = useConfirmModal();
const [isEditMode] = useEditMode();
if (!isEditMode) return null;
const openEditModal = () => {
openModal({
value: section.options,
onSuccessfulEdit: (options) => {
updateDynamicSection({
itemId: section.id,
newOptions: options,
});
},
});
};
const openRemoveModal = () => {
openConfirmModal({
title: tDynamic("remove.title"),
@@ -50,11 +35,6 @@ export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSectionIt
</ActionIcon>
</Menu.Target>
<Menu.Dropdown miw={128}>
<Menu.Label>{tItem("menu.label.settings")}</Menu.Label>
<Menu.Item leftSection={<IconPencil size={16} />} onClick={openEditModal}>
{tItem("action.edit")}
</Menu.Item>
<Menu.Divider />
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
{tDynamic("action.remove")}
@@ -11,15 +11,14 @@ interface Props {
}
export const BoardEmptySection = ({ section }: Props) => {
const { items, innerSections } = useSectionItems(section.id);
const totalLength = items.length + innerSections.length;
const { itemIds } = useSectionItems(section);
const [isEditMode] = useEditMode();
return (
<GridStack
section={section}
style={{ transitionDuration: "0s" }}
className={combineClasses("min-row", totalLength > 0 || isEditMode ? undefined : "grid-stack-empty-wrapper")}
className={combineClasses("min-row", itemIds.length > 0 || isEditMode ? undefined : "grid-stack-empty-wrapper")}
/>
);
};
@@ -4,24 +4,23 @@ import type { BoxProps } from "@mantine/core";
import { Box } from "@mantine/core";
import combineClasses from "clsx";
import type { DynamicSectionItem, Section } from "~/app/[locale]/boards/_types";
import type { Section } from "~/app/[locale]/boards/_types";
import { SectionContent } from "../content";
import { SectionProvider } from "../section-context";
import { useSectionItems } from "../use-section-items";
import { useGridstack } from "./use-gridstack";
interface Props extends BoxProps {
section: Exclude<Section, { kind: "dynamic" }> | DynamicSectionItem;
section: Section;
}
export const GridStack = ({ section, ...props }: Props) => {
const { items, innerSections } = useSectionItems(section.id);
const itemIds = [...items, ...innerSections].map((item) => item.id);
const { itemIds, innerSections } = useSectionItems(section);
const { refs } = useGridstack(section, itemIds);
return (
<SectionProvider value={{ section, items, innerSections, refs }}>
<SectionProvider value={{ section, innerSections, refs }}>
<Box
{...props}
data-kind={section.kind}
@@ -2,7 +2,7 @@ import type { RefObject } from "react";
import { createRef, useCallback, useEffect, useRef } from "react";
import { useElementSize } from "@mantine/hooks";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import type { GridHTMLElement, GridItemHTMLElement, GridStack, GridStackNode } from "@homarr/gridstack";
@@ -68,13 +68,10 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
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 =
section.kind === "dynamic" && "width" in section && typeof section.width === "number"
? section.width
: currentLayout.columnCount;
: board.columnCount;
const itemRefKeys = Object.keys(itemRefs.current);
// define items in itemRefs for easy access and reference to items
@@ -1,6 +1,5 @@
import { useCallback } from "react";
import { getCurrentLayout } from "@homarr/boards/context";
import { useUpdateBoard } from "@homarr/boards/updater";
interface MoveAndResizeInnerSection {
@@ -29,19 +28,9 @@ export const useSectionActions = () => {
sections: previous.sections.map((section) => {
// Return same section if section is not the one we're moving
if (section.id !== innerSectionId) return section;
if (section.kind !== "dynamic") return section;
const currentLayout = getCurrentLayout(previous);
return {
...section,
layouts: section.layouts.map((layout) => {
if (layout.layoutId !== currentLayout) return layout;
return {
...layout,
...positionProps,
};
}),
...positionProps,
};
}),
}));
@@ -57,20 +46,10 @@ export const useSectionActions = () => {
sections: previous.sections.map((section) => {
// Return section without changes when not the section we're moving
if (section.id !== innerSectionId) return section;
if (section.kind !== "dynamic") return section;
const currentLayout = getCurrentLayout(previous);
return {
...section,
layouts: section.layouts.map((layout) => {
if (layout.layoutId !== currentLayout) return layout;
return {
...layout,
...positionProps,
parentSectionId: sectionId,
};
}),
...positionProps,
parentSectionId: sectionId,
};
}),
};
@@ -1,12 +1,11 @@
import { createContext, useContext } from "react";
import type { DynamicSectionItem, Section, SectionItem } from "~/app/[locale]/boards/_types";
import type { Section } from "~/app/[locale]/boards/_types";
import type { UseGridstackRefs } from "./gridstack/use-gridstack";
interface SectionContextProps {
section: Exclude<Section, { kind: "dynamic" }> | DynamicSectionItem;
innerSections: DynamicSectionItem[];
items: SectionItem[];
section: Section;
innerSections: Exclude<Section, { kind: "category" } | { kind: "empty" }>[];
refs: UseGridstackRefs;
}
@@ -1,53 +1,16 @@
import { useMemo } from "react";
import { useRequiredBoard } from "@homarr/boards/context";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import type { Section } from "~/app/[locale]/boards/_types";
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
export const useSectionItems = (sectionId: string): { innerSections: DynamicSectionItem[]; items: SectionItem[] } => {
export const useSectionItems = (section: Section) => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
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],
const innerSections = board.sections.filter(
(innerSection): innerSection is Exclude<Section, { kind: "category" } | { kind: "empty" }> =>
innerSection.kind === "dynamic" && innerSection.parentSectionId === section.id,
);
return {
innerSections,
items,
itemIds: section.items.map((item) => item.id).concat(innerSections.map((section) => section.id)),
};
};
+5 -5
View File
@@ -2,7 +2,7 @@
"name": "@homarr/tasks",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"license": "MIT",
"type": "module",
"exports": {
".": "./src/index.ts"
@@ -38,17 +38,17 @@
"dayjs": "^1.11.13",
"dotenv": "^16.4.7",
"superjson": "2.2.2",
"undici": "7.4.0"
"undici": "7.3.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "^22.13.9",
"@types/node": "^22.13.5",
"dotenv-cli": "^8.0.0",
"eslint": "^9.21.0",
"prettier": "^3.5.3",
"prettier": "^3.5.2",
"tsx": "4.19.3",
"typescript": "^5.8.2"
"typescript": "^5.7.3"
}
}
+4 -4
View File
@@ -2,7 +2,7 @@
"name": "@homarr/websocket",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"license": "MIT",
"type": "module",
"main": "./src/main.ts",
"types": "./src/main.ts",
@@ -33,9 +33,9 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/ws": "^8.18.0",
"@types/ws": "^8.5.14",
"eslint": "^9.21.0",
"prettier": "^3.5.3",
"typescript": "^5.8.2"
"prettier": "^3.5.2",
"typescript": "^5.7.3"
}
}
+1 -2
View File
@@ -1,2 +1 @@
:: Please do not run this command in production. It is only for local testing.
docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 homarr:latest
docker run -p 7575:7575 homarr:latest
+10 -10
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.10.0",
"version": "1.7.0",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -38,22 +38,22 @@
"@semantic-release/github": "^11.0.1",
"@semantic-release/npm": "^12.0.1",
"@semantic-release/release-notes-generator": "^14.0.3",
"@turbo/gen": "^2.4.4",
"@turbo/gen": "^2.4.2",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^3.0.8",
"@vitest/ui": "^3.0.8",
"@vitest/coverage-v8": "^3.0.6",
"@vitest/ui": "^3.0.6",
"conventional-changelog-conventionalcommits": "^8.0.0",
"cross-env": "^7.0.3",
"jsdom": "^26.0.0",
"prettier": "^3.5.3",
"prettier": "^3.5.2",
"semantic-release": "^24.2.3",
"testcontainers": "^10.18.0",
"turbo": "^2.4.4",
"typescript": "^5.8.2",
"turbo": "^2.4.2",
"typescript": "^5.7.3",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.8"
"vitest": "^3.0.6"
},
"packageManager": "pnpm@10.5.2",
"packageManager": "pnpm@10.4.1",
"engines": {
"node": ">=22.14.0"
},
@@ -71,7 +71,7 @@
],
"allowNonAppliedPatches": true,
"overrides": {
"proxmox-api>undici": "7.4.0"
"proxmox-api>undici": "7.3.0"
},
"patchedDependencies": {
"pretty-print-error": "patches/pretty-print-error.patch"
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "@homarr/analytics",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"license": "MIT",
"type": "module",
"exports": {
".": "./index.ts"
@@ -33,6 +33,6 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.21.0",
"typescript": "^5.8.2"
"typescript": "^5.7.3"
}
}
@@ -36,7 +36,7 @@ const sendWidgetDataAsync = async (umamiInstance: Umami, analyticsSettings: type
if (!analyticsSettings.enableWidgetData) {
return;
}
const widgetCount = await db.$count(items);
const widgetCount = (await db.select({ count: count(items.id) }).from(items))[0]?.count ?? 0;
const response = await umamiInstance.track("server-widget-data", {
countWidgets: widgetCount,
@@ -52,7 +52,7 @@ const sendUserDataAsync = async (umamiInstance: Umami, analyticsSettings: typeof
if (!analyticsSettings.enableUserData) {
return;
}
const userCount = await db.$count(users);
const userCount = (await db.select({ count: count(users.id) }).from(users))[0]?.count ?? 0;
const response = await umamiInstance.track("server-user-data", {
countUsers: userCount,
+3 -3
View File
@@ -2,7 +2,7 @@
"name": "@homarr/api",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"license": "MIT",
"type": "module",
"exports": {
".": "./src/index.ts",
@@ -57,7 +57,7 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.21.0",
"prettier": "^3.5.3",
"typescript": "^5.8.2"
"prettier": "^3.5.2",
"typescript": "^5.7.3"
}
}
+18 -2
View File
@@ -37,7 +37,15 @@ export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
userPermissions: true,
items: {
with: {
item: true,
item: {
with: {
section: {
columns: {
boardId: true,
},
},
},
},
},
},
},
@@ -99,7 +107,15 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
secrets: true,
items: {
with: {
item: true,
item: {
with: {
section: {
columns: {
boardId: true,
},
},
},
},
},
},
userPermissions: true,
@@ -21,16 +21,21 @@ const getAllAppIdsOnPublicBoardsAsync = async () => {
const itemsWithApps = await db.query.items.findMany({
where: or(eq(items.kind, "app"), eq(items.kind, "bookmarks")),
with: {
board: {
columns: {
isPublic: true,
section: {
columns: {}, // Nothing
with: {
board: {
columns: {
isPublic: true,
},
},
},
},
},
});
return itemsWithApps
.filter((item) => item.board.isPublic)
.filter((item) => item.section.board.isPublic)
.flatMap((item) => {
if (item.kind === "app") {
const parsedOptions = SuperJSON.parse<WidgetComponentProps<"app">["options"]>(item.options);
File diff suppressed because it is too large. Load diff
@@ -1,186 +0,0 @@
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;
};
@@ -1,378 +0,0 @@
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(" "));
};
+8 -3
View File
@@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod";
import type { Database } from "@homarr/db";
import { and, createId, eq, handleTransactionsAsync, like, not } from "@homarr/db";
import { and, createId, eq, handleTransactionsAsync, like, not, sql } from "@homarr/db";
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
import { groupMembers, groupPermissions, groups } from "@homarr/db/schema";
import { everyoneGroup } from "@homarr/definitions";
@@ -42,7 +42,12 @@ export const groupRouter = createTRPCRouter({
.input(validation.common.paginated)
.query(async ({ input, ctx }) => {
const whereQuery = input.search ? like(groups.name, `%${input.search.trim()}%`) : undefined;
const groupCount = await ctx.db.$count(groups, whereQuery);
const groupCount = await ctx.db
.select({
count: sql<number>`count(*)`,
})
.from(groups)
.where(whereQuery);
const dbGroups = await ctx.db.query.groups.findMany({
with: {
@@ -69,7 +74,7 @@ export const groupRouter = createTRPCRouter({
...group,
members: group.members.map((member) => member.user),
})),
totalCount: groupCount,
totalCount: groupCount[0]?.count ?? 0,
};
}),
getById: permissionRequiredProcedure
+2 -1
View File
@@ -2,6 +2,7 @@ import type { AnySQLiteTable } from "drizzle-orm/sqlite-core";
import { isProviderEnabled } from "@homarr/auth/server";
import type { Database } from "@homarr/db";
import { count } from "@homarr/db";
import { apps, boards, groups, integrations, invites, users } from "@homarr/db/schema";
import { createTRPCRouter, publicProcedure } from "../trpc";
@@ -27,5 +28,5 @@ const getCountForTableAsync = async (db: Database, table: AnySQLiteTable, canVie
return 0;
}
return await db.$count(table);
return (await db.select({ count: count() }).from(table))[0]?.count ?? 0;
};
+2 -2
View File
@@ -1,4 +1,4 @@
import { and, like } from "@homarr/db";
import { and, count, like } from "@homarr/db";
import { icons } from "@homarr/db/schema";
import { validation } from "@homarr/validation";
@@ -24,7 +24,7 @@ export const iconsRouter = createTRPCRouter({
},
},
}),
countIcons: await ctx.db.$count(icons),
countIcons: (await ctx.db.select({ count: count() }).from(icons))[0]?.count ?? 0,
};
}),
});
@@ -23,7 +23,7 @@ import {
integrationKinds,
integrationSecretKindObject,
} from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { integrationCreator } from "@homarr/integrations";
import { validation } from "@homarr/validation";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
@@ -465,7 +465,7 @@ export const integrationRouter = createTRPCRouter({
.unstable_concat(createOneIntegrationMiddleware("query", ...getIntegrationKindsByCategory("search")))
.input(z.object({ integrationId: z.string(), query: z.string() }))
.query(async ({ ctx, input }) => {
const integrationInstance = await createIntegrationAsync(ctx.integration);
const integrationInstance = integrationCreator(ctx.integration);
return await integrationInstance.searchAsync(encodeURI(input.query));
}),
});
@@ -4,7 +4,7 @@ import { decryptSecret } from "@homarr/common/server";
import type { Integration } from "@homarr/db/schema";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import { getAllSecretKindOptions } from "@homarr/definitions";
import { createIntegrationAsync, IntegrationTestConnectionError } from "@homarr/integrations";
import { integrationCreator, IntegrationTestConnectionError } from "@homarr/integrations";
import { logger } from "@homarr/log";
type FormIntegration = Integration & {
@@ -66,7 +66,7 @@ export const testConnectionAsync = async (
const { secrets: _, ...baseIntegration } = integration;
const integrationInstance = await createIntegrationAsync({
const integrationInstance = integrationCreator({
...baseIntegration,
decryptedSecrets,
});
@@ -1,10 +1,10 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { asc, createId, eq, like } from "@homarr/db";
import { asc, createId, eq, like, sql } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import { searchEngines, users } from "@homarr/db/schema";
import { createIntegrationAsync } from "@homarr/integrations";
import { integrationCreator } from "@homarr/integrations";
import { validation } from "@homarr/validation";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
@@ -13,7 +13,12 @@ import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publ
export const searchEngineRouter = createTRPCRouter({
getPaginated: protectedProcedure.input(validation.common.paginated).query(async ({ input, ctx }) => {
const whereQuery = input.search ? like(searchEngines.name, `%${input.search.trim()}%`) : undefined;
const searchEngineCount = await ctx.db.$count(searchEngines, whereQuery);
const searchEngineCount = await ctx.db
.select({
count: sql<number>`count(*)`,
})
.from(searchEngines)
.where(whereQuery);
const dbSearachEngines = await ctx.db.query.searchEngines.findMany({
limit: input.pageSize,
@@ -23,7 +28,7 @@ export const searchEngineRouter = createTRPCRouter({
return {
items: dbSearachEngines,
totalCount: searchEngineCount,
totalCount: searchEngineCount[0]?.count ?? 0,
};
}),
getSelectable: protectedProcedure
@@ -134,14 +139,14 @@ export const searchEngineRouter = createTRPCRouter({
.unstable_concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr"))
.input(validation.common.mediaRequestOptions)
.query(async ({ ctx, input }) => {
const integration = await createIntegrationAsync(ctx.integration);
const integration = integrationCreator(ctx.integration);
return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId);
}),
requestMedia: protectedProcedure
.unstable_concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr"))
.input(validation.common.requestMedia)
.mutation(async ({ ctx, input }) => {
const integration = await createIntegrationAsync(ctx.integration);
const integration = integrationCreator(ctx.integration);
return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
}),
create: permissionRequiredProcedure
+98 -521
View File
@@ -2,8 +2,8 @@ import SuperJSON from "superjson";
import { describe, expect, it, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import type { Database, InferInsertModel } from "@homarr/db";
import { and, createId, eq, not } from "@homarr/db";
import type { Database } from "@homarr/db";
import { createId, eq } from "@homarr/db";
import {
boardGroupPermissions,
boards,
@@ -13,10 +13,7 @@ import {
groups,
integrationItems,
integrations,
itemLayouts,
items,
layouts,
sectionLayouts,
sections,
serverSettings,
users,
@@ -307,27 +304,17 @@ describe("createBoard should create a new board", () => {
await caller.createBoard({ name: "newBoard", columnCount: 24, isPublic: true });
// Assert
const dbBoard = await db.query.boards.findFirst({
with: {
sections: true,
layouts: true,
},
});
const dbBoard = await db.query.boards.findFirst();
expect(dbBoard).toBeDefined();
expect(dbBoard?.name).toBe("newBoard");
expect(dbBoard?.columnCount).toBe(24);
expect(dbBoard?.isPublic).toBe(true);
expect(dbBoard?.creatorId).toBe(defaultCreatorId);
expect(dbBoard?.sections.length).toBe(1);
const firstSection = dbBoard?.sections.at(0);
expect(firstSection?.kind).toBe("empty");
expect(firstSection?.xOffset).toBe(0);
expect(firstSection?.yOffset).toBe(0);
expect(dbBoard?.layouts.length).toBe(1);
const firstLayout = dbBoard?.layouts.at(0);
expect(firstLayout?.columnCount).toBe(24);
expect(firstLayout?.breakpoint).toBe(0);
const dbSection = await db.query.sections.findFirst();
expect(dbSection).toBeDefined();
expect(dbSection?.boardId).toBe(dbBoard?.id);
expect(dbSection?.kind).toBe("empty");
});
test("should throw error when user has no board-create permission", async () => {
@@ -600,6 +587,7 @@ describe("savePartialBoardSettings should save general settings", () => {
const newBackgroundImageSize = "cover";
const newBackgroundImageRepeat = "repeat";
const newBackgroundImageUrl = "http://background.image/url.png";
const newColumnCount = 2;
const newCustomCss = "body { background-color: blue; }";
const newOpacity = 0.8;
const newPrimaryColor = "#0000ff";
@@ -617,6 +605,7 @@ describe("savePartialBoardSettings should save general settings", () => {
backgroundImageRepeat: newBackgroundImageRepeat,
backgroundImageSize: newBackgroundImageSize,
backgroundImageUrl: newBackgroundImageUrl,
columnCount: newColumnCount,
customCss: newCustomCss,
opacity: newOpacity,
primaryColor: newPrimaryColor,
@@ -637,6 +626,7 @@ describe("savePartialBoardSettings should save general settings", () => {
expect(dbBoard?.backgroundImageRepeat).toBe(newBackgroundImageRepeat);
expect(dbBoard?.backgroundImageSize).toBe(newBackgroundImageSize);
expect(dbBoard?.backgroundImageUrl).toBe(newBackgroundImageUrl);
expect(dbBoard?.columnCount).toBe(newColumnCount);
expect(dbBoard?.customCss).toBe(newCustomCss);
expect(dbBoard?.opacity).toBe(newOpacity);
expect(dbBoard?.primaryColor).toBe(newPrimaryColor);
@@ -678,9 +668,9 @@ describe("saveBoard should save full board", () => {
kind: "empty",
yOffset: 0,
xOffset: 0,
items: [],
},
],
items: [],
});
const board = await db.query.boards.findFirst({
@@ -705,7 +695,7 @@ describe("saveBoard should save full board", () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, itemId, sectionId, layoutId } = await createFullBoardAsync(db, "default");
const { boardId, itemId, sectionId } = await createFullBoardAsync(db, "default");
await caller.saveBoard({
id: boardId,
@@ -715,25 +705,19 @@ describe("saveBoard should save full board", () => {
kind: "empty",
yOffset: 0,
xOffset: 0,
},
],
items: [
{
id: createId(),
kind: "clock",
options: { is24HourFormat: true },
integrationIds: [],
layouts: [
items: [
{
layoutId,
sectionId,
id: createId(),
kind: "clock",
options: { is24HourFormat: true },
integrationIds: [],
height: 1,
width: 1,
xOffset: 0,
yOffset: 0,
advancedOptions: {},
},
],
advancedOptions: {},
},
],
});
@@ -741,8 +725,11 @@ describe("saveBoard should save full board", () => {
const board = await db.query.boards.findFirst({
where: eq(boards.id, boardId),
with: {
sections: true,
items: true,
sections: {
with: {
items: true,
},
},
},
});
@@ -752,8 +739,9 @@ describe("saveBoard should save full board", () => {
const definedBoard = expectToBeDefined(board);
expect(definedBoard.sections.length).toBe(1);
expect(definedBoard.items.length).toBe(1);
expect(definedBoard.items[0]?.id).not.toBe(itemId);
const firstSection = expectToBeDefined(definedBoard.sections[0]);
expect(firstSection.items.length).toBe(1);
expect(firstSection.items[0]?.id).not.toBe(itemId);
expect(item).toBeUndefined();
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
});
@@ -768,7 +756,7 @@ describe("saveBoard should save full board", () => {
url: "http://localhost:3000",
} as const;
const { boardId, itemId, integrationId, sectionId, layoutId } = await createFullBoardAsync(db, "default");
const { boardId, itemId, integrationId, sectionId } = await createFullBoardAsync(db, "default");
await db.insert(integrations).values(anotherIntegration);
await caller.saveBoard({
@@ -779,25 +767,19 @@ describe("saveBoard should save full board", () => {
kind: "empty",
xOffset: 0,
yOffset: 0,
},
],
items: [
{
id: itemId,
kind: "clock",
options: { is24HourFormat: true },
integrationIds: [anotherIntegration.id],
layouts: [
items: [
{
layoutId,
sectionId,
id: itemId,
kind: "clock",
options: { is24HourFormat: true },
integrationIds: [anotherIntegration.id],
height: 1,
width: 1,
xOffset: 0,
yOffset: 0,
advancedOptions: {},
},
],
advancedOptions: {},
},
],
});
@@ -805,10 +787,13 @@ describe("saveBoard should save full board", () => {
const board = await db.query.boards.findFirst({
where: eq(boards.id, boardId),
with: {
sections: true,
items: {
sections: {
with: {
integrations: true,
items: {
with: {
integrations: true,
},
},
},
},
},
@@ -820,8 +805,9 @@ describe("saveBoard should save full board", () => {
const definedBoard = expectToBeDefined(board);
expect(definedBoard.sections.length).toBe(1);
expect(definedBoard.items.length).toBe(1);
const firstItem = expectToBeDefined(definedBoard.items[0]);
const firstSection = expectToBeDefined(definedBoard.sections[0]);
expect(firstSection.items.length).toBe(1);
const firstItem = expectToBeDefined(firstSection.items[0]);
expect(firstItem.integrations.length).toBe(1);
expect(firstItem.integrations[0]?.integrationId).not.toBe(integrationId);
expect(integration).toBeUndefined();
@@ -844,6 +830,7 @@ describe("saveBoard should save full board", () => {
id: newSectionId,
xOffset: 0,
yOffset: 1,
items: [],
...partialSection,
},
{
@@ -851,9 +838,9 @@ describe("saveBoard should save full board", () => {
kind: "empty",
xOffset: 0,
yOffset: 0,
items: [],
},
],
items: [],
});
const board = await db.query.boards.findFirst({
@@ -886,7 +873,7 @@ describe("saveBoard should save full board", () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, sectionId, layoutId } = await createFullBoardAsync(db, "default");
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
const newItemId = createId();
await caller.saveBoard({
@@ -897,25 +884,19 @@ describe("saveBoard should save full board", () => {
kind: "empty",
yOffset: 0,
xOffset: 0,
},
],
items: [
{
id: newItemId,
kind: "clock",
options: { is24HourFormat: true },
integrationIds: [],
layouts: [
items: [
{
layoutId,
sectionId,
id: newItemId,
kind: "clock",
options: { is24HourFormat: true },
integrationIds: [],
height: 1,
width: 1,
xOffset: 3,
yOffset: 2,
advancedOptions: {},
},
],
advancedOptions: {},
},
],
});
@@ -923,10 +904,9 @@ describe("saveBoard should save full board", () => {
const board = await db.query.boards.findFirst({
where: eq(boards.id, boardId),
with: {
sections: true,
items: {
sections: {
with: {
layouts: true,
items: true,
},
},
},
@@ -938,18 +918,17 @@ describe("saveBoard should save full board", () => {
const definedBoard = expectToBeDefined(board);
expect(definedBoard.sections.length).toBe(1);
expect(definedBoard.items.length).toBe(1);
const addedItem = expectToBeDefined(definedBoard.items.find((item) => item.id === newItemId));
const firstSection = expectToBeDefined(definedBoard.sections[0]);
expect(firstSection.items.length).toBe(1);
const addedItem = expectToBeDefined(firstSection.items.find((item) => item.id === newItemId));
expect(addedItem).toBeDefined();
expect(addedItem.id).toBe(newItemId);
expect(addedItem.kind).toBe("clock");
expect(addedItem.options).toBe(SuperJSON.stringify({ is24HourFormat: true }));
const firstLayout = expectToBeDefined(addedItem.layouts[0]);
expect(firstLayout.sectionId).toBe(sectionId);
expect(firstLayout.height).toBe(1);
expect(firstLayout.width).toBe(1);
expect(firstLayout.xOffset).toBe(3);
expect(firstLayout.yOffset).toBe(2);
expect(addedItem.height).toBe(1);
expect(addedItem.width).toBe(1);
expect(addedItem.xOffset).toBe(3);
expect(addedItem.yOffset).toBe(2);
expect(item).toBeDefined();
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
});
@@ -964,7 +943,7 @@ describe("saveBoard should save full board", () => {
url: "http://plex.local",
} as const;
const { boardId, itemId, sectionId, layoutId } = await createFullBoardAsync(db, "default");
const { boardId, itemId, sectionId } = await createFullBoardAsync(db, "default");
await db.insert(integrations).values(integration);
await caller.saveBoard({
@@ -975,25 +954,19 @@ describe("saveBoard should save full board", () => {
kind: "empty",
xOffset: 0,
yOffset: 0,
},
],
items: [
{
id: itemId,
kind: "clock",
options: { is24HourFormat: true },
integrationIds: [integration.id],
layouts: [
items: [
{
sectionId,
layoutId,
id: itemId,
kind: "clock",
options: { is24HourFormat: true },
integrationIds: [integration.id],
height: 1,
width: 1,
xOffset: 0,
yOffset: 0,
advancedOptions: {},
},
],
advancedOptions: {},
},
],
});
@@ -1001,10 +974,13 @@ describe("saveBoard should save full board", () => {
const board = await db.query.boards.findFirst({
where: eq(boards.id, boardId),
with: {
sections: true,
items: {
sections: {
with: {
integrations: true,
items: {
with: {
integrations: true,
},
},
},
},
},
@@ -1016,7 +992,9 @@ describe("saveBoard should save full board", () => {
const definedBoard = expectToBeDefined(board);
expect(definedBoard.sections.length).toBe(1);
const firstItem = expectToBeDefined(definedBoard.items.find((item) => item.id === itemId));
const firstSection = expectToBeDefined(definedBoard.sections[0]);
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[0]?.integrationId).toBe(integration.id);
expect(integrationItem).toBeDefined();
@@ -1047,6 +1025,7 @@ describe("saveBoard should save full board", () => {
xOffset: 0,
name: "Test",
collapsed: true,
items: [],
},
{
id: newSectionId,
@@ -1055,9 +1034,9 @@ describe("saveBoard should save full board", () => {
yOffset: 0,
xOffset: 0,
collapsed: false,
items: [],
},
],
items: [],
});
const board = await db.query.boards.findFirst({
@@ -1085,7 +1064,7 @@ describe("saveBoard should save full board", () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, itemId, sectionId, layoutId } = await createFullBoardAsync(db, "default");
const { boardId, itemId, sectionId } = await createFullBoardAsync(db, "default");
await caller.saveBoard({
id: boardId,
@@ -1095,25 +1074,19 @@ describe("saveBoard should save full board", () => {
kind: "empty",
yOffset: 0,
xOffset: 0,
},
],
items: [
{
id: itemId,
kind: "clock",
options: { is24HourFormat: false },
integrationIds: [],
layouts: [
items: [
{
layoutId,
sectionId,
id: itemId,
kind: "clock",
options: { is24HourFormat: false },
integrationIds: [],
height: 3,
width: 2,
xOffset: 7,
yOffset: 5,
advancedOptions: {},
},
],
advancedOptions: {},
},
],
});
@@ -1121,10 +1094,9 @@ describe("saveBoard should save full board", () => {
const board = await db.query.boards.findFirst({
where: eq(boards.id, boardId),
with: {
sections: true,
items: {
sections: {
with: {
layouts: true,
items: true,
},
},
},
@@ -1132,17 +1104,16 @@ describe("saveBoard should save full board", () => {
const definedBoard = expectToBeDefined(board);
expect(definedBoard.sections.length).toBe(1);
expect(definedBoard.items.length).toBe(1);
const firstItem = expectToBeDefined(definedBoard.items.find((item) => item.id === itemId));
const firstSection = expectToBeDefined(definedBoard.sections[0]);
expect(firstSection.items.length).toBe(1);
const firstItem = expectToBeDefined(firstSection.items.find((item) => item.id === itemId));
expect(firstItem.id).toBe(itemId);
expect(firstItem.kind).toBe("clock");
expect(SuperJSON.parse<{ is24HourFormat: boolean }>(firstItem.options).is24HourFormat).toBe(false);
const firstLayout = expectToBeDefined(firstItem.layouts[0]);
expect(firstLayout.sectionId).toBe(sectionId);
expect(firstLayout.height).toBe(3);
expect(firstLayout.width).toBe(2);
expect(firstLayout.xOffset).toBe(7);
expect(firstLayout.yOffset).toBe(5);
expect(firstItem.height).toBe(3);
expect(firstItem.width).toBe(2);
expect(firstItem.xOffset).toBe(7);
expect(firstItem.yOffset).toBe(5);
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
});
it("should fail when board not found", async () => {
@@ -1153,7 +1124,6 @@ describe("saveBoard should save full board", () => {
await caller.saveBoard({
id: "nonExistentBoardId",
sections: [],
items: [],
});
await expect(actAsync()).rejects.toThrowError("Board not found");
@@ -1323,165 +1293,6 @@ describe("saveGroupBoardPermissions should save group board permissions", () =>
);
});
const createExistingLayout = (id: string) => ({
id,
name: "Base",
columnCount: 10,
breakpoint: 0,
});
const createNewLayout = (columnCount: number) => ({
id: createId(),
name: "New layout",
columnCount,
breakpoint: 1400,
});
describe("saveLayouts should save layout changes", () => {
test("should add layout when not present in database", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
const newLayout = createNewLayout(12);
// Act
await caller.saveLayouts({
id: boardId,
layouts: [createExistingLayout(layoutId), newLayout],
});
// Assert
const layout = await db.query.layouts.findFirst({
where: not(eq(layouts.id, layoutId)),
});
const definedLayout = expectToBeDefined(layout);
expect(definedLayout.name).toBe(newLayout.name);
expect(definedLayout.columnCount).toBe(newLayout.columnCount);
expect(definedLayout.breakpoint).toBe(newLayout.breakpoint);
});
test("should add items and dynamic sections generated from grid-algorithm when new layout is added", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default");
const assignments = await createItemsAndSectionsAsync(db, {
boardId,
layoutId,
sectionId,
});
const newLayout = createNewLayout(3);
// Act
await caller.saveLayouts({
id: boardId,
layouts: [createExistingLayout(layoutId), newLayout],
});
// Assert
const layout = await db.query.layouts.findFirst({
where: not(eq(layouts.id, layoutId)),
});
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await expectLayoutForRootLayoutAsync(db, sectionId, layout!.id, {
...assignments.inRoot,
a: itemId,
});
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layout!.id, assignments.inDynamicSection);
});
test("should update layout when present in input", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
const updatedLayout = createExistingLayout(layoutId);
updatedLayout.breakpoint = 1400;
updatedLayout.name = "Updated layout";
// Act
await caller.saveLayouts({
id: boardId,
layouts: [updatedLayout],
});
// Assert
const layout = await db.query.layouts.findFirst({
where: eq(layouts.id, layoutId),
});
const definedLayout = expectToBeDefined(layout);
expect(definedLayout.name).toBe(updatedLayout.name);
expect(definedLayout.columnCount).toBe(updatedLayout.columnCount);
expect(definedLayout.breakpoint).toBe(updatedLayout.breakpoint);
});
test("should update position of items when column count changes", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default");
const assignments = await createItemsAndSectionsAsync(db, {
boardId,
layoutId,
sectionId,
});
const updatedLayout = createExistingLayout(layoutId);
updatedLayout.columnCount = 3;
// Act
await caller.saveLayouts({
id: boardId,
layouts: [updatedLayout],
});
// Assert
await expectLayoutForRootLayoutAsync(db, sectionId, layoutId, {
...assignments.inRoot,
a: itemId,
});
await expectLayoutForDynamicSectionAsync(db, assignments.inRoot.f, layoutId, assignments.inDynamicSection);
});
test("should remove layout when not present in input", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
// Act
await caller.saveLayouts({
id: boardId,
layouts: [createNewLayout(12)],
});
// Assert
const layout = await db.query.layouts.findFirst({
where: eq(layouts.id, layoutId),
});
expect(layout).toBeUndefined();
});
test("should fail when board not found", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { layoutId } = await createFullBoardAsync(db, "default");
// Act
const actAsync = async () =>
await caller.saveLayouts({
id: createId(),
layouts: [createExistingLayout(layoutId)],
});
// Assert
await expect(actAsync()).rejects.toThrowError("Board not found");
});
});
const expectInputToBeFullBoardWithName = (
input: RouterOutputs["board"]["getHomeBoard"],
props: { name: string } & Awaited<ReturnType<typeof createFullBoardAsync>>,
@@ -1491,8 +1302,8 @@ const expectInputToBeFullBoardWithName = (
expect(input.sections.length).toBe(1);
const firstSection = expectToBeDefined(input.sections[0]);
expect(firstSection.id).toBe(props.sectionId);
expect(input.items.length).toBe(1);
const firstItem = expectToBeDefined(input.items[0]);
expect(firstSection.items.length).toBe(1);
const firstItem = expectToBeDefined(firstSection.items[0]);
expect(firstItem.id).toBe(props.itemId);
expect(firstItem.kind).toBe("clock");
if (firstItem.kind === "clock") {
@@ -1515,15 +1326,6 @@ const createFullBoardAsync = async (db: Database, name: string) => {
creatorId: defaultCreatorId,
});
const layoutId = createId();
await db.insert(layouts).values({
id: layoutId,
name: "Base",
columnCount: 10,
breakpoint: 0,
boardId,
});
const sectionId = createId();
await db.insert(sections).values({
id: sectionId,
@@ -1537,18 +1339,12 @@ const createFullBoardAsync = async (db: Database, name: string) => {
await db.insert(items).values({
id: itemId,
kind: "clock",
boardId,
options: SuperJSON.stringify({ is24HourFormat: true }),
});
await db.insert(itemLayouts).values({
height: 1,
width: 1,
xOffset: 0,
yOffset: 0,
sectionId,
itemId,
layoutId,
options: SuperJSON.stringify({ is24HourFormat: true }),
});
const integrationId = createId();
@@ -1567,226 +1363,7 @@ const createFullBoardAsync = async (db: Database, name: string) => {
return {
boardId,
sectionId,
layoutId,
itemId,
integrationId,
};
};
const addItemAsync = async (
db: Database,
item: Partial<Pick<InferInsertModel<typeof itemLayouts>, "height" | "width" | "xOffset" | "yOffset">> & {
sectionId: string;
layoutId: string;
boardId: string;
},
) => {
const itemId = createId();
await db.insert(items).values({
id: itemId,
kind: "clock",
boardId: item.boardId,
options: SuperJSON.stringify({ is24HourFormat: true }),
});
await db.insert(itemLayouts).values({
itemId,
layoutId: item.layoutId,
sectionId: item.sectionId,
height: item.height ?? 1,
width: item.width ?? 1,
xOffset: item.xOffset ?? 0,
yOffset: item.yOffset ?? 0,
});
return itemId;
};
const addDynamicSectionAsync = async (
db: Database,
section: Partial<Pick<InferInsertModel<typeof sectionLayouts>, "xOffset" | "yOffset" | "width" | "height">> & {
parentSectionId: string;
boardId: string;
layoutId: string;
},
) => {
const sectionId = createId();
await db.insert(sections).values({
id: sectionId,
kind: "dynamic",
boardId: section.boardId,
});
await db.insert(sectionLayouts).values({
parentSectionId: section.parentSectionId,
layoutId: section.layoutId,
sectionId,
xOffset: section.xOffset ?? 0,
yOffset: section.yOffset ?? 0,
width: section.width ?? 1,
height: section.height ?? 1,
});
return sectionId;
};
const createItemsAndSectionsAsync = async (
db: Database,
options: { boardId: string; sectionId: string; layoutId: string },
) => {
const { boardId, layoutId, sectionId } = options;
// From:
// abbbbbccdd
// efffffccdd
// efffffggdd
// efffffgg
// To:
// a
// bbb
// cce
// cce
// dde
// dd
// dd
// fff
// fff
// fff
// fff
// gg
// gg
const itemB = await addItemAsync(db, { boardId, layoutId, sectionId, xOffset: 1, width: 5 });
const itemC = await addItemAsync(db, { boardId, layoutId, sectionId, xOffset: 6, width: 2, height: 2 });
const itemD = await addItemAsync(db, { boardId, layoutId, sectionId, xOffset: 8, width: 2, height: 3 });
const itemE = await addItemAsync(db, { boardId, layoutId, sectionId, yOffset: 1, height: 3 });
const sectionF = await addDynamicSectionAsync(db, {
yOffset: 1,
xOffset: 1,
width: 5,
height: 3,
parentSectionId: sectionId,
boardId,
layoutId,
});
const sectionG = await addDynamicSectionAsync(db, {
yOffset: 2,
xOffset: 6,
width: 2,
height: 2,
parentSectionId: sectionId,
boardId,
layoutId,
});
// From:
// hhhhh
// iiijj
// iii
// To:
// hhh
// iii
// iii
// jj
const itemH = await addItemAsync(db, { boardId, layoutId, sectionId: sectionF, width: 5 });
const itemI = await addItemAsync(db, { boardId, layoutId, sectionId: sectionF, width: 3, height: 2, yOffset: 1 });
const itemJ = await addItemAsync(db, { boardId, layoutId, sectionId: sectionF, width: 2, yOffset: 1, xOffset: 2 });
return {
inRoot: {
b: itemB,
c: itemC,
d: itemD,
e: itemE,
f: sectionF,
g: sectionG,
},
inDynamicSection: {
h: itemH,
i: itemI,
j: itemJ,
},
};
};
const expectLayoutForRootLayoutAsync = async (
db: Database,
sectionId: string,
layoutId: string,
assignments: Record<string, string>,
) => {
await expectLayoutInSectionAsync(
db,
sectionId,
layoutId,
`
a
bbb
cce
cce
dde
dd
dd
fff
fff
fff
fff
gg
gg`,
assignments,
);
};
const expectLayoutForDynamicSectionAsync = async (
db: Database,
sectionId: string,
layoutId: string,
assignments: Record<string, string>,
) => {
await expectLayoutInSectionAsync(
db,
sectionId,
layoutId,
`
hhh
iii
iii
jj`,
assignments,
);
};
const expectLayoutInSectionAsync = async (
db: Database,
sectionId: string,
layoutId: string,
layout: string,
assignments: Record<string, string>,
) => {
const itemsInSection = await db.query.itemLayouts.findMany({
where: and(eq(itemLayouts.sectionId, sectionId), eq(itemLayouts.layoutId, layoutId)),
});
const sectionsInSection = await db.query.sectionLayouts.findMany({
where: and(eq(sectionLayouts.parentSectionId, sectionId), eq(sectionLayouts.layoutId, layoutId)),
});
const entries = [...itemsInSection, ...sectionsInSection];
const lines = layout.split("\n").slice(1);
const keys = Object.keys(assignments);
const positions: Record<string, { x: number; y: number; w: number; h: number }> = {};
for (let yOffset = 0; yOffset < lines.length; yOffset++) {
const line = lines[yOffset];
if (!line) continue;
for (let xOffset = 0; xOffset < line.length; xOffset++) {
const char = line[xOffset];
if (!char) continue;
if (!keys.includes(char)) continue;
if (char in positions) continue;
const width = line.split("").filter((lineChar) => lineChar === char).length;
const height = lines.slice(yOffset).filter((line) => line.substring(xOffset).startsWith(char)).length;
positions[char] = { x: xOffset, y: yOffset, w: width, h: height };
}
}
for (const [key, { x, y, w, h }] of Object.entries(positions)) {
const entry = entries.find((entry) => ("itemId" in entry ? entry.itemId : entry.sectionId) === assignments[key]);
expect(entry, `Expect entry for ${key} to be defined in assignments=${JSON.stringify(assignments)}`).toBeDefined();
expect(entry?.xOffset, `Expect xOffset of entry for ${key} to be ${x} for entry=${JSON.stringify(entry)}`).toBe(x);
expect(entry?.yOffset, `Expect yOffset of entry for ${key} to be ${y} for entry=${JSON.stringify(entry)}`).toBe(y);
expect(entry?.width, `Expect width of entry for ${key} to be ${w} for entry=${JSON.stringify(entry)}`).toBe(w);
expect(entry?.height, `Expect height of entry for ${key} to be ${h} for entry=${JSON.stringify(entry)}`).toBe(h);
}
};
@@ -18,13 +18,11 @@ vi.mock("@homarr/common/server", async (importActual) => {
describe("testConnectionAsync should run test connection of integration", () => {
test("with input of only form secrets matching api key kind it should use form apiKey", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreator");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
testConnectionAsync: async () => await Promise.resolve(),
} as homarrIntegrations.PiHoleIntegrationV6),
);
factorySpy.mockReturnValue({
testConnectionAsync: async () => await Promise.resolve(),
} as homarrIntegrations.PiHoleIntegration);
optionsSpy.mockReturnValue([["apiKey"]]);
const integration = {
@@ -60,13 +58,11 @@ describe("testConnectionAsync should run test connection of integration", () =>
test("with input of only null form secrets and the required db secrets matching api key kind it should use db apiKey", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreator");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
testConnectionAsync: async () => await Promise.resolve(),
} as homarrIntegrations.PiHoleIntegrationV6),
);
factorySpy.mockReturnValue({
testConnectionAsync: async () => await Promise.resolve(),
} as homarrIntegrations.PiHoleIntegration);
optionsSpy.mockReturnValue([["apiKey"]]);
const integration = {
@@ -109,13 +105,11 @@ describe("testConnectionAsync should run test connection of integration", () =>
test("with input of form and db secrets matching api key kind it should use form apiKey", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreator");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
testConnectionAsync: async () => await Promise.resolve(),
} as homarrIntegrations.PiHoleIntegrationV6),
);
factorySpy.mockReturnValue({
testConnectionAsync: async () => await Promise.resolve(),
} as homarrIntegrations.PiHoleIntegration);
optionsSpy.mockReturnValue([["apiKey"]]);
const integration = {
@@ -158,13 +152,11 @@ describe("testConnectionAsync should run test connection of integration", () =>
test("with input of form apiKey and db secrets for username and password it should use form apiKey when both is allowed", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreator");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
testConnectionAsync: async () => await Promise.resolve(),
} as homarrIntegrations.PiHoleIntegrationV6),
);
factorySpy.mockReturnValue({
testConnectionAsync: async () => await Promise.resolve(),
} as homarrIntegrations.PiHoleIntegration);
optionsSpy.mockReturnValue([["username", "password"], ["apiKey"]]);
const integration = {
@@ -211,13 +203,11 @@ describe("testConnectionAsync should run test connection of integration", () =>
test("with input of null form apiKey and db secrets for username and password it should use db username and password when both is allowed", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreator");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
testConnectionAsync: async () => await Promise.resolve(),
} as homarrIntegrations.PiHoleIntegrationV6),
);
factorySpy.mockReturnValue({
testConnectionAsync: async () => await Promise.resolve(),
} as homarrIntegrations.PiHoleIntegration);
optionsSpy.mockReturnValue([["username", "password"], ["apiKey"]]);
const integration = {
+5 -9
View File
@@ -1,12 +1,12 @@
import { observable } from "@trpc/server/observable";
import { z } from "zod";
import type { Modify } from "@homarr/common/types";
import type { Integration } from "@homarr/db/schema";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { integrationCreator } from "@homarr/integrations";
import type { DnsHoleSummary } from "@homarr/integrations/types";
import { controlsInputSchema } from "@homarr/integrations/types";
import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
@@ -65,7 +65,7 @@ export const dnsHoleRouter = createTRPCRouter({
enable: protectedProcedure
.unstable_concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole")))
.mutation(async ({ ctx: { integration } }) => {
const client = await createIntegrationAsync(integration);
const client = integrationCreator(integration);
await client.enableAsync();
const innerHandler = dnsHoleRequestHandler.handler(integration, {});
@@ -76,14 +76,10 @@ export const dnsHoleRouter = createTRPCRouter({
}),
disable: protectedProcedure
.input(
z.object({
duration: z.number().optional(),
}),
)
.input(controlsInputSchema)
.unstable_concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole")))
.mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration);
const client = integrationCreator(integration);
await client.disableAsync(input.duration);
const innerHandler = dnsHoleRequestHandler.handler(integration, {});
+6 -6
View File
@@ -6,7 +6,7 @@ import type { Integration } from "@homarr/db/schema";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
import { createIntegrationAsync, downloadClientItemSchema } from "@homarr/integrations";
import { downloadClientItemSchema, integrationCreator } from "@homarr/integrations";
import { downloadClientRequestHandler } from "@homarr/request-handler/downloads";
import type { IntegrationAction } from "../../middlewares/integration";
@@ -69,7 +69,7 @@ export const downloadsRouter = createTRPCRouter({
.mutation(async ({ ctx }) => {
await Promise.all(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
const integrationInstance = integrationCreator(integration);
await integrationInstance.pauseQueueAsync();
}),
);
@@ -80,7 +80,7 @@ export const downloadsRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
await Promise.all(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
const integrationInstance = integrationCreator(integration);
await integrationInstance.pauseItemAsync(input.item);
}),
);
@@ -90,7 +90,7 @@ export const downloadsRouter = createTRPCRouter({
.mutation(async ({ ctx }) => {
await Promise.all(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
const integrationInstance = integrationCreator(integration);
await integrationInstance.resumeQueueAsync();
}),
);
@@ -101,7 +101,7 @@ export const downloadsRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
await Promise.all(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
const integrationInstance = integrationCreator(integration);
await integrationInstance.resumeItemAsync(input.item);
}),
);
@@ -112,7 +112,7 @@ export const downloadsRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
await Promise.all(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
const integrationInstance = integrationCreator(integration);
await integrationInstance.deleteItemAsync(input.item, input.fromDisk);
}),
);
@@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { integrationCreator } from "@homarr/integrations";
import type { Indexer } from "@homarr/integrations/types";
import { logger } from "@homarr/log";
import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-manager";
@@ -59,7 +59,7 @@ export const indexerManagerRouter = createTRPCRouter({
.mutation(async ({ ctx }) => {
await Promise.all(
ctx.integrations.map(async (integration) => {
const client = await createIntegrationAsync(integration);
const client = integrationCreator(integration);
await client.testAllAsync().catch((err) => {
logger.error("indexer-manager router - ", err);
throw new TRPCError({
@@ -2,7 +2,7 @@ import { observable } from "@trpc/server/observable";
import { z } from "zod";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { integrationCreator, MediaRequestStatus } from "@homarr/integrations";
import type { MediaRequest } from "@homarr/integrations/types";
import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
@@ -30,12 +30,14 @@ export const mediaRequestsRouter = createTRPCRouter({
);
return results
.flatMap(({ data, integration }) => data.map((request) => ({ ...request, integrationId: integration.id })))
.sort((dataA, dataB) => {
if (dataA.status === dataB.status) {
return dataB.createdAt.getTime() - dataA.createdAt.getTime();
.sort(({ status: statusA }, { status: statusB }) => {
if (statusA === MediaRequestStatus.PendingApproval) {
return -1;
}
return dataA.status - dataB.status;
if (statusB === MediaRequestStatus.PendingApproval) {
return 1;
}
return 0;
});
}),
subscribeToLatestRequests: publicProcedure
@@ -94,7 +96,7 @@ export const mediaRequestsRouter = createTRPCRouter({
.unstable_concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("mediaRequest")))
.input(z.object({ requestId: z.number(), answer: z.enum(["approve", "decline"]) }))
.mutation(async ({ ctx: { integration }, input }) => {
const integrationInstance = await createIntegrationAsync(integration);
const integrationInstance = integrationCreator(integration);
const innerHandler = mediaRequestListRequestHandler.handler(integration, {});
if (input.answer === "approve") {
+8 -1
View File
@@ -19,9 +19,16 @@ export const notebookRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
const item = await ctx.db.query.items.findFirst({
where: eq(items.id, input.itemId),
with: {
section: {
columns: {
boardId: true,
},
},
},
});
if (!item || item.boardId !== input.boardId) {
if (!item || item.section.boardId !== input.boardId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Specified item was not found",
@@ -2,7 +2,7 @@ import { observable } from "@trpc/server/observable";
import { z } from "zod";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { integrationCreator } from "@homarr/integrations";
import { smartHomeEntityStateRequestHandler } from "@homarr/request-handler/smart-home-entity-state";
import type { IntegrationAction } from "../../middlewares/integration";
@@ -45,7 +45,7 @@ export const smartHomeRouter = createTRPCRouter({
.unstable_concat(createSmartHomeIntegrationMiddleware("interact"))
.input(z.object({ entityId: z.string() }))
.mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration);
const client = integrationCreator(integration);
const success = await client.triggerToggleAsync(input.entityId);
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
@@ -57,7 +57,7 @@ export const smartHomeRouter = createTRPCRouter({
.unstable_concat(createSmartHomeIntegrationMiddleware("interact"))
.input(z.object({ automationId: z.string() }))
.mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration);
const client = integrationCreator(integration);
await client.triggerAutomationAsync(input.automationId);
}),
});
+5 -6
View File
@@ -2,7 +2,7 @@
"name": "@homarr/auth",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"license": "MIT",
"type": "module",
"exports": {
".": "./index.ts",
@@ -23,9 +23,8 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@auth/core": "^0.38.0",
"@auth/drizzle-adapter": "^1.8.0",
"@homarr/certificates": "workspace:^0.1.0",
"@auth/core": "^0.37.4",
"@auth/drizzle-adapter": "^1.7.4",
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
@@ -49,7 +48,7 @@
"@types/bcrypt": "5.0.2",
"@types/cookies": "0.9.0",
"eslint": "^9.21.0",
"prettier": "^3.5.3",
"typescript": "^5.8.2"
"prettier": "^3.5.2",
"typescript": "^5.7.3"
}
}
@@ -11,7 +11,9 @@ interface Integration {
id: string;
items: {
item: {
boardId: string;
section: {
boardId: string;
};
};
}[];
userPermissions: {
@@ -54,7 +56,7 @@ export const hasQueryAccessToIntegrationsAsync = async (
const integrationsWithBoardIds = integrationsWithoutUseAccessAndWithoutBoardViewAllAccess.map((integration) => ({
id: integration.id,
anyOfBoardIds: integration.items.map(({ item }) => item.boardId),
anyOfBoardIds: integration.items.map(({ item }) => item.section.boardId),
}));
const permissionsOfCurrentUserWhenPresent = await db.query.boardUserPermissions.findMany({
@@ -29,13 +29,13 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
const integrations = [
{
id: "1",
items: [{ item: { boardId: "1" } }],
items: [{ item: { section: { boardId: "1" } } }],
userPermissions: [],
groupPermissions: [],
},
{
id: "2",
items: [{ item: { boardId: "2" } }],
items: [{ item: { section: { boardId: "2" } } }],
userPermissions: [],
groupPermissions: [],
},
@@ -63,7 +63,7 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
const integrations = [
{
id: "1",
items: [{ item: { boardId: "1" } }],
items: [{ item: { section: { boardId: "1" } } }],
userPermissions: [],
groupPermissions: [],
},
@@ -131,7 +131,9 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
boardId: "1",
section: {
boardId: "1",
},
},
},
],
@@ -143,12 +145,16 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
boardId: "1",
section: {
boardId: "1",
},
},
},
{
item: {
boardId: "2",
section: {
boardId: "2",
},
},
},
],
@@ -183,7 +189,9 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
boardId: "1",
section: {
boardId: "1",
},
},
},
],
@@ -195,7 +203,9 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
boardId: "2",
section: {
boardId: "2",
},
},
},
],
@@ -230,7 +240,9 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
boardId: "1",
section: {
boardId: "1",
},
},
},
],
@@ -242,12 +254,16 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
boardId: "1",
section: {
boardId: "1",
},
},
},
{
item: {
boardId: "2",
section: {
boardId: "2",
},
},
},
],
@@ -284,7 +300,9 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
boardId: "1",
section: {
boardId: "1",
},
},
},
],
@@ -296,7 +314,9 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
boardId: "2",
section: {
boardId: "2",
},
},
},
],
@@ -333,7 +353,9 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
boardId: "1",
section: {
boardId: "1",
},
},
},
],
@@ -345,7 +367,9 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
boardId: "2",
section: {
boardId: "2",
},
},
},
],
@@ -377,7 +401,9 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
boardId: "1",
section: {
boardId: "1",
},
},
},
],
@@ -389,12 +415,16 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
boardId: "2",
section: {
boardId: "2",
},
},
},
{
item: {
boardId: "1",
section: {
boardId: "1",
},
},
},
],
@@ -422,7 +452,9 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
boardId: "1",
section: {
boardId: "1",
},
},
},
],
@@ -434,12 +466,16 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
boardId: "2",
section: {
boardId: "2",
},
},
},
{
item: {
boardId: "1",
section: {
boardId: "1",
},
},
},
],
@@ -466,7 +502,9 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
boardId: "1",
section: {
boardId: "1",
},
},
},
],
@@ -478,7 +516,9 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
boardId: "2",
section: {
boardId: "2",
},
},
},
],
@@ -503,7 +543,9 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
boardId: "1",
section: {
boardId: "1",
},
},
},
],
@@ -515,7 +557,9 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
boardId: "2",
section: {
boardId: "2",
},
},
},
],
@@ -541,7 +585,9 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
boardId: "1",
section: {
boardId: "1",
},
},
},
],
@@ -553,12 +599,16 @@ describe("hasQueryAccessToIntegrationsAsync should check if the user has query a
items: [
{
item: {
boardId: "2",
section: {
boardId: "2",
},
},
},
{
item: {
boardId: "1",
section: {
boardId: "1",
},
},
},
],
@@ -1,9 +1,6 @@
import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
import type { OIDCConfig } from "@auth/core/providers";
import type { Profile } from "@auth/core/types";
import { customFetch } from "next-auth";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { env } from "../../env";
import { createRedirectUri } from "../../redirect";
@@ -38,10 +35,6 @@ export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profil
provider: "oidc",
};
},
// The type for fetch is not identical, but for what we need it it's okay to not be an 1:1 match
// See documentation https://authjs.dev/guides/corporate-proxy?framework=next-js
// @ts-expect-error `undici` has a `duplex` option
[customFetch]: fetchWithTrustedCertificatesAsync,
});
export const extractProfileName = (profile: Profile) => {
-1
View File
@@ -11,7 +11,6 @@ import { colorSchemeCookieKey, everyoneGroup } from "@homarr/definitions";
import { createSignInEventHandler } from "../events";
vi.mock("next-auth", () => ({}));
vi.mock("../env", () => {
return {
env: {
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "@homarr/boards",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"license": "MIT",
"type": "module",
"exports": {
"./context": "./src/context.tsx",
@@ -33,6 +33,6 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.21.0",
"typescript": "^5.8.2"
"typescript": "^5.7.3"
}
}
+2 -42
View File
@@ -1,7 +1,7 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { createContext, useContext, useEffect } from "react";
import { usePathname } from "next/navigation";
import type { RouterOutputs } from "@homarr/api";
@@ -10,7 +10,7 @@ import { clientApi } from "@homarr/api/client";
import { updateBoardName } from "./updater";
const BoardContext = createContext<{
board: RouterOutputs["board"]["getBoardByName"];
board: RouterOutputs["board"]["getHomeBoard"];
} | null>(null);
export const BoardProvider = ({
@@ -68,43 +68,3 @@ export const useOptionalBoard = () => {
return context?.board ?? null;
};
export const getCurrentLayout = (board: RouterOutputs["board"]["getBoardByName"]) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (typeof window === "undefined") return board.layouts.at(0)!.id;
const sortedLayouts = board.layouts.sort((layoutA, layoutB) => layoutB.breakpoint - layoutA.breakpoint);
// Fallback to smallest if none exists with breakpoint smaller than window width
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return sortedLayouts.find((layout) => layout.breakpoint <= window.innerWidth)?.id ?? sortedLayouts.at(0)!.id;
};
export const useCurrentLayout = () => {
const board = useRequiredBoard();
const [currentLayout, setCurrentLayout] = useState(getCurrentLayout(board));
const onResize = useCallback(() => {
setCurrentLayout(getCurrentLayout(board));
}, [board]);
useEffect(() => {
if (typeof window === "undefined") return;
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
};
}, [onResize]);
return currentLayout;
};
export const getBoardLayouts = (board: RouterOutputs["board"]["getBoardByName"]) =>
board.layouts.map((layout) => layout.id);
export const useLayouts = () => {
const board = useRequiredBoard();
return getBoardLayouts(board);
};
+5 -3
View File
@@ -2,7 +2,7 @@
"name": "@homarr/certificates",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"license": "MIT",
"type": "module",
"exports": {
"./server": "./src/server.ts"
@@ -23,13 +23,15 @@
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"undici": "7.4.0"
"@homarr/log": "workspace:^0.1.0",
"cacheable-lookup": "^7.0.0",
"undici": "7.3.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.21.0",
"typescript": "^5.8.2"
"typescript": "^5.7.3"
}
}
+44
View File
@@ -8,6 +8,15 @@ import { fetch } from "undici";
import { env } from "@homarr/common/env";
import { LoggingAgent } from "@homarr/common/server";
import CacheableLookup from "cacheable-lookup";
import type { LookupOptions } from "node:dns";
import { logger } from "@homarr/log";
const cacheableLookup = new CacheableLookup({
maxTtl: 6_00,
errorTtl: 60,
fallbackDuration: 3600
});
const getCertificateFolder = () => {
return env.NODE_ENV === "production"
@@ -66,15 +75,50 @@ export const createCertificateAgentAsync = async () => {
return new LoggingAgent({
connect: {
ca: rootCertificates.concat(customCertificates.map((cert) => cert.content)),
// Override the DNS lookup with the https://www.npmjs.com/package/cacheable-lookup library lookup (see https://github.com/nodejs/undici/issues/2440#issuecomment-2181215481)
lookup(hostname, lookupOptions, callback) {
logger.info(`looking up ${hostname} with cacheable lookup`);
cacheableLookup.lookup(hostname, { all: lookupOptions.all, hints: lookupOptions.hints, family: convertToNodeJsIPFamily(lookupOptions.family) }, (err, address, family) => {
if (err) {
logger.error(`DNS Lookup failed for ${hostname}: ${err}`);
} else {
logger.info(`Cacheable Lookup for ${hostname}: ${JSON.stringify(address)}, family: ${family}`);
}
callback(err, address, family);
});
}
},
keepAliveTimeout: 10_000, // Keep connection open for 10s
keepAliveMaxTimeout: 30_000,
pipelining: 0
});
};
/**
* Converts the family
* @see https://nodejs.org/api/dns.html#dns_dns_lookup_hostname_options_callback
* @param lookupOptionsFamily the family that should be converted
*/
const convertToNodeJsIPFamily = (lookupOptionsFamily: LookupOptions['family']) => {
switch (lookupOptionsFamily) {
case 'IPv4': {
return 4;
}
case 'IPv6': {
return 6;
}
default: {
return undefined;
}
}
}
export const createAxiosCertificateInstanceAsync = async () => {
const customCertificates = await loadCustomRootCertificatesAsync();
return axios.create({
httpsAgent: new Agent({
ca: rootCertificates.concat(customCertificates.map((cert) => cert.content)),
keepAlive: true
}),
});
};
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "@homarr/cli",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"license": "MIT",
"type": "module",
"exports": {
".": "./index.ts"
@@ -34,6 +34,6 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.21.0",
"typescript": "^5.8.2"
"typescript": "^5.7.3"
}
}
+3 -3
View File
@@ -2,7 +2,7 @@
"name": "@homarr/common",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"license": "MIT",
"type": "module",
"exports": {
".": "./index.ts",
@@ -33,7 +33,7 @@
"next": "15.1.7",
"react": "19.0.0",
"react-dom": "19.0.0",
"undici": "7.4.0",
"undici": "7.3.0",
"zod": "^3.24.2"
},
"devDependencies": {
@@ -41,6 +41,6 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.21.0",
"typescript": "^5.8.2"
"typescript": "^5.7.3"
}
}
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "@homarr/cron-job-runner",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"license": "MIT",
"type": "module",
"exports": {
".": "./index.ts"
@@ -31,6 +31,6 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.21.0",
"typescript": "^5.8.2"
"typescript": "^5.7.3"
}
}
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "@homarr/cron-job-status",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"license": "MIT",
"type": "module",
"exports": {
".": "./index.ts",
@@ -30,6 +30,6 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.21.0",
"typescript": "^5.8.2"
"typescript": "^5.7.3"
}
}
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "@homarr/cron-jobs-core",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"license": "MIT",
"type": "module",
"exports": {
".": "./index.ts",
@@ -33,6 +33,6 @@
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node-cron": "^3.0.11",
"eslint": "^9.21.0",
"typescript": "^5.8.2"
"typescript": "^5.7.3"
}
}
+2 -10
View File
@@ -1,4 +1,3 @@
import { AxiosError } from "axios";
import cron from "node-cron";
import { Stopwatch } from "@homarr/common";
@@ -49,15 +48,8 @@ const createCallback = <TAllowedNames extends string, TName extends TAllowedName
}
await creatorOptions.onCallbackSuccess?.(name);
} catch (error) {
// Log AxiosError in a less detailed way to prevent very long output
if (error instanceof AxiosError) {
creatorOptions.logger.logError(
`Failed to run job '${name}': [AxiosError] ${error.message} ${error.response?.status} ${error.response?.config.url}\n${error.stack}`,
);
} else {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
creatorOptions.logger.logError(`Failed to run job '${name}': ${error}`);
}
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
creatorOptions.logger.logError(`Failed to run job '${name}': ${error}`);
await creatorOptions.onCallbackError?.(name, error);
}
};
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "@homarr/cron-jobs",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"license": "MIT",
"type": "module",
"exports": {
".": "./index.ts"
@@ -45,6 +45,6 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.21.0",
"typescript": "^5.8.2"
"typescript": "^5.7.3"
}
}
@@ -1,50 +0,0 @@
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';
Loaded 100 of 265 files, more files were not shown because too many files have changed in this diff. Show more