From 917195b2f2d29f13f328e1c1741746400e1e8979 Mon Sep 17 00:00:00 2001
From: Salihu <91833785+salihudickson@users.noreply.github.com>
Date: Tue, 25 Aug 2026 01:39:33 +0100
Subject: [PATCH] feat: enhanced search filters (#2398)
* advanced search filters
* improve labelId filtering in query
* index label ids on typesense
* index label ids on typesense
* filters on AI search
* refactor: detect typesense schema drift from the declared collection schema
* feat: show secondary search filters only when applied
Created by and Labels now stay hidden until they hold a value; a Filter
button at the right end of the bar lists the hidden ones and opens the
picked filter's dropdown immediately.
* fix: keep applied search filters in the order they were added
* feat: add title-only search mode
Adds a titleOnly flag to page search: the Postgres driver matches
titles via trigram-indexed unaccented LIKE ranked by word_similarity,
and a Match filter in the search spotlight switches between everything
and title-only matching.
* refactor: make title-only search a toggle chip
* fix: drop stale results when the search content type changes
* feat: add trigram index on attachment file names
* feat: extend title-only search to attachments
The titleOnly flag now matches attachment file names via the
separator-normalized trigram expression, and the toggle chip stays
available in attachment mode as File name only.
* refactor: keep the title-only label uniform across search modes
* feat: index txt attachments for content search
* feat: pin the current user at the top of the creator filter
* refactor: dim the you suffix in the creator filter
* feat: browse label-filtered pages without a query
Selecting labels now lists their pages newest-first before any text is
typed, on both the Postgres and Typesense drivers.
* fix: match any selected label instead of requiring all
* fix: use a gray spotlight selection instead of primary blue
* feat: include search results in the spotlight tab order
* feat: show last updated time on search results
* fix: stop empty-state flicker while the search query debounces
* feat: browse creator-filtered results without a query
Selecting a creator with an empty query now lists their pages (or
attachments in attachment mode) newest-first, with the same space and
page permission filtering as typed search.
* fix: show Untitled for pages without a title in search results
* quick page sharing fix
* minor search filter fixes
---------
Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
---
.../public/locales/en-US/translation.json | 4 +-
.../src/components/ui/checkbox-menu-item.tsx | 12 +
.../src/features/label/queries/label-query.ts | 1 +
.../search/components/creator-filter-menu.tsx | 152 +++++++++++
.../search/components/label-filter-menu.tsx | 127 +++++++++
.../search/components/search-result-item.tsx | 29 +-
.../search-spotlight-filters.module.css | 7 +
.../components/search-spotlight-filters.tsx | 253 +++++++++++++++---
.../search/components/search-spotlight.tsx | 56 +++-
.../search/hooks/use-unified-search.ts | 16 +-
.../src/features/search/types/search.types.ts | 3 +
apps/client/src/styles/a11y-overrides.css | 28 ++
.../attachment/services/attachment.service.ts | 4 +-
apps/server/src/core/label/label.service.ts | 14 +
apps/server/src/core/search/dto/search.dto.ts | 14 +-
apps/server/src/core/search/search.service.ts | 73 +++--
apps/server/src/core/share/share.service.ts | 13 +-
.../20260824T211732-page-title-trgm-index.ts | 17 ++
apps/server/src/ee | 2 +-
19 files changed, 740 insertions(+), 85 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
create mode 100644 apps/server/src/database/migrations/20260824T211732-page-title-trgm-index.ts
diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json
index e5768c7e4..14bb06d85 100644
--- a/apps/client/public/locales/en-US/translation.json
+++ b/apps/client/public/locales/en-US/translation.json
@@ -1308,5 +1308,7 @@
"Toggle workspace knowledge only": "Toggle workspace knowledge only",
"Read-only mode": "Read-only mode",
"AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat can search and read workspace content, but cannot create or edit pages.",
- "Toggle AI Chat read-only mode": "Toggle AI Chat read-only mode"
+ "Toggle AI Chat read-only mode": "Toggle AI Chat read-only mode",
+ "Title only": "Title only",
+ "you": "you"
}
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..ce4a93be1
--- /dev/null
+++ b/apps/client/src/features/search/components/creator-filter-menu.tsx
@@ -0,0 +1,152 @@
+import { ReactNode, useMemo, 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";
+import { useAtomValue } from "jotai";
+import { userAtom } from "@/features/user/atoms/current-user-atom.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;
+ opened?: boolean;
+ onOpenChange?: (opened: boolean) => void;
+};
+
+export function CreatorFilterMenu({
+ value,
+ onChange,
+ children,
+ width = 280,
+ position = "bottom-end",
+ zIndex,
+ opened,
+ onOpenChange,
+}: 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[]) ?? [];
+ const currentUser = useAtomValue(userAtom);
+
+ // pin the signed-in user on top so they never have to search themselves
+ const displayUsers = useMemo(() => {
+ if (!currentUser) return users;
+ const others = users.filter((user) => user.id !== currentUser.id);
+ const q = debouncedQuery.trim().toLowerCase();
+ const matchesQuery =
+ !q ||
+ currentUser.name?.toLowerCase().includes(q) ||
+ currentUser.email?.toLowerCase().includes(q);
+ return matchesQuery ? [currentUser as IUser, ...others] : users;
+ }, [users, currentUser, debouncedQuery]);
+
+ 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..2bc9db7a6
--- /dev/null
+++ b/apps/client/src/features/search/components/label-filter-menu.tsx
@@ -0,0 +1,127 @@
+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;
+ opened?: boolean;
+ onOpenChange?: (opened: boolean) => void;
+};
+
+export function LabelFilterMenu({
+ value,
+ onChange,
+ children,
+ width = 280,
+ position = "bottom-end",
+ zIndex,
+ opened,
+ onOpenChange,
+}: 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-result-item.tsx b/apps/client/src/features/search/components/search-result-item.tsx
index 24d3472b5..4412b9a97 100644
--- a/apps/client/src/features/search/components/search-result-item.tsx
+++ b/apps/client/src/features/search/components/search-result-item.tsx
@@ -19,6 +19,7 @@ import {
} from "@/features/search/types/search.types";
import DOMPurify from "dompurify";
import { useTranslation } from "react-i18next";
+import { timeAgo } from "@/lib/time.ts";
interface SearchResultItemProps {
result: IPageSearch | IAttachmentSearch;
@@ -26,6 +27,14 @@ interface SearchResultItemProps {
showSpace?: boolean;
}
+// Spotlight hardcodes tabIndex={-1} after spreading props; a ref wins and
+// React never writes -1 back because the prop value never changes
+const makeActionTabbable = (el: HTMLElement | null) => {
+ if (el) {
+ el.tabIndex = 0;
+ }
+};
+
export function SearchResultItem({
result,
isAttachmentResult,
@@ -46,6 +55,7 @@ export function SearchResultItem({
return (
-
-
{attachmentResult.fileName}
+
+
+ {attachmentResult.fileName}
+
+ {timeAgo(attachmentResult.updatedAt)}
+
+
{attachmentResult.space.name} • {attachmentResult.page.title}
@@ -96,6 +111,7 @@ export function SearchResultItem({
return (
{getPageIcon(pageResult?.icon)}
-
-
{pageResult.title}
+
+
+ {pageResult.title || t("Untitled")}
+
+ {timeAgo(pageResult.updatedAt)}
+
+
{showSpace && pageResult.space && (
diff --git a/apps/client/src/features/search/components/search-spotlight-filters.module.css b/apps/client/src/features/search/components/search-spotlight-filters.module.css
index e8073aab6..3709f2960 100644
--- a/apps/client/src/features/search/components/search-spotlight-filters.module.css
+++ b/apps/client/src/features/search/components/search-spotlight-filters.module.css
@@ -17,3 +17,10 @@
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-6));
}
}
+
+.filterButtonActive {
+ color: var(--mantine-color-blue-light-color);
+ &:hover {
+ color: var(--mantine-color-blue-light-color);
+ }
+}
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..96a7178ad 100644
--- a/apps/client/src/features/search/components/search-spotlight-filters.tsx
+++ b/apps/client/src/features/search/components/search-spotlight-filters.tsx
@@ -1,4 +1,5 @@
import React, { useState, useEffect } from "react";
+import cx from "clsx";
import {
Button,
Menu,
@@ -11,18 +12,24 @@ import {
import {
IconChevronDown,
IconBuilding,
+ IconPlus,
IconFileDescription,
IconCheck,
+ IconUser,
+ IconTag,
+ IconLetterCase,
} 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 +47,17 @@ 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 [titleOnly, setTitleOnly] = useState(false);
+ const [openedFilter, setOpenedFilter] = useState(null);
+ const [visibleFilters, setVisibleFilters] = useState([]);
const [workspace] = useAtom(workspaceAtom);
const { data: spacesData } = useGetSpacesQuery({ limit: 100 });
@@ -50,15 +65,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,38 +74,70 @@ export function SearchSpotlightFilters({
},
];
+ useEffect(() => {
+ onFiltersChange?.({
+ spaceId: selectedSpaceId,
+ contentType,
+ creatorId: selectedCreatorId,
+ labelIds: selectedLabelIds,
+ titleOnly,
+ });
+ }, [
+ selectedSpaceId,
+ contentType,
+ selectedCreatorId,
+ selectedLabelIds,
+ titleOnly,
+ onFiltersChange,
+ ]);
+
const handleSpaceSelect = (spaceId: string | null) => {
setSelectedSpaceId(spaceId);
+ };
- if (onFiltersChange) {
- onFiltersChange({
- spaceId: spaceId,
- contentType,
- });
+ const handleCreatorSelect = (user: { id: string; name: string } | null) => {
+ setSelectedCreatorId(user?.id ?? null);
+ setSelectedCreatorName(user?.name ?? null);
+ };
+
+ const handleLabelsSelect = (labelIds: string[]) => {
+ setSelectedLabelIds(labelIds);
+ };
+
+ const handleChangeContentType = (value: string) => {
+ setContentType(value);
+
+ if (value === "attachment") {
+ setSelectedLabelIds([]);
}
};
- const handleFilterChange = (filterType: string, value: any) => {
- let newSelectedSpaceId = selectedSpaceId;
- let newContentType = contentType;
+ const onDemandFilters = [
+ { key: "creator", label: t("Created by"), icon: IconUser, available: true },
+ {
+ key: "labels",
+ label: t("Labels"),
+ icon: IconTag,
+ available: contentType !== "attachment",
+ },
+ ];
- switch (filterType) {
- case "spaceId":
- newSelectedSpaceId = value;
- setSelectedSpaceId(value);
- break;
- case "contentType":
- newContentType = value;
- setContentType(value);
- break;
- }
+ const isFilterVisible = (key: string) => {
+ if (openedFilter === key) return true;
+ if (key === "creator") return !!selectedCreatorId;
+ if (key === "labels")
+ return contentType !== "attachment" && selectedLabelIds.length > 0;
+ return false;
+ };
- if (onFiltersChange) {
- onFiltersChange({
- spaceId: newSelectedSpaceId,
- contentType: newContentType,
- });
- }
+ const orderedVisibleFilters = visibleFilters.filter(isFilterVisible);
+ const addableFilters = onDemandFilters.filter(
+ (filter) => filter.available && !isFilterVisible(filter.key),
+ );
+
+ const revealFilter = (key: string) => {
+ setVisibleFilters((prev) => [...prev.filter((k) => k !== key), key]);
+ setOpenedFilter(key);
};
return (
@@ -122,8 +160,17 @@ export function SearchSpotlightFilters({
color="blue"
labelPosition="left"
styles={{
- root: { display: "flex", alignItems: "center" },
- label: { paddingRight: "8px", fontSize: "13px", fontWeight: 500 },
+ root: {
+ display: "flex",
+ alignItems: "center",
+ flexShrink: 0,
+ },
+ label: {
+ whiteSpace: "nowrap",
+ paddingRight: "8px",
+ fontSize: "13px",
+ fontWeight: 500,
+ },
}}
/>
@@ -181,7 +228,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 +242,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 +254,126 @@ export function SearchSpotlightFilters({
))}
+
+ {!isAiMode && (
+ }
+ className={cx(
+ classes.filterButton,
+ titleOnly && classes.filterButtonActive,
+ )}
+ fw={500}
+ aria-pressed={titleOnly}
+ onClick={() => setTitleOnly(!titleOnly)}
+ >
+ {t("Title only")}
+
+ )}
+
+ {!isAiMode &&
+ orderedVisibleFilters.map((filterKey) => {
+ if (filterKey === "creator") {
+ return (
+
+ setOpenedFilter(opened ? "creator" : null)
+ }
+ >
+ }
+ leftSection={}
+ className={classes.filterButton}
+ fw={500}
+ >
+ {selectedCreatorId
+ ? `${t("Created by")}: ${selectedCreatorName || t("Unknown")}`
+ : `${t("Created by")}: ${t("Anyone")}`}
+
+
+ );
+ }
+
+ if (filterKey === "labels") {
+ return (
+
+ setOpenedFilter(opened ? "labels" : null)
+ }
+ >
+ }
+ leftSection={}
+ className={classes.filterButton}
+ fw={500}
+ >
+ {selectedLabelIds.length > 0
+ ? `${t("Labels")} (${selectedLabelIds.length})`
+ : t("Labels")}
+
+
+ );
+ }
+
+ return null;
+ })}
+
+ {!isAiMode && addableFilters.length > 0 && (
+
+ )}
);
}
diff --git a/apps/client/src/features/search/components/search-spotlight.tsx b/apps/client/src/features/search/components/search-spotlight.tsx
index 3ada7ab1f..4e7977650 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 { Group, Button, VisuallyHidden, Text } from "@mantine/core";
+import React, { useState, useMemo, useEffect, useCallback } from "react";
import { useDebouncedValue } from "@mantine/hooks";
import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
@@ -31,6 +31,9 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
const [filters, setFilters] = useState<{
spaceId?: string | null;
contentType?: string;
+ creatorId?: string | null;
+ labelIds?: string[];
+ titleOnly?: boolean;
}>({
contentType: "page",
});
@@ -48,10 +51,25 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
params.spaceId = filters.spaceId;
}
+ if (filters.creatorId) {
+ params.creatorId = filters.creatorId;
+ }
+
+ if (filters.labelIds?.length) {
+ params.labelIds = filters.labelIds;
+ }
+
+ if (filters.titleOnly) {
+ params.titleOnly = true;
+ }
+
return params;
}, [debouncedSearchQuery, filters]);
- const { data: searchResults, isLoading } = useUnifiedSearch(
+ const {
+ data: searchResults,
+ isFetching,
+ } = useUnifiedSearch(
searchParams,
!isAiMode // Disable regular search when in AI mode
);
@@ -88,6 +106,11 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
}
}, [aiSearchError, t]);
+ const isFilterBrowse =
+ (filters.labelIds?.length ?? 0) > 0 || !!filters.creatorId;
+ // while the debounce is pending the empty list is not a settled "no results"
+ const isQuerySettled = query === debouncedSearchQuery;
+
// Determine result type for rendering
const isAttachmentSearch =
filters.contentType === "attachment" && hasAttachmentIndexing;
@@ -110,9 +133,9 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
}
};
- const handleFiltersChange = (newFilters: any) => {
+ const handleFiltersChange = useCallback((newFilters: any) => {
setFilters(newFilters);
- };
+ }, [setFilters]);
const handleAskClick = () => {
setIsAiMode(!isAiMode);
@@ -182,7 +205,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
? query.length > 0 && !isAiLoading && !aiSearchResult
? t("No answer available")
: ""
- : query.length > 0 && !isLoading
+ : (query.length > 0 || isFilterBrowse) && !isFetching
? resultItems.length === 0
? t("No results found")
: t("{{count}} results found", { count: resultItems.length })
@@ -209,15 +232,28 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
>
) : (
<>
- {query.length === 0 && resultItems.length === 0 && (
+ {query.length === 0 && !isFilterBrowse && resultItems.length === 0 && (
{t("Start typing to search...")}
)}
- {query.length > 0 && !isLoading && resultItems.length === 0 && (
-
{t("No results found...")}
- )}
+ {(query.length > 0 || isFilterBrowse) &&
+ !isFetching &&
+ isQuerySettled &&
+ resultItems.length === 0 && (
+
{t("No results found...")}
+ )}
{resultItems.length > 0 && <>{resultItems}>}
+
+ {(query.length > 0 || isFilterBrowse) &&
+ isFetching &&
+ resultItems.length === 0 && (
+
+
+ {t("Searching...")}
+
+
+ )}
>
)}
diff --git a/apps/client/src/features/search/hooks/use-unified-search.ts b/apps/client/src/features/search/hooks/use-unified-search.ts
index 5d294f4bd..9270477b9 100644
--- a/apps/client/src/features/search/hooks/use-unified-search.ts
+++ b/apps/client/src/features/search/hooks/use-unified-search.ts
@@ -39,6 +39,20 @@ export function useUnifiedSearch(
return await searchPage(backendParams);
}
},
- enabled: !!params.query && enabled,
+ enabled:
+ (!!params.query ||
+ (params.labelIds?.length ?? 0) > 0 ||
+ !!params.creatorId) &&
+ enabled,
+ // keep previous results only within the same search type; page results
+ // rendered as attachments (or vice versa) crash on missing fields
+ placeholderData: (previousData, previousQuery) => {
+ if (!params.query && !params.labelIds?.length && !params.creatorId)
+ return undefined;
+ if (previousQuery && previousQuery.queryKey[1] !== searchType) {
+ return undefined;
+ }
+ return previousData;
+ },
});
}
diff --git a/apps/client/src/features/search/types/search.types.ts b/apps/client/src/features/search/types/search.types.ts
index 9962b9ca2..60ae5d985 100644
--- a/apps/client/src/features/search/types/search.types.ts
+++ b/apps/client/src/features/search/types/search.types.ts
@@ -36,6 +36,9 @@ export interface IPageSearchParams {
query: string;
spaceId?: string;
shareId?: string;
+ creatorId?: string;
+ labelIds?: string[];
+ titleOnly?: boolean;
}
export interface IAttachmentSearch {
diff --git a/apps/client/src/styles/a11y-overrides.css b/apps/client/src/styles/a11y-overrides.css
index 25c1eafe7..1fb4264d9 100644
--- a/apps/client/src/styles/a11y-overrides.css
+++ b/apps/client/src/styles/a11y-overrides.css
@@ -33,3 +33,31 @@
color: var(--mantine-color-dimmed);
-webkit-text-fill-color: var(--mantine-color-dimmed);
}
+
+/* Spotlight's selected action ships as primary-filled blue with white text,
+ * but our custom action children (gray badge, dimmed snippet, gray icons)
+ * keep their light-surface colors on it and drop below WCAG AA 4.5:1
+ * (WCAG 1.4.3). Use a gray selection one step above the gray-0/dark-6
+ * hover instead - it matches the app's selection styling (we use no blue
+ * fills) and keeps every child at its already-passing resting contrast.
+ */
+.mantine-Spotlight-action[data-selected] {
+ background-color: light-dark(
+ var(--mantine-color-gray-1),
+ var(--mantine-color-dark-5)
+ );
+ color: var(--mantine-color-text);
+ --action-description-color: var(--mantine-color-dimmed);
+ --action-description-opacity: 1;
+}
+
+/* Result actions are tab stops (tabIndex restored via ref); give keyboard
+ * focus the same gray treatment as arrow-key selection so both navigation
+ * modes read identically (WCAG 2.4.7 focus visible).
+ */
+.mantine-Spotlight-action:focus-visible {
+ background-color: light-dark(
+ var(--mantine-color-gray-1),
+ var(--mantine-color-dark-5)
+ );
+}
diff --git a/apps/server/src/core/attachment/services/attachment.service.ts b/apps/server/src/core/attachment/services/attachment.service.ts
index 710f9d10b..2db13c817 100644
--- a/apps/server/src/core/attachment/services/attachment.service.ts
+++ b/apps/server/src/core/attachment/services/attachment.service.ts
@@ -116,8 +116,8 @@ export class AttachmentService {
});
}
- // Only index PDFs and DOCX files
- if (['.pdf', '.docx'].includes(attachment.fileExt.toLowerCase())) {
+ // Only index PDF, DOCX and TXT files
+ if (['.pdf', '.docx', '.txt'].includes(attachment.fileExt.toLowerCase())) {
await this.attachmentQueue.add(
QueueJob.ATTACHMENT_INDEX_CONTENT,
{
diff --git a/apps/server/src/core/label/label.service.ts b/apps/server/src/core/label/label.service.ts
index f0d63eba6..9ec0ab300 100644
--- a/apps/server/src/core/label/label.service.ts
+++ b/apps/server/src/core/label/label.service.ts
@@ -7,12 +7,15 @@ import { executeTx } from '@docmost/db/utils';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { normalizeLabelName } from './utils';
+import { EventEmitter2 } from "@nestjs/event-emitter";
+import { EventName } from "src/common/events/event.contants";
@Injectable()
export class LabelService {
constructor(
private readonly labelRepo: LabelRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
+ private readonly eventEmitter: EventEmitter2,
@InjectKysely() private readonly db: KyselyDB,
) {}
@@ -34,6 +37,12 @@ export class LabelService {
attached.push(label);
}
});
+
+ this.eventEmitter.emit(EventName.PAGE_UPDATED, {
+ pageIds: [pageId],
+ workspaceId: workspaceId,
+ });
+
return attached;
}
@@ -64,6 +73,11 @@ export class LabelService {
await this.labelRepo.deleteLabel(labelId, workspaceId, trx);
}
});
+
+ this.eventEmitter.emit(EventName.PAGE_UPDATED, {
+ pageIds: [pageId],
+ workspaceId: workspaceId,
+ });
}
async getPageLabels(pageId: string, pagination: PaginationOptions) {
diff --git a/apps/server/src/core/search/dto/search.dto.ts b/apps/server/src/core/search/dto/search.dto.ts
index 8be6d338d..89fb1b289 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,
@@ -8,9 +9,9 @@ import {
} from 'class-validator';
export class SearchDTO {
- @IsNotEmpty()
+ @IsOptional()
@IsString()
- query: string;
+ query?: string;
@IsOptional()
@IsUUID()
@@ -24,6 +25,15 @@ export class SearchDTO {
@IsUUID()
creatorId?: string;
+ @IsOptional()
+ @IsArray()
+ @IsUUID('all', { each: true })
+ labelIds?: string[];
+
+ @IsOptional()
+ @IsBoolean()
+ titleOnly?: boolean;
+
@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..3db29b274 100644
--- a/apps/server/src/core/search/search.service.ts
+++ b/apps/server/src/core/search/search.service.ts
@@ -29,12 +29,36 @@ export class SearchService {
workspaceId: string;
},
): Promise<{ items: SearchResponseDto[] }> {
- const { query } = searchParams;
+ const query = searchParams.query?.trim() ?? '';
+ const labelIds = [...new Set(searchParams.labelIds ?? [])];
+ // selected filters (labels, creator) are browsable without a query
+ const browseByFilters =
+ query.length < 1 &&
+ (labelIds.length > 0 || Boolean(searchParams.creatorId));
- if (query.length < 1) {
+ if (query.length < 1 && !browseByFilters) {
return { items: [] };
}
- const searchQuery = tsquery(query.trim() + '*');
+ const searchQuery = tsquery(query + '*');
+ const titleOnly = searchParams.titleOnly === true;
+ const titleQuery = query;
+ // escape LIKE wildcards; ranking keeps the raw query
+ const titleLikeQuery = query.replace(/[\\%_]/g, '\\$&');
+
+ const rankColumn = browseByFilters
+ ? sql
`0`.as('rank')
+ : titleOnly
+ ? sql`word_similarity(lower(f_unaccent(${titleQuery})), lower(f_unaccent(pages.title)))`.as(
+ 'rank',
+ )
+ : sql`ts_rank(tsv, to_tsquery('english', f_unaccent(${searchQuery})))`.as(
+ 'rank',
+ );
+ const highlightColumn = browseByFilters || titleOnly
+ ? sql`''`.as('highlight')
+ : sql`ts_headline('english', text_content, to_tsquery('english', f_unaccent(${searchQuery})),'MinWords=9, MaxWords=10, MaxFragments=3')`.as(
+ 'highlight',
+ );
let queryResults = this.db
.selectFrom('pages')
@@ -47,23 +71,41 @@ export class SearchService {
'creatorId',
'createdAt',
'updatedAt',
- sql`ts_rank(tsv, to_tsquery('english', f_unaccent(${searchQuery})))`.as(
- 'rank',
- ),
- sql`ts_headline('english', text_content, to_tsquery('english', f_unaccent(${searchQuery})),'MinWords=9, MaxWords=10, MaxFragments=3')`.as(
- 'highlight',
- ),
+ rankColumn,
+ highlightColumn,
])
- .where(
- 'tsv',
- '@@',
- sql`to_tsquery('english', f_unaccent(${searchQuery}))`,
+ .$if(!browseByFilters && !titleOnly, (qb) =>
+ qb.where(
+ 'tsv',
+ '@@',
+ sql`to_tsquery('english', f_unaccent(${searchQuery}))`,
+ ),
+ )
+ .$if(!browseByFilters && titleOnly, (qb) =>
+ qb.where((eb) =>
+ eb(
+ sql`lower(f_unaccent(pages.title))`,
+ 'like',
+ sql`lower(f_unaccent(${`%${titleLikeQuery}%`}))`,
+ ),
+ ),
)
.$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),
+ ),
+ )
.where('deletedAt', 'is', null)
- .orderBy('rank', 'desc')
+ .$if(browseByFilters, (qb) => qb.orderBy('updatedAt', 'desc'))
+ .$if(!browseByFilters, (qb) => qb.orderBy('rank', 'desc'))
.limit(searchParams.limit || 25)
.offset(searchParams.offset || 0);
@@ -71,8 +113,7 @@ export class SearchService {
queryResults = queryResults.select((eb) => this.pageRepo.withSpace(eb));
}
- if (searchParams.spaceId) {
- // search by spaceId
+ if (searchParams.spaceId && opts.userId) {
queryResults = queryResults.where('spaceId', '=', searchParams.spaceId);
} else if (opts.userId && !searchParams.spaceId) {
// only search spaces the user is a member of
diff --git a/apps/server/src/core/share/share.service.ts b/apps/server/src/core/share/share.service.ts
index 03ee31555..e567e6caa 100644
--- a/apps/server/src/core/share/share.service.ts
+++ b/apps/server/src/core/share/share.service.ts
@@ -46,8 +46,9 @@ export class ShareService {
throw new NotFoundException('Share not found');
}
- const isRestricted =
- await this.pagePermissionRepo.hasRestrictedAncestor(share.pageId);
+ const isRestricted = await this.pagePermissionRepo.hasRestrictedAncestor(
+ share.pageId,
+ );
if (isRestricted) {
throw new NotFoundException('Share not found');
}
@@ -110,6 +111,9 @@ export class ShareService {
}
async getSharedPage(dto: ShareInfoDto, workspaceId: string) {
+ //TODO: we should resolve the page from the share id
+ if (!dto.pageId) throw new NotFoundException('Shared page not found');
+
const share = await this.getShareForPage(dto.pageId, workspaceId);
if (!share) {
@@ -126,8 +130,9 @@ export class ShareService {
}
// Block access to restricted pages
- const isRestricted =
- await this.pagePermissionRepo.hasRestrictedAncestor(page.id);
+ const isRestricted = await this.pagePermissionRepo.hasRestrictedAncestor(
+ page.id,
+ );
if (isRestricted) {
throw new NotFoundException('Shared page not found');
}
diff --git a/apps/server/src/database/migrations/20260824T211732-page-title-trgm-index.ts b/apps/server/src/database/migrations/20260824T211732-page-title-trgm-index.ts
new file mode 100644
index 000000000..3cadad07b
--- /dev/null
+++ b/apps/server/src/database/migrations/20260824T211732-page-title-trgm-index.ts
@@ -0,0 +1,17 @@
+import { type Kysely, sql } from 'kysely';
+
+export async function up(db: Kysely): Promise {
+ await sql`CREATE INDEX IF NOT EXISTS pages_title_trgm_idx ON pages USING gin (lower(f_unaccent(title)) gin_trgm_ops)`.execute(
+ db,
+ );
+
+ // separators normalized to spaces so space-typed queries match How_to_export.pdf
+ await sql`CREATE INDEX IF NOT EXISTS attachments_file_name_trgm_idx ON attachments USING gin (lower(f_unaccent(translate(file_name, '_.-', ' '))) gin_trgm_ops)`.execute(
+ db,
+ );
+}
+
+export async function down(db: Kysely): Promise {
+ await sql`DROP INDEX IF EXISTS attachments_file_name_trgm_idx`.execute(db);
+ await sql`DROP INDEX IF EXISTS pages_title_trgm_idx`.execute(db);
+}
diff --git a/apps/server/src/ee b/apps/server/src/ee
index 62f745608..b51a46ef1 160000
--- a/apps/server/src/ee
+++ b/apps/server/src/ee
@@ -1 +1 @@
-Subproject commit 62f7456089d97ceedbcbad1ceabce09a4c7e1301
+Subproject commit b51a46ef104cb407a670462a36be79ef0da94f97