Compare commits

...
Author SHA1 Message Date
Philipinho e51a23e607 minor search filter fixes 2026-08-25 01:35:32 +01:00
Philipinho d83ba56180 quick page sharing fix 2026-08-25 01:14:55 +01:00
Philipinho 3e0c3c1234 fix: show Untitled for pages without a title in search results 2026-08-25 00:20:57 +01:00
Philipinho 1dae88c899 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.
2026-08-25 00:11:04 +01:00
Philipinho 01b36db348 fix: stop empty-state flicker while the search query debounces 2026-08-25 00:02:43 +01:00
Philipinho dc15c8efed feat: show last updated time on search results 2026-08-24 23:50:32 +01:00
Philipinho f7ae9e8e95 feat: include search results in the spotlight tab order 2026-08-24 23:47:03 +01:00
Philipinho 271fcc070e fix: use a gray spotlight selection instead of primary blue 2026-08-24 23:37:31 +01:00
Philipinho 94183b40f4 fix: match any selected label instead of requiring all 2026-08-24 23:20:46 +01:00
Philipinho 875bc42610 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.
2026-08-24 23:09:51 +01:00
Philipinho caa582606b refactor: dim the you suffix in the creator filter 2026-08-24 23:01:01 +01:00
Philipinho c559cb31a8 feat: pin the current user at the top of the creator filter 2026-08-24 22:58:11 +01:00
Philipinho b5a11798a3 feat: index txt attachments for content search 2026-08-24 22:47:53 +01:00
Philipinho 479808c1a9 refactor: keep the title-only label uniform across search modes 2026-08-24 22:24:51 +01:00
Philipinho 528329f50c 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.
2026-08-24 22:19:04 +01:00
Philipinho bbc7ff39c0 feat: add trigram index on attachment file names 2026-08-24 22:11:23 +01:00
Philipinho a1ae9b2227 fix: drop stale results when the search content type changes 2026-08-24 22:07:53 +01:00
Philipinho 6a177cbc4d refactor: make title-only search a toggle chip 2026-08-24 21:41:57 +01:00
Philipinho 5f270d0502 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.
2026-08-24 21:37:28 +01:00
Philipinho 99288ff8ad fix: keep applied search filters in the order they were added 2026-08-24 21:25:22 +01:00
Philipinho da58bb382e 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.
2026-08-24 21:19:58 +01:00
Philipinho cdfb48141f refactor: detect typesense schema drift from the declared collection schema 2026-08-24 21:02:03 +01:00
Philipinho 895bba9af5 Merge branch 'main' into feat/search-filters
# Conflicts:
#	apps/client/src/features/search/components/search-spotlight.tsx
#	apps/server/src/ee
2026-08-24 20:54:39 +01:00
Salihu 480117b43f filters on AI search 2026-08-18 23:53:12 +01:00
Salihu ca3c9dcfd7 index label ids on typesense 2026-08-17 16:17:03 +01:00
Salihu 436f257ef7 index label ids on typesense 2026-08-17 15:50:16 +01:00
Salihu f702e826e7 improve labelId filtering in query 2026-08-15 23:32:24 +01:00
Salihu fad4b08097 advanced search filters 2026-08-15 22:48:34 +01:00
19 changed files with 740 additions and 85 deletions
@@ -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"
}
@@ -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 }),
enabled,
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";
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 (
<Spotlight.Action
component={Link}
ref={makeActionTabbable}
//@ts-ignore
to={buildPageUrl(
attachmentResult.space.slug,
@@ -59,8 +69,13 @@ export function SearchResultItem({
<IconFile size={16} />
</Center>
<div style={{ flex: 1 }}>
<Text>{attachmentResult.fileName}</Text>
<div style={{ flex: 1, minWidth: 0 }}>
<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}>
{attachmentResult.space.name} {attachmentResult.page.title}
</Text>
@@ -96,6 +111,7 @@ export function SearchResultItem({
return (
<Spotlight.Action
component={Link}
ref={makeActionTabbable}
//@ts-ignore
to={buildPageUrl(
pageResult.space.slug,
@@ -107,8 +123,13 @@ export function SearchResultItem({
<Group wrap="nowrap" w="100%">
<Center>{getPageIcon(pageResult?.icon)}</Center>
<div style={{ flex: 1 }}>
<Text>{pageResult.title}</Text>
<div style={{ flex: 1, minWidth: 0 }}>
<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 && (
<Badge variant="light" size="xs" color="gray">
@@ -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);
}
}
@@ -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<string | null>(
spaceId || null,
spaceId || null
);
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 { 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,
},
}}
/>
</div>
@@ -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")}
</Badge>
)}
{!option.disabled &&
isAiMode &&
option.value === "attachment" && (
<Text size="xs" mt={4}>
{t("AI Answers not available for attachments")}
</Text>
)}
{!option.disabled && isAiMode && option.value === "attachment" && (
<Text size="xs" mt={4}>
{t("AI Answers not available for attachments")}
</Text>
)}
</div>
{contentType === option.value && <IconCheck size={20} aria-hidden />}
</Group>
@@ -209,6 +254,126 @@ export function SearchSpotlightFilters({
))}
</Menu.Dropdown>
</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>
);
}
@@ -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 && (
<Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty>
)}
{query.length > 0 && !isLoading && resultItems.length === 0 && (
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
)}
{(query.length > 0 || isFilterBrowse) &&
!isFetching &&
isQuerySettled &&
resultItems.length === 0 && (
<Spotlight.Empty>{t("No results found...")}</Spotlight.Empty>
)}
{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>
@@ -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;
},
});
}
@@ -36,6 +36,9 @@ export interface IPageSearchParams {
query: string;
spaceId?: string;
shareId?: string;
creatorId?: string;
labelIds?: string[];
titleOnly?: boolean;
}
export interface IAttachmentSearch {
+28
View File
@@ -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)
);
}
@@ -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,
{
@@ -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) {
+12 -2
View File
@@ -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;
+57 -16
View File
@@ -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<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
.selectFrom('pages')
@@ -47,23 +71,41 @@ export class SearchService {
'creatorId',
'createdAt',
'updatedAt',
sql<number>`ts_rank(tsv, to_tsquery('english', f_unaccent(${searchQuery})))`.as(
'rank',
),
sql<string>`ts_headline('english', text_content, to_tsquery('english', f_unaccent(${searchQuery})),'MinWords=9, MaxWords=10, MaxFragments=3')`.as(
'highlight',
),
rankColumn,
highlightColumn,
])
.where(
'tsv',
'@@',
sql<string>`to_tsquery('english', f_unaccent(${searchQuery}))`,
.$if(!browseByFilters && !titleOnly, (qb) =>
qb.where(
'tsv',
'@@',
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) =>
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
+9 -4
View File
@@ -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');
}
@@ -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);
}