Skip to content

Commit

Permalink
feat: add improved search (#1051)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Meierschlumpf authored Sep 20, 2024
1 parent 0c44af2 commit ce1ef3c
Show file tree
Hide file tree
Showing 64 changed files with 1,985 additions and 628 deletions.
Original file line number Diff line number Diff line change
@@ -1,39 +1,21 @@
"use client";

import { useCallback } from "react";
import { Affix, Button, Group, Menu } from "@mantine/core";
import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react";

import { revalidatePathActionAsync } from "@homarr/common/client";
import { useModalAction } from "@homarr/modals";
import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { BetaBadge } from "@homarr/ui";

interface CreateBoardButtonProps {
boardNames: string[];
}

export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
export const CreateBoardButton = () => {
const t = useI18n();
const { openModal: openAddModal } = useModalAction(AddBoardModal);
const { openModal: openImportModal } = useModalAction(ImportBoardModal);

const onCreateClick = useCallback(() => {
openAddModal({
onSettled: async () => {
await revalidatePathActionAsync("/manage/boards");
},
});
}, [openAddModal]);

const onImportClick = useCallback(() => {
openImportModal({ boardNames });
}, [openImportModal, boardNames]);

const buttonGroupContent = (
<>
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={onCreateClick}>
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openAddModal}>
{t("management.page.board.action.new.label")}
</Button>
<Menu position="bottom-end">
Expand All @@ -43,7 +25,7 @@ export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={onImportClick} leftSection={<IconFileImport size="1rem" />}>
<Menu.Item onClick={openImportModal} leftSection={<IconFileImport size="1rem" />}>
<Group>
{t("board.action.oldImport.label")}
<BetaBadge size="xs" />
Expand Down
2 changes: 1 addition & 1 deletion apps/nextjs/src/app/[locale]/manage/boards/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export default async function ManageBoardsPage() {
<Stack>
<Group justify="space-between">
<Title mb="md">{t("title")}</Title>
<CreateBoardButton boardNames={boards.map((board) => board.name)} />
<CreateBoardButton />
</Group>

<Grid mb={{ base: "xl", md: 0 }}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import { Container, Fieldset, Group, Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { getIntegrationName } from "@homarr/definitions";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { IntegrationAvatar } from "@homarr/ui";

import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { IntegrationAccessSettings } from "../../_components/integration-access-settings";
import { IntegrationAvatar } from "../../_integration-avatar";
import { EditIntegrationForm } from "./_integration-edit-form";

interface EditIntegrationPageProps {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ import { IconSearch } from "@tabler/icons-react";

import { getIntegrationName, integrationKinds } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";

import { IntegrationAvatar } from "../_integration-avatar";
import { IntegrationAvatar } from "@homarr/ui";

export const IntegrationCreateDropdownContent = () => {
const t = useI18n();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import { Container, Group, Stack, Title } from "@mantine/core";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { IntegrationAvatar } from "@homarr/ui";
import type { validation } from "@homarr/validation";
import { z } from "@homarr/validation";

import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { IntegrationAvatar } from "../_integration-avatar";
import { NewIntegrationForm } from "./_integration-new-form";

interface NewIntegrationPageProps {
Expand Down
3 changes: 1 addition & 2 deletions apps/nextjs/src/app/[locale]/manage/integrations/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,11 @@ import { objectEntries } from "@homarr/common";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { CountBadge } from "@homarr/ui";
import { CountBadge, IntegrationAvatar } from "@homarr/ui";

import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
import { IntegrationAvatar } from "./_integration-avatar";
import { DeleteIntegrationActionButton } from "./_integration-buttons";
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";

Expand Down
4 changes: 2 additions & 2 deletions apps/nextjs/src/app/[locale]/manage/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
{
label: t("items.help.items.documentation"),
icon: IconBook2,
href: "https://homarr.dev/docs/getting-started/prerequisites",
href: "https://homarr.dev/docs/getting-started/",
external: true,
},
{
Expand All @@ -123,7 +123,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
external: true,
},
{
label: t("items.tools.items.docker"),
label: t("items.help.items.discord"),
icon: IconBrandDiscord,
href: "https://discord.com/invite/aCsmEV5RgA",
external: true,
Expand Down
56 changes: 2 additions & 54 deletions apps/nextjs/src/app/[locale]/manage/users/groups/_add-group.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
"use client";

import { useCallback } from "react";
import { Button, Group, Stack, TextInput } from "@mantine/core";

import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { createModal, useModalAction } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useModalAction } from "@homarr/modals";
import { AddGroupModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";

import { MobileAffixButton } from "~/components/manage/mobile-affix-button";

Expand All @@ -27,50 +22,3 @@ export const AddGroup = () => {
</MobileAffixButton>
);
};

const AddGroupModal = createModal<void>(({ actions }) => {
const t = useI18n();
const { mutate, isPending } = clientApi.group.createGroup.useMutation();
const form = useZodForm(validation.group.create, {
initialValues: {
name: "",
},
});

return (
<form
onSubmit={form.onSubmit((values) => {
mutate(values, {
onSuccess() {
actions.closeModal();
void revalidatePathActionAsync("/manage/users/groups");
showSuccessNotification({
title: t("common.notification.create.success"),
message: t("group.action.create.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("common.notification.create.error"),
message: t("group.action.create.notification.error.message"),
});
},
});
})}
>
<Stack>
<TextInput label={t("group.field.name")} data-autofocus {...form.getInputProps("name")} />
<Group justify="right">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button loading={isPending} type="submit" color="teal">
{t("common.action.create")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("group.action.create.label"),
});
2 changes: 1 addition & 1 deletion apps/nextjs/src/components/board/items/item-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export const BoardItemMenu = ({
}}
>
{tItem("action.moveResize")}
</Menu.Item>{" "}
</Menu.Item>
<Menu.Item leftSection={<IconCopy size={16} />} onClick={() => duplicateItem({ itemId: item.id })}>
{tItem("action.duplicate")}
</Menu.Item>
Expand Down
9 changes: 5 additions & 4 deletions apps/nextjs/src/components/icons/picker/icon-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useState } from "react";
import { Combobox, Group, Image, InputBase, Skeleton, Text, useCombobox } from "@mantine/core";

import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n, useScopedI18n } from "@homarr/translation/client";

interface IconPickerProps {
initialValue?: string;
Expand All @@ -18,7 +18,8 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
const [search, setSearch] = useState(initialValue ?? "");
const [previewUrl, setPreviewUrl] = useState<string | null>(initialValue ?? null);

const t = useScopedI18n("common");
const t = useI18n();
const tCommon = useScopedI18n("common");

const { data, isFetching } = clientApi.icon.findIcons.useQuery({
searchText: search,
Expand Down Expand Up @@ -89,13 +90,13 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
rightSectionPointerEvents="none"
withAsterisk
error={error}
label={t("iconPicker.label")}
label={tCommon("iconPicker.label")}
/>
</Combobox.Target>

<Combobox.Dropdown>
<Combobox.Header>
<Text c="dimmed">{t("iconPicker.header", { countIcons: data?.countIcons })}</Text>
<Text c="dimmed">{tCommon("iconPicker.header", { countIcons: data?.countIcons })}</Text>
</Combobox.Header>
<Combobox.Options mah={350} style={{ overflowY: "auto" }}>
{totalOptions > 0 ? (
Expand Down
9 changes: 6 additions & 3 deletions apps/nextjs/src/components/layout/header/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import { TextInput, UnstyledButton } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";

import { openSpotlight } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";

import { HeaderButton } from "./button";
import classes from "./search.module.css";

export const DesktopSearchInput = () => {
const t = useScopedI18n("common.search");
const t = useI18n();

return (
<TextInput
Expand All @@ -21,7 +21,10 @@ export const DesktopSearchInput = () => {
leftSection={<IconSearch size={20} stroke={1.5} />}
onClick={openSpotlight}
>
{t("placeholder")}
{t("common.rtl", {
value: t("search.placeholder"),
symbol: "...",
})}
</TextInput>
);
};
Expand Down
13 changes: 11 additions & 2 deletions packages/api/src/router/app.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { TRPCError } from "@trpc/server";

import { asc, createId, eq } from "@homarr/db";
import { asc, createId, eq, like } from "@homarr/db";
import { apps } from "@homarr/db/schema/sqlite";
import { validation } from "@homarr/validation";
import { validation, z } from "@homarr/validation";

import { createTRPCRouter, publicProcedure } from "../trpc";

Expand All @@ -22,6 +22,15 @@ export const appRouter = createTRPCRouter({
orderBy: asc(apps.name),
});
}),
search: publicProcedure
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
.query(async ({ ctx, input }) => {
return await ctx.db.query.apps.findMany({
where: like(apps.name, `%${input.query}%`),
orderBy: asc(apps.name),
limit: input.limit,
});
}),
byId: publicProcedure.input(validation.app.byId).query(async ({ ctx, input }) => {
const app = await ctx.db.query.apps.findFirst({
where: eq(apps.id, input.id),
Expand Down
76 changes: 75 additions & 1 deletion packages/api/src/router/board.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { TRPCError } from "@trpc/server";
import superjson from "superjson";

import { constructBoardPermissions } from "@homarr/auth/shared";
import type { Database, SQL } from "@homarr/db";
import { and, createId, eq, inArray, or } from "@homarr/db";
import { and, createId, eq, inArray, like, or } from "@homarr/db";
import {
boardGroupPermissions,
boards,
Expand Down Expand Up @@ -109,6 +110,79 @@ export const boardRouter = createTRPCRouter({
isHome: currentUserWhenPresent?.homeBoardId === board.id,
}));
}),
search: publicProcedure
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
.query(async ({ ctx, input }) => {
const userId = ctx.session?.user.id;
const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
where: eq(boardUserPermissions.userId, userId ?? ""),
});

const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, userId ?? ""),
with: {
group: {
with: {
boardPermissions: {},
},
},
},
});
const boardIds = permissionsOfCurrentUserWhenPresent
.map((permission) => permission.boardId)
.concat(
permissionsOfCurrentUserGroupsWhenPresent
.map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId))
.flat(),
);

const currentUserWhenPresent = await ctx.db.query.users.findFirst({
where: eq(users.id, userId ?? ""),
});

const foundBoards = await ctx.db.query.boards.findMany({
where: and(
like(boards.name, `%${input.query}%`),
ctx.session?.user.permissions.includes("board-view-all")
? undefined
: or(
eq(boards.isPublic, true),
eq(boards.creatorId, ctx.session?.user.id ?? ""),
inArray(boards.id, boardIds),
),
),
limit: input.limit,
columns: {
id: true,
name: true,
creatorId: true,
isPublic: true,
logoImageUrl: true,
},
with: {
userPermissions: {
where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
},
groupPermissions: {
where:
permissionsOfCurrentUserGroupsWhenPresent.length >= 1
? inArray(
boardGroupPermissions.groupId,
permissionsOfCurrentUserGroupsWhenPresent.map((groupMember) => groupMember.groupId),
)
: undefined,
},
},
});

return foundBoards.map((board) => ({
id: board.id,
name: board.name,
logoImageUrl: board.logoImageUrl,
permissions: constructBoardPermissions(board, ctx.session),
isHome: currentUserWhenPresent?.homeBoardId === board.id,
}));
}),
createBoard: permissionRequiredProcedure
.requiresPermission("board-create")
.input(validation.board.create)
Expand Down
Loading

0 comments on commit ce1ef3c

Please sign in to comment.