Skip to content

Commit

Permalink
feat: Generic table (#109)
Browse files Browse the repository at this point in the history
* build infra of Table component

* add scrollbar

* - create generic table
- make simple example on story book

* fix: lint warnings

* fix: build error

* fix: update feedback from hachiojidev

* fix: update feedback

* fix: update type of value

The purpose of using value is to implement search feature ( by string )

* fix: lint issue fix on storybook component

* fix: remove lint disable line

* fix: remove eslint disable lines

Co-authored-by: Ranonpro <59729252+ranon127@users.noreply.github.com>
  • Loading branch information
plind-dm and plind-dm committed Jan 12, 2021
1 parent 80629a7 commit fd8d4b9
Show file tree
Hide file tree
Showing 7 changed files with 547 additions and 0 deletions.
40 changes: 40 additions & 0 deletions src/components/Table/example/const.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
export const columns = [
{
id: 1,
name: "id",
hidden: true,
},
{
id: 2,
name: "pool",
label: "POOL",
render: ({ value }: { value: React.ReactNode }): React.ReactNode => value,
},
{
id: 3,
name: "apy",
label: "APY",
},
{
id: 4,
name: "EARNED",
},
{
id: 5,
name: "STAKED",
},
{
id: 6,
name: "DETAILS",
},
{
id: 7,
name: "LINKS",
},
{
id: 8,
name: "TAGS",
},
];

export const data = [];
22 changes: 22 additions & 0 deletions src/components/Table/example/header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import styled from "styled-components";

const StyledTh = styled.th`
background: #eff4f5;
padding: 8px;
font-size: 12px;
color: #8f80ba;

&:first-child {
border-top-left-radius: 4px;
border-bottom-right-radius: 4px;
padding-left: 16px;
}

&:last-child {
border-top-right-radius: 4px;
border-bottom-left-radius: 4px;
padding-right: 16px;
}
`;

export default StyledTh;
258 changes: 258 additions & 0 deletions src/components/Table/hooks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
import { useMemo, useReducer, useEffect, ReactNode } from "react";
import {
ColumnByNamesType,
ColumnType,
TableState,
TableAction,
DataType,
UseTableReturnType,
UseTableOptionsType,
RowType,
HeaderType,
ColumnStateType,
HeaderRenderType,
} from "./types";
import { byTextAscending, byTextDescending } from "./utils";

const sortByColumn = <T extends DataType>(
data: RowType<T>[],
sortColumn: string,
columns: ColumnStateType<T>[]
): RowType<T>[] => {
let isAscending = null;
let sortedRows: RowType<T>[] = [...data];

columns.forEach((column) => {
// if the row was found
if (sortColumn === column.name) {
isAscending = column.sorted.asc;

if (column.sort) {
sortedRows = isAscending ? data.sort(column.sort) : data.sort(column.sort).reverse();
// default to sort by string
} else {
sortedRows = isAscending
? data.sort(byTextAscending((object) => object.original[sortColumn]))
: data.sort(byTextDescending((object) => object.original[sortColumn]));
}
}
});

return sortedRows;
};

const getPaginatedData = <T extends DataType>(rows: RowType<T>[], perPage: number, page: number) => {
const start = (page - 1) * perPage;
const end = start + perPage;
return rows.slice(start, end);
};

const getColumnsByName = <T extends DataType>(columns: ColumnType<T>[]): ColumnByNamesType<T> => {
const columnsByName: ColumnByNamesType<T> = {};
columns.forEach((column) => {
const col: ColumnType<T> = {
id: column.id,
name: column.name,
label: column.label,
};

if (column.render) {
col.render = column.render;
}
col.hidden = column.hidden;
columnsByName[column.name] = col;
});

return columnsByName;
};

const sortDataInOrder = <T extends DataType>(data: T[], columns: ColumnType<T>[]): T[] => {
return data.map((row: T) => {
const newRow: DataType = {};
columns.forEach((column) => {
if (!(column.name in row)) {
throw new Error(`Invalid row data, ${column.name} not found`);
}
newRow[column.name] = row[column.name];
});
return newRow as T;
});
};

const makeRender = <T extends DataType, K>(
valueT: K,
render: (({ value, row }: { value: K; row: T }) => ReactNode) | undefined,
row: T
) => {
return render ? () => render({ row, value: valueT }) : () => valueT;
};

const makeHeaderRender = (label: string, render?: HeaderRenderType<string>) => {
return render ? () => render({ label }) : () => label;
};
export const createReducer = <T extends DataType>() => (
state: TableState<T>,
action: TableAction<T>
): TableState<T> => {
switch (action.type) {
case "SET_ROWS": {
let rows = [...action.data];
// preserve sorting if a sort is already enabled when data changes
if (state.sortColumn) {
rows = sortByColumn(action.data, state.sortColumn, state.columns);
}

if (state.paginationEnabled === true) {
rows = getPaginatedData(rows, state.pagination.perPage, state.pagination.page);
}

if (state.paginationEnabled === true) {
rows = getPaginatedData(rows, state.pagination.perPage, state.pagination.page);
}

return {
...state,
rows,
originalRows: action.data,
};
}

case "GLOBAL_FILTER": {
const filteredRows = action.filter(state.originalRows);
const selectedRowsById: { [key: number]: boolean } = {};
state.selectedRows.forEach((row) => {
selectedRowsById[row.id] = !!row.selected;
});

return {
...state,
rows: filteredRows.map((row) => {
return selectedRowsById[row.id] ? { ...row, selected: selectedRowsById[row.id] } : { ...row };
}),
filterOn: true,
};
}
case "SEARCH_STRING": {
const stateCopySearch = { ...state };
stateCopySearch.rows = stateCopySearch.originalRows.filter((row) => {
return (
row.cells.filter((cell) => {
if (cell.value.includes(action.searchString)) {
return true;
}
return false;
}).length > 0
);
});
return stateCopySearch;
}
default:
throw new Error("Invalid reducer action");
}
};

export const useTable = <T extends DataType>(
columns: ColumnType<T>[],
data: T[],
options?: UseTableOptionsType<T>
): UseTableReturnType<T> => {
const columnsWithSorting: ColumnStateType<T>[] = useMemo(
() =>
columns.map((column) => {
return {
...column,
label: column.label ? column.label : column.name,
hidden: column.hidden ? column.hidden : false,
sort: column.sort,
sorted: {
on: false,
},
};
}),
[columns]
);
const columnsByName = useMemo(() => getColumnsByName(columnsWithSorting), [columnsWithSorting]);

const tableData: RowType<T>[] = useMemo(() => {
const sortedData = sortDataInOrder(data, columnsWithSorting);

const newData = sortedData.map((row, idx) => {
return {
id: idx,
selected: false,
hidden: false,
original: row,
cells: Object.entries(row)
.map(([column, value]) => {
return {
hidden: columnsByName[column].hidden,
field: column,
value,
render: makeRender(value, columnsByName[column].render, row),
};
})
.filter((cell) => !cell.hidden),
};
});
return newData;
}, [data, columnsWithSorting, columnsByName]);

const reducer = createReducer<T>();

const [state, dispatch] = useReducer(reducer, {
columns: columnsWithSorting,
columnsByName,
originalRows: tableData,
rows: tableData,
selectedRows: [],
toggleAllState: false,
filterOn: !!options?.filter,
sortColumn: null,
paginationEnabled: !!options?.pagination,
pagination: {
page: 1,
perPage: 10,
canNext: true,
canPrev: false,
nextPage: () => {
// nextPage feature
},
prevPage: () => {
// prevPage feature
},
},
});

useEffect(() => {
dispatch({ type: "SET_ROWS", data: tableData });
}, [tableData]);

const headers: HeaderType<T>[] = useMemo(() => {
return [
...state.columns.map((column) => {
const label = column.label ? column.label : column.name;
return {
...column,
render: makeHeaderRender(label, column.headerRender),
};
}),
];
}, [state.columns]);

useEffect(() => {
if (options?.filter) {
dispatch({ type: "GLOBAL_FILTER", filter: options.filter });
}
}, [options?.filter]);

return {
headers: headers.filter((column) => !column.hidden),
rows: state.rows,
originalRows: state.originalRows,
selectedRows: state.selectedRows,
dispatch,
setSearchString: (searchString: string) => dispatch({ type: "SEARCH_STRING", searchString }),
pagination: state.pagination,
toggleAllState: state.toggleAllState,
};
};
62 changes: 62 additions & 0 deletions src/components/Table/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import React, { useMemo } from "react";
import { useTable, ColumnType } from "./index";
import { data, columns } from "./example/const";
import StyledTh from "./example/header";
import { DataType } from './types'

const Table = <T extends DataType>({ _columns, _data }: { _columns: ColumnType<T>[]; _data: T[] }) => {
const { headers, rows } = useTable(_columns, _data, {
sortable: true,
});

return (
<table>
<thead>
<tr>
{headers.map((header) => (
<StyledTh
key={`header-${header.id}`}
data-testid={`column-${header.name}`}
>
{header.label}

{header.sorted && header.sorted.on ? <span data-testid={`sorted-${header.name}`} /> : null}
</StyledTh>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr data-testid={`row-${row.id}`} key={row.id}>
{row.cells.map((cell) => (
<td>{cell.render()}</td>
))}
</tr>
))}
</tbody>
</table>
);
};

const TableComponent: React.FunctionComponent = () => {
const memoColumns = useMemo(() => columns, []);
const memoData = useMemo(() => data, []);

return (
<Table _columns={memoColumns} _data={memoData} />
);
}

export default {
title: "Components/Table",
component: TableComponent,
argTypes: {},
};

export const Default: React.FC = () => {
return (
<div style={{ width: "500px" }}>
<TableComponent />
</div>
);
};
3 changes: 3 additions & 0 deletions src/components/Table/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./hooks";
export * from "./types";
export * from "./utils";

0 comments on commit fd8d4b9

Please sign in to comment.