feat: add context specific search and actions (#1570)

This commit is contained in:
Meier Lukas
2024-12-06 19:07:27 +01:00
committed by GitHub
parent aaa3e7ce0c
commit c840ff85bc
18 changed files with 517 additions and 183 deletions

View File

@@ -0,0 +1,34 @@
import { Group, Text } from "@mantine/core";
import { createGroup } from "../../lib/group";
import type { ContextSpecificItem } from "../home/context";
import { useSpotlightContextActions } from "../home/context";
export const contextSpecificActionsSearchGroups = createGroup<ContextSpecificItem>({
title: (t) => t("search.mode.command.group.localCommand.title"),
keyPath: "id",
Component(option) {
const icon =
typeof option.icon !== "string" ? (
<option.icon size={24} />
) : (
<img width={24} height={24} src={option.icon} alt={option.name} />
);
return (
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
{icon}
<Text>{option.name}</Text>
</Group>
);
},
useInteraction(option) {
return option.interaction();
},
filter(query, option) {
return option.name.toLowerCase().includes(query.toLowerCase());
},
useOptions() {
return useSpotlightContextActions();
},
});

View File

@@ -0,0 +1,166 @@
import { Group, Text, useMantineColorScheme } from "@mantine/core";
import type { TablerIcon } from "@tabler/icons-react";
import {
IconBox,
IconCategoryPlus,
IconFileImport,
IconLanguage,
IconMailForward,
IconMoon,
IconPlug,
IconSun,
IconUserPlus,
IconUsersGroup,
} from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { useModalAction } from "@homarr/modals";
import { AddBoardModal, AddGroupModal, ImportBoardModal, InviteCreateModal } from "@homarr/modals-collection";
import { useScopedI18n } from "@homarr/translation/client";
import { createGroup } from "../../lib/group";
import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction";
import { interaction } from "../../lib/interaction";
import { languageChildrenOptions } from "./children/language";
import { newIntegrationChildrenOptions } from "./children/new-integration";
// This has to be type so it can be interpreted as Record<string, unknown>.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type Command<TSearchInteraction extends SearchInteraction = SearchInteraction> = {
commandKey: string;
icon: TablerIcon;
name: string;
useInteraction: (
_c: Command<TSearchInteraction>,
query: string,
) => inferSearchInteractionDefinition<TSearchInteraction>;
};
export const globalCommandGroup = createGroup<Command>({
keyPath: "commandKey",
title: "Global commands",
useInteraction: (option, query) => option.useInteraction(option, query),
Component: ({ icon: Icon, name }) => (
<Group px="md" py="sm">
<Icon stroke={1.5} />
<Text>{name}</Text>
</Group>
),
filter(query, option) {
return option.name.toLowerCase().includes(query.toLowerCase());
},
useOptions() {
const tOption = useScopedI18n("search.mode.command.group.globalCommand.option");
const { colorScheme } = useMantineColorScheme();
const { data: session } = useSession();
const commands: (Command & { hidden?: boolean })[] = [
{
commandKey: "colorScheme",
icon: colorScheme === "dark" ? IconSun : IconMoon,
name: tOption(`colorScheme.${colorScheme === "dark" ? "light" : "dark"}`),
useInteraction: () => {
const { toggleColorScheme } = useMantineColorScheme();
return {
type: "javaScript",
onSelect: toggleColorScheme,
};
},
},
{
commandKey: "language",
icon: IconLanguage,
name: tOption("language.label"),
useInteraction: interaction.children(languageChildrenOptions),
},
{
commandKey: "newBoard",
icon: IconCategoryPlus,
name: tOption("newBoard.label"),
useInteraction() {
const { openModal } = useModalAction(AddBoardModal);
return {
type: "javaScript",
onSelect() {
openModal(undefined);
},
};
},
hidden: !session?.user.permissions.includes("board-create"),
},
{
commandKey: "importBoard",
icon: IconFileImport,
name: tOption("importBoard.label"),
useInteraction() {
const { openModal } = useModalAction(ImportBoardModal);
return {
type: "javaScript",
onSelect() {
openModal(undefined);
},
};
},
hidden: !session?.user.permissions.includes("board-create"),
},
{
commandKey: "newApp",
icon: IconBox,
name: tOption("newApp.label"),
useInteraction: interaction.link(() => ({ href: "/manage/apps/new" })),
hidden: !session?.user.permissions.includes("app-create"),
},
{
commandKey: "newIntegration",
icon: IconPlug,
name: tOption("newIntegration.label"),
useInteraction: interaction.children(newIntegrationChildrenOptions),
hidden: !session?.user.permissions.includes("integration-create"),
},
{
commandKey: "newUser",
icon: IconUserPlus,
name: tOption("newUser.label"),
useInteraction: interaction.link(() => ({ href: "/manage/users/new" })),
hidden: !session?.user.permissions.includes("admin"),
},
{
commandKey: "newInvite",
icon: IconMailForward,
name: tOption("newInvite.label"),
useInteraction() {
const { openModal } = useModalAction(InviteCreateModal);
return {
type: "javaScript",
onSelect() {
openModal(undefined);
},
};
},
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);
},
};
},
hidden: !session?.user.permissions.includes("admin"),
},
];
return commands.filter((command) => !command.hidden);
},
});

