import { Injectable } from '@angular/core'; import { PolicyService } from '@core/services/policy.service'; import { AdHocReportingAPI } from '@core/typings/api/ad-hoc-reporting.typing'; import { AdHocReportingUI } from '@core/typings/ui/ad-hoc-reporting.typing'; import { ReferenceFieldsUI, STANDARD_FIELDS_CATEGORY_ID } from '@core/typings/ui/reference-fields.typing'; import { environment } from '@environment'; import { AudienceMember, ManageAudienceMember } from '@features/audience/audience.typing'; import { BaseApplicationForLogic, FormDefinitionForUi } from '@features/configure-forms/form.typing'; import { FormsResolver } from '@features/configure-forms/resolvers/forms.resolver'; import { CustomDataTablesService } from '@features/custom-data-tables/custom-data-table.service'; import { ComponentHelperService } from '@features/formio/services/component-helper/component-helper.service'; import { LogicColumn, LogicColumnDisplay, LogicFilterTypes } from '@features/logic-builder/logic-builder.typing'; import { ReferenceFieldsService } from '@features/reference-fields/services/reference-fields.service'; import { AdvancedFilterGroup, APIResult, ArrayHelpersService, BetweenFilterOption, Column, ColumnFilterRow, DateService, FileService, FilterColumn, FilterHelpersService, FilterModalTypes, MultiValueListOptions, OneOfFilterOption, PaginatedResponse, PaginationOptions, SelectOption, SimpleNumberMap, SwitchState, TableDataDownloadFormat, TypeaheadSelectOption } from '@yourcause/common'; import { I18nService } from '@yourcause/common/i18n'; import { LogService } from '@yourcause/common/logging'; import { ConfirmationModalComponent, ModalFactory } from '@yourcause/common/modals'; import { NotifierService } from '@yourcause/common/notifier'; import { AttachYCState, BaseYCService } from '@yourcause/common/state'; import { isUndefined, uniq } from 'lodash'; import moment, { isMoment, Moment } from 'moment'; import { ManageReportFormData } from '../pages/manage-ad-hoc-report-modal/manage-ad-hoc-report-modal.component'; import { AdHocReportingDefinitions, RelatedObjectNames, RootObjectNames } from './ad-hoc-reporting-definitions.service'; import { AdHocReportingMappingService } from './ad-hoc-reporting-mapping.service'; import { AdHocReportingState } from './ad-hoc-reporting.state'; import { ReportingResources } from './reporting.resources'; @Injectable({ providedIn: 'root' }) @AttachYCState(AdHocReportingState) export class AdHocReportingService extends BaseYCService { constructor ( private logger: LogService, private fileService: FileService, private i18n: I18nService, private ahs: ArrayHelpersService, private formsResolver: FormsResolver, private reportingResources: ReportingResources, private adHocReportingMapper: AdHocReportingMappingService, private adHocReportingDefinitions: AdHocReportingDefinitions, private notifier: NotifierService, private dateService: DateService, private referenceFieldService: ReferenceFieldsService, private customDataTableService: CustomDataTablesService, private policyService: PolicyService, private filterHelperService: FilterHelpersService, private modalFactory: ModalFactory, private componentHelper: ComponentHelperService ) { super(); } get reports () { return this.get('reports'); } get MAX_COLUMNS () { return environment.maxAdHocReportColumns; } get reportDetails () { return this.get('reportDetails'); } get applicationTokenGroups () { return this.get('applicationTokenGroups'); } get formComponentMap () { return this.get('formComponentMap'); } get tableComponentMap () { return this.get('tableComponentMap'); } async prepareAdHocReportDetail ( reportId: number, standardReportTemplate?: AdHocReportingAPI.UserSavedReportForUi ) { await this.resolveModalDeps(); await this.fetchReportById(reportId, standardReportTemplate); const recordIds = this.getRecordIdsFromReportId(reportId); await this.resolveFormInfo( recordIds, this.adHocReportingDefinitions[ this.getReportById(reportId).object ] ); } adaptAdvancedAPIFiltersToUIGroups ( filterGroups: AdHocReportingAPI.AdvancedUserSavedFilterColumn[][], columns: AdHocReportingUI.ColumnDefinition[] ): AdvancedFilterGroup[] { const columnDefMap: Record = {}; const adaptedFilterGroups = filterGroups.map((filterGroup) => { return filterGroup.map((filter) => { const columnName = this.adHocReportingMapper.adaptFormColumnNameForView( filter.columnName ); if (columnName) { return { ...filter, columnName }; } else { return undefined; } }).filter((item) => !!item); }); return adaptedFilterGroups.map((filterGroup) => { // group filters so that we can combine "One of" filters below const subChunked = filterGroup.reduce< AdHocReportingAPI.AdvancedUserSavedFilterColumn[][] >((acc, filter, index) => { const foundColumn = columns.find(column => { const potentialColumn = this.adHocReportingMapper.getFullColumnName(column); return potentialColumn === filter.columnName; }); if (foundColumn) { columnDefMap[filter.columnName] = foundColumn; const previousFilter = filterGroup[index - 1]; if (previousFilter?.columnName === filter.columnName) { // if the previous filter in the sequence is for the same column // is a logical "OR" // and is an equals // and supports "One of" // we assume it's part of the same "One of" filter if ( previousFilter.filterType === FilterModalTypes.equals && OneOfFilterOption.types.includes(foundColumn.type) ) { let previousChunk = acc.pop(); // removes the last item from "acc" and returns it previousChunk = [ ...previousChunk, { ...filter } ]; return [ ...acc, previousChunk ]; } // if the previous filter in the sequence is an "AND" // and is a greater than // and supports between // and this filter is a less than // we assume they are both part of the same between filter if ( previousFilter.useLogicalOperatorAnd && (previousFilter.filterType === FilterModalTypes.greaterThan) && BetweenFilterOption.types.includes(foundColumn.type) ) { let previousChunk = acc.pop(); // removes the last item from "acc" and returns it previousChunk = [ ...previousChunk, { ...filter } ]; return [ ...acc, previousChunk ]; } const isMultiFilter = MultiValueListOptions.some(option => { return option.types.includes(foundColumn.type); }); // if this filter is a "multi" filter // and the previous chunk is of the same type // AND is either // includes with a previous of AND // or a not includes with a previous AND // or is equal/not equal TODO: make these work // combine them into one UI filter const isIncludes = filter.filterType === FilterModalTypes.multiValueIncludes; const isNotIncludes = filter.filterType === FilterModalTypes.multiValueNotIncludes; const shouldCombine = ((isIncludes || isNotIncludes) && previousFilter.useLogicalOperatorAnd) || (!isNotIncludes && !isIncludes); if ( isMultiFilter && shouldCombine && previousFilter.filterType === filter.filterType ) { let previousChunk = acc.pop(); // removes the last item from "acc" and returns it previousChunk = [ ...previousChunk, { ...filter } ]; return [ ...acc, previousChunk ]; } } return [ ...acc, [filter] ]; } return [ ...acc ]; }, []); return { useAnd: this.everyConditionIsOR(filterGroup) ? SwitchState.Untoggled : SwitchState.Toggled, filters: subChunked.map((filterChunk) => { const [filter] = filterChunk; const foundColumn = columnDefMap[filter.columnName]; const label = this.adHocReportingMapper.getColumnLabel(foundColumn); const groupFilter: Column = { label: this.adHocReportingMapper.getColumnLabel(foundColumn), columnName: this.adHocReportingMapper.getFullColumnName(foundColumn), i18nKey: label, // this is on purpose, the builder handles the translation visible: true, type: null, filterOnly: false }; // find the multi option that was selected for this filter const selectedMultiOption = MultiValueListOptions.find(option => { return option.types.includes(foundColumn.type) && option.api === filter.filterType; }); // if this filter has a multi option if (selectedMultiOption) { // default to the raw values (for text fields) let values = filter.filterValues; // use type assertion ('filterOptions' in foundColumn) // get the options selected and filter out any that are no longer relevant if ('filterOptions' in foundColumn) { const options = foundColumn.filterOptions.filter((option) => { return filterChunk.some((chunk) => { return chunk.filterValues.includes('' + option.value); }); }); values = options.map(option => option.value); } return { column: groupFilter, filter: selectedMultiOption, value: values }; } const isOneOf = OneOfFilterOption.types.includes(foundColumn.type) && filter.filterType === FilterModalTypes.equals; if (isOneOf && 'filterOptions' in foundColumn) { const options = foundColumn.filterOptions.filter(option => { return filterChunk.some(filterValue => { return ('' + filterValue.filterValue) === ('' + option.value); }); }); return { column: groupFilter, filter: OneOfFilterOption, value: options.map(option => option.value) }; } if (filter.filterType === 'bt') { return { column: groupFilter, filter: BetweenFilterOption, value: ('' + filter.filterValue) .split('<=>').map(subValue => moment(subValue)) }; } // we assume it's an "in between" filter if // there are two filters in this chunk // the first filter is a greater than // and the second filter is a less than const isBetween = filterChunk.length === 2 && filterChunk[0].filterType === FilterModalTypes.greaterThan && filterChunk[1].filterType === FilterModalTypes.lessThan; if (isBetween) { let values = [ filterChunk[0].filterValue, filterChunk[1].filterValue ]; // map dates to moment instances if (foundColumn.type === 'date') { values = [ moment(values[0] as any) as any, moment(values[1] as any) as any ]; } return { column: groupFilter, filter: BetweenFilterOption, value: values }; } const { value, filterOption } = this.filterHelperService.getFilterOptionAndValue( filter, foundColumn.type ); return { column: groupFilter, filter: filterOption, value }; }) }; }); } everyConditionIsOR (conditions: any[]) { return conditions.every((condition) => { return !condition.useLogicalOperatorAnd; }); } adaptAdvancedUIFiltersToPaginationAPI ( groups: AdvancedFilterGroup[] ): AdHocReportingAPI.PaginationAdvancedFilterColumnModel[][] { return groups.map((group) => { return group.filters.reduce((acc, filter) => { return [ ...acc, ...this.adaptAdvancedFiltersToAPI( filter, group ).map(advancedFilter => { return { columnName: advancedFilter.columnName, filter: { filterType: advancedFilter.filterType, filterValue: advancedFilter.filterValue, filterValues: advancedFilter.filterValues, useLogicalOperatorAnd: advancedFilter.useLogicalOperatorAnd } }; }) ]; }, []); }); } adaptAdvancedUIFiltersToReportAPI ( groups: AdvancedFilterGroup[] ): AdHocReportingAPI.AdvancedUserSavedFilterColumn[][] { return groups.map((group) => { return group.filters.reduce((acc, filter) => { return [ ...acc, ...this.adaptAdvancedFiltersToAPI(filter, group) ]; }, []); }); } adaptAdvancedFiltersToAPI ( filter: ColumnFilterRow, group: AdvancedFilterGroup ): AdHocReportingAPI.AdvancedUserSavedFilterColumn[] { const returnVal: AdHocReportingAPI.AdvancedUserSavedFilterColumn = { columnName: this.adHocReportingMapper.adaptFormColumnNameForApi(filter.column.columnName ?? filter.column.prop), filterType: filter.filter.api, filterValue: filter.value ?? '', useLogicalOperatorAnd: group.useAnd === SwitchState.Toggled, filterValues: [] }; if (MultiValueListOptions.includes(filter.filter)) { const filterValues: string[] = filter.value instanceof Array ? filter.value.map((value) => '' + value) : ['' + filter.value]; return [{ ...returnVal, filterValue: null, filterValues }]; } else if (filter.filter === OneOfFilterOption) { const filterValue: any[] = filter.value; return filterValue.map((value, index) => { return { ...returnVal, filterValue: value, useLogicalOperatorAnd: index === (filterValue.length - 1) ? returnVal.useLogicalOperatorAnd : false }; }); } else if (filter.filter === BetweenFilterOption) { const filterValue: [Moment, Moment]|[number, number] = filter.value; let values: [string, string]; if (isMoment(filterValue[0]) && isMoment(filterValue[1])) { values = [ filterValue[0].toISOString(), filterValue[1].toISOString() ]; } else { values = [ '' + filterValue[0], '' + filterValue[1] ]; } // between translates to greater than the first value // "AND" less than the second value return [ { columnName: returnVal.columnName, filterType: FilterModalTypes.greaterThan, filterValue: values[0], filterValues: [], useLogicalOperatorAnd: true }, { columnName: returnVal.columnName, filterType: FilterModalTypes.lessThan, filterValue: values[1], filterValues: [], useLogicalOperatorAnd: returnVal.useLogicalOperatorAnd } ]; } return [returnVal]; } resetReportDetails (id: number) { return this.set('reportDetails', { ...this.reportDetails, [id]: undefined }); } getObjectByReportModelType ( reportType: AdHocReportingAPI.AdHocReportModelType ): RootObjectNames { switch (reportType) { case AdHocReportingAPI.AdHocReportModelType.Application: return 'application'; case AdHocReportingAPI.AdHocReportModelType.ApplicationForm: return 'customForm'; case AdHocReportingAPI.AdHocReportModelType.Award: return 'award'; case AdHocReportingAPI.AdHocReportModelType.Payment: return 'payment'; case AdHocReportingAPI.AdHocReportModelType.ApplicationInKindAmountRequested: return 'applicationInKind'; case AdHocReportingAPI.AdHocReportModelType.InKindAwardItems: return 'awardInKind'; case AdHocReportingAPI.AdHocReportModelType.InKindPaymentItems: return 'paymentInKind'; case AdHocReportingAPI.AdHocReportModelType.Budgets: return 'budgets'; case AdHocReportingAPI.AdHocReportModelType.FundingSources: return 'fundingSources'; case AdHocReportingAPI.AdHocReportModelType.TodaysDate: return 'todaysDate'; case AdHocReportingAPI.AdHocReportModelType.Table: return 'table'; case AdHocReportingAPI.AdHocReportModelType.FieldGroup: return 'fieldGroup'; } } /** * * @param report the modal response that comes back from ManageAdHocReportModal * @returns the recordIds associated with this report */ getRecordIdsFromModalResponse (report: ManageReportFormData) { const formIds = this.getFormIdsForAPI(report.forms, report.primaryFormId); let recordIds = formIds; if (report.object === 'table') { const tableField = this.referenceFieldService.referenceFieldMap[ report.referenceFieldTableKey ]; recordIds = tableField ? [tableField.referenceFieldId] : []; } return recordIds; } /** * * @param reportId reportId * @returns the recordIds associated with this report */ getRecordIdsFromReportId (reportId: number): number[] { const detail = this.get('reportDetails')[reportId]; const isTable = detail?.reportModelType === AdHocReportingAPI.AdHocReportModelType.Table; let recordIds: number[] = []; if (isTable) { const tableField = this.referenceFieldService.referenceFieldMap[ detail.referenceFieldTableKey ]; recordIds = tableField ? [tableField.referenceFieldId] : []; } else { recordIds = detail?.forms.map((form) => form.id) ?? []; } return recordIds; } getComponentMap (property: RootObjectNames) { const isTableReport = property === 'table'; return isTableReport ? this.tableComponentMap : this.formComponentMap; } /** * * @param property Root object name, the property that the report is based on * @param recordIds This will be either form IDs or a referenceFieldTableId for fetching table components * @param usedColumns Columns already in use on report * @param usage Whether this is Ad Hoc, Charts, or Tokens */ getBuckets ( property: RootObjectNames, recordIds: number[], usedColumns?: AdHocReportingUI.ColumnImplementation[], usage = AdHocReportingUI.Usage.AD_HOC ): AdHocReportingUI.ColumnBucket[] { // if table report, we need to send those components instead const rootObject = this.adHocReportingDefinitions[property]; const categoryBuckets = this.adHocReportingMapper.getCategoryBuckets( recordIds, this.getComponentMap(property), usage, rootObject ); const related = rootObject.relatedObjects .map(key => key as RelatedObjectNames) .map((obj: RelatedObjectNames) => { return this.adHocReportingDefinitions[obj]; }).concat( rootObject.omitRootObject ? [] : [rootObject] ).concat( categoryBuckets ); const relatedObjects = related.filter((obj) => { if (usage !== AdHocReportingUI.Usage.TOKENS) { return !obj.tokenInsertOnly; } return true; }); const returnVal = relatedObjects.map((relatedObj) => { const columns = relatedObj.columns.filter((column) => { if (column.dashboardOnly) { return usage === AdHocReportingUI.Usage.DASHBOARDS; } return true; }).map(column => { column.parentBucketName = column.parentBucketName || (relatedObj.i18nKey ? this.i18n.translate(relatedObj.i18nKey, {}, relatedObj.display) : relatedObj.display); column.parentBucket = relatedObj.property; column.display = column.i18nKey ? this.i18n.translate(column.i18nKey, {}, column.display) : column.display; return column; }).map(definition => ({ sortType: AdHocReportingAPI.SortTypes.NoSort, visibleInReport: true, filterColumn: this.adHocReportingMapper.getBlankFilter(definition), columnNameOverride: '', definition })); return { property: relatedObj.property, display: relatedObj.display, i18nKey: relatedObj.i18nKey, allColumns: columns, columns: this.ahs.sort( columns.filter(column => { return usedColumns ? !usedColumns.some(imp => imp.definition === column.definition) : true; }), ['definition', 'display'] ).map((column) => { const definition = column.definition; if ('filterOptions' in definition) { (definition.filterOptions || []).map((option: any) => { return { label: option.label || option.display, value: option.value, hidden: option.hidden }; }); } return column; }) }; }); return this.ahs.sort(returnVal .filter((val, index) => returnVal .findIndex(ret => ret.property === val.property) === index ), 'display' ); // remove dupes } async saveReportDetails ( report: ManageReportFormData, columns?: AdHocReportingUI.ColumnImplementation[], groups?: AdvancedFilterGroup[], useAnd?: SwitchState, id?: number ) { try { const formIds = this.getFormIdsForAPI(report.forms, report.primaryFormId); await this.resolveFormInfo( this.getRecordIdsFromModalResponse(report), this.adHocReportingDefinitions[report.object] ); let referenceFieldTableId: number; if (report.object === 'table') { referenceFieldTableId = this.referenceFieldService.referenceFieldMap[ report.referenceFieldTableKey ]?.referenceFieldTableId; } const adapted: AdHocReportingAPI.CreateReportPayload|AdHocReportingAPI.UpdateReportPayload = { id, formIds, primaryFormId: report.primaryFormId, name: report.reportName, description: report.reportDescription, userSavedReportColumns: this.adHocReportingMapper.mapColumnsToApi( columns || [] ), referenceFieldTableId, reportType: AdHocReportingAPI.AdHocReportType.AdHoc, reportModelType: this.adHocReportingDefinitions[report.object].type, reportUsers: [], advancedFilterColumns: this.adaptAdvancedUIFiltersToReportAPI(groups ?? []), useLogicalOperatorAnd: useAnd === SwitchState.Toggled }; id = await this.reportingResources[id ? 'updateReport' : 'createReport']( adapted ); await this.fetchReportById(id); await this.fetchReports(); this.notifier.success(this.i18n.translate( 'common:notificationSavedAdHocReport', {}, 'Successfully updated this report' )); return id; } catch (e) { this.logger.error(e); this.notifier.error(this.i18n.translate( 'common:notificationErrorUpdatingTheReport', {}, 'There was an error updating the report' )); return null; } } getFormIdsForAPI ( formIds: number[] = [], primaryFormId: number ): number[] { if (primaryFormId) { // Make sure primary form gets into formIds array formIds = [ ...formIds, primaryFormId ]; } return formIds; } /** * @param recordIds Can be formId or tableId based on scenario * @param rootObject Object that report is based on */ async resolveFormInfo ( recordIds: number[], rootObject: AdHocReportingUI.RootObject ) { let componentMap: SimpleNumberMap = {}; if (recordIds.length > 0) { if (rootObject.property === 'table') { await this.fetchTableComponents(recordIds[0]); componentMap = this.tableComponentMap; } else { await Promise.all(recordIds.map((recordId) => { return this.fetchFormComponents(recordId); })); componentMap = this.formComponentMap; } const categoryMap = this.referenceFieldService.getCategoryMapFromRecordIds( recordIds, componentMap, AdHocReportingUI.Usage.AD_HOC, rootObject ); await this.customDataTableService.setOptionsListFromCategoryMap( categoryMap, recordIds, rootObject ); } } async handleCreateReportFromTemplate ( report: ManageReportFormData ): Promise { try { const formIds = this.getFormIdsForAPI(report.forms, report.primaryFormId); const recordIds = this.getRecordIdsFromModalResponse(report); await this.resolveFormInfo( recordIds, this.adHocReportingDefinitions[report.object] ); const template = report.startFromTemplate; const copy: AdHocReportingAPI.CreateReportPayload = { formIds, userSavedReportColumns: template.userSavedReportColumns, description: report.reportDescription, name: report.reportName, othersMayModify: template.othersMayModify, othersMayView: template.othersMayView, reportType: AdHocReportingAPI.AdHocReportType.AdHoc, reportModelType: this.adHocReportingDefinitions[report.object].type, reportUsers: [], referenceFieldTableId: this.referenceFieldService.referenceFieldMap[ report.referenceFieldTableKey ]?.referenceFieldTableId, advancedFilterColumns: template.advancedFilterColumns, useLogicalOperatorAnd: template.useLogicalOperatorAnd, primaryFormId: report.primaryFormId }; const reportId = await this.reportingResources.createReport(copy); await this.fetchReports(); this.notifier.success(this.i18n.translate( 'common:textSuccessCreatingReport', {}, 'Successfully created the report' )); return reportId; } catch (e) { this.logger.error(e); this.notifier.error(this.i18n.translate( 'common:textErrorCreatingReport', {}, 'There was an error creating the report' )); return null; } } async handleCopyReport (id: number) { try { const report = await this.reportingResources.getReportById(id); const reportUsers = await this.reportingResources.getSharedUsersForReport(id); const copy: AdHocReportingAPI.CreateReportPayload = { formIds: report.forms.map(form => form.id), userSavedReportColumns: report.userSavedReportColumns, description: report.description, name: report.name + ' - Copy', othersMayModify: report.othersMayModify, othersMayView: report.othersMayView, reportType: AdHocReportingAPI.AdHocReportType.AdHoc, reportModelType: report.reportModelType, reportUsers, referenceFieldTableId: report.referenceFieldTableId, advancedFilterColumns: report.advancedFilterColumns, useLogicalOperatorAnd: report.useLogicalOperatorAnd, primaryFormId: report.primaryFormId }; const reportId = await this.reportingResources.createReport(copy); if (report.hasSchedule) { const schedule = await this.reportingResources.getScheduleForReport(id); await this.handleScheduleReport( { showMaskedData: schedule.showMaskedData, reportId, sftpId: schedule.sftpId, audienceId: schedule.audienceId, oneOffUsers: [], frequencyType: schedule.frequencyType, hour: schedule.hour, day: schedule.day, frequencyValue: schedule.frequencyValue, expirationHours: schedule.expirationHours, failureDeliveryUsers: schedule.failureDeliveryUsers, exportFileTypeId: schedule.exportFileTypeId }, false, true ); } await this.fetchReports(); this.notifier.success(this.i18n.translate( 'reporting:notificationSuccessReportCopy', { name: report.name }, 'Successfully copied report __name__' )); } catch (e) { this.logger.error(e); this.notifier.error(this.i18n.translate( 'reporting:notificationErrorReportCopy', {}, 'There was an error copying the report' )); } } async fetchReports () { const reports = await this.reportingResources.getReports(); this.set('reports', reports .filter(report => report.reportType === AdHocReportingAPI.AdHocReportType.AdHoc )); } async fetchReportById ( id: number, standardReportTemplate?: AdHocReportingAPI.UserSavedReportForUi ) { try { let report: AdHocReportingAPI.UserSavedReport; if (!standardReportTemplate) { report = await this.reportingResources.getReportById(+id); } else { report = standardReportTemplate; } if ( !standardReportTemplate && ( report.isUserOwnedReport || report.accessType === AdHocReportingAPI.AdHocReportAccessType.MANAGE ) ) { const [ users, schedule ] = await Promise.all([ this.reportingResources.getSharedUsersForReport(id), this.reportingResources.getScheduleForReport(id) ]); this.set('reportDetails', { ...this.get('reportDetails'), [id]: { ...report, users, schedule: this.adaptScheduleForUI(schedule) } }); } else { this.set('reportDetails', { ...this.get('reportDetails'), [id]: { ...report, users: [], schedule: null } }); } return true; } catch (e) { this.logger.error(e); this.notifier.error(this.i18n.translate( 'reporting:textErrorFetchingReportDetails', {}, 'There was an error fetching report details' )); return false; } } adaptScheduleForUI ( schedule: AdHocReportingAPI.ReportScheduleFromApi ): AdHocReportingUI.AdHocReportSchedule { if (!schedule) { return null; } return { sftpId: schedule.sftpId, audienceId: schedule.audienceId, users: schedule.audienceUsers.concat(schedule.oneOffUsers), frequency: schedule.frequencyType, hours: this.dateService.convert24HourTimeToHour(schedule.hour), week: schedule.frequencyValue, month: schedule.frequencyValue, dayOfMonth: schedule.day, dayOfWeek: schedule.day, showMaskedData: schedule.showMaskedData, isAM: schedule.hour <= 11, expirationHours: schedule.expirationHours, exportFileTypeId: schedule.exportFileTypeId }; } async saveSharedUsersForReport ( id: number, users: ManageAudienceMember[]|AudienceMember[] ) { try { await this.reportingResources.saveSharedUsersForReport({ id, users }); await this.fetchReports(); this.resetReportDetails(id); this.notifier.success(this.i18n.translate( 'reporting:textSuccessfullySavedUsersForReport', {}, 'Successfully updated user permissions for report' )); } catch (e) { this.logger.error(e); this.notifier.error(this.i18n.translate( 'reporting:textErrorSavingUsersForReport', {}, 'There was an error updating user permissions for report' )); } } async deleteReport (id: number) { await this.reportingResources.removeReportById(id); await this.fetchReports(); } async resolveModalDeps () { await Promise.all([ this.formsResolver.resolve() ]); } getEndpointByObjectName ( property: RootObjectNames, attr: 'readOnlyEndpoint'|'editEndpoint'|'chartEndpoint' ) { return this.adHocReportingDefinitions[property][attr]; } getReportById (id: number): ManageReportFormData { const existingReport = this.get('reportDetails')[id]; return { reportDescription: existingReport.description, reportName: existingReport.name, forms: existingReport.forms.map(form => form.id), program: null, object: this.getObjectByReportModelType(existingReport.reportModelType), primaryFormId: existingReport.primaryFormId, referenceFieldTableKey: existingReport.referenceFieldTableKey, startFromTemplate: null }; } getReportColumnsById ( id: number, existingReport: AdHocReportingAPI.UserSavedReport = this.get('reportDetails')[id] ) { const property: RootObjectNames = this.getObjectByReportModelType( existingReport.reportModelType ); const recordIds = this.getRecordIdsFromReportId(id); const componentMap = this.getComponentMap(property); const buckets = this.getBuckets(property, recordIds); const rootObject = this.adHocReportingDefinitions[property]; const categoryBuckets = this.adHocReportingMapper.getCategoryBuckets( recordIds, componentMap, AdHocReportingUI.Usage.AD_HOC, rootObject ); const relatedObjects = rootObject.relatedObjects .map(objName => objName as RelatedObjectNames) .map((obj: RelatedObjectNames) => this.adHocReportingDefinitions[obj]) .concat(categoryBuckets); return this.adHocReportingMapper.mapReportColumnToColumnImplementation( existingReport.userSavedReportColumns, rootObject, relatedObjects, buckets ); } prepareAndGetReportRows ( reportId: number, paginationOptions: PaginationOptions, columns: AdHocReportingUI.ColumnImplementation[], objectName: RootObjectNames, filterGroups: AdvancedFilterGroup[], filtersUsingAnd: SwitchState, formIds: number[], primaryFormId: number, isMasked: boolean, isViewMode = false, referenceFieldTableId: number ): Promise> { paginationOptions = this.adHocReportingMapper.mapColumnsForPagination( columns, paginationOptions ); const endpoint = this.getEndpointByObjectName( objectName, isViewMode ? 'readOnlyEndpoint' : 'editEndpoint' ); const referenceFieldIds = this.extractRefFieldIds( columns ); const columnsForApi = this.adHocReportingMapper.mapColumnsToApi( columns ); const advancedFilters = this.adaptAdvancedUIFiltersToPaginationAPI( filterGroups ); const advancedPaginationOptions: AdHocReportingAPI.AdvancedPaginationOptionsModel = { ...paginationOptions, filterColumns: [], advancedFilterColumns: advancedFilters, useLogicalOperatorAnd: filtersUsingAnd === SwitchState.Toggled }; return this.getReportRows( advancedPaginationOptions, endpoint, formIds, [], columnsForApi, isViewMode ? reportId : null, primaryFormId, referenceFieldIds, referenceFieldTableId ); } async downloadAdHocReport ( paginationOptions: PaginationOptions, objectName: RootObjectNames, reportIdForViewOnly: number, columns: AdHocReportingUI.ColumnImplementation[], filterGroups: AdvancedFilterGroup[], filtersUsingAnd: SwitchState, reportName: string, format: TableDataDownloadFormat ) { if (paginationOptions) { paginationOptions = this.adHocReportingMapper.mapColumnsForPagination( columns, paginationOptions ); } const advancedFilters = this.adaptAdvancedUIFiltersToPaginationAPI( filterGroups ); const advancedPaginationOptions: AdHocReportingAPI.AdvancedPaginationOptionsModel = { ...paginationOptions, filterColumns: [], advancedFilterColumns: advancedFilters, useLogicalOperatorAnd: filtersUsingAnd === SwitchState.Toggled }; const endpoint = this.getEndpointByObjectName( objectName, 'readOnlyEndpoint' ); const data = await this.getReadOnlyReport( advancedPaginationOptions, endpoint, reportIdForViewOnly, format ); const extension = format === TableDataDownloadFormat.XLSX ? '.xlsx' : 'csv'; await this.fileService.downloadUrlAs(data, `${reportName}.${extension}`); } async getReadOnlyReport ( paginationOptions: AdHocReportingAPI.AdvancedPaginationOptionsModel, endpoint: string, reportIdForViewOnly: number, fileExportTypeId: TableDataDownloadFormat ) { try { const data = await this.reportingResources.getReadOnlyReport( paginationOptions, endpoint, reportIdForViewOnly, fileExportTypeId ); return data; } catch (e) { this.logger.error(e); this.notifier.error( this.i18n.translate( 'common:textErrorGettingReportData', {}, 'There was an error getting report data' ) ); } } async getReportRows ( paginationOptions: AdHocReportingAPI.AdvancedPaginationOptionsModel, endpoint: string, formIds: number[], formDataFields: string[], columnsForApi: AdHocReportingAPI.UserSavedReportColumn[], reportIdForViewOnly: number, // only pass if view only baseFormId: number, referenceFieldIds: number[] = [], referenceFieldTableId: number ) { const data = await this.reportingResources.getReportRows( paginationOptions, endpoint, formIds, formDataFields, baseFormId, columnsForApi, reportIdForViewOnly, referenceFieldIds, referenceFieldTableId, null ); return { success: true, data } as APIResult; } processCsv ( data: PaginatedResponse, columns: AdHocReportingUI.ColumnImplementation[], isMasked: boolean ) { const visibleColumns = columns.filter((column) => { return column.visibleInReport; }); let records = data.records; if (records.length === 0) { records = [{}]; } const adapted = records.map((record) => { const returnVal = visibleColumns.reduce((acc, column) => { let header = column.columnNameOverride || `${column.definition.display} (${ column.definition.parentBucketName })`; if (!isUndefined((acc as any)[header])) { let isOverride = true; let count = 0; while (isOverride) { count++; header = header + ' (' + count + ')'; if (!(acc as any)[header]) { isOverride = false; } } } return { ...acc, [header]: this.adHocReportingMapper.getValueForColumnFromRow( column.definition, record, isMasked ) }; }, {}); return returnVal; }); return this.fileService.convertObjectArrayToCSVString(adapted); } async fetchTableComponents (referenceFieldTableId: number) { const tableColumns = await this.referenceFieldService.setColumnsForTable( referenceFieldTableId ); const tableComps = tableColumns.map((column) => { const refField = this.referenceFieldService.referenceFieldMapById[ column.referenceFieldId ]; return { key: refField.key, type: refField.type, label: refField.name, referenceFieldKey: refField.key }; }); this.set('tableComponentMap', { ...this.tableComponentMap, [referenceFieldTableId]: tableComps }); } async handleSubsetComponents ( components: AdHocReportingAPI.FormComponentSummary[] ) { const dataPointComps: AdHocReportingUI.FormComponentSummary[] = []; await Promise.all( components.map(async (component) => { // get the ref field related to each comp const refField = this.referenceFieldService.getReferenceFieldFromCompType(component.type); // if it's a subset field get the subset rows if (refField?.type === ReferenceFieldsUI.ReferenceFieldTypes.Subset) { const subsetRows = await this.referenceFieldService.setDataPointsForSubset( refField.referenceFieldId ); // for each of the rows, push component info to list of comps being added to form comp map subsetRows.forEach((row) => { dataPointComps.push({ key: 'referenceFields-' + row.referenceField.key, type: row.referenceField.type, label: row.referenceField.name, referenceFieldKey: row.referenceField.key, referenceFieldTableId: refField.referenceFieldTableId }); }); } }) ); return dataPointComps; } async fetchFormComponents (formId: number) { const components = await this.reportingResources.getFormComponents(formId); const dataPointcomps = await this.handleSubsetComponents(components); const compsToAdd = [ ...components, ...dataPointcomps ]; const formComponentMap = { ...this.get('formComponentMap'), [formId]: compsToAdd .filter((comp) => { return !this.componentHelper.isLayoutComponent(comp.type); }) }; this.setFormComponentMap(formComponentMap); } setFormComponentMap ( formComponentMap: SimpleNumberMap ) { this.set('formComponentMap', formComponentMap); } resetFormComponentMap (formId: number) { this.set('formComponentMap', { ...this.get('formComponentMap'), [formId]: undefined }); } private checkColumnIsReferenceField (column: string) { return column.includes('category.') || column.includes('referenceFields.'); } getReportfieldBuckets (): AdHocReportingUI.ColumnBucket[] { const bucketsFromService = this.getBuckets('application', [], []); const buckets = bucketsFromService.filter((bucket) => { return bucket.columns.some((column) => { return column.definition.canBeUsedAsReportField; }); }); return buckets; } getReportFieldColumnsByBucket ( buckets: AdHocReportingUI.ColumnBucket[], bucketProp: string ) { const bucketInUse = buckets.find((bucket) => bucket.property === bucketProp); return bucketInUse.columns.filter( (column) => { return column.definition.canBeUsedAsReportField; }); } extractRefFieldIds ( columns: AdHocReportingUI.ColumnImplementation[], filterColumns: FilterColumn[] = [], userSavedReportColumns: AdHocReportingAPI.UserSavedReportColumn[] = [] ): number[] { const columnRefFields = columns.filter((col) => { return this.checkColumnIsReferenceField(col.definition.parentBucket); }).map(col => { return col.definition.column; }); const filterRefFields = filterColumns.filter((col) => { return this.checkColumnIsReferenceField(col.columnName); }).map((col) => { const split = col.columnName.split('.'); return split[split.length - 1]; }); const userSavedRefFields = userSavedReportColumns.filter((col) => { return this.checkColumnIsReferenceField(col.columnName); }).map(col => { const split = col.columnName.split('.'); return split[split.length - 1]; }); const keys = uniq([ ...columnRefFields, ...filterRefFields, ...userSavedRefFields ]); return this.referenceFieldService.convertRefFieldKeysToIds(keys); } async handleSendReport (payload: AdHocReportingAPI.SendReportPayload) { try { await this.reportingResources.sendReport(payload); this.notifier.success(this.i18n.translate( 'reporting:textSuccessSendingReport', {}, 'Successfully sent the report' )); await this.fetchReports(); } catch (e) { this.logger.error(e); this.notifier.error(this.i18n.translate( 'reporting:textErrorSendingReport', {}, 'There was an error sending the report' )); } } async handleDeleteScheduleReport ( reportId: number ) { try { await this.reportingResources.deleteScheduledReport(reportId); await this.fetchReports(); this.notifier.success( this.i18n.translate( 'reporting:textSuccessfullyDeletedReportSchedule', {}, 'Successfully deleted report schedule' ) ); } catch (e) { this.notifier.error( this.i18n.translate( 'reporting:textUnableToDeleteReportSchedule', {}, 'There was an error deleting this report schedule' ) ); } } async handleScheduleReport ( payload: AdHocReportingAPI.ScheduleReportPayload, isUpdate = true, skipToastr = false ) { try { const endpoint = isUpdate ? 'updateScheduledReport' : 'addScheduledReport'; await this.reportingResources[endpoint](payload); if (!skipToastr) { this.notifier.success(this.i18n.translate( isUpdate ? 'reporting:textSuccessUpdateScheduledReport' : 'reporting:textSuccessCreateScheduledReport', {}, isUpdate ? 'Successfully updated the scheduled report' : 'Successfully created the scheduled report' )); } await this.fetchReports(); } catch (e) { this.logger.error(e); if (!skipToastr) { this.notifier.error(this.i18n.translate( isUpdate ? 'reporting:textErrorUpdateScheduledReport' : 'reporting:textErrorCreateScheduledReport', {}, isUpdate ? 'There was an error updating the scheduled report' : 'There was an error creating the scheduled report' )); } } } setApplicationTokenGroups (usage: AdHocReportingUI.Usage) { if (!this.applicationTokenGroups) { const buckets = this.getBuckets( 'application', [], [], usage ); const refFieldBuckets = this.referenceFieldService.getCategoryTokenGroups( 'referenceFields', usage ); const groups = buckets.map((bucket) => { return { groupDisplay: bucket.display, tokens: bucket.columns.map((column) => { const def = column.definition; let adaptedColumnName; // standard fields should have the category of referenceFields if (column.definition.parentBucket === 'category.' + STANDARD_FIELDS_CATEGORY_ID) { adaptedColumnName = this.adHocReportingMapper.adaptFormColumnNameForApi(column.filterColumn?.columnName); } return { display: def.display, value: adaptedColumnName ?? `${def.parentBucket}.${def.column}` }; }) }; }); const allTokenGroups = [ ...groups, ...refFieldBuckets ]; this.set('applicationTokenGroups', allTokenGroups); } } getDetailLinkInfo (objectName: RootObjectNames) { let showBudgetDetailLink = false; let showFsDetailLink = false; let detailLinkTooltipText = ''; if (objectName === 'budgets') { showBudgetDetailLink = this.policyService.system.canManageBudgets(); detailLinkTooltipText = this.i18n.translate( 'BUDGET:textViewBudget', {}, 'View budget' ); } else if (objectName === 'fundingSources') { showFsDetailLink = this.policyService.system.canManageFundingSources(); detailLinkTooltipText = this.i18n.translate( 'BUDGET:textViewFundingSource', {}, 'View funding source' ); } else { detailLinkTooltipText = this.i18n.translate( 'common:textViewApplication', {}, 'View application' ); } return { showBudgetDetailLink, showFsDetailLink, detailLinkTooltipText }; } isColumnHeaderValid (columnName: string) { if (!columnName) { return true; } const characterRegex = new RegExp(/^[A-Za-z0-9\(\)_\.,/'#%:& \"\-\?]+$/); return characterRegex.test(columnName); } getInvalidColumnHeaders (columnNames: string[]) { return columnNames.filter((name) => { return !this.isColumnHeaderValid(name); }); } async openInvalidColumnHeadersModal ( invalidColumnHeaders: string[] ) { const confirmText = this.getInvalidColumnHeadersText(invalidColumnHeaders); await this.modalFactory.open( ConfirmationModalComponent, { modalHeader: this.i18n.translate( 'common:textInvalidColumnHeaders', {}, 'Invalid Column Header(s)' ), confirmButtonText: this.i18n.translate( 'CONFIG:textProceedToChanges', {}, 'Proceed to changes' ), confirmText } ); } getInvalidColumnHeadersText (invalidColumnHeaders: string[]) { let confirmText: string; if (invalidColumnHeaders.length > 0) { confirmText = this.i18n.translate( 'common:textColumnHeadersInvalidConfirm', {}, 'The following column header(s) have a special character that is invalid. Please update these headers to proceed with saving changes.' ) + '
'; invalidColumnHeaders.forEach((columnName) => { confirmText += `
  • ${columnName}
  • `; }); } return confirmText; } /** * * @returns Report field columns for logic builder */ getReportFieldColumns ( currentFormDefinition: FormDefinitionForUi[] ): LogicColumnDisplay[] { const reportFieldBuckets = this.getReportfieldBuckets(); const columnNameMap = currentFormDefinition.reduce>((acc, _curr, index) => { let namesForTab: Record = {}; this.componentHelper.eachComponent(currentFormDefinition[index].components, (comp) => { if (comp.type !== 'reportField') { return; } else { namesForTab = { ...namesForTab, [comp.reportFieldDataOptions.reportFieldDisplay]: comp.label }; } }); return { ...acc, ...namesForTab }; }, {}); const reportFields = reportFieldBuckets.reduce((acc, curr) => { const columns = this.getReportFieldColumnsByBucket(reportFieldBuckets, curr.property); const options = columns.map((column): LogicColumnDisplay => { const label = columnNameMap[column.definition.column]; // filter options is null here because we aren't going to use it const defLabel = this.i18n.translate( column.definition.i18nKey, {}, column.definition.defaultDisplay ); const field: LogicColumnDisplay = { column: [ 'reportFieldResponse', column.definition.parentBucket, column.definition.column ] as LogicColumn, label: label || defLabel, otherColumnOptions: [], type: column.definition.type as LogicFilterTypes, filterOptions: null }; return field; }); return [ ...acc, ...options ]; }, []); return reportFields; } }