feat: add improved search (#1051)

* feat: add improved search

* wip: add support for sorting, rename use-options to use-query-options, add use-options for local usage, add pages search group

* feat: add help links from manage layout to help search mode

* feat: add additional search engines

* feat: add group search details

* refactor: improve users search group type

* feat: add apps search group, add disabled search interaction

* feat: add integrations and boards for search

* wip: hook issue with react

* fix: hook issue regarding actions and interactions

* chore: address pull request feedback

* fix: format issues

* feat: add additional global actions to search

* chore: remove unused code

* fix: search engine short key

* fix: typecheck issues

* fix: deepsource issues

* fix: eslint issue

* fix: lint issues

* fix: unordered dependencies

* chore: address pull request feedback
This commit is contained in:
Meier Lukas
2024-09-20 16:51:42 +02:00
committed by GitHub
parent 0c44af2f67
commit ce1ef3cbe7
64 changed files with 1985 additions and 628 deletions

View File

@@ -0,0 +1,65 @@
import { Group, Stack, Text } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import { localeAttributes, supportedLanguages } from "@homarr/translation";
import { useChangeLocale, useCurrentLocale, useI18n } from "@homarr/translation/client";
import { createChildrenOptions } from "../../../lib/children";
export const languageChildrenOptions = createChildrenOptions<Record<string, unknown>>({
useActions: (_, query) => {
const normalizedQuery = query.trim().toLowerCase();
const currentLocale = useCurrentLocale();
return supportedLanguages
.map((localeKey) => ({ localeKey, attributes: localeAttributes[localeKey] }))
.filter(
({ attributes }) =>
attributes.name.toLowerCase().includes(normalizedQuery) ||
attributes.translatedName.toLowerCase().includes(normalizedQuery),
)
.sort(
(languageA, languageB) =>
Math.min(
languageA.attributes.name.toLowerCase().indexOf(normalizedQuery),
languageA.attributes.translatedName.toLowerCase().indexOf(normalizedQuery),
) -
Math.min(
languageB.attributes.name.toLowerCase().indexOf(normalizedQuery),
languageB.attributes.translatedName.toLowerCase().indexOf(normalizedQuery),
),
)
.map(({ localeKey, attributes }) => ({
key: localeKey,
component() {
return (
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%">
<Group wrap="nowrap">
<span className={`fi fi-${attributes.flagIcon}`} style={{ borderRadius: 4 }}></span>
<Group wrap="nowrap" gap="xs">
<Text>{attributes.name}</Text>
<Text size="xs" c="dimmed" inherit>
({attributes.translatedName})
</Text>
</Group>
</Group>
{localeKey === currentLocale && <IconCheck color="currentColor" size={24} />}
</Group>
);
},
useInteraction() {
const changeLocale = useChangeLocale();
return { type: "javaScript", onSelect: () => changeLocale(localeKey) };
},
}));
},
detailComponent: () => {
const t = useI18n();
return (
<Stack mx="md" my="sm">
<Text>{t("search.mode.command.group.globalCommand.option.language.children.detail.title")}</Text>
</Stack>
);
},
});

View File

@@ -0,0 +1,43 @@
import { Group, Stack, Text } from "@mantine/core";
import { objectEntries } from "@homarr/common";
import { integrationDefs } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "@homarr/ui";
import { createChildrenOptions } from "../../../lib/children";
import { interaction } from "../../../lib/interaction";
export const newIntegrationChildrenOptions = createChildrenOptions<Record<string, unknown>>({
useActions: (_, query) => {
const normalizedQuery = query.trim().toLowerCase();
return objectEntries(integrationDefs)
.filter(([, integrationDef]) => integrationDef.name.toLowerCase().includes(normalizedQuery))
.sort(
([, definitionA], [, definitionB]) =>
definitionA.name.toLowerCase().indexOf(normalizedQuery) -
definitionB.name.toLowerCase().indexOf(normalizedQuery),
)
.map(([kind, integrationDef]) => ({
key: kind,
component() {
return (
<Group mx="md" my="sm" wrap="nowrap" w="100%">
<IntegrationAvatar kind={kind} size="sm" />
<Text>{integrationDef.name}</Text>
</Group>
);
},
useInteraction: interaction.link(() => ({ href: `/manage/integrations/new?kind=${kind}` })),
}));
},
detailComponent() {
const t = useI18n();
return (
<Stack mx="md" my="sm">
<Text>{t("search.mode.command.group.globalCommand.option.newIntegration.children.detail.title")}</Text>
</Stack>
);
},
});

View File

@@ -0,0 +1,164 @@
import { Group, Text, useMantineColorScheme } from "@mantine/core";
import {
IconCategoryPlus,
IconFileImport,
IconLanguage,
IconMailForward,
IconMoon,
IconPackage,
IconPlug,
IconSun,
IconUserPlus,
IconUsersGroup,
} from "@tabler/icons-react";
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>;
};
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 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);
},
};
},
},
{
commandKey: "importBoard",
icon: IconFileImport,
name: tOption("importBoard.label"),
useInteraction() {
const { openModal } = useModalAction(ImportBoardModal);
return {
type: "javaScript",
onSelect() {
openModal(undefined);
},
};
},
},
{
commandKey: "newApp",
icon: IconPackage,
name: tOption("newApp.label"),
useInteraction: interaction.link(() => ({ href: "/manage/apps/new" })),
},
{
commandKey: "newIntegration",
icon: IconPlug,
name: tOption("newIntegration.label"),
useInteraction: interaction.children(newIntegrationChildrenOptions),
},
{
commandKey: "newUser",
icon: IconUserPlus,
name: tOption("newUser.label"),
useInteraction: interaction.link(() => ({ href: "/manage/users/new" })),
},
{
commandKey: "newInvite",
icon: IconMailForward,
name: tOption("newInvite.label"),
useInteraction() {
const { openModal } = useModalAction(InviteCreateModal);
return {
type: "javaScript",
onSelect() {
openModal(undefined);
},
};
},
},
{
commandKey: "newGroup",
icon: IconUsersGroup,
name: tOption("newGroup.label"),
useInteraction() {
const { openModal } = useModalAction(AddGroupModal);
return {
type: "javaScript",
onSelect() {
openModal(undefined);
},
};
},
},
];
return commands.filter((command) => !command.hidden);
},
}),
],
} satisfies SearchMode;