From fad4b080971e3bc7bd5236da71720f1f1888653d Mon Sep 17 00:00:00 2001 From: Salihu Date: Sat, 15 Aug 2026 22:48:34 +0100 Subject: [PATCH] advanced search filters --- .../src/components/ui/checkbox-menu-item.tsx | 12 ++ .../src/features/label/queries/label-query.ts | 1 + .../search/components/creator-filter-menu.tsx | 120 ++++++++++++++++ .../search/components/label-filter-menu.tsx | 120 ++++++++++++++++ .../components/search-spotlight-filters.tsx | 132 ++++++++++++------ .../search/components/search-spotlight.tsx | 16 ++- .../src/features/search/types/search.types.ts | 2 + apps/server/src/core/search/dto/search.dto.ts | 6 + apps/server/src/core/search/search.service.ts | 17 +++ 9 files changed, 380 insertions(+), 46 deletions(-) create mode 100644 apps/client/src/components/ui/checkbox-menu-item.tsx create mode 100644 apps/client/src/features/search/components/creator-filter-menu.tsx create mode 100644 apps/client/src/features/search/components/label-filter-menu.tsx diff --git a/apps/client/src/components/ui/checkbox-menu-item.tsx b/apps/client/src/components/ui/checkbox-menu-item.tsx new file mode 100644 index 000000000..12b1f990e --- /dev/null +++ b/apps/client/src/components/ui/checkbox-menu-item.tsx @@ -0,0 +1,12 @@ +import { UnstyledButton } from "@mantine/core"; +import { type ComponentPropsWithoutRef, forwardRef } from "react"; + +// Menu.Item hard-codes role="menuitem"; use as its `component` to restore role="menuitemcheckbox" so aria-checked works. +export const CheckboxMenuItem = forwardRef< + HTMLButtonElement, + ComponentPropsWithoutRef<"button"> +>((props, ref) => ( + +)); + +CheckboxMenuItem.displayName = "CheckboxMenuItem"; diff --git a/apps/client/src/features/label/queries/label-query.ts b/apps/client/src/features/label/queries/label-query.ts index 6b06c4e30..119618521 100644 --- a/apps/client/src/features/label/queries/label-query.ts +++ b/apps/client/src/features/label/queries/label-query.ts @@ -39,6 +39,7 @@ export function useWorkspaceLabelsQuery(query: string, enabled: boolean) { queryFn: () => getWorkspaceLabels({ type: "page", query, limit: 50 }), enabled, staleTime: 30 * 1000, + placeholderData: keepPreviousData }); } diff --git a/apps/client/src/features/search/components/creator-filter-menu.tsx b/apps/client/src/features/search/components/creator-filter-menu.tsx new file mode 100644 index 000000000..059422a91 --- /dev/null +++ b/apps/client/src/features/search/components/creator-filter-menu.tsx @@ -0,0 +1,120 @@ +import { ReactNode, useState } from "react"; +import { Divider, Group, Menu, ScrollArea, Text, TextInput } from "@mantine/core"; +import { useDebouncedValue } from "@mantine/hooks"; +import { IconCheck, IconSearch } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query"; +import { RadioMenuItem } from "@/components/ui/radio-menu-item"; +import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; +import { IUser } from "@/features/user/types/user.types.ts"; + +type CreatorFilterMenuProps = { + value: string | null; + onChange: (user: IUser | null) => void; + children: ReactNode; + width?: number; + position?: + | "bottom-start" + | "bottom-end" + | "bottom" + | "top-start" + | "top-end" + | "top"; + zIndex?: number; +}; + +export function CreatorFilterMenu({ + value, + onChange, + children, + width = 280, + position = "bottom-end", + zIndex, +}: CreatorFilterMenuProps) { + const { t } = useTranslation(); + const [searchQuery, setSearchQuery] = useState(""); + const [debouncedQuery] = useDebouncedValue(searchQuery, 300); + + const { data: suggestion, isLoading } = useSearchSuggestionsQuery({ + query: debouncedQuery, + includeUsers: true, + includeGroups: false, + includePages: false, + preload: true, + }); + + const users: IUser[] = (suggestion?.users as IUser[]) ?? []; + + return ( + + {children} + + } + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + size="sm" + variant="filled" + radius="sm" + styles={{ input: { marginBottom: 8 } }} + /> + + + onChange(null)} + > + +
+ + {t("Anyone")} + +
+ {!value && } +
+
+ + + + {users.length === 0 && ( + + {isLoading ? t("Loading...") : t("No users found")} + + )} + + {users.map((user) => ( + onChange(user)} + > + + +
+ + {user.name} + + {user.email && ( + + {user.email} + + )} +
+ {value === user.id && } +
+
+ ))} +
+
+
+ ); +} diff --git a/apps/client/src/features/search/components/label-filter-menu.tsx b/apps/client/src/features/search/components/label-filter-menu.tsx new file mode 100644 index 000000000..c23924b0e --- /dev/null +++ b/apps/client/src/features/search/components/label-filter-menu.tsx @@ -0,0 +1,120 @@ +import { ReactNode, useMemo, useState } from "react"; +import { + Group, + Menu, + ScrollArea, + Text, + TextInput, + useComputedColorScheme, +} from "@mantine/core"; +import { useDebouncedValue } from "@mantine/hooks"; +import { IconCheck, IconSearch } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { useWorkspaceLabelsQuery } from "@/features/label/queries/label-query.ts"; +import { getLabelColor } from "@/features/label/utils/label-colors.ts"; +import { CheckboxMenuItem } from "@/components/ui/checkbox-menu-item"; + +type LabelFilterMenuProps = { + value: string[]; + onChange: (labelIds: string[]) => void; + children: ReactNode; + width?: number; + position?: + | "bottom-start" + | "bottom-end" + | "bottom" + | "top-start" + | "top-end" + | "top"; + zIndex?: number; +}; + +export function LabelFilterMenu({ + value, + onChange, + children, + width = 280, + position = "bottom-end", + zIndex, +}: LabelFilterMenuProps) { + const { t } = useTranslation(); + const scheme = useComputedColorScheme("light"); + const [searchQuery, setSearchQuery] = useState(""); + const [debouncedQuery] = useDebouncedValue(searchQuery, 300); + + const { data, isLoading } = useWorkspaceLabelsQuery(debouncedQuery, true); + const labels = data?.items ?? []; + + const selectedSet = useMemo(() => new Set(value), [value]); + + const toggleLabel = (labelId: string) => { + if (selectedSet.has(labelId)) { + onChange(value.filter((id) => id !== labelId)); + } else { + onChange([...value, labelId]); + } + }; + + return ( + + {children} + + } + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + size="sm" + variant="filled" + radius="sm" + styles={{ input: { marginBottom: 8 } }} + /> + + + {labels.length === 0 && ( + + {isLoading ? t("Loading...") : t("No labels found")} + + )} + + {labels.map((label) => { + const isChecked = selectedSet.has(label.id); + const color = getLabelColor(label.name, scheme); + return ( + toggleLabel(label.id)} + > + + + + {label.name} + + {isChecked && } + + + ); + })} + + + + ); +} diff --git a/apps/client/src/features/search/components/search-spotlight-filters.tsx b/apps/client/src/features/search/components/search-spotlight-filters.tsx index 0b2bcc48c..9899151e9 100644 --- a/apps/client/src/features/search/components/search-spotlight-filters.tsx +++ b/apps/client/src/features/search/components/search-spotlight-filters.tsx @@ -13,16 +13,20 @@ import { IconBuilding, IconFileDescription, IconCheck, + IconUser, + IconTag, } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { useGetSpacesQuery } from "@/features/space/queries/space-query"; import { SpaceFilterMenu } from "@/features/space/components/space-filter-menu"; +import { CreatorFilterMenu } from "@/features/search/components/creator-filter-menu"; import { RadioMenuItem } from "@/components/ui/radio-menu-item"; import { useHasFeature } from "@/ee/hooks/use-feature"; import { Feature } from "@/ee/features"; import classes from "./search-spotlight-filters.module.css"; import { useAtom } from "jotai"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { LabelFilterMenu } from "./label-filter-menu"; interface SearchSpotlightFiltersProps { onFiltersChange?: (filters: any) => void; @@ -40,9 +44,14 @@ export function SearchSpotlightFilters({ const { t } = useTranslation(); const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING); const [selectedSpaceId, setSelectedSpaceId] = useState( - spaceId || null, + spaceId || null ); const [contentType, setContentType] = useState("page"); + const [selectedCreatorId, setSelectedCreatorId] = useState(null); + const [selectedCreatorName, setSelectedCreatorName] = useState( + null + ); + const [selectedLabelIds, setSelectedLabelIds] = useState([]); const [workspace] = useAtom(workspaceAtom); const { data: spacesData } = useGetSpacesQuery({ limit: 100 }); @@ -50,15 +59,6 @@ export function SearchSpotlightFilters({ ? spacesData?.items.find((space) => space.id === selectedSpaceId) : null; - useEffect(() => { - if (onFiltersChange) { - onFiltersChange({ - spaceId: selectedSpaceId, - contentType, - }); - } - }, []); - const contentTypeOptions = [ { value: "page", label: t("Pages") }, { @@ -68,37 +68,39 @@ export function SearchSpotlightFilters({ }, ]; + useEffect(() => { + onFiltersChange?.({ + spaceId: selectedSpaceId, + contentType, + creatorId: selectedCreatorId, + labelIds: selectedLabelIds, + }); + }, [ + selectedSpaceId, + contentType, + selectedCreatorId, + selectedLabelIds, + onFiltersChange, + ]); + const handleSpaceSelect = (spaceId: string | null) => { setSelectedSpaceId(spaceId); - - if (onFiltersChange) { - onFiltersChange({ - spaceId: spaceId, - contentType, - }); - } }; - const handleFilterChange = (filterType: string, value: any) => { - let newSelectedSpaceId = selectedSpaceId; - let newContentType = contentType; + const handleCreatorSelect = (user: { id: string; name: string } | null) => { + setSelectedCreatorId(user?.id ?? null); + setSelectedCreatorName(user?.name ?? null); + }; - switch (filterType) { - case "spaceId": - newSelectedSpaceId = value; - setSelectedSpaceId(value); - break; - case "contentType": - newContentType = value; - setContentType(value); - break; - } + const handleLabelsSelect = (labelIds: string[]) => { + setSelectedLabelIds(labelIds); + }; - if (onFiltersChange) { - onFiltersChange({ - spaceId: newSelectedSpaceId, - contentType: newContentType, - }); + const handleChangeContentType = (value: string) => { + setContentType(value); + + if (value === "attachment") { + setSelectedLabelIds([]); } }; @@ -181,7 +183,7 @@ export function SearchSpotlightFilters({ onClick={() => !option.disabled && contentType !== option.value && - handleFilterChange("contentType", option.value) + handleChangeContentType(option.value) } disabled={ option.disabled || (isAiMode && option.value === "attachment") @@ -195,13 +197,11 @@ export function SearchSpotlightFilters({ {t("Enterprise")} )} - {!option.disabled && - isAiMode && - option.value === "attachment" && ( - - {t("AI Answers not available for attachments")} - - )} + {!option.disabled && isAiMode && option.value === "attachment" && ( + + {t("AI Answers not available for attachments")} + + )} {contentType === option.value && } @@ -209,6 +209,52 @@ export function SearchSpotlightFilters({ ))} + + + + + + {contentType !== "attachment" && ( + + + + )} ); } diff --git a/apps/client/src/features/search/components/search-spotlight.tsx b/apps/client/src/features/search/components/search-spotlight.tsx index 4c5269f15..1707b9bed 100644 --- a/apps/client/src/features/search/components/search-spotlight.tsx +++ b/apps/client/src/features/search/components/search-spotlight.tsx @@ -1,7 +1,7 @@ import { Spotlight } from "@mantine/spotlight"; import { IconSearch, IconSparkles } from "@tabler/icons-react"; import { Group, Button, VisuallyHidden } from "@mantine/core"; -import React, { useState, useMemo, useEffect } from "react"; +import React, { useState, useMemo, useEffect, useCallback } from "react"; import { useDebouncedValue } from "@mantine/hooks"; import { useTranslation } from "react-i18next"; import { notifications } from "@mantine/notifications"; @@ -26,6 +26,8 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { const [filters, setFilters] = useState<{ spaceId?: string | null; contentType?: string; + creatorId?: string | null; + labelIds?: string[]; }>({ contentType: "page", }); @@ -43,6 +45,14 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { params.spaceId = filters.spaceId; } + if (filters.creatorId) { + params.creatorId = filters.creatorId; + } + + if (filters.labelIds?.length) { + params.labelIds = filters.labelIds; + } + return params; }, [debouncedSearchQuery, filters]); @@ -96,9 +106,9 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { /> )); - const handleFiltersChange = (newFilters: any) => { + const handleFiltersChange = useCallback((newFilters: any) => { setFilters(newFilters); - }; + }, [setFilters]); const handleAskClick = () => { setIsAiMode(!isAiMode); diff --git a/apps/client/src/features/search/types/search.types.ts b/apps/client/src/features/search/types/search.types.ts index 9962b9ca2..9a87aa32c 100644 --- a/apps/client/src/features/search/types/search.types.ts +++ b/apps/client/src/features/search/types/search.types.ts @@ -36,6 +36,8 @@ export interface IPageSearchParams { query: string; spaceId?: string; shareId?: string; + creatorId?: string; + labelIds?: string[]; } export interface IAttachmentSearch { diff --git a/apps/server/src/core/search/dto/search.dto.ts b/apps/server/src/core/search/dto/search.dto.ts index 8be6d338d..e5393063a 100644 --- a/apps/server/src/core/search/dto/search.dto.ts +++ b/apps/server/src/core/search/dto/search.dto.ts @@ -1,4 +1,5 @@ import { + IsArray, IsBoolean, IsNotEmpty, IsNumber, @@ -24,6 +25,11 @@ export class SearchDTO { @IsUUID() creatorId?: string; + @IsOptional() + @IsArray() + @IsUUID('all', { each: true }) + labelIds?: string[]; + @IsOptional() @IsNumber() limit?: number; diff --git a/apps/server/src/core/search/search.service.ts b/apps/server/src/core/search/search.service.ts index 9883b2654..bba1d0e1d 100644 --- a/apps/server/src/core/search/search.service.ts +++ b/apps/server/src/core/search/search.service.ts @@ -35,6 +35,7 @@ export class SearchService { return { items: [] }; } const searchQuery = tsquery(query.trim() + '*'); + const labelIds = [...new Set(searchParams.labelIds ?? [])]; let queryResults = this.db .selectFrom('pages') @@ -62,6 +63,22 @@ export class SearchService { .$if(Boolean(searchParams.creatorId), (qb) => qb.where('creatorId', '=', searchParams.creatorId), ) + .$if(labelIds?.length > 0, (qb) => + qb.where( + 'id', + 'in', + this.db + .selectFrom('pageLabels') + .select('pageId') + .where('labelId', 'in', labelIds) + .groupBy('pageId') + .having( + sql`count(distinct "label_id")`, + '=', + labelIds.length, + ), + ), + ) .where('deletedAt', 'is', null) .orderBy('rank', 'desc') .limit(searchParams.limit || 25)