From 08bdb0bb28285c6fb4e3f0e1401494c4ed4f7142 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 11 Jan 2026 22:39:58 +0000 Subject: [PATCH 1/2] feat(list-builder-view-grids): add new state architecture foundation (Phase 1) - Add NewGridState class with BehaviorSubject observables for columns and displayedColumns - Add NewGridStateDispatcher class for direct state updates without action/orchestrator pattern - Export new classes from public API This implements the foundation for refactoring from 'state object observable with static properties' to 'static state object with observable properties' pattern, eliminating timing issues that required scan operator workarounds. Co-Authored-By: benc@cognition.ai --- .../list-builder-view-grids/src/index.ts | 2 + .../state/new-grid-state-dispatcher.ts | 122 ++++++++++++++++++ .../list-view-grid/state/new-grid-state.ts | 92 +++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 libs/components/list-builder-view-grids/src/lib/modules/list-view-grid/state/new-grid-state-dispatcher.ts create mode 100644 libs/components/list-builder-view-grids/src/lib/modules/list-view-grid/state/new-grid-state.ts diff --git a/libs/components/list-builder-view-grids/src/index.ts b/libs/components/list-builder-view-grids/src/index.ts index e81c016100..0dfa5ad56c 100644 --- a/libs/components/list-builder-view-grids/src/index.ts +++ b/libs/components/list-builder-view-grids/src/index.ts @@ -20,6 +20,8 @@ export { GridStateOrchestrator, } from './lib/modules/list-view-grid/state/grid-state.rxstate'; export { GridState } from './lib/modules/list-view-grid/state/grid-state.state-node'; +export { NewGridState } from './lib/modules/list-view-grid/state/new-grid-state'; +export { NewGridStateDispatcher } from './lib/modules/list-view-grid/state/new-grid-state-dispatcher'; export { SkyListViewGridMessage } from './lib/modules/list-view-grid/types/list-view-grid-message'; export { SkyListViewGridMessageType } from './lib/modules/list-view-grid/types/list-view-grid-message-type'; diff --git a/libs/components/list-builder-view-grids/src/lib/modules/list-view-grid/state/new-grid-state-dispatcher.ts b/libs/components/list-builder-view-grids/src/lib/modules/list-view-grid/state/new-grid-state-dispatcher.ts new file mode 100644 index 0000000000..3c0eb5d824 --- /dev/null +++ b/libs/components/list-builder-view-grids/src/lib/modules/list-view-grid/state/new-grid-state-dispatcher.ts @@ -0,0 +1,122 @@ +import { Injectable } from '@angular/core'; +import { SkyGridColumnModel } from '@skyux/grids'; +import { AsyncList } from '@skyux/list-builder-common'; + +import { NewGridState } from './new-grid-state'; + +/** + * Dispatcher for the new grid state architecture. + * Works directly with BehaviorSubjects instead of using action/orchestrator pattern. + * + * This replaces the action dispatch pattern: + * ```typescript + * this.gridDispatcher.next(new ListViewGridColumnsLoadAction(columns, true)); + * ``` + * With direct method calls: + * ```typescript + * this.gridDispatcher.loadColumns(columns, true); + * ``` + * + * @internal + */ +@Injectable() +export class NewGridStateDispatcher { + constructor(private gridState: NewGridState) {} + + /** + * Load columns into the grid state. + * Replaces `ListViewGridColumnsLoadAction` dispatch pattern. + * @param columns The column models to load. + * @param refresh Whether to refresh (replace) existing columns. If false, columns are appended. + */ + public loadColumns(columns: SkyGridColumnModel[], refresh = false): void { + const currentColumns = this.gridState.getCurrentColumns(); + + const newAsyncList = new AsyncList( + refresh ? columns : [...currentColumns.items, ...columns], + Date.now(), + false, + refresh ? columns.length : currentColumns.items.length + columns.length + ); + + this.gridState.updateColumns(newAsyncList); + } + + /** + * Load displayed columns into the grid state. + * Replaces `ListViewDisplayedGridColumnsLoadAction` dispatch pattern. + * @param columns The displayed column models to load. + * @param refresh Whether to refresh (replace) existing displayed columns. If false, columns are appended. + */ + public loadDisplayedColumns( + columns: SkyGridColumnModel[], + refresh = false + ): void { + const currentDisplayedColumns = this.gridState.getCurrentDisplayedColumns(); + + const newAsyncList = new AsyncList( + refresh ? columns : [...currentDisplayedColumns.items, ...columns], + Date.now(), + false, + refresh + ? columns.length + : currentDisplayedColumns.items.length + columns.length + ); + + this.gridState.updateDisplayedColumns(newAsyncList); + } + + /** + * Update a single column's properties. + * @param columnId The ID of the column to update. + * @param updates Partial column model with properties to update. + */ + public updateColumn( + columnId: string, + updates: Partial + ): void { + const currentColumns = this.gridState.getCurrentColumns(); + const updatedItems = currentColumns.items.map((col) => { + if (col.id === columnId) { + return { ...col, ...updates } as SkyGridColumnModel; + } + return col; + }); + + const newAsyncList = new AsyncList( + updatedItems, + Date.now(), + false, + updatedItems.length + ); + + this.gridState.updateColumns(newAsyncList); + } + + /** + * Update a single displayed column's properties. + * @param columnId The ID of the displayed column to update. + * @param updates Partial column model with properties to update. + */ + public updateDisplayedColumn( + columnId: string, + updates: Partial + ): void { + const currentDisplayedColumns = this.gridState.getCurrentDisplayedColumns(); + const updatedItems = currentDisplayedColumns.items.map((col) => { + if (col.id === columnId) { + return { ...col, ...updates } as SkyGridColumnModel; + } + return col; + }); + + const newAsyncList = new AsyncList( + updatedItems, + Date.now(), + false, + updatedItems.length + ); + + this.gridState.updateDisplayedColumns(newAsyncList); + } +} diff --git a/libs/components/list-builder-view-grids/src/lib/modules/list-view-grid/state/new-grid-state.ts b/libs/components/list-builder-view-grids/src/lib/modules/list-view-grid/state/new-grid-state.ts new file mode 100644 index 0000000000..8d9b1af230 --- /dev/null +++ b/libs/components/list-builder-view-grids/src/lib/modules/list-view-grid/state/new-grid-state.ts @@ -0,0 +1,92 @@ +import { Injectable } from '@angular/core'; +import { SkyGridColumnModel } from '@skyux/grids'; +import { AsyncList } from '@skyux/list-builder-common'; + +import { BehaviorSubject, Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +/** + * New state management class that exposes individual observables instead of a single state object. + * This pattern eliminates timing issues where state updates are consumed out of order, + * removing the need for `scan` operator workarounds. + * + * Usage pattern changes from: + * ```typescript + * this.gridState.pipe(map(s => s.displayedColumns)) + * ``` + * To: + * ```typescript + * this.gridState.displayedColumns$ + * ``` + * + * @internal + */ +@Injectable() +export class NewGridState { + private readonly _columns$ = new BehaviorSubject>( + new AsyncList() + ); + + private readonly _displayedColumns$ = + new BehaviorSubject>( + new AsyncList() + ); + + /** + * Observable for the full columns AsyncList including metadata. + */ + public readonly columns$: Observable> = + this._columns$.asObservable(); + + /** + * Observable for the full displayedColumns AsyncList including metadata. + */ + public readonly displayedColumns$: Observable> = + this._displayedColumns$.asObservable(); + + /** + * Convenience observable for direct access to column items array. + */ + public readonly columnItems$: Observable = + this._columns$.pipe(map((asyncList) => asyncList.items)); + + /** + * Convenience observable for direct access to displayed column items array. + */ + public readonly displayedColumnItems$: Observable = + this._displayedColumns$.pipe(map((asyncList) => asyncList.items)); + + /** + * Updates the columns state. + * @param columns The new columns AsyncList to set. + */ + public updateColumns(columns: AsyncList): void { + this._columns$.next(columns); + } + + /** + * Updates the displayed columns state. + * @param displayedColumns The new displayed columns AsyncList to set. + */ + public updateDisplayedColumns( + displayedColumns: AsyncList + ): void { + this._displayedColumns$.next(displayedColumns); + } + + /** + * Gets the current columns value synchronously. + * Use this when you need immediate access to the current state. + */ + public getCurrentColumns(): AsyncList { + return this._columns$.getValue(); + } + + /** + * Gets the current displayed columns value synchronously. + * Use this when you need immediate access to the current state. + */ + public getCurrentDisplayedColumns(): AsyncList { + return this._displayedColumns$.getValue(); + } +} From 9bbeff9e01b35565c74d02aa285af9c8b2f13380 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 11 Jan 2026 22:46:20 +0000 Subject: [PATCH 2/2] feat(list-builder-view-grids): add new components using new state architecture (Phase 2) - Add NewSkyListViewGridComponent that removes scan operator workarounds - Add NewSkyListColumnSelectorActionComponent using combineLatest with individual observables - Update modules to declare and export new components - Export new components from public API The new components use direct subscriptions to BehaviorSubject observables from Phase 1, eliminating timing issues without needing scan operator workarounds. Co-Authored-By: benc@cognition.ai --- .../list-builder-view-grids/src/index.ts | 2 + .../list-column-selector-action.module.ts | 8 +- ...w-list-column-selector-action.component.ts | 195 ++++++ .../list-view-grid/list-view-grid.module.ts | 14 +- .../new-list-view-grid.component.ts | 624 ++++++++++++++++++ 5 files changed, 836 insertions(+), 7 deletions(-) create mode 100644 libs/components/list-builder-view-grids/src/lib/modules/list-column-selector-action/new-list-column-selector-action.component.ts create mode 100644 libs/components/list-builder-view-grids/src/lib/modules/list-view-grid/new-list-view-grid.component.ts diff --git a/libs/components/list-builder-view-grids/src/index.ts b/libs/components/list-builder-view-grids/src/index.ts index 0dfa5ad56c..79fc25720e 100644 --- a/libs/components/list-builder-view-grids/src/index.ts +++ b/libs/components/list-builder-view-grids/src/index.ts @@ -7,6 +7,7 @@ export { SkyColumnSelectorModule } from './lib/modules/column-selector/column-se export { SkyListColumnSelectorActionModule } from './lib/modules/list-column-selector-action/list-column-selector-action.module'; export { SkyListViewGridComponent } from './lib/modules/list-view-grid/list-view-grid.component'; +export { NewSkyListViewGridComponent } from './lib/modules/list-view-grid/new-list-view-grid.component'; export { SkyListViewGridModule } from './lib/modules/list-view-grid/list-view-grid.module'; export { ListViewGridColumnsOrchestrator } from './lib/modules/list-view-grid/state/columns/columns.orchestrator'; @@ -31,3 +32,4 @@ export { SkyListViewGridRowDeleteConfirmArgs } from './lib/modules/list-view-gri // Components and directives must be exported to support Angular’s “partial” Ivy compiler. // Obscure names are used to indicate types are not part of the public API. export { SkyListColumnSelectorActionComponent as λ1 } from './lib/modules/list-column-selector-action/list-column-selector-action.component'; +export { NewSkyListColumnSelectorActionComponent as λ2 } from './lib/modules/list-column-selector-action/new-list-column-selector-action.component'; diff --git a/libs/components/list-builder-view-grids/src/lib/modules/list-column-selector-action/list-column-selector-action.module.ts b/libs/components/list-builder-view-grids/src/lib/modules/list-column-selector-action/list-column-selector-action.module.ts index 4c3dfc4921..140a6d7256 100644 --- a/libs/components/list-builder-view-grids/src/lib/modules/list-column-selector-action/list-column-selector-action.module.ts +++ b/libs/components/list-builder-view-grids/src/lib/modules/list-column-selector-action/list-column-selector-action.module.ts @@ -12,6 +12,7 @@ import { SkyListBuilderViewGridsResourcesModule } from '../shared/sky-list-build import { SkyListColumnSelectorActionComponent } from './list-column-selector-action.component'; import { SkyListColumnSelectorButtonComponent } from './list-column-selector-button.component'; +import { NewSkyListColumnSelectorActionComponent } from './new-list-column-selector-action.component'; /** * @deprecated List builder view grid and its features are deprecated. Use data entry grid instead. For more information, see https://developer.blackbaud.com/skyux/components/data-entry-grid. @@ -20,6 +21,7 @@ import { SkyListColumnSelectorButtonComponent } from './list-column-selector-but declarations: [ SkyListColumnSelectorActionComponent, SkyListColumnSelectorButtonComponent, + NewSkyListColumnSelectorActionComponent, ], imports: [ CommonModule, @@ -29,6 +31,10 @@ import { SkyListColumnSelectorButtonComponent } from './list-column-selector-but SkyListToolbarModule, SkyIconModule, ], - exports: [SkyListColumnSelectorActionComponent, SkyColumnSelectorModule], + exports: [ + SkyListColumnSelectorActionComponent, + NewSkyListColumnSelectorActionComponent, + SkyColumnSelectorModule, + ], }) export class SkyListColumnSelectorActionModule {} diff --git a/libs/components/list-builder-view-grids/src/lib/modules/list-column-selector-action/new-list-column-selector-action.component.ts b/libs/components/list-builder-view-grids/src/lib/modules/list-column-selector-action/new-list-column-selector-action.component.ts new file mode 100644 index 0000000000..b92a9a8116 --- /dev/null +++ b/libs/components/list-builder-view-grids/src/lib/modules/list-column-selector-action/new-list-column-selector-action.component.ts @@ -0,0 +1,195 @@ +import { + AfterContentInit, + Component, + EventEmitter, + Input, + Optional, + Output, + TemplateRef, + ViewChild, +} from '@angular/core'; +import { SkyGridColumnModel } from '@skyux/grids'; +import { + ListState, + ListStateDispatcher, + ListToolbarItemModel, + SkyListSecondaryActionsComponent, +} from '@skyux/list-builder'; +import { SkyModalCloseArgs, SkyModalService } from '@skyux/modals'; + +import { Observable, combineLatest } from 'rxjs'; +import { + distinctUntilChanged, + map as observableMap, + take, +} from 'rxjs/operators'; + +import { + SkyColumnSelectorContext, + SkyColumnSelectorModel, +} from '../column-selector/column-selector-context'; +import { SkyColumnSelectorComponent } from '../column-selector/column-selector-modal.component'; +import { NewSkyListViewGridComponent } from '../list-view-grid/new-list-view-grid.component'; + +/** + * Provides a column selector modal for a list grid view when placed in a list toolbar. + * This component works with NewSkyListViewGridComponent and uses combineLatest + * with individual state observables instead of subscribing to the entire state object. + * + * @internal + */ +@Component({ + selector: 'sky-new-list-column-selector-action', + templateUrl: './list-column-selector-action.component.html', + standalone: false, +}) +export class NewSkyListColumnSelectorActionComponent + implements AfterContentInit +{ + /** + * Enables the column selector in the list toolbar. Set this attribute to the instance of + * the `sky-new-list-view-grid` component using the component's template reference variable. + */ + @Input() + public gridView: NewSkyListViewGridComponent; + + /** + * The `helpKey` string to associate with a help button in the grid header. + */ + @Input() + public helpKey: string; + + /** + * Fires when users click the help button and broadcasts the `helpKey`. + */ + @Output() + public helpOpened = new EventEmitter(); + + @ViewChild('columnChooser', { + static: true, + }) + private columnChooserTemplate: TemplateRef; + + private columnSelectorActionItemToolbarIndex = 7000; + + constructor( + public listState: ListState, + private modalService: SkyModalService, + private dispatcher: ListStateDispatcher, + @Optional() public secondaryActions: SkyListSecondaryActionsComponent + ) {} + + public ngAfterContentInit(): void { + if (!this.secondaryActions) { + const columnChooserItem = new ListToolbarItemModel({ + id: 'column-chooser', + template: this.columnChooserTemplate, + location: 'left', + }); + + this.dispatcher.toolbarAddItems( + [columnChooserItem], + this.columnSelectorActionItemToolbarIndex + ); + } + } + + public get isInGridView(): Observable { + return this.listState.pipe( + observableMap((s) => s.views.active), + observableMap((activeView) => { + return this.gridView && activeView === this.gridView.id; + }), + distinctUntilChanged() + ); + } + + public get isInGridViewAndSecondary(): Observable { + return this.listState.pipe( + observableMap((s) => s.views.active), + observableMap((activeView) => { + return ( + this.secondaryActions && + this.gridView && + activeView === this.gridView.id + ); + }), + distinctUntilChanged() + ); + } + + /** + * REFACTORED: Uses combineLatest with individual observables instead of + * subscribing to the entire state object. + */ + public openColumnSelector(): void { + /* istanbul ignore else */ + /* sanity check */ + if (this.gridView) { + // REFACTORED: Use combineLatest with individual observables + combineLatest([ + this.gridView.gridState.columnItems$, + this.gridView.gridState.displayedColumnItems$, + ]) + .pipe(take(1)) + .subscribe(([columnItems, displayedColumnItems]) => { + const columns: SkyColumnSelectorModel[] = columnItems + .filter((item: SkyGridColumnModel) => !item.locked) + .map((item: SkyGridColumnModel) => ({ + id: item.id, + heading: item.heading, + description: item.description, + })); + + const selectedColumnIds: string[] = displayedColumnItems + .filter((item: SkyGridColumnModel) => !item.locked) + .map((item: SkyGridColumnModel) => item.id); + + this.openModal(columns, selectedColumnIds); + }); + } + } + + private openModal( + columns: SkyColumnSelectorModel[], + selectedColumnIds: string[] + ): void { + const modalInstance = this.modalService.open(SkyColumnSelectorComponent, { + providers: [ + { + provide: SkyColumnSelectorContext, + useValue: { + columns, + selectedColumnIds, + }, + }, + ], + helpKey: this.helpKey, + }); + + modalInstance.helpOpened.subscribe((helpKey: string) => { + this.helpOpened.emit(helpKey); + this.helpOpened.complete(); + }); + + modalInstance.closed.subscribe((result: SkyModalCloseArgs) => { + if (result.reason === 'save' && result.data) { + this.handleColumnSelection(result.data); + } + }); + } + + /** + * REFACTORED: Uses new state observable and dispatcher method. + */ + private handleColumnSelection(newSelectedIds: string[]): void { + // Use columnItems$ observable to get current columns + this.gridView.gridState.columnItems$.pipe(take(1)).subscribe((columnItems) => { + const newDisplayedColumns = columnItems.filter((item) => { + return newSelectedIds.indexOf(item.id) > -1 || item.locked; + }); + // REFACTORED: Use new dispatcher method instead of action dispatch + this.gridView.gridDispatcher.loadDisplayedColumns(newDisplayedColumns, true); + }); + } +} diff --git a/libs/components/list-builder-view-grids/src/lib/modules/list-view-grid/list-view-grid.module.ts b/libs/components/list-builder-view-grids/src/lib/modules/list-view-grid/list-view-grid.module.ts index 18b2bfc64d..ca49adecfe 100644 --- a/libs/components/list-builder-view-grids/src/lib/modules/list-view-grid/list-view-grid.module.ts +++ b/libs/components/list-builder-view-grids/src/lib/modules/list-view-grid/list-view-grid.module.ts @@ -7,22 +7,24 @@ import { SkyListColumnSelectorActionModule } from '../list-column-selector-actio import { SkyListBuilderViewGridsResourcesModule } from '../shared/sky-list-builder-view-grids-resources.module'; import { SkyListViewGridComponent } from './list-view-grid.component'; +import { NewSkyListViewGridComponent } from './new-list-view-grid.component'; /** * @deprecated List builder view grid and its features are deprecated. Use data entry grid instead. For more information, see https://developer.blackbaud.com/skyux/components/data-entry-grid. */ @NgModule({ - declarations: [SkyListViewGridComponent], + declarations: [SkyListViewGridComponent, NewSkyListViewGridComponent], imports: [ CommonModule, SkyWaitModule, SkyGridModule, SkyListBuilderViewGridsResourcesModule, ], - exports: [ - SkyListViewGridComponent, - SkyListColumnSelectorActionModule, - SkyGridModule, - ], + exports: [ + SkyListViewGridComponent, + NewSkyListViewGridComponent, + SkyListColumnSelectorActionModule, + SkyGridModule, + ], }) export class SkyListViewGridModule {} diff --git a/libs/components/list-builder-view-grids/src/lib/modules/list-view-grid/new-list-view-grid.component.ts b/libs/components/list-builder-view-grids/src/lib/modules/list-view-grid/new-list-view-grid.component.ts new file mode 100644 index 0000000000..960bc840c2 --- /dev/null +++ b/libs/components/list-builder-view-grids/src/lib/modules/list-view-grid/new-list-view-grid.component.ts @@ -0,0 +1,624 @@ +import { + AfterContentInit, + ChangeDetectionStrategy, + Component, + ContentChildren, + EventEmitter, + Input, + OnDestroy, + Output, + QueryList, + ViewChild, + forwardRef, +} from '@angular/core'; +import { SkyLogService } from '@skyux/core'; +import { + SkyGridColumnComponent, + SkyGridColumnDescriptionModelChange, + SkyGridColumnHeadingModelChange, + SkyGridColumnModel, + SkyGridComponent, + SkyGridMessage, + SkyGridMessageType, + SkyGridSelectedRowsModelChange, + SkyGridSelectedRowsSource, +} from '@skyux/grids'; +import { + ListSearchModel, + ListSelectedModel, + ListState, + ListStateDispatcher, + ListViewComponent, +} from '@skyux/list-builder'; +import { + ListItemModel, + ListSortFieldSelectorModel, + getData, + getValue, + isObservable, +} from '@skyux/list-builder-common'; + +import { Observable, Subject, of as observableOf } from 'rxjs'; +import { + distinctUntilChanged, + map as observableMap, + take, + takeUntil, +} from 'rxjs/operators'; + +import { NewGridStateDispatcher } from './state/new-grid-state-dispatcher'; +import { NewGridState } from './state/new-grid-state'; +import { SkyListViewGridMessage } from './types/list-view-grid-message'; +import { SkyListViewGridMessageType } from './types/list-view-grid-message-type'; +import { SkyListViewGridRowDeleteCancelArgs } from './types/list-view-grid-row-delete-cancel-args'; +import { SkyListViewGridRowDeleteConfirmArgs } from './types/list-view-grid-row-delete-confirm-args'; + +/** + * Displays a grid for a SKY UX-themed list of data using the new state architecture. + * This component uses BehaviorSubject-based state management that eliminates timing issues + * present in the original SkyListViewGridComponent. + * + * @internal + */ +@Component({ + selector: 'sky-new-list-view-grid', + templateUrl: './list-view-grid.component.html', + styleUrls: ['./list-view-grid.component.scss'], + providers: [ + { + provide: ListViewComponent, + useExisting: forwardRef(() => NewSkyListViewGridComponent), + }, + NewGridState, + NewGridStateDispatcher, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false, +}) +export class NewSkyListViewGridComponent + extends ListViewComponent + implements AfterContentInit, OnDestroy +{ + /** + * The name of the view. + * @required + */ + @Input() + public set name(value: string) { + this.viewName = value; + } + + /** + * The columns to display by default based on the ID or field of the item. + */ + @Input() + public displayedColumns: string[] | Observable; + + /** + * The columns to hide by default based on the ID or field of the item. + */ + @Input() + public hiddenColumns: string[] | Observable; + + /** + * How the grid fits to its parent. + * @default "width" + */ + @Input() + public fit = 'width'; + + /** + * The width of the grid. + */ + @Input() + public width: number | Observable; + + /** + * The height of the grid. + */ + @Input() + public height: number | Observable; + + /** + * Whether to highlight search text within the grid. + * @default true + */ + @Input() + public highlightSearchText = true; + + /** + * The observable to send commands to the grid. + */ + @Input() + public set messageStream(stream: Subject) { + /* istanbul ignore else */ + if (this._messageStream) { + this._messageStream.unsubscribe(); + } + + this._messageStream = stream; + + this.initInlineDeleteMessages(); + } + + public get messageStream(): Subject { + return this._messageStream; + } + + /** + * The ID of the row to highlight. + */ + @Input() + public rowHighlightedId: string; + + /** + * Whether to enable the multiselect feature. + * @default false + */ + @Input() + public enableMultiselect = false; + + /** + * The unique key for the UI Config Service. + */ + @Input() + public settingsKey: string; + + /** + * Fires when users cancel the deletion of a row. + */ + @Output() + public rowDeleteCancel = + new EventEmitter(); + + /** + * Fires when users confirm the deletion of a row. + */ + @Output() + public rowDeleteConfirm = + new EventEmitter(); + + /** + * Fires when columns change. + */ + @Output() + public selectedColumnIdsChange = new EventEmitter(); + + @ViewChild(SkyGridComponent) + public gridComponent: SkyGridComponent; + + public get gridHeight(): Observable { + /* istanbul ignore next */ + return typeof this.height === 'number' + ? observableOf(this.height) + : this.height; + } + + public get gridWidth(): Observable { + /* istanbul ignore next */ + return typeof this.width === 'number' + ? observableOf(this.width) + : this.width; + } + + public columns: Observable; + + public selectedColumnIds: Observable; + + public items: Observable; + + /** + * Message stream for communicating with the internal grid instance + * @internal + */ + public gridMessageStream = new Subject(); + + public loading: Observable; + + public sortField: Observable; + + public currentSearchText: Observable; + + public multiselectSelectedIds: string[] = []; + + /** + * The search function to apply on the view data. + */ + // eslint-disable-next-line @angular-eslint/no-input-rename + @Input('search') + public searchFunction: (data: any, searchText: string) => boolean; + + @ContentChildren(SkyGridColumnComponent) + private columnComponents: QueryList; + + private ngUnsubscribe = new Subject(); + + private _messageStream = new Subject(); + + constructor( + state: ListState, + private dispatcher: ListStateDispatcher, + public gridState: NewGridState, + public gridDispatcher: NewGridStateDispatcher, + logger: SkyLogService + ) { + super(state, 'Grid View'); + + logger.deprecated('NewSkyListViewGridComponent', { + deprecationMajorVersion: 6, + moreInfoUrl: + 'https://developer.blackbaud.com/skyux/components/data-entry-grid', + replacementRecommendation: 'Use data entry grid instead.', + }); + } + + public ngAfterContentInit(): void { + // Watch for selection changes and update multiselectSelectedIds for local comparison. + this.state + .pipe( + observableMap((s) => s.selected.item), + takeUntil(this.ngUnsubscribe), + distinctUntilChanged(this.selectedMapEqual) + ) + .subscribe((items: ListSelectedModel) => { + const selectedIds: string[] = []; + + items.selectedIdMap.forEach((isSelected, id) => { + if (items.selectedIdMap.get(id) === true) { + selectedIds.push(id); + } + }); + + this.multiselectSelectedIds = selectedIds; + }); + + /* istanbul ignore next */ + if (this.columnComponents.length === 0) { + throw new Error( + 'Grid view requires at least one sky-grid-column to render.' + ); + } + + const columnModels = this.columnComponents.map((columnComponent) => { + return new SkyGridColumnModel(columnComponent.template, columnComponent); + }); + + if (this.width && !isObservable(this.width)) { + this.width = observableOf(this.width); + } + + if (this.height && !isObservable(this.height)) { + this.height = observableOf(this.height); + } + + // Setup Observables for template - REFACTORED: Direct subscription without scan + this.columns = this.gridState.columnItems$.pipe( + distinctUntilChanged(this.arraysEqual), + takeUntil(this.ngUnsubscribe) + ); + + this.selectedColumnIds = this.getSelectedIds(); + + this.items = this.getGridItems(); + + this.loading = this.state.pipe( + observableMap((s) => { + return s.items.loading; + }), + distinctUntilChanged(), + takeUntil(this.ngUnsubscribe) + ); + + this.sortField = this.state.pipe( + observableMap((s) => { + /* istanbul ignore else */ + /* sanity check */ + if (s.sort && s.sort.fieldSelectors) { + return s.sort.fieldSelectors[0]; + } + /* istanbul ignore next */ + /* sanity check */ + return undefined; + }), + distinctUntilChanged(), + takeUntil(this.ngUnsubscribe) + ); + + // REFACTORED: Use new state observable directly + this.gridState.columnItems$ + .pipe(takeUntil(this.ngUnsubscribe), distinctUntilChanged(this.arraysEqual)) + .subscribe((columns) => { + /* istanbul ignore else */ + if (this.hiddenColumns) { + getValue(this.hiddenColumns, (hiddenColumns: string[]) => { + this.gridDispatcher.loadDisplayedColumns( + columns.filter((x) => { + /* istanbul ignore next */ + /* sanity check */ + const id = x.id || x.field; + return hiddenColumns.indexOf(id) === -1; + }), + true + ); + }); + } else if (this.displayedColumns) { + /* istanbul ignore next */ + getValue(this.displayedColumns, (displayedColumns: string[]) => { + this.gridDispatcher.loadDisplayedColumns( + columns.filter( + (x) => displayedColumns.indexOf(x.id || x.field) !== -1 + ), + true + ); + }); + } else { + this.gridDispatcher.loadDisplayedColumns( + columns.filter((x) => !x.hidden), + true + ); + } + }); + + this.currentSearchText = this.state.pipe( + observableMap((s) => s.search.searchText), + distinctUntilChanged(), + takeUntil(this.ngUnsubscribe) + ); + + // REFACTORED: Use new dispatcher method + this.gridDispatcher.loadColumns(columnModels, true); + + this.handleColumnChange(); + + if (this.enableMultiselect) { + this.dispatcher.toolbarShowMultiselectToolbar(true); + } + + this.initInlineDeleteMessages(); + } + + public ngOnDestroy(): void { + this.ngUnsubscribe.next(); + this.ngUnsubscribe.complete(); + } + + /** + * If user makes selection, tell list-builder to update the list state. + * This logic should only run on user interaction - NOT programmatic updates. + */ + public onMultiselectSelectionChange( + event: SkyGridSelectedRowsModelChange + ): void { + if ( + event.source === SkyGridSelectedRowsSource.CheckboxChange || + event.source === SkyGridSelectedRowsSource.RowClick + ) { + this.state + .pipe( + observableMap((s) => s.items.items), + take(1) + ) + .subscribe((items: ListItemModel[]) => { + const newItemIds = this.arrayIntersection( + items.map((i) => i.id), + this.multiselectSelectedIds + ); + const newIds = items.filter((i) => i.isSelected).map((i) => i.id); + + // Check for deselected ids & send message to dispatcher. + const deselectedIds = this.arrayDiff(newItemIds, newIds); + if (deselectedIds.length > 0) { + this.dispatcher.setSelected(deselectedIds, false); + } + + // Check for selected ids & send message to dispatcher. + const selectedIds = this.arrayDiff(newIds, newItemIds); + if (selectedIds.length > 0) { + this.dispatcher.setSelected(selectedIds, true); + } + }); + } + } + + public columnIdsChanged(selectedColumnIds: string[]): void { + this.selectedColumnIds.pipe(take(1)).subscribe((currentIds) => { + if (!this.arraysEqual(selectedColumnIds, currentIds)) { + // REFACTORED: Use new state observable directly + this.gridState.columnItems$.pipe(take(1)).subscribe((columns) => { + const displayedColumns = selectedColumnIds.map( + (columnId) => columns.filter((c) => c.id === columnId)[0] + ); + // REFACTORED: Use new dispatcher method + this.gridDispatcher.loadDisplayedColumns(displayedColumns, true); + }); + } + }); + } + + public cancelRowDelete(args: SkyListViewGridRowDeleteCancelArgs): void { + this.rowDeleteCancel.emit(args); + } + + public confirmRowDelete(args: SkyListViewGridRowDeleteConfirmArgs): void { + this.rowDeleteConfirm.emit(args); + } + + public sortFieldChanged(sortField: ListSortFieldSelectorModel): void { + this.dispatcher.sortSetFieldSelectors([sortField]); + } + + public override onViewActive(): void { + // REFACTORED: No more scan operator needed - direct subscription to displayedColumnItems$ + this.gridState.displayedColumnItems$ + .pipe( + takeUntil(this.ngUnsubscribe), + distinctUntilChanged(this.arraysEqual) + ) + .subscribe((displayedColumns) => { + const setFunctions = + this.searchFunction !== undefined + ? [this.searchFunction] + : displayedColumns + .map( + (column) => + (data: any, searchText: string): any => + column.searchFunction( + getData(data, column.field), + searchText + ) + ) + .filter((c) => c !== undefined); + + this.state.pipe(take(1)).subscribe((s) => { + this.dispatcher.searchSetOptions( + new ListSearchModel({ + searchText: s.search.searchText, + functions: setFunctions, + fieldSelectors: displayedColumns.map((d) => d.field), + }) + ); + }); + }); + } + + private initInlineDeleteMessages(): void { + /* istanbul ignore next */ + if (this.messageStream) { + this.messageStream.subscribe((message: SkyListViewGridMessage) => { + if (message.type === SkyListViewGridMessageType.AbortDeleteRow) { + this.gridMessageStream.next({ + type: SkyGridMessageType.AbortDeleteRow, + data: { + abortDeleteRow: message.data.abortDeleteRow, + }, + }); + } else if ( + message.type === SkyListViewGridMessageType.PromptDeleteRow + ) { + this.gridMessageStream.next({ + type: SkyGridMessageType.PromptDeleteRow, + data: { + promptDeleteRow: message.data.promptDeleteRow, + }, + }); + } + }); + } + } + + private handleColumnChange(): void { + // watch for changes in column components + this.columnComponents.changes + .pipe(takeUntil(this.ngUnsubscribe)) + .subscribe(() => { + const columnModels = this.columnComponents.map((column) => { + return new SkyGridColumnModel(column.template, column); + }); + // REFACTORED: Use new dispatcher method + this.gridDispatcher.loadColumns(columnModels, true); + }); + + // Watch for column heading changes: + this.columnComponents.forEach((comp) => { + comp.headingModelChanges + .pipe(takeUntil(this.ngUnsubscribe)) + .subscribe((change: SkyGridColumnHeadingModelChange) => { + this.gridComponent.updateColumnHeading(change); + }); + comp.descriptionModelChanges + .pipe(takeUntil(this.ngUnsubscribe)) + .subscribe((change: SkyGridColumnDescriptionModelChange) => { + this.gridComponent.updateColumnDescription(change); + }); + }); + } + + /** + * REFACTORED: Removed scan operator workaround. + * The new state architecture ensures updates are always in order. + */ + private getGridItems(): Observable { + return this.state.pipe( + observableMap((s) => s.items.items), + distinctUntilChanged(), + takeUntil(this.ngUnsubscribe) + ); + } + + /** + * REFACTORED: Removed scan operator workaround. + * Direct subscription to displayedColumnItems$ from new state. + */ + private getSelectedIds(): Observable { + return this.gridState.displayedColumnItems$.pipe( + observableMap((columns: SkyGridColumnModel[]) => { + /* istanbul ignore next */ + /* sanity check */ + return columns.map((column) => { + return column.id || column.field; + }); + }), + distinctUntilChanged((previousValue: string[], newValue: string[]) => { + return this.haveColumnIdsChanged(previousValue, newValue); + }), + takeUntil(this.ngUnsubscribe) + ); + } + + private haveColumnIdsChanged( + previousValue: string[], + newValue: string[] + ): boolean { + if (previousValue.length !== newValue.length) { + this.selectedColumnIdsChange.emit(newValue); + return false; + } + + for (let i = 0; i < previousValue.length; i++) { + /* istanbul ignore if */ + if (previousValue[i] !== newValue[i]) { + this.selectedColumnIdsChange.emit(newValue); + return false; + } + } + return true; + } + + private selectedMapEqual( + prev: ListSelectedModel, + next: ListSelectedModel + ): boolean { + if (prev.selectedIdMap.size !== next.selectedIdMap.size) { + return false; + } + + const keys: string[] = []; + next.selectedIdMap.forEach((value, key) => { + keys.push(key); + }); + + for (const key of keys) { + const value = next.selectedIdMap.get(key); + if (value !== prev.selectedIdMap.get(key)) { + return false; + } + } + + return true; + } + + private arrayDiff(arrA: any[], arrB: any[]): any[] { + return arrA.filter((i) => arrB.indexOf(i) < 0); + } + + private arrayIntersection(arrA: any[], arrB: any[]): any[] { + return arrA.filter((value) => -1 !== arrB.indexOf(value)); + } + + private arraysEqual(arrayA: any[], arrayB: any[]): boolean { + return ( + arrayA.length === arrayB.length && + arrayA.every((value, index) => value === arrayB[index]) + ); + } +}