diff --git a/client/src/app/catalog/components/CatalogDataTable.tsx b/client/src/app/catalog/components/CatalogDataTable.tsx index ef83a07..774ad6d 100644 --- a/client/src/app/catalog/components/CatalogDataTable.tsx +++ b/client/src/app/catalog/components/CatalogDataTable.tsx @@ -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 ( - + ); }; diff --git a/client/src/app/catalog/components/CatalogDataTableFilter.tsx b/client/src/app/catalog/components/CatalogDataTableFilter.tsx new file mode 100644 index 0000000..a2c8d9d --- /dev/null +++ b/client/src/app/catalog/components/CatalogDataTableFilter.tsx @@ -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 extends React.HTMLAttributes { + table: Table; + filterFields?: DataTableFilterField[]; + fullWidthFilter?: boolean; +} + +export function CatalogDataTableFilter({ + table, + fullWidthFilter, + className, + children, + ...props +}: CatalogDataTableFilterProps) { + const { globalFilter, isFiltered, setGlobalFilter, resetGlobalFilter } = useDataTableContext(); + + const inputRef = React.useRef(null); + const [inputValue, setInputValue] = React.useState(""); + + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + 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 ( + +
+
+
+ {globalFilter && + globalFilter.map((filterTerm) => ( + + {filterTerm} + + + ))} +
+ 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' + /> + + {isFiltered && ( + + + + + +

Quitar todos los términos del filtro

+
+
+ )} + + + + + +

Añadir término al filtro (o pulsa Enter)

+
+
+
+
+
+
+ +

+ 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. +

