Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af13531adc | ||
|
|
7a5edb26c1 |
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
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".
|
||||
|
||||

|
||||
The name form expands below that board card and can be cancelled without leaving the list.
|
||||
|
||||
## Setting boards as your home board
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
Then select the certificate file you want to add.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||

|
||||
|
||||
@@ -269,12 +270,12 @@ The default is 4 hours in the future.
|
||||
|
||||

|
||||
|
||||
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:
|
||||
|
||||

|
||||
|
||||
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.
|
||||
@@ -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<HTMLInputElement>(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 (
|
||||
<DangerZoneRoot>
|
||||
@@ -88,9 +89,28 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
|
||||
label={t("section.dangerZone.action.rename.label")}
|
||||
description={t("section.dangerZone.action.rename.description")}
|
||||
action={
|
||||
<Button variant="subtle" color="red" onClick={onRenameClick}>
|
||||
{t("section.dangerZone.action.rename.button")}
|
||||
</Button>
|
||||
isRenaming ? (
|
||||
<Box component="form" onSubmit={handleRename}>
|
||||
<Group wrap="wrap" justify="end">
|
||||
<TextInput
|
||||
ref={renameInputRef}
|
||||
aria-label={t("section.dangerZone.action.rename.label")}
|
||||
size="sm"
|
||||
{...renameForm.getInputProps("name")}
|
||||
/>
|
||||
<Button variant="default" onClick={cancelRename} disabled={isRenamePending}>
|
||||
{tCommonAction("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" color="red" loading={isRenamePending}>
|
||||
{tCommonAction("confirm")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
) : (
|
||||
<Button variant="subtle" color="red" onClick={() => setIsRenaming(true)}>
|
||||
{t("section.dangerZone.action.rename.button")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{!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={
|
||||
<Button variant="subtle" color="red" loading={isChangeVisibilityPending} onClick={onVisibilityClick}>
|
||||
<InlineConfirmButton
|
||||
variant="subtle"
|
||||
color="red"
|
||||
loading={isChangeVisibilityPending}
|
||||
onConfirm={handleVisibilityChange}
|
||||
confirmLabel={tCommonAction("confirm")}
|
||||
>
|
||||
{t(`section.dangerZone.action.visibility.button.${visibility}`)}
|
||||
</Button>
|
||||
</InlineConfirmButton>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -108,9 +134,15 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
|
||||
label={tCommonAction("delete")}
|
||||
description={t("section.dangerZone.action.delete.description")}
|
||||
action={
|
||||
<Button variant="subtle" color="red" loading={isDeletePending} onClick={onDeleteClick}>
|
||||
<InlineConfirmButton
|
||||
variant="subtle"
|
||||
color="red"
|
||||
loading={isDeletePending}
|
||||
onConfirm={handleDelete}
|
||||
confirmLabel={tCommonAction("confirm")}
|
||||
>
|
||||
{tCommonAction("delete")}
|
||||
</Button>
|
||||
</InlineConfirmButton>
|
||||
}
|
||||
/>
|
||||
</DangerZoneRoot>
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [resettingLayoutId, setResettingLayoutId] = useState<string | null>(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
|
||||
<Group justify="space-between" mt="lg" gap="sm" wrap="wrap">
|
||||
<Group gap="xs">
|
||||
{layout.role !== "base" && persistedLayout && (
|
||||
<Button
|
||||
<InlineConfirmButton
|
||||
type="button"
|
||||
variant="default"
|
||||
leftSection={<IconRefresh size={16} color="var(--mantine-color-red-5)" />}
|
||||
loading={resettingLayoutId === layout.id}
|
||||
disabled={isSaving}
|
||||
onClick={() => confirmReset(layout)}
|
||||
onConfirm={() => handleReset(layout)}
|
||||
confirmLabel={tCommon("action.confirm")}
|
||||
>
|
||||
{tBoard("setting.section.layout.reset.action")}
|
||||
</Button>
|
||||
</InlineConfirmButton>
|
||||
)}
|
||||
{layout.role === "custom" && (
|
||||
<Button
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import { InlineConfirmActionIcon } from "@homarr/ui";
|
||||
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";
|
||||
|
||||
@@ -18,48 +17,41 @@ interface AppDeleteButtonProps {
|
||||
export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
|
||||
const t = useI18n("app.page.delete");
|
||||
const tCommon = useI18n("common");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { mutate, isPending } = clientApi.app.delete.useMutation();
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
openConfirmModal({
|
||||
title: t("title"),
|
||||
children: t("message", {
|
||||
name: app.name,
|
||||
}),
|
||||
onConfirm: () => {
|
||||
mutate(
|
||||
{ id: app.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: tCommon("notification.delete.success"),
|
||||
message: t("notification.success.message"),
|
||||
});
|
||||
void revalidatePathActionAsync("/manage/apps");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: tCommon("notification.delete.error"),
|
||||
message: t("notification.error.message"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
const onConfirm = useCallback(() => {
|
||||
mutate(
|
||||
{ id: app.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: tCommon("notification.delete.success"),
|
||||
message: t("notification.success.message"),
|
||||
});
|
||||
void revalidatePathActionAsync("/manage/apps");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: tCommon("notification.delete.error"),
|
||||
message: t("notification.error.message"),
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [app, mutate, t, tCommon, openConfirmModal]);
|
||||
);
|
||||
}, [app.id, mutate, t, tCommon]);
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
<InlineConfirmActionIcon
|
||||
confirmLabel={tCommon("action.confirm")}
|
||||
confirmationAriaLabel={tCommon("action.confirm")}
|
||||
onConfirm={onConfirm}
|
||||
loading={isPending}
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size={44}
|
||||
onClick={onClick}
|
||||
aria-label={tCommon("action.deleteNamed", { name: app.name })}
|
||||
>
|
||||
<IconTrash color="red" size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</InlineConfirmActionIcon>
|
||||
);
|
||||
};
|
||||
+12
-34
@@ -8,10 +8,8 @@ import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { DuplicateBoardModal } from "@homarr/modals-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
import { InlineConfirmMenuItem, Link } from "@homarr/ui";
|
||||
|
||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||
|
||||
@@ -25,9 +23,10 @@ interface BoardCardMenuDropdownProps {
|
||||
RouterOutputs["board"]["getManageOverview"][number],
|
||||
"id" | "name" | "creator" | "userPermissions" | "groupPermissions" | "isPublic"
|
||||
>;
|
||||
onDuplicate: () => void;
|
||||
}
|
||||
|
||||
export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => {
|
||||
export const BoardCardMenuDropdown = ({ board, onDuplicate }: BoardCardMenuDropdownProps) => {
|
||||
const t = useI18n("management.page.board.action");
|
||||
const tRoot = useI18n();
|
||||
const tCommon = useI18n("common");
|
||||
@@ -35,9 +34,6 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
||||
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
|
||||
const { data: session } = useSession();
|
||||
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { openModal: openDuplicateModal } = useModalAction(DuplicateBoardModal);
|
||||
|
||||
const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({
|
||||
onSettled: async () => {
|
||||
// Revalidate all as it's part of the user settings, /boards page and board manage page
|
||||
@@ -56,20 +52,10 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
||||
},
|
||||
});
|
||||
|
||||
const handleDeletion = useCallback(() => {
|
||||
openConfirmModal({
|
||||
title: tCommon("action.deleteNamed", { name: board.name }),
|
||||
children: t("delete.confirm.description", {
|
||||
name: board.name,
|
||||
}),
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
onConfirm: async () => {
|
||||
await deleteBoardMutation.mutateAsync({
|
||||
id: board.id,
|
||||
});
|
||||
},
|
||||
});
|
||||
}, [board.id, board.name, deleteBoardMutation, openConfirmModal, t, tCommon]);
|
||||
const handleDeletion = useCallback(
|
||||
() => deleteBoardMutation.mutateAsync({ id: board.id }),
|
||||
[board.id, deleteBoardMutation],
|
||||
);
|
||||
|
||||
const handleSetHomeBoard = useCallback(async () => {
|
||||
await setHomeBoardMutation.mutateAsync({ id: board.id });
|
||||
@@ -79,15 +65,6 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
||||
await setMobileHomeBoardMutation.mutateAsync({ id: board.id });
|
||||
}, [board.id, setMobileHomeBoardMutation]);
|
||||
|
||||
const handleDuplicateBoard = useCallback(() => {
|
||||
openDuplicateModal({
|
||||
board: {
|
||||
id: board.id,
|
||||
name: board.name,
|
||||
},
|
||||
});
|
||||
}, [board.id, board.name, openDuplicateModal]);
|
||||
|
||||
return (
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={handleSetHomeBoard} leftSection={<IconHome {...iconProps} />}>
|
||||
@@ -97,7 +74,7 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
||||
{t("setMobileHomeBoard.label")}
|
||||
</Menu.Item>
|
||||
{session?.user.permissions.includes("board-create") && (
|
||||
<Menu.Item onClick={handleDuplicateBoard} leftSection={<IconCopy {...iconProps} />}>
|
||||
<Menu.Item onClick={onDuplicate} leftSection={<IconCopy {...iconProps} />}>
|
||||
{tRoot("board.action.duplicate.title")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
@@ -117,14 +94,15 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Label c="red.7">{tCommon("dangerZone")}</Menu.Label>
|
||||
<Menu.Item
|
||||
<InlineConfirmMenuItem
|
||||
c="red.7"
|
||||
leftSection={<IconTrash {...iconProps} />}
|
||||
onClick={handleDeletion}
|
||||
onConfirm={handleDeletion}
|
||||
confirmLabel={tCommon("action.confirm")}
|
||||
disabled={deleteBoardMutation.isPending}
|
||||
>
|
||||
{tCommon("action.delete")}
|
||||
</Menu.Item>
|
||||
</InlineConfirmMenuItem>
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardSection,
|
||||
Collapse,
|
||||
Group,
|
||||
Menu,
|
||||
MenuTarget,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { IconDeviceMobile, IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { Link, UserAvatar } from "@homarr/ui";
|
||||
|
||||
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
|
||||
import { BoardCardMenuDropdown } from "./board-card-menu-dropdown";
|
||||
import { DuplicateBoardForm } from "./duplicate-board-form";
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
export const BoardCard = ({ board, isMenuVisible, labels }: BoardCardProps) => {
|
||||
const [isDuplicateOpen, setIsDuplicateOpen] = useState(false);
|
||||
const [duplicateFormKey, setDuplicateFormKey] = useState(0);
|
||||
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card padding={0} withBorder>
|
||||
<CardSection withBorder>
|
||||
<BoardLayoutThumbnail preview={board.preview} label={labels.preview} />
|
||||
</CardSection>
|
||||
<CardSection p="sm" withBorder>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0, flex: "1 1 12rem" }}>
|
||||
<Tooltip label={labels.visibility}>
|
||||
<VisibilityIcon size={20} stroke={1.5} />
|
||||
</Tooltip>
|
||||
<Text fw={700} truncate>
|
||||
{board.name}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" justify="flex-end" wrap="wrap" style={{ marginInlineStart: "auto" }}>
|
||||
{board.isHome && (
|
||||
<Tooltip label={labels.homeTooltip}>
|
||||
<Badge tt="none" color="yellow" variant="light" leftSection={<IconHomeFilled size=".7rem" />}>
|
||||
{labels.home}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{board.isMobileHome && (
|
||||
<Tooltip label={labels.mobileHomeTooltip}>
|
||||
<Badge tt="none" color="yellow" variant="light" leftSection={<IconDeviceMobile size=".7rem" />}>
|
||||
{labels.mobileHome}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{board.creator && (
|
||||
<Tooltip label={board.creator.name ?? labels.unknownCreator}>
|
||||
<UserAvatar user={board.creator} size="sm" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</CardSection>
|
||||
|
||||
<CardSection>
|
||||
<Group gap={0} wrap="nowrap">
|
||||
<Button
|
||||
style={{ border: "none", borderRadius: 0 }}
|
||||
component={Link}
|
||||
href={`/boards/${board.name}`}
|
||||
variant="default"
|
||||
h={44}
|
||||
fullWidth
|
||||
>
|
||||
{labels.open}
|
||||
</Button>
|
||||
{isMenuVisible && (
|
||||
<Menu position="bottom-end">
|
||||
<MenuTarget>
|
||||
<ActionIcon
|
||||
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
|
||||
variant="default"
|
||||
size={44}
|
||||
aria-label={labels.settings}
|
||||
>
|
||||
<IconDotsVertical size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</MenuTarget>
|
||||
<BoardCardMenuDropdown
|
||||
board={board}
|
||||
onDuplicate={() => {
|
||||
setDuplicateFormKey((value) => value + 1);
|
||||
setIsDuplicateOpen(true);
|
||||
}}
|
||||
/>
|
||||
</Menu>
|
||||
)}
|
||||
</Group>
|
||||
</CardSection>
|
||||
</Card>
|
||||
<Collapse expanded={isDuplicateOpen}>
|
||||
<Card mt="sm" withBorder>
|
||||
<DuplicateBoardForm key={duplicateFormKey} board={board} onCancel={() => setIsDuplicateOpen(false)} />
|
||||
</Card>
|
||||
</Collapse>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<ManageMobilePrimaryAction>
|
||||
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openModal}>
|
||||
{t("new.label")}
|
||||
</Button>
|
||||
</ManageMobilePrimaryAction>
|
||||
<Stack>
|
||||
<Group justify="end">
|
||||
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={toggle}>
|
||||
{t("new.label")}
|
||||
</Button>
|
||||
</Group>
|
||||
<Collapse expanded={isOpen}>
|
||||
<Card withBorder>
|
||||
<BoardCreateForm key={formKey} onCancel={close} />
|
||||
</Card>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
+22
-31
@@ -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<InnerProps>(({ 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<InnerProps>(({ actions, innerProp
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit(async (values) => {
|
||||
// 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<InnerProps>(({ actions, innerProp
|
||||
);
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Stack maw={720}>
|
||||
<Text size="sm" c="gray.6">
|
||||
{tBoard("action.duplicate.message", { name: innerProps.board.name })}
|
||||
{tBoard("action.duplicate.message", { name: board.name })}
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
label={tCommon("field.name")}
|
||||
data-autofocus
|
||||
autoFocus
|
||||
{...form.getInputProps("name")}
|
||||
description={
|
||||
boardNameStatus.description ? (
|
||||
<Group c={boardNameStatus.description.color} gap="xs" align="center">
|
||||
{boardNameStatus.description.icon ? <boardNameStatus.description.icon size={16} /> : null}
|
||||
<span>{boardNameStatus.description.label}</span>
|
||||
</Group>
|
||||
) : null
|
||||
}
|
||||
description={<BoardNameAvailability status={boardNameStatus} />}
|
||||
withAsterisk
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
|
||||
<Group justify="end" wrap="wrap">
|
||||
<Button variant="default" onClick={onCancel}>
|
||||
{tCommon("action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending}>
|
||||
{tCommon("action.create")}
|
||||
<Button type="submit" loading={isPending} disabled={!boardNameStatus.canSubmit}>
|
||||
{tBoard("action.duplicate.title")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle(t) {
|
||||
return t("board.action.duplicate.title");
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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 (
|
||||
<ManagePageLayout
|
||||
title={t("title")}
|
||||
primaryAction={
|
||||
canCreateBoards ? (
|
||||
<TourTarget id="manage-boards-create">
|
||||
<CreateBoardButton />
|
||||
</TourTarget>
|
||||
) : undefined
|
||||
}
|
||||
floatingPrimaryAction={canCreateBoards}
|
||||
>
|
||||
<ManagePageLayout title={t("title")}>
|
||||
{canCreateBoards && (
|
||||
<TourTarget id="manage-boards-create">
|
||||
<CreateBoardButton />
|
||||
</TourTarget>
|
||||
)}
|
||||
{boards.length === 0 && (
|
||||
<NoResults
|
||||
icon={IconLayoutDashboard}
|
||||
title={t("noResults.title")}
|
||||
description={t("noResults.description")}
|
||||
action={{ label: t("action.new.label"), href: "?create=true", hidden: !canCreateBoards }}
|
||||
/>
|
||||
<NoResults icon={IconLayoutDashboard} title={t("noResults.title")} description={t("noResults.description")} />
|
||||
)}
|
||||
{boards.length > 0 && (
|
||||
<TourTarget id="manage-boards-list">
|
||||
@@ -92,97 +59,3 @@ export default async function ManageBoardsPage() {
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Card padding={0} withBorder>
|
||||
<CardSection withBorder>
|
||||
<BoardLayoutThumbnail preview={board.preview} label={labels.preview} />
|
||||
</CardSection>
|
||||
<CardSection p="sm" withBorder>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0, flex: "1 1 12rem" }}>
|
||||
<Tooltip label={labels.visibility}>
|
||||
<VisibilityIcon size={20} stroke={1.5} />
|
||||
</Tooltip>
|
||||
<Text fw={700} truncate>
|
||||
{board.name}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" justify="flex-end" wrap="wrap" style={{ marginInlineStart: "auto" }}>
|
||||
{board.isHome && (
|
||||
<Tooltip label={labels.homeTooltip}>
|
||||
<Badge tt="none" color="yellow" variant="light" leftSection={<IconHomeFilled size=".7rem" />}>
|
||||
{labels.home}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{board.isMobileHome && (
|
||||
<Tooltip label={labels.mobileHomeTooltip}>
|
||||
<Badge tt="none" color="yellow" variant="light" leftSection={<IconDeviceMobile size=".7rem" />}>
|
||||
{labels.mobileHome}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{board.creator && (
|
||||
<Tooltip label={board.creator.name ?? labels.unknownCreator}>
|
||||
<UserAvatar user={board.creator} size="sm" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</CardSection>
|
||||
|
||||
<CardSection>
|
||||
<Group gap={0} wrap="nowrap">
|
||||
<Button
|
||||
style={{ border: "none", borderRadius: 0 }}
|
||||
component={Link}
|
||||
href={`/boards/${board.name}`}
|
||||
variant="default"
|
||||
h={44}
|
||||
fullWidth
|
||||
>
|
||||
{labels.open}
|
||||
</Button>
|
||||
{isMenuVisible && (
|
||||
<Menu position="bottom-end">
|
||||
<MenuTarget>
|
||||
<ActionIcon
|
||||
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
|
||||
variant="default"
|
||||
size={44}
|
||||
aria-label={labels.settings}
|
||||
>
|
||||
<IconDotsVertical size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</MenuTarget>
|
||||
<BoardCardMenuDropdown board={board} />
|
||||
</Menu>
|
||||
)}
|
||||
</Group>
|
||||
</CardSection>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -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 && (
|
||||
<Menu.Item
|
||||
<InlineConfirmMenuItem
|
||||
color="red"
|
||||
leftSection={<IconTrash {...iconProps} />}
|
||||
onClick={handleDelete}
|
||||
onConfirm={handleDelete}
|
||||
confirmLabel={tCommon("action.confirm")}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{t("action.delete")}
|
||||
</Menu.Item>
|
||||
</InlineConfirmMenuItem>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
+10
-8
@@ -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 }) {
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
|
||||
<WorkshopReportForm
|
||||
client={session.client}
|
||||
submissionId={submission.id}
|
||||
opened={reportOpened}
|
||||
onClose={reportControls.close}
|
||||
/>
|
||||
|
||||
<ManageStickyFooter
|
||||
secondary={
|
||||
<>
|
||||
@@ -241,6 +248,8 @@ export function WorkshopDetail({ id }: { id: string }) {
|
||||
leftSection={<IconFlag size={16} />}
|
||||
disabled={!session.user}
|
||||
onClick={reportControls.open}
|
||||
aria-expanded={reportOpened}
|
||||
aria-controls="workshop-report-form"
|
||||
>
|
||||
{t("report")}
|
||||
</Button>
|
||||
@@ -259,13 +268,6 @@ export function WorkshopDetail({ id }: { id: string }) {
|
||||
</Button>
|
||||
</ManageStickyFooter>
|
||||
</Stack>
|
||||
|
||||
<WorkshopReportModal
|
||||
client={session.client}
|
||||
submissionId={submission.id}
|
||||
opened={reportOpened}
|
||||
onClose={reportControls.close}
|
||||
/>
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
+77
-79
@@ -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 = <Text size="md">{tError(`certificate.description.${error.data.reason}`)}</Text>;
|
||||
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" ? (
|
||||
<Button
|
||||
type="button"
|
||||
<InlineConfirmButton
|
||||
variant="default"
|
||||
fullWidth
|
||||
onClick={error.data.reason === "hostnameMismatch" ? handleTrustHostname : handleTrustSelfSigned}
|
||||
loading={error.data.reason === "hostnameMismatch" ? isTrustHostnamePending : isTrustSelfSignedPending}
|
||||
onConfirm={error.data.reason === "hostnameMismatch" ? handleTrustHostname : handleTrustSelfSigned}
|
||||
confirmLabel={trustConfirmLabel}
|
||||
>
|
||||
{tError("certificate.action.trust.label")}
|
||||
</Button>
|
||||
</InlineConfirmButton>
|
||||
) : null}
|
||||
{error.data.reason === "untrusted" && !rootCertificate.isSelfSigned ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
fullWidth
|
||||
onClick={() =>
|
||||
openUploadModal({
|
||||
onSuccess() {
|
||||
setShowRetryButton(true);
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
{tError("certificate.action.upload.label")}
|
||||
</Button>
|
||||
<Stack gap="sm">
|
||||
{!showUploadForm && (
|
||||
<Button variant="default" fullWidth onClick={() => setShowUploadForm(true)}>
|
||||
{tError("certificate.action.upload.label")}
|
||||
</Button>
|
||||
)}
|
||||
{showUploadForm && (
|
||||
<Card withBorder>
|
||||
<CertificateUploadForm
|
||||
embedded
|
||||
onCancel={() => setShowUploadForm(false)}
|
||||
onSuccess={() => {
|
||||
setShowRetryButton(true);
|
||||
setShowUploadForm(false);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<ActionIcon
|
||||
<InlineConfirmActionIcon
|
||||
confirmLabel={tCommon("action.confirm")}
|
||||
confirmationAriaLabel={tCommon("action.confirm")}
|
||||
onConfirm={onConfirm}
|
||||
loading={isPending}
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size={44}
|
||||
onClick={() => {
|
||||
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 })}
|
||||
>
|
||||
<IconTrash color="red" size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</InlineConfirmActionIcon>
|
||||
);
|
||||
};
|
||||
@@ -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: () => <b>{media.name}</b> }),
|
||||
// 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 (
|
||||
<Tooltip label={t("action.delete.label")} openDelay={500}>
|
||||
<ActionIcon color="red" variant="subtle" onClick={onClick} loading={isPending}>
|
||||
<InlineConfirmActionIcon
|
||||
color="red"
|
||||
variant="subtle"
|
||||
onConfirm={onConfirm}
|
||||
confirmLabel={tCommon("action.confirm")}
|
||||
confirmationAriaLabel={tCommon("action.confirm")}
|
||||
loading={isPending}
|
||||
aria-label={t("action.delete.label")}
|
||||
>
|
||||
<IconTrash color="red" size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</InlineConfirmActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
+25
-33
@@ -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 (
|
||||
<ActionIcon
|
||||
<InlineConfirmActionIcon
|
||||
confirmLabel={actionT("confirm")}
|
||||
confirmationAriaLabel={actionT("confirm")}
|
||||
onConfirm={onConfirm}
|
||||
loading={isPending}
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size={44}
|
||||
onClick={onClick}
|
||||
aria-label={actionT("deleteNamed", { name: searchEngine.name })}
|
||||
>
|
||||
<IconTrash color="red" size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</InlineConfirmActionIcon>
|
||||
);
|
||||
};
|
||||
@@ -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<ApiKey> };
|
||||
|
||||
interface ApiKeysTableMeta {
|
||||
handleDelete: (id: string) => Promise<void>;
|
||||
confirmLabel: string;
|
||||
deleteTitle: string;
|
||||
isPendingDelete: boolean;
|
||||
}
|
||||
|
||||
const ApiKeyCreatedByCell = ({ row }: ApiKeyCellProps) => (
|
||||
<Group gap="xs">
|
||||
<UserAvatar user={row.original.user} size="sm" />
|
||||
<Text>{row.original.user.name}</Text>
|
||||
</Group>
|
||||
);
|
||||
|
||||
const ApiKeyActionsCell = ({ row, table }: ApiKeyCellProps & { table: MRT_TableInstance<ApiKey> }) => {
|
||||
const meta = table.options.meta as ApiKeysTableMeta;
|
||||
return (
|
||||
<Group gap="xs">
|
||||
<InlineConfirmActionIcon
|
||||
onConfirm={() => meta.handleDelete(row.original.id)}
|
||||
confirmLabel={meta.confirmLabel}
|
||||
confirmationAriaLabel={meta.confirmLabel}
|
||||
loading={meta.isPendingDelete}
|
||||
color="red"
|
||||
variant="subtle"
|
||||
aria-label={meta.deleteTitle}
|
||||
>
|
||||
<IconTrash size="1rem" />
|
||||
</InlineConfirmActionIcon>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export const ApiKeysManagement = ({ apiKeys, onCreated }: ApiKeysManagementProps) => {
|
||||
const [newApiKey, setNewApiKey] = useState<string | null>(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<MRT_ColumnDef<RouterOutputs["apiKeys"]["getAll"][number]>[]>(
|
||||
const columns = useMemo<MRT_ColumnDef<ApiKey>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "id",
|
||||
@@ -61,36 +92,32 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
|
||||
{
|
||||
accessorKey: "user",
|
||||
header: t("table.header.createdBy"),
|
||||
Cell: ({ row }) => (
|
||||
<Group gap={"xs"}>
|
||||
<UserAvatar user={row.original.user} size={"sm"} />
|
||||
<Text>{row.original.user.name}</Text>
|
||||
</Group>
|
||||
),
|
||||
Cell: ApiKeyCreatedByCell,
|
||||
},
|
||||
{
|
||||
header: t("table.header.actions"),
|
||||
Cell: ({ row }) => (
|
||||
<Group gap="xs">
|
||||
<ActionIcon onClick={() => handleDelete(row.original.id)} loading={isPendingDelete} c="red">
|
||||
<IconTrash size="1rem" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
),
|
||||
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: () => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
mutateCreate();
|
||||
}}
|
||||
loading={isPendingCreate}
|
||||
disabled={newApiKey !== null}
|
||||
>
|
||||
{t("button.createApiToken")}
|
||||
</Button>
|
||||
@@ -104,6 +131,7 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
|
||||
return (
|
||||
<Stack>
|
||||
<Title>{t("title")}</Title>
|
||||
{newApiKey && <NewApiKeyAlert apiKey={newApiKey} onDismiss={handleDismissNewApiKey} />}
|
||||
<MantineReactTable table={table} />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -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<string | null>("documentation");
|
||||
const [hasApiKeys, setHasApiKeys] = useState(apiKeys.length > 0);
|
||||
|
||||
return (
|
||||
<Tabs value={activeTab} onChange={setActiveTab}>
|
||||
@@ -48,10 +49,17 @@ export function ApiPageTabs({
|
||||
{mcpLabel}
|
||||
</TabsTab>
|
||||
</TabsList>
|
||||
<TabsPanel value="authentication">{authenticationPanel}</TabsPanel>
|
||||
<TabsPanel value="authentication">
|
||||
<ApiKeysManagement apiKeys={apiKeys} onCreated={() => setHasApiKeys(true)} />
|
||||
</TabsPanel>
|
||||
<TabsPanel value="documentation">{documentationPanel}</TabsPanel>
|
||||
<TabsPanel value="mcp">
|
||||
<McpInstructions baseUrl={baseUrl} hasApiKeys={hasApiKeys} toolGroups={toolGroups} />
|
||||
<McpInstructions
|
||||
baseUrl={baseUrl}
|
||||
hasApiKeys={hasApiKeys}
|
||||
toolGroups={toolGroups}
|
||||
onApiKeyCreated={() => setHasApiKeys(true)}
|
||||
/>
|
||||
</TabsPanel>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<Stack>
|
||||
<Text>{t("description")}</Text>
|
||||
<PasswordInput value={innerProps.apiKey} visible={visible} onVisibilityChange={toggle} readOnly />
|
||||
<CopyButton value={innerProps.apiKey}>
|
||||
{({ copy }) => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
copy();
|
||||
actions.closeModal();
|
||||
}}
|
||||
variant="default"
|
||||
fullWidth
|
||||
>
|
||||
{t("button")}
|
||||
</Button>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Stack>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle(t) {
|
||||
return t("management.page.tool.api.modal.createApiToken.title");
|
||||
},
|
||||
});
|
||||
@@ -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<string | null>(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
|
||||
<Text size="sm">{t("description")}</Text>
|
||||
</Alert>
|
||||
|
||||
{!hasApiKeys && (
|
||||
{!hasApiKeys && !newApiKey && (
|
||||
<Alert variant="light" color="yellow" icon={<IconKey size={18} />}>
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm">{t("noApiKey")}</Text>
|
||||
@@ -131,6 +136,8 @@ export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstruct
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{newApiKey && <NewApiKeyAlert apiKey={newApiKey} onDismiss={handleDismissNewApiKey} />}
|
||||
|
||||
<div>
|
||||
<Title order={5} mb="xs">
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
+26
-14
@@ -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 };
|
||||
+110
@@ -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>
|
||||
);
|
||||
};
|
||||
+30
-31
@@ -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>
|
||||
);
|
||||
};
|
||||
+25
-26
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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")} />
|
||||
)}
|
||||
|
||||
+5
-20
@@ -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>
|
||||
);
|
||||
};
|
||||
+30
-30
@@ -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,15 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
|
||||
)}
|
||||
</FileButton>
|
||||
{user.image && (
|
||||
<Menu.Item onClick={handleRemoveAvatar} leftSection={<IconPhotoX size={16} stroke={1.5} />}>
|
||||
<InlineConfirmMenuItem
|
||||
color="red"
|
||||
onConfirm={handleRemoveAvatar}
|
||||
confirmLabel={tCommon("action.confirm")}
|
||||
disabled={isPending}
|
||||
leftSection={<IconPhotoX size={16} stroke={1.5} />}
|
||||
>
|
||||
{tManageAvatar("removeImage.label")}
|
||||
</Menu.Item>
|
||||
</InlineConfirmMenuItem>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
+18
-27
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
+62
-51
@@ -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>
|
||||
);
|
||||
};
|
||||
+19
-25
@@ -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")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+15
-21
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}`}>
|
||||
|
||||
+2
-5
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" />;
|
||||
@@ -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>
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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,14 @@ const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBou
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<BoardRemoveConfirmationMenuItem
|
||||
label={tItem("action.remove")}
|
||||
confirmationLabel={tItem("remove.message")}
|
||||
<InlineConfirmMenuItem
|
||||
color="red"
|
||||
confirmLabel={tItem("remove.message")}
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onConfirm={() => removeItem({ itemId: item.id })}
|
||||
/>
|
||||
>
|
||||
{tItem("action.remove")}
|
||||
</InlineConfirmMenuItem>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
@@ -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">;
|
||||
@@ -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,14 @@ export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem
|
||||
{tItem("action.moveResize")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<BoardRemoveConfirmationMenuItem
|
||||
label={tContainer("action.remove")}
|
||||
confirmationLabel={tContainer("remove.message")}
|
||||
<InlineConfirmMenuItem
|
||||
color="red"
|
||||
confirmLabel={tContainer("remove.message")}
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onConfirm={() => removeContainer({ id: section.id })}
|
||||
/>
|
||||
>
|
||||
{tContainer("action.remove")}
|
||||
</InlineConfirmMenuItem>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
+63
-44
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export { AddBoardModal } from "./add-board-modal";
|
||||
export { DuplicateBoardModal } from "./duplicate-board-modal";
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./add-certificate-modal";
|
||||
@@ -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"),
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export { AddGroupModal } from "./add-group-modal";
|
||||
@@ -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";
|
||||
@@ -1,2 +0,0 @@
|
||||
export { InviteCopyModal } from "./invite-copy-modal";
|
||||
export { InviteCreateModal } from "./invite-create-modal";
|
||||
@@ -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 }));
|
||||
};
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
@@ -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"),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1783,6 +1783,7 @@
|
||||
"deleteNamed": "Delete {name}",
|
||||
"clearSearch": "Clear search",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"here": "here",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>();
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
+496
@@ -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"
|
||||
Reference in new issue
Block a user