diff --git a/src/components/EmptyState/EmptyState.scss b/src/components/EmptyState/EmptyState.scss index 77bfdf854..b6bf2e179 100644 --- a/src/components/EmptyState/EmptyState.scss +++ b/src/components/EmptyState/EmptyState.scss @@ -18,7 +18,6 @@ &_size_m { position: relative; - top: 20%; width: 800px; height: 240px; diff --git a/src/components/ProgressViewer/ProgressViewer.scss b/src/components/ProgressViewer/ProgressViewer.scss index 7a94f84b1..6f7b77288 100644 --- a/src/components/ProgressViewer/ProgressViewer.scss +++ b/src/components/ProgressViewer/ProgressViewer.scss @@ -1,5 +1,6 @@ .progress-viewer { position: relative; + z-index: 0; display: flex; overflow: hidden; diff --git a/src/components/TableWithControlsLayout/TableWithControlsLayout.scss b/src/components/TableWithControlsLayout/TableWithControlsLayout.scss index fd07708b0..6599267f4 100644 --- a/src/components/TableWithControlsLayout/TableWithControlsLayout.scss +++ b/src/components/TableWithControlsLayout/TableWithControlsLayout.scss @@ -25,6 +25,10 @@ @include sticky-top(); } + .ydb-virtual-table__head { + top: 62px; + } + .data-table__sticky_moving { // Place table head right after controls top: 62px !important; diff --git a/src/components/VirtualTable/TableChunk.tsx b/src/components/VirtualTable/TableChunk.tsx new file mode 100644 index 000000000..66b2288a5 --- /dev/null +++ b/src/components/VirtualTable/TableChunk.tsx @@ -0,0 +1,84 @@ +import {useEffect, useRef, memo} from 'react'; + +import type {Column, Chunk, GetRowClassName} from './types'; +import {LoadingTableRow, TableRow} from './TableRow'; +import {getArray} from './utils'; + +// With original memo generic types are lost +const typedMemo: (Component: T) => T = memo; + +interface TableChunkProps { + id: number; + chunkSize: number; + rowHeight: number; + columns: Column[]; + chunkData: Chunk | undefined; + observer: IntersectionObserver; + getRowClassName?: GetRowClassName; +} + +// Memoisation prevents chunks rerenders that could cause perfomance issues on big tables +export const TableChunk = typedMemo(function TableChunk({ + id, + chunkSize, + rowHeight, + columns, + chunkData, + observer, + getRowClassName, +}: TableChunkProps) { + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + if (el) { + observer.observe(el); + } + return () => { + if (el) { + observer.unobserve(el); + } + }; + }, [observer]); + + const dataLength = chunkData?.data?.length; + const chunkHeight = dataLength ? dataLength * rowHeight : chunkSize * rowHeight; + + const getLoadingRows = () => { + return getArray(chunkSize).map((value) => { + return ( + + ); + }); + }; + + const renderContent = () => { + if (!chunkData || !chunkData.active) { + return null; + } + + // Display skeletons in case of error + if (chunkData.loading || chunkData.error) { + return getLoadingRows(); + } + + return chunkData.data?.map((data, index) => { + return ( + + ); + }); + }; + + return ( + + {renderContent()} + + ); +}); diff --git a/src/components/VirtualTable/TableHead.tsx b/src/components/VirtualTable/TableHead.tsx new file mode 100644 index 000000000..a75d5b197 --- /dev/null +++ b/src/components/VirtualTable/TableHead.tsx @@ -0,0 +1,139 @@ +import {useState} from 'react'; + +import type {Column, OnSort, SortOrderType, SortParams} from './types'; +import {ASCENDING, DEFAULT_SORT_ORDER, DEFAULT_TABLE_ROW_HEIGHT, DESCENDING} from './constants'; +import {b} from './shared'; + +// Icon similar to original DataTable icons to keep the same tables across diferent pages and tabs +const SortIcon = ({order}: {order?: SortOrderType}) => { + return ( + + + + ); +}; + +interface ColumnSortIconProps { + sortOrder?: SortOrderType; + sortable?: boolean; + defaultSortOrder: SortOrderType; +} + +const ColumnSortIcon = ({sortOrder, sortable, defaultSortOrder}: ColumnSortIconProps) => { + if (sortable) { + return ( + + + + ); + } else { + return null; + } +}; + +interface TableHeadProps { + columns: Column[]; + onSort?: OnSort; + defaultSortOrder?: SortOrderType; + rowHeight?: number; +} + +export const TableHead = ({ + columns, + onSort, + defaultSortOrder = DEFAULT_SORT_ORDER, + rowHeight = DEFAULT_TABLE_ROW_HEIGHT, +}: TableHeadProps) => { + const [sortParams, setSortParams] = useState({}); + + const handleSort = (columnId: string) => { + let newSortParams: SortParams = {}; + + // Order is changed in following order: + // 1. Inactive Sort Order - gray icon of default order + // 2. Active default order + // 3. Active not default order + if (columnId === sortParams.columnId) { + if (sortParams.sortOrder && sortParams.sortOrder !== defaultSortOrder) { + setSortParams(newSortParams); + onSort?.(newSortParams); + return; + } + const newSortOrder = sortParams.sortOrder === ASCENDING ? DESCENDING : ASCENDING; + newSortParams = { + sortOrder: newSortOrder, + columnId: columnId, + }; + } else { + newSortParams = { + sortOrder: defaultSortOrder, + columnId: columnId, + }; + } + + onSort?.(newSortParams); + setSortParams(newSortParams); + }; + + const renderTableColGroups = () => { + return ( + + {columns.map((column) => { + return ; + })} + + ); + }; + + const renderTableHead = () => { + return ( + + + {columns.map((column) => { + const content = column.header ?? column.name; + const sortOrder = + sortParams.columnId === column.name ? sortParams.sortOrder : undefined; + + return ( + { + handleSort(column.name); + }} + > +
+ {content} + +
+ + ); + })} + + + ); + }; + + return ( + <> + {renderTableColGroups()} + {renderTableHead()} + + ); +}; diff --git a/src/components/VirtualTable/TableRow.tsx b/src/components/VirtualTable/TableRow.tsx new file mode 100644 index 000000000..35e8abaea --- /dev/null +++ b/src/components/VirtualTable/TableRow.tsx @@ -0,0 +1,91 @@ +import {type ReactNode} from 'react'; + +import {Skeleton} from '@gravity-ui/uikit'; + +import type {AlignType, Column, GetRowClassName} from './types'; +import {DEFAULT_ALIGN} from './constants'; +import {b} from './shared'; + +interface TableCellProps { + height: number; + align?: AlignType; + children: ReactNode; + className?: string; +} + +const TableRowCell = ({children, className, height, align = DEFAULT_ALIGN}: TableCellProps) => { + return ( + + {children} + + ); +}; + +interface LoadingTableRowProps { + columns: Column[]; + index: number; + height: number; +} + +export const LoadingTableRow = ({index, columns, height}: LoadingTableRowProps) => { + return ( + + {columns.map((column) => { + return ( + + + + ); + })} + + ); +}; + +interface TableRowProps { + columns: Column[]; + index: number; + row: T; + height: number; + getRowClassName?: GetRowClassName; +} + +export const TableRow = ({row, index, columns, getRowClassName, height}: TableRowProps) => { + const additionalClassName = getRowClassName?.(row); + + return ( + + {columns.map((column) => { + return ( + + {column.render({row, index})} + + ); + })} + + ); +}; + +interface EmptyTableRowProps { + columns: Column[]; + children?: ReactNode; +} + +export const EmptyTableRow = ({columns, children}: EmptyTableRowProps) => { + return ( + + + {children} + + + ); +}; diff --git a/src/components/VirtualTable/VirtualTable.scss b/src/components/VirtualTable/VirtualTable.scss new file mode 100644 index 000000000..614dde9b7 --- /dev/null +++ b/src/components/VirtualTable/VirtualTable.scss @@ -0,0 +1,146 @@ +@import '../../styles/mixins.scss'; + +.ydb-virtual-table { + $block: &; + $cell-border: 1px solid var(--virtual-table-border-color); + --virtual-table-cell-vertical-padding: 5px; + --virtual-table-cell-horizontal-padding: 10px; + + --virtual-table-sort-icon-space: 18px; + + --virtual-table-border-color: var(--yc-color-base-generic-hover); + --virtual-table-hover-color: var(--yc-color-base-float-hover); + + width: 100%; + @include body2-typography(); + + &__table { + width: 100%; + max-width: 100%; + + table-layout: fixed; + border-spacing: 0; + border-collapse: separate; + } + + &__row { + &:hover { + background: var(--virtual-table-hover-color); + } + + &_empty { + &:hover { + background-color: initial; + } + } + } + + &__head { + z-index: 1; + @include sticky-top(); + } + + &__th { + position: relative; + + padding: var(--virtual-table-cell-vertical-padding) + var(--virtual-table-cell-horizontal-padding); + + font-weight: bold; + cursor: default; + text-align: left; + + border-bottom: $cell-border; + + &_sortable { + cursor: pointer; + + #{$block}__head-cell { + padding-right: var(--virtual-table-sort-icon-space); + } + + &#{$block}__th_align_right { + #{$block}__head-cell { + padding-right: 0; + padding-left: var(--virtual-table-sort-icon-space); + } + + #{$block}__sort-icon { + right: auto; + left: 0; + + transform: translate(0, -50%) scaleX(-1); + } + } + } + } + + &__head-cell { + position: relative; + + display: inline-block; + overflow: hidden; + + box-sizing: border-box; + max-width: 100%; + + vertical-align: top; + white-space: nowrap; + text-overflow: ellipsis; + } + + &__sort-icon { + position: absolute; + top: 50%; + right: 0; + + display: inline-flex; + + color: inherit; + + transform: translate(0, -50%); + + &_shadow { + opacity: 0.15; + } + } + + &__icon { + vertical-align: top; + + &_desc { + transform: rotate(180deg); + } + } + + &__td { + overflow: hidden; + + padding: var(--virtual-table-cell-vertical-padding) + var(--virtual-table-cell-horizontal-padding); + + white-space: nowrap; + text-overflow: ellipsis; + + border-bottom: $cell-border; + } + + &__td, + &__th { + height: 40px; + + vertical-align: middle; + + &_align { + &_left { + text-align: left; + } + &_center { + text-align: center; + } + &_right { + text-align: right; + } + } + } +} diff --git a/src/components/VirtualTable/VirtualTable.tsx b/src/components/VirtualTable/VirtualTable.tsx new file mode 100644 index 000000000..bc691cfa5 --- /dev/null +++ b/src/components/VirtualTable/VirtualTable.tsx @@ -0,0 +1,277 @@ +import {useState, useReducer, useRef, useCallback, useEffect} from 'react'; + +import type {IResponseError} from '../../types/api/error'; + +import {TableWithControlsLayout} from '../TableWithControlsLayout/TableWithControlsLayout'; +import {ResponseError} from '../Errors/ResponseError'; + +import type { + Column, + OnSort, + FetchData, + SortParams, + RenderControls, + OnEntry, + OnLeave, + GetRowClassName, + RenderEmptyDataMessage, + RenderErrorMessage, +} from './types'; +import { + createVirtualTableReducer, + initChunk, + removeChunk, + resetChunks, + setChunkData, + setChunkError, + setChunkLoading, +} from './reducer'; +import {DEFAULT_REQUEST_TIMEOUT, DEFAULT_TABLE_ROW_HEIGHT} from './constants'; +import {TableHead} from './TableHead'; +import {TableChunk} from './TableChunk'; +import {EmptyTableRow} from './TableRow'; +import {useIntersectionObserver} from './useIntersectionObserver'; +import {getArray} from './utils'; +import i18n from './i18n'; +import {b} from './shared'; + +import './VirtualTable.scss'; + +interface VirtualTableProps { + limit: number; + fetchData: FetchData; + columns: Column[]; + getRowClassName?: GetRowClassName; + rowHeight?: number; + parentContainer?: Element | null; + initialSortParams?: SortParams; + renderControls?: RenderControls; + renderEmptyDataMessage?: RenderEmptyDataMessage; + renderErrorMessage?: RenderErrorMessage; + dependencyArray?: unknown[]; // Fully reload table on params change +} + +export const VirtualTable = ({ + limit, + fetchData, + columns, + getRowClassName, + rowHeight = DEFAULT_TABLE_ROW_HEIGHT, + parentContainer, + initialSortParams, + renderControls, + renderEmptyDataMessage, + renderErrorMessage, + dependencyArray, +}: VirtualTableProps) => { + const inited = useRef(false); + const tableContainer = useRef(null); + + const [state, dispatch] = useReducer(createVirtualTableReducer(), {}); + + const [sortParams, setSortParams] = useState(initialSortParams); + + const [totalEntities, setTotalEntities] = useState(limit); + const [foundEntities, setFoundEntities] = useState(0); + + const [error, setError] = useState(); + + const [pendingRequests, setPendingRequests] = useState>({}); + + const fetchChunkData = useCallback( + async (id: string) => { + dispatch(setChunkLoading(id)); + + const timer = setTimeout(async () => { + const offset = Number(id) * limit; + + try { + const response = await fetchData(limit, offset, sortParams); + const {data, total, found} = response; + + setTotalEntities(total); + setFoundEntities(found); + inited.current = true; + + dispatch(setChunkData(id, data)); + } catch (err) { + // Do not set error on cancelled requests + if ((err as IResponseError)?.isCancelled) { + return; + } + + dispatch(setChunkError(id, err as IResponseError)); + setError(err as IResponseError); + } + }, DEFAULT_REQUEST_TIMEOUT); + + setPendingRequests((reqs) => { + reqs[id] = timer; + return reqs; + }); + }, + [fetchData, limit, sortParams], + ); + + const onEntry = useCallback((id) => { + dispatch(initChunk(id)); + }, []); + + const onLeave = useCallback( + (id) => { + dispatch(removeChunk(id)); + + // If there is a pending request for the removed chunk, cancel it + // It made to prevent excessive requests on fast scroll + if (pendingRequests[id]) { + const timer = pendingRequests[id]; + window.clearTimeout(timer); + delete pendingRequests[id]; + } + }, + [pendingRequests], + ); + + // Load chunks if they become active + // This mecanism helps to set chunk active state from different sources, but load data only once + // Only currently active chunks should be in state so iteration by the whole state shouldn't be a problem + useEffect(() => { + for (const id of Object.keys(state)) { + const chunk = state[Number(id)]; + + if (chunk?.active && !chunk?.loading && !chunk?.wasLoaded) { + fetchChunkData(id); + } + } + }, [fetchChunkData, state]); + + // Reset table on filters change + useEffect(() => { + // Reset counts, so table unmount unneeded chunks + setTotalEntities(limit); + setFoundEntities(0); + setError(undefined); + + // Remove all chunks from state + dispatch(resetChunks()); + + // Reset table state for the controls + inited.current = false; + + // If there is a parent, scroll to parent container ref + // Else scroll to table top + // It helps to prevent layout shifts, when chunks quantity is changed + if (parentContainer) { + parentContainer.scrollTo(0, 0); + } else { + tableContainer.current?.scrollTo(0, 0); + } + + // Make table start to load data + dispatch(initChunk('0')); + }, [dependencyArray, limit, parentContainer]); + + // Reload currently active chunks + // Use case - sort params change, so data should be updated, but without chunks unmount + const reloadCurrentViewport = () => { + for (const id of Object.keys(state)) { + if (state[Number(id)]?.active) { + dispatch(initChunk(id)); + } + } + }; + + const handleSort: OnSort = (params) => { + setSortParams(params); + reloadCurrentViewport(); + }; + + const observer = useIntersectionObserver({onEntry, onLeave, parentContainer}); + + // Render at least 1 chunk + const totalLength = foundEntities || limit; + const chunksCount = Math.ceil(totalLength / limit); + + const renderChunks = () => { + if (!observer) { + return null; + } + + return getArray(chunksCount).map((value) => { + const chunkData = state[value]; + + return ( + + ); + }); + }; + + const renderData = () => { + if (inited.current && foundEntities === 0) { + return ( + + + {renderEmptyDataMessage ? renderEmptyDataMessage() : i18n('empty')} + + + ); + } + + // If first chunk is loaded with the error, display error + // In case of other chunks table will be inited + if (!inited.current && error) { + return ( + + + {renderErrorMessage ? ( + renderErrorMessage(error) + ) : ( + + )} + + + ); + } + + return renderChunks(); + }; + + const renderTable = () => { + return ( + + + {renderData()} +
+ ); + }; + + const renderContent = () => { + if (renderControls) { + return ( + + + {renderControls({inited: inited.current, totalEntities, foundEntities})} + + {renderTable()} + + ); + } + + return renderTable(); + }; + + return ( +
+ {renderContent()} +
+ ); +}; diff --git a/src/components/VirtualTable/constants.ts b/src/components/VirtualTable/constants.ts new file mode 100644 index 000000000..feb26cc34 --- /dev/null +++ b/src/components/VirtualTable/constants.ts @@ -0,0 +1,17 @@ +export const LEFT = 'left'; +export const CENTER = 'center'; +export const RIGHT = 'right'; + +export const DEFAULT_ALIGN = LEFT; + +export const ASCENDING = 1; +export const DESCENDING = -1; + +export const DEFAULT_SORT_ORDER = DESCENDING; + +// Time in ms after which request will be sent +export const DEFAULT_REQUEST_TIMEOUT = 200; + +export const DEFAULT_TABLE_ROW_HEIGHT = 40; + +export const DEFAULT_INTERSECTION_OBSERVER_MARGIN = '100%'; diff --git a/src/components/VirtualTable/i18n/en.json b/src/components/VirtualTable/i18n/en.json new file mode 100644 index 000000000..2444d95ed --- /dev/null +++ b/src/components/VirtualTable/i18n/en.json @@ -0,0 +1,3 @@ +{ + "empty": "No data" +} diff --git a/src/components/VirtualTable/i18n/index.ts b/src/components/VirtualTable/i18n/index.ts new file mode 100644 index 000000000..a1da51ba0 --- /dev/null +++ b/src/components/VirtualTable/i18n/index.ts @@ -0,0 +1,11 @@ +import {i18n, Lang} from '../../../utils/i18n'; + +import en from './en.json'; +import ru from './ru.json'; + +const COMPONENT = 'ydb-virtual-table'; + +i18n.registerKeyset(Lang.En, COMPONENT, en); +i18n.registerKeyset(Lang.Ru, COMPONENT, ru); + +export default i18n.keyset(COMPONENT); diff --git a/src/components/VirtualTable/i18n/ru.json b/src/components/VirtualTable/i18n/ru.json new file mode 100644 index 000000000..7337e70ce --- /dev/null +++ b/src/components/VirtualTable/i18n/ru.json @@ -0,0 +1,3 @@ +{ + "empty": "Нет данных" +} diff --git a/src/components/VirtualTable/index.ts b/src/components/VirtualTable/index.ts new file mode 100644 index 000000000..1d988c3a7 --- /dev/null +++ b/src/components/VirtualTable/index.ts @@ -0,0 +1,3 @@ +export * from './constants'; +export * from './types'; +export * from './VirtualTable'; diff --git a/src/components/VirtualTable/reducer.ts b/src/components/VirtualTable/reducer.ts new file mode 100644 index 000000000..220618791 --- /dev/null +++ b/src/components/VirtualTable/reducer.ts @@ -0,0 +1,143 @@ +import type {Reducer} from 'react'; + +import type {IResponseError} from '../../types/api/error'; + +import type {Chunk} from './types'; + +const INIT_CHUNK = 'infiniteTable/INIT_CHUNK'; +const REMOVE_CHUNK = 'infiniteTable/REMOVE_CHUNK'; +const SET_CHUNK_LOADING = 'infiniteTable/SET_CHUNK_LOADING'; +const SET_CHUNK_DATA = 'infiniteTable/SET_CHUNK_DATA'; +const SET_CHUNK_ERROR = 'infiniteTable/SET_CHUNK_ERROR'; +const RESET_CHUNKS = 'infiniteTable/RESET_CHUNKS'; + +type VirtualTableState = Record | undefined>; + +// Intermediary type to pass to ReducerAction (because ReturnType cannot correctly convert generics) +interface SetChunkDataAction { + type: typeof SET_CHUNK_DATA; + data: { + id: string; + data: T[]; + }; +} + +export const setChunkData = (id: string, data: T[]): SetChunkDataAction => { + return { + type: SET_CHUNK_DATA, + data: {id, data}, + } as const; +}; + +export const setChunkError = (id: string, error: IResponseError) => { + return { + type: SET_CHUNK_ERROR, + data: {id, error}, + } as const; +}; + +export const initChunk = (id: string) => { + return { + type: INIT_CHUNK, + data: {id}, + } as const; +}; + +export const setChunkLoading = (id: string) => { + return { + type: SET_CHUNK_LOADING, + data: {id}, + } as const; +}; + +export const removeChunk = (id: string) => { + return { + type: REMOVE_CHUNK, + data: {id}, + } as const; +}; + +export const resetChunks = () => { + return { + type: RESET_CHUNKS, + } as const; +}; + +type VirtualTableAction = + | SetChunkDataAction + | ReturnType + | ReturnType + | ReturnType + | ReturnType + | ReturnType; + +// Reducer wrapped in additional function to pass generic type +export const createVirtualTableReducer = + (): Reducer, VirtualTableAction> => + (state, action) => { + switch (action.type) { + case SET_CHUNK_DATA: { + const {id, data} = action.data; + + return { + ...state, + [id]: { + loading: false, + wasLoaded: true, + active: true, + data, + }, + }; + } + case SET_CHUNK_ERROR: { + const {id, error} = action.data; + + return { + ...state, + [id]: { + loading: false, + wasLoaded: true, + active: true, + error, + }, + }; + } + case INIT_CHUNK: { + const {id} = action.data; + + return { + ...state, + [id]: { + loading: false, + wasLoaded: false, + active: true, + }, + }; + } + case SET_CHUNK_LOADING: { + const {id} = action.data; + + return { + ...state, + [id]: { + loading: true, + wasLoaded: false, + active: true, + }, + }; + } + case REMOVE_CHUNK: { + const {id} = action.data; + + const newState = {...state}; + delete newState[id]; + + return newState; + } + case RESET_CHUNKS: { + return {}; + } + default: + return state; + } + }; diff --git a/src/components/VirtualTable/shared.ts b/src/components/VirtualTable/shared.ts new file mode 100644 index 000000000..3471df370 --- /dev/null +++ b/src/components/VirtualTable/shared.ts @@ -0,0 +1,3 @@ +import cn from 'bem-cn-lite'; + +export const b = cn('ydb-virtual-table'); diff --git a/src/components/VirtualTable/types.ts b/src/components/VirtualTable/types.ts new file mode 100644 index 000000000..549a18e5c --- /dev/null +++ b/src/components/VirtualTable/types.ts @@ -0,0 +1,60 @@ +import type {ReactNode} from 'react'; + +import type {IResponseError} from '../../types/api/error'; + +import {ASCENDING, CENTER, DESCENDING, LEFT, RIGHT} from './constants'; + +export interface Chunk { + active: boolean; + loading: boolean; + wasLoaded: boolean; + data?: T[]; + error?: IResponseError; +} + +export type GetChunk = (id: number) => Chunk | undefined; + +export type OnEntry = (id: string) => void; +export type OnLeave = (id: string) => void; + +export type AlignType = typeof LEFT | typeof RIGHT | typeof CENTER; +export type SortOrderType = typeof ASCENDING | typeof DESCENDING; + +export type SortParams = {columnId?: string; sortOrder?: SortOrderType}; +export type OnSort = (params: SortParams) => void; + +export interface Column { + name: string; + header?: ReactNode; + className?: string; + sortable?: boolean; + render: (props: {row: T; index: number}) => ReactNode; + width: number; + align: AlignType; +} + +export interface VirtualTableData { + data: T[]; + total: number; + found: number; +} + +export type FetchData = ( + limit: number, + offset: number, + sortParams?: SortParams, +) => Promise>; + +export type OnError = (error?: IResponseError) => void; + +interface ControlsParams { + totalEntities: number; + foundEntities: number; + inited: boolean; +} + +export type RenderControls = (params: ControlsParams) => ReactNode; +export type RenderEmptyDataMessage = () => ReactNode; +export type RenderErrorMessage = (error: IResponseError) => ReactNode; + +export type GetRowClassName = (row: T) => string | undefined; diff --git a/src/components/VirtualTable/useIntersectionObserver.ts b/src/components/VirtualTable/useIntersectionObserver.ts new file mode 100644 index 000000000..24dbe116d --- /dev/null +++ b/src/components/VirtualTable/useIntersectionObserver.ts @@ -0,0 +1,42 @@ +import {useEffect, useRef} from 'react'; + +import type {OnEntry, OnLeave} from './types'; +import {DEFAULT_INTERSECTION_OBSERVER_MARGIN} from './constants'; + +interface UseIntersectionObserverProps { + onEntry: OnEntry; + onLeave: OnLeave; + parentContainer?: Element | null; +} + +export const useIntersectionObserver = ({ + onEntry, + onLeave, + parentContainer, +}: UseIntersectionObserverProps) => { + const observer = useRef(); + + useEffect(() => { + const callback = (entries: IntersectionObserverEntry[]) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + onEntry(entry.target.id); + } else { + onLeave(entry.target.id); + } + }); + }; + + observer.current = new IntersectionObserver(callback, { + root: parentContainer, + rootMargin: DEFAULT_INTERSECTION_OBSERVER_MARGIN, + }); + + return () => { + observer.current?.disconnect(); + observer.current = undefined; + }; + }, [parentContainer, onEntry, onLeave]); + + return observer.current; +}; diff --git a/src/components/VirtualTable/utils.ts b/src/components/VirtualTable/utils.ts new file mode 100644 index 000000000..ffa1fa018 --- /dev/null +++ b/src/components/VirtualTable/utils.ts @@ -0,0 +1,3 @@ +export const getArray = (arrayLength: number) => { + return [...Array(arrayLength).keys()]; +}; diff --git a/src/containers/App/App.scss b/src/containers/App/App.scss index 1b01dde4c..fc4084e13 100644 --- a/src/containers/App/App.scss +++ b/src/containers/App/App.scss @@ -123,7 +123,8 @@ body, color: var(--yc-color-text-danger); } -.data-table__row:hover .entity-status__clipboard-button { +.data-table__row:hover .entity-status__clipboard-button, +.ydb-virtual-table__row:hover .entity-status__clipboard-button { display: flex; } diff --git a/src/containers/Cluster/Cluster.tsx b/src/containers/Cluster/Cluster.tsx index 9eb146c89..41c91e1b8 100644 --- a/src/containers/Cluster/Cluster.tsx +++ b/src/containers/Cluster/Cluster.tsx @@ -1,4 +1,4 @@ -import {useEffect, useMemo} from 'react'; +import {useEffect, useMemo, useRef} from 'react'; import {useLocation, useRouteMatch} from 'react-router'; import {useDispatch} from 'react-redux'; import cn from 'bem-cn-lite'; @@ -18,11 +18,13 @@ import {setHeaderBreadcrumbs} from '../../store/reducers/header/header'; import {getClusterInfo} from '../../store/reducers/cluster/cluster'; import {getClusterNodes} from '../../store/reducers/clusterNodes/clusterNodes'; import {parseNodesToVersionsValues, parseVersionsToVersionToColorMap} from '../../utils/versions'; -import {useAutofetcher, useTypedSelector} from '../../utils/hooks'; +import {useAutofetcher, useSetting, useTypedSelector} from '../../utils/hooks'; +import {USE_BACKEND_PARAMS_FOR_TABLES_KEY} from '../../utils/constants'; import {InternalLink} from '../../components/InternalLink'; import {Tenants} from '../Tenants/Tenants'; import {Nodes} from '../Nodes/Nodes'; +import {VirtualNodes} from '../Nodes/VirtualNodes'; import {Storage} from '../Storage/Storage'; import {Versions} from '../Versions/Versions'; @@ -46,11 +48,15 @@ function Cluster({ additionalNodesProps, additionalVersionsProps, }: ClusterProps) { + const container = useRef(null); + const dispatch = useDispatch(); const match = useRouteMatch<{activeTab: string}>(routes.cluster); const {activeTab = clusterTabsIds.tenants} = match?.params || {}; + const [useVirtualNodes] = useSetting(USE_BACKEND_PARAMS_FOR_TABLES_KEY); + const location = useLocation(); const queryParams = qs.parse(location.search, { ignoreQueryPrefix: true, @@ -104,7 +110,14 @@ function Cluster({ return ; } case clusterTabsIds.nodes: { - return ; + return useVirtualNodes ? ( + + ) : ( + + ); } case clusterTabsIds.storage: { return ; @@ -119,7 +132,7 @@ function Cluster({ }; return ( -
+
{ const [useNodesEndpoint] = useSetting(USE_NODES_ENDPOINT_IN_DIAGNOSTICS_KEY); - const requestParams = useNodesRequestParams({ - filter: searchValue, - problemFilter, - nodesUptimeFilter, - sortOrder, - sortValue, - }); - const fetchNodes = useCallback( (isBackground) => { if (!isBackground) { dispatch(setDataWasNotLoaded()); } - const params = requestParams || {}; - // For not DB entities we always use /compute endpoint instead of /nodes // since /nodes can return data only for tenants if (path && (!useNodesEndpoint || !isDatabaseEntityType(type))) { - dispatch(getComputeNodes({path, ...params})); + dispatch(getComputeNodes({path})); } else { - dispatch(getNodes({tenant: path, ...params})); + dispatch(getNodes({tenant: path})); } }, - [dispatch, path, type, useNodesEndpoint, requestParams], + [dispatch, path, type, useNodesEndpoint], ); useAutofetcher(fetchNodes, [fetchNodes], isClusterNodes ? true : autorefresh); diff --git a/src/containers/Nodes/VirtualNodes.tsx b/src/containers/Nodes/VirtualNodes.tsx new file mode 100644 index 000000000..2fc559b6c --- /dev/null +++ b/src/containers/Nodes/VirtualNodes.tsx @@ -0,0 +1,146 @@ +import {useCallback, useMemo, useState} from 'react'; +import cn from 'bem-cn-lite'; + +import type {AdditionalNodesProps} from '../../types/additionalProps'; +import type {ProblemFilterValue} from '../../store/reducers/settings/types'; +import type {NodesPreparedEntity} from '../../store/reducers/nodes/types'; +import {ProblemFilterValues} from '../../store/reducers/settings/settings'; +import { + NodesSortValue, + NodesUptimeFilterValues, + getProblemParamValue, + getUptimeParamValue, + isSortableNodesProperty, + isUnavailableNode, +} from '../../utils/nodes'; + +import {Search} from '../../components/Search'; +import {ProblemFilter} from '../../components/ProblemFilter'; +import {UptimeFilter} from '../../components/UptimeFIlter'; +import {EntitiesCount} from '../../components/EntitiesCount'; +import {AccessDenied} from '../../components/Errors/403'; +import {ResponseError} from '../../components/Errors/ResponseError'; +import {Illustration} from '../../components/Illustration'; +import { + type FetchData, + type RenderControls, + type RenderErrorMessage, + VirtualTable, + GetRowClassName, +} from '../../components/VirtualTable'; + +import {getNodesColumns} from './getNodesColumns'; +import {getNodes} from './getNodes'; +import i18n from './i18n'; + +import './Nodes.scss'; + +const b = cn('ydb-nodes'); + +interface NodesProps { + parentContainer?: Element | null; + additionalNodesProps?: AdditionalNodesProps; +} + +export const VirtualNodes = ({parentContainer, additionalNodesProps}: NodesProps) => { + const [searchValue, setSearchValue] = useState(''); + const [problemFilter, setProblemFilter] = useState(ProblemFilterValues.ALL); + const [uptimeFilter, setUptimeFilter] = useState( + NodesUptimeFilterValues.All, + ); + + const filters = useMemo(() => { + return [searchValue, problemFilter, uptimeFilter]; + }, [searchValue, problemFilter, uptimeFilter]); + + const fetchData = useCallback>( + async (limit, offset, {sortOrder, columnId} = {}) => { + return await getNodes({ + limit, + offset, + filter: searchValue, + problems_only: getProblemParamValue(problemFilter), + uptime: getUptimeParamValue(uptimeFilter), + sortOrder, + sortValue: columnId as NodesSortValue, + }); + }, + [problemFilter, searchValue, uptimeFilter], + ); + + const getRowClassName: GetRowClassName = (row) => { + return b('node', {unavailable: isUnavailableNode(row)}); + }; + + const handleSearchQueryChange = (value: string) => { + setSearchValue(value); + }; + const handleProblemFilterChange = (value: string) => { + setProblemFilter(value as ProblemFilterValue); + }; + const handleUptimeFilterChange = (value: string) => { + setUptimeFilter(value as NodesUptimeFilterValues); + }; + + const renderControls: RenderControls = ({totalEntities, foundEntities, inited}) => { + return ( + <> + + + + + + ); + }; + + const renderEmptyDataMessage = () => { + if ( + problemFilter !== ProblemFilterValues.ALL || + uptimeFilter !== NodesUptimeFilterValues.All + ) { + return ; + } + + return i18n('empty.default'); + }; + + const renderErrorMessage: RenderErrorMessage = (error) => { + if (error && error.status === 403) { + return ; + } + + return ; + }; + + const rawColumns = getNodesColumns({ + getNodeRef: additionalNodesProps?.getNodeRef, + }); + + const columns = rawColumns.map((column) => { + return {...column, sortable: isSortableNodesProperty(column.name)}; + }); + + return ( + + ); +}; diff --git a/src/containers/Nodes/getNodes.ts b/src/containers/Nodes/getNodes.ts new file mode 100644 index 000000000..e3b0e4101 --- /dev/null +++ b/src/containers/Nodes/getNodes.ts @@ -0,0 +1,26 @@ +import type {NodesApiRequestParams} from '../../store/reducers/nodes/types'; +import {prepareNodesData} from '../../store/reducers/nodes/utils'; + +const getConcurrentId = (limit?: number, offset?: number) => { + return `getNodes|offset${offset}|limit${limit}`; +}; + +export const getNodes = async ({ + type = 'any', + storage = false, + limit, + offset, + ...params +}: NodesApiRequestParams) => { + const response = await window.api.getNodes( + {type, storage, limit, offset, ...params}, + {concurrentId: getConcurrentId(limit, offset)}, + ); + const preparedResponse = prepareNodesData(response); + + return { + data: preparedResponse.Nodes || [], + found: preparedResponse.FoundNodes || 0, + total: preparedResponse.TotalNodes || 0, + }; +}; diff --git a/src/containers/Nodes/getNodesColumns.tsx b/src/containers/Nodes/getNodesColumns.tsx index 7431aaca2..01b9bc176 100644 --- a/src/containers/Nodes/getNodesColumns.tsx +++ b/src/containers/Nodes/getNodesColumns.tsx @@ -1,5 +1,6 @@ -import DataTable, {type Column} from '@gravity-ui/react-data-table'; +import DataTable, {type Column as DataTableColumn} from '@gravity-ui/react-data-table'; +import type {Column as VirtualTableColumn} from '../../components/VirtualTable'; import {PoolsGraph} from '../../components/PoolsGraph/PoolsGraph'; import {ProgressViewer} from '../../components/ProgressViewer/ProgressViewer'; import {TabletsStatistic} from '../../components/TabletsStatistic'; @@ -34,46 +35,52 @@ interface GetNodesColumnsProps { getNodeRef?: GetNodeRefFunc; } -const nodeIdColumn: Column = { +type NodesColumn = VirtualTableColumn & DataTableColumn; + +const nodeIdColumn: NodesColumn = { name: NODES_COLUMNS_IDS.NodeId, header: '#', - width: '80px', + width: 80, + render: ({row}) => row.NodeId, align: DataTable.RIGHT, sortable: false, }; -const getHostColumn = ( - getNodeRef?: GetNodeRefFunc, - fixedWidth = false, -): Column => ({ +const getHostColumn = (getNodeRef?: GetNodeRefFunc): NodesColumn => ({ name: NODES_COLUMNS_IDS.Host, render: ({row}) => { return ; }, - width: fixedWidth ? '350px' : undefined, + width: 350, align: DataTable.LEFT, sortable: false, }); -const dataCenterColumn: Column = { +const getHostColumnWithUndefinedWidth = ( + getNodeRef?: GetNodeRefFunc, +): DataTableColumn => { + return {...getHostColumn(getNodeRef), width: undefined}; +}; + +const dataCenterColumn: NodesColumn = { name: NODES_COLUMNS_IDS.DC, header: 'DC', align: DataTable.LEFT, render: ({row}) => (row.DataCenter ? row.DataCenter : '—'), - width: '60px', + width: 60, }; -const rackColumn: Column = { +const rackColumn: NodesColumn = { name: NODES_COLUMNS_IDS.Rack, header: 'Rack', align: DataTable.LEFT, render: ({row}) => (row.Rack ? row.Rack : '—'), - width: '80px', + width: 80, }; -const versionColumn: Column = { +const versionColumn: NodesColumn = { name: NODES_COLUMNS_IDS.Version, - width: '200px', + width: 200, align: DataTable.LEFT, render: ({row}) => { return {row.Version}; @@ -81,16 +88,17 @@ const versionColumn: Column = { sortable: false, }; -const uptimeColumn: Column = { +const uptimeColumn: NodesColumn = { name: NODES_COLUMNS_IDS.Uptime, header: 'Uptime', sortAccessor: ({StartTime}) => StartTime && -StartTime, + render: ({row}) => row.Uptime, align: DataTable.RIGHT, - width: '110px', + width: 110, sortable: false, }; -const memoryColumn: Column = { +const memoryColumn: NodesColumn = { name: NODES_COLUMNS_IDS.Memory, header: 'Memory', sortAccessor: ({MemoryUsed = 0}) => Number(MemoryUsed), @@ -103,21 +111,21 @@ const memoryColumn: Column = { } }, align: DataTable.RIGHT, - width: '120px', + width: 120, }; -const cpuColumn: Column = { +const cpuColumn: NodesColumn = { name: NODES_COLUMNS_IDS.CPU, header: 'CPU', sortAccessor: ({PoolStats = []}) => Math.max(...PoolStats.map(({Usage}) => Number(Usage))), defaultOrder: DataTable.DESCENDING, render: ({row}) => (row.PoolStats ? : '—'), align: DataTable.LEFT, - width: '80px', + width: 80, sortable: false, }; -const loadAverageColumn: Column = { +const loadAverageColumn: NodesColumn = { name: NODES_COLUMNS_IDS.LoadAverage, header: 'Load average', sortAccessor: ({LoadAverage = []}) => @@ -135,13 +143,13 @@ const loadAverageColumn: Column = { '—' ), align: DataTable.LEFT, - width: '140px', + width: 140, sortable: false, }; -const getTabletsColumn = (tabletsPath?: string): Column => ({ +const getTabletsColumn = (tabletsPath?: string): NodesColumn => ({ name: NODES_COLUMNS_IDS.Tablets, - width: '430px', + width: 430, render: ({row}) => { return row.Tablets ? ( => sortable: false, }); -const topNodesLoadAverageColumn: Column = { +const topNodesLoadAverageColumn: NodesColumn = { name: NODES_COLUMNS_IDS.TopNodesLoadAverage, header: 'Load', render: ({row}) => @@ -170,11 +178,11 @@ const topNodesLoadAverageColumn: Column = { '—' ), align: DataTable.LEFT, - width: '80px', + width: 80, sortable: false, }; -const topNodesMemoryColumn: Column = { +const topNodesMemoryColumn: NodesColumn = { name: NODES_COLUMNS_IDS.TopNodesMemory, header: 'Memory', render: ({row}) => @@ -189,17 +197,14 @@ const topNodesMemoryColumn: Column = { '—' ), align: DataTable.LEFT, - width: '140px', + width: 140, sortable: false, }; -export function getNodesColumns({ - tabletsPath, - getNodeRef, -}: GetNodesColumnsProps): Column[] { +export function getNodesColumns({tabletsPath, getNodeRef}: GetNodesColumnsProps): NodesColumn[] { return [ nodeIdColumn, - getHostColumn(getNodeRef, true), + getHostColumn(getNodeRef), dataCenterColumn, rackColumn, versionColumn, @@ -213,23 +218,28 @@ export function getNodesColumns({ export function getTopNodesByLoadColumns( getNodeRef?: GetNodeRefFunc, -): Column[] { - return [topNodesLoadAverageColumn, nodeIdColumn, getHostColumn(getNodeRef), versionColumn]; +): DataTableColumn[] { + return [ + topNodesLoadAverageColumn, + nodeIdColumn, + getHostColumnWithUndefinedWidth(getNodeRef), + versionColumn, + ]; } export function getTopNodesByCpuColumns( getNodeRef?: GetNodeRefFunc, -): Column[] { - return [cpuColumn, nodeIdColumn, getHostColumn(getNodeRef)]; +): DataTableColumn[] { + return [cpuColumn, nodeIdColumn, getHostColumnWithUndefinedWidth(getNodeRef)]; } export function getTopNodesByMemoryColumns({ tabletsPath, getNodeRef, -}: GetNodesColumnsProps): Column[] { +}: GetNodesColumnsProps): NodesColumn[] { return [ nodeIdColumn, - getHostColumn(getNodeRef, true), + getHostColumn(getNodeRef), uptimeColumn, topNodesMemoryColumn, topNodesLoadAverageColumn, diff --git a/src/containers/UserSettings/i18n/en.json b/src/containers/UserSettings/i18n/en.json index e9584f23f..8bc9a3f59 100644 --- a/src/containers/UserSettings/i18n/en.json +++ b/src/containers/UserSettings/i18n/en.json @@ -19,8 +19,8 @@ "settings.useNodesEndpoint.title": "Break the Nodes tab in Diagnostics", "settings.useNodesEndpoint.popover": "Use /viewer/json/nodes endpoint for Nodes Tab in diagnostics. It returns incorrect data on versions before 23-1", - "settings.useBackendParamsForTables.title": "Offload tables filters and sorting to backend", - "settings.useBackendParamsForTables.popover": "Filter and sort Nodes and Storage tables with request params. May increase performance, but could causes additional fetches and longer loading time on older versions", + "settings.useBackendParamsForTables.title": "Use virtual table for cluster Nodes tab", + "settings.useBackendParamsForTables.popover": "Use table with data load on scroll. It will increase performance, but could work unstable", "settings.enableAdditionalQueryModes.title": "Enable additional query modes", "settings.enableAdditionalQueryModes.popover": "Adds 'Data', 'YQL - QueryService' and 'PostgreSQL' modes. May not work on some versions", diff --git a/src/containers/UserSettings/i18n/ru.json b/src/containers/UserSettings/i18n/ru.json index 1e0dbeff0..4d088eca9 100644 --- a/src/containers/UserSettings/i18n/ru.json +++ b/src/containers/UserSettings/i18n/ru.json @@ -19,8 +19,8 @@ "settings.useNodesEndpoint.title": "Сломать вкладку Nodes в диагностике", "settings.useNodesEndpoint.popover": "Использовать эндпоинт /viewer/json/nodes для вкладки Nodes в диагностике. Может возвращать некорректные данные на версиях до 23-1", - "settings.useBackendParamsForTables.title": "Перенести фильтры и сортировку таблиц на бэкенд", - "settings.useBackendParamsForTables.popover": "Добавляет фильтрацию и сортировку таблиц Nodes и Storage с использованием параметров запроса. Может улушить производительность, но на старых версиях может привести к дополнительным запросам и большему времени ожидания загрузки", + "settings.useBackendParamsForTables.title": "Использовать виртуализированную таблицу для вкладки Nodes кластера", + "settings.useBackendParamsForTables.popover": "Использовать таблицу с загрузкой данных по скроллу. Это улучшит производительность, но может работать нестабильно", "settings.enableAdditionalQueryModes.title": "Включить дополнительные режимы выполнения запросов", "settings.enableAdditionalQueryModes.popover": "Добавляет режимы 'Data', 'YQL - QueryService' и 'PostgreSQL'. Может работать некорректно на некоторых версиях", diff --git a/src/utils/hooks/useNodesRequestParams.ts b/src/utils/hooks/useNodesRequestParams.ts index 20c2a3319..86d554e27 100644 --- a/src/utils/hooks/useNodesRequestParams.ts +++ b/src/utils/hooks/useNodesRequestParams.ts @@ -2,10 +2,9 @@ import {useMemo} from 'react'; import type {NodesGeneralRequestParams} from '../../store/reducers/nodes/types'; import type {ProblemFilterValue} from '../../store/reducers/settings/types'; -import {ProblemFilterValues} from '../../store/reducers/settings/settings'; -import {HOUR_IN_SECONDS, USE_BACKEND_PARAMS_FOR_TABLES_KEY} from '../constants'; -import {NodesUptimeFilterValues} from '../nodes'; +import {USE_BACKEND_PARAMS_FOR_TABLES_KEY} from '../constants'; +import {NodesUptimeFilterValues, getProblemParamValue, getUptimeParamValue} from '../nodes'; import {useSetting} from './useSetting'; interface NodesRawRequestParams @@ -27,11 +26,8 @@ export const useNodesRequestParams = ({ // Otherwise no params will be updated, no hooks that depend on requestParams will be triggered return useMemo(() => { if (useBackendParamsForTables) { - const problemsOnly = problemFilter === ProblemFilterValues.PROBLEMS; - const uptime = - nodesUptimeFilter === NodesUptimeFilterValues.SmallUptime - ? HOUR_IN_SECONDS - : undefined; + const problemsOnly = getProblemParamValue(problemFilter); + const uptime = getUptimeParamValue(nodesUptimeFilter); return { filter, diff --git a/src/utils/nodes.ts b/src/utils/nodes.ts index 7ef4f7639..d93e92d13 100644 --- a/src/utils/nodes.ts +++ b/src/utils/nodes.ts @@ -3,8 +3,12 @@ import type {TNodeInfo} from '../types/api/nodesList'; import type {NodesPreparedEntity} from '../store/reducers/nodes/types'; import type {NodesMap} from '../types/store/nodesList'; import type {ValueOf} from '../types/common'; +import type {ProblemFilterValue} from '../store/reducers/settings/types'; +import {ProblemFilterValues} from '../store/reducers/settings/settings'; import {EFlag} from '../types/api/enums'; +import {HOUR_IN_SECONDS} from './constants'; + export enum NodesUptimeFilterValues { 'All' = 'All', 'SmallUptime' = 'SmallUptime', @@ -27,6 +31,14 @@ export const prepareNodesMap = (nodesList?: TNodeInfo[]) => { }, new Map()); }; +export const getProblemParamValue = (problemFilter: ProblemFilterValue | undefined) => { + return problemFilter === ProblemFilterValues.PROBLEMS; +}; + +export const getUptimeParamValue = (nodesUptimeFilter: NodesUptimeFilterValues | undefined) => { + return nodesUptimeFilter === NodesUptimeFilterValues.SmallUptime ? HOUR_IN_SECONDS : undefined; +}; + /** * Values to sort /compute v2 and /nodes responses *