From 7a5edb26c161bf27ba1b9a3d5c9e22cec17d5a01 Mon Sep 17 00:00:00 2001 From: ajnart Date: Mon, 24 Aug 2026 17:29:31 +0200 Subject: [PATCH] feat: reduce disruptive UI confirmations --- apps/docs/docs/management/api/index.mdx | 3 + apps/docs/docs/management/boards/index.mdx | 12 +- .../docs/management/certificates/index.mdx | 11 +- .../docs/management/custom-widgets/index.mdx | 3 +- apps/docs/docs/management/users/index.mdx | 10 +- .../boards/[name]/settings/_danger.tsx | 166 +++--- .../boards/[name]/settings/_layout.tsx | 45 +- .../manage/apps/_app-delete-button.tsx | 58 +- .../_components/board-card-menu-dropdown.tsx | 46 +- .../manage/boards/_components/board-card.tsx | 133 +++++ .../_components/create-board-button.tsx | 54 +- .../_components/duplicate-board-form.tsx | 53 +- .../src/app/[locale]/manage/boards/page.tsx | 147 +----- .../custom-widgets/_custom-widget-actions.tsx | 52 +- .../workshop/[id]/_workshop-detail.tsx | 18 +- .../test-connection-certificate.tsx | 156 +++--- .../integrations/_integration-buttons.tsx | 59 +-- .../manage/medias/_actions/delete-media.tsx | 31 +- .../_search-engine-delete-button.tsx | 58 +- .../manage/tools/api/components/api-keys.tsx | 106 ++-- .../tools/api/components/api-page-tabs.tsx | 20 +- .../api/components/copy-api-key-modal.tsx | 34 -- .../tools/api/components/mcp-instructions.tsx | 25 +- .../api/components/new-api-key-alert.tsx | 50 ++ .../app/[locale]/manage/tools/api/page.tsx | 4 +- .../_components/add-certificate.tsx | 40 +- .../_components/certificate-upload-form.tsx | 110 ++++ .../_components/remove-certificate.tsx | 61 ++- .../hostnames/_components/remove-hostname.tsx | 51 +- .../manage/tools/certificates/page.tsx | 5 +- .../_components/_delete-user-button.tsx | 25 +- .../_components/_profile-avatar-form.tsx | 59 +-- .../_components/create-user-stepper.tsx | 45 +- .../users/groups/[id]/_delete-group.tsx | 76 ++- .../groups/[id]/_transfer-group-ownership.tsx | 113 ++-- .../groups/[id]/members/_add-group-member.tsx | 44 +- .../[id]/members/_remove-group-member.tsx | 36 +- .../[locale]/manage/users/groups/_client.tsx | 62 ++- .../groups/_components/group-create-form.tsx | 61 +++ .../app/[locale]/manage/users/groups/page.tsx | 4 +- .../_components/invite-create-button.tsx | 7 +- .../_components/invite-create-form.tsx | 138 +++++ .../users/invites/_components/invite-list.tsx | 76 ++- .../[locale]/manage/users/invites/page.tsx | 12 +- .../app/[locale]/widgets/[kind]/_content.tsx | 36 +- .../widgets/[kind]/_dimension-modal.tsx | 50 -- .../widgets/[kind]/_dimension-popover.tsx | 78 +++ .../src/components/access/access-picker.tsx | 130 +++++ .../components/access/group-access-form.tsx | 40 +- .../components/access/group-select-modal.tsx | 73 --- .../src/components/access/group-select.tsx | 66 +++ .../components/access/user-access-form.tsx | 44 +- .../components/access/user-select-modal.tsx | 103 ---- .../src/components/access/user-select.tsx | 75 +++ .../components/assistant/assistant-panel.tsx | 38 +- .../src/components/board/items/item-menu.tsx | 16 +- .../board/modals/board-rename-modal.tsx | 70 --- .../sections/container/container-menu.tsx | 14 +- .../workshop/workshop-report-form.tsx | 99 ++++ .../workshop/workshop-report-modal.tsx | 80 --- package.json | 1 + .../src/board-create/board-create-form.tsx} | 107 ++-- packages/forms-collection/src/index.tsx | 2 + .../modals-collection/src/boards/index.ts | 2 - .../certificates/add-certificate-modal.tsx | 75 --- .../src/certificates/index.ts | 1 - .../src/groups/add-group-modal.tsx | 57 -- .../modals-collection/src/groups/index.ts | 1 - packages/modals-collection/src/index.ts | 4 - .../modals-collection/src/invites/index.ts | 2 - .../src/invites/invite-copy-modal.tsx | 61 --- .../src/invites/invite-create-modal.tsx | 81 --- .../src/modes/command/global-group.tsx | 35 +- packages/translation/src/lang/en.json | 1 + packages/ui/src/components/index.tsx | 1 + .../src/components/inline-confirm-action.tsx | 239 +++++++++ packages/widgets/src/_inputs/form.ts | 3 +- .../_inputs/widget-location-input.spec.tsx | 8 +- .../src/_inputs/widget-location-input.tsx | 188 +++---- .../src/dns-hole/controls/TimerModal.tsx | 106 ---- .../src/dns-hole/controls/component.tsx | 140 +++-- .../src/dns-hole/controls/timer-popover.tsx | 146 ++++++ .../modals/widget-advanced-options-modal.tsx | 66 --- .../widgets/src/modals/widget-edit-modal.tsx | 232 ++++---- packages/widgets/src/notebook/notebook.tsx | 29 +- .../browser-agent/ux-coherence-happy-paths.sh | 496 ++++++++++++++++++ 86 files changed, 3198 insertions(+), 2277 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/manage/boards/_components/board-card.tsx rename packages/modals-collection/src/boards/duplicate-board-modal.tsx => apps/nextjs/src/app/[locale]/manage/boards/_components/duplicate-board-form.tsx (62%) delete mode 100644 apps/nextjs/src/app/[locale]/manage/tools/api/components/copy-api-key-modal.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/api/components/new-api-key-alert.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/certificate-upload-form.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/users/groups/_components/group-create-form.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-create-form.tsx delete mode 100644 apps/nextjs/src/app/[locale]/widgets/[kind]/_dimension-modal.tsx create mode 100644 apps/nextjs/src/app/[locale]/widgets/[kind]/_dimension-popover.tsx create mode 100644 apps/nextjs/src/components/access/access-picker.tsx delete mode 100644 apps/nextjs/src/components/access/group-select-modal.tsx create mode 100644 apps/nextjs/src/components/access/group-select.tsx delete mode 100644 apps/nextjs/src/components/access/user-select-modal.tsx create mode 100644 apps/nextjs/src/components/access/user-select.tsx delete mode 100644 apps/nextjs/src/components/board/modals/board-rename-modal.tsx create mode 100644 apps/nextjs/src/components/workshop/workshop-report-form.tsx delete mode 100644 apps/nextjs/src/components/workshop/workshop-report-modal.tsx rename packages/{modals-collection/src/boards/add-board-modal.tsx => forms-collection/src/board-create/board-create-form.tsx} (61%) delete mode 100644 packages/modals-collection/src/boards/index.ts delete mode 100644 packages/modals-collection/src/certificates/add-certificate-modal.tsx delete mode 100644 packages/modals-collection/src/certificates/index.ts delete mode 100644 packages/modals-collection/src/groups/add-group-modal.tsx delete mode 100644 packages/modals-collection/src/groups/index.ts delete mode 100644 packages/modals-collection/src/invites/index.ts delete mode 100644 packages/modals-collection/src/invites/invite-copy-modal.tsx delete mode 100644 packages/modals-collection/src/invites/invite-create-modal.tsx create mode 100644 packages/ui/src/components/inline-confirm-action.tsx delete mode 100644 packages/widgets/src/dns-hole/controls/TimerModal.tsx create mode 100644 packages/widgets/src/dns-hole/controls/timer-popover.tsx delete mode 100644 packages/widgets/src/modals/widget-advanced-options-modal.tsx create mode 100755 scripts/browser-agent/ux-coherence-happy-paths.sh diff --git a/apps/docs/docs/management/api/index.mdx b/apps/docs/docs/management/api/index.mdx index f919b7b2e..fab706520 100644 --- a/apps/docs/docs/management/api/index.mdx +++ b/apps/docs/docs/management/api/index.mdx @@ -29,6 +29,9 @@ Alternatively, you can [open the full interactive API reference](/api-reference) Many endpoints will require you to authenticate. At the top of the API reference, you'll find a button to authenticate yourself with an API key. You can obtain API keys from the "Authentication" tab at the top of the page. API keys will not expire and can be used indefinitely. +When you create a key, its one-time value is shown inline above the key table. Copy it before closing the notice; the +creation action remains disabled until the value has been acknowledged. Deletion uses a two-click inline confirmation +instead of opening a separate dialog. ### API Key Format diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index 11d1ba271..3fa95d2d4 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -34,9 +34,11 @@ arrow keys to move through the results and press `Enter` to open the selected bo ## Create a new board -When creating a board, the availability of the board name will be checked for you. +Select **New board** on the boards management page. The creation form expands in the page, so you can enter the board +details without opening a dialog or leaving the board list. The availability of the board name is checked as you type. -![](./img/create-board-modal.png) +The board menu also keeps duplication in context: select **Duplicate board**, choose the new name in the form below that +board, and create the copy without navigating to a separate page. ### Name @@ -56,6 +58,9 @@ This will enable public access for everyone - regardless of whether they are log This can be really useful if you want to have public boards in your company or for family and friends. Modifications for public boards must be allowed explicitly using user groups and their permissions. +Deleting a board from the management list uses an inline safeguard. Select **Delete**, then select **Confirm** in the +same menu item. No separate confirmation dialog is opened. + ## Board settings You navigate to the board settings by clicking on these three dots: @@ -405,8 +410,7 @@ nor the integrations the user does not have access to. Duplicating a board will create a new board with the same settings, all responsive layouts, and independent positions as the original board. Duplicating a board that contains Custom Widgets also requires an administrator. You can duplicate a board by clicking on the three dots in the list of boards and selecting "Duplicate board". - -![](./img/duplicate-board-modal.png) +The name form expands below that board card and can be cancelled without leaving the list. ## Setting boards as your home board diff --git a/apps/docs/docs/management/certificates/index.mdx b/apps/docs/docs/management/certificates/index.mdx index aa843bfe0..0c2b9853a 100644 --- a/apps/docs/docs/management/certificates/index.mdx +++ b/apps/docs/docs/management/certificates/index.mdx @@ -30,15 +30,12 @@ Navigate to `Management` > `Tools` > `Certificates`. ## Add a certificate -To add a certificate click on the "Add certificate" button. +To add a certificate click on the "Add certificate" button. The upload form expands directly on the certificates page. ![](./img/add-button.png) -Then select the certificate file you want to add. - -![](./img/add-modal.png) - -And click the "Add" button. +Then select the certificate file you want to add and click the "Add" button. You can cancel to collapse the form without +leaving the page. ## Delete a certificate @@ -46,7 +43,7 @@ To delete a certificate you can click on the trash icon in the certificate card. ![](./img/card.png) -Then confirm the deletion. +The first click arms the action and the second click confirms the deletion in place; no confirmation dialog is opened. ## Managing it through file system diff --git a/apps/docs/docs/management/custom-widgets/index.mdx b/apps/docs/docs/management/custom-widgets/index.mdx index 33dcfed89..133c29c4a 100644 --- a/apps/docs/docs/management/custom-widgets/index.mdx +++ b/apps/docs/docs/management/custom-widgets/index.mdx @@ -114,7 +114,8 @@ The install flow always links to the corresponding public Workshop entry under t For example, installing **Tautulli Activity** asks for both the user's Tautulli URL and API key. A stable public source such as PokéAPI keeps its published URL without creating unnecessary setup work. Workshop publishes technically valid widgets immediately; users can vote or report them, and Workshop moderators can -delete submissions. Community moderation is separate from Homarr administration and is not a security boundary. +delete submissions. The report form expands within the Workshop detail page instead of interrupting the review with a +dialog. Community moderation is separate from Homarr administration and is not a security boundary. Publishers should replace personal private-network hosts with a clear service placeholder before publishing. Homarr performs definitive validation again at install time. diff --git a/apps/docs/docs/management/users/index.mdx b/apps/docs/docs/management/users/index.mdx index 371ec61a7..9c32ba56f 100644 --- a/apps/docs/docs/management/users/index.mdx +++ b/apps/docs/docs/management/users/index.mdx @@ -108,7 +108,7 @@ However, you can create as many groups as you'd like. ### Creating a group Click on the button `New group` to create a new one. -Homarr will prompt you for a group name. +The group-name form expands above the group list and can be closed without leaving the page. ### Managing a group @@ -259,7 +259,8 @@ An invitation link can be used indefinitely. ### Creating an invitation -Click on the button: +Select the create-invitation button. The expiration form expands above the invitation table instead of opening a +dialog or navigating to another page: ![](./img/create-invitation-button.png) @@ -269,12 +270,12 @@ The default is 4 hours in the future. ![](./img/invitation-expiry-date.png) -Next, click `Create` and you'll get a confirmation with an ID, Token and a link that can be copied: +Next, click `Create` and the same section shows the ID, Token and a link that can be copied: ![](./img/create-invitation-confirmation.png) Both ID and Token are also in the URL. Ensure that you copy either ID & Token or the URL - it will never be shown again. -Clicking the `Copy & close` button will copy the URL to your clipboard. +Use **Copy** to copy the URL, then close the section when you are finished. ### Self-onboarding @@ -293,3 +294,4 @@ If an invitation needs to be deleted before or after the expiry, the delete butt The link will become invalid. If the invitation expires, the link will also expire. +The first click changes the action to **Confirm**; the second click deletes the invitation without opening a dialog. diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx index 9342a78d0..645d25058 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx @@ -1,15 +1,16 @@ "use client"; -import { useCallback } from "react"; +import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; -import { Button } from "@mantine/core"; +import { Box, Button, Group, TextInput } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; -import { useConfirmModal, useModalAction } from "@homarr/modals"; +import { useZodForm } from "@homarr/form"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmButton } from "@homarr/ui"; +import { boardRenameSchema } from "@homarr/validation/board"; -import { BoardRenameModal } from "~/components/board/modals/board-rename-modal"; import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone"; export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => { @@ -17,70 +18,70 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: const t = useI18n("board.setting"); const tCommonAction = useI18n("common.action"); const router = useRouter(); - const { openConfirmModal } = useConfirmModal(); - const { openModal } = useModalAction(BoardRenameModal); + const utils = clientApi.useUtils(); + const [isRenaming, setIsRenaming] = useState(false); + const renameInputRef = useRef(null); + const renameForm = useZodForm(boardRenameSchema.omit({ id: true }), { + initialValues: { + name: board.name, + }, + }); + const { mutate: renameBoard, isPending: isRenamePending } = clientApi.board.renameBoard.useMutation({ + onSettled() { + void utils.board.getBoardByName.invalidate({ name: board.name }); + void utils.board.getHomeBoard.invalidate(); + }, + }); const { mutate: changeVisibility, isPending: isChangeVisibilityPending } = clientApi.board.changeBoardVisibility.useMutation(); const { mutate: deleteBoard, isPending: isDeletePending } = clientApi.board.deleteBoard.useMutation(); - const utils = clientApi.useUtils(); const visibility = board.isPublic ? "public" : "private"; - const onRenameClick = useCallback( - () => - openModal({ + useEffect(() => { + if (isRenaming) renameInputRef.current?.focus(); + }, [isRenaming]); + + const handleRename = renameForm.onSubmit((values) => { + renameBoard( + { id: board.id, name: values.name }, + { + onSuccess() { + router.push(`/boards/${values.name}/settings`); + }, + }, + ); + }); + + const cancelRename = () => { + renameForm.reset(); + setIsRenaming(false); + }; + + const handleVisibilityChange = () => { + changeVisibility( + { id: board.id, - previousName: board.name, - onSuccess: (name) => router.push(`/boards/${name}/settings`), - }), - [board.id, board.name, router, openModal], - ); - - const onVisibilityClick = useCallback(() => { - openConfirmModal({ - title: t(`section.dangerZone.action.visibility.confirm.${visibility}.title`), - children: t(`section.dangerZone.action.visibility.confirm.${visibility}.description`), - onConfirm: () => { - changeVisibility( - { - id: board.id, - visibility: visibility === "public" ? "private" : "public", - }, - { - onSettled() { - void utils.board.getBoardByName.invalidate({ name: board.name }); - void utils.board.getHomeBoard.invalidate(); - }, - }, - ); + visibility: visibility === "public" ? "private" : "public", }, - }); - }, [ - board.id, - board.name, - changeVisibility, - t, - utils.board.getBoardByName, - utils.board.getHomeBoard, - visibility, - openConfirmModal, - ]); - - const onDeleteClick = useCallback(() => { - openConfirmModal({ - title: t("section.dangerZone.action.delete.confirm.title"), - children: t("section.dangerZone.action.delete.confirm.description"), - onConfirm: () => { - deleteBoard( - { id: board.id }, - { - onSettled: () => { - router.push("/"); - }, - }, - ); + { + onSettled() { + void utils.board.getBoardByName.invalidate({ name: board.name }); + void utils.board.getHomeBoard.invalidate(); + }, }, - }); - }, [board.id, deleteBoard, router, t, openConfirmModal]); + ); + }; + + const handleDelete = () => { + deleteBoard( + { id: board.id }, + { + onSettled: () => { + router.push("/"); + }, + }, + ); + }; return ( @@ -88,9 +89,28 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: label={t("section.dangerZone.action.rename.label")} description={t("section.dangerZone.action.rename.description")} action={ - + isRenaming ? ( + + + + + + + + ) : ( + + ) } /> {!hideVisibility && ( @@ -98,9 +118,15 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: label={t("section.dangerZone.action.visibility.label")} description={t(`section.dangerZone.action.visibility.description.${visibility}`)} action={ - + } /> )} @@ -108,9 +134,15 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: label={tCommonAction("delete")} description={t("section.dangerZone.action.delete.description")} action={ - + } /> diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx index 64f36ea03..fa72af452 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx @@ -23,9 +23,9 @@ import { IconEdit, IconRefresh } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { createId } from "@homarr/common"; import type { UseFormReturnType } from "@homarr/form"; -import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmButton } from "@homarr/ui"; import { SectionCard } from "~/components/manage/section-card"; import type { Board } from "../../_types"; @@ -46,7 +46,6 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync const tCommon = useI18n("common"); const router = useRouter(); const utils = clientApi.useUtils(); - const { openConfirmModal } = useConfirmModal(); const [editingLayoutId, setEditingLayoutId] = useState(null); const [resettingLayoutId, setResettingLayoutId] = useState(null); const { mutateAsync: resetLayout } = clientApi.board.resetLayout.useMutation({ @@ -84,29 +83,20 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync } }; - const confirmReset = (layout: FormValues["layouts"][number]) => { - openConfirmModal({ - title: tBoard("setting.section.layout.reset.confirm.title", { layoutName: layout.name }), - children: tBoard("setting.section.layout.reset.confirm.message"), - confirmProps: { children: tBoard("setting.section.layout.reset.action"), color: "red" }, - onConfirm() { - setResettingLayoutId(layout.id); - void (async () => { - try { - const canonicalLayout = board.layouts.find((candidate) => candidate.id === layout.id); - if (!canonicalLayout) return; + const handleReset = async (layout: FormValues["layouts"][number]) => { + setResettingLayoutId(layout.id); + try { + const canonicalLayout = board.layouts.find((candidate) => candidate.id === layout.id); + if (!canonicalLayout) return; - await resetLayout({ boardId: board.id, layoutId: canonicalLayout.id }); - await utils.board.getBoardByName.invalidate({ name: board.name }); - router.refresh(); - } catch { - // The mutation callback displays the error notification. - } finally { - setResettingLayoutId(null); - } - })(); - }, - }); + await resetLayout({ boardId: board.id, layoutId: canonicalLayout.id }); + await utils.board.getBoardByName.invalidate({ name: board.name }); + router.refresh(); + } catch { + // The mutation callback displays the error notification. + } finally { + setResettingLayoutId(null); + } }; const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts); @@ -236,16 +226,17 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync {layout.role !== "base" && persistedLayout && ( - + )} {layout.role === "custom" && ( + {isMenuVisible && ( + + + + + + + { + setDuplicateFormKey((value) => value + 1); + setIsDuplicateOpen(true); + }} + /> + + )} + + + + + + setIsDuplicateOpen(false)} /> + + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx b/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx index f6c9ed735..ce9059784 100644 --- a/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx +++ b/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx @@ -1,23 +1,55 @@ "use client"; -import { Button } from "@mantine/core"; +import { useEffect, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Button, Card, Collapse, Group, Stack } from "@mantine/core"; import { IconCategoryPlus } from "@tabler/icons-react"; -import { useModalAction } from "@homarr/modals"; -import { AddBoardModal } from "@homarr/modals-collection"; +import { BoardCreateForm } from "@homarr/forms-collection"; import { useI18n } from "@homarr/translation/client"; -import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action"; - export const CreateBoardButton = () => { const t = useI18n("management.page.board.action"); - const { openModal } = useModalAction(AddBoardModal); + const router = useRouter(); + const searchParams = useSearchParams(); + const [isOpen, setIsOpen] = useState(searchParams.get("create") === "true"); + const [formKey, setFormKey] = useState(0); + + useEffect(() => { + if (searchParams.get("create") !== "true") return; + + setFormKey((value) => value + 1); + setIsOpen(true); + }, [searchParams]); + + const close = () => { + setIsOpen(false); + setFormKey((value) => value + 1); + if (searchParams.has("create")) router.replace("/manage/boards", { scroll: false }); + }; + + const toggle = () => { + if (isOpen) { + close(); + return; + } + + setFormKey((value) => value + 1); + setIsOpen(true); + }; return ( - - - + + + + + + + + + + ); }; diff --git a/packages/modals-collection/src/boards/duplicate-board-modal.tsx b/apps/nextjs/src/app/[locale]/manage/boards/_components/duplicate-board-form.tsx similarity index 62% rename from packages/modals-collection/src/boards/duplicate-board-modal.tsx rename to apps/nextjs/src/app/[locale]/manage/boards/_components/duplicate-board-form.tsx index 9d9830757..3e0fff888 100644 --- a/packages/modals-collection/src/boards/duplicate-board-modal.tsx +++ b/apps/nextjs/src/app/[locale]/manage/boards/_components/duplicate-board-form.tsx @@ -1,29 +1,32 @@ +"use client"; + +import { useRouter } from "next/navigation"; import { Button, Group, Stack, Text, TextInput } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; import { useZodForm } from "@homarr/form"; +import { BoardNameAvailability, useBoardNameStatus } from "@homarr/forms-collection"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; import { boardDuplicateSchema } from "@homarr/validation/board"; -import { createModal } from "../../../modals/src/creator"; -import { useBoardNameStatus } from "./add-board-modal"; - -interface InnerProps { +interface DuplicateBoardFormProps { board: { id: string; name: string; }; + onCancel: () => void; } -export const DuplicateBoardModal = createModal(({ actions, innerProps }) => { +export const DuplicateBoardForm = ({ board, onCancel }: DuplicateBoardFormProps) => { const tBoard = useI18n("board"); const tCommon = useI18n("common"); + const router = useRouter(); const form = useZodForm(boardDuplicateSchema.omit({ id: true }), { mode: "controlled", initialValues: { - name: innerProps.board.name, + name: board.name, }, }); const boardNameStatus = useBoardNameStatus(form.values.name); @@ -36,20 +39,21 @@ export const DuplicateBoardModal = createModal(({ actions, innerProp return (
{ - // Prevent submit before name availability check if (!boardNameStatus.canSubmit) return; + await mutateAsync( { ...values, - id: innerProps.board.id, + id: board.id, }, { onSuccess() { - actions.closeModal(); showSuccessNotification({ title: tBoard("action.duplicate.notification.success.title"), message: tBoard("action.duplicate.notification.success.message"), }); + onCancel(); + router.refresh(); }, onError() { showErrorNotification({ @@ -61,39 +65,26 @@ export const DuplicateBoardModal = createModal(({ actions, innerProp ); })} > - + - {tBoard("action.duplicate.message", { name: innerProps.board.name })} + {tBoard("action.duplicate.message", { name: board.name })} - - {boardNameStatus.description.icon ? : null} - {boardNameStatus.description.label} - - ) : null - } + description={} withAsterisk /> - - - - ); -}).withOptions({ - defaultTitle(t) { - return t("board.action.duplicate.title"); - }, -}); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/boards/page.tsx b/apps/nextjs/src/app/[locale]/manage/boards/page.tsx index c20cf6c74..e3b3a1286 100644 --- a/apps/nextjs/src/app/[locale]/manage/boards/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/boards/page.tsx @@ -1,38 +1,15 @@ -import { - ActionIcon, - Badge, - Button, - Card, - CardSection, - Grid, - GridCol, - Group, - Menu, - MenuTarget, - Text, - Tooltip, -} from "@mantine/core"; -import { - IconDeviceMobile, - IconDotsVertical, - IconHomeFilled, - IconLayoutDashboard, - IconLock, - IconWorld, -} from "@tabler/icons-react"; +import { Grid, GridCol } from "@mantine/core"; +import { IconLayoutDashboard } from "@tabler/icons-react"; -import type { RouterOutputs } from "@homarr/api"; import { api } from "@homarr/api/server"; import { auth } from "@homarr/auth/next"; import { constructBoardPermissions } from "@homarr/auth/shared"; import { getI18n } from "@homarr/translation/server"; -import { Link, UserAvatar } from "@homarr/ui"; -import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail"; import { TourTarget } from "~/components/layout/header/tour-target"; import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { NoResults } from "~/components/no-results"; -import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown"; +import { BoardCard } from "./_components/board-card"; import { CreateBoardButton } from "./_components/create-board-button"; export default async function ManageBoardsPage() { @@ -42,24 +19,14 @@ export default async function ManageBoardsPage() { const canCreateBoards = session?.user.permissions.includes("board-create"); return ( - - - - ) : undefined - } - floatingPrimaryAction={canCreateBoards} - > + + {canCreateBoards && ( + + + + )} {boards.length === 0 && ( - + )} {boards.length > 0 && ( @@ -92,97 +59,3 @@ export default async function ManageBoardsPage() { ); } - -interface BoardCardProps { - board: RouterOutputs["board"]["getManageOverview"][number]; - isMenuVisible: boolean; - labels: { - visibility: string; - preview: string; - unknownCreator: string; - home: string; - homeTooltip: string; - mobileHome: string; - mobileHomeTooltip: string; - open: string; - settings: string; - }; -} - -const BoardCard = ({ board, isMenuVisible, labels }: BoardCardProps) => { - const VisibilityIcon = board.isPublic ? IconWorld : IconLock; - - return ( - - - - - - - - - - - - {board.name} - - - - - {board.isHome && ( - - }> - {labels.home} - - - )} - - {board.isMobileHome && ( - - }> - {labels.mobileHome} - - - )} - - {board.creator && ( - - - - )} - - - - - - - - {isMenuVisible && ( - - - - - - - - - )} - - - - ); -}; diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx index 8e376330b..e6ad0594b 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx @@ -20,10 +20,9 @@ import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; import { formatCustomWidgetImportIssues, parseCustomWidgetClipboardDetailed } from "@homarr/custom-widgets/core"; import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core"; -import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; -import { Link } from "@homarr/ui"; +import { InlineConfirmMenuItem, Link } from "@homarr/ui"; import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog"; import { downloadJson } from "~/components/custom-widgets/download"; @@ -40,7 +39,7 @@ interface WidgetRef { export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { const t = useI18n("customWidget"); - const { openConfirmModal } = useConfirmModal(); + const tCommon = useI18n("common"); const deleteMutation = clientApi.customWidget.delete.useMutation(); const duplicateMutation = clientApi.customWidget.duplicate.useMutation(); const toggleMutation = clientApi.customWidget.toggleEnabled.useMutation(); @@ -173,30 +172,24 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { }; const handleDelete = () => { - openConfirmModal({ - title: t("action.delete"), - children: t("action.deleteConfirm", { name: widget.name }), - onConfirm: () => { - deleteMutation.mutate( - { id: widget.id }, - { - onSuccess: () => { - showSuccessNotification({ - title: t("action.delete"), - message: t("notification.deleted", { name: widget.name }), - }); - void utils.customWidget.list.invalidate(); - void utils.customWidget.available.invalidate(); - void utils.widget.customApi.getData.invalidate(); - void revalidatePathActionAsync("/manage/custom-widgets"); - }, - onError: () => { - showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") }); - }, - }, - ); + deleteMutation.mutate( + { id: widget.id }, + { + onSuccess: () => { + showSuccessNotification({ + title: t("action.delete"), + message: t("notification.deleted", { name: widget.name }), + }); + void utils.customWidget.list.invalidate(); + void utils.customWidget.available.invalidate(); + void utils.widget.customApi.getData.invalidate(); + void revalidatePathActionAsync("/manage/custom-widgets"); + }, + onError: () => { + showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") }); + }, }, - }); + ); }; return ( @@ -263,14 +256,15 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { )} {!widget.migrationRequired && ( - } - onClick={handleDelete} + onConfirm={handleDelete} + confirmLabel={tCommon("action.confirm")} disabled={deleteMutation.isPending} > {t("action.delete")} - + )} diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/[id]/_workshop-detail.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/[id]/_workshop-detail.tsx index d357f37b5..77cd96cba 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/[id]/_workshop-detail.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/[id]/_workshop-detail.tsx @@ -35,7 +35,7 @@ import { ManageStickyFooter } from "~/components/manage/manage-sticky-footer"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { WorkshopCodeViewer } from "~/components/workshop/workshop-code-viewer"; import { getWorkshopWebUrl } from "~/components/workshop/workshop-client"; -import { WorkshopReportModal } from "~/components/workshop/workshop-report-modal"; +import { WorkshopReportForm } from "~/components/workshop/workshop-report-form"; import { WorkshopScreenshots } from "~/components/workshop/workshop-screenshots"; import { useWorkshopSession } from "~/components/workshop/workshop-session"; import { WorkshopVoteControl } from "~/components/workshop/workshop-vote-control"; @@ -213,6 +213,13 @@ export function WorkshopDetail({ id }: { id: string }) { + + @@ -241,6 +248,8 @@ export function WorkshopDetail({ id }: { id: string }) { leftSection={} disabled={!session.user} onClick={reportControls.open} + aria-expanded={reportOpened} + aria-controls="workshop-report-form" > {t("report")} @@ -259,13 +268,6 @@ export function WorkshopDetail({ id }: { id: string }) { - - ); } diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/_components/test-connection/test-connection-certificate.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/_components/test-connection/test-connection-certificate.tsx index c338e06ac..07b3c6da2 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/_components/test-connection/test-connection-certificate.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/_components/test-connection/test-connection-certificate.tsx @@ -6,11 +6,12 @@ import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; import { createId, getMantineColor } from "@homarr/common"; import { createDocumentationLink } from "@homarr/definitions"; -import { createModal, useConfirmModal, useModalAction } from "@homarr/modals"; -import { AddCertificateModal } from "@homarr/modals-collection"; +import { createModal, useModalAction } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useCurrentLocale, useI18n } from "@homarr/translation/client"; +import { InlineConfirmButton } from "@homarr/ui"; +import { CertificateUploadForm } from "~/app/[locale]/manage/tools/certificates/_components/certificate-upload-form"; import type { MappedCertificate, MappedTestConnectionCertificateError } from "./types"; interface CertificateErrorDetailsProps { @@ -23,80 +24,71 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP const { data: session } = useSession(); const isAdmin = session?.user.permissions.includes("admin") ?? false; const [showRetryButton, setShowRetryButton] = useState(false); + const [showUploadForm, setShowUploadForm] = useState(false); - const { openModal: openUploadModal } = useModalAction(AddCertificateModal); - const { openConfirmModal } = useConfirmModal(); - const { mutateAsync: trustHostnameAsync } = clientApi.certificates.trustHostnameMismatch.useMutation(); - const { mutateAsync: addCertificateAsync } = clientApi.certificates.addCertificate.useMutation(); + const { mutateAsync: trustHostnameAsync, isPending: isTrustHostnamePending } = + clientApi.certificates.trustHostnameMismatch.useMutation(); + const { mutateAsync: addCertificateAsync, isPending: isTrustSelfSignedPending } = + clientApi.certificates.addCertificate.useMutation(); const rootCertificate = getHeighestCertificate(error.data.certificate); - const handleTrustHostname = () => { + const handleTrustHostname = async () => { const { hostname } = new URL(url); - openConfirmModal({ - title: tError("certificate.hostnameMismatch.confirm.title"), - children: tError("certificate.hostnameMismatch.confirm.message"), - // eslint-disable-next-line no-restricted-syntax - async onConfirm() { - await trustHostnameAsync( - { - hostname, - certificate: error.data.certificate.pem, - }, - { - onSuccess() { - showSuccessNotification({ - title: tError("certificate.hostnameMismatch.notification.success.title"), - message: tError("certificate.hostnameMismatch.notification.success.message"), - }); - setShowRetryButton(true); - }, - onError() { - showErrorNotification({ - title: tError("certificate.hostnameMismatch.notification.error.title"), - message: tError("certificate.hostnameMismatch.notification.error.message"), - }); - }, - }, - ); + await trustHostnameAsync( + { + hostname, + certificate: error.data.certificate.pem, }, - }); + { + onSuccess() { + showSuccessNotification({ + title: tError("certificate.hostnameMismatch.notification.success.title"), + message: tError("certificate.hostnameMismatch.notification.success.message"), + }); + setShowRetryButton(true); + }, + onError() { + showErrorNotification({ + title: tError("certificate.hostnameMismatch.notification.error.title"), + message: tError("certificate.hostnameMismatch.notification.error.message"), + }); + }, + }, + ); }; - const handleTrustSelfSigned = () => { + const handleTrustSelfSigned = async () => { const { hostname } = new URL(url); - openConfirmModal({ - title: tError("certificate.selfSigned.confirm.title"), - children: tError("certificate.selfSigned.confirm.message"), - // eslint-disable-next-line no-restricted-syntax - async onConfirm() { - const formData = new FormData(); - formData.append( - "file", - new File([rootCertificate.pem], `${hostname}-${createId()}.crt`, { - type: "application/x-x509-ca-cert", - }), - ); - await addCertificateAsync(formData, { - onSuccess() { - showSuccessNotification({ - title: tError("certificate.selfSigned.notification.success.title"), - message: tError("certificate.selfSigned.notification.success.message"), - }); - setShowRetryButton(true); - }, - onError() { - showErrorNotification({ - title: tError("certificate.selfSigned.notification.error.title"), - message: tError("certificate.selfSigned.notification.error.message"), - }); - }, + const formData = new FormData(); + formData.append( + "file", + new File([rootCertificate.pem], `${hostname}-${createId()}.crt`, { + type: "application/x-x509-ca-cert", + }), + ); + await addCertificateAsync(formData, { + onSuccess() { + showSuccessNotification({ + title: tError("certificate.selfSigned.notification.success.title"), + message: tError("certificate.selfSigned.notification.success.message"), + }); + setShowRetryButton(true); + }, + onError() { + showErrorNotification({ + title: tError("certificate.selfSigned.notification.error.title"), + message: tError("certificate.selfSigned.notification.error.message"), }); }, }); }; const description = {tError(`certificate.description.${error.data.reason}`)}; + let trustConfirmLabel = tError("certificate.selfSigned.confirm.title"); + if (error.data.reason === "hostnameMismatch") { + trustConfirmLabel = tError("certificate.hostnameMismatch.confirm.title"); + } if (!isAdmin) { return ( @@ -130,30 +122,36 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP {(error.data.reason === "untrusted" && rootCertificate.isSelfSigned) || error.data.reason === "hostnameMismatch" ? ( - + ) : null} {error.data.reason === "untrusted" && !rootCertificate.isSelfSigned ? ( - + + {!showUploadForm && ( + + )} + {showUploadForm && ( + + setShowUploadForm(false)} + onSuccess={() => { + setShowRetryButton(true); + setShowUploadForm(false); + }} + /> + + )} + ) : null} ); diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/_integration-buttons.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/_integration-buttons.tsx index d10cd25d2..9f42967a5 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/_integration-buttons.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/_integration-buttons.tsx @@ -1,13 +1,12 @@ "use client"; -import { ActionIcon } from "@mantine/core"; import { IconTrash } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmActionIcon } from "@homarr/ui"; interface DeleteIntegrationActionButtonProps { integration: { id: string; name: string }; @@ -16,46 +15,42 @@ interface DeleteIntegrationActionButtonProps { export const DeleteIntegrationActionButton = ({ integration }: DeleteIntegrationActionButtonProps) => { const t = useI18n("integration.page.delete"); const tCommon = useI18n("common"); - const { openConfirmModal } = useConfirmModal(); const utils = clientApi.useUtils(); const { mutateAsync, isPending } = clientApi.integration.delete.useMutation(); + const onConfirm = () => + mutateAsync( + { id: integration.id }, + { + onSuccess: () => { + showSuccessNotification({ + title: tCommon("notification.delete.success"), + message: t("notification.success.message"), + }); + void revalidatePathActionAsync("/manage/integrations"); + void utils.integration.invalidate(); + }, + onError: () => { + showErrorNotification({ + title: tCommon("notification.delete.error"), + message: t("notification.error.message"), + }); + }, + }, + ); + return ( - { - openConfirmModal({ - title: t("title"), - children: t("message", integration), - onConfirm: () => { - void mutateAsync( - { id: integration.id }, - { - onSuccess: () => { - showSuccessNotification({ - title: tCommon("notification.delete.success"), - message: t("notification.success.message"), - }); - void revalidatePathActionAsync("/manage/integrations"); - void utils.integration.invalidate(); - }, - onError: () => { - showErrorNotification({ - title: tCommon("notification.delete.error"), - message: t("notification.error.message"), - }); - }, - }, - ); - }, - }); - }} aria-label={tCommon("action.deleteNamed", { name: integration.name })} > - + ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/medias/_actions/delete-media.tsx b/apps/nextjs/src/app/[locale]/manage/medias/_actions/delete-media.tsx index 5f1b2f954..fb0579db0 100644 --- a/apps/nextjs/src/app/[locale]/manage/medias/_actions/delete-media.tsx +++ b/apps/nextjs/src/app/[locale]/manage/medias/_actions/delete-media.tsx @@ -1,40 +1,41 @@ "use client"; -import { ActionIcon, Tooltip } from "@mantine/core"; +import { Tooltip } from "@mantine/core"; import { IconTrash } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useConfirmModal } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmActionIcon } from "@homarr/ui"; interface DeleteMediaProps { media: RouterOutputs["media"]["getPaginated"]["items"][number]; } export const DeleteMedia = ({ media }: DeleteMediaProps) => { - const { openConfirmModal } = useConfirmModal(); const t = useI18n("media"); + const tCommon = useI18n("common"); const { mutateAsync, isPending } = clientApi.media.deleteMedia.useMutation(); - const onClick = () => { - openConfirmModal({ - title: t("action.delete.label"), - children: t.rich("action.delete.description", { bName: () => {media.name} }), - // eslint-disable-next-line no-restricted-syntax - onConfirm: async () => { - await mutateAsync({ id: media.id }); - await revalidatePathActionAsync("/manage/medias"); - }, - }); + const onConfirm = async () => { + await mutateAsync({ id: media.id }); + await revalidatePathActionAsync("/manage/medias"); }; return ( - + - + ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/search-engines/_search-engine-delete-button.tsx b/apps/nextjs/src/app/[locale]/manage/search-engines/_search-engine-delete-button.tsx index 15a0c03bd..ab63a55a5 100644 --- a/apps/nextjs/src/app/[locale]/manage/search-engines/_search-engine-delete-button.tsx +++ b/apps/nextjs/src/app/[locale]/manage/search-engines/_search-engine-delete-button.tsx @@ -1,15 +1,14 @@ "use client"; import { useCallback } from "react"; -import { ActionIcon } from "@mantine/core"; import { IconTrash } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmActionIcon } from "@homarr/ui"; interface SearchEngineDeleteButtonProps { searchEngine: RouterOutputs["searchEngine"]["getPaginated"]["items"][number]; @@ -18,48 +17,41 @@ interface SearchEngineDeleteButtonProps { export const SearchEngineDeleteButton = ({ searchEngine }: SearchEngineDeleteButtonProps) => { const t = useI18n("search.engine.page.delete"); const actionT = useI18n("common.action"); - const { openConfirmModal } = useConfirmModal(); const { mutate, isPending } = clientApi.searchEngine.delete.useMutation(); - const onClick = useCallback(() => { - openConfirmModal({ - title: t("title"), - children: t("message", { - name: searchEngine.name, - }), - onConfirm: () => { - mutate( - { id: searchEngine.id }, - { - onSuccess: () => { - showSuccessNotification({ - title: t("notification.success.title"), - message: t("notification.success.message"), - }); - void revalidatePathActionAsync("/manage/search-engines"); - }, - onError: () => { - showErrorNotification({ - title: t("notification.error.title"), - message: t("notification.error.message"), - }); - }, - }, - ); + const onConfirm = useCallback(() => { + mutate( + { id: searchEngine.id }, + { + onSuccess: () => { + showSuccessNotification({ + title: t("notification.success.title"), + message: t("notification.success.message"), + }); + void revalidatePathActionAsync("/manage/search-engines"); + }, + onError: () => { + showErrorNotification({ + title: t("notification.error.title"), + message: t("notification.error.message"), + }); + }, }, - }); - }, [searchEngine, mutate, t, openConfirmModal]); + ); + }, [searchEngine.id, mutate, t]); return ( - - + ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/components/api-keys.tsx b/apps/nextjs/src/app/[locale]/manage/tools/api/components/api-keys.tsx index 53852934d..55073fe92 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/api/components/api-keys.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/api/components/api-keys.tsx @@ -1,34 +1,67 @@ "use client"; -import { useCallback, useMemo } from "react"; -import { ActionIcon, Button, Group, Stack, Text, Title } from "@mantine/core"; +import { useCallback, useMemo, useState } from "react"; +import { Button, Group, Stack, Text, Title } from "@mantine/core"; import { IconTrash } from "@tabler/icons-react"; -import type { MRT_ColumnDef } from "mantine-react-table"; +import type { MRT_ColumnDef, MRT_Row, MRT_TableInstance } from "mantine-react-table"; import { MantineReactTable, useMantineReactTable } from "mantine-react-table"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; import { invariantTechnicalLabels } from "@homarr/definitions"; -import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; -import { UserAvatar } from "@homarr/ui"; +import { InlineConfirmActionIcon, UserAvatar } from "@homarr/ui"; -import { CopyApiKeyModal } from "~/app/[locale]/manage/tools/api/components/copy-api-key-modal"; +import { NewApiKeyAlert } from "./new-api-key-alert"; interface ApiKeysManagementProps { apiKeys: RouterOutputs["apiKeys"]["getAll"]; + onCreated: () => void; } -export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => { - const { openModal } = useModalAction(CopyApiKeyModal); - const { openConfirmModal } = useConfirmModal(); +type ApiKey = RouterOutputs["apiKeys"]["getAll"][number]; +type ApiKeyCellProps = { row: MRT_Row }; + +interface ApiKeysTableMeta { + handleDelete: (id: string) => Promise; + confirmLabel: string; + deleteTitle: string; + isPendingDelete: boolean; +} + +const ApiKeyCreatedByCell = ({ row }: ApiKeyCellProps) => ( + + + {row.original.user.name} + +); + +const ApiKeyActionsCell = ({ row, table }: ApiKeyCellProps & { table: MRT_TableInstance }) => { + const meta = table.options.meta as ApiKeysTableMeta; + return ( + + meta.handleDelete(row.original.id)} + confirmLabel={meta.confirmLabel} + confirmationAriaLabel={meta.confirmLabel} + loading={meta.isPendingDelete} + color="red" + variant="subtle" + aria-label={meta.deleteTitle} + > + + + + ); +}; + +export const ApiKeysManagement = ({ apiKeys, onCreated }: ApiKeysManagementProps) => { + const [newApiKey, setNewApiKey] = useState(null); const { mutate: mutateCreate, isPending: isPendingCreate } = clientApi.apiKeys.create.useMutation({ - async onSuccess(data) { - openModal({ - apiKey: data.apiKey, - }); - await revalidatePathActionAsync("/manage/tools/api"); + onSuccess(data) { + setNewApiKey(data.apiKey); + onCreated(); }, }); const { mutateAsync: mutateDeleteAsync, isPending: isPendingDelete } = clientApi.apiKeys.delete.useMutation({ @@ -38,21 +71,19 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => { }); const t = useI18n("management.page.tool.api.tab.apiKey"); + const tCommon = useI18n("common"); const handleDelete = useCallback( - (id: string) => { - openConfirmModal({ - title: t("modal.delete.title"), - children: t("modal.delete.text"), - // eslint-disable-next-line no-restricted-syntax - async onConfirm() { - await mutateDeleteAsync({ apiKeyId: id }); - }, - }); + async (id: string) => { + await mutateDeleteAsync({ apiKeyId: id }); }, - [t, openConfirmModal, mutateDeleteAsync], + [mutateDeleteAsync], ); + const handleDismissNewApiKey = useCallback(async () => { + setNewApiKey(null); + await revalidatePathActionAsync("/manage/tools/api"); + }, []); - const columns = useMemo[]>( + const columns = useMemo[]>( () => [ { accessorKey: "id", @@ -61,36 +92,32 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => { { accessorKey: "user", header: t("table.header.createdBy"), - Cell: ({ row }) => ( - - - {row.original.user.name} - - ), + Cell: ApiKeyCreatedByCell, }, { header: t("table.header.actions"), - Cell: ({ row }) => ( - - handleDelete(row.original.id)} loading={isPendingDelete} c="red"> - - - - ), + Cell: ApiKeyActionsCell, }, ], - [t, handleDelete, isPendingDelete], + [t], ); const table = useMantineReactTable({ columns, data: apiKeys, + meta: { + handleDelete, + confirmLabel: tCommon("action.confirm"), + deleteTitle: t("modal.delete.title"), + isPendingDelete, + }, renderTopToolbarCustomActions: () => ( @@ -104,6 +131,7 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => { return ( {t("title")} + {newApiKey && } ); diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/components/api-page-tabs.tsx b/apps/nextjs/src/app/[locale]/manage/tools/api/components/api-page-tabs.tsx index c56dfc916..442f5446f 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/api/components/api-page-tabs.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/api/components/api-page-tabs.tsx @@ -5,6 +5,8 @@ import type { ReactNode } from "react"; import { Tabs, TabsList, TabsPanel, TabsTab } from "@mantine/core"; import { IconBrain, IconCode, IconKey } from "@tabler/icons-react"; +import type { RouterOutputs } from "@homarr/api"; +import { ApiKeysManagement } from "./api-keys"; import { McpInstructions } from "./mcp-instructions"; export interface McpToolGroup { @@ -17,9 +19,8 @@ interface ApiPageTabsProps { apiKeyLabel: string; mcpLabel: string; documentationPanel: ReactNode; - authenticationPanel: ReactNode; + apiKeys: RouterOutputs["apiKeys"]["getAll"]; baseUrl: string; - hasApiKeys: boolean; toolGroups: McpToolGroup[]; } @@ -28,12 +29,12 @@ export function ApiPageTabs({ apiKeyLabel, mcpLabel, documentationPanel, - authenticationPanel, + apiKeys, baseUrl, - hasApiKeys, toolGroups, }: ApiPageTabsProps) { const [activeTab, setActiveTab] = useState("documentation"); + const [hasApiKeys, setHasApiKeys] = useState(apiKeys.length > 0); return ( @@ -48,10 +49,17 @@ export function ApiPageTabs({ {mcpLabel} - {authenticationPanel} + + setHasApiKeys(true)} /> + {documentationPanel} - + setHasApiKeys(true)} + /> ); diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/components/copy-api-key-modal.tsx b/apps/nextjs/src/app/[locale]/manage/tools/api/components/copy-api-key-modal.tsx deleted file mode 100644 index dbaee24ef..000000000 --- a/apps/nextjs/src/app/[locale]/manage/tools/api/components/copy-api-key-modal.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Button, CopyButton, PasswordInput, Stack, Text } from "@mantine/core"; -import { useDisclosure } from "@mantine/hooks"; - -import { createModal } from "@homarr/modals"; -import { useI18n } from "@homarr/translation/client"; - -export const CopyApiKeyModal = createModal<{ apiKey: string }>(({ actions, innerProps }) => { - const t = useI18n("management.page.tool.api.modal.createApiToken"); - const [visible, { toggle }] = useDisclosure(false); - return ( - - {t("description")} - - - {({ copy }) => ( - - )} - - - ); -}).withOptions({ - defaultTitle(t) { - return t("management.page.tool.api.modal.createApiToken.title"); - }, -}); diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx b/apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx index 2a5251a4d..d3fd2e112 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx @@ -1,5 +1,6 @@ "use client"; +import { useCallback, useState } from "react"; import { Accordion, AccordionControl, @@ -36,12 +37,11 @@ import { import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useModalAction } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; -import { CopyApiKeyModal } from "./copy-api-key-modal"; import type { McpToolGroup } from "./api-page-tabs"; import classes from "./mcp-tools-accordion.module.css"; +import { NewApiKeyAlert } from "./new-api-key-alert"; const toolTypeDisplay: Record<"query" | "mutation", { color: string; method: string }> = { query: { color: "blue", method: "GET" }, @@ -52,18 +52,23 @@ interface McpInstructionsProps { baseUrl: string; hasApiKeys: boolean; toolGroups: McpToolGroup[]; + onApiKeyCreated: () => void; } -export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstructionsProps) { +export function McpInstructions({ baseUrl, hasApiKeys, toolGroups, onApiKeyCreated }: McpInstructionsProps) { const t = useI18n("management.page.tool.api.tab.mcp"); const tCommon = useI18n("common"); - const { openModal } = useModalAction(CopyApiKeyModal); + const [newApiKey, setNewApiKey] = useState(null); const { mutate: createApiKey, isPending } = clientApi.apiKeys.create.useMutation({ - async onSuccess(data) { - openModal({ apiKey: data.apiKey }); - await revalidatePathActionAsync("/manage/tools/api"); + onSuccess(data) { + setNewApiKey(data.apiKey); + onApiKeyCreated(); }, }); + const handleDismissNewApiKey = useCallback(async () => { + setNewApiKey(null); + await revalidatePathActionAsync("/manage/tools/api"); + }, []); const mcpUrl = `${baseUrl}/api/mcp`; const streamableHttpConfig = JSON.stringify( @@ -114,7 +119,7 @@ export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstruct {t("description")} - {!hasApiKeys && ( + {!hasApiKeys && !newApiKey && ( }> {t("noApiKey")} @@ -131,6 +136,8 @@ export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstruct )} + {newApiKey && } +
<Group gap="xs"> @@ -305,7 +312,7 @@ export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstruct <Stack gap={6}> {group.tools.map((tool) => ( <div key={tool.name} className={classes.toolRow}> - <Badge size="sm" w={50} radius="xs" variant="light" color={toolTypeDisplay[tool.type].color}> + <Badge size="sm" w={50} radius="xs" color={toolTypeDisplay[tool.type].color} variant="light"> {toolTypeDisplay[tool.type].method} </Badge> <div> diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/components/new-api-key-alert.tsx b/apps/nextjs/src/app/[locale]/manage/tools/api/components/new-api-key-alert.tsx new file mode 100644 index 000000000..2b2f03182 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/api/components/new-api-key-alert.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { Alert, Button, CopyButton, PasswordInput, Stack, Text } from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { IconCheck, IconCopy } from "@tabler/icons-react"; + +import { useI18n } from "@homarr/translation/client"; + +interface NewApiKeyAlertProps { + apiKey: string; + onDismiss: () => void | Promise<void>; +} + +export const NewApiKeyAlert = ({ apiKey, onDismiss }: NewApiKeyAlertProps) => { + const t = useI18n("management.page.tool.api.modal.createApiToken"); + const tCommon = useI18n("common"); + const [visible, { toggle }] = useDisclosure(false); + + return ( + <Alert color="yellow" title={t("title")}> + <Stack gap="sm"> + <Text size="sm">{t("description")}</Text> + <PasswordInput value={apiKey} visible={visible} onVisibilityChange={toggle} aria-label={t("title")} readOnly /> + <Stack gap="xs" align="stretch"> + <CopyButton value={apiKey} timeout={2_000}> + {({ copied, copy }) => { + let color: "green" | undefined; + let icon = <IconCopy size={16} />; + let label = tCommon("action.copy"); + if (copied) { + color = "green"; + icon = <IconCheck size={16} />; + label = tCommon("action.copied"); + } + + return ( + <Button onClick={copy} variant="default" color={color} leftSection={icon}> + {label} + </Button> + ); + }} + </CopyButton> + <Button onClick={onDismiss} variant="subtle"> + {tCommon("action.close")} + </Button> + </Stack> + </Stack> + </Alert> + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx index eb2cd98b7..f6e85e0ff 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx @@ -11,7 +11,6 @@ import { getI18n } from "@homarr/translation/server"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { extractMcpTools } from "~/app/api/mcp/_extract-tools"; -import { ApiKeysManagement } from "./components/api-keys"; import { ApiPageTabs } from "./components/api-page-tabs"; import { ScalarApiReference } from "./components/scalar-api-reference"; @@ -87,9 +86,8 @@ export default async function ApiPage() { apiKeyLabel={t("apiKey.label")} mcpLabel={t("mcp.label")} documentationPanel={<ScalarApiReference document={document} />} - authenticationPanel={<ApiKeysManagement apiKeys={apiKeys} />} + apiKeys={apiKeys} baseUrl={baseUrl} - hasApiKeys={apiKeys.length > 0} toolGroups={toolGroups} /> </Stack> diff --git a/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/add-certificate.tsx b/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/add-certificate.tsx index 206dcb0a2..21731871a 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/add-certificate.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/add-certificate.tsx @@ -1,23 +1,35 @@ "use client"; -import { Button } from "@mantine/core"; +import { Button, Card, Collapse, Stack } from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useModalAction } from "@homarr/modals"; -import { AddCertificateModal } from "@homarr/modals-collection"; import { useI18n } from "@homarr/translation/client"; -export const AddCertificateButton = () => { - const { openModal } = useModalAction(AddCertificateModal); +import { CertificateUploadForm } from "./certificate-upload-form"; + +export const AddCertificateSection = () => { + const [opened, { close, toggle }] = useDisclosure(false); const t = useI18n("certificate"); - const handleClick = () => { - openModal({ - async onSuccess() { - await revalidatePathActionAsync("/manage/tools/certificates"); - }, - }); - }; - - return <Button onClick={handleClick}>{t("action.create.label")}</Button>; + return ( + <Stack gap="sm"> + <Button onClick={toggle} variant="default" aria-expanded={opened}> + {t("action.create.label")} + </Button> + <Collapse expanded={opened}> + <Card withBorder> + <CertificateUploadForm + onCancel={close} + onSuccess={async () => { + await revalidatePathActionAsync("/manage/tools/certificates"); + close(); + }} + /> + </Card> + </Collapse> + </Stack> + ); }; + +export { CertificateUploadForm }; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/certificate-upload-form.tsx b/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/certificate-upload-form.tsx new file mode 100644 index 000000000..512caf068 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/certificate-upload-form.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { Button, FileInput, Group, Stack } from "@mantine/core"; +import { IconCertificate } from "@tabler/icons-react"; +import { z } from "zod/v4"; + +import { clientApi } from "@homarr/api/client"; +import type { MaybePromise } from "@homarr/common/types"; +import { useZodForm } from "@homarr/form"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; +import { useI18n } from "@homarr/translation/client"; +import { checkCertificateFile } from "@homarr/validation/certificates"; + +interface CertificateUploadFormProps { + embedded?: boolean; + onSuccess?: () => MaybePromise<void>; + onCancel?: () => void; +} + +export const CertificateUploadForm = ({ embedded = false, onSuccess, onCancel }: CertificateUploadFormProps) => { + const t = useI18n(); + const form = useZodForm( + z.object({ + file: z.file().check(checkCertificateFile), + }), + { + initialValues: { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + file: null!, + }, + }, + ); + const { mutateAsync, isPending } = clientApi.certificates.addCertificate.useMutation(); + + const handleSubmit = async () => { + const validation = form.validate(); + if (validation.hasErrors || !form.values.file) return; + + const formData = new FormData(); + formData.set("file", form.values.file); + await mutateAsync(formData, { + onSuccess: async () => { + showSuccessNotification({ + title: t("certificate.action.create.notification.success.title"), + message: t("certificate.action.create.notification.success.message"), + }); + form.reset(); + await onSuccess?.(); + }, + onError: () => { + showErrorNotification({ + title: t("certificate.action.create.notification.error.title"), + message: t("certificate.action.create.notification.error.message"), + }); + }, + }); + }; + + const handleCancel = () => { + form.reset(); + onCancel?.(); + }; + + const submit = () => { + void handleSubmit().catch(() => undefined); + }; + + let submitButtonType: "button" | "submit" = "submit"; + let handleSubmitClick: (() => void) | undefined; + if (embedded) { + submitButtonType = "button"; + handleSubmitClick = submit; + } + + const content = ( + <> + <FileInput + label={t("certificate.action.create.label")} + leftSection={<IconCertificate size={16} />} + {...form.getInputProps("file")} + /> + <Group justify="end"> + {onCancel && ( + <Button onClick={handleCancel} variant="subtle" color="gray"> + {t("common.action.cancel")} + </Button> + )} + <Button type={submitButtonType} onClick={handleSubmitClick} loading={isPending}> + {t("common.action.add")} + </Button> + </Group> + </> + ); + + if (embedded) { + return <Stack>{content}</Stack>; + } + + return ( + <Stack + component="form" + onSubmit={(event) => { + event.preventDefault(); + submit(); + }} + > + {content} + </Stack> + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/remove-certificate.tsx b/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/remove-certificate.tsx index 0f252cf84..274bdf816 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/remove-certificate.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/remove-certificate.tsx @@ -1,57 +1,56 @@ "use client"; -import { ActionIcon } from "@mantine/core"; import { IconTrash } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmActionIcon } from "@homarr/ui"; interface RemoveCertificateProps { fileName: string; } export const RemoveCertificate = ({ fileName }: RemoveCertificateProps) => { - const { openConfirmModal } = useConfirmModal(); - const { mutateAsync } = clientApi.certificates.removeCertificate.useMutation({ + const { mutateAsync, isPending } = clientApi.certificates.removeCertificate.useMutation({ async onSuccess() { await revalidatePathActionAsync("/manage/tools/certificates"); }, }); const t = useI18n("certificate"); + const tCommon = useI18n("common"); - const handleClick = () => { - openConfirmModal({ - title: t("action.remove.label"), - children: t("action.remove.confirm"), - // eslint-disable-next-line no-restricted-syntax - async onConfirm() { - await mutateAsync( - { fileName }, - { - onSuccess() { - showSuccessNotification({ - title: t("action.remove.notification.success.title"), - message: t("action.remove.notification.success.message"), - }); - }, - onError() { - showErrorNotification({ - title: t("action.remove.notification.error.title"), - message: t("action.remove.notification.error.message"), - }); - }, - }, - ); + const handleRemove = () => + mutateAsync( + { fileName }, + { + onSuccess() { + showSuccessNotification({ + title: t("action.remove.notification.success.title"), + message: t("action.remove.notification.success.message"), + }); + }, + onError() { + showErrorNotification({ + title: t("action.remove.notification.error.title"), + message: t("action.remove.notification.error.message"), + }); + }, }, - }); - }; + ); return ( - <ActionIcon onClick={handleClick} color="red" variant="subtle"> + <InlineConfirmActionIcon + onConfirm={handleRemove} + confirmLabel={tCommon("action.confirm")} + confirmationAriaLabel={tCommon("action.confirm")} + loading={isPending} + color="red" + variant="subtle" + aria-label={t("action.remove.label")} + > <IconTrash color="red" size={16} /> - </ActionIcon> + </InlineConfirmActionIcon> ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/certificates/hostnames/_components/remove-hostname.tsx b/apps/nextjs/src/app/[locale]/manage/tools/certificates/hostnames/_components/remove-hostname.tsx index beacd0913..10c52aff2 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/certificates/hostnames/_components/remove-hostname.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/certificates/hostnames/_components/remove-hostname.tsx @@ -1,13 +1,12 @@ "use client"; -import { ActionIcon } from "@mantine/core"; import { IconTrash } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmActionIcon } from "@homarr/ui"; interface RemoveHostnameActionIconProps { hostname: string; @@ -15,41 +14,41 @@ interface RemoveHostnameActionIconProps { } export const RemoveHostnameActionIcon = (input: RemoveHostnameActionIconProps) => { - const { mutateAsync } = clientApi.certificates.removeTrustedHostname.useMutation({ + const { mutateAsync, isPending } = clientApi.certificates.removeTrustedHostname.useMutation({ async onSuccess() { await revalidatePathActionAsync("/manage/tools/certificates/hostnames"); }, }); - const { openConfirmModal } = useConfirmModal(); const t = useI18n("certificate"); + const tCommon = useI18n("common"); - const handleRemove = () => { - openConfirmModal({ - title: t("action.removeHostname.label"), - children: t("action.removeHostname.confirm"), - // eslint-disable-next-line no-restricted-syntax - async onConfirm() { - await mutateAsync(input, { - onSuccess() { - showSuccessNotification({ - title: t("action.removeHostname.notification.success.title"), - message: t("action.removeHostname.notification.success.message"), - }); - }, - onError() { - showErrorNotification({ - title: t("action.removeHostname.notification.error.title"), - message: t("action.removeHostname.notification.error.message"), - }); - }, + const handleRemove = () => + mutateAsync(input, { + onSuccess() { + showSuccessNotification({ + title: t("action.removeHostname.notification.success.title"), + message: t("action.removeHostname.notification.success.message"), + }); + }, + onError() { + showErrorNotification({ + title: t("action.removeHostname.notification.error.title"), + message: t("action.removeHostname.notification.error.message"), }); }, }); - }; return ( - <ActionIcon color="red" variant="subtle" onClick={handleRemove}> + <InlineConfirmActionIcon + onConfirm={handleRemove} + confirmLabel={tCommon("action.confirm")} + confirmationAriaLabel={tCommon("action.confirm")} + loading={isPending} + color="red" + variant="subtle" + aria-label={t("action.removeHostname.label")} + > <IconTrash color="red" size={16} stroke={1.5} /> - </ActionIcon> + </InlineConfirmActionIcon> ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/certificates/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/certificates/page.tsx index 2f75a8be4..9be9bdb42 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/certificates/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/certificates/page.tsx @@ -13,7 +13,7 @@ import { Link } from "@homarr/ui"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { NoResults } from "~/components/no-results"; -import { AddCertificateButton } from "./_components/add-certificate"; +import { AddCertificateSection } from "./_components/add-certificate"; import { RemoveCertificate } from "./_components/remove-certificate"; interface CertificatesPageProps { @@ -69,10 +69,11 @@ export default async function CertificatesPage({ params }: CertificatesPageProps <Button variant="default" component={Link} href="/manage/tools/certificates/hostnames"> {t("page.list.toHostnames")} </Button> - <AddCertificateButton /> </Group> </Group> + <AddCertificateSection /> + {x509Certificates.length === 0 && ( <NoResults icon={IconCertificateOff} title={t("page.list.noResults.title")} /> )} diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_delete-user-button.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_delete-user-button.tsx index c26e7cf06..2b0417611 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_delete-user-button.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_delete-user-button.tsx @@ -2,20 +2,18 @@ import { useCallback } from "react"; import { useRouter } from "next/navigation"; -import { Button } from "@mantine/core"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useConfirmModal } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmButton } from "@homarr/ui"; interface DeleteUserButtonProps { user: RouterOutputs["user"]["getById"]; } export const DeleteUserButton = ({ user }: DeleteUserButtonProps) => { - const tUser = useI18n("user"); const tCommon = useI18n("common"); const router = useRouter(); const { mutateAsync: mutateUserDeletionAsync } = clientApi.user.delete.useMutation({ @@ -23,27 +21,14 @@ export const DeleteUserButton = ({ user }: DeleteUserButtonProps) => { await revalidatePathActionAsync("/manage/users").then(() => router.push("/manage/users")); }, }); - const { openConfirmModal } = useConfirmModal(); - const handleDelete = useCallback( - () => - openConfirmModal({ - title: tUser("action.delete.label"), - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - children: tUser("action.delete.confirm", { username: user.name! }), - // eslint-disable-next-line no-restricted-syntax - async onConfirm() { - await mutateUserDeletionAsync({ - userId: user.id, - }); - }, - }), - [user, mutateUserDeletionAsync, openConfirmModal, tUser], + () => mutateUserDeletionAsync({ userId: user.id }), + [mutateUserDeletionAsync, user.id], ); return ( - <Button onClick={handleDelete} variant="subtle" color="red"> + <InlineConfirmButton onConfirm={handleDelete} confirmLabel={tCommon("action.confirm")} variant="subtle" color="red"> {tCommon("action.delete")} - </Button> + </InlineConfirmButton> ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-avatar-form.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-avatar-form.tsx index 2a0830a0b..7aa475d2a 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-avatar-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-avatar-form.tsx @@ -8,10 +8,9 @@ import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; -import { UserAvatar } from "@homarr/ui"; +import { InlineConfirmMenuItem, UserAvatar } from "@homarr/ui"; import { useIsMobile } from "@homarr/ui/hooks"; interface UserProfileAvatarForm { @@ -19,7 +18,7 @@ interface UserProfileAvatarForm { } export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => { - const { mutate } = clientApi.user.setProfileImage.useMutation({ + const { mutate, mutateAsync, isPending } = clientApi.user.setProfileImage.useMutation({ async onSuccess() { // Revalidate all as the avatar is used in multiple places await revalidatePathActionAsync("/"); @@ -27,7 +26,6 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => { }); const [opened, { toggle }] = useDisclosure(false); const isMobile = useIsMobile(); - const { openConfirmModal } = useConfirmModal(); const tCommon = useI18n("common"); const tManageAvatar = useI18n("user.action.manageAvatar"); @@ -68,32 +66,28 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => { [mutate, user.id, tManageAvatar], ); - const handleRemoveAvatar = useCallback(() => { - openConfirmModal({ - title: tManageAvatar("removeImage.label"), - children: tManageAvatar("removeImage.confirm"), - onConfirm() { - mutate( - { - userId: user.id, - image: null, + const handleRemoveAvatar = useCallback( + () => + mutateAsync( + { + userId: user.id, + image: null, + }, + { + onSuccess() { + showSuccessNotification({ + message: tManageAvatar("removeImage.notification.success.message"), + }); }, - { - onSuccess() { - showSuccessNotification({ - message: tManageAvatar("removeImage.notification.success.message"), - }); - }, - onError() { - showErrorNotification({ - message: tManageAvatar("removeImage.notification.error.message"), - }); - }, + onError() { + showErrorNotification({ + message: tManageAvatar("removeImage.notification.error.message"), + }); }, - ); - }, - }); - }, [mutate, user.id, openConfirmModal, tManageAvatar]); + }, + ), + [mutateAsync, user.id, tManageAvatar], + ); return ( <Box pos="relative" display="flex" style={{ justifyContent: isMobile ? "center" : undefined }}> @@ -124,9 +118,14 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => { )} </FileButton> {user.image && ( - <Menu.Item onClick={handleRemoveAvatar} leftSection={<IconPhotoX size={16} stroke={1.5} />}> + <InlineConfirmMenuItem + onConfirm={handleRemoveAvatar} + confirmLabel={tCommon("action.confirm")} + disabled={isPending} + leftSection={<IconPhotoX size={16} stroke={1.5} />} + > {tManageAvatar("removeImage.label")} - </Menu.Item> + </InlineConfirmMenuItem> )} </Menu.Dropdown> </Menu> diff --git a/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx b/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx index de48d9ba1..2b01ca6a6 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx @@ -12,7 +12,6 @@ import { everyoneGroup, groupPermissions } from "@homarr/definitions"; import type { IsValid } from "@homarr/form"; import { useZodForm } from "@homarr/form"; import { UserCreatePasswordFields } from "@homarr/forms-collection"; -import { useModalAction } from "@homarr/modals"; import { showErrorNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; import { UserAvatar } from "@homarr/ui"; @@ -20,7 +19,7 @@ import { createCustomErrorParams } from "@homarr/validation/form/i18n"; import { optionalEmailSchema } from "@homarr/validation/email"; import { userPasswordSchema } from "@homarr/validation/user"; -import { GroupSelectModal } from "~/components/access/group-select-modal"; +import { GroupSelect } from "~/components/access/group-select"; import { StepperNavigationComponent } from "./stepper-navigation"; interface GroupWithPermissions { @@ -224,23 +223,6 @@ const GroupsForm = ({ addGroup, removeGroup, initialGroups }: GroupsFormProps) = const tCommon = useI18n("common"); const tPermission = useI18n("permission"); const [groups, { append, filter }] = useListState<GroupWithPermissions>(initialGroups); - const { openModal } = useModalAction(GroupSelectModal); - - const handleAddClick = () => { - openModal({ - presentGroupIds: groups.map((group) => group.id), - withPermissions: true, - onSelect({ id, name, permissions }) { - if (!permissions) return; - - startTransition(() => { - addGroup(id); - append({ id, name, permissions }); - }); - }, - }); - }; - const handleGroupRemove = (id: string) => { filter((group) => group.id !== id); removeGroup(id); @@ -256,14 +238,23 @@ const GroupsForm = ({ addGroup, removeGroup, initialGroups }: GroupsFormProps) = {tCreate("step.groups.description", { everyoneGroup })} </Text> </Stack> - <Button - variant="subtle" - color="gray" - leftSection={<IconPlus size={16} stroke={1.5} />} - onClick={handleAddClick} - > - {tCommon("action.add")} - </Button> + <GroupSelect + presentGroupIds={groups.map((group) => group.id)} + withPermissions + onSelect={({ id, name, permissions }) => { + if (!permissions) return; + + startTransition(() => { + addGroup(id); + append({ id, name, permissions }); + }); + }} + triggerProps={{ + variant: "subtle", + color: "gray", + leftSection: <IconPlus size={16} stroke={1.5} />, + }} + /> </Group> <Table> <Table.Thead> diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_delete-group.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_delete-group.tsx index 7db623644..5946062d8 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_delete-group.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_delete-group.tsx @@ -2,13 +2,13 @@ import { useCallback } from "react"; import { useRouter } from "next/navigation"; -import { Button, useMatches } from "@mantine/core"; +import { useMatches } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmButton } from "@homarr/ui"; interface DeleteGroupProps { group: { @@ -20,46 +20,38 @@ interface DeleteGroupProps { export const DeleteGroup = ({ group }: DeleteGroupProps) => { const router = useRouter(); const { mutateAsync } = clientApi.group.deleteGroup.useMutation(); - const { openConfirmModal } = useConfirmModal(); const tDelete = useI18n("group.action.delete"); const tCommon = useI18n("common"); - const handleDeletion = useCallback(() => { - openConfirmModal({ - title: tDelete("label"), - children: tDelete("confirm", { - name: group.name, - }), - // eslint-disable-next-line no-restricted-syntax - async onConfirm() { - await mutateAsync( - { - id: group.id, + const handleDeletion = useCallback( + () => + mutateAsync( + { + id: group.id, + }, + { + onSuccess() { + void revalidatePathActionAsync("/manage/users/groups"); + router.push("/manage/users/groups"); + showSuccessNotification({ + title: tCommon("notification.delete.success"), + message: tDelete("notification.success.message", { + name: group.name, + }), + }); }, - { - onSuccess() { - void revalidatePathActionAsync("/manage/users/groups"); - router.push("/manage/users/groups"); - showSuccessNotification({ - title: tCommon("notification.delete.success"), - message: tDelete("notification.success.message", { - name: group.name, - }), - }); - }, - onError() { - showErrorNotification({ - title: tCommon("notification.delete.error"), - message: tDelete("notification.error.message", { - name: group.name, - }), - }); - }, + onError() { + showErrorNotification({ + title: tCommon("notification.delete.error"), + message: tDelete("notification.error.message", { + name: group.name, + }), + }); }, - ); - }, - }); - }, [tDelete, tCommon, openConfirmModal, group.id, group.name, mutateAsync, router]); + }, + ), + [tDelete, tCommon, group.id, group.name, mutateAsync, router], + ); const fullWidth = useMatches({ xs: true, @@ -68,8 +60,14 @@ export const DeleteGroup = ({ group }: DeleteGroupProps) => { }); return ( - <Button variant="subtle" color="red" onClick={handleDeletion} fullWidth={fullWidth}> + <InlineConfirmButton + variant="subtle" + color="red" + onConfirm={handleDeletion} + confirmLabel={tCommon("action.confirm")} + fullWidth={fullWidth} + > {tDelete("label")} - </Button> + </InlineConfirmButton> ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_transfer-group-ownership.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_transfer-group-ownership.tsx index 1d0b26b8c..46afda084 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_transfer-group-ownership.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_transfer-group-ownership.tsx @@ -1,15 +1,14 @@ "use client"; -import { useCallback, useState } from "react"; -import { Button, useMatches } from "@mantine/core"; +import { useState } from "react"; +import { Button, Group, Stack, Text, useMatches } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useConfirmModal, useModalAction } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; -import { UserSelectModal } from "~/components/access/user-select-modal"; +import { UserSelect } from "~/components/access/user-select"; interface TransferGroupOwnershipProps { group: { @@ -19,75 +18,87 @@ interface TransferGroupOwnershipProps { }; } +interface SelectedOwner { + id: string; + name: string; +} + export const TransferGroupOwnership = ({ group }: TransferGroupOwnershipProps) => { const tTransfer = useI18n("group.action.transfer"); const tCommon = useI18n("common"); const [innerOwnerId, setInnerOwnerId] = useState(group.ownerId); - const { openModal } = useModalAction(UserSelectModal); - const { openConfirmModal } = useConfirmModal(); - const { mutateAsync } = clientApi.group.transferOwnership.useMutation({ + const [selectedOwner, setSelectedOwner] = useState<SelectedOwner>(); + const { mutateAsync, isPending } = clientApi.group.transferOwnership.useMutation({ async onSuccess() { await revalidatePathActionAsync(`/manage/users/groups/${group.id}`); }, }); - const handleTransfer = useCallback(() => { - openModal( + const handleTransfer = async () => { + if (!selectedOwner) return; + + await mutateAsync( { - confirmLabel: tCommon("action.continue"), - presentUserIds: innerOwnerId ? [innerOwnerId] : [], - onSelect: ({ id, name }) => { - openConfirmModal({ - title: tTransfer("label"), - children: tTransfer("confirm", { - name: group.name, - username: name, + groupId: group.id, + userId: selectedOwner.id, + }, + { + onSuccess() { + setInnerOwnerId(selectedOwner.id); + showSuccessNotification({ + title: tCommon("notification.transfer.success"), + message: tTransfer("notification.success.message", { + group: group.name, + user: selectedOwner.name, }), - // eslint-disable-next-line no-restricted-syntax - onConfirm: async () => { - await mutateAsync( - { - groupId: group.id, - userId: id, - }, - { - onSuccess() { - setInnerOwnerId(id); - showSuccessNotification({ - title: tCommon("notification.transfer.success"), - message: tTransfer("notification.success.message", { - group: group.name, - user: name, - }), - }); - }, - onError() { - showErrorNotification({ - title: tCommon("notification.transfer.error"), - message: tTransfer("notification.error.message"), - }); - }, - }, - ); - }, + }); + setSelectedOwner(undefined); + }, + onError() { + showErrorNotification({ + title: tCommon("notification.transfer.error"), + message: tTransfer("notification.error.message"), }); }, }, - { - title: tTransfer("label"), - }, ); - }, [group.id, group.name, innerOwnerId, mutateAsync, openConfirmModal, openModal, tCommon, tTransfer]); + }; const fullWidth = useMatches({ xs: true, sm: true, md: false, }); + const presentUserIds: string[] = []; + if (innerOwnerId) presentUserIds.push(innerOwnerId); + + if (!selectedOwner) { + return ( + <UserSelect + presentUserIds={presentUserIds} + onSelect={setSelectedOwner} + triggerLabel={tTransfer("label")} + triggerProps={{ variant: "subtle", color: "red", fullWidth }} + /> + ); + } return ( - <Button variant="subtle" color="red" onClick={handleTransfer} fullWidth={fullWidth}> - {tTransfer("label")} - </Button> + <Stack gap="xs" maw={420}> + <Text size="sm"> + {tTransfer("confirm", { + name: group.name, + username: selectedOwner.name, + })} + </Text> + <Group justify="end" wrap="wrap"> + <Button variant="default" onClick={() => setSelectedOwner(undefined)} disabled={isPending}> + {tCommon("action.cancel")} + </Button> + <Button color="red" onClick={() => void handleTransfer()} loading={isPending}> + {tCommon("action.confirm")} + </Button> + </Group> + </Stack> ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_add-group-member.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_add-group-member.tsx index 6585e3205..20d2aa381 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_add-group-member.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_add-group-member.tsx @@ -5,11 +5,9 @@ import { useCallback } from "react"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; import type { SupportedAuthProvider } from "@homarr/definitions"; -import { useModalAction } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; -import { UserSelectModal } from "~/components/access/user-select-modal"; -import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; +import { UserSelect } from "~/components/access/user-select"; interface AddGroupMemberProps { groupId: string; @@ -20,27 +18,23 @@ interface AddGroupMemberProps { export const AddGroupMember = ({ groupId, presentUserIds, allowedProviders }: AddGroupMemberProps) => { const tMembersAdd = useI18n("group.action.addMember"); const { mutateAsync } = clientApi.group.addMember.useMutation(); - const { openModal } = useModalAction(UserSelectModal); + const handleAddMember = useCallback( + async ({ id }: { id: string }) => { + await mutateAsync({ + userId: id, + groupId, + }); + await revalidatePathActionAsync(`/manage/users/groups/${groupId}/members`); + }, + [groupId, mutateAsync], + ); - const handleAddMember = useCallback(() => { - openModal( - { - // eslint-disable-next-line no-restricted-syntax - async onSelect({ id }) { - await mutateAsync({ - userId: id, - groupId, - }); - await revalidatePathActionAsync(`/manage/users/groups/${groupId}}/members`); - }, - presentUserIds, - allowedProviders, - }, - { - title: tMembersAdd("label"), - }, - ); - }, [openModal, presentUserIds, groupId, mutateAsync, tMembersAdd, allowedProviders]); - - return <MobileAffixButton onClick={handleAddMember}>{tMembersAdd("label")}</MobileAffixButton>; + return ( + <UserSelect + presentUserIds={presentUserIds} + allowedProviders={allowedProviders} + onSelect={handleAddMember} + triggerLabel={tMembersAdd("label")} + /> + ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_remove-group-member.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_remove-group-member.tsx index c290bd26a..600df6e81 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_remove-group-member.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_remove-group-member.tsx @@ -1,12 +1,11 @@ "use client"; import { useCallback } from "react"; -import { Button } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useConfirmModal } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmButton } from "@homarr/ui"; interface RemoveGroupMemberProps { groupId: string; @@ -15,30 +14,25 @@ interface RemoveGroupMemberProps { export const RemoveGroupMember = ({ groupId, user }: RemoveGroupMemberProps) => { const tCommon = useI18n("common"); - const tRemoveMember = useI18n("group.action.removeMember"); const { mutateAsync } = clientApi.group.removeMember.useMutation(); - const { openConfirmModal } = useConfirmModal(); - const handleRemove = useCallback(() => { - openConfirmModal({ - title: tRemoveMember("label"), - children: tRemoveMember("confirm", { - user: user.name ?? "", - }), - // eslint-disable-next-line no-restricted-syntax - onConfirm: async () => { - await mutateAsync({ - groupId, - userId: user.id, - }); - await revalidatePathActionAsync(`/manage/users/groups/${groupId}/members`); - }, + const handleRemove = useCallback(async () => { + await mutateAsync({ + groupId, + userId: user.id, }); - }, [openConfirmModal, mutateAsync, groupId, user.id, user.name, tRemoveMember]); + await revalidatePathActionAsync(`/manage/users/groups/${groupId}/members`); + }, [mutateAsync, groupId, user.id]); return ( - <Button variant="subtle" color="red.9" size="compact-sm" onClick={handleRemove}> + <InlineConfirmButton + variant="subtle" + color="red.9" + size="compact-sm" + confirmLabel={tCommon("action.confirm")} + onConfirm={handleRemove} + > {tCommon("action.remove")} - </Button> + </InlineConfirmButton> ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/_client.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/_client.tsx index 57d544ef3..2386d070f 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/_client.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/_client.tsx @@ -1,15 +1,14 @@ "use client"; -import { useMemo, useState } from "react"; -import { TextInput } from "@mantine/core"; +import { useEffect, useMemo, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Button, Card, Collapse, Group, Stack, TextInput } from "@mantine/core"; import { IconSearch } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; -import { useModalAction } from "@homarr/modals"; -import { AddGroupModal } from "@homarr/modals-collection"; import { useI18n } from "@homarr/translation/client"; -import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; +import { GroupCreateForm } from "./_components/group-create-form"; import { GroupsTable } from "./_groups-table"; interface GroupsListProps { @@ -18,6 +17,10 @@ interface GroupsListProps { export const GroupsList = ({ groups }: GroupsListProps) => { const [search, setSearch] = useState(""); + const router = useRouter(); + const searchParams = useSearchParams(); + const [isCreateOpen, setIsCreateOpen] = useState(searchParams.get("create") === "true"); + const [formKey, setFormKey] = useState(0); const initialGroupIds = useMemo( () => groups.sort((groupA, groupB) => groupA.position - groupB.position).map((group) => group.id), [groups], @@ -31,8 +34,46 @@ export const GroupsList = ({ groups }: GroupsListProps) => { ); const tGroup = useI18n("group"); + useEffect(() => { + if (searchParams.get("create") !== "true") return; + + setFormKey((value) => value + 1); + setIsCreateOpen(true); + }, [searchParams]); + + const closeCreate = () => { + setIsCreateOpen(false); + setFormKey((value) => value + 1); + if (searchParams.has("create")) router.replace("/manage/users/groups", { scroll: false }); + }; + + const toggleCreate = () => { + if (isCreateOpen) { + closeCreate(); + return; + } + + setFormKey((value) => value + 1); + setIsCreateOpen(true); + }; + return ( - <> + <Stack> + <Group justify="end"> + <Button onClick={toggleCreate}>{tGroup("action.create.label")}</Button> + </Group> + <Collapse expanded={isCreateOpen}> + <Card withBorder> + <GroupCreateForm + key={formKey} + onCancel={closeCreate} + onCreated={() => { + closeCreate(); + router.refresh(); + }} + /> + </Card> + </Collapse> <TextInput leftSection={<IconSearch size={20} stroke={1.5} />} value={search} @@ -40,13 +81,6 @@ export const GroupsList = ({ groups }: GroupsListProps) => { placeholder={`${tGroup("search")}...`} /> <GroupsTable groups={filteredGroups} initialGroupIds={initialGroupIds} hasFilter={search.length !== 0} /> - </> + </Stack> ); }; - -export const AddGroupButton = () => { - const tGroup = useI18n("group"); - const { openModal } = useModalAction(AddGroupModal); - - return <MobileAffixButton onClick={() => openModal()}>{tGroup("action.create.label")}</MobileAffixButton>; -}; diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/_components/group-create-form.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/_components/group-create-form.tsx new file mode 100644 index 000000000..35f2457cc --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/_components/group-create-form.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { Button, Group, Stack, TextInput } from "@mantine/core"; + +import { clientApi } from "@homarr/api/client"; +import { revalidatePathActionAsync } from "@homarr/common/client"; +import { useZodForm } from "@homarr/form"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; +import { useI18n } from "@homarr/translation/client"; +import { groupCreateSchema } from "@homarr/validation/group"; + +interface GroupCreateFormProps { + onCancel: () => void; + onCreated: () => void; +} + +export const GroupCreateForm = ({ onCancel, onCreated }: GroupCreateFormProps) => { + const tGroup = useI18n("group"); + const tCommon = useI18n("common"); + const form = useZodForm(groupCreateSchema, { + initialValues: { + name: "", + }, + }); + const { mutateAsync, isPending } = clientApi.group.createGroup.useMutation(); + + const handleSubmit = form.onSubmit(async (values) => { + await mutateAsync(values, { + async onSuccess() { + await revalidatePathActionAsync("/manage/users/groups"); + showSuccessNotification({ + title: tCommon("notification.create.success"), + message: tGroup("action.create.notification.success.message"), + }); + onCreated(); + }, + onError() { + showErrorNotification({ + title: tCommon("notification.create.error"), + message: tGroup("action.create.notification.error.message"), + }); + }, + }); + }); + + return ( + <form onSubmit={handleSubmit}> + <Stack> + <TextInput label={tCommon("field.name")} autoFocus {...form.getInputProps("name")} /> + <Group justify="end"> + <Button variant="default" onClick={onCancel}> + {tCommon("action.cancel")} + </Button> + <Button loading={isPending} type="submit"> + {tCommon("action.create")} + </Button> + </Group> + </Stack> + </form> + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/page.tsx index ba9df38e3..eced4a16d 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/page.tsx @@ -9,7 +9,7 @@ import { getI18n } from "@homarr/translation/server"; import { Link } from "@homarr/ui"; import { ManagePageLayout } from "~/components/manage/manage-page-layout"; -import { AddGroupButton, GroupsList } from "./_client"; +import { GroupsList } from "./_client"; import classes from "./groups.module.css"; export default async function GroupsListPage() { @@ -26,7 +26,7 @@ export default async function GroupsListPage() { const groupsWithoutEveryone = groups.filter((group) => group.name !== everyoneGroup); return ( - <ManagePageLayout title={tCommon("entity.groups")} primaryAction={<AddGroupButton />} floatingPrimaryAction> + <ManagePageLayout title={tCommon("entity.groups")}> <Stack> {dbEveryoneGroup && ( <UnstyledButton component={Link} href={`/manage/users/groups/${dbEveryoneGroup.id}`}> diff --git a/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-create-button.tsx b/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-create-button.tsx index f84865ffb..42ca121fd 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-create-button.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-create-button.tsx @@ -2,18 +2,15 @@ import { Button } from "@mantine/core"; -import { useModalAction } from "@homarr/modals"; -import { InviteCreateModal } from "@homarr/modals-collection"; import { useI18n } from "@homarr/translation/client"; import { MANAGE_ACTION_BUTTON_MIN_WIDTH } from "~/components/manage/manage-page.constants"; -export const InviteCreateButton = () => { +export const InviteCreateButton = ({ onClick }: { onClick: () => void }) => { const t = useI18n("management.page.user.invite"); - const { openModal } = useModalAction(InviteCreateModal); return ( - <Button miw={MANAGE_ACTION_BUTTON_MIN_WIDTH} onClick={() => openModal()}> + <Button miw={MANAGE_ACTION_BUTTON_MIN_WIDTH} onClick={onClick}> {t("action.new.title")} </Button> ); diff --git a/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-create-form.tsx b/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-create-form.tsx new file mode 100644 index 000000000..c56aecf0b --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-create-form.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useState } from "react"; +import type { ReactNode } from "react"; +import { Anchor, Button, CopyButton, Group, Mark, Stack, Text } from "@mantine/core"; +import { DateTimePicker } from "@mantine/dates"; +import dayjs from "dayjs"; + +import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; +import { invariantTechnicalLabels } from "@homarr/definitions"; +import { useForm } from "@homarr/form"; +import { useI18n } from "@homarr/translation/client"; + +type FormType = { + expirationDate: string; +}; + +type CreatedInvite = RouterOutputs["invite"]["createInvite"]; + +interface InviteCreateFormProps { + onClose: () => void; +} + +export const InviteCreateForm = ({ onClose }: InviteCreateFormProps) => { + const tInvite = useI18n("management.page.user.invite"); + const tCommon = useI18n("common"); + const [createdInvite, setCreatedInvite] = useState<CreatedInvite>(); + const utils = clientApi.useUtils(); + const { mutate, isPending } = clientApi.invite.createInvite.useMutation(); + const minDate = dayjs().add(1, "hour").toDate(); + const maxDate = dayjs().add(6, "months").toDate(); + const form = useForm<FormType>({ + initialValues: { + expirationDate: dayjs().add(4, "hours").toDate().toISOString(), + }, + }); + + if (createdInvite) { + return <CreatedInviteDetails invite={createdInvite} onClose={onClose} />; + } + + return ( + <form + onSubmit={form.onSubmit((values) => { + mutate( + { + expirationDate: new Date(values.expirationDate), + }, + { + onSuccess: (result) => { + void utils.invite.getAll.invalidate(); + setCreatedInvite(result); + }, + }, + ); + })} + > + <Stack> + <Text>{tInvite("action.new.description")}</Text> + <DateTimePicker + popoverProps={{ withinPortal: true }} + minDate={minDate} + maxDate={maxDate} + withAsterisk + valueFormat="DD MMM YYYY HH:mm" + label={tInvite("field.expirationDate.label")} + variant="filled" + {...form.getInputProps("expirationDate")} + /> + <Group justify="end" wrap="wrap"> + <Button variant="default" onClick={onClose}> + {tCommon("action.cancel")} + </Button> + <Button type="submit" loading={isPending}> + {tCommon("action.create")} + </Button> + </Group> + </Stack> + </form> + ); +}; + +const CreatedInviteDetails = ({ invite, onClose }: { invite: CreatedInvite; onClose: () => void }) => { + const tInvite = useI18n("management.page.user.invite"); + const tCommon = useI18n("common"); + const inviteUrl = createInviteUrl(invite); + + return ( + <Stack component="output" aria-live="polite"> + <Text> + {tInvite.rich("action.copy.description", { + b: bold, + })} + </Text> + <Stack gap="xs"> + <Text fw="bold">{tInvite("action.copy.link")}:</Text> + <Mark style={{ borderRadius: 4, overflowWrap: "anywhere" }} color="gray" px={5}> + <Anchor href={inviteUrl} target="_blank" rel="noreferrer"> + {inviteUrl} + </Anchor> + </Mark> + <Text fw="bold">{invariantTechnicalLabels.id}:</Text> + <Mark style={{ borderRadius: 4, overflowWrap: "anywhere" }} color="gray" px={5}> + {invite.id} + </Mark> + <Text fw="bold">{tInvite("field.token.label")}:</Text> + <Mark style={{ borderRadius: 4, overflowWrap: "anywhere" }} color="gray" px={5}> + {invite.token} + </Mark> + </Stack> + <Group justify="end" wrap="wrap"> + <Button variant="default" onClick={onClose}> + {tCommon("action.close")} + </Button> + <CopyButton value={inviteUrl}> + {({ copy }) => ( + <Button + onClick={() => { + copy(); + onClose(); + }} + > + {tInvite("action.copy.button")} + </Button> + )} + </CopyButton> + </Group> + </Stack> + ); +}; + +const bold = (children: ReactNode) => <b>{children}</b>; + +const createInviteUrl = ({ id, token }: CreatedInvite) => { + const path = `/auth/invite/${id}?token=${token}`; + return new URL(path, window.location.origin).toString(); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-list.tsx b/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-list.tsx index 09ca354d2..7502fa6c2 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-list.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-list.tsx @@ -1,7 +1,8 @@ "use client"; -import { useCallback, useMemo } from "react"; -import { ActionIcon } from "@mantine/core"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Card, Collapse, Group, Stack } from "@mantine/core"; import { IconTrash } from "@tabler/icons-react"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; @@ -11,9 +12,11 @@ import { MantineReactTable } from "mantine-react-table"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { invariantTechnicalLabels } from "@homarr/definitions"; -import { useConfirmModal } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmActionIcon } from "@homarr/ui"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; +import { InviteCreateButton } from "./invite-create-button"; +import { InviteCreateForm } from "./invite-create-form"; dayjs.extend(relativeTime); @@ -23,6 +26,10 @@ interface InviteListComponentProps { export const InviteListComponent = ({ initialInvites }: InviteListComponentProps) => { const t = useI18n("management.page.user.invite"); + const router = useRouter(); + const searchParams = useSearchParams(); + const [isCreateOpen, setIsCreateOpen] = useState(searchParams.get("create") === "true"); + const [formKey, setFormKey] = useState(0); const { data, isLoading } = clientApi.invite.getAll.useQuery(undefined, { initialData: initialInvites, refetchOnMount: false, @@ -73,28 +80,65 @@ export const InviteListComponent = ({ initialInvites }: InviteListComponentProps }, }); - return <MantineReactTable table={table} />; + useEffect(() => { + if (searchParams.get("create") !== "true") return; + + setFormKey((value) => value + 1); + setIsCreateOpen(true); + }, [searchParams]); + + const closeCreate = () => { + setIsCreateOpen(false); + setFormKey((value) => value + 1); + if (searchParams.has("create")) router.replace("/manage/users/invites", { scroll: false }); + }; + + const toggleCreate = () => { + if (isCreateOpen) { + closeCreate(); + return; + } + + setFormKey((value) => value + 1); + setIsCreateOpen(true); + }; + + return ( + <Stack> + <Group justify="end"> + <InviteCreateButton onClick={toggleCreate} /> + </Group> + <Collapse expanded={isCreateOpen}> + <Card withBorder> + <InviteCreateForm key={formKey} onClose={closeCreate} /> + </Card> + </Collapse> + <MantineReactTable table={table} /> + </Stack> + ); }; const RenderRowActions = ({ row }: { row: MRT_Row<RouterOutputs["invite"]["getAll"][number]> }) => { const t = useI18n("management.page.user.invite"); + const tCommon = useI18n("common"); const { mutate, isPending } = clientApi.invite.deleteInvite.useMutation(); const utils = clientApi.useUtils(); - const { openConfirmModal } = useConfirmModal(); const handleDelete = useCallback(() => { - openConfirmModal({ - title: t("action.delete.title"), - children: t("action.delete.description"), - onConfirm: () => { - mutate({ id: row.original.id }); - void utils.invite.getAll.invalidate(); - }, - }); - }, [openConfirmModal, row.original.id, mutate, utils, t]); + mutate({ id: row.original.id }); + void utils.invite.getAll.invalidate(); + }, [row.original.id, mutate, utils]); return ( - <ActionIcon variant="subtle" color="red" onClick={handleDelete} loading={isPending}> + <InlineConfirmActionIcon + variant="subtle" + color="red" + onConfirm={handleDelete} + confirmLabel={tCommon("action.confirm")} + confirmationAriaLabel={tCommon("action.confirm")} + loading={isPending} + aria-label={t("action.delete.title")} + > <IconTrash color="red" size={20} stroke={1.5} /> - </ActionIcon> + </InlineConfirmActionIcon> ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/users/invites/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/invites/page.tsx index 85dea15a8..5f6656e64 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/invites/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/invites/page.tsx @@ -5,9 +5,7 @@ import { auth } from "@homarr/auth/next"; import { isProviderEnabled } from "@homarr/auth/server"; import { getI18n } from "@homarr/translation/server"; -import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action"; import { ManagePageLayout } from "~/components/manage/manage-page-layout"; -import { InviteCreateButton } from "./_components/invite-create-button"; import { InviteListComponent } from "./_components/invite-list"; export default async function InvitesOverviewPage() { @@ -24,15 +22,7 @@ export default async function InvitesOverviewPage() { const initialInvites = await api.invite.getAll(); return ( - <ManagePageLayout - title={t("title")} - primaryAction={ - <ManageMobilePrimaryAction> - <InviteCreateButton /> - </ManageMobilePrimaryAction> - } - floatingPrimaryAction - > + <ManagePageLayout title={t("title")}> <InviteListComponent initialInvites={initialInvites} /> </ManagePageLayout> ); diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx index 551f2d656..b19b71919 100644 --- a/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx +++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx @@ -20,8 +20,8 @@ import { WidgetError } from "@homarr/widgets/errors"; import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import { LazyWidgetEditModal, preloadWidgetEditModal } from "~/components/board/items/lazy-widget-edit-modal"; -import type { Dimensions } from "./_dimension-modal"; -import { PreviewDimensionsModal } from "./_dimension-modal"; +import type { Dimensions } from "./_dimension-popover"; +import { PreviewDimensionsPopover } from "./_dimension-popover"; interface WidgetPreviewPageContentProps { kind: WidgetKind; @@ -32,7 +32,6 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps const t = useI18n(); const utils = clientApi.useUtils(); const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal); - const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal); const { definition: currentDefinition, Component } = use(loadWidgetResources(kind)); const [editMode, setEditMode] = useState(false); const [isEditorLoading, setIsEditorLoading] = useState(false); @@ -98,13 +97,6 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps }); }, [editMode, t]); - const openDimensionsModal = useCallback(() => { - openPreviewDimensionsModal({ - dimensions, - setDimensions, - }); - }, [dimensions, openPreviewDimensionsModal]); - const updateOptions = useCallback( ({ newOptions }: { newOptions: Record<string, unknown> }) => setState((current) => ({ ...current, options: { ...current.options, ...newOptions } })), @@ -162,15 +154,21 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps </ActionIcon> </Affix> <Affix bottom={12} right={72 + 120}> - <ActionIcon - size={48} - variant="default" - radius="xl" - onClick={openDimensionsModal} - aria-label={t("widgetPreview.dimensions.title")} - > - <IconDimensions size={24} /> - </ActionIcon> + <PreviewDimensionsPopover + dimensions={dimensions} + setDimensions={setDimensions} + target={(onClick) => ( + <ActionIcon + size={48} + variant="default" + radius="xl" + aria-label={t("widgetPreview.dimensions.title")} + onClick={onClick} + > + <IconDimensions size={24} /> + </ActionIcon> + )} + /> </Affix> </> ); diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/_dimension-modal.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/_dimension-modal.tsx deleted file mode 100644 index e8466f116..000000000 --- a/apps/nextjs/src/app/[locale]/widgets/[kind]/_dimension-modal.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client"; - -import { Button, Group, InputWrapper, Slider, Stack } from "@mantine/core"; - -import { useForm } from "@homarr/form"; -import { createModal } from "@homarr/modals"; -import { useI18n } from "@homarr/translation/client"; - -interface InnerProps { - dimensions: Dimensions; - setDimensions: (dimensions: Dimensions) => void; -} - -export const PreviewDimensionsModal = createModal<InnerProps>(({ actions, innerProps }) => { - const t = useI18n(); - const form = useForm({ - initialValues: innerProps.dimensions, - }); - - const handleSubmit = (values: Dimensions) => { - innerProps.setDimensions(values); - actions.closeModal(); - }; - - return ( - <form onSubmit={form.onSubmit(handleSubmit)}> - <Stack> - <InputWrapper label={t("item.moveResize.field.width.label")}> - <Slider min={64} max={1024} step={64} {...form.getInputProps("width")} /> - </InputWrapper> - <InputWrapper label={t("item.moveResize.field.height.label")}> - <Slider min={64} max={1024} step={64} {...form.getInputProps("height")} /> - </InputWrapper> - <Group justify="end"> - <Button variant="subtle" color="gray" onClick={actions.closeModal}> - {t("common.action.cancel")} - </Button> - <Button type="submit">{t("common.action.confirm")}</Button> - </Group> - </Stack> - </form> - ); -}).withOptions({ - defaultTitle: (t) => t("widgetPreview.dimensions.title"), -}); - -export interface Dimensions { - width: number; - height: number; -} diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/_dimension-popover.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/_dimension-popover.tsx new file mode 100644 index 000000000..46f3e8f8d --- /dev/null +++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/_dimension-popover.tsx @@ -0,0 +1,78 @@ +"use client"; + +import type { ReactNode } from "react"; +import { Button, Group, InputWrapper, Popover, Slider, Stack } from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; + +import { useForm } from "@homarr/form"; +import { useI18n } from "@homarr/translation/client"; + +interface PreviewDimensionsPopoverProps { + target: (onClick: () => void) => ReactNode; + dimensions: Dimensions; + setDimensions: (dimensions: Dimensions) => void; +} + +export const PreviewDimensionsPopover = ({ target, dimensions, setDimensions }: PreviewDimensionsPopoverProps) => { + const t = useI18n(); + const [opened, { close, open }] = useDisclosure(false); + const form = useForm({ + initialValues: dimensions, + }); + + const handleSubmit = (values: Dimensions) => { + setDimensions(values); + close(); + }; + + const handleClose = () => { + form.setValues(dimensions); + close(); + }; + + const handleToggle = () => { + if (opened) { + handleClose(); + return; + } + open(); + }; + + return ( + <Popover + opened={opened} + onChange={(nextOpened) => { + if (nextOpened) open(); + if (!nextOpened) handleClose(); + }} + position="top-end" + width="min(320px, calc(100vw - 2rem))" + withArrow + > + <Popover.Target>{target(handleToggle)}</Popover.Target> + <Popover.Dropdown> + <form onSubmit={form.onSubmit(handleSubmit)}> + <Stack> + <InputWrapper label={t("item.moveResize.field.width.label")}> + <Slider min={64} max={1024} step={64} {...form.getInputProps("width")} /> + </InputWrapper> + <InputWrapper label={t("item.moveResize.field.height.label")}> + <Slider min={64} max={1024} step={64} {...form.getInputProps("height")} /> + </InputWrapper> + <Group justify="end"> + <Button variant="subtle" color="gray" onClick={handleClose}> + {t("common.action.cancel")} + </Button> + <Button type="submit">{t("common.action.confirm")}</Button> + </Group> + </Stack> + </form> + </Popover.Dropdown> + </Popover> + ); +}; + +export interface Dimensions { + width: number; + height: number; +} diff --git a/apps/nextjs/src/components/access/access-picker.tsx b/apps/nextjs/src/components/access/access-picker.tsx new file mode 100644 index 000000000..98e8b8f04 --- /dev/null +++ b/apps/nextjs/src/components/access/access-picker.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useState } from "react"; +import type { ReactNode } from "react"; +import { Button, Center, Combobox, Group, Loader, ScrollArea, useCombobox } from "@mantine/core"; +import type { ButtonProps } from "@mantine/core"; + +export interface AccessPickerOption<T> { + value: string; + label: string; + keywords?: readonly string[]; + content: ReactNode; + item: T; +} + +export type AccessPickerTriggerProps = Omit<ButtonProps, "children" | "loading" | "onClick" | "type">; + +interface AccessPickerProps<T> { + label: string; + searchPlaceholder: string; + emptyMessage: string; + triggerLabel: string; + options: AccessPickerOption<T>[]; + isPending: boolean; + onSelect: (option: AccessPickerOption<T>) => void | Promise<void>; + triggerProps?: AccessPickerTriggerProps; +} + +export const AccessPicker = <T,>({ + label, + searchPlaceholder, + emptyMessage, + triggerLabel, + options, + isPending, + onSelect, + triggerProps, +}: AccessPickerProps<T>) => { + const [search, setSearch] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const combobox = useCombobox({ + onDropdownClose: () => { + combobox.resetSelectedOption(); + setSearch(""); + requestAnimationFrame(() => combobox.focusTarget()); + }, + onDropdownOpen: () => { + combobox.selectFirstOption(); + requestAnimationFrame(() => combobox.focusSearchInput()); + }, + }); + + const normalizedSearch = search.trim().toLowerCase(); + const filteredOptions = options.filter((option) => { + if (!normalizedSearch) return true; + + const searchableText = [option.label, ...(option.keywords ?? [])].join(" ").toLowerCase(); + return searchableText.includes(normalizedSearch); + }); + const visibleOptions = filteredOptions.slice(0, 5); + + const handleSelect = (value: string) => { + const option = options.find((item) => item.value === value); + if (!option || isSubmitting) return; + + combobox.closeDropdown(); + setIsSubmitting(true); + + const submit = async () => { + try { + await onSelect(option); + } finally { + setIsSubmitting(false); + } + }; + + void submit().catch(() => undefined); + }; + + return ( + <Combobox store={combobox} onOptionSubmit={handleSelect} width="target" withinPortal> + <Combobox.Target targetType="button"> + <Button + {...triggerProps} + onClick={() => combobox.toggleDropdown()} + rightSection={ + <Group gap={4} wrap="nowrap"> + {triggerProps?.rightSection} + <Combobox.Chevron /> + </Group> + } + loading={isSubmitting} + disabled={isSubmitting || triggerProps?.disabled} + > + {triggerLabel} + </Button> + </Combobox.Target> + + <Combobox.Dropdown> + <Combobox.Search + value={search} + onChange={(event) => { + setSearch(event.currentTarget.value); + combobox.updateSelectedOptionIndex(); + }} + placeholder={searchPlaceholder} + aria-label={label} + /> + <ScrollArea.Autosize mah={300} type="auto"> + <Combobox.Options> + {isPending && ( + <Center p="md"> + <LoaderPlaceholder /> + </Center> + )} + {!isPending && filteredOptions.length === 0 && <Combobox.Empty>{emptyMessage}</Combobox.Empty>} + {!isPending && + visibleOptions.map((option) => ( + <Combobox.Option value={option.value} key={option.value}> + {option.content} + </Combobox.Option> + ))} + </Combobox.Options> + </ScrollArea.Autosize> + </Combobox.Dropdown> + </Combobox> + ); +}; + +const LoaderPlaceholder = () => <Loader size="sm" />; diff --git a/apps/nextjs/src/components/access/group-access-form.tsx b/apps/nextjs/src/components/access/group-access-form.tsx index 4963887f6..d71495e56 100644 --- a/apps/nextjs/src/components/access/group-access-form.tsx +++ b/apps/nextjs/src/components/access/group-access-form.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { Anchor, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core"; import { IconPlus } from "@tabler/icons-react"; -import { useModalAction } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; import { Link } from "@homarr/ui"; @@ -11,7 +10,7 @@ import { AccessSelectRow } from "./access-table-rows"; import { useAccessContext } from "./context"; import type { AccessFormType } from "./form"; import { FormProvider, useForm } from "./form"; -import { GroupSelectModal } from "./group-select-modal"; +import { GroupSelect } from "./group-select"; import type { FormProps } from "./user-access-form"; export const GroupAccessForm = <TPermission extends string>({ @@ -24,8 +23,6 @@ export const GroupAccessForm = <TPermission extends string>({ const [groups, setGroups] = useState<Map<string, AccessQueryData<string>["groups"][number]["group"]>>( new Map(accessQueryData.groups.map(({ group }) => [group.id, group])), ); - const { openModal } = useModalAction(GroupSelectModal); - const tCommon = useI18n("common"); const tPermissions = useI18n("permission"); const form = useForm({ initialValues: { @@ -36,23 +33,6 @@ export const GroupAccessForm = <TPermission extends string>({ }, }); - const handleAddUser = () => { - openModal({ - presentGroupIds: form.values.items.map(({ principalId: id }) => id), - onSelect: (group) => { - setGroups((prev) => new Map(prev).set(group.id, group)); - form.setFieldValue("items", [ - { - principalId: group.id, - permission: defaultPermission, - }, - ...form.values.items, - ]); - handleCountChange((prev) => prev + 1); - }, - }); - }; - return ( <form onSubmit={form.onSubmit((values) => handleSubmit(values as AccessFormType<TPermission>))}> <FormProvider form={form}> @@ -79,9 +59,21 @@ export const GroupAccessForm = <TPermission extends string>({ </Table> <Group justify="space-between"> - <Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}> - {tCommon("action.add")} - </Button> + <GroupSelect + presentGroupIds={form.values.items.map(({ principalId: id }) => id)} + onSelect={(group) => { + setGroups((prev) => new Map(prev).set(group.id, group)); + form.setFieldValue("items", [ + { + principalId: group.id, + permission: defaultPermission, + }, + ...form.values.items, + ]); + handleCountChange((prev) => prev + 1); + }} + triggerProps={{ rightSection: <IconPlus size="1rem" />, variant: "light" }} + /> <Button type="submit" loading={isPending}> {tPermissions("action.saveGroup")} </Button> diff --git a/apps/nextjs/src/components/access/group-select-modal.tsx b/apps/nextjs/src/components/access/group-select-modal.tsx deleted file mode 100644 index d66f9f733..000000000 --- a/apps/nextjs/src/components/access/group-select-modal.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { useState } from "react"; -import { Button, Group, Loader, Select, Stack } from "@mantine/core"; - -import { clientApi } from "@homarr/api/client"; -import type { GroupPermissionKey } from "@homarr/definitions"; -import { useForm } from "@homarr/form"; -import { createModal } from "@homarr/modals"; -import { useI18n } from "@homarr/translation/client"; - -interface InnerProps { - withPermissions?: boolean; - presentGroupIds: string[]; - onSelect: (props: { id: string; name: string; permissions?: GroupPermissionKey[] }) => void | Promise<void>; - confirmLabel?: string; -} - -interface GroupSelectFormType { - groupId: string; -} - -export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }) => { - const tGroup = useI18n("group.action.select"); - const tCommon = useI18n("common"); - const { data: groups, isPending } = clientApi.group.selectable.useQuery({ - withPermissions: innerProps.withPermissions, - }); - const [loading, setLoading] = useState(false); - const form = useForm<GroupSelectFormType>(); - const handleSubmitAsync = async (values: GroupSelectFormType) => { - const currentGroup = groups?.find((group) => group.id === values.groupId); - if (!currentGroup) return; - setLoading(true); - await innerProps.onSelect({ - id: currentGroup.id, - name: currentGroup.name, - permissions: "permissions" in currentGroup ? (currentGroup.permissions as GroupPermissionKey[]) : undefined, - }); - - setLoading(false); - actions.closeModal(); - }; - - const confirmLabel = innerProps.confirmLabel ?? tCommon("action.add"); - - return ( - <form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}> - <Stack> - <Select - {...form.getInputProps("groupId")} - label={tGroup("label")} - clearable - searchable - leftSection={isPending ? <Loader size="xs" /> : undefined} - nothingFoundMessage={tGroup("notFound")} - limit={5} - data={groups - ?.filter((group) => !innerProps.presentGroupIds.includes(group.id)) - .map((group) => ({ value: group.id, label: group.name }))} - /> - <Group justify="end"> - <Button variant="default" onClick={actions.closeModal}> - {tCommon("action.cancel")} - </Button> - <Button type="submit" loading={loading}> - {confirmLabel} - </Button> - </Group> - </Stack> - </form> - ); -}).withOptions({ - defaultTitle: (t) => t("permission.groupSelect.title"), -}); diff --git a/apps/nextjs/src/components/access/group-select.tsx b/apps/nextjs/src/components/access/group-select.tsx new file mode 100644 index 000000000..476c96224 --- /dev/null +++ b/apps/nextjs/src/components/access/group-select.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { Text } from "@mantine/core"; + +import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; +import type { GroupPermissionKey } from "@homarr/definitions"; +import { useI18n } from "@homarr/translation/client"; + +import type { AccessPickerOption, AccessPickerTriggerProps } from "./access-picker"; +import { AccessPicker } from "./access-picker"; + +type SelectableGroup = RouterOutputs["group"]["selectable"][number]; + +interface GroupSelectProps { + withPermissions?: boolean; + presentGroupIds: string[]; + onSelect: (group: { id: string; name: string; permissions?: GroupPermissionKey[] }) => void | Promise<void>; + triggerLabel?: string; + triggerProps?: AccessPickerTriggerProps; +} + +export const GroupSelect = ({ + withPermissions, + presentGroupIds, + onSelect, + triggerLabel, + triggerProps, +}: GroupSelectProps) => { + const tGroup = useI18n("group.action.select"); + const tCommon = useI18n("common"); + const { data: groups, isPending } = clientApi.group.selectable.useQuery({ withPermissions }); + const availableGroups = (groups ?? []).filter((group) => !presentGroupIds.includes(group.id)); + const options: AccessPickerOption<SelectableGroup>[] = availableGroups.map((group) => ({ + value: group.id, + label: group.name, + keywords: getGroupPermissions(group) ?? [], + item: group, + content: <Text size="sm">{group.name}</Text>, + })); + + const handleSelect = ({ item: group }: AccessPickerOption<SelectableGroup>) => + onSelect({ + id: group.id, + name: group.name, + permissions: getGroupPermissions(group), + }); + + return ( + <AccessPicker + label={tGroup("label")} + searchPlaceholder={tGroup("label")} + emptyMessage={tGroup("notFound")} + triggerLabel={triggerLabel ?? tCommon("action.add")} + triggerProps={triggerProps} + options={options} + isPending={isPending} + onSelect={handleSelect} + /> + ); +}; + +const getGroupPermissions = (group: SelectableGroup) => { + if ("permissions" in group) return group.permissions as GroupPermissionKey[]; + return undefined; +}; diff --git a/apps/nextjs/src/components/access/user-access-form.tsx b/apps/nextjs/src/components/access/user-access-form.tsx index af85d1e61..8f7126992 100644 --- a/apps/nextjs/src/components/access/user-access-form.tsx +++ b/apps/nextjs/src/components/access/user-access-form.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { Anchor, Box, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core"; import { IconPlus } from "@tabler/icons-react"; -import { useModalAction } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; import { Link, UserAvatar } from "@homarr/ui"; @@ -11,7 +10,7 @@ import { AccessDisplayRow, AccessSelectRow } from "./access-table-rows"; import { useAccessContext } from "./context"; import type { AccessFormType, HandleCountChange } from "./form"; import { FormProvider, useForm } from "./form"; -import { UserSelectModal } from "./user-select-modal"; +import { UserSelect } from "./user-select"; export interface FormProps<TPermission extends string> { entity: { @@ -41,8 +40,6 @@ export const UsersAccessForm = <TPermission extends string>({ const [users, setUsers] = useState<Map<string, UserItemContentProps["user"]>>( new Map(accessQueryData.users.map(({ user }) => [user.id, user])), ); - const { openModal } = useModalAction(UserSelectModal); - const tCommon = useI18n("common"); const tPermissions = useI18n("permission"); const form = useForm({ initialValues: { @@ -52,25 +49,8 @@ export const UsersAccessForm = <TPermission extends string>({ })), }, }); - - const handleAddUser = () => { - const presentUserIds = form.values.items.map(({ principalId: id }) => id); - - openModal({ - presentUserIds: entity.ownerId ? presentUserIds.concat(entity.ownerId) : presentUserIds, - onSelect: (user) => { - setUsers((prev) => new Map(prev).set(user.id, user)); - form.setFieldValue("items", [ - { - principalId: user.id, - permission: defaultPermission, - }, - ...form.values.items, - ]); - handleCountChange((prev) => prev + 1); - }, - }); - }; + const presentUserIds = form.values.items.map(({ principalId: id }) => id); + if (entity.ownerId) presentUserIds.push(entity.ownerId); return ( <form onSubmit={form.onSubmit((values) => handleSubmit(values as AccessFormType<TPermission>))}> @@ -101,9 +81,21 @@ export const UsersAccessForm = <TPermission extends string>({ </Table> <Group justify="space-between"> - <Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}> - {tCommon("action.add")} - </Button> + <UserSelect + presentUserIds={presentUserIds} + onSelect={(user) => { + setUsers((prev) => new Map(prev).set(user.id, user)); + form.setFieldValue("items", [ + { + principalId: user.id, + permission: defaultPermission, + }, + ...form.values.items, + ]); + handleCountChange((prev) => prev + 1); + }} + triggerProps={{ rightSection: <IconPlus size="1rem" />, variant: "light" }} + /> <Button type="submit" loading={isPending}> {tPermissions("action.saveUser")} </Button> diff --git a/apps/nextjs/src/components/access/user-select-modal.tsx b/apps/nextjs/src/components/access/user-select-modal.tsx deleted file mode 100644 index 8ba1be5ec..000000000 --- a/apps/nextjs/src/components/access/user-select-modal.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { useState } from "react"; -import type { SelectProps } from "@mantine/core"; -import { Button, Group, Loader, Select, Stack } from "@mantine/core"; -import { IconCheck } from "@tabler/icons-react"; - -import type { RouterOutputs } from "@homarr/api"; -import { clientApi } from "@homarr/api/client"; -import type { SupportedAuthProvider } from "@homarr/definitions"; -import { useForm } from "@homarr/form"; -import { createModal } from "@homarr/modals"; -import { useI18n } from "@homarr/translation/client"; -import { UserAvatar } from "@homarr/ui"; - -interface InnerProps { - presentUserIds: string[]; - allowedProviders?: SupportedAuthProvider[]; - onSelect: (props: { id: string; name: string; image: string; email: string | null }) => void | Promise<void>; - confirmLabel?: string; -} - -interface UserSelectFormType { - userId: string; -} - -export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps }) => { - const tUser = useI18n("user.action.select"); - const tCommon = useI18n("common"); - const { data: users, isPending } = clientApi.user.selectable.useQuery({ - providers: innerProps.allowedProviders, - }); - const [loading, setLoading] = useState(false); - const form = useForm<UserSelectFormType>(); - const handleSubmitAsync = async (values: UserSelectFormType) => { - const currentUser = users?.find((user) => user.id === values.userId); - if (!currentUser) return; - setLoading(true); - await innerProps.onSelect({ - id: currentUser.id, - name: currentUser.name ?? "", - image: currentUser.image ?? "", - email: currentUser.email ?? null, - }); - - setLoading(false); - actions.closeModal(); - }; - - const confirmLabel = innerProps.confirmLabel ?? tCommon("action.add"); - const currentUser = users?.find((user) => user.id === form.values.userId); - - return ( - <form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}> - <Stack> - <Select - {...form.getInputProps("userId")} - label={tUser("label")} - searchable - clearable - leftSection={ - isPending ? <Loader size="xs" /> : currentUser ? <UserAvatar user={currentUser} size="xs" /> : undefined - } - nothingFoundMessage={tUser("notFound")} - renderOption={createRenderOption(users ?? [])} - limit={5} - data={users - ?.filter((user) => !innerProps.presentUserIds.includes(user.id)) - .map((user) => ({ value: user.id, label: user.name ?? "" }))} - /> - <Group justify="end"> - <Button variant="default" onClick={actions.closeModal}> - {tCommon("action.cancel")} - </Button> - <Button type="submit" loading={loading}> - {confirmLabel} - </Button> - </Group> - </Stack> - </form> - ); -}).withOptions({ - defaultTitle: (t) => t("permission.userSelect.title"), -}); - -const iconProps = { - stroke: 1.5, - color: "currentColor", - opacity: 0.6, - size: "1rem", -}; - -const createRenderOption = (users: RouterOutputs["user"]["selectable"]): SelectProps["renderOption"] => - function InnerRenderRoot({ option, checked }) { - const user = users.find((user) => user.id === option.value); - if (!user) return null; - - return ( - <Group flex="1" gap="xs"> - <UserAvatar user={user} size="xs" /> - {option.label} - {checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />} - </Group> - ); - }; diff --git a/apps/nextjs/src/components/access/user-select.tsx b/apps/nextjs/src/components/access/user-select.tsx new file mode 100644 index 000000000..c38bca1ce --- /dev/null +++ b/apps/nextjs/src/components/access/user-select.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { Group, Stack, Text } from "@mantine/core"; + +import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; +import type { SupportedAuthProvider } from "@homarr/definitions"; +import { useI18n } from "@homarr/translation/client"; +import { UserAvatar } from "@homarr/ui"; + +import type { AccessPickerOption, AccessPickerTriggerProps } from "./access-picker"; +import { AccessPicker } from "./access-picker"; + +type SelectableUser = RouterOutputs["user"]["selectable"][number]; + +interface UserSelectProps { + presentUserIds: string[]; + allowedProviders?: SupportedAuthProvider[]; + onSelect: (user: { id: string; name: string; image: string; email: string | null }) => void | Promise<void>; + triggerLabel?: string; + triggerProps?: AccessPickerTriggerProps; +} + +export const UserSelect = ({ + presentUserIds, + allowedProviders, + onSelect, + triggerLabel, + triggerProps, +}: UserSelectProps) => { + const tUser = useI18n("user.action.select"); + const tCommon = useI18n("common"); + const { data: users, isPending } = clientApi.user.selectable.useQuery({ providers: allowedProviders }); + const availableUsers = (users ?? []).filter((user) => !presentUserIds.includes(user.id)); + const options: AccessPickerOption<SelectableUser>[] = availableUsers.map((user) => ({ + value: user.id, + label: user.name ?? "", + keywords: [user.email ?? ""], + item: user, + content: ( + <Group wrap="nowrap" gap="xs"> + <UserAvatar user={user} size="xs" /> + <Stack gap={0}> + <Text size="sm">{user.name}</Text> + {user.email && ( + <Text size="xs" c="dimmed"> + {user.email} + </Text> + )} + </Stack> + </Group> + ), + })); + + const handleSelect = ({ item: user }: AccessPickerOption<SelectableUser>) => + onSelect({ + id: user.id, + name: user.name ?? "", + image: user.image ?? "", + email: user.email ?? null, + }); + + return ( + <AccessPicker + label={tUser("label")} + searchPlaceholder={tUser("label")} + emptyMessage={tUser("notFound")} + triggerLabel={triggerLabel ?? tCommon("action.add")} + triggerProps={triggerProps} + options={options} + isPending={isPending} + onSelect={handleSelect} + /> + ); +}; diff --git a/apps/nextjs/src/components/assistant/assistant-panel.tsx b/apps/nextjs/src/components/assistant/assistant-panel.tsx index a8b8eea9b..df27b7856 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.tsx +++ b/apps/nextjs/src/components/assistant/assistant-panel.tsx @@ -130,8 +130,8 @@ import { useTimeAgo } from "@homarr/common"; import { assistantProviderIds, assistantProviderPresets, assistantReasoningModes } from "@homarr/definitions"; import type { AssistantProvider } from "@homarr/definitions"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; -import { useConfirmModal } from "@homarr/modals"; import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client"; +import { InlineConfirmActionIcon } from "@homarr/ui"; import classes from "./assistant-panel.module.css"; import { getAssistantActivityState } from "./assistant-activity-state"; @@ -2001,7 +2001,6 @@ const ThreadListItem = () => { const t = useI18n("assistant"); const actionT = useI18n("common.action"); const aui = useAui(); - const { openConfirmModal } = useConfirmModal(); const onSelect = useContext(HistorySelectContext); const remoteId = useAuiState((state) => state.threadListItem.remoteId); const title = useAuiState((state) => state.threadListItem.title); @@ -2054,23 +2053,15 @@ const ThreadListItem = () => { } }; - const deleteConversation = () => { - openConfirmModal({ - title: t("deleteConversation.title"), - children: t("deleteConversation.description", { title: title ?? t("newConversation") }), - confirmProps: { color: "red" }, - labels: { confirm: actionT("delete"), cancel: actionT("cancel") }, - onConfirm: async () => { - try { - await aui.threadListItem().delete(); - } catch { - showErrorNotification({ - title: t("deleteConversation.failedTitle"), - message: t("deleteConversation.failedDescription"), - }); - } - }, - }); + const deleteConversation = async () => { + try { + await aui.threadListItem().delete(); + } catch { + showErrorNotification({ + title: t("deleteConversation.failedTitle"), + message: t("deleteConversation.failedDescription"), + }); + } }; if (renameOpened) { @@ -2152,16 +2143,19 @@ const ThreadListItem = () => { > <IconFileExport size={14} /> </ActionIcon> - <ActionIcon + <InlineConfirmActionIcon variant="subtle" color="red" size="sm" title={actionT("delete")} aria-label={actionT("delete")} - onClick={deleteConversation} + confirmLabel={t("deleteConversation.description", { title: title ?? t("newConversation") })} + confirmationAriaLabel={t("deleteConversation.title")} + confirmationChildren={<IconCheck size={14} />} + onConfirm={deleteConversation} > <IconTrash size={14} /> - </ActionIcon> + </InlineConfirmActionIcon> </Group> )} <ActionIcon diff --git a/apps/nextjs/src/components/board/items/item-menu.tsx b/apps/nextjs/src/components/board/items/item-menu.tsx index 1b18dd5a9..a0e14285c 100644 --- a/apps/nextjs/src/components/board/items/item-menu.tsx +++ b/apps/nextjs/src/components/board/items/item-menu.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { ActionIcon, Menu } from "@mantine/core"; -import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil } from "@tabler/icons-react"; +import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react"; import { useSession } from "@homarr/auth/client"; import { useEditMode } from "@homarr/boards/edit-mode"; @@ -8,14 +8,14 @@ import { getWidgetName } from "@homarr/definitions"; import { useModalAction } from "@homarr/modals"; import { useSettings } from "@homarr/settings"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmMenuItem } from "@homarr/ui"; import type { WidgetDefinition } from "@homarr/widgets/definition"; import type { SectionItem } from "~/app/[locale]/boards/_types"; -import { BoardRemoveConfirmationMenuItem } from "../remove-confirmation-menu-item"; import { useSectionContext } from "../sections/section-context"; import { useItemActions } from "./item-actions"; -import { useOpenItemMoveModal } from "./item-move-modal"; import itemContentClasses from "./item-content.module.css"; +import { useOpenItemMoveModal } from "./item-move-modal"; import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal"; interface BoardItemMenuProps { @@ -142,11 +142,13 @@ const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBou </Menu.Item> )} <Menu.Divider /> - <BoardRemoveConfirmationMenuItem - label={tItem("action.remove")} - confirmationLabel={tItem("remove.message")} + <InlineConfirmMenuItem + confirmLabel={tItem("remove.message")} + leftSection={<IconTrash size={16} />} onConfirm={() => removeItem({ itemId: item.id })} - /> + > + {tItem("action.remove")} + </InlineConfirmMenuItem> </Menu.Dropdown> </Menu> ); diff --git a/apps/nextjs/src/components/board/modals/board-rename-modal.tsx b/apps/nextjs/src/components/board/modals/board-rename-modal.tsx deleted file mode 100644 index 4e91146fb..000000000 --- a/apps/nextjs/src/components/board/modals/board-rename-modal.tsx +++ /dev/null @@ -1,70 +0,0 @@ -"use client"; - -import { Button, Group, Stack, TextInput } from "@mantine/core"; -import type { z } from "zod/v4"; - -import { clientApi } from "@homarr/api/client"; -import { useZodForm } from "@homarr/form"; -import { createModal } from "@homarr/modals"; -import { useI18n } from "@homarr/translation/client"; -import { boardRenameSchema } from "@homarr/validation/board"; - -interface InnerProps { - id: string; - previousName: string; - onSuccess?: (name: string) => void; -} - -export const BoardRenameModal = createModal<InnerProps>(({ actions, innerProps }) => { - const utils = clientApi.useUtils(); - const tCommon = useI18n("common"); - const { mutate, isPending } = clientApi.board.renameBoard.useMutation({ - onSettled() { - void utils.board.getBoardByName.invalidate({ - name: innerProps.previousName, - }); - void utils.board.getHomeBoard.invalidate(); - void utils.board.getAllBoards.invalidate(); - }, - }); - const form = useZodForm(boardRenameSchema.omit({ id: true }), { - initialValues: { - name: innerProps.previousName, - }, - }); - - const handleSubmit = (values: FormType) => { - mutate( - { - id: innerProps.id, - name: values.name, - }, - { - onSuccess: () => { - actions.closeModal(); - innerProps.onSuccess?.(values.name); - }, - }, - ); - }; - - return ( - <form onSubmit={form.onSubmit(handleSubmit)}> - <Stack> - <TextInput label={tCommon("field.name")} {...form.getInputProps("name")} data-autofocus /> - <Group justify="end"> - <Button variant="subtle" color="gray" onClick={actions.closeModal}> - {tCommon("action.cancel")} - </Button> - <Button type="submit" loading={isPending}> - {tCommon("action.confirm")} - </Button> - </Group> - </Stack> - </form> - ); -}).withOptions({ - defaultTitle: (t) => t("board.setting.section.dangerZone.action.rename.label"), -}); - -type FormType = Omit<z.infer<typeof boardRenameSchema>, "id">; diff --git a/apps/nextjs/src/components/board/sections/container/container-menu.tsx b/apps/nextjs/src/components/board/sections/container/container-menu.tsx index dfbc36f94..882d132e4 100644 --- a/apps/nextjs/src/components/board/sections/container/container-menu.tsx +++ b/apps/nextjs/src/components/board/sections/container/container-menu.tsx @@ -1,12 +1,12 @@ import { ActionIcon, Menu } from "@mantine/core"; -import { IconArrowsMove, IconLayoutKanban, IconPencil } from "@tabler/icons-react"; +import { IconArrowsMove, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react"; import { useEditMode } from "@homarr/boards/edit-mode"; import { useModalAction } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmMenuItem } from "@homarr/ui"; import type { ContainerSectionItem } from "~/app/[locale]/boards/_types"; -import { BoardRemoveConfirmationMenuItem } from "../../remove-confirmation-menu-item"; import { useOpenItemMoveModal } from "../../items/item-move-modal"; import { useSectionContext } from "../section-context"; import { useContainerActions } from "./container-actions"; @@ -59,11 +59,13 @@ export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem {tItem("action.moveResize")} </Menu.Item> <Menu.Divider /> - <BoardRemoveConfirmationMenuItem - label={tContainer("action.remove")} - confirmationLabel={tContainer("remove.message")} + <InlineConfirmMenuItem + confirmLabel={tContainer("remove.message")} + leftSection={<IconTrash size={16} />} onConfirm={() => removeContainer({ id: section.id })} - /> + > + {tContainer("action.remove")} + </InlineConfirmMenuItem> </Menu.Dropdown> </Menu> ); diff --git a/apps/nextjs/src/components/workshop/workshop-report-form.tsx b/apps/nextjs/src/components/workshop/workshop-report-form.tsx new file mode 100644 index 000000000..e770d0b7c --- /dev/null +++ b/apps/nextjs/src/components/workshop/workshop-report-form.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useState } from "react"; +import { Alert, Box, Button, CloseButton, Collapse, Group, Paper, Select, Stack, Text, Textarea } from "@mantine/core"; + +import { showSuccessNotification } from "@homarr/notifications"; +import { useI18n } from "@homarr/translation/client"; +import type { WorkshopBackend } from "@homarr/workshop/backend"; +import { useWorkshopReportMutation } from "@homarr/workshop/backend"; +import type { WorkshopReport } from "@homarr/workshop/schema"; + +const categories = ["outdated", "malicious", "spam", "copyright", "inappropriate", "other"] as const; + +interface WorkshopReportFormProps { + client: WorkshopBackend; + submissionId: string; + opened: boolean; + onClose(): void; +} + +export function WorkshopReportForm({ client, submissionId, opened, onClose }: WorkshopReportFormProps) { + const t = useI18n("workshop"); + const tCommon = useI18n("common"); + const [category, setCategory] = useState<WorkshopReport["category"]>("other"); + const [explanation, setExplanation] = useState(""); + const report = useWorkshopReportMutation(client); + + const close = () => { + setCategory("other"); + setExplanation(""); + report.reset(); + onClose(); + }; + + const submit = () => + report.mutate( + { submission: submissionId, category, explanation }, + { + onSuccess: () => { + showSuccessNotification({ + title: t("reportSent"), + message: t("reportSentDescription"), + }); + close(); + }, + }, + ); + + return ( + <Collapse id="workshop-report-form" expanded={opened}> + <Paper p="md"> + <form + onSubmit={(event) => { + event.preventDefault(); + submit(); + }} + > + <Stack> + <Group justify="space-between" align="flex-start" wrap="nowrap"> + <Box> + <Text fw={600}>{t("reportTitle")}</Text> + <Text size="sm" c="dimmed"> + {t("reportExplanation")} + </Text> + </Box> + <CloseButton aria-label={tCommon("action.close")} onClick={close} /> + </Group> + <Select + label={t("reportReason")} + value={category} + onChange={(value) => setCategory((value as WorkshopReport["category"] | null) ?? "other")} + data={categories.map((value) => ({ + value, + label: t(`reportCategory.${value}`), + }))} + allowDeselect={false} + /> + <Textarea + label={t("reportExplanation")} + minRows={4} + autosize + value={explanation} + onChange={(event) => setExplanation(event.currentTarget.value)} + /> + {report.error && <Alert color="red">{report.error.message}</Alert>} + <Group justify="flex-end" gap="sm"> + <Button variant="default" onClick={close}> + {tCommon("action.cancel")} + </Button> + <Button type="submit" color="red" loading={report.isPending} disabled={explanation.trim().length < 3}> + {t("reportSend")} + </Button> + </Group> + </Stack> + </form> + </Paper> + </Collapse> + ); +} diff --git a/apps/nextjs/src/components/workshop/workshop-report-modal.tsx b/apps/nextjs/src/components/workshop/workshop-report-modal.tsx deleted file mode 100644 index 89d447571..000000000 --- a/apps/nextjs/src/components/workshop/workshop-report-modal.tsx +++ /dev/null @@ -1,80 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { Alert, Button, Group, Modal, Select, Stack, Textarea } from "@mantine/core"; - -import { showSuccessNotification } from "@homarr/notifications"; -import { useI18n } from "@homarr/translation/client"; -import type { WorkshopBackend } from "@homarr/workshop/backend"; -import { useWorkshopReportMutation } from "@homarr/workshop/backend"; -import type { WorkshopReport } from "@homarr/workshop/schema"; - -const categories = ["outdated", "malicious", "spam", "copyright", "inappropriate", "other"] as const; - -interface WorkshopReportModalProps { - client: WorkshopBackend; - submissionId: string; - opened: boolean; - onClose(): void; -} - -export function WorkshopReportModal({ client, submissionId, opened, onClose }: WorkshopReportModalProps) { - const t = useI18n("workshop"); - const tCommon = useI18n("common"); - const [category, setCategory] = useState<WorkshopReport["category"]>("other"); - const [explanation, setExplanation] = useState(""); - const report = useWorkshopReportMutation(client); - - const close = () => { - setExplanation(""); - report.reset(); - onClose(); - }; - - const submit = () => - report.mutate( - { submission: submissionId, category, explanation }, - { - onSuccess: () => { - showSuccessNotification({ - title: t("reportSent"), - message: t("reportSentDescription"), - }); - close(); - }, - }, - ); - - return ( - <Modal opened={opened} onClose={close} title={t("reportTitle")} centered radius="md"> - <Stack> - <Select - label={t("reportReason")} - value={category} - onChange={(value) => setCategory((value as WorkshopReport["category"] | null) ?? "other")} - data={categories.map((value) => ({ - value, - label: t(`reportCategory.${value}`), - }))} - allowDeselect={false} - /> - <Textarea - label={t("reportExplanation")} - minRows={4} - autosize - value={explanation} - onChange={(event) => setExplanation(event.currentTarget.value)} - /> - {report.error && <Alert color="red">{report.error.message}</Alert>} - <Group justify="flex-end" gap="sm"> - <Button variant="default" onClick={close}> - {tCommon("action.cancel")} - </Button> - <Button color="red" loading={report.isPending} disabled={explanation.trim().length < 3} onClick={submit}> - {t("reportSend")} - </Button> - </Group> - </Stack> - </Modal> - ); -} diff --git a/package.json b/package.json index 347e76aa0..477724057 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "lint": "turbo lint --continue", "lint:fix": "turbo lint --continue -- --fix", "lint:ws": "pnpm dlx sherif@latest", + "qa:browser-agent:ux": "bash scripts/browser-agent/ux-coherence-happy-paths.sh", "release": "semantic-release", "scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts", "scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts", diff --git a/packages/modals-collection/src/boards/add-board-modal.tsx b/packages/forms-collection/src/board-create/board-create-form.tsx similarity index 61% rename from packages/modals-collection/src/boards/add-board-modal.tsx rename to packages/forms-collection/src/board-create/board-create-form.tsx index cf2a4848a..e96a13597 100644 --- a/packages/modals-collection/src/boards/add-board-modal.tsx +++ b/packages/forms-collection/src/board-create/board-create-form.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useRouter } from "next/navigation"; import { Button, Group, InputWrapper, Slider, Stack, Switch, TextInput } from "@mantine/core"; import { useDebouncedValue } from "@mantine/hooks"; @@ -6,12 +8,16 @@ import { IconAlertTriangle, IconCircleCheck } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; import { useZodForm } from "@homarr/form"; -import { createModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; +import type { TablerIcon } from "@homarr/ui"; import { boardColumnCountSchema, boardCreateSchema, boardNameSchema } from "@homarr/validation/board"; -export const AddBoardModal = createModal(({ actions }) => { +interface BoardCreateFormProps { + onCancel?: () => void; +} + +export const BoardCreateForm = ({ onCancel }: BoardCreateFormProps) => { const tBoard = useI18n("board"); const tCommon = useI18n("common"); const router = useRouter(); @@ -38,7 +44,6 @@ export const AddBoardModal = createModal(({ actions }) => { if (!boardNameStatus.canSubmit) return; mutate(values, { onSuccess: (result) => { - actions.closeModal(); showSuccessNotification({ title: tBoard("action.create.notification.success.title"), message: tBoard("action.create.notification.success.message", { name: values.name }), @@ -56,24 +61,17 @@ export const AddBoardModal = createModal(({ actions }) => { }); })} > - <Stack> + <Stack maw={720}> <TextInput label={tCommon("field.name")} - data-autofocus + withAsterisk + autoFocus {...form.getInputProps("name")} - description={ - boardNameStatus.description ? ( - <span> - <Group c={boardNameStatus.description.color} gap="xs" align="center"> - {boardNameStatus.description.icon ? <boardNameStatus.description.icon size={16} /> : null} - <span>{boardNameStatus.description.label}</span> - </Group> - </span> - ) : null - } + description={<BoardNameAvailability status={boardNameStatus} />} /> <InputWrapper label={tBoard("field.columnCount.label")} {...form.getInputProps("columnCount")}> <Slider + aria-label={tBoard("field.columnCount.label")} min={boardColumnCountSchema.minValue ?? undefined} max={boardColumnCountSchema.maxValue ?? undefined} step={1} @@ -87,50 +85,71 @@ export const AddBoardModal = createModal(({ actions }) => { {...form.getInputProps("isPublic")} /> - <Group justify="right"> - <Button onClick={actions.closeModal} variant="subtle" color="gray"> - {tCommon("action.cancel")} - </Button> - <Button type="submit" loading={isPending}> + <Group justify="end" wrap="wrap"> + {onCancel && ( + <Button variant="default" onClick={onCancel}> + {tCommon("action.cancel")} + </Button> + )} + <Button type="submit" loading={isPending} disabled={!boardNameStatus.canSubmit}> {tCommon("action.create")} </Button> </Group> </Stack> </form> ); -}).withOptions({ - defaultTitle: (t) => t("management.page.board.action.new.label"), -}); +}; export const useBoardNameStatus = (name: string) => { const tBoard = useI18n("board"); const tCommon = useI18n("common"); const [debouncedName] = useDebouncedValue(name, 250); + const isValidName = boardNameSchema.safeParse(name).success; + const isDebouncing = name !== debouncedName; const { data: boardExists, isLoading } = clientApi.board.exists.useQuery(debouncedName, { enabled: boardNameSchema.safeParse(debouncedName).success, }); + let description: { label: string; icon?: TablerIcon; color?: string } | undefined; + + if (debouncedName.trim() !== "") { + if (isDebouncing || isLoading) { + description = { + label: tBoard("action.create.availability.checking"), + }; + } else if (boardExists !== undefined) { + if (boardExists) { + description = { + icon: IconAlertTriangle, + label: tCommon("zod.errors.custom.boardAlreadyExists"), + color: "red", + }; + } else { + description = { + icon: IconCircleCheck, + label: tBoard("action.create.availability.available", { name: debouncedName }), + color: "green", + }; + } + } + } return { - canSubmit: !boardExists && !isLoading, - description: - debouncedName.trim() === "" - ? undefined - : isLoading - ? { - label: tBoard("action.create.availability.checking"), - } - : boardExists === undefined - ? undefined - : boardExists - ? { - icon: IconAlertTriangle, - label: tCommon("zod.errors.custom.boardAlreadyExists"), // The board ${debouncedName} already exists - color: "red", - } - : { - icon: IconCircleCheck, - label: tBoard("action.create.availability.available", { name: debouncedName }), - color: "green", - }, + canSubmit: isValidName && !isDebouncing && boardExists === false && !isLoading, + description, }; }; + +export type BoardNameStatus = ReturnType<typeof useBoardNameStatus>; + +export const BoardNameAvailability = ({ status }: { status: BoardNameStatus }) => { + if (!status.description) return null; + + const Icon = status.description.icon; + + return ( + <Group component="output" c={status.description.color} gap="xs" align="center" aria-live="polite"> + {Icon && <Icon size={16} />} + <span>{status.description.label}</span> + </Group> + ); +}; diff --git a/packages/forms-collection/src/index.tsx b/packages/forms-collection/src/index.tsx index 0dfaf90ca..6253a1d2c 100644 --- a/packages/forms-collection/src/index.tsx +++ b/packages/forms-collection/src/index.tsx @@ -1,6 +1,8 @@ export * from "./new-app/_app-new-form"; export * from "./new-app/_form"; +export * from "./board-create/board-create-form"; + export * from "./icon-picker/icon-picker"; export * from "./upload-media/upload-media"; diff --git a/packages/modals-collection/src/boards/index.ts b/packages/modals-collection/src/boards/index.ts deleted file mode 100644 index ff591c8ac..000000000 --- a/packages/modals-collection/src/boards/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { AddBoardModal } from "./add-board-modal"; -export { DuplicateBoardModal } from "./duplicate-board-modal"; diff --git a/packages/modals-collection/src/certificates/add-certificate-modal.tsx b/packages/modals-collection/src/certificates/add-certificate-modal.tsx deleted file mode 100644 index b414d0c6a..000000000 --- a/packages/modals-collection/src/certificates/add-certificate-modal.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { Button, FileInput, Group, Stack } from "@mantine/core"; -import { IconCertificate } from "@tabler/icons-react"; -import { z } from "zod/v4"; - -import { clientApi } from "@homarr/api/client"; -import type { MaybePromise } from "@homarr/common/types"; -import { useZodForm } from "@homarr/form"; -import { createModal } from "@homarr/modals"; -import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; -import { useI18n } from "@homarr/translation/client"; -import { checkCertificateFile } from "@homarr/validation/certificates"; - -interface InnerProps { - onSuccess?: () => MaybePromise<void>; -} - -export const AddCertificateModal = createModal<InnerProps>(({ actions, innerProps }) => { - const t = useI18n(); - const form = useZodForm( - z.object({ - file: z.file().check(checkCertificateFile), - }), - { - initialValues: { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - file: null!, - }, - }, - ); - const { mutateAsync } = clientApi.certificates.addCertificate.useMutation({ - async onSuccess() { - await innerProps.onSuccess?.(); - }, - }); - - return ( - <form - onSubmit={form.onSubmit(async (values) => { - const formData = new FormData(); - formData.set("file", values.file); - await mutateAsync(formData, { - onSuccess() { - showSuccessNotification({ - title: t("certificate.action.create.notification.success.title"), - message: t("certificate.action.create.notification.success.message"), - }); - actions.closeModal(); - }, - onError() { - showErrorNotification({ - title: t("certificate.action.create.notification.error.title"), - message: t("certificate.action.create.notification.error.message"), - }); - }, - }); - })} - > - <Stack> - <FileInput leftSection={<IconCertificate size={16} />} {...form.getInputProps("file")} /> - <Group justify="end"> - <Button onClick={actions.closeModal} variant="subtle" color="gray"> - {t("common.action.cancel")} - </Button> - <Button type="submit" loading={form.submitting}> - {t("common.action.add")} - </Button> - </Group> - </Stack> - </form> - ); -}).withOptions({ - defaultTitle(t) { - return t("certificate.action.create.label"); - }, -}); diff --git a/packages/modals-collection/src/certificates/index.ts b/packages/modals-collection/src/certificates/index.ts deleted file mode 100644 index 0bdf423ea..000000000 --- a/packages/modals-collection/src/certificates/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./add-certificate-modal"; diff --git a/packages/modals-collection/src/groups/add-group-modal.tsx b/packages/modals-collection/src/groups/add-group-modal.tsx deleted file mode 100644 index 817ce0072..000000000 --- a/packages/modals-collection/src/groups/add-group-modal.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Button, Group, Stack, TextInput } from "@mantine/core"; - -import { clientApi } from "@homarr/api/client"; -import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useZodForm } from "@homarr/form"; -import { createModal } from "@homarr/modals"; -import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; -import { useI18n } from "@homarr/translation/client"; -import { groupCreateSchema } from "@homarr/validation/group"; - -export const AddGroupModal = createModal<void>(({ actions }) => { - const tGroup = useI18n("group"); - const tCommon = useI18n("common"); - const { mutate, isPending } = clientApi.group.createGroup.useMutation(); - const form = useZodForm(groupCreateSchema, { - initialValues: { - name: "", - }, - }); - - return ( - <form - onSubmit={form.onSubmit((values) => { - mutate(values, { - onSuccess() { - actions.closeModal(); - void revalidatePathActionAsync("/manage/users/groups"); - showSuccessNotification({ - title: tCommon("notification.create.success"), - message: tGroup("action.create.notification.success.message"), - }); - }, - onError() { - showErrorNotification({ - title: tCommon("notification.create.error"), - message: tGroup("action.create.notification.error.message"), - }); - }, - }); - })} - > - <Stack> - <TextInput label={tCommon("field.name")} data-autofocus {...form.getInputProps("name")} /> - <Group justify="right"> - <Button onClick={actions.closeModal} variant="subtle" color="gray"> - {tCommon("action.cancel")} - </Button> - <Button loading={isPending} type="submit"> - {tCommon("action.create")} - </Button> - </Group> - </Stack> - </form> - ); -}).withOptions({ - defaultTitle: (t) => t("group.action.create.label"), -}); diff --git a/packages/modals-collection/src/groups/index.ts b/packages/modals-collection/src/groups/index.ts deleted file mode 100644 index 428bea2ec..000000000 --- a/packages/modals-collection/src/groups/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AddGroupModal } from "./add-group-modal"; diff --git a/packages/modals-collection/src/index.ts b/packages/modals-collection/src/index.ts index 02f9f3bee..6403ccd40 100644 --- a/packages/modals-collection/src/index.ts +++ b/packages/modals-collection/src/index.ts @@ -1,7 +1,3 @@ -export * from "./boards"; -export * from "./invites"; -export * from "./groups"; export * from "./search-engines"; export * from "./docker"; export * from "./apps"; -export * from "./certificates"; diff --git a/packages/modals-collection/src/invites/index.ts b/packages/modals-collection/src/invites/index.ts deleted file mode 100644 index f55df7d2b..000000000 --- a/packages/modals-collection/src/invites/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { InviteCopyModal } from "./invite-copy-modal"; -export { InviteCreateModal } from "./invite-create-modal"; diff --git a/packages/modals-collection/src/invites/invite-copy-modal.tsx b/packages/modals-collection/src/invites/invite-copy-modal.tsx deleted file mode 100644 index 199c0d74d..000000000 --- a/packages/modals-collection/src/invites/invite-copy-modal.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { usePathname } from "next/navigation"; -import { Button, CopyButton, Mark, Stack, Text } from "@mantine/core"; - -import type { RouterOutputs } from "@homarr/api"; -import { invariantTechnicalLabels } from "@homarr/definitions"; -import { createModal } from "@homarr/modals"; -import { useI18n } from "@homarr/translation/client"; -import { Link } from "@homarr/ui"; - -export const InviteCopyModal = createModal<RouterOutputs["invite"]["createInvite"]>(({ actions, innerProps }) => { - const t = useI18n("management.page.user.invite"); - const inviteUrl = useInviteUrl(innerProps); - - return ( - <Stack> - <Text> - {t.rich("action.copy.description", { - b: (children) => <b>{children}</b>, - })} - </Text> - <Link href={createPath(innerProps)}>{t("action.copy.link")}</Link> - <Stack gap="xs"> - <Text fw="bold">{invariantTechnicalLabels.id}:</Text> - <Mark style={{ borderRadius: 4 }} color="gray" px={5}> - {innerProps.id} - </Mark> - - <Text fw="bold">{t("field.token.label")}:</Text> - <Mark style={{ borderRadius: 4 }} color="gray" px={5}> - {innerProps.token} - </Mark> - </Stack> - <CopyButton value={inviteUrl}> - {({ copy }) => ( - <Button - onClick={() => { - copy(); - actions.closeModal(); - }} - variant="default" - fullWidth - > - {t("action.copy.button")} - </Button> - )} - </CopyButton> - </Stack> - ); -}).withOptions({ - defaultTitle(t) { - return t("management.page.user.invite.action.copy.title"); - }, -}); - -const createPath = ({ id, token }: RouterOutputs["invite"]["createInvite"]) => `/auth/invite/${id}?token=${token}`; - -const useInviteUrl = ({ id, token }: RouterOutputs["invite"]["createInvite"]) => { - const pathname = usePathname(); - - return window.location.href.replace(pathname, createPath({ id, token })); -}; diff --git a/packages/modals-collection/src/invites/invite-create-modal.tsx b/packages/modals-collection/src/invites/invite-create-modal.tsx deleted file mode 100644 index 23958f017..000000000 --- a/packages/modals-collection/src/invites/invite-create-modal.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { Button, Group, Stack, Text } from "@mantine/core"; -import { DateTimePicker } from "@mantine/dates"; -import dayjs from "dayjs"; -import relativeTime from "dayjs/plugin/relativeTime"; - -import { clientApi } from "@homarr/api/client"; -import { useForm } from "@homarr/form"; -import { createModal, useModalAction } from "@homarr/modals"; -import { useI18n } from "@homarr/translation/client"; - -import { InviteCopyModal } from "./invite-copy-modal"; - -dayjs.extend(relativeTime); - -interface FormType { - expirationDate: string; -} - -export const InviteCreateModal = createModal<void>(({ actions }) => { - const tInvite = useI18n("management.page.user.invite"); - const t = useI18n(); - const { openModal } = useModalAction(InviteCopyModal); - - const utils = clientApi.useUtils(); - const { mutate, isPending } = clientApi.invite.createInvite.useMutation(); - const minDate = dayjs().add(1, "hour").toDate(); - const maxDate = dayjs().add(6, "months").toDate(); - - const form = useForm<FormType>({ - initialValues: { - expirationDate: dayjs().add(4, "hours").toDate().toISOString(), - }, - }); - - const handleSubmit = (values: FormType) => { - mutate( - { - expirationDate: new Date(values.expirationDate), - }, - { - onSuccess: (result) => { - void utils.invite.getAll.invalidate(); - actions.closeModal(); - openModal(result); - }, - }, - ); - }; - - return ( - <form onSubmit={form.onSubmit(handleSubmit)}> - <Stack> - <Text>{tInvite("action.new.description")}</Text> - - <DateTimePicker - popoverProps={{ withinPortal: true }} - minDate={minDate} - maxDate={maxDate} - withAsterisk - valueFormat="DD MMM YYYY HH:mm" - label={tInvite("field.expirationDate.label")} - variant="filled" - {...form.getInputProps("expirationDate")} - /> - - <Group justify="end"> - <Button onClick={actions.closeModal} variant="subtle" color="gray"> - {t("common.action.cancel")} - </Button> - <Button type="submit" loading={isPending}> - {t("common.action.create")} - </Button> - </Group> - </Stack> - </form> - ); -}).withOptions({ - defaultTitle(t) { - return t("management.page.user.invite.action.new.title"); - }, -}); diff --git a/packages/spotlight/src/modes/command/global-group.tsx b/packages/spotlight/src/modes/command/global-group.tsx index 58622ef47..eb17047d7 100644 --- a/packages/spotlight/src/modes/command/global-group.tsx +++ b/packages/spotlight/src/modes/command/global-group.tsx @@ -12,8 +12,6 @@ import { import { useSession } from "@homarr/auth/client"; import type { GroupPermissionKey } from "@homarr/definitions"; -import { useModalAction } from "@homarr/modals"; -import { AddBoardModal, AddGroupModal, InviteCreateModal } from "@homarr/modals-collection"; import { useI18n } from "@homarr/translation/client"; import type { TablerIcon } from "@homarr/ui"; @@ -78,16 +76,7 @@ export const globalCommandGroup = createGroup<Command>({ commandKey: "newBoard", icon: IconCategoryPlus, name: tOption("newBoard.label"), - useInteraction() { - const { openModal } = useModalAction(AddBoardModal); - - return { - type: "javaScript", - onSelect() { - openModal(undefined); - }, - }; - }, + useInteraction: interaction.link(() => ({ href: "/manage/boards?create=true" })), hidden: !session?.user.permissions.includes("board-create"), }, { @@ -115,32 +104,14 @@ export const globalCommandGroup = createGroup<Command>({ commandKey: "newInvite", icon: IconMailForward, name: tOption("newInvite.label"), - useInteraction() { - const { openModal } = useModalAction(InviteCreateModal); - - return { - type: "javaScript", - onSelect() { - openModal(undefined); - }, - }; - }, + useInteraction: interaction.link(() => ({ href: "/manage/users/invites?create=true" })), hidden: !session?.user.permissions.includes("admin"), }, { commandKey: "newGroup", icon: IconUsersGroup, name: tOption("newGroup.label"), - useInteraction() { - const { openModal } = useModalAction(AddGroupModal); - - return { - type: "javaScript", - onSelect() { - openModal(undefined); - }, - }; - }, + useInteraction: interaction.link(() => ({ href: "/manage/users/groups?create=true" })), hidden: !session?.user.permissions.includes("admin"), }, ]; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index a9dfff623..8ebdd3511 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1783,6 +1783,7 @@ "deleteNamed": "Delete {name}", "clearSearch": "Clear search", "copy": "Copy", + "copied": "Copied", "refresh": "Refresh" }, "here": "here", diff --git a/packages/ui/src/components/index.tsx b/packages/ui/src/components/index.tsx index 776f12837..99fc5378e 100644 --- a/packages/ui/src/components/index.tsx +++ b/packages/ui/src/components/index.tsx @@ -1,5 +1,6 @@ export * from "./count-badge"; export { OverflowBadge } from "./overflow-badge"; +export { InlineConfirmActionIcon, InlineConfirmButton, InlineConfirmMenuItem } from "./inline-confirm-action"; export { SearchInput } from "./search-input"; export * from "./select-with-description"; export * from "./select-with-description-and-badge"; diff --git a/packages/ui/src/components/inline-confirm-action.tsx b/packages/ui/src/components/inline-confirm-action.tsx new file mode 100644 index 000000000..ad355e4ad --- /dev/null +++ b/packages/ui/src/components/inline-confirm-action.tsx @@ -0,0 +1,239 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import { ActionIcon, Button, Menu } from "@mantine/core"; +import type { ActionIconProps, ButtonProps, ElementProps, MenuItemProps } from "@mantine/core"; +import { IconCheck } from "@tabler/icons-react"; + +const confirmationTimeout = 4_000; + +const visuallyHidden = { + border: 0, + clip: "rect(0 0 0 0)", + height: 1, + margin: -1, + overflow: "hidden", + padding: 0, + position: "absolute" as const, + whiteSpace: "nowrap" as const, + width: 1, +}; + +interface InlineConfirmActionOptions { + confirmLabel: ReactNode; + onConfirm: () => void | Promise<void>; + disabled?: boolean; + pending?: boolean; + timeout?: number; +} + +const useInlineConfirmAction = ({ + confirmLabel, + onConfirm, + disabled = false, + pending = false, + timeout = confirmationTimeout, +}: InlineConfirmActionOptions) => { + const [confirming, setConfirming] = useState(false); + const [submitting, setSubmitting] = useState(false); + const actionRef = useRef<HTMLButtonElement>(null); + const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); + + const reset = useCallback(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = null; + setConfirming(false); + }, []); + + useEffect(() => { + if (!confirming) return; + + const handlePointerDown = (event: PointerEvent) => { + if (event.target instanceof Node && !actionRef.current?.contains(event.target)) reset(); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") reset(); + }; + + document.addEventListener("pointerdown", handlePointerDown, true); + document.addEventListener("keydown", handleKeyDown, true); + timeoutRef.current = setTimeout(reset, timeout); + + return () => { + document.removeEventListener("pointerdown", handlePointerDown, true); + document.removeEventListener("keydown", handleKeyDown, true); + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = null; + }; + }, [confirming, reset, timeout]); + + useEffect(() => { + if (disabled || pending) reset(); + }, [disabled, pending, reset]); + + const handleClick = useCallback(async () => { + if (disabled || pending || submitting) return; + if (!confirming) { + setConfirming(true); + return; + } + + setSubmitting(true); + try { + await onConfirm(); + } finally { + setSubmitting(false); + reset(); + } + }, [confirming, disabled, onConfirm, pending, reset, submitting]); + + const handleBlur = useCallback(() => reset(), [reset]); + + return { + actionRef, + confirming, + confirmLabel, + handleBlur, + handleClick, + reset, + submitting, + }; +}; + +type InlineConfirmButtonProps = Omit<ButtonProps, "children" | "disabled" | "loading" | "onClick"> & + ElementProps<"button", "color" | "disabled" | "onClick"> & + InlineConfirmActionOptions & { + children: ReactNode; + loading?: boolean; + }; + +export const InlineConfirmButton = ({ + children, + confirmLabel, + onConfirm, + disabled, + pending, + loading, + timeout, + variant, + ...props +}: InlineConfirmButtonProps) => { + const action = useInlineConfirmAction({ confirmLabel, onConfirm, disabled, pending: pending || loading, timeout }); + const label = action.confirming ? confirmLabel : children; + let nextVariant = variant; + if (action.confirming) nextVariant = "filled"; + + return ( + <> + <Button + {...props} + ref={action.actionRef} + variant={nextVariant} + disabled={disabled || pending || action.submitting} + loading={loading || action.submitting} + onClick={() => void action.handleClick().catch(() => undefined)} + onBlur={action.handleBlur} + aria-label={typeof label === "string" ? label : undefined} + data-confirming={action.confirming || undefined} + > + {label} + </Button> + <span aria-live="polite" style={visuallyHidden}> + {action.confirming ? confirmLabel : null} + </span> + </> + ); +}; + +type InlineConfirmActionIconProps = Omit<ActionIconProps, "children" | "disabled" | "loading" | "onClick"> & + InlineConfirmActionOptions & { + "aria-label"?: string; + children: ReactNode; + confirmationAriaLabel?: string; + confirmationChildren?: ReactNode; + loading?: boolean; + title?: string; + }; + +export const InlineConfirmActionIcon = ({ + children, + confirmLabel, + confirmationAriaLabel, + confirmationChildren, + onConfirm, + disabled, + pending, + loading, + timeout, + variant, + "aria-label": ariaLabel, + ...props +}: InlineConfirmActionIconProps) => { + const action = useInlineConfirmAction({ confirmLabel, onConfirm, disabled, pending: pending || loading, timeout }); + const nextAriaLabel = action.confirming + ? (confirmationAriaLabel ?? (typeof confirmLabel === "string" ? confirmLabel : ariaLabel)) + : ariaLabel; + let nextVariant = variant; + if (action.confirming) nextVariant = "filled"; + let nextChildren = children; + if (action.confirming) nextChildren = confirmationChildren ?? <IconCheck size="1rem" />; + + return ( + <> + <ActionIcon + {...props} + ref={action.actionRef} + variant={nextVariant} + disabled={disabled || pending || action.submitting} + loading={loading || action.submitting} + onClick={() => void action.handleClick().catch(() => undefined)} + onBlur={action.handleBlur} + aria-label={nextAriaLabel} + data-confirming={action.confirming || undefined} + > + {nextChildren} + </ActionIcon> + <span aria-live="polite" style={visuallyHidden}> + {action.confirming ? confirmLabel : null} + </span> + </> + ); +}; + +type InlineConfirmMenuItemProps = Omit<MenuItemProps, "children" | "closeMenuOnClick" | "disabled" | "onClick"> & + InlineConfirmActionOptions & { + children: ReactNode; + }; + +export const InlineConfirmMenuItem = ({ + children, + confirmLabel, + onConfirm, + disabled, + pending, + timeout, + ...props +}: InlineConfirmMenuItemProps) => { + const action = useInlineConfirmAction({ confirmLabel, onConfirm, disabled, pending, timeout }); + const label = action.confirming ? confirmLabel : children; + + return ( + <> + <Menu.Item + {...props} + ref={action.actionRef} + disabled={disabled || pending || action.submitting} + closeMenuOnClick={action.confirming} + onClick={() => void action.handleClick().catch(() => undefined)} + aria-label={typeof label === "string" ? label : undefined} + data-confirming={action.confirming || undefined} + > + {label} + </Menu.Item> + <span aria-live="polite" style={visuallyHidden}> + {action.confirming ? confirmLabel : null} + </span> + </> + ); +}; diff --git a/packages/widgets/src/_inputs/form.ts b/packages/widgets/src/_inputs/form.ts index 207eee02d..a0199b55f 100644 --- a/packages/widgets/src/_inputs/form.ts +++ b/packages/widgets/src/_inputs/form.ts @@ -4,5 +4,4 @@ import { createFormContext } from "@homarr/form"; import type { WidgetEditModalState } from "../modals/widget-edit-modal"; -export const [FormProvider, useFormContext, useForm] = - createFormContext<Omit<WidgetEditModalState, "advancedOptions">>(); +export const [FormProvider, useFormContext, useForm] = createFormContext<WidgetEditModalState>(); diff --git a/packages/widgets/src/_inputs/widget-location-input.spec.tsx b/packages/widgets/src/_inputs/widget-location-input.spec.tsx index 5595f8884..9eeb9b194 100644 --- a/packages/widgets/src/_inputs/widget-location-input.spec.tsx +++ b/packages/widgets/src/_inputs/widget-location-input.spec.tsx @@ -1,5 +1,7 @@ // @vitest-environment jsdom +/* eslint-disable unicorn/consistent-function-scoping -- Component mocks are intentionally colocated with their module factory. */ + import { act } from "react"; import type { ReactNode } from "react"; import { createRoot } from "react-dom/client"; @@ -30,6 +32,7 @@ vi.mock("@mantine/core", () => { return { Button: Container, + Collapse: Container, Fieldset: Container, Group: Container, NumberInput: Input, @@ -39,11 +42,6 @@ vi.mock("@mantine/core", () => { }; }); -vi.mock("@homarr/modals", () => ({ - createModal: () => ({ withOptions: () => null }), - useModalAction: () => ({ openModal: vi.fn() }), -})); - vi.mock("@homarr/translation/client", () => ({ useCurrentIntlLocale: () => "en", useI18n: () => (key: string) => key, diff --git a/packages/widgets/src/_inputs/widget-location-input.tsx b/packages/widgets/src/_inputs/widget-location-input.tsx index 9f1a26b1a..eb874b427 100644 --- a/packages/widgets/src/_inputs/widget-location-input.tsx +++ b/packages/widgets/src/_inputs/widget-location-input.tsx @@ -1,17 +1,18 @@ "use client"; -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { ActionIcon, Alert, Anchor, Button, + Collapse, Fieldset, Group, Loader, NumberInput, + Paper, Stack, - Table, Text, TextInput, Tooltip, @@ -20,7 +21,6 @@ import { IconClick, IconListSearch } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; -import { createModal, useModalAction } from "@homarr/modals"; import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client"; import { formatLocalizedCompactNumber } from "../common/locale"; @@ -33,7 +33,7 @@ export const WidgetLocationInput = ({ property, kind, options }: CommonWidgetInp const t = useWidgetInputTranslation(kind, property); const tLocation = useI18n("widget.common.location"); const form = useFormContext(); - const { openModal } = useModalAction(LocationSearchModal); + const [searchQuery, setSearchQuery] = useState<string | null>(null); const fieldPath = `options.${property}`; const inputProps = form.getInputProps(fieldPath); const value = isOptionLocation(inputProps.value) ? inputProps.value : options.defaultValue; @@ -58,26 +58,23 @@ export const WidgetLocationInput = ({ property, kind, options }: CommonWidgetInp handleChange(location); form.clearFieldError(`options.${property}.latitude`); form.clearFieldError(`options.${property}.longitude`); + setSearchQuery(null); }, [form, handleChange, property], ); const onSearch = useCallback(() => { if (!selectionEnabled) return; + setSearchQuery(value.name); + }, [selectionEnabled, value.name]); - openModal({ - query: value.name, - onLocationSelect, - }); - }, [selectionEnabled, value.name, onLocationSelect, openModal]); - - form.watch(`options.${property}.latitude`, ({ value }) => { - if (typeof value !== "number") return; + form.watch(`options.${property}.latitude`, ({ value: nextValue }) => { + if (typeof nextValue !== "number") return; form.setFieldValue(`options.${property}.name`, unknownLocation); }); - form.watch(`options.${property}.longitude`, ({ value }) => { - if (typeof value !== "number") return; + form.watch(`options.${property}.longitude`, ({ value: nextValue }) => { + if (typeof nextValue !== "number") return; form.setFieldValue(`options.${property}.name`, unknownLocation); }); @@ -121,6 +118,16 @@ export const WidgetLocationInput = ({ property, kind, options }: CommonWidgetInp value={longitudeInputProps.value ?? value.longitude} /> </Group> + + <Collapse expanded={searchQuery !== null}> + {searchQuery && ( + <LocationSearchResults + query={searchQuery} + onLocationSelect={onLocationSelect} + onClose={() => setSearchQuery(null)} + /> + )} + </Collapse> </Stack> </Fieldset> ); @@ -143,124 +150,123 @@ type LocationOnChange = ( }, ) => void; -interface LocationSearchInnerProps { +interface LocationSearchResultsProps { query: string; onLocationSelect: (location: OptionLocation) => void; + onClose: () => void; } -const LocationSearchModal = createModal<LocationSearchInnerProps>(({ actions, innerProps }) => { - const t = useI18n("widget.common.location.table"); +const LocationSearchResults = ({ query, onLocationSelect, onClose }: LocationSearchResultsProps) => { + const tLocation = useI18n("widget.common.location"); const tCommon = useI18n("common"); - const { data, isPending, error } = clientApi.location.searchCity.useQuery({ - query: innerProps.query, - }); + const { data, isPending, error, refetch } = clientApi.location.searchCity.useQuery({ query }); if (error) { return ( - <Alert title={tCommon("error")} color="red"> - {tCommon("action.tryAgain")} - </Alert> + <Stack gap="xs"> + <Alert title={tCommon("error")} color="red"> + <Button variant="light" color="red" size="compact-sm" onClick={() => void refetch()}> + {tCommon("action.tryAgain")} + </Button> + </Alert> + <Group justify="right"> + <Button variant="default" size="compact-sm" onClick={onClose}> + {tCommon("action.close")} + </Button> + </Group> + </Stack> ); } return ( - <Stack> - <Table striped> - <Table.Thead> - <Table.Tr> - <Table.Th style={{ width: "70%" }}>{t("header.city")}</Table.Th> - <Table.Th style={{ width: "50%" }}>{t("header.country")}</Table.Th> - <Table.Th>{t("header.coordinates")}</Table.Th> - <Table.Th>{t("header.population")}</Table.Th> - <Table.Th style={{ width: 40 }} /> - </Table.Tr> - </Table.Thead> - <Table.Tbody> - {isPending && ( - <Table.Tr> - <Table.Td colSpan={5}> - <Group justify="center"> - <Loader /> - </Group> - </Table.Td> - </Table.Tr> - )} - {data?.results.map((city) => ( - <LocationSelectTableRow - key={city.id} - city={city} - onLocationSelect={innerProps.onLocationSelect} - closeModal={actions.closeModal} - /> - ))} - </Table.Tbody> - </Table> + <Stack component="section" aria-label={tLocation("search")} gap="xs"> + <Stack gap={4} mah={320} style={{ overflow: "hidden auto" }}> + {isPending && ( + <Group justify="center" py="md"> + <Loader /> + </Group> + )} + {!isPending && data?.results.length === 0 && ( + <Text ta="center" c="dimmed" py="md"> + {tCommon("noResults")} + </Text> + )} + {data?.results.map((city) => ( + <LocationSelectResult key={city.id} city={city} onLocationSelect={onLocationSelect} /> + ))} + </Stack> <Group justify="right"> - <Button variant="light" onClick={actions.closeModal}> - {tCommon("action.cancel")} + <Button variant="default" size="compact-sm" onClick={onClose}> + {tCommon("action.close")} </Button> </Group> </Stack> ); -}).withOptions({ - defaultTitle(t) { - return t("widget.common.location.search"); - }, - size: "xl", -}); +}; -interface LocationSearchTableRowProps { +interface LocationSelectResultProps { city: RouterOutputs["location"]["searchCity"]["results"][number]; onLocationSelect: (location: OptionLocation) => void; - closeModal: () => void; } -const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: LocationSearchTableRowProps) => { +const LocationSelectResult = ({ city, onLocationSelect }: LocationSelectResultProps) => { const t = useI18n("widget.common.location.table"); const locale = useCurrentIntlLocale(); + let populationLabel = t("population.fallback"); + if (city.population) { + populationLabel = `${t("header.population")}: ${formatLocalizedCompactNumber(city.population, locale)}`; + } const onSelect = useCallback(() => { onLocationSelect({ name: city.name, latitude: city.latitude, longitude: city.longitude, }); - closeModal(); - }, [city, onLocationSelect, closeModal]); + }, [city, onLocationSelect]); return ( - <Table.Tr> - <Table.Td> - <Text style={{ whiteSpace: "nowrap" }}>{city.name}</Text> - </Table.Td> - <Table.Td> - <Text style={{ whiteSpace: "nowrap" }}>{city.country}</Text> - </Table.Td> - <Table.Td> - <Anchor target="_blank" href={`https://www.google.com/maps/place/${city.latitude},${city.longitude}`}> - <Text style={{ whiteSpace: "nowrap" }}> - {city.latitude}, {city.longitude} + <Paper withBorder p="xs"> + <Group justify="space-between" align="flex-start" wrap="nowrap"> + <Stack gap={2} miw={0}> + <Text fw={500} style={{ overflowWrap: "anywhere" }}> + {city.name} </Text> - </Anchor> - </Table.Td> - <Table.Td> - {city.population ? ( - <Text style={{ whiteSpace: "nowrap" }}>{formatLocalizedCompactNumber(city.population, locale)}</Text> - ) : ( - <Text c="gray"> {t("population.fallback")}</Text> - )} - </Table.Td> - <Table.Td> + <Text size="xs" c="dimmed" style={{ overflowWrap: "anywhere" }}> + {city.country} + </Text> + <Group gap="xs" wrap="wrap"> + <Anchor + size="xs" + target="_blank" + href={`https://www.google.com/maps/place/${city.latitude},${city.longitude}`} + > + {city.latitude}, {city.longitude} + </Anchor> + <Text size="xs" c="dimmed"> + {populationLabel} + </Text> + </Group> + </Stack> <Tooltip label={t("action.select", { city: city.name, countryCode: city.country_code ?? "??", })} > - <ActionIcon color="red" variant="subtle" onClick={onSelect}> + <ActionIcon + flex="0 0 auto" + color="red" + variant="subtle" + onClick={onSelect} + aria-label={t("action.select", { + city: city.name, + countryCode: city.country_code ?? "??", + })} + > <IconClick size="var(--mantine-font-size-md)" /> </ActionIcon> </Tooltip> - </Table.Td> - </Table.Tr> + </Group> + </Paper> ); }; diff --git a/packages/widgets/src/dns-hole/controls/TimerModal.tsx b/packages/widgets/src/dns-hole/controls/TimerModal.tsx deleted file mode 100644 index 02de22c0c..000000000 --- a/packages/widgets/src/dns-hole/controls/TimerModal.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { useRef, useState } from "react"; -import type { NumberInputHandlers } from "@mantine/core"; -import { ActionIcon, Button, Flex, Group, Modal, NumberInput, rem, Stack, Text } from "@mantine/core"; -import { IconClockPause } from "@tabler/icons-react"; - -import { useI18n } from "@homarr/translation/client"; - -interface TimerModalProps { - opened: boolean; - close: () => void; - selectedIntegrationIds: string[]; - disableDns: (data: { duration: number; integrationId: string }) => void; -} - -const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: TimerModalProps) => { - const t = useI18n("widget.dnsHoleControls"); - const tCommon = useI18n("common"); - const [hours, setHours] = useState(0); - const [minutes, setMinutes] = useState(0); - const hoursHandlers = useRef<NumberInputHandlers>(null); - const minutesHandlers = useRef<NumberInputHandlers>(null); - - const handleSetTimer = () => { - const duration = hours * 3600 + minutes * 60; - selectedIntegrationIds.forEach((integrationId) => { - disableDns({ duration, integrationId }); - }); - setHours(0); - setMinutes(0); - close(); - }; - - return ( - <Modal - withinPortal - radius="lg" - shadow="sm" - size="sm" - opened={opened} - onClose={() => { - close(); - setHours(0); - setMinutes(0); - }} - title={t("controls.setTimer")} - > - <Flex direction="column" align="center" justify="center"> - <Stack align="flex-end"> - <Group> - <Text>{tCommon("information.hours")}</Text> - <ActionIcon size={35} variant="default" onClick={() => hoursHandlers.current?.decrement()}> - – - </ActionIcon> - <NumberInput - hideControls - value={hours} - onChange={(val) => setHours(Number(val))} - handlersRef={hoursHandlers} - max={999} - min={0} - step={1} - styles={{ input: { width: rem(54), textAlign: "center" } }} - /> - <ActionIcon size={35} variant="default" onClick={() => hoursHandlers.current?.increment()}> - + - </ActionIcon> - </Group> - <Group> - <Text>{tCommon("information.minutes")}</Text> - <ActionIcon size={35} variant="default" onClick={() => minutesHandlers.current?.decrement()}> - – - </ActionIcon> - <NumberInput - hideControls - value={minutes} - onChange={(val) => setMinutes(Number(val))} - handlersRef={minutesHandlers} - max={59} - min={0} - step={1} - styles={{ input: { width: rem(54), textAlign: "center" } }} - /> - <ActionIcon size={35} variant="default" onClick={() => minutesHandlers.current?.increment()}> - + - </ActionIcon> - </Group> - </Stack> - <Text ta="center" c="dimmed" my={5}> - {t("controls.unlimited")} - </Text> - <Button - variant="light" - color="red" - leftSection={<IconClockPause size="var(--mantine-font-size-xl)" />} - h="2rem" - w="12rem" - onClick={handleSetTimer} - > - {t("controls.set")} - </Button> - </Flex> - </Modal> - ); -}; - -export default TimerModal; diff --git a/packages/widgets/src/dns-hole/controls/component.tsx b/packages/widgets/src/dns-hole/controls/component.tsx index 8ec849d2a..dec4e56aa 100644 --- a/packages/widgets/src/dns-hole/controls/component.tsx +++ b/packages/widgets/src/dns-hole/controls/component.tsx @@ -1,5 +1,8 @@ "use client"; +/* eslint-disable react/no-unstable-nested-components -- TimerPopover uses render props to wire controlled targets. */ +/* eslint-disable import/no-unassigned-import -- The shared widget stylesheet is loaded for its side effects. */ + import "../../widgets-common.css"; import { useState } from "react"; @@ -18,7 +21,6 @@ import { Tooltip, UnstyledButton, } from "@mantine/core"; -import { useDisclosure } from "@mantine/hooks"; import { IconCircleFilled, IconClockPause, IconPlayerPlay, IconPlayerStop } from "@tabler/icons-react"; import combineClasses from "clsx"; @@ -38,7 +40,7 @@ import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../com import { WidgetQueryLoadingState } from "../../common/query-state-indicator"; import actionTargetClasses from "../../common/action-target.module.css"; import classes from "./component.module.css"; -import TimerModal from "./TimerModal"; +import TimerPopover from "./timer-popover"; const dnsLightStatus = (enabled: boolean | undefined) => `var(--mantine-color-${typeof enabled === "undefined" ? "blue" : enabled ? "green" : "red"}-6`; @@ -123,11 +125,8 @@ export default function DnsHoleControlsWidget({ const t = useI18n("widget.dnsHoleControls"); - // Timer modal setup - const [selectedIntegrationIds, setSelectedIntegrationIds] = useState<string[]>([]); const [bulkPending, setBulkPending] = useState(false); const [bulkFailureCount, setBulkFailureCount] = useState(0); - const [opened, { close, open }] = useDisclosure(false); const controlAllButtonsVisible = options.showToggleAllButtons && integrationsWithInteractions.length > 0; const actionsPending = bulkPending || isEnabling || isDisabling; @@ -177,26 +176,29 @@ export default function DnsHoleControlsWidget({ </Button> </Tooltip> - <Tooltip label={t("controls.setTimer")}> - <Button - aria-label={t("controls.setTimer")} - size="xs" - p={0} - className="dns-hole-controls-timer-all-button" - onClick={() => { - setSelectedIntegrationIds(integrationsSummaries.enabled); - open(); - }} - disabled={integrationsSummaries.enabled.length === 0 || actionsPending} - variant="light" - color="yellow" - bd={0} - radius={board.itemRadius} - flex={1} - > - <IconClockPause className="dns-hole-controls-timer-all-icon" size="var(--mantine-font-size-md)" /> - </Button> - </Tooltip> + <TimerPopover + target={(onClick) => ( + <Tooltip label={t("controls.setTimer")}> + <Button + aria-label={t("controls.setTimer")} + size="xs" + p={0} + className="dns-hole-controls-timer-all-button" + disabled={integrationsSummaries.enabled.length === 0 || actionsPending} + variant="light" + color="yellow" + bd={0} + radius={board.itemRadius} + flex={1} + onClick={onClick} + > + <IconClockPause className="dns-hole-controls-timer-all-icon" size="var(--mantine-font-size-md)" /> + </Button> + </Tooltip> + )} + selectedIntegrationIds={integrationsSummaries.enabled} + disableDns={(input) => void disableDns(input).catch(() => undefined)} + /> <Tooltip label={t("controls.disableAll")}> <Button @@ -230,9 +232,8 @@ export default function DnsHoleControlsWidget({ key={summary.integration.id} integrationsWithInteractions={integrationsWithInteractions} toggleDns={toggleDns} + disableDns={(input) => void disableDns(input).catch(() => undefined)} data={summary} - setSelectedIntegrationIds={setSelectedIntegrationIds} - open={open} t={t} hasIconColor={board.iconColor !== null} rootWidth={width} @@ -255,13 +256,6 @@ export default function DnsHoleControlsWidget({ </Text> </Tooltip> )} - - <TimerModal - opened={opened} - close={close} - selectedIntegrationIds={selectedIntegrationIds} - disableDns={(input) => void disableDns(input).catch(() => undefined)} - /> </Stack> ); } @@ -269,9 +263,8 @@ export default function DnsHoleControlsWidget({ interface ControlsCardProps { integrationsWithInteractions: string[]; toggleDns: (integrationId: string) => Promise<void>; + disableDns: (data: { duration: number; integrationId: string }) => void; data: AvailableDnsSummaryResult; - setSelectedIntegrationIds: (integrationId: string[]) => void; - open: () => void; t: ReturnType<typeof useI18n<"widget.dnsHoleControls">>; hasIconColor: boolean; rootWidth: number; @@ -282,9 +275,8 @@ interface ControlsCardProps { const ControlsCard: React.FC<ControlsCardProps> = ({ integrationsWithInteractions, toggleDns, + disableDns, data, - setSelectedIntegrationIds, - open, t, hasIconColor, rootWidth, @@ -382,21 +374,24 @@ const ControlsCard: React.FC<ControlsCardProps> = ({ <IconPlayerStop size="var(--mantine-font-size-xs)" /> </ActionIcon> )} - <ActionIcon - className={actionTargetClasses.root} - aria-label={`${t("controls.setTimer")}: ${data.integration.name}`} - onClick={() => { - setSelectedIntegrationIds([data.integration.id]); - open(); - }} - size="sm" - color="yellow" - variant="light" - display={isInteractPermitted ? undefined : "none"} - disabled={!controlEnabled || !isEnabled} - > - <IconClockPause size="var(--mantine-font-size-xs)" /> - </ActionIcon> + <TimerPopover + target={(onClick) => ( + <ActionIcon + className={actionTargetClasses.root} + aria-label={`${t("controls.setTimer")}: ${data.integration.name}`} + size="sm" + color="yellow" + variant="light" + display={isInteractPermitted ? undefined : "none"} + disabled={!controlEnabled || !isEnabled} + onClick={onClick} + > + <IconClockPause size="var(--mantine-font-size-xs)" /> + </ActionIcon> + )} + selectedIntegrationIds={[data.integration.id]} + disableDns={disableDns} + /> </Group> )} {layout === "md" && ( @@ -445,24 +440,27 @@ const ControlsCard: React.FC<ControlsCardProps> = ({ </Flex> </Flex> {layout === "md" && ( - <ActionIcon - aria-label={`${t("controls.setTimer")}: ${data.integration.name}`} - className={combineClasses("dns-hole-controls-item-timer-button", actionTargetClasses.root)} - display={isInteractPermitted ? undefined : "none"} - disabled={!controlEnabled || !isEnabled} - color="yellow" - size={30} - radius={board.itemRadius} - bd={0} - ms={"auto"} - variant="subtle" - onClick={() => { - setSelectedIntegrationIds([data.integration.id]); - open(); - }} - > - <IconClockPause className="dns-hole-controls-item-timer-icon" size="var(--mantine-font-size-xl)" /> - </ActionIcon> + <TimerPopover + target={(onClick) => ( + <ActionIcon + aria-label={`${t("controls.setTimer")}: ${data.integration.name}`} + className={combineClasses("dns-hole-controls-item-timer-button", actionTargetClasses.root)} + display={isInteractPermitted ? undefined : "none"} + disabled={!controlEnabled || !isEnabled} + color="yellow" + size={30} + radius={board.itemRadius} + bd={0} + ms={"auto"} + variant="subtle" + onClick={onClick} + > + <IconClockPause className="dns-hole-controls-item-timer-icon" size="var(--mantine-font-size-xl)" /> + </ActionIcon> + )} + selectedIntegrationIds={[data.integration.id]} + disableDns={disableDns} + /> )} </Flex> </Card> diff --git a/packages/widgets/src/dns-hole/controls/timer-popover.tsx b/packages/widgets/src/dns-hole/controls/timer-popover.tsx new file mode 100644 index 000000000..68ef981e0 --- /dev/null +++ b/packages/widgets/src/dns-hole/controls/timer-popover.tsx @@ -0,0 +1,146 @@ +import type { ReactNode } from "react"; +import { useRef, useState } from "react"; +import type { NumberInputHandlers } from "@mantine/core"; +import { ActionIcon, Button, Flex, Group, NumberInput, Popover, rem, Stack, Text } from "@mantine/core"; +import { IconClockPause } from "@tabler/icons-react"; +import { useDisclosure } from "@mantine/hooks"; + +import { useI18n } from "@homarr/translation/client"; + +interface TimerPopoverProps { + target: (onClick: () => void) => ReactNode; + selectedIntegrationIds: string[]; + disableDns: (data: { duration: number; integrationId: string }) => void; +} + +const TimerPopover = ({ target, selectedIntegrationIds, disableDns }: TimerPopoverProps) => { + const t = useI18n("widget.dnsHoleControls"); + const tCommon = useI18n("common"); + const [opened, { close, open }] = useDisclosure(false); + const [hours, setHours] = useState(0); + const [minutes, setMinutes] = useState(0); + const hoursHandlers = useRef<NumberInputHandlers>(null); + const minutesHandlers = useRef<NumberInputHandlers>(null); + + const handleSetTimer = () => { + const duration = hours * 3600 + minutes * 60; + selectedIntegrationIds.forEach((integrationId) => { + disableDns({ duration, integrationId }); + }); + setHours(0); + setMinutes(0); + close(); + }; + + const reset = () => { + close(); + setHours(0); + setMinutes(0); + }; + + const handleToggle = () => { + if (opened) { + reset(); + return; + } + open(); + }; + + return ( + <Popover + opened={opened} + onChange={(nextOpened) => { + if (nextOpened) open(); + if (!nextOpened) reset(); + }} + position="bottom" + shadow="sm" + > + <Popover.Target>{target(handleToggle)}</Popover.Target> + <Popover.Dropdown> + <Stack gap="sm" maw={280}> + <Text fw={500}>{t("controls.setTimer")}</Text> + <Flex direction="column" align="center" justify="center"> + <Stack align="flex-end"> + <Group> + <Text>{tCommon("information.hours")}</Text> + <ActionIcon + size={35} + variant="default" + onClick={() => hoursHandlers.current?.decrement()} + aria-label={`− ${tCommon("information.hours")}`} + > + – + </ActionIcon> + <NumberInput + hideControls + value={hours} + onChange={(val) => setHours(Number(val))} + handlersRef={hoursHandlers} + aria-label={tCommon("information.hours")} + max={999} + min={0} + step={1} + styles={{ input: { width: rem(54), textAlign: "center" } }} + /> + <ActionIcon + size={35} + variant="default" + onClick={() => hoursHandlers.current?.increment()} + aria-label={`+ ${tCommon("information.hours")}`} + > + + + </ActionIcon> + </Group> + <Group> + <Text>{tCommon("information.minutes")}</Text> + <ActionIcon + size={35} + variant="default" + onClick={() => minutesHandlers.current?.decrement()} + aria-label={`− ${tCommon("information.minutes")}`} + > + – + </ActionIcon> + <NumberInput + hideControls + value={minutes} + onChange={(val) => setMinutes(Number(val))} + handlersRef={minutesHandlers} + aria-label={tCommon("information.minutes")} + max={59} + min={0} + step={1} + styles={{ input: { width: rem(54), textAlign: "center" } }} + /> + <ActionIcon + size={35} + variant="default" + onClick={() => minutesHandlers.current?.increment()} + aria-label={`+ ${tCommon("information.minutes")}`} + > + + + </ActionIcon> + </Group> + </Stack> + <Text ta="center" c="dimmed" my={5}> + {t("controls.unlimited")} + </Text> + <Button + variant="light" + color="red" + leftSection={<IconClockPause size="var(--mantine-font-size-xl)" />} + h="2rem" + w="12rem" + onClick={handleSetTimer} + > + {t("controls.set")} + </Button> + </Flex> + </Stack> + </Popover.Dropdown> + </Popover> + ); +}; + +export default TimerPopover; diff --git a/packages/widgets/src/modals/widget-advanced-options-modal.tsx b/packages/widgets/src/modals/widget-advanced-options-modal.tsx deleted file mode 100644 index 184fd4678..000000000 --- a/packages/widgets/src/modals/widget-advanced-options-modal.tsx +++ /dev/null @@ -1,66 +0,0 @@ -"use client"; - -import { CloseButton, ColorInput, Input, Stack, TextInput, useMantineTheme } from "@mantine/core"; - -import { useForm } from "@homarr/form"; -import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals"; -import { useI18n } from "@homarr/translation/client"; -import { TextMultiSelect } from "@homarr/ui"; -import type { BoardItemAdvancedOptions } from "@homarr/validation/shared"; - -interface InnerProps { - advancedOptions: BoardItemAdvancedOptions; - onSuccess: (options: BoardItemAdvancedOptions) => void; -} - -export const WidgetAdvancedOptionsModal = createModal<InnerProps>(({ actions, innerProps }) => { - const t = useI18n("item.edit"); - const theme = useMantineTheme(); - const form = useForm({ - initialValues: innerProps.advancedOptions, - }); - const handleSubmit = (values: BoardItemAdvancedOptions) => { - innerProps.onSuccess({ - ...values, - // we want to fallback to null if the title is empty - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - title: values.title?.trim() || null, - }); - actions.closeModal(); - }; - - return ( - <form onSubmit={form.onSubmit(handleSubmit)}> - <Stack> - <TextInput - label={t("field.title.label")} - data-autofocus - {...form.getInputProps("title")} - rightSection={<Input.ClearButton onClick={() => form.setFieldValue("title", "")} />} - /> - <TextMultiSelect label={t("field.customCssClasses.label")} {...form.getInputProps("customCssClasses")} /> - <ColorInput - label={t("field.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")} - /> - <ModalFormFooter onCancel={actions.closeModal} /> - </Stack> - </form> - ); -}).withOptions({ - defaultTitle(t) { - return t("item.edit.advancedOptions.title"); - }, - size: modalSizeForm, - transitionProps: { - duration: 0, - }, -}); diff --git a/packages/widgets/src/modals/widget-edit-modal.tsx b/packages/widgets/src/modals/widget-edit-modal.tsx index 5cb24cd9b..80582e325 100644 --- a/packages/widgets/src/modals/widget-edit-modal.tsx +++ b/packages/widgets/src/modals/widget-edit-modal.tsx @@ -5,19 +5,25 @@ import { useEffect, useId, useRef, useState } from "react"; import type { ComponentType, FormEvent, PropsWithChildren } from "react"; import { + Accordion, Alert, Badge, Box, Button, Center, + CloseButton, + ColorInput, Divider, Group, + Input as MantineInput, NumberInput, SimpleGrid, Stack, Tabs, Text, + TextInput, Tooltip, + useMantineTheme, } from "@mantine/core"; import { schemaResolver } from "@mantine/form"; import { useElementSize } from "@mantine/hooks"; @@ -30,11 +36,11 @@ import { objectEntries } from "@homarr/common"; import { IntegrationProvider, useSession } from "@homarr/auth/client"; import { useOptionalBoard } from "@homarr/boards/context"; import type { WidgetKind } from "@homarr/definitions"; -import { createModal, modalSizeForm, useModalAction } from "@homarr/modals"; +import { createModal, modalSizeForm } from "@homarr/modals"; import type { SettingsContextProps } from "@homarr/settings/creator"; import { SpotlightProvider } from "@homarr/spotlight"; import { useI18n } from "@homarr/translation/client"; -import { IntegrationAvatar } from "@homarr/ui"; +import { IntegrationAvatar, TextMultiSelect } from "@homarr/ui"; import { zodErrorMap } from "@homarr/validation/form/i18n"; import { getInputForType } from "../_inputs"; @@ -47,7 +53,6 @@ import { OPTIONS_SUPER_REFINE } from "../options"; import type { IntegrationSelectOption } from "../widget-integration-select"; import { WidgetIntegrationSelect } from "../widget-integration-select"; import { WidgetCardShell, WidgetTitleBadge } from "../widget-card-shell"; -import { WidgetAdvancedOptionsModal } from "./widget-advanced-options-modal"; import type { EmbeddedAppEditFormHandle } from "./embedded-app-edit-form"; import { EmbeddedAppEditForm } from "./embedded-app-edit-form"; import classes from "./widget-edit-modal.module.css"; @@ -314,7 +319,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({ const tCommon = useI18n("common"); const board = useOptionalBoard(); const { data: session } = useSession(); - const [advancedOptions, setAdvancedOptions] = useState<BoardItemAdvancedOptions>(innerProps.value.advancedOptions); + const theme = useMantineTheme(); const appEditRef = useRef<EmbeddedAppEditFormHandle>(null); const integrationEditHandles = useRef(new Map<string, EmbeddedIntegrationEditFormHandle>()); const integrationEditRefCallbacks = useRef( @@ -368,6 +373,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({ options: optionsSchema, integrationIds: integrationIdsSchema, advancedOptions: z.object({ + title: z.string().max(64).nullable(), customCssClasses: z.array(z.string()), borderColor: z.string(), }), @@ -377,7 +383,6 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({ validateInputOnBlur: true, validateInputOnChange: true, }); - const { openModal } = useModalAction(WidgetAdvancedOptionsModal); const canModifyApps = session?.user.permissions.includes("app-modify-all") ?? false; const canConfigureWidget = innerProps.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false); @@ -457,7 +462,10 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({ innerProps.onSuccessfulEdit( { ...values, - advancedOptions, + advancedOptions: { + ...values.advancedOptions, + title: values.advancedOptions.title?.trim() || null, + }, }, previewSize ?? undefined, ); @@ -519,14 +527,14 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({ <Box className={classes.workspace} data-with-preview={innerProps.previewComponent ? true : undefined} - data-with-settings={hasWidgetSettings ? true : undefined} + data-with-settings > {innerProps.previewComponent && ( <WidgetEditPreview kind={innerProps.kind} Component={innerProps.previewComponent} definition={definition} - state={{ ...form.values, advancedOptions }} + state={form.values} itemId={innerProps.itemId} boardId={innerProps.boardId ?? board?.id} dimensions={previewDimensions} @@ -536,107 +544,131 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({ PreviewWrapper={innerProps.previewWrapper} /> )} - {innerProps.previewComponent && hasWidgetSettings && ( - <Divider orientation="vertical" className={classes.previewDivider} /> - )} - <form id={widgetFormId} className={classes.settingsForm} onSubmit={onFormSubmit} hidden={!hasWidgetSettings}> - {hasWidgetSettings && ( - <Stack className={classes.settingsPanel} gap="sm"> - <Group gap="xs" wrap="nowrap"> - <IconSettings size={16} /> - <Text fw={600} size="sm"> - {tItem("settings.label")} - </Text> - </Group> - {canConfigureWidget && innerProps.integrationSupport && ( - <Box className={classes.fullWidthField}> - <WidgetIntegrationSelect - label={tItem("field.integrations.label")} - data={innerProps.integrationData} - canSelectMultiple={maxIntegrations > 1} - withAsterisk={integrationsRequired} - onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined} - {...form.getInputProps("integrationIds")} - /> - </Box> - )} - {canConfigureWidget && ( - <Stack gap="sm"> - {switchOptions.length > 0 && ( - <SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm" verticalSpacing="sm"> - {switchOptions.map(([key, value]) => { - const Input = getInputForType(value.type); - if (!Input) { - return null; - } + {innerProps.previewComponent && <Divider orientation="vertical" className={classes.previewDivider} />} + <form id={widgetFormId} className={classes.settingsForm} onSubmit={onFormSubmit}> + <Stack className={classes.settingsPanel} gap="sm"> + {hasWidgetSettings && ( + <> + <Group gap="xs" wrap="nowrap"> + <IconSettings size={16} /> + <Text fw={600} size="sm"> + {tItem("settings.label")} + </Text> + </Group> + {canConfigureWidget && innerProps.integrationSupport && ( + <Box className={classes.fullWidthField}> + <WidgetIntegrationSelect + label={tItem("field.integrations.label")} + data={innerProps.integrationData} + canSelectMultiple={maxIntegrations > 1} + withAsterisk={integrationsRequired} + onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined} + {...form.getInputProps("integrationIds")} + /> + </Box> + )} + {canConfigureWidget && ( + <Stack gap="sm"> + {switchOptions.length > 0 && ( + <SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm" verticalSpacing="sm"> + {switchOptions.map(([key, value]) => { + const Input = getInputForType(value.type); + if (!Input) { + return null; + } - return ( - <Box - key={key} - className={classes.switchOption} - data-option-type={value.type} - onClick={(event) => { - const target = event.target; - if (target instanceof Element && target.closest("label, input")) return; - const input = event.currentTarget.querySelector<HTMLInputElement>("input[type=checkbox]"); - if (!input?.disabled) input?.click(); - }} - > - <Input - kind={innerProps.kind} - property={key} - options={value as never} - initialOptions={innerProps.value.options} - itemId={innerProps.itemId} - boardId={innerProps.boardId ?? board?.id} - /> - </Box> - ); - })} - </SimpleGrid> - )} - {otherOptions.map(([key, value]) => { - const Input = getInputForType(value.type); - if (!Input) { - return null; - } + return ( + <Box + key={key} + className={classes.switchOption} + data-option-type={value.type} + onClick={(event) => { + const target = event.target; + if (target instanceof Element && target.closest("label, input")) return; + const input = event.currentTarget.querySelector<HTMLInputElement>("input[type=checkbox]"); + if (!input?.disabled) input?.click(); + }} + > + <Input + kind={innerProps.kind} + property={key} + options={value as never} + initialOptions={innerProps.value.options} + itemId={innerProps.itemId} + boardId={innerProps.boardId ?? board?.id} + /> + </Box> + ); + })} + </SimpleGrid> + )} + {otherOptions.map(([key, value]) => { + const Input = getInputForType(value.type); + if (!Input) { + return null; + } - return ( - <Box key={key} data-option-type={value.type}> - <Input - kind={innerProps.kind} - property={key} - options={value as never} - initialOptions={innerProps.value.options} - itemId={innerProps.itemId} - boardId={innerProps.boardId ?? board?.id} + return ( + <Box key={key} data-option-type={value.type}> + <Input + kind={innerProps.kind} + property={key} + options={value as never} + initialOptions={innerProps.value.options} + itemId={innerProps.itemId} + boardId={innerProps.boardId ?? board?.id} + /> + </Box> + ); + })} + </Stack> + )} + </> + )} + <Accordion variant="contained"> + <Accordion.Item value="advanced-options"> + <Accordion.Control>{tItem("advancedOptions.label")}</Accordion.Control> + <Accordion.Panel> + <Stack gap="sm"> + <TextInput + label={tItem("field.title.label")} + rightSection={ + <MantineInput.ClearButton + onClick={() => form.setFieldValue("advancedOptions.title", "")} + disabled={!form.values.advancedOptions.title} /> - </Box> - ); - })} - </Stack> - )} - </Stack> - )} + } + {...form.getInputProps("advancedOptions.title")} + /> + <TextMultiSelect + label={tItem("field.customCssClasses.label")} + {...form.getInputProps("advancedOptions.customCssClasses")} + /> + <ColorInput + label={tItem("field.borderColor.label")} + format="hex" + swatches={Object.values(theme.colors).map((color) => color[6])} + rightSection={ + <CloseButton + aria-label={tCommon("action.remove")} + onClick={() => form.setFieldValue("advancedOptions.borderColor", "")} + style={{ display: form.values.advancedOptions.borderColor ? undefined : "none" }} + /> + } + {...form.getInputProps("advancedOptions.borderColor")} + /> + </Stack> + </Accordion.Panel> + </Accordion.Item> + </Accordion> + </Stack> </form> </Box> ); const footer = ( <Box className={classes.footer}> - <Group justify="space-between" wrap="nowrap"> - <Button - variant="subtle" - type="button" - onClick={() => - openModal({ - advancedOptions, - onSuccess: setAdvancedOptions, - }) - } - > - {tItem("advancedOptions.label")} - </Button> + <Group justify="end" wrap="nowrap"> <Group gap="xs" wrap="nowrap"> <Button onClick={actions.closeModal} variant="subtle" color="gray"> {tCommon("action.cancel")} diff --git a/packages/widgets/src/notebook/notebook.tsx b/packages/widgets/src/notebook/notebook.tsx index cff8d40c3..073715298 100644 --- a/packages/widgets/src/notebook/notebook.tsx +++ b/packages/widgets/src/notebook/notebook.tsx @@ -66,6 +66,7 @@ import { useForm } from "@homarr/form"; import type { TranslationObject } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; import type { TablerIcon } from "@homarr/ui"; +import { InlineConfirmActionIcon } from "@homarr/ui"; import type { WidgetComponentProps } from "../definition"; import actionTargetClasses from "../common/action-target.module.css"; @@ -80,7 +81,6 @@ import { useSession } from "@homarr/auth/client"; import { constructBoardPermissions } from "@homarr/auth/shared"; import { useRequiredBoard } from "@homarr/boards/context"; import { hotkeys } from "@homarr/definitions"; -import { useConfirmModal } from "@homarr/modals"; const iconProps = { size: 30, @@ -285,21 +285,10 @@ export function Notebook({ return false; }, [editor]); - const { openConfirmModal } = useConfirmModal(); const handleEditCancel = useCallback(() => { if (savingRef.current) return; - openConfirmModal({ - title: t("dismiss.title"), - children: t("dismiss.message"), - labels: { - confirm: t("dismiss.action.discard"), - cancel: t("dismiss.action.keepEditing"), - }, - onConfirm: () => { - setIsEditing(handleEditCancelCallback); - }, - }); - }, [setIsEditing, handleEditCancelCallback, openConfirmModal, t]); + setIsEditing(handleEditCancelCallback); + }, [setIsEditing, handleEditCancelCallback]); const handleEditToggle = useCallback(async () => { if (!editor || savingRef.current) return; @@ -369,8 +358,7 @@ export function Notebook({ }, toolbar: { backgroundColor: "transparent", - borderColor: - "rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))", + borderColor: "rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))", padding: "0.5rem", }, content: { @@ -554,19 +542,22 @@ export function Notebook({ {isEditing ? <IconDeviceFloppy {...iconProps} /> : <IconEdit {...iconProps} />} </ActionIcon> {isEditing && ( - <ActionIcon + <InlineConfirmActionIcon className={`homarr-notebook-action ${actionTargetClasses.root}`} data-visible title={tCommon("action.cancel")} aria-label={tCommon("action.cancel")} + confirmLabel={t("dismiss.message")} + confirmationAriaLabel={t("dismiss.action.discard")} + confirmationChildren={<IconCheck {...iconProps} />} + onConfirm={handleEditCancel} color={primaryColor} variant="light" size={30} disabled={isSaving} - onClick={handleEditCancel} > <IconX {...iconProps} /> - </ActionIcon> + </InlineConfirmActionIcon> )} </Stack> )} diff --git a/scripts/browser-agent/ux-coherence-happy-paths.sh b/scripts/browser-agent/ux-coherence-happy-paths.sh new file mode 100755 index 000000000..2a172deab --- /dev/null +++ b/scripts/browser-agent/ux-coherence-happy-paths.sh @@ -0,0 +1,496 @@ +#!/usr/bin/env bash + +set -euo pipefail + +BASE_URL="${1:-${BASE_URL:-http://100.111.30.70:3201}}" +SCREENSHOT_DIR="${2:-${SCREENSHOT_DIR:-.screenshots/ux-coherence-browser-agent}}" +BASE_URL="${BASE_URL%/}" + +mkdir -p "$SCREENSHOT_DIR" +SCREENSHOT_DIR="$(cd "$SCREENSHOT_DIR" && pwd)" + +SESSION="$(mise exec -- agent-browser session id --scope worktree --prefix ux-coherence)" +BOARD_NAME="" +BOARD_CREATE_REQUESTED=0 +BOARD_CREATED=0 +BOARD_CLEANUP_PENDING=0 +API_KEY_CLEANUP_PENDING=0 +API_KEY_CREATED_ID="" +CLEANUP_RAN=0 +CLEANUP_DEADLINE=0 +declare -a API_KEY_IDS_BEFORE=() +declare -A API_KEY_IDS_BEFORE_SET=() +API_KEY_IDS_BEFORE_JSON="[]" + +ab() { + mise exec -- agent-browser --session "$SESSION" "$@" +} + +fail() { + echo "ux-coherence browser assertion failed: $*" >&2 + exit 1 +} + +assert_count() { + local selector="$1" + local expected="$2" + local message="$3" + local actual + + actual="$(ab get count "$selector")" + if [[ "$actual" != "$expected" ]]; then + fail "$message (expected $expected, got $actual)" + fi +} + +assert_no_dialog() { + local description="$1" + + assert_eval "(() => { const dialog = Array.from(document.querySelectorAll('[role=\"dialog\"]')).find((element) => element.offsetParent !== null); if (dialog) throw new Error('$description opened a visible dialog'); return true; })()" +} + +assert_no_modal_dialog() { + local description="$1" + + assert_eval "(() => { const modal = Array.from(document.querySelectorAll('[role=\"dialog\"][aria-modal=\"true\"]')).find((element) => element.offsetParent !== null); if (modal) throw new Error('$description opened a visible modal dialog'); return true; })()" +} + +assert_eval() { + local expression="$1" + + ab eval "$expression" >/dev/null +} + +parse_api_key_ids() { + local -n target="$1" + local remaining="$2" + local marker + local id + + target=() + while [[ "$remaining" =~ UX_COHERENCE_API_ID=([[:xdigit:]]{8}) ]]; do + marker="${BASH_REMATCH[0]}" + id="${BASH_REMATCH[1],,}" + target+=("$id") + remaining="${remaining#*"$marker"}" + done +} + +capture_api_key_ids() { + local target_name="$1" + local -n target="$target_name" + local output + local count + + output="$(ab eval "Array.from(document.querySelectorAll('button[aria-label=\"Delete API token\"]')).map((button) => 'UX_COHERENCE_API_ID=' + button.closest('tr')?.querySelector('td')?.textContent?.trim()).join('\\n')")" || return + count="$(ab get count 'button[aria-label="Delete API token"]')" || return + if [[ ! "$count" =~ ^[0-9]+$ ]]; then + return 1 + fi + + parse_api_key_ids "$target_name" "$output" + [[ "${#target[@]}" -eq "$count" ]] +} + +capture_cleanup_api_key_ids() { + local target_name="$1" + local -n target="$target_name" + local output + local count + + output="$(cleanup_ab eval "Array.from(document.querySelectorAll('button[aria-label=\"Delete API token\"]')).map((button) => 'UX_COHERENCE_API_ID=' + button.closest('tr')?.querySelector('td')?.textContent?.trim()).join('\\n')")" || return + count="$(cleanup_ab get count 'button[aria-label="Delete API token"]')" || return + if [[ ! "$count" =~ ^[0-9]+$ ]]; then + return 1 + fi + + parse_api_key_ids "$target_name" "$output" + [[ "${#target[@]}" -eq "$count" ]] +} + +api_key_ids_match_baseline() { + local -n current="$1" + local id + + if [[ "${#current[@]}" -ne "${#API_KEY_IDS_BEFORE[@]}" ]]; then + return 1 + fi + + for id in "${current[@]}"; do + if [[ -z "${API_KEY_IDS_BEFORE_SET[$id]+present}" ]]; then + return 1 + fi + done +} + +api_key_ids_to_json() { + local -n ids="$1" + local separator="" + local id + + printf '[' + for id in "${ids[@]}"; do + printf '%s"%s"' "$separator" "$id" + separator="," + done + printf ']' +} + +capture() { + local path="$1" + + # Development tooling is not part of Homarr and obscures the reviewed UI. + ab eval "document.querySelectorAll('nextjs-portal, button[aria-label=\"Open TanStack Devtools\"]').forEach((element) => element.style.setProperty('display', 'none', 'important'))" >/dev/null + ab screenshot --full "$path" +} + +retry_action_until() { + local action="$1" + local condition="$2" + local description="$3" + local result + + for _ in {1..240}; do + result="$(ab eval "$condition" 2>/dev/null || true)" + if [[ "$result" == "true" ]]; then + return + fi + + ab eval "$action" >/dev/null 2>&1 || true + ab wait 250 >/dev/null + done + + fail "$description" +} + +open_expandable() { + local selector="$1" + local description="$2" + + retry_action_until \ + "document.querySelector('$selector')?.click()" \ + "document.querySelector('$selector')?.getAttribute('aria-expanded') === 'true'" \ + "$description did not expand" +} + +cleanup_ab() { + local remaining=$((CLEANUP_DEADLINE - SECONDS)) + local command_timeout + + if ((remaining <= 0)); then + return 124 + fi + + command_timeout="$remaining" + if ((command_timeout > 10)); then + command_timeout=10 + fi + + AGENT_BROWSER_DEFAULT_TIMEOUT="$((command_timeout * 1000))" \ + timeout --signal=TERM "${command_timeout}s" mise exec -- agent-browser --session "$SESSION" "$@" +} + +cleanup_board_resource() { + local opened + + if ((BOARD_CREATED)); then + echo "Best-effort cleanup: confirmed board $BOARD_NAME" >&2 + else + echo "Best-effort cleanup: board creation request for $BOARD_NAME" >&2 + fi + cleanup_ab open "$BASE_URL/manage/boards" >/dev/null 2>&1 || return + cleanup_ab wait --load domcontentloaded >/dev/null 2>&1 || return + cleanup_ab wait --fn "document.querySelector('main') !== null" >/dev/null 2>&1 || return + opened="$(cleanup_ab eval "(() => { const card = Array.from(document.querySelectorAll('main .mantine-Card-root')).find((item) => item.querySelector('a[href=\"/boards/$BOARD_NAME\"]')); if (!card) return false; const settings = card.querySelector('button[aria-label=\"Settings\"]'); if (!settings) throw new Error('Board settings button was not found'); settings.click(); return true; })()" 2>/dev/null)" || return + if [[ "$opened" != "true" ]]; then + BOARD_CLEANUP_PENDING=0 + BOARD_CREATED=0 + return + fi + + cleanup_ab wait --fn "Array.from(document.querySelectorAll('[role=\"menuitem\"]')).some((item) => item.offsetParent !== null && item.textContent?.trim() === 'Delete')" >/dev/null 2>&1 || return + cleanup_ab eval "(() => { const item = Array.from(document.querySelectorAll('[role=\"menuitem\"]')).find((element) => element.offsetParent !== null && element.textContent?.trim() === 'Delete'); if (!item) throw new Error('Board Delete action was not found'); item.click(); return true; })()" >/dev/null 2>&1 || return + cleanup_ab wait --fn "Array.from(document.querySelectorAll('[role=\"menuitem\"]')).some((item) => item.offsetParent !== null && item.textContent?.trim() === 'Confirm')" >/dev/null 2>&1 || return + cleanup_ab eval "(() => { const item = Array.from(document.querySelectorAll('[role=\"menuitem\"]')).find((element) => element.offsetParent !== null && element.textContent?.trim() === 'Confirm'); if (!item) throw new Error('Board Confirm action was not found'); item.click(); return true; })()" >/dev/null 2>&1 || return + cleanup_ab wait --fn "!document.body.innerText.includes('$BOARD_NAME')" >/dev/null 2>&1 || return + BOARD_CLEANUP_PENDING=0 + BOARD_CREATED=0 +} + +cleanup_api_key_resources() { + local id + local -a current_ids=() + local -a created_ids=() + local -a remaining_ids=() + + echo "Best-effort cleanup: API keys created after the captured baseline" >&2 + cleanup_ab open "$BASE_URL/manage/tools/api" >/dev/null 2>&1 || return + cleanup_ab wait --load domcontentloaded >/dev/null 2>&1 || return + cleanup_ab wait --fn "Array.from(document.querySelectorAll('[role=\"tab\"]')).some((item) => item.textContent?.trim() === 'Authentication')" >/dev/null 2>&1 || return + cleanup_ab eval "Array.from(document.querySelectorAll('[role=\"tab\"]')).find((item) => item.textContent?.trim() === 'Authentication')?.click()" >/dev/null 2>&1 || return + cleanup_ab wait --fn "Array.from(document.querySelectorAll('h1, h2, h3')).some((heading) => heading.offsetParent !== null && heading.textContent?.trim() === 'API Keys')" >/dev/null 2>&1 || return + capture_cleanup_api_key_ids current_ids || return + + if [[ -n "$API_KEY_CREATED_ID" ]]; then + for id in "${current_ids[@]}"; do + if [[ "$id" == "$API_KEY_CREATED_ID" ]]; then + created_ids+=("$id") + fi + done + else + for id in "${current_ids[@]}"; do + if [[ -z "${API_KEY_IDS_BEFORE_SET[$id]+present}" ]]; then + created_ids+=("$id") + fi + done + fi + + for id in "${created_ids[@]}"; do + cleanup_ab eval "(() => { const id = '$id'; const button = Array.from(document.querySelectorAll('button[aria-label=\"Delete API token\"]')).find((item) => item.closest('tr')?.querySelector('td')?.textContent?.trim().toLowerCase() === id); if (!button) throw new Error('Created API key row was not found'); button.click(); return true; })()" >/dev/null 2>&1 || return + cleanup_ab wait --fn "(() => { const id = '$id'; const row = Array.from(document.querySelectorAll('tr')).find((item) => item.querySelector('td')?.textContent?.trim().toLowerCase() === id); return row?.querySelector('button[aria-label=\"Confirm\"]')?.offsetParent !== null; })()" >/dev/null 2>&1 || return + cleanup_ab eval "(() => { const id = '$id'; const row = Array.from(document.querySelectorAll('tr')).find((item) => item.querySelector('td')?.textContent?.trim().toLowerCase() === id); const button = row?.querySelector('button[aria-label=\"Confirm\"]'); if (!button) throw new Error('Created API key Confirm action was not found'); button.click(); return true; })()" >/dev/null 2>&1 || return + cleanup_ab wait --fn "!Array.from(document.querySelectorAll('tr')).some((item) => item.querySelector('td')?.textContent?.trim().toLowerCase() === '$id')" >/dev/null 2>&1 || return + done + + capture_cleanup_api_key_ids remaining_ids || return + if api_key_ids_match_baseline remaining_ids; then + API_KEY_CLEANUP_PENDING=0 + API_KEY_CREATED_ID="" + fi +} + +cleanup() { + local exit_code=$? + + if ((CLEANUP_RAN)); then + exit "$exit_code" + fi + + CLEANUP_RAN=1 + trap - EXIT INT TERM + set +e + CLEANUP_DEADLINE=$((SECONDS + 45)) + + if ((API_KEY_CLEANUP_PENDING)); then + cleanup_api_key_resources + fi + if ((BOARD_CLEANUP_PENDING && BOARD_CREATE_REQUESTED)); then + cleanup_board_resource + fi + + # Closing the session is always the final browser operation. + timeout --signal=TERM 5s mise exec -- agent-browser --session "$SESSION" close >/dev/null 2>&1 + exit "$exit_code" +} + +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +echo "Browser session: $SESSION" +echo "Base URL: $BASE_URL" +echo "Screenshots: $SCREENSHOT_DIR" + +# The first command launches Chromium. This host requires the sandbox override. +ab --args "--no-sandbox" open "$BASE_URL" +ab wait --load domcontentloaded +ab set viewport 1440 1000 +ab set media light reduced-motion +ab wait --fn "window.location.pathname.includes('/auth/login') || document.querySelector('button[aria-label=\"Edit board\"]') !== null" --timeout 120000 + +current_url="$(ab get url)" +if [[ "$current_url" == *"/auth/login"* ]]; then + # These credentials are visibly documented by the writable demo login page. + ab wait --text "Demo mode is enabled" + ab wait --fn "(() => { const input = document.querySelector('input[name=\"username\"]'); return input !== null && Object.keys(input).some((key) => key.startsWith('__reactProps$')); })()" --timeout 120000 + ab snapshot -i + ab find label "Username" fill "demo" --exact + ab find label "Password" fill "demo" --exact + ab find role button click --name "Login" --exact + ab wait --fn "!window.location.pathname.includes('/auth/login')" --timeout 120000 + ab snapshot -i +fi + +current_url="$(ab get url)" +if [[ "$current_url" == *"/auth/login"* ]]; then + fail "demo login did not leave the login page" +fi + +navigate() { + local path="$1" + + ab open "$BASE_URL$path" + ab wait --load domcontentloaded + ab snapshot -i +} + +echo "[1/5] Weather dimensions popover" +navigate "/widgets/weather" +ab wait 'button[aria-label="Change dimensions"]' --timeout 120000 +open_expandable 'button[aria-label="Change dimensions"]' "Weather dimensions popover" +ab wait --text "Width" +ab snapshot -i +# Mantine gives non-modal popovers a dialog role; verify that no modal shell opened. +assert_no_modal_dialog "Weather dimensions popover" +capture "$SCREENSHOT_DIR/01-weather-dimensions-popover.png" +assert_eval "(() => { const button = Array.from(document.querySelectorAll('button')).find((item) => item.textContent?.trim() === 'Cancel'); if (!button) throw new Error('Weather dimensions Cancel button was not found'); button.click(); return true; })()" +ab snapshot -i + +echo "[2/5] Board creation and inline cleanup" +BOARD_NAME="ux-$(date +%Y%m%d%H%M%S)-$BASHPID" +navigate "/manage/boards" +ab wait --fn "(() => { const button = Array.from(document.querySelectorAll('button')).find((item) => item.textContent?.trim() === 'New board'); return button !== undefined && Object.keys(button).some((key) => key.startsWith('__reactProps$')); })()" --timeout 120000 +ab find role button click --name "New board" --exact +ab wait --fn "Array.from(document.querySelectorAll('input')).some((input) => input.getAttribute('aria-label') === 'Name' || input.labels?.[0]?.textContent?.trim().startsWith('Name'))" --timeout 120000 +assert_no_modal_dialog "Board creation form" +ab snapshot -i +ab find role textbox fill "$BOARD_NAME" --name "Name" --exact +ab wait --text "$BOARD_NAME is available" +ab snapshot -i +assert_eval "(() => { const button = Array.from(document.querySelectorAll('button')).find((item) => item.textContent?.trim() === 'Create'); if (!button || button.disabled) throw new Error('Create board button is not enabled'); return true; })()" +capture "$SCREENSHOT_DIR/02-board-inline-create.png" +BOARD_CREATE_REQUESTED=1 +BOARD_CLEANUP_PENDING=1 +assert_eval "(() => { const button = Array.from(document.querySelectorAll('button')).find((item) => item.textContent?.trim() === 'Create'); if (!button || button.disabled) throw new Error('Create board button is not enabled'); button.click(); return true; })()" +ab wait --url "**/boards/$BOARD_NAME*" --timeout 120000 +BOARD_CREATED=1 +ab snapshot -i + +navigate "/manage/boards" +ab wait --text "$BOARD_NAME" +retry_action_until \ + "(() => { const card = Array.from(document.querySelectorAll('main .mantine-Card-root')).find((item) => item.querySelector('a[href=\"/boards/$BOARD_NAME\"]')); const settings = card?.querySelector('button[aria-label=\"Settings\"]'); settings?.scrollIntoView({ block: 'center' }); if (settings?.getAttribute('aria-expanded') !== 'true') settings?.click(); })()" \ + "Array.from(document.querySelectorAll('[role=\"menuitem\"]')).some((element) => element.offsetParent !== null && element.textContent?.trim() === 'Delete')" \ + "Created board actions did not open" +ab snapshot -i +retry_action_until \ + "Array.from(document.querySelectorAll('[role=\"menuitem\"]')).find((element) => element.offsetParent !== null && element.textContent?.trim() === 'Delete')?.click()" \ + "Array.from(document.querySelectorAll('[role=\"menuitem\"]')).some((element) => element.offsetParent !== null && element.textContent?.trim() === 'Confirm')" \ + "Board Delete action did not enter confirmation state" +ab snapshot -i +retry_action_until \ + "Array.from(document.querySelectorAll('[role=\"menuitem\"]')).find((element) => element.offsetParent !== null && element.textContent?.trim() === 'Confirm')?.click()" \ + "!document.body.innerText.includes('$BOARD_NAME')" \ + "Created board still exists after cleanup" +ab snapshot -i +assert_eval "(() => { if (document.body.innerText.includes('$BOARD_NAME')) throw new Error('Created board still exists after cleanup'); return true; })()" +BOARD_CLEANUP_PENDING=0 +BOARD_CREATED=0 + +echo "[3/5] Calendar inline removal and reload restore" +navigate "/" +ab wait '[data-kind="calendar"]' --timeout 120000 +if [[ "$(ab get count 'button[aria-label="Edit board"]')" == "1" ]]; then + retry_action_until \ + "document.querySelector('button[aria-label=\"Edit board\"]')?.click()" \ + "document.querySelector('button[aria-label=\"Settings for Calendar\"]') !== null" \ + "Board did not enter edit mode" + ab snapshot -i +fi +ab hover '[data-kind="calendar"]' +ab wait --fn "(() => { const button = document.querySelector('button[aria-label=\"Settings for Calendar\"]'); return button !== null && button.offsetParent !== null; })()" --timeout 120000 +ab snapshot -i +retry_action_until \ + "document.querySelector('button[aria-label=\"Settings for Calendar\"]')?.click()" \ + "Array.from(document.querySelectorAll('[role=\"menuitem\"]')).some((element) => element.offsetParent !== null && element.textContent?.trim() === 'Remove item')" \ + "Calendar actions did not open" +ab snapshot -i +retry_action_until \ + "Array.from(document.querySelectorAll('[role=\"menuitem\"]')).find((element) => element.offsetParent !== null && element.textContent?.trim() === 'Remove item')?.click()" \ + "Array.from(document.querySelectorAll('[role=\"menuitem\"]')).some((element) => element.offsetParent !== null && element.textContent?.trim() === 'Are you sure?')" \ + "Calendar removal did not enter confirmation state" +ab snapshot -i +assert_no_dialog "Calendar removal confirmation" +capture "$SCREENSHOT_DIR/03-calendar-inline-remove-confirmation.png" +retry_action_until \ + "Array.from(document.querySelectorAll('[role=\"menuitem\"]')).find((element) => element.offsetParent !== null && element.textContent?.trim() === 'Are you sure?')?.click()" \ + "document.querySelector('[data-kind=\"calendar\"]') === null" \ + "Calendar item was not removed" +ab snapshot -i +ab reload +ab wait --load domcontentloaded +ab wait '[data-kind="calendar"]' +ab snapshot -i +assert_count '[data-kind="calendar"]' 1 "Calendar item was not restored by reloading without Save" + +echo "[4/5] API token inline creation and exact-row cleanup" +navigate "/manage/tools/api" +retry_action_until \ + "Array.from(document.querySelectorAll('[role=\"tab\"]')).find((element) => element.textContent?.trim() === 'Authentication')?.click()" \ + "Array.from(document.querySelectorAll('h1, h2, h3')).some((heading) => heading.offsetParent !== null && heading.textContent?.trim() === 'API Keys')" \ + "Authentication tab did not open" +ab snapshot -i +capture_api_key_ids API_KEY_IDS_BEFORE || fail "Could not capture the pre-create API key IDs" +API_KEY_IDS_BEFORE_SET=() +for api_key_id in "${API_KEY_IDS_BEFORE[@]}"; do + API_KEY_IDS_BEFORE_SET["$api_key_id"]=1 +done +API_KEY_IDS_BEFORE_JSON="$(api_key_ids_to_json API_KEY_IDS_BEFORE)" +API_KEY_CLEANUP_PENDING=1 +ab wait --fn "(() => { const button = Array.from(document.querySelectorAll('button')).find((item) => item.textContent?.trim() === 'Create API token'); return button !== undefined && !button.disabled && Object.keys(button).some((key) => key.startsWith('__reactProps$')); })()" --timeout 120000 +ab find role button click --name "Create API token" --exact +ab wait --text "API token created" --timeout 120000 +ab snapshot -i -s "table" +assert_eval "(() => { const button = Array.from(document.querySelectorAll('button')).find((item) => item.textContent?.trim() === 'Create API token'); if (!button?.disabled) throw new Error('Create API token is not disabled while the new token is visible'); return true; })()" +assert_no_dialog "API token creation result" +# The token stays masked; do not snapshot, reveal, copy, or read the secret alert. +capture "$SCREENSHOT_DIR/04-api-token-created-inline.png" +retry_action_until \ + "Array.from(document.querySelectorAll('button')).find((item) => item.textContent?.trim() === 'Close' && item.offsetParent !== null)?.click()" \ + "!document.body.innerText.includes('API token created')" \ + "API token result did not close" +ab snapshot -i +ab reload +ab wait --load domcontentloaded +retry_action_until \ + "Array.from(document.querySelectorAll('[role=\"tab\"]')).find((element) => element.textContent?.trim() === 'Authentication')?.click()" \ + "Array.from(document.querySelectorAll('h1, h2, h3')).some((heading) => heading.offsetParent !== null && heading.textContent?.trim() === 'API Keys')" \ + "Authentication tab did not reopen after refreshing API keys" +ab wait --fn "(() => { const previous = new Set($API_KEY_IDS_BEFORE_JSON); const created = Array.from(document.querySelectorAll('button[aria-label=\"Delete API token\"]')).map((button) => button.closest('tr')?.querySelector('td')?.textContent?.trim().toLowerCase()).filter(Boolean).filter((id) => !previous.has(id)); return created.length === 1; })()" --timeout 120000 +ab snapshot -i +declare -a API_KEY_IDS_AFTER_CREATE=() +capture_api_key_ids API_KEY_IDS_AFTER_CREATE || fail "Could not capture the post-create API key IDs" +for api_key_id in "${API_KEY_IDS_AFTER_CREATE[@]}"; do + if [[ -z "${API_KEY_IDS_BEFORE_SET[$api_key_id]+present}" ]]; then + if [[ -n "$API_KEY_CREATED_ID" ]]; then + fail "More than one API key appeared after creation" + fi + API_KEY_CREATED_ID="$api_key_id" + fi +done +if [[ -z "$API_KEY_CREATED_ID" ]]; then + fail "Could not identify the newly created API token row" +fi +assert_eval "(() => { const id = '$API_KEY_CREATED_ID'; const button = Array.from(document.querySelectorAll('button[aria-label=\"Delete API token\"]')).find((item) => item.closest('tr')?.querySelector('td')?.textContent?.trim().toLowerCase() === id); if (!button) throw new Error('Created API key row was not found'); button.setAttribute('aria-label', 'Delete created API token'); return true; })()" +ab find role button click --name "Delete created API token" --exact +ab wait --fn "document.querySelector('button[aria-label=\"Confirm\"]')?.offsetParent !== null" +ab snapshot -i +assert_no_dialog "API token deletion confirmation" +capture "$SCREENSHOT_DIR/05-api-token-inline-delete-confirmation.png" +ab find role button click --name "Confirm" --exact +ab wait --fn "!Array.from(document.querySelectorAll('tr')).some((item) => item.querySelector('td')?.textContent?.trim().toLowerCase() === '$API_KEY_CREATED_ID')" --timeout 120000 +ab snapshot -i +assert_eval "(() => { const previous = new Set($API_KEY_IDS_BEFORE_JSON); const current = Array.from(document.querySelectorAll('button[aria-label=\"Delete API token\"]')).map((button) => button.closest('tr')?.querySelector('td')?.textContent?.trim().toLowerCase()).filter(Boolean); if (current.length !== previous.size || current.some((id) => !previous.has(id))) throw new Error('New API token row still exists after cleanup'); return true; })()" +capture "$SCREENSHOT_DIR/06-api-token-cleaned-up.png" +declare -a API_KEY_IDS_AFTER_CLEANUP=() +capture_api_key_ids API_KEY_IDS_AFTER_CLEANUP || fail "Could not verify API key cleanup" +api_key_ids_match_baseline API_KEY_IDS_AFTER_CLEANUP || fail "API key cleanup did not restore the pre-create IDs" +API_KEY_CLEANUP_PENDING=0 +API_KEY_CREATED_ID="" + +echo "[5/5] Certificate upload inline expansion" +navigate "/manage/tools/certificates" +retry_action_until \ + "Array.from(document.querySelectorAll('button')).find((item) => item.textContent?.trim() === 'Add certificate' && item.getAttribute('aria-expanded') === 'false')?.click()" \ + "Array.from(document.querySelectorAll('button')).some((item) => item.textContent?.trim() === 'Add certificate' && item.getAttribute('aria-expanded') === 'true')" \ + "Add certificate section did not expand" +ab snapshot -i +assert_no_dialog "Add certificate section" +capture "$SCREENSHOT_DIR/07-certificate-upload-inline.png" +retry_action_until \ + "Array.from(document.querySelectorAll('button')).find((item) => item.textContent?.trim() === 'Cancel' && item.offsetParent !== null)?.click()" \ + "Array.from(document.querySelectorAll('button')).some((item) => item.textContent?.trim() === 'Add certificate' && item.getAttribute('aria-expanded') === 'false')" \ + "Add certificate section did not close" +ab snapshot -i + +echo "UX coherence happy paths passed. Screenshots remain in $SCREENSHOT_DIR"