diff --git a/apps/back-office/src/app/application/application-routing.module.ts b/apps/back-office/src/app/application/application-routing.module.ts index bcd5dac006..ad7669e91c 100644 --- a/apps/back-office/src/app/application/application-routing.module.ts +++ b/apps/back-office/src/app/application/application-routing.module.ts @@ -182,6 +182,14 @@ const routes: Routes = [ }, }, }, + { + path: 'triggers', + loadChildren: () => + import('./pages/triggers/triggers.module').then( + (m) => m.TriggersModule + ), + canActivate: [PermissionGuard], + }, ], }, { diff --git a/apps/back-office/src/app/application/application.component.ts b/apps/back-office/src/app/application/application.component.ts index e3dedc61ed..9aff45d19b 100644 --- a/apps/back-office/src/app/application/application.component.ts +++ b/apps/back-office/src/app/application/application.component.ts @@ -158,6 +158,11 @@ export class ApplicationComponent path: './settings/subscriptions', icon: 'add_to_queue', }, + { + name: this.translate.instant('common.trigger.few'), + path: './settings/triggers', + icon: 'announcement', + }, ]; } if (application.canUpdate) { diff --git a/apps/back-office/src/app/application/pages/triggers/components/manage-trigger-modal/graphql/queries.ts b/apps/back-office/src/app/application/pages/triggers/components/manage-trigger-modal/graphql/queries.ts new file mode 100644 index 0000000000..9f43297765 --- /dev/null +++ b/apps/back-office/src/app/application/pages/triggers/components/manage-trigger-modal/graphql/queries.ts @@ -0,0 +1,34 @@ +import { gql } from 'apollo-angular'; + +/** Graphql request for getting channels */ +export const GET_CHANNELS = gql` + query getChannels($application: ID) { + channels(application: $application) { + id + title + } + } +`; + +/** Graphql request for getting resource layout */ +export const GET_LAYOUT = gql` + query GetLayout($resource: ID!, $id: ID) { + resource(id: $resource) { + layouts(ids: [$id]) { + edges { + node { + id + name + query + createdAt + display + } + } + } + metadata { + name + type + } + } + } +`; diff --git a/apps/back-office/src/app/application/pages/triggers/components/manage-trigger-modal/manage-trigger-modal.component.html b/apps/back-office/src/app/application/pages/triggers/components/manage-trigger-modal/manage-trigger-modal.component.html new file mode 100644 index 0000000000..26ae1ff38e --- /dev/null +++ b/apps/back-office/src/app/application/pages/triggers/components/manage-trigger-modal/manage-trigger-modal.component.html @@ -0,0 +1,311 @@ + + + +

+ {{ + data.trigger + ? ('components.triggers.editTrigger' | translate : { type: 'components.triggers.' + data.triggerType | translate} ) + : ('components.triggers.createTrigger' | translate : { type: 'components.triggers.' + data.triggerType | translate} ) + }} +

+
+ + +
+ +
+ + +
+ +
+ + + + {{ 'common.email.one' | translate }} + + + {{ 'components.triggers.notification' | translate }} + + +
+ + + {{ 'components.triggers.selectNotificationType' | translate }} + + +
+ + + + {{ template.name }} + + + + + +
+ + + + + + + +
+ + + {{ + 'components.triggers.redirect.active' + | translate + }} + + + +
+ + + + {{ 'components.triggers.redirect.url' | translate }} + + + {{ 'components.triggers.redirect.recordIds' | translate }} + + +
+ +
+ + + + + + {{ page.name }} + + +
+
+
+
+ + +

{{ 'components.customNotifications.edit.dataset' | translate }}

+ +
+ + +
+ + + +
+ + + + +
+
+ + + {{ + 'components.widget.settings.grid.layouts.add.title' | translate + }} + + + + + +

{{ 'components.customNotifications.edit.recipients.title' | translate }}

+ + + {{ 'components.triggers.selectNotificationType' | translate }} + + + + +
+ + {{ 'components.triggers.customNotificationRecipientsType.' + option | translate }} + +
+ + +
+ + + + {{ list.name }} + + +
+ + +
+ + + + {{ field.name }} + + +
+ + +
+ + + + {{ field.name }} + + +
+ + +
+ + +
+ + +
+ + + + {{ channel.title }} + + +
+
+
+
+ + + + + {{ 'common.close' | translate }} + + + {{ (data.trigger ? 'common.update' : 'common.create') | translate }} + + +
diff --git a/apps/back-office/src/app/application/pages/triggers/components/manage-trigger-modal/manage-trigger-modal.component.scss b/apps/back-office/src/app/application/pages/triggers/components/manage-trigger-modal/manage-trigger-modal.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/back-office/src/app/application/pages/triggers/components/manage-trigger-modal/manage-trigger-modal.component.spec.ts b/apps/back-office/src/app/application/pages/triggers/components/manage-trigger-modal/manage-trigger-modal.component.spec.ts new file mode 100644 index 0000000000..d5a4de64aa --- /dev/null +++ b/apps/back-office/src/app/application/pages/triggers/components/manage-trigger-modal/manage-trigger-modal.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ManageTriggerModalComponent } from './manage-trigger-modal.component'; + +describe('ManageTriggerModalComponent', () => { + let component: ManageTriggerModalComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ManageTriggerModalComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ManageTriggerModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/back-office/src/app/application/pages/triggers/components/manage-trigger-modal/manage-trigger-modal.component.ts b/apps/back-office/src/app/application/pages/triggers/components/manage-trigger-modal/manage-trigger-modal.component.ts new file mode 100644 index 0000000000..3a52f8ee96 --- /dev/null +++ b/apps/back-office/src/app/application/pages/triggers/components/manage-trigger-modal/manage-trigger-modal.component.ts @@ -0,0 +1,344 @@ +import { Dialog, DIALOG_DATA } from '@angular/cdk/dialog'; +import { Component, Inject, OnInit } from '@angular/core'; +import { FormGroup, Validators } from '@angular/forms'; +import { + ApplicationService, + CustomNotification, + customNotificationRecipientsType, + GridLayoutService, + Layout, + Resource, + Template, + TemplateTypeEnum, + UnsubscribeComponent, + DistributionList, + Channel, + ChannelsQueryResponse, + ResourceQueryResponse, +} from '@oort-front/shared'; +import { NotificationType, Triggers, TriggersType } from '../../triggers.types'; +import { takeUntil } from 'rxjs'; +import { get } from 'lodash'; +import { Apollo } from 'apollo-angular'; +import { GET_CHANNELS, GET_LAYOUT } from './graphql/queries'; + +/** + * Dialog data interface. + */ +interface DialogData { + trigger?: CustomNotification; + triggerType: TriggersType; + formGroup: FormGroup; + resource: Resource; +} + +/** Recipients options for email type trigger */ +const emailRecipientsOptions = [ + customNotificationRecipientsType.distributionList, + customNotificationRecipientsType.email, + customNotificationRecipientsType.userField, + customNotificationRecipientsType.emailField, +]; + +/** Recipients options for notification type trigger */ +const notificationRecipientsOptions = [ + customNotificationRecipientsType.channel, + customNotificationRecipientsType.userField, +]; + +/** + * Edit/create trigger modal. + */ +@Component({ + selector: 'app-manage-trigger-modal', + templateUrl: './manage-trigger-modal.component.html', + styleUrls: ['./manage-trigger-modal.component.scss'], +}) +export class ManageTriggerModalComponent + extends UnsubscribeComponent + implements OnInit +{ + /** Trigger form group */ + public formGroup!: FormGroup; + /** Triggers enum */ + public TriggersEnum = Triggers; + /** Layout */ + public layout?: Layout; + /** List of channels */ + public channels?: Channel[]; + /** Available pages from the application */ + public pages: any[] = []; + /** List of recipients options depending on selected type */ + public recipientsTypeOptions?: + | typeof emailRecipientsOptions + | typeof notificationRecipientsOptions; + + /** @returns application distribution lists */ + get distributionLists(): DistributionList[] { + return this.applicationService.distributionLists || []; + } + + /** @returns application templates */ + get templates(): Template[] { + return (this.applicationService.templates || []).filter( + (x) => x.type === this.formGroup.value.notificationType + ); + } + + /** @returns available users fields */ + get userFields(): any[] { + return get(this.data.resource, 'metadata', []).filter( + (x) => x.type === 'users' + ); + } + + /** @returns available email fields */ + get emailFields(): any[] { + return get(this.data.resource, 'metadata', []).filter( + (x) => x.type === 'email' + ); + } + + /** Indicates if initiating component */ + private init = true; + + /** + * Edit/create trigger modal. + * + * @param data dialog data + * @param gridLayoutService Shared dataset layout service + * @param applicationService Shared application service + * @param dialog Dialog service + * @param apollo The apollo client + */ + constructor( + @Inject(DIALOG_DATA) public data: DialogData, + private gridLayoutService: GridLayoutService, + private applicationService: ApplicationService, + private dialog: Dialog, + private apollo: Apollo + ) { + super(); + this.formGroup = this.data.formGroup; + this.onNotificationTypeChange( + this.formGroup.controls.notificationType.value + ); + } + + ngOnInit(): void { + // Load all application channels + this.getChannels(); + + // If editing trigger, get layout + if (this.data.trigger?.layout) { + this.getLayout(this.data.trigger?.layout); + } + + // Get available application pages + this.pages = this.applicationService.getPages(); + + // Add email validation to recipients field if recipients type is email + this.formGroup + .get('recipientsType') + ?.valueChanges.pipe(takeUntil(this.destroy$)) + .subscribe((value) => { + if (value === 'email') { + this.formGroup.get('recipients')?.addValidators(Validators.email); + } else { + this.formGroup.get('recipients')?.removeValidators(Validators.email); + } + }); + + this.init = false; + } + + /** + * Opens modal for layout selection/creation + */ + public async addLayout() { + const { AddLayoutModalComponent } = await import('@oort-front/shared'); + const dialogRef = this.dialog.open(AddLayoutModalComponent, { + data: { + resource: this.data.resource, + hasLayouts: this.data.resource.hasLayouts, + }, + }); + dialogRef.closed.pipe(takeUntil(this.destroy$)).subscribe((value: any) => { + if (value) { + if (typeof value === 'string') { + this.getLayout(value); + } else { + this.layout = value; + this.formGroup.get('layout')?.setValue(value.id); + } + } + }); + } + + /** + * Edit chosen layout, in a modal. If saved, update it. + */ + public async editLayout(): Promise { + const { EditLayoutModalComponent } = await import('@oort-front/shared'); + const dialogRef = this.dialog.open(EditLayoutModalComponent, { + disableClose: true, + data: { + layout: this.layout, + queryName: this.data.resource?.queryName, + }, + }); + dialogRef.closed.pipe(takeUntil(this.destroy$)).subscribe((value: any) => { + if (value && this.layout) { + this.gridLayoutService + .editLayout(this.layout, value, this.data.resource?.id) + .subscribe((res: any) => { + this.layout = res.data?.editLayout; + }); + } + }); + } + + /** + * Unset layout. + */ + public removeLayout(): void { + this.formGroup.get('layout')?.setValue(null); + this.layout = undefined; + } + + /** + * Opens modal for adding a new email template + */ + public async addTemplate() { + const { EditTemplateModalComponent } = await import('@oort-front/shared'); + const dialogRef = this.dialog.open(EditTemplateModalComponent, { + disableClose: true, + }); + dialogRef.closed.pipe(takeUntil(this.destroy$)).subscribe((value: any) => { + if (value) { + const content = + value.type === TemplateTypeEnum.EMAIL + ? { + subject: value.subject, + body: value.body, + } + : { + title: value.title, + description: value.description, + }; + this.applicationService.addTemplate( + { + name: value.name, + type: value.type, + content, + }, + (template: Template) => { + this.formGroup.get('template')?.setValue(template.id || null); + } + ); + } + }); + } + + /** + * Handle redirect active or not: + * If redirection not active, remove validator from url and type controls if necessary. + * If active, add validator to type. + */ + public onRedirectToggle(): void { + if (!this.formGroup.value.redirect.active) { + this.formGroup + .get('redirect.type') + ?.removeValidators(Validators.required); + this.onRedirectTypeChange(undefined); + } else { + this.formGroup.get('redirect.type')?.addValidators(Validators.required); + } + this.formGroup.get('redirect.type')?.updateValueAndValidity(); + } + + /** + * Handle redirect type change. + * + * @param type selected notification type + */ + public onRedirectTypeChange(type: 'url' | 'recordIds' | undefined): void { + if (!this.init) { + this.formGroup.get('redirect.url')?.setValue(''); + } + if (type) { + if (type === 'url') { + this.formGroup.get('redirect.url')?.addValidators(Validators.required); + } else { + this.formGroup + .get('redirect.url') + ?.removeValidators(Validators.required); + } + } else { + this.formGroup.get('redirect.url')?.removeValidators(Validators.required); + } + this.formGroup.get('redirect.url')?.updateValueAndValidity(); + } + + /** + * Handle notification type change. + * + * @param type selected notification type + */ + public onNotificationTypeChange(type: NotificationType | undefined): void { + if (!this.init) { + this.formGroup.get('recipients')?.setValue(''); + this.formGroup.get('recipientsType')?.setValue(''); + this.formGroup.get('template')?.setValue(''); + } + if (type) { + if (type === NotificationType.email) { + this.formGroup.get('redirect.active')?.setValue(false); + this.formGroup.get('redirect.type')?.setValue(''); + this.onRedirectToggle(); + this.recipientsTypeOptions = emailRecipientsOptions; + } else { + this.onRedirectToggle(); + this.recipientsTypeOptions = notificationRecipientsOptions; + } + } + } + + /** + * Load channels query data. + */ + private getChannels(): void { + this.apollo + .query({ + query: GET_CHANNELS, + variables: { + application: this.applicationService.application.getValue()?.id, + }, + }) + .pipe(takeUntil(this.destroy$)) + .subscribe(({ data }) => { + this.channels = data.channels; + }); + } + + /** + * Load layout by its id. + * + * @param layoutId id of the layout + */ + private getLayout(layoutId: string): void { + this.apollo + .query({ + query: GET_LAYOUT, + variables: { + id: layoutId, + resource: this.data.resource.id, + }, + }) + .pipe(takeUntil(this.destroy$)) + .subscribe(({ data }) => { + this.layout = data.resource.layouts?.edges[0]?.node; + this.formGroup.get('layout')?.setValue(this.layout?.id); + }); + } +} diff --git a/apps/back-office/src/app/application/pages/triggers/components/triggers-filter/triggers-filter.component.html b/apps/back-office/src/app/application/pages/triggers/components/triggers-filter/triggers-filter.component.html new file mode 100644 index 0000000000..f6d304baae --- /dev/null +++ b/apps/back-office/src/app/application/pages/triggers/components/triggers-filter/triggers-filter.component.html @@ -0,0 +1,30 @@ +
+ + + +
+
+ +
+ + + +
+
+ + {{ 'common.filter.clear' | translate }} + +
+
diff --git a/apps/back-office/src/app/application/pages/triggers/components/triggers-filter/triggers-filter.component.scss b/apps/back-office/src/app/application/pages/triggers/components/triggers-filter/triggers-filter.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/back-office/src/app/application/pages/triggers/components/triggers-filter/triggers-filter.component.spec.ts b/apps/back-office/src/app/application/pages/triggers/components/triggers-filter/triggers-filter.component.spec.ts new file mode 100644 index 0000000000..1521bcfff2 --- /dev/null +++ b/apps/back-office/src/app/application/pages/triggers/components/triggers-filter/triggers-filter.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TriggersFilterComponent } from './triggers-filter.component'; + +describe('TriggersFilterComponent', () => { + let component: TriggersFilterComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [TriggersFilterComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TriggersFilterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/back-office/src/app/application/pages/triggers/components/triggers-filter/triggers-filter.component.ts b/apps/back-office/src/app/application/pages/triggers/components/triggers-filter/triggers-filter.component.ts new file mode 100644 index 0000000000..9388a17fb9 --- /dev/null +++ b/apps/back-office/src/app/application/pages/triggers/components/triggers-filter/triggers-filter.component.ts @@ -0,0 +1,103 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { FormBuilder } from '@angular/forms'; +import { UnsubscribeComponent } from '@oort-front/shared'; +import { debounceTime, distinctUntilChanged, takeUntil } from 'rxjs'; + +/** + * Triggers filter component. + */ +@Component({ + selector: 'app-triggers-filter', + templateUrl: './triggers-filter.component.html', + styleUrls: ['./triggers-filter.component.scss'], +}) +export class TriggersFilterComponent + extends UnsubscribeComponent + implements OnInit +{ + /** Event emitter for filter */ + @Output() filter = new EventEmitter(); + /** Loading status */ + @Input() loading = false; + /** Form */ + public form = this.fb.group({ + startDate: [null], + endDate: [null], + }); + /** Show flag */ + public show = false; + + /** + * Triggers filter component. + * + * @param fb Angular form builder + */ + constructor(private fb: FormBuilder) { + super(); + } + + ngOnInit(): void { + this.form.valueChanges + .pipe( + debounceTime(1000), + distinctUntilChanged(), + takeUntil(this.destroy$) + ) + .subscribe((value) => { + this.emitFilter(value); + }); + } + + /** + * Emits the filter value, so the main component can get it. + * + * @param value filter value + */ + private emitFilter(value: any): void { + const filters: any[] = []; + if (value.search) { + filters.push({ + field: 'name', + operator: 'contains', + value: value.search, + }); + } + if (value.startDate) { + filters.push({ + field: 'createdAt', + operator: 'gte', + value: value.startDate, + }); + } + if (value.endDate) { + filters.push({ + field: 'createdAt', + operator: 'lte', + value: value.endDate, + }); + } + const filter = { + logic: 'and', + filters, + }; + this.filter.emit(filter); + } + + /** + * Clears form. + */ + clear(): void { + this.form.reset(); + } + + /** + * Clears date range. + */ + clearDateFilter(): void { + this.form.setValue({ + ...this.form.getRawValue(), + startDate: null, + endDate: null, + }); + } +} diff --git a/apps/back-office/src/app/application/pages/triggers/components/triggers-list/triggers-list.component.html b/apps/back-office/src/app/application/pages/triggers/components/triggers-list/triggers-list.component.html new file mode 100644 index 0000000000..c6d4f55538 --- /dev/null +++ b/apps/back-office/src/app/application/pages/triggers/components/triggers-list/triggers-list.component.html @@ -0,0 +1,121 @@ + +
+

{{ ('common.trigger.few' | translate) }}

+ + + {{ 'components.triggers.addTrigger' | translate }} + +
+ + + + + + +
+ +

+ {{ 'components.triggers.noTriggers' | translate }} +

+
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ {{ 'common.trigger.one' | translate }} + + {{ element.name }} + + {{ 'common.type.one' | translate }} + + {{ 'components.triggers.' + element.type | translate }} + +
+ + {{ 'components.triggers.openFilter' | translate }} + + + + + +
+
+
+
diff --git a/apps/back-office/src/app/application/pages/triggers/components/triggers-list/triggers-list.component.scss b/apps/back-office/src/app/application/pages/triggers/components/triggers-list/triggers-list.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/back-office/src/app/application/pages/triggers/components/triggers-list/triggers-list.component.spec.ts b/apps/back-office/src/app/application/pages/triggers/components/triggers-list/triggers-list.component.spec.ts new file mode 100644 index 0000000000..0d3826f415 --- /dev/null +++ b/apps/back-office/src/app/application/pages/triggers/components/triggers-list/triggers-list.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TriggersListComponent } from './triggers-list.component'; + +describe('TriggersListComponent', () => { + let component: TriggersListComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [TriggersListComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TriggersListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/back-office/src/app/application/pages/triggers/components/triggers-list/triggers-list.component.ts b/apps/back-office/src/app/application/pages/triggers/components/triggers-list/triggers-list.component.ts new file mode 100644 index 0000000000..17c1593517 --- /dev/null +++ b/apps/back-office/src/app/application/pages/triggers/components/triggers-list/triggers-list.component.ts @@ -0,0 +1,175 @@ +import { + Component, + EventEmitter, + Input, + OnChanges, + Output, + SimpleChanges, +} from '@angular/core'; +import { triggers, Triggers, TriggersType } from '../../triggers.types'; +import { + CustomNotification, + Resource, + UnsubscribeComponent, + UpdateCustomNotificationMutationResponse, +} from '@oort-front/shared'; +import { SnackbarService } from '@oort-front/ui'; +import { Apollo } from 'apollo-angular'; +import { Dialog } from '@angular/cdk/dialog'; +import { takeUntil } from 'rxjs'; +import { EDIT_CUSTOM_NOTIFICATION_FILTERS } from '../../graphql/mutations'; + +type TriggerTableElement = { + name: string; + type: Triggers; + trigger: CustomNotification; +}; + +/** + * Triggers list component. + */ +@Component({ + selector: 'app-triggers-list', + templateUrl: './triggers-list.component.html', + styleUrls: ['./triggers-list.component.scss'], +}) +export class TriggersListComponent + extends UnsubscribeComponent + implements OnChanges +{ + /** Triggers list */ + @Input() triggersList: CustomNotification[] = []; + /** Disabled flag */ + @Input() disabled = false; + /** Current application id */ + @Input() applicationId!: string; + /** Trigger resource */ + @Input() openedResource?: Resource; + + /** Event emitter for edit trigger */ + // eslint-disable-next-line @angular-eslint/no-output-on-prefix + @Output() onEdit = new EventEmitter<{ + trigger: CustomNotification; + type: TriggersType; + }>(); + /** Event emitter for delete trigger */ + // eslint-disable-next-line @angular-eslint/no-output-on-prefix + @Output() onDelete = new EventEmitter<{ + trigger: CustomNotification; + }>(); + /** Event emitter for add new trigger */ + // eslint-disable-next-line @angular-eslint/no-output-on-prefix + @Output() onAdd = new EventEmitter<{ type: TriggersType }>(); + /** I updating data */ + @Output() updating = new EventEmitter(); + /** Event emitter for handle trigger objet updated */ + // eslint-disable-next-line @angular-eslint/no-output-on-prefix + @Output() edited = new EventEmitter<{ + trigger: CustomNotification; + }>(); + + /** Triggers */ + public triggers = new Array(); + /** Triggers types */ + public TriggersTypes = triggers; + /** Displayed columns */ + public displayedColumns: string[] = ['name', 'type', 'actions']; + + /** + * Triggers list component. + * + * @param apollo Apollo client service + * @param snackBar shared snackbar service + * @param dialog Dialog service + */ + constructor( + private apollo: Apollo, + private snackBar: SnackbarService, + public dialog: Dialog + ) { + super(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.triggersList) { + this.triggers = this.setTableElements(this.triggersList); + } + } + + /** + * Open filter modal of the selected trigger + * + * @param trigger Selected trigger + */ + public async onOpenFilter(trigger: CustomNotification): Promise { + const { TriggersResourceFiltersComponent } = await import( + '../triggers-resource-filters/triggers-resource-filters.component' + ); + const dialogRef = this.dialog.open(TriggersResourceFiltersComponent, { + data: { + trigger, + resource: this.openedResource, + }, + }); + dialogRef.closed.pipe(takeUntil(this.destroy$)).subscribe((value) => { + if (value) { + this.updating.emit(true); + this.apollo + .mutate({ + mutation: EDIT_CUSTOM_NOTIFICATION_FILTERS, + variables: { + id: trigger.id, + triggersFilters: value, + application: this.applicationId, + }, + }) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: ({ errors, data }) => { + if (data?.editCustomNotification) { + this.edited.emit({ trigger: data.editCustomNotification }); + } + if (errors) { + this.snackBar.openSnackBar(errors[0].message, { error: true }); + } + this.updating.emit(false); + }, + error: (err) => { + this.snackBar.openSnackBar(err.message, { error: true }); + this.updating.emit(false); + }, + }); + } + }); + } + + /** + * Serialize list of table elements from triggers + * + * @param triggersList triggers to serialize + * @returns serialized elements + */ + private setTableElements( + triggersList: CustomNotification[] + ): TriggerTableElement[] { + return triggersList.map((x: CustomNotification) => this.setTableElement(x)); + } + + /** + * Serialize single table element from trigger + * + * @param trigger resource to serialize + * @returns serialized element + */ + private setTableElement(trigger: CustomNotification): TriggerTableElement { + return { + name: trigger.name ?? 'Nameless trigger', + type: trigger.onRecordCreation + ? Triggers.onRecordCreation + : trigger.onRecordUpdate + ? Triggers.onRecordUpdate + : Triggers.cronBased, + trigger, + }; + } +} diff --git a/apps/back-office/src/app/application/pages/triggers/components/triggers-resource-filters/triggers-resource-filters.component.html b/apps/back-office/src/app/application/pages/triggers/components/triggers-resource-filters/triggers-resource-filters.component.html new file mode 100644 index 0000000000..b0d37043f7 --- /dev/null +++ b/apps/back-office/src/app/application/pages/triggers/components/triggers-resource-filters/triggers-resource-filters.component.html @@ -0,0 +1,27 @@ + + +

{{ 'common.filter.few' | translate }}

+
+ + + + + + + + + + {{ 'common.close' | translate }} + + + {{ (data.trigger.filter ? 'common.update' : 'common.create') | translate }} + + +
diff --git a/apps/back-office/src/app/application/pages/triggers/components/triggers-resource-filters/triggers-resource-filters.component.scss b/apps/back-office/src/app/application/pages/triggers/components/triggers-resource-filters/triggers-resource-filters.component.scss new file mode 100644 index 0000000000..22420f74b7 --- /dev/null +++ b/apps/back-office/src/app/application/pages/triggers/components/triggers-resource-filters/triggers-resource-filters.component.scss @@ -0,0 +1,14 @@ +:host { + display: flex; + flex-direction: column; + gap: 1em; + height: 100%; +} + +.expanded-filter { + padding-block: 1em; + display: flex; + flex-direction: column; + gap: 1em; + cursor: auto; +} diff --git a/apps/back-office/src/app/application/pages/triggers/components/triggers-resource-filters/triggers-resource-filters.component.spec.ts b/apps/back-office/src/app/application/pages/triggers/components/triggers-resource-filters/triggers-resource-filters.component.spec.ts new file mode 100644 index 0000000000..6749127fb8 --- /dev/null +++ b/apps/back-office/src/app/application/pages/triggers/components/triggers-resource-filters/triggers-resource-filters.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TriggersResourceFiltersComponent } from './triggers-resource-filters.component'; + +describe('TriggersResourceFiltersComponent', () => { + let component: TriggersResourceFiltersComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [TriggersResourceFiltersComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TriggersResourceFiltersComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/back-office/src/app/application/pages/triggers/components/triggers-resource-filters/triggers-resource-filters.component.ts b/apps/back-office/src/app/application/pages/triggers/components/triggers-resource-filters/triggers-resource-filters.component.ts new file mode 100644 index 0000000000..ad4d44e953 --- /dev/null +++ b/apps/back-office/src/app/application/pages/triggers/components/triggers-resource-filters/triggers-resource-filters.component.ts @@ -0,0 +1,77 @@ +import { Component, Inject, Input, OnInit } from '@angular/core'; +import { FormBuilder, UntypedFormGroup } from '@angular/forms'; +import { + Access, + CustomNotification, + FiltersService, + Resource, +} from '@oort-front/shared'; +import { TranslateService } from '@ngx-translate/core'; +import { get } from 'lodash'; +import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; + +/** + * Dialog data interface. + */ +interface DialogData { + trigger: CustomNotification; + resource: Resource; +} + +/** + * Component for displaying the filtering options for the resource triggers. + */ +@Component({ + selector: 'app-triggers-resource-filters', + templateUrl: './triggers-resource-filters.component.html', + styleUrls: ['./triggers-resource-filters.component.scss'], +}) +export class TriggersResourceFiltersComponent implements OnInit { + /** Id of the opened application */ + @Input() applicationId!: string; + /** If the resource is disabled */ + @Input() disabled = false; + + /** Filter fields */ + public filterFields: any[] = []; + /** Form group */ + public form: UntypedFormGroup = new UntypedFormGroup({}); + + /** + * Component for displaying the filtering options for the resource triggers. + * + * @param data dialog data + * @param translate Angular translate service + * @param fb Angular form builder + * @param filtersService Filters service + * @param dialogRef This is the reference of the dialog that will be opened. + */ + constructor( + @Inject(DIALOG_DATA) public data: DialogData, + public translate: TranslateService, + private fb: FormBuilder, + public filtersService: FiltersService, + private dialogRef: DialogRef + ) {} + + async ngOnInit(): Promise { + const filters = this.data.trigger.filter; + this.form = this.createFilterFormGroup(filters); + + this.filterFields = get(this.data.resource, 'metadata', []) + .filter((x: any) => x.filterable !== false) + .map((x: any) => ({ ...x })); + } + + /** + * Create filters filter group from value + * + * @param filter initial value + * @returns filter as form group + */ + private createFilterFormGroup(filter?: any) { + return this.fb.group({ + filter: this.filtersService.createFilterGroup(filter), + }); + } +} diff --git a/apps/back-office/src/app/application/pages/triggers/graphql/mutations.ts b/apps/back-office/src/app/application/pages/triggers/graphql/mutations.ts new file mode 100644 index 0000000000..74696d2aed --- /dev/null +++ b/apps/back-office/src/app/application/pages/triggers/graphql/mutations.ts @@ -0,0 +1,33 @@ +import { gql } from 'apollo-angular'; + +/** Edit triggers filters mutation */ +export const EDIT_CUSTOM_NOTIFICATION_FILTERS = gql` + mutation editCustomNotification( + $application: ID! + $id: ID! + $triggersFilters: JSON + ) { + editCustomNotification( + application: $application + id: $id + triggersFilters: $triggersFilters + ) { + id + name + description + schedule + notificationType + resource + layout + template + recipients + recipientsType + status + onRecordCreation + onRecordUpdate + applicationTrigger + redirect + filter + } + } +`; diff --git a/apps/back-office/src/app/application/pages/triggers/graphql/queries.ts b/apps/back-office/src/app/application/pages/triggers/graphql/queries.ts new file mode 100644 index 0000000000..105baef0e8 --- /dev/null +++ b/apps/back-office/src/app/application/pages/triggers/graphql/queries.ts @@ -0,0 +1,100 @@ +import { gql } from 'apollo-angular'; + +/** Graphql query for getting resources with a filter and more data */ +export const GET_RESOURCES = gql` + query GetResources( + $first: Int + $afterCursor: ID + $filter: JSON + $sortField: String + $sortOrder: String + $application: ID! + ) { + resources( + first: $first + afterCursor: $afterCursor + filter: $filter + sortField: $sortField + sortOrder: $sortOrder + ) { + edges { + node { + id + name + customNotifications(application: $application) { + id + name + description + schedule + notificationType + resource + layout + template + recipients + recipientsType + status + onRecordCreation + onRecordUpdate + applicationTrigger + redirect + filter + } + } + cursor + } + totalCount + pageInfo { + hasNextPage + endCursor + } + } + } +`; + +/** GraphQL query to get a single resource */ +export const GET_RESOURCE = gql` + query GetResources($id: ID!, $application: ID!) { + resource(id: $id) { + id + name + fields + hasLayouts + queryName + customNotifications(application: $application) { + id + name + description + schedule + notificationType + resource + layout + template + recipients + recipientsType + status + onRecordCreation + onRecordUpdate + applicationTrigger + redirect + filter + } + metadata { + name + type + editor + filter + multiSelect + options + fields { + name + type + editor + filter + multiSelect + options + } + usedIn + } + } + } +`; diff --git a/apps/back-office/src/app/application/pages/triggers/triggers-routing.module.ts b/apps/back-office/src/app/application/pages/triggers/triggers-routing.module.ts new file mode 100644 index 0000000000..3a207a7bff --- /dev/null +++ b/apps/back-office/src/app/application/pages/triggers/triggers-routing.module.ts @@ -0,0 +1,20 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; +import { TriggersComponent } from './triggers.component'; + +/** List of routes of triggers module */ +const routes: Routes = [ + { + path: '', + component: TriggersComponent, + }, +]; + +/** + * Triggers routing module. + */ +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class TriggersRoutingModule {} diff --git a/apps/back-office/src/app/application/pages/triggers/triggers.component.html b/apps/back-office/src/app/application/pages/triggers/triggers.component.html new file mode 100644 index 0000000000..96141384e2 --- /dev/null +++ b/apps/back-office/src/app/application/pages/triggers/triggers.component.html @@ -0,0 +1,130 @@ +

{{ 'common.trigger.few' | translate }}

+ + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ {{ 'common.name' | translate }} + + {{ element.resource.name }} + +
+ + + + + + + + +
+
+
+ + + + +
+
+ + + +
+
+ + + diff --git a/apps/back-office/src/app/application/pages/triggers/triggers.component.scss b/apps/back-office/src/app/application/pages/triggers/triggers.component.scss new file mode 100644 index 0000000000..3b554313bd --- /dev/null +++ b/apps/back-office/src/app/application/pages/triggers/triggers.component.scss @@ -0,0 +1,14 @@ +.expanded-form { + overflow: hidden; + display: flex; + flex-direction: column; + justify-content: center; + + & > * { + margin-block: 16px; + } +} + +.mat-column-expandedDetail { + @apply bg-zinc-200; +} diff --git a/apps/back-office/src/app/application/pages/triggers/triggers.component.spec.ts b/apps/back-office/src/app/application/pages/triggers/triggers.component.spec.ts new file mode 100644 index 0000000000..20d393026c --- /dev/null +++ b/apps/back-office/src/app/application/pages/triggers/triggers.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TriggersComponent } from './triggers.component'; + +describe('TriggersComponent', () => { + let component: TriggersComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [TriggersComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TriggersComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/back-office/src/app/application/pages/triggers/triggers.component.ts b/apps/back-office/src/app/application/pages/triggers/triggers.component.ts new file mode 100644 index 0000000000..d05217c516 --- /dev/null +++ b/apps/back-office/src/app/application/pages/triggers/triggers.component.ts @@ -0,0 +1,611 @@ +import { Component, OnInit } from '@angular/core'; +import { + Resource, + ResourcesQueryResponse, + UnsubscribeComponent, + updateQueryUniqueValues, + CustomNotification, + ApplicationService, + ResourceQueryResponse, + cronValidator, + ConfirmService, +} from '@oort-front/shared'; +import { + animate, + state, + style, + transition, + trigger, +} from '@angular/animations'; +import { Apollo, QueryRef } from 'apollo-angular'; +import { + handleTablePageEvent, + SnackbarService, + UIPageChangeEvent, +} from '@oort-front/ui'; +import { takeUntil } from 'rxjs'; +import { GET_RESOURCE, GET_RESOURCES } from './graphql/queries'; +import { Triggers, TriggersType } from './triggers.types'; +import { clone, get, isEqual, isNil } from 'lodash'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { Dialog } from '@angular/cdk/dialog'; +import { TranslateService } from '@ngx-translate/core'; + +/** Default page size */ +const DEFAULT_PAGE_SIZE = 10; + +/** Interface of table elements */ +interface TableTriggerResourceElement { + resource: Resource; + triggers: { + name: TriggersType; + icon: string; + variant: string; + tooltip: string; + }[]; +} + +/** + * Triggers page component for application. + */ +@Component({ + selector: 'app-triggers', + templateUrl: './triggers.component.html', + styleUrls: ['./triggers.component.scss'], + animations: [ + trigger('detailExpand', [ + state('collapsed', style({ height: '0px', minHeight: '0' })), + state('expanded', style({ height: '*' })), + transition( + 'expanded <=> collapsed', + animate('225ms cubic-bezier(0.4, 0.0, 0.2, 1)') + ), + ]), + ], +}) +export class TriggersComponent extends UnsubscribeComponent implements OnInit { + /** TABLE ELEMENTS */ + /** Resources query */ + private resourcesQuery!: QueryRef; + /** Displayed columns */ + public displayedColumns: string[] = ['name', 'info']; + /** Resources */ + public resources = new Array(); + /** Cached resources */ + public cachedResources: Resource[] = []; + + /** FILTERING */ + /** Filter */ + public filter: any; + /** Filter loading */ + public filterLoading = false; + + /** SINGLE RESOURCE */ + /** Updating status */ + public updating = false; + /** Opened resource */ + public openedResource?: Resource; + + /** TRIGGERS */ + /** Trigger form group */ + public triggerFormGroup!: ReturnType; + /** Triggers enum */ + public TriggersEnum = Triggers; + + /** PAGINATION */ + /** Loading status */ + public loading = true; + /** Page info */ + public pageInfo = { + pageIndex: 0, + pageSize: DEFAULT_PAGE_SIZE, + length: 0, + endCursor: '', + }; + + /** Current application id */ + public applicationId!: string; + + /** + * Triggers page component for application. + * + * @param apollo Apollo client service + * @param snackBar shared snackbar service + * @param applicationService Shared application service + * @param fb Angular form builder + * @param dialog Dialog service + * @param translate Angular translate service + * @param confirmService Shared confirmation service + */ + constructor( + private apollo: Apollo, + private snackBar: SnackbarService, + private applicationService: ApplicationService, + private fb: FormBuilder, + public dialog: Dialog, + private translate: TranslateService, + private confirmService: ConfirmService + ) { + super(); + this.applicationId = + this.applicationService.application.getValue()?.id ?? ''; + } + + /** Load the resources. */ + ngOnInit(): void { + this.resourcesQuery = this.apollo.watchQuery({ + query: GET_RESOURCES, + variables: { + first: DEFAULT_PAGE_SIZE, + sortField: 'name', + sortOrder: 'asc', + application: this.applicationId, + }, + }); + + this.resourcesQuery.valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe(({ data, loading }) => { + this.updateValues(data, loading); + }); + } + + /** + * Custom TrackByFunction to compute the identity of items in an iterable, so when + * updating fields the scroll don't get back to the beginning of the table. + * + * @param index index of the item in the table + * @param item item table + * @returns unique value for all unique inputs + */ + public getUniqueIdentifier(index: number, item: any): any { + return item.resource.id; + } + + /** + * Filters resources and updates table. + * + * @param filter filter event. + */ + public onFilter(filter: any): void { + this.filterLoading = true; + this.filter = filter; + this.fetchResources(true); + } + + /** + * Handles page event. + * + * @param e page event. + */ + public onPage(e: UIPageChangeEvent): void { + const cachedData = handleTablePageEvent( + e, + this.pageInfo, + this.cachedResources + ); + if (cachedData && cachedData.length === this.pageInfo.pageSize) { + this.resources = this.setTableElements(cachedData); + } else { + this.fetchResources(); + } + } + + /** + * Delete selected trigger + * + * @param trigger Selected trigger + */ + public onDeleteTrigger(trigger: CustomNotification): void { + const dialogRef = this.confirmService.openConfirmModal({ + title: this.translate.instant('common.deleteObject', { + name: this.translate.instant('common.trigger.one').toLowerCase(), + }), + content: this.translate.instant('components.triggers.confirmDelete', { + name: trigger.name, + }), + confirmText: this.translate.instant('components.confirmModal.delete'), + confirmVariant: 'danger', + }); + dialogRef.closed.pipe(takeUntil(this.destroy$)).subscribe((value: any) => { + if (value) { + this.applicationService.deleteCustomNotification( + trigger.id as string, + () => { + const index = this.openedResource?.customNotifications?.findIndex( + (cn: CustomNotification) => isEqual(cn.id, trigger.id) + ); + if (!isNil(index) && index !== -1) { + const customNotifications = clone( + this.openedResource?.customNotifications + ) as CustomNotification[]; + customNotifications.splice(index, 1); + + this.refreshResourcesOnCustomNotificationUpdate( + customNotifications + ); + } + } + ); + } + }); + } + + /** + * Open modal to edit selected trigger + * + * @param trigger Selected trigger + * @param triggerType Trigger type + */ + public async onEditTrigger( + trigger: CustomNotification, + triggerType: TriggersType + ): Promise { + const triggerFormGroup = await this.getTriggerForm(trigger, triggerType); + const { ManageTriggerModalComponent } = await import( + './components/manage-trigger-modal/manage-trigger-modal.component' + ); + const dialogRef = this.dialog.open(ManageTriggerModalComponent, { + data: { + trigger, + triggerType, + formGroup: triggerFormGroup, + resource: this.openedResource, + }, + }); + + dialogRef.closed.pipe(takeUntil(this.destroy$)).subscribe((value) => { + if (value) { + this.applicationService.updateCustomNotification( + trigger.id as string, + value, + () => { + this.handleTriggerEdition(value); + } + ); + } + }); + } + + /** + * Handle trigger edition + * + * @param trigger trigger object updated + */ + public handleTriggerEdition(trigger: CustomNotification): void { + const index = this.openedResource?.customNotifications?.findIndex( + (cn: CustomNotification) => isEqual(cn.id, trigger.id) + ); + if (!isNil(index) && index !== -1) { + const customNotifications = clone( + this.openedResource?.customNotifications + ) as CustomNotification[]; + const updatedTrigger = { + ...customNotifications[index], + ...trigger, + }; + customNotifications[index] = updatedTrigger; + this.refreshResourcesOnCustomNotificationUpdate(customNotifications); + } + } + + /** + * Open modal to create a new trigger of the selected type + * + * @param triggerType Trigger type + */ + public async onCreateTrigger(triggerType: TriggersType): Promise { + const triggerFormGroup = await this.getTriggerForm(null, triggerType); + const { ManageTriggerModalComponent } = await import( + './components/manage-trigger-modal/manage-trigger-modal.component' + ); + const dialogRef = this.dialog.open(ManageTriggerModalComponent, { + data: { + triggerType, + formGroup: triggerFormGroup, + resource: this.openedResource, + }, + }); + + dialogRef.closed.pipe(takeUntil(this.destroy$)).subscribe((value) => { + if (value) { + this.applicationService.addCustomNotification( + value, + (newTrigger: any) => { + if (newTrigger) { + const newValue = { + ...value, + ...newTrigger, + }; + + const customNotifications = ( + this.openedResource?.customNotifications || [] + ).concat([newValue]) as CustomNotification[]; + + this.refreshResourcesOnCustomNotificationUpdate( + customNotifications + ); + } + } + ); + } + }); + } + + /** + * Toggles the accordion for the clicked resource and fetches its forms + * + * @param resource The resource element for the resource to be toggled + */ + public toggleResource(resource: Resource): void { + if (resource.id === this.openedResource?.id) { + this.openedResource = undefined; + } else { + this.updating = true; + this.apollo + .query({ + query: GET_RESOURCE, + variables: { + id: resource.id, + application: this.applicationId, + }, + }) + .pipe(takeUntil(this.destroy$)) + .subscribe(({ data }) => { + if (data.resource) { + this.openedResource = data.resource; + } + this.updating = false; + }); + } + } + + /** + * Build trigger reactive form group. + * + * @param trigger Selected trigger, if any + * @param triggerType Trigger type + * @returns Notification form group + */ + private getTriggerForm( + trigger: CustomNotification | null, + triggerType: TriggersType + ): Promise { + return new Promise((resolve) => { + const formGroup = this.fb.group({ + name: [get(trigger, 'name', ''), Validators.required], + applicationTrigger: true, + status: 'active', + schedule: [get(trigger, 'schedule', '')], + onRecordCreation: [get(trigger, 'onRecordCreation', false)], + onRecordUpdate: [get(trigger, 'onRecordUpdate', false)], + notificationType: [ + get(trigger, 'notificationType', 'email'), + Validators.required, + ], + resource: [ + { + value: get(trigger, 'resource', this.openedResource?.id), + disabled: true, + }, + Validators.required, + ], + layout: [get(trigger, 'layout', ''), Validators.required], + template: [get(trigger, 'template', ''), Validators.required], + recipientsType: [ + get(trigger, 'recipientsType', ''), + Validators.required, + ], + recipients: [get(trigger, 'recipients', ''), Validators.required], + redirect: this.fb.group({ + active: [get(trigger, 'redirect.active', '')], + type: [get(trigger, 'redirect.type', '')], + url: [get(trigger, 'redirect.url', '')], + }), + }); + + if (triggerType === Triggers.cronBased) { + formGroup.controls.schedule.addValidators([ + Validators.required, + cronValidator(), + ]); + formGroup.controls.schedule.updateValueAndValidity(); + } else if (triggerType === Triggers.onRecordCreation) { + formGroup.controls.onRecordCreation.setValue(true); + } else if (triggerType === Triggers.onRecordUpdate) { + formGroup.controls.onRecordUpdate.setValue(true); + } + + resolve(formGroup); + }); + } + + /** + * Serialize single table element from resource + * + * @param resource resource to serialize + * @returns serialized element + */ + private setTableElement(resource: Resource): TableTriggerResourceElement { + return { + resource, + triggers: [ + Triggers.cronBased, + Triggers.onRecordCreation, + Triggers.onRecordUpdate, + ].map((trigger) => ({ + name: trigger, + icon: this.getIcon(trigger), + variant: this.getVariant(resource, trigger), + tooltip: this.getTooltip(resource, trigger), + })), + }; + } + + /** + * Gets the correspondent icon for a given trigger + * + * @param trigger The trigger name + * @returns the name of the icon to be displayed + */ + private getIcon(trigger: Triggers) { + switch (trigger) { + case Triggers.cronBased: + return 'schedule_send'; + case Triggers.onRecordCreation: + return 'add_circle'; + case Triggers.onRecordUpdate: + return 'edit'; + } + } + + /** + * Gets the correspondent variant for a given trigger + * + * @param resource A resource + * @param trigger The trigger name + * @returns the name of the variant to be displayed + */ + private getVariant(resource: Resource, trigger: Triggers) { + const fieldName = trigger === Triggers.cronBased ? 'schedule' : trigger; + const hasTrigger = + resource.customNotifications?.some( + (notification: CustomNotification) => notification[fieldName] + ) ?? false; + switch (hasTrigger) { + case true: + return 'primary'; + case false: + return 'grey'; + } + } + + /** + * Gets the correspondent tooltip for a given trigger + * + * @param resource A resource + * @param trigger The trigger name + * @returns the tooltip to be displayed + */ + private getTooltip(resource: Resource, trigger: Triggers) { + const fieldName = trigger === Triggers.cronBased ? 'schedule' : trigger; + const hasTrigger = + resource.customNotifications?.some( + (notification: CustomNotification) => notification[fieldName] + ) ?? false; + switch (hasTrigger) { + case true: { + switch (trigger) { + case Triggers.cronBased: + return 'components.triggers.tooltip.withCronBasedTrigger'; + case Triggers.onRecordCreation: + return 'components.triggers.tooltip.withOnRecordCreationTrigger'; + case Triggers.onRecordUpdate: + return 'components.triggers.tooltip.withOnRecordUpdateTrigger'; + } + } + // eslint-disable-next-line no-fallthrough + case false: { + switch (trigger) { + case Triggers.cronBased: + return 'components.triggers.tooltip.withoutCronBasedTrigger'; + case Triggers.onRecordCreation: + return 'components.triggers.tooltip.withoutOnRecordCreationTrigger'; + case Triggers.onRecordUpdate: + return 'components.triggers.tooltip.withoutOnRecordUpdateTrigger'; + } + } + } + } + + /** + * Serialize list of table elements from resource + * + * @param resources resources to serialize + * @returns serialized elements + */ + private setTableElements( + resources: Resource[] + ): TableTriggerResourceElement[] { + return resources.map((x: Resource) => this.setTableElement(x)); + } + + /** + * Update resource data value + * + * @param data query response data + * @param loading loading status + */ + private updateValues(data: ResourcesQueryResponse, loading: boolean) { + const mappedValues = data.resources?.edges?.map((x) => x.node); + this.cachedResources = updateQueryUniqueValues( + this.cachedResources, + mappedValues + ); + this.resources = this.setTableElements( + this.cachedResources.slice( + this.pageInfo.pageSize * this.pageInfo.pageIndex, + this.pageInfo.pageSize * (this.pageInfo.pageIndex + 1) + ) + ); + this.pageInfo.length = data.resources.totalCount; + this.pageInfo.endCursor = data.resources.pageInfo.endCursor; + this.loading = loading; + this.updating = loading; + this.filterLoading = false; + } + + /** + * Update resources query. + * + * @param refetch erase previous query results + */ + private fetchResources(refetch?: boolean): void { + this.updating = true; + if (refetch) { + this.cachedResources = []; + this.pageInfo.pageIndex = 0; + this.resourcesQuery.refetch({ + first: this.pageInfo.pageSize, + filter: this.filter, + afterCursor: null, + }); + } else { + this.loading = true; + this.resourcesQuery + .fetchMore({ + variables: { + first: this.pageInfo.pageSize, + filter: this.filter, + afterCursor: this.pageInfo.endCursor, + }, + }) + .then((results: any) => + this.updateValues(results.data, results.loading) + ); + } + } + + /** + * Set the triggers lists by type of the current opened resource + * + * @param customNotifications updated custom notifications list of the opened resource + */ + private refreshResourcesOnCustomNotificationUpdate( + customNotifications: CustomNotification[] + ): void { + this.openedResource = { + ...this.openedResource, + customNotifications, + }; + const tableElements = clone(this.resources); + const resourceIndex = tableElements.findIndex((element) => + isEqual(element.resource.id, this.openedResource?.id) + ); + if (!isNil(resourceIndex) && resourceIndex !== -1) { + const updatedElement = this.setTableElement(this.openedResource); + tableElements[resourceIndex] = updatedElement; + this.resources = tableElements; + } + } +} diff --git a/apps/back-office/src/app/application/pages/triggers/triggers.module.ts b/apps/back-office/src/app/application/pages/triggers/triggers.module.ts new file mode 100644 index 0000000000..40f6dc42d2 --- /dev/null +++ b/apps/back-office/src/app/application/pages/triggers/triggers.module.ts @@ -0,0 +1,74 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { TriggersComponent } from './triggers.component'; +import { + AlertModule, + ButtonModule, + CheckboxModule, + DialogModule, + DividerModule, + ErrorMessageModule, + FormWrapperModule, + IconModule, + MenuModule, + PaginatorModule, + RadioModule, + SelectMenuModule, + TableModule, + TooltipModule, + DateModule as UiDateModule, +} from '@oort-front/ui'; +import { + CronExpressionControlModule, + FilterModule, + ListFilterComponent, + ReadableCronModule, + SkeletonTableModule, +} from '@oort-front/shared'; +import { TranslateModule } from '@ngx-translate/core'; +import { TriggersFilterComponent } from './components/triggers-filter/triggers-filter.component'; +import { ReactiveFormsModule } from '@angular/forms'; +import { TriggersRoutingModule } from './triggers-routing.module'; +import { TriggersResourceFiltersComponent } from './components/triggers-resource-filters/triggers-resource-filters.component'; +import { ManageTriggerModalComponent } from './components/manage-trigger-modal/manage-trigger-modal.component'; +import { TriggersListComponent } from './components/triggers-list/triggers-list.component'; + +/** + * Triggers page module. + */ +@NgModule({ + declarations: [ + TriggersComponent, + TriggersFilterComponent, + TriggersResourceFiltersComponent, + ManageTriggerModalComponent, + TriggersListComponent, + ], + imports: [ + CommonModule, + TranslateModule, + ButtonModule, + PaginatorModule, + IconModule, + FormWrapperModule, + ReactiveFormsModule, + ListFilterComponent, + UiDateModule, + TableModule, + SkeletonTableModule, + TriggersRoutingModule, + TooltipModule, + FilterModule, + CronExpressionControlModule, + ReadableCronModule, + DialogModule, + SelectMenuModule, + DividerModule, + RadioModule, + ErrorMessageModule, + AlertModule, + MenuModule, + CheckboxModule, + ], +}) +export class TriggersModule {} diff --git a/apps/back-office/src/app/application/pages/triggers/triggers.types.ts b/apps/back-office/src/app/application/pages/triggers/triggers.types.ts new file mode 100644 index 0000000000..964394ef45 --- /dev/null +++ b/apps/back-office/src/app/application/pages/triggers/triggers.types.ts @@ -0,0 +1,22 @@ +/** Triggers type for Resource */ +export enum Triggers { + cronBased = 'cronBased', + onRecordCreation = 'onRecordCreation', + onRecordUpdate = 'onRecordUpdate', +} + +/** Triggers type for Resource */ +export enum NotificationType { + email = 'email', + notification = 'notification', +} + +/** + * Triggers typw + */ +export const triggers = [ + 'cronBased', + 'onRecordCreation', + 'onRecordUpdate', +] as const; +export type TriggersType = (typeof triggers)[number]; diff --git a/apps/back-office/src/app/dashboard/pages/resources/resources.component.ts b/apps/back-office/src/app/dashboard/pages/resources/resources.component.ts index 028ccb7e63..fe782a2f17 100644 --- a/apps/back-office/src/app/dashboard/pages/resources/resources.component.ts +++ b/apps/back-office/src/app/dashboard/pages/resources/resources.component.ts @@ -159,7 +159,7 @@ export class ResourcesComponent extends UnsubscribeComponent implements OnInit { } /** - * Filters applications and updates table. + * Filters resources and updates table. * * @param filter filter event. */ diff --git a/apps/back-office/src/environments/environment.ts b/apps/back-office/src/environments/environment.ts index 90736b3ddb..e33b60a66d 100644 --- a/apps/back-office/src/environments/environment.ts +++ b/apps/back-office/src/environments/environment.ts @@ -19,7 +19,7 @@ import { Environment } from './environment.type'; * Authentication configuration */ const authConfig: AuthConfig = { - issuer: 'https://id-dev.oortcloud.tech/auth/realms/oort', + issuer: 'https://id-dev.oortcloud.tech/realms/oort', redirectUri: 'http://localhost:4200/', postLogoutRedirectUri: 'http://localhost:4200/auth/', clientId: 'oort-client', diff --git a/apps/front-office/src/environments/environment.ts b/apps/front-office/src/environments/environment.ts index 580ed2b528..fb3f7478c1 100644 --- a/apps/front-office/src/environments/environment.ts +++ b/apps/front-office/src/environments/environment.ts @@ -17,7 +17,7 @@ import { Environment } from './environment.type'; /** Authentication configuration of the module. */ const authConfig: AuthConfig = { - issuer: 'https://id-dev.oortcloud.tech/auth/realms/oort', + issuer: 'https://id-dev.oortcloud.tech/realms/oort', redirectUri: 'http://localhost:4200/', postLogoutRedirectUri: 'http://localhost:4200/auth/', clientId: 'oort-client', diff --git a/libs/shared/src/i18n/en.json b/libs/shared/src/i18n/en.json index 0deeec1738..921ac17f32 100644 --- a/libs/shared/src/i18n/en.json +++ b/libs/shared/src/i18n/en.json @@ -474,6 +474,11 @@ "insert": "Type '{' to inject expressions and variables" } }, + "trigger": { + "few": "Triggers", + "none": "No triggers", + "one": "Trigger" + }, "true": "True", "type": { "few": "Types", @@ -767,6 +772,7 @@ "recipients": { "distributionList": "Use a distribution list", "email": "Input a single email address", + "emailField": "Group by dataset email field", "title": "Recipients", "userField": "Group by dataset user field" }, @@ -1032,6 +1038,8 @@ "value": "Value" }, "notifications": { + "noRecord": "No record to be redirected", + "noRedirect": "Can't redirect. Missing information.", "paginator": { "ariaLabel": "Select page of notifications" }, @@ -1316,7 +1324,6 @@ "features": "Features", "newFilter": "New filter", "noFeatures": "No feature detected", - "noFilters": "No access filters detected", "noForms": "No forms detected", "noSteps": "No steps detected", "removeFilter": "Remove filter", @@ -1383,9 +1390,44 @@ }, "type": { "email": "Email", + "notification": "Notification", "title": "Template type" } }, + "triggers": { + "addTrigger": "Add trigger", + "confirmDelete": "Do you confirm the deletion of the trigger {{name}}?", + "createTrigger": "New {{type}} Trigger", + "cronBased": "Cron Based", + "customNotificationRecipientsType": { + "channel": "Channel", + "distributionList": "Distribution list", + "email": "Email", + "emailField": "Email field", + "userField": "User field" + }, + "editTrigger": "Edit {{type}} Trigger", + "noTriggers": "No triggers detected", + "notification": "Notification", + "onRecordCreation": "On Record Creation", + "onRecordUpdate": "On Record Update", + "openFilter": "OPEN FILTER", + "redirect": { + "active": "Add redirection when clicking on notification", + "recordIds": "Redirect to record(s) details", + "type": "Redirection type", + "url": "Redirect to page" + }, + "selectNotificationType": "Please select a notification type first", + "tooltip": { + "withCronBasedTrigger": "Resource with a cron based trigger", + "withOnRecordCreationTrigger": "Resource with a trigger on record creation", + "withOnRecordUpdateTrigger": "Resource with a trigger on record update", + "withoutCronBasedTrigger": "Resource without any cron based trigger", + "withoutOnRecordCreationTrigger": "Resource without any trigger on record creation", + "withoutOnRecordUpdateTrigger": "Resource without any trigger on record update" + } + }, "user": { "delete": { "confirmationMessage": "Do you confirm the deletion of the user {{name}}?", diff --git a/libs/shared/src/i18n/fr.json b/libs/shared/src/i18n/fr.json index 1ce44bf70c..f45c32ac34 100644 --- a/libs/shared/src/i18n/fr.json +++ b/libs/shared/src/i18n/fr.json @@ -474,6 +474,11 @@ "insert": "Tapez '{' pour utiliser des expressions et des variables" } }, + "trigger": { + "few": "Déclencheurs", + "none": "Aucun déclencheur", + "one": "Déclenchement" + }, "true": "Vrai", "type": { "few": "Types", @@ -773,6 +778,7 @@ "recipients": { "distributionList": "Utiliser une liste de distribution", "email": "Utiliser une adresse email unique", + "emailField": "Grouper par e-mail détectés dans les enregistrements", "title": "Destinataires", "userField": "Grouper par utilisateurs détectés dans les enregistrements" }, @@ -1043,6 +1049,8 @@ "value": "Valeur" }, "notifications": { + "noRecord": "Aucun enregistrement à rediriger", + "noRedirect": "Impossible de rediriger. Informations manquantes.", "paginator": { "ariaLabel": "Sélectionnez la page de notifications" }, @@ -1395,9 +1403,44 @@ }, "type": { "email": "E-mail", + "notification": "Notification", "title": "Type de modèle" } }, + "triggers": { + "addTrigger": "Ajouter un déclencheur", + "confirmDelete": "Confirmez-vous la suppression du déclencheur {{name}} ?", + "createTrigger": "Nouveau {{type}} déclencheur", + "cronBased": "Basé sur Cron", + "customNotificationRecipientsType": { + "channel": "Canal", + "distributionList": "Liste de distribution", + "email": "E-mail", + "emailField": "Champ e-mail", + "userField": "Champ de l'utilisateur" + }, + "editTrigger": "Modifier {{type}} déclencheur", + "noTriggers": "Aucun déclencheur détecté", + "notification": "Notification", + "onRecordCreation": "Création d'un enregistrement", + "onRecordUpdate": "Mise à jour sur l'enregistrement", + "openFilter": "OUVRIR LE FILTRE", + "redirect": { + "active": "Ajouter une redirection lors d'un clic sur une notification", + "recordIds": "Redirection vers les détails du ou des enregistrements", + "type": "Type de redirection", + "url": "Redirection vers la page" + }, + "selectNotificationType": "Veuillez d'abord sélectionner un type de notification", + "tooltip": { + "withCronBasedTrigger": "Ressource avec un déclencheur basé sur cron", + "withOnRecordCreationTrigger": "Ressource avec un déclencheur lors de la création d'un enregistrement", + "withOnRecordUpdateTrigger": "Ressource avec un déclencheur lors de la mise à jour de l'enregistrement", + "withoutCronBasedTrigger": "Ressource sans aucun déclencheur basé sur cron", + "withoutOnRecordCreationTrigger": "Ressource sans aucun déclencheur lors de la création d'un enregistrement", + "withoutOnRecordUpdateTrigger": "Ressource sans aucun déclencheur lors de la mise à jour de l'enregistrement" + } + }, "user": { "delete": { "confirmationMessage": "Voulez-vous vraiment supprimer l'utilisateur {{name}} ?", diff --git a/libs/shared/src/i18n/test.json b/libs/shared/src/i18n/test.json index 5cdeba4fd7..03ab1129e7 100644 --- a/libs/shared/src/i18n/test.json +++ b/libs/shared/src/i18n/test.json @@ -474,6 +474,11 @@ "insert": "******" } }, + "trigger": { + "few": "******", + "none": "******", + "one": "******" + }, "true": "******", "type": { "few": "******", @@ -767,6 +772,7 @@ "recipients": { "distributionList": "******", "email": "******", + "emailField": "******", "title": "******", "userField": "******" }, @@ -1032,6 +1038,8 @@ "value": "******" }, "notifications": { + "noRecord": "******", + "noRedirect": "******", "paginator": { "ariaLabel": "******" }, @@ -1316,7 +1324,6 @@ "features": "******", "newFilter": "******", "noFeatures": "******", - "noFilters": "******", "noForms": "******", "noSteps": "******", "removeFilter": "******", @@ -1383,9 +1390,44 @@ }, "type": { "email": "******", + "notification": "******", "title": "******" } }, + "triggers": { + "addTrigger": "******", + "confirmDelete": "****** {{name}} ******", + "createTrigger": "****** {{type}} ******", + "cronBased": "******", + "customNotificationRecipientsType": { + "channel": "******", + "distributionList": "******", + "email": "******", + "emailField": "******", + "userField": "******" + }, + "editTrigger": "****** {{type}} ******", + "noTriggers": "******", + "notification": "******", + "onRecordCreation": "******", + "onRecordUpdate": "******", + "openFilter": "******", + "redirect": { + "active": "******", + "recordIds": "******", + "type": "******", + "url": "******" + }, + "selectNotificationType": "******", + "tooltip": { + "withCronBasedTrigger": "******", + "withOnRecordCreationTrigger": "******", + "withOnRecordUpdateTrigger": "******", + "withoutCronBasedTrigger": "******", + "withoutOnRecordCreationTrigger": "******", + "withoutOnRecordUpdateTrigger": "******" + } + }, "user": { "delete": { "confirmationMessage": "****** {{name}} ******", diff --git a/libs/shared/src/index.ts b/libs/shared/src/index.ts index 042efbe038..8d99efc6c7 100644 --- a/libs/shared/src/index.ts +++ b/libs/shared/src/index.ts @@ -26,6 +26,7 @@ export * from './lib/services/editor/editor.service'; export * from './lib/services/rest/rest.service'; export * from './lib/services/map/map-layers.service'; export * from './lib/services/form-builder/form-builder.service'; +export * from './lib/services/filters/filters.service'; // === DIRECTIVES === export * from './lib/directives/skeleton/public-api'; @@ -55,6 +56,9 @@ export * from './lib/models/layout.model'; export * from './lib/models/aggregation.model'; export * from './lib/models/reference-data.model'; export * from './lib/models/metadata.model'; +export * from './lib/models/custom-notification.model'; +export * from './lib/models/template.model'; +export * from './lib/models/distribution-list.model'; // === COMPONENTS === export * from './lib/components/aggregation/edit-aggregation-modal/edit-aggregation-modal.component'; @@ -73,6 +77,7 @@ export * from './lib/components/confirm-modal/public-api'; export * from './lib/components/user-summary/public-api'; export * from './lib/components/users/public-api'; export * from './lib/components/templates/public-api'; +export * from './lib/components/templates/components/edit-template-modal/public-api'; export * from './lib/components/roles/public-api'; export * from './lib/components/convert-modal/public-api'; export * from './lib/components/record-history/public-api'; @@ -96,6 +101,7 @@ export * from './lib/components/users/public-api'; export * from './lib/components/upload-records/public-api'; export * from './lib/components/upload-menu/public-api'; export * from './lib/components/payload-modal/payload-modal.component'; +export * from './lib/components/filter/public-api'; // Export of controls export * from './lib/components/controls/public-api'; @@ -117,6 +123,7 @@ export * from './lib/survey/components/test-service-dropdown/test-service-dropdo /** Grid Layouts */ export * from './lib/components/grid-layout/edit-layout-modal/public-api'; +export * from './lib/components/grid-layout/add-layout-modal/add-layout-modal.component'; // === UI === export * from './lib/components/ui/aggregation-builder/public-api'; diff --git a/libs/shared/src/lib/components/filter/public-api.ts b/libs/shared/src/lib/components/filter/public-api.ts new file mode 100644 index 0000000000..5c07e3d9e2 --- /dev/null +++ b/libs/shared/src/lib/components/filter/public-api.ts @@ -0,0 +1 @@ +export * from './filter.module'; diff --git a/libs/shared/src/lib/components/grid-layout/add-layout-modal/public-api.ts b/libs/shared/src/lib/components/grid-layout/add-layout-modal/public-api.ts new file mode 100644 index 0000000000..b348bcb415 --- /dev/null +++ b/libs/shared/src/lib/components/grid-layout/add-layout-modal/public-api.ts @@ -0,0 +1 @@ +export * from './add-layout-modal.component'; diff --git a/libs/shared/src/lib/components/layout/graphql/queries.ts b/libs/shared/src/lib/components/layout/graphql/queries.ts index 5c324959ce..431e6c4c8e 100644 --- a/libs/shared/src/lib/components/layout/graphql/queries.ts +++ b/libs/shared/src/lib/components/layout/graphql/queries.ts @@ -12,6 +12,7 @@ export const GET_NOTIFICATIONS = gql` action content createdAt + redirect channel { id title diff --git a/libs/shared/src/lib/components/layout/layout.component.html b/libs/shared/src/lib/components/layout/layout.component.html index e640fbd8ab..a244f8999b 100644 --- a/libs/shared/src/lib/components/layout/layout.component.html +++ b/libs/shared/src/lib/components/layout/layout.component.html @@ -132,19 +132,27 @@

{{ 'components.notifications.readAll' | translate }}
- + + +
{ - this.formGroup.get('recipients')?.setValue(null); - if (value === 'email') { - this.formGroup.get('recipients.')?.addValidators(Validators.email); - } else { - this.formGroup.get('recipients.')?.removeValidators(Validators.email); - } - }); + // Build resource query this.resourcesQuery = this.apollo.watchQuery({ query: GET_RESOURCES, @@ -196,6 +186,7 @@ export class EditNotificationModalComponent get(this.notification, 'schedule', ''), [Validators.required, cronValidator()], ], + status: 'active', notificationType: [{ value: 'email', disabled: true }], resource: [get(this.notification, 'resource', ''), Validators.required], layout: [get(this.notification, 'layout', ''), Validators.required], @@ -294,20 +285,28 @@ export class EditNotificationModalComponent disableClose: true, }); dialogRef.closed.pipe(takeUntil(this.destroy$)).subscribe((value: any) => { - if (value) + if (value) { + const content = + value.type === TemplateTypeEnum.EMAIL + ? { + subject: value.subject, + body: value.body, + } + : { + title: value.title, + description: value.description, + }; this.applicationService.addTemplate( { name: value.name, - type: TemplateTypeEnum.EMAIL, - content: { - subject: value.subject, - body: value.body, - }, + type: value.type, + content, }, (template: Template) => { this.formGroup.get('template')?.setValue(template.id || null); } ); + } }); } } diff --git a/libs/shared/src/lib/components/record-modal/graphql/queries.ts b/libs/shared/src/lib/components/record-modal/graphql/queries.ts index 493436956d..dc46dace3e 100644 --- a/libs/shared/src/lib/components/record-modal/graphql/queries.ts +++ b/libs/shared/src/lib/components/record-modal/graphql/queries.ts @@ -10,6 +10,7 @@ export const GET_RECORD_BY_ID = gql` data createdAt modifiedAt + userCanEdit createdBy { name } diff --git a/libs/shared/src/lib/components/record-modal/record-modal.component.ts b/libs/shared/src/lib/components/record-modal/record-modal.component.ts index f1c3b4cfd4..5708c2dbdb 100644 --- a/libs/shared/src/lib/components/record-modal/record-modal.component.ts +++ b/libs/shared/src/lib/components/record-modal/record-modal.component.ts @@ -28,6 +28,7 @@ import { FormActionsModule } from '../form-actions/form-actions.module'; import { DateModule } from '../../pipes/date/date.module'; import { SpinnerModule, ButtonModule } from '@oort-front/ui'; import { DialogModule } from '@oort-front/ui'; +import { isNil } from 'lodash'; /** * Interface that describes the structure of the data that will be shown in the dialog @@ -157,6 +158,9 @@ export class RecordModalComponent }) ).then(({ data }) => { this.record = data.record; + this.canEdit = !isNil(this.data.canUpdate) + ? this.data.canUpdate + : this.record.userCanEdit; this.modifiedAt = this.record.modifiedAt || null; if (!this.data.template) { this.form = this.record.form; diff --git a/libs/shared/src/lib/components/role-summary/role-resources/permissions.types.ts b/libs/shared/src/lib/components/role-summary/role-resources/permissions.types.ts index 0bc93f1db7..bf99f09ba5 100644 --- a/libs/shared/src/lib/components/role-summary/role-resources/permissions.types.ts +++ b/libs/shared/src/lib/components/role-summary/role-resources/permissions.types.ts @@ -1,3 +1,5 @@ +import { Access } from '../../../services/filters/filters.service'; + /** Permission type for Resource */ export enum Permission { SEE = 'canSeeRecords', @@ -5,18 +7,6 @@ export enum Permission { UPDATE = 'canUpdateRecords', DELETE = 'canDeleteRecords', } -/** Role access interface */ -export interface Access { - logic: string; - filters: ( - | { - field: string; - operator: string; - value?: string; - } - | Access - )[]; -} export type ResourceRolePermissions = { [key in Permission]: { diff --git a/libs/shared/src/lib/components/role-summary/role-resources/resource-access-filters/resource-access-filters.component.html b/libs/shared/src/lib/components/role-summary/role-resources/resource-access-filters/resource-access-filters.component.html index c873b9cb3e..477189eb86 100644 --- a/libs/shared/src/lib/components/role-summary/role-resources/resource-access-filters/resource-access-filters.component.html +++ b/libs/shared/src/lib/components/role-summary/role-resources/resource-access-filters/resource-access-filters.component.html @@ -21,7 +21,7 @@

{{ 'components.role.summary.noFilters' | translate }}

{{ 'components.queryBuilder.filter.title' | translate }} - {{ getAccessString(element.access) }} + {{ filtersService.getAccessString(element.access) }} diff --git a/libs/shared/src/lib/components/role-summary/role-resources/resource-access-filters/resource-access-filters.component.ts b/libs/shared/src/lib/components/role-summary/role-resources/resource-access-filters/resource-access-filters.component.ts index 1319a91a7b..fbd63c5e9c 100644 --- a/libs/shared/src/lib/components/role-summary/role-resources/resource-access-filters/resource-access-filters.component.ts +++ b/libs/shared/src/lib/components/role-summary/role-resources/resource-access-filters/resource-access-filters.component.ts @@ -9,14 +9,17 @@ import { trigger, } from '@angular/animations'; import { Resource } from '../../../../models/resource.model'; -import { Access, Permission } from '../permissions.types'; -import { createFilterGroup } from '../../../query-builder/query-builder-forms'; +import { Permission } from '../permissions.types'; import { FormBuilder, UntypedFormArray, UntypedFormGroup, } from '@angular/forms'; import { RestService } from '../../../../services/rest/rest.service'; +import { + Access, + FiltersService, +} from '../../../../services/filters/filters.service'; import { firstValueFrom } from 'rxjs'; type AccessPermissions = { @@ -51,29 +54,6 @@ const BASE_PERMISSIONS = { ], }) export class RoleResourceFiltersComponent implements OnInit { - /** Map of operators to their translation */ - private opMap: { - [key: string]: string; - } = { - eq: this.translate.instant('kendo.grid.filterEqOperator'), - neq: this.translate.instant('kendo.grid.filterNotEqOperator'), - contains: this.translate.instant('kendo.grid.filterContainsOperator'), - doesnotcontain: this.translate.instant( - 'kendo.grid.filterNotContainsOperator' - ), - startswith: this.translate.instant('kendo.grid.filterStartsWithOperator'), - endswith: this.translate.instant('kendo.grid.filterEndsWithOperator'), - isnull: this.translate.instant('kendo.grid.filterIsNullOperator'), - isnotnull: this.translate.instant('kendo.grid.filterIsNotNullOperator'), - isempty: this.translate.instant('kendo.grid.filterIsEmptyOperator'), - isnotempty: this.translate.instant('kendo.grid.filterIsNotEmptyOperator'), - in: this.translate.instant('kendo.grid.filterIsInOperator'), - notin: this.translate.instant('kendo.grid.filterIsNotInOperator'), - gt: this.translate.instant('kendo.grid.filterGtOperator'), - gte: this.translate.instant('kendo.grid.filterGteOperator'), - lt: this.translate.instant('kendo.grid.filterLtOperator'), - lte: this.translate.instant('kendo.grid.filterLteOperator'), - }; /** List of permission types */ public permissionTypes = Object.values(Permission); @@ -112,11 +92,13 @@ export class RoleResourceFiltersComponent implements OnInit { * @param translate Angular translate service * @param fb Angular form builder * @param restService REST service + * @param filtersService Filters service */ constructor( public translate: TranslateService, private fb: FormBuilder, - private restService: RestService + private restService: RestService, + public filtersService: FiltersService ) {} async ngOnInit(): Promise { @@ -149,6 +131,7 @@ export class RoleResourceFiltersComponent implements OnInit { this.filtersFormArray = this.fb.array( filters.map((x) => this.createAccessFilterFormGroup(x)) ); + this.initialValue = this.filtersFormArray.value; this.filterFields = get(this.resource, 'metadata', []) .filter((x: any) => x.filterable !== false) @@ -189,7 +172,9 @@ export class RoleResourceFiltersComponent implements OnInit { */ private createAccessFilterFormGroup(filter?: AccessPermissions) { return this.fb.group({ - access: createFilterGroup(get(filter, 'access', null)), + access: this.filtersService.createFilterGroup( + get(filter, 'access', null) + ), permissions: this.fb.group({ canCreateRecords: get(filter, 'permissions.canCreateRecords', false), canDeleteRecords: get(filter, 'permissions.canDeleteRecords', false), @@ -212,43 +197,6 @@ export class RoleResourceFiltersComponent implements OnInit { this.filters = this.setTableElements(this.filtersFormArray.value); } - /** - * Gets the string representation of an access object - * - * @param access The access object - * @returns the string representation of an access object - */ - getAccessString(access: Access) { - const rulesStr: string[] = []; - access.filters.forEach((rule) => { - // nested access - // eslint-disable-next-line no-prototype-builtins - if (rule.hasOwnProperty('logic')) { - const nestedAccess = rule as Access; - rulesStr.push(`(${this.getAccessString(nestedAccess)})`); - } else { - const r = rule as { - field: string; - operator: string; - value: string; - }; - rulesStr.push( - `${r.field} ${this.opMap[ - r.operator - ].toLowerCase()} ${this.getPrettyValue(r.field, r.value)}`.trim() - ); - } - }); - if (rulesStr.length) - return rulesStr.join( - ` ${(access.logic === 'and' - ? this.translate.instant('kendo.grid.filterAndLogic') - : this.translate.instant('kendo.grid.filterOrLogic') - ).toLowerCase()} ` - ); - else return this.translate.instant('components.role.summary.newFilter'); - } - /** * Serialize single table element from filter * diff --git a/libs/shared/src/lib/components/role-summary/role-resources/role-resources.component.ts b/libs/shared/src/lib/components/role-summary/role-resources/role-resources.component.ts index 75bbb7f230..f56b51bb03 100644 --- a/libs/shared/src/lib/components/role-summary/role-resources/role-resources.component.ts +++ b/libs/shared/src/lib/components/role-summary/role-resources/role-resources.component.ts @@ -241,7 +241,7 @@ export class RoleResourcesComponent } /** - * Filters applications and updates table. + * Filters resources and updates table. * * @param filter filter event. */ @@ -534,7 +534,7 @@ export class RoleResourcesComponent * * @param resource A resource * @param permission The permission name - * @returns the name of the icon to be displayed + * @returns the name of the variant to be displayed */ private getVariant(resource: Resource, permission: Permission) { const permissionLevel = this.permissionLevel(resource, permission); @@ -554,7 +554,7 @@ export class RoleResourcesComponent * * @param resource A resource * @param permission The permission name - * @returns the name of the icon to be displayed + * @returns the tooltip to be displayed */ private getTooltip(resource: Resource, permission: Permission) { const permissionLevel = this.permissionLevel(resource, permission); diff --git a/libs/shared/src/lib/components/templates/components/edit-template-modal/edit-template-modal.component.html b/libs/shared/src/lib/components/templates/components/edit-template-modal/edit-template-modal.component.html index cadc0b92fc..23e7ae8d08 100644 --- a/libs/shared/src/lib/components/templates/components/edit-template-modal/edit-template-modal.component.html +++ b/libs/shared/src/lib/components/templates/components/edit-template-modal/edit-template-modal.component.html @@ -14,10 +14,13 @@

- + {{ 'components.templates.type.email' | translate }} + + {{ 'components.triggers.notification' | translate }} +
@@ -25,6 +28,28 @@

+ +
+ + + +
+ +
+ +
>
+
diff --git a/libs/shared/src/lib/components/templates/components/edit-template-modal/edit-template-modal.component.ts b/libs/shared/src/lib/components/templates/components/edit-template-modal/edit-template-modal.component.ts index 47b6da3c72..4ea74b831a 100644 --- a/libs/shared/src/lib/components/templates/components/edit-template-modal/edit-template-modal.component.ts +++ b/libs/shared/src/lib/components/templates/components/edit-template-modal/edit-template-modal.component.ts @@ -3,6 +3,7 @@ import { FormBuilder, Validators } from '@angular/forms'; import { DialogRef, DIALOG_DATA } from '@angular/cdk/dialog'; import { EditorService } from '../../../../services/editor/editor.service'; import { + DESCRIPTION_EDITOR_CONFIG, EMAIL_EDITOR_CONFIG, INLINE_EDITOR_CONFIG, } from '../../../../const/tinymce.const'; @@ -20,6 +21,8 @@ import { TooltipModule, } from '@oort-front/ui'; import { DialogModule, FormWrapperModule } from '@oort-front/ui'; +import { UnsubscribeComponent } from '../../../utils/unsubscribe/unsubscribe.component'; +import { takeUntil } from 'rxjs'; /** Model for the data input */ interface DialogData { @@ -29,9 +32,20 @@ interface DialogData { } /** Available body editor keys for autocompletion */ -const BODY_EDITOR_AUTOCOMPLETE_KEYS = ['{{now}}', '{{today}}', '{{dataset}}']; +const BODY_EDITOR_AUTOCOMPLETE_KEYS = [ + '{{now}}', + '{{today}}', + '{{dataset}}', + '{{recordId}}', +]; /** Available subject editor keys for autocompletion */ const SUBJECT_EDITOR_AUTOCOMPLETE_KEYS = ['{{now}}', '{{today}}']; +/** Available body editor keys for autocompletion */ +const DESCRIPTION_EDITOR_AUTOCOMPLETE_KEYS = [ + '{{now}}', + '{{today}}', + '{{recordId}}', +]; /** Component for editing a template */ @Component({ @@ -54,14 +68,19 @@ const SUBJECT_EDITOR_AUTOCOMPLETE_KEYS = ['{{now}}', '{{today}}']; templateUrl: './edit-template-modal.component.html', styleUrls: ['./edit-template-modal.component.scss'], }) -export class EditTemplateModalComponent implements OnInit { +export class EditTemplateModalComponent + extends UnsubscribeComponent + implements OnInit +{ // === REACTIVE FORM === /** Reactive form for the template */ - form = this.fb.group({ + public form = this.fb.group({ name: [get(this.data, 'name', null), Validators.required], type: [get(this.data, 'type', 'email'), Validators.required], - subject: [get(this.data, 'content.subject', null), Validators.required], - body: [get(this.data, 'content.body', ''), Validators.required], + subject: [get(this.data, 'content.subject', null)], + body: [get(this.data, 'content.body', '')], + description: [get(this.data, 'content.description', '')], + title: [get(this.data, 'content.title', null)], }); /** tinymce body editor */ @@ -70,6 +89,9 @@ export class EditTemplateModalComponent implements OnInit { /** tinymce subject editor */ public subjectEditor: RawEditorSettings = INLINE_EDITOR_CONFIG; + /** tinymce description editor */ + public descriptionEditor: RawEditorSettings = DESCRIPTION_EDITOR_CONFIG; + /** * Component for editing a template * @@ -84,6 +106,7 @@ export class EditTemplateModalComponent implements OnInit { @Inject(DIALOG_DATA) public data: DialogData, private editorService: EditorService ) { + super(); // Set the editor base url based on the environment file this.bodyEditor.base_url = editorService.url; // Set the editor language @@ -104,5 +127,33 @@ export class EditTemplateModalComponent implements OnInit { this.subjectEditor, SUBJECT_EDITOR_AUTOCOMPLETE_KEYS.map((key) => ({ value: key, text: key })) ); + + this.editorService.addCalcAndKeysAutoCompleter( + this.descriptionEditor, + DESCRIPTION_EDITOR_AUTOCOMPLETE_KEYS.map((key) => ({ + value: key, + text: key, + })) + ); + + // Add required validation fields depending on type + this.form + .get('type') + ?.valueChanges.pipe(takeUntil(this.destroy$)) + .subscribe((value) => { + if (value === 'email') { + this.form.get('subject')?.addValidators(Validators.required); + this.form.get('body')?.addValidators(Validators.required); + + this.form.get('title')?.removeValidators(Validators.required); + this.form.get('description')?.removeValidators(Validators.required); + } else { + this.form.get('title')?.addValidators(Validators.required); + this.form.get('description')?.addValidators(Validators.required); + + this.form.get('subject')?.removeValidators(Validators.required); + this.form.get('body')?.removeValidators(Validators.required); + } + }); } } diff --git a/libs/shared/src/lib/components/templates/components/edit-template-modal/public-api.ts b/libs/shared/src/lib/components/templates/components/edit-template-modal/public-api.ts new file mode 100644 index 0000000000..0e7e92daf1 --- /dev/null +++ b/libs/shared/src/lib/components/templates/components/edit-template-modal/public-api.ts @@ -0,0 +1 @@ +export * from './edit-template-modal.component'; diff --git a/libs/shared/src/lib/components/templates/templates.component.html b/libs/shared/src/lib/components/templates/templates.component.html index aa98d2ffb6..82911fe8fb 100644 --- a/libs/shared/src/lib/components/templates/templates.component.html +++ b/libs/shared/src/lib/components/templates/templates.component.html @@ -10,7 +10,7 @@ icon="add" category="secondary" variant="primary" - (click)="addEmailTemplate()" + (click)="addTemplate()" > {{ 'components.templates.edit.new' | translate }} @@ -59,7 +59,7 @@ > - diff --git a/libs/shared/src/lib/components/templates/templates.component.ts b/libs/shared/src/lib/components/templates/templates.component.ts index 5428a6f27e..e09851a16c 100644 --- a/libs/shared/src/lib/components/templates/templates.component.ts +++ b/libs/shared/src/lib/components/templates/templates.component.ts @@ -56,7 +56,7 @@ export class TemplatesComponent extends UnsubscribeComponent implements OnInit { * * @param template The template to edit */ - async editEmailTemplate(template: any): Promise { + async editTemplate(template: any): Promise { const { EditTemplateModalComponent } = await import( './components/edit-template-modal/edit-template-modal.component' ); @@ -66,14 +66,21 @@ export class TemplatesComponent extends UnsubscribeComponent implements OnInit { }); dialogRef.closed.pipe(takeUntil(this.destroy$)).subscribe((value: any) => { if (value) { + const content = + value.type === TemplateTypeEnum.EMAIL + ? { + subject: value.subject, + body: value.body, + } + : { + title: value.title, + description: value.description, + }; this.applicationService.editTemplate({ id: template.id, name: value.name, - type: TemplateTypeEnum.EMAIL, - content: { - subject: value.subject, - body: value.body, - }, + type: value.type, + content, }); this.snackBar.openSnackBar( this.translate.instant('common.notifications.objectUpdated', { @@ -122,8 +129,8 @@ export class TemplatesComponent extends UnsubscribeComponent implements OnInit { }); } - /** Opens modal for adding a new email template */ - async addEmailTemplate(): Promise { + /** Opens modal for adding a new template */ + async addTemplate(): Promise { const { EditTemplateModalComponent } = await import( './components/edit-template-modal/edit-template-modal.component' ); @@ -132,13 +139,20 @@ export class TemplatesComponent extends UnsubscribeComponent implements OnInit { }); dialogRef.closed.pipe(takeUntil(this.destroy$)).subscribe((value: any) => { if (value) { + const content = + value.type === TemplateTypeEnum.EMAIL + ? { + subject: value.subject, + body: value.body, + } + : { + title: value.title, + description: value.description, + }; this.applicationService.addTemplate({ name: value.name, - type: TemplateTypeEnum.EMAIL, - content: { - subject: value.subject, - body: value.body, - }, + type: value.type, + content, }); this.snackBar.openSnackBar( this.translate.instant('common.notifications.objectCreated', { diff --git a/libs/shared/src/lib/components/widgets/common/tab-actions/tab-actions.component.ts b/libs/shared/src/lib/components/widgets/common/tab-actions/tab-actions.component.ts index 475f8845df..16c8f08841 100644 --- a/libs/shared/src/lib/components/widgets/common/tab-actions/tab-actions.component.ts +++ b/libs/shared/src/lib/components/widgets/common/tab-actions/tab-actions.component.ts @@ -1,8 +1,6 @@ import { Component, Input, OnInit } from '@angular/core'; import { UntypedFormGroup } from '@angular/forms'; import { ApplicationService } from '../../../../services/application/application.service'; -import { Application } from '../../../../models/application.model'; -import { ContentType, Page } from '../../../../models/page.model'; import { takeUntil } from 'rxjs'; import { UnsubscribeComponent } from '../../../utils/unsubscribe/unsubscribe.component'; import { DashboardState } from '../../../../models/dashboard.model'; @@ -137,8 +135,7 @@ export class TabActionsComponent this.showSelectPage = this.formGroup.controls.actions.get('navigateToPage')?.value; // Add available pages to the list of available keys - const application = this.applicationService.application.getValue(); - this.pages = this.getPages(application); + this.pages = this.applicationService.getPages(); this.states = this.dashboardService.states.getValue() || []; this.formGroup.controls.actions .get('navigateToPage') @@ -147,34 +144,4 @@ export class TabActionsComponent this.showSelectPage = val; }); } - - /** - * Get available pages from app - * - * @param application application - * @returns list of pages and their url - */ - private getPages(application: Application | null) { - return ( - application?.pages?.map((page: any) => ({ - id: page.id, - name: page.name, - urlParams: this.getPageUrlParams(application, page), - placeholder: `{{page(${page.id})}}`, - })) || [] - ); - } - - /** - * Get page url params - * - * @param application application - * @param page page to get url from - * @returns url of the page - */ - private getPageUrlParams(application: Application, page: Page): string { - return page.type === ContentType.form - ? `${application.id}/${page.type}/${page.id}` - : `${application.id}/${page.type}/${page.content}`; - } } diff --git a/libs/shared/src/lib/const/tinymce.const.ts b/libs/shared/src/lib/const/tinymce.const.ts index 82e32e5e3d..1b6a6c7ba0 100644 --- a/libs/shared/src/lib/const/tinymce.const.ts +++ b/libs/shared/src/lib/const/tinymce.const.ts @@ -328,3 +328,12 @@ export const INLINE_EDITOR_CONFIG: RawEditorSettings = { height: 50, content_style: 'p { margin: 0 !important; }', }; + +/** Description Editor tinymce configuration. */ +export const DESCRIPTION_EDITOR_CONFIG: RawEditorSettings = { + menubar: false, + toolbar: '', + plugins: '', + height: 100, + content_style: 'p { margin: 0 !important; }', +}; diff --git a/libs/shared/src/lib/models/custom-notification.model.ts b/libs/shared/src/lib/models/custom-notification.model.ts index a8d7009b47..d15eabcb4e 100644 --- a/libs/shared/src/lib/models/custom-notification.model.ts +++ b/libs/shared/src/lib/models/custom-notification.model.ts @@ -1,3 +1,14 @@ +/** + * Enum of custom notification recipients type. + */ +export const customNotificationRecipientsType = { + email: 'email', + userField: 'userField', + emailField: 'emailField', + distributionList: 'distributionList', + channel: 'channel', +}; + /** Interface of Custom Notification objects */ export interface CustomNotification { id?: string; @@ -15,6 +26,16 @@ export interface CustomNotification { createdAt?: Date; modifiedAt?: Date; status?: string; + onRecordCreation?: boolean; + onRecordUpdate?: boolean; + applicationTrigger?: boolean; + filter?: any; + redirect?: { + active: boolean; + type: 'url' | 'recordIds'; + url?: string; + recordIds?: string[]; + }; } /** Model for add custom notification mutation response */ diff --git a/libs/shared/src/lib/models/notification.model.ts b/libs/shared/src/lib/models/notification.model.ts index 6957e9afcb..233aa09653 100644 --- a/libs/shared/src/lib/models/notification.model.ts +++ b/libs/shared/src/lib/models/notification.model.ts @@ -12,6 +12,15 @@ export interface Notification { createdAt?: Date; channel?: Channel; seenBy?: User[]; + user?: User; + redirect?: { + active: boolean; + type: 'url' | 'recordIds'; + url?: string; + recordIds?: string[]; + layout?: string; + resource?: string; + }; } /** Model for notification subscription response */ diff --git a/libs/shared/src/lib/models/record.model.ts b/libs/shared/src/lib/models/record.model.ts index ddae4a23b7..4df40c1ab3 100644 --- a/libs/shared/src/lib/models/record.model.ts +++ b/libs/shared/src/lib/models/record.model.ts @@ -27,6 +27,7 @@ export interface Record { canUpdate?: boolean; canDelete?: boolean; validationErrors?: { question: string; errors: string[] }[]; + userCanEdit?: boolean; } /** Model for record graphql query response */ diff --git a/libs/shared/src/lib/models/resource.model.ts b/libs/shared/src/lib/models/resource.model.ts index c384bfa16e..d793127118 100644 --- a/libs/shared/src/lib/models/resource.model.ts +++ b/libs/shared/src/lib/models/resource.model.ts @@ -1,5 +1,6 @@ import { Connection } from '../utils/graphql/connection.type'; import { Aggregation } from './aggregation.model'; +import { CustomNotification } from './custom-notification.model'; import { Form } from './form.model'; import { GraphqlNodesResponse } from './graphql-query.model'; import { Layout } from './layout.model'; @@ -36,6 +37,8 @@ export interface Resource { padding: number; }; importField?: string; + customNotifications?: CustomNotification[]; + hasLayouts?: boolean; } /** Model for resource query response object */ diff --git a/libs/shared/src/lib/models/template.model.ts b/libs/shared/src/lib/models/template.model.ts index 0e3dd5144a..aa236e8510 100644 --- a/libs/shared/src/lib/models/template.model.ts +++ b/libs/shared/src/lib/models/template.model.ts @@ -1,6 +1,7 @@ /** Enum for types of template */ export enum TemplateTypeEnum { EMAIL = 'email', + NOTIFICATION = 'notification', } /** Model for Template object */ diff --git a/libs/shared/src/lib/services/application-notifications/graphql/mutations.ts b/libs/shared/src/lib/services/application-notifications/graphql/mutations.ts index 59a81e60ab..0a757ed6fd 100644 --- a/libs/shared/src/lib/services/application-notifications/graphql/mutations.ts +++ b/libs/shared/src/lib/services/application-notifications/graphql/mutations.ts @@ -39,6 +39,7 @@ export const UPDATE_CUSTOM_NOTIFICATION = gql` layout description recipientsType + filter } } `; diff --git a/libs/shared/src/lib/services/application/application.service.ts b/libs/shared/src/lib/services/application/application.service.ts index ecbd31a05b..d21e18f131 100644 --- a/libs/shared/src/lib/services/application/application.service.ts +++ b/libs/shared/src/lib/services/application/application.service.ts @@ -1670,7 +1670,7 @@ export class ApplicationService { application: application.id, template: { name: template.name, - type: 'email', + type: template.type, content: template.content, }, }, @@ -2076,4 +2076,34 @@ export class ApplicationService { }); } } + + /** + * Get available pages from app + * + * @returns list of pages and their url + */ + public getPages() { + const application = this.application.getValue(); + return ( + application?.pages?.map((page: any) => ({ + id: page.id, + name: page.name, + urlParams: this.getPageUrlParams(application, page), + placeholder: `{{page(${page.id})}}`, + })) || [] + ); + } + + /** + * Get page url params + * + * @param application application + * @param page page to get url from + * @returns url of the page + */ + private getPageUrlParams(application: Application, page: Page): string { + return page.type === ContentType.form + ? `${application.id}/${page.type}/${page.id}` + : `${application.id}/${page.type}/${page.content}`; + } } diff --git a/libs/shared/src/lib/services/filters/filters.service.spec.ts b/libs/shared/src/lib/services/filters/filters.service.spec.ts new file mode 100644 index 0000000000..0c2fbb0b1d --- /dev/null +++ b/libs/shared/src/lib/services/filters/filters.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { FiltersService } from './filters.service'; + +describe('FiltersService', () => { + let service: FiltersService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(FiltersService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/libs/shared/src/lib/services/filters/filters.service.ts b/libs/shared/src/lib/services/filters/filters.service.ts new file mode 100644 index 0000000000..9300c84ef8 --- /dev/null +++ b/libs/shared/src/lib/services/filters/filters.service.ts @@ -0,0 +1,129 @@ +import { Injectable } from '@angular/core'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { TranslateService } from '@ngx-translate/core'; +import { FILTER_OPERATORS } from '../../components/filter/filter.const'; + +/** Filters access interface */ +export interface Access { + logic: string; + filters: ( + | { + field: string; + operator: string; + value?: string; + } + | Access + )[]; +} + +/** + * Shared filters service with common filter elements. + */ +@Injectable({ providedIn: 'root' }) +export class FiltersService { + /** Map of operators to their translation */ + public filterOperatorsMap: { + [key: string]: string; + } = { + eq: this.translate.instant('kendo.grid.filterEqOperator'), + neq: this.translate.instant('kendo.grid.filterNotEqOperator'), + contains: this.translate.instant('kendo.grid.filterContainsOperator'), + doesnotcontain: this.translate.instant( + 'kendo.grid.filterNotContainsOperator' + ), + startswith: this.translate.instant('kendo.grid.filterStartsWithOperator'), + endswith: this.translate.instant('kendo.grid.filterEndsWithOperator'), + isnull: this.translate.instant('kendo.grid.filterIsNullOperator'), + isnotnull: this.translate.instant('kendo.grid.filterIsNotNullOperator'), + isempty: this.translate.instant('kendo.grid.filterIsEmptyOperator'), + isnotempty: this.translate.instant('kendo.grid.filterIsNotEmptyOperator'), + in: this.translate.instant('kendo.grid.filterIsInOperator'), + notin: this.translate.instant('kendo.grid.filterIsNotInOperator'), + gt: this.translate.instant('kendo.grid.filterGtOperator'), + gte: this.translate.instant('kendo.grid.filterGteOperator'), + lt: this.translate.instant('kendo.grid.filterLtOperator'), + lte: this.translate.instant('kendo.grid.filterLteOperator'), + }; + + /** + * Shared filters service with common filter elements. + * + * @param translate Angular translate service + * @param formBuilder Angular form builder + */ + constructor( + public translate: TranslateService, + private formBuilder: FormBuilder + ) {} + + /** + * Builds a filter form + * + * @param filter Initial filter + * @returns Filter form + */ + public createFilterGroup(filter: any): FormGroup { + if (filter?.filters) { + const filters = filter.filters.map((x: any) => this.createFilterGroup(x)); + return this.formBuilder.group({ + logic: filter.logic || 'and', + filters: this.formBuilder.array(filters), + }); + } + if (filter?.field) { + const group = this.formBuilder.group({ + field: filter.field, + operator: filter.operator || 'eq', + value: Array.isArray(filter.value) ? [filter.value] : filter.value, + }); + if ( + FILTER_OPERATORS.find((op) => op.value === filter.operator) + ?.disableValue + ) { + group.get('value')?.disable(); + } + return group; + } + return this.formBuilder.group({ + logic: 'and', + filters: this.formBuilder.array([]), + }); + } + + /** + * Gets the string representation of an access object + * + * @param access The access object + * @returns the string representation of an access object + */ + public getAccessString(access: Access) { + const rulesStr: string[] = []; + access.filters.forEach((rule) => { + // nested access + // eslint-disable-next-line no-prototype-builtins + if (rule.hasOwnProperty('logic')) { + const nestedAccess = rule as Access; + rulesStr.push(`(${this.getAccessString(nestedAccess)})`); + } else { + const r = rule as { + field: string; + operator: string; + value: string; + }; + rulesStr.push( + `${r.field} ${this.filterOperatorsMap[r.operator].toLowerCase()} ${ + r.value + }`.trim() + ); + } + }); + if (rulesStr.length) + return rulesStr.join( + ` ${(access.logic === 'and' + ? this.translate.instant('kendo.grid.filterAndLogic') + : this.translate.instant('kendo.grid.filterOrLogic') + ).toLowerCase()} ` + ); + else return this.translate.instant('components.role.summary.newFilter'); + } +} diff --git a/libs/shared/src/lib/services/notification/graphql/queries.ts b/libs/shared/src/lib/services/notification/graphql/queries.ts index d0931078c7..83980a663b 100644 --- a/libs/shared/src/lib/services/notification/graphql/queries.ts +++ b/libs/shared/src/lib/services/notification/graphql/queries.ts @@ -11,6 +11,7 @@ export const GET_NOTIFICATIONS = gql` action content createdAt + redirect channel { id title @@ -18,6 +19,9 @@ export const GET_NOTIFICATIONS = gql` id } } + user { + id + } seenBy { id name @@ -33,3 +37,26 @@ export const GET_NOTIFICATIONS = gql` } } `; + +/** Graphql request for getting resource layout */ +export const GET_LAYOUT = gql` + query GetLayout($resource: ID!, $id: ID) { + resource(id: $resource) { + layouts(ids: [$id]) { + edges { + node { + id + name + query + createdAt + display + } + } + } + metadata { + name + type + } + } + } +`; diff --git a/libs/shared/src/lib/services/notification/graphql/subscriptions.ts b/libs/shared/src/lib/services/notification/graphql/subscriptions.ts index 853cdf6dd5..051e284883 100644 --- a/libs/shared/src/lib/services/notification/graphql/subscriptions.ts +++ b/libs/shared/src/lib/services/notification/graphql/subscriptions.ts @@ -15,6 +15,9 @@ export const NOTIFICATION_SUBSCRIPTION = gql` id } } + user { + id + } seenBy { id name diff --git a/libs/shared/src/lib/services/notification/notification.service.ts b/libs/shared/src/lib/services/notification/notification.service.ts index 4fd48635dc..376b372635 100644 --- a/libs/shared/src/lib/services/notification/notification.service.ts +++ b/libs/shared/src/lib/services/notification/notification.service.ts @@ -1,8 +1,8 @@ import { Apollo, QueryRef } from 'apollo-angular'; import { Injectable } from '@angular/core'; -import { BehaviorSubject, Observable } from 'rxjs'; +import { BehaviorSubject, firstValueFrom, Observable } from 'rxjs'; import { SEE_NOTIFICATION, SEE_NOTIFICATIONS } from './graphql/mutations'; -import { GET_NOTIFICATIONS } from './graphql/queries'; +import { GET_LAYOUT, GET_NOTIFICATIONS } from './graphql/queries'; import { NOTIFICATION_SUBSCRIPTION } from './graphql/subscriptions'; import { Notification, @@ -12,6 +12,12 @@ import { SeeNotificationsMutationResponse, } from '../../models/notification.model'; import { updateQueryUniqueValues } from '../../utils/update-queries'; +import { SnackbarService } from '@oort-front/ui'; +import { TranslateService } from '@ngx-translate/core'; +import { ResourceQueryResponse } from '../../models/resource.model'; +import { clone, get } from 'lodash'; +import { Layout } from '../../models/layout.model'; +import { Dialog } from '@angular/cdk/dialog'; /** Pagination: number of items per query */ const ITEMS_PER_PAGE = 10; @@ -57,8 +63,16 @@ export class NotificationService { * Shared notification service. Subscribes to Apollo to automatically fetch new notifications. * * @param apollo Apollo client + * @param snackBar shared snackbar service + * @param dialog Dialog service + * @param translate Angular translate service */ - constructor(private apollo: Apollo) {} + constructor( + private apollo: Apollo, + private snackBar: SnackbarService, + private dialog: Dialog, + private translate: TranslateService + ) {} /** * If notifications are empty, fetch all notifications and listen to new one. @@ -123,6 +137,68 @@ export class NotificationService { }); } + /** + * Redirect to records modal after clicking on notification with active redirection. + * + * @param notification The notification that was clicked on + */ + public async redirectToRecords(notification: Notification) { + const redirect = notification.redirect; + + if (redirect && redirect.active && redirect.layout && redirect.resource) { + if (!redirect.recordIds?.length) { + // No record id detected + this.snackBar.openSnackBar( + this.translate.instant('components.notifications.noRecord'), + { error: true } + ); + } + + if (redirect.recordIds?.length === 1) { + // Open record modal to single record id + const { RecordModalComponent } = await import( + '../../components/record-modal/record-modal.component' + ); + this.dialog.open(RecordModalComponent, { + data: { + recordId: redirect.recordIds[0], + }, + autoFocus: false, + }); + } else if (redirect.recordIds?.length) { + // Get layout selected on trigger + const layout = await this.getNotificationLayout( + redirect.layout, + redirect.resource + ); + + if (layout?.query) { + // Open ResourceGridModalComponent to multiple record ids + const { ResourceGridModalComponent } = await import( + '../../components/search-resource-grid-modal/search-resource-grid-modal.component' + ); + this.dialog.open(ResourceGridModalComponent, { + data: { + gridSettings: clone(layout.query), + }, + }); + } else { + this.snackBar.openSnackBar( + this.translate.instant( + 'components.widget.summaryCard.errors.invalidSource' + ), + { error: true } + ); + } + } + } else { + this.snackBar.openSnackBar( + this.translate.instant('components.notifications.noRedirect'), + { error: true } + ); + } + } + /** * Marks all notifications as seen and remove it from the array of notifications. */ @@ -169,4 +245,32 @@ export class NotificationService { this.hasNextPage.next(data.notifications.pageInfo.hasNextPage); this.firstLoad = false; } + + /** + * Get notification with trigger redirection layout + * + * @param layout layout id + * @param resource resource id + * @returns Layout object + */ + private async getNotificationLayout( + layout: string, + resource: string + ): Promise { + const apolloRes = await firstValueFrom( + this.apollo.query({ + query: GET_LAYOUT, + variables: { + id: layout, + resource, + }, + }) + ); + + if (get(apolloRes, 'data')) { + return apolloRes.data.resource.layouts?.edges[0]?.node; + } else { + return undefined; + } + } }