feat: add context specific search and actions (#1570)
This commit is contained in:
@@ -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();
|
||||
},
|
||||
});
|
||||
166
packages/spotlight/src/modes/command/global-group.tsx
Normal file
166
packages/spotlight/src/modes/command/global-group.tsx
Normal 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);
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
34
packages/spotlight/src/modes/home/context-specific-group.tsx
Normal file
34
packages/spotlight/src/modes/home/context-specific-group.tsx
Normal 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();
|
||||
},
|
||||
});
|
||||
122
packages/spotlight/src/modes/home/context.tsx
Normal file
122
packages/spotlight/src/modes/home/context.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
8
packages/spotlight/src/modes/home/index.tsx
Normal file
8
packages/spotlight/src/modes/home/index.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user