From 393f3b977f58a71f9c84468a3e48a7fbaee5e6e3 Mon Sep 17 00:00:00 2001 From: Nikita Vakula Date: Mon, 25 Dec 2023 19:26:49 +0100 Subject: [PATCH 1/3] Added SizeChecker component SizeChecker is used to determine the size of the item in the collection. It fetches one item from the server and renders it. But its visibility is set to hidden. Signed-off-by: Nikita Vakula --- src/SizeChecker.tsx | 80 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 src/SizeChecker.tsx diff --git a/src/SizeChecker.tsx b/src/SizeChecker.tsx new file mode 100644 index 0000000..1e75729 --- /dev/null +++ b/src/SizeChecker.tsx @@ -0,0 +1,80 @@ +/** + * VirtualTable component. + * + * @author Nikita Vakula + */ + +import React, { + useEffect, useRef, ReactNode, useState, forwardRef, Ref, useImperativeHandle, +} from 'react'; + +import './base.css'; + +import { DataSource, } from './helpers/types'; + +/** + * Represent the rectangular. + */ +interface Rect { + x: number; + y: number; + height: number; + width: number; +} + +interface Args { + renderer: (data: Type, classes: string) => ReactNode; + fetcher: DataSource; + on_ready: () => void; +} + +interface ISizeChecker { + height: () => number; +} + +/** + * @description SizeChecker component. + * + * This component is used for checking the dimensions that are required to display the + * item of type Type. + * + * @component + */ +const SizeChecker = ({ renderer, fetcher, on_ready }: Args, ref: Ref): JSX.Element => { + const invisible = useRef(null); + const [data, setData] = useState>([]); + + useImperativeHandle(ref, () => ({ + height: () => { + if (invisible && invisible.current) { + return invisible.current.clientHeight; + } + return 0; + } + }), [invisible]); + + useEffect(() => { + fetcher.fetch(0, 1).then((result) => { + if (result.items.length) { + setData(result.items); + on_ready(); + } + }); + }, [fetcher]); + + if (data.length) { + return ( +
+ {renderer(data[0], '')} +
+ ); + } + + return null; +} + +export default forwardRef(SizeChecker); From 0b7d411464cd2214d3a96297df6c6953985f46bf Mon Sep 17 00:00:00 2001 From: Nikita Vakula Date: Wed, 3 Jan 2024 18:53:48 +0100 Subject: [PATCH 2/3] Reworked the state of the app Signed-off-by: Nikita Vakula --- src/VirtualTable.tsx | 383 +++++++++---------------- src/__tests__/helpers.ts | 81 +----- src/helpers/LazyPaginatedCollection.ts | 110 ------- src/helpers/collections.ts | 105 ++++--- src/helpers/reducer.ts | 147 ++++++++++ src/helpers/state.ts | 25 ++ src/helpers/types.ts | 36 +++ 7 files changed, 427 insertions(+), 460 deletions(-) delete mode 100644 src/helpers/LazyPaginatedCollection.ts create mode 100644 src/helpers/reducer.ts create mode 100644 src/helpers/state.ts diff --git a/src/VirtualTable.tsx b/src/VirtualTable.tsx index 1e23644..b8899bc 100644 --- a/src/VirtualTable.tsx +++ b/src/VirtualTable.tsx @@ -5,44 +5,24 @@ */ import React, { - useReducer, useEffect, useState, useRef, ReactNode, + useReducer, useEffect, useRef, ReactNode, } from 'react'; -import PropTypes from 'prop-types'; - -import { slideItems, Page } from './helpers/collections'; - -import './base.css'; - -import { LazyPaginatedCollection } from './helpers/LazyPaginatedCollection'; -import { Style, DataSource } from './helpers/types'; import { Container, Row, Col } from 'react-bootstrap'; +import PropTypes from 'prop-types'; -/** - * Represent the rectangular. - */ -interface Rect { - x: number; - y: number; - height: number; - width: number; -} +import { fetch_items, get_items } from './helpers/collections'; -interface State { - ready: boolean; - scrollTop: number; - itemHeight: number; - itemCount: number; - page: Page; - offset: number; - selected: number; - hovered: number; - rect: Rect; -} +import { + reducer, + Selection, + SCROLL, SELECT, INITIALIZED, + INITIALIZE, LOADED, LOAD, RESET +} from './helpers/reducer'; +import { get_initial_state, get_total_count } from './helpers/state'; +import { DataSource, Status, Style, Data, Pages } from './helpers/types'; +import SizeChecker from './SizeChecker'; -interface Action { - type: 'scroll' | 'render' | 'loaded' | 'click' | 'hover'; - data: Partial> -} +import './base.css'; interface Args { height: number; @@ -51,52 +31,10 @@ interface Args { style?: Style; } -function get_initial_state(): State { - return { - ready: false, - scrollTop: 0, - itemHeight: 0, - itemCount: 0, - page: { - items: [], - offset: 0, - }, - offset: 0, - selected: -1, - hovered: -1, - rect: { - x: 0, - y: 0, - height: 0, - width: 0, - }, - } -} function calculatePageCount(pageHeight: number, itemHeight: number) { return 2 * Math.floor(pageHeight / itemHeight); } -/** - * Reducer function for managing state changes. - * - * @template {Type} - * @param {State} state - The current state of the application. - * @param {Action} action - The action object that describes the state change. - * @returns {State} - The new state after applying the action. - */ -function reducer(state: State, action: Action): State { - switch (action.type) { - case 'scroll': - case 'render': - case 'loaded': - case 'click': - case 'hover': - return { ...state, ...action.data }; - default: - return state; - } -}; - /** * @description VirtualTable component. * @@ -108,10 +46,16 @@ export default function VirtualTable({ height, renderer, fetcher, style }: const ref = useRef(null); const invisible = useRef(null); const scrolldiv = useRef(null); - const [collection, setCollection] = useState>(() => new LazyPaginatedCollection(1, fetcher)); - const [state, dispatch] = useReducer(reducer, get_initial_state()); + const [state, dispatch] = useReducer(reducer, {}, get_initial_state); - const generate = (offset: number, d: Array) => { + const get_height = () => { + if (invisible && invisible.current) { + return invisible.current.height(); + } + return 0; + } + + const generate = (offset: number, d: Array) => { const ret = []; for (let i = 0; i < d.length; i += 1) { @@ -124,120 +68,70 @@ export default function VirtualTable({ height, renderer, fetcher, style }: } else if (i + offset === state.hovered && style) { className = `${className} ${style.hover}`; } - ret.push(
{renderer(d[i], className)}
); + ret.push(
{renderer(d[i], className)}
); } return ret; }; - - // A callback to update the table view in case of resize event. - const handler = () => { - let itemHeight = state.itemHeight; - let rect = state.rect; - if (invisible && invisible.current) { - itemHeight = invisible.current.clientHeight; - } - if (ref && ref.current) { - rect = ref.current.getBoundingClientRect(); - } - - // Update the size of the widget and the size of the items - dispatch({ - type: 'render', - data: { - rect, - itemHeight, - scrollTop: 0, - selected: -1, - hovered: -1, - page: { - items: [], - offset: 0, - } - }, - }); - - // If the item's height is already known, then update the lazy collection - // and re-fetch the items. - if (itemHeight) { - const new_collection = new LazyPaginatedCollection(calculatePageCount(rect.height, itemHeight), fetcher); - new_collection.slice(0, new_collection.pageSize()).then((result) => { - dispatch({ - type: 'loaded', - data: { - page: result, - itemCount: new_collection.count(), - }, - }); - setCollection(new_collection); - }); - } - }; - // Effect that updates the lazy collection in case fetcher gets updated useEffect(() => { - setCollection(new LazyPaginatedCollection(collection.pageSize() ? collection.pageSize() : 1, fetcher)); - }, [fetcher]); - - // Effect to fetch the first item (to draw a fake item to get the true size if the item) - // and the total number of items. - useEffect(() => { - collection.slice(0, collection.pageSize()).then((result) => { - dispatch({ - type: 'loaded', - data: { - ready: true, - page: result, - itemCount: collection.count(), - }, - }); + dispatch({ + type: RESET, }); - - window.addEventListener('resize', handler); - return function cleanup() { - window.removeEventListener('resize', handler, true); - } - }, []); + }, [fetcher]); // Effect to run on all state updates. useEffect(() => { - if (state.ready) { - if (state.itemHeight) { - const offset = Math.floor(state.scrollTop / state.itemHeight); - const c = calculatePageCount(height, state.itemHeight); - if (c === collection.pageSize() && state.offset !== offset) { - // Update the offset first and then start fetching the necessary items. - // This ensures a non-interruptive user experience, where all the - // required data is already available. + const itemHeight = get_height(); + switch (state.status) { + case Status.Loading: + if (itemHeight) { dispatch({ - type: 'loaded', - data: { - offset, - }, + type: INITIALIZED, }); - collection.slice(offset, collection.pageSize()).then((result) => { - if (state.offset !== result.offset) { + } + break; + case Status.Loaded: + if (itemHeight) { + const offset = Math.floor(state.scrollTop / itemHeight); + const c = calculatePageCount(height, itemHeight); + let data_pages: Pages = state.data ? state.data.pages : {}; + const page_index = Math.floor(offset / c); + for (let i = -1; i < 2; ++i) { + if (page_index + i > -1 && data_pages[page_index + i] === undefined) { dispatch({ - type: 'loaded', - data: { - page: result, - itemCount: collection.count(), + type: LOAD, + payload: { + pages: [page_index + i], }, }); + fetch_items(page_index + i, 1, c, fetcher).then((result) => { + dispatch({ + type: LOADED, + payload: { + data: result, + }, + }); + }); } - }); + } } - } else { - handler(); - } + break; + case Status.Unavailable: + case Status.None: + dispatch({ + type: INITIALIZE, + }); + break; } }, [state]); // Effect to run on each render to make sure that the scrolltop of // the item container is up-to-date. useEffect(() => { - if (ref.current) { - ref.current.scrollTop = state.scrollTop % state.itemHeight; + const itemHeight = get_height(); + if (ref.current && itemHeight) { + ref.current.scrollTop = state.scrollTop % itemHeight; } }); @@ -247,80 +141,89 @@ export default function VirtualTable({ height, renderer, fetcher, style }: } return ( - - - -
- {state.ready && state.itemHeight === 0 && -
- {renderer(state.page.items[0], '')} -
} - {state.itemHeight !== 0 && generate(state.offset, slideItems(state.offset, state.page))} -
-
{ - const index = Math.floor((e.clientY + state.scrollTop - ref.current.getBoundingClientRect().top) / state.itemHeight); - const childElement = ref.current.children[index - state.offset]; - if (childElement) { - const event = new Event('mouseover', { bubbles: true, cancelable: false }); - childElement.children[0].dispatchEvent(event); - dispatch({ - type: 'hover', - data: { - hovered: index, - }, - }); - } - }} - onClick={(e) => { - const index = Math.floor((e.clientY + state.scrollTop - ref.current.getBoundingClientRect().top) / state.itemHeight); - const childElement = ref.current.children[index - state.offset]; - if (childElement) { - const clickEvent = new Event('click', { bubbles: true, cancelable: false }); - childElement.children[0].dispatchEvent(clickEvent); + <> + dispatch({ + type: INITIALIZED, + })} fetcher={fetcher} renderer={renderer} /> + + + +
+ {get_height() !== 0 && state.data && generate(Math.floor(state.scrollTop / get_height()), get_items(Math.floor(state.scrollTop / get_height()), state.data))} +
+
{ + const position = Math.floor((e.clientY + ref.current.scrollTop - scrolldiv.current.getBoundingClientRect().top) / get_height()); + const offset = Math.floor(state.scrollTop / get_height()); + const index = position + offset; + const childElement = ref.current.children[index - Math.floor(state.scrollTop / get_height())]; + if (childElement) { + const event = new Event('mouseover', { bubbles: true, cancelable: false }); + childElement.children[0].dispatchEvent(event); + dispatch({ + type: SELECT, + payload: { + selection: Selection.HOVER, + index, + }, + }); + } + }} + onClick={(e) => { + const position = Math.floor((e.clientY + ref.current.scrollTop - scrolldiv.current.getBoundingClientRect().top) / get_height()); + const offset = Math.floor(state.scrollTop / get_height()); + const index = position + offset; + const childElement = ref.current.children[index - Math.floor(state.scrollTop / get_height())]; + if (childElement) { + const clickEvent = new Event('click', { bubbles: true, cancelable: false }); + childElement.children[0].dispatchEvent(clickEvent); + dispatch({ + type: SELECT, + payload: { + selection: Selection.CLICK, + index, + }, + }); + } + }} + onScroll={(e) => { dispatch({ - type: 'click', - data: { - selected: index, + type: SCROLL, + payload: { + scrollTop: (e.target as HTMLElement).scrollTop, }, }); - } - }} - onScroll={(e) => { - dispatch({ - type: 'scroll', - data: { - scrollTop: (e.target as HTMLElement).scrollTop, - }, - }); - }} - > -
-
- - - + }} + > +
+
+ + + + ); } diff --git a/src/__tests__/helpers.ts b/src/__tests__/helpers.ts index 9560441..0787153 100644 --- a/src/__tests__/helpers.ts +++ b/src/__tests__/helpers.ts @@ -1,6 +1,5 @@ -import { LazyPaginatedCollection } from '../helpers/LazyPaginatedCollection'; -import { slideItems } from '../helpers/collections'; -import { DataSource, Result } from '../helpers/types'; +import { get_items } from '../helpers/collections'; +import { DataSource, Data, Result, Status } from '../helpers/types'; describe('Helpers', () => { const COLLECTION_COUNT = 1234; @@ -30,69 +29,19 @@ describe('Helpers', () => { afterEach(() => { }); - it('LazyPaginatedCollection', (done) => { - const collection = new LazyPaginatedCollection( - COLLECTION_PAGE_SIZE, - new TestSource() - ); - - const all = []; - all.push(collection.slice(1021367, 1) - .then((value) => { - expect(value.items.length).toBe(0); - })); - - all.push(collection.slice(-2, 1) - .then((value) => { - expect(value.items.length).toBe(0); - })); - - all.push(collection.slice(254, 1) - .then((value) => { - expect(value.items[0]).toBe(254); - expect(value.offset).toBe(254); - })); - - all.push(collection.slice(234, 10) - .then((result) => { - expect(result.items).toEqual([...Array(10).keys()].map((value) => value + 234)); - expect(result.offset).toEqual(234); - })); - - all.push(collection.slice(0, 3) - .then((result) => { - expect(result.items).toEqual([...Array(3).keys()]); - })); - - all.push(collection.slice(3, 27) - .then((result) => { - expect(result.items).toEqual([...Array(27).keys()].map((value) => value + 3)); - expect(collection.count()).toEqual(COLLECTION_COUNT); - })); - - all.push(collection.slice(-1, 27) - .then((result) => { - expect(result.items).toEqual([]); - })); - - all.push(collection.slice(-1, -27) - .then((result) => { - expect(result.items).toEqual([]); - })); - - all.push(collection.slice(1231, 35) - .then((result) => { - expect(result.items).toEqual([1231, 1232, 1233]); - })); - - Promise.all(all).then(() => done()); - }); - it('collections', () => { - const items = [0, 1, 2]; - - expect(slideItems(4, { items, offset: 3 })).toEqual([1, 2, undefined]); - expect(slideItems(2, { items, offset: 3 })).toEqual([undefined, 0, 1]); - expect(slideItems(7, { items, offset: 3 })).toEqual([undefined, undefined, undefined]); + const data: Data = { + pageSize: 3, + totalCount: 10, + pages: { + 0: Status.Loading, + 1: [3, 4, 5], + 2: [6, 7, 8], + } + }; + + expect(get_items(4, data)).toEqual([4, 5, 6]); + expect(get_items(2, data)).toEqual([undefined, 3, 4]); + expect(get_items(8, data)).toEqual([8, undefined]); }); }); diff --git a/src/helpers/LazyPaginatedCollection.ts b/src/helpers/LazyPaginatedCollection.ts deleted file mode 100644 index f13dd6f..0000000 --- a/src/helpers/LazyPaginatedCollection.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { Page } from './collections'; -import { Result, DataSource } from './types' - -/** - * Calss representing a lazy paginated collection of data. - */ -export class LazyPaginatedCollection { - // Stores a map of Promises to page requests. A key - // corresponds to the index of the item within a collection. - // A value corresponds to a Promise of the fetch request of the items - // from an offset tht corresponds to the key of the map. The number of items - // requested equals #pageSize property. - #pageOffsets: { [id: number]: Promise> }; - - // Total number of items in a collection. -1 if collection was not loaded. - #totalCount: number; - - // Corresponds to the (at most) number of items fetched at each reauest. - #pageSize: number; - - #retrieve: DataSource; - - /** - * Constructs a new collection. - * - * @param {number} pageSize Page size - * @param {DataSource} retrieve A data fetcher - */ - constructor(pageSize: number, retrieve: DataSource) { - this.#pageOffsets = {}; - this.#totalCount = -1; - this.#pageSize = pageSize; - this.#retrieve = retrieve; - } - - /** - * Returns the size of the page. - * - * @returns {number} - */ - pageSize(): number { - return this.#pageSize; - } - - /** - * Returns a number of items in a collection. - * - * @returns {number} A number of items in a collection - * (0 if a collection is empty or has not been loaded yet) - */ - count(): number { - if (this.#totalCount <= 0) { - return 0; - } - - return this.#totalCount; - } - - /** - * Returns an offset of the first index in the page to fetch. - * - * @private - * @returns {number} - */ - #pageIndexFor = (index: number) => (index - (index % this.#pageSize)); - - /** - * Returns an slice of a collection. - * - * @async - * @param {number} index An index of the first item to fetch. - * @param {number} count Max number of items to fetch. - * @returns {Promise>} - */ - async slice(index: number, count: number): Promise> { - // Invalid offset or count => an empty list - if (index < 0 || count <= 0) { - return Promise.resolve({ - items: [], - offset: index, - }); - } - - const offset = this.#pageIndexFor(index); - // Stores all promises - const all = []; - for (let i = offset; i < index + count; i += this.#pageSize) { - if (this.#pageOffsets[i] === undefined) { - this.#pageOffsets[i] = this.#retrieve.fetch(i, this.#pageSize); - } - - all.push(this.#pageOffsets[i]); - } - - // Filter out all the errors that might erase while fetching a particular page - return Promise.all(all.map((promise) => promise.catch((err) => err))) - .then((results) => results.filter((result) => !(result instanceof Error))) - .then((results) => { - const ret: Array = []; - for (let i = 0; i < results.length; i += 1) { - this.#totalCount = results[i].totalCount; - ret.splice(ret.length, 0, ...results[i].items); - } - return Promise.resolve({ - items: ret.slice(index % this.#pageSize, (index % this.#pageSize) + count), - offset: index, - }); - }); - } -} diff --git a/src/helpers/collections.ts b/src/helpers/collections.ts index 4183e56..491b609 100644 --- a/src/helpers/collections.ts +++ b/src/helpers/collections.ts @@ -1,58 +1,75 @@ +import { Data, DataSource, Result } from './types'; +import { get_page_status, Status } from './types'; + /** - * Represent a page. + * Returns an array of items with a specified page size, + * beginning at an offset from the collection. * - * @typedef {Object} Page * @template {Type} + * @param {number} offset An offset + * @param {Data} data Items + * @returns {Array} */ -export interface Page { - /** - * Page items - */ - items: Array; - /** - * An offset (the index of the first item in the slice) - */ - offset: number; +export function get_items(offset: number, data: Data): Array { + const page_offset = Math.floor(offset / data.pageSize); + const ret: Array = []; + for (let i of [page_offset, page_offset + 1]) { + switch (get_page_status(data, i)) { + case Status.None: + case Status.Loading: + ret.push(...Array.from({ length: Math.min(data.pageSize, data.totalCount - i * data.pageSize) }, () => undefined)); + break; + case Status.Loaded: + ret.push(...(data.pages[i] as Array)); + break; + case Status.Unavailable: + default: + break; + } + } + + const slice_begin = offset % data.pageSize; + return ret.slice(slice_begin, slice_begin + data.pageSize); } /** - * Constructs a new slice of data. - * - * Takes a slice of the collection and based on the comparison of - * slice's offset to the current offset cuts it and prepends/appends - * empty elements to the cut. For example: - * - * For a slice {3, [0, 1, 2]} and currentOffset 4 the result is [1, 2, undefined]. - * - * For {3, [0, 1, 2]} and 2 => [undefined, 0, 1]. + * Fetches items. * - * For {3, [0, 1, 2]} and 7 => [undefined, undefined, undefined]. - * - * @template {Type} - * @param {number} currentOffset A new offset - * @param {Page} page A page - * @returns {Page} + * @async + * @param {number} page_index An index of the first page to fetch. + * @param {number} page_count Max number of pages to fetch. + * @param {number} page_size The size of the page. + * @returns {Promise>} */ -export function slideItems(currentOffset: number, { items, offset }: Page) { - const count = items.length; - // Nothing to do - if (offset === currentOffset) { - return items; +export async function fetch_items(page_index: number, page_count: number, page_size: number, fetcher: DataSource): Promise> { + // Invalid offset or count => an empty list + if (page_index < 0 || page_count <= 0 || page_size <= 0) { + return Promise.resolve({ + totalCount: 0, + pageSize: page_size, + pages: {}, + }); } - if (Math.abs(offset - currentOffset) >= count) { - return [...Array(count).keys()].map(() => undefined); - } - - const diff = Math.abs(currentOffset - offset); - - if (offset < currentOffset) { - const tmp = items.slice(diff, count); - tmp.splice(tmp.length, 0, ...Array(diff).fill(undefined)); - return tmp; + // Stores all promises + const promises: Array>> = []; + for (let i = page_index * page_size; i < (page_index + page_count) * page_size; i += page_size) { + promises.push(fetcher.fetch(i, page_size)); } - const tmp = Array(diff).fill(undefined); - tmp.splice(tmp.length, 0, ...items.slice(0, count - diff)); - return tmp; + // Filter out all the errors that might erase while fetching a particular page + return Promise.all>>(promises.map((promise) => promise.catch((err) => err))) + .then((results) => results.filter((result) => !(result instanceof Error))) + .then((results) => { + const ret: Data = { + totalCount: 0, + pageSize: page_size, + pages: {} + }; + for (let result of results) { + ret.totalCount = result.totalCount; + ret.pages[result.from / page_size] = result.items; + } + return Promise.resolve(ret); + }); } diff --git a/src/helpers/reducer.ts b/src/helpers/reducer.ts new file mode 100644 index 0000000..4dba6a5 --- /dev/null +++ b/src/helpers/reducer.ts @@ -0,0 +1,147 @@ +import { Data, Status } from './types'; +import { State, get_initial_state } from './state'; + +export const SCROLL = 'scroll' +export const SELECT = 'SELECT'; +export const LOAD = 'LOAD'; +export const LOADED = 'LOADED'; +export const RESET = 'RESET'; +export const INITIALIZE = 'INITIALIZE'; +export const INITIALIZED = 'INITIALIZED'; + +export enum Selection { + CLICK, + HOVER, +} + +interface ScrollAction { + type: typeof SCROLL; + payload: { + scrollTop: number; + } +} + +interface SelectAction { + type: typeof SELECT; + payload: { + selection: Selection; + index: number; + } +} + +interface LoadedAction { + type: typeof LOADED; + payload: { + data: Data; + } +} + +interface LoadAction { + type: typeof LOAD; + payload: { + pages: Array; + } +} + +interface InitializeAction { + type: typeof INITIALIZE; +} + +interface ResetAction { + type: typeof RESET; +} + +interface InitializedAction { + type: typeof INITIALIZED; +} + +type Action = ScrollAction | SelectAction | LoadedAction | ResetAction | LoadAction | InitializeAction | InitializedAction; +/** + * Reducer function for managing state changes. + * + * @template {Type} + * @param {State} state - The current state of the application. + * @param {Action} action - The action object that describes the state change. + * @returns {State} - The new state after applying the action. + */ +export function reducer(state: State, action: Action): State { + switch (action.type) { + case RESET: + return { + ...get_initial_state(), + }; + case INITIALIZE: + return { + ...state, + status: Status.Loading, + }; + case INITIALIZED: + if (state.status === Status.Loading) { + return { + ...state, + status: Status.Loaded, + }; + } + return state; + case SCROLL: + return { + ...state, + ...action.payload, + }; + case LOAD: + if (state.status !== Status.Loaded) { + return state; + } + const request: {[key: number]: typeof Status.Loading} = {}; + for (let page of action.payload.pages) { + request[page] = Status.Loading; + } + return { + ...state, + status: Status.Loaded, + data: { + ...state?.data, + pages: { + ...state.data?.pages, + ...request, + } + } + }; + case LOADED: + if (state.data?.pageSize !== action.payload.data.pageSize || state.data?.totalCount !== action.payload.data.totalCount) { + return { + ...get_initial_state(), + status: Status.Loaded, + data: action.payload.data, + }; + } + return { + ...state, + status: Status.Loaded, + data: { + ...state?.data, + pages: { + ...state.data?.pages, + ...action.payload.data.pages, + } + } + }; + case SELECT: + switch (action.payload.selection) { + case Selection.CLICK: + return { + ...state, + selected: action.payload.index, + }; + case Selection.HOVER: + default: + return { + ...state, + hovered: action.payload.index, + }; + } + default: + break; + } + return state; +}; diff --git a/src/helpers/state.ts b/src/helpers/state.ts new file mode 100644 index 0000000..5a4b9d4 --- /dev/null +++ b/src/helpers/state.ts @@ -0,0 +1,25 @@ +import { Data, Status } from './types'; + +export interface State { + status: Status; + scrollTop: number; + data?: Data; + selected: number; + hovered: number; +} + +export function get_total_count(state: State): number { + if (state.data) { + return state.data.totalCount; + } + return 0; +} + +export function get_initial_state(): State { + return { + status: Status.None, + scrollTop: 0, + selected: -1, + hovered: -1, + } +} diff --git a/src/helpers/types.ts b/src/helpers/types.ts index 8c043bf..211fd54 100644 --- a/src/helpers/types.ts +++ b/src/helpers/types.ts @@ -1,3 +1,5 @@ +import { instanceOf } from "prop-types"; + /** * Represents the result of the fetch. */ @@ -16,6 +18,40 @@ export interface Result { totalCount: number; } +export enum Status { + None, + Loading, + Loaded, + Unavailable, +} + +export interface Pages { + [page: number]: Array | Status.Loading; +} + +export interface Data { + totalCount: number; + pageSize: number; + pages: Pages; +} + +export function get_page_status(data: Data, index: number): Status { + const { totalCount, pageSize, pages } = data; + if (totalCount <= 0 || pageSize <= 0 || index < 0 || index * pageSize >= totalCount) { + return Status.Unavailable; + } + + if (!pages.hasOwnProperty(index)) { + return Status.None; + } + + if (pages[index] === Status.Loading) { + return Status.Loading; + } + + return Status.Loaded; +} + /** * Represents the style of the item in the table. */ From 4c33298c4ff07735f189bab393e210072f31e802 Mon Sep 17 00:00:00 2001 From: Nikita Vakula Date: Thu, 4 Jan 2024 12:26:39 +0100 Subject: [PATCH 3/3] Bumped version to 1.1.10 Signed-off-by: Nikita Vakula --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c827a6a..2f0252c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@krjakbrjak/virtualtable", - "version": "1.1.9", + "version": "1.1.10", "description": "", "repository": { "type": "git",