import { HttpErrorResponse } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { TranslationService } from '@core/services/translation.service'; import { ReferenceFieldAPI } from '@core/typings/api/reference-fields.typing'; import { AdHocReportingUI } from '@core/typings/ui/ad-hoc-reporting.typing'; import { ReferenceFieldsUI } from '@core/typings/ui/reference-fields.typing'; import { ClientSettingsService } from '@features/client-settings/client-settings.service'; import { BasicForm, ExportForm, ExportFormResponse, FormAudience, FormStates, FormTypes, FORM_TYPES } from '@features/configure-forms/form.typing'; import { FormResources } from '@features/configure-forms/forms.resources'; import { FormsState } from '@features/configure-forms/forms.state'; import { StandardFormTemplate } from '@features/platform-admin/standard-product-configuration/standard-product-configuration.typing'; import { ReferenceFieldsService } from '@features/reference-fields/services/reference-fields.service'; import { ArrayHelpersService, Base64, Column, FileService, TypeaheadSelectOption } from '@yourcause/common'; import { I18nService } from '@yourcause/common/i18n'; import { LogService } from '@yourcause/common/logging'; import { ConfirmAndTakeActionService } from '@yourcause/common/modals'; import { NotifierService } from '@yourcause/common/notifier'; import { AttachYCState, BaseYCService } from '@yourcause/common/state'; import { uniq } from 'lodash'; import moment from 'moment'; @AttachYCState(FormsState) @Injectable({ providedIn: 'root' }) export class FormsService extends BaseYCService { constructor ( private logger: LogService, private formResources: FormResources, private notifier: NotifierService, private fileService: FileService, private arrayHelper: ArrayHelpersService, private translationService: TranslationService, private referenceFieldService: ReferenceFieldsService, private i18n: I18nService, private clientSettingsService: ClientSettingsService, private confirmAndTakeActionService: ConfirmAndTakeActionService ) { super(); this.prepareFormTypes(); } get loaded () { return this.get('loaded'); } get published () { return this.get('published'); } get draft () { return this.get('draft'); } get allFormOptions () { return this.get('allFormOptions'); } get allPublishedFormOptions () { return this.get('allPublishedFormOptions'); } get applicantFormOptions () { return this.get('applicantFormOptions'); } get managerFormOptions () { return this.get('managerFormOptions'); } get myFormOptions () { return this.get('myFormOptions'); } get myApplicantFormOptions () { return this.get('myApplicantFormOptions'); } get myManagerFormOptions () { return this.get('myManagerFormOptions'); } get myDependentFormFilteringOptions () { return this.get('myDependentFormFilteringOptions'); } get forms () { return this.get('forms'); } get standardFormTemplates () { return this.get('standardFormTemplates'); } get formTypes () { return this.get('formTypes'); } get formTypeMap () { return this.get('formTypeMap'); } get typeAudienceMap () { return this.get('typeAudienceMap'); } /** * Sets standard templates * * @param templates: standard form templates */ setStandardFormTemplates (templates: StandardFormTemplate[]) { this.set('standardFormTemplates', templates); } /** * Sets loaded * * @param loaded: is loaded? */ setLoaded (loaded: boolean) { this.set('loaded', loaded); } /** * Sets draft forms * * @param forms: draft forms to set */ setDraftForms (forms: BasicForm[]) { this.set('draft', forms); } /** * Sets published forms * * @param forms: published forms to set */ setPublishedForms (forms: BasicForm[]) { this.set('published', forms); } /** * Gets routing form options * * @returns Published routing form options */ getRoutingFormOptions () { const translations = this.translationService.viewTranslations.FormTranslation; return this.arrayHelper.sort( this.published.filter((item) => { return item.formType === FormTypes.ROUTING; }).map((form) => { return { label: translations[form.formId]?.Name ?? form.name, value: form.formId }; }), 'label' ); } /** * Returns form type options * * @returns form type options */ getFormTypeOptions () { const types = this.arrayHelper.sort(this.formTypes, 'name'); const audienceMap: { [x: string]: string; } = { [FormAudience.APPLICANT]: this.i18n.translate('common:lblApplicant'), [FormAudience.MANAGER]: this.i18n.translate( 'GLOBAL:textGrantManager', {}, 'Grant manager' ) }; return types.map((type) => { const name = this.translateFormTypes(type.id); return { option: `
${name}
${audienceMap[type.audience]} `, label: name, value: type.id }; }); } /** * Refetches the forms */ async refreshForms () { this.set('loaded', false); this.resetMyDependentFormFilterOptions(); await this.prepareForms(); } /** * Returns form type given form ID * * @param formId: form id * @returns form type */ getFormTypeFromFormId (formId: number) { const found = this.published?.find((form) => { return +form.formId === +formId; }); return found ? found.formType : null; } /** * Sets the forms on the state * * @param draft: draft forms array * @param published: published forms array */ setForms (draft: BasicForm[], published: BasicForm[]) { const forms: BasicForm[] = []; draft.forEach((form) => { forms.push({ ...form, state: FormStates.DRAFT }); }); published.forEach((form) => { forms.push({ ...form, state: FormStates.PUBLISHED }); }); this.set('forms', forms); } /** * Fetches the forms if they have not been loaded yet */ async prepareForms () { if (!this.get('loaded')) { await this.getAndSetForms(); await this.setFormOptions(); } } /** * Sets form type helpers */ prepareFormTypes () { this.setFormTypes(); this.setFormTypeMap(); } /** * Fetches and sets the form options */ async setFormOptions () { const segmentedForms = await this.formResources.getFormsSegmented(); const allForms: TypeaheadSelectOption[] = []; const applicantForms: TypeaheadSelectOption[] = []; const managerForms: TypeaheadSelectOption[] = []; const myForms: TypeaheadSelectOption[] = []; const myApplicantForms: TypeaheadSelectOption[] = []; const myManagerForms: TypeaheadSelectOption[] = []; const formTranslations = this.translationService.viewTranslations.FormTranslation; this.forms.forEach((form) => { const translation = formTranslations[form.formId]; const formOption = { label: translation && translation.Name ? translation.Name : form.name, value: form.formId }; allForms.push(formOption); if (form.state === FormStates.PUBLISHED) { const audience = this.getAudienceFromFormType(form.formType); if (audience === FormAudience.APPLICANT) { applicantForms.push(formOption); } else { managerForms.push(formOption); } if (segmentedForms?.includes(form.formId)) { myForms.push(formOption); if (audience === FormAudience.APPLICANT) { myApplicantForms.push(formOption); } else { myManagerForms.push(formOption); } } } }); const allPublishedForms = [ ...managerForms, ...applicantForms ]; this.sortAndSetOptions(allForms, 'allFormOptions'); this.sortAndSetOptions(applicantForms, 'applicantFormOptions'); this.sortAndSetOptions(managerForms, 'managerFormOptions'); this.sortAndSetOptions(allPublishedForms, 'allPublishedFormOptions'); this.sortAndSetOptions(myForms, 'myFormOptions'); this.sortAndSetOptions(myApplicantForms, 'myApplicantFormOptions'); this.sortAndSetOptions(myManagerForms, 'myManagerFormOptions'); } /** * Given options array, it will be sorted and assigned to the attr on the state * * @param options: Options to assign to attr * @param attr: Attr to store the options on */ sortAndSetOptions ( options: TypeaheadSelectOption[], attr: 'applicantFormOptions'|'managerFormOptions'|'allPublishedFormOptions'|'myFormOptions'|'myApplicantFormOptions'|'myManagerFormOptions'|'allFormOptions' ) { const sorted = this.arrayHelper.sort(options, 'label'); this.set(attr, sorted); } /** * Returns the audience by passing in form type * * @param formType: form type * @returns audience of the form */ getAudienceFromFormType (formType: FormTypes): FormAudience { const type = this.formTypes.find((t) => { return +t.id === +formType; }); return type ? type.audience : null; } /** * Is this a manager form? * * @param formId: form id * @returns if the form is a manager form */ isManagerForm (formId: number) { const type = this.getFormTypeFromFormId(formId); const audience = this.getAudienceFromFormType(type); return audience === FormAudience.MANAGER; } /** * Gets the default language given a form ID * * @param formId: form ID * @returns the default lang from the form ID */ getDefaultLangFromFormId (formId: number) { const found = this.forms?.find((form) => { return form.formId === formId; }); return found?.defaultLanguageId || this.clientSettingsService.defaultLanguage; } /** * Given an array of forms, returns the most common default lang in the array * * @param formIds: form ids * @returns the most common default lang from the forms */ getMostCommonDefaultLangFromArray ( formIds: number[] ) { const forms = this.draft.concat(this.published); return this.translationService.getMostCommonDefaultLangFromArray( forms.map((form) => { return { defaultLanguageId: form.defaultLanguageId, id: form.formId }; }), formIds ); } /** * Gets and sets forms if not already loaded */ async getAndSetForms () { if (!this.get('loaded')) { const forms = await this.formResources.getForms(); const drafts = forms.filter((form) => { return form.isDraft; }); const published = forms.filter((form) => { return !form.isDraft; }); this.setDraftForms(drafts); this.setPublishedForms(published); this.setForms(drafts, published); this.setLoaded(true); } } /** * Sets the form types */ setFormTypes () { if (!this.formTypes) { const typeAudienceMap: { [x: number]: FormAudience; } = {}; const types = FORM_TYPES; types.forEach((type) => { typeAudienceMap[type.id] = type.audience; }); this.set('formTypes', types); this.set('typeAudienceMap', typeAudienceMap); } } /** * Translates form types * * @param formType: form type * @returns the translated form type */ translateFormTypes (formType: FormTypes) { switch (formType) { case FormTypes.REQUEST: default: return this.i18n.translate('FORMS:textRequest'); case FormTypes.ELIGIBILITY: return this.i18n.translate('FORMS:textEligibility'); case FormTypes.LOI: return this.i18n.translate('FORMS:textLOI', {}, 'LOI'); case FormTypes.REVIEW: return this.i18n.translate('FORMS:textReview'); case FormTypes.AWARD: return this.i18n.translate('common:lblAward'); case FormTypes.APPROVE_DECISION: return this.i18n.translate('FORMS:textApproveDecision', {}, 'Approve (Decision)'); case FormTypes.DECLINE_DECISION: return this.i18n.translate('FORMS:textDeclineDecision', {}, 'Decline (Decision)'); case FormTypes.PAYMENT: return this.i18n.translate('common:lblPayment'); case FormTypes.PROGRESS_REPORT: return this.i18n.translate('FORMS:textProgressReport', {}, 'Progress report'); case FormTypes.GRANT_AGREEMENT: return this.i18n.translate('FORMS:textGrantAgreement', {}, 'Grant agreement'); case FormTypes.SITE_VISIT: return this.i18n.translate('FORMS:textSiteVisit', {}, 'Site visit'); case FormTypes.MEETING_NOTES: return this.i18n.translate('FORMS:textMeetingNotes', {}, 'Meeting notes'); case FormTypes.ADDITIONAL_DOCUMENTATION: return this.i18n.translate('FORMS:textAdditionalDocumentation', {}, 'Additional documentation'); case FormTypes.APPROVAL: return this.i18n.translate('FORMS:textApproval'); case FormTypes.OTHER_APPLICANT_FORM: return this.i18n.translate('FORMS:textOtherApplicantForm', {}, 'Other applicant form'); case FormTypes.OTHER_GRANT_MANAGER_FORM: return this.i18n.translate('FORMS:textOtherGrantManagerForm', {}, 'Other grant manager form'); case FormTypes.NOMINATION: return this.i18n.translate('FORMS:textNomination'); case FormTypes.ROUTING: return this.i18n.translate('FORMS:textRoutingForm', {}, 'Routing form'); } } /** * Sets the form type helper map * Form type enum on left and translated name on right */ setFormTypeMap () { const map: { [x: string]: string; } = {}; this.formTypes.forEach((type) => { const name = this.translateFormTypes(type.id); map[+type.id] = name; }); this.set('formTypeMap', map); } /** * Handles removing a form or a revision * * @param formId: Form ID * @param revisionId: Revision ID * @param isRevision: isRevision removal? */ async handleRemoveFormOrRevision ( formId: number, revisionId: number, isRevision: boolean ) { try { if (isRevision) { await this.deleteRevision(formId, revisionId); } else { await this.formResources.deleteAllRevisionsOfForm(formId); } await Promise.all([ this.refreshForms(), this.referenceFieldService.resetFieldsAndCategories() ]); this.notifier.success(this.i18n.translate( isRevision ? 'FORMS:textSuccessRemovedRevision' : 'FORMS:textSuccessRemovedForm', {}, isRevision ? 'Successfully removed the revision' : 'Successfully removed the form' )); } catch (err) { const e = err as HttpErrorResponse; this.logger.error(e); if ( e.error.message && e.error.message.startsWith( 'This form is currently tied to the following Grant Program(s)' ) ) { this.notifier.error( this.i18n.translate( 'FORMS:textFormCannotBeRemovedProgram', {}, 'This form cannot be removed because it is tied to a grant program' ) ); } else { this.notifier.error( this.i18n.translate( isRevision ? 'FORMS:textErrorRemovingRevision' : 'FORMS:textErrorRemovingForm', {}, isRevision ? 'There was an error removing your revision' : 'There was an error removing your form' ) ); } } } /** * Deletes a form revision * * @param formId: form id * @param revisionId: revision id */ async deleteRevision (formId: number, revisionId: number) { return this.formResources.deleteRevision(formId, revisionId); } /** * Exports the selected forms * * @param forms: forms to export */ async exportForms (exportFormsPayload: ExportForm[]) { try { const exportedForms = await this.formResources.exportForms( exportFormsPayload ); this.fileService.downloadRaw( Base64.encode(JSON.stringify(exportedForms)), `forms_export_${moment().format('YYYYMMDDHHmmss')}.bin` ); this.notifier.success(this.i18n.translate( 'FORMS:textSuccessfullyExportedSelectedForms', {}, 'Successfully exported the selected forms' )); } catch (e) { this.logger.error(e); this.notifier.error(this.i18n.translate( 'FORMS:textErrorExportingSelectedForms', {}, 'There was an error exporting the selected forms' )); } } /** * Parses the form export file * * @param formExport: Form export to parse * @returns the forms parsed */ parseFormExport (formExport: string) { let forms: ExportFormResponse[]; try { forms = JSON.parse(Base64.decode(formExport)); } catch (e) { this.logger.error(e); this.notifier.error(this.i18n.translate( 'FORMS:notificationInvalidFileType', {}, 'The provided file was invalid, please obtain a valid file and try again.' )); } return forms; } /** * Imports the form export file * * @param formExport: form export string */ async importForms (formExport: string) { const forms = this.parseFormExport(formExport); if (forms) { try { await this.formResources.importForms(forms); await Promise.all([ this.refreshForms(), this.referenceFieldService.resetFieldsAndCategories() ]); const hasExternalApi = this.clientSettingsService.clientSettings.canConfigureWebservices; this.notifier.success(this.i18n.translate( hasExternalApi ? 'FORMS:notificationSuccessImportFormsWebServiceWarning' : 'FORMS:notificationSuccessImportForms', {}, hasExternalApi ? 'Successfully imported your form(s). Importing a form that uses a web service component may require an update to the web service configuration.' : 'Successfully imported your form(s)' )); } catch (e) { this.logger.error(e); this.notifier.error(this.i18n.translate( 'FORMS:notificationErrorImportingForms', {}, 'There was an error importing your form(s)' )); } } } /** * Returns programs related to a form * * @param formId: form id * @returns programs tied to the form */ async getProgramsRelatedToForm (formId: number) { const progTranslations = this.translationService.viewTranslations.Grant_Program; const programIds = await this.formResources.getProgramsRelatedToForm(formId); return this.arrayHelper.sort(programIds.map((id) => { return progTranslations[id] && progTranslations[id].Name ? progTranslations[id].Name : ''; })); } /** * Publishes the form * * @param formId: form id to publish * @param revisionId: revision id to publish * @returns if it passed */ async handlePublishForm ( formId: number, revisionId: number, formName: string ) { const result = await this.confirmAndTakeActionService.confirmAndTakeAction( `api/manager/forms/${formId}/revisions/${revisionId}/publish`, {}, this.i18n.translate( 'GLOBAL:textPublish', {}, 'Publish' ), formName, this.i18n.translate( 'common:textConfirmPublishForm', {}, 'Are you sure you want to publish this form?' ), this.i18n.translate( 'GLOBAL:textPublish', {}, 'Publish' ), this.i18n.translate( 'FORMS:textSuccessPublishForm', {}, 'Successfully published the form' ), this.i18n.translate( 'FORMS:lblErrorPublishingForm', {}, 'There was an error publishing your form' ), 'post', false ); return result?.passed; } /** * Given form id, returns form name * * @param formId: form id * @returns form info */ getFormFromId (formId: number) { return this.forms.find((form) => { return form.formId === formId; }); } /** * Gets the form given the ID and the state * * @param formId: form id * @param isPublished: is the form published * @returns fomr info */ getFormByIdAndState ( formId: number, isPublished: boolean ) { return this.forms.find((form) => { return form.formId === formId && (isPublished ? form.state === FormStates.PUBLISHED : form.state === FormStates.DRAFT ); }); } /** * Gets the latest revision ID * * @param formId: form id * @param isPublished: is the form published * @returns the latest revision id */ getLatestRevisionId ( formId: number, isPublished: boolean ) { const foundForm = this.getFormByIdAndState(formId, isPublished); if (!foundForm) { const foundTemplate = this.standardFormTemplates.find((template) => { return template.formId === formId; }); return foundTemplate?.revisionId; } return foundForm?.revisionId; } /** * Gets form revision options * * @param formId: form id * @param isPublished: is the form published * @param doNotAllowUpdateCurrentRevision: is update current revision restricted? * @returns the form revision options */ getFormRevisionOptions ( formId: number, isPublished: boolean, doNotAllowUpdateCurrentRevision: boolean ) { const found = this.getFormByIdAndState(formId, isPublished); if ( !doNotAllowUpdateCurrentRevision && found.revisions?.length > 0 ) { const latestRevisionId = this.getLatestRevisionId( formId, isPublished ); const revisions = found.revisions.filter((revision) => { // Only include older revisions that have responses tied to them or is latest return !revision.revisionCanBeRemoved || revision.formRevisionId === latestRevisionId; }).map((revision) => { return { label: revision.version, value: revision.formRevisionId }; }); const sorted = this.arrayHelper.sort([ ...revisions, { label: found.revisionVersion, value: found.revisionId } ], 'label'); return sorted.map((item) => { return { label: `v${item.label}`, value: item.value }; }); } else { return [{ label: `v${found.revisionVersion}`, value: found.revisionId }]; } } /** * Get GM forms with single response fields * * @returns forms with single response fields */ getGmFormsWithSingleResponseFields () { return this.formResources.getGmFormsWithSingleResponseFields(); } /** * Resets my dependent form filtering options */ resetMyDependentFormFilterOptions () { this.set('myDependentFormFilteringOptions', undefined); } /** * Sets my dependent form filtering options */ async setMyDependentFormFilterOptions () { await this.prepareForms(); if (!this.myDependentFormFilteringOptions) { const forms = await this.formResources.getMyFormsWithFieldDetails(); const formTranslations = this.translationService.viewTranslations.FormTranslation; const formOptions: TypeaheadSelectOption[] = []; forms.forEach((form) => { const refIds = uniq(form.referenceFieldIds); const childOptions = this.arrayHelper.sort(refIds.map((id) => { return this.referenceFieldService.referenceFieldMapById[id]; }).filter((field) => { return field && (field.type !== ReferenceFieldsUI.ReferenceFieldTypes.Subset) && (field.type !== ReferenceFieldsUI.ReferenceFieldTypes.Table) && (field.formAudience === FormAudience.APPLICANT || field.isSingleResponse) && !field.isEncrypted && !field.isMasked; }).map((field) => { return { label: field.name, value: this.getRefFieldColumn(field) }; }), 'label'); if (childOptions.length > 0) { let formName = formTranslations[form.formId]?.Name; if (!formName) { formName = this.getFormNameFromFormId(form.formId); } formOptions.push({ label: formName, value: this.getFormColumn(form.formId, formName, childOptions) }); } }); const options = this.arrayHelper.sort(formOptions, 'label'); this.set('myDependentFormFilteringOptions', options); } } /** * Gets form name given form id * * @param formId: form id * @returns form name */ getFormNameFromFormId (formId: number) { // This is a fallback if a translation is not returned return this.forms.find((form) => { return form.formId === formId; })?.name; } /** * Gets the form column for filtering options * * @param formId: form id * @param formName: form name * @param childOptions child options for the form * @returns the form column */ getFormColumn ( formId: number, formName: string, childOptions: TypeaheadSelectOption[] ): Column { return { label: formName, visible: true, type: 'text', labelOnly: true, prop: '' + formId, options: [], dependentColumnOptions: childOptions, columnName: formName, filterOnly: true }; } /** * Gets the reference field column for filtering optionsß * * @param field: reference field * @returns the reference field column */ getRefFieldColumn ( field: ReferenceFieldAPI.ReferenceFieldDisplayModel ): Column { const [columnDef]: AdHocReportingUI.ColumnDefinition[] = this.referenceFieldService.getReferenceFieldColumnDef({ ...field, formIds: [] }); return { label: field.name, visible: true, type: columnDef.type, labelOnly: true, prop: '' + field.referenceFieldId, options: [], columnName: field.name, filterOnly: true, onSelectedAsFilter: async (column: Column) => { column.options = await this.referenceFieldService.getCdtOptionsFromRefField( +column.prop ); } }; } /** * Is this an eligibility form? * * @param formId: form id * @returns if this form is an eligibility form */ getIsEligibilityForm (formId: number) { const type = this.getFormTypeFromFormId(formId); return type === FormTypes.ELIGIBILITY; } }