diff --git a/.changeset/mean-shoes-behave.md b/.changeset/mean-shoes-behave.md new file mode 100644 index 000000000..ed32e482a --- /dev/null +++ b/.changeset/mean-shoes-behave.md @@ -0,0 +1,6 @@ +--- +'@o2s/blocks.invoice-list': patch +'@o2s/ui': patch +--- + +fixed incorrect price rendering in `DataView` component diff --git a/.changeset/shy-bobcats-count.md b/.changeset/shy-bobcats-count.md new file mode 100644 index 000000000..6f196b29d --- /dev/null +++ b/.changeset/shy-bobcats-count.md @@ -0,0 +1,5 @@ +--- +'@o2s/ui': patch +--- + +removed unnecessary prop destructuring diff --git a/packages/blocks/invoice-list/src/api-harmonization/invoice-list.mapper.ts b/packages/blocks/invoice-list/src/api-harmonization/invoice-list.mapper.ts index df1ca42a7..cb935430b 100644 --- a/packages/blocks/invoice-list/src/api-harmonization/invoice-list.mapper.ts +++ b/packages/blocks/invoice-list/src/api-harmonization/invoice-list.mapper.ts @@ -29,6 +29,9 @@ export const mapInvoiceList = ( downloadButtonAriaDescription: cms.downloadButtonAriaDescription, initialFilters: cms.initialFilters, cardHeaderSlots: cms.cardHeaderSlots, + enableRowSelection: cms.enableRowSelection, + bulkActionsLabel: cms.bulkActionsLabel, + downloadAllButtonLabel: cms.downloadAllButtonLabel, }; }; diff --git a/packages/blocks/invoice-list/src/api-harmonization/invoice-list.model.ts b/packages/blocks/invoice-list/src/api-harmonization/invoice-list.model.ts index d80311fe0..bd06f0308 100644 --- a/packages/blocks/invoice-list/src/api-harmonization/invoice-list.model.ts +++ b/packages/blocks/invoice-list/src/api-harmonization/invoice-list.model.ts @@ -30,6 +30,9 @@ export class InvoiceListBlock extends ApiModels.Block.Block { right?: string; bottom?: string; }; + enableRowSelection?: boolean; + bulkActionsLabel?: string; + downloadAllButtonLabel?: string; } export class Invoice { @@ -47,10 +50,6 @@ export class Invoice { value: Invoices.Model.Invoice['paymentDueDate']; displayValue: string; }; - totalAmountDue!: { - value: Invoices.Model.Invoice['totalAmountDue']['value']; - }; - totalNetAmountDue!: { - value: Invoices.Model.Invoice['totalNetAmountDue']['value']; - }; + totalAmountDue!: Invoices.Model.Invoice['totalAmountDue']; + totalNetAmountDue!: Invoices.Model.Invoice['totalNetAmountDue']; } diff --git a/packages/blocks/invoice-list/src/frontend/InvoiceList.client.stories.tsx b/packages/blocks/invoice-list/src/frontend/InvoiceList.client.stories.tsx index e7c6d2c44..75e5b5021 100644 --- a/packages/blocks/invoice-list/src/frontend/InvoiceList.client.stories.tsx +++ b/packages/blocks/invoice-list/src/frontend/InvoiceList.client.stories.tsx @@ -175,9 +175,11 @@ export const Default: Story = { }, totalAmountDue: { value: 1250.5, + currency: 'EUR', }, totalNetAmountDue: { value: 1016.67, + currency: 'EUR', }, paymentDueDate: { displayValue: '07/15/2024', @@ -197,9 +199,11 @@ export const Default: Story = { }, totalAmountDue: { value: 3450.75, + currency: 'EUR', }, totalNetAmountDue: { value: 2805.49, + currency: 'EUR', }, paymentDueDate: { displayValue: '06/10/2024', @@ -219,9 +223,11 @@ export const Default: Story = { }, totalAmountDue: { value: 780.25, + currency: 'EUR', }, totalNetAmountDue: { value: 634.35, + currency: 'EUR', }, paymentDueDate: { displayValue: '07/05/2024', @@ -241,9 +247,11 @@ export const Default: Story = { }, totalAmountDue: { value: 0, + currency: 'EUR', }, totalNetAmountDue: { value: 0, + currency: 'EUR', }, paymentDueDate: { displayValue: '05/20/2024', @@ -263,9 +271,11 @@ export const Default: Story = { }, totalAmountDue: { value: 5670.3, + currency: 'EUR', }, totalNetAmountDue: { value: 4610, + currency: 'EUR', }, paymentDueDate: { displayValue: '06/15/2024', diff --git a/packages/blocks/invoice-list/src/frontend/InvoiceList.client.tsx b/packages/blocks/invoice-list/src/frontend/InvoiceList.client.tsx index 7a1ebe6c4..5751176b9 100644 --- a/packages/blocks/invoice-list/src/frontend/InvoiceList.client.tsx +++ b/packages/blocks/invoice-list/src/frontend/InvoiceList.client.tsx @@ -1,6 +1,8 @@ 'use client'; +import { IntlMessageFormat } from 'intl-messageformat'; import { Download } from 'lucide-react'; +import { useLocale } from 'next-intl'; import React, { useState, useTransition } from 'react'; import { Mappings, Utils } from '@o2s/utils.frontend'; @@ -27,6 +29,7 @@ import { InvoiceListPureProps } from './InvoiceList.types'; export const InvoiceListPure: React.FC = ({ locale, accessToken, routing, ...component }) => { const { labels } = useGlobalContext(); + const currentLocale = useLocale(); const initialFilters: Request.GetInvoiceListBlockQuery = { id: component.id, @@ -44,6 +47,7 @@ export const InvoiceListPure: React.FC = ({ locale, access const [data, setData] = useState(component); const [filters, setFilters] = useState(initialFilters); const [viewMode, setViewMode] = useState<'list' | 'grid'>(initialViewMode); + const [selectedRows, setSelectedRows] = useState>(new Set()); const [isPending, startTransition] = useTransition(); const handleFilter = (data: Partial) => { @@ -71,6 +75,7 @@ export const InvoiceListPure: React.FC = ({ locale, access setFilters(initialFilters); setData(newData); + setSelectedRows(new Set()); } catch (_error) { toast({ variant: 'destructive', @@ -94,6 +99,33 @@ export const InvoiceListPure: React.FC = ({ locale, access } }; + const handleBulkDownload = async (selectedInvoiceIds: string[]) => { + if (selectedInvoiceIds.length === 0) return; + + startTransition(async () => { + try { + for (const invoiceId of selectedInvoiceIds) { + try { + const response = await sdk.blocks.getInvoicePdf(invoiceId, { 'x-locale': locale }, accessToken); + Utils.DownloadFile.downloadFile( + response, + data.downloadFileName?.replace('{id}', invoiceId) || `invoice-${invoiceId}.pdf`, + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + } catch (error) { + console.error(`Failed to download invoice ${invoiceId}:`, error); + } + } + } catch (_error) { + toast({ + variant: 'destructive', + title: labels.errors.requestError.title, + description: labels.errors.requestError.content, + }); + } + }); + }; + // Define columns configuration outside JSX for better readability const columns = data.table.data.columns.map((column) => { switch (column.id) { @@ -156,6 +188,25 @@ export const InvoiceListPure: React.FC = ({ locale, access } : undefined; + const bulkActions = component.downloadAllButtonLabel + ? (selectedRowKeys: Set) => { + const selectedIds = Array.from(selectedRowKeys).map(String); + return ( + + ); + } + : undefined; + + const bulkActionsLabel = component.bulkActionsLabel + ? (count: number) => { + const msg = new IntlMessageFormat(component.bulkActionsLabel!, currentLocale); + return String(msg.format({ count })); + } + : undefined; + return (
{initialData.length > 0 ? ( @@ -195,6 +246,12 @@ export const InvoiceListPure: React.FC = ({ locale, access columns={columns} actions={actions} cardHeaderSlots={data.cardHeaderSlots} + enableRowSelection={component.enableRowSelection} + selectedRows={selectedRows} + onSelectionChange={setSelectedRows} + getRowKey={(item) => item.id} + bulkActions={bulkActions} + bulkActionsLabel={bulkActionsLabel} /> {data.pagination && ( diff --git a/packages/blocks/notification-list/src/frontend/NotificationList.client.tsx b/packages/blocks/notification-list/src/frontend/NotificationList.client.tsx index b1f4ef202..77c8fc374 100644 --- a/packages/blocks/notification-list/src/frontend/NotificationList.client.tsx +++ b/packages/blocks/notification-list/src/frontend/NotificationList.client.tsx @@ -52,6 +52,7 @@ export const NotificationListPure: React.FC = ({ const [data, setData] = useState(component); const [filters, setFilters] = useState(initialFilters); const [viewMode, setViewMode] = useState<'list' | 'grid'>(initialViewMode); + const [selectedRows, setSelectedRows] = useState>(new Set()); const [isPending, startTransition] = useTransition(); const handleFilter = (data: Partial) => { @@ -62,6 +63,7 @@ export const NotificationListPure: React.FC = ({ setFilters(newFilters); setData(newData); + setSelectedRows(new Set()); } catch (_error) { toast({ variant: 'destructive', @@ -83,6 +85,7 @@ export const NotificationListPure: React.FC = ({ setFilters(initialFilters); setData(newData); + setSelectedRows(new Set()); } catch (_error) { toast({ variant: 'destructive', @@ -201,6 +204,10 @@ export const NotificationListPure: React.FC = ({ columns={columns} actions={actions} cardHeaderSlots={data.cardHeaderSlots} + enableRowSelection={component.enableRowSelection} + selectedRows={selectedRows} + onSelectionChange={setSelectedRows} + getRowKey={(item) => item.id} /> {data.pagination && ( diff --git a/packages/blocks/notification-list/src/frontend/NotificationList.types.ts b/packages/blocks/notification-list/src/frontend/NotificationList.types.ts index 89da92ebd..0a3705b26 100644 --- a/packages/blocks/notification-list/src/frontend/NotificationList.types.ts +++ b/packages/blocks/notification-list/src/frontend/NotificationList.types.ts @@ -8,6 +8,7 @@ export interface NotificationListProps { locale: string; routing: ReturnType; hasPriority?: boolean; + enableRowSelection?: boolean; } export type NotificationListPureProps = NotificationListProps & Model.NotificationListBlock; diff --git a/packages/blocks/order-list/src/frontend/OrderList.client.tsx b/packages/blocks/order-list/src/frontend/OrderList.client.tsx index e1d899d91..7ad63bcb2 100644 --- a/packages/blocks/order-list/src/frontend/OrderList.client.tsx +++ b/packages/blocks/order-list/src/frontend/OrderList.client.tsx @@ -52,6 +52,7 @@ export const OrderListPure: React.FC = ({ locale, accessToke const [data, setData] = useState(component); const [filters, setFilters] = useState(initialFilters); const [viewMode, setViewMode] = useState<'list' | 'grid'>(initialViewMode); + const [selectedRows, setSelectedRows] = useState>(new Set()); const [isPending, startTransition] = useTransition(); @@ -60,8 +61,10 @@ export const OrderListPure: React.FC = ({ locale, accessToke try { const newFilters = { ...filters, ...data }; const newData = await sdk.blocks.getOrderList(newFilters, { 'x-locale': locale }, accessToken); + setFilters(newFilters); setData(newData); + setSelectedRows(new Set()); } catch (_error) { toast({ variant: 'destructive', @@ -78,6 +81,7 @@ export const OrderListPure: React.FC = ({ locale, accessToke const newData = await sdk.blocks.getOrderList(initialFilters, { 'x-locale': locale }, accessToken); setFilters(initialFilters); setData(newData); + setSelectedRows(new Set()); } catch (_error) { toast({ variant: 'destructive', @@ -207,6 +211,9 @@ export const OrderListPure: React.FC = ({ locale, accessToke columns={columns} actions={actions} cardHeaderSlots={data.cardHeaderSlots} + enableRowSelection={component.enableRowSelection} + selectedRows={selectedRows} + onSelectionChange={setSelectedRows} getRowKey={(item) => item.id.value} /> diff --git a/packages/blocks/order-list/src/frontend/OrderList.types.ts b/packages/blocks/order-list/src/frontend/OrderList.types.ts index 21d2932fc..d145e1949 100644 --- a/packages/blocks/order-list/src/frontend/OrderList.types.ts +++ b/packages/blocks/order-list/src/frontend/OrderList.types.ts @@ -8,6 +8,7 @@ export interface OrderListProps { locale: string; routing: ReturnType; hasPriority?: boolean; + enableRowSelection?: boolean; } export interface OrderListRendererProps extends Omit { diff --git a/packages/blocks/product-list/src/frontend/ProductList.client.tsx b/packages/blocks/product-list/src/frontend/ProductList.client.tsx index e9b74bb39..dde283d38 100644 --- a/packages/blocks/product-list/src/frontend/ProductList.client.tsx +++ b/packages/blocks/product-list/src/frontend/ProductList.client.tsx @@ -35,6 +35,7 @@ export const ProductListPure: React.FC = ({ locale, access const [filters, setFilters] = useState(initialFilters); const [isPending, startTransition] = useTransition(); const [viewMode, setViewMode] = useState('grid'); + const [selectedRows, setSelectedRows] = useState>(new Set()); const handleFilter = (data: Partial) => { startTransition(async () => { @@ -42,6 +43,7 @@ export const ProductListPure: React.FC = ({ locale, access const newData = await sdk.blocks.getProductList(newFilters, { 'x-locale': locale }, accessToken); setFilters(newFilters); setData(newData); + setSelectedRows(new Set()); }); }; @@ -50,6 +52,7 @@ export const ProductListPure: React.FC = ({ locale, access const newData = await sdk.blocks.getProductList(initialFilters, { 'x-locale': locale }, accessToken); setFilters(initialFilters); setData(newData); + setSelectedRows(new Set()); }); }; @@ -162,6 +165,9 @@ export const ProductListPure: React.FC = ({ locale, access columns={columns} actions={actions} getRowKey={(item) => item.id} + enableRowSelection={component.enableRowSelection} + selectedRows={selectedRows} + onSelectionChange={setSelectedRows} />
)} diff --git a/packages/blocks/product-list/src/frontend/ProductList.types.ts b/packages/blocks/product-list/src/frontend/ProductList.types.ts index 41b6cbfc0..4e9699024 100644 --- a/packages/blocks/product-list/src/frontend/ProductList.types.ts +++ b/packages/blocks/product-list/src/frontend/ProductList.types.ts @@ -7,6 +7,7 @@ export interface ProductListProps { accessToken?: string; locale: string; routing: ReturnType; + enableRowSelection?: boolean; } export type ProductListPureProps = ProductListProps & Model.ProductListBlock; diff --git a/packages/blocks/ticket-list/src/frontend/TicketList.client.tsx b/packages/blocks/ticket-list/src/frontend/TicketList.client.tsx index 31c9dd011..6694d517a 100644 --- a/packages/blocks/ticket-list/src/frontend/TicketList.client.tsx +++ b/packages/blocks/ticket-list/src/frontend/TicketList.client.tsx @@ -49,6 +49,7 @@ export const TicketListPure: React.FC = ({ locale, accessTo const [data, setData] = useState(component); const [filters, setFilters] = useState(initialFilters); const [viewMode, setViewMode] = useState<'list' | 'grid'>(initialViewMode); + const [selectedRows, setSelectedRows] = useState>(new Set()); const [isPending, startTransition] = useTransition(); @@ -59,6 +60,7 @@ export const TicketListPure: React.FC = ({ locale, accessTo const newData = await sdk.blocks.getTicketList(newFilters, { 'x-locale': locale }, accessToken); setFilters(newFilters); setData(newData); + setSelectedRows(new Set()); } catch (_error) { toast({ variant: 'destructive', @@ -75,6 +77,7 @@ export const TicketListPure: React.FC = ({ locale, accessTo const newData = await sdk.blocks.getTicketList(initialFilters, { 'x-locale': locale }, accessToken); setFilters(initialFilters); setData(newData); + setSelectedRows(new Set()); } catch (_error) { toast({ variant: 'destructive', @@ -214,6 +217,10 @@ export const TicketListPure: React.FC = ({ locale, accessTo columns={columns} actions={actions} cardHeaderSlots={data.cardHeaderSlots} + enableRowSelection={component.enableRowSelection} + selectedRows={selectedRows} + onSelectionChange={setSelectedRows} + getRowKey={(item) => item.id} /> {data.pagination && ( diff --git a/packages/blocks/ticket-list/src/frontend/TicketList.types.ts b/packages/blocks/ticket-list/src/frontend/TicketList.types.ts index 62c2a7c11..2c6813235 100644 --- a/packages/blocks/ticket-list/src/frontend/TicketList.types.ts +++ b/packages/blocks/ticket-list/src/frontend/TicketList.types.ts @@ -9,6 +9,7 @@ export interface TicketListProps { routing: ReturnType; hasPriority?: boolean; isDraftModeEnabled?: boolean; + enableRowSelection?: boolean; } export type TicketListPureProps = TicketListProps & Model.TicketListBlock; diff --git a/packages/framework/src/modules/cms/models/blocks/invoice-list.model.ts b/packages/framework/src/modules/cms/models/blocks/invoice-list.model.ts index 5fe09e2e4..cac643526 100644 --- a/packages/framework/src/modules/cms/models/blocks/invoice-list.model.ts +++ b/packages/framework/src/modules/cms/models/blocks/invoice-list.model.ts @@ -27,4 +27,7 @@ export class InvoiceListBlock extends Block.Block { right?: string; bottom?: string; }; + enableRowSelection?: boolean; + bulkActionsLabel?: string; + downloadAllButtonLabel?: string; } diff --git a/packages/integrations/contentful-cms/src/modules/cms/mappers/blocks/cms.invoice-list.mapper.ts b/packages/integrations/contentful-cms/src/modules/cms/mappers/blocks/cms.invoice-list.mapper.ts index 0545b248c..1bbe59f03 100644 --- a/packages/integrations/contentful-cms/src/modules/cms/mappers/blocks/cms.invoice-list.mapper.ts +++ b/packages/integrations/contentful-cms/src/modules/cms/mappers/blocks/cms.invoice-list.mapper.ts @@ -114,6 +114,9 @@ const MOCK_INVOICE_LIST_BLOCK_EN: CMS.Model.InvoiceListBlock.InvoiceListBlock = }, downloadFileName: 'invoice-{id}.pdf', downloadButtonAriaDescription: 'Download invoice {id}', + enableRowSelection: true, + bulkActionsLabel: '{count, plural, one {# item selected} other {# items selected}}', + downloadAllButtonLabel: 'Download selected', }; const MOCK_INVOICE_LIST_BLOCK_DE: CMS.Model.InvoiceListBlock.InvoiceListBlock = { @@ -230,6 +233,9 @@ const MOCK_INVOICE_LIST_BLOCK_DE: CMS.Model.InvoiceListBlock.InvoiceListBlock = }, downloadFileName: 'rechnung-{id}.pdf', downloadButtonAriaDescription: 'Rechnung {id} herunterladen', + enableRowSelection: true, + bulkActionsLabel: '{count, plural, one {# Element ausgewählt} other {# Elemente ausgewählt}}', + downloadAllButtonLabel: 'Ausgewählte herunterladen', }; const MOCK_INVOICE_LIST_BLOCK_PL: CMS.Model.InvoiceListBlock.InvoiceListBlock = { @@ -346,6 +352,10 @@ const MOCK_INVOICE_LIST_BLOCK_PL: CMS.Model.InvoiceListBlock.InvoiceListBlock = }, downloadFileName: 'faktura-{id}.pdf', downloadButtonAriaDescription: 'Pobierz fakturę {id}', + enableRowSelection: true, + bulkActionsLabel: + '{count, plural, one {Wybrano # element} few {Wybrano # elementy} many {Wybrano # elementów} other {Wybrano # elementów}}', + downloadAllButtonLabel: 'Pobierz zaznaczone', }; export const mapInvoiceListBlock = (locale: string): CMS.Model.InvoiceListBlock.InvoiceListBlock => { diff --git a/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.invoice-list.mapper.ts b/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.invoice-list.mapper.ts index f2bec90f4..6cf745698 100644 --- a/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.invoice-list.mapper.ts +++ b/packages/integrations/mocked/src/modules/cms/mappers/blocks/cms.invoice-list.mapper.ts @@ -132,6 +132,9 @@ const MOCK_INVOICE_LIST_BLOCK_EN: CMS.Model.InvoiceListBlock.InvoiceListBlock = right: 'paymentStatus', bottom: 'paymentDueDate', }, + enableRowSelection: true, + bulkActionsLabel: '{count, plural, one {# item selected} other {# items selected}}', + downloadAllButtonLabel: 'Download selected', }; const MOCK_INVOICE_LIST_BLOCK_DE: CMS.Model.InvoiceListBlock.InvoiceListBlock = { @@ -266,6 +269,9 @@ const MOCK_INVOICE_LIST_BLOCK_DE: CMS.Model.InvoiceListBlock.InvoiceListBlock = right: 'paymentStatus', bottom: 'paymentDueDate', }, + enableRowSelection: true, + bulkActionsLabel: '{count, plural, one {# Element ausgewählt} other {# Elemente ausgewählt}}', + downloadAllButtonLabel: 'Ausgewählte herunterladen', }; const MOCK_INVOICE_LIST_BLOCK_PL: CMS.Model.InvoiceListBlock.InvoiceListBlock = { @@ -400,6 +406,10 @@ const MOCK_INVOICE_LIST_BLOCK_PL: CMS.Model.InvoiceListBlock.InvoiceListBlock = right: 'paymentStatus', bottom: 'paymentDueDate', }, + enableRowSelection: true, + bulkActionsLabel: + '{count, plural, one {Wybrano # element} few {Wybrano # elementy} many {Wybrano # elementów} other {Wybrano # elementów}}', + downloadAllButtonLabel: 'Pobierz zaznaczone', }; export const mapInvoiceListBlock = (locale: string): CMS.Model.InvoiceListBlock.InvoiceListBlock => { diff --git a/packages/integrations/strapi-cms/src/modules/cms/mappers/blocks/cms.invoice-list.mapper.ts b/packages/integrations/strapi-cms/src/modules/cms/mappers/blocks/cms.invoice-list.mapper.ts index 44b9fc4c6..b416e4bcd 100644 --- a/packages/integrations/strapi-cms/src/modules/cms/mappers/blocks/cms.invoice-list.mapper.ts +++ b/packages/integrations/strapi-cms/src/modules/cms/mappers/blocks/cms.invoice-list.mapper.ts @@ -39,6 +39,9 @@ export const mapInvoiceListBlock = (data: GetComponentQuery): CMS.Model.InvoiceL downloadFileName: component.downloadFileName, downloadButtonAriaDescription: component.downloadButtonAriaDescription, initialFilters: undefined, // TODO: add initial filters field in CMS + enableRowSelection: undefined, // TODO: add enableRowSelection field in CMS + bulkActionsLabel: undefined, // TODO: add bulkActionsLabel field in CMS + downloadAllButtonLabel: undefined, // TODO: add downloadAllButtonLabel field in CMS }; } diff --git a/packages/ui/src/components/Cards/InformativeCard/InformativeCard.tsx b/packages/ui/src/components/Cards/InformativeCard/InformativeCard.tsx index bd87b440f..39ea23dd6 100644 --- a/packages/ui/src/components/Cards/InformativeCard/InformativeCard.tsx +++ b/packages/ui/src/components/Cards/InformativeCard/InformativeCard.tsx @@ -4,11 +4,10 @@ import React from 'react'; import { cn } from '@o2s/ui/lib/utils'; import { DynamicIcon } from '@o2s/ui/components/DynamicIcon'; +import { RichText } from '@o2s/ui/components/RichText'; import { Typography } from '@o2s/ui/elements/typography'; -import { RichText } from '../../RichText'; - import { InformativeCardProps } from './InformativeCard.types'; const InformativeCardContent: React.FC> = ({ diff --git a/packages/ui/src/components/Cards/InformativeCard/InformativeCard.types.ts b/packages/ui/src/components/Cards/InformativeCard/InformativeCard.types.ts index 0a70465b5..bdcfb6c88 100644 --- a/packages/ui/src/components/Cards/InformativeCard/InformativeCard.types.ts +++ b/packages/ui/src/components/Cards/InformativeCard/InformativeCard.types.ts @@ -1,6 +1,6 @@ import { Models } from '@o2s/utils.frontend'; -import { DynamicIconProps } from '../../DynamicIcon'; +import { DynamicIconProps } from '@o2s/ui/components/DynamicIcon'; export interface InformativeCardMeta { __id: string; diff --git a/packages/ui/src/components/Cards/PricingCard/PricingCard.tsx b/packages/ui/src/components/Cards/PricingCard/PricingCard.tsx index 5c2e8a8eb..f4efee806 100644 --- a/packages/ui/src/components/Cards/PricingCard/PricingCard.tsx +++ b/packages/ui/src/components/Cards/PricingCard/PricingCard.tsx @@ -6,6 +6,7 @@ import { cn } from '@o2s/ui/lib/utils'; import { DynamicIcon } from '@o2s/ui/components/DynamicIcon'; import { Image } from '@o2s/ui/components/Image'; +import { LinkList } from '@o2s/ui/components/LinkList'; import { Price } from '@o2s/ui/components/Price'; import { RichText } from '@o2s/ui/components/RichText'; import { TooltipHover } from '@o2s/ui/components/TooltipHover'; @@ -14,8 +15,6 @@ import { Badge } from '@o2s/ui/elements/badge'; import { Button } from '@o2s/ui/elements/button'; import { Typography } from '@o2s/ui/elements/typography'; -import { LinkList } from '../../LinkList'; - import { FeatureItemProps, PricingCardProps } from './PricingCard.types'; export const FeatureItem: React.FC = ({ title, description, icon }) => { diff --git a/packages/ui/src/components/DataGrid/DataGrid.stories.tsx b/packages/ui/src/components/DataGrid/DataGrid.stories.tsx index 8c318c2a8..2031206d1 100644 --- a/packages/ui/src/components/DataGrid/DataGrid.stories.tsx +++ b/packages/ui/src/components/DataGrid/DataGrid.stories.tsx @@ -1,9 +1,9 @@ import type { Meta, StoryObj } from '@storybook/react'; import { ArrowRight, Download } from 'lucide-react'; -import { Button } from '@o2s/ui/elements/button'; +import type { DataListColumnConfig } from '@o2s/ui/components/DataList'; -import type { DataListColumnConfig } from '../DataList/DataList.types'; +import { Button } from '@o2s/ui/elements/button'; import { DataGrid } from './DataGrid'; import type { DataGridProps } from './DataGrid.types'; diff --git a/packages/ui/src/components/DataGrid/DataGrid.tsx b/packages/ui/src/components/DataGrid/DataGrid.tsx index 7cd2e1ad8..96e975cf9 100644 --- a/packages/ui/src/components/DataGrid/DataGrid.tsx +++ b/packages/ui/src/components/DataGrid/DataGrid.tsx @@ -2,12 +2,13 @@ import React from 'react'; import { cn } from '@o2s/ui/lib/utils'; +import { DataListColumnConfig } from '@o2s/ui/components/DataList'; + import { Card, CardContent, CardFooter, CardHeader } from '@o2s/ui/elements/card'; import { Separator } from '@o2s/ui/elements/separator'; import { Typography } from '@o2s/ui/elements/typography'; import { renderCell } from '../../lib/renderCell'; -import { DataListColumnConfig } from '../DataList/DataList.types'; import { DataGridProps } from './DataGrid.types'; diff --git a/packages/ui/src/components/DataList/DataList.stories.tsx b/packages/ui/src/components/DataList/DataList.stories.tsx index bb246917a..854521abd 100644 --- a/packages/ui/src/components/DataList/DataList.stories.tsx +++ b/packages/ui/src/components/DataList/DataList.stories.tsx @@ -1,4 +1,8 @@ import type { Meta, StoryObj } from '@storybook/react'; +import { Download, Trash2 } from 'lucide-react'; +import React, { useState } from 'react'; + +import { Button } from '@o2s/ui/elements/button'; import { DataList } from './DataList'; import type { DataListColumnConfig } from './DataList.types'; @@ -210,3 +214,78 @@ export const WithCustomStyling: Story = { className: 'border-2 border-gray-300', }, }; + +export const WithRowSelection: Story = { + render: () => { + const Component = () => { + const [selectedRows, setSelectedRows] = useState>(new Set()); + + return ( +
+
+ {selectedRows.size} of {sampleTickets.length} row(s) selected. +
+ []} + columns={ticketColumns as DataListColumnConfig>[]} + enableRowSelection={true} + selectedRows={selectedRows} + onSelectionChange={setSelectedRows} + /> +
+ ); + }; + + return ; + }, +}; + +export const WithRowSelectionAndBulkActions: Story = { + render: () => { + const Component = () => { + const [selectedRows, setSelectedRows] = useState>(new Set()); + + const selectedItems = sampleTickets.filter((ticket, index) => selectedRows.has(ticket.id || index)); + + const handleBulkDelete = () => { + console.log('Deleting tickets:', selectedItems); + setSelectedRows(new Set()); + }; + + const handleBulkExport = () => { + console.log('Exporting tickets:', selectedItems); + }; + + return ( +
+ {selectedRows.size > 0 && ( +
+ + {selectedRows.size} {selectedRows.size === 1 ? 'ticket' : 'tickets'} selected + +
+ + +
+
+ )} + []} + columns={ticketColumns as DataListColumnConfig>[]} + enableRowSelection={true} + selectedRows={selectedRows} + onSelectionChange={setSelectedRows} + /> +
+ ); + }; + + return ; + }, +}; diff --git a/packages/ui/src/components/DataList/DataList.tsx b/packages/ui/src/components/DataList/DataList.tsx index 65dbc0959..9a6cd61a8 100644 --- a/packages/ui/src/components/DataList/DataList.tsx +++ b/packages/ui/src/components/DataList/DataList.tsx @@ -1,8 +1,11 @@ -import React from 'react'; +import React, { useMemo } from 'react'; + +import { cn } from '@o2s/ui/lib/utils'; + +import { Checkbox } from '@o2s/ui/elements/checkbox'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@o2s/ui/elements/table'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../elements/table'; import { renderCell } from '../../lib/renderCell'; -import { cn } from '../../lib/utils'; import { DataListProps } from './DataList.types'; @@ -17,6 +20,9 @@ export function DataList>({ getRowKey, className, getRowClassName, + enableRowSelection = false, + selectedRows, + onSelectionChange, }: DataListProps) { // Default row key extractor const defaultGetRowKey = (item: T, index: number) => { @@ -28,10 +34,60 @@ export function DataList>({ const rowKeyExtractor = getRowKey || defaultGetRowKey; + // Row selection handlers + const handleSelectAll = (checked: boolean) => { + if (!onSelectionChange) return; + + const allKeysOnCurrentPage = new Set(data.map((item, index) => rowKeyExtractor(item, index))); + const currentSelected = new Set(selectedRows || []); + + if (checked) { + // Add all keys from current page to existing selection + allKeysOnCurrentPage.forEach((key) => currentSelected.add(key)); + } else { + // Remove only keys from current page from selection + allKeysOnCurrentPage.forEach((key) => currentSelected.delete(key)); + } + + onSelectionChange(currentSelected); + }; + + const handleRowSelect = (rowKey: string | number, checked: boolean) => { + if (!onSelectionChange) return; + + const newSelected = new Set(selectedRows || []); + if (checked) { + newSelected.add(rowKey); + } else { + newSelected.delete(rowKey); + } + onSelectionChange(newSelected); + }; + + // Calculate selection state + const allRowKeys = useMemo(() => data.map((item, index) => rowKeyExtractor(item, index)), [data, rowKeyExtractor]); + // Count only selected items on current page + const selectedOnCurrentPage = useMemo( + () => allRowKeys.filter((key) => selectedRows?.has(key)).length, + [allRowKeys, selectedRows], + ); + const allSelected = + enableRowSelection && data.length > 0 && selectedOnCurrentPage === allRowKeys.length && allRowKeys.length > 0; + const someSelected = enableRowSelection && selectedOnCurrentPage > 0 && selectedOnCurrentPage < allRowKeys.length; + return ( + {enableRowSelection && ( + + + + )} {columns.map((column) => ( >({ {data.map((item, index) => { const rowKey = rowKeyExtractor(item, index); const rowClassName = getRowClassName ? getRowClassName(item) : undefined; + const isRowSelected = enableRowSelection && selectedRows?.has(rowKey); return ( + {enableRowSelection && ( + + handleRowSelect(rowKey, !!checked)} + aria-label="Select row" + /> + + )} {columns.map((column) => { const value = item[column.id]; const cellContent = renderCell(value, item, column); diff --git a/packages/ui/src/components/DataList/DataList.types.ts b/packages/ui/src/components/DataList/DataList.types.ts index 687a5cec7..eff0ef9d3 100644 --- a/packages/ui/src/components/DataList/DataList.types.ts +++ b/packages/ui/src/components/DataList/DataList.types.ts @@ -151,4 +151,19 @@ export interface DataListProps { * Optional row className function */ getRowClassName?: (item: T) => string; + + /** + * Enable row selection with checkboxes + */ + enableRowSelection?: boolean; + + /** + * Set of selected row keys (controlled mode) + */ + selectedRows?: Set; + + /** + * Callback when selection changes + */ + onSelectionChange?: (selected: Set) => void; } diff --git a/packages/ui/src/components/DataView/DataView.tsx b/packages/ui/src/components/DataView/DataView.tsx index 85e317250..58ff50ca6 100644 --- a/packages/ui/src/components/DataView/DataView.tsx +++ b/packages/ui/src/components/DataView/DataView.tsx @@ -10,14 +10,38 @@ export const DataView = >({ viewMode, cardHeaderSlots, columnsCount, + enableRowSelection, + selectedRows, + onSelectionChange, + bulkActions, + bulkActionsLabel, + data, ...props }: DataViewProps) => { + const selectedCount = selectedRows?.size || 0; + const showBulkActions = + enableRowSelection && bulkActions && selectedRows && selectedCount > 0 && viewMode !== 'grid'; + return ( <> + {showBulkActions && ( +
+ {bulkActionsLabel && ( + {bulkActionsLabel(selectedCount)} + )} +
{bulkActions(selectedRows)}
+
+ )} {viewMode === 'grid' ? ( - + ) : ( - + )} ); diff --git a/packages/ui/src/components/DataView/DataView.types.ts b/packages/ui/src/components/DataView/DataView.types.ts index 16fafd0c9..1a42c8847 100644 --- a/packages/ui/src/components/DataView/DataView.types.ts +++ b/packages/ui/src/components/DataView/DataView.types.ts @@ -1,3 +1,5 @@ +import { ReactNode } from 'react'; + import { DataListActionsConfig, DataListColumnConfig, DataListProps } from '../DataList'; export interface DataViewProps { @@ -16,5 +18,10 @@ export interface DataViewProps { data: T[]; columns: DataListColumnConfig[]; actions?: DataListActionsConfig; + enableRowSelection?: boolean; + selectedRows?: Set; + onSelectionChange?: (selected: Set) => void; + bulkActions?: (selectedRowKeys: Set) => ReactNode; + bulkActionsLabel?: (count: number) => string; getRowKey?: DataListProps['getRowKey']; } diff --git a/packages/ui/src/components/LinkList/LinkList.tsx b/packages/ui/src/components/LinkList/LinkList.tsx index 58323e024..32df7f29d 100644 --- a/packages/ui/src/components/LinkList/LinkList.tsx +++ b/packages/ui/src/components/LinkList/LinkList.tsx @@ -2,9 +2,9 @@ import React from 'react'; import { cn } from '@o2s/ui/lib/utils'; -import { Link } from '@o2s/ui/elements/link'; +import { DynamicIcon } from '@o2s/ui/components/DynamicIcon'; -import { DynamicIcon } from '../DynamicIcon'; +import { Link } from '@o2s/ui/elements/link'; import { LinkListProps } from './LinkList.types'; diff --git a/packages/ui/src/lib/renderCell.tsx b/packages/ui/src/lib/renderCell.tsx index 4317ce933..50afab775 100644 --- a/packages/ui/src/lib/renderCell.tsx +++ b/packages/ui/src/lib/renderCell.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import { DataListColumnConfig } from '@o2s/ui/components/DataList'; import { Price } from '@o2s/ui/components/Price'; import { Badge } from '@o2s/ui/elements/badge'; @@ -7,8 +8,6 @@ import { BadgeStatus } from '@o2s/ui/elements/badge-status'; import { Button } from '@o2s/ui/elements/button'; import { Link } from '@o2s/ui/elements/link'; -import { DataListColumnConfig } from '../components/DataList/DataList.types'; - /** * Default cell renderer based on column type * Shared utility for both DataList and DataGrid components @@ -56,7 +55,7 @@ export function renderCell( case 'price': { if (typeof value === 'object' && value !== null && 'value' in value) { - const priceValue = value.value as { value: number; currency?: string }; + 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';