import { DecimalPipe } from '@angular/common'; import { Injectable } from '@angular/core'; import { CurrencyService } from '@core/services/currency.service'; import { FormMaskingService } from '@core/services/form-masking.service'; import { TimeZoneService } from '@core/services/time-zone.service'; import { AdHocReportingAPI } from '@core/typings/api/ad-hoc-reporting.typing'; import { ReferenceFieldAPI } from '@core/typings/api/reference-fields.typing'; import { TimeZone } from '@core/typings/api/time-zone.typing'; import { AdHocReportingUI } from '@core/typings/ui/ad-hoc-reporting.typing'; import { STANDARD_FIELDS_CATEGORY_ID } from '@core/typings/ui/reference-fields.typing'; import { ClientSettingsService } from '@features/client-settings/client-settings.service'; import { GCDashboards } from '@features/dashboards/dashboards.typing'; import { ReferenceFieldsService } from '@features/reference-fields/services/reference-fields.service'; import { APISortColumn, ArrayHelpersService, ColumnFilterRow, FilterColumn, FilterHelpersService, FilterModalTypes, GetNestedPipe, PaginationOptions, SelectOption, SimpleNumberMap, TableColumnDirective, TypeaheadSelectOption, ValueComparisonService } from '@yourcause/common'; import { I18nService } from '@yourcause/common/i18n'; import { isUndefined } from 'lodash'; import moment from 'moment'; import { AdHocReportingDefinitions, RootObjectNames } from './ad-hoc-reporting-definitions.service'; @Injectable({ providedIn: 'root' }) export class AdHocReportingMappingService { filterValueSplit = '<=>'; private getNested = new GetNestedPipe(); private decimal = new DecimalPipe('en-US'); constructor ( private currencyService: CurrencyService, private adHocReportingDefinitions: AdHocReportingDefinitions, private i18n: I18nService, private clientSettingsService: ClientSettingsService, private timeZoneService: TimeZoneService, private ahs: ArrayHelpersService, private referenceFieldService: ReferenceFieldsService, private formMaskingService: FormMaskingService, private filterHelperService: FilterHelpersService, private valueComparisonService: ValueComparisonService ) { } getBlankFilter (definition: AdHocReportingUI.ColumnDefinition): FilterColumn { return { columnName: `${definition.parentBucket}.${definition.column}`, filters: [] }; } mapColumnsToColumnDirective (columns: AdHocReportingUI.ColumnImplementation[]) { return columns.map(col => { const { definition } = col; if (!col.tableColumn) { this.mapColToDirective(definition, col); } else { col.tableColumn.ycTableColumnOverrideVisible = col.visibleInReport; } return col.tableColumn; }); } private mapColToDirective ( definition: AdHocReportingUI.ColumnDefinition, col: AdHocReportingUI.ColumnImplementation ) { const dir = new TableColumnDirective(null, null); dir.ycTableColumnLabelOnly = true; dir.ycTableColumn = this.getColumnLabel(definition); dir.ycTableColumnClass = definition.class; dir.ycTableColumnProp = definition.parentBucket + '.' + definition.column; dir.ycTableColumnFilterType = definition.type; dir.ycTableColumnOverrideVisible = col.visibleInReport; dir.ycTableColumnNoFiltering = col.definition.noFiltering; if (definition.type === 'typeaheadSingleEquals') { dir.ycTableColumnFilterType = 'list'; } if (definition.type === 'currency') { dir.ycTableColumnFilterType = 'number'; } if ('filterOptions' in definition) { dir.ycTableColumnOptions = definition.filterOptions; } col.tableColumn = dir; } getColumnLabel (definition: AdHocReportingUI.ColumnDefinition): string { return definition.display + ' (' + definition.parentBucketName + ')'; } mapApiToColumnFilters ( column: AdHocReportingAPI.UserSavedReportColumn ): FilterColumn { return { columnName: column.columnName, filters: column.userSavedFilterColumns.map(filterColumn => { if (filterColumn.filterType === FilterModalTypes.between) { filterColumn.filterValue = (filterColumn.filterValue as string).split( this.filterValueSplit).map(val => moment(val) ) as any; } return filterColumn; }) }; } addReferenceFieldMapToReportingRow ( initialRow: AdHocReportingUI.ReportResponseRow ) { const referenceFieldDetailMap: AdHocReportingUI.ReferenceFieldResponseMap = {}; const referenceFieldValueMap = (initialRow.referenceFields || []) .reduce>((acc, current) => { referenceFieldDetailMap[current.referenceFieldKey] = current; if (!!current.currencyValue) { return { ...acc, [current.referenceFieldKey + '.currencyValue']: current.currencyValue, [current.referenceFieldKey]: current.value }; } else { return { ...acc, [current.referenceFieldKey]: current.value }; } }, {}); const row: AdHocReportingUI.AdaptedReportResponseRow = { ...initialRow, referenceFieldValueMap, referenceFieldDetailMap }; return row; } /** * * @param def The column definition, used for formatting data * @param initialRow Used for the value of the report cell * @param masked Determines the visibility of the data */ getValueForColumnFromRow ( def: AdHocReportingUI.ColumnDefinition, initialRow: AdHocReportingUI.ReportResponseRow, masked: boolean ) { const adaptedRow = this.addReferenceFieldMapToReportingRow(initialRow); const value = this.isRefFieldColumn(def.parentBucket) ? adaptedRow.referenceFieldValueMap[def.column] : this.getNested.transform(adaptedRow, { key: def.parentBucket as RootObjectNames, subKey: def.column }); return this.getFormattedDisplayValue( value, def, adaptedRow, masked, false ); } /** * * @param value The value of the record * @param def Used for formatting the record * @param row Contains additional data for formatting and reference field info * @param masked Determines visibility of the data * @param displayNoneForNoValue Shows 'None' if no data */ getFormattedDisplayValue ( value: any, def: AdHocReportingUI.ColumnDefinition, row: AdHocReportingUI.AdaptedReportResponseRow, masked: boolean, displayNoneForNoValue = false ) { const field = this.referenceFieldService.getReferenceFieldByKey(def.column); const parentBucket = field ? 'referenceFieldValueMap' : def.parentBucket; if (masked && field?.isMasked) { return this.formMaskingService.formFieldMask; } // Multiple value answers are stored as a separated list // so format it for display let separator = ','; if (field) { separator = this.referenceFieldService.getSupportsMultipleSeparator( field ); } if ('filterOptions' in def) { if (def.format === 'label') { let foundLabel: string; const options: (TypeaheadSelectOption|SelectOption)[] = def.filterOptions; if ( value && (field?.supportsMultiple || def.type === 'multiValueList') ) { foundLabel = value.split(separator).map((val: string) => { return this.getSelectedOption(val, options); }).filter((val: string) => !!val).join(', '); } else { foundLabel = this.getSelectedOption(value, options); } if (foundLabel) { return foundLabel; } } } else if (value) { if (field && (def as AdHocReportingUI.FileColumnDefinition).format === 'file') { return this.adaptFilesForDisplay( row, field ); } else if (field?.supportsMultiple) { value = value.split(separator).join(', '); } } const noValue = isUndefined(value) || value === null; if (noValue) { if (displayNoneForNoValue) { return this.i18n.translate('common:textNone', {}, 'None'); } else if (def.defaultDisplay || def.defaultI18nKey) { if (def.defaultI18nKey) { return this.i18n.translate( def.defaultI18nKey, {}, def.defaultDisplay ); } return def.defaultDisplay; } } switch (def.type) { case 'boolean': if (noValue) { return ''; } if (typeof(value) === 'string') { const string = value; if (value.length && !isNaN(+string)) { value = +value; } else { return ''; } } return this.i18n.translate( value ? 'common:textYes' : 'common:textNo' ); case 'date': const timezoneID = this.clientSettingsService.clientSettings ? this.clientSettingsService.clientSettings.defaultTimezone || 'UTC' : 'UTC'; const timezone = this.timeZoneService.returnTimeZoneFromID(timezoneID); return value ? this.returnDateOrDateTime(value, def, timezone) : ''; case 'currency': if (def.format === 'text') { return value; } else { return this.currencyService.formatMoney( value, def.format === 'otherColumn' ? row[parentBucket as RootObjectNames][def.formatSource] : undefined ); } case 'number': if (def.format === 'id') { return value; } else if (def.format === 'percent') { const percentVal = '' + ((value || 0) * 100); return parseFloat(percentVal).toFixed(2) + '%'; } else if (def.format === 'wholeNumberPercent') { const percentVal = '' + (value || 0); return parseFloat(percentVal).toFixed(2) + '%'; } else { return this.valueComparisonService.isNumber(value) ? this.decimal.transform(value) : value; } } return value; } adaptFilesForDisplay ( row: AdHocReportingUI.AdaptedReportResponseRow, field: ReferenceFieldAPI.ReferenceFieldDisplayModel ) { const detail = row.referenceFieldDetailMap[field.key]; const files = field.supportsMultiple ? detail.files : [detail.file]; const adapted = this.referenceFieldService.getFilesFromApplicationRefFieldResponse( files, +row.application.id, +row.form?.applicationFormId ); return adapted.map((file) => { return file.fileUrl; }).join(', '); } getSelectedOption ( value: any, options: (TypeaheadSelectOption|SelectOption)[] ) { const foundOption = options.find((option) => { return option.value === value || +option.value === +value; }); return foundOption?.label; } mapColumnToTableFilter ( column: AdHocReportingUI.ColumnImplementation ): ColumnFilterRow[] { if (!column.tableColumn) { const { definition } = column; this.mapColToDirective(definition, column); } return this.filterHelperService.adaptColumnToColumnFilterRow( column.tableColumn.forApiFilter(), column.filterColumn.filters ); } // Make sure column implementation has between filters stored ensureBetweenFiltersAccurate ( columns: AdHocReportingUI.ColumnImplementation[], paginationOptions: PaginationOptions ) { const filterColumns = paginationOptions.filterColumns; columns.forEach(column => { const foundFilterColumns = filterColumns.filter(filterColumn => ( this.getFullColumnName(column.definition) === filterColumn.columnName )); const columnHasFilters = foundFilterColumns.length > 0; if (!columnHasFilters) { column.filterColumn.filters = []; } else { column.filterColumn = { columnName: column.definition.column, filters: foundFilterColumns.reduce((acc, val) => { return [ ...acc, ...val.filters ]; }, []) }; } if (column.definition.type === 'date') { const afterFilterColumn = foundFilterColumns .find(filterColumn => filterColumn.filters .some(filter => filter.filterType === FilterModalTypes.greaterThan) ); const beforeFilterColumn = foundFilterColumns .find(filterColumn => filterColumn.filters .some(filter => filter.filterType === FilterModalTypes.lessThan) ); if ( afterFilterColumn && beforeFilterColumn ) { column.filterColumn = { columnName: column.definition.column, filters: [{ filterType: FilterModalTypes.between, filterValue: [ afterFilterColumn.filters[0].filterValue as any, beforeFilterColumn.filters[0].filterValue ] }] }; } } }); } isRefFieldColumn (columnName: string) { return columnName.includes('category.') || columnName.includes('referenceFields.'); } getRefFieldByColumnName (columnName: string) { if (this.isRefFieldColumn(columnName)) { const columnNameParts = columnName.split('.'); const refFieldKey = columnNameParts[2] || columnNameParts[1]; // For Standard Product Fields, there is no category (name parts will be one fewer) const refField = this.referenceFieldService.getReferenceFieldByKey(refFieldKey); return refField; } return null; } getFullColumnName (columnDefinition: AdHocReportingUI.ColumnDefinition) { return columnDefinition.parentBucket + '.' + columnDefinition.column; } adaptFormColumnNameForApi (columnName: string) { if (this.isRefFieldColumn(columnName)) { const refField = this.getRefFieldByColumnName(columnName); if (refField.isStandardProductField) { return `referenceFields.${refField.key}`; } return columnName.replace(/category\.\d+\./, 'referenceFields.'); } return columnName; } adaptFormColumnNameForView (columnName: string) { if (columnName.includes('referenceFields.')) { const additionalKeyString = this.additionalKeyString(columnName); const key = columnName.split('.')[1]; const field = this.referenceFieldService.getReferenceFieldByKey(key); if (field) { const categoryId = field.isStandardProductField ? STANDARD_FIELDS_CATEGORY_ID : field.categoryId; const adaptedKey = key + additionalKeyString; return `category.${categoryId}.${adaptedKey}`; } else { // If the field is not found, that means it was removed return null; } } return columnName; } additionalKeyString (columnName: string) { const addCurrency = columnName.includes('currencyValue'); const additionalKeyString = '.currencyValue'; return addCurrency ? additionalKeyString : ''; } mapColumnsForPagination ( columns: AdHocReportingUI.ColumnImplementation[], paginationOptions: PaginationOptions, rowsPerPage = 10 ): PaginationOptions { this.ensureBetweenFiltersAccurate(columns, paginationOptions); let sortColumns: APISortColumn[] = []; if (paginationOptions.sortColumns.length) { sortColumns = paginationOptions.sortColumns.map((col) => { return { ...col, columnName: this.adaptFormColumnNameForApi( col.columnName ) }; }); } else { sortColumns = columns.filter((column) => { return column.sortType !== AdHocReportingAPI.SortTypes.NoSort; }).map((col) => { return { sortAscending: col.sortType === AdHocReportingAPI.SortTypes.Ascending, columnName: this.adaptFormColumnNameForApi( col.definition.parentBucket + '.' + col.definition.column ) }; }); } const options = { ...paginationOptions, rowsPerPage, filterColumns: paginationOptions.filterColumns.map(column => { if (this.isRefFieldColumn(column.columnName)) { column = { ...column, columnName: this.adaptFormColumnNameForApi(column.columnName) }; } return column; }), sortColumns }; if (!options.sortColumns.length) { const column = columns[0]; options.sortColumns = [{ sortAscending: true, columnName: this.adaptFormColumnNameForApi( column.definition.parentBucket + '.' + column.definition.column ) }]; } return options; } mapColumnsToApi ( columns: AdHocReportingUI.ColumnImplementation[] ) { return columns.map((column, index) => { const columnName = `${column.definition.parentBucket}.${column.definition.column}`; const refField = this.getRefFieldByColumnName(columnName); return { referenceFieldId: refField?.referenceFieldId, referenceFieldTableId: column.definition.referenceFieldTableId, columnName: this.adaptFormColumnNameForApi(columnName), sortType: column.sortType, sortPriority: index + 1, userSavedFilterColumns: this.mapColumnFiltersToApiFilters( column.filterColumn ), isVisible: column.visibleInReport, displayName: column.columnNameOverride, isChartAggregate: false, isChartGroupingColumn: false, isChartSubGroupingColumn: false, chartAggregateType: null }; }); } mapColumnFiltersToApiFilters ( columnFilters: FilterColumn ) { return columnFilters.filters .map(columnFilter => ({ filterType: columnFilter.filterType, filterValue: columnFilter.filterValue instanceof Array ? columnFilter.filterValue.join(this.filterValueSplit) : columnFilter.filterValue as any })); } returnDateOrDateTime ( value: any, def: AdHocReportingUI.DateColumnDefinition, timezone: TimeZone ) { if (def.format === 'date') { // when we format as date we ignore time (and timezone) because clients are often looking // for the scheduled date const momentReturn = this.timeZoneService.returnMidnightUTCDateShort(value); return momentReturn; } else { return moment( this.timeZoneService.returnDateForMgmtDisplay(value, timezone.offset)).format('lll'); } } mapSimpleColumnToColumnImplementation ( simpleCols: GCDashboards.SimpleColumn[], buckets: AdHocReportingUI.ColumnBucket[] ): AdHocReportingUI.ColumnImplementation[] { const allColumns = buckets.reduce((acc, bucket) => { return [ ...acc, ...bucket.allColumns ]; }, [] as AdHocReportingUI.ColumnImplementation[]); return simpleCols.map((col) => { const found = allColumns.find((bucketCol) => { return `${bucketCol.definition.parentBucket}.${bucketCol.definition.column}` === col.column; }); return { ...found, columnNameOverride: col.columnNameOverride }; }); } mapReportColumnToColumnImplementation ( userSavedReportColumns: AdHocReportingAPI.UserSavedReportColumn[], rootObject: AdHocReportingUI.RootObject, relatedObjects: (AdHocReportingUI.RootObject|AdHocReportingUI.RelatedObject)[], buckets: AdHocReportingUI.ColumnBucket[] ) { return this.ahs.sort(userSavedReportColumns, 'sortPriority') .map((column) => { const columnName = this.adaptFormColumnNameForView(column.columnName); if (columnName) { column.columnName = columnName; const useRoot = this.propertyIsSubstringOfColumn( column.columnName, rootObject.property ); const foundObj = useRoot ? rootObject : relatedObjects.find((relatedObj) => { return this.propertyIsSubstringOfColumn( column.columnName, relatedObj.property ); }); if (!foundObj) { // this usually means a form was removed return undefined; } const relatedBucket = buckets.find((bucket) => { return bucket.property === foundObj.property; }); const foundColumn = relatedBucket.columns .find(relatedColumn => { return column.columnName === this.getFullColumnName(relatedColumn.definition); }); if (!foundColumn) { // this usually means a form component was removed from the form return undefined; } return { ...foundColumn, columnNameOverride: column.displayName, filterColumn: this.mapApiToColumnFilters(column), visibleInReport: column.isVisible, sortType: column.sortType }; } else { // Reference field no longer exists return undefined; } }).filter((item) => !!item); } private propertyIsSubstringOfColumn ( column: string, property: string ): boolean { if ( column.startsWith('category.') && !property.startsWith('category.') ) { return false; } const columnParts = column.split('.'); return property.split('.').every((relatedPart) => { return columnParts.includes(relatedPart); }); } getCategoryBuckets ( recordIds: number[], componentMap: SimpleNumberMap, usage = AdHocReportingUI.Usage.AD_HOC, rootObject?: AdHocReportingUI.RootObject ): AdHocReportingUI.RootObject[] { const categoryMap = this.referenceFieldService.getCategoryMapFromRecordIds( recordIds, componentMap, usage, rootObject ); return Object.keys(categoryMap).map((categoryId) => { return this.getCategoryObject(categoryId, categoryMap[categoryId]); }); } getCategoryObject ( categoryId: number|string, fields: ReferenceFieldAPI.ReferenceFieldAdHocModel[] ): AdHocReportingUI.RootObject { return { ...this.adHocReportingDefinitions.customForm, property: `category.${categoryId}`, display: categoryId === STANDARD_FIELDS_CATEGORY_ID ? this.i18n.translate( 'common:textStandardFields', {}, 'Standard fields' ) : this.referenceFieldService.categoryNameMap[categoryId], i18nKey: null, columns: fields.reduce((acc, field) => { return [ ...acc, ...this.referenceFieldService.getReferenceFieldColumnDef( field ) ]; }, []) }; } }