From 81ea41272175a89bc22d81b4c9b1b3a15720f6a6 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 21 Aug 2026 00:25:25 +0200 Subject: [PATCH] refactor(board): restore classic Mantine Menu for board add actions and remove universalCreate --- .../boards/(content)/_edit-actions.tsx | 94 ++++--- .../src/components/create/setup-analytics.ts | 2 +- .../create/universal-create-actions.spec.ts | 77 ------ .../create/universal-create-actions.ts | 120 --------- .../universal-create-modal.module.d.css.ts | 5 - .../create/universal-create-modal.tsx | 246 ------------------ .../create/universal-create-spotlight.tsx | 35 --- .../create/universal-create-trigger.tsx | 49 ---- apps/nextjs/src/components/layout/header.tsx | 5 - packages/translation/src/lang/en.json | 55 ---- 10 files changed, 60 insertions(+), 628 deletions(-) delete mode 100644 apps/nextjs/src/components/create/universal-create-actions.spec.ts delete mode 100644 apps/nextjs/src/components/create/universal-create-actions.ts delete mode 100644 apps/nextjs/src/components/create/universal-create-modal.module.d.css.ts delete mode 100644 apps/nextjs/src/components/create/universal-create-modal.tsx delete mode 100644 apps/nextjs/src/components/create/universal-create-spotlight.tsx delete mode 100644 apps/nextjs/src/components/create/universal-create-trigger.tsx diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_edit-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_edit-actions.tsx index 87eda8813..3723ea9d3 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_edit-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_edit-actions.tsx @@ -1,15 +1,18 @@ "use client"; -import { useEffect, useRef } from "react"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { IconPlus } from "@tabler/icons-react"; +import { Group, Menu } from "@mantine/core"; +import { IconBox, IconChevronDown, IconLayoutGridAdd, IconPlug, IconPlus, IconResize } from "@tabler/icons-react"; +import { useSession } from "@homarr/auth/client"; import { useRequiredBoard } from "@homarr/boards/context"; import { useModalAction } from "@homarr/modals"; +import { AppSelectModal } from "@homarr/modals-collection"; import { useI18n } from "@homarr/translation/client"; -import { useBoardAddActions } from "~/components/board/use-board-add-actions"; -import { UniversalCreateModal } from "~/components/create/universal-create-modal"; +import { useItemActions } from "~/components/board/items/item-actions"; +import { ItemSelectModal } from "~/components/board/items/item-select-modal"; +import { useContainerActions } from "~/components/board/sections/container/container-actions"; +import { IntegrationSelectModal } from "~/components/integration/integration-select-modal"; import { HeaderButton } from "~/components/layout/header/button"; export default function BoardEditActions() { @@ -17,41 +20,62 @@ export default function BoardEditActions() { } const AddMenu = () => { + const { data: session } = useSession(); const board = useRequiredBoard(); - const { addWidget, addApp, connectService, addContainer, canConnectService } = useBoardAddActions(); - const { openModal } = useModalAction(UniversalCreateModal); + const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal); + const { openModal: openAppSelectModal } = useModalAction(AppSelectModal); + const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal); + const { addContainer } = useContainerActions(); + const { createItem } = useItemActions(); const t = useI18n(); - const pathname = usePathname(); - const router = useRouter(); - const searchParams = useSearchParams(); - const handledInitialAdd = useRef(false); - useEffect(() => { - if (handledInitialAdd.current || searchParams.get("add") !== "true") return; - handledInitialAdd.current = true; - addWidget(); + const handleSelectItem = () => { + openItemSelectModal({ boardId: board.id }); + }; - const nextSearchParams = new URLSearchParams(searchParams.toString()); - nextSearchParams.delete("add"); - const query = nextSearchParams.toString(); - router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false }); - }, [addWidget, board.id, pathname, router, searchParams]); + const handleSelectApp = () => { + openAppSelectModal({ + onSelect: (app) => { + createItem({ + kind: "app", + options: { appId: app.id }, + }); + }, + withCreate: session?.user.permissions.includes("app-create") ?? false, + }); + }; + + const handleAddIntegration = () => { + openIntegrationSelectModal({}); + }; return ( - - openModal({ - boardActions: { - widget: addWidget, - app: addApp, - integration: canConnectService ? connectService : undefined, - container: addContainer, - }, - }) - } - > - - + + + + + + + + + + + } onClick={handleSelectItem}> + {t("item.action.create")} + + } onClick={handleSelectApp}> + {t("app.action.add")} + + {session?.user.permissions.includes("integration-create") && ( + } onClick={handleAddIntegration}> + {t("integration.action.create")} + + )} + + } onClick={addContainer}> + {t("section.container.action.create")} + + + ); }; diff --git a/apps/nextjs/src/components/create/setup-analytics.ts b/apps/nextjs/src/components/create/setup-analytics.ts index 5a7e12815..caacb89d7 100644 --- a/apps/nextjs/src/components/create/setup-analytics.ts +++ b/apps/nextjs/src/components/create/setup-analytics.ts @@ -37,7 +37,7 @@ export const useSetupAnalytics = () => { onError() { showErrorNotification({ title: t("common.error"), - message: t("universalCreate.analyticsError"), + message: t("common.error"), }); }, }); diff --git a/apps/nextjs/src/components/create/universal-create-actions.spec.ts b/apps/nextjs/src/components/create/universal-create-actions.spec.ts deleted file mode 100644 index 60e323a4b..000000000 --- a/apps/nextjs/src/components/create/universal-create-actions.spec.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, test } from "vitest"; - -import type { RankedUniversalCreateAction } from "./universal-create-actions"; -import { filterAndRankUniversalCreateActions, getUniversalCreateActionDefinitions } from "./universal-create-actions"; - -describe("getUniversalCreateActionDefinitions", () => { - test("only exposes actions allowed without a board context", () => { - const actions = getUniversalCreateActionDefinitions({ - hasBoardContext: false, - permissions: ["board-create", "integration-create"], - }); - - expect(actions.map(({ key }) => key)).toEqual(["integration", "board"]); - }); - - test("places globally-created apps in the library group", () => { - const actions = getUniversalCreateActionDefinitions({ hasBoardContext: false, permissions: ["app-create"] }); - - expect(actions).toEqual([expect.objectContaining({ key: "app", group: "library" })]); - }); - - test("keeps board content actions available without global create permissions", () => { - const actions = getUniversalCreateActionDefinitions({ hasBoardContext: true, permissions: [] }); - - expect(actions.map(({ key }) => key)).toEqual(["widget", "app", "container"]); - }); - - test("exposes the complete surface to administrators on a board", () => { - const actions = getUniversalCreateActionDefinitions({ - hasBoardContext: true, - permissions: ["admin", "app-create", "integration-create", "board-create"], - }); - - expect(actions.map(({ key }) => key)).toEqual([ - "widget", - "app", - "integration", - "container", - "board", - "workshop", - "customWidget", - ]); - }); -}); - -describe("filterAndRankUniversalCreateActions", () => { - const actions = [ - { key: "widget", group: "currentBoard", priority: 100, name: "Add widget", description: "Add content" }, - { - key: "integration", - group: "library", - priority: 80, - name: "Connect service", - description: "Create an integration", - keywords: ["provider"], - }, - { key: "board", group: "boards", priority: 60, name: "Create board", description: "Start a dashboard" }, - ] satisfies RankedUniversalCreateAction[]; - - test("uses product priority when no search is entered", () => { - expect(filterAndRankUniversalCreateActions(actions, "").map(({ key }) => key)).toEqual([ - "widget", - "integration", - "board", - ]); - }); - - test("ranks a name prefix ahead of a description or keyword match", () => { - const results = filterAndRankUniversalCreateActions(actions, "create"); - - expect(results.map(({ key }) => key)).toEqual(["board", "integration"]); - }); - - test("matches keywords and removes unrelated actions", () => { - expect(filterAndRankUniversalCreateActions(actions, "provider").map(({ key }) => key)).toEqual(["integration"]); - }); -}); diff --git a/apps/nextjs/src/components/create/universal-create-actions.ts b/apps/nextjs/src/components/create/universal-create-actions.ts deleted file mode 100644 index 7f9d37643..000000000 --- a/apps/nextjs/src/components/create/universal-create-actions.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { GroupPermissionKey } from "@homarr/definitions"; - -export const universalCreateActionKeys = [ - "widget", - "app", - "integration", - "container", - "board", - "workshop", - "customWidget", -] as const; - -export type UniversalCreateActionKey = (typeof universalCreateActionKeys)[number]; -export type UniversalCreateActionGroup = "currentBoard" | "library" | "boards"; - -export interface UniversalCreateContext { - hasBoardContext: boolean; - permissions: readonly GroupPermissionKey[]; -} - -interface UniversalCreateActionDefinition { - key: UniversalCreateActionKey; - group: UniversalCreateActionGroup; - priority: number; - isVisible: (context: UniversalCreateContext) => boolean; -} - -const hasPermission = (context: UniversalCreateContext, permission: GroupPermissionKey) => - context.permissions.includes(permission); - -const definitions: readonly UniversalCreateActionDefinition[] = [ - { - key: "widget", - group: "currentBoard", - priority: 100, - isVisible: ({ hasBoardContext }) => hasBoardContext, - }, - { - key: "app", - group: "currentBoard", - priority: 90, - isVisible: (context) => context.hasBoardContext || hasPermission(context, "app-create"), - }, - { - key: "integration", - group: "library", - priority: 80, - isVisible: (context) => hasPermission(context, "integration-create"), - }, - { - key: "container", - group: "currentBoard", - priority: 70, - isVisible: ({ hasBoardContext }) => hasBoardContext, - }, - { - key: "board", - group: "boards", - priority: 60, - isVisible: (context) => hasPermission(context, "board-create"), - }, - { - key: "workshop", - group: "library", - priority: 40, - isVisible: (context) => hasPermission(context, "admin"), - }, - { - key: "customWidget", - group: "library", - priority: 30, - isVisible: (context) => hasPermission(context, "admin"), - }, -]; - -export interface RankedUniversalCreateAction { - key: UniversalCreateActionKey; - group: UniversalCreateActionGroup; - priority: number; - name: string; - description: string; - keywords?: readonly string[]; -} - -export const getUniversalCreateActionDefinitions = (context: UniversalCreateContext) => - definitions - .filter((definition) => definition.isVisible(context)) - .map((definition) => - definition.key === "app" && !context.hasBoardContext ? { ...definition, group: "library" as const } : definition, - ); - -const normalize = (value: string) => value.trim().toLocaleLowerCase(); - -export const filterAndRankUniversalCreateActions = ( - actions: readonly TAction[], - query: string, -) => { - const normalizedQuery = normalize(query); - - return actions - .map((action) => { - const name = normalize(action.name); - const searchableText = normalize([action.name, action.description, ...(action.keywords ?? [])].join(" ")); - const relevance = - normalizedQuery.length === 0 - ? 0 - : name === normalizedQuery - ? 300 - : name.startsWith(normalizedQuery) - ? 200 - : searchableText.includes(normalizedQuery) - ? 100 - : -1; - - return { action, relevance }; - }) - .filter(({ relevance }) => relevance >= 0) - .toSorted((left, right) => right.relevance - left.relevance || right.action.priority - left.action.priority) - .map(({ action }) => action); -}; diff --git a/apps/nextjs/src/components/create/universal-create-modal.module.d.css.ts b/apps/nextjs/src/components/create/universal-create-modal.module.d.css.ts deleted file mode 100644 index 5de57c8f7..000000000 --- a/apps/nextjs/src/components/create/universal-create-modal.module.d.css.ts +++ /dev/null @@ -1,5 +0,0 @@ -declare const classes: { - readonly action: string; - readonly actionIcon: string; -}; -export default classes; diff --git a/apps/nextjs/src/components/create/universal-create-modal.tsx b/apps/nextjs/src/components/create/universal-create-modal.tsx deleted file mode 100644 index ab66845e2..000000000 --- a/apps/nextjs/src/components/create/universal-create-modal.tsx +++ /dev/null @@ -1,246 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useRef, useState } from "react"; -import { useRouter } from "next/navigation"; -import { Badge, Center, Divider, Input, ScrollArea, SimpleGrid, Stack, Text, ThemeIcon } from "@mantine/core"; -import { - IconApps, - IconBox, - IconBuildingStore, - IconCode, - IconLayoutDashboard, - IconLayoutGridAdd, - IconPlugConnected, - IconSearch, -} from "@tabler/icons-react"; - -import { useSession } from "@homarr/auth/client"; -import type { GroupPermissionKey } from "@homarr/definitions"; -import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals"; -import { AddBoardModal } from "@homarr/modals-collection"; -import { useScopedI18n } from "@homarr/translation/client"; -import { SelectableCard } from "@homarr/ui"; -import type { TablerIcon } from "@homarr/ui"; - -import type { - RankedUniversalCreateAction, - UniversalCreateActionGroup, - UniversalCreateActionKey, -} from "./universal-create-actions"; -import { filterAndRankUniversalCreateActions, getUniversalCreateActionDefinitions } from "./universal-create-actions"; -import type { SetupMetricEntryPoint } from "./setup-analytics"; -import { useSetupAnalytics } from "./setup-analytics"; - -type BoardActionKey = "widget" | "app" | "integration" | "container"; - -export interface UniversalCreateModalProps { - boardActions?: Partial void>>; - entryPoint?: SetupMetricEntryPoint; -} - -const actionIcons = { - widget: IconApps, - app: IconBox, - integration: IconPlugConnected, - container: IconLayoutGridAdd, - board: IconLayoutDashboard, - workshop: IconBuildingStore, - customWidget: IconCode, -} satisfies Record; - -const groupColors: Record = { - currentBoard: "primaryColor", - library: "cyan", - boards: "teal", -}; - -const selectGridCols = { base: 1, sm: 2, md: 3 }; - -export const UniversalCreateModal = createModal(({ actions, innerProps }) => { - const [search, setSearch] = useState(""); - const { data: session } = useSession(); - const router = useRouter(); - const t = useScopedI18n("universalCreate"); - const { openModal: openAddBoardModal } = useModalAction(AddBoardModal); - const trackedOpen = useRef(false); - const boardActions = innerProps.boardActions; - const entryPoint = innerProps.entryPoint ?? (boardActions ? "board" : "header"); - const trackSetup = useSetupAnalytics(); - const permissions = (session?.user.permissions ?? []) as GroupPermissionKey[]; - const definitions = getUniversalCreateActionDefinitions({ - hasBoardContext: boardActions !== undefined, - permissions, - }); - - useEffect(() => { - if (trackedOpen.current) return; - trackedOpen.current = true; - trackSetup("surface-opened", { - entryPoint, - hasBoardContext: boardActions !== undefined, - }); - }, [boardActions, entryPoint, trackSetup]); - - const invokeAction = (key: UniversalCreateActionKey) => { - const boardAction = key in (boardActions ?? {}) ? boardActions?.[key as BoardActionKey] : undefined; - actions.closeModal(); - trackSetup("intent-selected", { - entryPoint, - intent: key, - outcome: "continued", - hasBoardContext: boardActions !== undefined, - }); - - if (boardAction) { - boardAction(); - return; - } - - switch (key) { - case "app": - router.push("/manage/apps/new"); - break; - case "integration": - router.push("/manage/integrations/new"); - break; - case "board": - openAddBoardModal(undefined); - break; - case "workshop": - router.push("/manage/custom-widgets/workshop"); - break; - case "customWidget": - router.push("/manage/custom-widgets/new"); - break; - case "widget": - case "container": - break; - } - }; - - const resolvedActions = useMemo( - () => - definitions.map( - (definition) => - ({ - ...definition, - name: t(`action.${definition.key}.label`), - description: t(`action.${definition.key}.description`), - keywords: t(`action.${definition.key}.keywords`).split(" "), - }) satisfies RankedUniversalCreateAction, - ), - [definitions, t], - ); - - const filteredActions = useMemo( - () => filterAndRankUniversalCreateActions(resolvedActions, search), - [resolvedActions, search], - ); - - const isSearching = search.trim().length > 0; - const groups = ["currentBoard", "library", "boards"] satisfies UniversalCreateActionGroup[]; - - return ( - - {/* Top Search Input */} - setSearch(event.currentTarget.value)} - leftSection={} - placeholder={t("search.placeholder")} - aria-label={t("search.label")} - data-autofocus - onKeyDown={(event) => { - if (event.key === "Enter" && filteredActions.length === 1 && filteredActions[0]) { - invokeAction(filteredActions[0].key); - } - }} - /> - - {/* Action Cards Grid */} - - - {isSearching ? ( - - {filteredActions.map((action) => { - const Icon = actionIcons[action.key]; - return ( - invokeAction(action.key)} - aria-label={action.name} - icon={ - - - - } - title={action.name} - topRight={ - - {t(`group.${action.group}`)} - - } - description={action.description} - /> - ); - })} - - ) : ( - groups.map((group) => { - const groupActions = resolvedActions - .filter((action) => action.group === group) - .toSorted((left, right) => right.priority - left.priority); - if (groupActions.length === 0) return null; - - return ( - - - {t(`group.${group}`)} - - } - labelPosition="left" - /> - - {groupActions.map((action) => { - const Icon = actionIcons[action.key]; - return ( - invokeAction(action.key)} - aria-label={action.name} - icon={ - - - - } - title={action.name} - topRight={ - - {t(`group.${action.group}`)} - - } - description={action.description} - /> - ); - })} - - - ); - }) - )} - - {filteredActions.length === 0 && ( -
- {t("search.noResults")} -
- )} -
-
-
- ); -}).withOptions({ - defaultTitle: (t) => t("universalCreate.title"), - size: modalSizeSelect, -}); diff --git a/apps/nextjs/src/components/create/universal-create-spotlight.tsx b/apps/nextjs/src/components/create/universal-create-spotlight.tsx deleted file mode 100644 index 35832a64e..000000000 --- a/apps/nextjs/src/components/create/universal-create-spotlight.tsx +++ /dev/null @@ -1,35 +0,0 @@ -"use client"; - -import { useMemo } from "react"; -import { IconPlus } from "@tabler/icons-react"; - -import { useModalAction } from "@homarr/modals"; -import { useRegisterSpotlightContextResults } from "@homarr/spotlight"; -import { useScopedI18n } from "@homarr/translation/client"; - -import { UniversalCreateModal } from "./universal-create-modal"; - -/** Keeps Universal Create available from ordinary Spotlight search, without requiring command mode. */ -export const UniversalCreateSpotlightResult = () => { - const t = useScopedI18n("universalCreate"); - const { openModal } = useModalAction(UniversalCreateModal); - const item = useMemo( - () => ({ - id: "universal-create", - name: t("title"), - description: t("trigger.description"), - icon: IconPlus, - alwaysVisible: true, - interaction: () => ({ - type: "javaScript" as const, - onSelect: () => { - openModal({ entryPoint: "spotlight" }); - }, - }), - }), - [openModal, t], - ); - - useRegisterSpotlightContextResults("universal-create", [item], [item]); - return null; -}; diff --git a/apps/nextjs/src/components/create/universal-create-trigger.tsx b/apps/nextjs/src/components/create/universal-create-trigger.tsx deleted file mode 100644 index 3bf2d4269..000000000 --- a/apps/nextjs/src/components/create/universal-create-trigger.tsx +++ /dev/null @@ -1,49 +0,0 @@ -"use client"; - -import { useEffect, useRef } from "react"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { IconPlus } from "@tabler/icons-react"; - -import { useModalAction } from "@homarr/modals"; -import { useScopedI18n } from "@homarr/translation/client"; - -import { HeaderButton } from "~/components/layout/header/button"; -import { UniversalCreateModal } from "./universal-create-modal"; - -export const UniversalCreateHeaderAction = () => { - const t = useScopedI18n("universalCreate"); - const { openModal } = useModalAction(UniversalCreateModal); - - return ( - openModal({ entryPoint: "header" })} aria-label={t("trigger.label")}> - - - ); -}; - -export const UniversalCreateQueryGate = () => { - const pathname = usePathname(); - const router = useRouter(); - const searchParams = useSearchParams(); - const { openModal } = useModalAction(UniversalCreateModal); - const handled = useRef(false); - const shouldOpen = searchParams.get("create") === "true"; - - useEffect(() => { - if (!shouldOpen) { - handled.current = false; - return; - } - if (handled.current) return; - - handled.current = true; - openModal({ entryPoint: "spotlight" }); - - const nextSearchParams = new URLSearchParams(searchParams.toString()); - nextSearchParams.delete("create"); - const query = nextSearchParams.toString(); - router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false }); - }, [openModal, pathname, router, searchParams, shouldOpen]); - - return null; -}; diff --git a/apps/nextjs/src/components/layout/header.tsx b/apps/nextjs/src/components/layout/header.tsx index 74e52d5b8..2ad377576 100644 --- a/apps/nextjs/src/components/layout/header.tsx +++ b/apps/nextjs/src/components/layout/header.tsx @@ -3,8 +3,6 @@ import { AppShellHeader, Group, UnstyledButton } from "@mantine/core"; import { Link } from "@homarr/ui"; -import { UniversalCreateHeaderAction, UniversalCreateQueryGate } from "~/components/create/universal-create-trigger"; -import { UniversalCreateSpotlightResult } from "~/components/create/universal-create-spotlight"; import { ClientBurger } from "./header/burger"; import { DesktopSearchInput, MobileSearchButton } from "./header/search"; import { TourTarget } from "./header/tour-target"; @@ -38,7 +36,6 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => { - {hasNavigation && } {actions} @@ -46,8 +43,6 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => { - - ); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index fbfc0bb5c..3611ce4c4 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -236,61 +236,6 @@ } } }, - "universalCreate": { - "title": "Create", - "analyticsError": "Usage metrics could not be recorded. Your action was not affected.", - "trigger": { - "label": "Create something", - "description": "Add content, connect a service, or create a board from one place." - }, - "search": { - "label": "Search create actions", - "placeholder": "What do you want to create?", - "noResults": "No matching actions" - }, - "group": { - "currentBoard": "Current board", - "library": "Apps and services", - "boards": "Boards" - }, - "action": { - "widget": { - "label": "Add widget", - "description": "Add live information or controls to this board.", - "keywords": "item dashboard content" - }, - "app": { - "label": "Add or create app", - "description": "Place an app shortcut on this board or create one for your library.", - "keywords": "shortcut bookmark application" - }, - "integration": { - "label": "Connect service", - "description": "Connect a service so widgets can use its data.", - "keywords": "integration provider connection" - }, - "container": { - "label": "Add container", - "description": "Group related board content into a resizable section.", - "keywords": "section group layout" - }, - "board": { - "label": "Create board", - "description": "Start a new dashboard and add its first widget.", - "keywords": "dashboard page" - }, - "workshop": { - "label": "Browse Workshop", - "description": "Discover community-built widgets and install them.", - "keywords": "community discover install" - }, - "customWidget": { - "label": "Create custom widget", - "description": "Build a widget tailored to your own workflow.", - "keywords": "code jsx developer" - } - } - }, "user": { "title": "Users", "name": "User",