feat: reduce disruptive UI confirmations (#6688)
* feat: reduce disruptive UI confirmations * fix: keep destructive confirmations red
This commit is contained in:
1 parent
9c441571bc
commit
a002b91add
86 files changed
+3201
-2277
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user