From d4b76279e08470d1684529e04219a90d0cae5ba3 Mon Sep 17 00:00:00 2001 From: Ayush Date: Fri, 3 Jan 2025 21:36:47 +0530 Subject: [PATCH] [Feat]: Notification Setting Page (#2712) --------- Co-authored-by: Sebastian Leidig --- .../note-details/note-details.component.html | 2 +- .../admin-entity-details.component.ts | 2 - .../admin-entity-form.component.html | 3 +- .../admin-entity-form.component.ts | 5 +- .../admin-entity-list.component.ts | 11 +- .../admin-entity-public-forms-component.html | 20 ++ .../admin-entity-public-forms-component.scss | 0 ...dmin-entity-public-forms-component.spec.ts | 26 +++ .../admin-entity-public-forms-component.ts | 29 +++ .../core/admin/admin-entity.service.spec.ts | 13 +- src/app/core/admin/admin-entity.service.ts | 68 +++++- ...-entity-general-settings.component.spec.ts | 8 + ...admin-entity-general-settings.component.ts | 5 +- .../admin-entity/admin-entity.component.html | 13 +- .../admin-entity.component.spec.ts | 2 - .../admin-entity/admin-entity.component.ts | 87 +++----- .../edit-configurable-enum.component.ts | 4 - .../basic-autocomplete.component.ts | 50 ++--- .../entities-table.component.ts | 21 +- ...tity-inline-edit-actions.component.spec.ts | 12 +- .../entity-inline-edit-actions.component.ts | 2 +- .../entity-field-edit.component.html | 1 + .../entity-form/entity-form.service.spec.ts | 46 +++- .../entity-form/entity-form.service.ts | 18 +- .../default-value.service.spec.ts | 2 + .../inherited-value.service.spec.ts | 18 +- .../entity-details/form/form.component.ts | 7 +- .../related-entities.component.ts | 3 +- .../entity/default-datatype/edit-component.ts | 6 + .../edit-entity-type.component.html | 12 +- .../edit-entity-type.component.spec.ts | 2 +- .../edit-entity-type.component.ts | 9 +- .../entity-type-select.component.html | 12 -- .../entity-type-select.component.spec.ts | 8 +- .../entity-type-select.component.ts | 47 ++-- .../dialog-buttons.component.html | 4 +- .../dialog-buttons.component.spec.ts | 20 +- .../dialog-buttons.component.ts | 18 +- .../row-details/row-details.component.html | 2 +- .../dialog-view/dialog-view.component.spec.ts | 25 +-- .../ui/dialog-view/dialog-view.component.ts | 18 +- .../user-account/user-account.component.html | 6 +- .../user-account/user-account.component.ts | 5 +- .../notification-item.component.spec.ts | 1 - .../notification-method-select.component.html | 17 ++ ...tification-method-select.component.spec.ts | 23 ++ .../notification-method-select.component.ts | 27 +++ .../notification-settings.component.html | 200 ++++++++++++++++++ .../notification-settings.component.scss | 131 ++++++++++++ .../notification-settings.component.spec.ts | 32 +++ .../notification-settings.component.ts | 123 +++++++++++ .../notification/notification.component.html | 2 + .../notification.component.spec.ts | 1 - .../demo-public-form-generator.service.ts | 3 +- ...edit-public-form-columns.component.spec.ts | 4 + .../edit-public-form-columns.component.ts | 24 +++ .../public-form/public-form.component.spec.ts | 10 +- .../public-form/public-form.component.ts | 5 +- .../todo-details/todo-details.component.html | 2 +- .../todo-details/todo-details.component.ts | 5 +- src/styles/globals/_flex-classes.scss | 23 +- 61 files changed, 1055 insertions(+), 250 deletions(-) create mode 100644 src/app/core/admin/admin-entity-public-forms/admin-entity-public-forms-component.html create mode 100644 src/app/core/admin/admin-entity-public-forms/admin-entity-public-forms-component.scss create mode 100644 src/app/core/admin/admin-entity-public-forms/admin-entity-public-forms-component.spec.ts create mode 100644 src/app/core/admin/admin-entity-public-forms/admin-entity-public-forms-component.ts delete mode 100644 src/app/core/entity/entity-type-select/entity-type-select.component.html create mode 100644 src/app/features/notification/notification-method-select/notification-method-select.component.html create mode 100644 src/app/features/notification/notification-method-select/notification-method-select.component.spec.ts create mode 100644 src/app/features/notification/notification-method-select/notification-method-select.component.ts create mode 100644 src/app/features/notification/notification-settings/notification-settings.component.html create mode 100644 src/app/features/notification/notification-settings/notification-settings.component.scss create mode 100644 src/app/features/notification/notification-settings/notification-settings.component.spec.ts create mode 100644 src/app/features/notification/notification-settings/notification-settings.component.ts diff --git a/src/app/child-dev-project/notes/note-details/note-details.component.html b/src/app/child-dev-project/notes/note-details/note-details.component.html index 7f531f906e..9256917b1a 100644 --- a/src/app/child-dev-project/notes/note-details/note-details.component.html +++ b/src/app/child-dev-project/notes/note-details/note-details.component.html @@ -47,7 +47,7 @@ - + + + + + + } +
+ +
+ + +
+
+

+ Where you receive notifications + @if (hasPushNotificationEnabled) { +
+ Enabled +
+ } @else { +
+ Disabled +
+ } +

+
+ Notifications are always visible through the bell icon in the toolbar at + the top of the application. You can enable other ways to get + notifications sent to you below. +
+
+
+
+
+ +

Browser

+
+
+
+ Notification Center (in app) + +
+
+
+
+ Push notifications + +
+ +
+
+ +
+
+ +

Email

+
+
+
+ Email + Coming Soon +
+ +
+
+
+
+ diff --git a/src/app/features/notification/notification-settings/notification-settings.component.scss b/src/app/features/notification/notification-settings/notification-settings.component.scss new file mode 100644 index 0000000000..264025fabf --- /dev/null +++ b/src/app/features/notification/notification-settings/notification-settings.component.scss @@ -0,0 +1,131 @@ +@use "variables/colors"; +@use "variables/sizes"; +@use "../../../../styles/variables/breakpoints"; +@use "@angular/material/core/style/elevation" as mat-elevation; + +.full-width { + width: 100%; +} + +.notifications-container { + padding: 16px; +} + +.notification-types-container { + margin-top: sizes.$large; +} + +.browser-notifications-container { + margin-top: sizes.$large; +} + +.notification-setting-title { + margin-bottom: sizes.$x-small; +} + +.panel-wrapper { + @include mat-elevation.elevation(2); + border-radius: 10px; + padding: 16px; +} + +.receive-notifications-container { + @extend .panel-wrapper; + background-color: colors.$background; +} + +.notifications-options-container { + @extend .panel-wrapper; + background-color: colors.$background; + margin-top: sizes.$regular; + gap: 40px; + + @media screen and (min-width: breakpoints.$xxl) { + flex-direction: row; + } +} + +.notification-options-wrapper { + @extend .full-width; + + @media screen and (min-width: breakpoints.$xxl) { + flex-direction: row; + } +} + +.notification-rule-actions { + gap: 15px; +} + +.browser-notification-list { + margin: 0px 0px 16px 20px; +} + +.notification-toggle { + margin-left: 10px; +} + +.new-notification-rule { + margin-left: 5px; +} + +.no-margin { + margin: 0; +} + +.add-new-notification-rule { + display: flex; + justify-content: center; + margin-top: 30px; +} + +.add-new-rule-button { + width: 50%; + border-radius: 20px; + padding: 22px; + background-color: aliceblue; +} + +.full-width-field { + @extend .full-width; + display: inline-block; + + ::ng-deep mat-form-field { + @extend .full-width; + } +} + +.notification-rule-option { + width: 80%; +} + +.coming-soon-label { + color: colors.$inactive; + margin-left: 10px; + font-style: italic; +} + +.browser-notifications-title { + margin-bottom: 20px; +} + +.text-badge { + font-size: 12px; + font-weight: bold; + margin-left: 10px; + border-radius: 25px; + padding-inline: 15px; + border: 1px solid; +} + +.disabled-text { + @extend .text-badge; + border-color: #fa222a; + background-color: #eba7a9; +} + +.enabled-text { + @extend .text-badge; + border-color: green; + background-color: #beddba; +} diff --git a/src/app/features/notification/notification-settings/notification-settings.component.spec.ts b/src/app/features/notification/notification-settings/notification-settings.component.spec.ts new file mode 100644 index 0000000000..4760638e32 --- /dev/null +++ b/src/app/features/notification/notification-settings/notification-settings.component.spec.ts @@ -0,0 +1,32 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { NotificationSettingsComponent } from "./notification-settings.component"; +import { + EntityRegistry, + entityRegistry, +} from "app/core/entity/database-entity.decorator"; +import { FontAwesomeTestingModule } from "@fortawesome/angular-fontawesome/testing"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; + +describe("NotificationSettingComponent", () => { + let component: NotificationSettingsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + NotificationSettingsComponent, + FontAwesomeTestingModule, + BrowserAnimationsModule, + ], + providers: [{ provide: EntityRegistry, useValue: entityRegistry }], + }).compileComponents(); + + fixture = TestBed.createComponent(NotificationSettingsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/notification/notification-settings/notification-settings.component.ts b/src/app/features/notification/notification-settings/notification-settings.component.ts new file mode 100644 index 0000000000..906fc4a1f7 --- /dev/null +++ b/src/app/features/notification/notification-settings/notification-settings.component.ts @@ -0,0 +1,123 @@ +import { Component } from "@angular/core"; +import { MatSlideToggle } from "@angular/material/slide-toggle"; +import { MatInputModule } from "@angular/material/input"; +import { + FaIconComponent, + FontAwesomeModule, +} from "@fortawesome/angular-fontawesome"; +import { Logging } from "app/core/logging/logging.service"; +import { + FormArray, + FormControl, + FormGroup, + FormsModule, + ReactiveFormsModule, +} from "@angular/forms"; +import { MatTooltip, MatTooltipModule } from "@angular/material/tooltip"; +import { MatButtonModule } from "@angular/material/button"; +import { MatFormFieldModule } from "@angular/material/form-field"; +import { EntityTypeSelectComponent } from "app/core/entity/entity-type-select/entity-type-select.component"; +import { HelpButtonComponent } from "app/core/common-components/help-button/help-button.component"; +import { NotificationMethodSelectComponent } from "app/features/notification/notification-method-select/notification-method-select.component"; +import { ConfirmationDialogService } from "app/core/common-components/confirmation-dialog/confirmation-dialog.service"; + +/** + * UI for current user to configure individual notification settings. + */ +@Component({ + selector: "app-notification-settings", + standalone: true, + imports: [ + MatSlideToggle, + MatInputModule, + FontAwesomeModule, + FormsModule, + MatFormFieldModule, + MatTooltip, + FaIconComponent, + MatButtonModule, + MatTooltipModule, + EntityTypeSelectComponent, + HelpButtonComponent, + NotificationMethodSelectComponent, + ReactiveFormsModule, + ], + templateUrl: "./notification-settings.component.html", + styleUrl: "./notification-settings.component.scss", +}) +export class NotificationSettingsComponent { + hasPushNotificationEnabled: boolean = false; + notificationSetting = new FormGroup({ + notificationRules: new FormArray([]), + }); + + constructor(private confirmationDialog: ConfirmationDialogService) {} + + /** + * Adds a new notification rule and initializes its default values. + */ + addNewNotificationRule() { + // TODO: Update this Form Group when we implement the logic to dynamically update the notification notificationRules. + const newNotificationRule = new FormGroup({ + entityType: new FormControl(""), + notificationRuleCondition: new FormControl(""), + notificationMethod: new FormControl("Push"), + enabled: new FormControl(false), + }); + + this.notificationRules.push(newNotificationRule); + } + + /** + * Gets the FormArray of notification rules. + * This is used to access the collection of individual notification rules in the form group. + */ + get notificationRules(): FormArray { + return this.notificationSetting.get("notificationRules") as FormArray; + } + + /** + * Retrieves the FormControl for the form field at a specified index. + * This allows accessing and manipulating the form field within a specific notification rule. + */ + getFormField(index: number, fieldName: string): FormControl { + return this.notificationRules.at(index).get(fieldName) as FormControl; + } + + /** + * Opens a confirmation dialog, and removes the notification + * rule at the specified index. + * @param index The index of the notification rule to remove + */ + async confirmRemoveNotificationRule(index: number) { + const confirmed = await this.confirmationDialog.getConfirmation( + "Delete notification rule", + "Are you sure you want to remove this notification rule?", + ); + + if (!confirmed) { + return; + } + + this.notificationRules.removeAt(index); + return true; + } + + /** + * Enables or disables notifications and updates the backend. + * @param index The index of the notification rule being toggled. + */ + onEnableNotification() { + // TODO: Implement the logic to enable the notification for user and update the value in CouchDB backend. + this.hasPushNotificationEnabled = !this.hasPushNotificationEnabled; + Logging.log("Browser notifications toggled."); + } + + /** + * Sends a test notification. + */ + testNotification() { + // TODO: Implement the logic to test the notification setting. + Logging.log("Notification settings test successful."); + } +} diff --git a/src/app/features/notification/notification.component.html b/src/app/features/notification/notification.component.html index 4d0c12687a..5d65edb2b0 100644 --- a/src/app/features/notification/notification.component.html +++ b/src/app/features/notification/notification.component.html @@ -8,6 +8,7 @@ matTooltip="Notifications" i18n-matTooltip="notifications toolbar icon tooltip" [matMenuTriggerFor]="notificationList" + #notificationListTrigger="matMenuTrigger" > Notifications mat-menu-item [routerLink]="['/user-account']" [queryParams]="{ tabIndex: 1 }" + (click)="closeOnlySubmenu(notificationListTrigger, $event)" > Notification settings diff --git a/src/app/features/notification/notification.component.spec.ts b/src/app/features/notification/notification.component.spec.ts index de65a6fc65..500a866acc 100644 --- a/src/app/features/notification/notification.component.spec.ts +++ b/src/app/features/notification/notification.component.spec.ts @@ -1,5 +1,4 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; - import { NotificationComponent } from "./notification.component"; import { EntityMapperService } from "../../core/entity/entity-mapper/entity-mapper.service"; import { mockEntityMapper } from "../../core/entity/entity-mapper/mock-entity-mapper-service"; diff --git a/src/app/features/public-form/demo-public-form-generator.service.ts b/src/app/features/public-form/demo-public-form-generator.service.ts index 88e23c7e35..0406652bae 100644 --- a/src/app/features/public-form/demo-public-form-generator.service.ts +++ b/src/app/features/public-form/demo-public-form-generator.service.ts @@ -15,10 +15,11 @@ export class DemoPublicFormGeneratorService extends DemoDataGenerator { let component: EditPublicFormColumnsComponent; let fixture: ComponentFixture; let mockEntityRegistry: Partial; let mockEntityFormService: jasmine.SpyObj; + let entityMapper: MockEntityMapperService; const testColumns = [ { @@ -41,6 +44,7 @@ describe("EditPublicFormColumnsComponent", () => { { provide: Database, useValue: mockDatabase }, { provide: EntityRegistry, useValue: mockEntityRegistry }, { provide: EntityFormService, useValue: mockEntityFormService }, + { provide: EntityMapperService, useValue: entityMapper }, ], }).compileComponents(); }); diff --git a/src/app/features/public-form/edit-public-form-columns/edit-public-form-columns.component.ts b/src/app/features/public-form/edit-public-form-columns/edit-public-form-columns.component.ts index b208715527..c1fd39b5c3 100644 --- a/src/app/features/public-form/edit-public-form-columns/edit-public-form-columns.component.ts +++ b/src/app/features/public-form/edit-public-form-columns/edit-public-form-columns.component.ts @@ -7,6 +7,9 @@ import { EntityRegistry } from "app/core/entity/database-entity.decorator"; import { AdminEntityFormComponent } from "app/core/admin/admin-entity-details/admin-entity-form/admin-entity-form.component"; import { FormConfig } from "app/core/entity-details/form/form.component"; import { FieldGroup } from "app/core/entity-details/form/field-group"; +import { AdminEntityService } from "app/core/admin/admin-entity.service"; +import { EntitySchemaField } from "app/core/entity/schema/entity-schema-field"; + @Component({ selector: "app-edit-public-form-columns", standalone: true, @@ -21,14 +24,35 @@ export class EditPublicFormColumnsComponent { entityConstructor: EntityConstructor; publicFormConfig: FormConfig; + private originalEntitySchemaFields: [string, EntitySchemaField][]; private entities = inject(EntityRegistry); + private adminEntityService = inject(AdminEntityService); override ngOnInit(): void { if (this.entity) { this.entityConstructor = this.entities.get(this.entity["entity"]); this.publicFormConfig = { fieldGroups: this.formControl.getRawValue() }; + this.formControl.valueChanges.subscribe( + (v) => (this.publicFormConfig = { fieldGroups: v }), + ); + } + + this.originalEntitySchemaFields = JSON.parse( + JSON.stringify(Array.from(this.entityConstructor.schema.entries())), + ); + if (this.entityForm) { + this.entityForm.onFormStateChange.subscribe((event) => { + if (event === "saved") + this.adminEntityService.setAndSaveEntityConfig( + this.entityConstructor, + ); + if (event === "cancelled") + this.entityConstructor.schema = new Map( + this.originalEntitySchemaFields, + ); + }); } } diff --git a/src/app/features/public-form/public-form.component.spec.ts b/src/app/features/public-form/public-form.component.spec.ts index e645203782..25c8f0def6 100644 --- a/src/app/features/public-form/public-form.component.spec.ts +++ b/src/app/features/public-form/public-form.component.spec.ts @@ -93,10 +93,7 @@ describe("PublicFormComponent", () => { component.submit(); - expect(saveSpy).toHaveBeenCalledWith( - component.form.formGroup, - component.entity, - ); + expect(saveSpy).toHaveBeenCalledWith(component.form, component.entity); tick(); expect(openSnackbarSpy).toHaveBeenCalled(); })); @@ -111,10 +108,7 @@ describe("PublicFormComponent", () => { component.submit(); - expect(saveSpy).toHaveBeenCalledWith( - component.form.formGroup, - component.entity, - ); + expect(saveSpy).toHaveBeenCalledWith(component.form, component.entity); tick(); expect(openSnackbarSpy).toHaveBeenCalledWith( jasmine.stringContaining("invalid"), diff --git a/src/app/features/public-form/public-form.component.ts b/src/app/features/public-form/public-form.component.ts index efd81ac252..c8d4545e5a 100644 --- a/src/app/features/public-form/public-form.component.ts +++ b/src/app/features/public-form/public-form.component.ts @@ -68,10 +68,7 @@ export class PublicFormComponent implements OnInit { async submit() { try { - await this.entityFormService.saveChanges( - this.form.formGroup, - this.entity, - ); + await this.entityFormService.saveChanges(this.form, this.entity); this.snackbar.open($localize`Successfully submitted form`); } catch (e) { if (e instanceof InvalidFormFieldError) { diff --git a/src/app/features/todos/todo-details/todo-details.component.html b/src/app/features/todos/todo-details/todo-details.component.html index 43c3324570..5fb7418aac 100644 --- a/src/app/features/todos/todo-details/todo-details.component.html +++ b/src/app/features/todos/todo-details/todo-details.component.html @@ -24,5 +24,5 @@

{{ entity.subject }}

*ngIf="!entity.isNew" style="margin-right: 8px" > - + diff --git a/src/app/features/todos/todo-details/todo-details.component.ts b/src/app/features/todos/todo-details/todo-details.component.ts index ab6e53e613..869c498e4f 100644 --- a/src/app/features/todos/todo-details/todo-details.component.ts +++ b/src/app/features/todos/todo-details/todo-details.component.ts @@ -67,10 +67,7 @@ export class TodoDetailsComponent implements OnInit { async completeTodo() { if (this.form.formGroup.dirty) { // we assume the user always wants to save pending changes rather than discard them - await this.entityFormService.saveChanges( - this.form.formGroup, - this.entity, - ); + await this.entityFormService.saveChanges(this.form, this.entity); } await this.todoService.completeTodo(this.entity); this.dialogRef.close(); diff --git a/src/styles/globals/_flex-classes.scss b/src/styles/globals/_flex-classes.scss index bc2e59147e..6d48e9b9b2 100644 --- a/src/styles/globals/_flex-classes.scss +++ b/src/styles/globals/_flex-classes.scss @@ -49,7 +49,7 @@ * between the items is maxed. This is useful, for example, when two items * need to be placed at opposite ends (for example left and right or top and bottom) * - + The main axis is dependent on the flex direction ++ The main axis is dependent on the flex direction * - flex-direction = row : main axis is x-axis * - flex-direction = column : main axis is y-axis * @@ -65,6 +65,27 @@ justify-content: space-between; } +/** + * Justifies (places the items on their main axis) items so that they are + * centered along the main axis. This is useful, for example, when you want + * to place all items equidistantly from the center of the container. + * + * + The main axis is dependent on the flex direction + * - flex-direction = row : main axis is x-axis + * - flex-direction = column : main axis is y-axis + * + * Example assuming flex-direction = row: + * + * Without justify-content-center: + * | ----- | ----- | | + * + * With justify-content-center: + * | ----- ----- | + */ +.justify-content-center { + justify-content: center; +} + /** * aligns the items (i.e. places the items on the non main axis) */