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,17 @@
import type { inferSearchInteractionOptions } from "../../lib/interaction";
import { ChildrenActionItem } from "./items/children-action-item";
interface SpotlightChildrenActionsProps {
childrenOptions: inferSearchInteractionOptions<"children">;
query: string;
}
export const SpotlightChildrenActions = ({ childrenOptions, query }: SpotlightChildrenActionsProps) => {
const actions = childrenOptions.useActions(childrenOptions.option, query);
return actions
.filter((action) => (typeof action.hide === "function" ? !action.hide(childrenOptions.option) : !action.hide))
.map((action) => (
<ChildrenActionItem key={action.key} childrenOptions={childrenOptions} query={query} action={action} />
));
};

View File

@@ -0,0 +1,87 @@
import { Center, Loader } from "@mantine/core";
import type { TranslationObject } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { SearchGroup } from "../../lib/group";
import type { inferSearchInteractionOptions } from "../../lib/interaction";
import { SpotlightNoResults } from "../no-results";
import { SpotlightGroupActionItem } from "./items/group-action-item";
interface GroupActionsProps<TOption extends Record<string, unknown>> {
group: SearchGroup<TOption>;
query: string;
setMode: (mode: keyof TranslationObject["search"]["mode"]) => void;
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
}
export const SpotlightGroupActions = <TOption extends Record<string, unknown>>({
group,
query,
setMode,
setChildrenOptions,
}: GroupActionsProps<TOption>) => {
// This does work as the same amount of hooks is called on every render
const useOptions =
"options" in group ? () => group.options : "useOptions" in group ? group.useOptions : group.useQueryOptions;
const options = useOptions(query);
const t = useI18n();
if (Array.isArray(options)) {
const filteredOptions = options
.filter((option) => ("filter" in group ? group.filter(query, option) : false))
.sort((optionA, optionB) => {
if ("sort" in group) {
return group.sort?.(query, [optionA, optionB]) ?? 0;
}
return 0;
});
if (filteredOptions.length === 0) {
return <SpotlightNoResults />;
}
return filteredOptions.map((option) => (
<SpotlightGroupActionItem
key={option[group.keyPath] as never}
option={option}
group={group}
query={query}
setMode={setMode}
setChildrenOptions={setChildrenOptions}
/>
));
}
if (options.isLoading) {
return (
<Center w="100%" py="sm">
<Loader size="sm" />
</Center>
);
}
if (options.isError) {
return <Center py="sm">{t("search.error.fetch")}</Center>;
}
if (!options.data) {
return null;
}
if (options.data.length === 0) {
return <SpotlightNoResults />;
}
return options.data.map((option) => (
<SpotlightGroupActionItem
key={option[group.keyPath] as never}
option={option}
group={group}
query={query}
setMode={setMode}
setChildrenOptions={setChildrenOptions}
/>
));
};

View File

