/** * DataTable — the shared listing table for every admin screen (core and add-ons). * * Gives each list the same chrome: a toolbar with the item count, search and filters, * bulk selection with a bulk-actions bar, sortable columns, pagination and an optional * grid view. Rows are plain objects; columns describe how to render them. * * Exposed to add-ons as window.StreamSeraAdminApi.ui.DataTable. */ import { __, sprintf } from '@wordpress/i18n'; import { useEffect, useMemo, useState } from '@wordpress/element'; import { Button, SearchControl, SelectControl } from '@wordpress/components'; import type { ReactNode } from 'react'; import { I } from '../ui'; export interface DataTableColumn< R = any > { id: string; label: ReactNode; /** Cell renderer. */ render: ( row: R, index: number ) => ReactNode; /** Sort accessor; omit to make the column unsortable. */ sortValue?: ( row: R ) => string | number | null | undefined; width?: number | string; align?: 'left' | 'right' | 'center'; /** Hide below 782px (phones). */ hideOnMobile?: boolean; /** Hidden at tablet widths (≤ 960px) and below. */ hideOnTablet?: boolean; className?: string; } export interface DataTableBulkAction< R = any > { id: string; label: string; icon?: string; destructive?: boolean; /** Called with the selected rows. Return a promise to show a busy state. */ onApply: ( rows: R[] ) => void | Promise< void >; /** Hide the action unless every selected row passes. */ when?: ( row: R ) => boolean; } export interface DataTableFilter { id: string; label: string; value: string; options: Array< { label: string; value: string } >; onChange: ( value: string ) => void; } export interface DataTablePagination { /** Server mode: total number of rows across all pages. Client mode when omitted. */ total?: number; page?: number; perPage?: number; onPageChange?: ( page: number ) => void; onPerPageChange?: ( perPage: number ) => void; perPageOptions?: number[]; } export interface DataTableProps< R = any > { columns: DataTableColumn< R >[]; rows: R[]; rowKey: ( row: R ) => string | number; /** Singular / plural noun for the count ("station", "stations"). */ noun?: [ string, string ]; loading?: boolean; empty?: ReactNode; /** Row selection + bulk actions bar. */ selectable?: boolean; bulkActions?: DataTableBulkAction< R >[]; /** Toolbar bits. */ search?: { value: string; onChange: ( v: string ) => void; placeholder?: string; }; /** Client-side search haystack for a row (used with or without a controlled `search`). */ searchText?: ( row: R ) => string; filters?: DataTableFilter[]; toolbarEnd?: ReactNode; toolbarStart?: ReactNode; /** Per-row action buttons (rendered in the last column). */ rowActions?: ( row: R ) => ReactNode; /** Row click target (e.g. navigate). */ onRowClick?: ( row: R ) => void; pagination?: DataTablePagination | false; /** 'list' (table) or 'grid' (cards via renderCard). */ view?: 'list' | 'grid'; onViewChange?: ( view: 'list' | 'grid' ) => void; renderCard?: ( row: R, helpers: { selected: boolean; toggle: () => void } ) => ReactNode; /** Initial sort. */ defaultSort?: { column: string; direction: 'asc' | 'desc' }; /** Rendered inside the panel, below the table, e.g. a legend. */ footer?: ReactNode; className?: string; /** Row class names. */ rowClassName?: ( row: R ) => string; /** Remember per-page in localStorage under this key. */ storageKey?: string; } const PER_PAGE_DEFAULT = 10; const PER_PAGE_OPTIONS = [ 10, 25, 50 ]; function readStored( key?: string, fallback = PER_PAGE_DEFAULT ) { if ( ! key ) { return fallback; } try { const v = parseInt( window.localStorage.getItem( `streamsera:perpage:${ key }` ) || '', 10 ); return v && PER_PAGE_OPTIONS.includes( v ) ? v : fallback; } catch ( e ) { return fallback; } } /** * Compact native checkbox (CheckboxControl is too tall for 48px rows and lacks a visually-hidden label). * @param root0 * @param root0.checked * @param root0.indeterminate * @param root0.onChange * @param root0.label */ function Check( { checked, indeterminate = false, onChange, label, }: { checked: boolean; indeterminate?: boolean; onChange: () => void; label: string; } ) { return ( { if ( el ) { el.indeterminate = indeterminate; } } } onChange={ onChange } aria-label={ label } /> ); } /** * Letter-initial fallback tile when a row has no image. * @param root0 * @param root0.src * @param root0.label * @param root0.size * @param root0.round * @param root0.className */ export function Thumb( { src, label, size = 36, round = false, className = '', }: { src?: string | null; label?: string; size?: number; round?: boolean; className?: string; } ) { const [ broken, setBroken ] = useState( false ); useEffect( () => setBroken( false ), [ src ] ); const initial = ( label || '?' ).trim().charAt( 0 ).toUpperCase() || '?'; const hue = useMemo( () => { let h = 0; for ( const ch of label || '' ) { h = ( h * 31 + ch.charCodeAt( 0 ) ) % 360; } return h; }, [ label ] ); const style = { width: size, height: size, '--ss-thumb-h': hue, } as React.CSSProperties; if ( src && ! broken ) { return ( setBroken( true ) } /> ); } return ( ); } export const Avatar = ( props: Parameters< typeof Thumb >[ 0 ] ) => ( ); export default function DataTable< R = any >( props: DataTableProps< R > ) { const { columns, rows, rowKey, noun, loading, empty, selectable = false, bulkActions = [], search, searchText, filters = [], toolbarEnd, toolbarStart, rowActions, onRowClick, pagination, view = 'list', onViewChange, renderCard, defaultSort, footer, className = '', rowClassName, storageKey, } = props; const [ localQ, setLocalQ ] = useState( '' ); const [ selected, setSelected ] = useState< Set< string | number > >( new Set() ); const [ sort, setSort ] = useState( defaultSort || null ); const [ busy, setBusy ] = useState< string | null >( null ); const server = !! ( pagination && pagination.total !== undefined ); const [ localPage, setLocalPage ] = useState( 1 ); const [ localPerPage, setLocalPerPage ] = useState( () => readStored( storageKey, ( pagination && pagination.perPage ) || PER_PAGE_DEFAULT ) ); const q = search ? search.value : localQ; const setQ = search ? search.onChange : setLocalQ; // Client-side search + sort. const filtered = useMemo( () => { let out = rows; // Filter client-side whenever a haystack is given (controlled search box or not); // only a server-paginated table is expected to have filtered already. if ( ! server && q && searchText ) { const needle = q.toLowerCase(); out = out.filter( ( r ) => searchText( r ).toLowerCase().includes( needle ) ); } if ( sort ) { const col = columns.find( ( c ) => c.id === sort.column ); if ( col && col.sortValue ) { const dir = sort.direction === 'asc' ? 1 : -1; out = [ ...out ].sort( ( a, b ) => { const va = col.sortValue!( a ) ?? ''; const vb = col.sortValue!( b ) ?? ''; if ( typeof va === 'number' && typeof vb === 'number' ) { return ( va - vb ) * dir; } return ( String( va ).localeCompare( String( vb ), undefined, { numeric: true, } ) * dir ); } ); } } return out; }, [ rows, q, server, searchText, sort, columns ] ); // Pagination. const paginate = pagination !== false; const perPage = server ? pagination!.perPage || PER_PAGE_DEFAULT : localPerPage; const page = server ? pagination!.page || 1 : localPage; const total = server ? pagination!.total! : filtered.length; const pages = Math.max( 1, Math.ceil( total / perPage ) ); useEffect( () => { if ( ! server && localPage > pages ) { setLocalPage( pages ); } }, [ pages, localPage, server ] ); const visible = useMemo( () => { if ( ! paginate || server ) { return filtered; } const start = ( page - 1 ) * perPage; return filtered.slice( start, start + perPage ); }, [ filtered, paginate, server, page, perPage ] ); const goTo = ( p: number ) => { const next = Math.min( pages, Math.max( 1, p ) ); if ( server ) { pagination!.onPageChange?.( next ); } else { setLocalPage( next ); } }; const changePerPage = ( n: number ) => { if ( server ) { pagination!.onPerPageChange?.( n ); } else { setLocalPerPage( n ); setLocalPage( 1 ); } if ( storageKey ) { try { window.localStorage.setItem( `streamsera:perpage:${ storageKey }`, String( n ) ); } catch ( e ) { /* ignore */ } } }; // Selection. const keys = visible.map( rowKey ); const allVisibleSelected = keys.length > 0 && keys.every( ( k ) => selected.has( k ) ); const someVisibleSelected = keys.some( ( k ) => selected.has( k ) ); const toggleAll = () => { setSelected( ( prev ) => { const next = new Set( prev ); if ( allVisibleSelected ) { keys.forEach( ( k ) => next.delete( k ) ); } else { keys.forEach( ( k ) => next.add( k ) ); } return next; } ); }; const toggle = ( k: string | number ) => setSelected( ( prev ) => { const next = new Set( prev ); if ( next.has( k ) ) { next.delete( k ); } else { next.add( k ); } return next; } ); useEffect( () => { // Drop selections that no longer exist (after delete / reload). const present = new Set( rows.map( rowKey ) ); setSelected( ( prev ) => { const next = new Set( [ ...prev ].filter( ( k ) => present.has( k ) ) ); return next.size === prev.size ? prev : next; } ); }, [ rows ] ); // eslint-disable-line react-hooks/exhaustive-deps const selectedRows = rows.filter( ( r ) => selected.has( rowKey( r ) ) ); const selectAllEverywhere = () => setSelected( new Set( filtered.map( rowKey ) ) ); const runBulk = async ( action: DataTableBulkAction< R > ) => { setBusy( action.id ); try { await action.onApply( selectedRows ); } finally { setBusy( null ); setSelected( new Set() ); } }; const genericCount = sprintf( // translators: %s: number of rows __( '%s items', 'streamsera' ), String( total ) ); const countLabel = noun ? `${ total } ${ total === 1 ? noun[ 0 ] : noun[ 1 ] }` : genericCount; const start = total ? ( page - 1 ) * perPage + 1 : 0; const end = Math.min( total, page * perPage ); const onSort = ( col: DataTableColumn< R > ) => { if ( ! col.sortValue ) { return; } setSort( ( prev ) => prev && prev.column === col.id ? { column: col.id, direction: prev.direction === 'asc' ? 'desc' : 'asc', } : { column: col.id, direction: 'asc' } ); }; const colCount = columns.length + ( selectable ? 1 : 0 ) + ( rowActions ? 1 : 0 ); return (
{ countLabel } { toolbarStart }
{ filters.map( ( f ) => ( { f.onChange( v ); goTo( 1 ); } } __nextHasNoMarginBottom /> ) ) } { ( search || searchText ) && ( { setQ( v ); goTo( 1 ); } } placeholder={ ( search && search.placeholder ) || __( 'Search', 'streamsera' ) } __nextHasNoMarginBottom /> ) } { onViewChange && (
) } { toolbarEnd }
{ selectable && selected.size > 0 && (
{ selected.size < filtered.length && filtered.length > visible.length && ( ) } { bulkActions .filter( ( a ) => ! a.when || selectedRows.every( a.when ) ) .map( ( a ) => ( ) ) }
) } { loading && (
) } { ! visible.length ? (
{ empty || (

{ __( 'Nothing matches', 'streamsera' ) }

{ __( 'Try a different search or clear the filters.', 'streamsera' ) }

) }
) : view === 'grid' && renderCard ? (
{ visible.map( ( r ) => { const k = rowKey( r ); const isSel = selected.has( k ); return (
{ selectable && ( toggle( k ) } label={ __( 'Select', 'streamsera' ) } /> ) } { renderCard( r, { selected: isSel, toggle: () => toggle( k ), } ) }
); } ) }
) : (
{ selectable && ( ) } { columns.map( ( c ) => { const active = sort && sort.column === c.id; return ( ); } ) } { rowActions && ( { visible.map( ( r, i ) => { const k = rowKey( r ); const isSel = selected.has( k ); return ( { const t = e.target as HTMLElement; if ( t.closest( 'a, button, input, label, .ss-table__actions, .ss-dt__check-col' ) ) { return; } onRowClick( r ); } : undefined } > { selectable && ( ) } { columns.map( ( c ) => ( ) ) } { rowActions && ( ) } ); } ) } { footer && ( ) }
{ c.sortValue ? ( ) : ( c.label ) } ) }
toggle( k ) } label={ __( 'Select row', 'streamsera' ) } /> { c.render( r, i ) } { rowActions( r ) }
{ footer }
) } { paginate && total > 0 && (
{ sprintf( // translators: %1$s: first row number, %2$s: last row number, %3$s: total rows __( '%1$s–%2$s of %3$s', 'streamsera' ), String( start ), String( end ), String( total ) ) }
) }
); }