+
+
+ ); +} diff --git a/client/src/app/catalog/hooks/useCatalogList.tsx b/client/src/app/catalog/hooks/useCatalogList.tsx index 7b98ab6..d4c8ed9 100644 --- a/client/src/app/catalog/hooks/useCatalogList.tsx +++ b/client/src/app/catalog/hooks/useCatalogList.tsx @@ -8,7 +8,7 @@ export type UseCatalogListParams = { pageIndex: number; pageSize: number; }; - searchTerm?: string; + searchTerm?: string[]; enabled?: boolean; queryOptions?: Record; }; @@ -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(), diff --git a/client/src/components/DataTable/DataTableToolbar/DataTableToolbar.tsx b/client/src/components/DataTable/DataTableToolbar/DataTableToolbar.tsx index 5a8d881..352ffec 100644 --- a/client/src/components/DataTable/DataTableToolbar/DataTableToolbar.tsx +++ b/client/src/components/DataTable/DataTableToolbar/DataTableToolbar.tsx @@ -11,12 +11,10 @@ import { DataTableColumnOptions } from "./DataTableColumnOptions"; interface DataTableToolbarProps extends React.HTMLAttributes { table: Table; filterFields?: DataTableFilterField[]; - fullWidthFilter?: boolean; } export function DataTableToolbar({ table, - fullWidthFilter, className, children, ...props diff --git a/client/src/lib/axios/createAxiosDataProvider.ts b/client/src/lib/axios/createAxiosDataProvider.ts index 8089b95..0cc1dbc 100644 --- a/client/src/lib/axios/createAxiosDataProvider.ts +++ b/client/src/lib/axios/createAxiosDataProvider.ts @@ -28,27 +28,27 @@ export const createAxiosDataProvider = ( getApiAuthorization: getApiAuthLib, getList: async (params: IGetListDataProviderParams): Promise> => { - 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 || {}; diff --git a/client/src/lib/hooks/useDataSource/DataSource.ts b/client/src/lib/hooks/useDataSource/DataSource.ts index 16052eb..4919644 100644 --- a/client/src/lib/hooks/useDataSource/DataSource.ts +++ b/client/src/lib/hooks/useDataSource/DataSource.ts @@ -19,7 +19,7 @@ export interface IFilterItemDataProviderParam { export interface IGetListDataProviderParams { resource: string; - quickSearchTerm?: string; + quickSearchTerm?: string[]; pagination?: IPaginationDataProviderParam; sort?: ISortItemDataProviderParam[]; filters?: IFilterItemDataProviderParam[]; diff --git a/client/src/lib/hooks/useDataTable/DataTableContext.tsx b/client/src/lib/hooks/useDataTable/DataTableContext.tsx index 8d04910..93febde 100644 --- a/client/src/lib/hooks/useDataTable/DataTableContext.tsx +++ b/client/src/lib/hooks/useDataTable/DataTableContext.tsx @@ -16,8 +16,8 @@ export interface IDataTableContextState { setPagination: (newPagination: PaginationState) => void; sorting: SortingState; setSorting: Dispatch>; - globalFilter?: string; - setGlobalFilter: (newGlobalFilter: string) => void; + globalFilter: string[]; + setGlobalFilter: Dispatch>; resetGlobalFilter: () => void; isFiltered: boolean; } @@ -26,13 +26,13 @@ export const DataTableContext = createContext(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(initialGlobalFilter); + const [globalFilter, setGlobalFilter] = useState(initialGlobalFilter || []); const [sorting, setSorting] = useState([]); const isFiltered = useMemo(() => Boolean(globalFilter && globalFilter.length), [globalFilter]); - const resetGlobalFilter = useCallback(() => setGlobalFilter(""), []); + const resetGlobalFilter = useCallback(() => setGlobalFilter([]), []); return ( { }, 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"]], }; diff --git a/server/src/contexts/common/application/services/QueryCriteriaService.ts b/server/src/contexts/common/application/services/QueryCriteriaService.ts index 7b8080d..0b4dfbd 100644 --- a/server/src/contexts/common/application/services/QueryCriteriaService.ts +++ b/server/src/contexts/common/application/services/QueryCriteriaService.ts @@ -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, - ): IQueryCriteria { + public static parse(params: Partial): 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; } diff --git a/server/src/contexts/common/infrastructure/sequelize/SequelizeRepository.ts b/server/src/contexts/common/infrastructure/sequelize/SequelizeRepository.ts index 55668e5..bae5dd5 100644 --- a/server/src/contexts/common/infrastructure/sequelize/SequelizeRepository.ts +++ b/server/src/contexts/common/infrastructure/sequelize/SequelizeRepository.ts @@ -87,6 +87,8 @@ export abstract class SequelizeRepository implements IRepository { ...params, }; + console.log(args.where); + const result = _model.findAndCountAll(args); console.timeEnd("_findAll"); diff --git a/server/src/contexts/common/infrastructure/sequelize/queryBuilder/SequelizeQueryBuilder.ts b/server/src/contexts/common/infrastructure/sequelize/queryBuilder/SequelizeQueryBuilder.ts index 6b0c133..b677d3f 100644 --- a/server/src/contexts/common/infrastructure/sequelize/queryBuilder/SequelizeQueryBuilder.ts +++ b/server/src/contexts/common/infrastructure/sequelize/queryBuilder/SequelizeQueryBuilder.ts @@ -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], }); } } diff --git a/shared/lib/contexts/common/domain/entities/QueryCriteria/QuickSearch/QuickSearchCriteria.ts b/shared/lib/contexts/common/domain/entities/QueryCriteria/QuickSearch/QuickSearchCriteria.ts index e4e3e48..caf1943 100644 --- a/shared/lib/contexts/common/domain/entities/QueryCriteria/QuickSearch/QuickSearchCriteria.ts +++ b/shared/lib/contexts/common/domain/entities/QueryCriteria/QuickSearch/QuickSearchCriteria.ts @@ -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> implements IQuickSearchCriteria { - protected static validate(value: UndefinedOr) { - const searchString = value; + protected static validate(value: UndefinedOr) { + 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( - RuleValidator.RULE_IS_TYPE_STRING, - searchString, - ); + const stringArrayOrError = RuleValidator.validate(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) { - const stringOrError = this.validate(value); + public static create(value: UndefinedOr) { + 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(new QuickSearchCriteria(_term)); + return Result.ok(new QuickSearchCriteria(sanitizedTerms)); } - private static sanitize(searchTerm: UndefinedOr): 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 { + 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 {