View File

@@ -1,173 +1,9 @@
import { Group, Text, useMantineColorScheme } from "@mantine/core";
import {
IconBox,
IconCategoryPlus,
IconFileImport,
IconLanguage,
IconMailForward,
IconMoon,
IconPlug,
IconSun,
IconUserPlus,
IconUsersGroup,
} from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { useModalAction } from "@homarr/modals";
import { AddBoardModal, AddGroupModal, ImportBoardModal, InviteCreateModal } from "@homarr/modals-collection";
import { useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { createGroup } from "../../lib/group";
import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction";
import { interaction } from "../../lib/interaction";
import type { SearchMode } from "../../lib/mode";
import { languageChildrenOptions } from "./children/language";
import { newIntegrationChildrenOptions } from "./children/new-integration";
// This has to be type so it can be interpreted as Record<string, unknown>.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type Command<TSearchInteraction extends SearchInteraction = SearchInteraction> = {
commandKey: string;
icon: TablerIcon;
name: string;
useInteraction: (
_c: Command<TSearchInteraction>,
query: string,
) => inferSearchInteractionDefinition<TSearchInteraction>;
};
import { contextSpecificActionsSearchGroups } from "./context-specific-group";
import { globalCommandGroup } from "./global-group";
export const commandMode = {
modeKey: "command",
character: ">",
groups: [
createGroup<Command>({
keyPath: "commandKey",
title: "Global commands",
useInteraction: (option, query) => option.useInteraction(option, query),
Component: ({ icon: Icon, name }) => (
<Group px="md" py="sm">
<Icon stroke={1.5} />
<Text>{name}</Text>
</Group>
),
filter(query, option) {
return option.name.toLowerCase().includes(query.toLowerCase());
},
useOptions() {
const tOption = useScopedI18n("search.mode.command.group.globalCommand.option");
const { colorScheme } = useMantineColorScheme();
const { data: session } = useSession();
const commands: (Command & { hidden?: boolean })[] = [
{
commandKey: "colorScheme",
icon: colorScheme === "dark" ? IconSun : IconMoon,
name: tOption(`colorScheme.${colorScheme === "dark" ? "light" : "dark"}`),
useInteraction: () => {
const { toggleColorScheme } = useMantineColorScheme();
return {
type: "javaScript",
onSelect: toggleColorScheme,
};
},
},
{
commandKey: "language",
icon: IconLanguage,
name: tOption("language.label"),
useInteraction: interaction.children(languageChildrenOptions),
},
{
commandKey: "newBoard",
icon: IconCategoryPlus,
name: tOption("newBoard.label"),
useInteraction() {
const { openModal } = useModalAction(AddBoardModal);
return {
type: "javaScript",
onSelect() {
openModal(undefined);
},
};
},
hidden: !session?.user.permissions.includes("board-create"),
},
{
commandKey: "importBoard",
icon: IconFileImport,
name: tOption("importBoard.label"),
useInteraction() {
const { openModal } = useModalAction(ImportBoardModal);
return {
type: "javaScript",
onSelect() {
openModal(undefined);
},
};
},
hidden: !session?.user.permissions.includes("board-create"),
},
{
commandKey: "newApp",
icon: IconBox,
name: tOption("newApp.label"),
useInteraction: interaction.link(() => ({ href: "/manage/apps/new" })),
hidden: !session?.user.permissions.includes("app-create"),
},
{
commandKey: "newIntegration",
icon: IconPlug,
name: tOption("newIntegration.label"),
useInteraction: interaction.children(newIntegrationChildrenOptions),
hidden: !session?.user.permissions.includes("integration-create"),
},
{
commandKey: "newUser",
icon: IconUserPlus,
name: tOption("newUser.label"),
useInteraction: interaction.link(() => ({ href: "/manage/users/new" })),
hidden: !session?.user.permissions.includes("admin"),
},
{
commandKey: "newInvite",
icon: IconMailForward,
name: tOption("newInvite.label"),
useInteraction() {
const { openModal } = useModalAction(InviteCreateModal);
return {
type: "javaScript",
onSelect() {
openModal(undefined);
},
};
},
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);
},
};
},
hidden: !session?.user.permissions.includes("admin"),
},
];
return commands.filter((command) => !command.hidden);
},
}),
],
groups: [contextSpecificActionsSearchGroups, globalCommandGroup],
} satisfies SearchMode;

View File

@@ -0,0 +1,34 @@
import { Group, Text } from "@mantine/core";
import { createGroup } from "../../lib/group";
import type { ContextSpecificItem } from "./context";
import { useSpotlightContextResults } from "./context";
export const contextSpecificSearchGroups = createGroup<ContextSpecificItem>({
title: (t) => t("search.mode.home.group.local.title"),
keyPath: "id",
Component(option) {
const icon =
typeof option.icon !== "string" ? (
<option.icon size={24} />
) : (
<img width={24} height={24} src={option.icon} alt={option.name} />
);
return (
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
{icon}
<Text>{option.name}</Text>
</Group>
);
},
useInteraction(option) {
return option.interaction();
},
filter(query, option) {
return option.name.toLowerCase().includes(query.toLowerCase());
},
useOptions() {
return useSpotlightContextResults();
},
});

