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>
This commit is contained in:
Salihu
2026-08-25 01:39:33 +01:00
committed by GitHub
co-authored by Philipinho
parent cd597f0161
commit 917195b2f2
19 changed files with 740 additions and 85 deletions
@@ -1308,5 +1308,7 @@
"Toggle workspace knowledge only": "Toggle workspace knowledge only", "Toggle workspace knowledge only": "Toggle workspace knowledge only",
"Read-only mode": "Read-only mode", "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.", "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"
} }
@@ -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) => (
<UnstyledButton ref={ref} {...props} role="menuitemcheckbox" />
));
CheckboxMenuItem.displayName = "CheckboxMenuItem";
@@ -39,6 +39,7 @@ export function useWorkspaceLabelsQuery(query: string, enabled: boolean) {
queryFn: () => getWorkspaceLabels({ type: "page", query, limit: 50 }), queryFn: () => getWorkspaceLabels({ type: "page", query, limit: 50 }),
enabled, enabled,
staleTime: 30 * 1000, staleTime: 30 * 1000,
placeholderData: keepPreviousData
}); });
} }
@@ -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 (
<Menu
shadow="md"
width={width}
position={position}
zIndex={zIndex}
opened={opened}
onChange={onOpenChange}
>
<Menu.Target>{children}</Menu.Target>
<Menu.Dropdown>
<TextInput
placeholder={t("Find a user")}
data-autofocus
autoFocus
leftSection={<IconSearch size={16} />}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
size="sm"
variant="filled"
radius="sm"
styles={{ input: { marginBottom: 8 } }}
/>
<ScrollArea.Autosize mah={280}>
<Menu.Item
component={RadioMenuItem}
aria-checked={!value}
onClick={() => onChange(null)}
>
<Group flex="1" gap="xs">
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{t("Anyone")}
</Text>
</div>
{!value && <IconCheck size={20} aria-hidden />}
</Group>
</Menu.Item>
<Divider my="xs" />
{displayUsers.length === 0 && (
<Text size="xs" c="dimmed" px="xs" py="sm">
{isLoading ? t("Loading...") : t("No users found")}
</Text>
)}
{displayUsers.map((user) => (
<Menu.Item
key={user.id}
component={RadioMenuItem}
aria-checked={value === user.id}
onClick={() => onChange(user)}
>
<Group flex="1" gap="xs">
<CustomAvatar
avatarUrl={user.avatarUrl}
size={20}
name={user.name}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{user.name}
{user.id === currentUser?.id && (
<Text span size="sm" c="dimmed" fw={400}>
{" "}
({t("you")})
</Text>
)}
</Text>
{user.email && (
<Text size="xs" c="dimmed" truncate>
{user.email}
</Text>
)}
</div>
{value === user.id && <IconCheck size={20} aria-hidden />}
</Group>
</Menu.Item>
))}
</ScrollArea.Autosize>
</Menu.Dropdown>
</Menu>
);
}
@@ -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 (
<Menu
shadow="md"
width={width}
position={position}
zIndex={zIndex}
opened={opened}
onChange={onOpenChange}
closeOnItemClick={false}
>
<Menu.Target>{children}</Menu.Target>
<Menu.Dropdown>
<TextInput
placeholder={t("Find a label")}
data-autofocus
autoFocus
leftSection={<IconSearch size={16} />}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
size="sm"
variant="filled"
radius="sm"
styles={{ input: { marginBottom: 8 } }}
/>
<ScrollArea.Autosize mah={280}>
{labels.length === 0 && (
<Text size="xs" c="dimmed" px="xs" py="sm">
{isLoading ? t("Loading...") : t("No labels found")}
</Text>
)}
{labels.map((label) => {
const isChecked = selectedSet.has(label.id);
const color = getLabelColor(label.name, scheme);
return (
<Menu.Item
key={label.id}
type="button"
component={CheckboxMenuItem}
aria-checked={isChecked}
onClick={() => toggleLabel(label.id)}
>
<Group flex="1" gap="xs">
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: color.dot,
flexShrink: 0,
}}
/>
<Text size="sm" fw={500} style={{ flex: 1 }} truncate>
{label.name}
</Text>
{isChecked && <IconCheck size={20} aria-hidden />}
</Group>
</Menu.Item>
);
})}
</ScrollArea.Autosize>
</Menu.Dropdown>
</Menu>
);
}
@@ -19,6 +19,7 @@ import {
} from "@/features/search/types/search.types"; } from "@/features/search/types/search.types";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { timeAgo } from "@/lib/time.ts";
interface SearchResultItemProps { interface SearchResultItemProps {
result: IPageSearch | IAttachmentSearch; result: IPageSearch | IAttachmentSearch;
@@ -26,6 +27,14 @@ interface SearchResultItemProps {
showSpace?: boolean; 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({ export function SearchResultItem({
result, result,
isAttachmentResult, isAttachmentResult,
@@ -46,6 +55,7 @@ export function SearchResultItem({
return ( return (
<Spotlight.Action <Spotlight.Action
component={Link} component={Link}
ref={makeActionTabbable}
//@ts-ignore //@ts-ignore
to={buildPageUrl( to={buildPageUrl(
attachmentResult.space.slug, attachmentResult.space.slug,
@@ -59,8 +69,13 @@ export function SearchResultItem({
<IconFile size={16} /> <IconFile size={16} />
</Center> </Center>
<div style={{ flex: 1 }}> <div style={{ flex: 1, minWidth: 0 }}>
<Text>{attachmentResult.fileName}</Text> <Group justify="space-between" wrap="nowrap" gap="xs">
<Text truncate>{attachmentResult.fileName}</Text>
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{timeAgo(attachmentResult.updatedAt)}
</Text>
</Group>
<Text size="xs" opacity={0.6}> <Text size="xs" opacity={0.6}>
{attachmentResult.space.name} {attachmentResult.page.title} {attachmentResult.space.name} {attachmentResult.page.title}
</Text> </Text>
@@ -96,6 +111,7 @@ export function SearchResultItem({
return ( return (
<Spotlight.Action <Spotlight.Action
component={Link} component={Link}
ref={makeActionTabbable}
//@ts-ignore //@ts-ignore
to={buildPageUrl( to={buildPageUrl(
pageResult.space.slug, pageResult.space.slug,
@@ -107,8 +123,13 @@ export function SearchResultItem({
<Group wrap="nowrap" w="100%"> <Group wrap="nowrap" w="100%">
<Center>{getPageIcon(pageResult?.icon)}</Center> <Center>{getPageIcon(pageResult?.icon)}</Center>
<div style={{ flex: 1 }}> <div style={{ flex: 1, minWidth: 0 }}>
<Text>{pageResult.title}</Text> <Group justify="space-between" wrap="nowrap" gap="xs">
<Text truncate>{pageResult.title || t("Untitled")}</Text>
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{timeAgo(pageResult.updatedAt)}
</Text>
</Group>
{showSpace && pageResult.space && ( {showSpace && pageResult.space && (
<Badge variant="light" size="xs" color="gray"> <Badge variant="light" size="xs" color="gray">
@@ -17,3 +17,10 @@
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-6)); 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);
}
}
@@ -1,4 +1,5 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import cx from "clsx";
import { import {
Button, Button,
Menu, Menu,
@@ -11,18 +12,24 @@ import {
import { import {
IconChevronDown, IconChevronDown,
IconBuilding, IconBuilding,
IconPlus,
IconFileDescription, IconFileDescription,
IconCheck, IconCheck,
IconUser,
IconTag,
IconLetterCase,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useGetSpacesQuery } from "@/features/space/queries/space-query"; import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { SpaceFilterMenu } from "@/features/space/components/space-filter-menu"; 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 { RadioMenuItem } from "@/components/ui/radio-menu-item";
import { useHasFeature } from "@/ee/hooks/use-feature"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features"; import { Feature } from "@/ee/features";
import classes from "./search-spotlight-filters.module.css"; import classes from "./search-spotlight-filters.module.css";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { LabelFilterMenu } from "./label-filter-menu";
interface SearchSpotlightFiltersProps { interface SearchSpotlightFiltersProps {
onFiltersChange?: (filters: any) => void; onFiltersChange?: (filters: any) => void;
@@ -40,9 +47,17 @@ export function SearchSpotlightFilters({
const { t } = useTranslation(); const { t } = useTranslation();
const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING); const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING);
const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>( const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(
spaceId || null, spaceId || null
); );
const [contentType, setContentType] = useState<string | null>("page"); const [contentType, setContentType] = useState<string | null>("page");
const [selectedCreatorId, setSelectedCreatorId] = useState<string | null>(null);
const [selectedCreatorName, setSelectedCreatorName] = useState<string | null>(
null
);
const [selectedLabelIds, setSelectedLabelIds] = useState<string[]>([]);
const [titleOnly, setTitleOnly] = useState(false);
const [openedFilter, setOpenedFilter] = useState<string | null>(null);
const [visibleFilters, setVisibleFilters] = useState<string[]>([]);
const [workspace] = useAtom(workspaceAtom); const [workspace] = useAtom(workspaceAtom);
const { data: spacesData } = useGetSpacesQuery({ limit: 100 }); const { data: spacesData } = useGetSpacesQuery({ limit: 100 });
@@ -50,15 +65,6 @@ export function SearchSpotlightFilters({
? spacesData?.items.find((space) => space.id === selectedSpaceId) ? spacesData?.items.find((space) => space.id === selectedSpaceId)
: null; : null;
useEffect(() => {
if (onFiltersChange) {
onFiltersChange({
spaceId: selectedSpaceId,
contentType,
});
}
}, []);
const contentTypeOptions = [ const contentTypeOptions = [
{ value: "page", label: t("Pages") }, { 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) => { const handleSpaceSelect = (spaceId: string | null) => {
setSelectedSpaceId(spaceId); setSelectedSpaceId(spaceId);
};
if (onFiltersChange) { const handleCreatorSelect = (user: { id: string; name: string } | null) => {
onFiltersChange({ setSelectedCreatorId(user?.id ?? null);
spaceId: spaceId, setSelectedCreatorName(user?.name ?? null);
contentType, };
});
const handleLabelsSelect = (labelIds: string[]) => {
setSelectedLabelIds(labelIds);
};
const handleChangeContentType = (value: string) => {
setContentType(value);
if (value === "attachment") {
setSelectedLabelIds([]);
} }
}; };
const handleFilterChange = (filterType: string, value: any) => { const onDemandFilters = [
let newSelectedSpaceId = selectedSpaceId; { key: "creator", label: t("Created by"), icon: IconUser, available: true },
let newContentType = contentType; {
key: "labels",
label: t("Labels"),
icon: IconTag,
available: contentType !== "attachment",
},
];
switch (filterType) { const isFilterVisible = (key: string) => {
case "spaceId": if (openedFilter === key) return true;
newSelectedSpaceId = value; if (key === "creator") return !!selectedCreatorId;
setSelectedSpaceId(value); if (key === "labels")
break; return contentType !== "attachment" && selectedLabelIds.length > 0;
case "contentType": return false;
newContentType = value; };
setContentType(value);
break;
}
if (onFiltersChange) { const orderedVisibleFilters = visibleFilters.filter(isFilterVisible);
onFiltersChange({ const addableFilters = onDemandFilters.filter(
spaceId: newSelectedSpaceId, (filter) => filter.available && !isFilterVisible(filter.key),
contentType: newContentType, );
});
} const revealFilter = (key: string) => {
setVisibleFilters((prev) => [...prev.filter((k) => k !== key), key]);
setOpenedFilter(key);
}; };
return ( return (
@@ -122,8 +160,17 @@ export function SearchSpotlightFilters({
color="blue" color="blue"
labelPosition="left" labelPosition="left"
styles={{ styles={{
root: { display: "flex", alignItems: "center" }, root: {
label: { paddingRight: "8px", fontSize: "13px", fontWeight: 500 }, display: "flex",
alignItems: "center",
flexShrink: 0,
},
label: {
whiteSpace: "nowrap",
paddingRight: "8px",
fontSize: "13px",
fontWeight: 500,
},
}} }}
/> />
</div> </div>
@@ -181,7 +228,7 @@ export function SearchSpotlightFilters({
onClick={() => onClick={() =>
!option.disabled && !option.disabled &&
contentType !== option.value && contentType !== option.value &&
handleFilterChange("contentType", option.value) handleChangeContentType(option.value)
} }
disabled={ disabled={
option.disabled || (isAiMode && option.value === "attachment") option.disabled || (isAiMode && option.value === "attachment")
@@ -195,13 +242,11 @@ export function SearchSpotlightFilters({
{t("Enterprise")} {t("Enterprise")}
</Badge> </Badge>
)} )}
{!option.disabled && {!option.disabled && isAiMode && option.value === "attachment" && (
isAiMode && <Text size="xs" mt={4}>
option.value === "attachment" && ( {t("AI Answers not available for attachments")}
<Text size="xs" mt={4}> </Text>
{t("AI Answers not available for attachments")} )}
</Text>
)}
</div> </div>
{contentType === option.value && <IconCheck size={20} aria-hidden />} {contentType === option.value && <IconCheck size={20} aria-hidden />}
</Group> </Group>
@@ -209,6 +254,126 @@ export function SearchSpotlightFilters({
))} ))}
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
{!isAiMode && (
<Button
variant={titleOnly ? "light" : "subtle"}
color={titleOnly ? "blue" : "gray"}
size="sm"
radius="xl"
leftSection={<IconLetterCase size={16} />}
className={cx(
classes.filterButton,
titleOnly && classes.filterButtonActive,
)}
fw={500}
aria-pressed={titleOnly}
onClick={() => setTitleOnly(!titleOnly)}
>
{t("Title only")}
</Button>
)}
{!isAiMode &&
orderedVisibleFilters.map((filterKey) => {
if (filterKey === "creator") {
return (
<CreatorFilterMenu
key="creator"
value={selectedCreatorId}
onChange={handleCreatorSelect}
position="bottom-start"
width={250}
zIndex={getDefaultZIndex("max")}
opened={openedFilter === "creator"}
onOpenChange={(opened) =>
setOpenedFilter(opened ? "creator" : null)
}
>
<Button
variant="subtle"
color="gray"
size="sm"
rightSection={<IconChevronDown size={14} />}
leftSection={<IconUser size={16} />}
className={classes.filterButton}
fw={500}
>
{selectedCreatorId
? `${t("Created by")}: ${selectedCreatorName || t("Unknown")}`
: `${t("Created by")}: ${t("Anyone")}`}
</Button>
</CreatorFilterMenu>
);
}
if (filterKey === "labels") {
return (
<LabelFilterMenu
key="labels"
value={selectedLabelIds}
onChange={handleLabelsSelect}
position="bottom-start"
width={250}
zIndex={getDefaultZIndex("max")}
opened={openedFilter === "labels"}
onOpenChange={(opened) =>
setOpenedFilter(opened ? "labels" : null)
}
>
<Button
variant="subtle"
color="gray"
size="sm"
rightSection={<IconChevronDown size={14} />}
leftSection={<IconTag size={16} />}
className={classes.filterButton}
fw={500}
>
{selectedLabelIds.length > 0
? `${t("Labels")} (${selectedLabelIds.length})`
: t("Labels")}
</Button>
</LabelFilterMenu>
);
}
return null;
})}
{!isAiMode && addableFilters.length > 0 && (
<Menu
shadow="md"
width={200}
position="bottom-end"
zIndex={getDefaultZIndex("max")}
>
<Menu.Target>
<Button
variant="subtle"
color="gray"
size="sm"
leftSection={<IconPlus size={16} />}
className={classes.filterButton}
style={{ marginLeft: "auto" }}
fw={500}
>
{t("Filter")}
</Button>
</Menu.Target>
<Menu.Dropdown>
{addableFilters.map((filter) => (
<Menu.Item
key={filter.key}
leftSection={<filter.icon size={16} />}
onClick={() => revealFilter(filter.key)}
>
{filter.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
)}
</div> </div>
); );
} }
@@ -1,7 +1,7 @@
import { Spotlight } from "@mantine/spotlight"; import { Spotlight } from "@mantine/spotlight";
import { IconSearch, IconSparkles } from "@tabler/icons-react"; import { IconSearch, IconSparkles } from "@tabler/icons-react";
import { Group, Button, VisuallyHidden } from "@mantine/core"; import { Group, Button, VisuallyHidden, Text } from "@mantine/core";
import React, { useState, useMemo, useEffect } from "react"; import React, { useState, useMemo, useEffect, useCallback } from "react";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
@@ -31,6 +31,9 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
const [filters, setFilters] = useState<{ const [filters, setFilters] = useState<{
spaceId?: string | null; spaceId?: string | null;
contentType?: string; contentType?: string;
creatorId?: string | null;
labelIds?: string[];
titleOnly?: boolean;
}>({ }>({
contentType: "page", contentType: "page",
}); });
@@ -48,10 +51,25 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
params.spaceId = filters.spaceId; 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; return params;
}, [debouncedSearchQuery, filters]); }, [debouncedSearchQuery, filters]);
const { data: searchResults, isLoading } = useUnifiedSearch( const {
data: searchResults,
isFetching,
} = useUnifiedSearch(
searchParams, searchParams,
!isAiMode // Disable regular search when in AI mode !isAiMode // Disable regular search when in AI mode
); );
@@ -88,6 +106,11 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
} }
}, [aiSearchError, t]); }, [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 // Determine result type for rendering
const isAttachmentSearch = const isAttachmentSearch =
filters.contentType === "attachment" && hasAttachmentIndexing; 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(newFilters);
}; }, [setFilters]);
const handleAskClick = () => { const handleAskClick = () => {
setIsAiMode(!isAiMode); setIsAiMode(!isAiMode);
@@ -182,7 +205,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
? query.length > 0 && !isAiLoading && !aiSearchResult ? query.length > 0 && !isAiLoading && !aiSearchResult
? t("No answer available") ? t("No answer available")
: "" : ""
: query.length > 0 && !isLoading : (query.length > 0 || isFilterBrowse) && !isFetching
? resultItems.length === 0 ? resultItems.length === 0
? t("No results found") ? t("No results found")
: t("{{count}} results found", { count: resultItems.length }) : 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 && (
<Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty> <Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty>
)} )}
{query.length > 0 && !isLoading && resultItems.length === 0 && ( {(query.length > 0 || isFilterBrowse) &&
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty> !isFetching &&
)} isQuerySettled &&
resultItems.length === 0 && (
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
)}
{resultItems.length > 0 && <>{resultItems}</>} {resultItems.length > 0 && <>{resultItems}</>}
{(query.length > 0 || isFilterBrowse) &&
isFetching &&
resultItems.length === 0 && (
<Spotlight.Empty>
<Text size="sm" style={{ marginTop: 10 }}>
{t("Searching...")}
</Text>
</Spotlight.Empty>
)}
</> </>
)} )}
</Spotlight.ActionsList> </Spotlight.ActionsList>
@@ -39,6 +39,20 @@ export function useUnifiedSearch(
return await searchPage(backendParams); 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;
},
}); });
} }
@@ -36,6 +36,9 @@ export interface IPageSearchParams {
query: string; query: string;
spaceId?: string; spaceId?: string;
shareId?: string; shareId?: string;
creatorId?: string;
labelIds?: string[];
titleOnly?: boolean;
} }
export interface IAttachmentSearch { export interface IAttachmentSearch {
+28
View File
@@ -33,3 +33,31 @@
color: var(--mantine-color-dimmed); color: var(--mantine-color-dimmed);
-webkit-text-fill-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)
);
}
@@ -116,8 +116,8 @@ export class AttachmentService {
}); });
} }
// Only index PDFs and DOCX files // Only index PDF, DOCX and TXT files
if (['.pdf', '.docx'].includes(attachment.fileExt.toLowerCase())) { if (['.pdf', '.docx', '.txt'].includes(attachment.fileExt.toLowerCase())) {
await this.attachmentQueue.add( await this.attachmentQueue.add(
QueueJob.ATTACHMENT_INDEX_CONTENT, QueueJob.ATTACHMENT_INDEX_CONTENT,
{ {
@@ -7,12 +7,15 @@ import { executeTx } from '@docmost/db/utils';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'; import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { normalizeLabelName } from './utils'; import { normalizeLabelName } from './utils';
import { EventEmitter2 } from "@nestjs/event-emitter";
import { EventName } from "src/common/events/event.contants";
@Injectable() @Injectable()
export class LabelService { export class LabelService {
constructor( constructor(
private readonly labelRepo: LabelRepo, private readonly labelRepo: LabelRepo,
private readonly pagePermissionRepo: PagePermissionRepo, private readonly pagePermissionRepo: PagePermissionRepo,
private readonly eventEmitter: EventEmitter2,
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
) {} ) {}
@@ -34,6 +37,12 @@ export class LabelService {
attached.push(label); attached.push(label);
} }
}); });
this.eventEmitter.emit(EventName.PAGE_UPDATED, {
pageIds: [pageId],
workspaceId: workspaceId,
});
return attached; return attached;
} }
@@ -64,6 +73,11 @@ export class LabelService {
await this.labelRepo.deleteLabel(labelId, workspaceId, trx); await this.labelRepo.deleteLabel(labelId, workspaceId, trx);
} }
}); });
this.eventEmitter.emit(EventName.PAGE_UPDATED, {
pageIds: [pageId],
workspaceId: workspaceId,
});
} }
async getPageLabels(pageId: string, pagination: PaginationOptions) { async getPageLabels(pageId: string, pagination: PaginationOptions) {
+12 -2
View File
@@ -1,4 +1,5 @@
import { import {
IsArray,
IsBoolean, IsBoolean,
IsNotEmpty, IsNotEmpty,
IsNumber, IsNumber,
@@ -8,9 +9,9 @@ import {
} from 'class-validator'; } from 'class-validator';
export class SearchDTO { export class SearchDTO {
@IsNotEmpty() @IsOptional()
@IsString() @IsString()
query: string; query?: string;
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
@@ -24,6 +25,15 @@ export class SearchDTO {
@IsUUID() @IsUUID()
creatorId?: string; creatorId?: string;
@IsOptional()
@IsArray()
@IsUUID('all', { each: true })
labelIds?: string[];
@IsOptional()
@IsBoolean()
titleOnly?: boolean;
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
limit?: number; limit?: number;
+57 -16
View File
@@ -29,12 +29,36 @@ export class SearchService {
workspaceId: string; workspaceId: string;
}, },
): Promise<{ items: SearchResponseDto[] }> { ): 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: [] }; 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<number>`0`.as('rank')
: titleOnly
? sql<number>`word_similarity(lower(f_unaccent(${titleQuery})), lower(f_unaccent(pages.title)))`.as(
'rank',
)
: sql<number>`ts_rank(tsv, to_tsquery('english', f_unaccent(${searchQuery})))`.as(
'rank',
);
const highlightColumn = browseByFilters || titleOnly
? sql<string>`''`.as('highlight')
: sql<string>`ts_headline('english', text_content, to_tsquery('english', f_unaccent(${searchQuery})),'MinWords=9, MaxWords=10, MaxFragments=3')`.as(
'highlight',
);
let queryResults = this.db let queryResults = this.db
.selectFrom('pages') .selectFrom('pages')
@@ -47,23 +71,41 @@ export class SearchService {
'creatorId', 'creatorId',
'createdAt', 'createdAt',
'updatedAt', 'updatedAt',
sql<number>`ts_rank(tsv, to_tsquery('english', f_unaccent(${searchQuery})))`.as( rankColumn,
'rank', highlightColumn,
),
sql<string>`ts_headline('english', text_content, to_tsquery('english', f_unaccent(${searchQuery})),'MinWords=9, MaxWords=10, MaxFragments=3')`.as(
'highlight',
),
]) ])
.where( .$if(!browseByFilters && !titleOnly, (qb) =>
'tsv', qb.where(
'@@', 'tsv',
sql<string>`to_tsquery('english', f_unaccent(${searchQuery}))`, '@@',
sql<string>`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) => .$if(Boolean(searchParams.creatorId), (qb) =>
qb.where('creatorId', '=', searchParams.creatorId), 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) .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) .limit(searchParams.limit || 25)
.offset(searchParams.offset || 0); .offset(searchParams.offset || 0);
@@ -71,8 +113,7 @@ export class SearchService {
queryResults = queryResults.select((eb) => this.pageRepo.withSpace(eb)); queryResults = queryResults.select((eb) => this.pageRepo.withSpace(eb));
} }
if (searchParams.spaceId) { if (searchParams.spaceId && opts.userId) {
// search by spaceId
queryResults = queryResults.where('spaceId', '=', searchParams.spaceId); queryResults = queryResults.where('spaceId', '=', searchParams.spaceId);
} else if (opts.userId && !searchParams.spaceId) { } else if (opts.userId && !searchParams.spaceId) {
// only search spaces the user is a member of // only search spaces the user is a member of
+9 -4
View File
@@ -46,8 +46,9 @@ export class ShareService {
throw new NotFoundException('Share not found'); throw new NotFoundException('Share not found');
} }
const isRestricted = const isRestricted = await this.pagePermissionRepo.hasRestrictedAncestor(
await this.pagePermissionRepo.hasRestrictedAncestor(share.pageId); share.pageId,
);
if (isRestricted) { if (isRestricted) {
throw new NotFoundException('Share not found'); throw new NotFoundException('Share not found');
} }
@@ -110,6 +111,9 @@ export class ShareService {
} }
async getSharedPage(dto: ShareInfoDto, workspaceId: string) { 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); const share = await this.getShareForPage(dto.pageId, workspaceId);
if (!share) { if (!share) {
@@ -126,8 +130,9 @@ export class ShareService {
} }
// Block access to restricted pages // Block access to restricted pages
const isRestricted = const isRestricted = await this.pagePermissionRepo.hasRestrictedAncestor(
await this.pagePermissionRepo.hasRestrictedAncestor(page.id); page.id,
);
if (isRestricted) { if (isRestricted) {
throw new NotFoundException('Shared page not found'); throw new NotFoundException('Shared page not found');
} }
@@ -0,0 +1,17 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
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<any>): Promise<void> {
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);
}