From 2c945a0b1491304812501d38777ffcb585496920 Mon Sep 17 00:00:00 2001 From: Leszek Date: Fri, 13 Sep 2024 11:07:47 +0200 Subject: [PATCH 1/4] Create `UniversalTable` and `PaginatedQueryUniversalTable` components Also update few `angle-*` icons --- ...paginatedQueryUniversalTable.component.tsx | 78 +++++ .../universalTable.component.tsx | 325 ++++++++++++++++++ .../universalTable/universalTable.module.scss | 232 +++++++++++++ jsapp/svg-icons/angle-bar-left.svg | 1 + jsapp/svg-icons/angle-bar-right.svg | 1 + jsapp/svg-icons/angle-down.svg | 2 +- jsapp/svg-icons/angle-left.svg | 2 +- jsapp/svg-icons/angle-right.svg | 2 +- jsapp/svg-icons/angle-up.svg | 2 +- package-lock.json | 45 +++ package.json | 1 + 11 files changed, 687 insertions(+), 4 deletions(-) create mode 100644 jsapp/js/universalTable/paginatedQueryUniversalTable.component.tsx create mode 100644 jsapp/js/universalTable/universalTable.component.tsx create mode 100644 jsapp/js/universalTable/universalTable.module.scss create mode 100644 jsapp/svg-icons/angle-bar-left.svg create mode 100644 jsapp/svg-icons/angle-bar-right.svg diff --git a/jsapp/js/universalTable/paginatedQueryUniversalTable.component.tsx b/jsapp/js/universalTable/paginatedQueryUniversalTable.component.tsx new file mode 100644 index 0000000000..b0af53d43a --- /dev/null +++ b/jsapp/js/universalTable/paginatedQueryUniversalTable.component.tsx @@ -0,0 +1,78 @@ +// Libraries +import React, {useState, useMemo} from 'react'; + +// Partial components +import UniversalTable from './universalTable.component'; + +// Types +import type {UseQueryResult} from '@tanstack/react-query'; +import type {PaginatedResponse} from 'js/dataInterface'; +import type {UniversalTableColumn} from './universalTable.component'; + +interface PaginatedQueryHook extends Function { + (limit: number, offset: number): UseQueryResult>; +} + +interface PaginatedQueryUniversalTableProps { + queryHook: PaginatedQueryHook; + // Below are props from `UniversalTable` that should come from the parent + // component (these are kind of "configuration" props). The other + // `UniversalTable` props are being handled here internally. + columns: UniversalTableColumn[]; +} + +const PAGE_SIZES = [10, 30, 50, 100]; +const DEFAULT_PAGE_SIZE = PAGE_SIZES[0]; + +/** + * This is a wrapper component for `UniversalTable`. It should be used in + * situations when you use `react-query` to fetch data, and the data is + * paginated. This component handles pagination in a neat, DRY way. + * + * All the rest of the functionalities are the same as `UniversalTable`. + */ +export default function PaginatedQueryUniversalTable( + props: PaginatedQueryUniversalTableProps +) { + const [pagination, setPagination] = useState({ + limit: DEFAULT_PAGE_SIZE, + offset: 0, + }); + + const paginatedQuery = props.queryHook(pagination.limit, pagination.offset); + + const availablePages = useMemo( + () => Math.ceil((paginatedQuery.data?.count ?? 0) / pagination.limit), + [paginatedQuery.data, pagination] + ); + + const currentPageIndex = useMemo( + () => Math.ceil(pagination.offset / pagination.limit), + [pagination] + ); + + const data = paginatedQuery.data?.results || []; + + return ( + + columns={props.columns} + data={data} + pageIndex={currentPageIndex} + pageCount={availablePages} + pageSize={pagination.limit} + pageSizes={PAGE_SIZES} + onRequestPaginationChange={(newPageInfo, oldPageInfo) => { + // Calculate new offset and limit from what we've got + let newOffset = newPageInfo.pageIndex * newPageInfo.pageSize; + const newLimit = newPageInfo.pageSize; + + // If we change page size, we switch back to first page + if (newPageInfo.pageSize !== oldPageInfo.pageSize) { + newOffset = 0; + } + + setPagination({limit: newLimit, offset: newOffset}); + }} + /> + ); +} diff --git a/jsapp/js/universalTable/universalTable.component.tsx b/jsapp/js/universalTable/universalTable.component.tsx new file mode 100644 index 0000000000..c221c77d06 --- /dev/null +++ b/jsapp/js/universalTable/universalTable.component.tsx @@ -0,0 +1,325 @@ +// Libraries +import React from 'react'; +import cx from 'classnames'; +import { + flexRender, + getCoreRowModel, + useReactTable, + type CellContext, + type Column, + type PaginationState, + type TableOptions, +} from '@tanstack/react-table'; + +// Partial components +import Button from 'js/components/common/button'; +import KoboSelect from 'js/components/common/koboSelect'; + +// Utilities +import {generateUuid} from 'js/utils'; + +// Styles +import styles from './universalTable.module.scss'; + +export interface UniversalTableColumn { + /** + * Pairs to data object properties. It is using dot notation, so it's possible + * to match data from a nested object :ok:. + */ + key: string; + /** + * Most of the times this would be just a string, but we are open to + * anything really. + */ + label: React.ReactNode; + isPinned?: boolean; + /** + * 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. + */ + size?: number; + /** + * This is an optional formatter function that will be used when rendering + * the cell value. Without it a literal text value will be rendered. + */ + cellFormatter?: (value: string) => React.ReactNode; +} + +interface UniversalTableProps { + /** A list of column definitions */ + columns: UniversalTableColumn[]; + data: DataItem[]; + // PAGINATION + // To see footer with pagination you need to pass all these below: + /** Starts with `0` */ + pageIndex?: number; + /** Total number of pages of data. */ + pageCount?: number; + /** + * One of `pageSizes`. It is de facto the `limit` from the `offset` + `limit` + * pair used for paginatin the endpoint. + */ + pageSize?: number; + pageSizes?: number[]; + /** + * A way for the table to say "user wants to change pagination". It's being + * triggered for both page size and page changes. + */ + 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; + // ENDPAGINATION +} + +const DEFAULT_COLUMN_SIZE = { + size: 200, // starting column size + minSize: 100, // enforced during column resizing + maxSize: 600, // enforced during column resizing +}; + +/** + * This is a nice wrapper for the `@tanstack/react-table`. It uses only + * a limited selection of all possible features, and provides consistent looks. + * + * You are responsible for passing column definitions (important!) and the data + * to match these definitions (obviously). When using it, you need to also pass + * the TS type of the data item, so it knows what to expect. + * + * It has column pinning (on column definition level, i.e. you need to tell it + * which columns are pinned), and column resizing (works out of the box!). + * + * It has (optional) pagination. If you pass all the required props, you can + * expect to get user pagination requests through the callback function named + * `onRequestPaginationChange`. + */ +export default function UniversalTable( + props: UniversalTableProps +) { + function getCommonClassNames(column: Column) { + return cx({ + [styles.isPinned]: Boolean(column.getIsPinned()), + }); + } + + const columns = props.columns.map((columnDef) => { + return { + accessorKey: columnDef.key, + header: () => columnDef.label, + cell: (cellProps: CellContext) => { + if (columnDef.cellFormatter) { + return columnDef.cellFormatter(cellProps.getValue()); + } else { + return cellProps.renderValue(); + } + }, + size: columnDef.size || DEFAULT_COLUMN_SIZE.size, + }; + }); + + // We define options as separate object to make the optional pagination truly + // optional. + const options: TableOptions = { + columns: columns, + data: props.data, + getCoreRowModel: getCoreRowModel(), + columnResizeMode: 'onChange', + //override default column sizing + defaultColumn: DEFAULT_COLUMN_SIZE, + }; + + 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 || []}; + + const hasPagination = ( + props.pageIndex !== undefined && + props.pageCount !== undefined && + props.pageSize !== undefined && + props.pageSizes !== undefined && + props.onRequestPaginationChange !== undefined + ); + + // Add pagination related options if needed + if ( + hasPagination && + // `hasPagination` handles everything, but we need these two for TypeScript: + props.pageSize !== undefined && + props.pageIndex !== undefined + ) { + options.manualPagination = true; + options.pageCount = props.pageCount; + options.state.pagination = { + pageSize: props.pageSize, + pageIndex: props.pageIndex, + }; + //update the pagination state when internal APIs mutate the pagination state + options.onPaginationChange = (updater) => { + // make sure updater is callable (to avoid typescript warning) + if (typeof updater !== 'function') { + return; + } + + // The `table` below is defined before usage, but we are sure it will be + // there, given this is a callback function for it. + // eslint-disable-next-line @typescript-eslint/no-use-before-define + const oldPageInfo = table.getState().pagination; + + const newPageInfo = updater(oldPageInfo); + + if (props.onRequestPaginationChange) { + props.onRequestPaginationChange(newPageInfo, oldPageInfo); + } + }; + } + + // Here we build the headless table that we would render below + const table = useReactTable(options); + + const currentPageString = String(table.getState().pagination.pageIndex + 1); + const totalPagesString = String(table.getPageCount()); + + return ( +
+
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+ {!header.isPlaceholder && + flexRender( + header.column.columnDef.header, + header.getContext() + ) + } + + {/* + TODO: if we ever see performance issues while resizing, + there is a way to fix that, see: + https://tanstack.com/table/latest/docs/guide/column-sizing#advanced-column-resizing-performance + */} +
header.column.resetSize()} + onMouseDown={header.getResizeHandler()} + onTouchStart={header.getResizeHandler()} + className={cx(styles.resizer, { + [styles.isResizing]: header.column.getIsResizing(), + })} + /> +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} +
+
+ + {hasPagination && ( +
+
+
+ + { + return { + value: String(pageSize), + label: t('##number## rows').replace('##number##', String(pageSize)), + }; + })} + selectedOption={String(table.getState().pagination.pageSize)} + onChange={(newSelectedOption: string | null) => { + table.setPageSize(Number(newSelectedOption)); + }} + placement='up-left' + /> +
+ )} +
+
+ ); +} diff --git a/jsapp/js/universalTable/universalTable.module.scss b/jsapp/js/universalTable/universalTable.module.scss new file mode 100644 index 0000000000..58f3196a98 --- /dev/null +++ b/jsapp/js/universalTable/universalTable.module.scss @@ -0,0 +1,232 @@ +@use 'scss/colors'; +@use 'scss/mixins'; + +// Because of scrollable content of the table, we need to do something more +// fancy with rounded corners. +// The actual radius (used for outer wrapper's borders): +$universal-table-border-radius: 6px; +// The radius of the inner elements (used for elements with backgrounds): +$universal-table-border-radius-inner: $universal-table-border-radius - 2px; + +// TODO see if this needs to be something from `z-indexes` file, or if such +// local numbers would be ok. +$z-index-resizer: 2; +$z-index-pinned: 3; +$z-index-pinned-header: 4; +$z-index-resizer-active: 5; + +// We need this to have `overflow: hidden` on the table, but we can't do it on +// one of the other wrappers, as we need borders (sorry!). +.universalTableRootContainer { + overflow: hidden; +} + +.universalTableRoot { + border: 1px solid colors.$kobo-gray-200; + background-color: colors.$kobo-white; + border-radius: $universal-table-border-radius; + width: 100%; + // We set it here intentionally, so noone will think about setting it to + // `hidden`, as it breaks some non obvious things in the table (e.g. the page + // size dropdown in footer). + overflow: visible; +} + +.tableContainer { + overflow-x: auto; + position: relative; + border-radius: $universal-table-border-radius-inner; +} + +.table { + // reset table browser styles first + margin: 0; + padding: 0; + background: none; + border: none; + border-spacing: 0; + background-image: none; + // the actual styles: + background-color: colors.$kobo-white; + // box-shadow and borders will not work with positon: sticky otherwise + border-collapse: separate !important; + // This is needed so that the table takes whole width if there is small amount + // of columns + min-width: 100%; +} + +.tableCell { + background-color: colors.$kobo-white; +} + +.tableHeaderCell { + background-color: colors.$kobo-gray-100; + color: colors.$kobo-gray-700; + position: relative; + font-size: 12px; + font-weight: normal; + text-align: initial; +} + +.tableCell, +.tableHeaderCell { + padding: 12px 20px; + border-bottom: 1px solid colors.$kobo-gray-200; +} + +// ----------------------------------------------------------------------------- +// 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 + z-index: $z-index-pinned; +} + +.tableHeaderCell.isPinned { + z-index: $z-index-pinned-header; +} +// ----------------------------------------------------------------------------- + +// ----------------------------------------------------------------------------- +// Column resizing styles: +// We display resizer of a cell on a right side of it, the left side would be +// handled by previous cell. To make things easier, left side resizer of current +// cell will be a "fake" resizer. +.tableHeaderCell::before, +.resizer { + position: absolute; + background-color: colors.$kobo-gray-500; + height: 28px; + width: 1px; + top: 50%; + transform: translateY(-50%); + z-index: $z-index-resizer; + cursor: col-resize; + touch-action: none; + user-select: none; + border-radius: 2px; + // We start off with these not being visible + display: none; +} + +.tableHeaderCell::before { + content: ''; + left: -1px; + pointer-events: none; +} + +.resizer { + right: 0; +} + +// We want the resizer to have more active space than what's being seen. This +// will improve UX by makin it easier to aim and hit it :) +.resizer::after { + content: ''; + position: absolute; + background-color: transparent; + width: 24px; + height: 150%; + top: -25%; + left: -12px; +} + +// This is the line that we display while resizing the table. It takes whole +// height of the table. +.resizer::before { + display: none; + content: ''; + position: absolute; + top: -24px; + width: 100%; + // We want the line to be seen as taking the whole table, but in reality it + // has the height of the viewport - if user has a really long table, and tries + // to scroll down while dragging the resizer, they will possibly see that + // the line has its limits. We are ok with that :ok:. + height: 100vh; + background-color: colors.$kobo-blue; +} + +.resizer:hover { + background-color: colors.$kobo-blue; + outline: 4px solid colors.$kobo-light-blue; +} + +.resizer.isResizing { + background-color: colors.$kobo-blue; + outline: none; + z-index: $z-index-resizer; +} + +// We display two resizers when mouse is over the cell for them. We also display +// them while resizing is being done (useful for a moment, when user drags +// the resizer further away from the cell, and it didn't move yet due to +// animation happening or lag). +// We want to display resizer of this cell, and a fake resizer on the left side. +// When user moves mouse to the fake resizer, the previous cell resizer (so +// an actual one) will be used. +.tableHeaderCell:hover::before, +.tableHeaderCell:hover .resizer, +.resizer.isResizing, +.resizer.isResizing::before { + display: initial; +} + +// We need the resizer to appear over the pinned column - this is needed for +// a moment when we resize pinned column. +.resizer.isResizing, +.resizer.isResizing::before { + z-index: $z-index-resizer-active; +} + +// We need this to avoid having empty space to the right of the last table +// column due to the resizer active space ::after "hack" +.tableHeaderCell:last-child .resizer::after { + width: 12px; +} +// ----------------------------------------------------------------------------- + +// ----------------------------------------------------------------------------- +// Table footer and pagination styles: +.tableFooter { + @include mixins.centerRowFlex; + justify-content: space-between; + background-color: colors.$kobo-gray-100; + padding: 10px 20px; + border-bottom-left-radius: $universal-table-border-radius-inner; + border-bottom-right-radius: $universal-table-border-radius-inner; +} + +.pagination { + @include mixins.centerRowFlex; +} + +.paginationNumbering { + display: inline; + margin: 0 15px; +} + +.pageSizeSelect { + width: auto !important; + min-width: 120px; +} +// ----------------------------------------------------------------------------- diff --git a/jsapp/svg-icons/angle-bar-left.svg b/jsapp/svg-icons/angle-bar-left.svg new file mode 100644 index 0000000000..90c2970405 --- /dev/null +++ b/jsapp/svg-icons/angle-bar-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/jsapp/svg-icons/angle-bar-right.svg b/jsapp/svg-icons/angle-bar-right.svg new file mode 100644 index 0000000000..fb3d3652c8 --- /dev/null +++ b/jsapp/svg-icons/angle-bar-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/jsapp/svg-icons/angle-down.svg b/jsapp/svg-icons/angle-down.svg index 7dfd9b1204..06d28b9b4c 100644 --- a/jsapp/svg-icons/angle-down.svg +++ b/jsapp/svg-icons/angle-down.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/jsapp/svg-icons/angle-left.svg b/jsapp/svg-icons/angle-left.svg index 575b535a15..5d60411ab0 100644 --- a/jsapp/svg-icons/angle-left.svg +++ b/jsapp/svg-icons/angle-left.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/jsapp/svg-icons/angle-right.svg b/jsapp/svg-icons/angle-right.svg index ce86320b98..38c9f008e0 100644 --- a/jsapp/svg-icons/angle-right.svg +++ b/jsapp/svg-icons/angle-right.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/jsapp/svg-icons/angle-up.svg b/jsapp/svg-icons/angle-up.svg index bfd8dd9227..22bb3c5d11 100644 --- a/jsapp/svg-icons/angle-up.svg +++ b/jsapp/svg-icons/angle-up.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 476c374338..186116b775 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@mapbox/leaflet-omnivore": "^0.3.4", "@sentry/react": "^7.61.0", "@tanstack/react-query": "^5.49.2", + "@tanstack/react-table": "^8.20.5", "alertifyjs": "^1.13.1", "backbone": "^1.4.0", "backbone-validation": "^0.11.5", @@ -7126,6 +7127,37 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-table": { + "version": "8.20.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.20.5.tgz", + "integrity": "sha512-WEHopKw3znbUZ61s9i0+i9g8drmDo6asTWbrQh8Us63DAk/M0FkmIqERew6P71HI75ksZ2Pxyuf4vvKh9rAkiA==", + "dependencies": { + "@tanstack/table-core": "8.20.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.20.5", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.20.5.tgz", + "integrity": "sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "9.3.4", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", @@ -30986,6 +31018,19 @@ "@tanstack/query-devtools": "5.50.1" } }, + "@tanstack/react-table": { + "version": "8.20.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.20.5.tgz", + "integrity": "sha512-WEHopKw3znbUZ61s9i0+i9g8drmDo6asTWbrQh8Us63DAk/M0FkmIqERew6P71HI75ksZ2Pxyuf4vvKh9rAkiA==", + "requires": { + "@tanstack/table-core": "8.20.5" + } + }, + "@tanstack/table-core": { + "version": "8.20.5", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.20.5.tgz", + "integrity": "sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==" + }, "@testing-library/dom": { "version": "9.3.4", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", diff --git a/package.json b/package.json index e39af32745..d47ca67ce1 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "@mapbox/leaflet-omnivore": "^0.3.4", "@sentry/react": "^7.61.0", "@tanstack/react-query": "^5.49.2", + "@tanstack/react-table": "^8.20.5", "alertifyjs": "^1.13.1", "backbone": "^1.4.0", "backbone-validation": "^0.11.5", From 6f62c3e1412c464395764cfa4a8107244547ec36 Mon Sep 17 00:00:00 2001 From: Leszek Date: Tue, 17 Sep 2024 15:45:51 +0200 Subject: [PATCH 2/4] code review fixes --- ...paginatedQueryUniversalTable.component.tsx | 2 +- .../universalTable.component.tsx | 297 ++++++++++-------- .../universalTable/universalTable.module.scss | 48 ++- 3 files changed, 189 insertions(+), 158 deletions(-) diff --git a/jsapp/js/universalTable/paginatedQueryUniversalTable.component.tsx b/jsapp/js/universalTable/paginatedQueryUniversalTable.component.tsx index b0af53d43a..167d49059c 100644 --- a/jsapp/js/universalTable/paginatedQueryUniversalTable.component.tsx +++ b/jsapp/js/universalTable/paginatedQueryUniversalTable.component.tsx @@ -60,7 +60,7 @@ export default function PaginatedQueryUniversalTable( pageIndex={currentPageIndex} pageCount={availablePages} pageSize={pagination.limit} - pageSizes={PAGE_SIZES} + pageSizeOptions={PAGE_SIZES} onRequestPaginationChange={(newPageInfo, oldPageInfo) => { // Calculate new offset and limit from what we've got let newOffset = newPageInfo.pageIndex * newPageInfo.pageSize; diff --git a/jsapp/js/universalTable/universalTable.component.tsx b/jsapp/js/universalTable/universalTable.component.tsx index c221c77d06..490cb1a770 100644 --- a/jsapp/js/universalTable/universalTable.component.tsx +++ b/jsapp/js/universalTable/universalTable.component.tsx @@ -1,5 +1,5 @@ // Libraries -import React from 'react'; +import React, {useState, useRef, useCallback} from 'react'; import cx from 'classnames'; import { flexRender, @@ -56,11 +56,11 @@ interface UniversalTableProps { /** Total number of pages of data. */ pageCount?: number; /** - * One of `pageSizes`. It is de facto the `limit` from the `offset` + `limit` + * One of `pageSizeOptions`. It is de facto the `limit` from the `offset` + `limit` * pair used for paginatin the endpoint. */ pageSize?: number; - pageSizes?: 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. @@ -101,6 +101,26 @@ const DEFAULT_COLUMN_SIZE = { export default function UniversalTable( props: UniversalTableProps ) { + // We need table height for the resizers + const [tableHeight, setTableHeight] = useState(0); + const tableRef = useRef(null); + + const moveCallback = useCallback(() => { + if (tableRef.current) { + setTableHeight(tableRef.current.clientHeight); + } + }, []); + + function onResizerStart() { + document.addEventListener('mousemove', moveCallback); + document.addEventListener('touchmove', moveCallback); + } + + function onResizerEnd() { + document.removeEventListener('mousemove', moveCallback); + document.removeEventListener('touchmove', moveCallback); + } + function getCommonClassNames(column: Column) { return cx({ [styles.isPinned]: Boolean(column.getIsPinned()), @@ -129,7 +149,7 @@ export default function UniversalTable( data: props.data, getCoreRowModel: getCoreRowModel(), columnResizeMode: 'onChange', - //override default column sizing + // Override default column sizing defaultColumn: DEFAULT_COLUMN_SIZE, }; @@ -144,17 +164,17 @@ export default function UniversalTable( const hasPagination = ( props.pageIndex !== undefined && - props.pageCount !== undefined && - props.pageSize !== undefined && - props.pageSizes !== undefined && - props.onRequestPaginationChange !== undefined + props.pageCount && + props.pageSize && + props.pageSizeOptions && + props.onRequestPaginationChange ); // Add pagination related options if needed if ( hasPagination && // `hasPagination` handles everything, but we need these two for TypeScript: - props.pageSize !== undefined && + props.pageSize && props.pageIndex !== undefined ) { options.manualPagination = true; @@ -190,136 +210,153 @@ export default function UniversalTable( const totalPagesString = String(table.getPageCount()); return ( -
-
-
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
- {!header.isPlaceholder && - flexRender( - header.column.columnDef.header, - header.getContext() - ) - } +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + - ))} - - ))} - - - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - ))} - - ))} - -
+ {!header.isPlaceholder && + flexRender( + header.column.columnDef.header, + header.getContext() + ) + } - {/* - TODO: if we ever see performance issues while resizing, - there is a way to fix that, see: - https://tanstack.com/table/latest/docs/guide/column-sizing#advanced-column-resizing-performance - */} -
header.column.resetSize()} - onMouseDown={header.getResizeHandler()} - onTouchStart={header.getResizeHandler()} - className={cx(styles.resizer, { - [styles.isResizing]: header.column.getIsResizing(), - })} - /> -
header.column.resetSize()} + onMouseDown={(event) => { + onResizerStart(); + header.getResizeHandler()(event); + }} + onTouchStart={(event) => { + onResizerStart(); + header.getResizeHandler()(event); + }} + onMouseUp={() => {onResizerEnd();}} + onTouchEnd={() => {onResizerEnd();}} + className={cx(styles.resizer, { + [styles.isResizing]: header.column.getIsResizing(), + })} > - {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} -
-
- - {hasPagination && ( -
-
-
+
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} +
+
-
+ + + { + return { + value: String(pageSize), + label: t('##number## rows').replace('##number##', String(pageSize)), + }; + })} + selectedOption={String(table.getState().pagination.pageSize)} + onChange={(newSelectedOption: string | null) => { + table.setPageSize(Number(newSelectedOption)); + }} + placement='up-left' + /> + + )}
); } diff --git a/jsapp/js/universalTable/universalTable.module.scss b/jsapp/js/universalTable/universalTable.module.scss index 58f3196a98..c6128fe281 100644 --- a/jsapp/js/universalTable/universalTable.module.scss +++ b/jsapp/js/universalTable/universalTable.module.scss @@ -8,6 +8,8 @@ $universal-table-border-radius: 6px; // The radius of the inner elements (used for elements with backgrounds): $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; @@ -15,12 +17,6 @@ $z-index-pinned: 3; $z-index-pinned-header: 4; $z-index-resizer-active: 5; -// We need this to have `overflow: hidden` on the table, but we can't do it on -// one of the other wrappers, as we need borders (sorry!). -.universalTableRootContainer { - overflow: hidden; -} - .universalTableRoot { border: 1px solid colors.$kobo-gray-200; background-color: colors.$kobo-white; @@ -115,10 +111,9 @@ $z-index-resizer-active: 5; .resizer { position: absolute; background-color: colors.$kobo-gray-500; - height: 28px; width: 1px; - top: 50%; - transform: translateY(-50%); + top: $universal-table-resizer-top; + bottom: $universal-table-resizer-top; z-index: $z-index-resizer; cursor: col-resize; touch-action: none; @@ -152,17 +147,13 @@ $z-index-resizer-active: 5; // This is the line that we display while resizing the table. It takes whole // height of the table. -.resizer::before { - display: none; +.resizerLine { content: ''; position: absolute; - top: -24px; + top: -1 * $universal-table-resizer-top; width: 100%; - // We want the line to be seen as taking the whole table, but in reality it - // has the height of the viewport - if user has a really long table, and tries - // to scroll down while dragging the resizer, they will possibly see that - // the line has its limits. We are ok with that :ok:. - height: 100vh; + // Height is being handled by JS code + height: auto; background-color: colors.$kobo-blue; } @@ -174,7 +165,9 @@ $z-index-resizer-active: 5; .resizer.isResizing { background-color: colors.$kobo-blue; outline: none; - z-index: $z-index-resizer; + // We need the resizer to appear over the pinned column - this is needed for + // a moment when we resize pinned column. + z-index: $z-index-resizer-active; } // We display two resizers when mouse is over the cell for them. We also display @@ -182,20 +175,21 @@ $z-index-resizer-active: 5; // the resizer further away from the cell, and it didn't move yet due to // animation happening or lag). // We want to display resizer of this cell, and a fake resizer on the left side. -// When user moves mouse to the fake resizer, the previous cell resizer (so -// an actual one) will be used. +// When user moves mouse to the fake resizer, the right-side resizer of +// the previous cell resizer will be used instead. .tableHeaderCell:hover::before, .tableHeaderCell:hover .resizer, -.resizer.isResizing, -.resizer.isResizing::before { +.resizer.isResizing { display: initial; } -// We need the resizer to appear over the pinned column - this is needed for -// a moment when we resize pinned column. -.resizer.isResizing, -.resizer.isResizing::before { - z-index: $z-index-resizer-active; +// On screens without hover we want the resizers to be always visible +@media (hover: none) { + .tableHeaderCell::before, + .tableHeaderCell .resizer, + .resizer.isResizing { + display: initial; + } } // We need this to avoid having empty space to the right of the last table From 08f6350743b2f3cb3bc723f404e53b9d84235ee4 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Wed, 18 Sep 2024 10:01:53 -0400 Subject: [PATCH 3/4] Fix usage with owner discrepancy in the container --- format-python.sh | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/format-python.sh b/format-python.sh index 54dab59530..23889eb2c6 100755 --- a/format-python.sh +++ b/format-python.sh @@ -2,17 +2,20 @@ BASE_REVISION=$1 -if [ -n "${UWSGI_USER}" ] && [ "${DEBIAN_FRONTEND}" == "noninteractive" ] && [ "${TMP_DIR}" == "/srv/tmp" ]; then - INSIDE_CONTAINER=1 -else - INSIDE_CONTAINER=0 +WHOAMI=$(whoami) +OWNER=$(ls -ld . | awk '{print $3}') +GOSU_USER="" + +if [ "$WHOAMI" != "$OWNER" ]; then + GOSU_USER=$OWNER fi if [ -z "$BASE_REVISION" ]; then - BASE_REVISION="origin/beta" + echo "You must provide the base branch, e.g.: format-python.sh origin/beta" + exit elif [ "$BASE_REVISION" == "-l" ] || [ "$BASE_REVISION" == "--last" ]; then - if [ "$INSIDE_CONTAINER" == "1" ]; then - BASE_REVISION=$(gosu "$UWSGI_USER" git log --oneline| head -n 1 | awk '{ print $1}') + if [ -n "$GOSU_USER" ]; then + BASE_REVISION=$(gosu "$GOSU_USER" git log --oneline| head -n 1 | awk '{ print $1}') else BASE_REVISION=$(git log --oneline| head -n 1 | awk '{ print $1}') fi @@ -27,16 +30,16 @@ fi # - Unnecessary parentheses after class definition (--select UP039) # - Indentation warning (--select W1) # - No newline at end of file (--select W292) -if [ "$INSIDE_CONTAINER" == "1" ]; then - PYTHON_CHANGES=$(gosu "$UWSGI_USER" git diff --name-only "$BASE_REVISION" | grep '\.py') +if [ -n "$GOSU_USER" ]; then + PYTHON_CHANGES=$(gosu "$GOSU_USER" git diff --name-only "$BASE_REVISION" | grep '\.py') else PYTHON_CHANGES=$(git diff --name-only "$BASE_REVISION" | grep '\.py') fi if [ -n "$PYTHON_CHANGES" ]; then echo "Using ruff..." - if [ "$INSIDE_CONTAINER" == "1" ]; then - gosu "$UWSGI_USER" git diff --name-only "$BASE_REVISION" | grep '\.py' | xargs ruff check --select Q --select I --select F401 --select UP026 --select UP034 --select UP039 --select W292 --fix + if [ -n "$GOSU_USER" ]; then + gosu "$GOSU_USER" git diff --name-only "$BASE_REVISION" | grep '\.py' | xargs ruff check --select Q --select I --select F401 --select UP026 --select UP034 --select UP039 --select W292 --fix else git diff --name-only "$BASE_REVISION" | grep '\.py' | xargs ruff check --select Q --select I --select F401 --select UP026 --select UP034 --select UP039 --select W292 --fix fi @@ -45,8 +48,8 @@ if [ -n "$PYTHON_CHANGES" ]; then # --isort: Using isort # --revision: Compare changes with revision $BASE_REVISION echo "Using darker..." - if [ "$INSIDE_CONTAINER" == "1" ]; then - gosu "$UWSGI_USER" darker --isort --revision "$BASE_REVISION" + if [ -n "$GOSU_USER" ]; then + gosu "$GOSU_USER" darker --isort --revision "$BASE_REVISION" else darker --isort --revision "$BASE_REVISION" fi From 86fe0a17d4cf79f81dd06fd328e8b9de6353ff74 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Wed, 18 Sep 2024 16:21:27 -0400 Subject: [PATCH 4/4] Fix CI python linter when no changes are detected --- .github/workflows/darker.yml | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/.github/workflows/darker.yml b/.github/workflows/darker.yml index cc0c251fef..18dd2763d1 100644 --- a/.github/workflows/darker.yml +++ b/.github/workflows/darker.yml @@ -5,14 +5,25 @@ jobs: darker: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - name: Checkout code + uses: actions/checkout@v2 with: fetch-depth: 0 - - uses: actions/setup-python@v5 - - name: Install pip dependencies - run: python -m pip install flake8 flake8-quotes isort - - uses: akaihola/darker@master + + - name: Set up Python + uses: actions/setup-python@v5 with: - options: '--check --isort -L "flake8 --max-line-length=88"' - revision: "origin/${{ github.event.pull_request.base.ref }}" - version: "~=2.1.1" + python-version: '3.10' + + - name: Install pip dependencies + run: python -m pip install darker[isort] flake8 flake8-quotes isort --quiet + + # use `--ignore=F821` to avoid raising false positive error in typing + # annotations with string, e.g. def my_method(my_model: 'ModelName') + + # darker still exit with code 1 even with no errors on changes + - name: Run Darker with base commit + run: | + output=$(darker --check --isort -L "flake8 --max-line-length=88 --ignore=F821" kpi kobo hub -r ${{ github.event.pull_request.base.sha }}) + [[ -n "$output" ]] && echo "$output" && exit 1 || exit 0 + shell: /usr/bin/bash {0}