View File

@@ -0,0 +1,122 @@
import type { DependencyList, PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import type { TablerIcon } from "@homarr/ui";
import type { inferSearchInteractionDefinition, SearchInteraction } from "../../lib/interaction";
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export type ContextSpecificItem = {
id: string;
name: string;
icon: TablerIcon | string;
interaction: () => inferSearchInteractionDefinition<SearchInteraction>;
disabled?: boolean;
};
interface SpotlightContextProps {
items: ContextSpecificItem[];
registerItems: (key: string, results: ContextSpecificItem[]) => void;
unregisterItems: (key: string) => void;
}
const createSpotlightContext = (displayName: string) => {
const SpotlightContext = createContext<SpotlightContextProps | null>(null);
SpotlightContext.displayName = displayName;
const Provider = ({ children }: PropsWithChildren) => {
const [itemsMap, setItemsMap] = useState<Map<string, { items: ContextSpecificItem[]; count: number }>>(new Map());
const registerItems = useCallback((key: string, newItems: ContextSpecificItem[]) => {
setItemsMap((prevItems) => {
const newItemsMap = new Map(prevItems);
newItemsMap.set(key, { items: newItems, count: (newItemsMap.get(key)?.count ?? 0) + 1 });
return newItemsMap;
});
}, []);
const unregisterItems = useCallback((key: string) => {
setItemsMap((prevItems) => {
const registrationCount = prevItems.get(key)?.count ?? 0;
if (registrationCount <= 1) {
const newItemsMap = new Map(prevItems);
newItemsMap.delete(key);
return newItemsMap;
}
const newItemsMap = new Map(prevItems);
newItemsMap.set(key, { items: newItemsMap.get(key)?.items ?? [], count: registrationCount - 1 });
return prevItems;
});
}, []);
const items = useMemo(() => Array.from(itemsMap.values()).flatMap(({ items }) => items), [itemsMap]);
return (
<SpotlightContext.Provider value={{ items, registerItems, unregisterItems }}>
{children}
</SpotlightContext.Provider>
);
};
const useSpotlightContextItems = () => {
const context = useContext(SpotlightContext);
if (!context) {
throw new Error(`useSpotlightContextItems must be used within SpotlightContext[displayName=${displayName}]`);
}
return context.items;
};
const useRegisterSpotlightContextItems = (
key: string,
items: ContextSpecificItem[],
dependencyArray: DependencyList,
) => {
const context = useContext(SpotlightContext);
if (!context) {
throw new Error(
`useRegisterSpotlightContextItems must be used within SpotlightContext[displayName=${displayName}]`,
);
}
useEffect(() => {
context.registerItems(
key,
items.filter((item) => !item.disabled),
);
return () => {
context.unregisterItems(key);
};
// We ignore the results
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...dependencyArray, key]);
};
return [SpotlightContext, Provider, useSpotlightContextItems, useRegisterSpotlightContextItems] as const;
};
const [_ResultContext, ResultProvider, useSpotlightContextResults, useRegisterSpotlightContextResults] =
createSpotlightContext("SpotlightContextSpecificResults");
const [_ActionContext, ActionProvider, useSpotlightContextActions, useRegisterSpotlightContextActions] =
createSpotlightContext("SpotlightContextSpecificActions");
export {
useRegisterSpotlightContextActions,
useRegisterSpotlightContextResults,
useSpotlightContextActions,
useSpotlightContextResults,
};
export const SpotlightProvider = ({ children }: PropsWithChildren) => {
return (
<ResultProvider>
<ActionProvider>{children}</ActionProvider>
</ResultProvider>
);
};

View File

@@ -0,0 +1,8 @@
import type { SearchMode } from "../../lib/mode";
import { contextSpecificSearchGroups } from "./context-specific-group";
export const homeMode = {
character: undefined,
modeKey: "home",
groups: [contextSpecificSearchGroups],
} satisfies SearchMode;

View File

@@ -11,10 +11,11 @@ import type { SearchMode } from "../lib/mode";
import { appIntegrationBoardMode } from "./app-integration-board";
import { commandMode } from "./command";
import { externalMode } from "./external";
import { homeMode } from "./home";
import { pageMode } from "./page";
import { userGroupMode } from "./user-group";
const searchModesWithoutHelp = [userGroupMode, appIntegrationBoardMode, externalMode, commandMode, pageMode] as const;
const searchModesForHelp = [userGroupMode, appIntegrationBoardMode, externalMode, commandMode, pageMode] as const;
const helpMode = {
modeKey: "help",
@@ -82,4 +83,4 @@ const helpMode = {
},
} satisfies SearchMode;
export const searchModes = [...searchModesWithoutHelp, helpMode] as const;
export const searchModes = [...searchModesForHelp, helpMode, homeMode] as const;