Skip to content

Commit

Permalink
feat(UniversalTable): column pinning improvements TASK-1312 (#5338)
Browse files Browse the repository at this point in the history
### 📣 Summary
Improve multiple pinned columns handling. Allow pinning columns to right
side. Improve pinned column styling based on table having horizontal
scrollbar. Add reusable `useViewportSize` and `useWindowEvent` hooks.
Add extensive story for testing the component, including left/right
column pinning, the amount of columns and much more.

### 💭 Notes
Both `useViewportSize` and `useWindowEvent` hooks were copied from
`@mantine/hooks`, retaining the original code as closely as possible. We
most possibly will use Mantine in near future, and we should replace
both with the ones from Mantine package.

Pinned columns have visual shadow to distinct them from other columns
(useful when scrolling table horizontally). That shadow is not being
added if table has no horizontal scrollbar.

@magicznyleszek this should be used in actions column added in
#5309 (should be always pinned to
right)

### 👀 Preview steps
Best way to test component changes:
1. run storybook locally
2. go to http://localhost:6006/?path=/docs/misc-universaltable--docs
3. 🟢 verify columns pinning works for all possible combinations - story
allows pinning one or two columns on both sides
4. 🟢 verify pinned columns have shadow only if table has lots of columns
(i.e. if there is horizontal scrollbar)
5. 🟢 verify all the other props of component work as expected

Verify that Organization Members table still works:
1. ℹ️ have multiple different users
2. for one of the users (e.g. "joe"), use
http://kf.kobo.local/admin/organizations/organization/ to add multiple
users into joe's organization
3. for one of the users (e.g. "sue") set the role to "admin"
4. enable "Multi-members override" for joe's organization
5. enable feature flag `mmosEnabled`
7. navigate to `#/account/organization/members` (as "joe")
8. 🟢 notice that the table works as previously 👌 

Verify that Recent Project Activity table still works:
1. note: this particular table displays mock data right now
2. for a deployed project go to `#/forms/<uid>/settings/activity`
3. 🟢 notice that the table works as previously 👌

---------

Co-authored-by: RuthShryock <[email protected]>
  • Loading branch information
magicznyleszek and RuthShryock authored Dec 12, 2024
1 parent 8094a92 commit 8e473b8
Show file tree
Hide file tree
Showing 5 changed files with 317 additions and 48 deletions.
25 changes: 25 additions & 0 deletions jsapp/js/hooks/useViewportSize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// TODO: in near future either replace with `@mantine/hooks` or other hooks library
// This is a copy of: https://github.com/mantinedev/mantine/blob/master/packages/%40mantine/hooks/src/use-viewport-size/use-viewport-size.ts
import {useCallback, useEffect, useState} from 'react';
import {useWindowEvent} from './useWindowEvent';

const eventListerOptions = {
passive: true,
};

export function useViewportSize() {
const [windowSize, setWindowSize] = useState({
width: 0,
height: 0,
});

const setSize = useCallback(() => {
setWindowSize({ width: window.innerWidth || 0, height: window.innerHeight || 0 });
}, []);

useWindowEvent('resize', setSize, eventListerOptions);
useWindowEvent('orientationchange', setSize, eventListerOptions);
useEffect(setSize, []);

return windowSize;
}
16 changes: 16 additions & 0 deletions jsapp/js/hooks/useWindowEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// TODO: in near future either replace with `@mantine/hooks` or other hooks library
// This is a copy of: https://github.com/mantinedev/mantine/blob/master/packages/%40mantine/hooks/src/use-window-event/use-window-event.ts
import {useEffect} from 'react';