@@ -0,0 +1,32 @@
import { Spotlight } from "@mantine/spotlight";
import type { TranslationObject } from "@homarr/translation";
import { translateIfNecessary } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { SearchGroup } from "../../../lib/group";
import type { inferSearchInteractionOptions } from "../../../lib/interaction";
import { SpotlightGroupActions } from "../group-actions";
interface SpotlightActionGroupsProps {
groups: SearchGroup[];
query: string;
setMode: (mode: keyof TranslationObject["search"]["mode"]) => void;
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
}
export const SpotlightActionGroups = ({ groups, query, setMode, setChildrenOptions }: SpotlightActionGroupsProps) => {
const t = useI18n();
return groups.map((group) => (
<Spotlight.ActionsGroup key={translateIfNecessary(t, group.title)} label={translateIfNecessary(t, group.title)}>
{/*eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<SpotlightGroupActions<any>
group={group}
query={query}
setMode={setMode}
setChildrenOptions={setChildrenOptions}
/>
</Spotlight.ActionsGroup>
));
};

View File

@@ -0,0 +1,7 @@
.spotlightAction:hover {
background-color: alpha(var(--mantine-primary-color-filled), 0.1);
}
.spotlightAction[data-selected="true"] {
background-color: alpha(var(--mantine-primary-color-filled), 0.3);
}

View File

@@ -0,0 +1,30 @@
import Link from "next/link";
import { Spotlight } from "@mantine/spotlight";
import type { inferSearchInteractionOptions } from "../../../lib/interaction";
import classes from "./action-item.module.css";
interface ChildrenActionItemProps {
childrenOptions: inferSearchInteractionOptions<"children">;
query: string;
action: ReturnType<inferSearchInteractionOptions<"children">["useActions"]>[number];
}
export const ChildrenActionItem = ({ childrenOptions, action, query }: ChildrenActionItemProps) => {
const interaction = action.useInteraction(childrenOptions.option, query);
const renderRoot =
interaction.type === "link"
? (props: Record<string, unknown>) => {
return <Link href={interaction.href} target={interaction.newTab ? "_blank" : undefined} {...props} />;
}
: undefined;
const onClick = interaction.type === "javaScript" ? interaction.onSelect : undefined;
return (
<Spotlight.Action renderRoot={renderRoot} onClick={onClick} className={classes.spotlightAction}>
<action.component {...childrenOptions.option} />
</Spotlight.Action>
);
};

View File

@@ -0,0 +1,54 @@
import Link from "next/link";
import { Spotlight } from "@mantine/spotlight";
import type { TranslationObject } from "@homarr/translation";
import type { SearchGroup } from "../../../lib/group";
import type { inferSearchInteractionOptions } from "../../../lib/interaction";
import classes from "./action-item.module.css";
interface SpotlightGroupActionItemProps<TOption extends Record<string, unknown>> {
option: TOption;
query: string;
setMode: (mode: keyof TranslationObject["search"]["mode"]) => void;
setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void;
group: SearchGroup<TOption>;
}
export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>>({
group,
query,
setMode,
setChildrenOptions,
option,
}: SpotlightGroupActionItemProps<TOption>) => {
const interaction = group.useInteraction(option, query);
const renderRoot =
interaction.type === "link"
? (props: Record<string, unknown>) => {
return <Link href={interaction.href} target={interaction.newTab ? "_blank" : undefined} {...props} />;
}
: undefined;
const handleClickAsync = async () => {
if (interaction.type === "javaScript") {
await interaction.onSelect();
} else if (interaction.type === "mode") {
setMode(interaction.mode);
} else if (interaction.type === "children") {
setChildrenOptions(interaction);
}
};
return (
<Spotlight.Action
renderRoot={renderRoot}
onClick={handleClickAsync}
closeSpotlightOnTrigger={interaction.type !== "mode" && interaction.type !== "children"}
className={classes.spotlightAction}
>
<group.component {...option} />
</Spotlight.Action>
);
};

View File

@@ -0,0 +1,9 @@
import { Spotlight } from "@mantine/spotlight";
import { useI18n } from "@homarr/translation/client";
export const SpotlightNoResults = () => {
const t = useI18n();
return <Spotlight.Empty>{t("search.nothingFound")}</Spotlight.Empty>;
};

View File

@@ -0,0 +1,118 @@
"use client";
import { useMemo, useRef, useState } from "react";
import { ActionIcon, Center, Group, Kbd } from "@mantine/core";
import { Spotlight as MantineSpotlight } from "@mantine/spotlight";
import { IconSearch, IconX } from "@tabler/icons-react";
import type { TranslationObject } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { inferSearchInteractionOptions } from "../lib/interaction";
import { searchModes } from "../modes";
import { selectAction, spotlightStore } from "../spotlight-store";
import { SpotlightChildrenActions } from "./actions/children-actions";
import { SpotlightActionGroups } from "./actions/groups/action-group";
export const Spotlight = () => {
const [query, setQuery] = useState("");
const [mode, setMode] = useState<keyof TranslationObject["search"]["mode"]>("help");
const [childrenOptions, setChildrenOptions] = useState<inferSearchInteractionOptions<"children"> | null>(null);
const t = useI18n();
const inputRef = useRef<HTMLInputElement>(null);
const activeMode = useMemo(() => searchModes.find((searchMode) => searchMode.modeKey === mode), [mode]);
if (!activeMode) {
return null;
}
return (
<MantineSpotlight.Root
onSpotlightClose={() => {
setMode("help");
setChildrenOptions(null);
}}
query={query}
onQueryChange={(query) => {
if (mode !== "help" || query.length !== 1) {
setQuery(query);
}
const modeToActivate = searchModes.find((mode) => mode.character === query);
if (!modeToActivate) {
return;
}
setMode(modeToActivate.modeKey);
setQuery("");
setTimeout(() => selectAction(0, spotlightStore));
}}
store={spotlightStore}
>
<MantineSpotlight.Search
placeholder={t("common.rtl", {
value: t("search.placeholder"),
symbol: "...",
})}
ref={inputRef}
leftSectionWidth={activeMode.modeKey !== "help" ? 80 : 48}
leftSection={
<Group align="center" wrap="nowrap" gap="xs" w="100%" h="100%">
<Center w={48} h="100%">
<IconSearch stroke={1.5} />
</Center>
{activeMode.modeKey !== "help" ? <Kbd size="sm">{activeMode.character}</Kbd> : null}
</Group>
}
rightSection={
mode === "help" ? undefined : (
<ActionIcon
onClick={() => {
setMode("help");
setChildrenOptions(null);
inputRef.current?.focus();
}}
variant="subtle"
>
<IconX stroke={1.5} />
</ActionIcon>
)
}
value={query}
onKeyDown={(event) => {
if (query.length === 0 && mode !== "help" && event.key === "Backspace") {
setMode("help");
setChildrenOptions(null);
}
}}
/>
{childrenOptions ? (
<Group>
<childrenOptions.detailComponent options={childrenOptions.option as never} />
</Group>
) : null}
<MantineSpotlight.ActionsList>
{childrenOptions ? (
<SpotlightChildrenActions childrenOptions={childrenOptions} query={query} />
) : (
<SpotlightActionGroups
setMode={(mode) => {
setMode(mode);
setChildrenOptions(null);
setTimeout(() => selectAction(0, spotlightStore));
}}
setChildrenOptions={(options) => {
setChildrenOptions(options);
setQuery("");
setTimeout(() => selectAction(0, spotlightStore));
}}
query={query}
groups={activeMode.groups}
/>
)}
</MantineSpotlight.ActionsList>
</MantineSpotlight.Root>
);
};