Skip to content

Commit

Permalink
feat: make data-table sortable
Browse files Browse the repository at this point in the history
  • Loading branch information
abelflopes committed Jan 25, 2024
1 parent 3a97731 commit 92e1e11
Show file tree
Hide file tree
Showing 4 changed files with 121 additions and 6 deletions.
1 change: 1 addition & 0 deletions packages/components/data-table/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"react": "^18.2.0"
},
"dependencies": {
"@react-ck/icon": "^1.4.2",
"@react-ck/table": "^1.3.1",
"change-case": "^5.4.2"
}
Expand Down
100 changes: 94 additions & 6 deletions packages/components/data-table/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,38 @@
import React, { useMemo } from "react";
import React, { useCallback, useMemo, useState } from "react";
import { Table, type TableProps } from "@react-ck/table";
import * as CC from "change-case";
import { stringFromObject } from "./utils/string-from-object";
import { Icon } from "@react-ck/icon";
import styles from "./styles/index.module.scss";
import { componentToText } from "./utils/component-to-text";

// TODO: add pagination
// TODO: add section table

/** Type representing the data structure for the DataTable component */
type TableData = Array<Record<string, React.ReactNode>>;

export type SortCallback<T extends TableData> = (key: keyof T[number]) => void;

/**
* DataTableProps interface represents the props for the DataTable component.
* @typeParam T - The type of data provided for the DataTable.
*/
export interface DataTableProps<T extends TableData = TableData>
extends Omit<TableProps, "children"> {
export interface DataTableProps<T extends TableData> extends Omit<TableProps, "children"> {
/** Headers mapping to define column names and their corresponding header content */
headers?: Record<keyof T[number], React.ReactNode>;
headers?: Partial<Record<keyof T[number], React.ReactNode>>;
/** Data to be displayed in the table. Should be an array of objects with keys matching the headers' keys (non-mandatory) */
data: T;
/** Automatically create table headers based on the row keys */
autoHeaders?: boolean;
/** Allow user to sort tale content by clicking on table headers */
sortable?: boolean | Array<keyof T[number]>;
/** Sort callback to allow handling sorting externally instead of automatic string / number sorting */
onSort?: SortCallback<T>;
}

const sortModes = ["asc", "desc", "none"] as const;

/**
* Data table is a component that transforms a JSON structure into a table
* @param props - {@link DataTableProps}
Expand All @@ -33,8 +43,13 @@ export const DataTable = <T extends TableData>({
headers,
data,
autoHeaders,
sortable,
onSort,
...otherProps
}: Readonly<DataTableProps<T>>): React.ReactElement => {
const [sortMode, setSortMode] = useState<(typeof sortModes)[number]>("none");
const [sortKey, setSortKey] = useState<keyof T[number] | undefined>(undefined);

const keys = useMemo(
() =>
[...data.flatMap((index) => Object.keys(index)), ...(headers ? Object.keys(headers) : [])]
Expand All @@ -46,6 +61,7 @@ export const DataTable = <T extends TableData>({
[data, headers],
);

// Generate headers object merged into autoHeaders, if applicable
const computedHeaders = useMemo(
() => ({
...(autoHeaders ? Object.fromEntries(keys.map((k) => [k, CC.capitalCase(k)])) : undefined),
Expand All @@ -54,19 +70,91 @@ export const DataTable = <T extends TableData>({
[autoHeaders, keys, headers],
);

// Handle sorting
const defaultOnSort = useCallback<SortCallback<T>>(
(key) => {
if (!sortable || (Array.isArray(sortable) && !sortable.includes(key))) return;

if (key === sortKey) {
const nextSortMode = sortModes[(sortModes.indexOf(sortMode) + 1) % sortModes.length];
if (!nextSortMode) throw new Error("Invalid sort mode");
setSortMode(nextSortMode);
} else {
setSortMode("asc");
}

setSortKey(key);
},
[sortKey, sortMode, sortable],
);

// Sort data if applicable
const sortedData = useMemo(() => {
if (onSort || sortMode === "none" || !sortKey) return data;

const k = String(sortKey);

return [...data].sort((a, b) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
let valueA = a[k]!;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
let valueB = b[k]!;

if (!isNaN(Number(valueA)) && !isNaN(Number(valueB))) {
valueA = Number(valueA);
valueB = Number(valueB);
} else if (React.isValidElement(valueA) && React.isValidElement(valueB)) {
valueA = (componentToText(valueA) ?? "").trim().toLowerCase();
valueB = (componentToText(valueB) ?? "").trim().toLowerCase();
} else {
valueA = String(valueA).trim().toLowerCase();
valueB = String(valueB).trim().toLowerCase();
}

if (valueA > valueB) return sortMode === "desc" ? -1 : 1;
if (valueA < valueB) return sortMode === "desc" ? 1 : -1;
return 0;
});
}, [data, onSort, sortKey, sortMode]);

return (
<Table {...otherProps}>
{computedHeaders && (
<thead>
<tr>
{keys.map((key) => (
<th key={key}>{computedHeaders[key]}</th>
<th
key={key}
className={
sortable === true || (Array.isArray(sortable) && sortable.includes(key))
? styles.sortable_header
: undefined
}
onClick={() => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- FIXME: remove type assertion
defaultOnSort(key as keyof T[number]);
}}>
{computedHeaders[key]}

{sortable === true ||
(Array.isArray(sortable) && sortable.includes(key) && (
<>
{sortKey === key && sortMode === "asc" && (
<Icon name="chevron-up" className={styles.sortable_header_icon} />
)}

{sortKey === key && sortMode === "desc" && (
<Icon name="chevron-down" className={styles.sortable_header_icon} />
)}
</>
))}
</th>
))}
</tr>
</thead>
)}
<tbody>
{data.map((row) => (
{sortedData.map((row) => (
<tr key={stringFromObject(row)}>
{keys.map((key) => (
<td key={key}>{row[key]}</td>
Expand Down
8 changes: 8 additions & 0 deletions packages/components/data-table/src/styles/index.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.sortable_header {
cursor: pointer;
}

.sortable_header_icon {
margin-left: 10px;
vertical-align: text-bottom;
}
18 changes: 18 additions & 0 deletions packages/components/data-table/src/utils/component-to-text.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import React from "react";
import ReactDOM from "react-dom/server";

/** Extract text only from component markup */

export const componentToText = (component: NonNullable<React.ReactNode>): string | undefined => {
let text = "";
// eslint-disable-next-line react/jsx-no-useless-fragment -- needed to be received as a react node by react dom server
const html = ReactDOM.renderToString(<>{component}</>);
const el = document.createElement("span");
el.innerHTML = html;

if (el.textContent && el.textContent.length > 0) {
text = el.textContent;
}

return text.trim().length > 0 ? text : undefined;
};

0 comments on commit 92e1e11

Please sign in to comment.