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 (
+
+ );
+}
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 (
+
+ );
+}
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({
))}
+
+
+ }
+ leftSection={}
+ className={classes.filterButton}
+ fw={500}
+ >
+ {selectedCreatorId
+ ? `${t("Created by")}: ${selectedCreatorName || t("Unknown")}`
+ : `${t("Created by")}: ${t("Anyone")}`}
+
+
+
+ {contentType !== "attachment" && (
+
+ }
+ leftSection={}
+ className={classes.filterButton}
+ fw={500}
+ >
+ {selectedLabelIds.length > 0
+ ? `${t("Labels")} (${selectedLabelIds.length})`
+ : t("Labels")}
+
+
+ )}
);
}
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)