diff --git a/packages/app-elements/package.json b/packages/app-elements/package.json index caa54ecd0..bc0b4fad3 100644 --- a/packages/app-elements/package.json +++ b/packages/app-elements/package.json @@ -51,6 +51,7 @@ "@commercelayer/sdk": "8.0.0-beta.11", "@date-fns/tz": "^1.5.0", "@monaco-editor/react": "~4.7.0", + "@tanstack/react-table": "^9.0.0", "@types/lodash-es": "^4.17.12", "@types/react": "19.2.13", "@types/react-datepicker": "^7.0.0", diff --git a/packages/app-elements/src/main.ts b/packages/app-elements/src/main.ts index 01b1497cd..8a41ccd73 100644 --- a/packages/app-elements/src/main.ts +++ b/packages/app-elements/src/main.ts @@ -430,4 +430,12 @@ export { type UseResourceListConfig, useResourceList, } from "#ui/resources/useResourceList" +export { + type ResourceTableColumn, + type ResourceTableProps, + type ResourceTableSort, + type UseResourceTableConfig, + type UseResourceTableReturn, + useResourceTable, +} from "#ui/resources/useResourceTable" export { useTrackingDetails } from "#ui/resources/useTrackingDetails" diff --git a/packages/app-elements/src/ui/atoms/Container.tsx b/packages/app-elements/src/ui/atoms/Container.tsx index cf991b900..06d4e7a53 100644 --- a/packages/app-elements/src/ui/atoms/Container.tsx +++ b/packages/app-elements/src/ui/atoms/Container.tsx @@ -5,6 +5,16 @@ export interface ContainerProps { * Set min height as screen size. Default is `true`. */ minHeight?: boolean + /** + * Let the content span all the available width instead of being constrained + * to the standard readable column (632px from the `md` breakpoint up). + * + * Use it for data-dense pages such as tables. When the app runs inside the + * dashboard, the horizontal breathing room comes from the dashboard layout, + * which also needs to render the route without the legacy side column. + * @default false + */ + fullWidth?: boolean /** * CSS class name */ @@ -20,6 +30,7 @@ export const Container: React.FC = ({ children, className, minHeight = true, + fullWidth = false, ...rest }) => { return ( @@ -27,6 +38,9 @@ export const Container: React.FC = ({ className={cn( "container mx-auto flex flex-col px-4 md:px-0", { "min-h-screen": minHeight }, + // `md:max-w-none` opts out of the capped width set by the `container` + // utility (see styles/global.css) + { "w-full md:max-w-none": fullWidth }, className, )} {...rest} diff --git a/packages/app-elements/src/ui/composite/HomePageLayout.tsx b/packages/app-elements/src/ui/composite/HomePageLayout.tsx index eea7895b9..62acf5212 100644 --- a/packages/app-elements/src/ui/composite/HomePageLayout.tsx +++ b/packages/app-elements/src/ui/composite/HomePageLayout.tsx @@ -2,9 +2,11 @@ import type { JSX, ReactNode } from "react" import { useTokenProvider } from "#providers/TokenProvider" import type { PageHeadingProps } from "#ui/atoms/PageHeading" import type { PageHeadingToolbarProps } from "#ui/atoms/PageHeading/PageHeadingToolbar" -import { PageLayout } from "./PageLayout" +import { PageLayout, type PageLayoutProps } from "./PageLayout" -export interface HomePageLayoutProps extends Pick { +export interface HomePageLayoutProps + extends Pick, + Pick { /** * Page content */ @@ -23,6 +25,7 @@ export function HomePageLayout({ title, children, toolbar, + fullWidth, }: HomePageLayoutProps): JSX.Element { const { settings: { mode, dashboardUrl, isInDashboard, onAppClose }, @@ -34,6 +37,7 @@ export function HomePageLayout({ mode={mode} gap="only-top" scrollToTop + fullWidth={fullWidth} navigationButton={ isInDashboard && onAppClose == null ? undefined diff --git a/packages/app-elements/src/ui/composite/PageLayout.tsx b/packages/app-elements/src/ui/composite/PageLayout.tsx index 4374683c8..570e8c7e3 100644 --- a/packages/app-elements/src/ui/composite/PageLayout.tsx +++ b/packages/app-elements/src/ui/composite/PageLayout.tsx @@ -12,7 +12,7 @@ export type PageLayoutProps = Pick< PageHeadingProps, "title" | "description" | "navigationButton" | "toolbar" | "gap" > & - Pick & { + Pick & { /** * Page content */ @@ -52,6 +52,7 @@ export const PageLayout = withSkeletonTemplate( mode, gap, minHeight, + fullWidth, scrollToTop, overlay = false, isLoading, @@ -98,7 +99,14 @@ export const PageLayout = withSkeletonTemplate( } return ( - + {component} ) diff --git a/packages/app-elements/src/ui/resources/useResourceFilters/FiltersSearchBar.tsx b/packages/app-elements/src/ui/resources/useResourceFilters/FiltersSearchBar.tsx index 12513c25b..5b0f43edc 100644 --- a/packages/app-elements/src/ui/resources/useResourceFilters/FiltersSearchBar.tsx +++ b/packages/app-elements/src/ui/resources/useResourceFilters/FiltersSearchBar.tsx @@ -11,7 +11,7 @@ import type { } from "./types" export interface FilterSearchBarProps - extends Pick { + extends Pick { /** * Array of instruction items to build the filters behaviors */ @@ -49,6 +49,7 @@ function FiltersSearchBar({ queryString, predicateWhitelist, debounceMs, + variant, }: FilterSearchBarProps): JSX.Element { const { adaptUrlQueryToFormValues, adaptFormValuesToUrlQuery } = makeFilterAdapters({ @@ -97,6 +98,7 @@ function FiltersSearchBar({ onSearch={updateTextFilter} autoFocus={safeInitialValue !== undefined && safeInitialValue.length > 0} debounceMs={debounceMs} + variant={variant} /> ) } diff --git a/packages/app-elements/src/ui/resources/useResourceFilters/useResourceFilters.tsx b/packages/app-elements/src/ui/resources/useResourceFilters/useResourceFilters.tsx index ef539e561..6a496b21c 100644 --- a/packages/app-elements/src/ui/resources/useResourceFilters/useResourceFilters.tsx +++ b/packages/app-elements/src/ui/resources/useResourceFilters/useResourceFilters.tsx @@ -3,6 +3,7 @@ import { type JSX, useCallback, useEffect, useMemo, useState } from "react" import { useTranslation } from "react-i18next" import { useTokenProvider } from "#providers/TokenProvider" import { Spacer } from "#ui/atoms/Spacer" +import type { SearchBarProps } from "#ui/composite/SearchBar" import { type UseResourceListConfig, useResourceList, @@ -11,6 +12,11 @@ import type { ResourceListProps, UseResourceListReturnWithPagination, } from "#ui/resources/useResourceList/useResourceList" +import { useResourceTable } from "#ui/resources/useResourceTable" +import type { + ResourceTableProps, + UseResourceTableConfig, +} from "#ui/resources/useResourceTable/types" import { makeFilterAdapters } from "./adapters" import { FiltersForm as FiltersFormComponent, @@ -62,6 +68,11 @@ interface UseResourceFiltersHook { * @default 'Search...' */ searchBarPlaceholder?: string + /** + * Visual variant of the search bar. Use `outline` to match the style used + * in the dashboard (metrics) pages. + */ + searchBarVariant?: SearchBarProps["variant"] /** * Milliseconds to wait before triggering the search bar callback * @default 500 @@ -93,6 +104,28 @@ interface UseResourceFiltersHook { hideTitle?: boolean }, ) => React.ReactNode + /** + * Filtered ResourceTable component based on current active filters. + * Table sibling of `FilteredList`: renders a column-model data table wired + * to the active search/filters and pagination. + */ + FilteredTable: ( + props: Omit, "query" | "metricsQuery"> & + ResourceTableProps & { + query?: Omit< + NonNullable["query"]>, + "filters" + > + metricsQuery?: Omit< + NonNullable["metricsQuery"]>, + "filter" + > & { + /** Filters need to be configured within the `useResourceFilters` options. */ + filter?: never + } + hideTitle?: boolean + }, + ) => React.ReactNode /** * SDK filters object to be used in the sdk query */ @@ -148,6 +181,11 @@ export function useResourceFilters({ [sdkFilters], ) + const FilteredTable = useMemo( + () => makeFilteredTable({ sdkFilters, adapters }), + [sdkFilters], + ) + const SearchWithNav = useMemo(() => { return makeSearchWithNav({ validInstructions, @@ -187,6 +225,7 @@ export function useResourceFilters({ SearchWithNav, FiltersForm, FilteredList, + FilteredTable, viewTitle, } } @@ -266,6 +305,83 @@ const makeFilteredList: (options: { ) } +// internal implementation of the ResourceTable component exposed from the useResourceTable hook +function ResourceTableComponent({ + type, + columns, + query, + metricsQuery, + preProcess, + paginationType = "pagination", + paginationScrollTo, + onRowClick, + getRowHref, + sort, + onSortChange, + defaultSort, + ...tableProps +}: UseResourceTableConfig & ResourceTableProps): JSX.Element { + const { ResourceTable, Pagination } = useResourceTable({ + type, + columns, + query, + metricsQuery, + preProcess, + paginationType, + paginationScrollTo, + onRowClick, + getRowHref, + sort, + onSortChange, + defaultSort, + }) + + return ( + <> + + + + ) +} + +const makeFilteredTable: (options: { + sdkFilters: QueryFilter | undefined + adapters: ReturnType +}) => UseResourceFiltersHook["FilteredTable"] = + ({ sdkFilters, adapters }) => + ({ type, query, metricsQuery, hideTitle, ...tableProps }) => { + const { t } = useTranslation() + + if (sdkFilters == null) { + return null + } + + return ( + + ) + } + const makeSearchWithNav: (_options: { validInstructions: FiltersInstructions predicateWhitelist: string[] @@ -276,6 +392,7 @@ const makeSearchWithNav: (_options: { onUpdate, searchBarPlaceholder, searchBarDebounceMs, + searchBarVariant, hideSearchBar, hideFiltersNav, // we need this value as prop to avoid re-rendering the component and losing the focus on searchbar @@ -296,6 +413,7 @@ const makeSearchWithNav: (_options: { queryString={queryStringProp} placeholder={searchBarPlaceholder ?? t("common.search")} debounceMs={searchBarDebounceMs} + variant={searchBarVariant} instructions={validInstructions} onUpdate={onUpdate} predicateWhitelist={predicateWhitelist} diff --git a/packages/app-elements/src/ui/resources/useResourceList/adaptMetricsOrderToCore.ts b/packages/app-elements/src/ui/resources/useResourceList/adaptMetricsOrderToCore.ts index e410c11ba..257982a8d 100644 --- a/packages/app-elements/src/ui/resources/useResourceList/adaptMetricsOrderToCore.ts +++ b/packages/app-elements/src/ui/resources/useResourceList/adaptMetricsOrderToCore.ts @@ -1,4 +1,4 @@ -import type { Order } from "@commercelayer/sdk" +import type { Customer, Order } from "@commercelayer/sdk" import type { CurrencyCode } from "#helpers/currencies" import { formatCentsToCurrency } from "#ui/forms/InputCurrency" @@ -73,6 +73,11 @@ export interface MetricsResourceOrder { first_name?: string last_name?: string } + customer?: { + id?: string + email?: string + total_orders_count?: number + } } export function adaptMetricsOrderToCore( @@ -279,5 +284,20 @@ export function adaptMetricsOrderToCore( zip_code: metricsOrder.billing_address.zip_code, } : undefined, + + customer: + metricsOrder.customer != null + ? // `Customer` requires a `status` that the metrics payload does not + // carry. It is left absent rather than invented, so anything reading it + // sees "unknown" instead of a plausible but wrong value. + ({ + id: metricsOrder.customer.id ?? "", + created_at: "", + updated_at: "", + type: "customers", + email: metricsOrder.customer.email ?? "", + total_orders_count: metricsOrder.customer.total_orders_count, + } as Customer) + : undefined, } } diff --git a/packages/app-elements/src/ui/resources/useResourceList/listFetcher.ts b/packages/app-elements/src/ui/resources/useResourceList/listFetcher.ts index 4a496bf05..3ad3808f1 100644 --- a/packages/app-elements/src/ui/resources/useResourceList/listFetcher.ts +++ b/packages/app-elements/src/ui/resources/useResourceList/listFetcher.ts @@ -37,11 +37,18 @@ export async function listFetcher({ query, mode = "infinite", pageNumber, + cursor, }: { currentData?: FetcherResponse> resourceType: TResource mode?: "infinite" | "pagination" pageNumber?: number + /** + * Metrics API only: the cursor that opens the requested page. Used in + * `pagination` mode, where the caller keeps track of one cursor per page + * (the metrics API can only move forward on its own). + */ + cursor?: string | null } & ( | { client: CommerceLayerBundle @@ -68,7 +75,13 @@ export async function listFetcher({ ...query, search: { ...query.search, - cursor: currentData?.meta.cursor ?? null, + // in pagination mode the caller owns the cursor (it can jump back to + // an already-visited page); in infinite mode we just keep going + // forward from the last response + cursor: + mode === "pagination" + ? (cursor ?? null) + : (currentData?.meta.cursor ?? null), }, }) : // @ts-expect-error "Expression produces a union type that is too complex to represent" @@ -87,8 +100,24 @@ export async function listFetcher({ : uniqBy(existingList.concat(listResponse), "id") // The core SDK's `meta.cursor` is an object we don't use here; keep only the // string cursor set by the metrics client for infinite scrolling. - const { cursor, ...rest } = listResponse.meta - const meta = { ...rest, cursor: typeof cursor === "string" ? cursor : null } + const { cursor: responseCursor, ...rest } = listResponse.meta + const meta = { + ...rest, + cursor: typeof responseCursor === "string" ? responseCursor : null, + } + + // The metrics API reports neither the current page nor a total page count + // (its `pageCount` is only a "has more" flag). In pagination mode the caller + // drives the page number, so derive honest values from the real `recordCount`. + // Infinite mode is left untouched: there `pageCount`/`currentPage` are what + // `hasMorePages` is computed from. + if (clientType === "metricsClient" && mode === "pagination") { + meta.currentPage = pageToFetch + meta.pageCount = + meta.recordsPerPage > 0 + ? Math.max(1, Math.ceil(meta.recordCount / meta.recordsPerPage)) + : 1 + } return { list: uniqueList, meta } } diff --git a/packages/app-elements/src/ui/resources/useResourceList/metricsApiClient.ts b/packages/app-elements/src/ui/resources/useResourceList/metricsApiClient.ts index 363d1aa97..4c6508d1b 100644 --- a/packages/app-elements/src/ui/resources/useResourceList/metricsApiClient.ts +++ b/packages/app-elements/src/ui/resources/useResourceList/metricsApiClient.ts @@ -119,12 +119,14 @@ const makeMetricsApiClient: MakeMetricsApiClient = ({ ] as unknown as ListResponseMetrics // fake meta just to make the list compatible with core sdk ListResponse - // plus the addition of `cursor` to support infinite scrolling with metrics api + // plus the addition of `cursor` to support infinite scrolling with metrics api. + // `pageCount` is a "has more" flag here (the metrics API cannot report a total + // page count); `listFetcher` derives a real one in pagination mode. list.meta = { pageCount: json.meta.pagination.cursor == null ? 1 : 2, recordCount: json.meta.pagination.record_count, currentPage: 1, - recordsPerPage: 25, + recordsPerPage: query.search?.limit ?? 25, cursor: json.meta.pagination.cursor, } diff --git a/packages/app-elements/src/ui/resources/useResourceList/useResourceList.tsx b/packages/app-elements/src/ui/resources/useResourceList/useResourceList.tsx index cc210ef8b..323ba2840 100644 --- a/packages/app-elements/src/ui/resources/useResourceList/useResourceList.tsx +++ b/packages/app-elements/src/ui/resources/useResourceList/useResourceList.tsx @@ -124,7 +124,9 @@ export type UseResourceListConfig = { preProcess?: (list: Array>) => Array> /** * Pagination type: 'infinite' for infinite scrolling (default), 'pagination' for classic prev/next pagination. - * Note: 'pagination' mode is only supported for Core API (not Metrics API). + * Works with both the Core API and the Metrics API. Since the Metrics API is + * cursor-based, prev/next works by remembering the cursor that opens each + * visited page; arbitrary page jumps are not possible there. */ paginationType?: "infinite" | "pagination" /** @@ -216,18 +218,25 @@ export function useResourceList({ ) const [currentPage, setCurrentPage] = React.useState(1) const listRef = React.useRef(null) + /** + * Metrics API + `pagination` mode only: the cursor that opens each page. + * Index 0 is page 1 (no cursor); after loading page N we learn the cursor for + * page N+1. The metrics API can only move forward, so remembering the cursors + * we have seen is what makes "previous page" possible. + */ + const metricsCursorsRef = React.useRef>([null]) - // Validate that pagination mode is not used with metrics API - if (paginationType === "pagination" && metricsQuery != null) { - throw new Error( - "Pagination mode is not supported with Metrics API. Please use infinite scrolling (default) or switch to Core API.", - ) - } + const resetMetricsCursors = useCallback(() => { + metricsCursorsRef.current = [null] + }, []) + // Both queries are watched: for metrics-backed lists `metricsQuery` is the one + // that actually drives the request (deep-compared, so inline objects are safe). const isQueryChanged = useIsChanged({ - value: query, + value: { query, metricsQuery }, onChange: () => { setCurrentPage(1) + resetMetricsCursors() dispatch({ type: "reset" }) void fetchMore({ query, pageNumber: 1 }) }, @@ -249,6 +258,13 @@ export function useResourceList({ resourceType: type, mode: paginationType, pageNumber, + // metrics pagination: hand over the cursor that opens the requested page + cursor: + metricsQuery != null && + paginationType === "pagination" && + pageNumber != null + ? (metricsCursorsRef.current[pageNumber - 1] ?? null) + : undefined, ...(metricsQuery != null ? { clientType: "metricsClient", @@ -261,6 +277,16 @@ export function useResourceList({ query, }), }) + // remember the cursor that will open the *next* page, so it can be + // revisited (forwards or backwards) without refetching from page 1 + if ( + metricsQuery != null && + paginationType === "pagination" && + pageNumber != null + ) { + metricsCursorsRef.current[pageNumber] = + listResponse.meta.cursor ?? null + } dispatch({ type: "loaded", payload: listResponse }) } catch (err) { dispatch({ type: "error", payload: parseApiErrorMessage(err) }) @@ -315,12 +341,13 @@ export function useResourceList({ const refresh = useCallback(() => { setCurrentPage(1) + resetMetricsCursors() dispatch({ type: "reset" }) void fetchMore({ query, pageNumber: paginationType === "pagination" ? 1 : undefined, }) - }, [query, paginationType, fetchMore]) + }, [query, paginationType, fetchMore, resetMetricsCursors]) const handlePageChange = useCallback( (newPage: number) => { diff --git a/packages/app-elements/src/ui/resources/useResourceTable/index.tsx b/packages/app-elements/src/ui/resources/useResourceTable/index.tsx new file mode 100644 index 000000000..bccc9f851 --- /dev/null +++ b/packages/app-elements/src/ui/resources/useResourceTable/index.tsx @@ -0,0 +1,8 @@ +export type { + ResourceTableColumn, + ResourceTableProps, + ResourceTableSort, + UseResourceTableConfig, + UseResourceTableReturn, +} from "./types" +export { useResourceTable } from "./useResourceTable" diff --git a/packages/app-elements/src/ui/resources/useResourceTable/types.ts b/packages/app-elements/src/ui/resources/useResourceTable/types.ts new file mode 100644 index 000000000..8331c0979 --- /dev/null +++ b/packages/app-elements/src/ui/resources/useResourceTable/types.ts @@ -0,0 +1,193 @@ +import type { ListableResourceType } from "@commercelayer/sdk" +import type { FC, ReactNode } from "react" +import type { SectionProps } from "#ui/atoms/Section" +import type { Resource } from "../useResourceList/listFetcher" +import type { UseResourceListConfig } from "../useResourceList/useResourceList" + +/** + * A single column definition for a `ResourceTable`. + * + * This is app-elements' own column type: TanStack Table is an implementation + * detail and its `ColumnDef` is intentionally not exposed here (see + * `docs/adr/0001-encapsulate-tanstack-table.md`). + */ +export interface ResourceTableColumn { + /** + * Header content. A plain string or any node (icon, tooltip, …). + */ + header: ReactNode + /** + * Cell renderer for this column. Receives the fetched resource for the row + * and returns whatever should be displayed in the cell. + */ + cell: (props: { resource: Resource }) => ReactNode + /** + * Stable, unique column id. + * When omitted it falls back to `sortBy`, then to a positional `col-`. + * Provide one explicitly when two columns would otherwise collide. + */ + id?: string + /** + * Horizontal alignment applied to both the header and the cells. + * @default 'left' + */ + align?: "left" | "right" | "center" + /** + * Optional CSS class applied to the column header, typically for width + * control (e.g. `"w-1/2"`). + */ + width?: string + /** + * Hide this column below the given breakpoint; it is shown at that width and + * up. These are app-elements' own breakpoints (see `styles/global.css`, which + * resets Tailwind's defaults): `md` 768px, `lg` 992px, `xl` 1280px. There is + * deliberately no `sm`. + * + * Common cases: `"md"` hides on mobile (shown on tablet + desktop), `"lg"` + * shows on desktop only. The column's data is still fetched; only its + * rendering is suppressed via CSS, so there is no layout shift on resize. + */ + hideBelow?: "md" | "lg" | "xl" + /** + * When set, the column becomes sortable and this value is the CommerceLayer + * SDK sort attribute it sorts by (e.g. `"created_at"`). + * + * Sorting is server-side: clicking the header drives the SDK `sort` query + * param and refetches. Rows are never reordered client-side. + */ + sortBy?: string +} + +/** + * SDK sort expression, e.g. `"created_at"` (asc) or `"-created_at"` (desc). + * `undefined` means no explicit table sort is applied. + */ +export type ResourceTableSort = string | undefined + +export type UseResourceTableConfig = + Omit, "metricsQuery" | "query"> & { + /** The columns to render, in display order. */ + columns: Array> + /** + * SDK query object, excluding `pageNumber` (handled internally) and + * `sort` (owned by the table's sorting state — set the initial sort with + * `sort` instead). + */ + query?: Omit["query"]>, "sort"> + /** + * When set, data is fetched from the Metrics API instead of the Core API. + * + * Sorting still works: a column's `sortBy` is sent as the metrics + * `search.sort_by` (so use metrics attribute names, e.g. `"order.placed_at"`) + * together with the matching `search.sort` direction — omit `search.sort_by` + * here and let the table own it. + */ + metricsQuery?: { + search: { + limit?: number + fields?: string[] + } + /** + * Metrics filters. When the table is rendered through + * `useResourceFilters`' `FilteredTable`, this is injected from the active + * filters and must not be set here. + */ + filter?: Record + } + /** + * Optional row-level click handler. When provided the whole row becomes + * interactive (hover affordance + click). Use it to navigate with your + * app's router. + * + * The click event is passed as second argument, so it can be forwarded to + * helpers that need it (e.g. `navigateTo(...).onClick`). + */ + onRowClick?: ( + resource: Resource, + event: React.MouseEvent, + ) => void + /** + * Return an href to make each row a real link (rendered as a stretched + * anchor over the row). This enables native link behavior — cmd/ctrl/middle + * click opens the row in a new tab, and the URL shows on hover. + * + * Combine with `onRowClick` for client-side navigation: a plain click calls + * `onRowClick` (and suppresses the default navigation), while modified + * clicks fall through to the browser. Return `undefined` to leave a row + * non-navigable. + * + * Note: avoid interactive elements in the first column when using this — the + * stretched anchor sits over the row (in-cell controls would need their own + * `relative`/`z-10` to stay clickable). + */ + getRowHref?: (resource: Resource) => string | undefined + /** + * Controlled sort value (SDK sort expression, e.g. `"-created_at"`). + * Pass together with `onSortChange` to own the sort state (e.g. persist it + * in the URL). When omitted the table manages sort internally. + */ + sort?: ResourceTableSort + /** + * Called when the user changes the sort. Provide together with `sort` for + * controlled mode; the callback receives the new SDK sort expression (or + * `undefined` when sorting is cleared). + */ + onSortChange?: (sort: ResourceTableSort) => void + /** + * Initial sort used only when the table manages sort internally + * (uncontrolled). Ignored when `sort`/`onSortChange` are provided. + */ + defaultSort?: ResourceTableSort + } + +/** Props of the `ResourceTable` component returned by the hook. */ +export interface ResourceTableProps { + /** Title. Can be a node or a function receiving the record count. */ + title?: ((recordCount: number | undefined) => ReactNode) | ReactNode + /** Action button rendered next to the title. */ + actionButton?: SectionProps["actionButton"] + /** + * Rendered when the table has no rows. + * When omitted, a default message based on the resource name is shown. + */ + emptyState?: ReactNode + /** Force the title size. Defaults to `normal`. */ + titleSize?: SectionProps["titleSize"] + /** `boxed` wraps the table in a bordered card. */ + variant?: "boxed" + /** + * How the table behaves when its content is wider than the container. + * - `"fit"` (default): the table fills the container width; columns share the + * available space (and wrap/shrink). Pair with `hideBelow` on columns to + * drop low-value columns on small screens. + * - `"scroll"`: the table keeps its natural (unwrapped) width and scrolls + * horizontally inside its own container; the title/action button stay fixed. + * @default 'fit' + */ + layout?: "fit" | "scroll" +} + +export interface UseResourceTableReturn< + TResource extends ListableResourceType, +> { + /** The component that renders the data table. */ + ResourceTable: FC + /** Prev/next pagination controls. Renders `null` unless in `pagination` mode with more than one page. */ + Pagination: FC + /** The rows currently displayed (current page, or accumulated in infinite mode). */ + list?: Array> + /** SDK pagination metadata. */ + meta?: import("../useResourceList/listFetcher").FetcherResponse< + Resource + >["meta"] + isLoading: boolean + isFirstLoading: boolean + error?: string + /** Removes a row from the UI only (call after a successful delete API call). */ + removeItem: (resourceId: string) => void + /** Clears fetched data and refetches from the first page. */ + refresh: () => void + hasMorePages?: boolean + /** The active sort (SDK sort expression), whether controlled or internal. */ + sort: ResourceTableSort +} diff --git a/packages/app-elements/src/ui/resources/useResourceTable/useResourceTable.tsx b/packages/app-elements/src/ui/resources/useResourceTable/useResourceTable.tsx new file mode 100644 index 000000000..5df9ceef3 --- /dev/null +++ b/packages/app-elements/src/ui/resources/useResourceTable/useResourceTable.tsx @@ -0,0 +1,597 @@ +import type { ListableResourceType } from "@commercelayer/sdk" +import { + createColumnHelper, + rowSortingFeature, + type SortingState, + tableFeatures, + useTable, +} from "@tanstack/react-table" +import cn from "classnames" +import { type FC, useCallback, useMemo, useRef, useState } from "react" +import { formatResourceName } from "#helpers/resources" +import { t } from "#providers/I18NProvider" +import { EmptyState } from "#ui/atoms/EmptyState" +import { Icon } from "#ui/atoms/Icon" +import { Section } from "#ui/atoms/Section" +import { SkeletonTemplate } from "#ui/atoms/SkeletonTemplate" +import { Spacer } from "#ui/atoms/Spacer" +import { Table, Td, Th, Tr } from "#ui/atoms/Table" +import { Text } from "#ui/atoms/Text" +import type { Resource } from "../useResourceList/listFetcher" +import { + type UseResourceListConfig, + type UseResourceListReturnWithPagination, + useResourceList, +} from "../useResourceList/useResourceList" +import { computeTitleWithTotalCount } from "../useResourceList/utils" +import { VisibilityTrigger } from "../useResourceList/VisibilityTrigger" +import type { + ResourceTableColumn, + ResourceTableProps, + ResourceTableSort, + UseResourceTableConfig, + UseResourceTableReturn, +} from "./types" + +// Static, prop-free feature registry. Only the row-sorting feature is needed: +// sorting is server-side (`manualSorting`), so no sorted row model is registered +// (see docs/adr/0002-server-side-table-operations.md). +const tableFeaturesConfig = tableFeatures({ rowSortingFeature }) + +// Stable empty-data reference to avoid invalidating the table's models on every +// render while the first page is loading. +const EMPTY_DATA: unknown[] = [] + +// Minimal row shape used internally for TanStack typing (see note in the hook). +// Every CommerceLayer resource has a string `id`, which is all TanStack needs +// from us (`getRowId`); the real resource is recovered via a cast in each cell. +type TableRow = { id: string } + +/** Resolve a stable column id: explicit `id`, then `sortBy`, then positional. */ +function getColumnId( + column: ResourceTableColumn, + index: number, +): string { + return column.id ?? column.sortBy ?? `col-${index}` +} + +/** Parse an SDK sort expression (`"-created_at"`) into `{ attribute, desc }`. */ +function parseSort( + sort: ResourceTableSort, +): { attribute: string; desc: boolean } | undefined { + if (sort == null || sort === "") { + return undefined + } + const desc = sort.startsWith("-") + return { attribute: desc ? sort.slice(1) : sort, desc } +} + +function alignClassName( + align: ResourceTableColumn["align"], +): string | undefined { + switch (align) { + case "right": + return "text-right" + case "center": + return "text-center" + default: + return undefined + } +} + +// Full literal class strings (not interpolated) so Tailwind v4's source scanner +// keeps them in the compiled stylesheet. Only breakpoints that actually exist in +// `styles/global.css` may be used — that file resets Tailwind's defaults, so a +// variant like `sm:` would produce no CSS and hide the column at every width. +function hideBelowClassName( + hideBelow: ResourceTableColumn["hideBelow"], +): string | undefined { + switch (hideBelow) { + case "md": + return "hidden md:table-cell" + case "lg": + return "hidden lg:table-cell" + case "xl": + return "hidden xl:table-cell" + default: + return undefined + } +} + +/** + * `useResourceTable` fetches a CommerceLayer resource type and renders it as a + * data table driven by a column model, backed by TanStack Table v9. + * + * It reuses `useResourceList`'s fetch/pagination layer verbatim and only + * replaces item rendering with a TanStack-driven table. Sorting, filtering, + * search and pagination are all resolved server-side. + */ +export function useResourceTable( + config: UseResourceTableConfig, +): UseResourceTableReturn { + const { + type, + columns, + query, + metricsQuery, + preProcess, + paginationType = "pagination", + paginationScrollTo, + onRowClick, + getRowHref, + sort: controlledSort, + onSortChange, + defaultSort, + } = config + + // Sort state: controlled when `onSortChange` is provided, otherwise internal. + const isControlled = onSortChange != null + const [internalSort, setInternalSort] = useState( + () => defaultSort, + ) + const sort = isControlled ? controlledSort : internalSort + const setSort = useCallback( + (next: ResourceTableSort) => { + if (isControlled) { + onSortChange?.(next) + } else { + setInternalSort(next) + } + }, + [isControlled, onSortChange], + ) + + const isMetrics = metricsQuery != null + + // Merge the active sort into the query that drives the fetch. Changing it makes + // useResourceList refetch from page 1. + // Core API: the SDK types `sort` against known resource fields, while our + // `sortBy` is a free ransack attribute string, hence the cast. + // Metrics API: `query` is not sent at all, so the sort goes to the metrics + // `search.sort_by`/`sort` instead. + const mergedQuery = useMemo( + () => + ({ + ...query, + ...(!isMetrics && sort != null && sort !== "" ? { sort: [sort] } : {}), + }) as NonNullable["query"]>, + [query, sort, isMetrics], + ) + + const mergedMetricsQuery = useMemo(() => { + if (metricsQuery == null) { + return undefined + } + const parsed = parseSort(sort) + return { + ...metricsQuery, + filter: metricsQuery.filter ?? {}, + search: { + ...metricsQuery.search, + ...(parsed != null + ? { + sort_by: parsed.attribute, + sort: parsed.desc ? ("desc" as const) : ("asc" as const), + } + : {}), + }, + } as NonNullable["metricsQuery"]> + }, [metricsQuery, sort]) + + const result = useResourceList({ + type, + query: mergedQuery, + metricsQuery: mergedMetricsQuery, + preProcess, + paginationType, + paginationScrollTo, + }) + + const { + list, + meta, + isLoading, + isFirstLoading, + error, + removeItem, + refresh, + fetchMore, + hasMorePages, + } = result + + const Pagination = + paginationType === "pagination" + ? (result as UseResourceListReturnWithPagination).Pagination + : NullComponent + + // Map the active sort onto TanStack's controlled sorting state. + const sorting = useMemo(() => { + const parsed = parseSort(sort) + if (parsed == null) { + return [] + } + const index = columns.findIndex( + (column) => column.sortBy === parsed.attribute, + ) + const column = columns[index] + if (column == null) { + return [] + } + return [{ id: getColumnId(column, index), desc: parsed.desc }] + }, [sort, columns]) + + const onSortingChange = useCallback( + (updater: SortingState | ((old: SortingState) => SortingState)) => { + const next = typeof updater === "function" ? updater(sorting) : updater + const first = next[0] + if (first == null) { + setSort(undefined) + return + } + const index = columns.findIndex( + (column, i) => getColumnId(column, i) === first.id, + ) + const attribute = columns[index]?.sortBy + if (attribute == null) { + return + } + setSort(`${first.desc ? "-" : ""}${attribute}`) + }, + [sorting, columns, setSort], + ) + + const tableColumns = useMemo(() => { + // Type the column helper/table with a minimal row shape rather than the full + // `Resource` SDK union: pushing that large conditional type + // through TanStack's generics while `TResource` is unresolved triggers + // "excessively deep" (TS2589). The real row type is preserved by the + // `ResourceTableColumn` public API and the cast in `cell`. + const helper = createColumnHelper() + return helper.columns( + columns.map((column, index) => + // Accessor (not display) columns: TanStack's `getCanSort` requires an + // `accessorFn`, so a display column can never be sortable. The accessor + // value itself is unused — sorting is server-side (`manualSorting`) — so + // it returns a trivial `null`. The cell renders from `row.original`. + helper.accessor(() => null, { + id: getColumnId(column, index), + header: () => column.header, + cell: ({ row }) => + column.cell({ resource: row.original as Resource }), + enableSorting: column.sortBy != null, + }), + ), + ) + }, [columns]) + + const table = useTable({ + features: tableFeaturesConfig, + columns: tableColumns, + data: (list ?? EMPTY_DATA) as TableRow[], + getRowId: (row) => row.id, + manualSorting: true, + enableMultiSort: false, + enableSortingRemoval: true, + state: { sorting }, + onSortingChange, + }) + + const columnCount = columns.length + const isEmpty = !isFirstLoading && (list?.length ?? 0) === 0 + const isApiError = error != null && list == null + + /** + * Everything the table body reads is funnelled through a ref so that + * `ResourceTable` can be created **once** (empty dependency list) and keep a + * stable component type. + * + * With these values as dependencies instead, the identity changed on almost + * every render — callers pass row handlers inline, and `fetchMore` itself is + * rebuilt whenever swr returns a new data object. A new component type makes + * React unmount the whole table and build it again, which re-creates every cell + * and visibly re-loads the row images. + * + * Reading from the ref is safe because the component that owns this hook + * re-renders on each of these changes, which re-runs the body below. + */ + const renderRef = useRef({ + table, + columns, + columnCount, + type, + meta, + isApiError, + isEmpty, + isFirstLoading, + isLoading, + hasMorePages, + paginationType, + fetchMore, + onRowClick, + getRowHref, + }) + renderRef.current = { + table, + columns, + columnCount, + type, + meta, + isApiError, + isEmpty, + isFirstLoading, + isLoading, + hasMorePages, + paginationType, + fetchMore, + onRowClick, + getRowHref, + } + + const ResourceTable = useCallback>( + ({ + title, + actionButton, + emptyState, + titleSize, + variant, + layout = "fit", + }) => { + const { + table, + columns, + columnCount, + type, + meta, + isApiError, + isEmpty, + isFirstLoading, + isLoading, + hasMorePages, + paginationType, + fetchMore, + onRowClick, + getRowHref, + } = renderRef.current + + const recordCount = meta?.recordCount + const computedTitle = + typeof title === "function" + ? title(recordCount) + : computeTitleWithTotalCount({ title, recordCount }) + + if (isApiError) { + return ( + + ) + } + + const defaultEmptyState = ( + + No {formatResourceName({ resource: type, count: "plural" })}. + + ) + + const thead = ( + + {table.getHeaderGroups()[0]?.headers.map((header, index) => { + const definition = columns[index] + const canSort = header.column.getCanSort() + const sorted = header.column.getIsSorted() + const label = + return ( + + {canSort ? ( + + ) : ( + label + )} + + ) + })} + + ) + + const renderSkeletonRows = (count: number, keyPrefix: string) => + Array.from({ length: count }).map((_, rowIndex) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static skeleton rows never reorder + + {columns.map((column, colIndex) => ( + +   + + ))} + + )) + + const tbody = ( + <> + {isFirstLoading + ? renderSkeletonRows(8, "skeleton") + : table.getRowModel().rows.map((row) => { + const resource = row.original as Resource + const href = getRowHref?.(resource) + const clickable = href != null || onRowClick != null + return ( + { + onRowClick(resource, event) + } + : undefined + } + role={ + href == null && onRowClick != null ? "button" : undefined + } + className={cn( + clickable && "cursor-pointer hover:bg-gray-50", + // positioning context for the stretched-link `::after` + href != null && "relative", + )} + > + {row.getAllCells().map((cell, colIndex) => { + const content = + return ( + + {href != null && colIndex === 0 ? ( + // Stretched link: a real anchor on the first cell's + // content whose `::after` covers the whole row. Gives + // new-tab / cmd-click semantics; plain clicks are + // handled client-side via onRowClick when provided. + { + if ( + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) { + return + } + if (onRowClick != null) { + event.preventDefault() + onRowClick(resource, event) + } + }} + className="text-inherit no-underline after:absolute after:inset-0" + > + {content} + + ) : ( + content + )} + + ) + })} + + ) + })} + {paginationType === "infinite" && !isFirstLoading ? ( + isLoading ? ( + renderSkeletonRows(2, "skeleton-more") + ) : ( + + + { + if (entry.isIntersecting) { + void fetchMore() + } + }} + /> + + + ) + ) : null} + + ) + + if (isEmpty) { + return ( +
+ {emptyState ?? defaultEmptyState} +
+ ) + } + + return ( +
+ + {layout === "scroll" ? ( +
+ + + ) : ( +
+ )} + + + ) + }, + // created once: every value it reads comes from `renderRef` + [], + ) + + return { + ResourceTable, + Pagination, + list, + meta, + isLoading, + isFirstLoading, + error, + removeItem, + refresh, + hasMorePages, + sort, + } +} + +const NullComponent: FC = () => null diff --git a/packages/docs/src/mocks/data/metrics.js b/packages/docs/src/mocks/data/metrics.js new file mode 100644 index 000000000..8157efab9 --- /dev/null +++ b/packages/docs/src/mocks/data/metrics.js @@ -0,0 +1,118 @@ +import { HttpResponse, http } from "msw" + +/** + * Mock for the Metrics API `search` endpoint. + * + * The real endpoint is cursor-paginated: every response carries the cursor that + * opens the *next* page (or `null` on the last one) plus the total + * `record_count`. Here the cursor simply encodes the offset, which is enough to + * exercise both infinite scrolling and prev/next pagination. + */ + +const TOTAL_RECORDS = 47 + +const markets = ["Europe", "US", "New York", "Italy"] +const currencies = ["EUR", "USD", "JPY", "EUR"] +const people = [ + ["Michael", "Jordan"], + ["Darth", "Vader"], + ["Ada", "Lovelace"], + ["Grace", "Hopper"], +] +const countries = ["IT", "US", "US", "IT"] +// status, payment_status, fulfillment_status — only combinations the order +// status dictionary actually maps, so every row renders a real badge +const statuses = [ + ["placed", "authorized", "unfulfilled"], // awaiting approval + ["approved", "paid", "in_progress"], // in progress + ["approved", "paid", "fulfilled"], // fulfilled + ["cancelled", "voided", "unfulfilled"], // cancelled + ["approved", "authorized", "in_progress"], // in progress +] + +/** Deterministic dataset, so stories and screenshots stay stable. */ +const allOrders = Array.from({ length: TOTAL_RECORDS }, (_, index) => { + const [status, paymentStatus, fulfillmentStatus] = + statuses[index % statuses.length] + const [firstName, lastName] = people[index % people.length] + // spread the dates so sorting is visible + const day = String((index % 28) + 1).padStart(2, "0") + const placedAt = `2024-06-${day}T${String(index % 24).padStart(2, "0")}:45:00.000Z` + + return { + id: `metrics-order-${index}`, + type: "orders", + number: `${19346512 + index}`, + status, + payment_status: paymentStatus, + fulfillment_status: fulfillmentStatus, + currency_code: currencies[index % currencies.length], + // the metrics API returns amounts in units, not cents + total_amount: 49.99 + index * 37.5, + total_amount_with_taxes: 49.99 + index * 37.5, + placed_at: placedAt, + updated_at: placedAt, + created_at: placedAt, + guest: false, + tax_included: true, + market: { + id: `market-${index % markets.length}`, + name: markets[index % markets.length], + number: `${350 + (index % markets.length)}`, + }, + billing_address: { + first_name: firstName, + last_name: lastName, + country_code: countries[index % countries.length], + city: "Cogorno", + state_code: "GE", + zip_code: "16030", + }, + } +}) + +/** `order.placed_at` -> `placed_at` */ +const toFieldName = (sortBy) => String(sortBy ?? "").replace(/^order\./, "") + +const parseOffset = (cursor) => { + const offset = Number(String(cursor ?? "").replace("offset-", "")) + return Number.isFinite(offset) && offset > 0 ? offset : 0 +} + +const metricsOrdersSearch = http.post( + "https://mock.localhost/metrics/orders/search", + async ({ request }) => { + const body = await request.json() + const search = body?.search ?? {} + const limit = search.limit ?? 25 + const offset = parseOffset(search.cursor) + + const field = toFieldName(search.sort_by) + const direction = search.sort === "asc" ? 1 : -1 + + const sorted = + field === "" + ? [...allOrders] + : [...allOrders].sort((a, b) => { + const left = a[field] + const right = b[field] + if (left === right) return 0 + return (left > right ? 1 : -1) * direction + }) + + const page = sorted.slice(offset, offset + limit) + const nextOffset = offset + limit + + return HttpResponse.json({ + data: page, + meta: { + pagination: { + record_count: TOTAL_RECORDS, + cursor: nextOffset < TOTAL_RECORDS ? `offset-${nextOffset}` : null, + }, + }, + }) + }, +) + +export default [metricsOrdersSearch] diff --git a/packages/docs/src/mocks/handlers.js b/packages/docs/src/mocks/handlers.js index 2482a7761..83d42f37f 100644 --- a/packages/docs/src/mocks/handlers.js +++ b/packages/docs/src/mocks/handlers.js @@ -4,6 +4,7 @@ import bundles from "./data/bundles" import customers from "./data/customers" import lineItems from "./data/line_items" import markets from "./data/markets" +import metrics from "./data/metrics" import orders from "./data/orders" import sku_lists from "./data/sku_lists" import skus from "./data/skus" @@ -17,6 +18,7 @@ export const handlers = [ ...customers, ...lineItems, ...markets, + ...metrics, ...orders, ...skus, ...sku_lists, diff --git a/packages/docs/src/stories/resources/useResourceTable.stories.tsx b/packages/docs/src/stories/resources/useResourceTable.stories.tsx new file mode 100644 index 000000000..1dcbd7b61 --- /dev/null +++ b/packages/docs/src/stories/resources/useResourceTable.stories.tsx @@ -0,0 +1,464 @@ +import type { Meta, StoryFn } from "@storybook/react-vite" +import { + getOrderDisplayStatus, + getOrderPaymentStatusName, +} from "#dictionaries/orders" +import type { CurrencyCode } from "#helpers/currencies" +import { formatDate } from "#helpers/date" +import { formatDisplayName } from "#helpers/name" +import { CoreSdkProvider } from "#providers/CoreSdkProvider" +import { MockTokenProvider as TokenProvider } from "#providers/TokenProvider/MockTokenProvider" +import { Badge } from "#ui/atoms/Badge" +import { Button } from "#ui/atoms/Button" +import { Icon } from "#ui/atoms/Icon" +import { Text } from "#ui/atoms/Text" +import { formatCentsToCurrency } from "#ui/forms/InputCurrency" +import { + type ResourceTableColumn, + useResourceTable, +} from "#ui/resources/useResourceTable" + +const setup: Meta = { + title: "Resources/useResourceTable", + parameters: { + layout: "padded", + docs: { + source: { + type: "code", + }, + }, + }, + decorators: [ + (Story) => ( + + + + + + ), + ], +} +export default setup + +const columns: Array> = [ + { + header: "Number", + sortBy: "number", + cell: ({ resource }) => `#${resource.number}`, + }, + { + header: "Market", + cell: ({ resource }) => resource.market?.name, + }, + { + header: "Total", + align: "right", + sortBy: "total_amount_cents", + cell: ({ resource }) => resource.formatted_total_amount, + }, +] + +/** + * `useResourceTable` renders a CommerceLayer resource as a data table driven by + * a column model, backed by TanStack Table. Columns (including each header and + * cell) are defined by the consumer; the component owns rendering, loading + * skeletons, empty state, sorting, and pagination. + */ +export const Default: StoryFn = () => { + const { ResourceTable } = useResourceTable({ + type: "orders", + columns, + }) + + return ( + + Order + + } + /> + ) +} + +/** + * Declare a `sortBy` on any column to make its header sortable. Sorting is + * server-side: clicking the header drives the SDK `sort` param and refetches. + */ +export const WithSorting: StoryFn = () => { + const { ResourceTable } = useResourceTable({ + type: "orders", + columns, + defaultSort: "-number", + }) + + return +} + +/** + * Provide `onRowClick` to make the whole row interactive. Use it to navigate + * with your app's router. + * + * Pass `getRowHref` too to render each row as a real link: cmd/ctrl/middle click + * opens a new tab and the URL shows on hover, while a plain click is handled by + * `onRowClick` (client-side navigation). + */ +export const WithRowClick: StoryFn = () => { + const { ResourceTable } = useResourceTable({ + type: "orders", + columns, + getRowHref: (order) => `/orders/${order.id}`, + onRowClick: (order) => { + console.log("clicked order", order.id) + }, + }) + + return +} + +/** + * By default a table uses classic prev/next pagination. + */ +export const WithPagination: StoryFn = () => { + const { ResourceTable, Pagination } = useResourceTable({ + type: "orders", + columns, + query: { pageSize: 10 }, + paginationScrollTo: "list", + }) + + return ( + <> + + + + ) +} +WithPagination.parameters = { + docs: { + canvas: { + sourceState: "none", + }, + }, +} + +/** + * Opt into infinite scrolling with `paginationType: "infinite"`. + */ +export const WithInfiniteScrolling: StoryFn = () => { + const { ResourceTable } = useResourceTable({ + type: "orders", + columns, + query: { pageSize: 10 }, + paginationType: "infinite", + }) + + return +} +WithInfiniteScrolling.parameters = { + docs: { + canvas: { + sourceState: "none", + }, + }, +} + +/** + * Set `hideBelow` on a column to hide it below a breakpoint (Tailwind `sm`/`md`/`lg`/`xl`). + * Resize the preview: MARKET is hidden below `md` (mobile), and NUMBER's id column + * below `lg` (mobile + tablet). Data is still fetched — only rendering is suppressed, + * so there is no layout shift. + */ +export const ResponsiveColumns: StoryFn = () => { + const { ResourceTable } = useResourceTable({ + type: "orders", + columns: [ + { + header: "ID", + hideBelow: "lg", + cell: ({ resource }) => resource.id, + }, + { + header: "Number", + sortBy: "number", + cell: ({ resource }) => `#${resource.number}`, + }, + { + header: "Market", + hideBelow: "md", + cell: ({ resource }) => resource.market?.name, + }, + { + header: "Total", + align: "right", + sortBy: "total_amount_cents", + cell: ({ resource }) => resource.formatted_total_amount, + }, + ], + }) + + return +} + +/** + * With `layout="scroll"`, a table wider than its container keeps its natural + * width and scrolls horizontally instead of squishing columns. The title and + * action button stay fixed. This is an alternative to hiding columns with + * `hideBelow` — useful when every column matters. Narrow the preview to see it. + */ +export const HorizontalScroll: StoryFn = () => { + const { ResourceTable } = useResourceTable({ + type: "orders", + columns: [ + { + header: "Number", + sortBy: "number", + cell: ({ resource }) => `#${resource.number}`, + }, + { header: "Status", cell: ({ resource }) => resource.status }, + { header: "Payment", cell: ({ resource }) => resource.payment_status }, + { + header: "Fulfillment", + cell: ({ resource }) => resource.fulfillment_status, + }, + { header: "Market", cell: ({ resource }) => resource.market?.name }, + { header: "Email", cell: ({ resource }) => resource.customer_email }, + { + header: "Placed at", + sortBy: "placed_at", + cell: ({ resource }) => resource.placed_at, + }, + { + header: "Total", + align: "right", + sortBy: "total_amount_cents", + cell: ({ resource }) => resource.formatted_total_amount, + }, + ], + }) + + return +} + +/** + * The `boxed` variant wraps the table in a bordered card. + */ +export const WithEmptyState: StoryFn = () => { + const { ResourceTable } = useResourceTable({ + type: "orders", + columns, + query: { filters: { market_id_eq: "not-existing-id" } }, + }) + + return ( + No orders found} + /> + ) +} + +/** + * A realistic "Orders" list mimicking a product mockup: two-line ORDER and + * CUSTOMER cells, a colored STATUS badge, a right-aligned AMOUNT with the + * payment status beneath it, a sortable ORDER header, and clickable rows + * (real links via `getRowHref`). + */ +export const OrdersMockup: StoryFn = () => { + const { ResourceTable } = useResourceTable({ + type: "orders", + getRowHref: (order) => `/orders/${order.id}`, + onRowClick: (order) => { + console.log("open order", order.id) + }, + columns: [ + { + header: "Order", + sortBy: "number", + cell: ({ resource }) => ( +
+ + {resource.market?.name} #{resource.number} + + + {resource.placed_at?.slice(0, 10) ?? "—"} + +
+ ), + }, + { + header: "Customer", + cell: ({ resource }) => ( +
+ + {resource.billing_address?.full_name ?? "—"} + + + {resource.customer_email} + +
+ ), + }, + { + header: "Status", + cell: ({ resource }) => { + const status = + resource.status === "cancelled" + ? { label: "cancelled", variant: "secondary" as const } + : resource.fulfillment_status === "fulfilled" + ? { label: "fulfilled", variant: "success" as const } + : resource.fulfillment_status === "in_progress" + ? { label: "in progress", variant: "warning" as const } + : { label: "awaiting approval", variant: "warning" as const } + return {status.label} + }, + }, + { + header: "Amount", + align: "right", + cell: ({ resource }) => ( +
+ + {resource.formatted_total_amount} + + + {resource.payment_status} + +
+ ), + }, + ], + }) + + return ( + "Orders"} + actionButton={ + + } + /> + ) +} + +/** + * Set `metricsQuery` to fetch from the **Metrics API** (`/metrics/orders/search`) + * instead of the Core API. + * + * The metrics API is cursor-based, so prev/next pagination works by remembering + * the cursor that opens each visited page — arbitrary page jumps are not + * possible. Sorting is server-side too: a column's `sortBy` is sent as the + * metrics `search.sort_by`, so use metrics attribute names (`order.placed_at`). + * + * Note that metrics orders carry `total_amount` in units (not a formatted + * string) and provide no `customer_email`, hence the amount helper and the + * country code as the customer's second line. + */ +export const FromMetricsApi: StoryFn = () => { + const { ResourceTable, Pagination } = useResourceTable({ + type: "orders", + metricsQuery: { + search: { + limit: 10, + fields: ["order.*", "billing_address.*", "market.*", "customer.*"], + }, + }, + defaultSort: "-order.placed_at", + columns: [ + { + header: "Order", + sortBy: "order.placed_at", + width: "w-1/3", + cell: ({ resource }) => ( +
+ + {`${resource.market?.name ?? "Order"} #${resource.number ?? ""}`} + + + {formatDate({ + format: "full", + isoDate: resource.placed_at ?? undefined, + })} + +
+ ), + }, + { + header: "Customer", + hideBelow: "md", + cell: ({ resource }) => ( +
+ + {formatDisplayName( + resource.billing_address?.first_name ?? "", + resource.billing_address?.last_name ?? "", + )}{" "} + ({resource.billing_address?.country_code ?? "—"}) + + + {resource.customer?.email ?? "—"} + +
+ ), + }, + { + header: "Status", + cell: ({ resource }) => { + const displayStatus = getOrderDisplayStatus(resource) + return ( + + {displayStatus.label} + + ) + }, + }, + { + header: "Amount", + align: "right", + sortBy: "order.total_amount", + cell: ({ resource }) => ( +
+ + {resource.currency_code != null && "total_amount" in resource + ? formatCentsToCurrency( + (resource.total_amount as number) * 100, + resource.currency_code as CurrencyCode, + ) + : resource.formatted_total_amount} + + + {getOrderPaymentStatusName(resource.payment_status)} + +
+ ), + }, + ], + }) + + return ( + <> + + + + ) +} +FromMetricsApi.parameters = { + docs: { + canvas: { + sourceState: "none", + }, + }, +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3588c0959..105dd134d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@monaco-editor/react': specifier: ~4.7.0 version: 4.7.0(monaco-editor@0.53.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/react-table': + specifier: ^9.0.0 + version: 9.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@types/lodash-es': specifier: ^4.17.12 version: 4.17.12 @@ -2497,6 +2500,25 @@ packages: '@tailwindcss/postcss@4.3.3': resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==} + '@tanstack/react-store@0.11.0': + resolution: {integrity: sha512-tX4YXh3PDkmpvGQWkWqKpzs/MSqbtuwY9dWdWhtV9Q50PmO+jOkUKIWIX4G85dwt7lxdHLXsiaEKPdKmC8F41w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/react-table@9.0.0': + resolution: {integrity: sha512-Q/Z49MQcdMwge67U+LTjSEQJCnQE9/tNWK5IpiYex9JFDzfjNkLIi7yzGA4dfW4UpuMBrtqbSnSzn7oPr60T+w==} + engines: {node: '>=20'} + peerDependencies: + react: '>=18' + + '@tanstack/store@0.11.0': + resolution: {integrity: sha512-WlzzCt3xi0G6pCAJu1U+2jiECwabETDpQDi3hfkFZvJii9AuZqEKbOiVarX1/bWhTNjU486yQtJCCasi/0q+Cw==} + + '@tanstack/table-core@9.0.0': + resolution: {integrity: sha512-IyKCc4D7d/+I9euQntlVDQ7lnilmFRKpIROBeI5/866adFy+65xWvCFPz76NZ5j2lXe/RFDvFVV7bfETmwHEMA==} + engines: {node: '>=20'} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -8062,6 +8084,27 @@ snapshots: postcss: 8.5.25 tailwindcss: 4.3.3 + '@tanstack/react-store@0.11.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tanstack/store': 0.11.0 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + use-sync-external-store: 1.6.0(react@19.2.4) + + '@tanstack/react-table@9.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tanstack/react-store': 0.11.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/table-core': 9.0.0 + react: 19.2.4 + transitivePeerDependencies: + - react-dom + + '@tanstack/store@0.11.0': {} + + '@tanstack/table-core@9.0.0': + dependencies: + '@tanstack/store': 0.11.0 + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.7