@@ -13,10 +12,3 @@ export default function FallBackLoading({ text, ...rest }) {
);
}
-FallBackLoading.propTypes = {
- text: PropTypes.string,
-};
-
-FallBackLoading.defaultProps = {
- text: "Loading...",
-};
diff --git a/src/components/misc/Toaster.jsx b/src/components/misc/Toaster.jsx
index 5ff20b3..0fc2408 100644
--- a/src/components/misc/Toaster.jsx
+++ b/src/components/misc/Toaster.jsx
@@ -1,5 +1,4 @@
import React, { useState, useCallback } from "react";
-import PropTypes from "prop-types";
import { Toast, ToastBody, ToastHeader, Spinner } from "reactstrap";
import { MdError, MdWarning, MdInfo, MdCheckCircle } from "react-icons/md";
@@ -16,10 +15,10 @@ function getIcon(color) {
export default function Toaster({
header,
- body,
- color,
- timeout,
- showToggle,
+ body = null,
+ color = "info",
+ timeout = 4000,
+ showToggle = false,
...props
}) {
// state
@@ -44,18 +43,3 @@ export default function Toaster({
);
}
-Toaster.propTypes = {
- ...Toast.propTypes,
- header: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
- body: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
- color: PropTypes.string,
- timeout: PropTypes.number,
- showToggle: PropTypes.bool,
-};
-
-Toaster.defaultProps = {
- body: null,
- color: "info",
- timeout: 4000,
- showToggle: false,
-};
diff --git a/src/components/misc/UserBubble.jsx b/src/components/misc/UserBubble.jsx
index c6c6322..434a64c 100644
--- a/src/components/misc/UserBubble.jsx
+++ b/src/components/misc/UserBubble.jsx
@@ -1,13 +1,11 @@
import React from "react";
-import PropTypes from "prop-types";
import classnames from "classnames";
/**
* @type {component}
* @param props
*/
-export default function UserBubble(props) {
- const { userInfo, size, className, ...elProps } = props;
+export default function UserBubble({ userInfo, size = "sm", className = undefined, ...elProps }) {
const userInitials =
userInfo?.first_name?.charAt(0).concat(userInfo?.last_name?.charAt(0)) ||
@@ -39,13 +37,3 @@ export default function UserBubble(props) {
);
}
-UserBubble.propTypes = {
- size: PropTypes.oneOf(["xs", "sm"]),
- className: PropTypes.string,
- userInfo: PropTypes.object.isRequired,
-};
-
-UserBubble.defaultProps = {
- size: "sm",
- className: undefined,
-};
diff --git a/src/components/modals/ConfirmModal.jsx b/src/components/modals/ConfirmModal.jsx
index 15e84ee..6a18627 100644
--- a/src/components/modals/ConfirmModal.jsx
+++ b/src/components/modals/ConfirmModal.jsx
@@ -1,21 +1,20 @@
import React, { Fragment } from "react";
-import { render, unmountComponentAtNode } from "react-dom";
-import PropTypes from "prop-types";
+import { createRoot } from "react-dom/client";
import { Modal, ModalHeader, ModalBody, ModalFooter, Button } from "reactstrap";
function ConfirmModal({
onClose,
- message,
- title,
- confirmText,
- cancelText,
- confirmColor,
- cancelColor,
- className,
- buttonsComponent,
- size,
- bodyComponent,
- modalProps,
+ message = "Are you sure?",
+ title = "Warning!",
+ confirmText = "Ok",
+ cancelText = "Cancel",
+ confirmColor = "primary",
+ cancelColor = "",
+ className = "",
+ buttonsComponent = null,
+ size = null,
+ bodyComponent = null,
+ modalProps = {},
}) {
let buttonsContent = (
@@ -54,43 +53,16 @@ function ConfirmModal({
);
}
-ConfirmModal.defaultProps = {
- message: "Are you sure?",
- title: "Warning!",
- confirmText: "Ok",
- cancelText: "Cancel",
- confirmColor: "primary",
- cancelColor: "",
- className: "",
- buttonsComponent: null,
- size: null,
- bodyComponent: null,
- modalProps: {},
-};
-
-ConfirmModal.propTypes = {
- onClose: PropTypes.func.isRequired,
- message: PropTypes.node,
- title: PropTypes.node,
- confirmText: PropTypes.node,
- cancelText: PropTypes.node,
- confirmColor: PropTypes.string,
- cancelColor: PropTypes.string,
- className: PropTypes.string,
- size: PropTypes.string,
- buttonsComponent: PropTypes.func,
- bodyComponent: PropTypes.func,
- modalProps: PropTypes.object,
-};
-
export const confirm = (props) => new Promise((resolve) => {
- let el = document.createElement("div");
+ const el = document.createElement("div");
+ document.body.appendChild(el);
+ const root = createRoot(el);
const handleResolve = (result) => {
- unmountComponentAtNode(el);
- el = null;
+ root.unmount();
+ el.remove();
resolve(result);
};
- render(, el);
+ root.render();
});
export default ConfirmModal;
diff --git a/src/components/nav/DropdownNavLink.jsx b/src/components/nav/DropdownNavLink.jsx
index 381a949..f3b194b 100644
--- a/src/components/nav/DropdownNavLink.jsx
+++ b/src/components/nav/DropdownNavLink.jsx
@@ -1,5 +1,4 @@
import React from "react";
-import PropTypes from "prop-types";
import { NavLink as RRNavLink } from "react-router-dom";
import { DropdownItem } from "reactstrap";
@@ -22,7 +21,3 @@ export default function DropdownNavLink(props) {
);
}
-DropdownNavLink.propTypes = {
- children: PropTypes.node.isRequired,
- ...RRNavLink.propTypes,
-};
diff --git a/src/components/nav/HoverDropdown.jsx b/src/components/nav/HoverDropdown.jsx
index fc0537d..708145d 100644
--- a/src/components/nav/HoverDropdown.jsx
+++ b/src/components/nav/HoverDropdown.jsx
@@ -1,13 +1,11 @@
import React from "react";
-import PropTypes from "prop-types";
import { Dropdown } from "reactstrap";
/**
* @type {component}
* @param props
*/
-function HoverDropdown(props) {
- const { defaultOpen, ...toPassProps } = props;
+function HoverDropdown({ defaultOpen = false, ...toPassProps }) {
const [isOpen, setIsOpen] = React.useState(defaultOpen);
@@ -23,13 +21,4 @@ function HoverDropdown(props) {
);
}
-HoverDropdown.defaultProps = {
- defaultOpen: false,
-};
-
-HoverDropdown.propTypes = {
- ...Dropdown.propTypes,
- defaultOpen: PropTypes.bool,
-};
-
export default HoverDropdown;
diff --git a/src/components/nav/NavLink.jsx b/src/components/nav/NavLink.jsx
index aceaf1c..ddf97e4 100644
--- a/src/components/nav/NavLink.jsx
+++ b/src/components/nav/NavLink.jsx
@@ -1,5 +1,4 @@
import React from "react";
-import PropTypes from "prop-types";
import classnames from "classnames";
import { NavLink as RSNavLink } from "reactstrap";
import { NavLink as RRNavLink } from "react-router-dom";
@@ -14,7 +13,7 @@ const type2ClassnameMap = {
};
// component
-export default function NavLink({ type, children, className, ...props }) {
+export default function NavLink({ type = "default", children, className = null, ...props }) {
return (
row?.[column.accessor];
+ return () => undefined;
+}
+
+function normalizeColumn(column, index) {
+ const id = getColumnId(column) || `column_${index}`;
+ const accessorFn = getAccessorFn(column);
+
+ return {
+ ...column,
+ id,
+ accessorFn,
+ };
+}
+
+function getLegacyRowValue(row, column) {
+ if (!column?.accessorFn) return undefined;
+ return column.accessorFn(row);
+}
+
+function legacyDefaultFilterFn(row, columnId, filterValue) {
+ if (filterValue === undefined || filterValue === null || filterValue === "")
+ return true;
+
+ const normalizedFilter = `${filterValue}`.toLowerCase();
+ const rowValue = row.getValue(columnId);
+
+ if (Array.isArray(rowValue)) {
+ return rowValue.some((value) =>
+ `${value ?? ""}`.toLowerCase().includes(normalizedFilter)
+ );
+ }
+
+ return `${rowValue ?? ""}`.toLowerCase().includes(normalizedFilter);
+}
+
/**
* Suitable when data is already available client side. Thus, pagination/filtering/sorting can be performed client side too.
*/
function DataTable({
- config: userConfig,
- onSelectedRowChange,
- SubComponent,
- tableProps,
- tableEmptyNode,
- TableBodyComponent,
- ...rest
+ config: userConfig = defaultConfig,
+ onSelectedRowChange = undefined,
+ SubComponent = undefined,
+ tableProps = undefined,
+ tableEmptyNode = "No Data",
+ TableBodyComponent = undefined,
+ columns = [],
+ data = [],
+ initialState = defaultInitialState,
+ stateReducer = undefined,
+ isRowSelectable = () => true,
+ customProps = undefined,
+ manualPagination = false,
+ manualFilters = false,
+ manualSortBy = false,
+ pageCount = undefined,
}) {
- // merge user specified config with default config
const config = React.useMemo(
() => ({ ...defaultConfig, ...userConfig, }),
[userConfig]
@@ -35,124 +88,430 @@ function DataTable({
const tableArgs = React.useMemo(() => makeTableArgs(config), [config]);
- const {
- getTableProps,
- getTableBodyProps,
- headerGroups,
- footerGroups,
- prepareRow,
- visibleColumns,
- page,
- gotoPage,
- pageOptions,
- selectedFlatRows,
- state: { pageIndex, },
- } = useTable(
- {
- defaultColumn,
- initialState: defaultInitialState,
- autoResetPage: false,
- autoResetExpanded: false,
- autoResetGroupBy: false,
- // autoResetSelectedRows: false,
- autoResetSortBy: false,
- autoResetFilters: false,
- autoResetRowState: false,
- ...rest,
+ const hookRegistry = React.useMemo(() => {
+ const registry = { visibleColumns: [], getRowProps: [], };
+ tableArgs.forEach((hook) => {
+ if (typeof hook === "function") hook(registry);
+ });
+ return registry;
+ }, [tableArgs]);
+
+ const baseColumns = React.useMemo(
+ () => columns.map((column, index) => normalizeColumn(column, index)),
+ [columns]
+ );
+
+ const baseInstance = React.useMemo(
+ () => ({ customProps, isRowSelectable, }),
+ [customProps, isRowSelectable]
+ );
+
+ const legacyColumns = React.useMemo(() => {
+ let transformedColumns = baseColumns;
+ hookRegistry.visibleColumns.forEach((transformer) => {
+ transformedColumns = transformer(transformedColumns, { instance: baseInstance, });
+ });
+ return transformedColumns;
+ }, [baseColumns, hookRegistry, baseInstance]);
+
+ const tanstackColumns = React.useMemo(
+ () =>
+ legacyColumns.map((column) => ({
+ id: column.id,
+ accessorFn: column.accessorFn,
+ enableSorting: config.enableSortBy && !column.disableSortBy,
+ enableColumnFilter: config.enableFilters && !!column.Filter,
+ filterFn: column.filterFn || legacyDefaultFilterFn,
+ meta: { legacyColumn: column, },
+ })),
+ [legacyColumns, config.enableSortBy, config.enableFilters]
+ );
+
+ const legacyInitialState = React.useMemo(
+ () => ({ ...defaultInitialState, ...initialState, }),
+ [initialState]
+ );
+
+ const [tableState, setTableState] = React.useState(() => ({
+ pagination: {
+ pageIndex: legacyInitialState.pageIndex || 0,
+ pageSize: legacyInitialState.pageSize || 10,
+ },
+ sorting: legacyInitialState.sortBy || [],
+ columnFilters: legacyInitialState.filters || [],
+ rowSelection: legacyInitialState.selectedRowIds || {},
+ expanded: {},
+ }));
+
+ const reduceState = React.useCallback(
+ (nextState, action, prevState) => {
+ if (!stateReducer) return nextState;
+ return stateReducer(nextState, action, prevState) || nextState;
},
- ...tableArgs
+ [stateReducer]
);
- useMountedLayoutEffect(() => {
- if (onSelectedRowChange) {
- if (selectedFlatRows?.length) {
- const originalRows = selectedFlatRows.map((r) => r.original);
- onSelectedRowChange(originalRows);
- } else {
- onSelectedRowChange([]);
- }
- }
+ const updateState = React.useCallback(
+ (producer, action) => {
+ setTableState((previousState) => {
+ const nextState = producer(previousState);
+ return reduceState(nextState, action, previousState);
+ });
+ },
+ [reduceState]
+ );
+
+ const toggleRowSelected = React.useCallback(
+ (rowId, selected) => {
+ updateState(
+ (previousState) => ({
+ ...previousState,
+ rowSelection: {
+ ...previousState.rowSelection,
+ [rowId]: selected,
+ },
+ }),
+ { type: "toggleRowSelected", id: rowId, value: selected, }
+ );
+ },
+ [updateState]
+ );
+
+ const table = useReactTable({
+ data,
+ columns: tanstackColumns,
+ state: tableState,
+ pageCount,
+ manualPagination,
+ manualFiltering: manualFilters,
+ manualSorting: manualSortBy,
+ getCoreRowModel: getCoreRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ enableRowSelection: (row) =>
+ isRowSelectable({
+ id: row.id,
+ original: row.original,
+ }),
+ onPaginationChange: (updater) => {
+ updateState(
+ (previousState) => {
+ const nextPagination =
+ typeof updater === "function"
+ ? updater(previousState.pagination)
+ : updater;
+
+ return {
+ ...previousState,
+ pagination: nextPagination,
+ };
+ },
+ {
+ type:
+ typeof updater === "function"
+ ? "gotoPage"
+ : updater.pageIndex !== tableState.pagination.pageIndex
+ ? "gotoPage"
+ : "setPageSize",
+ pageIndex:
+ typeof updater === "function"
+ ? updater(tableState.pagination).pageIndex
+ : updater.pageIndex,
+ }
+ );
+ },
+ onSortingChange: (updater) => {
+ updateState(
+ (previousState) => ({
+ ...previousState,
+ sorting:
+ typeof updater === "function"
+ ? updater(previousState.sorting)
+ : updater,
+ }),
+ { type: "toggleSortBy", }
+ );
+ },
+ onColumnFiltersChange: (updater) => {
+ updateState(
+ (previousState) => ({
+ ...previousState,
+ columnFilters:
+ typeof updater === "function"
+ ? updater(previousState.columnFilters)
+ : updater,
+ }),
+ { type: "setFilter", }
+ );
+ },
+ onRowSelectionChange: (updater) => {
+ updateState(
+ (previousState) => ({
+ ...previousState,
+ rowSelection:
+ typeof updater === "function"
+ ? updater(previousState.rowSelection)
+ : updater,
+ }),
+ { type: "toggleRowSelected", }
+ );
+ },
+ onExpandedChange: (updater) => {
+ updateState(
+ (previousState) => ({
+ ...previousState,
+ expanded:
+ typeof updater === "function"
+ ? updater(previousState.expanded)
+ : updater,
+ }),
+ { type: "toggleRowExpanded", }
+ );
+ },
+ });
+
+ const toLegacyRow = React.useCallback(
+ (row) => ({
+ id: row.id,
+ original: row.original,
+ values: Object.fromEntries(
+ legacyColumns.map((column) => [column.id, getLegacyRowValue(row.original, column)])
+ ),
+ isSelected: row.getIsSelected(),
+ isExpanded: row.getIsExpanded(),
+ getToggleRowSelectedProps: () => ({
+ checked: row.getIsSelected(),
+ onChange: (event) => row.toggleSelected(event.target.checked),
+ }),
+ getToggleRowExpandedProps: () => ({
+ role: "button",
+ tabIndex: 0,
+ onClick: () => row.toggleExpanded(),
+ onKeyDown: (event) => {
+ if (event.key === "Enter" || event.key === " ") row.toggleExpanded();
+ },
+ }),
+ }),
+ [legacyColumns]
+ );
+
+ const pageRows = table.getRowModel().rows;
+ const legacyPageRows = React.useMemo(
+ () => pageRows.map((row) => toLegacyRow(row)),
+ [pageRows, toLegacyRow]
+ );
+
+ const selectedFlatRows = React.useMemo(
+ () => table.getSelectedRowModel().flatRows.map((row) => toLegacyRow(row)),
+ [table, toLegacyRow]
+ );
+
+ React.useEffect(() => {
+ if (!onSelectedRowChange) return;
+ onSelectedRowChange(selectedFlatRows.map((row) => row.original));
}, [onSelectedRowChange, selectedFlatRows]);
- const footerAvailable = footerGroups[0]?.headers.filter(h => h.Footer.length).length !== 0;
+ const tableInstanceContext = React.useMemo(
+ () => ({
+ ...baseInstance,
+ page: legacyPageRows,
+ selectedFlatRows,
+ toggleRowSelected,
+ }),
+ [baseInstance, legacyPageRows, selectedFlatRows, toggleRowSelected]
+ );
+
+ const renderHeaderContent = React.useCallback(
+ (column) => {
+ const Header = column?.Header;
+ if (typeof Header === "function") {
+ return (
+
+ );
+ }
+ return Header;
+ },
+ [selectedFlatRows, toggleRowSelected, legacyPageRows]
+ );
+
+ const renderFooterContent = React.useCallback((column) => {
+ const Footer = column?.Footer;
+ if (typeof Footer === "function") return ;
+ return Footer;
+ }, []);
+
+ const footerAvailable = legacyColumns.some((column) => !!column.Footer);
+
+ const preFilteredRows = React.useMemo(
+ () =>
+ data.map((rowObj) => ({
+ values: Object.fromEntries(
+ legacyColumns.map((column) => [column.id, getLegacyRowValue(rowObj, column)])
+ ),
+ })),
+ [data, legacyColumns]
+ );
+
+ const getFilterElement = React.useCallback(
+ (column) => {
+ if (!column?.Filter || !config.enableFilters) return null;
+
+ const filterValue =
+ tableState.columnFilters.find((filter) => filter.id === column.id)?.value || "";
+
+ return (
+ {
+ table.getColumn(column.id)?.setFilterValue(value);
+ },
+ }}
+ />
+ );
+ },
+ [config.enableFilters, preFilteredRows, table, tableState.columnFilters]
+ );
+
+ const getCellContent = React.useCallback(
+ (cell, legacyRow) => {
+ const legacyColumn = cell.column.columnDef.meta?.legacyColumn;
+ const cellValue = cell.getValue();
+ if (typeof legacyColumn?.Cell === "function") {
+ return legacyColumn.Cell({
+ value: cellValue,
+ row: legacyRow,
+ column: legacyColumn,
+ instance: tableInstanceContext,
+ });
+ }
+ return cellValue;
+ },
+ [tableInstanceContext]
+ );
+
+ const getRowProps = React.useCallback(
+ (legacyRow) => {
+ let rowProps = { key: legacyRow.id, };
+ hookRegistry.getRowProps.forEach((getProps) => {
+ const output = getProps(rowProps, { row: legacyRow, });
+ if (Array.isArray(output)) {
+ const [, extraProps] = output;
+ rowProps = { ...rowProps, ...extraProps, };
+ }
+ });
+ return rowProps;
+ },
+ [hookRegistry]
+ );
+
+ const pageOptions = React.useMemo(
+ () => Array.from({ length: table.getPageCount(), }, (_, index) => index),
+ [table]
+ );
+
+ const tableBody = TableBodyComponent ? (
+
+ ) : (
+ pageRows.map((row) => {
+ const legacyRow = toLegacyRow(row);
+ const { key, ...rowProps } = getRowProps(legacyRow);
+
+ return (
+
+
+ {row.getVisibleCells().map((cell) => (
+ |
+ {getCellContent(cell, legacyRow)}
+ |
+ ))}
+
+ {SubComponent && config?.enableExpanded && legacyRow?.isExpanded && (
+
+ |
+
+ |
+
+ )}
+
+ );
+ })
+ );
- // Use the state and functions returned from useTable to build your UI
return (
-
- {/* Table Head */}
+
- {headerGroups.map((headerGroup) => (
-
- {headerGroup.headers.map((column) => (
-
- (
+
+ {headerGroup.headers.map((header) => {
+ const legacyColumn = header.column.columnDef.meta?.legacyColumn;
+ const sortState = header.column.getIsSorted();
+ const canSort = config.enableSortBy && header.column.getCanSort();
+
+ return (
+ |
- {column.render("Header")}
- {column.canSort &&
- (column.isSorted ? (
- column.isSortedDesc ? (
-
+ {
+ if (event.key === "Enter" || event.key === " ") {
+ header.column.toggleSorting();
+ }
+ }
+ : undefined
+ }
+ >
+ {renderHeaderContent(legacyColumn)}
+ {canSort &&
+ (sortState ? (
+ sortState === "desc" ? (
+
+ ) : (
+
+ )
) : (
-
- )
- ) : (
-
- ))}
-
-
- {column.canFilter && column.render("Filter")}
-
- |
- ))}
+
+ ))}
+
+ {getFilterElement(legacyColumn)}
+
+ );
+ })}
))}
|
- {/* Table Body */}
-
- {page?.length ? (
- !TableBodyComponent ? (
- page.map((row) => {
- prepareRow(row);
- const { key, ...rowProps } = row.getRowProps();
- return (
-
- {/* table row */}
-
- {row.cells.map((cell) => (
- |
- {cell.render("Cell")}
- |
- ))}
-
- {/* SubComponent */}
- {SubComponent && config?.enableExpanded && row?.isExpanded && (
-
- |
-
- |
-
- )}
-
- );
- })
- ) : (
-
- )
+
+ {pageRows?.length ? (
+ tableBody
) : (
|
{tableEmptyNode}
@@ -160,48 +519,30 @@ function DataTable({
|
)}
- {/* Table Footer */}
- {footerAvailable &&
-
- {footerGroups.map(group => (
-
- {group.headers.map(column => (
- | {column.render("Footer")} |
- ))}
-
- ))}
-
- }
+ {footerAvailable && (
+
+ {table.getFooterGroups().map((group) => (
+
+ {group.headers.map((header) => (
+ |
+ {renderFooterContent(header.column.columnDef.meta?.legacyColumn)}
+ |
+ ))}
+
+ ))}
+
+ )}
- {/* Paginator */}
- {pageOptions.length > 1 && (
-
- )}
+ {pageOptions.length > 1 && (
+ table.setPageIndex(nextPage)}
+ className="table-paginator"
+ />
+ )}
);
}
-DataTable.propTypes = {
- config: PropTypes.object,
- onSelectedRowChange: PropTypes.func,
- SubComponent: PropTypes.oneOfType([PropTypes.func, PropTypes.elementType]),
- tableProps: PropTypes.object,
- tableEmptyNode: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
- TableBodyComponent: PropTypes.func,
-};
-
-DataTable.defaultProps = {
- config: defaultConfig,
- onSelectedRowChange: undefined,
- SubComponent: undefined,
- tableProps: undefined,
- tableEmptyNode: "No Data",
- TableBodyComponent: undefined,
-};
-
export default DataTable;
diff --git a/src/components/table/Paginator.jsx b/src/components/table/Paginator.jsx
index 55cff84..943b5a4 100644
--- a/src/components/table/Paginator.jsx
+++ b/src/components/table/Paginator.jsx
@@ -1,5 +1,4 @@
import React from "react";
-import PropTypes from "prop-types";
import { Pagination, PaginationItem, PaginationLink } from "reactstrap";
// constants
@@ -72,10 +71,4 @@ function Paginator(props) {
);
}
-Paginator.propTypes = {
- pageOptions: PropTypes.array.isRequired,
- pageIndex: PropTypes.number.isRequired,
- onPaginate: PropTypes.func.isRequired,
-};
-
export default React.memo(Paginator);
diff --git a/src/components/table/hooks.jsx b/src/components/table/hooks.jsx
index 5ee2fa6..e54c1b7 100644
--- a/src/components/table/hooks.jsx
+++ b/src/components/table/hooks.jsx
@@ -1,7 +1,4 @@
-/* eslint-disable react/prop-types */
-
import React from "react";
-import { useRowSelect, useExpanded } from "react-table";
import { FormGroup, Input, UncontrolledTooltip, Label } from "reactstrap";
import ArrowToggleIcon from "../icons/ArrowToggleIcon";
@@ -26,7 +23,6 @@ export const createUseRowDisabledHook = (hocProps) => (hooks) => {
{
id: "data-table-disabled_row_action",
width: 80,
- // eslint-disable-next-line react/prop-types
Cell: ({ row: { original: obj, }, }) =>
obj?.permissions?.edit ? (
<>
@@ -53,7 +49,6 @@ export const createUseRowDisabledHook = (hocProps) => (hooks) => {
};
export const rowSelectHooks = [
- useRowSelect,
(hooks) => {
hooks.visibleColumns.push((columns, { instance: { isRowSelectable, }, }) => [
{
@@ -108,7 +103,6 @@ export const rowSelectHooks = [
];
export const rowExpandHooks = [
- useExpanded,
(hooks) => {
hooks.visibleColumns.push((columns) => [
{
diff --git a/src/components/table/useQueryParamsTable.jsx b/src/components/table/useQueryParamsTable.jsx
index e9de71b..e7e4951 100644
--- a/src/components/table/useQueryParamsTable.jsx
+++ b/src/components/table/useQueryParamsTable.jsx
@@ -1,6 +1,7 @@
import React from "react";
import { useNavigate, useLocation } from "react-router-dom";
-import { useAsyncDebounce } from "react-table";
+
+import useAsyncDebounce from "../../hooks/useAsyncDebounce";
import {
serializeFilterParams,
@@ -65,18 +66,25 @@ function useQueryParamsTable() {
const tableStateReducer = React.useCallback(
(newState, action) => {
+ const filters = newState?.filters || newState?.columnFilters || [];
+ const sortBy = newState?.sortBy || newState?.sorting || [];
+ const pageIndex =
+ typeof action?.pageIndex === "number"
+ ? action.pageIndex
+ : newState?.pageIndex || newState?.pagination?.pageIndex || 0;
+
switch (action.type) {
case "gotoPage":
setParams((currParams) => ({
...currParams,
- page: action.pageIndex + 1,
+ page: pageIndex + 1,
}));
break;
case "setFilter":
- onTableFilterDebounced(newState.filters);
+ onTableFilterDebounced(filters);
break;
case "toggleSortBy":
- onTableSortDebounced(newState.sortBy);
+ onTableSortDebounced(sortBy);
break;
default:
break;
diff --git a/src/components/table/utils.jsx b/src/components/table/utils.jsx
index c7c0b3f..9580ee3 100644
--- a/src/components/table/utils.jsx
+++ b/src/components/table/utils.jsx
@@ -1,21 +1,9 @@
-import {
- useSortBy,
- useFilters,
- usePagination,
- useColumnOrder,
- useFlexLayout
-} from "react-table";
-
import { rowSelectHooks, rowExpandHooks } from "./hooks";
// gotta maintain the order of these plugins
function makeTableArgs(config) {
- const args = [useColumnOrder];
- if (config?.enableFlexLayout) args.push(useFlexLayout);
- if (config?.enableFilters) args.push(useFilters);
- if (config?.enableSortBy) args.push(useSortBy);
+ const args = [];
if (config?.enableExpanded) args.push(...rowExpandHooks);
- args.push(usePagination);
if (config?.enableSelection) args.push(...rowSelectHooks);
return [...args, ...config.customHooks];
}
diff --git a/src/components/tabs/RouterTabs.jsx b/src/components/tabs/RouterTabs.jsx
index d139dad..f2074f7 100644
--- a/src/components/tabs/RouterTabs.jsx
+++ b/src/components/tabs/RouterTabs.jsx
@@ -1,5 +1,4 @@
import React from "react";
-import PropTypes from "prop-types";
import classnames from "classnames";
import { Nav } from "reactstrap";
@@ -21,9 +20,8 @@ import useRouterTabs from "./useRouterTabs";
* ```
*
*/
-function RouterTabs(props) {
+function RouterTabs({ routes, className = undefined, overflow = false, redirect = true, children = null, extraNavComponent = null, ...rest }) {
// props
- const { routes, className, overflow, redirect, children, extraNavComponent, ...rest } = props;
const navClasses = classnames("nav-tabs", className);
@@ -44,29 +42,4 @@ function RouterTabs(props) {
);
}
-RouterTabs.propTypes = {
- routes: PropTypes.arrayOf(
- PropTypes.shape({
- key: PropTypes.string.isRequired,
- location: PropTypes.string.isRequired,
- Title: PropTypes.oneOfType([PropTypes.func, PropTypes.object]).isRequired,
- Component: PropTypes.oneOfType([PropTypes.func, PropTypes.object])
- .isRequired,
- })
- ).isRequired,
- redirect: PropTypes.bool,
- overflow: PropTypes.bool,
- className: PropTypes.string,
- children: PropTypes.node,
- extraNavComponent: PropTypes.node,
-};
-
-RouterTabs.defaultProps = {
- redirect: true,
- overflow: false,
- className: undefined,
- children: null,
- extraNavComponent: null,
-};
-
export default RouterTabs;
diff --git a/src/components/tabs/Tabs.jsx b/src/components/tabs/Tabs.jsx
index a613c61..4d197ea 100644
--- a/src/components/tabs/Tabs.jsx
+++ b/src/components/tabs/Tabs.jsx
@@ -1,6 +1,5 @@
import React from "react";
-import PropTypes from "prop-types";
import { TabContent, TabPane, Nav, NavItem, NavLink } from "reactstrap";
import classnames from "classnames";
@@ -8,9 +7,7 @@ import classnames from "classnames";
* @type {component}
* @param props
*/
-function Tabs(props) {
- const { tabTitles, renderables, defaultTab, overflow, className, ...rest } =
- props;
+function Tabs({ tabTitles, renderables, defaultTab = 0, overflow = false, className = undefined, ...rest }) {
const [activeTab, setActiveTab] = React.useState(defaultTab);
@@ -49,20 +46,4 @@ function Tabs(props) {
);
}
-Tabs.defaultProps = {
- defaultTab: 0,
- overflow: false,
- className: undefined,
-};
-
-Tabs.propTypes = {
- tabTitles: PropTypes.arrayOf(
- PropTypes.oneOfType([PropTypes.string, PropTypes.node, PropTypes.object])
- ).isRequired,
- renderables: PropTypes.arrayOf(PropTypes.func).isRequired,
- defaultTab: PropTypes.number,
- overflow: PropTypes.bool,
- className: PropTypes.string,
-};
-
export default Tabs;
diff --git a/src/components/text/SlicedText.jsx b/src/components/text/SlicedText.jsx
index 875ad54..83ab479 100644
--- a/src/components/text/SlicedText.jsx
+++ b/src/components/text/SlicedText.jsx
@@ -1,10 +1,9 @@
import React from "react";
-import PropTypes from "prop-types";
import { UncontrolledTooltip } from "reactstrap";
import { nanoid } from "nanoid";
import CopyToClipboardButton from "../buttons/CopyToClipboardButton";
-function SlicedText({ value, id, cutoffLength, ...rest }) {
+function SlicedText({ value, id = undefined, cutoffLength = 15, ...rest }) {
// vars
const btnId = id || `copybtn-${nanoid(4)}`;
@@ -26,15 +25,4 @@ function SlicedText({ value, id, cutoffLength, ...rest }) {
);
}
-SlicedText.propTypes = {
- value: PropTypes.string.isRequired,
- id: PropTypes.string,
- cutoffLength: PropTypes.number,
-};
-
-SlicedText.defaultProps = {
- id: undefined,
- cutoffLength: 15,
-};
-
export default React.memo(SlicedText, (pp, np) => pp.id === np.id);
diff --git a/src/components/time/DateHoverable.jsx b/src/components/time/DateHoverable.jsx
index 99df407..a88553d 100644
--- a/src/components/time/DateHoverable.jsx
+++ b/src/components/time/DateHoverable.jsx
@@ -1,5 +1,4 @@
import React from "react";
-import PropTypes from "prop-types";
import classnames from "classnames";
import {
ListGroup,
@@ -15,9 +14,7 @@ import {
} from "date-fns";
import { nanoid } from "nanoid";
-
-function DateHoverable(props) {
- const { id, value, className, noHover, ago, showAgo, format: formatProp, showFormat, ...rest } = props;
+function DateHoverable({ id = undefined, value, className = undefined, noHover = false, ago = false, showAgo = false, format: formatProp = "PPpppp", showFormat = "p PP", ...rest }) {
const [utcVal, userTz, userTzVal] = React.useMemo(() => {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
@@ -66,25 +63,4 @@ function DateHoverable(props) {
);
}
-DateHoverable.propTypes = {
- value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
- className: PropTypes.string,
- format: PropTypes.string,
- id: PropTypes.string,
- noHover: PropTypes.bool,
- showFormat: PropTypes.string,
- ago: PropTypes.bool,
- showAgo: PropTypes.bool,
-};
-
-DateHoverable.defaultProps = {
- ago: false,
- className: undefined,
- format: "PPpppp",
- id: undefined,
- noHover: false,
- showAgo: false,
- showFormat: "p PP",
-};
-
export default React.memo(DateHoverable);
diff --git a/src/components/time/ElasticTimePicker.jsx b/src/components/time/ElasticTimePicker.jsx
index 522f049..a6df800 100644
--- a/src/components/time/ElasticTimePicker.jsx
+++ b/src/components/time/ElasticTimePicker.jsx
@@ -1,5 +1,4 @@
import React from "react";
-import PropTypes from "prop-types";
import { Button, ButtonGroup } from "reactstrap";
import { IoInfinite } from "react-icons/io5";
import { sub } from "date-fns";
@@ -35,9 +34,7 @@ function intervalToTime(ti) {
* />
* ```
*/
-function ElasticTimePicker(props) {
- const { onChange, size, defaultSelected, intervals, showInfinity, ...rest } =
- props;
+function ElasticTimePicker({ onChange, size = "sm", defaultSelected = "24h", intervals = Object.keys(TIME_INTERVALS), showInfinity = false, ...rest }) {
// state
const [selected, setSelected] = React.useState(defaultSelected);
@@ -86,19 +83,4 @@ function ElasticTimePicker(props) {
);
}
-ElasticTimePicker.defaultProps = {
- size: "sm",
- defaultSelected: "24h",
- intervals: Object.keys(TIME_INTERVALS),
- showInfinity: false,
-};
-
-ElasticTimePicker.propTypes = {
- onChange: PropTypes.func.isRequired,
- defaultSelected: PropTypes.string,
- size: PropTypes.string,
- intervals: PropTypes.arrayOf(PropTypes.string),
- showInfinity: PropTypes.bool,
-};
-
export default ElasticTimePicker;
diff --git a/src/hooks/index.js b/src/hooks/index.js
index fb33145..a67fc26 100644
--- a/src/hooks/index.js
+++ b/src/hooks/index.js
@@ -1,3 +1,4 @@
export { default as useAxiosComponentLoader } from "./useAxiosComponentLoader";
export { default as useFuzzySearch } from "./useFuzzySearch";
-export { default as useDebounceInput } from "./useDebounceInput";
\ No newline at end of file
+export { default as useDebounceInput } from "./useDebounceInput";
+export { default as useAsyncDebounce } from "./useAsyncDebounce";
\ No newline at end of file
diff --git a/src/hooks/useAsyncDebounce.jsx b/src/hooks/useAsyncDebounce.jsx
new file mode 100644
index 0000000..c953414
--- /dev/null
+++ b/src/hooks/useAsyncDebounce.jsx
@@ -0,0 +1,57 @@
+import React from "react";
+
+/**
+ * Debounces a callback and returns a promise that resolves with the latest call result.
+ * This mirrors the behavior commonly relied on from react-table's useAsyncDebounce helper.
+ */
+export default function useAsyncDebounce(defaultFn, defaultWait = 0) {
+ const fnRef = React.useRef(defaultFn);
+ const waitRef = React.useRef(defaultWait);
+ const debounceRef = React.useRef({});
+
+ React.useEffect(() => {
+ fnRef.current = defaultFn;
+ }, [defaultFn]);
+
+ React.useEffect(() => {
+ waitRef.current = defaultWait;
+ }, [defaultWait]);
+
+ React.useEffect(
+ () => () => {
+ if (debounceRef.current.timeout) {
+ clearTimeout(debounceRef.current.timeout);
+ }
+ },
+ []
+ );
+
+ return React.useCallback((...args) => {
+ if (!debounceRef.current.promise) {
+ debounceRef.current.promise = new Promise((resolve, reject) => {
+ debounceRef.current.resolve = resolve;
+ debounceRef.current.reject = reject;
+ });
+ }
+
+ if (debounceRef.current.timeout) {
+ clearTimeout(debounceRef.current.timeout);
+ }
+
+ debounceRef.current.timeout = setTimeout(async () => {
+ delete debounceRef.current.timeout;
+
+ try {
+ debounceRef.current.resolve(await fnRef.current(...args));
+ } catch (error) {
+ debounceRef.current.reject(error);
+ } finally {
+ delete debounceRef.current.promise;
+ delete debounceRef.current.resolve;
+ delete debounceRef.current.reject;
+ }
+ }, waitRef.current);
+
+ return debounceRef.current.promise;
+ }, []);
+}
diff --git a/src/hooks/useFuzzySearch.jsx b/src/hooks/useFuzzySearch.jsx
index b28b955..b966b62 100644
--- a/src/hooks/useFuzzySearch.jsx
+++ b/src/hooks/useFuzzySearch.jsx
@@ -1,6 +1,7 @@
import React from "react";
import { matchSorter } from "match-sorter";
-import { useAsyncDebounce } from "react-table";
+
+import useAsyncDebounce from "./useAsyncDebounce";
/**
* React hook for fuzzy searching text among list of objects.
diff --git a/src/stores/useTimePickerStore.jsx b/src/stores/useTimePickerStore.jsx
index 5fa8d0b..6686018 100644
--- a/src/stores/useTimePickerStore.jsx
+++ b/src/stores/useTimePickerStore.jsx
@@ -1,6 +1,6 @@
-import create from "zustand";
-import { persist } from "zustand/middleware";
+import { create } from "zustand";
+import { persist, createJSONStorage } from "zustand/middleware";
// constants
const DEFAULT_RANGE_DATEFORMAT_MAP = {
@@ -31,7 +31,7 @@ const useTimePickerStore = create(
}),
{
name: "certegoUI-useTimePickerStore", // unique name
- getStorage: () => localStorage,
+ storage: createJSONStorage(() => localStorage),
}
)
);
diff --git a/src/stores/useToastr.jsx b/src/stores/useToastr.jsx
index e94ee00..1315f5a 100644
--- a/src/stores/useToastr.jsx
+++ b/src/stores/useToastr.jsx
@@ -1,4 +1,4 @@
-import create from "zustand";
+import { create } from "zustand";
import { nanoid } from "nanoid";
// store
diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss
index cfa890c..ba19b82 100644
--- a/src/styles/_variables.scss
+++ b/src/styles/_variables.scss
@@ -270,8 +270,8 @@ $chat-nav-bubble-color: linear-gradient(
#fff 100%
);
-@import "~bootstrap/scss/functions";
-@import "~bootstrap/scss/variables";
+@import "bootstrap/scss/functions";
+@import "bootstrap/scss/variables";
// merge colors (Workaround BUG Bootstrap 5.1 https://github.com/twbs/bootstrap/issues/34756)
// bug should be fixed after 5.2.0 so we removed the code after the following line
diff --git a/src/styles/root.scss b/src/styles/root.scss
index abae01f..f729d2f 100644
--- a/src/styles/root.scss
+++ b/src/styles/root.scss
@@ -4,7 +4,7 @@
// theme
@import "variables";
-@import "~bootstrap/scss/bootstrap";
+@import "bootstrap/scss/bootstrap";
@import url("https://fonts.googleapis.com/css?family=Montserrat:200,300,400,500,600|Roboto:700");
// local files
diff --git a/tests/visual/table.visual.spec.js b/tests/visual/table.visual.spec.js
new file mode 100644
index 0000000..770a2d2
--- /dev/null
+++ b/tests/visual/table.visual.spec.js
@@ -0,0 +1,189 @@
+import { test, expect } from "@playwright/test";
+
+async function freezeExternalNetwork(page, baseURL) {
+ await page.route("**/*", async (route) => {
+ const url = route.request().url();
+
+ if (
+ url.startsWith(baseURL) ||
+ url.startsWith("data:") ||
+ url.startsWith("blob:")
+ ) {
+ await route.continue();
+ return;
+ }
+
+ await route.abort();
+ });
+}
+
+async function openTable(page, baseURL) {
+ await freezeExternalNetwork(page, baseURL);
+ await page.goto("/#/table");
+ await expect(page.getByRole("heading", { name: "Table", })).toBeVisible();
+ await expect(page.getByTestId("table-visual-interactive")).toBeVisible();
+}
+
+test.describe("table visual states", () => {
+ test("default state", async ({ page, baseURL, browserName, isMobile, }) => {
+ test.skip(browserName !== "chromium");
+ test.skip(isMobile);
+
+ await openTable(page, baseURL);
+
+ await expect(page.getByTestId("table-visual-interactive")).toHaveScreenshot(
+ "table-default-desktop.png",
+ {
+ animations: "disabled",
+ caret: "hide",
+ }
+ );
+ });
+
+ test("sorted ascending", async ({ page, baseURL, browserName, isMobile, }) => {
+ test.skip(browserName !== "chromium");
+ test.skip(isMobile);
+
+ await openTable(page, baseURL);
+
+ const section = page.getByTestId("table-visual-interactive");
+ await section.getByRole("button", { name: /Title/i, }).click();
+
+ await expect(section).toHaveScreenshot("table-sorted-asc-desktop.png", {
+ animations: "disabled",
+ caret: "hide",
+ });
+ });
+
+ test("sorted descending", async ({ page, baseURL, browserName, isMobile, }) => {
+ test.skip(browserName !== "chromium");
+ test.skip(isMobile);
+
+ await openTable(page, baseURL);
+
+ const section = page.getByTestId("table-visual-interactive");
+ const titleHeader = section.getByRole("button", { name: /Title/i, });
+
+ await titleHeader.click();
+ await titleHeader.click();
+
+ await expect(section).toHaveScreenshot("table-sorted-desc-desktop.png", {
+ animations: "disabled",
+ caret: "hide",
+ });
+ });
+
+ test("active filters", async ({ page, baseURL, browserName, isMobile, }) => {
+ test.skip(browserName !== "chromium");
+ test.skip(isMobile);
+
+ await openTable(page, baseURL);
+
+ const section = page.getByTestId("table-visual-interactive");
+ const titleFilter = section.locator("#datatable-select-title");
+
+ await titleFilter.fill("Alpha Watch");
+ await titleFilter.press("Enter");
+
+ await expect(section).toHaveScreenshot("table-filtered-desktop.png", {
+ animations: "disabled",
+ caret: "hide",
+ });
+ });
+
+ test("expanded row", async ({ page, baseURL, browserName, isMobile, }) => {
+ test.skip(browserName !== "chromium");
+ test.skip(isMobile);
+
+ await openTable(page, baseURL);
+
+ const section = page.getByTestId("table-visual-interactive");
+
+ await section.locator("tbody tr [role='button']").first().click();
+ await expect(section.getByTestId("expanded-row-1")).toBeVisible();
+
+ await expect(section).toHaveScreenshot("table-expanded-desktop.png", {
+ animations: "disabled",
+ caret: "hide",
+ });
+ });
+
+ test("selected rows", async ({ page, baseURL, browserName, isMobile, }) => {
+ test.skip(browserName !== "chromium");
+ test.skip(isMobile);
+
+ await openTable(page, baseURL);
+
+ const section = page.getByTestId("table-visual-interactive");
+
+ await section.locator("#data-table-checkbox_cell_0").check();
+ await section.locator("#data-table-checkbox_cell_2").check();
+ await expect(page.getByTestId("table-visual-selected-count")).toHaveText("2 selected");
+
+ await expect(section).toHaveScreenshot("table-selected-desktop.png", {
+ animations: "disabled",
+ caret: "hide",
+ });
+ });
+
+ test("disabled row styling", async ({ page, baseURL, browserName, isMobile, }) => {
+ test.skip(browserName !== "chromium");
+ test.skip(isMobile);
+
+ await openTable(page, baseURL);
+
+ await expect(page.getByTestId("table-visual-disabled")).toHaveScreenshot(
+ "table-disabled-row-desktop.png",
+ {
+ animations: "disabled",
+ caret: "hide",
+ }
+ );
+ });
+
+ test("empty state", async ({ page, baseURL, browserName, isMobile, }) => {
+ test.skip(browserName !== "chromium");
+ test.skip(isMobile);
+
+ await openTable(page, baseURL);
+
+ await expect(page.getByTestId("table-visual-empty")).toHaveScreenshot(
+ "table-empty-state-desktop.png",
+ {
+ animations: "disabled",
+ caret: "hide",
+ }
+ );
+ });
+
+ test("paginator ellipsis", async ({ page, baseURL, browserName, isMobile, }) => {
+ test.skip(browserName !== "chromium");
+ test.skip(isMobile);
+
+ await openTable(page, baseURL);
+
+ await expect(
+ page.getByTestId("table-visual-interactive").locator(".table-paginator")
+ ).toHaveScreenshot("table-paginator-ellipsis-desktop.png", {
+ animations: "disabled",
+ caret: "hide",
+ });
+ });
+});
+
+test.describe("table mobile visual state", () => {
+ test("default state on mobile", async ({ page, baseURL, browserName, isMobile, }) => {
+ test.skip(browserName !== "chromium");
+ test.skip(!isMobile);
+
+ await openTable(page, baseURL);
+
+ await expect(page.getByTestId("table-visual-interactive")).toHaveScreenshot(
+ "table-default-mobile.png",
+ {
+ animations: "disabled",
+ caret: "hide",
+ }
+ );
+ });
+});
diff --git a/tests/visual/table.visual.spec.js-snapshots/table-default-desktop.png b/tests/visual/table.visual.spec.js-snapshots/table-default-desktop.png
new file mode 100644
index 0000000..4edd085
Binary files /dev/null and b/tests/visual/table.visual.spec.js-snapshots/table-default-desktop.png differ
diff --git a/tests/visual/table.visual.spec.js-snapshots/table-default-mobile.png b/tests/visual/table.visual.spec.js-snapshots/table-default-mobile.png
new file mode 100644
index 0000000..23252bb
Binary files /dev/null and b/tests/visual/table.visual.spec.js-snapshots/table-default-mobile.png differ
diff --git a/tests/visual/table.visual.spec.js-snapshots/table-disabled-row-desktop.png b/tests/visual/table.visual.spec.js-snapshots/table-disabled-row-desktop.png
new file mode 100644
index 0000000..a5030cd
Binary files /dev/null and b/tests/visual/table.visual.spec.js-snapshots/table-disabled-row-desktop.png differ
diff --git a/tests/visual/table.visual.spec.js-snapshots/table-empty-state-desktop.png b/tests/visual/table.visual.spec.js-snapshots/table-empty-state-desktop.png
new file mode 100644
index 0000000..8f77b07
Binary files /dev/null and b/tests/visual/table.visual.spec.js-snapshots/table-empty-state-desktop.png differ
diff --git a/tests/visual/table.visual.spec.js-snapshots/table-expanded-desktop.png b/tests/visual/table.visual.spec.js-snapshots/table-expanded-desktop.png
new file mode 100644
index 0000000..06e3c22
Binary files /dev/null and b/tests/visual/table.visual.spec.js-snapshots/table-expanded-desktop.png differ
diff --git a/tests/visual/table.visual.spec.js-snapshots/table-filtered-desktop.png b/tests/visual/table.visual.spec.js-snapshots/table-filtered-desktop.png
new file mode 100644
index 0000000..b72089e
Binary files /dev/null and b/tests/visual/table.visual.spec.js-snapshots/table-filtered-desktop.png differ
diff --git a/tests/visual/table.visual.spec.js-snapshots/table-paginator-ellipsis-desktop.png b/tests/visual/table.visual.spec.js-snapshots/table-paginator-ellipsis-desktop.png
new file mode 100644
index 0000000..df24fea
Binary files /dev/null and b/tests/visual/table.visual.spec.js-snapshots/table-paginator-ellipsis-desktop.png differ
diff --git a/tests/visual/table.visual.spec.js-snapshots/table-selected-desktop.png b/tests/visual/table.visual.spec.js-snapshots/table-selected-desktop.png
new file mode 100644
index 0000000..2719fe3
Binary files /dev/null and b/tests/visual/table.visual.spec.js-snapshots/table-selected-desktop.png differ
diff --git a/tests/visual/table.visual.spec.js-snapshots/table-sorted-asc-desktop.png b/tests/visual/table.visual.spec.js-snapshots/table-sorted-asc-desktop.png
new file mode 100644
index 0000000..bf657aa
Binary files /dev/null and b/tests/visual/table.visual.spec.js-snapshots/table-sorted-asc-desktop.png differ
diff --git a/tests/visual/table.visual.spec.js-snapshots/table-sorted-desc-desktop.png b/tests/visual/table.visual.spec.js-snapshots/table-sorted-desc-desktop.png
new file mode 100644
index 0000000..7858789
Binary files /dev/null and b/tests/visual/table.visual.spec.js-snapshots/table-sorted-desc-desktop.png differ
diff --git a/vitest.config.mjs b/vitest.config.mjs
new file mode 100644
index 0000000..16d16eb
--- /dev/null
+++ b/vitest.config.mjs
@@ -0,0 +1,12 @@
+import { defineConfig } from "vitest/config";
+import react from "@vitejs/plugin-react";
+
+export default defineConfig({
+ plugins: [react({ include: /\.(jsx?|tsx?)$/ })],
+ test: {
+ environment: "jsdom",
+ globals: true,
+ setupFiles: ["./vitest.setup.mjs"],
+ include: ["src/**/*.{test,spec}.{js,jsx}"],
+ },
+});
diff --git a/vitest.setup.mjs b/vitest.setup.mjs
new file mode 100644
index 0000000..d0de870
--- /dev/null
+++ b/vitest.setup.mjs
@@ -0,0 +1 @@
+import "@testing-library/jest-dom";