import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; import { Component, ElementRef, EventEmitter, HostListener, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, ViewChild } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { FormMaskingService } from '@core/services/form-masking.service'; import { LocationService } from '@core/services/location.service'; import { SpinnerService } from '@core/services/spinner.service'; import { AdHocReportingAPI } from '@core/typings/api/ad-hoc-reporting.typing'; import { AdHocReportingUI } from '@core/typings/ui/ad-hoc-reporting.typing'; import { BudgetService } from '@features/budgets/budget.service'; import { AdvancedFiltersModalComponent } from '@features/reporting/pages/advanced-filters-modal/advanced-filters-modal.component'; import { UpdateHeaderColumnModalComponent } from '@features/reporting/pages/update-header-column-modal/update-header-column-modal.component'; import { AdHocReportingDefinitions, RootObjectNames } from '@features/reporting/services/ad-hoc-reporting-definitions.service'; import { AdHocReportingMappingService } from '@features/reporting/services/ad-hoc-reporting-mapping.service'; import { AccordionStyle, AdvancedFilterGroup, APIResult, AutoTableRepository, AutoTableRepositoryFactory, BulkAction, Column, ColumnFilterRow, Columns, DebounceFactory, FilterBuilderService, PaginationOptions, SimpleDebounceFactory, SwitchState, TableDownloadDetails, TypeSafeFormBuilder, TypeSafeFormGroup, ValueComparisonService } from '@yourcause/common'; import { I18nService } from '@yourcause/common/i18n'; import { ConfirmationModalComponent, ModalFactory } from '@yourcause/common/modals'; import { NotifierService } from '@yourcause/common/notifier'; import { AdHocReportingService } from '../../services/ad-hoc-reporting.service'; @Component({ selector: 'gc-report-builder', templateUrl: './ad-hoc-report-builder.component.html', styleUrls: ['./ad-hoc-report-builder.component.scss'] }) export class AdHocReportBuilderComponent implements OnInit, OnChanges, OnDestroy { @Input() objectName: RootObjectNames; @Input() columns: AdHocReportingUI.ColumnImplementation[]; @Input() get recordIds (): number[] { return this._recordIds; } set recordIds (value: number[]) { if (this._recordIds !== value) { this._recordIds = value; this.formsChanged(value); } } @Input() reportDetails: AdHocReportingAPI.UserSavedReportDetail; @Input() mode: AdHocReportingUI.AdHocBuilderComponentMode; @Input() filterGroups: AdvancedFilterGroup[] = []; @Input() isStandardTemplate: boolean; @Input() filtersUsingAnd: SwitchState; @Input() endpoint: string; @Output() columnsChange = new EventEmitter(); @Output() onToggleViewMode = new EventEmitter<{ toViewMode: boolean; route: string; }>(); // When view mode is toggled from edit, we need to save the report so no data is lost and the read only report has the latest columns. @Output() filterGroupsChange = new EventEmitter(); @Output() filtersUsingAndChange = new EventEmitter(); @ViewChild('builderWrapper') builderWrapper: ElementRef; @ViewChild('bucketWrapper') bucketWrapper: ElementRef; @ViewChild('accordionWrapper') accordionWrapper: ElementRef; columnBulkActions: BulkAction[] = [{ exec: (columns: Columns) => { columns.forEach(column => { const reportColumn = this.columns.find(_column => { return this.adHocReportingMapper.getFullColumnName( _column.definition ) === column.prop; }); this.removeColumnFromReport(reportColumn); }); this.modalFactory.dismissAllOpen(); }, label: this.i18n.translate( 'MANAGE:textRemoveColumnFromReport', {}, 'Remove column from report' ) }]; id = this.activatedRoute.snapshot.params.id; private _recordIds: number[]; SortTypes = AdHocReportingAPI.SortTypes; AccessTypes = AdHocReportingAPI.AdHocReportAccessType; buckets: AdHocReportingUI.ColumnBucket[]; originalBuckets: AdHocReportingUI.ColumnBucket[]; filteredBuckets: AdHocReportingUI.ColumnBucket[]; accordionType = AccordionStyle.Compact; searchTerm: string; columnNameFilter: string; filteredColumns: AdHocReportingUI.ColumnImplementation[]; filterFormGroup: TypeSafeFormGroup<{ searchTerm: string }> = this.formBuilder.group({ searchTerm: '' }); repository: AutoTableRepository; usedColumnsId = 'used-columns'; removeColumnsId = 'remove-columns'; potentiallyRemoving = false; overRemoveZone = false; removedColumns: AdHocReportingUI.ColumnImplementation[] = []; filterBadgeValue: number; detailLinkTooltipText: string; showBudgetDetailLink = false; showFsDetailLink = false; showMaskToggle = false; masked = false; relatedFieldsPromptTracker: { [x: number]: boolean; } = {}; constructor ( private i18n: I18nService, private filterBuilderService: FilterBuilderService, private spinnerService: SpinnerService, private valueComparisonService: ValueComparisonService, private notifier: NotifierService, private repositoryFactory: AutoTableRepositoryFactory, private adHocReportingService: AdHocReportingService, private adHocReportingMapper: AdHocReportingMappingService, private adHocReportingDefinitions: AdHocReportingDefinitions, private formBuilder: TypeSafeFormBuilder, private activatedRoute: ActivatedRoute, private modalFactory: ModalFactory, private budgetService: BudgetService, private formMaskingService: FormMaskingService, private locationService: LocationService ) { } get isViewMode () { return this.mode === AdHocReportingUI.AdHocBuilderComponentMode.VIEW; } get isScaffold () { return this.mode === AdHocReportingUI.AdHocBuilderComponentMode.SCAFFOLD; } get isEditMode () { return this.mode === AdHocReportingUI.AdHocBuilderComponentMode.EDIT; } @HostListener('window:scroll') onScroll () { if ( !this.isViewMode && this.builderWrapper.nativeElement && this.accordionWrapper.nativeElement ) { const topThreshold = 30; const bottomThreshold = 60 + topThreshold; const tableRect = this.builderWrapper.nativeElement.getBoundingClientRect(); const bucketRect = this.accordionWrapper.nativeElement.getBoundingClientRect(); const tableTop = tableRect.top; const bucketTop = bucketRect.top; const bucketBottom = window.innerHeight - bucketRect.bottom; const tableAboveTop = tableTop < topThreshold; const bucketBelowBottom = bucketBottom < bottomThreshold; const bucketAboveTop = bucketTop < topThreshold; if (tableAboveTop && bucketBelowBottom) { const difference = -(tableTop - topThreshold); this.builderWrapper.nativeElement.style.paddingTop = `${difference}px`; } else if (!bucketAboveTop) { this.builderWrapper.nativeElement.style.paddingTop = ''; } } } ngOnInit () { this.spinnerService.startSpinner(); this.setBuckets(); const columns = this.adHocReportingMapper.mapColumnsToColumnDirective(this.columns); const filterColumns = this.isViewMode || this.isScaffold ? [] : this.mapColumnsToFilterColumns(); this.repository = this.repositoryFactory.create({ columnsReorderable: true, columns, tableDataFactory: this.getFactory(), tableDownloadFactory: this.getTableDownloadFactory(), rows: (this.isScaffold || this.isEditMode) ? [] : undefined, filterColumns, key: 'AD_HOC_REPORTING', notifier: this.notifier, rowsPerPage: 10, valueComparisonService: this.valueComparisonService, debounceDuration: 0, skipAddToState: true }); this.updateBadgeNumber(); this.setDetailLinkInfo(); this.setSupportsMasking(); this.repository.reset(0); this.spinnerService.stopSpinner(); } setSupportsMasking () { const maskResult = this.formMaskingService.checkShouldShowMaskToggleForAdHoc(this.columns); this.showMaskToggle = maskResult.shouldShow; this.masked = maskResult.defaultSetting; if (this.showMaskToggle) { this.masked = true; } } toggleMask () { this.masked = !this.masked; } setDetailLinkInfo () { const info = this.adHocReportingService.getDetailLinkInfo(this.objectName); this.showBudgetDetailLink = info.showBudgetDetailLink; this.showFsDetailLink = info.showFsDetailLink; this.detailLinkTooltipText = info.detailLinkTooltipText; } ngOnChanges (changes: SimpleChanges) { if (changes.columns && this.repository) { this.repository.columns = this.adHocReportingMapper.mapColumnsToColumnDirective( this.columns ); if (!this.isViewMode) { this.repository.filterColumns = this.mapColumnsToFilterColumns(); } this.repository.reset(); } } setBuckets () { this.buckets = this.adHocReportingService.getBuckets( this.objectName, this.recordIds, this.columns ); this.setFilteredBuckets(); } getUniqueColumnKey (def: AdHocReportingUI.ColumnDefinition) { return `${def.parentBucket}.${def.column}`; } getUsedColumns () { return this.columns.map((col) => { return this.getUniqueColumnKey(col.definition); }); } setFilteredBuckets () { const usedColumns = this.getUsedColumns(); this.filteredBuckets = this.buckets.map(bucket => { return { ...bucket, columns: bucket.columns.filter(column => { const notAlreadyUsed = !usedColumns.includes( this.getUniqueColumnKey(column.definition) ); return notAlreadyUsed; }) }; }); } setFilteredColumns () { this.filteredColumns = this.filteredBuckets .reduce((acc, bucket) => { return [ ...acc, ...bucket.columns.filter(({ definition }) => { return definition.display.toLowerCase().includes( this.searchTerm?.toLowerCase() ); }) ]; }, []); } private formsChanged (value: number[]) { if (this.objectName && this.columns && value) { this.setBuckets(); } } private mapColumnsToFilterColumns (): ColumnFilterRow[] { return this.columns.reduce[]>((acc, column) => [ ...acc, ...this.adHocReportingMapper.mapColumnToTableFilter(column) ], []); } handleColumnOptionChange () { this.columns = this.repository.uiFilterColumns.map(uiFilterCol => { const foundColumn = this.columns.find(({ definition }) => { const name = definition.parentBucket + '.' + definition.column; return uiFilterCol.columnName === name; }); foundColumn.visibleInReport = uiFilterCol.visible; return foundColumn; }); this.cleanupColumnChange(); } toggleSort (column: AdHocReportingUI.ColumnImplementation) { (column.sortType === this.SortTypes.Descending) ? column.sortType = this.SortTypes.NoSort : ++column.sortType; this.repository.reset(); } async toggleViewMode () { const route = `/management/reporting/ad-hoc/${ this.isViewMode ? 'edit' : 'view' }/${this.reportDetails.id}`; this.onToggleViewMode.emit({ toViewMode: !this.isViewMode, route }); } async openAdvancedFilters () { const groups = this.filterGroups.map((group) => { group.filters = group.filters.map((filter) => { const foundCol = this.repository.uiFilterColumns.find((col) => { return col.columnName === filter?.column?.columnName; }); if (foundCol) { filter.column = foundCol; } return filter; }); return group; }); const result = await this.modalFactory.open( AdvancedFiltersModalComponent, { columns: this.repository.uiFilterColumns, groups, globalAnd: this.filtersUsingAnd } ); if (result) { this.filterGroups = result.groups; this.filtersUsingAnd = result.useAnd; this.filterGroupsChange.emit(this.filterGroups); this.filtersUsingAndChange.emit(result.useAnd); this.repository.reset(); this.updateBadgeNumber(); } } updateBadgeNumber () { this.filterBadgeValue = this.filterBuilderService.getBadgeNumber(this.filterGroups); } startMouseOver () { this.overRemoveZone = true; } stopMouseOver () { this.overRemoveZone = true; } startDragReportColumn () { this.potentiallyRemoving = true; } stopDragReportColumn () { this.potentiallyRemoving = false; } async drop (event: CdkDragDrop) { const { isPointerOverContainer } = event; const targetIsColumns = isPointerOverContainer && (event.container.id === this.usedColumnsId); const sourceIsColumns = event.previousContainer.id === this.usedColumnsId; if (targetIsColumns && sourceIsColumns) { // rearranging report columns moveItemInArray( event.container.data, event.previousIndex, event.currentIndex ); } else if (targetIsColumns && !sourceIsColumns) { // adding to report const addedColumnsArray = await this.returnOneOrAllRelatedColumns( event.item.data.columns ?? [event.item.data] ); this.columns = [ ...this.columns.slice(0, event.currentIndex), ...addedColumnsArray, ...this.columns.slice(event.currentIndex) ]; const repoColumns = this.adHocReportingMapper.mapColumnsToColumnDirective( this.columns ); this.repository.columns = repoColumns; this.repository.reset(); } else if (sourceIsColumns && !targetIsColumns) { // removing from report const removedColumn: AdHocReportingUI.ColumnImplementation = event.item.data; this.removeColumnFromReport(removedColumn); } else { return; } this.cleanupColumnChange(); } private cleanupColumnChange () { this.buckets = [ ...this.buckets ]; this.columnsChange.emit(this.columns); this.repository.setUiFilterColumns( this.adHocReportingMapper.mapColumnsToColumnDirective(this.columns) ); this.repository.setFilterColumns( this.mapColumnsToFilterColumns() ); window.getSelection().removeAllRanges(); this.setFilteredBuckets(); this.setFilteredColumns(); } private removeColumnFromReport ( removedColumn: AdHocReportingUI.ColumnImplementation ) { removedColumn.filterColumn = this.adHocReportingMapper.getBlankFilter( removedColumn.definition ); const foundIndex = this.columns.findIndex((col) => { return this.adHocReportingMapper.getFullColumnName(col.definition) === this.adHocReportingMapper.getFullColumnName(removedColumn.definition); }); if (foundIndex > -1) { this.columns = [ ...this.columns.slice(0, foundIndex), ...this.columns.slice(foundIndex + 1) ]; this.filterGroups = this.filterBuilderService.remapColumns( this.columns.map(col => col.tableColumn.forApiFilter()), this.filterGroups, true ); this.cleanupColumnChange(); } this.updateBadgeNumber(); if (this.columns.length === 0) { this.repository.results = []; this.repository.totalRecords = 0; } } private getFactory (): SimpleDebounceFactory, APIResult> { if (this.isScaffold || this.isEditMode) { return undefined; } let isViewMode = this.isViewMode; if (this.isStandardTemplate) { isViewMode = false; } return DebounceFactory.createSimple( (paginationOptions: PaginationOptions) => { if (this.columns && this.columns.length) { return this.adHocReportingService.prepareAndGetReportRows( this.reportDetails.id, paginationOptions, this.columns, this.objectName, this.filterGroups, this.filtersUsingAnd, this.recordIds, this.reportDetails.primaryFormId, this.masked, isViewMode, this.reportDetails.referenceFieldTableId ); } return { data: { records: [], recordCount: 0 }, success: true }; }, 0); } getTableDownloadFactory () { if (this.isScaffold || this.isEditMode) { return null; } return DebounceFactory.createSimple(async (details: TableDownloadDetails) => { if (this.columns && this.columns.length) { this.spinnerService.startSpinner(); const res = await this.adHocReportingService.downloadAdHocReport( details.options, this.objectName, this.reportDetails.id, this.columns, this.filterGroups, this.filtersUsingAnd, this.reportDetails.name, details.format ); this.spinnerService.stopSpinner(); return res; } return null; }, 0); } async updateColumnHeaderModal ( column: AdHocReportingUI.ColumnImplementation ) { const newName = await this.modalFactory.open( UpdateHeaderColumnModalComponent, { name: column.columnNameOverride, defaultName: column.definition.display } ); if (newName) { column.columnNameOverride = newName; this.columnsChange.emit(this.columns); } } navigateToDetail (row: Record>) { if (this.showBudgetDetailLink || this.showFsDetailLink) { const isBudget = this.showBudgetDetailLink; const path = this.budgetService.getUrlForDetail( isBudget, isBudget ? +row.budget.id : +row.fundingSource.id ); window.open( this.locationService.getRelativeUrl(path) ); } else { this.locationService.navigateToApp(row, this.objectName); } } async returnOneOrAllRelatedColumns ( columnsArray: AdHocReportingUI.ColumnImplementation[] ): Promise { if (columnsArray && columnsArray.length === 1) { const columnAdded = columnsArray[0]; const columnAddedRefTableFieldId = columnAdded.definition.referenceFieldTableId; if (columnAddedRefTableFieldId && columnAdded.definition.parentTableDisplay) { const remainingColumns = this.buckets.reduce((acc, curr) => { return [ ...acc, ...curr.columns ]; }, []); // check the remaining columns for related fields const relatedColumns = remainingColumns.filter((column) => { return column.definition && column.definition.referenceFieldTableId === columnAddedRefTableFieldId; }); if (relatedColumns && !this.relatedFieldsPromptTracker[columnAddedRefTableFieldId]) { // ask the user if they want to add const deps = { confirmButtonText: this.i18n.translate( 'common:linkAdd', {}, 'Add' ), confirmText: this.i18n.translate( 'reporting:textDoYouWantToAddRelatedFields', { relatedField: columnAdded.definition.parentTableDisplay }, 'Do you want to add the other fields related to __relatedField__?' ), modalHeader: this.i18n.translate( 'reporting:textAddRelatedFields', {}, 'Add related fields' ) }; const addRelatedFields = await this.modalFactory.open(ConfirmationModalComponent, deps); if (addRelatedFields) { return relatedColumns; } else { this.relatedFieldsPromptTracker[columnAdded.definition.referenceFieldTableId] = true; return columnsArray; } }; // this array includes the column added return columnsArray; } return columnsArray; } return columnsArray; } ngOnDestroy () { this.adHocReportingDefinitions.reset(); } }