mirror of
https://github.com/docmost/docmost.git
synced 2026-08-28 17:27:06 +08:00
advanced search filters
This commit is contained in:
@@ -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,120 @@
|
|||||||
|
import { ReactNode, useState } from "react";
|
||||||
|
import { Divider, Group, Menu, ScrollArea, Text, TextInput } from "@mantine/core";
|
||||||
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
|
import { IconCheck, IconSearch } from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query";
|
||||||
|
import { RadioMenuItem } from "@/components/ui/radio-menu-item";
|
||||||
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
|
import { IUser } from "@/features/user/types/user.types.ts";
|
||||||
|
|
||||||
|
type CreatorFilterMenuProps = {
|
||||||
|
value: string | null;
|
||||||
|
onChange: (user: IUser | null) => void;
|
||||||
|
children: ReactNode;
|
||||||
|
width?: number;
|
||||||
|
position?:
|
||||||
|
| "bottom-start"
|
||||||
|
| "bottom-end"
|
||||||
|
| "bottom"
|
||||||
|
| "top-start"
|
||||||
|
| "top-end"
|
||||||
|
| "top";
|
||||||
|
zIndex?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CreatorFilterMenu({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
children,
|
||||||
|
width = 280,
|
||||||
|
position = "bottom-end",
|
||||||
|
zIndex,
|
||||||
|
}: CreatorFilterMenuProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [debouncedQuery] = useDebouncedValue(searchQuery, 300);
|
||||||
|
|
||||||
|
const { data: suggestion, isLoading } = useSearchSuggestionsQuery({
|
||||||
|
query: debouncedQuery,
|
||||||
|
includeUsers: true,
|
||||||
|
includeGroups: false,
|
||||||
|
includePages: false,
|
||||||
|
preload: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const users: IUser[] = (suggestion?.users as IUser[]) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu shadow="md" width={width} position={position} zIndex={zIndex}>
|
||||||
|
<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" />
|
||||||
|
|
||||||
|
{users.length === 0 && (
|
||||||
|
<Text size="xs" c="dimmed" px="xs" py="sm">
|
||||||
|
{isLoading ? t("Loading...") : t("No users found")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{users.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}
|
||||||
|
</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,120 @@
|
|||||||
|
import { ReactNode, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Group,
|
||||||
|
Menu,
|
||||||
|
ScrollArea,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
useComputedColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
|
import { IconCheck, IconSearch } from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useWorkspaceLabelsQuery } from "@/features/label/queries/label-query.ts";
|
||||||
|
import { getLabelColor } from "@/features/label/utils/label-colors.ts";
|
||||||
|
import { CheckboxMenuItem } from "@/components/ui/checkbox-menu-item";
|
||||||
|
|
||||||
|
type LabelFilterMenuProps = {
|
||||||
|
value: string[];
|
||||||
|
onChange: (labelIds: string[]) => void;
|
||||||
|
children: ReactNode;
|
||||||
|
width?: number;
|
||||||
|
position?:
|
||||||
|
| "bottom-start"
|
||||||
|
| "bottom-end"
|
||||||
|
| "bottom"
|
||||||
|
| "top-start"
|
||||||
|
| "top-end"
|
||||||
|
| "top";
|
||||||
|
zIndex?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LabelFilterMenu({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
children,
|
||||||
|
width = 280,
|
||||||
|
position = "bottom-end",
|
||||||
|
zIndex,
|
||||||
|
}: LabelFilterMenuProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const scheme = useComputedColorScheme("light");
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [debouncedQuery] = useDebouncedValue(searchQuery, 300);
|
||||||
|
|
||||||
|
const { data, isLoading } = useWorkspaceLabelsQuery(debouncedQuery, true);
|
||||||
|
const labels = data?.items ?? [];
|
||||||
|
|
||||||
|
const selectedSet = useMemo(() => new Set(value), [value]);
|
||||||
|
|
||||||
|
const toggleLabel = (labelId: string) => {
|
||||||
|
if (selectedSet.has(labelId)) {
|
||||||
|
onChange(value.filter((id) => id !== labelId));
|
||||||
|
} else {
|
||||||
|
onChange([...value, labelId]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu
|
||||||
|
shadow="md"
|
||||||
|
width={width}
|
||||||
|
position={position}
|
||||||
|
zIndex={zIndex}
|
||||||
|
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}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,16 +13,20 @@ import {
|
|||||||
IconBuilding,
|
IconBuilding,
|
||||||
IconFileDescription,
|
IconFileDescription,
|
||||||
IconCheck,
|
IconCheck,
|
||||||
|
IconUser,
|
||||||
|
IconTag,
|
||||||
} 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 +44,14 @@ 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 [workspace] = useAtom(workspaceAtom);
|
const [workspace] = useAtom(workspaceAtom);
|
||||||
|
|
||||||
const { data: spacesData } = useGetSpacesQuery({ limit: 100 });
|
const { data: spacesData } = useGetSpacesQuery({ limit: 100 });
|
||||||
@@ -50,15 +59,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,37 +68,39 @@ export function SearchSpotlightFilters({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onFiltersChange?.({
|
||||||
|
spaceId: selectedSpaceId,
|
||||||
|
contentType,
|
||||||
|
creatorId: selectedCreatorId,
|
||||||
|
labelIds: selectedLabelIds,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
selectedSpaceId,
|
||||||
|
contentType,
|
||||||
|
selectedCreatorId,
|
||||||
|
selectedLabelIds,
|
||||||
|
onFiltersChange,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleSpaceSelect = (spaceId: string | null) => {
|
const handleSpaceSelect = (spaceId: string | null) => {
|
||||||
setSelectedSpaceId(spaceId);
|
setSelectedSpaceId(spaceId);
|
||||||
|
|
||||||
if (onFiltersChange) {
|
|
||||||
onFiltersChange({
|
|
||||||
spaceId: spaceId,
|
|
||||||
contentType,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFilterChange = (filterType: string, value: any) => {
|
const handleCreatorSelect = (user: { id: string; name: string } | null) => {
|
||||||
let newSelectedSpaceId = selectedSpaceId;
|
setSelectedCreatorId(user?.id ?? null);
|
||||||
let newContentType = contentType;
|
setSelectedCreatorName(user?.name ?? null);
|
||||||
|
};
|
||||||
|
|
||||||
switch (filterType) {
|
const handleLabelsSelect = (labelIds: string[]) => {
|
||||||
case "spaceId":
|
setSelectedLabelIds(labelIds);
|
||||||
newSelectedSpaceId = value;
|
};
|
||||||
setSelectedSpaceId(value);
|
|
||||||
break;
|
|
||||||
case "contentType":
|
|
||||||
newContentType = value;
|
|
||||||
setContentType(value);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onFiltersChange) {
|
const handleChangeContentType = (value: string) => {
|
||||||
onFiltersChange({
|
setContentType(value);
|
||||||
spaceId: newSelectedSpaceId,
|
|
||||||
contentType: newContentType,
|
if (value === "attachment") {
|
||||||
});
|
setSelectedLabelIds([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -181,7 +183,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 +197,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 +209,52 @@ export function SearchSpotlightFilters({
|
|||||||
))}
|
))}
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
|
<CreatorFilterMenu
|
||||||
|
value={selectedCreatorId}
|
||||||
|
onChange={handleCreatorSelect}
|
||||||
|
position="bottom-start"
|
||||||
|
width={250}
|
||||||
|
zIndex={getDefaultZIndex("max")}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{contentType !== "attachment" && (
|
||||||
|
<LabelFilterMenu
|
||||||
|
value={selectedLabelIds}
|
||||||
|
onChange={handleLabelsSelect}
|
||||||
|
position="bottom-start"
|
||||||
|
width={250}
|
||||||
|
zIndex={getDefaultZIndex("max")}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
</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 } 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";
|
||||||
@@ -26,6 +26,8 @@ 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[];
|
||||||
}>({
|
}>({
|
||||||
contentType: "page",
|
contentType: "page",
|
||||||
});
|
});
|
||||||
@@ -43,6 +45,14 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
return params;
|
return params;
|
||||||
}, [debouncedSearchQuery, filters]);
|
}, [debouncedSearchQuery, filters]);
|
||||||
|
|
||||||
@@ -96,9 +106,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);
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export interface IPageSearchParams {
|
|||||||
query: string;
|
query: string;
|
||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
shareId?: string;
|
shareId?: string;
|
||||||
|
creatorId?: string;
|
||||||
|
labelIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IAttachmentSearch {
|
export interface IAttachmentSearch {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
@@ -24,6 +25,11 @@ export class SearchDTO {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
creatorId?: string;
|
creatorId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsUUID('all', { each: true })
|
||||||
|
labelIds?: string[];
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export class SearchService {
|
|||||||
return { items: [] };
|
return { items: [] };
|
||||||
}
|
}
|
||||||
const searchQuery = tsquery(query.trim() + '*');
|
const searchQuery = tsquery(query.trim() + '*');
|
||||||
|
const labelIds = [...new Set(searchParams.labelIds ?? [])];
|
||||||
|
|
||||||
let queryResults = this.db
|
let queryResults = this.db
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
@@ -62,6 +63,22 @@ export class SearchService {
|
|||||||
.$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)
|
||||||
|
.groupBy('pageId')
|
||||||
|
.having(
|
||||||
|
sql<number>`count(distinct "label_id")`,
|
||||||
|
'=',
|
||||||
|
labelIds.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
.where('deletedAt', 'is', null)
|
.where('deletedAt', 'is', null)
|
||||||
.orderBy('rank', 'desc')
|
.orderBy('rank', 'desc')
|
||||||
.limit(searchParams.limit || 25)
|
.limit(searchParams.limit || 25)
|
||||||
|
|||||||
Reference in New Issue
Block a user