diff --git a/libs/components/list-builder-view-grids/src/index.ts b/libs/components/list-builder-view-grids/src/index.ts index e81c016100..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'; @@ -20,6 +21,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'; @@ -29,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]) + ); + } +} 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(); + } +}