From 47d716292148bbf37c93157a0710d18a7fcc04ea Mon Sep 17 00:00:00 2001 From: bonkalol Date: Wed, 7 Feb 2024 10:18:47 +0300 Subject: [PATCH] Update branch, chagned b-virtual-scroll -> b-virtual-scroll-new --- components-lock.json | 4 +- .../test/api/component-object/index.ts | 14 +- .../test/api/component-object/styles.ts | 2 +- .../test/api/helpers/index.ts | 14 +- .../test/api/helpers/interface.ts | 6 +- .../test/unit/functional/emitter/payload.ts | 6 +- .../test/unit/functional/props/props.ts | 12 +- .../test/unit/functional/rendering/default.ts | 12 +- .../functional/rendering/items-factory.ts | 8 +- .../unit/functional/rendering/items-mode.ts | 8 +- .../test/unit/functional/state/default.ts | 14 +- .../test/unit/functional/state/emitter.ts | 8 +- .../initialization/initialization.ts | 12 +- .../test/unit/lifecycle/slots/slots.ts | 8 +- .../test/unit/scenario/last-render.ts | 8 +- .../test/unit/scenario/manual-rendering.ts | 12 +- .../test/unit/scenario/reload.ts | 6 +- .../test/unit/scenario/retry.ts | 10 +- .../base/b-virtual-scroll/CHANGELOG.md | 6 - .../base/b-virtual-scroll/README.md | 1252 ++--------------- .../base/b-virtual-scroll/b-virtual-scroll.ss | 2 +- .../base/b-virtual-scroll/b-virtual-scroll.ts | 640 +++++---- .../b-virtual-scroll_theme_demo.styl | 30 + src/components/base/b-virtual-scroll/const.ts | 184 --- .../base/b-virtual-scroll/handlers.ts | 234 --- .../base/b-virtual-scroll/interface.ts | 222 +++ .../base/b-virtual-scroll/interface/common.ts | 81 -- .../b-virtual-scroll/interface/component.ts | 336 ----- .../base/b-virtual-scroll/interface/events.ts | 76 - .../base/b-virtual-scroll/interface/index.ts | 12 - .../b-virtual-scroll/interface/requests.ts | 27 - .../b-virtual-scroll/modules/chunk-render.ts | 403 ++++++ .../b-virtual-scroll/modules/chunk-request.ts | 418 ++++++ .../modules/component-render.ts | 218 +++ .../b-virtual-scroll/modules/emitter/index.ts | 57 - .../modules/emitter/interface.ts | 55 - .../modules/factory/engines/vdom.ts | 24 - .../b-virtual-scroll/modules/factory/index.ts | 134 -- .../base/b-virtual-scroll/modules/helpers.ts | 106 ++ .../b-virtual-scroll/modules/helpers/index.ts | 26 - .../modules/observer/const.ts | 12 - .../observer/engines/intersection-observer.ts | 45 - .../modules/observer/index.ts | 60 - .../modules/observer/interface.ts | 27 - .../b-virtual-scroll/modules/slots/index.ts | 164 --- .../modules/slots/interface.ts | 17 - .../b-virtual-scroll/modules/state/helpers.ts | 45 - .../b-virtual-scroll/modules/state/index.ts | 228 --- src/components/base/b-virtual-scroll/props.ts | 333 ----- .../base/b-virtual-scroll/test/index.js | 32 + .../test/runners/events/chunk-loaded.js | 230 +++ .../test/runners/events/chunk-loading.js | 106 ++ .../test/runners/events/data-change.js | 216 +++ .../test/runners/events/db-change.js | 184 +++ .../test/runners/functional/items.js | 102 ++ .../test/runners/functional/render-next.js | 102 ++ .../test/runners/functional/state.js | 263 ++++ .../test/runners/render/render.js | 205 +++ .../test/runners/slots/empty.js | 123 ++ .../test/runners/slots/render-next.js | 498 +++++++ .../base/b-virtual-scroll/test/unit/render.ts | 139 ++ .../pages/p-v4-components-demo/index.js | 1 + tests/helpers/component-object/builder.ts | 1 + yarn.lock | 6 +- 64 files changed, 4168 insertions(+), 3678 deletions(-) rename src/components/base/{b-virtual-scroll => b-virtual-scroll-new}/test/api/component-object/index.ts (94%) rename src/components/base/{b-virtual-scroll => b-virtual-scroll-new}/test/api/component-object/styles.ts (95%) rename src/components/base/{b-virtual-scroll => b-virtual-scroll-new}/test/api/helpers/index.ts (96%) rename src/components/base/{b-virtual-scroll => b-virtual-scroll-new}/test/api/helpers/interface.ts (95%) rename src/components/base/{b-virtual-scroll => b-virtual-scroll-new}/test/unit/functional/emitter/payload.ts (97%) rename src/components/base/{b-virtual-scroll => b-virtual-scroll-new}/test/unit/functional/props/props.ts (90%) rename src/components/base/{b-virtual-scroll => b-virtual-scroll-new}/test/unit/functional/rendering/default.ts (93%) rename src/components/base/{b-virtual-scroll => b-virtual-scroll-new}/test/unit/functional/rendering/items-factory.ts (97%) rename src/components/base/{b-virtual-scroll => b-virtual-scroll-new}/test/unit/functional/rendering/items-mode.ts (93%) rename src/components/base/{b-virtual-scroll => b-virtual-scroll-new}/test/unit/functional/state/default.ts (95%) rename src/components/base/{b-virtual-scroll => b-virtual-scroll-new}/test/unit/functional/state/emitter.ts (98%) rename src/components/base/{b-virtual-scroll => b-virtual-scroll-new}/test/unit/lifecycle/initialization/initialization.ts (96%) rename src/components/base/{b-virtual-scroll => b-virtual-scroll-new}/test/unit/lifecycle/slots/slots.ts (98%) rename src/components/base/{b-virtual-scroll => b-virtual-scroll-new}/test/unit/scenario/last-render.ts (96%) rename src/components/base/{b-virtual-scroll => b-virtual-scroll-new}/test/unit/scenario/manual-rendering.ts (89%) rename src/components/base/{b-virtual-scroll => b-virtual-scroll-new}/test/unit/scenario/reload.ts (96%) rename src/components/base/{b-virtual-scroll => b-virtual-scroll-new}/test/unit/scenario/retry.ts (93%) create mode 100644 src/components/base/b-virtual-scroll/b-virtual-scroll_theme_demo.styl delete mode 100644 src/components/base/b-virtual-scroll/const.ts delete mode 100644 src/components/base/b-virtual-scroll/handlers.ts create mode 100644 src/components/base/b-virtual-scroll/interface.ts delete mode 100644 src/components/base/b-virtual-scroll/interface/common.ts delete mode 100644 src/components/base/b-virtual-scroll/interface/component.ts delete mode 100644 src/components/base/b-virtual-scroll/interface/events.ts delete mode 100644 src/components/base/b-virtual-scroll/interface/index.ts delete mode 100644 src/components/base/b-virtual-scroll/interface/requests.ts create mode 100644 src/components/base/b-virtual-scroll/modules/chunk-render.ts create mode 100644 src/components/base/b-virtual-scroll/modules/chunk-request.ts create mode 100644 src/components/base/b-virtual-scroll/modules/component-render.ts delete mode 100644 src/components/base/b-virtual-scroll/modules/emitter/index.ts delete mode 100644 src/components/base/b-virtual-scroll/modules/emitter/interface.ts delete mode 100644 src/components/base/b-virtual-scroll/modules/factory/engines/vdom.ts delete mode 100644 src/components/base/b-virtual-scroll/modules/factory/index.ts create mode 100644 src/components/base/b-virtual-scroll/modules/helpers.ts delete mode 100644 src/components/base/b-virtual-scroll/modules/helpers/index.ts delete mode 100644 src/components/base/b-virtual-scroll/modules/observer/const.ts delete mode 100644 src/components/base/b-virtual-scroll/modules/observer/engines/intersection-observer.ts delete mode 100644 src/components/base/b-virtual-scroll/modules/observer/index.ts delete mode 100644 src/components/base/b-virtual-scroll/modules/observer/interface.ts delete mode 100644 src/components/base/b-virtual-scroll/modules/slots/index.ts delete mode 100644 src/components/base/b-virtual-scroll/modules/slots/interface.ts delete mode 100644 src/components/base/b-virtual-scroll/modules/state/helpers.ts delete mode 100644 src/components/base/b-virtual-scroll/modules/state/index.ts delete mode 100644 src/components/base/b-virtual-scroll/props.ts create mode 100644 src/components/base/b-virtual-scroll/test/index.js create mode 100644 src/components/base/b-virtual-scroll/test/runners/events/chunk-loaded.js create mode 100644 src/components/base/b-virtual-scroll/test/runners/events/chunk-loading.js create mode 100644 src/components/base/b-virtual-scroll/test/runners/events/data-change.js create mode 100644 src/components/base/b-virtual-scroll/test/runners/events/db-change.js create mode 100644 src/components/base/b-virtual-scroll/test/runners/functional/items.js create mode 100644 src/components/base/b-virtual-scroll/test/runners/functional/render-next.js create mode 100644 src/components/base/b-virtual-scroll/test/runners/functional/state.js create mode 100644 src/components/base/b-virtual-scroll/test/runners/render/render.js create mode 100644 src/components/base/b-virtual-scroll/test/runners/slots/empty.js create mode 100644 src/components/base/b-virtual-scroll/test/runners/slots/render-next.js create mode 100644 src/components/base/b-virtual-scroll/test/unit/render.ts diff --git a/components-lock.json b/components-lock.json index 8668027300..10dbc7ddf5 100644 --- a/components-lock.json +++ b/components-lock.json @@ -1,5 +1,5 @@ { - "hash": "3ae069d52d1989b96e528e8e9288454145d1b772025f020f37e2e2148bfea48d", + "hash": "098211bcc31aa8c403e2f0f126046ef09ad2fb212477edeb5a60735ae3e661f0", "data": { "%data": "%data:Map", "%data:Map": [ @@ -2263,6 +2263,7 @@ "b-tree", "b-window", "b-virtual-scroll", + "b-virtual-scroll-new", "b-bottom-slide", "b-slider", "b-sidebar", @@ -2305,6 +2306,7 @@ "b-tree", "b-window", "b-virtual-scroll", + "b-virtual-scroll-new", "b-bottom-slide", "b-slider", "b-sidebar", diff --git a/src/components/base/b-virtual-scroll/test/api/component-object/index.ts b/src/components/base/b-virtual-scroll-new/test/api/component-object/index.ts similarity index 94% rename from src/components/base/b-virtual-scroll/test/api/component-object/index.ts rename to src/components/base/b-virtual-scroll-new/test/api/component-object/index.ts index 82df6d4ed4..7931d2c36f 100644 --- a/src/components/base/b-virtual-scroll/test/api/component-object/index.ts +++ b/src/components/base/b-virtual-scroll-new/test/api/component-object/index.ts @@ -10,16 +10,16 @@ import type { Locator, Page } from 'playwright'; import { ComponentObject, Scroll } from 'tests/helpers'; -import type bVirtualScroll from 'components/base/b-virtual-scroll/b-virtual-scroll'; -import type { ComponentRefs, VirtualScrollState } from 'components/base/b-virtual-scroll/interface'; -import type { SlotsStateObj } from 'components/base/b-virtual-scroll/modules/slots'; +import type bVirtualScrollNew from 'components/base/b-virtual-scroll-new/b-virtual-scroll-new'; +import type { ComponentRefs, VirtualScrollState } from 'components/base/b-virtual-scroll-new/interface'; +import type { SlotsStateObj } from 'components/base/b-virtual-scroll-new/modules/slots'; -import { testStyles } from 'components/base/b-virtual-scroll/test/api/component-object/styles'; +import { testStyles } from 'components/base/b-virtual-scroll-new/test/api/component-object/styles'; /** - * The component object API for testing the {@link bVirtualScroll} component. + * The component object API for testing the {@link bVirtualScrollNew} component. */ -export class VirtualScrollComponentObject extends ComponentObject { +export class VirtualScrollComponentObject extends ComponentObject { /** * The locator for the container ref. */ @@ -38,7 +38,7 @@ export class VirtualScrollComponentObject extends ComponentObject *'); diff --git a/src/components/base/b-virtual-scroll/test/api/component-object/styles.ts b/src/components/base/b-virtual-scroll-new/test/api/component-object/styles.ts similarity index 95% rename from src/components/base/b-virtual-scroll/test/api/component-object/styles.ts rename to src/components/base/b-virtual-scroll-new/test/api/component-object/styles.ts index 7a7da255dc..aa44ed81ef 100644 --- a/src/components/base/b-virtual-scroll/test/api/component-object/styles.ts +++ b/src/components/base/b-virtual-scroll-new/test/api/component-object/styles.ts @@ -18,7 +18,7 @@ export const testStyles = ` content: attr(data-index); } -.b-virtual-scroll__container { +.b-virtual-scroll-new__container { min-width: 20px; min-height: 20px; } diff --git a/src/components/base/b-virtual-scroll/test/api/helpers/index.ts b/src/components/base/b-virtual-scroll-new/test/api/helpers/index.ts similarity index 96% rename from src/components/base/b-virtual-scroll/test/api/helpers/index.ts rename to src/components/base/b-virtual-scroll-new/test/api/helpers/index.ts index 9bd608bb71..6824e2b947 100644 --- a/src/components/base/b-virtual-scroll/test/api/helpers/index.ts +++ b/src/components/base/b-virtual-scroll-new/test/api/helpers/index.ts @@ -10,20 +10,20 @@ import type { Page } from 'playwright'; import test from 'tests/config/unit/test'; -import { createInitialState as createInitialStateObj } from 'components/base/b-virtual-scroll/modules/state/helpers'; -import type { MountedChild, ComponentItem, VirtualScrollState, MountedItem } from 'components/base/b-virtual-scroll/interface'; -import { componentEvents, componentObserverLocalEvents } from 'components/base/b-virtual-scroll/const'; +import { createInitialState as createInitialStateObj } from 'components/base/b-virtual-scroll-new/modules/state/helpers'; +import type { MountedChild, ComponentItem, VirtualScrollState, MountedItem } from 'components/base/b-virtual-scroll-new/interface'; +import { componentEvents, componentObserverLocalEvents } from 'components/base/b-virtual-scroll-new/const'; import { paginationHandler } from 'tests/helpers/providers/pagination'; import { RequestInterceptor } from 'tests/helpers/providers/interceptor'; -import { VirtualScrollComponentObject } from 'components/base/b-virtual-scroll/test/api/component-object'; -import type { DataConveyor, DataItemCtor, MountedItemCtor, StateApi, VirtualScrollTestHelpers, MountedSeparatorCtor, IndexedObj } from 'components/base/b-virtual-scroll/test/api/helpers/interface'; +import { VirtualScrollComponentObject } from 'components/base/b-virtual-scroll-new/test/api/component-object'; +import type { DataConveyor, DataItemCtor, MountedItemCtor, StateApi, VirtualScrollTestHelpers, MountedSeparatorCtor, IndexedObj } from 'components/base/b-virtual-scroll-new/test/api/helpers/interface'; -export * from 'components/base/b-virtual-scroll/test/api/component-object'; +export * from 'components/base/b-virtual-scroll-new/test/api/component-object'; /** - * Creates a helper API for convenient testing of the `b-virtual-scroll` component + * Creates a helper API for convenient testing of the `b-virtual-scroll-new` component * @param page - the page object representing the testing page. */ export async function createTestHelpers(page: Page): Promise { diff --git a/src/components/base/b-virtual-scroll/test/api/helpers/interface.ts b/src/components/base/b-virtual-scroll-new/test/api/helpers/interface.ts similarity index 95% rename from src/components/base/b-virtual-scroll/test/api/helpers/interface.ts rename to src/components/base/b-virtual-scroll-new/test/api/helpers/interface.ts index 450ad4b51c..0f0c7c0f4a 100644 --- a/src/components/base/b-virtual-scroll/test/api/helpers/interface.ts +++ b/src/components/base/b-virtual-scroll-new/test/api/helpers/interface.ts @@ -6,11 +6,11 @@ * https://github.com/V4Fire/Client/blob/master/LICENSE */ -import type { ComponentItem, VirtualScrollState, MountedChild, MountedItem } from 'components/base/b-virtual-scroll/interface'; +import type { ComponentItem, VirtualScrollState, MountedChild, MountedItem } from 'components/base/b-virtual-scroll-new/interface'; import type { SpyObject } from 'tests/helpers/mock/interface'; import type { RequestInterceptor } from 'tests/helpers/providers/interceptor'; -import type { VirtualScrollComponentObject } from 'components/base/b-virtual-scroll/test/api/component-object'; +import type { VirtualScrollComponentObject } from 'components/base/b-virtual-scroll-new/test/api/component-object'; /** * The interface defining the data conveyor for convenient data manipulation. @@ -136,7 +136,7 @@ export interface StateApi { */ export interface VirtualScrollTestHelpers { /** - * The component object representing the `bVirtualScroll` component. + * The component object representing the `bVirtualScrollNew` component. */ component: VirtualScrollComponentObject; diff --git a/src/components/base/b-virtual-scroll/test/unit/functional/emitter/payload.ts b/src/components/base/b-virtual-scroll-new/test/unit/functional/emitter/payload.ts similarity index 97% rename from src/components/base/b-virtual-scroll/test/unit/functional/emitter/payload.ts rename to src/components/base/b-virtual-scroll-new/test/unit/functional/emitter/payload.ts index c3eaabba22..3c0e9d3d1a 100644 --- a/src/components/base/b-virtual-scroll/test/unit/functional/emitter/payload.ts +++ b/src/components/base/b-virtual-scroll-new/test/unit/functional/emitter/payload.ts @@ -12,10 +12,10 @@ import test from 'tests/config/unit/test'; -import { createTestHelpers, filterEmitterCalls } from 'components/base/b-virtual-scroll/test/api/helpers'; -import type { VirtualScrollTestHelpers } from 'components/base/b-virtual-scroll/test/api/helpers/interface'; +import { createTestHelpers, filterEmitterCalls } from 'components/base/b-virtual-scroll-new/test/api/helpers'; +import type { VirtualScrollTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers/interface'; -test.describe('', () => { +test.describe('', () => { let component: VirtualScrollTestHelpers['component'], provider: VirtualScrollTestHelpers['provider'], diff --git a/src/components/base/b-virtual-scroll/test/unit/functional/props/props.ts b/src/components/base/b-virtual-scroll-new/test/unit/functional/props/props.ts similarity index 90% rename from src/components/base/b-virtual-scroll/test/unit/functional/props/props.ts rename to src/components/base/b-virtual-scroll-new/test/unit/functional/props/props.ts index 3437ca89ef..75702fb07f 100644 --- a/src/components/base/b-virtual-scroll/test/unit/functional/props/props.ts +++ b/src/components/base/b-virtual-scroll-new/test/unit/functional/props/props.ts @@ -16,11 +16,11 @@ import test from 'tests/config/unit/test'; import { fromQueryString } from 'core/url'; -import type bVirtualScroll from 'components/base/b-virtual-scroll/b-virtual-scroll'; -import { createTestHelpers } from 'components/base/b-virtual-scroll/test/api/helpers'; -import type { VirtualScrollTestHelpers } from 'components/base/b-virtual-scroll/test/api/helpers/interface'; +import type bVirtualScrollNew from 'components/base/b-virtual-scroll-new/b-virtual-scroll-new'; +import { createTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers'; +import type { VirtualScrollTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers/interface'; -test.describe('', () => { +test.describe('', () => { let component: VirtualScrollTestHelpers['component'], provider: VirtualScrollTestHelpers['provider'], @@ -44,7 +44,7 @@ test.describe('', () => { .withDefaultPaginationProviderProps({chunkSize}) .withProps({ chunkSize, - '@hook:beforeDataCreate': (ctx: bVirtualScroll['unsafe']) => jestMock.spy(ctx.componentFactory, 'produceComponentItems') + '@hook:beforeDataCreate': (ctx: bVirtualScrollNew['unsafe']) => jestMock.spy(ctx.componentFactory, 'produceComponentItems') }) .build({useDummy: true}); @@ -76,7 +76,7 @@ test.describe('', () => { chunkSize, requestQuery: () => ({get: {param1: 'param1'}}), shouldPerformDataRequest: () => false, - '@hook:beforeDataCreate': (ctx: bVirtualScroll['unsafe']) => jestMock.spy(ctx.componentFactory, 'produceComponentItems') + '@hook:beforeDataCreate': (ctx: bVirtualScrollNew['unsafe']) => jestMock.spy(ctx.componentFactory, 'produceComponentItems') }) .build(); diff --git a/src/components/base/b-virtual-scroll/test/unit/functional/rendering/default.ts b/src/components/base/b-virtual-scroll-new/test/unit/functional/rendering/default.ts similarity index 93% rename from src/components/base/b-virtual-scroll/test/unit/functional/rendering/default.ts rename to src/components/base/b-virtual-scroll-new/test/unit/functional/rendering/default.ts index da50e43bdd..e4aa44055d 100644 --- a/src/components/base/b-virtual-scroll/test/unit/functional/rendering/default.ts +++ b/src/components/base/b-virtual-scroll-new/test/unit/functional/rendering/default.ts @@ -14,12 +14,12 @@ import test from 'tests/config/unit/test'; import { Scroll } from 'tests/helpers'; -import type { VirtualScrollState, ShouldPerform } from 'components/base/b-virtual-scroll/interface'; -import { createTestHelpers } from 'components/base/b-virtual-scroll/test/api/helpers'; -import type { VirtualScrollTestHelpers } from 'components/base/b-virtual-scroll/test/api/helpers/interface'; -import type bVirtualScroll from 'components/base/b-virtual-scroll/b-virtual-scroll'; +import type { VirtualScrollState, ShouldPerform } from 'components/base/b-virtual-scroll-new/interface'; +import { createTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers'; +import type { VirtualScrollTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers/interface'; +import type bVirtualScrollNew from 'components/base/b-virtual-scroll-new/b-virtual-scroll-new'; -test.describe('', () => { +test.describe('', () => { let component: VirtualScrollTestHelpers['component'], provider: VirtualScrollTestHelpers['provider'], @@ -146,7 +146,7 @@ test.describe('', () => { shouldPerformDataRender, shouldStopRequestingData: () => true, chunkSize, - '@hook:beforeDataCreate': (ctx: bVirtualScroll) => jestMock.spy(ctx.unsafe.componentFactory, 'produceNodes') + '@hook:beforeDataCreate': (ctx: bVirtualScrollNew) => jestMock.spy(ctx.unsafe.componentFactory, 'produceNodes') }); await component.build(); diff --git a/src/components/base/b-virtual-scroll/test/unit/functional/rendering/items-factory.ts b/src/components/base/b-virtual-scroll-new/test/unit/functional/rendering/items-factory.ts similarity index 97% rename from src/components/base/b-virtual-scroll/test/unit/functional/rendering/items-factory.ts rename to src/components/base/b-virtual-scroll-new/test/unit/functional/rendering/items-factory.ts index ffadc30f69..e43b8790d9 100644 --- a/src/components/base/b-virtual-scroll/test/unit/functional/rendering/items-factory.ts +++ b/src/components/base/b-virtual-scroll-new/test/unit/functional/rendering/items-factory.ts @@ -12,12 +12,12 @@ import test from 'tests/config/unit/test'; -import type { ComponentItemFactory, ComponentItem, ShouldPerform, VirtualScrollState } from 'components/base/b-virtual-scroll/interface'; +import type { ComponentItemFactory, ComponentItem, ShouldPerform, VirtualScrollState } from 'components/base/b-virtual-scroll-new/interface'; -import { createTestHelpers } from 'components/base/b-virtual-scroll/test/api/helpers'; -import type { VirtualScrollTestHelpers } from 'components/base/b-virtual-scroll/test/api/helpers/interface'; +import { createTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers'; +import type { VirtualScrollTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers/interface'; -test.describe(' rendering via itemsFactory', () => { +test.describe(' rendering via itemsFactory', () => { let component: VirtualScrollTestHelpers['component'], provider: VirtualScrollTestHelpers['provider'], diff --git a/src/components/base/b-virtual-scroll/test/unit/functional/rendering/items-mode.ts b/src/components/base/b-virtual-scroll-new/test/unit/functional/rendering/items-mode.ts similarity index 93% rename from src/components/base/b-virtual-scroll/test/unit/functional/rendering/items-mode.ts rename to src/components/base/b-virtual-scroll-new/test/unit/functional/rendering/items-mode.ts index 1e7d07180e..dd48e058ce 100644 --- a/src/components/base/b-virtual-scroll/test/unit/functional/rendering/items-mode.ts +++ b/src/components/base/b-virtual-scroll-new/test/unit/functional/rendering/items-mode.ts @@ -12,11 +12,11 @@ import test from 'tests/config/unit/test'; -import type { ShouldPerform } from 'components/base/b-virtual-scroll/interface'; -import { createTestHelpers } from 'components/base/b-virtual-scroll/test/api/helpers'; -import type { VirtualScrollTestHelpers } from 'components/base/b-virtual-scroll/test/api/helpers/interface'; +import type { ShouldPerform } from 'components/base/b-virtual-scroll-new/interface'; +import { createTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers'; +import type { VirtualScrollTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers/interface'; -test.describe('', () => { +test.describe('', () => { let component: VirtualScrollTestHelpers['component'], provider: VirtualScrollTestHelpers['provider'], diff --git a/src/components/base/b-virtual-scroll/test/unit/functional/state/default.ts b/src/components/base/b-virtual-scroll-new/test/unit/functional/state/default.ts similarity index 95% rename from src/components/base/b-virtual-scroll/test/unit/functional/state/default.ts rename to src/components/base/b-virtual-scroll-new/test/unit/functional/state/default.ts index d14a258ee0..a1577bbfae 100644 --- a/src/components/base/b-virtual-scroll/test/unit/functional/state/default.ts +++ b/src/components/base/b-virtual-scroll-new/test/unit/functional/state/default.ts @@ -12,14 +12,14 @@ import test from 'tests/config/unit/test'; -import type bVirtualScroll from 'components/base/b-virtual-scroll/b-virtual-scroll'; -import { defaultShouldProps } from 'components/base/b-virtual-scroll/const'; -import type { ComponentItem, ShouldPerform } from 'components/base/b-virtual-scroll/interface'; +import type bVirtualScrollNew from 'components/base/b-virtual-scroll-new/b-virtual-scroll-new'; +import { defaultShouldProps } from 'components/base/b-virtual-scroll-new/const'; +import type { ComponentItem, ShouldPerform } from 'components/base/b-virtual-scroll-new/interface'; -import { createTestHelpers } from 'components/base/b-virtual-scroll/test/api/helpers'; -import type { VirtualScrollTestHelpers } from 'components/base/b-virtual-scroll/test/api/helpers/interface'; +import { createTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers'; +import type { VirtualScrollTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers/interface'; -test.describe('', () => { +test.describe('', () => { let component: VirtualScrollTestHelpers['component'], provider: VirtualScrollTestHelpers['provider'], @@ -35,7 +35,7 @@ test.describe('', () => { test('Initial state', async () => { const chunkSize = 12, - mockFn = await component.mockFn((ctx: bVirtualScroll) => ctx.getVirtualScrollState()); + mockFn = await component.mockFn((ctx: bVirtualScrollNew) => ctx.getVirtualScrollState()); provider.response(200, {data: []}, {delay: (10).seconds()}); diff --git a/src/components/base/b-virtual-scroll/test/unit/functional/state/emitter.ts b/src/components/base/b-virtual-scroll-new/test/unit/functional/state/emitter.ts similarity index 98% rename from src/components/base/b-virtual-scroll/test/unit/functional/state/emitter.ts rename to src/components/base/b-virtual-scroll-new/test/unit/functional/state/emitter.ts index a08250a3cd..09e6e4144b 100644 --- a/src/components/base/b-virtual-scroll/test/unit/functional/state/emitter.ts +++ b/src/components/base/b-virtual-scroll-new/test/unit/functional/state/emitter.ts @@ -14,11 +14,11 @@ import test from 'tests/config/unit/test'; -import { createTestHelpers, filterEmitterResults } from 'components/base/b-virtual-scroll/test/api/helpers'; -import type { VirtualScrollTestHelpers } from 'components/base/b-virtual-scroll/test/api/helpers/interface'; -import type { VirtualScrollState } from 'components/base/b-virtual-scroll/interface'; +import { createTestHelpers, filterEmitterResults } from 'components/base/b-virtual-scroll-new/test/api/helpers'; +import type { VirtualScrollTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers/interface'; +import type { VirtualScrollState } from 'components/base/b-virtual-scroll-new/interface'; -test.describe('', () => { +test.describe('', () => { let component: VirtualScrollTestHelpers['component'], provider: VirtualScrollTestHelpers['provider'], diff --git a/src/components/base/b-virtual-scroll/test/unit/lifecycle/initialization/initialization.ts b/src/components/base/b-virtual-scroll-new/test/unit/lifecycle/initialization/initialization.ts similarity index 96% rename from src/components/base/b-virtual-scroll/test/unit/lifecycle/initialization/initialization.ts rename to src/components/base/b-virtual-scroll-new/test/unit/lifecycle/initialization/initialization.ts index 66d9a4fa0d..758e6d1a61 100644 --- a/src/components/base/b-virtual-scroll/test/unit/lifecycle/initialization/initialization.ts +++ b/src/components/base/b-virtual-scroll-new/test/unit/lifecycle/initialization/initialization.ts @@ -12,13 +12,13 @@ import test from 'tests/config/unit/test'; -import type bVirtualScroll from 'components/base/b-virtual-scroll/b-virtual-scroll'; -import { defaultShouldProps } from 'components/base/b-virtual-scroll/const'; +import type bVirtualScrollNew from 'components/base/b-virtual-scroll-new/b-virtual-scroll-new'; +import { defaultShouldProps } from 'components/base/b-virtual-scroll-new/const'; -import { createTestHelpers } from 'components/base/b-virtual-scroll/test/api/helpers'; -import type { VirtualScrollTestHelpers } from 'components/base/b-virtual-scroll/test/api/helpers/interface'; +import { createTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers'; +import type { VirtualScrollTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers/interface'; -test.describe('', () => { +test.describe('', () => { let component: VirtualScrollTestHelpers['component'], initLoadSpy: VirtualScrollTestHelpers['initLoadSpy'], @@ -26,7 +26,7 @@ test.describe('', () => { state: VirtualScrollTestHelpers['state']; const hookProp = { - '@hook:beforeDataCreate': (ctx: bVirtualScroll['unsafe']) => { + '@hook:beforeDataCreate': (ctx: bVirtualScrollNew['unsafe']) => { const original = ctx.componentInternalState.compile.bind(ctx.componentInternalState); diff --git a/src/components/base/b-virtual-scroll/test/unit/lifecycle/slots/slots.ts b/src/components/base/b-virtual-scroll-new/test/unit/lifecycle/slots/slots.ts similarity index 98% rename from src/components/base/b-virtual-scroll/test/unit/lifecycle/slots/slots.ts rename to src/components/base/b-virtual-scroll-new/test/unit/lifecycle/slots/slots.ts index 8bd49a8aa9..44b73fc89d 100644 --- a/src/components/base/b-virtual-scroll/test/unit/lifecycle/slots/slots.ts +++ b/src/components/base/b-virtual-scroll-new/test/unit/lifecycle/slots/slots.ts @@ -16,13 +16,13 @@ import test from 'tests/config/unit/test'; import { BOM } from 'tests/helpers'; -import type { ShouldPerform } from 'components/base/b-virtual-scroll/b-virtual-scroll'; -import type { SlotsStateObj } from 'components/base/b-virtual-scroll/modules/slots'; +import type { ShouldPerform } from 'components/base/b-virtual-scroll-new/b-virtual-scroll-new'; +import type { SlotsStateObj } from 'components/base/b-virtual-scroll-new/modules/slots'; -import { createTestHelpers } from 'components/base/b-virtual-scroll/test/api/helpers'; +import { createTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers'; // eslint-disable-next-line max-lines-per-function -test.describe('', () => { +test.describe('', () => { let component: Awaited>['component'], provider: Awaited>['provider'], diff --git a/src/components/base/b-virtual-scroll/test/unit/scenario/last-render.ts b/src/components/base/b-virtual-scroll-new/test/unit/scenario/last-render.ts similarity index 96% rename from src/components/base/b-virtual-scroll/test/unit/scenario/last-render.ts rename to src/components/base/b-virtual-scroll-new/test/unit/scenario/last-render.ts index 3cb692b3f8..0267962350 100644 --- a/src/components/base/b-virtual-scroll/test/unit/scenario/last-render.ts +++ b/src/components/base/b-virtual-scroll-new/test/unit/scenario/last-render.ts @@ -12,13 +12,13 @@ import test from 'tests/config/unit/test'; -import { createTestHelpers, filterEmitterResults } from 'components/base/b-virtual-scroll/test/api/helpers'; -import type { VirtualScrollTestHelpers } from 'components/base/b-virtual-scroll/test/api/helpers/interface'; -import type { ComponentItem, VirtualScrollState } from 'components/base/b-virtual-scroll/interface'; +import { createTestHelpers, filterEmitterResults } from 'components/base/b-virtual-scroll-new/test/api/helpers'; +import type { VirtualScrollTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers/interface'; +import type { ComponentItem, VirtualScrollState } from 'components/base/b-virtual-scroll-new/interface'; const j = (...str: string[]) => str.join(', '); -test.describe('', () => { +test.describe('', () => { let component: VirtualScrollTestHelpers['component'], provider: VirtualScrollTestHelpers['provider'], diff --git a/src/components/base/b-virtual-scroll/test/unit/scenario/manual-rendering.ts b/src/components/base/b-virtual-scroll-new/test/unit/scenario/manual-rendering.ts similarity index 89% rename from src/components/base/b-virtual-scroll/test/unit/scenario/manual-rendering.ts rename to src/components/base/b-virtual-scroll-new/test/unit/scenario/manual-rendering.ts index f3bf9d07ee..1f54457352 100644 --- a/src/components/base/b-virtual-scroll/test/unit/scenario/manual-rendering.ts +++ b/src/components/base/b-virtual-scroll-new/test/unit/scenario/manual-rendering.ts @@ -15,11 +15,11 @@ import test from 'tests/config/unit/test'; import type { ComponentElement } from 'core/component'; -import type bVirtualScroll from 'components/base/b-virtual-scroll/b-virtual-scroll'; -import { createTestHelpers } from 'components/base/b-virtual-scroll/test/api/helpers'; -import type { VirtualScrollTestHelpers } from 'components/base/b-virtual-scroll/test/api/helpers/interface'; +import type bVirtualScrollNew from 'components/base/b-virtual-scroll-new/b-virtual-scroll-new'; +import { createTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers'; +import type { VirtualScrollTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers/interface'; -test.describe('', () => { +test.describe('', () => { let component: VirtualScrollTestHelpers['component'], provider: VirtualScrollTestHelpers['provider'], @@ -36,7 +36,7 @@ test.describe('', () => { type: 'div', attrs: { id: 'renderNext', - '@click': () => (>document.querySelector('.b-virtual-scroll')).component?.initLoadNext() + '@click': () => (>document.querySelector('.b-virtual-scroll-new')).component?.initLoadNext() } }, @@ -44,7 +44,7 @@ test.describe('', () => { type: 'div', attrs: { id: 'retry', - '@click': () => (>document.querySelector('.b-virtual-scroll')).component?.initLoadNext() + '@click': () => (>document.querySelector('.b-virtual-scroll-new')).component?.initLoadNext() } } }); diff --git a/src/components/base/b-virtual-scroll/test/unit/scenario/reload.ts b/src/components/base/b-virtual-scroll-new/test/unit/scenario/reload.ts similarity index 96% rename from src/components/base/b-virtual-scroll/test/unit/scenario/reload.ts rename to src/components/base/b-virtual-scroll-new/test/unit/scenario/reload.ts index fb9c78591f..3f4223282a 100644 --- a/src/components/base/b-virtual-scroll/test/unit/scenario/reload.ts +++ b/src/components/base/b-virtual-scroll-new/test/unit/scenario/reload.ts @@ -12,10 +12,10 @@ import test from 'tests/config/unit/test'; -import { createTestHelpers, filterEmitterCalls } from 'components/base/b-virtual-scroll/test/api/helpers'; -import type { VirtualScrollTestHelpers } from 'components/base/b-virtual-scroll/test/api/helpers/interface'; +import { createTestHelpers, filterEmitterCalls } from 'components/base/b-virtual-scroll-new/test/api/helpers'; +import type { VirtualScrollTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers/interface'; -test.describe('', () => { +test.describe('', () => { let component: VirtualScrollTestHelpers['component'], provider: VirtualScrollTestHelpers['provider'], diff --git a/src/components/base/b-virtual-scroll/test/unit/scenario/retry.ts b/src/components/base/b-virtual-scroll-new/test/unit/scenario/retry.ts similarity index 93% rename from src/components/base/b-virtual-scroll/test/unit/scenario/retry.ts rename to src/components/base/b-virtual-scroll-new/test/unit/scenario/retry.ts index 642ecbd5ef..e1f24542e4 100644 --- a/src/components/base/b-virtual-scroll/test/unit/scenario/retry.ts +++ b/src/components/base/b-virtual-scroll-new/test/unit/scenario/retry.ts @@ -14,11 +14,11 @@ import test from 'tests/config/unit/test'; import type { ComponentElement } from 'core/component'; -import type bVirtualScroll from 'components/base/b-virtual-scroll/b-virtual-scroll'; -import { createTestHelpers } from 'components/base/b-virtual-scroll/test/api/helpers'; -import type { VirtualScrollTestHelpers } from 'components/base/b-virtual-scroll/test/api/helpers/interface'; +import type bVirtualScrollNew from 'components/base/b-virtual-scroll-new/b-virtual-scroll-new'; +import { createTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers'; +import type { VirtualScrollTestHelpers } from 'components/base/b-virtual-scroll-new/test/api/helpers/interface'; -test.describe('', () => { +test.describe('', () => { let component: VirtualScrollTestHelpers['component'], provider: VirtualScrollTestHelpers['provider'], @@ -35,7 +35,7 @@ test.describe('', () => { type: 'div', attrs: { id: 'retry', - '@click': () => (>document.querySelector('.b-virtual-scroll')).component?.initLoadNext() + '@click': () => (>document.querySelector('.b-virtual-scroll-new')).component?.initLoadNext() } } }); diff --git a/src/components/base/b-virtual-scroll/CHANGELOG.md b/src/components/base/b-virtual-scroll/CHANGELOG.md index b90965a1c8..404e88c91c 100644 --- a/src/components/base/b-virtual-scroll/CHANGELOG.md +++ b/src/components/base/b-virtual-scroll/CHANGELOG.md @@ -9,12 +9,6 @@ Changelog > - :house: [Internal] > - :nail_care: [Polish] -## v4.0.0-beta.?? (2023-??-??) - -#### :boom: Breaking Change - -* Major update. Visit [readme](./readme) to see migration guide. - ## v4.0.0-beta.36 (2023-10-23) #### :bug: Bug Fix diff --git a/src/components/base/b-virtual-scroll/README.md b/src/components/base/b-virtual-scroll/README.md index b2316c6174..b85f94bbd9 100644 --- a/src/components/base/b-virtual-scroll/README.md +++ b/src/components/base/b-virtual-scroll/README.md @@ -1,1226 +1,218 @@ - - -**Table of Contents** - -- [components/base/b-virtual-scroll](#componentsbaseb-virtual-scroll) - - [Synopsis](#synopsis) - - [Modifiers](#modifiers) - - [Events](#events) - - [Usage](#usage) - - [How to Implement Simple Rendering via DataProvider?](#how-to-implement-simple-rendering-via-dataprovider) - - [How to Implement Simple Rendering via `items`?](#how-to-implement-simple-rendering-via-items) - - [How to Implement Component Rendering on Click Instead of Scroll?](#how-to-implement-component-rendering-on-click-instead-of-scroll) - - [How to Reinitialize the Component?](#how-to-reinitialize-the-component) - - [How to Reload a Failed Request?](#how-to-reload-a-failed-request) - - [Component State](#component-state) - - [Converting Data to the Required Format](#converting-data-to-the-required-format) - - [Sliders or Multi-Column Content](#sliders-or-multi-column-content) - - [How to Use "Should-Like" Functions?](#how-to-use-should-like-functions) - - [Overview of Functions](#overview-of-functions) - - [Best Practices](#best-practices) - - [Control the Rendering Conveyor with `itemsFactory`](#control-the-rendering-conveyor-with-itemsfactory) - - [`itemsProcessors` and Global Component Processing](#itemsprocessors-and-global-component-processing) - - [`request` and `requestQuery`](#request-and-requestquery) - - [Component Understanding](#component-understanding) - - [Lifecycle](#lifecycle) - - [`renderGuard` and `loadDataOrPerformRender`](#renderguard-and-loaddataorperformrender) - - [Performing Last Render](#performing-last-render) - - [Difference between ComponentItem with type `item` and `separator`](#difference-between-componentitem-with-type-item-and-separator) - - [Overriding in Child Layers](#overriding-in-child-layers) - - [Frequently Asked Questions](#frequently-asked-questions) - - [Slots](#slots) - - [API](#api) - - [Props](#props) - - [[shouldPerformDataRender = `(state: VirtualScrollState) => state.isInitialRender || state.remainingItems === 0`]](#shouldperformdatarender--state-virtualscrollstate--stateisinitialrender--stateremainingitems--0) - - [[shouldPerformDataRequest = `(state: VirtualScrollState) => state.lastLoadedData.length > 0`]](#shouldperformdatarequest--state-virtualscrollstate--statelastloadeddatalength--0) - - [[shouldStopRequestingData = `(state: VirtualScrollState) => state.lastLoadedData.length > 0`]](#shouldstoprequestingdata--state-virtualscrollstate--statelastloadeddatalength--0) - - [[chunkSize = `10`]](#chunksize--10) - - [[requestQuery]](#requestquery) - - [[itemsFactory]](#itemsfactory) - - [[itemsProcessors = `{}`]](#itemsprocessors--) - - [`tombstoneCount`](#tombstonecount) - - [Methods](#methods) - - [getNextDataSlice](#getnextdataslice) - - [getVirtualScrollState](#getvirtualscrollstate) - - [initLoadNext](#initloadnext) - - [Other Properties](#other-properties) - - [Migration from `b-virtual-scroll` version 3.x.x](#migration-from-b-virtual-scroll-version-3xx) - - [API Migration](#api-migration) - - [What's Next](#whats-next) - - [Streaming Data Rendering](#streaming-data-rendering) - - [Alternative Approach to Component Rendering](#alternative-approach-to-component-rendering) - - [Partial Rendering (can be achieved easily through `renderGuard`)](#partial-rendering-can-be-achieved-easily-through-renderguard) - - [Updating Nodes in the DOM Tree (describe implementation challenges, component allows inserting different components)](#updating-nodes-in-the-dom-tree-describe-implementation-challenges-component-allows-inserting-different-components) - - [Integration with RTX](#integration-with-rtx) - - - # components/base/b-virtual-scroll -The `b-virtual-scroll` component is designed for rendering a large array of various data. -It uses a special approach that renders chunks of components while avoiding changes to the parent component's state. -This allows for optimizing the rendering of large lists of components, making it more efficient. -If you have ever tried to render 100 components using v-for, you may have noticed that the interface starts to lag. -The `b-virtual-scroll` component aims to eliminate this lag by rendering components in portions, providing a better alternative to using v-for for such cases. +This module provides a component to render component sequences with the support of lazy loading and dynamically updating. +This component can be very efficient if you need to render a good amount of elements. ## Synopsis -- The component extends [[iData]]. - -- The component implements [[iItems]] traits. - -- By default, the component's root tag is set to `
`. +* The component extends [[iData]]. -## Modifiers +* The component implements the [[iItems]] trait. -See the implemented modifiers or the parent component. +* By default, the component's root tag is set to `
`. ## Events -| EventName | Description | Payload description | Payload | -|---------------------|---------------------------------------------------------|---------------------------------------------|----------------------------| -| `dataLoadSuccess` | Data loading has succeeded. | `data: object[], isInitialLoading: boolean` | `[data, isInitialLoading]` | -| `dataLoadStart` | Data loading has started. | `isInitialLoading: boolean` | `[isInitialLoading]` | -| `dataLoadError` | An error occurred while loading data. | `isInitialLoading: boolean` | `[isInitialLoading]` | -| `dataLoadEmpty` | Successful load with no data. | | `[]` | -| `resetState` | Reset component state. | | `[]` | -| `lifecycleDone` | All component data is rendered and loaded. | | `[]` | -| `convertDataToDB` | Trigger data conversion to the `DB`. | `data: unknown` | `[data]` | -| `elementEnter` | The element has entered the viewport. | `componentItem: MountedChild` | `[componentItem]` | -| `renderStart` | Rendering of items has started. | | `[]` | -| `renderDone` | Rendering of items has finished. | | `[]` | -| `renderEngineStart` | Rendering of items has started with the render engine. | | `[]` | -| `renderEngineDone` | Rendering of items has finished with the render engine. | | `[]` | -| `domInsertStart` | DOM node insertion has started. | | `[]` | -| `domInsertDone` | DOM node insertion has finished. | | `[]` | - -Also, you can see the implemented traits or the parent component. - -## Usage - -The component offers various usage options: it can load and render data on scroll, on click, or even load a large volume of data at once but render it in portions. Would you like to implement a global rendering process for components in order to integrate a specific element (e.g., an advertisement) after each component? No problem - the component provides processor functions that enable this functionality. Do you want to implement your own strategy for "when to load" and "when to render"? The component also offers special functions that allow for this customization. - -Below, we will explore a few basic usage scenarios and delve into the component's API in greater detail. - -### How to Implement Simple Rendering via DataProvider? - -To implement simple rendering, you need to follow several steps: - -1. Set up a data provider for the component. For example, we'll use a provider named `Provider` that returns data in the format `{data: object[]}`, where the number of objects depends on the request parameter `count`: - - ```snakeskin - < b-virtual-scroll & - :dataProvider = 'Provider' - . - ``` - - > It's important to note that `b-virtual-scroll` expects data in this specific format (`{data: object[]}`). If your provider returns data in a different format, you can use processors in either the provider or the component using the `convertDataToDb` prop. - -2. Let's say we want to load and render 12 components at a time. To achieve this, you need to specify the `request` and `chunkSize` props for the `b-virtual-scroll` component. The `request` prop defines the request parameters (standard behavior of `iData`), and `chunkSize` specifies the number of items to render in each rendering cycle: - - ```snakeskin - < b-virtual-scroll & - :dataProvider = 'Provider' | - :request = {get: {count: 12}} | - :chunkSize = 12 - . - ``` - -3. To avoid loading the same data repeatedly and load different data for each subsequent request, you need to pass the `page` request parameter to the `Provider`. This parameter indicates the page number of the data to be loaded: - - To achieve this, use the `requestQuery` prop in the `b-virtual-scroll` component. `requestQuery` is a function prop that `b-virtual-scroll` calls, passing its own state as an argument, before making a request. You can return the appropriate `page` value based on the component's state. The difference between `request` and `requestQuery` is that changes to the latter won't cause the component to reinitialize. These two props are merged to form the final request parameters passed to the provider. - - ```snakeskin - < b-virtual-scroll & - :dataProvider = 'Provider' | - :request = {get: {count: 12}} | - :requestQuery = (state) => ({get: {page: state.loadPage}}) | - :chunkSize = 12 - . - ``` - - In the example above, the `page` parameter is extracted from the component's state, specifically `loadPage`, which increments after each successful data load. - -4. Now that you have set up data loading with pagination, you need to specify what `b-virtual-scroll` will render: +| EventName | Description | Payload description | Payload | +|------------------|--------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------|-----------------------------| +| dbChange | The event is fired after receiving data from a data provider. The event won't be fired if the data is empty. | Cumulative data of all tied requests | `RemoteData` | +| dataChange | The event is fired after changing a data batch | Data batch value | `unknown[]` | +| chunkLoading | The event is fired before start to load data from a data provider | Current page | `number` | +| chunkLoaded | The event is fired after every successful response from a data provider | A structure with raw and normalized data that takes from a data provider | `LastLoadedChunk`, `number` | +| chunkRenderStart | The event is fired before components are rendered | chunk number | `number` | +| chunkRender | The event is fired after rendered nodes inserted into DOM | Render items, chunk number | `RenderItem[]`, `number` | - To control what `b-virtual-scroll` renders, you can use the following props: +Also, you can see the parent component and the component traits. - - `item`: The name of the component to be rendered. It can also be a function that returns the component's name. - - - `itemProps`: The props for the component. Typically, this is a function that returns the props for each item based on the loaded data. - - - `itemKey`: The uniq id of the component. - - Rendering occurs after data is loaded. - - ```snakeskin - < b-virtual-scroll & - :dataProvider = 'Provider' | - :request = {get: {count: 12}} | - :requestQuery = (state) => ({get: {page: state.loadPage}}) | - :chunkSize = 12 | - :item = 'b-dummy' | - :itemKey = (el) => el.uuid | - :itemProps = (el) => ({name: el.name, type: el.type}) - . - ``` - - What is `el` you can inquire about in the `itemKey` and `itemProps` functions? `el` is one of the objects in the `data` array loaded by the `dataProvider`. `b-virtual-scroll` takes the array of loaded data and calls these functions for each of the objects in this array, allowing you to transform data into components that are suitable for rendering in the `b-virtual-scroll` component. - -This setup will display a component on the page that loads and renders 12 items at once. When scrolling down, a new request with a different `page` value will be made, and after a successful load, new components will be rendered. - -However, if your component takes a long time to load data (e.g., 1 second), you might notice that there is initially empty space, and then the content suddenly appears, which can be unexpected for users. To avoid this, `b-virtual-scroll` provides slots that allow you to render a "loader" while data is being loaded. +## Usage -Let's add a `loader` slot to our component to provide a better user experience during loading: +### Basic -```snakeskin -< b-virtual-scroll & - :dataProvider = 'Provider' | - :request = {get: {count: 12}} | - :requestQuery = (state) => ({get: {page: state.loadPage}}) | - :chunkSize = 12 | - :item = 'b-dummy' | - :itemProps = (el) => ({name: el.name, type: el.type}) -. - < template #loader - < .&__loader - Data loading in progress ``` - -Now, users will see a friendly message indicating that content will appear shortly, preventing them from being surprised by sudden content changes. - -### How to Implement Simple Rendering via `items`? - -The approach to rendering data using the `items` prop is not significantly different from the approach when data is obtained from a `dataProvider`. - -Instead of passing the `dataProvider` and request* parameters, -you need to pass the items prop which contains an array of data to be rendered by the components: - - ```snakeskin - < b-virtual-scroll & - :chunkSize = 12 | - :items = data | - :item = 'b-dummy' | - :itemKey = (el) => el.uuid | - :itemProps = (el) => ({name: el.name, type: el.type}) - . - ``` - - These data can be loaded by some other component or they can be static. - It doesn't matter, what's important is that the `b-virtual-scroll` component will take these data and process them through the rendering pipeline. - -There are also some minor differences in the component's event model. -Unlike `b-virtual-scroll` which uses a `dataProvider`, a component with items will not emit certain events, specifically `dataLoadStart` and `convertDataToDB`. - -The component will also ignore the `shouldPerformDataRequest` and `shouldStopRequestingData` props, as they have no meaning when there is no `dataProvider`. - -### How to Implement Component Rendering on Click Instead of Scroll? - -The `b-virtual-scroll` component, in addition to scroll-based loading, can also load data on other events, such as a click on a button. - -To implement this approach, follow these steps: - -1. Disable scroll observers using the `disableObserver` prop by setting it to `true`. - - ```snakeskin - < b-virtual-scroll & - :dataProvider = 'Provider' | - :disableObserver = true | - ... - . - ``` - -2. Set the `shouldPerformDataRender` prop to a function that always returns `true`. This function will be called for each attempt to render data. We will discuss this function in more detail in the following sections. - - ```snakeskin - < b-virtual-scroll & - :dataProvider = 'Provider' | - :disableObserver = true | - :shouldPerformDataRender = () => true | - ... - . - ``` - -3. Gain access to the methods of `b-virtual-scroll` using the standard `ref` mechanism. - - ```snakeskin - < b-virtual-scroll & - ref = scroll | - :dataProvider = 'Provider' | - :disableObserver = true | - :shouldPerformDataRender = () => true | - ... - . - ``` - - After these manipulations, `b-virtual-scroll` will no longer load data on scroll, and data loading will only occur when the `initLoadNext` method is called. This method will be used to load and render data on a button click event. - -4. Now, you need to add a button that triggers the `initLoadNext` method when clicked. - - ```snakeskin - < b-virtual-scroll & - ref = scroll | - :dataProvider = 'Provider' | - :disableObserver = true | - :shouldPerformDataRender = () => true | - ... - . - - < b-button & - @click = $refs.scroll.initLoadNext - . - Load more data - ``` - -Now, when you click the button, data will be loaded and rendered. However, you may notice that the data loading button doesn't disappear when all data is loaded, during data loading, or in case of an error. Fortunately, `b-virtual-scroll` provides a slot for displaying such a button, and it handles the logic of hiding it during loading, errors, and so on. Clients don't need to implement additional logic; you just need to move your button to the appropriate slot, specifically the `renderNext` slot. - -```snakeskin < b-virtual-scroll & - ref = scroll | - :dataProvider = 'Provider' | - :disableObserver = true | - :shouldPerformDataRender = () => true | - ... + :dataProvider = 'demo.Pagination' | + :request = {get: {chunkSize: 12}} | + :item = 'b-card' | + :itemKey = (el, i) => resolveKey(el) | + :itemProps = getPropsForOption | + :dbConverter = convertDataToVirtual . - - < template #renderNext - < b-button & - @click = $refs.scroll.initLoadNext - . - Load more data ``` -Now, your button will be displayed only when there's more data to load, and it will automatically hide during data loading and in case of any errors. - -### How to Reinitialize the Component? - -There are often situations where you need to redraw all the data that was rendered using `b-virtual-scroll`. For example, additional filtering may have been applied, making previously rendered data in `b-virtual-scroll` outdated. - -In such cases, the component provides several ways to reinitialize it. This allows you to clear the state to its initial state, effectively removing previously rendered components and resetting the state. After the state is reset, the component will start its lifecycle as if it were created from scratch. Let's explore the options for resetting the state. - -1. Updating the `request` prop. - -2. Triggering an event in the `globalEmitter` from the following list: - - `reset` - - `reset.silence` - - `reset.load` - - `reset.load.silence` - - This means the component will automatically reload when any of these events are triggered (standard `iData` logic). - -3. Calling the `reload` or `initLoad` method. - -In which cases should you use each option? - -If you have filters on the page and a data request that should be rendered using `b-virtual-scroll`, the `request` prop is the most suitable option. You can set the `request` prop to reference the current filter state. This way, when the filter state changes on the page, the component will be automatically reinitialized. - -Let's consider an example: - -**p-page.ts** +The component expects that loaded data will have the structure that matches with the `RemoteData` interface. ```typescript -@component() -class pPage extends iDynamicPage { - @field() - filterUuid: string; - - onFilterClick(newFilter: string): void { - this.filterUuid = newFilter; - } +export interface RemoteData extends Dictionary { + /** + * Data to render + */ + data?: object[]; + + /** + * Total number of elements + */ + total?: number; } ``` -**p-page.ss** - -```snakeskin -< b-virtual-scroll & - :dataProvider = 'Provider' | - :request = {get: {count: 12, filter: filterUuid}} | - ... -. -``` +You can use `dbConverter` to convert data to match this interface. -In this example, when the `filterUuid` field on the `pPage` changes, `b-virtual-scroll` will perform reinitialization and reload the data. +To specify what kind of component to render, you have to use the `option` property. +Mind, the property can be defined as a string or function. -If you need to update the component's state at a specific moment in time, regardless of the context, you can use the `reload` or `initLoad` methods. +### Manual data display control -### How to Reload a Failed Request? +By default, data is requested and rendered automatically (when scrolling the page), you can override this behavior to load and render data manually. -Did your data fail to load due to a network or server error? No worries! The `initLoadNext` method comes to the rescue, allowing you to retry the failed request. -In addition to the `initLoadNext` method, `b-virtual-scroll` provides a `retry` slot that is displayed only when the request fails. +To set loading and rendering data in manual mode, set the `loadStrategy` prop to `manual`. -This makes it straightforward to implement a retry mechanism for a failed request: - -```snakeskin +``` < b-virtual-scroll & - :dataProvider = 'Provider' | - :request = {get: {count: 12, filter: filterUuid}} | - ... + :dataProvider = 'demo.Pagination' | + :dbConverter = convertDataToVirtual | + :request = {get: {chunkSize: 12}} | + :loadStrategy = 'manual' | + + :item = 'b-card' | + :itemKey = (el, i) => resolveKey(el) | + :itemProps = getPropsForItem | . - < template #retry - < .&__retry @click = initLoadNext - Retry last request + < template #renderNext = o + < .&__render-next @click = o.ctx.renderNext + Render or load next ``` -### Component State +Initial loading and request will be made automatically, but after that `renderNext` method will need to be used to request and render data. -The `b-virtual-scroll` component is quite substantial and has its own internal state that complements the component's state. This internal state is reset when the component is reinitialized to its initial state and changes regularly during the component's lifecycle. The component's state contains a wealth of information useful for the client, such as the loaded data, the number of elements remaining outside the user's viewport, and more. +## Slots -To retrieve the component's state, you can use a special method called `getVirtualScrollState`: +The component supports a bunch of slots to provide. -**p-page.ts** +1. `tombstone` This slot is displayed only during data loading, it will be duplicated `chunkSize` number of times. +This slot is great if you want to display skeletons while the component is loading data. -```typescript -@component() -class pPage extends iDynamicPage { - protected override readonly $refs!: { - scroll: bVirtualScroll; - }; - - getScrollState(): VirtualScrollState { - return this.$refs.scroll.getVirtualScrollState(); - } -} ``` - -This method returns the current "internal" state of the component. - -### Converting Data to the Required Format - -The `b-virtual-scroll` component expects data in a specific format: - -```typescript -interface VirtualScrollDb { - data: unknown[]; -} +< b-virtual-scroll + < template #tombstone + < .&__skeleton ``` -The `data` array should contain the data items used to render the components. -The `dbConverter` prop allows you to convert data into a format suitable for `b-virtual-scroll` after data has been loaded. +2. `loader` This slot is displayed only during data loading. +This slot is great if you want to display something while the component is loading data. -```snakeskin -< b-virtual-scroll & - ... - :dbConverter = (data) => ({data: data.nestedData.data}) -. +``` +< b-virtual-scroll < template #loader - < .&__loader - Data loading in progress + < b-loader ``` -### Sliders or Multi-Column Content - -Sometimes, there is a need to render a large amount of data not in a typical vertical strip where one item follows another, but, for example, in a strip consisting of multiple columns or in a slider. - -All of these can be implemented using HTML/CSS layout and providing CSS classes in `b-virtual-scroll`. -There is no need to specify any additional props for `b-virtual-scroll`. For `b-virtual-scroll`, the content layout doesn't matter. - -### How to Use "Should-Like" Functions? - -#### Overview of Functions - -The component provides several "should-like" props that determine whether to perform certain actions. Each of these functions serves a different purpose and is called at a specific moment in time. Let's take a detailed look at each of these functions and their purposes: - -- `shouldPerformDataRequest`: This function indicates the need to load a chunk of data. If it returns `true`, a data request will be made. This function takes the "internal" component state as input and should return a boolean value. It is called when any component rendered by `b-virtual-scroll`, which has not yet entered the viewport, enters the viewport. - - > It's important to note that clients do not need to check whether data is currently being loaded or not; the `b-virtual-scroll` component handles this check itself and prevents data from being requested if a loading process is already active. - - An example implementation of this function could be to check how many items are left in the viewport, and if half of the rendered items are within the viewport, start loading more: - - ```typescript - const shouldPerformDataRequest = (state: VirtualScrollState): boolean => { - // Example: Request data if the remaining items till the end is less than or equal to 10 - return state.remainingItems <= 10; - }; - ``` - - The default implementation checks whether anything was loaded in the last request and, if so, allows another request: - - ```typescript - const shouldPerformDataRequest = (state: VirtualScrollState, _ctx: bVirtualScroll): boolean => { - const isLastRequestNotEmpty = () => state.lastLoadedData.length > 0; - return isLastRequestNotEmpty(); - }; - ``` - -- `shouldStopRequestingData`: This function indicates the need to stop requesting data and tells the component that the data loading lifecycle has completed. If it returns `true`, the `b-virtual-scroll` component will not attempt to request more data until the component is reinitialized, which leads to an update of the lifecycle. This function is called after every successful data load. - - An example implementation of this function could be to check whether the number of loaded items equals the total number of items that can be returned by the pagination for the current query: - - ```typescript - const shouldStopRequestingData = (state: VirtualScrollState): boolean => { - // Example: Stop requesting data when the total number of items equals the current number of loaded items - return state.lastLoadedRawData?.total === state.data.length; - }; - ``` - - The default implementation checks whether anything was loaded in the last request and, if so, allows requests to continue: - - ```typescript - const shouldPerformDataRequest = (state: VirtualScrollState, _ctx: bVirtualScroll): boolean => { - const isLastRequestNotEmpty = () => state.lastLoadedData.length > 0; - return isLastRequestNotEmpty(); - }; - ``` - -- `shouldPerformDataRender`: This function indicates the need to render the loaded data. If it returns `true`, the `b-virtual-scroll` component will call the component rendering functions and insert them into the DOM tree. This function is called when there is loaded but unrendered data. - - An example implementation of this function could be to check how many items are left before reaching the end of the component's container: - - ```typescript - const shouldPerformDataRender = (state: VirtualScrollState, _ctx: bVirtualScroll): boolean => state.remainingItems === 0; - ``` - - The default implementation is similar to the example above. - -#### Best Practices - -Here are some tips for efficiently implementing data loading on the client side while providing a seamless user experience: - -- Load data well in advance before you intend to render it. Data loading can be slow, but rendering data is much faster. Therefore, it is recommended to start data loading significantly in advance and perform rendering closer to the end of the scroll. This way, users will experience a smoother scrolling of the component. - - For example, you can implement this approach as follows: - - ```typescript - const shouldPerformDataRequest = (state: VirtualScrollState, _ctx: bVirtualScroll): boolean => { - // Start loading when half of the components are in the viewport - return state.remainingItems <= chunkSize / 2; - } - ``` +3. `empty` This slot is displayed if the component has no data at all to render after completing data requests. - ```typescript - const shouldPerformDataRender = (state: VirtualScrollState, _ctx: bVirtualScroll): boolean => { - // Start rendering when only 2 components are left to the end - return state.remainingItems <= 2; - } - ``` - -- Avoid making the last useless request: This pertains to the `shouldPerformDataRequest` and `shouldStopRequestingData` functions. By default, these functions check the last data chunk to see if it returned anything. It's better to avoid this and inform the component in advance that all data has been loaded. You can achieve this by comparing the value returned by your server, indicating the total number of items with the current number of items in `b-virtual-scroll`, as demonstrated in the example above. - -### Control the Rendering Conveyor with `itemsFactory` - -`itemsFactory` is a prop that allows you to take control of component rendering. Suppose you want to render twice as many components for a single data slice. Achieving this using `iItems` props (`item`, `itemProps`, etc.) might not be possible. However, such situations may arise, and this prop is created to solve them. - -Let's consider a scenario in which we need to add a date separator component before each component with a different date from the next one. To achieve this, we will create an implementation of `itemsFactory` in which: - -1. We will access the `b-virtual-scroll` state to retrieve the loaded data. -2. We will take the previous element to determine if their dates differ, indicating whether we need to insert a date separator. -3. We will assemble an array with an abstract representation of the components to be rendered and return it from `itemsFactory`. - -```typescript -const itemsFactory = (state, ctx) => { - const - lastLoadedData = state.lastLoadedData, - allData = state.data, - items = []; - - lastLoadedData.forEach((current, i) => { - const - // Retrieve the previous data element relative to the given - prev = allData[(allData.length - lastLoadedData.length + i) - 1], - // Retrieve the next data element relative to the given - next = allData[i + 1]; - - if (!prev || prev.date !== current.date) { - items.push({ - item: 'b-date-separator', - key: current.uuid + 'separator', - type: 'separator', - children: [], - props: { - date: current.date - } - }); - } - - items.push({ - item: 'b-main-item', - key: current.uuid, - type: 'item', - children: [], - props: { - data: current - } - }); - }); - - return items; -} ``` - -As you can see in the example above, we access the last chunk of loaded data and all component data to find the previous and next data elements relative to the current one. Then, we compare their dates, and if they are not equal, we add the `b-date-separator` component before adding the `b-main-item`. This way, we collect the components to be rendered in an array and return it from the `itemsFactory` function. - -### `itemsProcessors` and Global Component Processing - -This prop is a middleware function that is called after `b-virtual-scroll` has compiled the abstract representation of components, and before it passes this representation to the rendering engine. Each function in the chain receives the result of the previous function, with the first function in the chain receiving the result of the `itemsFactory` call. The function should return an abstract representation of components that conforms to the `ComponentItem[]` interface. - -Here is an example to illustrate when `itemsProcessors` is called: - --> itemsFactory -> **itemsProcessors** -> render components via render engine -> insert components into the DOM tree - -With this prop, you can implement various scenarios, such as changing one component to another, adding components, prop migrations, and more. For some scenarios, you can also use global overrides if you need to implement some processing for all `b-virtual-scroll` instances in your application. To add a global processor, you can override the `itemsProcessors` constant located in `base/b-virtual-scroll/const.ts` within your codebase and add a function to it. - -Here's an example scenario where we need to change the name of one component to another: - -**@v4fire/client/components/base/b-virtual-scroll/const.ts** - -```typescript -export const itemsProcessors: ItemsProcessors = {}; +< b-virtual-scroll + < template #empty + < .&__empty + There is no data to render ``` -**your-project/components/base/b-virtual-scroll/const.ts** +4. `retry` This slot is displayed if the component data request error occurs. -```typescript -import { itemsProcessors } from '@v4fire/client/components/base/b-virtual-scroll/const.ts' - -export const itemsProcessors: ItemsProcessors = { - ...itemsProcessors, - - migrateCardComponent: (items: ComponentItem[]) => { - return items.map((item) => { - if (item.item === 'b-card') { - console.warn('Deprecation: b-card is deprecated.'); - - return { - ...item, - props: convertProps(item.props), - item: 'b-mega-card' - }; - } - - return item; - }); - } -}; ``` - -> It's important to note that `itemsProcessors` functions cannot be asynchronous. - -Let's also look at another common scenario: - -**Task**: Add advertising components after certain components throughout the entire application. - -**Solution**: Instead of manually defining the `itemsFactory` function in multiple places to call a pre-prepared function, you can: - - 1. Establish an agreement with clients to mark the components before or after which advertising should be displayed using meta information of the component's abstract representation (`ComponentItem`), which will be passed from the client to the component via the `itemMeta` prop: - - ```snakeskin - < b-virtual-scroll & - // ... - :itemMeta = (data) => ({ads: data.component === 'b-card' ? 'after' : false}) - . - ``` - - 2. Implement a global `itemsProcessor` that will add advertisements based on the meta-information. - - ```typescript - import { itemsProcessors } from '@v4fire/client/components/base/b-virtual-scroll/const.ts' - - export const itemsProcessors: ItemsProcessors = { - ...itemsProcessors, - - addAds: (items: ComponentItem[]) => { - const newItems: ComponentItem[] = []; - - const adsComponent = { - item: 'b-ads', - key: current.uuid + 'ads', - type: 'item', - children: [], - props: { - // ... - } - } - - return items.map((item) => { - const itemsToPush = []; - itemsToPush.push(item); - - if (item.meta.ads === 'after') { - itemsToPush.push(adsComponent); - } - - if (item.meta.ads === 'before') { - itemsToPush.unshift(adsComponent); - } - - newItems.push(...itemsToPush); - }); - - return newItems; - } - }; - ``` - -After these steps, a neighboring advertising component will be added to all components with the appropriate `meta.ads` value. - -It is also perfectly valid to do without using global processing itemsProcessors. -To achieve this, you just need to avoid overriding the constant and instead pass processors as props: - - ```typescript - class MyPageComponent { - get itemsProcessors() { - return [ - (items: ComponentItem[]) => { - const newItems: ComponentItem[] = []; - - const adsComponent = { - item: 'b-ads', - key: current.uuid + 'ads', - type: 'item', - children: [], - props: { - // ... - } - } - - return items.map((item) => { - const itemsToPush = []; - itemsToPush.push(item); - - if (item.meta.ads === 'after') { - itemsToPush.push(adsComponent); - } - - if (item.meta.ads === 'before') { - itemsToPush.unshift(adsComponent); - } - - newItems.push(...itemsToPush); - }); - - return newItems; - } - ] - } - } - ``` - -### `request` and `requestQuery` - -To pass query parameters from the `b-virtual-scroll` component to the data provider, two props are specified: `request` and `requestQuery`. But why are there two of them, and what is the difference between them? Let's break it down: - -- `request` is a prop inherited from `iData`. When the value of this prop changes, it triggers the `initLoad` method. In the case of `b-virtual-scroll`, this is interpreted as a need to reset the component's state to its initial state and start a new lifecycle from scratch. In essence, `request` represents static request parameters for one lifecycle of the component. This prop is suitable for parameters that directly affect the need to invalidate the `b-virtual-scroll` state. - -- `requestQuery` is a prop defined by `b-virtual-scroll`. One key difference from `request` is that this prop can be a function, and whatever is returned from this function will be set as query parameters. This prop is used to implement pagination. It takes the "internal" state of `b-virtual-scroll` as input and returns query parameters. Changing this prop does not lead to the reinitialization of the component. - -The `request` prop and the result of calling the `requestQuery` function are merged together and then passed to the data provider as query parameters. - -### Component Understanding - -#### Lifecycle - -The component's lifecycle consists of several events and states. When the component is initialized and starts its initial data loading, it emits two events: `initLoadStart` and `dataLoadStart`. The `initLoadStart` event is a standard event emitted by every component and occurs each time the component's data is initially loaded. The `dataLoadStart` event is emitted for every data loading. - -1. `initLoadStart` - The initial data loading of the component has started. -2. `dataLoadStart` - The data loading of the component has started. - -After successful data loading, the following events are emitted: - -1. `convertDataToDB` - The data conversion has been performed. -2. `initLoad` - The initial data loading of the component has completed. -3. `dataLoadSuccess` - The data loading of the component has completed. - -When the `convertDataToDB` event is emitted, the component's state is already updated with the `lastLoadedRawData` field. The `initLoad` and `dataLoadSuccess` events are emitted after updating the component's state, including `VirtualScrollState.data`, `VirtualScrollState.loadPage`, and some other fields. - -After successful data loading, the component consults the `shouldStopRequestingData` method to determine whether it should stop loading further data. - -Next, the component invokes the `renderGuard` to determine if the data can be rendered or not. If the `renderGuard` allows rendering, the following events are emitted: - -1. `renderStart` - The component rendering has started. -2. `renderEngineStart` - The component rendering using the rendering engine has started. -3. `renderEngineDone` - The component rendering using the rendering engine has completed. -4. `domInsertStart` - The DOM insertion has started. -5. `domInsertDone` - The DOM insertion has completed. This event is asynchronous as it uses RAF (Request Animation Frame) for DOM insertion. -6. `renderDone` - The component rendering has finished. - -Afterward, the component waits for user actions, specifically when the user sees any component on the page. The component then calls the - -`shouldPerformDataRequest` or `shouldPerformDataRender` functions on the client side, depending on the availability of data. This process repeats until all data has been loaded and rendered. - -1. `lifecycleDone` - Occurs when all data has been loaded and rendered on the page. - -#### `renderGuard` and `loadDataOrPerformRender` - -The `b-virtual-scroll` component relies on the `renderGuard` and `loadDataOrPerformRender` functions to determine whether to render data, load data, or complete the component's lifecycle. - -The `loadDataOrPerformRender` function is the entry point for the data loading and rendering cycle. -This function consults the `renderGuard`, which determines whether data can be rendered based on the data state and provides reasons for rejection only if it has not permitted the rendering. - -Understanding `renderGuard`: - -```mermaid -flowchart TD - A["Start: renderGuard Function"] --> B["Check if dataSlice.length < chunkSize"] - B -- "True" --> C["Check if state.areRequestsStopped and state.isLastRender"] - C -- "True" --> D["Return: {result: false, reason: 'done'}"] - C -- "False" --> E["Return: {result: false, reason: 'notEnoughData'}"] - B -- "False" --> F["Check if state.isInitialRender"] - F -- "True" --> G["Return: {result: true}"] - F -- "False" --> H["Invoke shouldPerformDataRender"] - H -- "Not Defined or True" --> I["Return: {result: true}"] - H -- "False" --> J["Return: {result: false, reason: 'noPermission'}"] -``` - -Understanding `loadDataOrPerformRender`: - -```mermaid -flowchart TD - A["Start: loadDataOrPerformRender Function"] --> B["Check if state.isLastErrored"] - B -- "True" --> C["Return"] - B -- "False" --> D["Invoke renderGuard(state)"] - D --> E["Check renderGuard result"] - E -- "True" --> F["Invoke performRender()"] - E -- "False" --> G["Check renderGuard reason"] - G -- "done" --> H["Invoke onLifecycleDone()"] - G -- "notEnoughData" --> I["Check if state.areRequestsStopped"] - I -- "True" --> J["Invoke performRender() and onLifecycleDone()"] - I -- "False" --> K["Check if shouldPerformDataRequest()"] - K -- "True" --> L["Invoke initLoadNext()"] - K -- "False" --> M["Check if state.isInitialRender"] - M -- "True" --> N["Invoke performRender()"] - M -- "False" --> O["Return"] +< b-virtual-scroll + < template #retry = o + < .&__retry @click = o.ctx.reloadLast + Retry last request ``` -#### Performing Last Render - -The `b-virtual-scroll` component adheres to a strategy where it always performs a "final" rendering. -This final rendering is always triggered after the client has indicated that data requests are complete (`shouldStopRequestingData`) and the data for rendering is nearing its end or has been exhausted. -To inform the client that the current rendering cycle is the last, the component sets the `isLastRender` flag in its state to `true` before the `renderStart` event and before initiating the rendering cycle, as well as before calling `itemsFactory` and `itemsProcessors`. - -Let's consider a scenario where this can be useful. Suppose we need to render 10 items at a time, and after all data has been loaded and rendered, we need to add an advertising block. -Imagine a situation where our provider initially responds with an array of 10 items, and then with an array of 0 items. If our `shouldStopRequestingData` strategy returns `true` when the provider returns less than 10 items, the `b-virtual-scroll` component will still attempt to render, even without data. This rendering will occur with the `isLastRender` flag set to `true`. -The actual rendering through rendering engines will only happen if the chain of `itemsFactory` -> `itemsProcessors` returns components for rendering; otherwise, no rendering will occur. -Also, if no rendering occurs, certain events such as `renderEngine*` and `domInsert*` will not be emitted, as there is nothing to render or insert. -This approach guarantees that the client will always have the opportunity to insert something at the end of the feed. Below is a demonstration of this approach: +5. `renderNext` This slot is displayed if the component has data to render or requests are not stopped. +This slot can be useful if you want to provide the ability to manually request additional data. -```typescript -@component() -class pPage { - shouldStopRequestingData(state: VirtualScrollState): boolean { - return state.lastLoadedData < 10; - } - - itemsFactory(state: VirtualScrollState): ComponentItem[] { - const items: ComponentItem[] = state.lastLoadedData.map((itemData, index) => { - return { - type: 'item', - item: 'section', - props: { - id: `element-${index}` - }, - key: `item-${index}`, - children: [] - }; - }); - - if (state.isLastRender) { - items.push({ - type: 'item', - item: 'button', - props: { - id: `lastElement` - }, - key: `lastElement`, - children: [] - }) - } - - return items; - } -} ``` - -This example demonstrates the `b-virtual-scroll` component's capability to handle a final rendering phase, even when the incoming data stream has been exhausted. This flexibility allows for dynamic and versatile implementations, like adding a unique element at the end of a list, ensuring a seamless and user-centric experience. - -#### Difference between ComponentItem with type `item` and `separator` - -The component allows rendering two types of components: - -- `item` - Main component (main content). -- `separator` - Other components, such as dividers or separators. - -There is no significant difference between them, except that they are treated differently in fields like `remainingItems` in the `VirtualScrollState`. As the name suggests, the `remainingItems` property only considers components with the `item` type, while `remainingChildren` considers components with both `item` and `separator` types. - -The distinction between `item` and `separator` types is mainly used for calculating certain properties based on the type of components present in the `VirtualScrollState`, such as the number of items till the end of the scroll. - -#### Overriding in Child Layers - -The main use case for overriding in child layers is to modify the default behavior of functions or methods. - -For example, it may be useful to override the logic of `shouldStopRequestingData` if you want to implement a default logic that takes into account the `total` field of the response when making a decision. - -There may also be situations where you need to modify the `renderGuard`. Currently, the component loads data until the number of items reaches the `chunkSize` and then renders them. By overriding the `renderGuard`, you can achieve partial rendering, where the component renders the available data regardless of whether it reaches the `chunkSize`. - -### Frequently Asked Questions - -- How to assign a class to components rendered within `b-virtual-scroll`? - - To achieve this, you need to include the `class` field in the props of the component that should be rendered. You can do this by returning it from the `itemProps` function, like this: - - ```typescript - const itemProps = (this: bMyComponent) => ({ - // ... - class: this.provide.classes({'virtual-scroll-item': true}) - }) - ``` - -- Can I use only `shouldPerformDataRequest` and not use `shouldStopRequestingData`? - - Hypothetically, you can. However, this may cause issues with the `done` slot and the `lifecycleDone` event; they will not work correctly. Therefore, it is strongly recommended to separate the logic into whether data should be loaded now (`shouldPerformDataRequest`) and whether data loading is completed (all data is loaded) (`shouldStopRequestingData`). - -- Can I set `chunkSize` to 10 if the request returns 89 items at a time? - - Yes, you can. `b-virtual-scroll` will render the data in chunks until it has rendered all of it. - -- Can I set `chunkSize` to 10 if the request returns 5 items at a time? - - Yes, you can. `b-virtual-scroll` will make requests (one at a time!) until the number of loaded items is greater than or equal to the value specified in `chunkSize`. - -- Can I render a different number of items on each render cycle? - - Yes, you can. `b-virtual-scroll` provides two options: - - 1. Specify the `chunkSize` prop as a function that returns a number depending on something. Let’s say we want to render 6 elements at the first render, 12 at the second, and 18 in subsequent ones: - - ```typescript - const chunkSize = (state: VirtualScrollState) => [6, 12, 18][state.renderPage] ?? 18 - ``` - - 2. Use the `itemsFactory` prop and return any number of elements from this function. - -- Suppose I want to load 1000 data items once and not make any more requests. How can I achieve this? - - 1. Set `chunkSize` to a suitable value, for example, 10, if you want 10 components to be rendered in one rendering cycle. - - 2. Set up `dataProvider` and request parameters. - - 3. Set the `shouldStopRequestingData` function to always return `true`. - - After these manipulations, `b-virtual-scroll` will load the data using `dataProvider` once and then render all the loaded data in chunks. - -- Data loading is complete, but the components are not rendering. Why could this happen? - - 1. Ensure that your data has a format suitable for `b-virtual-scroll`, specifically `{data: any[]}`. If your data has a different format, you can convert it using the `dbConverter` prop, which should return the transformed data, or convert the data in another location, such as in the provider's post-processor. - - 2. Make sure that your `should-*` functions are correctly defined, and their conditions are met. - - 3. Ensure that your component is included in the bundle in the `index.js` file of your page or component. - - 4. Verify that there are no errors in specifying the component's name in the `item` prop and no issues with props in `itemProps`. - -- The same components are being rendered multiple times in a row. Why could this happen? - - 1. Ensure that you implement pagination using request parameters, and possibly the `requestQuery` prop. You might be loading the same data repeatedly because the request parameters are not changing. - - 2. If you have overridden `itemsFactory` and are managing the data rendering flow yourself, ensure that there are no errors in the data slice you are using for rendering. - -## Slots - -The component supports several slots for customization: - -1. The `loader` slot allows you to display different content (usually skeletons) while the data is being loaded: - - ```snakeskin - < b-virtual-scroll - < template #loader - < .&__loader - Data loading in progress - ``` - -2. The `tombstone` slot allows you to display different content (usually skeletons) that will be repeated `tombstoneCount` times while the data is being loaded: - - ```snakeskin - < b-virtual-scroll :tombstoneCount = 3 - < template #tombstone - < .&__skeleton - Skeleton - ``` - -3. The `retry` slot allows you to display different content (usually a prompt to retry loading data) when there is an error in data loading: - - ```snakeskin - < b-virtual-scroll - < template #retry - < .&__retry @click = initLoadNext - Retry last request - ``` - -4. The `empty` slot allows you to display different content when the component receives an empty data set during the initial loading: - - ```snakeskin - < b-virtual-scroll - < template #empty - < .&__empty - No data - ``` - -5. The `done` slot allows you to display different content when the component has finished loading and rendering all the data. The `done` slot -will be displayed after `lifecycleDone` event is fired: - - ```snakeskin - < b-virtual-scroll - < template #done - < .&__done - Load and render complete - ``` - -6. The `renderNext` slot allows you to display different content when the component is not loading data and has not entered the lifecycle completion state. -This slot can be useful when implementing lazy content rendering on button click: - - ```snakeskin - < b-virtual-scroll - < template #renderNext - < .&__render-next - Render next - ``` - -## API - -### Props - -#### [shouldPerformDataRender = `(state: VirtualScrollState) => state.isInitialRender || state.remainingItems === 0`] - -This function is called in the `bVirtualScroll.renderGuard` after other checks are completed. -It receives the component state as input and determines whether the component should render the next chunk of components. -The function should return a boolean value: `true` to allow the rendering of the next chunk, or `false` to prevent it. - -Example usage: - -```typescript -const shouldPerformDataRender = (state: VirtualScrollState): boolean => { - return state.isInitialRender || state.remainingItems === 0; -}; +< b-virtual-scroll + < template #retry = o + < .&__retry @click = o.ctx.renderNext + Render next ``` -#### [shouldPerformDataRequest = `(state: VirtualScrollState) => state.lastLoadedData.length > 0`] - -The `shouldPerformDataRequest` property of `bVirtualScroll` allows you to control whether the component should request additional data based on the component state. -This function allows the component to understand whether the data loading lifecycle is complete or not. - -Here's an example of how you can use `shouldPerformDataRequest`: +6. `done` This slot is displayed if the component rendered and requested all data. -```typescript -const shouldPerformDataRequest = (state: VirtualScrollState): boolean => { - // Example: Request data if the remaining items till the end is less than or equal to 10 - return state.remainingItems <= 10; -}; ``` - -In this example, the function checks the `remainingItems` property of the component state. -If the remaining number of items till the end is less than or equal to 10, it returns `true` to indicate that the component should perform a data request. -You can adjust the condition based on your specific requirements. - -By implementing the `shouldPerformDataRequest` function, you have control over when the component should request additional data. -This allows you to customize the data loading behavior based on the state of the component. - -#### [shouldStopRequestingData = `(state: VirtualScrollState) => state.lastLoadedData.length > 0`] - -This function is called on each data loading cycle. It determines whether the component should stop requesting new data. -The function should return a boolean value: `true` to stop requesting data, or `false` to continue requesting data. - -Here's an example of how you can use `shouldStopRequestingData`: - -```typescript -const shouldStopRequestingData = (state: VirtualScrollState): boolean => { - // Example: Stop requesting data when the total number of items equals the current number of loaded items - return state.lastLoadedRawData?.total === state.data.length; -}; +< b-virtual-scroll + < template + < .&__done + All data are rendered and requested ``` -In this example, the function compares the total property of `lastLoadedRawData` with the length of the data array. -If the two values are equal, it returns true to indicate that the component should stop requesting new data. -This condition suggests that all available items have been loaded, and there is no need for further data requests. - -You can customize the `shouldStopRequestingData` function to fit your specific scenario. -By implementing this function, you have control over when the component should stop requesting new data, based on the comparison between the total number of items and the current number of loaded items. - -#### [chunkSize = `10`] +## API -The amount of data required to perform one cycle of item rendering. This prop is used by the `bVirtualScroll` component to determine the number of components to render in each cycle. -It can be either a fixed number or a function that returns the number dynamically based on the component state. +Also, you can see the parent component and the component traits. -Here are some examples: +### Props -```typescript -const chunkSize = (state: VirtualScrollState): number => { - // Example 1: Incrementing chunk size for each render page - return (state.renderPage + 1) * 10; - - // Example 2: Dynamic chunk size based on the state - // Replace the condition and calculation with your custom logic - if (state.isInitialRender) { - return 20; - } else if (state.renderPage < 3) { - return 15; - } else { - return 10; - } -}; -``` +#### [cacheSize = `400`] -In Example 1, the chunk size increases by 10 for each render page. For the initial render, it will be 10, then 20, 30, and so on. -In Example 2, the chunk size is dynamically determined based on the component state. It assigns different chunk sizes based on different conditions. +The maximum number of elements to cache. -By using a function for `chunkSize`, you have the flexibility to adjust the rendering behavior based on the state of the component and other factors. +#### [renderGap = `10`] -#### [requestQuery] +Number of elements till the page bottom that should initialize a new render iteration. -- Type: `Function` -- Default: `undefined` +#### [chunkSize = `10`] -A function that returns the GET parameters for a request. This function is called for each request and receives the current component state as input. -It should return an object containing the request parameters. These parameters will be merged with the parameters from the `request` prop, giving priority to the `request` prop. +Number of elements per one render chunk. -Pagination example: +#### [tombstonesSize] -```typescript -const requestQuery = (state: VirtualScrollState): Dictionary => { - return { - get: { - page: state.loadPage, - limit: 10 - // Other pagination parameters - } - }; -}; -``` +Number of tombstones to render. -#### [itemsFactory] +#### [clearNodes = `false`] -A factory function used to generate an array of `ComponentItem` objects representing the components to be rendered. -This function is called during the rendering process and receives the component state and context as arguments. It should return an array of `ComponentItem` objects. +If true, then elements are dropped from a DOM tree after scrolling. +This method is recommended to use if you need to display a huge number of elements and prevent an OOM error. -The default implementation uses the `chunkSize` and `iItems` trait props to slice the data and generate the components. -However, you can override this function to implement a custom rendering strategy. +#### [cacheNodes = `true`] -Here's an example of how you can use the itemsFactory property to generate ComponentItem objects based on the `lastLoadedData` property: +If true, then created nodes are cached. -```typescript -const itemsFactory = (state: VirtualScrollState): ComponentItem[] => { - const items: ComponentItem[] = state.lastLoadedData.map((itemData, index) => { - // Construct a ComponentItem object for each item in the lastLoadedData array - return { - type: 'item', - item: 'b-button', - props: { - id: `button-${index}` - }, - key: `item-${index}`, - children: { - default: `Item ${index + 1}` - } - }; - }); - - return items; -}; -``` +#### [requestQuery] -#### [itemsProcessors = `{}`] +A function that returns parameters to make a request. -This prop is a middleware function that is called after `b-virtual-scroll` has compiled the abstract representation of components, and before it passes this representation to the rendering engine. +#### [getData] -This function can be useful in cases where you need to implement some processing of the abstract representation of components, such as mutating props or adding additional components. +A function to request a new data chunk to render. -#### `tombstoneCount` +#### [shouldMakeRequest] -- Type: `number` -- Default: `undefined` +When this function returns true the component will be able to request additional data. -Specifies the number of times the `tombstone` component will be rendered. This prop can be useful if you want to render multiple `tombstone` components using a single specified element. -For example, if you set `tombstoneCount` to 3, then three `tombstone` components will be rendered on your page. +#### [shouldStopRequest] -Note: The `tombstone` component is used to represent empty or unloaded components in the virtual scroll. It is rendered as a placeholder until the actual component data is loaded and rendered. +When this function returns true the component will stop to request new data. ### Methods -#### getNextDataSlice - -Returns the next data slice that should be rendered based on the `chunkSize`. - -#### getVirtualScrollState - -Returns the current state of the component. - -#### initLoadNext - -Initializes the loading of the next data chunk. In case the loading fails, calling this method again will attempt to reload it. - -### Other Properties - -The `bVirtualScroll` class extends `iData` and includes additional properties related to slots, component state, and observers. Please refer to the documentation of `iData` for more details on those properties. - -## Migration from `b-virtual-scroll` version 3.x.x - -### API Migration - -- Prop `renderGap` deleted -> use `shouldPerformDataRender`; -- Prop `shouldRequestMore` deleted -> use `shouldPerformDataRequest`; -- Prop `shouldStopRequest` deleted -> use `shouldStopRequestingData`; -- Prop `getData` was removed; -- Deprecated props `option-like` deleted -> use `iItems` props; -- Method renamed `getDataStateSnapshot` -> `getVirtualScrollState`; -- Method `reloadLast` -> `initLoadNext`; -- `VirtualItemEl` interface is removed. Now, the client receives a single data item in the `iItems` methods. To maintain logic with `current`, `prev`, `next`, you can use the following approach: - - ```typescript - function getProps(dataItem: DataInterface, index: number): Dictionary { - const - {data, lastLoadedData} = this.$refs.scroll.getVirtualScrollState(); - - const - current = dataItem, - /* Retrieve the previous data element relative to the given */ - prev = data[(data.length - lastLoadedData.length + i) - 1], - /* Retrieve the next data element relative to the given */ - next = lastLoadedData[i + 1]; - } - ``` - -- Interface `DataState` -> `VirtualScrollState`: - - `DataState.currentPage` -> `VirtualScrollState.loadPage`; - - `DataState.lastLoadedChunk.raw` -> `VirtualScrollState.lastLoadedRawData`; - - etc. - -## What's Next - -The component currently lacks some features that may improve its functionality and make it more suitable for different scenarios. - -### Streaming Data Rendering - -- Planned for implementation. - -There is a request for streaming data rendering from the server. -This can be implemented using the standard V4 `dataProvider` API, but it requires further modifications to the component to handle streaming data events. - -### Alternative Approach to Component Rendering - -- Planned as an experiment. +#### reInit -Currently, the component uses the `iBlock.vdom` API, which creates a new rendering engine instance for each chunk. -It is hypothetically possible to reuse the rendering engine instead. However, there are challenges to consider. -For example, the Vue 3 rendering engine removes previously rendered DOM nodes and destroys components when attempting to use the rendering function and `forceUpdate` with a different VNode to render. +Re-initializes the component. -### Partial Rendering (can be achieved easily through `renderGuard`) +#### reloadLast -- Not planned for implementation. +Reloads the last request (if there is no `db` or `options` the method calls reload). -Currently, the component loads data until the number of items reaches the `chunkSize` and then renders them. By overriding the `renderGuard`, you can achieve partial rendering, where the component renders the available data regardless of whether it reaches the `chunkSize`. +#### renderNext -### Updating Nodes in the DOM Tree (describe implementation challenges, component allows inserting different components) +Tries to render the next data chunk. +The method emits a new request for data if necessary. -- Planned as an experiment. +#### getCurrentDataState -Currently, `b-virtual-scroll` does not remove old nodes when rendering new chunks within the same lifecycle. Implementing this feature is not a priority, but it should not be ignored either. The main reasons why this feature was not included in the initial release are: +Returns an object with the current data state of the component. -- Previous experiments showed no performance degradation after rendering and inserting 30x(5-8) components into the DOM tree. -- The inability to reuse DOM nodes: typical components allow reusing DOM nodes, but `b-virtual-scroll` enables clients to easily render different components. It is important to note that reusing DOM nodes provides the greatest benefit, not just simple insertion/removal of entire sections from the DOM tree. -- The need to implement two-way data rendering: Since memory is limited, storing a large number of rendered components in memory is not ideal. This requires destroying previously rendered components and then rendering them again. However, this approach can cause delays when scrolling back up. -- Since scroll events need to be used to render data, additional heuristics or props indicating the scroll direction and the number of columns being rendered may need to be added to correctly maintain the node map. +#### getItemAttrs -### Integration with RTX +Returns additional props to pass to an item component. -- High priority. +#### getItemComponentName -Why have `b-virtual-scroll` without RTX? +Returns a component name to render an item. diff --git a/src/components/base/b-virtual-scroll/b-virtual-scroll.ss b/src/components/base/b-virtual-scroll/b-virtual-scroll.ss index b218ad189b..3c2f2d4590 100644 --- a/src/components/base/b-virtual-scroll/b-virtual-scroll.ss +++ b/src/components/base/b-virtual-scroll/b-virtual-scroll.ss @@ -19,7 +19,7 @@ ref = tombstones | v-if = $slots['tombstone'] . - < .&__tombstone v-for = i in tombstoneCount || chunkSize + < .&__tombstone v-for = i in tombstonesSize || chunkSize += self.slot('tombstone') < .&__loader & diff --git a/src/components/base/b-virtual-scroll/b-virtual-scroll.ts b/src/components/base/b-virtual-scroll/b-virtual-scroll.ts index e8bd54b9d6..1568a4b64a 100644 --- a/src/components/base/b-virtual-scroll/b-virtual-scroll.ts +++ b/src/components/base/b-virtual-scroll/b-virtual-scroll.ts @@ -12,401 +12,481 @@ */ import symbolGenerator from 'core/symbol'; -import type { AsyncOptions } from 'core/async'; -import type iItems from 'components/traits/i-items/i-items'; -import VDOM, { create, render } from 'components/friends/vdom'; -import { iVirtualScrollHandlers } from 'components/base/b-virtual-scroll/handlers'; -import { bVirtualScrollAsyncGroup, bVirtualScrollDomInsertAsyncGroup, componentModes, renderGuardRejectionReason } from 'components/base/b-virtual-scroll/const'; -import type { VirtualScrollState, RenderGuardResult, $ComponentRefs, UnsafeBVirtualScroll, ItemsProcessors, ComponentMode } from 'components/base/b-virtual-scroll/interface'; +import DOM, { watchForIntersection, appendChild } from 'components/friends/dom'; +import VDOM, { render, create } from 'components/friends/vdom'; +import Block, { getFullElementName } from 'components/friends/block'; -import { ComponentTypedEmitter, componentTypedEmitter } from 'components/base/b-virtual-scroll/modules/emitter'; -import { ComponentInternalState } from 'components/base/b-virtual-scroll/modules/state'; -import { SlotsStateController } from 'components/base/b-virtual-scroll/modules/slots'; -import { ComponentFactory } from 'components/base/b-virtual-scroll/modules/factory'; -import { Observer } from 'components/base/b-virtual-scroll/modules/observer'; +import iItems, { IterationKey } from 'components/traits/i-items/i-items'; -import iData, { component, system, watch, wait, RequestParams, UnsafeGetter } from 'components/super/i-data/i-data'; +import iData, { + + component, + computed, + prop, + system, + field, + watch, + wait, + hook, + + RequestParams, + RequestError, + + InitLoadOptions, + RetryRequestFn, + CheckDBEquality, + + UnsafeGetter + +} from 'components/super/i-data/i-data'; + +import ComponentRender from 'components/base/b-virtual-scroll/modules/component-render'; +import ChunkRender from 'components/base/b-virtual-scroll/modules/chunk-render'; +import ChunkRequest from 'components/base/b-virtual-scroll/modules/chunk-request'; + +import { getRequestParams, isAsyncReplaceError } from 'components/base/b-virtual-scroll/modules/helpers'; + +import type { + + GetData, + RemoteData, + + RequestFn, + RequestQueryFn, + + LocalState, + LoadStrategy, + + DataState, + MergeDataStateParams, + + UnsafeBVirtualScroll + +} from 'components/base/b-virtual-scroll/interface'; -export * from 'components/base/b-virtual-scroll/interface'; -export * from 'components/base/b-virtual-scroll/const'; export * from 'components/super/i-data/i-data'; +export * from 'components/base/b-virtual-scroll/modules/helpers'; +export * from 'components/base/b-virtual-scroll/interface'; + +export { RequestFn, RemoteData, RequestQueryFn, GetData }; -const $$ = symbolGenerator(); +DOM.addToPrototype(watchForIntersection, appendChild); +VDOM.addToPrototype(render, create); +Block.addToPrototype(getFullElementName); -VDOM.addToPrototype({create, render}); +const + $$ = symbolGenerator(); @component() -export default class bVirtualScroll extends iVirtualScrollHandlers implements iItems { +export default class bVirtualScroll extends iData implements iItems { + /** {@link iItems.Item} */ + readonly Item!: object; - /** {@link componentTypedEmitter} */ - @system((ctx) => componentTypedEmitter(ctx)) - protected readonly componentEmitter!: ComponentTypedEmitter; + /** {@link iItems.Items} */ + readonly Items!: Array; - /** {@link SlotsStateController} */ - @system((ctx) => new SlotsStateController(ctx)) - protected readonly slotsStateController!: SlotsStateController; + override readonly DB!: RemoteData; - /** {@link ComponentInternalState} */ - @system((ctx) => new ComponentInternalState(ctx)) - protected readonly componentInternalState!: ComponentInternalState; + override readonly checkDBEquality: CheckDBEquality = false; - /** {@link ComponentFactory} */ - @system((ctx) => new ComponentFactory(ctx)) - protected readonly componentFactory!: ComponentFactory; + /** {@link LoadStrategy} */ + @prop({type: String, watch: 'syncPropsWatcher'}) + readonly loadStrategy: LoadStrategy = 'scroll'; - /** {@link Observer} */ - @system((ctx) => new Observer(ctx)) - protected readonly observer!: Observer; + /** {@link iItems.item} */ + @prop({type: [String, Function], required: false}) + readonly item?: iItems['item']; - protected override readonly $refs!: iData['$refs'] & $ComponentRefs; + /** {@link iItems.itemKey} */ + @prop({type: [String, Function], required: false}) + readonly itemKey?: iItems['itemKey']; - // @ts-ignore (getter instead readonly) - override get requestParams(): iData['requestParams'] { - return { - get: { - ...this.requestQuery?.(this.getVirtualScrollState())?.get, - ...Object.isDictionary(this.request?.get) ? this.request?.get : undefined - } - }; - } + /** {@link iItems.itemProps} */ + @prop({type: [Function, Object], default: () => ({})}) + readonly itemProps!: iItems['itemProps']; - override get unsafe(): UnsafeGetter> { - return Object.cast(this); - } + /** {@link iItems.items} */ + @prop(Array) + readonly itemsProp: this['Items'] = []; /** - * {@link ComponentMode} + * The maximum number of elements to cache */ - get componentMode(): ComponentMode { - return this.items ? componentModes.items : componentModes.dataProvider; - } + @prop({type: Number, watch: 'syncPropsWatcher', validator: Number.isNatural}) + readonly cacheSize: number = 400; /** - * Initializes the loading of the next data chunk - * @throws {@link ReferenceError} if there is no `dataProvider` set. + * Number of elements till the page bottom that should initialize a new render iteration */ - initLoadNext(): CanUndef> { - if (!this.dataProvider) { - throw ReferenceError('Missing dataProvider'); - } + @prop({type: Number, validator: Number.isNatural}) + readonly renderGap: number = 10; - const - state = this.getVirtualScrollState(); + /** + * Number of elements per one render chunk + */ + @prop({type: Number, validator: Number.isNatural}) + readonly chunkSize: number = 10; - if (state.isLoadingInProgress) { - return; - } + /** + * Number of tombstones to render + */ + @prop({type: Number, required: false, validator: Number.isNatural}) + readonly tombstonesSize?: number; - if (this.db == null) { - return this.initLoad(); - } + /** + * If true, then elements are dropped from a DOM tree after scrolling. + * This method is recommended to use if you need to display a huge number of elements and prevent an OOM error. + */ + @prop(Boolean) + readonly clearNodes: boolean = false; - this.onDataLoadStart(false); + /** + * If true, then created nodes are cached + */ + @prop({type: Boolean, watch: 'syncPropsWatcher'}) + readonly cacheNodes: boolean = true; - const - params = this.getRequestParams(), - get = this.dataProvider.get(params[0], {...params[1], showProgress: false}); - - return get - .then((res) => { - if (res == null) { - return; - } - - this.onDataLoadSuccess(false, this.convertDataToDB(res)); - }) - .catch(stderr); - } + /** + * Function that returns parameters to make a request + */ + @prop({type: Function, required: false}) + readonly requestQuery?: RequestQueryFn; + + @prop({type: [Object, Array], required: false}) + override readonly request?: RequestParams; /** - * Returns the internal component state - * {@link VirtualScrollState} + * Function to request a new data chunk to render */ - getVirtualScrollState(): Readonly { - return this.componentInternalState.compile(); - } + @prop({type: Function, default: (ctx: bVirtualScroll, query) => ctx.dataProvider?.get(query), required: false}) + readonly getData!: GetData; /** - * Returns the next slice of data that should be rendered - * - * @param state - * @param chunkSize + * When this function returns true the component will be able to request additional data */ - getNextDataSlice(state: VirtualScrollState, chunkSize: number): object[] { - const - nextDataSliceStartIndex = this.componentInternalState.getDataCursor(), - nextDataSliceEndIndex = nextDataSliceStartIndex + chunkSize; + @prop({type: Function, default: (v: DataState) => v.itemsTillBottom <= 10 && !v.isLastEmpty}) + readonly shouldMakeRequest!: RequestFn; - return state.data.slice(nextDataSliceStartIndex, nextDataSliceEndIndex); + /** + * When this function returns true the component will stop to request new data + */ + @prop({type: Function, default: (v) => v.isLastEmpty}) + readonly shouldStopRequest!: RequestFn; + + /** {@link iItems.items} */ + @computed({dependencies: ['itemsStore']}) + get items(): this['Items'] { + return this.itemsStore ?? []; + } + + /** {@link iItems.items} */ + set items(value: this['Items']) { + this.field.set('itemsStore', value); + } + + override get unsafe(): UnsafeGetter> { + return Object.cast(this); } + /** {@link iItems.items} */ + @field((o) => o.sync.link()) + protected itemsStore!: iItems['items']; + /** - * Returns the chunk size that should be rendered - * @param state - current lifecycle state. + * Total amount of items that can be loaded */ - getChunkSize(state: VirtualScrollState): number { - return Object.isFunction(this.chunkSize) ? - this.chunkSize(state, this) : - this.chunkSize; - } + @system() + protected total?: number; /** - * Returns an items processors - * @returns + * Local component state */ - getItemsProcessors(): CanUndef { - return this.itemsProcessors; + protected get localState(): LocalState { + return this.localStateStore; } - override reload(...args: Parameters): ReturnType { - this.componentStatus = 'loading'; - return super.reload(...args); + /** + * @param state + * @emits `localEmitter:localState.loading()` + * @emits `localEmitter:localState.ready()` + * @emits `localEmitter:localState.error()` + */ + protected set localState(state: LocalState) { + this.localStateStore = state; + this.localEmitter.emit(`localState.${state}`); } - @watch({path: 'items', provideArgs: false}) - override initLoad(...args: Parameters): ReturnType { - if (!this.lfc.isBeforeCreate()) { - this.reset(); - } + /** + * Local component state store + */ + @system() + protected localStateStore: LocalState = 'init'; - this.componentInternalState.setIsLoadingInProgress(true); + // @ts-ignore (getter instead readonly) + override get requestParams(): RequestParams { + return { + get: { + ...this.requestQuery?.(this.getDataStateSnapshot())?.get, + ...Object.isDictionary(this.request?.get) ? this.request?.get : undefined + } + }; + } - const - initLoadResult = super.initLoad(...args); + /** + * API for scroll rendering + */ + @system((o) => new ChunkRender(o)) + protected chunkRender!: ChunkRender; - if (this.componentMode === componentModes.items) { - if (Object.isPromise(initLoadResult)) { - return initLoadResult - .then(() => this.initItems()) - .catch(stderr); - } + /** + * API for scroll data requests + */ + @system((o) => new ChunkRequest(o)) + protected chunkRequest!: ChunkRequest; - return this.initItems(); - } + /** + * API for dynamic component rendering + */ + @system((o) => new ComponentRender(o)) + protected componentRender!: ComponentRender; + + protected override readonly $refs!: iData['$refs'] & { + container: HTMLElement; + loader?: HTMLElement; + tombstones?: HTMLElement; + empty?: HTMLElement; + retry?: HTMLElement; + done?: HTMLElement; + renderNext?: HTMLElement; + }; - this.onDataLoadStart(true); + /** + * @param [data] + * @param [opts] + * + * @emits `chunkLoading(page: number)` + * */ + override initLoad(data?: unknown, opts?: InitLoadOptions): CanPromise { + this.async.clearAll({label: 'chunkRequest.waitForInitCalls'}); - if (Object.isPromise(initLoadResult)) { - initLoadResult - .then(() => { - if (this.db == null) { - return; - } + if (!this.lfc.isBeforeCreate()) { + this.reInit(); + } - this.onDataLoadSuccess(true, this.db); - }) - .catch(stderr); + if (this.isActivated) { + this.emit('chunkLoading', 0); } - return initLoadResult; + return super.initLoad(data, opts); } /** - * Initializes the data passed through the items prop + * Re-initializes the component */ - @wait({defer: true}) - protected initItems(): CanPromise { - if ( - this.componentMode !== componentModes.items || - !this.items - ) { - return; - } - - this.onItemsInit(this.items); - } - - protected override convertDataToDB(data: unknown): O | this['DB'] { - this.onConvertDataToDB(data); - const result = super.convertDataToDB(data); - - return result; + reInit(): void { + this.componentRender.reInit(); + this.chunkRequest.reset(); + this.chunkRender.reInit(); } /** - * Merges all request parameters from the component fields `requestProp` and `requestQuery` - * {@link RequestParams} + * Reloads the last request (if there is no `db` or `options` the method calls reload) */ - protected getRequestParams(): RequestParams { - const label: AsyncOptions = { - label: $$.initLoadNext, - group: bVirtualScrollAsyncGroup, - join: 'replace' - }; + reloadLast(): void { + if (!this.db || this.chunkRequest.data.length === 0) { + this.reload().catch(stderr); - const defParams = this.dataProvider?.getDefaultRequestParams('get'); - - if (Array.isArray(defParams)) { - Object.assign(defParams[1], label); + } else { + this.chunkRequest.reloadLast(); } - - return defParams; } /** - * Short-hand wrapper for calling {@link bVirtualScroll.shouldStopRequestingData}, which also caches the - * result of the call and, if {@link bVirtualScroll.shouldStopRequestingData} returns `true`, does not call - * this function again until the life cycle is updated and the state is reset. + * Tries to render the next data chunk. + * The method emits a new request for data if necessary. */ - protected shouldStopRequestingDataWrapper(): boolean { - if (this.componentMode === componentModes.items) { - this.componentInternalState.setIsRequestsStopped(true); - return true; - } - - const state = this.getVirtualScrollState(); + renderNext(): void { + const + {localState, chunkRequest, dataProvider, items} = this; - if (state.areRequestsStopped) { - return state.areRequestsStopped; + if (localState !== 'ready' || dataProvider == null && items.length === 0) { + return; } - const newVal = this.shouldStopRequestingData(state, this); - - this.componentInternalState.setIsRequestsStopped(newVal); - return newVal; + chunkRequest.try().catch(stderr); } /** - * Short-hand wrapper for calling {@link bVirtualScroll.shouldPerformDataRequest}, removing the need to pass - * state and context when calling {@link bVirtualScroll.shouldPerformDataRequest}. + * Returns an object with the current data state of the component + * + * @typeParam ITEM - data item to render + * @typeParam RAW - raw provider data */ - protected shouldPerformDataRequestWrapper(): boolean { - if (this.componentMode === componentModes.items) { - return false; + getCurrentDataState< + ITEM extends object = object, + RAW extends object = object + >(): DataState { + let overrideParams: MergeDataStateParams = {}; + + if (this.componentStatus !== 'ready' || !Object.isTruly(this.dataProvider)) { + overrideParams = { + currentPage: 0, + ...overrideParams + }; } - return this.shouldPerformDataRequest(this.getVirtualScrollState(), this); + return this.getDataStateSnapshot(overrideParams, this.chunkRequest, this.chunkRender); } /** - * Resets the component state to its initial state + * Returns additional props to pass to an item component + * + * @param el + * @param i */ - protected reset(): void { - this.onReset(); + getItemAttrs(el: this['Item'], i: number): CanUndef { + const + {itemProps} = this; + + return Object.isFunction(itemProps) ? + itemProps(el, i, { + key: this.getItemKey(el, i), + ctx: this + }) : + itemProps; } /** - * This function asks the client whether rendering can be performed. - * It is called after successful data load or when the child component enters the visible area. - * The client responds with an object indicating whether rendering is allowed or the reason for denial. - * - * Based on the result of this function, the component takes appropriate actions. For example, - * it may load data if it is not sufficient for rendering, or perform rendering if all conditions are met. + * Returns a component name to render an item * - * @param state + * @param el + * @param i */ - protected renderGuard(state: VirtualScrollState): RenderGuardResult { - const - chunkSize = this.getChunkSize(state), - dataSlice = this.getNextDataSlice(state, chunkSize); - - if (dataSlice.length < chunkSize) { - if (state.areRequestsStopped && state.isLastRender) { - return { - result: false, - reason: renderGuardRejectionReason.done - }; - } - - return { - result: false, - reason: renderGuardRejectionReason.notEnoughData - }; - } - - if (state.isInitialRender) { - return { - result: true - }; - } - - const - clientResponse = this.shouldPerformDataRender?.(state, this) ?? true; + getItemComponentName(el: this['Item'], i: number): string { + const {item} = this; + return Object.isFunction(item) ? item(el, i) : item; + } - return { - result: clientResponse, - reason: !clientResponse ? renderGuardRejectionReason.noPermission : undefined - }; + /** {@link iItems.getItemKey} */ + getItemKey(el: this['Item'], i: number): CanUndef { + return iItems.getItemKey(this, el, i); } /** - * A function that performs actions (data loading/rendering) depending - * on the result of the {@link bVirtualScroll.renderGuard} method. + * Takes a snapshot of the current data state and returns it + * + * @param [overrideParams] + * @param [chunkRequest] + * @param [chunkRender] * - * This function is the "starting point" for rendering components and is called after successful data loading - * or when rendered items enter the viewport. + * @typeParam ITEM - data item to render + * @typeParam RAW - raw provider data */ - protected loadDataOrPerformRender(): void { - const - state = this.getVirtualScrollState(); + protected getDataStateSnapshot< + ITEM extends object = object, + RAW extends unknown = unknown + >( + overrideParams?: MergeDataStateParams, + chunkRequest?: ChunkRequest, + chunkRender?: ChunkRender + ): DataState { + return getRequestParams(chunkRequest, chunkRender, overrideParams); + } - if (state.isLastErrored) { + /** @emits `chunkLoaded(lastLoadedChunk:` [[LastLoadedChunk]]`)` */ + protected override initRemoteData(): void { + if (!this.db) { return; } + this.localState = 'init'; + const - {result, reason} = this.renderGuard(state); + {data, total} = this.db; - if (result) { - return this.performRender(); - } + if (data && data.length > 0) { + const lastLoadedChunk = { + normalized: data, + raw: this.chunkRequest.lastLoadedChunk.raw + }; - if (reason === renderGuardRejectionReason.done) { - this.onLifecycleDone(); - return; - } + const params = this.getDataStateSnapshot({ + data, + total, + lastLoadedData: data, + lastLoadedChunk + }); - if (reason === renderGuardRejectionReason.notEnoughData) { - if (state.areRequestsStopped) { - this.performRender(); - this.onLifecycleDone(); + this.chunkRequest.lastLoadedChunk = lastLoadedChunk; + this.chunkRequest.shouldStopRequest(params); + this.chunkRequest.data = data; + this.total = total; - } else if (this.shouldPerformDataRequestWrapper()) { - void this.initLoadNext(); + } else { + this.chunkRequest.isLastEmpty = true; - } else if (state.isInitialRender) { - this.performRender(); - } + const + params = this.getDataStateSnapshot({isLastEmpty: true}); + + this.chunkRequest.shouldStopRequest(params); } + + this.emit('chunkLoaded', this.chunkRequest.lastLoadedChunk); + this.chunkRequest.init().catch(stderr); + } + + protected override convertDataToDB(data: object): O | this['DB'] { + this.chunkRequest.lastLoadedChunk.raw = data; + return super.convertDataToDB(data); } /** - * Renders components using {@link bVirtualScroll.componentFactory} and inserts them into the DOM tree + * Initializes rendering on the items passed to the component */ - protected performRender(): void { - this.onRenderStart(); - - const - items = this.componentFactory.produceComponentItems(), - nodes = this.componentFactory.produceNodes(items), - mounted = this.componentFactory.produceMounted(items, nodes); + @hook('mounted') + @watch(['itemsStore']) + @wait('ready', {defer: true, label: $$.initOptions}) + protected initItems(): CanPromise { + if (this.dataProvider !== undefined) { + return; + } - if (mounted.length === 0) { - return this.onRenderDone(); + if (this.localState === 'ready') { + this.reInit(); } - this.observer.observe(mounted); - this.onDomInsertStart(mounted); + this.chunkRequest.lastLoadedChunk.normalized = Object.isArray(this.items) ? [...this.items] : []; + this.chunkRequest.init().catch(stderr); + } + /** + * Synchronization of the component props + */ + @wait('ready', {defer: true, label: $$.syncPropsWatcher}) + protected syncPropsWatcher(): CanPromise { + return this.reInit(); + } + + protected override syncDataProviderWatcher(initLoad?: boolean): void { const - fragment = document.createDocumentFragment(), - {renderPage} = this.getVirtualScrollState(), - asyncGroup = `${bVirtualScrollDomInsertAsyncGroup}:${renderPage}`; - - nodes.forEach((node) => { - this.dom.appendChild(fragment, node, { - group: asyncGroup, - destroyIfComponent: true - }); - }); + provider = this.dataProviderProp; - this.async.requestAnimationFrame(() => { - this.$refs.container.appendChild(fragment); + if (provider === undefined) { + this.reInit(); - this.onDomInsertDone(); - this.onRenderDone(); + } else { + super.syncDataProviderWatcher(initLoad); + } + } + + protected override onRequestError(err: Error | RequestError, retry: RetryRequestFn): void { + super.onRequestError(err, retry); + + if (isAsyncReplaceError(err)) { + return; + } - }, {label: $$.insertDomRaf, group: asyncGroup}); + this.localState = 'error'; } } diff --git a/src/components/base/b-virtual-scroll/b-virtual-scroll_theme_demo.styl b/src/components/base/b-virtual-scroll/b-virtual-scroll_theme_demo.styl new file mode 100644 index 0000000000..ce7c80bb2d --- /dev/null +++ b/src/components/base/b-virtual-scroll/b-virtual-scroll_theme_demo.styl @@ -0,0 +1,30 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +b-virtual-scroll_theme_demo extends b-virtual-scroll + /theme &__option-el + position relative + + display flex + justify-content center + align-items center + + size 200px + margin 20px + + background-color red + + &:after + content attr(data-index) + font-size 20px + color #FFF + + /theme &__skeleton + size 200px + margin 20px + background-color gray diff --git a/src/components/base/b-virtual-scroll/const.ts b/src/components/base/b-virtual-scroll/const.ts deleted file mode 100644 index eab83f3d36..0000000000 --- a/src/components/base/b-virtual-scroll/const.ts +++ /dev/null @@ -1,184 +0,0 @@ -/*! - * V4Fire Client Core - * https://github.com/V4Fire/Client - * - * Released under the MIT license - * https://github.com/V4Fire/Client/blob/master/LICENSE - */ - -import type bVirtualScroll from 'components/base/b-virtual-scroll/b-virtual-scroll'; - -import type { ComponentItemType, VirtualScrollState, ItemsProcessors } from 'components/base/b-virtual-scroll/interface'; - -/** - * Base group for performing asynchronous operations of the component. - */ -export const bVirtualScrollAsyncGroup = 'b-virtual-scroll'; - -/** - * Group for asynchronous operations related to inserting nodes into the DOM tree. - */ -export const bVirtualScrollDomInsertAsyncGroup = `${bVirtualScrollAsyncGroup}:dom-insert`; - -/** - * Component modes. - */ -export const componentModes = { - /** - * In this mode, data is not loaded via a data provider, but instead passed in through the items prop. - */ - items: 'items', - - /** - * In this mode, data is loaded via a data provider. - */ - dataProvider: 'dataProvider' -}; - -/** - * Component data-related events (emitted in `selfEmitter`). - */ -export const componentDataLocalEvents = { - /** - * Data loading has started. - */ - dataLoadStart: 'dataLoadStart', - - /** - * An error occurred while loading data. - */ - dataLoadError: 'dataLoadError', - - /** - * Data has been successfully loaded. - */ - dataLoadSuccess: 'dataLoadSuccess', - - /** - * Successful load with no data. - */ - dataLoadEmpty: 'dataLoadEmpty' -}; - -/** - * Component events. - */ -export const componentLifecycleEvents = { - /** - * Reset component state. - */ - resetState: 'resetState', - - /** - * Trigger data conversion to the `DB`. - */ - convertDataToDB: 'convertDataToDB', - - /** - * This event is emitted when all component data is rendered and loaded. - */ - lifecycleDone: 'lifecycleDone' -}; - -/** - * Component rendering events. - */ -export const componentRenderLocalEvents = { - /** - * Rendering of items has started. - */ - renderStart: 'renderStart', - - /** - * Rendering of items has finished. - */ - renderDone: 'renderDone', - - /** - * Rendering of items has started with the render engine. - */ - renderEngineStart: 'renderEngineStart', - - /** - * Rendering of items has finished with the render engine. - */ - renderEngineDone: 'renderEngineDone', - - /** - * DOM node insertion has started. - */ - domInsertStart: 'domInsertStart', - - /** - * DOM node insertion has finished. - */ - domInsertDone: 'domInsertDone' -}; - -/** - * Events of the element observer. - */ -export const componentObserverLocalEvents = { - /** - * The element has entered the viewport. - */ - elementEnter: 'elementEnter' -}; - -export const componentEvents = { - ...componentDataLocalEvents, - ...componentRenderLocalEvents, - ...componentLifecycleEvents, - ...componentObserverLocalEvents -}; - -/** - * Reasons for rejecting a render operation. - */ -export const renderGuardRejectionReason = { - /** - * Insufficient data to perform a render (e.g., `data.length` is 5 and `chunkSize` is 12). - */ - notEnoughData: 'notEnoughData', - - /** - * All rendering operations have been completed. - */ - done: 'done', - - /** - * The client returns `false` in `shouldPerformDataRender`. - */ - noPermission: 'noPermission' -}; - -/** - * {@link ComponentItemType} - */ -export const componentItemType: ComponentItemType = { - item: 'item', - separator: 'separator' -}; - -export const defaultShouldProps = { - /** {@link bVirtualScroll.shouldStopRequestingData} */ - shouldStopRequestingData: (state: VirtualScrollState, _ctx: bVirtualScroll): boolean => { - const isLastRequestEmpty = () => state.lastLoadedData.length === 0; - return isLastRequestEmpty(); - }, - - /** {@link bVirtualScroll.shouldPerformDataRequest} */ - shouldPerformDataRequest: (state: VirtualScrollState, _ctx: bVirtualScroll): boolean => { - const isLastRequestNotEmpty = () => state.lastLoadedData.length > 0; - return isLastRequestNotEmpty(); - }, - - /** {@link bVirtualScroll.shouldPerformDataRender} */ - shouldPerformDataRender: (state: VirtualScrollState, _ctx: bVirtualScroll): boolean => - state.isInitialRender || state.remainingItems === 0 -}; - -/** - * {@link bVirtualScroll.itemsProcessors} - */ -export const itemsProcessors: ItemsProcessors = {}; diff --git a/src/components/base/b-virtual-scroll/handlers.ts b/src/components/base/b-virtual-scroll/handlers.ts deleted file mode 100644 index 136e17a64e..0000000000 --- a/src/components/base/b-virtual-scroll/handlers.ts +++ /dev/null @@ -1,234 +0,0 @@ -/*! - * V4Fire Client Core - * https://github.com/V4Fire/Client - * - * Released under the MIT license - * https://github.com/V4Fire/Client/blob/master/LICENSE - */ - -import iVirtualScrollProps from 'components/base/b-virtual-scroll/props'; - -import type bVirtualScroll from 'components/base/b-virtual-scroll/b-virtual-scroll'; -import type { MountedChild } from 'components/base/b-virtual-scroll/interface'; - -import { bVirtualScrollAsyncGroup, componentEvents } from 'components/base/b-virtual-scroll/const'; -import { isAsyncReplaceError } from 'components/base/b-virtual-scroll/modules/helpers'; - -import iData, { component } from 'components/super/i-data/i-data'; - -/** - * A class that provides an API to handle events emitted by the {@link bVirtualScroll} component. - * This class is designed to work in conjunction with {@link bVirtualScroll}. - */ -@component() -export abstract class iVirtualScrollHandlers extends iVirtualScrollProps { - /** - * Handler: component reset event. - * Resets the component state to its initial state. - */ - protected onReset(this: bVirtualScroll): void { - this.componentInternalState.reset(); - this.observer.reset(); - - this.async.clearAll({group: new RegExp(bVirtualScrollAsyncGroup)}); - - this.componentEmitter.emit(componentEvents.resetState); - } - - /** - * Handler: render start event. - * Triggered when the component rendering starts. - */ - protected onRenderStart(this: bVirtualScroll): void { - this.componentInternalState.updateIsLastRender(); - this.componentEmitter.emit(componentEvents.renderStart); - } - - /** - * Handler: render engine start event. - * Triggered when the component rendering using the rendering engine starts. - */ - protected onRenderEngineStart(this: bVirtualScroll): void { - this.componentEmitter.emit(componentEvents.renderEngineStart); - } - - /** - * Handler: render engine done event. - * Triggered when the component rendering using the rendering engine is completed. - */ - protected onRenderEngineDone(this: bVirtualScroll): void { - this.componentEmitter.emit(componentEvents.renderEngineDone); - } - - /** - * Handler: DOM insert start event. - * Triggered when the insertion of rendered components into the DOM tree starts. - * - * @param childList - */ - protected onDomInsertStart(this: bVirtualScroll, childList: MountedChild[]): void { - this.componentInternalState.updateDataOffset(); - this.componentInternalState.updateMounted(childList); - this.componentInternalState.setIsInitialRender(false); - this.componentInternalState.incrementRenderPage(); - - this.componentEmitter.emit(componentEvents.domInsertStart); - } - - /** - * Handler: DOM insert done event. - * Triggered when the insertion of rendered components into the DOM tree is completed. - */ - protected onDomInsertDone(this: bVirtualScroll): void { - this.componentEmitter.emit(componentEvents.domInsertDone); - } - - /** - * Handler: render done event. - * Triggered when rendering is completed. - */ - protected onRenderDone(this: bVirtualScroll): void { - this.componentEmitter.emit(componentEvents.renderDone); - } - - /** - * Handler: lifecycle done event. - * Triggered when the internal lifecycle of the component is completed. - */ - protected onLifecycleDone(this: bVirtualScroll): void { - const - state = this.getVirtualScrollState(); - - if (state.isLifecycleDone) { - return; - } - - this.slotsStateController.doneState(); - this.componentInternalState.setIsLifecycleDone(true); - this.componentEmitter.emit(componentEvents.lifecycleDone); - } - - /** - * Handler: convert data to database event. - * Triggered when the loaded data is converted. - * - * @param data - the converted data. - */ - protected onConvertDataToDB(this: bVirtualScroll, data: unknown): void { - this.componentInternalState.setRawLastLoaded(data); - this.componentEmitter.emit(componentEvents.convertDataToDB, data); - } - - /** - * Handler: data load start event. - * Triggered when data loading starts. - * - * @param isInitialLoading - indicates whether it is an initial component loading. - */ - protected onDataLoadStart(this: bVirtualScroll, isInitialLoading: boolean): void { - this.componentInternalState.setIsLoadingInProgress(true); - this.componentInternalState.setIsLastErrored(false); - this.slotsStateController.loadingProgressState(isInitialLoading); - - this.componentEmitter.emit(componentEvents.dataLoadStart, isInitialLoading); - } - - /** - * Handler: data load success event. - * Triggered when data loading is successfully completed. - * - * @param isInitialLoading - indicates whether it is an initial component loading. - * @param data - the loaded data. - * @throws {@link ReferenceError} if the loaded data does not have a "data" field. - */ - protected onDataLoadSuccess(this: bVirtualScroll, isInitialLoading: boolean, data: unknown): void { - this.componentInternalState.setIsLoadingInProgress(false); - - const - dataToProvide = Object.isPlainObject(data) ? data.data : data; - - if (!Array.isArray(dataToProvide)) { - throw new ReferenceError('Missing data to perform render'); - } - - this.componentInternalState.updateData(dataToProvide, isInitialLoading); - this.componentInternalState.incrementLoadPage(); - - const - isRequestsStopped = this.shouldStopRequestingDataWrapper(); - - this.componentEmitter.emit(componentEvents.dataLoadSuccess, dataToProvide, isInitialLoading); - - this.slotsStateController.loadingSuccessState(); - - if ( - isInitialLoading && - isRequestsStopped && - Object.size(dataToProvide) === 0 - ) { - this.onDataEmpty(); - this.onLifecycleDone(); - - } else { - this.loadDataOrPerformRender(); - } - } - - /** - * Handler: data load error event. - * Triggered when data loading fails. - * - * @param isInitialLoading - indicates whether it is an initial component loading. - */ - protected onDataLoadError(this: bVirtualScroll, isInitialLoading: boolean): void { - this.componentInternalState.setIsLoadingInProgress(false); - this.componentInternalState.setIsLastErrored(true); - this.slotsStateController.loadingFailedState(); - - this.componentEmitter.emit(componentEvents.dataLoadError, isInitialLoading); - } - - protected override onRequestError(this: bVirtualScroll, ...args: Parameters): ReturnType { - const - err = args[0]; - - if (isAsyncReplaceError(err)) { - return; - } - - const - state = this.getVirtualScrollState(); - - this.onDataLoadError(state.isInitialLoading); - return super.onRequestError(err, this.initLoad.bind(this)); - } - - /** - * Handler: data empty event. - * Triggered when the loaded data is empty. - */ - protected onDataEmpty(this: bVirtualScroll): void { - this.slotsStateController.emptyState(); - - this.componentEmitter.emit(componentEvents.dataLoadEmpty); - } - - /** - * Handler: component enters the viewport - * @param component - the component that enters the viewport. - */ - protected onElementEnters(this: bVirtualScroll, component: MountedChild): void { - this.componentInternalState.setMaxViewedIndex(component); - this.loadDataOrPerformRender(); - - this.componentEmitter.emit(componentEvents.elementEnter, component); - } - - /** - * Handler: items to render was updated - * @param items - */ - protected onItemsInit(this: bVirtualScroll, items: Exclude): void { - this.onDataLoadSuccess(true, items); - } -} diff --git a/src/components/base/b-virtual-scroll/interface.ts b/src/components/base/b-virtual-scroll/interface.ts new file mode 100644 index 0000000000..03498a508f --- /dev/null +++ b/src/components/base/b-virtual-scroll/interface.ts @@ -0,0 +1,222 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +import type bVirtualScroll from 'components/base/b-virtual-scroll/b-virtual-scroll'; +import type { UnsafeIData } from 'components/super/i-data/i-data'; + +export interface RequestQueryFn { + (params: DataState): Dictionary; +} +export interface RequestFn { + (params: DataState): boolean; +} + +export interface GetData { + (ctx: bVirtualScroll, query: CanUndef): Promise; +} + +export interface VirtualItemEl { + /** + * Current render data + */ + current: T; + + /** + * Previous render data + */ + prev: CanUndef; + + /** + * Next render data + */ + next: CanUndef; +} + +/** + * @deprecated + * {@link VirtualItemEl} + */ +export type OptionEl = VirtualItemEl; + +/** + * @typeParam ITEM - data item to render + * @typeParam RAW - raw provider data + */ +export interface DataState { + /** + * Number of the last loaded page + */ + currentPage: number; + + /** + * Number of a page to upload + */ + nextPage: number; + + /** + * All loaded data + */ + data: object[]; + + /** + * Number of items to show till the page bottom is reached + */ + itemsTillBottom: number; + + /** + * Items to render + */ + items: Array>; + + /** + * Data that pending to be rendered + */ + pendingData: object[]; + + /** + * True if the last requested data response was empty + */ + isLastEmpty: boolean; + + /** + * Last loaded data chunk + */ + lastLoadedChunk: { + /** + * Normalized data (processed with `dbConverter`) + */ + normalized: ITEM[]; + + /** + * Raw provider data + */ + raw: CanUndef; + }; + + /** + * @deprecated + * {@link DataState.lastLoadedChunk} + */ + lastLoadedData: ITEM[]; + + /** + * `total` property from the loaded data + */ + total: CanUndef; +} + +export interface RemoteData extends Dictionary { + /** + * Data to render components + */ + data?: object[]; + + /** + * Total number of elements + */ + total?: number; +} + +export interface RenderItem { + /** + * Component data + */ + data: T; + + /** + * Component DOM element + */ + node: CanUndef; + + /** + * Component destructor + */ + destructor: CanUndef; + + /** + * Component position in a DOM tree + */ + index: number; +} + +/** + * Attributes of items to render + */ +export type ItemAttrs = Dictionary; + +/** + * Last loaded data chunk + * + * @typeParam DATA - data to render + * @typeParam RAW - raw provider data + */ +export interface LastLoadedChunk { + normalized: DATA; + raw: CanUndef; +} + +export interface DataToRender { + itemAttrs: Dictionary; + itemParams: VirtualItemEl; + index: number; +} + +/** + * Local state of a component: + * + * * `error` - indicates the component loading error appear + * * `init` - indicates the component now loading the first chunk of data + * * `ready` - indicates the component now is ready to render data + */ +export type LocalState = 'init' | 'ready' | 'error'; + +/** + * The loading strategy: + * + * * `scroll` - will prompt the client to load data every time a new element appears in the viewport + * * `manual` - there is only one way to load data: by using `renderNext` method (except the initial load) + */ +export type LoadStrategy = 'scroll' | 'manual'; + +/** + * Display state of the ref + */ +export type RefDisplayState = '' | 'none'; + +/** + * `bVirtualScroll` `$refs` + */ +export type bVirtualScrollRefs = bVirtualScroll['$refs']; + +// @ts-ignore (unsafe) +export interface UnsafeBVirtualScroll extends UnsafeIData { + // @ts-ignore (access) + total: CTX['total']; + + // @ts-ignore (access) + localState: CTX['localState']; + + // @ts-ignore (access) + chunkRender: CTX['chunkRender']; + + // @ts-ignore (access) + chunkRequest: CTX['chunkRequest']; + + // @ts-ignore (access) + componentRender: CTX['componentRender']; + + // @ts-ignore (access) + getDataStateSnapshot: CTX['getDataStateSnapshot']; + + // @ts-ignore (access) + onRequestError: CTX['onRequestError']; +} + +export type MergeDataStateParams = { + [key in keyof DataState]?: DataState[key]; +}; diff --git a/src/components/base/b-virtual-scroll/interface/common.ts b/src/components/base/b-virtual-scroll/interface/common.ts deleted file mode 100644 index d2377e09f7..0000000000 --- a/src/components/base/b-virtual-scroll/interface/common.ts +++ /dev/null @@ -1,81 +0,0 @@ -/*! - * V4Fire Client Core - * https://github.com/V4Fire/Client - * - * Released under the MIT license - * https://github.com/V4Fire/Client/blob/master/LICENSE - */ - -import type bVirtualScroll from 'components/base/b-virtual-scroll/b-virtual-scroll'; - -import type { renderGuardRejectionReason } from 'components/base/b-virtual-scroll/b-virtual-scroll'; -import type { VirtualScrollState } from 'components/base/b-virtual-scroll/interface/component'; - -import type { UnsafeIData } from 'components/super/i-data/i-data'; - -/** - * Interface representing the response of the client to the `renderGuard` method for rendering requests. - * - * To grant permission for rendering, the response object should have the following structure: - * - * ```typescript - * const canPerform: RenderGuardResult = { - * result: true - * } - * ``` - * - * To deny rendering, the response object should have the following structure: - * - * ```typescript - * const canPerform: RenderGuardResult = { - * result: false, - * reason: 'notEnoughData' - * } - * ``` - * - * Based on the result of this function, the component takes appropriate actions. For example, - * it may load data if it is not sufficient for rendering, or perform rendering if all conditions are met. - */ -export interface RenderGuardResult { - /** - * If `true`, rendering is permitted; if `false`, rendering is denied. - */ - result: boolean; - - /** - * The reason for rejecting the rendering request. - */ - reason?: keyof RenderGuardRejectionReason; -} - -/** - * {@link renderGuardRejectionReason} - */ -export type RenderGuardRejectionReason = typeof renderGuardRejectionReason; - -/** - * A function used to query the client about whether to perform a specific action or not. - */ -export interface ShouldPerform { - (state: VirtualScrollState, ctx: bVirtualScroll): RES; -} - -// @ts-ignore (extend) -export interface UnsafeBVirtualScroll extends UnsafeIData { - // @ts-ignore (access) - onRenderEngineStart: CTX['onRenderEngineStart']; - // @ts-ignore (access) - onRenderEngineDone: CTX['onRenderEngineDone']; - // @ts-ignore (access) - onElementEnters: CTX['onElementEnters']; - // @ts-ignore (access) - componentEmitter: CTX['componentEmitter']; - // @ts-ignore (access) - slotsStateController: CTX['slotsStateController']; - // @ts-ignore (access) - componentInternalState: CTX['componentInternalState']; - // @ts-ignore (access) - componentFactory: CTX['componentFactory']; - // @ts-ignore (access) - observer: CTX['observer']; -} diff --git a/src/components/base/b-virtual-scroll/interface/component.ts b/src/components/base/b-virtual-scroll/interface/component.ts deleted file mode 100644 index 7f048074b4..0000000000 --- a/src/components/base/b-virtual-scroll/interface/component.ts +++ /dev/null @@ -1,336 +0,0 @@ -/*! - * V4Fire Client Core - * https://github.com/V4Fire/Client - * - * Released under the MIT license - * https://github.com/V4Fire/Client/blob/master/LICENSE - */ - -import type bVirtualScroll from 'components/base/b-virtual-scroll/b-virtual-scroll'; -import type { componentModes } from 'components/base/b-virtual-scroll/b-virtual-scroll'; - -/** - * State of the current component lifecycle. - * - * @typeParam DATA - Instance of the data element. - * @typeParam RAW_DATA - the data loaded from the server but not yet processed. - * This type parameter determines the type of the {@link VirtualScrollState.lastLoadedRawData} property - */ -export interface VirtualScrollState { - /** - * The largest component index of type `item` that appeared in the viewport. - */ - maxViewedItem: CanUndef; - - /** - * The largest component index of any type that appeared in the viewport. - */ - maxViewedChild: CanUndef; - - /** - * The number of components of type `item` that have not yet been visible to the user. - */ - remainingItems: CanUndef; - - /** - * The number of components of any type that have not yet been visible to the user. - */ - remainingChildren: CanUndef; - - /** - * The current page number for loading data. - * It changes after each successful data load. - */ - loadPage: number; - - /** - * The current page number for rendering data. - * It changes after each successful rendering. - */ - renderPage: number; - - /** - * Indicates if the last loaded data is empty. - */ - isLastEmpty: boolean; - - /** - * Indicates if the last data load ended with an error. - */ - isLastErrored: boolean; - - /** - * Indicates if the component is in the initial loading state. - */ - isInitialLoading: boolean; - - /** - * Indicates if the component is in the initial rendering state. - */ - isInitialRender: boolean; - - /** - * Indicates if the component has stopped making requests. - */ - areRequestsStopped: boolean; - - /** - * Indicates if there is an ongoing loading process. - */ - isLoadingInProgress: boolean; - - /** - * Indicates if the component's lifecycle is done, i.e., all data is rendered and loaded. - */ - isLifecycleDone: boolean; - - /** - * Indicates whether the current render process is the last one in the current lifecycle. - * - * The isLastRender flag is set to true after a request, - * when the client notifies the component that it has finished loading all its data - * ({@link VirtualScrollState.areRequestsStopped} is set to true) and there is either no data left to render - * or there is less than {@link VirtualScrollState.chunkSize} remaining to render. - * When these conditions are met, the isLastRender flag will be set to true. - */ - isLastRender: boolean; - - /** - * The last loaded data. - */ - lastLoadedData: Readonly; - - /** - * The component data. - */ - data: Readonly; - - /** - * List of all components of type `item` that have been rendered. - */ - items: Readonly; - - /** - * List of all components that have been rendered. - */ - childList: Readonly; - - /** - * The last loaded raw data. - */ - lastLoadedRawData: CanUndef; -} - -/** - * Private (not accessible to the client) component state. - * - * This state stores all the internal component state that should not be - * accessible to the client. - */ -export interface PrivateComponentState { - /** - * Pointer to the index of the data element that was last rendered. - */ - dataOffset: number; -} - -/** - * {@link componentModes} - */ -export type ComponentModes = typeof componentModes; - -/** - * {@link ComponentModes} - */ -export type ComponentMode = keyof ComponentModes; - -/** - * Types of rendered components. - */ -export interface ComponentItemType { - /** - * This type indicates that the component is the "main" component to render. - * - * For example, in the {@link VirtualScrollState} interface, you can notice that - * there are specific fields for the `item` type, such as `remainingItems`. - * - * Components with this type are stored both in the `items` array and the `childList` array in - * {@link VirtualScrollState}. - */ - item: 'item'; - - /** - * This type indicates that the component is "secondary". - * - * Components with this type are stored in the `childList` array in {@link VirtualScrollState}. - */ - separator: 'separator'; -} - -/** - * Abstract representation of a component to be rendered. - * - * To render a `b-button` component with the default slot, the following set of parameters needs to be passed: - * - * @example - * ```typescript - * const bButton = { - * type: 'item', - * item: 'b-button', - * props: { - * id: 'button' - * }, - * key: 'unique id', - * children: { - * default: 'Hello world' - * } - * } - * ``` - */ -export interface ComponentItem { - /** - * The type of the component (item or separator). - */ - type: keyof ComponentItemType; - - /** - * The name of the component, e.g., `b-button` or `section`. - */ - item: string; - - /** - * The component's properties. - */ - props?: Dictionary; - - /** - * Unique key for this component (data set). - */ - key: string; - - /** - * Children nodes of the component. - */ - children?: VNodeChildren; - - /** - * {@link ComponentItemMeta} - */ - meta?: ComponentItemMeta; -} - -/** - * Meta information for a component that will not be used during rendering, - * but will be available for reading/changing in `itemsProcessors`. - */ -export interface ComponentItemMeta extends Dictionary { - /** - * A conditionally reserved property that contains the data based - * on which this abstract representation of the component was created. - * - * If `iItems` props are used to create representations, `b-virtual-scroll` will automatically add - * this property to the `meta` parameters. - */ - readonly data?: unknown; -} - -/** - * Represents any mounted component (item or separator) within the DOM tree. - */ -export interface MountedChild extends ComponentItem { - /** - * The DOM node associated with the component. - */ - node: HTMLElement; - - /** - * The index of the component within the list of children. - */ - childIndex: number; -} - -/** - * Represents a mounted item component within the DOM tree. - */ -export interface MountedItem extends MountedChild { - /** - * The index of the item within the list of items. - */ - itemIndex: number; -} - -/** - * Represents the nodes of a component. - */ -export interface ComponentRefs { - /** - * The container element in which components are rendered. - */ - container: HTMLElement; - - /** - * The slot that is displayed while data is being loaded. - */ - loader?: HTMLElement; - - /** - * The slot that is displayed for tombstones. - */ - tombstones?: HTMLElement; - - /** - * The slot that is displayed when data loading is complete and there is no data. - */ - empty?: HTMLElement; - - /** - * The slot that is displayed when a data loading error occurs. - */ - retry?: HTMLElement; - - /** - * The slot that is displayed when all data is loaded and rendered. - */ - done?: HTMLElement; - - /** - * The slot that is displayed when there is no active loading. - */ - renderNext?: HTMLElement; -} - -export type $ComponentRefs = ComponentRefs & Dictionary; - -/** - * The type of data stored by the component. - */ -export interface ComponentDb { - /** - * The component data. - */ - data: unknown[]; - - /** - * The total number of data items. - */ - total?: number; -} - -/** - * Typeof {@link bVirtualScroll.itemsFactory}. - */ -export interface ComponentItemFactory { - (state: VirtualScrollState, ctx: bVirtualScroll): ComponentItem[]; -} - -/** - * A middleware function used to modify elements compiled within {@link bVirtualScroll.itemsFactory}. - */ -export interface ItemsProcessor { - (componentItems: ComponentItem[], ctx: bVirtualScroll): ComponentItem[]; -} - -/** - * Type for {@link bVirtualScroll.itemsProcessors}. - */ -export type ItemsProcessors = ItemsProcessor | Record | ItemsProcessor[]; diff --git a/src/components/base/b-virtual-scroll/interface/events.ts b/src/components/base/b-virtual-scroll/interface/events.ts deleted file mode 100644 index 719024069a..0000000000 --- a/src/components/base/b-virtual-scroll/interface/events.ts +++ /dev/null @@ -1,76 +0,0 @@ -/*! - * V4Fire Client Core - * https://github.com/V4Fire/Client - * - * Released under the MIT license - * https://github.com/V4Fire/Client/blob/master/LICENSE - */ - -import type { MountedChild } from 'components/base/b-virtual-scroll/interface/component'; - -import { - - componentDataLocalEvents, - componentLifecycleEvents, - componentObserverLocalEvents, - componentRenderLocalEvents - -} from 'components/base/b-virtual-scroll/const'; - -/** - * {@link componentDataLocalEvents} - */ -export type ComponentDataLocalEvents = typeof componentDataLocalEvents; - -/** - * {@link componentLifecycleEvents} - */ -export type ComponentLifecycleEvents = typeof componentLifecycleEvents; - -/** - * {@link componentRenderLocalEvents} - */ -export type ComponentRenderLocalEvents = typeof componentRenderLocalEvents; - -/** - * {@link componentObserverLocalEvents} - */ -export type ComponentObserverLocalEvents = typeof componentObserverLocalEvents; - -/** - * Possible component events. - */ -export type ComponentEvents = - keyof ComponentDataLocalEvents | - keyof ComponentLifecycleEvents | - keyof ComponentRenderLocalEvents | - keyof ComponentObserverLocalEvents; - -/** - * Mapping of event names and their event arguments. - * [Event Name: Event Arguments] - */ -export interface LocalEventPayloadMap { - [componentDataLocalEvents.dataLoadSuccess]: [data: object[], isInitialLoading: boolean]; - [componentDataLocalEvents.dataLoadStart]: [isInitialLoading: boolean]; - [componentDataLocalEvents.dataLoadError]: [isInitialLoading: boolean]; - [componentDataLocalEvents.dataLoadEmpty]: []; - - [componentLifecycleEvents.resetState]: []; - [componentLifecycleEvents.lifecycleDone]: []; - [componentLifecycleEvents.convertDataToDB]: [data: unknown]; - - [componentObserverLocalEvents.elementEnter]: [componentItem: MountedChild]; - - [componentRenderLocalEvents.renderStart]: []; - [componentRenderLocalEvents.renderDone]: []; - [componentRenderLocalEvents.renderEngineStart]: []; - [componentRenderLocalEvents.renderEngineDone]: []; - [componentRenderLocalEvents.domInsertStart]: []; - [componentRenderLocalEvents.domInsertDone]: []; -} - -/** - * Returns the type of event arguments. - */ -export type LocalEventPayload = LocalEventPayloadMap[T]; diff --git a/src/components/base/b-virtual-scroll/interface/index.ts b/src/components/base/b-virtual-scroll/interface/index.ts deleted file mode 100644 index 9ea53371bd..0000000000 --- a/src/components/base/b-virtual-scroll/interface/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -/*! - * V4Fire Client Core - * https://github.com/V4Fire/Client - * - * Released under the MIT license - * https://github.com/V4Fire/Client/blob/master/LICENSE - */ - -export * from 'components/base/b-virtual-scroll/interface/events'; -export * from 'components/base/b-virtual-scroll/interface/component'; -export * from 'components/base/b-virtual-scroll/interface/requests'; -export * from 'components/base/b-virtual-scroll/interface/common'; diff --git a/src/components/base/b-virtual-scroll/interface/requests.ts b/src/components/base/b-virtual-scroll/interface/requests.ts deleted file mode 100644 index 4d39dd224f..0000000000 --- a/src/components/base/b-virtual-scroll/interface/requests.ts +++ /dev/null @@ -1,27 +0,0 @@ -/*! - * V4Fire Client Core - * https://github.com/V4Fire/Client - * - * Released under the MIT license - * https://github.com/V4Fire/Client/blob/master/LICENSE - */ - -import type { CreateRequestOptions, RequestQuery } from 'components/super/i-data/i-data'; -import type { VirtualScrollState } from 'components/base/b-virtual-scroll/interface/component'; - -/** - * Function that returns the GET parameters for a request. - */ -export interface RequestQueryFn { - /** - * Returns the GET parameters for a request. - * - * @param state - the component state. - */ - (state: VirtualScrollState): Dictionary; -} - -/** - * Requests parameters. - */ -export type VirtualScrollRequestParams = [RequestQuery, CreateRequestOptions]; diff --git a/src/components/base/b-virtual-scroll/modules/chunk-render.ts b/src/components/base/b-virtual-scroll/modules/chunk-render.ts new file mode 100644 index 0000000000..934697009a --- /dev/null +++ b/src/components/base/b-virtual-scroll/modules/chunk-render.ts @@ -0,0 +1,403 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +import symbolGenerator from 'core/symbol'; + +import type { WatchOptions } from 'core/dom/intersection-watcher'; + +import Friend from 'components/friends/friend'; +import DOM, { watchForIntersection, appendChild } from 'components/friends/dom'; + +import type iBlock from 'components/super/i-block/i-block'; +import type bVirtualScroll from 'components/base/b-virtual-scroll/b-virtual-scroll'; + +import type ComponentRender from 'components/base/b-virtual-scroll/modules/component-render'; +import type ChunkRequest from 'components/base/b-virtual-scroll/modules/chunk-request'; + +import type { RenderItem, VirtualItemEl } from 'components/base/b-virtual-scroll/interface'; + +DOM.addToPrototype({appendChild, watchForIntersection}); + +const + $$ = symbolGenerator(); + +export default class ChunkRender extends Friend { + override readonly C!: bVirtualScroll; + + /** + * Render items + */ + items: RenderItem[] = []; + + /** + * Index of the last element that intersects the viewport + */ + lastIntersectsItem: number = 0; + + /** + * Chunk number of the current render + */ + chunk: number = 0; + + /** + * Last rendered range + */ + lastRenderRange: number[] = [0, 0]; + + /** + * Async group + */ + readonly asyncGroup: string = 'scroll-render:'; + + /** + * Number of items + */ + get itemsCount(): number { + return this.items.length; + } + + /** + * Async in-view label prefix + */ + protected readonly asyncInViewPrefix: string = 'in-view:'; + + /** + * Refs state update map + */ + protected refsUpdateMap: Map = new Map(); + + /** + * API for dynamic component rendering + */ + protected get componentRender(): ComponentRender { + return this.ctx.componentRender; + } + + /** + * API for scroll data requests + */ + protected get chunkRequest(): ChunkRequest { + return this.ctx.chunkRequest; + } + + /** + * Returns a random threshold number + */ + protected get randomThreshold(): number { + return Math.floor((Math.random() * (0.06 - 0.01) + 0.01) * 100) / 100; + } + + constructor(component: iBlock) { + super(component); + this.component.on('hook:mounted', this.initEventHandlers.bind(this)); + } + + /** + * Re-initializes the rendering process + */ + reInit(): void { + this.lastIntersectsItem = 0; + this.lastRenderRange = [0, 0]; + this.chunk = 0; + this.items = []; + this.refsUpdateMap = new Map(); + + this.async.clearAll({group: new RegExp(this.asyncGroup)}); + + this.setLoadersVisibility(true, true); + this.setRefVisibility('retry', false, true); + this.setRefVisibility('done', false, true); + this.setRefVisibility('empty', false, true); + this.setRefVisibility('renderNext', false, true); + + this.initEventHandlers(); + } + + /** + * Initializes render items + * @param data + */ + initItems(data: unknown[]): void { + this.items = this.items.concat(data.map(this.createRenderItem.bind(this))); + } + + /** + * Renders the component content + * + * @emits `chunkRender:renderStart(chunkNumber: number)` + * @emits `chunkRender:renderComplete(chunkNumber: number)` + * @emits `chunkRender:beforeMount(chunkNumber: number)` + * @emits `chunkRender:mounted(renderItems:` [[RenderItem]]`[], chunkNumber: number)` + */ + render(): void { + if (this.ctx.localState !== 'ready') { + return; + } + + const + {ctx, chunk, items} = this; + + const + renderFrom = (chunk - 1) * ctx.chunkSize, + renderTo = chunk * ctx.chunkSize, + renderItems = items.slice(renderFrom, renderTo); + + if ( + renderFrom === this.lastRenderRange[0] && + renderTo === this.lastRenderRange[1] || + renderItems.length === 0 + ) { + return; + } + + const + currentChunk = this.chunk; + + this.chunk++; + this.lastRenderRange = [renderFrom, renderTo]; + + ctx.emit('chunkRender:renderStart', currentChunk); + + const + nodes = this.renderItems(renderItems); + + ctx.emit('chunkRender:renderComplete', currentChunk); + ctx.emit('chunkRender:beforeMount', currentChunk); + + if (nodes.length === 0) { + return; + } + + const + fragment = document.createDocumentFragment(); + + for (let i = 0; i < nodes.length; i++) { + this.dom.appendChild(fragment, nodes[i], { + group: this.asyncGroup, + destroyIfComponent: true + }); + } + + this.async.requestAnimationFrame(() => { + this.refs.container.appendChild(fragment); + ctx.emit('chunkRender:mounted', renderItems, currentChunk); + }, {group: this.asyncGroup}); + } + + /** + * Hides or shows the specified ref + * + * @param ref + * @param show + * @param [immediate] - if settled as `true` will immediately update a DOM tree + */ + setRefVisibility(ref: keyof bVirtualScroll['$refs'], show: boolean, immediate: boolean = false): void { + const + refEl = >this.refs[ref]; + + if (!refEl) { + return; + } + + if (immediate) { + refEl.style.display = show ? '' : 'none'; + return; + } + + this.refsUpdateMap.set(ref, show); + this.performRefsVisibilityUpdate(); + } + + /** + * Hides or shows refs of the loader and tombstones + * + * @param show + * @param [immediate] - if settled as `true` will immediately update a DOM tree + */ + setLoadersVisibility(show: boolean, immediate: boolean = false): void { + this.setRefVisibility('tombstones', show, immediate); + this.setRefVisibility('loader', show, immediate); + } + + /** + * Tries to show the `renderNext` slot + */ + tryShowRenderNextSlot(): void { + const + {ctx, chunkRequest} = this; + + if (ctx.dataProvider == null && ctx.items.length === 0) { + return; + } + + if (chunkRequest.isDone) { + return; + } + + this.setRefVisibility('renderNext', true); + } + + /** + * Updates visibility of refs by using `requestAnimationFrame` + */ + protected performRefsVisibilityUpdate(): void { + this.async.requestAnimationFrame(() => { + this.refsUpdateMap.forEach((show, ref) => { + const + state = show ? '' : 'none', + refEl = >this.refs[ref]; + + if (!refEl) { + return; + } + + refEl.style.display = state; + }); + + this.refsUpdateMap.clear(); + + }, {label: $$.updateRefsVisibility, group: this.asyncGroup, join: true}); + } + + /** + * Event handlers initialization + */ + protected initEventHandlers(): void { + this.ctx.localEmitter.once('localState.ready', this.onReady.bind(this), {label: $$.reInitReady}); + this.ctx.localEmitter.once('localState.error', this.onError.bind(this), {label: $$.reInitError}); + } + + /** + * Renders the specified items + * @param items + */ + protected renderItems(items: RenderItem[]): HTMLElement[] { + const + nodes = this.componentRender.render(items); + + for (let i = 0; i < nodes.length; i++) { + const + node = nodes[i], + item = items[i]; + + item.node = node; + + const itemsData = { + current: item.data, + prev: items[i - 1]?.data, + next: items[i + 1]?.data + }; + + if (!Object.isFunction(node[$$.inView])) { + this.wrapInView(item, itemsData); + } + } + + return nodes; + } + + /** + * Wraps the specified item node with the `in-view` directive + * + * @param item + * @param itemData + */ + protected wrapInView(item: RenderItem, itemData: VirtualItemEl): void { + const + {ctx} = this, + {node} = item; + + if (ctx.loadStrategy === 'manual') { + return; + } + + const + label = `${this.asyncGroup}:${this.asyncInViewPrefix}${ctx.getItemKey(itemData, item.index)}`; + + if (!node) { + return; + } + + const inViewOpts = { + ...this.getInViewOptions(), + group: this.asyncGroup, + label + }; + + this.dom.watchForIntersection(node, inViewOpts, () => this.onNodeIntersect(item.index)); + } + + /** + * Returns a render item by the specified parameters + * + * @param data - data to render in item + * @param index - index of the item + */ + protected createRenderItem(data: object, index: number): RenderItem { + return { + data, + index: this.itemsCount + index, + node: undefined, + destructor: undefined + }; + } + + /** + * Returns options to initialize the `in-view` directive + */ + protected getInViewOptions(): WatchOptions { + return { + delay: 0, + threshold: this.randomThreshold, + once: !this.ctx.clearNodes + }; + } + + /** + * Handler: element becomes visible in the viewport + * @param index + */ + protected onNodeIntersect(index: number): void { + const + {ctx, items, lastIntersectsItem} = this, + {chunkSize, renderGap} = ctx; + + const + currentRender = (this.chunk - 1) * chunkSize; + + this.lastIntersectsItem = index; + + if (index + renderGap + chunkSize >= items.length) { + this.chunkRequest.try().catch(stderr); + } + + if (index >= lastIntersectsItem) { + if (currentRender - index <= renderGap) { + this.render(); + } + } + } + + /** + * Handler: component ready + */ + protected onReady(): void { + this.setLoadersVisibility(false); + this.chunk++; + this.render(); + } + + /** + * Handler: error occurred + */ + protected onError(): void { + this.setLoadersVisibility(false); + this.setRefVisibility('renderNext', false); + this.setRefVisibility('retry', true); + } +} diff --git a/src/components/base/b-virtual-scroll/modules/chunk-request.ts b/src/components/base/b-virtual-scroll/modules/chunk-request.ts new file mode 100644 index 0000000000..49d332b696 --- /dev/null +++ b/src/components/base/b-virtual-scroll/modules/chunk-request.ts @@ -0,0 +1,418 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +import symbolGenerator from 'core/symbol'; +import Friend from 'components/friends/friend'; + +import type bVirtualScroll from 'components/base/b-virtual-scroll/b-virtual-scroll'; +import type ChunkRender from 'components/base/b-virtual-scroll/modules/chunk-render'; + +import { isAsyncClearError } from 'components/base/b-virtual-scroll/modules/helpers'; +import type { RemoteData, DataState, LastLoadedChunk } from 'components/base/b-virtual-scroll/interface'; + +const + $$ = symbolGenerator(); + +export default class ChunkRequest extends Friend { + override readonly C!: bVirtualScroll; + + /** + * Current page + */ + page: number = 1; + + /** + * Total amount of elements being loaded + */ + total: number = 0; + + /** + * All loaded data + */ + data: unknown[] = []; + + /** + * Last loaded data chunk that was processed with `dbConverter` + * + * @deprecated + * {@link ChunkRequest.lastLoadedChunk} + */ + lastLoadedData: unknown[] = []; + + /** + * Last loaded data chunk + */ + lastLoadedChunk: LastLoadedChunk = { + normalized: [], + raw: undefined + }; + + /** + * True if all requests for additional data has been requested + */ + isDone: boolean = false; + + /** + * True if the last request returned an empty array or undefined + */ + isLastEmpty: boolean = false; + + /** + * Contains data that pending to be rendered + */ + pendingData: object[] = []; + + /** + * A buffer to accumulate data from the main request and all additional requests. + * Sometimes a data provider can't provide the whole batch of data in one request, + * so you need to emit some extra requests till the data batch is filled. + */ + currentAccumulatedData: CanUndef = undefined; + + /** + * Contains `currentAccumulatedData` from previous requests cycle + */ + previousDataStore: CanUndef = undefined; + + /** {@link ChunkRequest.previousDataStore} */ + get previousData(): CanUndef { + return this.previousDataStore; + } + + /** + * @emits dataChange(v: unknown) + * {@link ChunkRequest.previousDataStore} + */ + set previousData(v: unknown) { + this.previousDataStore = v; + this.ctx.emit('dataChange', v); + } + + /** + * API for scroll rendering + */ + protected get chunkRender(): ChunkRender { + return this.ctx.chunkRender; + } + + /** + * Resets the current state + */ + reset(): void { + this.total = 0; + this.page = 1; + + this.lastLoadedData = []; + this.data = []; + this.lastLoadedChunk = {raw: undefined, normalized: []}; + this.pendingData = []; + + this.isDone = false; + this.isLastEmpty = false; + this.currentAccumulatedData = undefined; + this.previousDataStore = undefined; + + this.async.clearTimeout({label: 'chunkRequest.waitForInitCalls'}); + this.async.cancelRequest({label: $$.request}); + } + + /** + * Reloads the last request + */ + reloadLast(): void { + this.isDone = false; + this.isLastEmpty = false; + + this.chunkRender.setRefVisibility('retry', false); + this.try().catch(stderr); + } + + /** + * Initializes the request module + */ + async init(): Promise { + await this.async.sleep(15, {label: 'chunkRequest.waitForInitCalls'}); + + const + {chunkSize, dataProvider} = this.ctx; + + this.pendingData = [...this.lastLoadedChunk.normalized]; + + if (this.pendingData.length < chunkSize && dataProvider != null && !this.isDone) { + this.currentAccumulatedData = this.ctx.db?.data; + } + + await this.try(false); + + if ( + this.ctx.localState !== 'error' && + this.pendingData.length === 0 && + this.chunkRender.itemsCount === 0 && + this.isDone + ) { + this.chunkRender.setRefVisibility('empty', true); + } + + this.chunkRender.tryShowRenderNextSlot(); + + if (this.previousData === undefined && Array.isArray(this.ctx.db?.data)) { + this.previousData = this.ctx.db!.data; + } + + this.ctx.localState = 'ready'; + } + + /** + * Tries to request additional data + * + * @param [initialCall] + * + * @emits `dbChange(data:` [[RemoteData]]`)` + * @emits `chunkLoading(page: number)` + */ + try(initialCall: boolean = true): Promise> { + const + {ctx, chunkRender} = this, + {chunkSize, dataProvider} = ctx; + + const + resolved = Promise.resolve(undefined); + + const additionParams = { + lastLoadedChunk: { + ...this.lastLoadedChunk, + normalized: this.lastLoadedChunk.normalized + } + }; + + if (this.pendingData.length > 0) { + if (dataProvider == null) { + chunkRender.initItems(this.pendingData.splice(0, chunkSize)); + chunkRender.render(); + + if (this.pendingData.length === 0) { + this.emitDone(); + } + + return resolved; + } + + if (this.pendingData.length >= chunkSize) { + chunkRender.initItems(this.pendingData.splice(0, chunkSize)); + chunkRender.render(); + + if (this.isDone && this.pendingData.length === 0) { + this.emitDone(); + } + + return resolved; + } + } + + const updateCurrentData = () => { + if (this.currentAccumulatedData != null) { + this.previousData = this.currentAccumulatedData; + this.currentAccumulatedData = undefined; + } + }; + + const shouldRequest = ctx.loadStrategy === 'scroll' ? + ctx.shouldMakeRequest(ctx.getDataStateSnapshot(additionParams, this, chunkRender)) : + true; + + if (this.isDone) { + updateCurrentData(); + this.onRequestsDone(); + return resolved; + } + + const cantRequest = () => this.isDone || + !shouldRequest || + ctx.dataProvider == null || + ctx.mods.progress === 'true'; + + if (cantRequest()) { + return resolved; + } + + if (initialCall) { + this.currentAccumulatedData = undefined; + } + + chunkRender.setLoadersVisibility(true); + chunkRender.setRefVisibility('renderNext', false); + + ctx.emit('chunkLoading', this.page); + + return this.load() + .then((v) => { + if (Object.size(v?.data) === 0) { + this.isLastEmpty = true; + + this.shouldStopRequest(this.ctx.getDataStateSnapshot({ + lastLoadedData: [], + lastLoadedChunk: { + raw: undefined, + normalized: [] + } + }, this, chunkRender)); + + chunkRender.setLoadersVisibility(false); + updateCurrentData(); + + return; + } + + const + data = (v).data!; + + this.page++; + this.isLastEmpty = false; + + this.data = this.data.concat(data); + this.pendingData = this.pendingData.concat(data); + this.currentAccumulatedData = Array.concat(this.currentAccumulatedData ?? [], data); + + ctx.emit('dbChange', {...v, data: this.data}); + this.shouldStopRequest(this.ctx.getCurrentDataState()); + + if (this.pendingData.length < ctx.chunkSize) { + return this.try(false); + } + + this.previousData = this.currentAccumulatedData; + this.currentAccumulatedData = undefined; + + chunkRender.setLoadersVisibility(false); + + if (!this.isDone) { + chunkRender.initItems(this.pendingData.splice(0, chunkSize)); + chunkRender.render(); + } + + if (!this.isDone || this.pendingData.length > 0) { + chunkRender.setRefVisibility('renderNext', true); + } + + }).catch((err) => { + if (isAsyncClearError(err)) { + return; + } + + stderr(err); + return undefined; + }); + } + + /** + * Checks for the possibility of stopping data requests + * @param params + */ + shouldStopRequest(params: DataState): boolean { + const {ctx} = this; + this.isDone = ctx.shouldStopRequest(params); + + if (this.isDone) { + this.onRequestsDone(); + } + + return this.isDone; + } + + /** + * Sets `isDone` to `true` and fires `onRequestDone` handler + */ + protected emitDone(): void { + this.isDone = true; + this.onRequestsDone(); + } + + /** + * Loads additional data + * @emits `chunkLoaded(lastLoadedChunk:` [[LastLoadedChunk]]`)` + */ + protected load(): Promise> { + const { + ctx, + chunkRender + } = this; + + void ctx.setMod('progress', true); + + const + defaultRequestParams = ctx.dataProvider?.getDefaultRequestParams('get'), + params = >(defaultRequestParams ?? [])[0]; + + Object.assign(params, ctx.requestQuery?.(ctx.getCurrentDataState())?.get); + + return ctx.async.request(ctx.getData(this.component, params), {label: $$.request}) + .then((data) => { + this.ctx.localState = 'ready'; + void ctx.removeMod('progress', true); + this.lastLoadedChunk.raw = data; + + const + converted = data != null ? ctx.convertDataToDB(data) : undefined; + + this.lastLoadedChunk.normalized = Object.size(converted?.data) <= 0 ? + this.lastLoadedChunk.normalized = [] : + this.lastLoadedChunk.normalized = converted!.data!; + + ctx.emit('chunkLoaded', this.lastLoadedChunk, this.page); + return converted; + }) + + .catch((err) => { + void ctx.removeMod('progress', true); + + if (isAsyncClearError(err)) { + return Promise.reject(err); + } + + chunkRender.setRefVisibility('retry', true); + chunkRender.setRefVisibility('renderNext', false); + + this.ctx.onRequestError(err, this.ctx.reloadLast.bind(this.ctx)); + stderr(err); + + this.lastLoadedChunk.raw = []; + this.lastLoadedChunk.normalized = []; + + return undefined; + }); + } + + /** + * Handler: all requests are done + */ + protected onRequestsDone(): void { + const + {ctx, chunkRender, async: $a} = this, + {chunkSize} = ctx; + + if (this.pendingData.length > 0) { + chunkRender.initItems(this.pendingData.splice(0, chunkSize)); + chunkRender.render(); + } + + if (this.pendingData.length === 0) { + chunkRender.setRefVisibility('done', true); + chunkRender.setRefVisibility('renderNext', false); + } + + $a.wait(() => ctx.localState === 'ready', {label: $$.requestDoneWaitForReady}) + .then(() => { + if (this.pendingData.length === 0) { + chunkRender.setRefVisibility('done', true); + } + + chunkRender.setLoadersVisibility(false); + }) + .catch(stderr); + } +} diff --git a/src/components/base/b-virtual-scroll/modules/component-render.ts b/src/components/base/b-virtual-scroll/modules/component-render.ts new file mode 100644 index 0000000000..11ee3e4e62 --- /dev/null +++ b/src/components/base/b-virtual-scroll/modules/component-render.ts @@ -0,0 +1,218 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +import Friend from 'components/friends/friend'; +import { mergeProps } from 'core/component/render'; + +import type ScrollRender from 'components/base/b-virtual-scroll/modules/chunk-render'; +import type bVirtualScroll from 'components/base/b-virtual-scroll/b-virtual-scroll'; + +import type { RenderItem, DataToRender, ItemAttrs, VirtualItemEl } from 'components/base/b-virtual-scroll/interface'; + +export default class ComponentRender extends Friend { + override readonly C!: bVirtualScroll; + + /** + * Async group + */ + readonly asyncGroup: string = 'component-render'; + + /** + * If false, the cache flushing process is not currently running + */ + protected canDropCache: boolean = false; + + /** + * Rendered items cache + */ + protected nodesCache: Dictionary = Object.createDict(); + + /** + * True if rendered nodes can be cached + */ + protected get canCache(): boolean { + return this.ctx.cacheNodes && this.ctx.clearNodes; + } + + /** + * API for scroll rendering + */ + protected get scrollRender(): ScrollRender { + return this.ctx.chunkRender; + } + + /** + * Classname for options + */ + get optionClass(): CanUndef { + return this.ctx.block?.getFullElementName('option-el'); + } + + /** + * Re-initializes component render + */ + reInit(): void { + Object.keys(this.nodesCache).forEach((key) => { + const el = this.nodesCache[key]; + el?.remove(); + }); + + this.nodesCache = Object.createDict(); + } + + /** + * Returns a node from the cache by the specified key + * @param key + */ + getCachedComponent(key: string): CanUndef { + return this.nodesCache[key]; + } + + /** + * Saves a node to the cache by the specified key + * + * @param key + * @param node + */ + cacheNode(key: string, node: HTMLElement): HTMLElement { + if (!this.ctx.cacheNodes) { + return node; + } + + this.nodesCache[key] = node; + + const + {nodesCache, ctx: {cacheSize}} = this; + + if (Object.keys(nodesCache).length > cacheSize) { + this.canDropCache = true; + } + + return node; + } + + /** {@link bVirtualScroll.getOptionKey} */ + getItemKey(data: VirtualItemEl, index: number): string { + return String(this.ctx.getItemKey(data, index)); + } + + /** + * Renders the specified chunk of items + * @param items + */ + render(items: RenderItem[]): HTMLElement[] { + const + {canCache} = this; + + const + res: HTMLElement[] = [], + needRender: Array<[RenderItem, number, VirtualItemEl]> = []; + + for (let i = 0; i < items.length; i++) { + const + item = items[i]; + + if (item.node) { + res[i] = item.node; + continue; + } + + const getItemKeyData = { + current: item.data, + prev: items[i - 1]?.data, + next: items[i + 1]?.data + }; + + if (canCache) { + const + key = this.getItemKey(getItemKeyData, item.index), + node = this.getCachedComponent(key); + + if (node) { + res[i] = node; + item.node = node; + continue; + } + } + + needRender.push([item, i, getItemKeyData]); + } + + if (needRender.length > 0) { + const + nodes = this.createComponents(needRender.map(([item]) => item)); + + for (let i = 0; i < needRender.length; i++) { + const + [item, indexesToAssign, getItemKeyData] = needRender[i], + node = nodes[i]; + + const + key = this.getItemKey(getItemKeyData, item.index); + + if (canCache) { + this.cacheNode(key, item.node = node); + } + + res[indexesToAssign] = node; + } + } + + return res; + } + + /** + * Creates and renders components by the specified parameters + * @param items + */ + protected createComponents(items: RenderItem[]): HTMLElement[] { + const + {ctx: c, scrollRender: {items: totalItems}} = this; + + const render = (children: DataToRender[]) => { + const map = ({itemAttrs, itemParams, index}) => + this.ctx.vdom.create(c.getItemComponentName(itemParams, index), itemAttrs); + + return c.vdom.render(children.map(map)); + }; + + const getChildrenAttrs = (props: ItemAttrs) => ({ + attrs: mergeProps(props, {class: this.optionClass}) + }); + + const getItemEl = (data, i: number) => ({ + current: data, + prev: totalItems[i - 1]?.data, + next: totalItems[i + 1]?.data + }); + + const + children: DataToRender[] = []; + + for (let i = 0; i < items.length; i++) { + const + item = items[i], + itemParams = getItemEl(item.data, item.index), + itemIndex = item.index; + + const attrs = c.getItemAttrs(getItemEl(item.data, item.index), item.index); + + children.push({itemParams, itemAttrs: getChildrenAttrs(attrs!), index: itemIndex}); + } + + const + // https://github.com/vuejs/core/issues/6061 + res = render(children).filter((node) => node.nodeType !== node.TEXT_NODE); + + if (res.length === 0) { + throw new Error('Failed to render components. Possibly an error occurred while creating the components.'); + } + + return res; + } +} diff --git a/src/components/base/b-virtual-scroll/modules/emitter/index.ts b/src/components/base/b-virtual-scroll/modules/emitter/index.ts deleted file mode 100644 index 3fa5f7ba70..0000000000 --- a/src/components/base/b-virtual-scroll/modules/emitter/index.ts +++ /dev/null @@ -1,57 +0,0 @@ -/*! - * V4Fire Client Core - * https://github.com/V4Fire/Client - * - * Released under the MIT license - * https://github.com/V4Fire/Client/blob/master/LICENSE - */ - -import type { AsyncOptions } from 'core/async'; - -import type bVirtualScroll from 'components/base/b-virtual-scroll/b-virtual-scroll'; -import type { ComponentEvents, LocalEventPayload } from 'components/base/b-virtual-scroll/interface'; -import type { ComponentTypedEmitter } from 'components/base/b-virtual-scroll/modules/emitter/interface'; - -export * from 'components/base/b-virtual-scroll/modules/emitter/interface'; - -/** - * Provides methods for interacting with the `selfEmitter` using typed events - * @param ctx - */ -export function componentTypedEmitter(ctx: bVirtualScroll): ComponentTypedEmitter { - const once = ( - event: EVENT, - handler: (...args: LocalEventPayload) => void, - asyncOpts?: AsyncOptions - ) => { - ctx.once(event, handler, asyncOpts); - }; - - const on = ( - event: EVENT, - handler: (...args: LocalEventPayload) => void, - asyncOpts?: AsyncOptions - ) => { - ctx.on(event, handler, asyncOpts); - }; - - const promisifyOnce = ( - event: EVENT, - asyncOpts?: AsyncOptions - ) => ctx.promisifyOnce(event, asyncOpts); - - const emit = ( - event: EVENT, - ...payload: LocalEventPayload - ) => { - ctx.emit(event, ...payload); - }; - - return { - once, - on, - promisifyOnce, - emit - }; -} - diff --git a/src/components/base/b-virtual-scroll/modules/emitter/interface.ts b/src/components/base/b-virtual-scroll/modules/emitter/interface.ts deleted file mode 100644 index 7381ff526d..0000000000 --- a/src/components/base/b-virtual-scroll/modules/emitter/interface.ts +++ /dev/null @@ -1,55 +0,0 @@ -/*! - * V4Fire Client Core - * https://github.com/V4Fire/Client - * - * Released under the MIT license - * https://github.com/V4Fire/Client/blob/master/LICENSE - */ - -import type { AsyncOptions } from 'core/async'; -import type { ComponentEvents, LocalEventPayload } from 'components/base/b-virtual-scroll/interface'; - -/** - * An interface representing the typed `selfEmitter` methods. - */ -export interface ComponentTypedEmitter { - /** - * @param event - the event name. - * @param handler - the event handler function. - * @param [asyncOpts] - Optional async options. - */ - once( - event: EVENT, - handler: (...args: LocalEventPayload) => void, - asyncOpts?: AsyncOptions - ): void; - - /** - * @param event - the event name. - * @param handler - the event handler function. - * @param [asyncOpts] - Optional async options. - */ - on( - event: EVENT, - handler: (...args: LocalEventPayload) => void, - asyncOpts?: AsyncOptions - ): void; - - /** - * @param event - the event name. - * @param [asyncOpts] - Optional async options. - */ - promisifyOnce( - event: EVENT, - asyncOpts?: AsyncOptions - ): Promise>; - - /** - * @param event - the event name. - * @param payload - the event payload. - */ - emit( - event: EVENT, - ...payload: LocalEventPayload - ): void; -} diff --git a/src/components/base/b-virtual-scroll/modules/factory/engines/vdom.ts b/src/components/base/b-virtual-scroll/modules/factory/engines/vdom.ts deleted file mode 100644 index df4beb229a..0000000000 --- a/src/components/base/b-virtual-scroll/modules/factory/engines/vdom.ts +++ /dev/null @@ -1,24 +0,0 @@ -/*! - * V4Fire Client Core - * https://github.com/V4Fire/Client - * - * Released under the MIT license - * https://github.com/V4Fire/Client/blob/master/LICENSE - */ - -import type { VNodeDescriptor } from 'components/friends/vdom'; -import type bVirtualScroll from 'components/base/b-virtual-scroll/b-virtual-scroll'; - -/** - * Renders the provided `VNodes` to the `HTMLElements` via `vdom.render` API. - * - * @param ctx - * @param items - */ -export function render(ctx: bVirtualScroll, items: VNodeDescriptor[]): HTMLElement[] { - const - vnodes = ctx.vdom.create(...items), - nodes = ctx.vdom.render(vnodes); - - return nodes; -} diff --git a/src/components/base/b-virtual-scroll/modules/factory/index.ts b/src/components/base/b-virtual-scroll/modules/factory/index.ts deleted file mode 100644 index 1ff9e46de6..0000000000 --- a/src/components/base/b-virtual-scroll/modules/factory/index.ts +++ /dev/null @@ -1,134 +0,0 @@ -/*! - * V4Fire Client Core - * https://github.com/V4Fire/Client - * - * Released under the MIT license - * https://github.com/V4Fire/Client/blob/master/LICENSE - */ - -import Friend from 'components/friends/friend'; -import type { VNodeDescriptor } from 'components/friends/vdom'; - -import type bVirtualScroll from 'components/base/b-virtual-scroll/b-virtual-scroll'; -import type { ComponentItem, ItemsProcessor, MountedChild, MountedItem } from 'components/base/b-virtual-scroll/interface'; -import { isItem } from 'components/base/b-virtual-scroll/modules/helpers'; - -import * as vdomRender from 'components/base/b-virtual-scroll/modules/factory/engines/vdom'; - -/** - * A friendly class that provides an API for component production, specifically tailored for the `bVirtualScroll` class. - */ -export class ComponentFactory extends Friend { - override readonly C!: bVirtualScroll; - - /** - * Produces component items based on the current state and context. - * Returns an array of component items. - */ - produceComponentItems(): ComponentItem[] { - const - {ctx} = this; - - return this.itemsProcessor(ctx.itemsFactory(ctx.getVirtualScrollState(), ctx)); - } - - /** - * Produces DOM nodes from an array of component items. - * Returns an array of DOM nodes representing the component items. - * - * @param componentItems - an array of component items - */ - produceNodes(componentItems: ComponentItem[]): HTMLElement[] { - if (componentItems.length === 0) { - return []; - } - - const createDescriptor = (item: ComponentItem): VNodeDescriptor => ({ - type: item.item, - attrs: item.props, - children: item.children - }); - - const descriptors = componentItems.map(createDescriptor); - return this.callRenderEngine(descriptors); - } - - /** - * Augments `ComponentItem` with various properties such as the component node, item index, and child index. - * - * @param items - * @param nodes - */ - produceMounted(items: ComponentItem[], nodes: HTMLElement[]): Array { - const - {ctx} = this, - {items: mountedItems, childList} = ctx.getVirtualScrollState(); - - let - itemsCounter = 0; - - return items.map((item, i) => { - if (isItem(item)) { - const res = { - ...item, - node: nodes[i], - itemIndex: mountedItems.length + itemsCounter, - childIndex: childList.length + i - }; - - itemsCounter++; - return res; - } - - return { - ...item, - node: nodes[i], - childIndex: mountedItems.length + i - }; - }); - } - - /** - * Invokes the {@link bVirtualScroll.itemsProcessors} function and returns its result - * @param items - the list of items to process. - */ - protected itemsProcessor(items: ComponentItem[]): ComponentItem[] { - const - {ctx} = this, - itemsProcessors = ctx.getItemsProcessors(); - - if (!itemsProcessors) { - return items; - } - - if (Object.isFunction(itemsProcessors)) { - return itemsProcessors(items, ctx); - } - - Object.forEach(itemsProcessors, (processor) => { - items = processor(items, ctx); - }); - - return items; - } - - /** - * Calls the render engine to render the components based on the provided descriptors. - * Returns an array of rendered DOM nodes. - * - * @param descriptors - an array of VNode descriptors. - */ - protected callRenderEngine(descriptors: VNodeDescriptor[]): HTMLElement[] { - const - {ctx} = this; - - ctx.onRenderEngineStart(); - - const - res = vdomRender.render(ctx, descriptors); - - ctx.onRenderEngineDone(); - - return res; - } -} diff --git a/src/components/base/b-virtual-scroll/modules/helpers.ts b/src/components/base/b-virtual-scroll/modules/helpers.ts new file mode 100644 index 0000000000..32738cf489 --- /dev/null +++ b/src/components/base/b-virtual-scroll/modules/helpers.ts @@ -0,0 +1,106 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +import type ChunkRender from 'components/base/b-virtual-scroll/modules/chunk-render'; +import type ChunkRequest from 'components/base/b-virtual-scroll/modules/chunk-request'; +import type { DataState } from 'components/base/b-virtual-scroll/interface'; + +/** + * Returns accumulated data among `b-virtual-scroll`,` chunk-render`, `chunk-request` and passes it to the client + * to make any decisions. For instance, one more chunk of data needs to be loaded. + * + * @param [chunkRequestCtx] + * @param [chunkRenderCtx] + * @param [merge] + * + * @typeParam ITEM - data item to render + * @typeParam RAW - raw provider data + */ +export function getRequestParams( + chunkRequestCtx?: ChunkRequest, + chunkRenderCtx?: ChunkRender, + merge?: Dictionary +): DataState { + const + component = chunkRenderCtx?.component ?? chunkRequestCtx?.component, + pendingData = chunkRequestCtx?.pendingData ?? []; + + const lastLoadedData = >chunkRequestCtx?.lastLoadedChunk.normalized; + + const base: DataState = { + currentPage: 0, + nextPage: 1, + + data: [], + items: [], + isLastEmpty: false, + itemsTillBottom: 0, + total: undefined, + + pendingData, + + lastLoadedData: lastLoadedData ?? [], + lastLoadedChunk: { + raw: undefined, + normalized: lastLoadedData ?? [] + } + }; + + const params = chunkRequestCtx && chunkRenderCtx ? + { + items: chunkRenderCtx.items, + itemsTillBottom: chunkRenderCtx.items.length - chunkRenderCtx.lastIntersectsItem, + + currentPage: chunkRequestCtx.page, + isLastEmpty: chunkRequestCtx.isLastEmpty, + total: component?.unsafe.total, + + pendingData, + data: chunkRequestCtx.data, + + lastLoadedData: lastLoadedData ?? [], + lastLoadedChunk: { + raw: chunkRequestCtx.lastLoadedChunk.raw, + normalized: lastLoadedData ?? [] + } + } : + base; + + const + mergeLastLoadedChunk = merge?.lastLoadedChunk; + + const merged = { + ...params, + ...merge, + lastLoadedChunk: { + ...params.lastLoadedChunk, + ...mergeLastLoadedChunk + } + }; + + return >{ + ...merged, + nextPage: merged.currentPage + 1 + }; +} + +/** + * True if the specified value is an `async replace` error + * @param val + */ +export function isAsyncReplaceError(val: unknown): boolean { + return Object.isPlainObject(val) && val.join === 'replace'; +} + +/** + * True if the specified value is an `async clear` error + * @param val + */ +export function isAsyncClearError(val: unknown): boolean { + return Object.isPlainObject(val) && val.type === 'clearAsync'; +} diff --git a/src/components/base/b-virtual-scroll/modules/helpers/index.ts b/src/components/base/b-virtual-scroll/modules/helpers/index.ts deleted file mode 100644 index dc5832db2e..0000000000 --- a/src/components/base/b-virtual-scroll/modules/helpers/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -/*! - * V4Fire Client Core - * https://github.com/V4Fire/Client - * - * Released under the MIT license - * https://github.com/V4Fire/Client/blob/master/LICENSE - */ - -import { componentItemType } from 'components/base/b-virtual-scroll/const'; -import type { MountedItem } from 'components/base/b-virtual-scroll/interface'; - -/** - * Returns `true` if the value is of type `MountedItem`, otherwise `false` - * @param val - the value to check. - */ -export function isItem(val: any): val is MountedItem { - return Object.isPlainObject(val) && val.type === componentItemType.item; -} - -/** - * Returns `true` if the specified value is an `async replace` error - * @param val - */ -export function isAsyncReplaceError(val: unknown): boolean { - return Object.isPlainObject(val) && val.join === 'replace'; -} diff --git a/src/components/base/b-virtual-scroll/modules/observer/const.ts b/src/components/base/b-virtual-scroll/modules/observer/const.ts deleted file mode 100644 index 9f8a9be196..0000000000 --- a/src/components/base/b-virtual-scroll/modules/observer/const.ts +++ /dev/null @@ -1,12 +0,0 @@ -/*! - * V4Fire Client Core - * https://github.com/V4Fire/Client - * - * Released under the MIT license - * https://github.com/V4Fire/Client/blob/master/LICENSE - */ - -/** - * Group for async operations of the observer module. - */ -export const observerAsyncGroup = '[[OBSERVER]]'; diff --git a/src/components/base/b-virtual-scroll/modules/observer/engines/intersection-observer.ts b/src/components/base/b-virtual-scroll/modules/observer/engines/intersection-observer.ts deleted file mode 100644 index 71d74c93d4..0000000000 --- a/src/components/base/b-virtual-scroll/modules/observer/engines/intersection-observer.ts +++ /dev/null @@ -1,45 +0,0 @@ -/*! - * V4Fire Client Core - * https://github.com/V4Fire/Client - * - * Released under the MIT license - * https://github.com/V4Fire/Client/blob/master/LICENSE - */ - -import Friend from 'components/friends/friend'; - -import type bVirtualScroll from 'components/base/b-virtual-scroll/b-virtual-scroll'; -import type { MountedChild } from 'components/base/b-virtual-scroll/interface'; - -import { observerAsyncGroup } from 'components/base/b-virtual-scroll/modules/observer/const'; -import type { ObserverEngine } from 'components/base/b-virtual-scroll/modules/observer/interface'; - -export default class IoObserver extends Friend implements ObserverEngine { - - /** - * {@link bVirtualScroll} - */ - override readonly C!: bVirtualScroll; - - /** - * {@link ObserverEngine.watchForIntersection} - * @param components - */ - watchForIntersection(components: MountedChild[]): void { - const - {ctx} = this; - - for (const component of components) { - ctx.dom.watchForIntersection(component.node, { - group: observerAsyncGroup, - label: component.key, - once: true, - delay: 0 - }, () => ctx.onElementEnters(component)); - } - } - - reset(): void { - this.async.clearAll({group: new RegExp(observerAsyncGroup)}); - } -} diff --git a/src/components/base/b-virtual-scroll/modules/observer/index.ts b/src/components/base/b-virtual-scroll/modules/observer/index.ts deleted file mode 100644 index c87c4c860b..0000000000 --- a/src/components/base/b-virtual-scroll/modules/observer/index.ts +++ /dev/null @@ -1,60 +0,0 @@ -/*! - * V4Fire Client Core - * https://github.com/V4Fire/Client - * - * Released under the MIT license - * https://github.com/V4Fire/Client/blob/master/LICENSE - */ - -import Friend from 'components/friends/friend'; - -import type bVirtualScroll from 'components/base/b-virtual-scroll/b-virtual-scroll'; -import type { MountedChild } from 'components/base/b-virtual-scroll/interface'; - -import IoObserver from 'components/base/b-virtual-scroll/modules/observer/engines/intersection-observer'; - -export { default as IoObserver } from 'components/base/b-virtual-scroll/modules/observer/engines/intersection-observer'; - -/** - * Observer class for `bVirtualScroll` component. - * It provides observation capabilities using different engines such as IoObserver and ScrollObserver. - */ -export class Observer extends Friend { - override readonly C!: bVirtualScroll; - - /** - * The observation engine used by the Observer. - */ - protected engine: IoObserver; - - /** - * @param ctx - the `bVirtualScroll` component instance. - */ - constructor(ctx: bVirtualScroll) { - super(ctx); - - this.engine = new IoObserver(ctx); - } - - /** - * Starts observing the specified mounted elements - * @param mounted - an array of elements to be observed. - */ - observe(mounted: MountedChild[]): void { - const - {ctx} = this; - - if (ctx.disableObserver) { - return; - } - - this.engine.watchForIntersection(mounted); - } - - /** - * Resets the module state - */ - reset(): void { - this.engine.reset(); - } -} diff --git a/src/components/base/b-virtual-scroll/modules/observer/interface.ts b/src/components/base/b-virtual-scroll/modules/observer/interface.ts deleted file mode 100644 index f5f3a248dc..0000000000 --- a/src/components/base/b-virtual-scroll/modules/observer/interface.ts +++ /dev/null @@ -1,27 +0,0 @@ -/*! - * V4Fire Client Core - * https://github.com/V4Fire/Client - * - * Released under the MIT license - * https://github.com/V4Fire/Client/blob/master/LICENSE - */ - -import type { MountedItem } from 'components/base/b-virtual-scroll/interface'; - -/** - * Interface representing an observer engine for watching components entering the viewport. - */ -export interface ObserverEngine { - /** - * Initializes a watcher to track when components enter the viewport. - * - * @param components - An array of mounted items to be watched. - */ - watchForIntersection(components: MountedItem[]): void; - - /** - * Resets the state of the observer engine. - * This can be used to clear any existing observers and reset the module to its initial state. - */ - reset(): void; -} diff --git a/src/components/base/b-virtual-scroll/modules/slots/index.ts b/src/components/base/b-virtual-scroll/modules/slots/index.ts deleted file mode 100644 index 533783de8b..0000000000 --- a/src/components/base/b-virtual-scroll/modules/slots/index.ts +++ /dev/null @@ -1,164 +0,0 @@ -/*! - * V4Fire Client Core - * https://github.com/V4Fire/Client - * - * Released under the MIT license - * https://github.com/V4Fire/Client/blob/master/LICENSE - */ - -import symbolGenerator from 'core/symbol'; -import type { AsyncOptions } from 'core/async'; - -import Friend from 'components/friends/friend'; - -import type bVirtualScroll from 'components/base/b-virtual-scroll/b-virtual-scroll'; -import type { SlotsStateObj } from 'components/base/b-virtual-scroll/modules/slots/interface'; - -export * from 'components/base/b-virtual-scroll/modules/slots/interface'; - -export const - $$ = symbolGenerator(), - slotsStateControllerAsyncGroup = 'slotsStateController'; - -/** - * A class that manages the visibility of slots based on different states. - */ -export class SlotsStateController extends Friend { - - override readonly C!: bVirtualScroll; - - /** - * Options for the asynchronous operations. - */ - protected readonly asyncUpdateLabel: AsyncOptions = { - label: $$.updateSlotsVisibility, - group: slotsStateControllerAsyncGroup - }; - - /** - * The last state of the slots. - */ - protected lastState?: SlotsStateObj; - - /** - * Displays the slots that should be shown when the data state is empty - */ - emptyState(): void { - this.setSlotsVisibility({ - container: true, - done: true, - empty: true, - loader: false, - renderNext: false, - retry: false, - tombstones: false - }); - } - - /** - * Displays the slots that should be shown when the lifecycle is done - */ - doneState(): void { - this.setSlotsVisibility({ - container: true, - done: true, - empty: this.lastState?.empty ?? false, - loader: false, - renderNext: false, - retry: false, - tombstones: false - }); - } - - /** - * Displays the slots that should be shown during data loading progress - * @param [immediate] - if set to true, {@link requestAnimationFrame} will not be used to switch the state. - */ - loadingProgressState(immediate: boolean = false): void { - this.setSlotsVisibility({ - container: true, - loader: true, - tombstones: true, - done: false, - empty: false, - renderNext: false, - retry: false - }, immediate); - } - - /** - * Displays the slots that should be shown when data loading fails - */ - loadingFailedState(): void { - this.setSlotsVisibility({ - container: true, - retry: true, - done: false, - empty: false, - loader: false, - renderNext: false, - tombstones: false - }); - } - - /** - * Displays the slots that should be shown when data loading is successful - */ - loadingSuccessState(): void { - this.setSlotsVisibility({ - container: true, - done: false, - empty: false, - loader: false, - renderNext: true, - retry: false, - tombstones: false - }); - } - - /** - * Resets the state of the module - */ - reset(): void { - this.async.clearAll({group: new RegExp(slotsStateControllerAsyncGroup)}); - this.lastState = undefined; - } - - /** - * Sets the visibility state of the slots. - * - * @param stateObj - an object specifying the visibility state of each slot. - * @param [immediate] - if set to true, {@link requestAnimationFrame} will not be used to switch the state. - */ - protected setSlotsVisibility(stateObj: Required, immediate: boolean = false): void { - this.lastState = stateObj; - - this.async.cancelAnimationFrame(this.asyncUpdateLabel); - - const update = () => { - for (const [name, state] of Object.entries(stateObj)) { - this.setDisplayState(name, state); - } - }; - - if (immediate) { - return update(); - } - - this.async.requestAnimationFrame(update, this.asyncUpdateLabel); - } - - /** - * Sets the display state of a slot. - * - * @param name - the name of the slot. - * @param state - the visibility state of the slot. - */ - protected setDisplayState(name: keyof SlotsStateObj, state: boolean): void { - const ref = this.ctx.$refs[name]; - - if (ref instanceof HTMLElement) { - ref.style.display = state ? '' : 'none'; - } - } -} diff --git a/src/components/base/b-virtual-scroll/modules/slots/interface.ts b/src/components/base/b-virtual-scroll/modules/slots/interface.ts deleted file mode 100644 index b5599dd4a2..0000000000 --- a/src/components/base/b-virtual-scroll/modules/slots/interface.ts +++ /dev/null @@ -1,17 +0,0 @@ -/*! - * V4Fire Client Core - * https://github.com/V4Fire/Client - * - * Released under the MIT license - * https://github.com/V4Fire/Client/blob/master/LICENSE - */ - -import type { ComponentRefs } from 'components/base/b-virtual-scroll/interface'; - -/** - * Represents the state of slots. - * [slotName: slotVisibility] - */ -export type SlotsStateObj = { - [key in keyof ComponentRefs]: boolean; -}; diff --git a/src/components/base/b-virtual-scroll/modules/state/helpers.ts b/src/components/base/b-virtual-scroll/modules/state/helpers.ts deleted file mode 100644 index df42d3b041..0000000000 --- a/src/components/base/b-virtual-scroll/modules/state/helpers.ts +++ /dev/null @@ -1,45 +0,0 @@ -/*! - * V4Fire Client Core - * https://github.com/V4Fire/Client - * - * Released under the MIT license - * https://github.com/V4Fire/Client/blob/master/LICENSE - */ - -import type { VirtualScrollState, PrivateComponentState } from 'components/base/b-virtual-scroll/interface'; - -/** - * Creates an initial state object for a component - */ -export function createInitialState(): VirtualScrollState { - return { - loadPage: 0, - renderPage: 0, - remainingItems: undefined, - remainingChildren: undefined, - maxViewedItem: undefined, - maxViewedChild: undefined, - data: [], - lastLoadedData: [], - lastLoadedRawData: undefined, - isLastEmpty: false, - isInitialLoading: true, - items: [], - childList: [], - isInitialRender: true, - areRequestsStopped: false, - isLoadingInProgress: false, - isLifecycleDone: false, - isLastErrored: false, - isLastRender: false - }; -} - -/** - * Creates an initial private state object for a component - */ -export function createPrivateInitialState(): PrivateComponentState { - return { - dataOffset: 0 - }; -} diff --git a/src/components/base/b-virtual-scroll/modules/state/index.ts b/src/components/base/b-virtual-scroll/modules/state/index.ts deleted file mode 100644 index a6bc28d8e3..0000000000 --- a/src/components/base/b-virtual-scroll/modules/state/index.ts +++ /dev/null @@ -1,228 +0,0 @@ -/*! - * V4Fire Client Core - * https://github.com/V4Fire/Client - * - * Released under the MIT license - * https://github.com/V4Fire/Client/blob/master/LICENSE - */ - -import Friend from 'components/friends/friend'; - -import type bVirtualScroll from 'components/base/b-virtual-scroll/b-virtual-scroll'; -import { isItem } from 'components/base/b-virtual-scroll/modules/helpers'; -import { createInitialState, createPrivateInitialState } from 'components/base/b-virtual-scroll/modules/state/helpers'; -import type { MountedChild, VirtualScrollState, MountedItem, PrivateComponentState } from 'components/base/b-virtual-scroll/interface'; - -/** - * Friendly to the `bVirtualScroll` class that represents the internal state of a component. - */ -export class ComponentInternalState extends Friend { - override readonly C!: bVirtualScroll; - - /** - * Current state of the component. - */ - protected state: VirtualScrollState = createInitialState(); - - /** - * Current private state of the component. - */ - protected privateState: PrivateComponentState = createPrivateInitialState(); - - /** - * Compiles and returns the current state of the component. - * - * @returns The current state of the component. - */ - compile(): Readonly { - return this.state; - } - - /** - * Resets the state of the component - */ - reset(): void { - this.state = createInitialState(); - this.privateState = createPrivateInitialState(); - } - - /** - * Increments the load page pointer - */ - incrementLoadPage(): void { - this.state.loadPage++; - } - - /** - * Increments the render page pointer - */ - incrementRenderPage(): void { - this.state.renderPage++; - } - - /** - * Updates the loaded data state. - * - * @param data - the new data to update the state. - * @param isInitialLoading - indicates if it's the initial loading. - */ - updateData(data: object[], isInitialLoading: boolean): void { - this.state.data = this.state.data.concat(data); - this.state.isLastEmpty = data.length === 0; - this.state.isInitialLoading = isInitialLoading; - this.state.lastLoadedData = data; - } - - /** - * Updates the arrays with mounted child elements of the component - * @param mounted - the mounted child elements. - */ - updateMounted(mounted: MountedChild[]): void { - const - {state} = this, - childList = state.childList, - itemsList = state.items, - newItems = mounted.filter((child) => child.type === 'item'); - - childList.push(...mounted); - itemsList.push(...newItems); - - this.updateRemainingChildren(); - } - - /** - * Updates the indicator that shows whether the current rendering process is the - * last one in this lifecycle. - */ - updateIsLastRender(): void { - const - {state, ctx} = this; - - if (!state.areRequestsStopped) { - return; - } - - const - chunkSize = ctx.getChunkSize(state), - dataOffset = this.getDataCursor() + chunkSize; - - if (>state.data[dataOffset] == null) { - state.isLastRender = true; - } - } - - /** - * Updates the state of the last raw loaded data - * @param data - the last raw loaded data. - */ - setRawLastLoaded(data: unknown): void { - this.state.lastLoadedRawData = data; - } - - /** - * Sets the flag indicating if it's the initial render cycle - * @param value - the value of the flag. - */ - setIsInitialRender(value: boolean): void { - this.state.isInitialRender = value; - } - - /** - * Sets the flag indicating if requests are stopped and the component won't make any more requests - * until the lifecycle is refreshed. - * - * @param value - the value of the flag. - */ - setIsRequestsStopped(value: boolean): void { - this.state.areRequestsStopped = value; - } - - /** - * Sets the flag indicating if the component's lifecycle is done - * @param value - the value of the flag. - */ - setIsLifecycleDone(value: boolean): void { - this.state.isLifecycleDone = value; - } - - /** - * Sets the flag indicating if the component is currently loading data - * @param value - the value of the flag. - */ - setIsLoadingInProgress(value: boolean): void { - this.state.isLoadingInProgress = value; - } - - /** - * Sets a flag indicating whether the last load operation ended with an error - * @param value - the value to set. - */ - setIsLastErrored(value: boolean): void { - this.state.isLastErrored = value; - } - - /** - * Sets the maximum viewed index based on the passed component's index - * @param component - the component to compare and update the maximum viewed index. - */ - setMaxViewedIndex(component: MountedChild): void { - const - {state} = this, - {childIndex} = component; - - if (isItem(component) && (state.maxViewedItem == null || state.maxViewedItem < component.itemIndex)) { - state.maxViewedItem = component.itemIndex; - state.remainingItems = state.items.length - 1 - state.maxViewedItem; - } - - if (state.maxViewedChild == null || state.maxViewedChild < childIndex) { - state.maxViewedChild = component.childIndex; - state.remainingChildren = state.childList.length - 1 - state.maxViewedChild; - } - - this.updateRemainingChildren(); - } - - /** - * Returns the cursor indicating the last index of the last rendered data element - */ - getDataCursor(): number { - return this.privateState.dataOffset; - } - - /** - * Updates the cursor indicating the last index of the last rendered data element - */ - updateDataOffset(): void { - const - {ctx, state} = this, - current = this.getDataCursor(), - chunkSize = ctx.getChunkSize(state); - - this.privateState.dataOffset = current + chunkSize; - } - - /** - * Updates the state of the tillEnd-like fields. - * Calculates the remaining number of child elements until the end and the remaining number of items until the end. - */ - updateRemainingChildren(): void { - const - {state} = this; - - if (state.maxViewedChild == null) { - state.remainingChildren = state.childList.length - 1; - - } else { - state.remainingChildren = state.childList.length - 1 - state.maxViewedChild; - } - - if (state.maxViewedItem == null) { - state.remainingItems = state.items.length - 1; - - } else { - state.remainingItems = state.items.length - 1 - state.maxViewedItem; - } - } -} - diff --git a/src/components/base/b-virtual-scroll/props.ts b/src/components/base/b-virtual-scroll/props.ts deleted file mode 100644 index 58342b2d11..0000000000 --- a/src/components/base/b-virtual-scroll/props.ts +++ /dev/null @@ -1,333 +0,0 @@ -/*! - * V4Fire Client Core - * https://github.com/V4Fire/Client - * - * Released under the MIT license - * https://github.com/V4Fire/Client/blob/master/LICENSE - */ - -import type iItems from 'components/traits/i-items/i-items'; -import type { CreateFromItemFn } from 'components/traits/i-items/i-items'; - -import type bVirtualScroll from 'components/base/b-virtual-scroll/b-virtual-scroll'; - -import type { - - VirtualScrollState, - ComponentDb, - RequestQueryFn, - ShouldPerform, - ItemsProcessors, - - ComponentItemFactory, - ComponentItemType, - ComponentItem, - ComponentItemMeta - -} from 'components/base/b-virtual-scroll/interface'; - -import { defaultShouldProps, componentItemType, itemsProcessors } from 'components/base/b-virtual-scroll/const'; - -import type { Observer } from 'components/base/b-virtual-scroll/modules/observer'; - -import iData, { component, prop } from 'components/super/i-data/i-data'; - -@component() -export default abstract class iVirtualScrollProps extends iData { - /** {@link iItems.item} */ - readonly Item!: object; - - /** {@link iItems.Items} */ - readonly Items!: Array; - - /** {@link iItems.item} */ - @prop({type: [String, Function]}) - readonly item?: iItems['item']; - - /** {@link iItems.items} */ - @prop({type: [String, Function]}) - readonly items: iItems['items']; - - /** {@link iItems.itemKey} */ - @prop({type: [String, Function]}) - readonly itemKey?: CreateFromItemFn; - - /** {@link ComponentItemType} */ - @prop({type: [String, Function]}) - readonly itemType: keyof ComponentItemType | CreateFromItemFn = componentItemType.item; - - /** {@link iItems.itemProps} */ - @prop({type: [Function, Object], default: () => ({})}) - readonly itemProps!: iItems['itemProps']; - - /** - * Meta information for a component that will not be used during rendering, - * but will be available for reading/changing in `itemsProcessors`. - * - * If a function is provided, it will be called; otherwise, the value will be preserved "as is". - * - * @example - * ```typescript - * const itemMeta = (data) => ({ - * componentData: data - * }) - * ``` - */ - @prop() - readonly itemMeta?: CreateFromItemFn; - - /** - * Specifies the number of times the `tombstone` component will be rendered. - * - * This prop can be useful if you want to render multiple `tombstone` components - * using a single specified element. For example, if you set `tombstoneCount` to 3, - * then three `tombstone` components will be rendered on your page. - * - * @example - * ``` - * < b-virtual-scroll :tombstoneCount = 3 - * < template #tombstone - * < .&__skeleton - * Skeleton - * ``` - * - * ```html - *
Skeleton
- *
Skeleton
- *
Skeleton
- * ``` - */ - @prop(Number) - readonly tombstoneCount?: number; - - /** - * This factory function is used to pass information about the components that need to be rendered. - * The function should return an array of arbitrary length consisting of objects that satisfy the - * {@link ComponentItem} interface. - * - * By default, the rendering strategy is based on the `chunkSize` and `iItems` trait. - * In other words, the default implementation takes a data slice of length `chunkSize` - * and calls the `iItems` functions to generate a `ComponentItem` object. - * - * However, the client can implement any required strategy by overriding this function. - * - * For example, it is possible to define a function - * that takes the last loaded data and renders twice as many components: - * - * @example - * ```typescript - * const itemsFactory = (state) => { - * const data = state.lastLoadedData; - * - * const items = data.map((item) => ({ - * item: 'section', - * key: Object.cast(undefined), - * type: 'item', - * children: [], - * props: { - * 'data-index': item.i - * } - * })); - * - * return [...items, ...items]; - * } - * ``` - */ - @prop({ - type: Function, - default: (state: VirtualScrollState, ctx: bVirtualScroll) => { - const descriptors = ctx.getNextDataSlice(state, ctx.getChunkSize(state)).map((data, i) => ({ - key: ctx.itemKey?.(data, i), - - item: Object.isFunction(ctx.item) ? ctx.item(data, i) : ctx.item, - type: Object.isFunction(ctx.itemType) ? ctx.itemType(data, i) : ctx.itemType, - - meta: { - data, - ...Object.isFunction(ctx.itemMeta) ? ctx.itemMeta(data, i) : ctx.itemMeta - }, - - props: Object.isFunction(ctx.itemProps) ? - ctx.itemProps(data, i, { - key: ctx.itemKey?.(data, i), - ctx - }) : - ctx.itemProps - })); - - return descriptors; - } - }) - - readonly itemsFactory!: ComponentItemFactory; - - /** - * This processor function enables you to manipulate previously compiled - * {@link ComponentItem}s via {@link bVirtualScroll.itemsFactory}. It allows you to add components to render, - * mutate props, and add children. It acts as middleware for rendering components. - * - * Scenarios where you might use this functionality: - * - * **Scenario**: Add an advertisement component after each rendered component - * in `b-virtual-scroll` throughout the app. - * - * **Solution**: Instead of overriding {@link bVirtualScroll.itemsFactory} inline, - * use {@link bVirtualScroll.itemsProcessors} for a centralized solution. - * - * @example - * ```typescript - * const addAds = (items: ComponentItem[]) => { - * const newItems = []; - * - * items.forEach((item) => { - * newItems.push(item); - * - * if (item.type === 'item') { - * newItems.push({ - * type: 'separator', - * item: 'b-ads-component', - * props: { prop: 'val' }, - * key: 'uniqueKey' - * }); - * } - * }); - * - * return newItems; - * } - * ``` - * - * To set this function as the global component processor in `b-virtual-scroll`, - * override the `itemsProcessors` constant (in `base/b-virtual-scroll/const.ts`) of your layer and export it. - * - * @example - * ```typescript - * export const itemsProcessors = { - * addAds - * } - * ``` - * - * After redefining this, `b-virtual-scroll` renders `b-ads-component` after - * each `item` component. - * - * **Scenario**: Replace `b-card` components with `b-mega-card` throughout the app - * and modify props. - * - * **Solution**: Add a processor function that changes the component name and mutates props. - * - * @example - * ```typescript - * const itemsProcessors = { - * addAds, - * migrateCardComponent: (items: ComponentItem[]) => { - * return items.map((item) => { - * if (item.item === 'b-card') { - * console.warn('Deprecation: b-card is deprecated.'); - * - * return { - * ...item, - * props: convertProps(item.props), - * item: 'b-mega-card' - * }; - * } - * - * return item; - * }); - * } - * } - * ``` - */ - @prop({ - type: [Function, Object, Array], - default: itemsProcessors - }) - - readonly itemsProcessors?: ItemsProcessors; - - override readonly DB!: ComponentDb; - - /** - * A function that returns the GET parameters for a request. This function is called for each request. It receives the - * current component state and should return the request parameters. These parameters are merged with the parameters - * from the `request` prop in favor of the second one. - * - * This function is useful when you need to pass pagination parameters or any other parameters that should not trigger - * a component's state reload, unlike changing the `request` prop. - * - * {@link RequestQueryFn} - */ - @prop({type: Function}) - readonly requestQuery?: RequestQueryFn; - - /** - * The amount of data required to perform one cycle of item rendering. - * - * This prop is primarily used to determine whether a specific action with the data needs to be performed - * ({@link bVirtualScroll.renderGuard}), and only secondarily for component rendering. - * - * By default, this prop is used in {@link bVirtualScroll.itemsFactory} to slice the data - * according to the {@link bVirtualScroll.chunkSize} and render components based on it. - * However, it is possible to define a custom {@link bVirtualScroll.itemsFactory} and render as many components - * as desired in one cycle of rendering. In this case, the `chunkSize` will only have significance for the data. - * - * This prop can also be a function that should return the amount of data required to perform one cycle of rendering. - * For example, different values can be specified depending on the rendering page: - * - * @example - * ```typescript - * const chunkSize = (state: VirtualScrollState) => { - * return [6, 12, 18][state.renderPage] ?? 18; - * } - * ``` - */ - @prop({type: [Number, Function]}) - readonly chunkSize: number | ShouldPerform = 10; - - /** - * When this function returns true the component will stop to request new data. - * This function will be called on each data loading cycle. - */ - @prop({ - type: Function, - default: defaultShouldProps.shouldStopRequestingData - }) - - readonly shouldStopRequestingData!: ShouldPerform; - - /** - * When this function returns true the component will be able to request additional data. - * This function will be called each time a new element enters the viewport. - */ - @prop({ - type: Function, - default: defaultShouldProps.shouldPerformDataRequest - }) - - readonly shouldPerformDataRequest!: ShouldPerform; - - /** - * This function is called in the {@link bVirtualScroll.renderGuard} after other checks are completed. - * - * This function receives the component state as input, based on which the client - * should determine whether the component should render the next chunk of components. - * - * For example, if we want to render the next data chunk only when the client - * has seen all the main (`type=item`) components, we can implement the following function: - * - * @example - * ```typescript - * const shouldPerformDataRender = (state) => { - * return state.isInitialRender || state.remainingItems === 0; - * } - * ``` - */ - @prop({type: Function, default: defaultShouldProps.shouldPerformDataRender}) - readonly shouldPerformDataRender?: ShouldPerform; - - /** - * Setting this property to false will disable the {@link Observer observation module}. This is useful when you - * want to implement lazy rendering not based on scrolling but on some other event, such as a click. In this case, - * you should use manual invocation of the `initLoadNext` method to render chunks. - */ - @prop(Boolean) - readonly disableObserver: boolean = false; -} diff --git a/src/components/base/b-virtual-scroll/test/index.js b/src/components/base/b-virtual-scroll/test/index.js new file mode 100644 index 0000000000..bfc1bf2e89 --- /dev/null +++ b/src/components/base/b-virtual-scroll/test/index.js @@ -0,0 +1,32 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +// @ts-check + +/** + * @typedef {import('playwright').Page} Page + */ + +const + h = include('tests/helpers').default, + u = include('tests/utils').default; + +/** + * Starts a test + * + * @param {Page} page + * @param {object} params + * @returns {Promise} + */ +module.exports = async (page, params) => { + const + test = u.getCurrentTest(); + + await h.utils.setup(page, params.context); + return test(page); +}; diff --git a/src/components/base/b-virtual-scroll/test/runners/events/chunk-loaded.js b/src/components/base/b-virtual-scroll/test/runners/events/chunk-loaded.js new file mode 100644 index 0000000000..20edbc8bae --- /dev/null +++ b/src/components/base/b-virtual-scroll/test/runners/events/chunk-loaded.js @@ -0,0 +1,230 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +// @ts-check + +/** + * @typedef {import('playwright').Page} Page + */ + +const + h = include('tests/helpers').default; + +/** @param {Page} page */ +module.exports = (page) => { + let + component, + node, + container; + + beforeEach(async () => { + await page.evaluate(() => { + globalThis.removeCreatedComponents(); + + const baseAttrs = { + theme: 'demo', + option: 'section', + optionProps: ({current}) => ({'data-index': current.i}) + }; + + const scheme = [ + { + attrs: { + ...baseAttrs, + id: 'target' + } + } + ]; + + globalThis.renderComponents('b-virtual-scroll', scheme); + }); + + await h.bom.waitForIdleCallback(page); + await h.component.waitForComponentStatus(page, '.b-virtual-scroll', 'ready'); + + component = await h.component.waitForComponent(page, '#target'); + node = await h.dom.waitForEl(page, '#target'); + container = await h.dom.waitForRef(node, 'container'); + }); + + const + getArray = (offset = 0, length = 12) => ({data: Array.from(Array(length), (v, i) => ({i: i + offset}))}), + firstChunkExpected = getArray(); + + const subscribe = () => component.evaluate((ctx) => new Promise((res) => ctx.watch(':onChunkLoaded', res))); + + const setProps = (requestProps = {}) => component.evaluate((ctx, requestProps) => { + ctx.dataProvider = 'demo.Pagination'; + ctx.chunkSize = 10; + ctx.request = {get: {chunkSize: 12, id: Math.random(), ...requestProps}}; + }, requestProps); + + describe('b-virtual-scroll `chunkLoaded` event', () => { + describe('emitted', () => { + it('after loading the first chunk', async () => { + const subscribePromise = subscribe(); + await setProps(); + + await expectAsync(subscribePromise).toBeResolved(); + }); + + it('after loading the second chunk', async () => { + await setProps(); + await h.dom.waitForEl(container, 'section'); + + const subscribePromise = subscribe(); + + await h.scroll.scrollToBottom(page); + await expectAsync(subscribePromise).toBeResolved(); + }); + + it('when loading the first chunk after re-initialization', async () => { + await setProps(); + await h.dom.waitForEl(container, 'section'); + + const subscribePromise = subscribe(); + + await component.evaluate((ctx) => ctx.request = {get: {chunkSize: 20}}); + await expectAsync(subscribePromise).toBeResolved(); + }); + + it('three times to get the full data batch', async () => { + await component.evaluate((ctx) => { + ctx.watch(':onChunkLoaded', () => { + ctx.tmp.called = (ctx.tmp.called ?? 0) + 1; + }); + }); + + await setProps({chunkSize: 4}); + await h.dom.waitForEl(container, 'section'); + + expect(await component.evaluate((ctx) => ctx.tmp.called)).toBe(3); + }); + + it('after successful loading of the first chunk without payload', async () => { + const subscribePromise = subscribe(); + + await setProps({chunkSize: 0, total: 0}); + + await expectAsync(subscribePromise).toBeResolved(); + }); + + it('after successful loading of the second chunk without payload', async () => { + await setProps({chunkSize: 12, total: 12}); + await h.dom.waitForEl(container, 'section'); + + const subscribePromise = subscribe(); + + await h.scroll.scrollToBottom(page); + await expectAsync(subscribePromise).toBeResolved(); + }); + }); + + describe('not emitted', () => { + it('if there was a request error', async () => { + await component.evaluate((ctx) => ctx.watch(':onChunkLoaded', () => ctx.tmp.change = true)); + + await setProps({failOn: 0}); + await h.bom.waitForIdleCallback(page, {sleepAfterIdles: 1000}); + + expect(await component.evaluate((ctx) => ctx.tmp.change)).toBeUndefined(); + }); + }); + + describe('has correct payload', () => { + it('after loading the first chunk', async () => { + const subscribePromise = subscribe(); + + await component.evaluate((ctx) => { + ctx.dataProvider = 'demo.Pagination'; + ctx.chunkSize = 10; + ctx.request = {get: {chunkSize: 12, additionalData: {size: 12}}}; + }); + + await expectAsync(subscribePromise).toBeResolvedTo({ + normalized: firstChunkExpected.data, + raw: {data: firstChunkExpected.data, size: 12} + }); + }); + + it('after loading the second chunk', async () => { + await component.evaluate((ctx) => { + ctx.dataProvider = 'demo.Pagination'; + ctx.chunkSize = 10; + ctx.request = {get: {chunkSize: 12, additionalData: {size: 12}}}; + }); + + await h.dom.waitForEl(container, 'section'); + + const subscribePromise = subscribe(); + + await h.scroll.scrollToBottom(page); + await expectAsync(subscribePromise).toBeResolved({ + normalized: firstChunkExpected.data, + raw: {data: firstChunkExpected.data, size: 12} + }); + }); + + it('after loading the first chunk with an empty payload', async () => { + const subscribePromise = subscribe(); + + await component.evaluate((ctx) => { + ctx.dataProvider = 'demo.Pagination'; + ctx.chunkSize = 10; + ctx.request = {get: {id: Math.random(), chunkSize: 0, total: 0, additionalData: {size: 12}}}; + }); + + await expectAsync(subscribePromise).toBeResolved({ + normalized: [], + raw: {data: [], size: 12} + }); + }); + + it('after loading the second chunk with an empty payload', async () => { + await component.evaluate((ctx) => { + ctx.dataProvider = 'demo.Pagination'; + ctx.chunkSize = 10; + ctx.request = {get: {id: Math.random(), chunkSize: 12, total: 12, additionalData: {size: 12}}}; + }); + + await h.dom.waitForEl(container, 'section'); + + const subscribePromise = subscribe(); + + await h.scroll.scrollToBottom(page); + await expectAsync(subscribePromise).toBeResolved({ + normalized: [], + raw: {data: [], size: 12} + }); + }); + + it('when loading the first chunk in parts', async () => { + await component.evaluate((ctx) => { + ctx.tmp.eventAccumulator = {}; + + ctx.watch(':onChunkLoaded', (val) => { + ctx.tmp.called = (ctx.tmp.called ?? 0) + 1; + ctx.tmp.eventAccumulator[ctx.tmp.called] = Object.fastClone(val); + }); + + ctx.dataProvider = 'demo.Pagination'; + ctx.chunkSize = 10; + ctx.request = {get: {chunkSize: 4, id: Math.random(), additionalData: {size: 12}}}; + }); + + await h.dom.waitForEl(container, 'section'); + + expect(await component.evaluate((ctx) => ctx.tmp.eventAccumulator)).toEqual({ + 1: {normalized: getArray(0, 4).data, raw: {data: getArray(0, 4).data, size: 12}}, + 2: {normalized: getArray(4, 4).data, raw: {data: getArray(4, 4).data, size: 12}}, + 3: {normalized: getArray(8, 4).data, raw: {data: getArray(8, 4).data, size: 12}} + }); + }); + }); + }); +}; diff --git a/src/components/base/b-virtual-scroll/test/runners/events/chunk-loading.js b/src/components/base/b-virtual-scroll/test/runners/events/chunk-loading.js new file mode 100644 index 0000000000..286f8e8597 --- /dev/null +++ b/src/components/base/b-virtual-scroll/test/runners/events/chunk-loading.js @@ -0,0 +1,106 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +// @ts-check + +/** + * @typedef {import('playwright').Page} Page + */ + +const + h = include('tests/helpers').default; + +/** @param {Page} page */ +module.exports = (page) => { + let + component, + node, + container; + + beforeEach(async () => { + await page.evaluate(() => { + globalThis.removeCreatedComponents(); + + const baseAttrs = { + theme: 'demo', + option: 'section', + optionProps: ({current}) => ({'data-index': current.i}) + }; + + const scheme = [ + { + attrs: { + ...baseAttrs, + id: 'target' + } + } + ]; + + globalThis.renderComponents('b-virtual-scroll', scheme); + }); + + await h.bom.waitForIdleCallback(page); + await h.component.waitForComponentStatus(page, '.b-virtual-scroll', 'ready'); + + component = await h.component.waitForComponent(page, '#target'); + node = await h.dom.waitForEl(page, '#target'); + container = await h.dom.waitForRef(node, 'container'); + }); + + const subscribe = () => component.evaluate((ctx) => new Promise((res) => ctx.watch(':onChunkLoading', res))); + + const setProps = (requestProps = {}) => component.evaluate((ctx, requestProps) => { + ctx.dataProvider = 'demo.Pagination'; + ctx.chunkSize = 10; + ctx.request = {get: {chunkSize: 12, id: Math.random(), ...requestProps}}; + }, requestProps); + + describe('b-virtual-scroll `chunkLoading` event', () => { + describe('emitted', () => { + it('when loading the first chunk', async () => { + const subscribePromise = subscribe(); + + await setProps(); + await expectAsync(subscribePromise).toBeResolvedTo(0); + }); + + it('when loading the second chunk', async () => { + await setProps(); + await h.dom.waitForEl(container, 'section'); + + const subscribePromise = subscribe(); + + await h.scroll.scrollToBottom(page); + await expectAsync(subscribePromise).toBeResolvedTo(1); + }); + + it('when loading the first chunk after re-initialization', async () => { + await setProps(); + await h.dom.waitForEl(container, 'section'); + + const subscribePromise = subscribe(); + await setProps({id: Math.random()}); + + await expectAsync(subscribePromise).toBeResolvedTo(0); + }); + + it('three times when loading a full chunk', async () => { + await component.evaluate((ctx) => ctx.watch(':onChunkLoading', (val) => { + ctx.tmp.currentCall = (ctx.tmp.currentCall ?? 0) + 1; + ctx.tmp[ctx.tmp.currentCall] = val; + })); + + await setProps({chunkSize: 4}); + await h.dom.waitForEl(container, 'section'); + + expect(await component.evaluate((ctx) => ctx.tmp.currentCall)).toBe(3); + expect(await component.evaluate((ctx) => [ctx.tmp[1], ctx.tmp[2], ctx.tmp[3]])).toEqual([0, 1, 2]); + }); + }); + }); +}; diff --git a/src/components/base/b-virtual-scroll/test/runners/events/data-change.js b/src/components/base/b-virtual-scroll/test/runners/events/data-change.js new file mode 100644 index 0000000000..d05883d4c5 --- /dev/null +++ b/src/components/base/b-virtual-scroll/test/runners/events/data-change.js @@ -0,0 +1,216 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +// @ts-check + +/** + * @typedef {import('playwright').Page} Page + */ + +const + h = include('tests/helpers').default; + +/** @param {Page} page */ +module.exports = (page) => { + let + component, + node, + container; + + const + getArray = (offset = 0, length = 12) => Array.from(Array(length), (v, i) => ({i: i + offset})), + firstChunkExpected = getArray(), + secondChunkExpected = getArray(12); + + const setProps = (requestProps = {}) => component.evaluate((ctx, requestProps) => { + ctx.dataProvider = 'demo.Pagination'; + ctx.chunkSize = 10; + ctx.request = {get: {chunkSize: 12, id: Math.random(), ...requestProps}}; + }, requestProps); + + const subscribe = () => component.evaluate((ctx) => new Promise((res) => ctx.watch(':onDataChange', res))); + + beforeEach(async () => { + await page.evaluate(() => { + globalThis.removeCreatedComponents(); + + const baseAttrs = { + theme: 'demo', + option: 'section', + optionProps: ({current}) => ({'data-index': current.i}) + }; + + const scheme = [ + { + attrs: { + ...baseAttrs, + id: 'target' + } + } + ]; + + globalThis.renderComponents('b-virtual-scroll', scheme); + }); + + await h.bom.waitForIdleCallback(page); + await h.component.waitForComponentStatus(page, '.b-virtual-scroll', 'ready'); + + component = await h.component.waitForComponent(page, '#target'); + node = await h.dom.waitForEl(page, '#target'); + container = await h.dom.waitForRef(node, 'container'); + }); + + describe('b-virtual-scroll `dataChange` event', () => { + describe('emitted', () => { + it('after loading the first chunk', async () => { + const subscribePromise = subscribe(); + + await setProps(); + await expectAsync(subscribePromise).toBeResolved(); + }); + + it('after loading the second chunk', async () => { + await setProps(); + await h.dom.waitForEl(container, 'section'); + + const subscribePromise = subscribe(); + + await h.scroll.scrollToBottom(page); + await expectAsync(subscribePromise).toBeResolved(); + }); + + it('after loading the first part of the batch and stopping further loading because `shouldStopRequest` have returned `true`', async () => { + const subscribePromise = subscribe(); + + await component.evaluate((ctx) => { + ctx.dataProvider = 'demo.Pagination'; + ctx.request = {get: {chunkSize: 4, id: Math.random()}}; + ctx.shouldStopRequest = () => true; + }); + + await expectAsync(subscribePromise).toBeResolvedTo(getArray(0, 4)); + }); + + it('after loading the second part of the batch and stopping further loading because `shouldStopRequest` have returned `true`', async () => { + const subscribePromise = subscribe(); + + await component.evaluate((ctx) => { + ctx.dataProvider = 'demo.Pagination'; + ctx.request = {get: {chunkSize: 4, id: Math.random()}}; + ctx.shouldStopRequest = (v) => v.pendingData.length === 8; + }); + + await expectAsync(subscribePromise).toBeResolvedTo(getArray(0, 8)); + }); + + it('after loading the first part of the second batch and stopping further loading because `shouldStopRequest` have returned `true`', async () => { + await component.evaluate((ctx) => { + ctx.dataProvider = 'demo.Pagination'; + ctx.request = {get: {chunkSize: 4, id: Math.random()}}; + ctx.shouldStopRequest = (v) => { + const {lastLoadedChunk: {normalized}} = v; + return normalized[normalized.length - 1].i === 15; + }; + }); + + await h.dom.waitForEl(container, 'section'); + + const subscribePromise = subscribe(); + await h.scroll.scrollToBottom(page); + + await expectAsync(subscribePromise).toBeResolvedTo(getArray(12, 4)); + }); + + }); + + describe('not emitted', () => { + it('if there was a request error', async () => { + await component.evaluate((ctx) => ctx.watch(':onDataChange', () => ctx.tmp.change = true)); + + await setProps({failOn: 0}); + await h.bom.waitForIdleCallback(page, {sleepAfterIdles: 1000}); + + expect(await component.evaluate((ctx) => ctx.tmp.change)).toBeUndefined(); + }); + + it('if there was a request error on the second chunk', async () => { + await setProps({failOn: 1}); + await h.dom.waitForEl(container, 'section'); + + await component.evaluate((ctx) => ctx.watch(':onDataChange', () => ctx.tmp.change = true)); + + await h.scroll.scrollToBottom(page); + await h.bom.waitForIdleCallback(page, {sleepAfterIdles: 500}); + + expect(await component.evaluate((ctx) => ctx.tmp.change)).toBeUndefined(); + }); + }); + + describe('has correct payload', () => { + it('if nothing was loaded', async () => { + const subscribePromise = subscribe(); + + await setProps({total: 0, chunkSize: 0}); + await expectAsync(subscribePromise).toBeResolvedTo([]); + }); + + describe('after loading', () => { + it('first chunk', async () => { + const subscribePromise = subscribe(); + + await setProps({chunkSize: 12}); + await expectAsync(subscribePromise).toBeResolvedTo(firstChunkExpected); + }); + + it('second chunk', async () => { + await setProps({chunkSize: 12}); + await h.dom.waitForEl(container, 'section'); + + const subscribePromise = subscribe(); + + await h.scroll.scrollToBottom(page); + await expectAsync(subscribePromise).toBeResolvedTo(secondChunkExpected); + }); + }); + + describe('after re-initialization', () => { + it('and loading the first chunk with 2 requests', async () => { + await setProps({id: undefined}); + await h.dom.waitForEl(container, 'section'); + + const subscribePromise = subscribe(); + + await component.evaluate((ctx) => ctx.request = {get: {chunkSize: 6, id: Math.random()}}); + + await expectAsync(subscribePromise).toBeResolvedTo(firstChunkExpected); + }); + + it('and loading the second chunk with 2 requests', async () => { + await setProps({id: undefined}); + await h.dom.waitForEl(container, 'section'); + + await component.evaluate((ctx) => ctx.watch(':onDataChange', (val) => { + ctx.tmp.currentCall = ctx.tmp.currentCall ?? 0; + ctx.tmp[ctx.tmp.currentCall] = val; + ctx.tmp.currentCall++; + })); + + await component.evaluate((ctx) => ctx.request = {get: {chunkSize: 6, id: Math.random()}}); + await h.bom.waitForIdleCallback(page, {sleepAfterIdles: 1000}); + + expect(await component.evaluate((ctx) => ctx.tmp[0])).toEqual(firstChunkExpected); + + await h.scroll.scrollToBottom(page); + await h.bom.waitForIdleCallback(page, {sleepAfterIdles: 1000}); + + expect(await component.evaluate((ctx) => ctx.tmp[1])).toEqual(secondChunkExpected); + }); + }); + }); + }); +}; diff --git a/src/components/base/b-virtual-scroll/test/runners/events/db-change.js b/src/components/base/b-virtual-scroll/test/runners/events/db-change.js new file mode 100644 index 0000000000..b0780bc9a7 --- /dev/null +++ b/src/components/base/b-virtual-scroll/test/runners/events/db-change.js @@ -0,0 +1,184 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +// @ts-check + +/** + * @typedef {import('playwright').Page} Page + */ + +const + h = include('tests/helpers').default; + +/** @param {Page} page */ +module.exports = (page) => { + let + component, + node, + container; + + const + getArray = (offset = 0, length = 12) => ({data: Array.from(Array(length), (v, i) => ({i: i + offset}))}), + firstChunkExpected = getArray(); + + const setProps = (requestProps = {}) => component.evaluate((ctx, requestProps) => { + ctx.dataProvider = 'demo.Pagination'; + ctx.chunkSize = 10; + ctx.request = {get: {chunkSize: 12, id: Math.random(), ...requestProps}}; + }, requestProps); + + const subscribe = () => component.evaluate((ctx) => new Promise((res) => ctx.watch(':onDBChange', res))); + + beforeEach(async () => { + await page.evaluate(() => { + globalThis.removeCreatedComponents(); + + const baseAttrs = { + theme: 'demo', + option: 'section', + optionProps: ({current}) => ({'data-index': current.i}) + }; + + const scheme = [ + { + attrs: { + ...baseAttrs, + id: 'target' + } + } + ]; + + globalThis.renderComponents('b-virtual-scroll', scheme); + }); + + await h.bom.waitForIdleCallback(page); + await h.component.waitForComponentStatus(page, '.b-virtual-scroll', 'ready'); + + component = await h.component.waitForComponent(page, '#target'); + node = await h.dom.waitForEl(page, '#target'); + container = await h.dom.waitForRef(node, 'container'); + }); + + describe('b-virtual-scroll `dbChange` event', () => { + describe('emitted', () => { + it('after loading the first chunk', async () => { + const subscribePromise = subscribe(); + await setProps(); + + await expectAsync(subscribePromise).toBeResolved(); + }); + + it('after loading the second chunk', async () => { + await setProps(); + await h.dom.waitForEl(container, 'section'); + + const subscribePromise = subscribe(); + + await h.scroll.scrollToBottom(page); + await expectAsync(subscribePromise).toBeResolved(); + }); + + it('after loading the first chunk after re-initialization', async () => { + await setProps(); + await h.dom.waitForEl(container, 'section'); + + const subscribePromise = subscribe(); + + await component.evaluate((ctx) => ctx.request = {get: {chunkSize: 20}}); + await expectAsync(subscribePromise).toBeResolved(); + }); + + it('three times to get the full data batch', async () => { + await component.evaluate((ctx) => { + ctx.watch(':onDBChange', () => { + ctx.tmp.called = (ctx.tmp.called ?? 0) + 1; + }); + }); + + await setProps({chunkSize: 4}); + await h.dom.waitForEl(container, 'section'); + + expect(await component.evaluate((ctx) => ctx.tmp.called)).toBe(3); + }); + + it('after successful loading of the first chunk without payload', async () => { + const subscribePromise = subscribe(); + + await setProps({chunkSize: 0, total: 0}); + + await expectAsync(subscribePromise).toBeResolved(); + }); + }); + + describe('not emitted', () => { + it('if there was a request error', async () => { + await component.evaluate((ctx) => { + ctx.watch(':onDBChange', () => { + ctx.tmp.called = (ctx.tmp.called ?? 0) + 1; + }); + }); + + await setProps({failOn: 0}); + await h.bom.waitForIdleCallback(page, {sleepAfterIdles: 500}); + + expect(await component.evaluate((ctx) => ctx.tmp.called)).toBeUndefined(); + }); + + it('after successful loading of the second chunk without payload', async () => { + await setProps({chunkSize: 12, total: 12}); + await h.dom.waitForEl(container, 'section'); + + await component.evaluate((ctx) => { + ctx.watch(':onDBChange', () => { + ctx.tmp.called = (ctx.tmp.called ?? 0) + 1; + }); + }); + + await h.scroll.scrollToBottom(page); + expect(await component.evaluate((ctx) => ctx.tmp.called)).toBeUndefined(); + }); + }); + + describe('has correct payload', () => { + it('after loading the first chunk', async () => { + const subscribePromise = subscribe(); + + await setProps({chunkSize: 4}); + await h.dom.waitForEl(container, 'section'); + + await expectAsync(subscribePromise).toBeResolvedTo(getArray(0, 4)); + }); + + it('after loading two chunks', async () => { + await component.evaluate((ctx) => { + ctx.watch(':onDBChange', (val) => { + ctx.tmp.called = (ctx.tmp.called ?? 0) + 1; + ctx.tmp[ctx.tmp.called] = Object.fastClone(val); + }); + }); + + await setProps({chunkSize: 6}); + await h.dom.waitForEl(container, 'section'); + + expect(await component.evaluate((ctx) => ctx.tmp[1])).toEqual(getArray(0, 6)); + expect(await component.evaluate((ctx) => ctx.tmp[2])).toEqual(getArray(0, 12)); + + }); + + it('after re-initialization and loading the first chunk', async () => { + await setProps({chunkSize: 6}); + await h.dom.waitForEl(container, 'section'); + + const subscribePromise = subscribe(); + + await setProps({chunkSize: 12, id: Math.random()}); + await expectAsync(subscribePromise).toBeResolvedTo(firstChunkExpected); + }); + }); + }); +}; diff --git a/src/components/base/b-virtual-scroll/test/runners/functional/items.js b/src/components/base/b-virtual-scroll/test/runners/functional/items.js new file mode 100644 index 0000000000..970b40be35 --- /dev/null +++ b/src/components/base/b-virtual-scroll/test/runners/functional/items.js @@ -0,0 +1,102 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +// @ts-check + +/** + * @typedef {import('playwright').Page} Page + */ + +const + h = include('tests/helpers').default; + +/** @param {Page} page */ +module.exports = (page) => { + let + node, + container, + component; + + const renderComponent = async (attrs = {}) => { + await page.evaluate(([attrs]) => { + globalThis.removeCreatedComponents(); + + const baseAttrs = { + theme: 'demo', + dataProvider: 'demo.Pagination', + chunkSize: 10, + request: {get: {chunkSize: 10, id: Math.random()}}, + item: 'section', + itemProps: ({current}) => ({'data-index': current.i}), + itemKey: (data) => data.current.i, + optionKey: (data) => data.current.i + }; + + const scheme = [ + { + attrs: { + ...baseAttrs, + id: 'target', + ...attrs + } + } + ]; + + globalThis.renderComponents('b-virtual-scroll', scheme); + }, [attrs]); + + node = await h.dom.waitForEl(page, '#target'); + component = await h.component.waitForComponent(page, '#target'); + container = await h.dom.waitForRef(node, 'container'); + }; + + beforeEach(async () => { + await h.component.waitForComponent(page, '#root-component'); + await page.evaluate(() => globalThis.removeCreatedComponents()); + }); + + describe('b-virtual-scroll with the `iItems` trait', () => { + it('renders a correct item', async () => { + await renderComponent(); + await h.dom.waitForEl(container, 'section'); + expect(await container.$('section')).toBeTruthy(); + }); + + it('renders an item with provided props', async () => { + await renderComponent(); + await h.dom.waitForEl(container, 'section'); + const attrVal = await (await container.$('section')).evaluate((el) => el.getAttribute('data-index')); + expect(parseInt(attrVal, 10)).toBe(0); + }); + + it('uses the deprecated `optionKey` property', async () => { + await renderComponent({ + itemKey: undefined + }); + + const optionKey1 = await component.evaluate((ctx) => ctx.getItemKey({current: {i: 0}})); + expect(optionKey1).toBe(0); + + const optionKey2 = await component.evaluate((ctx) => ctx.getItemKey({current: {i: 1}})); + expect(optionKey2).toBe(1); + }); + + it('uses the `itemKey` property', async () => { + await renderComponent({ + optionKey: undefined + }); + + const itemKey1 = await component.evaluate((ctx) => ctx.getItemKey({current: {i: 0}})); + expect(itemKey1).toBe(0); + + const itemKey2 = await component.evaluate((ctx) => ctx.getItemKey({current: {i: 1}})); + expect(itemKey2).toBe(1); + }); + }); + +}; diff --git a/src/components/base/b-virtual-scroll/test/runners/functional/render-next.js b/src/components/base/b-virtual-scroll/test/runners/functional/render-next.js new file mode 100644 index 0000000000..826fcb7725 --- /dev/null +++ b/src/components/base/b-virtual-scroll/test/runners/functional/render-next.js @@ -0,0 +1,102 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +// @ts-check + +/** + * @typedef {import('playwright').Page} Page + */ + +const + h = include('tests/helpers').default; + +/** @param {Page} page */ +module.exports = (page) => { + let + component, + node, + container; + + const + getContainerChildCount = () => component.evaluate((ctx) => ctx.$refs.container.childElementCount); + + beforeEach(async () => { + await page.evaluate(() => { + globalThis.removeCreatedComponents(); + + const baseAttrs = { + theme: 'demo', + option: 'section', + optionProps: ({current}) => ({'data-index': current.i}) + }; + + const scheme = [ + { + attrs: { + ...baseAttrs, + id: 'target' + } + } + ]; + + globalThis.renderComponents('b-virtual-scroll', scheme); + }); + + await h.bom.waitForIdleCallback(page); + await h.component.waitForComponentStatus(page, '.b-virtual-scroll', 'ready'); + + node = await h.dom.waitForEl(page, '#target'); + component = await h.component.waitForComponent(page, '#target'); + container = await h.dom.waitForRef(node, 'container'); + + await component.evaluate((ctx) => { + ctx.dataProvider = 'demo.Pagination'; + ctx.chunkSize = 10; + ctx.request = {get: {chunkSize: 10, id: Math.random()}}; + }); + }); + + describe('b-virtual-scroll', () => { + ['manual', 'scroll'].forEach((strategy) => { + describe(`renderNext with loadStrategy: ${strategy}`, () => { + it('renders the next data batch', async () => { + await component.evaluate((ctx, strategy) => { + ctx.loadStrategy = strategy; + ctx.request = {get: {chunkSize: 20, id: Math.random()}}; + }, strategy); + + await h.dom.waitForEl(container, 'section'); + + expect(await component.evaluate((ctx) => ctx.chunkRequest.pendingData.length)).toBe(10); + await component.evaluate((ctx) => ctx.renderNext()); + + expect(await component.evaluate((ctx) => ctx.chunkRequest.pendingData.length)).toBe(0); + await h.dom.waitForEl(container, 'section:nth-child(11)'); + + expect(await getContainerChildCount()).toBe(20); + }); + + it('requests and renders the next data batch', async () => { + await component.evaluate((ctx, strategy) => { + ctx.loadStrategy = strategy; + ctx.request = {get: {chunkSize: 10, id: Math.random()}}; + }, strategy); + + await h.dom.waitForEl(container, 'section'); + expect(await component.evaluate((ctx) => ctx.chunkRequest.pendingData.length)).toBe(0); + + await component.evaluate((ctx) => ctx.renderNext()); + await h.dom.waitForEl(container, 'section:nth-child(11)'); + + expect(await component.evaluate((ctx) => ctx.chunkRequest.pendingData.length)).toBe(0); + expect(await getContainerChildCount()).toBe(20); + }); + }); + }); + }); +}; diff --git a/src/components/base/b-virtual-scroll/test/runners/functional/state.js b/src/components/base/b-virtual-scroll/test/runners/functional/state.js new file mode 100644 index 0000000000..85d5e1ef9d --- /dev/null +++ b/src/components/base/b-virtual-scroll/test/runners/functional/state.js @@ -0,0 +1,263 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +// @ts-check + +/** + * @typedef {import('playwright').Page} Page + */ + +const + h = include('tests/helpers').default; + +/** @param {Page} page */ +module.exports = (page) => { + let + component, + node, + container; + + const + getArray = (offset = 0, length = 12) => ({data: Array.from(Array(length), (v, i) => ({i: i + offset}))}), + firstChunkExpected = getArray(), + secondChunkExpected = getArray(12); + + const getExpected = (params = {}) => ({ + items: undefined, + + itemsTillBottom: undefined, + currentPage: 0, + nextPage: 1, + isLastEmpty: false, + total: undefined, + + data: [], + pendingData: [], + + lastLoadedData: [], + lastLoadedChunk: { + raw: undefined, + normalized: [] + }, + ...params + }); + + const getCurrentComponentState = () => component.evaluate((ctx) => ({ + ...ctx.getCurrentDataState(), + itemsTillBottom: undefined, + items: undefined + })); + + const setProps = (requestProps = {}) => component.evaluate((ctx, requestProps) => { + ctx.dataProvider = 'demo.Pagination'; + ctx.chunkSize = 10; + ctx.request = {get: {chunkSize: 12, id: Math.random(), ...requestProps}}; + }, requestProps); + + const + initialTimeout = globalThis.jasmine.DEFAULT_TIMEOUT_INTERVAL; + + beforeAll(() => { + globalThis.jasmine.DEFAULT_TIMEOUT_INTERVAL = (20).seconds(); + }); + + afterAll(() => { + globalThis.jasmine.DEFAULT_TIMEOUT_INTERVAL = initialTimeout; + }); + + beforeEach(async () => { + await h.utils.reloadAndWaitForIdle(page); + await h.component.waitForComponent(page, '#root-component'); + + await page.evaluate(() => { + globalThis.removeCreatedComponents(); + + const baseAttrs = { + theme: 'demo', + option: 'section', + optionProps: ({current}) => ({'data-index': current.i}) + }; + + const scheme = [ + { + attrs: { + ...baseAttrs, + id: 'target' + } + } + ]; + + globalThis.renderComponents('b-virtual-scroll', scheme); + }); + + await h.bom.waitForIdleCallback(page); + await h.component.waitForComponentStatus(page, '.b-virtual-scroll', 'ready'); + + component = await h.component.waitForComponent(page, '#target'); + node = await h.dom.waitForEl(page, '#target'); + container = await h.dom.waitForRef(node, 'container'); + }); + + describe('b-virtual-scroll `getCurrentDataState`', () => { + describe('returns the correct value', () => { + it('if there is no `dataProvider`', async () => { + const + expected = getExpected(), + current = await getCurrentComponentState(); + + expect(current).toEqual(expected); + }); + + it('after loading the first chunk', async () => { + const expected = getExpected({ + currentPage: 1, + nextPage: 2, + data: firstChunkExpected.data, + pendingData: getArray(10, 2).data, + lastLoadedData: firstChunkExpected.data, + lastLoadedChunk: { + raw: firstChunkExpected, + normalized: firstChunkExpected.data + } + }); + + await setProps(); + await h.dom.waitForEl(container, 'section'); + + const current = await getCurrentComponentState(); + expect(current).toEqual(expected); + }); + + it('after loading the second chunk', async () => { + const expected = getExpected({ + currentPage: 2, + nextPage: 3, + data: getArray(0, 24).data, + pendingData: getArray(20, 4).data, + lastLoadedData: secondChunkExpected.data, + lastLoadedChunk: { + raw: secondChunkExpected, + normalized: secondChunkExpected.data + } + }); + + await setProps(); + await h.dom.waitForEl(container, 'section'); + + await h.scroll.scrollToBottom(page); + await h.dom.waitForEl(container, 'section:nth-child(11)'); + + const current = await getCurrentComponentState(); + expect(current).toEqual(expected); + }); + + it('after re-initialization and without `dataProvider`', async () => { + const expected = getExpected({currentPage: 0, nextPage: 1}); + + await setProps(); + await h.dom.waitForEl(container, 'section'); + + await component.evaluate((ctx) => { + ctx.dataProvider = ''; + ctx.request = undefined; + ctx.reInit(); + }); + + await h.dom.waitForEl(container, 'section', {to: 'unmount'}); + await h.bom.waitForIdleCallback(page, {sleepAfterIdles: 1000}); + + const current = await getCurrentComponentState(); + expect(current).toEqual(expected); + }); + + it('after re-initialization and with `dataProvider`', async () => { + const expected = getExpected({ + currentPage: 1, + nextPage: 2, + data: firstChunkExpected.data, + pendingData: getArray(10, 2).data, + lastLoadedData: firstChunkExpected.data, + lastLoadedChunk: { + raw: firstChunkExpected, + normalized: firstChunkExpected.data + } + }); + + await setProps(); + await h.dom.waitForEl(container, 'section'); + + await setProps({id: Math.random()}); + await h.bom.waitForIdleCallback(page); + await h.dom.waitForEl(container, 'section', {to: 'mount'}); + + const current = await getCurrentComponentState(); + expect(current).toEqual(expected); + }); + + it('if for the full loading it was necessary to go several times to `dataProvider`', async () => { + const expected = getExpected({ + currentPage: 3, + nextPage: 4, + data: firstChunkExpected.data, + pendingData: getArray(10, 2).data, + lastLoadedData: getArray(8, 4).data, + lastLoadedChunk: { + raw: getArray(8, 4), + normalized: getArray(8, 4).data + } + }); + + await setProps({chunkSize: 4}); + await h.dom.waitForEl(container, 'section'); + + const current = await getCurrentComponentState(); + expect(current).toEqual(expected); + }); + }); + }); + + describe('b-virtual-scroll `getDataStateSnapshot`', () => { + describe('returns the correct value', () => { + it('with `chunkRequest` and `chunkRender`', async () => { + const + expected = getExpected(), + current = await component.evaluate((ctx) => ctx.getDataStateSnapshot({ + items: undefined, + itemsTillBottom: undefined + })); + + expect(current).toEqual(expected); + }); + + it('with `chunkRequest`', async () => { + const + expected = getExpected(), + current = await component.evaluate((ctx) => ctx.getDataStateSnapshot({ + items: undefined, + itemsTillBottom: undefined + }, ctx.chunkRequest)); + + expect(current).toEqual(expected); + }); + + it('with override params, `chunkRequest` and `chunkRender`', async () => { + const expected = getExpected({ + currentPage: 1, + nextPage: 2 + }); + + const current = await component.evaluate((ctx) => ctx.getDataStateSnapshot({ + items: undefined, + itemsTillBottom: undefined + }, ctx.chunkRequest, ctx.chunkRender)); + + expect(current).toEqual(expected); + }); + }); + }); +}; diff --git a/src/components/base/b-virtual-scroll/test/runners/render/render.js b/src/components/base/b-virtual-scroll/test/runners/render/render.js new file mode 100644 index 0000000000..b2bc6574fd --- /dev/null +++ b/src/components/base/b-virtual-scroll/test/runners/render/render.js @@ -0,0 +1,205 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +// @ts-check + +/** + * @typedef {import('playwright').Page} Page + */ + +const + h = include('tests/helpers').default; + +/** @param {Page} page */ +module.exports = (page) => { + let + component, + node, + container; + + const + getContainerChildCount = () => component.evaluate((ctx) => ctx.$refs.container.childElementCount); + + const setProps = async (reqParams) => { + await component.evaluate((ctx, reqParams) => { + ctx.dataProvider = 'demo.Pagination'; + ctx.chunkSize = 10; + ctx.request = {get: {chunkSize: 10, id: Math.random(), ...reqParams}}; + }, reqParams); + + await h.dom.waitForEl(container, 'section'); + }; + + const + initialTimeout = globalThis.jasmine.DEFAULT_TIMEOUT_INTERVAL; + + beforeAll(() => { + globalThis.jasmine.DEFAULT_TIMEOUT_INTERVAL = (20).seconds(); + }); + + afterAll(() => { + globalThis.jasmine.DEFAULT_TIMEOUT_INTERVAL = initialTimeout; + }); + + beforeEach(async () => { + await page.evaluate(() => { + globalThis.removeCreatedComponents(); + + const baseAttrs = { + theme: 'demo', + option: 'section', + optionProps: ({current}) => ({'data-index': current.i}) + }; + + const scheme = [ + { + attrs: { + ...baseAttrs, + id: 'target' + } + } + ]; + + globalThis.renderComponents('b-virtual-scroll', scheme); + globalThis.componentNode = document.querySelector('.b-virtual-scroll'); + }); + + await h.bom.waitForIdleCallback(page); + await h.component.waitForComponentStatus(page, '.b-virtual-scroll', 'ready'); + + node = await h.dom.waitForEl(page, '#target'); + component = await h.component.waitForComponent(page, '#target'); + container = await h.dom.waitForRef(node, 'container'); + }); + + describe('b-virtual-scroll rendering', () => { + describe('after re-initialization', () => { + describe('by changing the `request` prop', () => { + it('removes old elements', async () => { + await setProps(); + + await component.evaluate((ctx) => ctx.request = {get: {chunkSize: 10, total: 0}}); + await h.dom.waitForEl(container, 'section', {to: 'unmount'}); + + expect(await component.evaluate((ctx) => ctx.$refs.container.childElementCount === 0)).toBeTrue(); + }); + + it('renders new', async () => { + await setProps(); + + const + chunkSize = await component.evaluate((ctx) => ctx.requestParams.get.chunkSize); + + await h.dom.waitForEl(container, `section:nth-child(${chunkSize - 1})`); + expect(await getContainerChildCount()).toBe(chunkSize); + + await component.evaluate((ctx) => ctx.request = {get: {chunkSize: 4, total: 4, id: 'uniq-options'}}); + + await h.dom.waitForEl(container, 'section', {to: 'unmount'}); + await h.dom.waitForEl(container, 'section'); + + const + newChunkSize = await component.evaluate((ctx) => ctx.requestParams.get.chunkSize); + + await h.dom.waitForEl(container, `section:nth-child(${newChunkSize - 1})`); + expect(await getContainerChildCount()).toBe(newChunkSize); + }); + }); + + describe('by changing the `request` prop while second data batch loading is in progress', () => { + it('should render first chunk with correct data', async () => { + await component.evaluate((ctx) => { + ctx.dataProvider = 'demo.Pagination'; + ctx.chunkSize = 2; + ctx.request = {get: {chunkSize: 2, delay: 1500, id: Math.random()}}; + }); + + await h.dom.waitForEl(container, 'section'); + + await component.evaluate((ctx) => { + ctx.dataProvider = 'demo.Pagination'; + ctx.chunkSize = 2; + ctx.request = {get: {chunkSize: 2, i: 10, total: 2, delay: 1500, id: Math.random()}}; + }); + + expect(await h.dom.waitForEl(container, '[data-index="10"]')); + expect(await getContainerChildCount()).toBe(2); + }); + }); + + describe('by changing the `dataProvider` prop', () => { + it('removes old elements', async () => { + await setProps(); + + await component.evaluate((ctx) => ctx.dataProvider = undefined); + await h.dom.waitForEl(container, 'section', {to: 'unmount'}); + + expect(await component.evaluate((ctx) => ctx.$refs.container.childElementCount === 0)).toBeTrue(); + }); + + it('renders new', async () => { + await h.dom.waitForEl(container, 'section', {to: 'unmount'}); + expect(await component.evaluate((ctx) => ctx.$refs.container.childElementCount === 0)).toBeTrue(); + + await component.evaluate((ctx) => ctx.dataProvider = 'demo.Pagination'); + await h.dom.waitForEl(container, 'section'); + + expect(await getContainerChildCount()).toBeGreaterThan(0); + }); + }); + }); + + describe('with `items`', () => { + it('renders the first chunk', async () => { + const + chunkSize = await component.evaluate((ctx) => ctx.chunkSize); + + await component.evaluate((ctx) => ctx.items = Array.from(Array(40), (v, i) => ({i}))); + await h.dom.waitForEl(container, 'section'); + + expect(await getContainerChildCount()).toBe(chunkSize); + }); + + it('renders all available `items`', async () => { + await component.evaluate((ctx) => ctx.items = Array.from(Array(40), (v, i) => ({i}))); + await h.dom.waitForEl(container, 'section'); + + const + total = await component.evaluate((ctx) => ctx.items.length), + checkFn = async () => await getContainerChildCount() === total; + + await h.scroll.scrollToBottomWhile(page, checkFn, {timeout: 1e5}); + expect(await getContainerChildCount()).toBe(total); + }); + + it('does not render more than received data', async () => { + await component.evaluate((ctx) => ctx.items = Array.from(Array(40), (v, i) => ({i}))); + await h.dom.waitForEl(container, 'section'); + + const + total = await component.evaluate((ctx) => ctx.items.length), + checkFn = async () => await getContainerChildCount() === total; + + await h.scroll.scrollToBottomWhile(page, checkFn, {timeout: 1e5}); + expect(await getContainerChildCount()).toBe(total); + + await h.bom.waitForIdleCallback(page); + await h.scroll.scrollToBottom(page); + expect(await getContainerChildCount()).toBe(total); + }); + }); + + describe('without `items` and` dataProvider` specified', () => { + it('does not render anything', async () => { + expect(await component.evaluate((ctx) => ctx.items.length === 0)).toBeTrue(); + expect(await component.evaluate((ctx) => ctx.dataProvider === undefined)).toBeTrue(); + expect(await component.evaluate((ctx) => ctx.$refs.container.childElementCount === 0)).toBeTrue(); + }); + }); + }); +}; diff --git a/src/components/base/b-virtual-scroll/test/runners/slots/empty.js b/src/components/base/b-virtual-scroll/test/runners/slots/empty.js new file mode 100644 index 0000000000..7cc73ee19a --- /dev/null +++ b/src/components/base/b-virtual-scroll/test/runners/slots/empty.js @@ -0,0 +1,123 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +// @ts-check + +/** + * @typedef {import('playwright').Page} Page + */ + +const + h = include('tests/helpers').default; + +/** @param {Page} page */ +module.exports = (page) => { + const components = { + emptyWithSlot: undefined, + emptyNoSlot: undefined, + emptyWithData: undefined + }; + + const nodes = { + emptyWithSlot: undefined, + emptyNoSlot: undefined, + emptyWithData: undefined + }; + + beforeAll(async () => { + await page.evaluate(() => { + const baseAttrs = { + theme: 'demo', + option: 'section', + optionProps: ({current}) => ({'data-index': current.i}) + }; + + const slots = { + empty: { + tag: 'div', + attrs: { + id: 'empty', + 'data-test-ref': 'empty' + }, + content: 'Empty' + } + }; + + const scheme = [ + { + attrs: { + ...baseAttrs, + dataProvider: 'demo.Pagination', + dbConverter: ({data}) => ({data: data.splice(0, 4)}), + id: 'emptyNoSlot' + } + }, + { + attrs: { + ...baseAttrs, + dataProvider: 'demo.Pagination', + dbConverter: ({data}) => ({data: data.splice(0, 4)}), + request: {get: {chunkSize: 8, total: 8}}, + id: 'emptyWithData' + }, + + content: { + empty: slots.empty + } + }, + { + attrs: { + ...baseAttrs, + dataProvider: 'demo.Pagination', + dbConverter: () => ({data: []}), + id: 'emptyWithSlot' + }, + + content: { + empty: slots.empty + } + } + ]; + + globalThis.renderComponents('b-virtual-scroll', scheme); + }); + + await h.bom.waitForIdleCallback(page); + + for (let keys = Object.keys(components), i = 0; i < keys.length; i++) { + const key = keys[i]; + + nodes[key] = await h.dom.waitForEl(page, `#${key}`); + await nodes[key].evaluate((ctx) => ctx.style.display = ''); + + // eslint-disable-next-line require-atomic-updates + components[key] = await h.component.getComponentById(page, key); + } + }); + + describe('b-virtual-scroll slot empty', () => { + describe('does not render `empty slot`', () => { + it('if it is not set', async () => { + expect(await components.emptyNoSlot.evaluate((ctx) => Boolean(ctx.$slots['empty']))).toBe(false); + expect(await h.dom.getRef(nodes.emptyNoSlot, 'empty')).toBeFalsy(); + }); + + it('if there is data', async () => { + expect(await components.emptyWithData.evaluate((ctx) => Boolean(ctx.$slots['empty']))).toBe(true); + expect(await nodes.emptyWithData.waitForSelector('#empty', {state: 'hidden'})).toBeFalsy(); + }); + }); + + describe('render `empty slot`', () => { + it('if it is set and there is no data', async () => { + expect(await components.emptyWithSlot.evaluate((ctx) => Boolean(ctx.$slots['empty']))).toBe(true); + expect(await h.dom.getRef(nodes.emptyWithSlot, 'empty')).toBeTruthy(); + }); + }); + }); +}; diff --git a/src/components/base/b-virtual-scroll/test/runners/slots/render-next.js b/src/components/base/b-virtual-scroll/test/runners/slots/render-next.js new file mode 100644 index 0000000000..8316b3ec40 --- /dev/null +++ b/src/components/base/b-virtual-scroll/test/runners/slots/render-next.js @@ -0,0 +1,498 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +/* eslint-disable max-lines-per-function */ +// @ts-check + +/** + * @typedef {import('playwright').Page} Page + */ + +const + h = include('tests/helpers').default; + +/** @param {Page} page */ +module.exports = (page) => { + const components = { + renderNextWithSlot: undefined, + renderNextNoSlot: undefined + }; + + const nodes = { + renderNextWithSlot: undefined, + renderNextNoSlot: undefined + }; + + const containers = { + renderNextWithSlot: undefined, + renderNextNoSlot: undefined + }; + + const isNotHidden = async (selector, ctx) => { + const + el = await ctx.$(selector), + state = await el.evaluate((ctx) => ctx.parentNode.style.display); + + return state === ''; + }; + + const + initialTimeout = globalThis.jasmine.DEFAULT_TIMEOUT_INTERVAL; + + beforeAll(() => { + globalThis.jasmine.DEFAULT_TIMEOUT_INTERVAL = (20).seconds(); + }); + + beforeEach(async () => { + await h.utils.reloadAndWaitForIdle(page); + await h.component.waitForComponent(page, '#root-component'); + + await page.evaluate(() => { + const dummy = document.querySelector('#dummy-component'); + + if (dummy) { + document.querySelector('#dummy-component').remove(); + } + + globalThis.removeCreatedComponents(); + + const baseAttrs = { + theme: 'demo', + option: 'section', + optionProps: ({current}) => ({'data-index': current.i}) + }; + + const slots = { + renderNext: { + tag: 'div', + attrs: { + id: 'renderNext', + 'data-test-ref': 'renderNext' + } + } + }; + + const scheme = [ + { + attrs: { + ...baseAttrs, + dataProvider: 'demo.Pagination', + loadStrategy: 'manual', + id: 'renderNextNoSlot' + } + }, + + { + attrs: { + ...baseAttrs, + dataProvider: 'demo.Pagination', + loadStrategy: 'manual', + id: 'renderNextWithSlot' + }, + + content: { + renderNext: slots.renderNext + } + } + ]; + + globalThis.renderComponents('b-virtual-scroll', scheme, '.p-v4-components-demo'); + }); + + await h.bom.waitForIdleCallback(page); + await h.component.waitForComponentStatus(page, '.b-virtual-scroll', 'ready'); + + const + allComponents = await page.$$('.b-virtual-scroll'); + + for (let i = 0; i < allComponents.length; i++) { + await allComponents[i].evaluate((ctx) => ctx.style.display = 'none'); + } + + for (let keys = Object.keys(components), i = 0; i < keys.length; i++) { + const key = keys[i]; + + nodes[key] = await h.dom.waitForEl(page, `#${key}`); + await nodes[key].evaluate((ctx) => ctx.style.display = ''); + containers[key] = await h.dom.waitForRef(nodes[key], 'container'); + + // eslint-disable-next-line require-atomic-updates + components[key] = await h.component.getComponentById(page, key); + } + + await components.renderNextWithSlot.evaluate((ctx) => { + ctx.dataProvider = 'demo.Pagination'; + ctx.request = {get: {}}; + ctx.shouldStopRequest = (v) => v.isLastEmpty; + + return new Promise((res) => { + if (ctx.isReady) { + return res(); + } + + ctx.localEmitter.on('localState.ready', res); + }); + }); + + await h.bom.waitForIdleCallback(page); + }); + + afterAll(() => { + globalThis.jasmine.DEFAULT_TIMEOUT_INTERVAL = initialTimeout; + }); + + describe('b-virtual-scroll `renderNext` slot', () => { + describe('not render', () => { + it('if it is not set', async () => { + expect(await components.renderNextNoSlot.evaluate((ctx) => Boolean(ctx.$slots['empty']))).toBe(false); + expect(await h.dom.getRef(nodes.renderNextNoSlot, 'empty')).toBeFalsy(); + }); + + it('there are no loaded data', async () => { + await components.renderNextWithSlot.evaluate((ctx) => { + ctx.dbConverter = () => ({data: []}); + ctx.request = {get: {total: 0, chunkSize: 0, id: Math.random()}}; + + return new Promise((res) => { + ctx.localEmitter.on('localState.ready', res); + }); + }); + + await h.bom.waitForIdleCallback(page); + await h.bom.waitForRAF(page); + + expect(await isNotHidden('#renderNext', nodes.renderNextWithSlot)).toBeFalse(); + }); + + it('there are no data', async () => { + await components.renderNextWithSlot.evaluate((ctx) => { + ctx.dataProvider = undefined; + ctx.items = []; + + return new Promise((res) => { + ctx.localEmitter.on('localState.ready', res); + }); + }); + + await h.bom.waitForRAF(page); + expect(await isNotHidden('#renderNext', nodes.renderNextWithSlot)).toBeFalse(); + }); + + it('if initial loading in progress', async () => { + await components.renderNextWithSlot.evaluate((ctx) => { + ctx.dataProvider = 'demo.Pagination'; + ctx.request = {get: {chunkSize: 10, sleep: 1000}}; + }); + + await h.bom.waitForIdleCallback(page); + await h.bom.waitForRAF(page); + + await expectAsync(page.waitForFunction(() => { + const + node = document.querySelector('#renderNext'), + parent = node.parentElement; + + return parent.style.display === 'none'; + })).toBeResolved(); + }); + + it('if the second batch of data loading in progress', async () => { + await components.renderNextWithSlot.evaluate((ctx) => { + ctx.dataProvider = 'demo.Pagination'; + ctx.request = {get: {total: 20, chunkSize: 10, id: Math.random(), sleep: 500}}; + ctx.chunkSize = 10; + }); + + await h.dom.waitForEl(containers.renderNextWithSlot, 'section'); + await h.scroll.scrollToBottom(page); + await h.dom.waitForRef(nodes.renderNextWithSlot, 'renderNext'); + await components.renderNextWithSlot.evaluate((ctx) => ctx.renderNext()); + await h.bom.waitForRAF(page); + + expect(await isNotHidden('#renderNext', nodes.renderNextWithSlot)).toBeFalse(); + }); + + it('if all data were loaded after the initial request', async () => { + await components.renderNextWithSlot.evaluate((ctx) => { + ctx.dataProvider = 'demo.Pagination'; + ctx.request = {get: {total: 10, chunkSize: 10, id: Math.random()}}; + ctx.shouldStopRequest = () => true; + ctx.chunkSize = 10; + }); + + await h.dom.waitForEl(containers.renderNextWithSlot, 'section'); + await h.bom.waitForIdleCallback(page); + + expect(await isNotHidden('#renderNext', nodes.renderNextWithSlot)).toBeFalse(); + }); + + it('if all data were loaded after the second batch load', async () => { + await components.renderNextWithSlot.evaluate((ctx) => { + ctx.dataProvider = 'demo.Pagination'; + ctx.request = {get: {total: 20, chunkSize: 10, id: Math.random(), sleep: 50}}; + ctx.shouldStopRequest = ({data}) => data.length === 20; + ctx.chunkSize = 10; + }); + + await h.dom.waitForEl(containers.renderNextWithSlot, 'section'); + await h.dom.waitForRef(nodes.renderNextWithSlot, 'renderNext'); + await components.renderNextWithSlot.evaluate((ctx) => ctx.renderNext()); + + await h.dom.waitForEl(containers.renderNextWithSlot, 'section:nth-child(11)'); + await h.bom.waitForIdleCallback(page); + await h.bom.waitForRAF(page); + + expect(await (await nodes.renderNextWithSlot.$('#renderNext')).evaluate((ctx) => ctx.parentNode.style.display)).toBe('none'); + }); + + it('if all items were rendered', async () => { + await components.renderNextWithSlot.evaluate((ctx) => { + ctx.dataProvider = undefined; + ctx.chunkSize = 10; + // @ts-ignore + ctx.items = Array.from(Array(10), (v, i) => ({i})); + }); + + await h.dom.waitForEl(containers.renderNextWithSlot, 'section'); + await h.bom.waitForRAF(page); + expect(await isNotHidden('#renderNext', nodes.renderNextWithSlot)).toBeFalse(); + }); + + it('if all items were rendered after second render', async () => { + await components.renderNextWithSlot.evaluate((ctx) => { + ctx.dataProvider = undefined; + ctx.chunkSize = 10; + ctx.items = Array.from(Array(20), (v, i) => ({i})); + }); + + await h.dom.waitForEl(containers.renderNextWithSlot, 'section'); + await h.dom.waitForRef(nodes.renderNextWithSlot, 'renderNext'); + await components.renderNextWithSlot.evaluate((ctx) => ctx.renderNext()); + await h.dom.waitForEl(containers.renderNextWithSlot, 'section:nth-child(11)'); + await h.bom.waitForIdleCallback(page); + await h.bom.waitForRAF(page); + + expect(await isNotHidden('#renderNext', nodes.renderNextWithSlot)).toBeFalse(); + }); + + it('if all data were rendered and loaded', async () => { + await components.renderNextWithSlot.evaluate((ctx) => { + ctx.dataProvider = 'demo.Pagination'; + ctx.shouldStopRequest = ({data}) => data.length === 80; + ctx.request = {get: {total: 80, chunkSize: 40, id: Math.random()}}; + ctx.chunkSize = 20; + + return new Promise((res) => { + ctx.localEmitter.once('localState.ready', res); + }); + }); + + await h.dom.waitForEl(containers.renderNextWithSlot, 'section'); + + let + renders = 1; + + const + totalRenders = 4; + + while (renders < totalRenders) { + await h.dom.waitForRef(nodes.renderNextWithSlot, 'renderNext'); + await components.renderNextWithSlot.evaluate((ctx) => ctx.renderNext()); + await h.dom.waitForEl(containers.renderNextWithSlot, `section:nth-child(${(renders * 20) + 1})`); + await h.bom.waitForIdleCallback(page); + await h.bom.waitForRAF(page); + + renders++; + } + + expect(await isNotHidden('#renderNext', nodes.renderNextWithSlot)).toBeFalse(); + }); + + it('if an error appears on the initial loading', async () => { + await components.renderNextWithSlot.evaluate((ctx) => { + const p = new Promise((res) => { + ctx.watch(':onRequestError', res); + }); + + ctx.dataProvider = 'demo.Pagination'; + ctx.request = {get: {total: 20, chunkSize: 10, id: Math.random(), failOn: 0, sleep: 500}}; + ctx.chunkSize = 10; + + return p; + }); + + await h.bom.waitForIdleCallback(page); + expect(await isNotHidden('#renderNext', nodes.renderNextWithSlot)).toBeFalse(); + }); + + it('if an error appears on the second data batch loading', async () => { + const requestErrorPromise = components.renderNextWithSlot.evaluate((ctx) => new Promise((res) => { + ctx.watch(':requestError', res); + })); + + await components.renderNextWithSlot.evaluate((ctx) => { + ctx.dataProvider = 'demo.Pagination'; + ctx.request = {get: {total: 40, chunkSize: 10, id: Math.random(), failOn: 1, sleep: 50}}; + ctx.chunkSize = 10; + }); + + await h.dom.waitForEl(containers.renderNextWithSlot, 'section'); + await h.dom.waitForRef(nodes.renderNextWithSlot, 'renderNext'); + + await components.renderNextWithSlot.evaluate((ctx) => ctx.renderNext()); + await requestErrorPromise; + + await h.bom.waitForIdleCallback(page); + await h.bom.waitForRAF(page); + + expect(await isNotHidden('#renderNext', nodes.renderNextWithSlot)).toBeFalse(); + }); + }); + + describe('render', () => { + it('after initial loading', async () => { + await components.renderNextWithSlot.evaluate((ctx) => { + ctx.dataProvider = 'demo.Pagination'; + ctx.request = {get: {total: 20, chunkSize: 10, id: Math.random()}}; + ctx.chunkSize = 10; + + return new Promise((res) => { + ctx.localEmitter.on('localState.ready', res); + }); + }); + + await h.bom.waitForIdleCallback(page); + expect(await isNotHidden('#renderNext', nodes.renderNextWithSlot)).toBeTrue(); + }); + + it('after loading of the second data batch', async () => { + await components.renderNextWithSlot.evaluate((ctx) => { + ctx.dataProvider = 'demo.Pagination'; + ctx.request = {get: {total: 40, chunkSize: 10, id: Math.random()}}; + ctx.chunkSize = 10; + + return new Promise((res) => { + ctx.localEmitter.on('localState.ready', res); + }); + }); + + await h.dom.waitForEl(containers.renderNextWithSlot, 'section'); + await h.dom.waitForRef(nodes.renderNextWithSlot, 'renderNext'); + await components.renderNextWithSlot.evaluate((ctx) => ctx.renderNext()); + await h.dom.waitForEl(containers.renderNextWithSlot, 'section:nth-child(11)'); + await h.bom.waitForIdleCallback(page); + await h.bom.waitForRAF(page); + + expect(await isNotHidden('#renderNext', nodes.renderNextWithSlot)).toBeTrue(); + }); + + it('after the initial rendering with items provided', async () => { + await components.renderNextWithSlot.evaluate((ctx) => { + ctx.dataProvider = undefined; + // @ts-ignore + ctx.items = Array.from(Array(20), (v, i) => ({i})); + }); + + await h.dom.waitForEl(containers.renderNextWithSlot, 'section'); + await h.bom.waitForIdleCallback(page); + + expect(await isNotHidden('#renderNext', nodes.renderNextWithSlot)).toBeTrue(); + }); + + it('after the second rendering with items provided', async () => { + await components.renderNextWithSlot.evaluate((ctx) => { + ctx.dataProvider = undefined; + // @ts-ignore + ctx.items = Array.from(Array(40), (v, i) => ({i})); + }); + + await h.dom.waitForEl(containers.renderNextWithSlot, 'section'); + await h.dom.waitForRef(nodes.renderNextWithSlot, 'renderNext'); + await components.renderNextWithSlot.evaluate((ctx) => ctx.renderNext()); + await h.dom.waitForEl(containers.renderNextWithSlot, 'section:nth-child(11)'); + await h.bom.waitForRAF(page); + + expect(await isNotHidden('#renderNext', nodes.renderNextWithSlot)).toBeTrue(); + }); + + it('until all data are rendered', async () => { + await components.renderNextWithSlot.evaluate((ctx) => { + ctx.dataProvider = 'demo.Pagination'; + ctx.shouldStopRequest = ({data}) => data.length === 60; + ctx.request = {get: {total: 60, chunkSize: 30, id: Math.random()}}; + ctx.chunkSize = 10; + + return new Promise((res) => { + ctx.localEmitter.on('localState.ready', res); + }); + }); + + await h.dom.waitForEl(containers.renderNextWithSlot, 'section'); + + let + renders = 1; + + const + totalRenders = 5; + + while (renders < totalRenders) { + await h.dom.waitForRef(nodes.renderNextWithSlot, 'renderNext'); + await components.renderNextWithSlot.evaluate((ctx) => ctx.renderNext()); + await h.dom.waitForEl(containers.renderNextWithSlot, `section:nth-child(${(renders * 10) + 1})`); + await h.bom.waitForRAF(page); + + expect(await isNotHidden('#renderNext', nodes.renderNextWithSlot)).toBeTrue(); + renders++; + } + }); + + it('if there was an error on the initial loading, but after retrying all fine', async () => { + const requestErrorPromise = components.renderNextWithSlot.evaluate((ctx) => new Promise((res) => { + ctx.watch(':requestError', res); + })); + + await components.renderNextWithSlot.evaluate((ctx) => { + ctx.dataProvider = 'demo.Pagination'; + ctx.request = {get: {total: 40, chunkSize: 10, id: Math.random(), failOn: 0, failCount: 1, sleep: 50}}; + ctx.chunkSize = 10; + }); + + await requestErrorPromise; + await components.renderNextWithSlot.evaluate((ctx) => ctx.reloadLast()); + + await h.dom.waitForEl(containers.renderNextWithSlot, 'section'); + await h.bom.waitForRAF(page); + + expect(await isNotHidden('#renderNext', nodes.renderNextWithSlot)).toBeTrue(); + }); + + it('if there was an error on the second data batch loading, but after retrying all fine', async () => { + const requestErrorPromise = components.renderNextWithSlot.evaluate((ctx) => new Promise((res) => { + ctx.watch(':requestError', res); + })); + + await components.renderNextWithSlot.evaluate((ctx) => { + ctx.dataProvider = 'demo.Pagination'; + ctx.request = {get: {total: 40, chunkSize: 10, id: Math.random(), failOn: 1, failCount: 1, sleep: 50}}; + ctx.chunkSize = 10; + }); + + await h.dom.waitForEl(containers.renderNextWithSlot, 'section'); + + await components.renderNextWithSlot.evaluate((ctx) => ctx.renderNext()); + await requestErrorPromise; + await h.bom.waitForIdleCallback(page); + await components.renderNextWithSlot.evaluate((ctx) => ctx.reloadLast()); + await h.dom.waitForEl(containers.renderNextWithSlot, 'section:nth-child(11)'); + await h.bom.waitForRAF(page); + + expect(await isNotHidden('#renderNext', nodes.renderNextWithSlot)).toBeTrue(); + }); + }); + }); +}; diff --git a/src/components/base/b-virtual-scroll/test/unit/render.ts b/src/components/base/b-virtual-scroll/test/unit/render.ts new file mode 100644 index 0000000000..86f2eb133c --- /dev/null +++ b/src/components/base/b-virtual-scroll/test/unit/render.ts @@ -0,0 +1,139 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +import test from 'tests/config/unit/test'; + +import type bVirtualScroll from 'components/base/b-virtual-scroll/b-virtual-scroll'; + +import Component from 'tests/helpers/component'; +import Scroll from 'tests/helpers/scroll'; +import BOM from 'tests/helpers/bom'; +import { interceptPaginationRequest } from 'tests/helpers/providers/pagination'; + +test.describe('b-virtual-scroll render', () => { + + const baseAttrs = { + theme: 'demo', + item: 'section', + id: 'target', + itemProps: ({current}) => ({'data-index': current.i}) + }; + + const providerProps = (reqParams = {}) => ({ + dataProvider: 'Provider', + chunkSize: 10, + request: {get: {chunkSize: 10, id: Math.random(), ...reqParams}} + }); + + const attrs = (attrs = {}) => ({attrs: { + ...baseAttrs, + ...attrs + }}); + + const + sectionSelector = '.b-virtual-scroll__container section', + buttonSelector = '.b-virtual-scroll__container button', + getContainerChildCount = (c) => c.evaluate((ctx) => ctx.$refs.container.childElementCount); + + test.beforeEach(async ({context, demoPage}) => { + await interceptPaginationRequest(context); + await demoPage.goto(); + }); + + test.describe('with `dataProvider`', () => { + test('renders the first chunk', async ({page}) => { + const + component = await Component.createComponent(page, 'b-virtual-scroll', attrs(providerProps())), + chunkSize = await component.evaluate((ctx) => ctx.chunkSize); + + await page.waitForSelector(sectionSelector, {state: 'attached'}); + test.expect(await getContainerChildCount(component)).toBe(chunkSize); + }); + + test('renders b-button', async ({page}) => { + const + component = await Component.createComponent(page, 'b-virtual-scroll', attrs({...providerProps(), item: 'b-button'})), + chunkSize = await component.evaluate((ctx) => ctx.chunkSize); + + await page.waitForSelector(buttonSelector, {state: 'attached'}); + test.expect(await getContainerChildCount(component)).toBe(chunkSize); + }); + + test('renders all available items', async ({page}) => { + const + component = await Component.createComponent(page, 'b-virtual-scroll', attrs(providerProps({total: 40}))); + + const + total = await component.evaluate((ctx) => ctx.field.get('requestParams.get.total')), + checkFn = async () => await getContainerChildCount(component) === total; + + await Scroll.scrollToBottomWhile(page, checkFn, {timeout: 1e5}); + + test.expect(await getContainerChildCount(component)).toBe(total); + }); + + test('does not render more than received data', async ({page}) => { + const + component = await Component.createComponent(page, 'b-virtual-scroll', attrs(providerProps({total: 40}))); + + const + total = await component.evaluate((ctx) => ctx.field.get('requestParams.get.total')), + checkFn = async () => await getContainerChildCount(component) === total; + + await Scroll.scrollToBottomWhile(page, checkFn, {timeout: 1e5}); + test.expect(await getContainerChildCount(component)).toBe(total); + + await BOM.waitForIdleCallback(page); + await Scroll.scrollToBottom(page); + test.expect(await getContainerChildCount(component)).toBe(total); + }); + + test('renders the first chunk with 3 requests to get the full chunk', async ({page}) => { + const + component = await Component.createComponent(page, 'b-virtual-scroll', attrs(providerProps({chunkSize: 4}))); + + const + chunkSize = await component.evaluate((ctx) => ctx.chunkSize); + + await page.waitForSelector(sectionSelector, {state: 'attached'}); + + test.expect(await getContainerChildCount(component)).toBe(chunkSize); + }); + + test('renders the first chunk with truncated data in all loaded chunks', async ({page}) => { + const component = await Component.createComponent(page, 'b-virtual-scroll', attrs({ + dataProvider: 'Provider', + chunkSize: 4, + request: {get: {chunkSize: 8, total: 32, id: 'uniq'}}, + dbConverter: ({data}) => ({data: data.splice(0, 1)}) + })); + + const + chunkSize = await component.evaluate((ctx) => ctx.chunkSize); + + await page.waitForSelector(sectionSelector, {state: 'attached'}); + + test.expect(await getContainerChildCount(component)).toBe(chunkSize); + }); + + test('renders all data if `shouldStopRequest` returns true', async ({page}) => { + const component = await Component.createComponent(page, 'b-virtual-scroll', attrs({ + dataProvider: 'Provider', + chunkSize: 10, + request: {get: {chunkSize: 40, total: 80, id: Math.random(), delay: 100}}, + shouldStopRequest: ({data}) => data.length === 80 + })); + + const + checkFn = async () => await getContainerChildCount(component) === 80; + + await Scroll.scrollToBottomWhile(page, checkFn, {timeout: 1e5}); + test.expect(await getContainerChildCount(component)).toBe(80); + }); + }); +}); diff --git a/src/components/pages/p-v4-components-demo/index.js b/src/components/pages/p-v4-components-demo/index.js index ec8982afc2..2aa7c0573c 100644 --- a/src/components/pages/p-v4-components-demo/index.js +++ b/src/components/pages/p-v4-components-demo/index.js @@ -19,6 +19,7 @@ package('p-v4-components-demo') 'b-tree', 'b-window', 'b-virtual-scroll', + 'b-virtual-scroll-new', 'b-bottom-slide', 'b-slider', 'b-sidebar', diff --git a/tests/helpers/component-object/builder.ts b/tests/helpers/component-object/builder.ts index 7b265c8e8c..37a333e819 100644 --- a/tests/helpers/component-object/builder.ts +++ b/tests/helpers/component-object/builder.ts @@ -126,6 +126,7 @@ export default abstract class ComponentObjectBuilder { this.id = `${this.componentName}_${Math.random().toString()}`; this.props = {'data-component-object-id': this.id}; this.node = page.locator(`[data-component-object-id="${this.id}"]`); + this.componentClassImportPath = path.join( path.relative(`${process.cwd()}/src`, resolve.blockSync(this.componentName)!), `/${this.componentName}.ts` diff --git a/yarn.lock b/yarn.lock index 2ed6dd3f18..9815d794fb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5646,8 +5646,8 @@ __metadata: linkType: hard "@v4fire/core@github:V4Fire/Core#v4": - version: 4.0.0-alpha.16 - resolution: "@v4fire/core@https://github.com/V4Fire/Core.git#commit=44bf4e874e6c0e3a086cb37c6fc1d9c92d6ad563" + version: 4.0.0-alpha.20 + resolution: "@v4fire/core@https://github.com/V4Fire/Core.git#commit=c6f90dac060fff61b4cedf4a98171b75da3a0bc9" dependencies: "@babel/core": "npm:7.17.5" "@babel/helper-module-transforms": "npm:7.16.7" @@ -5791,7 +5791,7 @@ __metadata: optional: true xhr2: optional: true - checksum: c9fcab75ac8ca0549beeca11e7ac7d2b2bd2107ec579c7a7482a4108d2de6252548bf1dd8743d3a8ea11816ebf82c6eb6a591fc44df7adca0abd490020590c73 + checksum: b73d008d57159b8a69c7d4914e50b0f2e82c7ac9a76e52d87cae06bd8e3a34905409a2529e04cebd1173f027629fdebe763a97318daa6fd2950533ff67d7126b languageName: node linkType: hard