diff --git a/.github/renovate.json b/.github/renovate.json index 1f5acc772..69fb4cdad 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -1,15 +1,30 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": ["config:recommended"], + "extends": [ + "config:recommended" + ], "packageRules": [ { - "matchPackagePatterns": ["^@homarr/"], + "matchPackagePatterns": [ + "^@homarr/" + ], "enabled": false + }, + { + "matchUpdateTypes": [ + "minor", + "patch", + "pin", + "digest" + ], + "automerge": true } ], "updateInternalDeps": true, "rangeStrategy": "bump", "automerge": false, - "baseBranches": ["dev"], + "baseBranches": [ + "dev" + ], "dependencyDashboard": false -} +} \ No newline at end of file diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 44846a959..228dd9989 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -66,3 +66,9 @@ jobs: - name: Test run: pnpm test + + - name: 'Report Coverage' + # Set if: always() to also generate the report if tests are failing + # Only works if you set `reportOnFailure: true` in your vite config as specified above + if: always() + uses: davelosert/vitest-coverage-report-action@v2 diff --git a/.github/workflows/conventional-commits.yml b/.github/workflows/conventional-commits.yml new file mode 100644 index 000000000..8f2216bd7 --- /dev/null +++ b/.github/workflows/conventional-commits.yml @@ -0,0 +1,16 @@ + +# https://github.com/webiny/action-conventional-commits?tab=readme-ov-file + +name: Conventional Commits + +on: + pull_request: + branches: [ dev ] + +jobs: + build: + name: Conventional Commits + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: webiny/action-conventional-commits@v1.3.0 \ No newline at end of file diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 94bfba67a..046523358 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -30,8 +30,20 @@ jobs: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} uses: Ilshidur/action-discord@master with: - args: "Deployment of an image has been triggered" + args: "Deployment of an image has been triggered: [run ${{ github.run_number }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})" - uses: actions/checkout@v4 + - name: Get Next Version + id: semver + uses: ietf-tools/semver-action@v1 + with: + token: ${{ github.token }} + branch: master + - name: Discord notification + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + uses: Ilshidur/action-discord@master + with: + args: "Semver computed next tag to be ${{ steps.semver.outputs.next }}. Current is ${{ steps.semver.outputs.current }}" - uses: pnpm/action-setup@v2 with: version: 8 @@ -44,23 +56,16 @@ jobs: run: pnpm install - name: Build artifacts run: pnpm build - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Bump version and push tag - id: githubTagAction - uses: anothrNick/github-tag-action@1.69.0 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - WITH_V: false - DRY_RUN: true - name: Discord notification env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} uses: Ilshidur/action-discord@master with: - args: "Image has been tagged as ${{ steps.githubTagAction.outputs.new_tag }}" + args: "Built application artifacts. Building images..." + - 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 @@ -68,7 +73,7 @@ jobs: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=raw,value=latest - type=raw,value=${{ steps.githubTagAction.outputs.new_tag }} + type=raw,value=${{ steps.semver.outputs.next }} - name: Build and push id: buildPushAction uses: docker/build-push-action@v5 @@ -86,4 +91,4 @@ jobs: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} uses: Ilshidur/action-discord@master with: - args: "Image built with ID ${{ steps.buildPushAction.outputs.imageid }}" + args: "Deployment of image has completed. Image ID is '${{ steps.buildPushAction.outputs.imageid }}'. This was a dry run." diff --git a/.github/workflows/pr-conventional-commits.yml b/.github/workflows/pr-conventional-commits.yml new file mode 100644 index 000000000..e3dfb587b --- /dev/null +++ b/.github/workflows/pr-conventional-commits.yml @@ -0,0 +1,20 @@ +name: "Lint PR" + +on: + pull_request_target: + types: + - opened + - edited + - synchronize + +permissions: + pull-requests: read + +jobs: + main: + name: Validate PR title + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/renovate-automatic-approval b/.github/workflows/renovate-automatic-approval new file mode 100644 index 000000000..b4701ed96 --- /dev/null +++ b/.github/workflows/renovate-automatic-approval @@ -0,0 +1,23 @@ +name: Approve Renovate PRs +on: + pull_request: + types: [opened, synchronize] + +jobs: + approve-renovate-prs: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Install GitHub CLI + run: sudo apt-get install -y gh + + - name: Approve Renovate PRs + env: + GITHUB_TOKEN: ${{ secrets.RENOVATE_APPROVE_TOKEN }} + run: | + for pr in $(gh pr list --author homarr-renovate[bot] --json number --jq .[].number); do + gh pr review $pr --approve --body "Automatically approved by GitHub Action" + done diff --git a/.nvmrc b/.nvmrc index f203ab89b..48b14e6b2 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.13.1 +20.14.0 diff --git a/.vscode/settings.json b/.vscode/settings.json index 53c760db0..b9a963aad 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,6 +10,7 @@ "cSpell.words": [ "superjson", "homarr", - "trpc" + "trpc", + "Umami" ] } \ No newline at end of file diff --git a/README.md b/README.md index 2eb9f8222..3a88af411 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # THIS PROJECT IS STILL UNSTABLE AND WE DO NOT PROVIDE ANY SUPPORT FOR ISSUES THAT OCCURE. + ## PLEASE DO NOT OPEN ANY ISSUES OR DISCUSSIONS + ### EVERYTHING IS SUBJECT TO CHANGE Please use [this](https://github.com/ajnart/homarr) version of Homarr when you want to use it diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 5c78e7643..d82c1decd 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -14,6 +14,7 @@ "with-env": "dotenv -e ../../.env --" }, "dependencies": { + "@homarr/analytics": "workspace:^0.1.0", "@homarr/api": "workspace:^0.1.0", "@homarr/auth": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0", @@ -21,6 +22,7 @@ "@homarr/definitions": "workspace:^0.1.0", "@homarr/form": "workspace:^0.1.0", "@homarr/gridstack": "^1.0.0", + "@homarr/integrations": "workspace:^0.1.0", "@homarr/log": "workspace:^", "@homarr/modals": "workspace:^0.1.0", "@homarr/notifications": "workspace:^0.1.0", @@ -29,16 +31,16 @@ "@homarr/ui": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0", "@homarr/widgets": "workspace:^0.1.0", - "@mantine/colors-generator": "^7.9.2", - "@mantine/hooks": "^7.9.2", - "@mantine/modals": "^7.9.2", - "@mantine/tiptap": "^7.9.2", + "@mantine/colors-generator": "^7.10.0", + "@mantine/hooks": "^7.10.0", + "@mantine/modals": "^7.10.0", + "@mantine/tiptap": "^7.10.0", "@homarr/server-settings": "workspace:^0.1.0", "@t3-oss/env-nextjs": "^0.10.1", - "@tanstack/react-query": "^5.37.1", - "@tanstack/react-query-devtools": "^5.37.1", - "@tanstack/react-query-next-experimental": "5.37.1", - "@trpc/client": "11.0.0-rc.374", + "@tanstack/react-query": "^5.40.0", + "@tanstack/react-query-devtools": "^5.40.0", + "@tanstack/react-query-next-experimental": "5.40.0", + "@trpc/client": "11.0.0-rc.377", "@trpc/next": "next", "@trpc/react-query": "next", "@trpc/server": "next", @@ -49,12 +51,13 @@ "dayjs": "^1.11.11", "dotenv": "^16.4.5", "flag-icons": "^7.2.2", - "glob": "^10.3.15", - "jotai": "^2.8.1", + "glob": "^10.4.1", + "jotai": "^2.8.2", "next": "^14.2.3", "postcss-preset-mantine": "^1.15.0", "react": "18.3.1", "react-dom": "18.3.1", + "react-error-boundary": "^4.0.13", "sass": "^1.77.2", "superjson": "2.2.1", "use-deep-compare-effect": "^1.8.1" @@ -65,12 +68,12 @@ "@homarr/tsconfig": "workspace:^0.1.0", "@types/chroma-js": "2.4.4", "@types/node": "^20.12.12", - "@types/react": "^18.3.2", + "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "concurrently": "^8.2.2", "eslint": "^8.57.0", "prettier": "^3.2.5", - "tsx": "4.10.5", + "tsx": "4.11.0", "typescript": "^5.4.5" }, "eslintConfig": { diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index 35414e252..86db1c66c 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -11,6 +11,7 @@ import { auth } from "@homarr/auth/next"; import { ModalProvider } from "@homarr/modals"; import { Notifications } from "@homarr/notifications"; +import { Analytics } from "~/components/layout/analytics"; import { JotaiProvider } from "./_client-providers/jotai"; import { NextInternationalProvider } from "./_client-providers/next-international"; import { AuthProvider } from "./_client-providers/session"; @@ -76,6 +77,7 @@ export default function Layout(props: { children: React.ReactNode; params: { loc + diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/analytics.settings.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/analytics.settings.tsx index dfe86b363..7ac93b917 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/analytics.settings.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/analytics.settings.tsx @@ -96,18 +96,23 @@ const SwitchSetting = ({ title: string; text: ReactNode; }) => { + const disabled = formKey !== "enableGeneral" && !form.values.enableGeneral; const handleClick = React.useCallback(() => { + if (disabled) { + return; + } form.setFieldValue(formKey, !form.values[formKey]); - }, [form, formKey]); + }, [form, formKey, disabled]); + return ( - - + + {title} {text} - - - + + + ); }; diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx index 9a2a1f100..d38c868a2 100644 --- a/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx +++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx @@ -3,21 +3,24 @@ import { useCallback, useMemo, useState } from "react"; import { ActionIcon, Affix, Card } from "@mantine/core"; import { IconDimensions, IconPencil, IconToggleLeft, IconToggleRight } from "@tabler/icons-react"; +import { QueryErrorResetBoundary } from "@tanstack/react-query"; +import { ErrorBoundary } from "react-error-boundary"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; import { useModalAction } from "@homarr/modals"; import { showSuccessNotification } from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; -import type { BoardItemAdvancedOptions, BoardItemIntegration } from "@homarr/validation"; +import type { BoardItemAdvancedOptions } from "@homarr/validation"; import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, WidgetEditModal, widgetImports, } from "@homarr/widgets"; +import { WidgetError } from "@homarr/widgets/errors"; -import { PreviewDimensionsModal } from "./_dimension-modal"; import type { Dimensions } from "./_dimension-modal"; +import { PreviewDimensionsModal } from "./_dimension-modal"; interface WidgetPreviewPageContentProps { kind: WidgetKind; @@ -41,11 +44,11 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie }); const [state, setState] = useState<{ options: Record; - integrations: BoardItemIntegration[]; + integrationIds: string[]; advancedOptions: BoardItemAdvancedOptions; }>({ options: reduceWidgetOptionsWithDefaultValues(kind, {}), - integrations: [], + integrationIds: [], advancedOptions: { customCssClasses: [], }, @@ -86,17 +89,26 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie return ( <> = 96 ? undefined : 4}> - integrationData.find((integration) => integration.id === stateIntegration.id)!, + + {({ reset }) => ( + ( + + )} + > + + )} - width={dimensions.width} - height={dimensions.height} - isEditMode={editMode} - boardId={undefined} - itemId={undefined} - /> + diff --git a/apps/nextjs/src/components/board/items/item-actions.tsx b/apps/nextjs/src/components/board/items/item-actions.tsx index 309dab8bd..2246853ea 100644 --- a/apps/nextjs/src/components/board/items/item-actions.tsx +++ b/apps/nextjs/src/components/board/items/item-actions.tsx @@ -2,7 +2,7 @@ import { useCallback } from "react"; import { createId } from "@homarr/db/client"; import type { WidgetKind } from "@homarr/definitions"; -import type { BoardItemAdvancedOptions, BoardItemIntegration } from "@homarr/validation"; +import type { BoardItemAdvancedOptions } from "@homarr/validation"; import type { EmptySection, Item } from "~/app/[locale]/boards/_types"; import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client"; @@ -38,7 +38,7 @@ interface UpdateItemAdvancedOptions { interface UpdateItemIntegrations { itemId: string; - newIntegrations: BoardItemIntegration[]; + newIntegrations: string[]; } interface CreateItem { @@ -63,7 +63,7 @@ export const useItemActions = () => { options: {}, width: 1, height: 1, - integrations: [], + integrationIds: [], advancedOptions: { customCssClasses: [], }, @@ -157,7 +157,7 @@ export const useItemActions = () => { if (item.id !== itemId) return item; return { ...item, - ...("integrations" in item ? { integrations: newIntegrations } : {}), + ...("integrationIds" in item ? { integrationIds: newIntegrations } : {}), }; }), }; diff --git a/apps/nextjs/src/components/board/sections/content.tsx b/apps/nextjs/src/components/board/sections/content.tsx index cdd71266f..487207eb4 100644 --- a/apps/nextjs/src/components/board/sections/content.tsx +++ b/apps/nextjs/src/components/board/sections/content.tsx @@ -2,11 +2,13 @@ // Ignored because of gridstack attributes import type { RefObject } from "react"; -import { useMemo } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { ActionIcon, Card, Menu } from "@mantine/core"; import { useElementSize } from "@mantine/hooks"; import { IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react"; +import { QueryErrorResetBoundary } from "@tanstack/react-query"; import combineClasses from "clsx"; +import { ErrorBoundary } from "react-error-boundary"; import { clientApi } from "@homarr/api/client"; import { useConfirmModal, useModalAction } from "@homarr/modals"; @@ -18,6 +20,7 @@ import { WidgetEditModal, widgetImports, } from "@homarr/widgets"; +import { WidgetError } from "@homarr/widgets/errors"; import type { Item } from "~/app/[locale]/boards/_types"; import { useEditMode, useRequiredBoard } from "~/app/[locale]/boards/(content)/_context"; @@ -104,22 +107,43 @@ const BoardItemContent = ({ item, ...dimensions }: ItemContentProps) => { if (!serverData?.isReady) return null; return ( - <> - - - + + {({ reset }) => ( + ( + <> + + + + )} + > + + + + )} + ); }; -const ItemMenu = ({ offset, item }: { offset: number; item: Item }) => { +const ItemMenu = ({ + offset, + item, + resetErrorBoundary, +}: { + offset: number; + item: Item; + resetErrorBoundary?: () => void; +}) => { + const refResetErrorBoundaryOnNextRender = useRef(false); const tItem = useScopedI18n("item"); const t = useI18n(); const { openModal } = useModalAction(WidgetEditModal); @@ -129,6 +153,14 @@ const ItemMenu = ({ offset, item }: { offset: number; item: Item }) => { const { data: integrationData, isPending } = clientApi.integration.all.useQuery(); const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]); + // Reset error boundary on next render if item has been edited + useEffect(() => { + if (refResetErrorBoundaryOnNextRender.current) { + resetErrorBoundary?.(); + refResetErrorBoundaryOnNextRender.current = false; + } + }, [item, resetErrorBoundary]); + if (!isEditMode || isPending) return null; const openEditModal = () => { @@ -137,9 +169,9 @@ const ItemMenu = ({ offset, item }: { offset: number; item: Item }) => { value: { advancedOptions: item.advancedOptions, options: item.options, - integrations: item.integrations, + integrationIds: item.integrationIds, }, - onSuccessfulEdit: ({ options, integrations, advancedOptions }) => { + onSuccessfulEdit: ({ options, integrationIds, advancedOptions }) => { updateItemOptions({ itemId: item.id, newOptions: options, @@ -150,8 +182,9 @@ const ItemMenu = ({ offset, item }: { offset: number; item: Item }) => { }); updateItemIntegrations({ itemId: item.id, - newIntegrations: integrations, + newIntegrations: integrationIds, }); + refResetErrorBoundaryOnNextRender.current = true; }, integrationData: (integrationData ?? []).filter( (integration) => diff --git a/apps/nextjs/src/components/layout/analytics.tsx b/apps/nextjs/src/components/layout/analytics.tsx new file mode 100644 index 000000000..322d7a0a6 --- /dev/null +++ b/apps/nextjs/src/components/layout/analytics.tsx @@ -0,0 +1,15 @@ +import Script from "next/script"; + +import { UMAMI_WEBSITE_ID } from "@homarr/analytics"; +import { api } from "@homarr/api/server"; + +export const Analytics = async () => { + // For static pages it will not find any analytics data so we do not include the script on them + const analytics = await api.serverSettings.getAnalytics().catch(() => null); + + if (analytics?.enableGeneral) { + return