oxios 1.10.1

Oxios Agent OS — Agent Operating System powered by oxi-sdk
import { ArrowDown, ArrowUp, ArrowUpDown, FolderOpen } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { ColumnFilter } from './column-filter'
import { EmptyState } from './empty-state'
import { LoadingCards } from './loading'
import { Pagination } from './pagination'
import { SearchBar } from './search-bar'

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

export interface Column<T> {
  /** Column header label */
  header: string
  /** Key on T to read, or custom render function */
  accessor: keyof T | ((row: T) => React.ReactNode)
  /** Sort key — if provided, column is sortable */
  sortKey?: keyof T
  /** Filter definition — if provided, column is filterable */
  filter?: {
    options: { label: string; value: string }[]
  }
  /** Additional CSS class for cells */
  className?: string
  /** Mobile card view priority */
  mobilePriority?: 'primary' | 'secondary' | 'hidden'
}

export interface DataTableProps<T> {
  /** Column definitions */
  columns: Column<T>[]
  /** Row data */
  data: T[]
  /** Unique key extractor for each row */
  keyExtractor: (row: T) => string
  /** Row click handler */
  onRowClick?: (row: T) => void

  // Search
  /** Enable global search bar */
  searchable?: boolean
  /** Placeholder text for search input */
  searchPlaceholder?: string
  /** Fields to search across */
  searchKeys?: (keyof T)[]
  /** External search value (controlled) */
  searchValue?: string
  /** External search change handler */
  onSearchChange?: (value: string) => void

  // Filtering
  /** Column-level filter definitions */
  filterable?: {
    key: keyof T
    options: { label: string; value: string }[]
  }[]

  // Sorting
  /** Keys that are sortable */
  sortable?: (keyof T)[]
  /** Initial sort key */
  defaultSortKey?: keyof T
  /** Initial sort direction */
  defaultSortDir?: 'asc' | 'desc'

  // Pagination
  /** Enable pagination with given page size */
  pagination?: { pageSize: number }
  /** Show mobile card view (dual-render: hidden on md+ via CSS) */
  mobileCardView?: boolean

