From 8c5def00326bc5f706dfcdf886979f96dabee496 Mon Sep 17 00:00:00 2001 From: Aqil-Ahmad Date: Sun, 19 Oct 2025 13:30:17 +0500 Subject: [PATCH] feat: add reusable datalist component --- .../src/frontend/InvoiceList.client.tsx | 189 ++++++--------- .../src/frontend/NotificationList.client.tsx | 186 ++++++--------- .../src/frontend/OrderList.client.tsx | 220 +++++++----------- .../src/frontend/TicketList.client.tsx | 137 ++++------- .../components/DataList/DataList.stories.tsx | 212 +++++++++++++++++ .../ui/src/components/DataList/DataList.tsx | 179 ++++++++++++++ .../src/components/DataList/DataList.types.ts | 154 ++++++++++++ packages/ui/src/components/DataList/index.ts | 2 + 8 files changed, 825 insertions(+), 454 deletions(-) create mode 100644 packages/ui/src/components/DataList/DataList.stories.tsx create mode 100644 packages/ui/src/components/DataList/DataList.tsx create mode 100644 packages/ui/src/components/DataList/DataList.types.ts create mode 100644 packages/ui/src/components/DataList/index.ts diff --git a/packages/blocks/invoice-list/src/frontend/InvoiceList.client.tsx b/packages/blocks/invoice-list/src/frontend/InvoiceList.client.tsx index 68f0979c..1acf0df5 100644 --- a/packages/blocks/invoice-list/src/frontend/InvoiceList.client.tsx +++ b/packages/blocks/invoice-list/src/frontend/InvoiceList.client.tsx @@ -5,25 +5,22 @@ import React, { useState, useTransition } from 'react'; import { Mappings, Utils } from '@o2s/utils.frontend'; -import { cn } from '@o2s/ui/lib/utils'; - import { toast } from '@o2s/ui/hooks/use-toast'; import { useGlobalContext } from '@o2s/ui/providers/GlobalProvider'; +import { DataList } from '@o2s/ui/components/DataList'; +import type { DataListColumnConfig } from '@o2s/ui/components/DataList'; import { FiltersSection } from '@o2s/ui/components/Filters'; import { NoResults } from '@o2s/ui/components/NoResults'; import { Pagination } from '@o2s/ui/components/Pagination'; -import { Price } from '@o2s/ui/components/Price'; -import { Badge } from '@o2s/ui/elements/badge'; import { Button } from '@o2s/ui/elements/button'; import { Link } from '@o2s/ui/elements/link'; import { LoadingOverlay } from '@o2s/ui/elements/loading-overlay'; import { Separator } from '@o2s/ui/elements/separator'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@o2s/ui/elements/table'; -import { Request } from '../api-harmonization/invoice-list.client'; +import { Model, Request } from '../api-harmonization/invoice-list.client'; import { sdk } from '../sdk'; import { InvoiceListPureProps } from './InvoiceList.types'; @@ -74,6 +71,65 @@ export const InvoiceListPure: React.FC = ({ locale, access } }; + // Define columns configuration outside JSX for better readability + const columns = data.table.data.columns.map((column) => { + switch (column.id) { + case 'type': + return { + ...column, + type: 'text', + cellClassName: 'max-w-[100px] md:max-w-sm', + }; + case 'paymentStatus': + return { + ...column, + type: 'badge', + variant: (value: string) => + Mappings.InvoiceBadge.invoiceBadgePaymentStatusVariants[ + value as keyof typeof Mappings.InvoiceBadge.invoiceBadgePaymentStatusVariants + ], + }; + case 'paymentDueDate': + return { + ...column, + type: 'date', + }; + case 'totalAmountDue': + case 'totalNetAmountDue': + return { + ...column, + type: 'price', + headerClassName: 'text-right', + cellClassName: 'text-right', + config: { currencyKey: 'currency' }, + }; + default: + return { + ...column, + type: 'text', + }; + } + }) as DataListColumnConfig[]; + + const actions = data.table.data.actions + ? { + ...data.table.data.actions, + render: (invoice: Model.Invoice) => ( + + + + ), + } + : undefined; + return (
{initialData.length > 0 ? ( @@ -91,121 +147,12 @@ export const InvoiceListPure: React.FC = ({ locale, access {data.invoices.data.length ? (
- - - - {data.table.data.columns.map((column) => ( - - {column.title} - - ))} - {data.table.data.actions && ( - - {data.table.data.actions.title} - - )} - - - - {data.invoices.data.map((invoice) => { - return ( - - {data.table.data.columns.map((column) => { - switch (column.id) { - case 'type': - return ( - - {invoice[column.id].displayValue} - - ); - case 'id': - return ( - - {invoice[column.id]} - - ); - case 'paymentStatus': - return ( - - - {invoice[column.id].displayValue} - - - ); - case 'paymentDueDate': - return ( - - {invoice[column.id].displayValue} - - ); - case 'totalAmountDue': - case 'totalNetAmountDue': - return ( - - - - ); - default: - return null; - } - })} - {data.table.data.actions && ( - - - - - - )} - - ); - })} - -
+ invoice.id} + columns={columns} + actions={actions} + /> {data.pagination && ( = ({ }); }; + // Define columns configuration outside JSX for better readability + const columns = data.table.columns.map((column) => { + switch (column.id) { + case 'status': + return { + ...column, + type: 'custom', + title: '', + cellClassName: 'text-center', + render: (_value: unknown, notification: Model.Notification) => { + const isUnViewed = notification.status.value === 'UNVIEWED'; + return isUnViewed ? : null; + }, + }; + case 'title': + return { + ...column, + type: 'text', + cellClassName: (notification: Model.Notification) => + cn('max-w-[200px] lg:max-w-md', notification.status.value === 'UNVIEWED' && 'font-semibold'), + }; + case 'type': + return { + ...column, + type: 'text', + cellClassName: (notification: Model.Notification) => + cn(notification.status.value === 'UNVIEWED' && 'font-semibold'), + }; + case 'priority': + return { + ...column, + type: 'badge', + variant: (value: string) => + Mappings.NotificationBadge.notificationBadgePriorityVariants[ + value as keyof typeof Mappings.NotificationBadge.notificationBadgePriorityVariants + ], + }; + case 'createdAt': + case 'updatedAt': + return { + ...column, + type: 'date', + cellClassName: (notification: Model.Notification) => + cn(notification.status.value === 'UNVIEWED' && 'font-semibold'), + }; + default: + return { + ...column, + type: 'text', + }; + } + }) as DataListColumnConfig[]; + const actions = data.table.actions + ? { + ...data.table.actions, + render: (notification: Model.Notification) => ( + + + + ), + } + : undefined; + return (
{initialData.length > 0 ? ( @@ -78,113 +144,15 @@ export const NotificationListPure: React.FC = ({ {data.notifications.data.length ? (
- - - - {data.table.columns.map((column) => ( - - {column.id !== 'status' ? column.title : null} - - ))} - {data.table.actions && ( - - {data.table.actions.title} - - )} - - - - {data.notifications.data.map((notification) => { - const isUnViewed = notification.status.value === 'UNVIEWED'; - return ( - - {data.table.columns.map((column) => { - switch (column.id) { - case 'status': - return ( - - {isUnViewed && ( - - )} - - ); - case 'title': - return ( - - {notification.title} - - ); - case 'type': - return ( - - {notification[column.id].label} - - ); - case 'priority': - return ( - - - {notification[column.id].label} - - - ); - case 'createdAt': - case 'updatedAt': - return ( - - {notification[column.id]} - - ); - default: - return null; - } - })} - {data.table.actions && ( - - - - )} - - ); - })} - -
+ notification.id} + getRowClassName={(notification) => { + return notification.status.value === 'UNVIEWED' ? '' : ''; + }} + columns={columns} + actions={actions} + /> {data.pagination && ( = ({ locale, accessToke }); }; + // Define columns configuration outside JSX for better readability + const columns = data.table.columns.map((column) => { + switch (column.id) { + case 'id': + return { + ...column, + type: 'custom', + cellClassName: 'py-0', + render: (value: unknown, order: Model.Order) => { + const idValue = value as { label: string }; + return ( + + ); + }, + }; + case 'createdAt': + case 'paymentDueDate': + return { + ...column, + type: 'date', + }; + case 'status': + return { + ...column, + type: 'badge', + variant: (value: string) => + Mappings.OrderBadge.orderBadgeVariants[ + value as keyof typeof Mappings.OrderBadge.orderBadgeVariants + ], + }; + case 'subtotal': + return { + ...column, + type: 'price', + headerClassName: 'text-right', + cellClassName: 'text-right', + }; + default: + return { + ...column, + type: 'text', + }; + } + }) as DataListColumnConfig[]; + const actions = data.table.actions + ? { + ...data.table.actions, + cellClassName: 'py-0 w-[180px]', + render: (order: Model.Order) => ( +
+ + + + + + + + + + {data.reorderLabel} + + + + +
+ ), + } + : undefined; + return (
{initialData.length > 0 ? ( @@ -78,137 +155,12 @@ export const OrderListPure: React.FC = ({ locale, accessToke {data.orders.data.length ? (
- - - - {data.table.columns.map((column) => { - switch (column.id) { - case 'subtotal': - return ( - - {column.title} - - ); - default: - return ( - - {column.title} - - ); - } - })} - {data.table.actions && ( - - {data.table.actions.title} - - )} - - - - {data.orders.data.map((order) => ( - - {data.table.columns.map((column) => { - switch (column.id) { - case 'id': - return ( - - - - ); - case 'createdAt': - case 'paymentDueDate': - return ( - - {order[column.id].label} - - ); - case 'status': - return ( - - - {order[column.id].label} - - - ); - case 'subtotal': - return ( - - - - ); - default: - return null; - } - })} - {data.table.actions && ( - -
- - - - - - - - - - {data.reorderLabel} - - - - -
-
- )} -
- ))} -
-
+ order.id.value} + columns={columns} + actions={actions} + /> {data.pagination && ( = ({ locale, accessTo }); }; + // Define columns configuration outside JSX for better readability + const columns = data.table.columns.map((column) => { + switch (column.id) { + case 'topic': + return { + ...column, + type: 'text', + cellClassName: 'max-w-[200px] lg:max-w-md', + }; + case 'status': + return { + ...column, + type: 'badge', + variant: (value: string) => + Mappings.TicketBadge.ticketBadgeVariants[ + value as keyof typeof Mappings.TicketBadge.ticketBadgeVariants + ], + }; + case 'updatedAt': + return { + ...column, + type: 'date', + }; + default: + return { + ...column, + type: 'text', + }; + } + }) as DataListColumnConfig[]; + const actions = data.table.actions + ? { + ...data.table.actions, + render: (ticket: Model.Ticket) => ( + + ), + } + : undefined; + return (
{initialData.length > 0 ? ( @@ -113,94 +157,7 @@ export const TicketListPure: React.FC = ({ locale, accessTo {data.tickets.data.length ? (
- - - - {data.table.columns.map((column) => ( - - {column.title} - - ))} - {data.table.actions && ( - - {data.table.actions.title} - - )} - - - - {data.tickets.data.map((ticket) => ( - - {data.table.columns.map((column) => { - switch (column.id) { - case 'topic': - return ( - - {ticket[column.id].label} - - ); - case 'type': - return ( - - {ticket[column.id].label} - - ); - case 'status': - return ( - - - {ticket[column.id].label} - - - ); - case 'updatedAt': - return ( - - {ticket[column.id]} - - ); - default: - return null; - } - })} - {data.table.actions && ( - - - - )} - - ))} - -
+ {data.pagination && ( = { + title: 'Components/DataList', + component: DataList, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +type Ticket = { + id: string; + topic: { label: string; value: string }; + type: { label: string; value: string }; + status: { label: string; value: string }; + updatedAt: string; + detailsUrl: string; +}; + +const sampleTickets: Ticket[] = [ + { + id: '1', + topic: { label: 'Login Issue', value: 'login-issue' }, + type: { label: 'Technical', value: 'technical' }, + status: { label: 'Open', value: 'open' }, + updatedAt: '2024-01-15', + detailsUrl: '/tickets/1', + }, + { + id: '2', + topic: { label: 'Billing Question', value: 'billing' }, + type: { label: 'Billing', value: 'billing' }, + status: { label: 'In Progress', value: 'in_progress' }, + updatedAt: '2024-01-14', + detailsUrl: '/tickets/2', + }, + { + id: '3', + topic: { label: 'Feature Request', value: 'feature' }, + type: { label: 'General', value: 'general' }, + status: { label: 'Closed', value: 'closed' }, + updatedAt: '2024-01-13', + detailsUrl: '/tickets/3', + }, +]; + +const ticketColumns: DataListColumnConfig[] = [ + { + id: 'topic', + title: 'Topic', + type: 'text', + cellClassName: 'truncate max-w-[200px]', + }, + { + id: 'type', + title: 'Type', + type: 'text', + }, + { + id: 'status', + title: 'Status', + type: 'badge', + variant: (value: string) => { + switch (value) { + case 'open': + return 'default'; + case 'in_progress': + return 'secondary'; + case 'closed': + return 'outline'; + default: + return 'default'; + } + }, + }, + { + id: 'updatedAt', + title: 'Last Updated', + type: 'date', + }, +]; + +type Order = { + id: string; + date: string; + status: { label: string; value: string }; + total: { value: number; currency: string }; +}; + +const sampleOrders: Order[] = [ + { + id: '001', + date: '2024-01-15', + status: { label: 'Completed', value: 'completed' }, + total: { value: 299.99, currency: 'USD' }, + }, + { + id: '002', + date: '2024-01-14', + status: { label: 'Pending', value: 'pending' }, + total: { value: 149.5, currency: 'USD' }, + }, +]; + +const orderColumns: DataListColumnConfig[] = [ + { id: 'id', title: 'Order ID', type: 'text' }, + { id: 'date', title: 'Date', type: 'date' }, + { + id: 'status', + title: 'Status', + type: 'badge', + variant: (value: string) => (value === 'completed' ? 'default' : 'secondary'), + }, + { + id: 'total', + title: 'Total', + type: 'price', + headerClassName: 'text-right', + cellClassName: 'text-right', + }, +]; + +export const BasicTextColumns: Story = { + args: { + data: sampleTickets, + columns: [ + { id: 'topic', title: 'Topic', type: 'text' }, + { id: 'type', title: 'Type', type: 'text' }, + { id: 'updatedAt', title: 'Date', type: 'text' }, + ], + }, +}; + +export const WithBadges: Story = { + args: { + data: sampleTickets as Record[], + columns: ticketColumns as DataListColumnConfig>[], + }, +}; + +export const WithActions: Story = { + args: { + data: sampleTickets as Record[], + columns: ticketColumns as DataListColumnConfig>[], + actions: { + title: 'Actions', + render: (_item) => , + }, + }, +}; + +export const WithPriceColumns: Story = { + args: { + data: sampleOrders as Record[], + columns: orderColumns as DataListColumnConfig>[], + }, +}; + +export const CustomCellRenderer: Story = { + args: { + data: sampleTickets as Record[], + columns: [ + { + id: 'topic', + title: 'Topic', + type: 'custom', + render: (value: unknown, item: Record) => { + const topicValue = value as { label: string }; + return ( +
+ {topicValue.label} + #{String(item.id)} +
+ ); + }, + }, + { id: 'type', title: 'Type', type: 'text' }, + { + id: 'status', + title: 'Status', + type: 'badge', + variant: (_value: string) => 'default' as const, + }, + ] as DataListColumnConfig>[], + }, +}; + +export const EmptyState: Story = { + args: { + data: [] as Record[], + columns: ticketColumns as DataListColumnConfig>[], + }, +}; + +export const WithCustomStyling: Story = { + args: { + data: sampleTickets as Record[], + columns: ticketColumns.map((col) => ({ + ...col, + headerClassName: 'bg-gray-100 font-bold', + cellClassName: 'py-4', + })) as DataListColumnConfig>[], + className: 'border-2 border-gray-300', + }, +}; diff --git a/packages/ui/src/components/DataList/DataList.tsx b/packages/ui/src/components/DataList/DataList.tsx new file mode 100644 index 00000000..97912386 --- /dev/null +++ b/packages/ui/src/components/DataList/DataList.tsx @@ -0,0 +1,179 @@ +import React from 'react'; + +import { cn } from '@o2s/ui/lib/utils'; + +import { Price } from '@o2s/ui/components/Price'; + +import { Badge } from '@o2s/ui/elements/badge'; +import { BadgeStatus } from '@o2s/ui/elements/badge-status'; +import { Button } from '@o2s/ui/elements/button'; +import { Link } from '@o2s/ui/elements/link'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@o2s/ui/elements/table'; + +import { DataListColumnConfig, DataListProps } from './DataList.types'; + +/** + * Default cell renderer based on column type + */ +function renderCell(value: Record, item: T, column: DataListColumnConfig): React.ReactNode { + if (value === null || value === undefined) { + return null; + } + + if (column.type === 'custom') { + return column.render(value, item, column); + } + + switch (column.type) { + case 'text': { + const displayField = column.displayField || 'label'; + if (typeof value === 'object' && value !== null) { + return String(value[displayField]); + } + return String(value); + } + + case 'badge': { + const badgeLabel = String(value[column.labelField || 'label']); + const badgeValue = String(value[column.valueField || 'value']); + const variant = column.variant ? column.variant(badgeValue) : 'default'; + + return {badgeLabel}; + } + + case 'status': + return ; + + case 'date': { + const dateDisplayField = column.displayField || 'label'; + if (typeof value === 'object' && value !== null) { + return String(value[dateDisplayField]); + } + return String(value); + } + + case 'price': { + if (typeof value === 'object' && value !== null && 'value' in value) { + const priceValue = value as { value: number; currency?: string }; + const currency = ( + column.config?.currencyKey ? String(item[column.config.currencyKey]) : priceValue.currency || 'USD' + ) as 'USD' | 'EUR' | 'GBP' | 'PLN'; + return ; + } + if (typeof value === 'object' && value !== null) { + return ; + } + return null; + } + + case 'link': { + const linkDisplayField = column.displayField || 'label'; + const linkLabel = + typeof value === 'object' && value !== null ? String(value[linkDisplayField]) : String(value); + const _href = column.config?.hrefKey ? String(item[column.config.hrefKey]) : '#'; + const linkVariant = column.config?.variant ? column.config.variant : 'link'; + const linkClassName = column.config?.className + ? column.config.className + : 'flex items-center justify-end gap-2'; + + if (linkVariant === 'link') { + return ( + + + + ); + } + return ( + + ); + } + + default: + return String(value); + } +} + +/** + * DataList component - A reusable table component for displaying data + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function DataList>({ + data, + columns, + actions, + getRowKey, + className, + getRowClassName, +}: DataListProps) { + // Default row key extractor + const defaultGetRowKey = (item: T, index: number) => { + if ('id' in item) { + return String(item.id); + } + return index; + }; + + const rowKeyExtractor = getRowKey || defaultGetRowKey; + + return ( + + + + {columns.map((column) => ( + + {column.title} + + ))} + {actions && ( + + {actions.title} + + )} + + + + {data.map((item, index) => { + const rowKey = rowKeyExtractor(item, index); + const rowClassName = getRowClassName ? getRowClassName(item) : undefined; + + return ( + + {columns.map((column) => { + const value = item[column.id]; + const cellContent = renderCell(value, item, column); + + const cellClassName = + typeof column.cellClassName === 'string' + ? column.cellClassName + : column.cellClassName?.(item); + + const defaultClassName = column.type === 'text' ? 'truncate whitespace-nowrap' : ''; + + return ( + + {cellContent} + + ); + })} + {actions && ( + + {actions.render ? actions.render(item) : null} + + )} + + ); + })} + +
+ ); +} diff --git a/packages/ui/src/components/DataList/DataList.types.ts b/packages/ui/src/components/DataList/DataList.types.ts new file mode 100644 index 00000000..687a5cec --- /dev/null +++ b/packages/ui/src/components/DataList/DataList.types.ts @@ -0,0 +1,154 @@ +import { Models } from '@o2s/framework/modules'; +import { ReactNode } from 'react'; + +type DataTableColumn = Models.DataTable.DataTableColumn; +type DataTableActions = Models.DataTable.DataTableActions; + +/** + * Column type definitions for DataList + */ +export type ColumnType = 'text' | 'badge' | 'date' | 'price' | 'link' | 'status' | 'custom'; + +/** + * Common configuration shared by all column types + */ +interface DataListColumnCommonConfig extends DataTableColumn { + type: ColumnType; + + /** + * Additional CSS classes for the header cell + */ + headerClassName?: string; + + /** + * Additional CSS classes for the body cell + */ + cellClassName?: string | ((item: T) => string); +} + +/** + * Text column configuration + */ +export interface DataListColumnTextConfig extends DataListColumnCommonConfig { + type: 'text'; + displayField?: string; +} + +/** + * Badge column configuration + */ +export interface DataListColumnBadgeConfig extends DataListColumnCommonConfig { + type: 'badge'; + labelField?: string; + valueField?: string; + variant?: (value: string) => 'default' | 'destructive' | 'outline' | 'secondary'; +} + +/** + * Date column configuration + */ +export interface DataListColumnDateConfig extends DataListColumnCommonConfig { + type: 'date'; + displayField?: string; + valueField?: string; +} + +/** + * Price column configuration + */ +export interface DataListColumnPriceConfig extends DataListColumnCommonConfig { + type: 'price'; + config?: { + currencyKey?: keyof T; + }; +} + +/** + * Link column configuration + */ +export interface DataListColumnLinkConfig extends DataListColumnCommonConfig { + type: 'link'; + displayField?: string; + config?: { + hrefKey?: keyof T; + variant?: 'link' | 'default'; + className?: string; + }; +} + +/** + * Status column configuration + */ +export interface DataListColumnStatusConfig extends DataListColumnCommonConfig { + type: 'status'; +} + +/** + * Custom column configuration with render function + */ +export interface DataListColumnCustomConfig extends DataListColumnCommonConfig { + type: 'custom'; + render: (value: unknown, item: T, column: DataListColumnCustomConfig) => ReactNode; +} + +/** + * Discriminated union of all column configuration types + */ +export type DataListColumnConfig = + | DataListColumnTextConfig + | DataListColumnBadgeConfig + | DataListColumnDateConfig + | DataListColumnPriceConfig + | DataListColumnLinkConfig + | DataListColumnStatusConfig + | DataListColumnCustomConfig; + +/** + * Actions configuration with rendering options + */ +export interface DataListActionsConfig extends DataTableActions { + /** + * Custom renderer for the actions cell + */ + render?: (item: T) => ReactNode; + + /** + * Additional CSS classes for the actions cell + */ + cellClassName?: string; +} + +/** + * Props for DataList component + */ +export interface DataListProps { + /** + * Array of data items to display + */ + data: T[]; + + /** + * Column configurations + */ + columns: DataListColumnConfig[]; + + /** + * Optional actions configuration + */ + actions?: DataListActionsConfig; + + /** + * Optional row key extractor + */ + getRowKey?: (item: T, index: number) => string | number; + + /** + * Optional className for the table + */ + className?: string; + + /** + * Optional row className function + */ + getRowClassName?: (item: T) => string; +} diff --git a/packages/ui/src/components/DataList/index.ts b/packages/ui/src/components/DataList/index.ts new file mode 100644 index 00000000..321d85a3 --- /dev/null +++ b/packages/ui/src/components/DataList/index.ts @@ -0,0 +1,2 @@ +export { DataList } from './DataList'; +export type { DataListProps, DataListColumnConfig, DataListActionsConfig, ColumnType } from './DataList.types';