export function useWindowEvent<K extends string>(
type: K,
listener: K extends keyof WindowEventMap
? (this: Window, ev: WindowEventMap[K]) => void
: (this: Window, ev: CustomEvent) => void,
options?: boolean | AddEventListenerOptions
) {
useEffect(() => {
window.addEventListener(type as any, listener, options);
return () => window.removeEventListener(type as any, listener, options);
}, [type, listener]);
}
81 changes: 59 additions & 22 deletions jsapp/js/universalTable/universalTable.component.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Libraries
import React, {useState, useRef, useCallback} from 'react';
import type React from 'react';
import {useState, useRef, useCallback, type CSSProperties, useEffect} from 'react';
import cx from 'classnames';
import {
flexRender,
Expand All @@ -9,7 +10,9 @@ import {
type Column,
type PaginationState,
type TableOptions,
type ColumnPinningPosition,
} from '@tanstack/react-table';
import {useViewportSize} from 'jsapp/js/hooks/useViewportSize';

// Partial components
import LoadingSpinner from 'js/components/common/loadingSpinner';
Expand All @@ -33,7 +36,7 @@ export interface UniversalTableColumn<DataItem> {
* anything really.
*/
label: React.ReactNode;
isPinned?: boolean;
isPinned?: ColumnPinningPosition;
/**
* This is override for the default width of a column. Use it if you need more
* space for your data, or if you display something very short.
Expand All @@ -49,7 +52,7 @@ export interface UniversalTableColumn<DataItem> {

interface UniversalTableProps<DataItem> {
/** A list of column definitions */
columns: UniversalTableColumn<DataItem>[];
columns: Array<UniversalTableColumn<DataItem>>;
data: DataItem[];
/**
* When set to `true`, a spinner with overlay will be displayed over the table
Expand All @@ -63,21 +66,20 @@ interface UniversalTableProps<DataItem> {
/** Total number of pages of data. */
pageCount?: number;
/**
* One of `pageSizeOptions`. It is de facto the `limit` from the `offset` + `limit`
* pair used for paginatin the endpoint.
* One of `pageSizeOptions`. It is de facto the `limit` from the `offset` and
* `limit` pair used for paginating the endpoint.
*/
pageSize?: number;
pageSizeOptions?: number[];
/**
* A way for the table to say "user wants to change pagination". It's being
* triggered for both page size and page changes.
* triggered for both page navigation and page size changes.
*
* Provides an object with current `pageIndex` and `pageSize` (one or both
* values are new). The second object shows previous pagination, use it to
* compare and understand what has happened :)
*/
onRequestPaginationChange?: (
/**
* Provides an object with current `pageIndex` and `pageSize` (one or both
* values are new). The second object shows previous pagination, use it to
* compare what has happened :)
*/
newPageInfo: PaginationState,
oldPageInfo: PaginationState
) => void;
Expand Down Expand Up @@ -110,7 +112,10 @@ export default function UniversalTable<DataItem>(
) {
// We need table height for the resizers
const [tableHeight, setTableHeight] = useState(0);
const [hasHorizontalScrollbar, setHasHorizontalScrollbar] = useState(false);
const tableRef = useRef<HTMLTableElement>(null);
const tableContainerRef = useRef<HTMLTableElement>(null);
const {width, height} = useViewportSize();

const moveCallback = useCallback(() => {
if (tableRef.current) {
Expand All @@ -129,11 +134,24 @@ export default function UniversalTable<DataItem>(
}

function getCommonClassNames(column: Column<DataItem>) {
const isPinned = column.getIsPinned();
return cx({
[styles.isPinned]: Boolean(column.getIsPinned()),
[styles.isPinnedLeft]: isPinned === 'left',
[styles.isPinnedRight]: isPinned === 'right',
[styles.isLastLeftPinnedColumn]: isPinned === 'left' && column.getIsLastColumn('left'),
[styles.isFirstRightPinnedColumn]: isPinned === 'right' && column.getIsFirstColumn('right'),
});
}

function getCommonColumnStyles(column: Column<DataItem>): CSSProperties {
const isPinned = column.getIsPinned();
return {
left: isPinned === 'left' ? `${column.getStart('left')}px` : undefined,
right: isPinned === 'right' ? `${column.getAfter('right')}px` : undefined,
width: `${column.getSize()}px`,
};
}

const columns = props.columns.map((columnDef) => {
return {
accessorKey: columnDef.key,
Expand All @@ -160,14 +178,14 @@ export default function UniversalTable<DataItem>(
defaultColumn: DEFAULT_COLUMN_SIZE,
};

// Set separately because we set both `.columnPinning` and (sometimes)
// `.pagination` and we need to be careful not to overwrite `.state` object.
options.state = {};

// Set separately to not get overriden by pagination options. This is a list
// of columns that are pinned to the left side.
const pinnedColumns = props.columns
.filter((col) => col.isPinned)
.map((col) => col.key);
options.state.columnPinning = {left: pinnedColumns || []};
options.state.columnPinning = {
left: props.columns.filter((col) => col.isPinned === 'left').map((col) => col.key),
right: props.columns.filter((col) => col.isPinned === 'right').map((col) => col.key),
};

const hasPagination = Boolean(
props.pageIndex !== undefined &&
Expand Down Expand Up @@ -216,9 +234,23 @@ export default function UniversalTable<DataItem>(
const currentPageString = String(table.getState().pagination.pageIndex + 1);
const totalPagesString = String(table.getPageCount());

// Calculate the total width of all columns and the width of container, to
// guess if there is a horizontal scrollbar
useEffect(() => {
const columnsWidth = table.getTotalSize();
let containerWidth = Infinity;
if (tableContainerRef.current) {
containerWidth = tableContainerRef.current.offsetWidth;
}

setHasHorizontalScrollbar(columnsWidth > containerWidth);
}, [props, table, width, height]);

return (
<div className={styles.universalTableRoot}>
<div className={styles.tableContainer}>
<div className={cx(styles.universalTableRoot, {
[styles.hasHorizontalScrollbar]: hasHorizontalScrollbar,
})}>
<div className={styles.tableContainer} ref={tableContainerRef}>
{props.isSpinnerVisible &&
<div className={styles.spinnerOverlay}>
<LoadingSpinner message={false} />
Expand All @@ -240,7 +272,7 @@ export default function UniversalTable<DataItem>(
styles.tableHeaderCell,
getCommonClassNames(header.column)
)}
style={{width: `${header.getSize()}px`}}
style={{...getCommonColumnStyles(header.column)}}
>
{!header.isPlaceholder &&
flexRender(
Expand All @@ -254,6 +286,11 @@ export default function UniversalTable<DataItem>(
there is a way to fix that, see:
https://tanstack.com/table/latest/docs/guide/column-sizing#advanced-column-resizing-performance
*/}
{/*
TODO: one of the resizers will not work for columns that
are `isLastLeftPinnedColumn` or `isFirstRightPinnedColumn`
and we are ok with this for now.
*/}
<div
onDoubleClick={() => header.column.resetSize()}
onMouseDown={(event) => {
Expand Down Expand Up @@ -292,7 +329,7 @@ export default function UniversalTable<DataItem>(
styles.tableCell,
getCommonClassNames(cell.column)
)}
style={{width: `${cell.column.getSize()}px`}}
style={{...getCommonColumnStyles(cell.column)}}
>
{flexRender(
cell.column.columnDef.cell,
Expand Down
84 changes: 58 additions & 26 deletions jsapp/js/universalTable/universalTable.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ $universal-table-border-radius-inner: $universal-table-border-radius - 2px;

$universal-table-resizer-top: 8px;

// TODO see if this needs to be something from `z-indexes` file, or if such
// local numbers would be ok.
$z-index-resizer: 2;
// For sure pinned needs to be over .resizer, so it doesn't appear in
// weird/funny position when scrolling table horizontally.
$z-index-pinned: 3;
$z-index-pinned-header: 4;
$z-index-resizer-active: 5;
$z-index-pinned-header-hover: 5;
$z-index-resizer-active: 6;
$z-index-spinner: 7;

.universalTableRoot {
border: 1px solid colors.$kobo-gray-200;
Expand Down Expand Up @@ -56,6 +58,9 @@ $z-index-resizer-active: 5;
// This is needed so that the table takes whole width if there is small amount
// of columns
min-width: 100%;
// Needed for columns to keep the sizes - without this there is a gap between
// pinned columns
table-layout: fixed;
}

.tableCell {
Expand All @@ -78,6 +83,7 @@ $z-index-resizer-active: 5;
}

.spinnerOverlay {
z-index: $z-index-spinner;
position: absolute;
top: 0;
right: 0;
Expand All @@ -89,34 +95,60 @@ $z-index-resizer-active: 5;

// -----------------------------------------------------------------------------
// Pinned column styles:
.tableCell.isPinned,
.tableHeaderCell.isPinned {
position: sticky;
// react-table can handle left and right pinning, but we are only interested
// in left pinning here
left: 0;
border-right: 1px solid colors.$kobo-gray-200;

&::after {
content: '';
position: absolute;
left: calc(100% + 1px);
top: 0;
height: 100%;
width: 7px;
background: linear-gradient(to right, rgba(0, 0, 0, 6%), transparent);
}
}

.tableCell.isPinned {
// For sure it needs to be over .resizer, so it doesn't appear in weird/funny
// position when scrolling table horizontally
.tableCell.isPinnedLeft,
.tableCell.isPinnedRight {
z-index: $z-index-pinned;
}

.tableHeaderCell.isPinned {
.tableHeaderCell.isPinnedLeft,
.tableHeaderCell.isPinnedRight, {
z-index: $z-index-pinned-header;
}

.tableHeaderCell.isPinnedLeft:hover,
.tableHeaderCell.isPinnedRight:hover, {
z-index: $z-index-pinned-header-hover;
}

.tableCell.isPinnedLeft,
.tableHeaderCell.isPinnedLeft,
.tableCell.isPinnedRight,
.tableHeaderCell.isPinnedRight {
position: sticky;
}

// No need for shadows and visual distinction for pinned columns - if there is
// no horizontal scrollbar
.hasHorizontalScrollbar {
.isLastLeftPinnedColumn,
.isFirstRightPinnedColumn {
&::after {
content: '';
position: absolute;
top: 0;
height: 100%;
width: 7px;
}
}

.isLastLeftPinnedColumn {
border-right: 1px solid colors.$kobo-gray-200;

&::after {
left: calc(100% + 1px);
background: linear-gradient(to left, transparent, rgba(0 0 0 / 6%));
}
}

.isFirstRightPinnedColumn {
border-left: 1px solid colors.$kobo-gray-200;

&::after {
right: calc(100% + 1px);
background: linear-gradient(to right, transparent, rgba(0 0 0 / 6%));
}
}
}
// -----------------------------------------------------------------------------

// -----------------------------------------------------------------------------
Expand Down
Loading

0 comments on commit 8e473b8

Please sign in to comment.