En el filtro de la búsqueda del catálogo, poner filtros complejos compuestos por AND

This commit is contained in:
David Arranz 2024-12-16 16:28:32 +01:00
parent 1b4e496860
commit 560fe06a08
13 changed files with 234 additions and 92 deletions

View File

@ -3,7 +3,6 @@ import { Card, CardContent } from "@/ui";
import { DataTableSkeleton, ErrorOverlay, SimpleEmptyState } from "@/components"; import { DataTableSkeleton, ErrorOverlay, SimpleEmptyState } from "@/components";
import { DataTable } from "@/components"; import { DataTable } from "@/components";
import { DataTableToolbar } from "@/components/DataTable/DataTableToolbar";
import { useDataTable, useDataTableContext } from "@/lib/hooks"; import { useDataTable, useDataTableContext } from "@/lib/hooks";
import { IListArticles_Response_DTO, MoneyValue } from "@shared/contexts"; import { IListArticles_Response_DTO, MoneyValue } from "@shared/contexts";
import { ColumnDef, Row } from "@tanstack/react-table"; import { ColumnDef, Row } from "@tanstack/react-table";
@ -11,6 +10,7 @@ import { t } from "i18next";
import { useMemo } from "react"; import { useMemo } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useCatalogList } from "../hooks"; import { useCatalogList } from "../hooks";
import { CatalogDataTableFilter } from "./CatalogDataTableFilter";
export const CatalogDataTable = () => { export const CatalogDataTable = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@ -109,7 +109,7 @@ export const CatalogDataTable = () => {
return ( return (
<DataTable table={table} paginationOptions={{ visible: true }}> <DataTable table={table} paginationOptions={{ visible: true }}>
<DataTableToolbar table={table} /> <CatalogDataTableFilter table={table} />
</DataTable> </DataTable>
); );
}; };

View File

@ -0,0 +1,140 @@
import { Table } from "@tanstack/react-table";
import { ButtonGroup } from "@/components";
import { DataTableFilterField, useDataTableContext } from "@/lib/hooks";
import { Badge, Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/ui";
import { t } from "i18next";
import { PlusIcon, XIcon } from "lucide-react";
import React from "react";
interface CatalogDataTableFilterProps<TData> extends React.HTMLAttributes<HTMLDivElement> {
table: Table<TData>;
filterFields?: DataTableFilterField<TData>[];
fullWidthFilter?: boolean;
}
export function CatalogDataTableFilter<TData>({
table,
fullWidthFilter,
className,
children,
...props
}: CatalogDataTableFilterProps<TData>) {
const { globalFilter, isFiltered, setGlobalFilter, resetGlobalFilter } = useDataTableContext();
const inputRef = React.useRef<HTMLInputElement>(null);
const [inputValue, setInputValue] = React.useState("");
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && inputValue.trim()) {
e.preventDefault();
setGlobalFilter((prev) => [...prev, inputValue.trim()]);
setInputValue("");
}
if (e.key === "Backspace" && !inputValue && globalFilter.length > 0) {
e.preventDefault();
setGlobalFilter((prev) => prev.slice(0, -1));
}
},
[globalFilter, inputValue]
);
const removeFilterTerm = React.useCallback((filterTerm: string) => {
setGlobalFilter((prev) => prev.filter((f) => f !== filterTerm));
}, []);
const addFilterTerm = React.useCallback(() => {
if (inputValue.trim()) {
setGlobalFilter((prev) => [...prev, inputValue.trim()]);
setInputValue("");
inputRef.current?.focus();
}
}, [inputValue]);
return (
<TooltipProvider>
<div className='space-y-2'>
<div className='relative p-2 border rounded-md border-input'>
<div className='flex flex-wrap items-center gap-2'>
{globalFilter &&
globalFilter.map((filterTerm) => (
<Badge
key={filterTerm}
variant='default'
className='px-1 text-base font-normal rounded-sm'
>
{filterTerm}
<Button
variant='ghost'
onClick={() => removeFilterTerm(filterTerm)}
className='h-auto p-0 px-1 ml-1 hover:bg-transparent'
>
<Tooltip>
<TooltipTrigger asChild>
<XIcon className='w-4 h-4' />
</TooltipTrigger>
<TooltipContent>
<p>Quitar este término del filtro</p>
</TooltipContent>
</Tooltip>
<span className='sr-only'>Eliminar filtro {filterTerm}</span>
</Button>
</Badge>
))}
<div className='flex-1 flex items-center min-w-[300px]'>
<input
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder='Escribe aquí para filtrar...'
className='flex-1 w-full h-8 bg-transparent outline-none placeholder:text-muted-foreground'
/>
<ButtonGroup>
{isFiltered && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='outline'
onClick={() => resetGlobalFilter()}
className='h-8 px-2 transition-all lg:px-3'
>
<XIcon className='w-4 h-4 mr-2' />
{t("common.reset_filter")}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Quitar todos los términos del filtro</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='outline'
size='icon'
onClick={addFilterTerm}
className='w-8 h-8 p-0 hover:bg-muted'
>
<PlusIcon className='w-4 h-4' />
<span className='sr-only'>Añadir término al filtro</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Añadir término al filtro (o pulsa Enter)</p>
</TooltipContent>
</Tooltip>
</ButtonGroup>
</div>
</div>
</div>
<p className='text-sm text-muted-foreground'>
Presiona Enter o haz clic en el botón + para añadir un término al filtro. Usa múltiples
términos para una búsqueda más amplia.
</p>
</div>
</TooltipProvider>
);
}

