From 0008e7f9cd07a31179b8f8986fb0e4d750da736e Mon Sep 17 00:00:00 2001 From: "Agostnho F Teles Jr." Date: Sun, 15 Mar 2026 19:45:55 -0300 Subject: [PATCH 01/12] feat: implement sorting functionality in OrdersGrid with pagination and update related components --- components/OrderGrid/OrderGrid.styles.css | 12 +++ .../OrderGrid/OrderGridWithPagination.tsx | 31 ++++-- .../OrderGridWithPaginationClient.tsx | 5 +- .../OrderGrid/__tests__/OrderGrid.test.tsx | 19 ++++ .../OrderGridWithPaginationClient.test.tsx | 12 ++- .../__tests__/order-grid.navigation.test.ts | 42 ++++++++ .../__tests__/order-grid.query.test.ts | 46 +++++++++ components/OrderGrid/columns.ts | 7 ++ components/OrderGrid/index.tsx | 3 +- components/OrderGrid/order-grid.navigation.ts | 44 +++++++++ components/OrderGrid/order-grid.query.ts | 30 ++++++ components/OrderGrid/parts/Head.tsx | 53 +++++++--- components/OrderGrid/types.ts | 10 ++ lib/orders/__tests__/orders.service.test.ts | 25 +++++ lib/orders/orders.constants.ts | 5 + lib/orders/orders.service.ts | 21 +++- lib/orders/orders.sort.ts | 98 +++++++++++++++++++ lib/orders/orders.sort.types.ts | 12 +++ public/assets/add_box_60dp.svg | 1 + 19 files changed, 449 insertions(+), 27 deletions(-) create mode 100644 components/OrderGrid/__tests__/order-grid.navigation.test.ts create mode 100644 components/OrderGrid/__tests__/order-grid.query.test.ts create mode 100644 components/OrderGrid/order-grid.navigation.ts create mode 100644 components/OrderGrid/order-grid.query.ts create mode 100644 lib/orders/orders.constants.ts create mode 100644 lib/orders/orders.sort.ts create mode 100644 lib/orders/orders.sort.types.ts create mode 100644 public/assets/add_box_60dp.svg diff --git a/components/OrderGrid/OrderGrid.styles.css b/components/OrderGrid/OrderGrid.styles.css index c4cc17e..6acbbbe 100644 --- a/components/OrderGrid/OrderGrid.styles.css +++ b/components/OrderGrid/OrderGrid.styles.css @@ -35,6 +35,18 @@ @apply font-semibold bg-black text-white; } +.orders-grid__sort-link { + @apply inline-flex w-full items-center justify-between gap-2 text-white no-underline; +} + +.orders-grid__sort-link--active { + @apply font-semibold; +} + +.orders-grid__sort-indicator { + @apply text-grid-xm; +} + .orders-grid__td { @apply text-grid-xm; } diff --git a/components/OrderGrid/OrderGridWithPagination.tsx b/components/OrderGrid/OrderGridWithPagination.tsx index aadc0c7..def3050 100644 --- a/components/OrderGrid/OrderGridWithPagination.tsx +++ b/components/OrderGrid/OrderGridWithPagination.tsx @@ -1,9 +1,16 @@ import { getPaginatedOrdersForGrid } from '@/lib/orders/orders.service'; import OrderGridWithPaginationClient from './OrderGridWithPaginationClient'; +import { + buildOrderGridHref, + buildOrderGridSortLinks, +} from './order-grid.navigation'; +import { resolveOrderGridQuery } from './order-grid.query'; type OrderGridWithPaginationProps = { searchParams: Promise<{ page?: string; + sortBy?: string; + sortDir?: string; }>; pageSize?: number; }; @@ -13,23 +20,31 @@ const OrderGridWithPagination = async ({ pageSize = 15, }: OrderGridWithPaginationProps) => { const resolvedSearchParams = await searchParams; - const parsedPage = Number.parseInt(resolvedSearchParams.page ?? '1', 10); - const requestedPage = Number.isNaN(parsedPage) - ? 1 - : Math.max(1, parsedPage); + const { requestedPage, sortBy, sortDir } = + resolveOrderGridQuery(resolvedSearchParams); const { items, currentPage, totalPages, prevPage, nextPage } = - await getPaginatedOrdersForGrid(requestedPage, pageSize); + await getPaginatedOrdersForGrid(requestedPage, pageSize, sortBy, sortDir); + + const sortLinks = buildOrderGridSortLinks(sortBy, sortDir); const pagination = { currentPage, totalPages, - prevHref: prevPage ? `/?page=${prevPage}` : undefined, - nextHref: nextPage ? `/?page=${nextPage}` : undefined, + prevHref: prevPage + ? buildOrderGridHref(prevPage, sortBy, sortDir) + : undefined, + nextHref: nextPage + ? buildOrderGridHref(nextPage, sortBy, sortDir) + : undefined, }; return ( - + ); }; diff --git a/components/OrderGrid/OrderGridWithPaginationClient.tsx b/components/OrderGrid/OrderGridWithPaginationClient.tsx index e3820a7..f1015f9 100644 --- a/components/OrderGrid/OrderGridWithPaginationClient.tsx +++ b/components/OrderGrid/OrderGridWithPaginationClient.tsx @@ -4,7 +4,7 @@ import { useMemo, useState } from 'react'; import OrderDetailsModal from '@/components/OrderDetailsModal'; import type { Order } from '@/lib/orders/orders.types'; import OrdersGrid from './index'; -import type { OrdersGridPaginationData } from './types'; +import type { OrdersGridPaginationData, OrdersGridSortState } from './types'; import { OrdersGridPagination } from './parts/Pagination'; type SelectionState = { @@ -15,11 +15,13 @@ type SelectionState = { type OrderGridWithPaginationClientProps = { orders: Order[]; pagination: OrdersGridPaginationData; + sortState: OrdersGridSortState; }; const OrderGridWithPaginationClient = ({ orders, pagination, + sortState, }: OrderGridWithPaginationClientProps) => { const dataToken = useMemo(() => Symbol('orders-data-token'), [orders]); const [selection, setSelection] = useState(null); @@ -43,6 +45,7 @@ const OrderGridWithPaginationClient = ({ /> { setSelection({ orderId: order.id, dataToken }); }} diff --git a/components/OrderGrid/__tests__/OrderGrid.test.tsx b/components/OrderGrid/__tests__/OrderGrid.test.tsx index 8ffe3e8..0c21b72 100644 --- a/components/OrderGrid/__tests__/OrderGrid.test.tsx +++ b/components/OrderGrid/__tests__/OrderGrid.test.tsx @@ -76,4 +76,23 @@ describe('OrderGrid', () => { expect(OrdersGrid.Body).toBeDefined(); expect(OrdersGrid.defaultColumns).toBe(defaultColumns); }); + + it('renders sortable header links with active indicator', () => { + render( + , + ); + + const priceSortLink = screen.getByRole('link', { name: 'Ordenar por Preço' }); + expect(priceSortLink).toBeInTheDocument(); + expect(priceSortLink).toHaveTextContent('▼'); + }); }); diff --git a/components/OrderGrid/__tests__/OrderGridWithPaginationClient.test.tsx b/components/OrderGrid/__tests__/OrderGridWithPaginationClient.test.tsx index 514a5e3..c31952e 100644 --- a/components/OrderGrid/__tests__/OrderGridWithPaginationClient.test.tsx +++ b/components/OrderGrid/__tests__/OrderGridWithPaginationClient.test.tsx @@ -3,8 +3,15 @@ import { makeOrder } from '@/lib/orders/__tests__/fixtures/orders.fixture'; import OrderGridWithPaginationClient from '../OrderGridWithPaginationClient'; describe('OrderGridWithPaginationClient', () => { - it('does not keep modal open across remounts with different list keys', () => { + it('does not reopen modal when selected order becomes stale after list updates', () => { const pagination = { currentPage: 1, totalPages: 1 }; + const sortState = { + sortBy: 'timestamp' as const, + sortDir: 'desc' as const, + sortLinks: { + timestamp: '/?page=1&sortBy=timestamp&sortDir=asc', + }, + }; const selectedOrder = makeOrder({ id: '1', instrument: 'PETR4' }); const anotherOrder = makeOrder({ id: '2', instrument: 'VALE3' }); @@ -12,6 +19,7 @@ describe('OrderGridWithPaginationClient', () => { , ); @@ -22,6 +30,7 @@ describe('OrderGridWithPaginationClient', () => { , ); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); @@ -30,6 +39,7 @@ describe('OrderGridWithPaginationClient', () => { , ); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); diff --git a/components/OrderGrid/__tests__/order-grid.navigation.test.ts b/components/OrderGrid/__tests__/order-grid.navigation.test.ts new file mode 100644 index 0000000..4402c7d --- /dev/null +++ b/components/OrderGrid/__tests__/order-grid.navigation.test.ts @@ -0,0 +1,42 @@ +jest.mock('@/lib/orders/orders.sort', () => ({ + getDefaultSortDirForField: (field: string) => + field === 'timestamp' ? 'desc' : 'asc', +})); + +import { + buildOrderGridHref, + buildOrderGridSortLinks, + getNextSortDir, +} from '../order-grid.navigation'; + +describe('order-grid.navigation', () => { + it('builds grid href with page and sorting params', () => { + const href = buildOrderGridHref(3, 'price', 'asc'); + + expect(href).toBe('/?page=3&sortBy=price&sortDir=asc'); + }); + + it('toggles direction when sorting same field', () => { + expect(getNextSortDir('price', 'asc', 'price')).toBe('desc'); + expect(getNextSortDir('price', 'desc', 'price')).toBe('asc'); + }); + + it('uses field default direction when sorting a different field', () => { + expect(getNextSortDir('timestamp', 'desc', 'price')).toBe('asc'); + expect(getNextSortDir('price', 'asc', 'timestamp')).toBe('desc'); + }); + + it('builds sort links for all sortable fields', () => { + const links = buildOrderGridSortLinks('price', 'asc'); + + expect(links.instrument).toBe('/?page=1&sortBy=instrument&sortDir=asc'); + expect(links.side).toBe('/?page=1&sortBy=side&sortDir=asc'); + expect(links.price).toBe('/?page=1&sortBy=price&sortDir=desc'); + expect(links.quantity).toBe('/?page=1&sortBy=quantity&sortDir=asc'); + expect(links.remainingQuantity).toBe( + '/?page=1&sortBy=remainingQuantity&sortDir=asc', + ); + expect(links.status).toBe('/?page=1&sortBy=status&sortDir=asc'); + expect(links.timestamp).toBe('/?page=1&sortBy=timestamp&sortDir=desc'); + }); +}); diff --git a/components/OrderGrid/__tests__/order-grid.query.test.ts b/components/OrderGrid/__tests__/order-grid.query.test.ts new file mode 100644 index 0000000..5589efb --- /dev/null +++ b/components/OrderGrid/__tests__/order-grid.query.test.ts @@ -0,0 +1,46 @@ +jest.mock('@/lib/orders/orders.sort', () => ({ + resolveOrdersSortBy: (value?: string) => + value === 'price' || value === 'timestamp' ? value : 'timestamp', + resolveOrdersSortDir: (value?: string) => + value === 'asc' || value === 'desc' ? value : 'desc', +})); + +import { resolveOrderGridQuery } from '../order-grid.query'; + +describe('order-grid.query', () => { + it('returns defaults when query params are missing', () => { + const result = resolveOrderGridQuery({}); + + expect(result).toEqual({ + requestedPage: 1, + sortBy: 'timestamp', + sortDir: 'desc', + }); + }); + + it('clamps page to at least 1', () => { + expect(resolveOrderGridQuery({ page: '0' }).requestedPage).toBe(1); + expect(resolveOrderGridQuery({ page: '-10' }).requestedPage).toBe(1); + expect(resolveOrderGridQuery({ page: '5' }).requestedPage).toBe(5); + }); + + it('falls back to defaults for invalid sorting params', () => { + const result = resolveOrderGridQuery({ + sortBy: 'invalid-field', + sortDir: 'invalid-dir', + }); + + expect(result.sortBy).toBe('timestamp'); + expect(result.sortDir).toBe('desc'); + }); + + it('resolves valid sorting params', () => { + const result = resolveOrderGridQuery({ + sortBy: 'price', + sortDir: 'asc', + }); + + expect(result.sortBy).toBe('price'); + expect(result.sortDir).toBe('asc'); + }); +}); diff --git a/components/OrderGrid/columns.ts b/components/OrderGrid/columns.ts index 9030fc7..93f2cce 100644 --- a/components/OrderGrid/columns.ts +++ b/components/OrderGrid/columns.ts @@ -6,42 +6,49 @@ export const defaultColumns: OrdersGridColumn[] = [ key: 'instrument', label: 'Instrumento', width: '16%', + sortKey: 'instrument', render: (order) => order.instrument, }, { key: 'side', label: 'Lado', width: '12%', + sortKey: 'side', render: (order) => order.side, }, { key: 'price', label: 'Preço', width: '12%', + sortKey: 'price', render: (order) => order.price, }, { key: 'quantity', label: 'Qtd', width: '12%', + sortKey: 'quantity', render: (order) => order.quantity, }, { key: 'remainingQuantity', label: 'Qtd Restante', width: '12%', + sortKey: 'remainingQuantity', render: (order) => order.remainingQuantity, }, { key: 'status', label: 'Status', width: '12%', + sortKey: 'status', render: (order) => order.status, }, { key: 'timestamp', label: 'Data/Hora', width: '24%', + sortKey: 'timestamp', render: (order) => formatTimestampBR(order.timestamp), }, ]; diff --git a/components/OrderGrid/index.tsx b/components/OrderGrid/index.tsx index e1f971e..00f2ffa 100644 --- a/components/OrderGrid/index.tsx +++ b/components/OrderGrid/index.tsx @@ -19,6 +19,7 @@ const OrdersGrid = (({ orders, columns = defaultColumns, onRowClick, + sortState, }: OrdersGridProps) => { return ( @@ -28,7 +29,7 @@ const OrdersGrid = (({ ))} - + diff --git a/components/OrderGrid/order-grid.navigation.ts b/components/OrderGrid/order-grid.navigation.ts new file mode 100644 index 0000000..8c05815 --- /dev/null +++ b/components/OrderGrid/order-grid.navigation.ts @@ -0,0 +1,44 @@ +import { + getDefaultSortDirForField, +} from '@/lib/orders/orders.sort'; +import { + ORDERS_SORT_FIELDS, + type OrdersSortBy, + type OrdersSortDir, +} from '@/lib/orders/orders.sort.types'; + +export const buildOrderGridHref = ( + page: number, + sortBy: OrdersSortBy, + sortDir: OrdersSortDir, +): string => { + const params = new URLSearchParams(); + params.set('page', String(page)); + params.set('sortBy', sortBy); + params.set('sortDir', sortDir); + return `/?${params.toString()}`; +}; + +export const getNextSortDir = ( + currentSortBy: OrdersSortBy, + currentSortDir: OrdersSortDir, + targetSortBy: OrdersSortBy, +): OrdersSortDir => { + if (currentSortBy === targetSortBy) { + return currentSortDir === 'asc' ? 'desc' : 'asc'; + } + + return getDefaultSortDirForField(targetSortBy); +}; + +export const buildOrderGridSortLinks = ( + sortBy: OrdersSortBy, + sortDir: OrdersSortDir, +): Record => { + return Object.fromEntries( + ORDERS_SORT_FIELDS.map((field) => [ + field, + buildOrderGridHref(1, field, getNextSortDir(sortBy, sortDir, field)), + ]), + ) as Record; +}; diff --git a/components/OrderGrid/order-grid.query.ts b/components/OrderGrid/order-grid.query.ts new file mode 100644 index 0000000..bb3bafe --- /dev/null +++ b/components/OrderGrid/order-grid.query.ts @@ -0,0 +1,30 @@ +import { + resolveOrdersSortBy, + resolveOrdersSortDir, + } from '@/lib/orders/orders.sort'; +import type { OrdersSortBy, OrdersSortDir } from '@/lib/orders/orders.sort.types'; + +type OrderGridQueryParams = { + page?: string; + sortBy?: string; + sortDir?: string; +}; + +export type ResolvedOrderGridQuery = { + requestedPage: number; + sortBy: OrdersSortBy; + sortDir: OrdersSortDir; +}; + +export const resolveOrderGridQuery = ( + query: OrderGridQueryParams, +): ResolvedOrderGridQuery => { + const parsedPage = Number.parseInt(query.page ?? '1', 10); + const requestedPage = Number.isNaN(parsedPage) ? 1 : Math.max(1, parsedPage); + + return { + requestedPage, + sortBy: resolveOrdersSortBy(query.sortBy), + sortDir: resolveOrdersSortDir(query.sortDir), + }; +}; diff --git a/components/OrderGrid/parts/Head.tsx b/components/OrderGrid/parts/Head.tsx index 6b64702..71cd577 100644 --- a/components/OrderGrid/parts/Head.tsx +++ b/components/OrderGrid/parts/Head.tsx @@ -1,13 +1,44 @@ +import Link from 'next/link'; import type { OrdersGridHeadProps } from '../types'; -export const OrdersGridHead = ({ columns }: OrdersGridHeadProps) => ( - - - {columns.map((column) => ( - - {column.label} - - ))} - - -); +export const OrdersGridHead = ({ columns, sortState }: OrdersGridHeadProps) => { + return ( + + + {columns.map((column) => { + const sortHref = + sortState && column.sortKey + ? sortState.sortLinks[column.sortKey] + : undefined; + const isActive = Boolean( + sortState && column.sortKey && sortState.sortBy === column.sortKey, + ); + const ariaSort = isActive + ? sortState?.sortDir === 'asc' + ? 'ascending' + : 'descending' + : 'none'; + + return ( + + {sortHref && column.sortKey ? ( + + {column.label} + + + ) : ( + column.label + )} + + ); + })} + + + ); +}; diff --git a/components/OrderGrid/types.ts b/components/OrderGrid/types.ts index c0bae65..239910c 100644 --- a/components/OrderGrid/types.ts +++ b/components/OrderGrid/types.ts @@ -1,17 +1,26 @@ import type { Order } from '@/lib/orders/orders.types'; +import type { OrdersSortBy, OrdersSortDir } from '@/lib/orders/orders.sort.types'; import type { ReactNode } from 'react'; export type OrdersGridColumn = { key: string; label: string; width?: string; + sortKey?: OrdersSortBy; render: (order: Order) => ReactNode; }; +export type OrdersGridSortState = { + sortBy: OrdersSortBy; + sortDir: OrdersSortDir; + sortLinks: Partial>; +}; + export type OrdersGridProps = { orders: Order[]; columns?: OrdersGridColumn[]; onRowClick?: (order: Order) => void; + sortState?: OrdersGridSortState; }; export type OrdersGridPaginationData = { @@ -31,6 +40,7 @@ export type OrdersGridTableProps = { export type OrdersGridHeadProps = { columns: OrdersGridColumn[]; + sortState?: OrdersGridSortState; }; export type OrdersGridBodyProps = { diff --git a/lib/orders/__tests__/orders.service.test.ts b/lib/orders/__tests__/orders.service.test.ts index 4b6efc3..6409784 100644 --- a/lib/orders/__tests__/orders.service.test.ts +++ b/lib/orders/__tests__/orders.service.test.ts @@ -65,4 +65,29 @@ describe('orders.service', () => { expect(result.prevPage).toBeUndefined(); expect(result.nextPage).toBeUndefined(); }); + + it('sorts by price ascending before pagination', async () => { + mockedFindAllOrders.mockResolvedValue([ + makeOrder({ id: '1', price: 15.2 }), + makeOrder({ id: '2', price: 9.5 }), + makeOrder({ id: '3', price: 11.0 }), + ]); + + const result = await getPaginatedOrdersForGrid(1, 15, 'price', 'asc'); + + expect(result.items.map((order) => order.id)).toEqual(['2', '3', '1']); + }); + + it('sorts by status descending using market priority', async () => { + mockedFindAllOrders.mockResolvedValue([ + makeOrder({ id: '1', status: 'Executada' }), + makeOrder({ id: '2', status: 'Aberta' }), + makeOrder({ id: '3', status: 'Cancelada' }), + makeOrder({ id: '4', status: 'Parcial' }), + ]); + + const result = await getPaginatedOrdersForGrid(1, 15, 'status', 'desc'); + + expect(result.items.map((order) => order.id)).toEqual(['2', '4', '1', '3']); + }); }); diff --git a/lib/orders/orders.constants.ts b/lib/orders/orders.constants.ts new file mode 100644 index 0000000..603141c --- /dev/null +++ b/lib/orders/orders.constants.ts @@ -0,0 +1,5 @@ +import type { OrdersSortBy, OrdersSortDir } from './orders.sort.types'; + +export const DEFAULT_PAGE_SIZE = 15; +export const DEFAULT_ORDERS_SORT_BY: OrdersSortBy = 'timestamp'; +export const DEFAULT_ORDERS_SORT_DIR: OrdersSortDir = 'desc'; diff --git a/lib/orders/orders.service.ts b/lib/orders/orders.service.ts index 9c8c2f0..a85c68f 100644 --- a/lib/orders/orders.service.ts +++ b/lib/orders/orders.service.ts @@ -1,5 +1,12 @@ -import type { Order } from './orders.types'; +import { + DEFAULT_ORDERS_SORT_BY, + DEFAULT_ORDERS_SORT_DIR, + DEFAULT_PAGE_SIZE, +} from './orders.constants'; import { findAllOrders } from './orders.repository'; +import { getSortedOrders } from './orders.sort'; +import type { OrdersSortBy, OrdersSortDir } from './orders.sort.types'; +import type { Order } from './orders.types'; type PaginatedOrdersResult = { items: Order[]; @@ -12,16 +19,20 @@ type PaginatedOrdersResult = { export const getOrdersForGrid: () => Promise = async () => { const orders = await findAllOrders(); - return [...orders].sort( - (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(), + return getSortedOrders( + orders, + DEFAULT_ORDERS_SORT_BY, + DEFAULT_ORDERS_SORT_DIR, ); }; export const getPaginatedOrdersForGrid = async ( requestedPage: number, - pageSize = 15, + pageSize = DEFAULT_PAGE_SIZE, + sortBy: OrdersSortBy = DEFAULT_ORDERS_SORT_BY, + sortDir: OrdersSortDir = DEFAULT_ORDERS_SORT_DIR, ): Promise => { - const orders = await getOrdersForGrid(); + const orders = getSortedOrders(await findAllOrders(), sortBy, sortDir); const safeRequestedPage = Number.isFinite(requestedPage) ? requestedPage : 1; const totalPages = Math.max(1, Math.ceil(orders.length / pageSize)); const currentPage = Math.min(Math.max(1, safeRequestedPage), totalPages); diff --git a/lib/orders/orders.sort.ts b/lib/orders/orders.sort.ts new file mode 100644 index 0000000..24e7000 --- /dev/null +++ b/lib/orders/orders.sort.ts @@ -0,0 +1,98 @@ +import { + DEFAULT_ORDERS_SORT_BY, + DEFAULT_ORDERS_SORT_DIR, +} from './orders.constants'; +import type { Order } from './orders.types'; +import { + ORDERS_SORT_FIELDS, + type OrdersSortBy, + type OrdersSortDir, +} from './orders.sort.types'; + +const STATUS_RANK = { + Cancelada: 0, + Executada: 1, + Parcial: 2, + Aberta: 3, +} as const satisfies Record; + +const SIDE_RANK = { + Compra: 0, + Venda: 1, +} as const satisfies Record; + +const ORDERS_SORT_FIELDS_SET = new Set(ORDERS_SORT_FIELDS); + +export const isOrdersSortBy = (value: string): value is OrdersSortBy => + ORDERS_SORT_FIELDS_SET.has(value); + +export const resolveOrdersSortBy = (value?: string): OrdersSortBy => { + if (!value) { + return DEFAULT_ORDERS_SORT_BY; + } + + return isOrdersSortBy(value) ? value : DEFAULT_ORDERS_SORT_BY; +}; + +export const resolveOrdersSortDir = (value?: string): OrdersSortDir => + value === 'asc' || value === 'desc' ? value : DEFAULT_ORDERS_SORT_DIR; + +export const getDefaultSortDirForField = ( + sortBy: OrdersSortBy, +): OrdersSortDir => (sortBy === 'timestamp' ? 'desc' : 'asc'); + +const compareBySortField = ( + a: Order, + b: Order, + sortBy: OrdersSortBy, +): number => { + switch (sortBy) { + case 'timestamp': + return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(); + case 'instrument': + return a.instrument.localeCompare(b.instrument, 'pt-BR', { + sensitivity: 'base', + }); + case 'side': + return SIDE_RANK[a.side] - SIDE_RANK[b.side]; + case 'price': + return a.price - b.price; + case 'quantity': + return a.quantity - b.quantity; + case 'remainingQuantity': + return a.remainingQuantity - b.remainingQuantity; + case 'status': + return STATUS_RANK[a.status] - STATUS_RANK[b.status]; + default: + return 0; + } +}; + +const compareByDefaultTieBreak = (a: Order, b: Order): number => { + const timestampCompare = + new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(); + + if (timestampCompare !== 0) { + return timestampCompare; + } + + return b.id.localeCompare(a.id, 'pt-BR', { numeric: true, sensitivity: 'base' }); +}; + +export const getSortedOrders = ( + orders: Order[], + sortBy: OrdersSortBy, + sortDir: OrdersSortDir, +): Order[] => { + const direction = sortDir === 'asc' ? 1 : -1; + + return [...orders].sort((a, b) => { + const primaryCompare = compareBySortField(a, b, sortBy) * direction; + + if (primaryCompare !== 0) { + return primaryCompare; + } + + return compareByDefaultTieBreak(a, b); + }); +}; diff --git a/lib/orders/orders.sort.types.ts b/lib/orders/orders.sort.types.ts new file mode 100644 index 0000000..3d7cb07 --- /dev/null +++ b/lib/orders/orders.sort.types.ts @@ -0,0 +1,12 @@ +export const ORDERS_SORT_FIELDS = [ + 'timestamp', + 'instrument', + 'side', + 'price', + 'quantity', + 'remainingQuantity', + 'status', +] as const; + +export type OrdersSortBy = (typeof ORDERS_SORT_FIELDS)[number]; +export type OrdersSortDir = 'asc' | 'desc'; diff --git a/public/assets/add_box_60dp.svg b/public/assets/add_box_60dp.svg new file mode 100644 index 0000000..7a9ecb6 --- /dev/null +++ b/public/assets/add_box_60dp.svg @@ -0,0 +1 @@ + \ No newline at end of file From 6a63ad010d0e6b1f4bee833b72c6657789e672c5 Mon Sep 17 00:00:00 2001 From: "Agostnho F Teles Jr." Date: Sun, 15 Mar 2026 19:57:44 -0300 Subject: [PATCH 02/12] feat: normalize pagination inputs in getPaginatedOrdersForGrid function --- lib/orders/__tests__/orders.service.test.ts | 14 ++++++++++++++ lib/orders/orders.service.ts | 15 +++++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/lib/orders/__tests__/orders.service.test.ts b/lib/orders/__tests__/orders.service.test.ts index 6409784..c2e4e86 100644 --- a/lib/orders/__tests__/orders.service.test.ts +++ b/lib/orders/__tests__/orders.service.test.ts @@ -90,4 +90,18 @@ describe('orders.service', () => { expect(result.items.map((order) => order.id)).toEqual(['2', '4', '1', '3']); }); + + it('normalizes non-positive and fractional pagination inputs', async () => { + mockedFindAllOrders.mockResolvedValue(makeOrders(20)); + + const withInvalidPage = await getPaginatedOrdersForGrid(-3, 5.8); + expect(withInvalidPage.currentPage).toBe(1); + expect(withInvalidPage.totalPages).toBe(4); + expect(withInvalidPage.items).toHaveLength(5); + + const withInvalidPageSize = await getPaginatedOrdersForGrid(1.9, 0); + expect(withInvalidPageSize.currentPage).toBe(1); + expect(withInvalidPageSize.totalPages).toBe(2); + expect(withInvalidPageSize.items).toHaveLength(15); + }); }); diff --git a/lib/orders/orders.service.ts b/lib/orders/orders.service.ts index a85c68f..6bbb1d5 100644 --- a/lib/orders/orders.service.ts +++ b/lib/orders/orders.service.ts @@ -33,11 +33,18 @@ export const getPaginatedOrdersForGrid = async ( sortDir: OrdersSortDir = DEFAULT_ORDERS_SORT_DIR, ): Promise => { const orders = getSortedOrders(await findAllOrders(), sortBy, sortDir); - const safeRequestedPage = Number.isFinite(requestedPage) ? requestedPage : 1; - const totalPages = Math.max(1, Math.ceil(orders.length / pageSize)); + const normalizedPageSize = + Number.isFinite(pageSize) && pageSize > 0 + ? Math.floor(pageSize) + : DEFAULT_PAGE_SIZE; + const safeRequestedPage = + Number.isFinite(requestedPage) && requestedPage > 0 + ? Math.floor(requestedPage) + : 1; + const totalPages = Math.max(1, Math.ceil(orders.length / normalizedPageSize)); const currentPage = Math.min(Math.max(1, safeRequestedPage), totalPages); - const startIndex = (currentPage - 1) * pageSize; - const items = orders.slice(startIndex, startIndex + pageSize); + const startIndex = (currentPage - 1) * normalizedPageSize; + const items = orders.slice(startIndex, startIndex + normalizedPageSize); return { items, From 6471ea4a4d06bc5f7a46c7917b629831b99bb0ac Mon Sep 17 00:00:00 2001 From: "Agostnho F Teles Jr." Date: Sun, 15 Mar 2026 20:05:47 -0300 Subject: [PATCH 03/12] feat: update OrderGrid query types to use OrderGridQueryParams for improved type safety --- app/page.tsx | 5 ++--- components/OrderGrid/OrderGridWithPagination.tsx | 11 +++++------ components/OrderGrid/order-grid.query.ts | 15 +++++++++------ 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index ddb715e..1314f85 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,10 +1,9 @@ import OrderGridWithPagination from '@/components/OrderGrid/OrderGridWithPagination'; +import type { OrderGridQueryParams } from '@/components/OrderGrid/order-grid.query'; import Header from '@/components/Header'; type HomeProps = { - searchParams: Promise<{ - page?: string; - }>; + searchParams: Promise; }; export default async function Home({ searchParams }: HomeProps) { diff --git a/components/OrderGrid/OrderGridWithPagination.tsx b/components/OrderGrid/OrderGridWithPagination.tsx index def3050..b11f413 100644 --- a/components/OrderGrid/OrderGridWithPagination.tsx +++ b/components/OrderGrid/OrderGridWithPagination.tsx @@ -4,14 +4,13 @@ import { buildOrderGridHref, buildOrderGridSortLinks, } from './order-grid.navigation'; -import { resolveOrderGridQuery } from './order-grid.query'; +import { + resolveOrderGridQuery, + type OrderGridQueryParams, +} from './order-grid.query'; type OrderGridWithPaginationProps = { - searchParams: Promise<{ - page?: string; - sortBy?: string; - sortDir?: string; - }>; + searchParams: Promise; pageSize?: number; }; diff --git a/components/OrderGrid/order-grid.query.ts b/components/OrderGrid/order-grid.query.ts index bb3bafe..3402e22 100644 --- a/components/OrderGrid/order-grid.query.ts +++ b/components/OrderGrid/order-grid.query.ts @@ -1,10 +1,13 @@ import { resolveOrdersSortBy, resolveOrdersSortDir, - } from '@/lib/orders/orders.sort'; -import type { OrdersSortBy, OrdersSortDir } from '@/lib/orders/orders.sort.types'; +} from '@/lib/orders/orders.sort'; +import type { + OrdersSortBy, + OrdersSortDir, +} from '@/lib/orders/orders.sort.types'; -type OrderGridQueryParams = { +export type OrderGridQueryParams = { page?: string; sortBy?: string; sortDir?: string; @@ -19,9 +22,9 @@ export type ResolvedOrderGridQuery = { export const resolveOrderGridQuery = ( query: OrderGridQueryParams, ): ResolvedOrderGridQuery => { - const parsedPage = Number.parseInt(query.page ?? '1', 10); - const requestedPage = Number.isNaN(parsedPage) ? 1 : Math.max(1, parsedPage); - + const parsedPage = Number(query.page); + const requestedPage = + Number.isInteger(parsedPage) && parsedPage > 0 ? parsedPage : 1; return { requestedPage, sortBy: resolveOrdersSortBy(query.sortBy), From bc59fb97c5b6b0c4701bc539832e1f833b741669 Mon Sep 17 00:00:00 2001 From: "Agostnho F Teles Jr." Date: Sun, 15 Mar 2026 20:34:04 -0300 Subject: [PATCH 04/12] feat: enhance aria-sort handling in OrdersGridHead and add test for active sorting --- components/OrderGrid/parts/Head.tsx | 21 ++++++++--- .../OrderGrid/parts/__tests__/Head.test.tsx | 37 +++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/components/OrderGrid/parts/Head.tsx b/components/OrderGrid/parts/Head.tsx index 71cd577..6b12a36 100644 --- a/components/OrderGrid/parts/Head.tsx +++ b/components/OrderGrid/parts/Head.tsx @@ -14,13 +14,17 @@ export const OrdersGridHead = ({ columns, sortState }: OrdersGridHeadProps) => { sortState && column.sortKey && sortState.sortBy === column.sortKey, ); const ariaSort = isActive - ? sortState?.sortDir === 'asc' + ? sortState && sortState.sortDir === 'asc' ? 'ascending' : 'descending' - : 'none'; + : undefined; return ( - + {sortHref && column.sortKey ? ( { href={sortHref} > {column.label} -