  // Display
  /** Empty state message */
  emptyMessage?: string
  /** Loading state */
  loading?: boolean
  /** Table caption for a11y */
  caption?: string
  /** Additional CSS class */
  className?: string
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

type SortState<T> = { key: keyof T; dir: 'asc' | 'desc' } | null

function getCellValue<T>(row: T, accessor: Column<T>['accessor']): React.ReactNode {
  if (typeof accessor === 'function') return accessor(row)
  const val = row[accessor]
  if (val === null || val === undefined) return ''
  return String(val)
}

function getStringValue<T>(row: T, key: keyof T): string {
  const val = row[key]
  if (val === null || val === undefined) return ''
  if (typeof val === 'string') return val.toLowerCase()
  if (typeof val === 'number' || typeof val === 'boolean') return String(val).toLowerCase()
  if (val instanceof Date) return val.toISOString().toLowerCase()
  return String(val).toLowerCase()
}

// ---------------------------------------------------------------------------
// CardRow (mobile card view)
// ---------------------------------------------------------------------------

function CardRow<T>({
  row,
  columns,
  onRowClick,
}: {
  row: T
  columns: Column<T>[]
  onRowClick?: (row: T) => void
}) {
  const primary = columns.find((c) => c.mobilePriority === 'primary')
  const secondary = columns.filter((c) => c.mobilePriority === 'secondary')

  return (
    <button
      type="button"
      onClick={() => onRowClick?.(row)}
      className="w-full text-left p-4 hover:bg-muted/50 active:bg-muted transition-colors"
    >
      {primary && (
        <div className="font-medium text-sm truncate">{getCellValue(row, primary.accessor)}</div>
      )}
      {secondary.length > 0 && (
        <div className="mt-1 grid grid-cols-2 gap-x-3 gap-y-1 text-xs text-muted-foreground">
          {secondary.map((col) => (
            <div key={String(col.accessor)} className="flex gap-1 min-w-0">
              <span className="opacity-60 shrink-0">{col.header}:</span>
              <span className="truncate">{getCellValue(row, col.accessor)}</span>
            </div>
          ))}
        </div>
      )}
    </button>
  )
}

// ---------------------------------------------------------------------------
// DataTable
// ---------------------------------------------------------------------------

export function DataTable<T>({
  columns,
  data,
  keyExtractor,
  onRowClick,
  searchable,
  searchPlaceholder,
  searchKeys,
  searchValue: externalSearch,
  onSearchChange,
  filterable,
  sortable,
  defaultSortKey,
  defaultSortDir,
  pagination,
  mobileCardView,
  emptyMessage,
  loading,
  caption,
  className,
}: DataTableProps<T>) {
  const { t } = useTranslation()

  // ── Internal state (uncontrolled mode) ──
  const [internalSearch, setInternalSearch] = useState('')
  const [sort, setSort] = useState<SortState<T>>(
    defaultSortKey ? { key: defaultSortKey, dir: defaultSortDir ?? 'asc' } : null,
  )
  const [filters, setFilters] = useState<Record<string, string[]>>({})
  const [page, setPage] = useState(1)
  const [pageSize, setPageSize] = useState(pagination?.pageSize ?? 20)

  // Use external search value if provided
  const search = externalSearch ?? internalSearch
  const setSearch = onSearchChange ?? setInternalSearch

  // ── Filter / Search / Sort ──
  const processed = useMemo(() => {
    let result = [...data]

    // Search
    if (search) {
      const lower = search.toLowerCase()
      const keys =
        searchKeys ??
        (columns
          .map((c) => (typeof c.accessor === 'function' ? undefined : c.accessor))
          .filter(Boolean) as (keyof T)[])
      result = result.filter((row) => keys.some((key) => getStringValue(row, key).includes(lower)))
    }

    // Filters
    for (const [key, selected] of Object.entries(filters)) {
      if (selected.length === 0) continue
      const typedKey = key as keyof T
      result = result.filter((row) => {
        const val = getStringValue(row, typedKey)
        return selected.some((s) => val === s.toLowerCase())
      })
    }

    // Sort
    if (sort) {
      result.sort((a, b) => {
        const aVal = getStringValue(a, sort.key)
        const bVal = getStringValue(b, sort.key)
        const cmp = aVal.localeCompare(bVal)
        return sort.dir === 'asc' ? cmp : -cmp
      })
    }

    return result
  }, [data, search, filters, sort, searchKeys, columns])

  // ── Pagination ──
  const total = processed.length
  const totalPages = Math.max(1, Math.ceil(total / pageSize))
  const safePage = Math.min(page, totalPages)
  const paginated = pagination
    ? processed.slice((safePage - 1) * pageSize, safePage * pageSize)
    : processed

  // Reset page when search/filters change
  const handleSearch = (val: string) => {
    setSearch(val)
    setPage(1)
  }

  const handleFilterChange = (key: string, selected: string[]) => {
    setFilters((prev) => ({ ...prev, [key]: selected }))
    setPage(1)
  }

  const handleSort = (key: keyof T) => {
    setSort((prev) => {
      if (!prev || prev.key !== key) return { key, dir: 'asc' }
      if (prev.dir === 'asc') return { key, dir: 'desc' }
      return null // Third click: remove sort
    })
  }

  // ── Render ──
  const hasToolbar = searchable || (filterable && filterable.length > 0)

  if (loading) return <LoadingCards count={3} />

  return (
    <div className={cn('rounded-xl border', className)}>
      {/* Toolbar */}
      {hasToolbar && (
        <div className="flex items-center gap-2 px-4 py-3 border-b bg-muted/30">
          {searchable && (
            <SearchBar value={search} onChange={handleSearch} placeholder={searchPlaceholder} />
          )}
          {filterable?.map((f) => (
            <ColumnFilter
              key={String(f.key)}
              columnKey={String(f.key)}
              label={
                columns.find((c) => {
                  const acc = c.accessor
                  return typeof acc !== 'function' && acc === f.key
                })?.header ?? String(f.key)
              }
              options={f.options}
              selected={filters[String(f.key)] ?? []}
              onChange={(selected) => handleFilterChange(String(f.key), selected)}
            />
          ))}
          {sort && (
            <span className="ml-auto text-xs text-muted-foreground">
              {t('dataTable.sortedBy', 'Sorted by')}{' '}
              {columns.find((c) => {
                const acc = c.accessor
                return typeof acc !== 'function' && acc === sort.key
              })?.header ?? String(sort.key)}{' '}
              {sort.dir === 'asc' ? '↑' : '↓'}
            </span>
          )}
        </div>
      )}

      {/* Table — hidden on mobile when card view active */}
      <div className={cn('overflow-x-auto', mobileCardView && 'hidden md:block')}>
        <table className="w-full" aria-label={caption}>
          {caption && <caption className="sr-only">{caption}</caption>}
          <thead>
            <tr className="border-b bg-muted/50">
              {columns.map((col) => {
                const canSort = sortable?.includes(
                  col.sortKey ??
                    ((typeof col.accessor !== 'function' ? col.accessor : undefined) as keyof T),
                )
                const sortKey =
                  col.sortKey ??
                  ((typeof col.accessor !== 'function' ? col.accessor : undefined) as keyof T)
                const isSorted = sort?.key === sortKey

                return (
                  <th
                    key={col.header}
                    scope="col"
                    className={cn(
                      'px-4 py-3 text-left text-sm font-medium text-muted-foreground',
                      canSort && 'cursor-pointer select-none hover:text-foreground',
                      col.className,
                    )}
                    onClick={() => canSort && sortKey && handleSort(sortKey)}
                  >
                    <span className="inline-flex items-center gap-1">
                      {col.header}
                      {canSort &&
                        (isSorted ? (
                          sort?.dir === 'asc' ? (
                            <ArrowUp className="h-3.5 w-3.5" />
                          ) : (
                            <ArrowDown className="h-3.5 w-3.5" />
                          )
                        ) : (
                          <ArrowUpDown className="h-3.5 w-3.5 opacity-30" />
                        ))}
                    </span>
                  </th>
                )
              })}
            </tr>
          </thead>
          <tbody>
            {paginated.length === 0 ? (
              <tr>
                <td colSpan={columns.length} className="py-12 text-center">
                  <EmptyState
                    icon={<FolderOpen className="h-8 w-8" />}
                    title={emptyMessage ?? t('dataTable.noResults', 'No results found')}
                    className="py-6"
                  />
                </td>
              </tr>
            ) : (
              paginated.map((row) => (
                <tr
                  key={keyExtractor(row)}
                  className={cn(
                    'border-b last:border-0 transition-all',
                    onRowClick &&
                      'cursor-pointer select-none hover:bg-muted/80 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset',
                  )}
                  onClick={() => onRowClick?.(row)}
                  tabIndex={onRowClick ? 0 : undefined}
                  onKeyDown={(e) => {
                    if (e.key === 'Enter') onRowClick?.(row)
                  }}
                >
                  {columns.map((col) => (
                    <td
                      key={String(col.accessor)}
                      className={cn('px-4 py-3 text-sm', col.className)}
                    >
                      {getCellValue(row, col.accessor)}
                    </td>
                  ))}
                </tr>
              ))
            )}
          </tbody>
        </table>
      </div>

      {/* Pagination */}
      {pagination && total > 0 && (
        <div className="border-t">
          <Pagination
            page={safePage}
            limit={pageSize}
            total={total}
            onPageChange={setPage}
            onLimitChange={setPageSize}
          />
        </div>
      )}

      {/* Mobile card view */}
      {mobileCardView !== false && (
        <div className="divide-y md:hidden">
          {paginated.map((row) => (
            <CardRow key={keyExtractor(row)} row={row} columns={columns} onRowClick={onRowClick} />
          ))}
        </div>
      )}
    </div>
  )
}