View File

@ -8,7 +8,7 @@ export type UseCatalogListParams = {
pageIndex: number; pageIndex: number;
pageSize: number; pageSize: number;
}; };
searchTerm?: string; searchTerm?: string[];
enabled?: boolean; enabled?: boolean;
queryOptions?: Record<string, unknown>; queryOptions?: Record<string, unknown>;
}; };
@ -22,7 +22,7 @@ export const useCatalogList = (params: UseCatalogListParams): UseCatalogListResp
const dataSource = useDataSource(); const dataSource = useDataSource();
const keys = useQueryKey(); const keys = useQueryKey();
const { pagination, searchTerm = undefined, enabled = true, queryOptions } = params; const { pagination, searchTerm = [], enabled = true, queryOptions } = params;
return useList({ return useList({
queryKey: keys().data().resource("catalog").action("list").params(params).get(), queryKey: keys().data().resource("catalog").action("list").params(params).get(),

View File

@ -11,12 +11,10 @@ import { DataTableColumnOptions } from "./DataTableColumnOptions";
interface DataTableToolbarProps<TData> extends React.HTMLAttributes<HTMLDivElement> { interface DataTableToolbarProps<TData> extends React.HTMLAttributes<HTMLDivElement> {
table: Table<TData>; table: Table<TData>;
filterFields?: DataTableFilterField<TData>[]; filterFields?: DataTableFilterField<TData>[];
fullWidthFilter?: boolean;
} }
export function DataTableToolbar<TData>({ export function DataTableToolbar<TData>({
table, table,
fullWidthFilter,
className, className,
children, children,
...props ...props

View File

@ -28,27 +28,27 @@ export const createAxiosDataProvider = (
getApiAuthorization: getApiAuthLib, getApiAuthorization: getApiAuthLib,
getList: async <R>(params: IGetListDataProviderParams): Promise<IListResponse_DTO<R>> => { getList: async <R>(params: IGetListDataProviderParams): Promise<IListResponse_DTO<R>> => {
const { resource, quickSearchTerm, pagination, filters, sort } = params; const { resource, quickSearchTerm, pagination, filters = [], sort = [] } = params;
const url = `${apiUrl}/${resource}`; const url = `${apiUrl}/${resource}`;
const urlParams = new URLSearchParams(); const urlParams = new URLSearchParams();
const queryPagination = extractPaginationParams(pagination); const { page, limit } = extractPaginationParams(pagination);
urlParams.append("page", String(queryPagination.page)); urlParams.append("page", String(page));
urlParams.append("limit", String(queryPagination.limit)); urlParams.append("limit", String(limit));
const generatedSort = extractSortParams(sort); const generatedSort = extractSortParams(sort);
if (generatedSort && generatedSort.length > 0) { if (generatedSort.length) {
urlParams.append("$sort_by", generatedSort.join(",")); urlParams.append("$sort_by", generatedSort.join(","));
} }
const queryQuickSearch = quickSearchTerm || generateQuickSearch(filters); const queryQuickSearch = generateQuickSearch(quickSearchTerm, filters);
if (queryQuickSearch) { if (queryQuickSearch.length) {
urlParams.append("q", queryQuickSearch); urlParams.append("q", queryQuickSearch.join(","));
} }
const queryFilters = extractFilterParams(filters); const queryFilters = extractFilterParams(filters);
if (queryFilters && queryFilters.length > 0) { if (queryFilters.length) {
urlParams.append("$filters", queryFilters.join(",")); urlParams.append("$filters", queryFilters.join(","));
} }
@ -389,28 +389,20 @@ export const createAxiosDataProvider = (
}); });
const extractSortParams = (sort: ISortItemDataProviderParam[] = []) => const extractSortParams = (sort: ISortItemDataProviderParam[] = []) =>
sort.map((item) => `${item.order === "DESC" ? "-" : "+"}${item.field}`); sort.map(({ field, order }) => `${order === "DESC" ? "-" : "+"}${field}`);
const extractFilterParams = (filters?: IFilterItemDataProviderParam[]): string[] => { const extractFilterParams = (filters: IFilterItemDataProviderParam[] = []) =>
let queryFilters: string[] = []; filters
if (filters) { .filter(({ field }) => field !== "q")
queryFilters = filters .map(({ field, operator, value }) => `${field}[${operator}]${value}`);
.filter((item) => item.field !== "q")
.map(({ field, operator, value }) => `${field}[${operator}]${value}`);
}
return queryFilters;
};
const generateQuickSearch = (filters?: IFilterItemDataProviderParam[]): string | undefined => { const generateQuickSearch = (
let quickSearch: string | undefined = undefined; quickSearchTerm: string[] = [],
if (filters) { filters: IFilterItemDataProviderParam[] = []
const qsArray = filters.filter((item) => item.field === "q"); ) =>
if (qsArray.length > 0) { filters.find(({ field }) => field === "q")?.value
quickSearch = qsArray[0].value; ? [filters.find(({ field }) => field === "q")!.value]
} : quickSearchTerm;
}
return quickSearch;
};
const extractPaginationParams = (pagination?: IPaginationDataProviderParam) => { const extractPaginationParams = (pagination?: IPaginationDataProviderParam) => {
const { pageIndex = INITIAL_PAGE_INDEX, pageSize = INITIAL_PAGE_SIZE } = pagination || {}; const { pageIndex = INITIAL_PAGE_INDEX, pageSize = INITIAL_PAGE_SIZE } = pagination || {};

View File

@ -19,7 +19,7 @@ export interface IFilterItemDataProviderParam {
export interface IGetListDataProviderParams { export interface IGetListDataProviderParams {
resource: string; resource: string;
quickSearchTerm?: string; quickSearchTerm?: string[];
pagination?: IPaginationDataProviderParam; pagination?: IPaginationDataProviderParam;
sort?: ISortItemDataProviderParam[]; sort?: ISortItemDataProviderParam[];
filters?: IFilterItemDataProviderParam[]; filters?: IFilterItemDataProviderParam[];

View File

@ -16,8 +16,8 @@ export interface IDataTableContextState {
setPagination: (newPagination: PaginationState) => void; setPagination: (newPagination: PaginationState) => void;
sorting: SortingState; sorting: SortingState;
setSorting: Dispatch<SetStateAction<SortingState>>; setSorting: Dispatch<SetStateAction<SortingState>>;
globalFilter?: string; globalFilter: string[];
setGlobalFilter: (newGlobalFilter: string) => void; setGlobalFilter: Dispatch<SetStateAction<string[]>>;
resetGlobalFilter: () => void; resetGlobalFilter: () => void;
isFiltered: boolean; isFiltered: boolean;
} }
@ -26,13 +26,13 @@ export const DataTableContext = createContext<IDataTableContextState | null>(nul
export const DataTableProvider = ({ export const DataTableProvider = ({
syncWithLocation = true, syncWithLocation = true,
initialGlobalFilter = undefined, initialGlobalFilter = [],
initialPageIndex, initialPageIndex,
initialPageSize, initialPageSize,
children, children,
}: PropsWithChildren<{ }: PropsWithChildren<{
syncWithLocation?: boolean; syncWithLocation?: boolean;
initialGlobalFilter?: string; initialGlobalFilter?: string[];
initialPageIndex?: number; initialPageIndex?: number;
initialPageSize?: number; initialPageSize?: number;
}>) => { }>) => {
@ -41,11 +41,11 @@ export const DataTableProvider = ({
initialPageIndex, initialPageIndex,
initialPageSize, initialPageSize,
}); });
const [globalFilter, setGlobalFilter] = useState<string | undefined>(initialGlobalFilter); const [globalFilter, setGlobalFilter] = useState<string[]>(initialGlobalFilter || []);
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const isFiltered = useMemo(() => Boolean(globalFilter && globalFilter.length), [globalFilter]); const isFiltered = useMemo(() => Boolean(globalFilter && globalFilter.length), [globalFilter]);
const resetGlobalFilter = useCallback(() => setGlobalFilter(""), []); const resetGlobalFilter = useCallback(() => setGlobalFilter([]), []);
return ( return (
<DataTableContext.Provider <DataTableContext.Provider

View File

@ -49,7 +49,7 @@ export class ListArticlesController extends ExpressController {
limit: Joi.number().optional(), limit: Joi.number().optional(),
$sort_by: Joi.string().optional(), $sort_by: Joi.string().optional(),
$filters: Joi.string().optional(), $filters: Joi.string().optional(),
q: Joi.string().optional(), q: Joi.string().optional().allow(null).allow(""),
}).optional(); }).optional();
return RuleValidator.validate(schema, query); return RuleValidator.validate(schema, query);
@ -72,7 +72,6 @@ export class ListArticlesController extends ExpressController {
try { try {
const queryCriteria: IQueryCriteria = QueryCriteriaService.parse(queryParams); const queryCriteria: IQueryCriteria = QueryCriteriaService.parse(queryParams);
const result: ListArticlesResult = await this.useCase.execute({ const result: ListArticlesResult = await this.useCase.execute({
queryCriteria, queryCriteria,
}); });

View File

@ -81,17 +81,21 @@ export default (sequelize: Sequelize) => {
}, },
scopes: { scopes: {
quickSearch(value) { quickSearch(values: string[]) {
/**
* {
[Op.or]: [
{ description: { [Op.like]: '%apple%' } },
{ description: { [Op.like]: '%banana%' } }
]
}
*/
return { return {
where: { where: {
[Op.or]: { [Op.and]: values
reference: { .map((value) => [{ description: { [Op.like]: `%${value}%` } }])
[Op.like]: `%${value}%`, .flat(),
},
description: {
[Op.like]: `%${value}%`,
},
},
}, },
order: [["description", "ASC"]], order: [["description", "ASC"]],
}; };

View File

@ -15,13 +15,11 @@ export interface IQueryCriteriaServiceProps {
sort_by: string; sort_by: string;
fields: string; fields: string;
filters: string; filters: string;
q: string; q: string; // <- si viene separado por comas, es un array
} }
export class QueryCriteriaService extends ApplicationService { export class QueryCriteriaService extends ApplicationService {
public static parse( public static parse(params: Partial<IQueryCriteriaServiceProps>): IQueryCriteria {
params: Partial<IQueryCriteriaServiceProps>,
): IQueryCriteria {
const { const {
page = undefined, page = undefined,
limit = undefined, limit = undefined,
@ -42,8 +40,7 @@ export class QueryCriteriaService extends ApplicationService {
const _order: OrderCriteria = QueryCriteriaService.parseOrder(sort_by); const _order: OrderCriteria = QueryCriteriaService.parseOrder(sort_by);
const _quickSearch: QuickSearchCriteria = const _quickSearch: QuickSearchCriteria = QueryCriteriaService.parseQuickSearch(q);
QueryCriteriaService.parseQuickSearch(q);
return QueryCriteria.create({ return QueryCriteria.create({
pagination: _pagination, pagination: _pagination,
@ -89,7 +86,7 @@ export class QueryCriteriaService extends ApplicationService {
} }
protected static parseQuickSearch(quickSearch?: string): QuickSearchCriteria { protected static parseQuickSearch(quickSearch?: string): QuickSearchCriteria {
const quickSearchOrError = QuickSearchCriteria.create(quickSearch); const quickSearchOrError = QuickSearchCriteria.create(quickSearch?.split(","));
if (quickSearchOrError.isFailure) { if (quickSearchOrError.isFailure) {
throw quickSearchOrError.error; throw quickSearchOrError.error;
} }

View File

@ -87,6 +87,8 @@ export abstract class SequelizeRepository<T> implements IRepository<T> {
...params, ...params,
}; };
console.log(args.where);
const result = _model.findAndCountAll(args); const result = _model.findAndCountAll(args);
console.timeEnd("_findAll"); console.timeEnd("_findAll");

View File

@ -47,7 +47,7 @@ export class SequelizeQueryBuilder implements ISequelizeQueryBuilder {
if (!quickSearchCriteria.isEmpty()) { if (!quickSearchCriteria.isEmpty()) {
if (_model && _model.options.scopes && _model.options.scopes["quickSearch"]) { if (_model && _model.options.scopes && _model.options.scopes["quickSearch"]) {
_model = _model.scope({ _model = _model.scope({
method: ["quickSearch", quickSearchCriteria.value], method: ["quickSearch", quickSearchCriteria.searchTerms],
}); });
} }
} }

View File

@ -2,62 +2,72 @@ import Joi from "joi";
import { UndefinedOr } from "../../../../../../utilities"; import { UndefinedOr } from "../../../../../../utilities";
import { RuleValidator } from "../../../RuleValidator"; import { RuleValidator } from "../../../RuleValidator";
import { Result } from "../../Result"; import { Result } from "../../Result";
import { StringValueObject } from "../../StringValueObject"; import { ValueObject } from "../../ValueObject";
export interface IQuickSearchCriteria { export interface IQuickSearchCriteria {
searchTerm: string; searchTerms: string[];
toJSON(): string; isEmpty(): boolean;
toStringArray(): string[];
toString(): string; toString(): string;
toJSON(): string;
toPrimitive(): string;
} }
export class QuickSearchCriteria export class QuickSearchCriteria
extends StringValueObject extends ValueObject<UndefinedOr<string[]>>
implements IQuickSearchCriteria implements IQuickSearchCriteria
{ {
protected static validate(value: UndefinedOr<string>) { protected static validate(value: UndefinedOr<string[]>) {
const searchString = value; const searchStringArray = value;
const schema = Joi.array().items(Joi.string().trim().allow("")).default([]);
if ( const stringArrayOrError = RuleValidator.validate<string[]>(schema, searchStringArray);
RuleValidator.validate(
RuleValidator.RULE_NOT_NULL_OR_UNDEFINED,
searchString,
).isSuccess
) {
const stringOrError = RuleValidator.validate<string>(
RuleValidator.RULE_IS_TYPE_STRING,
searchString,
);
if (stringOrError.isFailure) { if (stringArrayOrError.isFailure) {
return stringOrError; return stringArrayOrError;
}
} }
return Result.ok(searchString); return Result.ok(searchStringArray);
} }
public static create(value: UndefinedOr<string>) { public static create(value: UndefinedOr<string[]>) {
const stringOrError = this.validate(value); const stringArrayOrError = this.validate(value);
if (stringOrError.isFailure) { if (stringArrayOrError.isFailure) {
return Result.fail(stringOrError.error); return Result.fail(stringArrayOrError.error);
} }
const _term = QuickSearchCriteria.sanitize(stringOrError.object); const sanitizedTerms = QuickSearchCriteria.sanitize(stringArrayOrError.object);
return Result.ok<QuickSearchCriteria>(new QuickSearchCriteria(_term)); return Result.ok<QuickSearchCriteria>(new QuickSearchCriteria(sanitizedTerms));
} }
private static sanitize(searchTerm: UndefinedOr<string>): string { private static sanitize(terms: string[] | undefined): string[] {
return String(Joi.string().default("").trim().validate(searchTerm).value); return terms ? terms.map((term) => term.trim()).filter((term) => term.length > 0) : [];
} }
get searchTerm(): string { get value(): UndefinedOr<string[]> {
return this.toString(); return !this.isEmpty() ? this.props : undefined;
} }
get searchTerms(): string[] {
return this.toStringArray();
}
public isEmpty = (): boolean => {
return this.props ? this.props.length === 0 : true;
};
public toStringArray = (): string[] => {
return this.props ? [...this.props] : [];
};
public toString = (): string => {
return this.toStringArray().toString();
};
public toJSON(): string { public toJSON(): string {
return JSON.stringify(this.toString()); return JSON.stringify(this.toStringArray());
} }
public toPrimitive(): string { public toPrimitive(): string {