En el filtro de la búsqueda del catálogo, poner filtros complejos compuestos por AND
This commit is contained in:
parent
1b4e496860
commit
560fe06a08
@ -3,7 +3,6 @@ import { Card, CardContent } from "@/ui";
|
||||
import { DataTableSkeleton, ErrorOverlay, SimpleEmptyState } from "@/components";
|
||||
|
||||
import { DataTable } from "@/components";
|
||||
import { DataTableToolbar } from "@/components/DataTable/DataTableToolbar";
|
||||
import { useDataTable, useDataTableContext } from "@/lib/hooks";
|
||||
import { IListArticles_Response_DTO, MoneyValue } from "@shared/contexts";
|
||||
import { ColumnDef, Row } from "@tanstack/react-table";
|
||||
@ -11,6 +10,7 @@ import { t } from "i18next";
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useCatalogList } from "../hooks";
|
||||
import { CatalogDataTableFilter } from "./CatalogDataTableFilter";
|
||||
|
||||
export const CatalogDataTable = () => {
|
||||
const navigate = useNavigate();
|
||||
@ -109,7 +109,7 @@ export const CatalogDataTable = () => {
|
||||
|
||||
return (
|
||||
<DataTable table={table} paginationOptions={{ visible: true }}>
|
||||
<DataTableToolbar table={table} />
|
||||
<CatalogDataTableFilter table={table} />
|
||||
</DataTable>
|
||||
);
|
||||
};
|
||||
|
||||
140
client/src/app/catalog/components/CatalogDataTableFilter.tsx
Normal file
140
client/src/app/catalog/components/CatalogDataTableFilter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -8,7 +8,7 @@ export type UseCatalogListParams = {
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
};
|
||||
searchTerm?: string;
|
||||
searchTerm?: string[];
|
||||
enabled?: boolean;
|
||||
queryOptions?: Record<string, unknown>;
|
||||
};
|
||||
@ -22,7 +22,7 @@ export const useCatalogList = (params: UseCatalogListParams): UseCatalogListResp
|
||||
const dataSource = useDataSource();
|
||||
const keys = useQueryKey();
|
||||
|
||||
const { pagination, searchTerm = undefined, enabled = true, queryOptions } = params;
|
||||
const { pagination, searchTerm = [], enabled = true, queryOptions } = params;
|
||||
|
||||
return useList({
|
||||
queryKey: keys().data().resource("catalog").action("list").params(params).get(),
|
||||
|
||||
@ -11,12 +11,10 @@ import { DataTableColumnOptions } from "./DataTableColumnOptions";
|
||||
interface DataTableToolbarProps<TData> extends React.HTMLAttributes<HTMLDivElement> {
|
||||
table: Table<TData>;
|
||||
filterFields?: DataTableFilterField<TData>[];
|
||||
fullWidthFilter?: boolean;
|
||||
}
|
||||
|
||||
export function DataTableToolbar<TData>({
|
||||
table,
|
||||
fullWidthFilter,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
|
||||
@ -28,27 +28,27 @@ export const createAxiosDataProvider = (
|
||||
getApiAuthorization: getApiAuthLib,
|
||||
|
||||
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 urlParams = new URLSearchParams();
|
||||
|
||||
const queryPagination = extractPaginationParams(pagination);
|
||||
urlParams.append("page", String(queryPagination.page));
|
||||
urlParams.append("limit", String(queryPagination.limit));
|
||||
const { page, limit } = extractPaginationParams(pagination);
|
||||
urlParams.append("page", String(page));
|
||||
urlParams.append("limit", String(limit));
|
||||
|
||||
const generatedSort = extractSortParams(sort);
|
||||
if (generatedSort && generatedSort.length > 0) {
|
||||
if (generatedSort.length) {
|
||||
urlParams.append("$sort_by", generatedSort.join(","));
|
||||
}
|
||||
|
||||
const queryQuickSearch = quickSearchTerm || generateQuickSearch(filters);
|
||||
if (queryQuickSearch) {
|
||||
urlParams.append("q", queryQuickSearch);
|
||||
const queryQuickSearch = generateQuickSearch(quickSearchTerm, filters);
|
||||
if (queryQuickSearch.length) {
|
||||
urlParams.append("q", queryQuickSearch.join(","));
|
||||
}
|
||||
|
||||
const queryFilters = extractFilterParams(filters);
|
||||
if (queryFilters && queryFilters.length > 0) {
|
||||
if (queryFilters.length) {
|
||||
urlParams.append("$filters", queryFilters.join(","));
|
||||
}
|
||||
|
||||
@ -389,28 +389,20 @@ export const createAxiosDataProvider = (
|
||||
});
|
||||
|
||||
const extractSortParams = (sort: ISortItemDataProviderParam[] = []) =>
|
||||
sort.map((item) => `${item.order === "DESC" ? "-" : "+"}${item.field}`);
|
||||
sort.map(({ field, order }) => `${order === "DESC" ? "-" : "+"}${field}`);
|
||||
|
||||
const extractFilterParams = (filters?: IFilterItemDataProviderParam[]): string[] => {
|
||||
let queryFilters: string[] = [];
|
||||
if (filters) {
|
||||
queryFilters = filters
|
||||
.filter((item) => item.field !== "q")
|
||||
.map(({ field, operator, value }) => `${field}[${operator}]${value}`);
|
||||
}
|
||||
return queryFilters;
|
||||
};
|
||||
const extractFilterParams = (filters: IFilterItemDataProviderParam[] = []) =>
|
||||
filters
|
||||
.filter(({ field }) => field !== "q")
|
||||
.map(({ field, operator, value }) => `${field}[${operator}]${value}`);
|
||||
|
||||
const generateQuickSearch = (filters?: IFilterItemDataProviderParam[]): string | undefined => {
|
||||
let quickSearch: string | undefined = undefined;
|
||||
if (filters) {
|
||||
const qsArray = filters.filter((item) => item.field === "q");
|
||||
if (qsArray.length > 0) {
|
||||
quickSearch = qsArray[0].value;
|
||||
}
|
||||
}
|
||||
return quickSearch;
|
||||
};
|
||||
const generateQuickSearch = (
|
||||
quickSearchTerm: string[] = [],
|
||||
filters: IFilterItemDataProviderParam[] = []
|
||||
) =>
|
||||
filters.find(({ field }) => field === "q")?.value
|
||||
? [filters.find(({ field }) => field === "q")!.value]
|
||||
: quickSearchTerm;
|
||||
|
||||
const extractPaginationParams = (pagination?: IPaginationDataProviderParam) => {
|
||||
const { pageIndex = INITIAL_PAGE_INDEX, pageSize = INITIAL_PAGE_SIZE } = pagination || {};
|
||||
|
||||
@ -19,7 +19,7 @@ export interface IFilterItemDataProviderParam {
|
||||
|
||||
export interface IGetListDataProviderParams {
|
||||
resource: string;
|
||||
quickSearchTerm?: string;
|
||||
quickSearchTerm?: string[];
|
||||
pagination?: IPaginationDataProviderParam;
|
||||
sort?: ISortItemDataProviderParam[];
|
||||
filters?: IFilterItemDataProviderParam[];
|
||||
|
||||
@ -16,8 +16,8 @@ export interface IDataTableContextState {
|
||||
setPagination: (newPagination: PaginationState) => void;
|
||||
sorting: SortingState;
|
||||
setSorting: Dispatch<SetStateAction<SortingState>>;
|
||||
globalFilter?: string;
|
||||
setGlobalFilter: (newGlobalFilter: string) => void;
|
||||
globalFilter: string[];
|
||||
setGlobalFilter: Dispatch<SetStateAction<string[]>>;
|
||||
resetGlobalFilter: () => void;
|
||||
isFiltered: boolean;
|
||||
}
|
||||
@ -26,13 +26,13 @@ export const DataTableContext = createContext<IDataTableContextState | null>(nul
|
||||
|
||||
export const DataTableProvider = ({
|
||||
syncWithLocation = true,
|
||||
initialGlobalFilter = undefined,
|
||||
initialGlobalFilter = [],
|
||||
initialPageIndex,
|
||||
initialPageSize,
|
||||
children,
|
||||
}: PropsWithChildren<{
|
||||
syncWithLocation?: boolean;
|
||||
initialGlobalFilter?: string;
|
||||
initialGlobalFilter?: string[];
|
||||
initialPageIndex?: number;
|
||||
initialPageSize?: number;
|
||||
}>) => {
|
||||
@ -41,11 +41,11 @@ export const DataTableProvider = ({
|
||||
initialPageIndex,
|
||||
initialPageSize,
|
||||
});
|
||||
const [globalFilter, setGlobalFilter] = useState<string | undefined>(initialGlobalFilter);
|
||||
const [globalFilter, setGlobalFilter] = useState<string[]>(initialGlobalFilter || []);
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const isFiltered = useMemo(() => Boolean(globalFilter && globalFilter.length), [globalFilter]);
|
||||
const resetGlobalFilter = useCallback(() => setGlobalFilter(""), []);
|
||||
const resetGlobalFilter = useCallback(() => setGlobalFilter([]), []);
|
||||
|
||||
return (
|
||||
<DataTableContext.Provider
|
||||
|
||||
@ -49,7 +49,7 @@ export class ListArticlesController extends ExpressController {
|
||||
limit: Joi.number().optional(),
|
||||
$sort_by: Joi.string().optional(),
|
||||
$filters: Joi.string().optional(),
|
||||
q: Joi.string().optional(),
|
||||
q: Joi.string().optional().allow(null).allow(""),
|
||||
}).optional();
|
||||
|
||||
return RuleValidator.validate(schema, query);
|
||||
@ -72,7 +72,6 @@ export class ListArticlesController extends ExpressController {
|
||||
|
||||
try {
|
||||
const queryCriteria: IQueryCriteria = QueryCriteriaService.parse(queryParams);
|
||||
|
||||
const result: ListArticlesResult = await this.useCase.execute({
|
||||
queryCriteria,
|
||||
});
|
||||
|
||||
@ -81,17 +81,21 @@ export default (sequelize: Sequelize) => {
|
||||
},
|
||||
|
||||
scopes: {
|
||||
quickSearch(value) {
|
||||
quickSearch(values: string[]) {
|
||||
/**
|
||||
* {
|
||||
[Op.or]: [
|
||||
{ description: { [Op.like]: '%apple%' } },
|
||||
{ description: { [Op.like]: '%banana%' } }
|
||||
]
|
||||
}
|
||||
*/
|
||||
|
||||
return {
|
||||
where: {
|
||||
[Op.or]: {
|
||||
reference: {
|
||||
[Op.like]: `%${value}%`,
|
||||
},
|
||||
description: {
|
||||
[Op.like]: `%${value}%`,
|
||||
},
|
||||
},
|
||||
[Op.and]: values
|
||||
.map((value) => [{ description: { [Op.like]: `%${value}%` } }])
|
||||
.flat(),
|
||||
},
|
||||
order: [["description", "ASC"]],
|
||||
};
|
||||
|
||||
@ -15,13 +15,11 @@ export interface IQueryCriteriaServiceProps {
|
||||
sort_by: string;
|
||||
fields: string;
|
||||
filters: string;
|
||||
q: string;
|
||||
q: string; // <- si viene separado por comas, es un array
|
||||
}
|
||||
|
||||
export class QueryCriteriaService extends ApplicationService {
|
||||
public static parse(
|
||||
params: Partial<IQueryCriteriaServiceProps>,
|
||||
): IQueryCriteria {
|
||||
public static parse(params: Partial<IQueryCriteriaServiceProps>): IQueryCriteria {
|
||||
const {
|
||||
page = undefined,
|
||||
limit = undefined,
|
||||
@ -42,8 +40,7 @@ export class QueryCriteriaService extends ApplicationService {
|
||||
|
||||
const _order: OrderCriteria = QueryCriteriaService.parseOrder(sort_by);
|
||||
|
||||
const _quickSearch: QuickSearchCriteria =
|
||||
QueryCriteriaService.parseQuickSearch(q);
|
||||
const _quickSearch: QuickSearchCriteria = QueryCriteriaService.parseQuickSearch(q);
|
||||
|
||||
return QueryCriteria.create({
|
||||
pagination: _pagination,
|
||||
@ -89,7 +86,7 @@ export class QueryCriteriaService extends ApplicationService {
|
||||
}
|
||||
|
||||
protected static parseQuickSearch(quickSearch?: string): QuickSearchCriteria {
|
||||
const quickSearchOrError = QuickSearchCriteria.create(quickSearch);
|
||||
const quickSearchOrError = QuickSearchCriteria.create(quickSearch?.split(","));
|
||||
if (quickSearchOrError.isFailure) {
|
||||
throw quickSearchOrError.error;
|
||||
}
|
||||
|
||||
@ -87,6 +87,8 @@ export abstract class SequelizeRepository<T> implements IRepository<T> {
|
||||
...params,
|
||||
};
|
||||
|
||||
console.log(args.where);
|
||||
|
||||
const result = _model.findAndCountAll(args);
|
||||
|
||||
console.timeEnd("_findAll");
|
||||
|
||||
@ -47,7 +47,7 @@ export class SequelizeQueryBuilder implements ISequelizeQueryBuilder {
|
||||
if (!quickSearchCriteria.isEmpty()) {
|
||||
if (_model && _model.options.scopes && _model.options.scopes["quickSearch"]) {
|
||||
_model = _model.scope({
|
||||
method: ["quickSearch", quickSearchCriteria.value],
|
||||
method: ["quickSearch", quickSearchCriteria.searchTerms],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,62 +2,72 @@ import Joi from "joi";
|
||||
import { UndefinedOr } from "../../../../../../utilities";
|
||||
import { RuleValidator } from "../../../RuleValidator";
|
||||
import { Result } from "../../Result";
|
||||
import { StringValueObject } from "../../StringValueObject";
|
||||
import { ValueObject } from "../../ValueObject";
|
||||
|
||||
export interface IQuickSearchCriteria {
|
||||
searchTerm: string;
|
||||
toJSON(): string;
|
||||
searchTerms: string[];
|
||||
isEmpty(): boolean;
|
||||
toStringArray(): string[];
|
||||
toString(): string;
|
||||
toJSON(): string;
|
||||
toPrimitive(): string;
|
||||
}
|
||||
|
||||
export class QuickSearchCriteria
|
||||
extends StringValueObject
|
||||
extends ValueObject<UndefinedOr<string[]>>
|
||||
implements IQuickSearchCriteria
|
||||
{
|
||||
protected static validate(value: UndefinedOr<string>) {
|
||||
const searchString = value;
|
||||
protected static validate(value: UndefinedOr<string[]>) {
|
||||
const searchStringArray = value;
|
||||
const schema = Joi.array().items(Joi.string().trim().allow("")).default([]);
|
||||
|
||||
if (
|
||||
RuleValidator.validate(
|
||||
RuleValidator.RULE_NOT_NULL_OR_UNDEFINED,
|
||||
searchString,
|
||||
).isSuccess
|
||||
) {
|
||||
const stringOrError = RuleValidator.validate<string>(
|
||||
RuleValidator.RULE_IS_TYPE_STRING,
|
||||
searchString,
|
||||
);
|
||||
const stringArrayOrError = RuleValidator.validate<string[]>(schema, searchStringArray);
|
||||
|
||||
if (stringOrError.isFailure) {
|
||||
return stringOrError;
|
||||
}
|
||||
if (stringArrayOrError.isFailure) {
|
||||
return stringArrayOrError;
|
||||
}
|
||||
|
||||
return Result.ok(searchString);
|
||||
return Result.ok(searchStringArray);
|
||||
}
|
||||
|
||||
public static create(value: UndefinedOr<string>) {
|
||||
const stringOrError = this.validate(value);
|
||||
public static create(value: UndefinedOr<string[]>) {
|
||||
const stringArrayOrError = this.validate(value);
|
||||
|
||||
if (stringOrError.isFailure) {
|
||||
return Result.fail(stringOrError.error);
|
||||
if (stringArrayOrError.isFailure) {
|
||||
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 {
|
||||
return String(Joi.string().default("").trim().validate(searchTerm).value);
|
||||
private static sanitize(terms: string[] | undefined): string[] {
|
||||
return terms ? terms.map((term) => term.trim()).filter((term) => term.length > 0) : [];
|
||||
}
|
||||
|
||||
get searchTerm(): string {
|
||||
return this.toString();
|
||||
get value(): UndefinedOr<string[]> {
|
||||
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 {
|
||||
return JSON.stringify(this.toString());
|
||||
return JSON.stringify(this.toStringArray());
|
||||
}
|
||||
|
||||
public toPrimitive(): string {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user