From 83ddb006548602640ec312594b9bb9f26f3417de Mon Sep 17 00:00:00 2001 From: Derek Burgman Date: Sun, 6 Feb 2022 03:23:04 -0600 Subject: [PATCH] feat: added dbxList --- .../checklist.item.field.component.ts | 5 +- .../fields/date/datetime.field.component.ts | 4 +- .../generic/pickable.field.component.ts | 6 +- .../generic/searchable.field.component.ts | 4 +- .../src/lib/layout/bar/banner.component.html | 1 + .../src/lib/layout/bar/banner.component.ts | 8 + .../dbx-web/src/lib/layout/list/_list.scss | 28 ++- packages/dbx-web/src/lib/layout/list/index.ts | 7 +- .../src/lib/layout/list/list.component.html | 20 +- .../src/lib/layout/list/list.component.ts | 186 +++++++++--------- ...ent.ts => list.content.empty.component.ts} | 0 .../src/lib/layout/list/list.directive.ts | 88 +++++++++ .../src/lib/layout/list/list.layout.module.ts | 2 +- .../src/lib/layout/list/list.selection.ts | 10 - .../lib/layout/list/list.view.component.ts | 48 +++++ .../list/list.view.selection.component.ts | 30 +++ .../dbx-web/src/lib/layout/list/list.view.ts | 34 ++++ .../layout/component/item.list.component.ts | 30 +++ .../component/item.list.view.component.html | 6 + .../layout/container/layout.component.html | 2 +- .../layout/container/list.component.html | 20 +- .../layout/container/list.component.ts | 40 +++- .../doc/modules/layout/doc.layout.module.ts | 7 +- .../lib/loading/loading.context.model.spec.ts | 10 +- .../lib/loading/loading.context.state.list.ts | 14 +- .../loading/loading.context.state.model.ts | 18 +- .../src/lib/loading/loading.context.state.ts | 18 +- .../rxjs/src/lib/loading/loading.state.ts | 2 +- packages/rxjs/src/lib/rxjs/getter.ts | 21 ++ packages/rxjs/src/lib/rxjs/index.ts | 1 + packages/rxjs/src/lib/rxjs/value.ts | 18 +- packages/util/src/lib/array/array.number.ts | 13 +- packages/util/src/lib/date/date.ts | 1 + 33 files changed, 544 insertions(+), 158 deletions(-) create mode 100644 packages/dbx-web/src/lib/layout/bar/banner.component.html create mode 100644 packages/dbx-web/src/lib/layout/bar/banner.component.ts rename packages/dbx-web/src/lib/layout/list/{list.empty.component.ts => list.content.empty.component.ts} (100%) create mode 100644 packages/dbx-web/src/lib/layout/list/list.directive.ts delete mode 100644 packages/dbx-web/src/lib/layout/list/list.selection.ts create mode 100644 packages/dbx-web/src/lib/layout/list/list.view.component.ts create mode 100644 packages/dbx-web/src/lib/layout/list/list.view.selection.component.ts create mode 100644 packages/dbx-web/src/lib/layout/list/list.view.ts create mode 100644 packages/demo/src/app/modules/doc/modules/layout/component/item.list.component.ts create mode 100644 packages/demo/src/app/modules/doc/modules/layout/component/item.list.view.component.html create mode 100644 packages/rxjs/src/lib/rxjs/getter.ts diff --git a/packages/dbx-form/src/lib/formly/fields/checklist/checklist.item.field.component.ts b/packages/dbx-form/src/lib/formly/fields/checklist/checklist.item.field.component.ts index ed31c8f7a..f0eb59978 100644 --- a/packages/dbx-form/src/lib/formly/fields/checklist/checklist.item.field.component.ts +++ b/packages/dbx-form/src/lib/formly/fields/checklist/checklist.item.field.component.ts @@ -1,4 +1,4 @@ -import { filterMaybe } from '@dereekb/rxjs'; +import { filterMaybe, switchMapMaybeObs } from '@dereekb/rxjs'; import { shareReplay, distinctUntilChanged, switchMap, map } from 'rxjs/operators'; import { BehaviorSubject } from 'rxjs'; import { @@ -33,8 +33,7 @@ export class DbxChecklistItemFieldComponent extends FieldType>>(undefined); readonly displayContent$ = this._displayContent.pipe( - filterMaybe(), - switchMap(x => x), + switchMapMaybeObs(), distinctUntilChanged(), shareReplay(1) ); diff --git a/packages/dbx-form/src/lib/formly/fields/date/datetime.field.component.ts b/packages/dbx-form/src/lib/formly/fields/date/datetime.field.component.ts index 19feb720b..d0acefb6d 100644 --- a/packages/dbx-form/src/lib/formly/fields/date/datetime.field.component.ts +++ b/packages/dbx-form/src/lib/formly/fields/date/datetime.field.component.ts @@ -10,7 +10,7 @@ import { BehaviorSubject, Observable, of, combineLatest, Subject, merge, interva import { Maybe, ReadableTimeString } from '@dereekb/util'; import { MatDatepickerInputEvent } from '@angular/material/datepicker'; import { addMinutes, isSameDay, isSameMinute, startOfDay } from 'date-fns'; -import { filterMaybe, SubscriptionObject } from '@dereekb/rxjs'; +import { filterMaybe, SubscriptionObject, switchMapMaybeObs } from '@dereekb/rxjs'; export enum DateTimeFieldTimeMode { /** @@ -172,7 +172,7 @@ export class DbxDateTimeFieldComponent extends FieldType x), shareReplay(1)); + readonly config$ = this._config.pipe(switchMapMaybeObs(), shareReplay(1)); readonly rawDateTime$: Observable = combineLatest([ this.dateValue$.pipe(filterMaybe()), diff --git a/packages/dbx-form/src/lib/formly/fields/generic/pickable.field.component.ts b/packages/dbx-form/src/lib/formly/fields/generic/pickable.field.component.ts index e085edba6..1b4b693ec 100644 --- a/packages/dbx-form/src/lib/formly/fields/generic/pickable.field.component.ts +++ b/packages/dbx-form/src/lib/formly/fields/generic/pickable.field.component.ts @@ -1,5 +1,5 @@ import { DbxInjectedComponentConfig } from "@dereekb/dbx-core"; -import { beginLoading, LoadingStateContext, LoadingState, successResult, mapLoadingStateResults, filterMaybe } from "@dereekb/rxjs"; +import { beginLoading, LoadingStateContextInstance, LoadingState, successResult, mapLoadingStateResults, filterMaybe } from "@dereekb/rxjs"; import { convertMaybeToArray, findUnique, groupValues, makeValuesGroupMap, Maybe } from "@dereekb/util"; import { Component, Directive, ElementRef, OnDestroy, OnInit, Type, ViewChild } from "@angular/core"; import { FormControl, AbstractControl } from "@angular/forms"; @@ -222,7 +222,7 @@ export class AbstractDbxPickableItemFieldDirective extends FieldType = this.items$.pipe( map(x => successResult(x)), @@ -233,7 +233,7 @@ export class AbstractDbxPickableItemFieldDirective extends FieldType[]> = this.searchResultsState$.pipe( map(x => x?.model ?? []) diff --git a/packages/dbx-web/src/lib/layout/bar/banner.component.html b/packages/dbx-web/src/lib/layout/bar/banner.component.html new file mode 100644 index 000000000..281c6866c --- /dev/null +++ b/packages/dbx-web/src/lib/layout/bar/banner.component.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/packages/dbx-web/src/lib/layout/bar/banner.component.ts b/packages/dbx-web/src/lib/layout/bar/banner.component.ts new file mode 100644 index 000000000..aa2afa059 --- /dev/null +++ b/packages/dbx-web/src/lib/layout/bar/banner.component.ts @@ -0,0 +1,8 @@ +import { Component } from '@angular/core'; + +// TODO: Banner either should be removed or repurposed. +@Component({ + selector: 'dbx-banner', + templateUrl: './banner.component.html' +}) +export class DbxBannerComponent { } diff --git a/packages/dbx-web/src/lib/layout/list/_list.scss b/packages/dbx-web/src/lib/layout/list/_list.scss index 3b4efdba9..c00a484ad 100644 --- a/packages/dbx-web/src/lib/layout/list/_list.scss +++ b/packages/dbx-web/src/lib/layout/list/_list.scss @@ -4,7 +4,33 @@ // MARK: Mixin -@mixin core() {} +@mixin core() { + + .dbx-list { + overflow: hidden; + height: 100%; + } + + .dbx-list-view { + max-height: 100%; + overflow: auto; + box-sizing: border-box; + + &.dbx-list-padded { + padding-bottom: 8px; + } + + .mat-list-base { + padding-top: 0; + } + + } + + .dbx-list-content-box { + padding: 4px; + } + +} @mixin color($theme-config) {} diff --git a/packages/dbx-web/src/lib/layout/list/index.ts b/packages/dbx-web/src/lib/layout/list/index.ts index a18f4688c..cf4447107 100644 --- a/packages/dbx-web/src/lib/layout/list/index.ts +++ b/packages/dbx-web/src/lib/layout/list/index.ts @@ -1,4 +1,7 @@ -export * from './list.empty.component'; +export * from './list.content.empty.component'; export * from './list.component'; -export * from './list.selection'; +export * from './list.directive'; export * from './list.layout.module'; +export * from './list.view.component'; +export * from './list.view.selection.component'; +export * from './list.view'; diff --git a/packages/dbx-web/src/lib/layout/list/list.component.html b/packages/dbx-web/src/lib/layout/list/list.component.html index 57ed69ef7..a34c422e7 100644 --- a/packages/dbx-web/src/lib/layout/list/list.component.html +++ b/packages/dbx-web/src/lib/layout/list/list.component.html @@ -1,10 +1,10 @@ - - - - - - - - + + + + + + + diff --git a/packages/dbx-web/src/lib/layout/list/list.component.ts b/packages/dbx-web/src/lib/layout/list/list.component.ts index acb9b8288..de95225ab 100644 --- a/packages/dbx-web/src/lib/layout/list/list.component.ts +++ b/packages/dbx-web/src/lib/layout/list/list.component.ts @@ -1,34 +1,14 @@ -import { catchError } from 'rxjs/operators'; -import { exhaustMap } from 'rxjs'; -import { Component, ComponentFactoryResolver, Input, Type, ViewChild, ViewContainerRef, EventEmitter, Output, OnDestroy, OnInit, ElementRef, HostListener, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core'; +import { catchError, filter, exhaustMap, merge, map, Subject, switchMap, shareReplay, distinctUntilChanged, of, Observable, BehaviorSubject } from 'rxjs'; +import { Component, Input, EventEmitter, Output, OnDestroy, ElementRef, HostListener, ChangeDetectorRef } from '@angular/core'; import { DbxInjectedComponentConfig, tapDetectChanges } from '@dereekb/dbx-core'; -import { SubscriptionObject, ListLoadingStateContext } from '@dereekb/rxjs'; -import { Maybe } from '@dereekb/util'; -import { map, Subject, BehaviorSubject, switchMap, shareReplay, distinctUntilChanged, of } from 'rxjs'; -import { Observable } from 'rxjs'; -import { ListSelectionState } from './list.selection'; +import { SubscriptionObject, ListLoadingStateContextInstance, ListLoadingState, filterMaybe, tapLog } from '@dereekb/rxjs'; +import { Maybe, Milliseconds } from '@dereekb/util'; +import { DbxListView, ListSelectionState } from './list.view'; /** - * Interface for list components that render a list of models. + * Direction the scroll was triggered moving. */ -export interface DbxListContentComponent { - /** - * Models observable. - */ - readonly models$: Observable; - /** - * Optional clicked event emitter. - */ - clicked?: EventEmitter; - /** - * Optional selection changed event emitter. - */ - selectionChange?: EventEmitter>; - /** - * Sets the models input source. - */ - setModels$(models$: Observable): void; -} +export type DbxListScrollDirectionTrigger = 'up' | 'down'; /** * Used to trigger the loading of additional items. @@ -40,11 +20,32 @@ export type DbxListLoadMoreHandler = () => Observable | void; /** * DbxListComponent configuration. */ -export interface DbxListConfig = DbxListContentComponent> extends DbxInjectedComponentConfig { +export interface DbxListConfig = DbxListView> extends DbxInjectedComponentConfig { + + /** + * Whether or not to hide the list content when it is an empty list. + */ + hideOnEmpty?: boolean; + + /** + * Whether or not this list should scroll upward from the bottom, like a message list. + */ + invertedList?: boolean; + + /** + * Distance to scroll. + */ + scrollDistance?: number; + + /** + * Number of ms to throttle scrolling events. + */ + throttle?: Milliseconds; + /** * (Optional) onClick handler */ - onClick?: (model: T) => void; + onClick?: (value: T) => void; /** * (Optional) onSelection handler @@ -55,6 +56,7 @@ export interface DbxListConfig = DbxList * (Optional) handler function to load more items. */ loadMore?: DbxListLoadMoreHandler; + } /** @@ -65,37 +67,15 @@ export interface DbxListConfig = DbxList @Component({ selector: 'dbx-list', templateUrl: './list.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, host: { - 'class': 'dbx-list', + 'class': 'd-block dbx-list', '[class.dbx-list-padded]': 'padded' } }) -export class DbxListComponent = DbxListContentComponent> implements OnDestroy { - - /** - * Whether or not this list should scroll upward from the bottom. - */ - @Input() - invertedList = false; - - /** - * Distance to scroll. - */ - @Input() - scrollDistance = 1.5; - - /** - * Number of ms to throttle scrolling events. - */ - @Input() - throttle = 50; +export class DbxListComponent = DbxListView, S extends ListLoadingState = ListLoadingState> implements OnDestroy { - /** - * Whether or not to hide the list content when it is an empty list. - */ - @Input() - hideOnEmpty: boolean = true; + readonly DEFAULT_SCROLL_DISTANCE = 1.5; + readonly DEFAULT_THROTTLE_SCROLL = 50; /** * Whether or not to add bottom padding to the list content. @@ -104,27 +84,39 @@ export class DbxListComponent = Db padded: boolean = true; @Output() - loadMore = new EventEmitter(); - - @Output() - scrollAny = new EventEmitter(); + contentScrolled = new EventEmitter(); private _content!: DbxListInternalViewComponent; - private _config = new Subject>>(); - private _hideOnEmpty = new BehaviorSubject(false); - readonly context = new ListLoadingStateContext({ showLoadingOnNoModel: false }); - readonly models$: Observable = this.context.models$; - readonly isEmpty$ = this.context.isEmpty$; + private _loadMoreTrigger = new Subject(); + private _scrollTrigger = new Subject(); + private _config = new BehaviorSubject>>(undefined); private _loadMoreSub = new SubscriptionObject(); private _onClickSub = new SubscriptionObject(); private _onSelectionChangeSub = new SubscriptionObject(); - readonly injectedComponentConfig$: Observable>> = this._config.pipe( + readonly context = new ListLoadingStateContextInstance({ showLoadingOnNoModel: false }); + readonly isEmpty$ = this.context.isEmpty$; + + readonly hideOnEmpty$: Observable = this._config.pipe(filterMaybe(), map(x => Boolean(x.hideOnEmpty)), distinctUntilChanged(), shareReplay(1)); + readonly invertedList$: Observable = this._config.pipe(filterMaybe(), map(x => Boolean(x?.throttle)), distinctUntilChanged(), shareReplay(1)); + readonly throttleScroll$: Observable = this._config.pipe(map(x => (x?.throttle) ?? this.DEFAULT_THROTTLE_SCROLL), distinctUntilChanged(), shareReplay(1)); + readonly scrollDistance$: Observable = this._config.pipe(map(x => (x?.scrollDistance) ?? this.DEFAULT_SCROLL_DISTANCE), distinctUntilChanged(), shareReplay(1)); + + readonly scrollLoadMoreTrigger$ = this._config.pipe( + switchMap((config) => { + const loadNextDirection = config?.invertedList ? 'up' : 'down'; + return this._scrollTrigger.pipe(filter(x => x === loadNextDirection)); + }) + ); + + readonly loadMore$ = merge(this.scrollLoadMoreTrigger$, this._loadMoreTrigger); + + readonly injectedComponentConfig$: Observable>> = this._config.pipe( distinctUntilChanged(), map((config) => { - let injectedComponentConfig: Maybe>; + let injectedComponentConfig: Maybe>; if (config) { const { componentClass, init, onClick, onSelectionChange, loadMore } = config; @@ -132,14 +124,16 @@ export class DbxListComponent = Db injectedComponentConfig = { componentClass: config.componentClass, injector: config.injector, - init: (instance: L) => { + init: (instance: V) => { if (init) { init(instance); } + instance.setListContext(this.context); + if (loadMore) { - this._loadMoreSub.subscription = this.loadMore.pipe( + this._loadMoreSub.subscription = this.loadMore$.pipe( // Throttle additional loading calls using exhaustMap until observable returns, if one is returned. exhaustMap(() => { const result = loadMore(); @@ -157,8 +151,8 @@ export class DbxListComponent = Db } if (onClick) { - if (instance.clicked) { - this._onClickSub.subscription = instance.clicked.subscribe(onClick); + if (instance.clickValue) { + this._onClickSub.subscription = instance.clickValue.subscribe(onClick); } else { console.error(`onClick() was passed to listConfig, but target class ${componentClass} has no clicked event emitter.`); } @@ -177,10 +171,11 @@ export class DbxListComponent = Db return injectedComponentConfig; }), + distinctUntilChanged(), shareReplay(1) ); - readonly hideContent$ = this._hideOnEmpty.pipe( + readonly hideContent$ = this.hideOnEmpty$.pipe( switchMap((hide) => (hide) ? this.isEmpty$ : of(false)), distinctUntilChanged(), tapDetectChanges(this.cdRef), @@ -189,8 +184,30 @@ export class DbxListComponent = Db constructor(readonly cdRef: ChangeDetectorRef) { } + ngOnDestroy(): void { + delete (this as any)._content; // remove parent-child relation. + this._scrollTrigger.complete(); + this._loadMoreTrigger.complete(); + this._config.complete(); + + this._onClickSub.destroy(); + this._loadMoreSub.destroy(); + this._onSelectionChangeSub.destroy(); + + this.context.destroy(); + } + + @Input() + get state$(): Observable { + return this.context.state$; + } + + set state$(state$: Maybe>) { + this.context.setStateObs(state$); + } + @Input() - set config(config: DbxListConfig) { + set config(config: Maybe>) { this._config.next(config); } @@ -220,25 +237,16 @@ export class DbxListComponent = Db } catch (err) { } } - ngOnDestroy(): void { - delete (this as any)._content; // remove parent-child relation. - this._onClickSub.destroy(); - this._loadMoreSub.destroy(); - this._onSelectionChangeSub.destroy(); - } - onScrollDown(): void { - // console.log('On scrolled down.'); - if (!this.invertedList) { - this.loadMore.emit(); - } + this._scrollTrigger.next('down'); } onScrollUp(): void { - // console.log('On scrolled up.'); - if (this.invertedList) { - this.loadMore.emit(); - } + this._scrollTrigger.next('up'); + } + + loadMore(): void { + this._loadMoreTrigger.next(); } // MARK: Internal @@ -267,7 +275,7 @@ export class DbxListComponent = Db selector: 'dbx-list-view', template: '', host: { - 'class': 'dbx-list-view' + 'class': 'd-block dbx-list-view' } }) export class DbxListInternalViewComponent { @@ -279,7 +287,7 @@ export class DbxListInternalViewComponent { @HostListener('scroll', ['$event']) onScrollEvent($event: any): void { const position = $event.target.scrollTop; - this.parent.scrollAny.emit(position); + this.parent.contentScrolled.emit(position); } } diff --git a/packages/dbx-web/src/lib/layout/list/list.empty.component.ts b/packages/dbx-web/src/lib/layout/list/list.content.empty.component.ts similarity index 100% rename from packages/dbx-web/src/lib/layout/list/list.empty.component.ts rename to packages/dbx-web/src/lib/layout/list/list.content.empty.component.ts diff --git a/packages/dbx-web/src/lib/layout/list/list.directive.ts b/packages/dbx-web/src/lib/layout/list/list.directive.ts new file mode 100644 index 000000000..89ec23f31 --- /dev/null +++ b/packages/dbx-web/src/lib/layout/list/list.directive.ts @@ -0,0 +1,88 @@ +import { ListLoadingState } from '@dereekb/rxjs'; +import { switchMapMaybeObs, filterMaybe, ObservableGetter, getter } from '@dereekb/rxjs'; +import { Observable, BehaviorSubject, map, shareReplay, isObservable, of, switchMap } from 'rxjs'; +import { Output, EventEmitter, OnInit, OnDestroy, Directive, Type, Input } from "@angular/core"; +import { DbxListConfig } from "./list.component"; +import { DbxListView, ListSelectionState } from "./list.view"; +import { Maybe } from '@dereekb/util'; + +export const DEFAULT_STATIC_LIST_DIRECTIVE_TEMPLATE = ` + + + + + +`; + +// MARK: Wrapper +export const DEFAULT_LIST_WRAPPER_DIRECTIVE_TEMPLATE = ` + + + + + +`; + +export interface DbxListWrapperConfig = DbxListView> extends Omit, 'onClick' | 'loadMore'> { } + +@Directive() +export abstract class AbstractDbxListWrapperDirective = DbxListView, C extends DbxListWrapperConfig = DbxListWrapperConfig, S extends ListLoadingState = ListLoadingState> implements OnInit, OnDestroy { + + private readonly _init = new BehaviorSubject>>(undefined); + readonly config$ = this._init.pipe( + filterMaybe(), + getter(), + map((x: C) => this._buildListConfig(x)), + shareReplay(1)); + + @Input() + state$?: Maybe>; + + @Output() + clickItem = new EventEmitter(); + + @Output() + loadMore = new EventEmitter(); + + constructor(readonly initConfig: ObservableGetter) { } + + ngOnInit(): void { + this._init.next(this.initConfig); + } + + ngOnDestroy(): void { + this._init.complete(); + this.clickItem.complete(); + this.loadMore.complete(); + } + + protected _buildListConfig(config: C): DbxListConfig { + return { + ...config, + onClick: (x) => this.clickItem.emit(x), + loadMore: () => this.loadMore.emit() + }; + } + +} + +// MARK: Selection Wrapper +export interface DbxSelectionListWrapperConfig = DbxListView> extends Omit, 'onSelectionChange'> { } + +@Directive() +export abstract class AbstractDbxSelectionListWrapperDirective = DbxListView, C extends DbxSelectionListWrapperConfig = DbxSelectionListWrapperConfig, S extends ListLoadingState = ListLoadingState> extends AbstractDbxListWrapperDirective { + + @Output() + selectionChange = new EventEmitter>(); + + override ngOnDestroy(): void { + this.selectionChange.complete(); + } + + protected override _buildListConfig(config: C): DbxListConfig { + const result = super._buildListConfig(config); + result.onSelectionChange = (x) => this.selectionChange.next(x); + return result; + } + +} diff --git a/packages/dbx-web/src/lib/layout/list/list.layout.module.ts b/packages/dbx-web/src/lib/layout/list/list.layout.module.ts index c56049f78..bec8baedb 100644 --- a/packages/dbx-web/src/lib/layout/list/list.layout.module.ts +++ b/packages/dbx-web/src/lib/layout/list/list.layout.module.ts @@ -3,7 +3,7 @@ import { NgModule } from '@angular/core'; import { DbxInjectedComponentModule } from '@dereekb/dbx-core'; import { DbxLoadingModule } from '../../loading/loading.module'; import { DbxListComponent, DbxListInternalViewComponent } from './list.component'; -import { DbxListEmptyContentComponent } from './list.empty.component'; +import { DbxListEmptyContentComponent } from './list.content.empty.component'; import { InfiniteScrollModule } from 'ngx-infinite-scroll'; @NgModule({ diff --git a/packages/dbx-web/src/lib/layout/list/list.selection.ts b/packages/dbx-web/src/lib/layout/list/list.selection.ts deleted file mode 100644 index c887cbcd5..000000000 --- a/packages/dbx-web/src/lib/layout/list/list.selection.ts +++ /dev/null @@ -1,10 +0,0 @@ - -export interface ListItemSelectionState { - disabled?: boolean; - selected?: boolean; - value: T; -} - -export interface ListSelectionState { - items: ListItemSelectionState[]; -} diff --git a/packages/dbx-web/src/lib/layout/list/list.view.component.ts b/packages/dbx-web/src/lib/layout/list/list.view.component.ts new file mode 100644 index 000000000..e86830e78 --- /dev/null +++ b/packages/dbx-web/src/lib/layout/list/list.view.component.ts @@ -0,0 +1,48 @@ +import { ListLoadingStateContext, switchMapMaybeObs } from '@dereekb/rxjs'; +import { BehaviorSubject, Observable, of } from 'rxjs'; +import { Directive, EventEmitter, Input, OnDestroy, Output } from '@angular/core'; +import { shareReplay } from 'rxjs/operators'; +import { DbxListView } from './list.view'; +import { Maybe } from '@dereekb/util'; + +/** + * Abstract DbxListView implementation. + */ +@Directive() +export abstract class AbstractDbxListViewDirective implements DbxListView, OnDestroy { + + private readonly _values$ = new BehaviorSubject>>(undefined); + readonly values$ = this._values$.pipe(switchMapMaybeObs(), shareReplay(1)); + + @Output() + clickValue = new EventEmitter(); + + constructor() { } + + @Input() + set valueArray(values: Maybe) { + this.setValues(values ? of(values) : undefined); + } + + @Input() + set values(values: Maybe>) { + this.setValues(values); + } + + ngOnDestroy(): void { + this._values$.complete(); + } + + onClickValue(value: T) { + this.clickValue.emit(value); + } + + setListContext(state: ListLoadingStateContext): void { + this.setValues(state.models$); + } + + setValues(valuesObs: Maybe>): void { + this._values$.next(valuesObs); + } + +} diff --git a/packages/dbx-web/src/lib/layout/list/list.view.selection.component.ts b/packages/dbx-web/src/lib/layout/list/list.view.selection.component.ts new file mode 100644 index 000000000..af9c26f7e --- /dev/null +++ b/packages/dbx-web/src/lib/layout/list/list.view.selection.component.ts @@ -0,0 +1,30 @@ +import { Directive, EventEmitter, Output } from '@angular/core'; +import { MatSelectionListChange } from '@angular/material/list'; +import { ListSelectionState, ListSelectionStateItem } from './list.view'; +import { AbstractDbxListViewDirective } from './list.view.component'; + + +/** + * Abstract list view that has a pre-built-in selection change event for an Angular Material MatSelectionListChange. + */ +@Directive() +export abstract class AbstractSelectionValueListViewDirective extends AbstractDbxListViewDirective { + + @Output() + selectionChange = new EventEmitter>(); + + selectionChanged(selection: ListSelectionState): void { + this.selectionChange.emit(selection); + } + + matSelectionChanged(selection: MatSelectionListChange): void { + const options = selection.source.selectedOptions.selected; + const items: ListSelectionStateItem[] = options.map(x => { + const { value, selected, disabled } = x; + return ({ value, selected, disabled }); + }); + + this.selectionChanged({ items }); + } + +} diff --git a/packages/dbx-web/src/lib/layout/list/list.view.ts b/packages/dbx-web/src/lib/layout/list/list.view.ts new file mode 100644 index 000000000..14688fbe9 --- /dev/null +++ b/packages/dbx-web/src/lib/layout/list/list.view.ts @@ -0,0 +1,34 @@ +import { ListLoadingState, ListLoadingStateContext } from "@dereekb/rxjs"; +import { EventEmitter } from "@angular/core"; + +export interface ListSelectionStateItem { + disabled?: boolean; + selected?: boolean; + value: T; +} + +export interface ListSelectionState { + items: ListSelectionStateItem[]; +} + +/** + * Interface for a view that renders the items of a DbxList. + */ +export interface DbxListView = ListLoadingState> { + /** + * (Optional) clicked event emitter. + * + * If available, the DbxList will subscribe to it automatically. + */ + clickValue?: EventEmitter; + /** + * (Optional) selection changed event emitter. + * + * If available, the DbxList will subscribe to it automatically. + */ + selectionChange?: EventEmitter>; + /** + * Sets the models input loading state context for the view to render from. + */ + setListContext(state: ListLoadingStateContext): void; +} diff --git a/packages/demo/src/app/modules/doc/modules/layout/component/item.list.component.ts b/packages/demo/src/app/modules/doc/modules/layout/component/item.list.component.ts new file mode 100644 index 000000000..4308d0b47 --- /dev/null +++ b/packages/demo/src/app/modules/doc/modules/layout/component/item.list.component.ts @@ -0,0 +1,30 @@ +import { Component } from "@angular/core"; +import { AbstractDbxSelectionListWrapperDirective, AbstractSelectionValueListViewDirective, DEFAULT_LIST_WRAPPER_DIRECTIVE_TEMPLATE } from "@dereekb/dbx-web"; + +export interface DocItem { + name: string; + icon: string; +} + +/** + * Demo DbxSelectionListWrapperDirective + */ +@Component({ + selector: 'doc-item-list', + template: DEFAULT_LIST_WRAPPER_DIRECTIVE_TEMPLATE +}) +export class DocItemListComponent extends AbstractDbxSelectionListWrapperDirective { + + constructor() { + super({ + componentClass: DocItemListViewComponent + }); + } + +} + +@Component({ + selector: 'doc-item-list-view', + templateUrl: './item.list.view.component.html' +}) +export class DocItemListViewComponent extends AbstractSelectionValueListViewDirective { } diff --git a/packages/demo/src/app/modules/doc/modules/layout/component/item.list.view.component.html b/packages/demo/src/app/modules/doc/modules/layout/component/item.list.view.component.html new file mode 100644 index 000000000..818c9ad42 --- /dev/null +++ b/packages/demo/src/app/modules/doc/modules/layout/component/item.list.view.component.html @@ -0,0 +1,6 @@ + + + {{ item.icon }} +

{{ item.name }}

+
+
diff --git a/packages/demo/src/app/modules/doc/modules/layout/container/layout.component.html b/packages/demo/src/app/modules/doc/modules/layout/container/layout.component.html index 3a2aa9134..6c4e99b31 100644 --- a/packages/demo/src/app/modules/doc/modules/layout/container/layout.component.html +++ b/packages/demo/src/app/modules/doc/modules/layout/container/layout.component.html @@ -1,4 +1,4 @@ - + diff --git a/packages/demo/src/app/modules/doc/modules/layout/container/list.component.html b/packages/demo/src/app/modules/doc/modules/layout/container/list.component.html index 7d9b16f47..42f52619f 100644 --- a/packages/demo/src/app/modules/doc/modules/layout/container/list.component.html +++ b/packages/demo/src/app/modules/doc/modules/layout/container/list.component.html @@ -1,9 +1,17 @@ - - + + + - - + - - + +

It is important that a dbx-list has a set height available to it, otherwise it will expand to it's content height.

+

Selected: {{ selectionState | json }}

+

Models: {{ count$ | async }}

+
+ +
+
+
+
diff --git a/packages/demo/src/app/modules/doc/modules/layout/container/list.component.ts b/packages/demo/src/app/modules/doc/modules/layout/container/list.component.ts index a15cc8416..2a22e0b29 100644 --- a/packages/demo/src/app/modules/doc/modules/layout/container/list.component.ts +++ b/packages/demo/src/app/modules/doc/modules/layout/container/list.component.ts @@ -1,17 +1,41 @@ -import { DbxListConfig } from '@dereekb/dbx-web'; -import { Component } from '@angular/core'; +import { ListSelectionState } from '@dereekb/dbx-web'; +import { ListLoadingState, successResult, tapLog } from '@dereekb/rxjs'; +import { BehaviorSubject, map, switchMap, startWith, Observable, delay, of } from 'rxjs'; +import { Component, OnInit } from '@angular/core'; +import { DocItem } from '../component/item.list.component'; +import { range, takeFront } from '@dereekb/util'; @Component({ templateUrl: './list.component.html' }) -export class DocLayoutListComponent { - - // todo +export class DocLayoutListComponent implements OnInit { - /* - readonly config: DbxListConfig = { + readonly numberToLoadPerUpdate = 50; + selectionState?: ListSelectionState; + + private _values = new BehaviorSubject([]); + readonly state$: Observable> = this._values.pipe( + switchMap((x) => { + return of(successResult(x)).pipe( + delay((Math.random() * 500) + 1000), + startWith>({ loading: true, model: takeFront(x, x.length - this.numberToLoadPerUpdate) }) + ); + }) + ); + + readonly count$ = this.state$.pipe(map(x => x.model?.length ?? 0)); + + loadMore() { + this._values.next(this._values.value.concat(range(this.numberToLoadPerUpdate).map(x => ({ icon: 'house', name: `${x}-${Math.random() * x}` })))) + } + + onSelectionChange(event: ListSelectionState) { + this.selectionState = event; + } + + ngOnInit(): void { + this.loadMore(); } - */ } diff --git a/packages/demo/src/app/modules/doc/modules/layout/doc.layout.module.ts b/packages/demo/src/app/modules/doc/modules/layout/doc.layout.module.ts index cc503b5c6..0d02196b3 100644 --- a/packages/demo/src/app/modules/doc/modules/layout/doc.layout.module.ts +++ b/packages/demo/src/app/modules/doc/modules/layout/doc.layout.module.ts @@ -8,6 +8,7 @@ import { DocLayoutHomeComponent } from './container/home.component'; import { DocSharedModule } from '../shared/doc.shared.module'; import { DocLayoutContentComponent } from './container/content.component'; import { DocLayoutBarComponent } from './container/bar.component'; +import { DocItemListComponent, DocItemListViewComponent } from './component/item.list.component'; @NgModule({ imports: [ @@ -22,7 +23,9 @@ import { DocLayoutBarComponent } from './container/bar.component'; DocLayoutBarComponent, DocLayoutSectionComponent, DocLayoutContentComponent, - DocLayoutListComponent - ], + DocLayoutListComponent, + DocItemListComponent, + DocItemListViewComponent + ] }) export class DocLayoutModule { } diff --git a/packages/rxjs/src/lib/loading/loading.context.model.spec.ts b/packages/rxjs/src/lib/loading/loading.context.model.spec.ts index ddb0d9765..1d12346ab 100644 --- a/packages/rxjs/src/lib/loading/loading.context.model.spec.ts +++ b/packages/rxjs/src/lib/loading/loading.context.model.spec.ts @@ -1,4 +1,4 @@ -import { LoadingStateContext } from './loading.context.state.model'; +import { LoadingStateContextInstance } from './loading.context.state.model'; import { loadingStateIsLoading, successResult } from '.'; import { of } from 'rxjs'; import { first } from 'rxjs/operators'; @@ -7,10 +7,10 @@ describe('LoadingStateContext', () => { describe('no state observable.', () => { - let context: LoadingStateContext; + let context: LoadingStateContextInstance; beforeEach(() => { - context = new LoadingStateContext(); + context = new LoadingStateContextInstance(); }); it('should return a loading state stream.', (done) => { @@ -49,7 +49,7 @@ describe('LoadingStateContext', () => { expect(loadingStateIsLoading(state)).toBe(false); - const context = new LoadingStateContext({ obs: of(state), showLoadingOnNoModel: true }); + const context = new LoadingStateContextInstance({ obs: of(state), showLoadingOnNoModel: true }); context.stream$.pipe(first()).subscribe({ next: ({ loading }) => { @@ -70,7 +70,7 @@ describe('LoadingStateContext', () => { expect(loadingStateIsLoading(state)).toBe(false); - const context = new LoadingStateContext({ obs: of(state), showLoadingOnNoModel: false }); + const context = new LoadingStateContextInstance({ obs: of(state), showLoadingOnNoModel: false }); context.stream$.pipe(first()).subscribe({ next: ({ loading }) => { diff --git a/packages/rxjs/src/lib/loading/loading.context.state.list.ts b/packages/rxjs/src/lib/loading/loading.context.state.list.ts index 094d35900..e007f96a0 100644 --- a/packages/rxjs/src/lib/loading/loading.context.state.list.ts +++ b/packages/rxjs/src/lib/loading/loading.context.state.list.ts @@ -4,7 +4,7 @@ import { Observable, distinctUntilChanged } from 'rxjs'; import { filter, map, shareReplay } from 'rxjs/operators'; import { LoadingContextEvent } from './loading.context'; import { ListLoadingState } from './loading.state'; -import { AbstractLoadingEventForLoadingPairConfig, AbstractLoadingStateContext } from './loading.context.state'; +import { AbstractLoadingEventForLoadingPairConfig, AbstractLoadingStateContext, AbstractLoadingStateContextInstance, LoadingStateContextInstanceInputConfig } from './loading.context.state'; export interface ListLoadingStateContextEvent extends LoadingContextEvent { list?: Maybe; @@ -12,10 +12,16 @@ export interface ListLoadingStateContextEvent extends LoadingContextEvent { export interface LoadingEventForListLoadingStateConfig = ListLoadingState> extends AbstractLoadingEventForLoadingPairConfig, Partial { } +export interface ListLoadingStateContext = ListLoadingState> extends AbstractLoadingStateContext> { + readonly list$: Observable; + readonly models$: Observable; + readonly isEmpty$: Observable; +} + /** * LoadingContext implementation that uses a ListLoadingState observable. */ -export class ListLoadingStateContext = ListLoadingState> extends AbstractLoadingStateContext, LoadingEventForListLoadingStateConfig> { +export class ListLoadingStateContextInstance = ListLoadingState> extends AbstractLoadingStateContextInstance, LoadingEventForListLoadingStateConfig> { /** * Returns the current models or an empty list. @@ -58,3 +64,7 @@ export class ListLoadingStateContext = Li } } + +export function listLoadingStateContext = ListLoadingState>(config: LoadingStateContextInstanceInputConfig>): ListLoadingStateContextInstance { + return new ListLoadingStateContextInstance(config); +} diff --git a/packages/rxjs/src/lib/loading/loading.context.state.model.ts b/packages/rxjs/src/lib/loading/loading.context.state.model.ts index 32f5e3c02..e2ab22c7f 100644 --- a/packages/rxjs/src/lib/loading/loading.context.state.model.ts +++ b/packages/rxjs/src/lib/loading/loading.context.state.model.ts @@ -3,25 +3,31 @@ import { loadingStateIsLoading } from '@dereekb/rxjs'; import { Observable } from 'rxjs'; import { map, shareReplay, filter } from 'rxjs/operators'; import { LoadingContextEvent } from './loading.context'; -import { AbstractLoadingEventForLoadingPairConfig, AbstractLoadingStateContext } from './loading.context.state'; +import { AbstractLoadingEventForLoadingPairConfig, AbstractLoadingStateContext, AbstractLoadingStateContextInstance, LoadingStateContextInstanceInputConfig } from './loading.context.state'; import { LoadingState } from './loading.state'; -export interface LoadingStateEvent extends LoadingContextEvent { +export interface LoadingStateContextEvent extends LoadingContextEvent { model?: Maybe; } export interface LoadingEventForLoadingPairConfig extends AbstractLoadingEventForLoadingPairConfig { } +export interface LoadingStateContext = LoadingState> extends AbstractLoadingStateContext> { + readonly list$: Observable; + readonly models$: Observable; + readonly isEmpty$: Observable; +} + /** * LoadingContext implementation for a LoadingState. */ -export class LoadingStateContext = LoadingState> extends AbstractLoadingStateContext, LoadingEventForLoadingPairConfig> { +export class LoadingStateContextInstance = LoadingState> extends AbstractLoadingStateContextInstance, LoadingEventForLoadingPairConfig> { readonly model$: Observable> = this.stream$.pipe(map(x => x.model), shareReplay(1)); readonly modelAfterLoaded$: Observable> = this.stream$.pipe(filter(x => !x.loading), map(x => x.model), shareReplay(1)); - protected loadingEventForLoadingPair(pair: S, { showLoadingOnNoModel }: LoadingEventForLoadingPairConfig = {}): LoadingStateEvent { + protected loadingEventForLoadingPair(pair: S, { showLoadingOnNoModel }: LoadingEventForLoadingPairConfig = {}): LoadingStateContextEvent { let loading: boolean = false; const error = pair?.error; @@ -43,3 +49,7 @@ export class LoadingStateContext = LoadingSta } } + +export function loadingStateContext = LoadingState>(config: LoadingStateContextInstanceInputConfig>): LoadingStateContextInstance { + return new LoadingStateContextInstance(config); +} diff --git a/packages/rxjs/src/lib/loading/loading.context.state.ts b/packages/rxjs/src/lib/loading/loading.context.state.ts index 92338f50e..1a328bdf7 100644 --- a/packages/rxjs/src/lib/loading/loading.context.state.ts +++ b/packages/rxjs/src/lib/loading/loading.context.state.ts @@ -5,7 +5,6 @@ import { mergeMap, map, startWith, switchMap, shareReplay, distinctUntilChanged, import { LoadingContext, LoadingContextEvent } from './loading.context'; import { LoadingState } from './loading.state'; - export interface AbstractLoadingStateEvent extends LoadingContextEvent { model?: Maybe; } @@ -21,10 +20,21 @@ export interface AbstractLoadingEventForLoadingPairConfig = LoadingState, E extends LoadingContextEvent = LoadingContextEvent> { + readonly stateObs$: Observable>>; + readonly stateSubject$: Observable>; + readonly state$: Observable; + readonly stream$: Observable; + readonly loading$: Observable; +} + +export type LoadingStateContextInstanceInputConfig = Observable | C; + /** * Abstract LoadingContext implementation using LoadingState. */ -export abstract class AbstractLoadingStateContext = LoadingState, E extends LoadingContextEvent = LoadingContextEvent, C extends AbstractLoadingEventForLoadingPairConfig = AbstractLoadingEventForLoadingPairConfig> implements LoadingContext, Destroyable { +export abstract class AbstractLoadingStateContextInstance = LoadingState, E extends LoadingContextEvent = LoadingContextEvent, C extends AbstractLoadingEventForLoadingPairConfig = AbstractLoadingEventForLoadingPairConfig> + implements AbstractLoadingStateContext, LoadingContext, Destroyable { private _stateSubject$ = new BehaviorSubject>>(undefined); private _config: C; @@ -54,7 +64,7 @@ export abstract class AbstractLoadingStateContext = this.stream$.pipe(map(x => x.loading), shareReplay(1)); - constructor(config?: Observable | C) { + constructor(config?: LoadingStateContextInstanceInputConfig) { if (isObservable(config)) { this._config = { obs: config @@ -72,7 +82,7 @@ export abstract class AbstractLoadingStateContext): void { + setStateObs(state: Maybe>): void { this._stateSubject$.next(state); } diff --git a/packages/rxjs/src/lib/loading/loading.state.ts b/packages/rxjs/src/lib/loading/loading.state.ts index 4d4f8ba82..382486915 100644 --- a/packages/rxjs/src/lib/loading/loading.state.ts +++ b/packages/rxjs/src/lib/loading/loading.state.ts @@ -22,7 +22,7 @@ export interface LoadingErrorPair { * A model/error pair used in loading situations. */ export interface LoadingState extends LoadingErrorPair { - model?: Maybe; + model?: Maybe; // todo: rename to value } /** diff --git a/packages/rxjs/src/lib/rxjs/getter.ts b/packages/rxjs/src/lib/rxjs/getter.ts new file mode 100644 index 000000000..67c9adcfb --- /dev/null +++ b/packages/rxjs/src/lib/rxjs/getter.ts @@ -0,0 +1,21 @@ +import { Observable, OperatorFunction, switchMap, of, isObservable } from 'rxjs'; + +/** + * A value that is either the value or an observable that returns the value. + */ +export type ObservableGetter = T | Observable; + +/** + * Switch map for an ObservableGetter that pipes through the value. + * + * @returns + */ +export function getter(): OperatorFunction, T> { + return switchMap(x => { + if (isObservable(x)) { + return x; + } else { + return of(x); + } + }); +} diff --git a/packages/rxjs/src/lib/rxjs/index.ts b/packages/rxjs/src/lib/rxjs/index.ts index 209af7a3d..f84730102 100644 --- a/packages/rxjs/src/lib/rxjs/index.ts +++ b/packages/rxjs/src/lib/rxjs/index.ts @@ -1,5 +1,6 @@ export * from './array'; export * from './boolean'; +export * from './getter'; export * from './loading'; export * from './misc'; export * from './number'; diff --git a/packages/rxjs/src/lib/rxjs/value.ts b/packages/rxjs/src/lib/rxjs/value.ts index da491c032..b0c5da53b 100644 --- a/packages/rxjs/src/lib/rxjs/value.ts +++ b/packages/rxjs/src/lib/rxjs/value.ts @@ -23,7 +23,7 @@ export function skipFirstMaybe(): MonoTypeOperatorFunction> { * @param defaultValue * @returns */ -export function switchMapMaybeObs(defaultValue?: Maybe): OperatorFunction>>, Maybe> { +export function switchMapMaybeDefault(defaultValue: Maybe = undefined): OperatorFunction>>, Maybe> { return switchMap((x: Maybe>>) => { if (x != null) { return x; @@ -32,3 +32,19 @@ export function switchMapMaybeObs(defaultValue?: Maybe): OperatorFun } }) } + +/** + * Combines both filterMaybe and switchMap to build a subscriber that emits only concrete values. + * + * @returns + */ +export function switchMapMaybeObs(): OperatorFunction>>, T> { + return (source: Observable>>>) => { + const subscriber: Observable = source.pipe( + filterMaybe(), + switchMap(x => x) + ) as Observable; + + return subscriber; + }; +} diff --git a/packages/util/src/lib/array/array.number.ts b/packages/util/src/lib/array/array.number.ts index ece343ed0..40de88cd5 100644 --- a/packages/util/src/lib/array/array.number.ts +++ b/packages/util/src/lib/array/array.number.ts @@ -46,9 +46,20 @@ export function reduceNumbersFn(reduceFn: ((a: number, b: numb * @param param0 * @returns */ -export function range({ start = 0, end }: { start?: number, end: number }): number[] { +export function range(input: number | { start?: number, end: number }): number[] { const range = []; + let start: number; + let end: number; + + if (typeof input === 'number') { + start = 0; + end = input; + } else { + start = input.start ?? 0; + end = input.end; + } + for (let i = start; i < end; i += 1) { range.push(i); } diff --git a/packages/util/src/lib/date/date.ts b/packages/util/src/lib/date/date.ts index a464e1e19..a24cadcde 100644 --- a/packages/util/src/lib/date/date.ts +++ b/packages/util/src/lib/date/date.ts @@ -56,6 +56,7 @@ export type UnixDateTimeNumber = number; */ export type DateOrUnixDateTimeNumber = Date | UnixDateTimeNumber; +export type Milliseconds = number; export type Seconds = number; export type Minutes = number; export type Hours = number;