diff --git a/apps/devfractal-demo/package.json b/apps/devfractal-demo/package.json
index 672bfba7..a70bc6e0 100644
--- a/apps/devfractal-demo/package.json
+++ b/apps/devfractal-demo/package.json
@@ -71,6 +71,7 @@
"react-dom": "18.2.0",
"react-error-boundary": "4.0.11",
"react-router-dom": "6.16.0",
+ "react-virtuoso": "4.6.1",
"sort-by": "1.2.0",
"tailwind-merge": "1.14.0",
"tailwindcss-animate": "1.0.7",
diff --git a/apps/devfractal-demo/src/main.tsx b/apps/devfractal-demo/src/main.tsx
index 71cb63d0..a42fc70d 100644
--- a/apps/devfractal-demo/src/main.tsx
+++ b/apps/devfractal-demo/src/main.tsx
@@ -3,7 +3,7 @@ import { RouterProvider, createBrowserRouter } from 'react-router-dom'
import { queryClient } from '@/queryClient'
-import { DataTable } from './server-side/DataTable'
+import { DataGridApp } from './server-side/DataGridApp'
import './global.css'
if (process.env.NODE_ENV === 'development') {
@@ -14,7 +14,7 @@ if (process.env.NODE_ENV === 'development') {
const router = createBrowserRouter([
{
path: '*',
- element: ,
+ element: ,
},
])
diff --git a/apps/devfractal-demo/src/mocks/data-table/dataHandlers.ts b/apps/devfractal-demo/src/mocks/data-table/dataHandlers.ts
index 190e7f31..08b6b9ee 100644
--- a/apps/devfractal-demo/src/mocks/data-table/dataHandlers.ts
+++ b/apps/devfractal-demo/src/mocks/data-table/dataHandlers.ts
@@ -1,62 +1,26 @@
-import { fromSearchParams, isArray, isNotNullish, toInt } from 'devfractal'
+import { fromSearchParams } from 'devfractal'
import { rest } from 'msw'
-import {
- getSearchedProducts,
- getSlicedProducts,
- getSortedProducts,
-} from './operations'
+import { getDataFromTable } from './database'
export const dataHandlers = [
rest.get('/api/data/products', (req, res, ctx) => {
const queryParams = fromSearchParams(req.url.searchParams)
-
- if (
- isNotNullish(queryParams.searchBy) &&
- isNotNullish(queryParams.search)
- ) {
- return res(
- ctx.json(
- getSearchedProducts(
- toInt(queryParams.page),
- toInt(queryParams.limit),
- isArray(queryParams.column)
- ? queryParams.column
- : [queryParams.column],
- queryParams.searchBy,
- queryParams.search,
- queryParams.sortBy,
- queryParams.order,
- ),
- ),
- )
- }
-
- if (isNotNullish(queryParams.sortBy) && isNotNullish(queryParams.order)) {
- return res(
- ctx.json(
- getSortedProducts(
- toInt(queryParams.page),
- toInt(queryParams.limit),
- isArray(queryParams.column)
- ? queryParams.column
- : [queryParams.column],
- queryParams.sortBy,
- queryParams.order,
- ),
- ),
- )
- }
+ const { show, column, page, limit, sortBy, order, searchBy, search } =
+ queryParams
return res(
ctx.json(
- getSlicedProducts(
- toInt(queryParams.page),
- toInt(queryParams.limit),
- isArray(queryParams.column)
- ? queryParams.column
- : [queryParams.column],
- ),
+ getDataFromTable({
+ show,
+ column,
+ page,
+ limit,
+ sortBy,
+ order,
+ searchBy,
+ search,
+ }),
),
)
}),
diff --git a/apps/devfractal-demo/src/mocks/data-table/database.ts b/apps/devfractal-demo/src/mocks/data-table/database.ts
new file mode 100644
index 00000000..e9f27db7
--- /dev/null
+++ b/apps/devfractal-demo/src/mocks/data-table/database.ts
@@ -0,0 +1,232 @@
+import {
+ any,
+ chain,
+ filter,
+ isArray,
+ isDefined,
+ omit$,
+ paged,
+ pipe,
+} from 'devfractal'
+
+import { data, type Product } from '@/server-side'
+
+import { orderBy, picked } from './utils'
+
+export type ProductsResponse = {
+ products: Array
+ currentPage: number
+ totalPages: number
+ totalItems: number
+ columns: string[]
+}
+
+export function getDataFromTable(params: {
+ show: 'all' | 'paged'
+ column: string | string[]
+ page?: number
+ limit?: number
+ sortBy?: string
+ order?: 'asc' | 'desc'
+ searchBy?: keyof Product | 'all'
+ search?: string
+}): ProductsResponse {
+ const selected = isArray(params.column) ? params.column : [params.column]
+
+ if (
+ params.show === 'all' &&
+ isDefined(params.sortBy) &&
+ isDefined(params.order) &&
+ isDefined(params.searchBy) &&
+ isDefined(params.search)
+ ) {
+ const searchKey = params.searchBy
+ const searchStr = params.search
+
+ return {
+ products: chain(
+ data,
+ orderBy(params.sortBy as keyof Product, params.order),
+ filter(product => hasSearchString(product, searchKey, searchStr)),
+ picked([...selected] as (keyof Product)[]),
+ ),
+ columns: selected,
+ currentPage: 0,
+ totalItems: data.length,
+ totalPages: 0,
+ }
+ }
+
+ if (
+ params.show === 'all' &&
+ isDefined(params.sortBy) &&
+ isDefined(params.order)
+ ) {
+ return {
+ products: chain(
+ data,
+ orderBy(params.sortBy as keyof Product, params.order),
+ picked([...selected] as (keyof Product)[]),
+ ),
+ columns: selected,
+ currentPage: 0,
+ totalItems: data.length,
+ totalPages: 0,
+ }
+ }
+
+ if (
+ params.show === 'all' &&
+ isDefined(params.searchBy) &&
+ isDefined(params.search)
+ ) {
+ const searchKey = params.searchBy
+ const searchStr = params.search
+
+ return {
+ products: chain(
+ data,
+ filter(product => hasSearchString(product, searchKey, searchStr)),
+ picked([...selected] as (keyof Product)[]),
+ ),
+ columns: selected,
+ currentPage: 0,
+ totalItems: data.length,
+ totalPages: 0,
+ }
+ }
+
+ if (params.show === 'all') {
+ return {
+ products: chain(data, picked([...selected] as (keyof Product)[])),
+ columns: selected,
+ currentPage: 0,
+ totalItems: data.length,
+ totalPages: 0,
+ }
+ }
+
+ return getPaginatedProducts({ ...omit$(params, ['show']) })
+}
+
+function getPaginatedProducts(params: {
+ column: string | string[]
+ page?: number
+ limit?: number
+ sortBy?: string
+ order?: 'asc' | 'desc'
+ searchBy?: keyof Product | 'all'
+ search?: string
+}): ProductsResponse {
+ const selected = isArray(params.column) ? params.column : [params.column]
+
+ if (
+ isDefined(params.page) &&
+ isDefined(params.limit) &&
+ isDefined(params.sortBy) &&
+ isDefined(params.order) &&
+ isDefined(params.searchBy) &&
+ isDefined(params.search)
+ ) {
+ const searchKey = params.searchBy
+ const searchStr = params.search
+
+ return {
+ products: chain(
+ data,
+ paged(params.page, params.limit),
+ orderBy(params.sortBy as keyof Product, params.order),
+ filter(product => hasSearchString(product, searchKey, searchStr)),
+ picked([...selected] as (keyof Product)[]),
+ ),
+ columns: selected,
+ currentPage: params.page,
+ totalItems: data.length,
+ totalPages: Math.ceil(data.length / params.limit),
+ }
+ }
+
+ if (
+ isDefined(params.page) &&
+ isDefined(params.limit) &&
+ isDefined(params.sortBy) &&
+ isDefined(params.order)
+ ) {
+ return {
+ products: chain(
+ data,
+ paged(params.page, params.limit),
+ orderBy(params.sortBy as keyof Product, params.order),
+ picked([...selected] as (keyof Product)[]),
+ ),
+ columns: selected,
+ currentPage: params.page,
+ totalItems: data.length,
+ totalPages: Math.ceil(data.length / params.limit),
+ }
+ }
+
+ if (
+ isDefined(params.page) &&
+ isDefined(params.limit) &&
+ isDefined(params.searchBy) &&
+ isDefined(params.search)
+ ) {
+ const searchKey = params.searchBy
+ const searchStr = params.search
+
+ return {
+ products: chain(
+ data,
+ paged(params.page, params.limit),
+ filter(product => hasSearchString(product, searchKey, searchStr)),
+ picked([...selected] as (keyof Product)[]),
+ ),
+ columns: selected,
+ currentPage: params.page,
+ totalItems: data.length,
+ totalPages: Math.ceil(data.length / params.limit),
+ }
+ }
+
+ if (isDefined(params.page) && isDefined(params.limit)) {
+ return {
+ products: chain(
+ data,
+ paged(params.page, params.limit),
+ picked([...selected] as (keyof Product)[]),
+ ),
+ columns: selected,
+ currentPage: params.page,
+ totalItems: data.length,
+ totalPages: Math.ceil(data.length / params.limit),
+ }
+ }
+
+ return {
+ products: chain(
+ data,
+ paged(1, 10),
+ picked([...selected] as (keyof Product)[]),
+ ),
+ columns: selected,
+ currentPage: 1,
+ totalItems: data.length,
+ totalPages: Math.ceil(data.length / 10),
+ }
+}
+
+function hasSearchString, K extends keyof T>(
+ obj: T,
+ key: K | 'all',
+ text: string,
+): boolean {
+ if (key !== 'all') {
+ return obj[key].toString().toLowerCase().includes(text.toLowerCase())
+ }
+
+ return pipe(
+ Object.values(obj),
+ any(value => value.toString().toLowerCase().includes(text.toLowerCase())),
+ )
+}
diff --git a/apps/devfractal-demo/src/mocks/data-table/operations.ts b/apps/devfractal-demo/src/mocks/data-table/operations.ts
deleted file mode 100644
index c7a69f7b..00000000
--- a/apps/devfractal-demo/src/mocks/data-table/operations.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-import { pick$ } from 'devfractal'
-
-import { data, type Product, type Products } from '@/server-side/products'
-import { iorderBy } from '@/server-side/utils'
-
-export type ProductsResponse = {
- products: Products
- currentPage: number
- totalPages: number
- totalItems: number
- columns: string[]
-}
-
-const products: Products = []
-
-// Load data
-const intializeProducts = (): void => {
- products.push(...data)
-}
-
-intializeProducts()
-
-// Operations
-
-export const getSlicedProducts = (
- page: number,
- limit: number,
- columns: string[],
-): ProductsResponse => {
- // @TODO: use paged from array methods
- const endIndex = page * limit
- const startIndex = endIndex - limit
-
- const totalPages = Math.ceil(products.length / limit)
-
- return {
- products: products
- .slice(startIndex, endIndex)
- .map(product =>
- pick$(product, ['id', ...(columns as (keyof Product)[])]),
- ),
- currentPage: page,
- totalPages,
- totalItems: products.length,
- columns,
- }
-}
-
-export const getSortedProducts = (
- page: number,
- limit: number,
- columns: string[],
- sortKey: keyof Product,
- order: 'asc' | 'desc',
-): ProductsResponse => {
- const slicedResult = getSlicedProducts(page, limit, columns)
-
- return {
- ...slicedResult,
- products: iorderBy(slicedResult.products, sortKey, order),
- }
-}
-
-// @TODO: Function overloading?
-
-export const getSearchedProducts = (
- page: number,
- limit: number,
- columns: string[],
- searchBy: keyof Product | 'all',
- search: string,
- sortKey?: keyof Product,
- order?: 'asc' | 'desc',
-): ProductsResponse => {
- const result =
- sortKey && order
- ? getSortedProducts(page, limit, columns, sortKey, order)
- : getSlicedProducts(page, limit, columns)
-
- const products =
- searchBy !== 'all'
- ? result.products.filter(product =>
- product[searchBy]
- .toString()
- .toLowerCase()
- .includes(search.toLowerCase()),
- )
- : result.products.filter(product =>
- Object.values(product).some(value =>
- value.toString().toLowerCase().includes(search.toLowerCase()),
- ),
- )
-
- const totalPages = Math.ceil(products.length / limit)
-
- return {
- ...result,
- products,
- totalPages,
- }
-}
diff --git a/apps/devfractal-demo/src/mocks/data-table/utils.ts b/apps/devfractal-demo/src/mocks/data-table/utils.ts
new file mode 100644
index 00000000..0d5af526
--- /dev/null
+++ b/apps/devfractal-demo/src/mocks/data-table/utils.ts
@@ -0,0 +1,56 @@
+import { all, chain, isDefined, map, pick$, pipe } from 'devfractal'
+
+type Order = 'asc' | 'desc'
+
+function cmp(key: K, order: Order) {
+ return (a: T, b: T): number => {
+ const x = a[key]
+ const y = b[key]
+
+ if (x < y) {
+ return order === 'asc' ? -1 : 1
+ }
+ if (x > y) {
+ return order === 'asc' ? 1 : -1
+ }
+ return 0
+ }
+}
+
+export function iorderBy(
+ arr: Iterable,
+ key: K,
+ order: Order = 'asc',
+): T[] {
+ return [...arr].sort(cmp(key, order))
+}
+
+export function orderBy(
+ key: K,
+ order: Order = 'asc',
+) {
+ return (arr: Iterable) => {
+ return [...arr].sort(cmp(key, order))
+ }
+}
+
+// @TODO: Possible to convert to type guard?
+export function areDefined(arr: unknown[]): boolean {
+ return pipe(
+ arr,
+ all(el => isDefined(el)),
+ )
+}
+
+export function picked(keys: K[]) {
+ return (arr: Iterable): Iterable> => {
+ return chain(
+ arr,
+ map(e => pick$(e, [...keys])),
+ )
+ }
+}
+
+export function insertAt(src: readonly T[], index: number, value: T) {
+ return [...src.slice(0, index), value, ...src.slice(index)]
+}
diff --git a/apps/devfractal-demo/src/server-side/DataTable.tsx b/apps/devfractal-demo/src/server-side/DataGridApp.tsx
similarity index 66%
rename from apps/devfractal-demo/src/server-side/DataTable.tsx
rename to apps/devfractal-demo/src/server-side/DataGridApp.tsx
index 90ec1495..2a56453c 100644
--- a/apps/devfractal-demo/src/server-side/DataTable.tsx
+++ b/apps/devfractal-demo/src/server-side/DataGridApp.tsx
@@ -1,30 +1,34 @@
import { useQuery } from '@tanstack/react-query'
import {
- Table,
VStack,
Text,
HStack,
toInt,
toSearch,
remove$,
- insert$,
fromSearchParams,
+ isEmptyString,
+ omit$,
+ isArray,
} from 'devfractal'
import { useSearchParams } from 'react-router-dom'
-import { DataBody } from './components/DataBody'
-import { DataHeader } from './components/DataHeaders'
+import { insertAt } from '@/mocks/data-table/utils'
+
+import { DataTable } from './components/DataTable'
import { Header } from './components/Header'
import { Pagination } from './components/Pagination'
+import { VirtualDataTable } from './components/VirtualDataTable'
import { headers } from './products'
import { fetchProducts } from './query'
// @TODO: Row operations: https://ui.shadcn.com/docs/components/combobox#dropdown-menu
// https://tailwindcss.com/docs/hover-focus-and-other-states#styling-based-on-parent-state
-export function DataTable(): JSX.Element {
+export function DataGridApp(): JSX.Element {
const [state, setState] = useSearchParams(
toSearch({
+ show: 'paged',
page: 1,
limit: 10,
column: ['title', 'price', 'brand', 'category'],
@@ -37,6 +41,8 @@ export function DataTable(): JSX.Element {
const { isLoading, isSuccess, data } = useQuery({
queryKey: [
'products',
+ queryParams.show,
+ queryParams.show,
queryParams.page,
queryParams.limit,
queryParams.column,
@@ -47,8 +53,9 @@ export function DataTable(): JSX.Element {
],
queryFn: () =>
fetchProducts({
- page: toInt(queryParams.page),
- limit: toInt(queryParams.limit),
+ show: queryParams.show,
+ page: queryParams.page ? toInt(queryParams.page) : undefined,
+ limit: queryParams.limit ? toInt(queryParams.limit) : undefined,
column: queryParams.column,
sortBy: queryParams.sortBy,
order: queryParams.order,
@@ -93,18 +100,33 @@ export function DataTable(): JSX.Element {
}
function handleSearch(value: { searchBy: string; search: string }) {
- setState(toSearch({ ...queryParams, ...value }))
+ const nextSearchState = isEmptyString(value.search)
+ ? omit$(queryParams, ['searchBy', 'search'])
+ : { ...queryParams, ...value }
+
+ setState(toSearch(nextSearchState))
+ }
+
+ function handleInfinite() {
+ const show = queryParams.show === 'all' ? 'paged' : 'all'
+ const params =
+ show === 'all' ? omit$(queryParams, ['page', 'limit']) : queryParams
+
+ setState(toSearch({ ...params, show }))
}
- // @TODO: Improve - add generics may be
function handleColumns(header: string) {
+ const selected = isArray(queryParams.column)
+ ? queryParams.column
+ : [queryParams.column]
+
const index = headers.indexOf(header)
- const column = queryParams.column.includes(header)
- ? (remove$(
- queryParams.column,
- queryParams.column.indexOf(header),
- ) as string[])
- : insert$(queryParams.column, index, header)
+
+ const column = selected.includes(header)
+ ? selected.length > 1
+ ? remove$(selected, selected.indexOf(header))
+ : selected
+ : insertAt(selected, index, header)
setState(toSearch({ ...queryParams, column }))
}
@@ -121,25 +143,35 @@ export function DataTable(): JSX.Element {
onSelect={handleColumns}
columns={data.columns}
/>
-
+ )}
- {/* @TODO: CLean up prop passing */}
>
) : (
diff --git a/apps/devfractal-demo/src/server-side/components/DataBody.tsx b/apps/devfractal-demo/src/server-side/components/DataBody.tsx
deleted file mode 100644
index df450bde..00000000
--- a/apps/devfractal-demo/src/server-side/components/DataBody.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import { TableBody, TableCell, TableRow } from 'devfractal'
-
-// @TODO: Improve generics
-export function DataBody<
- T extends { id: number; [k: string]: number | string },
->({ data }: { data: readonly T[] }): JSX.Element {
- return (
-
- {data.length > 0 ? (
- data.map(product => {
- return (
-
- {Object.keys(product)
- .filter(key => key !== 'id')
- .map(item => {
- return {product[item]}
- })}
-
- )
- })
- ) : (
-
- No data!
-
- )}
-
- )
-}
diff --git a/apps/devfractal-demo/src/server-side/components/DataTable.tsx b/apps/devfractal-demo/src/server-side/components/DataTable.tsx
new file mode 100644
index 00000000..519836dd
--- /dev/null
+++ b/apps/devfractal-demo/src/server-side/components/DataTable.tsx
@@ -0,0 +1,90 @@
+import { faker } from '@faker-js/faker'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+ isArray,
+ keys,
+} from 'devfractal'
+
+import { HeaderWrapper } from './HeaderWrapper'
+
+export function DataTable(props: {
+ data: Array