mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
Merge pull request #113 from ktdd/search-improvements-v1
Search improvements
This commit is contained in:
@@ -57,7 +57,7 @@ use tantivy::{
|
|||||||
AggregationCollector, Key,
|
AggregationCollector, Key,
|
||||||
},
|
},
|
||||||
collector::{Count, FacetCollector, TopDocs},
|
collector::{Count, FacetCollector, TopDocs},
|
||||||
query::{AllQuery, BooleanQuery, EmptyQuery, Occur, Query, QueryParser, RangeQuery, TermQuery},
|
query::{AllQuery, BooleanQuery, EmptyQuery, Occur, Query, QueryParser, RangeQuery, RegexQuery, TermQuery},
|
||||||
schema::{Facet, IndexRecordOption, Value},
|
schema::{Facet, IndexRecordOption, Value},
|
||||||
store::{Compressor, ZstdCompressor},
|
store::{Compressor, ZstdCompressor},
|
||||||
DocAddress, Index, IndexBuilder, IndexReader, IndexSettings, IndexWriter, Order,
|
DocAddress, Index, IndexBuilder, IndexReader, IndexSettings, IndexWriter, Order,
|
||||||
@@ -306,11 +306,12 @@ impl EnvelopeIndexManager {
|
|||||||
(f.f_bcc, &filter.bcc),
|
(f.f_bcc, &filter.bcc),
|
||||||
] {
|
] {
|
||||||
if let Some(ref v) = opt_value {
|
if let Some(ref v) = opt_value {
|
||||||
let term = Term::from_field_text(field, v);
|
if let Ok(query) = RegexQuery::from_pattern(v.as_str(), field) {
|
||||||
subqueries.push((
|
subqueries.push((
|
||||||
Occur::Must,
|
Occur::Must,
|
||||||
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
|
Box::new(query),
|
||||||
));
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,11 +328,12 @@ impl EnvelopeIndexManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref name) = filter.attachment_name {
|
if let Some(ref name) = filter.attachment_name {
|
||||||
let term = Term::from_field_text(f.f_attachments, name);
|
if let Ok(query) = RegexQuery::from_pattern(name.as_str(), f.f_attachments) {
|
||||||
subqueries.push((
|
subqueries.push((
|
||||||
Occur::Must,
|
Occur::Must,
|
||||||
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
|
Box::new(query),
|
||||||
));
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let start_bound = if let Some(from) = filter.since {
|
let start_bound = if let Some(from) = filter.since {
|
||||||
@@ -351,20 +353,28 @@ impl EnvelopeIndexManager {
|
|||||||
subqueries.push((Occur::Must, Box::new(q)));
|
subqueries.push((Occur::Must, Box::new(q)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(account_id) = filter.account_id {
|
if let Some(account_ids) = filter.account_ids {
|
||||||
let term = Term::from_field_u64(f.f_account_id, account_id);
|
let mut should_queries: Vec<(Occur, Box<dyn Query>)> = Vec::new();
|
||||||
subqueries.push((
|
for id in account_ids {
|
||||||
Occur::Must,
|
let term = Term::from_field_u64(f.f_account_id, id);
|
||||||
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
|
should_queries.push((
|
||||||
));
|
Occur::Should,
|
||||||
|
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
subqueries.push((Occur::Must, Box::new(BooleanQuery::new(should_queries))));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(mailbox_id) = filter.mailbox_id {
|
if let Some(mailbox_ids) = filter.mailbox_ids {
|
||||||
let term = Term::from_field_u64(f.f_mailbox_id, mailbox_id);
|
let mut should_queries: Vec<(Occur, Box<dyn Query>)> = Vec::new();
|
||||||
subqueries.push((
|
for id in mailbox_ids {
|
||||||
Occur::Must,
|
let term = Term::from_field_u64(f.f_mailbox_id, id);
|
||||||
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
|
should_queries.push((
|
||||||
));
|
Occur::Should,
|
||||||
|
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
subqueries.push((Occur::Must, Box::new(BooleanQuery::new(should_queries))));
|
||||||
}
|
}
|
||||||
|
|
||||||
let start_bound = if let Some(from) = filter.min_size {
|
let start_bound = if let Some(from) = filter.min_size {
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ pub struct SearchFilter {
|
|||||||
pub bcc: Option<String>,
|
pub bcc: Option<String>,
|
||||||
pub since: Option<i64>,
|
pub since: Option<i64>,
|
||||||
pub before: Option<i64>,
|
pub before: Option<i64>,
|
||||||
pub account_id: Option<u64>,
|
pub account_ids: Option<Vec<u64>>,
|
||||||
pub mailbox_id: Option<u64>,
|
pub mailbox_ids: Option<Vec<u64>>,
|
||||||
pub min_size: Option<u64>,
|
pub min_size: Option<u64>,
|
||||||
pub max_size: Option<u64>,
|
pub max_size: Option<u64>,
|
||||||
pub message_id: Option<String>,
|
pub message_id: Option<String>,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import axiosInstance from "@/api/axiosInstance";
|
|||||||
|
|
||||||
|
|
||||||
export interface MailboxData {
|
export interface MailboxData {
|
||||||
|
account_id: number;
|
||||||
attributes: { attr: string; extension: string | null }[];
|
attributes: { attr: string; extension: string | null }[];
|
||||||
delimiter: string | null;
|
delimiter: string | null;
|
||||||
exists: number;
|
exists: number;
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ export function VirtualizedSelect({
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
if (selectedLabels.length === 0) return placeholder;
|
if (selectedLabels.length === 0) return placeholder;
|
||||||
return `${selectedLabels[0]} +${selectedLabels.length - 1} more`;
|
return selectedLabels.join(", ");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -287,7 +287,7 @@ export function VirtualizedSelect({
|
|||||||
className={cn('justify-between', className)}
|
className={cn('justify-between', className)}
|
||||||
disabled={isLoading || disabled}
|
disabled={isLoading || disabled}
|
||||||
>
|
>
|
||||||
{getDisplayText()}
|
<span className='truncate'>{getDisplayText()}</span>
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|||||||
@@ -32,46 +32,42 @@ import { VirtualizedSelect } from "@/components/virtualized-select";
|
|||||||
import useMinimalAccountList from "@/hooks/use-minimal-account-list";
|
import useMinimalAccountList from "@/hooks/use-minimal-account-list";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { list_mailboxes, MailboxData } from "@/api/mailbox/api";
|
import { list_mailboxes, MailboxData } from "@/api/mailbox/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQueries } from "@tanstack/react-query";
|
||||||
import { useSearchContext } from "./context";
|
import { useSearchContext } from "./context";
|
||||||
import { toast } from "@/hooks/use-toast";
|
import { toast } from "@/hooks/use-toast";
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
||||||
const getSearchFilterSchema = (t: (key: string) => string) => z.object({
|
const searchFilterSchema = z.object({
|
||||||
text: z.string().optional().or(z.literal("")),
|
text: z.string().optional().or(z.literal("")),
|
||||||
from: z
|
from: z
|
||||||
.string()
|
.string()
|
||||||
.email({ message: t('validation.invalidEmail') })
|
|
||||||
.optional()
|
.optional()
|
||||||
.or(z.literal("")),
|
.or(z.literal("")),
|
||||||
to: z
|
to: z
|
||||||
.string()
|
.string()
|
||||||
.email({ message: t('validation.invalidEmail') })
|
|
||||||
.optional()
|
.optional()
|
||||||
.or(z.literal("")),
|
.or(z.literal("")),
|
||||||
cc: z
|
cc: z
|
||||||
.string()
|
.string()
|
||||||
.email({ message: t('validation.invalidEmail') })
|
|
||||||
.optional()
|
.optional()
|
||||||
.or(z.literal("")),
|
.or(z.literal("")),
|
||||||
bcc: z
|
bcc: z
|
||||||
.string()
|
.string()
|
||||||
.email({ message: t('validation.invalidEmail') })
|
|
||||||
.optional()
|
.optional()
|
||||||
.or(z.literal("")),
|
.or(z.literal("")),
|
||||||
has_attachment: z.boolean().optional(),
|
has_attachment: z.boolean().optional(),
|
||||||
attachment_name: z.string().optional().or(z.literal("")),
|
attachment_name: z.string().optional().or(z.literal("")),
|
||||||
since: z.date().optional(),
|
since: z.date().optional(),
|
||||||
before: z.date().optional(),
|
before: z.date().optional(),
|
||||||
account_id: z.number().optional().or(z.literal("")),
|
account_ids: z.array(z.number()),
|
||||||
mailbox_id: z.number().optional().or(z.literal("")),
|
mailbox_ids: z.array(z.number()),
|
||||||
size_preset: z.enum(['any', 'tiny', 'small', 'medium', 'large', 'huge']).optional(),
|
size_preset: z.enum(['any', 'tiny', 'small', 'medium', 'large', 'huge']).optional(),
|
||||||
message_id: z.string().optional().or(z.literal("")),
|
message_id: z.string().optional().or(z.literal("")),
|
||||||
});
|
});
|
||||||
|
|
||||||
type SearchFilterForm = z.infer<ReturnType<typeof getSearchFilterSchema>>;
|
type SearchFilterForm = z.infer<typeof searchFilterSchema>;
|
||||||
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -89,6 +85,7 @@ const isEmptyValue = (value: any): boolean => {
|
|||||||
if (typeof value === 'number' && isNaN(value)) return true;
|
if (typeof value === 'number' && isNaN(value)) return true;
|
||||||
if (value === false) return true;
|
if (value === false) return true;
|
||||||
if (value === 0) return true;
|
if (value === 0) return true;
|
||||||
|
if (Array.isArray(value) && value.length === 0) return true;
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -120,11 +117,10 @@ function withSizePreset(values: Record<string, any>) {
|
|||||||
export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChange }: Props) {
|
export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChange }: Props) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
const [selectedAccountId, setSelectedAccountId] = useState<number | undefined>(undefined);
|
const [selectedAccountIds, setSelectedAccountIds] = useState<number[]>([]);
|
||||||
const { accountsOptions, isLoading: accountsIsLoading } = useMinimalAccountList();
|
const { accountsOptions, isLoading: accountsIsLoading } = useMinimalAccountList();
|
||||||
const { selectedTags } = useSearchContext();
|
const { selectedTags } = useSearchContext();
|
||||||
|
|
||||||
const searchFilterSchema = getSearchFilterSchema(t)
|
|
||||||
const form = useForm<SearchFilterForm>({
|
const form = useForm<SearchFilterForm>({
|
||||||
resolver: zodResolver(searchFilterSchema),
|
resolver: zodResolver(searchFilterSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -139,23 +135,29 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
|
|||||||
has_attachment: false,
|
has_attachment: false,
|
||||||
since: undefined,
|
since: undefined,
|
||||||
before: undefined,
|
before: undefined,
|
||||||
account_id: undefined,
|
account_ids: [],
|
||||||
mailbox_id: undefined,
|
mailbox_ids: [],
|
||||||
},
|
},
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: mailboxes, isLoading: isMailboxesLoading } = useQuery({
|
|
||||||
queryKey: ['search-account-mailboxes', `${selectedAccountId}`],
|
const { mailboxes, isMailboxesLoading } = useQueries({
|
||||||
queryFn: () => list_mailboxes(selectedAccountId!, false),
|
queries: selectedAccountIds.map((id) => ({
|
||||||
enabled: !!selectedAccountId,
|
queryKey: ['search-account-mailboxes', id],
|
||||||
|
queryFn: () => list_mailboxes(id!, false),
|
||||||
|
})),
|
||||||
|
combine: (results) => ({
|
||||||
|
mailboxes: results.flatMap((result) => result.data?.sort((a, b) => a.name.localeCompare(b.name))),
|
||||||
|
isMailboxesLoading: results.some((result) => result.isLoading),
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const mailboxesOptions = mailboxes?.filter((item) => !!item).map((mailbox: MailboxData) => ({
|
||||||
const mailboxesOptions = mailboxes?.map((mailbox: MailboxData) => ({
|
|
||||||
value: mailbox.id.toString(),
|
value: mailbox.id.toString(),
|
||||||
label: mailbox.name,
|
label: mailbox.name,
|
||||||
|
description: accountsOptions.find((item) => Number(item.value) === mailbox.account_id)!.label
|
||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
|
|
||||||
@@ -163,7 +165,6 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
|
|||||||
let cleaned = cleanEmpty(values);
|
let cleaned = cleanEmpty(values);
|
||||||
const payload = withSizePreset(cleaned);
|
const payload = withSizePreset(cleaned);
|
||||||
|
|
||||||
|
|
||||||
const finalPayload =
|
const finalPayload =
|
||||||
selectedTags.length > 0
|
selectedTags.length > 0
|
||||||
? { ...payload, tags: selectedTags }
|
? { ...payload, tags: selectedTags }
|
||||||
@@ -189,12 +190,12 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
|
|||||||
attachment_name: "",
|
attachment_name: "",
|
||||||
since: undefined,
|
since: undefined,
|
||||||
before: undefined,
|
before: undefined,
|
||||||
account_id: undefined,
|
account_ids: [],
|
||||||
mailbox_id: undefined,
|
mailbox_ids: [],
|
||||||
size_preset: 'any',
|
size_preset: 'any',
|
||||||
message_id: "",
|
message_id: "",
|
||||||
});
|
});
|
||||||
setSelectedAccountId(undefined);
|
setSelectedAccountIds([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (<Sheet
|
return (<Sheet
|
||||||
@@ -218,21 +219,21 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
|
|||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="account_id"
|
name="account_ids"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="min-w-[180px]">
|
<FormItem className="min-w-[180px]">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<FormLabel className="text-xs whitespace-nowrap">{t('search.account')}:</FormLabel>
|
<FormLabel className="text-xs whitespace-nowrap">{t('search.account')}</FormLabel>
|
||||||
<FormControl className="flex-1">
|
<FormControl className="flex-1">
|
||||||
<VirtualizedSelect
|
<VirtualizedSelect
|
||||||
options={accountsOptions}
|
options={accountsOptions}
|
||||||
isLoading={accountsIsLoading}
|
isLoading={accountsIsLoading}
|
||||||
onSelectOption={(values) => {
|
onSelectOption={(values) => {
|
||||||
const account_id = parseInt(values[0], 10);
|
const ids = values.map((id) => parseInt(id, 10)).sort()
|
||||||
setSelectedAccountId(account_id);
|
setSelectedAccountIds(ids)
|
||||||
field.onChange(account_id);
|
field.onChange(ids)
|
||||||
}}
|
}}
|
||||||
value={field.value?.toString() ?? ""}
|
value={field.value.map(String)}
|
||||||
placeholder={t('search.selectAccount')}
|
placeholder={t('search.selectAccount')}
|
||||||
className="h-10 w-full"
|
className="h-10 w-full"
|
||||||
noItemsComponent={
|
noItemsComponent={
|
||||||
@@ -247,6 +248,7 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
multiple
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</div>
|
</div>
|
||||||
@@ -256,17 +258,19 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
|
|||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="mailbox_id"
|
name="mailbox_ids"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="min-w-[180px]">
|
<FormItem className="min-w-[180px]">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<FormLabel className="text-xs whitespace-nowrap">{t('search.mailbox')}:</FormLabel>
|
<FormLabel className="text-xs whitespace-nowrap">{t('search.mailbox')}</FormLabel>
|
||||||
<FormControl className="flex-1">
|
<FormControl className="flex-1">
|
||||||
<VirtualizedSelect
|
<VirtualizedSelect
|
||||||
options={mailboxesOptions}
|
options={mailboxesOptions}
|
||||||
isLoading={isMailboxesLoading}
|
isLoading={isMailboxesLoading}
|
||||||
onSelectOption={(values) => field.onChange(parseInt(values[0], 10))}
|
onSelectOption={(values) => {
|
||||||
value={field.value?.toString() ?? ""}
|
field.onChange(values.map((id) => parseInt(id, 10)))
|
||||||
|
}}
|
||||||
|
value={field.value.map(String)}
|
||||||
placeholder={t('search.selectMailbox')}
|
placeholder={t('search.selectMailbox')}
|
||||||
className="h-10 w-full"
|
className="h-10 w-full"
|
||||||
noItemsComponent={
|
noItemsComponent={
|
||||||
@@ -276,6 +280,7 @@ export function SearchFormDialog({ onSubmit, isLoading, reset, open, onOpenChang
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
multiple
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user