From 3063dbe7d165b36f3301b90a743cb476c3828aef Mon Sep 17 00:00:00 2001 From: Ajay Gandecha Date: Sat, 18 May 2024 18:35:52 -0400 Subject: [PATCH 1/3] Angular v17 Upgrade: Organizations Feature Refactor (#455) * Upgrade to Angular v16 (#447) * Upgrade Angular Version to 17 (#448) * Run Upgrade Script * Upgrade Angular Material to v16 * Upgrade Angular Material to v17 * Run Material Migration (Not Entirely Complete) * Add new organization service * Begin Cleaning up the Organization Page * Remove Imports * Upgrade Pipe and Widgets to New Conventions * Refactor Profile Service, Remove Profile Resolver Usage in Page, Refactor Organization Details * Remove Import * Work on Organization Details Refactor * Fix Gear Icon ngExpressionChanged Error * Remove Description Tooltip on Organization Card * Revert Back to using Observables for Service Functions * Revert signal dependency on organization details * Remove Unused Imports, Rename OrganizationDetailsResolver * Refactor Organization Admin Feature * Refactor Organizations Editor * Edit Documentation and Clean Up * Remove Unused Import Statement * Fix all nits * Attempt Fix of Absolute Imports * Fix Relative Imports --- backend/api/organizations.py | 8 +- .../academics-home.component.ts | 2 +- .../course-catalog.component.ts | 2 +- .../section-offerings.component.ts | 2 +- .../event-details/event-details.component.ts | 2 +- .../event-editor/event-editor.component.ts | 6 +- .../event-list-admin.component.ts | 8 +- .../event/event-page/event-page.component.ts | 4 +- .../navigation-admin-gear.service.ts | 36 ++- .../app/navigation/navigation.component.html | 10 +- .../organization-list-admin.component.css | 35 --- .../organization-list-admin.component.html | 40 ---- .../list/organization-list-admin.component.ts | 124 ----------- .../organization-admin-permission.guard.ts | 20 -- .../organization-admin-permission.service.ts | 44 ---- .../organization-admin.component.css | 34 ++- .../organization-admin.component.html | 33 ++- .../organization-admin.component.ts | 82 ++++++- .../organization-admin.service.ts | 59 ----- .../organization-details.component.html | 39 ++-- .../organization-details.component.ts | 29 +-- .../organization-editor.component.html | 11 +- .../organization-editor.component.ts | 209 ++++++------------ .../organization-editor.guard.ts | 34 +++ .../organization-page.component.html | 9 +- .../organization-page.component.ts | 77 ++----- .../organization-routing.module.ts | 4 +- .../app/organization/organization.module.ts | 10 +- .../app/organization/organization.resolver.ts | 22 +- .../app/organization/organization.service.ts | 98 +++++--- .../organization-filter.pipe.ts | 0 .../src/app/organization/rx-organization.ts | 32 --- .../organization-card.widget.html | 46 ++-- .../organization-card.widget.ts | 15 +- ...organization-details-info-card.widget.html | 43 ++-- .../organization-details-info-card.widget.ts | 20 +- frontend/src/app/permission.guard.ts | 1 - frontend/src/app/permission.service.ts | 11 +- frontend/src/app/profile/profile.service.ts | 33 ++- .../shared/event-list/event-list.widget.ts | 2 +- 40 files changed, 507 insertions(+), 789 deletions(-) delete mode 100644 frontend/src/app/organization/organization-admin/list/organization-list-admin.component.css delete mode 100644 frontend/src/app/organization/organization-admin/list/organization-list-admin.component.html delete mode 100644 frontend/src/app/organization/organization-admin/list/organization-list-admin.component.ts delete mode 100644 frontend/src/app/organization/organization-admin/organization-admin-permission.guard.ts delete mode 100644 frontend/src/app/organization/organization-admin/organization-admin-permission.service.ts delete mode 100644 frontend/src/app/organization/organization-admin/organization-admin.service.ts create mode 100644 frontend/src/app/organization/organization-editor/organization-editor.guard.ts rename frontend/src/app/organization/{organization-filter => pipes}/organization-filter.pipe.ts (100%) delete mode 100644 frontend/src/app/organization/rx-organization.ts diff --git a/backend/api/organizations.py b/backend/api/organizations.py index b7cf2da29..11bcbfdb9 100644 --- a/backend/api/organizations.py +++ b/backend/api/organizations.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends -from ..services import OrganizationService +from ..services import OrganizationService, RoleService from ..models.organization import Organization from ..models.organization_details import OrganizationDetails from ..api.authentication import registered_user @@ -44,6 +44,7 @@ def new_organization( organization: Organization, subject: User = Depends(registered_user), organization_service: OrganizationService = Depends(), + role_service: RoleService = Depends(), ) -> Organization: """ Create organization @@ -60,7 +61,10 @@ def new_organization( HTTPException 422 if create() raises an Exception """ - return organization_service.create(subject, organization) + new_organization = organization_service.create(subject, organization) + # Create a new role for the organization newly created + role_service.create(subject, new_organization.slug) + return new_organization @api.get( diff --git a/frontend/src/app/academics/academics-home/academics-home.component.ts b/frontend/src/app/academics/academics-home/academics-home.component.ts index 8972baa04..1ef02b947 100644 --- a/frontend/src/app/academics/academics-home/academics-home.component.ts +++ b/frontend/src/app/academics/academics-home/academics-home.component.ts @@ -37,7 +37,7 @@ export class AcademicsHomeComponent implements OnInit { ) {} ngOnInit() { - this.gearService.showAdminGear( + this.gearService.showAdminGearByPermissionCheck( 'academics.*', '*', '', diff --git a/frontend/src/app/academics/course-catalog/course-catalog.component.ts b/frontend/src/app/academics/course-catalog/course-catalog.component.ts index b2358856a..a8e09dc07 100644 --- a/frontend/src/app/academics/course-catalog/course-catalog.component.ts +++ b/frontend/src/app/academics/course-catalog/course-catalog.component.ts @@ -70,7 +70,7 @@ export class CoursesHomeComponent implements OnInit { } ngOnInit() { - this.gearService.showAdminGear( + this.gearService.showAdminGearByPermissionCheck( 'academics.*', '*', '', diff --git a/frontend/src/app/academics/section-offerings/section-offerings.component.ts b/frontend/src/app/academics/section-offerings/section-offerings.component.ts index 8663975af..b1645461a 100644 --- a/frontend/src/app/academics/section-offerings/section-offerings.component.ts +++ b/frontend/src/app/academics/section-offerings/section-offerings.component.ts @@ -102,7 +102,7 @@ export class SectionOfferingsComponent implements OnInit { } ngOnInit() { - this.gearService.showAdminGear( + this.gearService.showAdminGearByPermissionCheck( 'academics.*', '*', '', diff --git a/frontend/src/app/event/event-details/event-details.component.ts b/frontend/src/app/event/event-details/event-details.component.ts index 0b6d0b048..42c1593c4 100644 --- a/frontend/src/app/event/event-details/event-details.component.ts +++ b/frontend/src/app/event/event-details/event-details.component.ts @@ -74,7 +74,7 @@ export class EventDetailsComponent { } ngOnInit() { - this.gearService.showAdminGear( + this.gearService.showAdminGearByPermissionCheck( 'events.*', '*', '', diff --git a/frontend/src/app/event/event-editor/event-editor.component.ts b/frontend/src/app/event/event-editor/event-editor.component.ts index 0155bcfca..3bdab28c9 100644 --- a/frontend/src/app/event/event-editor/event-editor.component.ts +++ b/frontend/src/app/event/event-editor/event-editor.component.ts @@ -14,14 +14,14 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { EventService } from '../event.service'; import { profileResolver } from '../../profile/profile.resolver'; import { Profile, PublicProfile } from '../../profile/profile.service'; -import { OrganizationService } from '../../organization/organization.service'; import { Observable, map } from 'rxjs'; import { eventDetailResolver } from '../event.resolver'; import { PermissionService } from 'src/app/permission.service'; -import { organizationDetailResolver } from 'src/app/organization/organization.resolver'; +import { organizationResolver } from 'src/app/organization/organization.resolver'; import { Organization } from 'src/app/organization/organization.model'; import { Event, RegistrationType } from '../event.model'; import { DatePipe } from '@angular/common'; +import { OrganizationService } from 'src/app/organization/organization.service'; @Component({ selector: 'app-event-editor', @@ -35,7 +35,7 @@ export class EventEditorComponent { title: 'Event Editor', resolve: { profile: profileResolver, - organization: organizationDetailResolver, + organization: organizationResolver, event: eventDetailResolver } }; diff --git a/frontend/src/app/event/event-list-admin/event-list-admin.component.ts b/frontend/src/app/event/event-list-admin/event-list-admin.component.ts index 820c0e9ba..a61a2a99a 100644 --- a/frontend/src/app/event/event-list-admin/event-list-admin.component.ts +++ b/frontend/src/app/event/event-list-admin/event-list-admin.component.ts @@ -1,8 +1,6 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { OrganizationAdminPermissionGuard } from 'src/app/organization/organization-admin/organization-admin-permission.guard'; import { MatSnackBar } from '@angular/material/snack-bar'; -import { OrganizationAdminService } from 'src/app/organization/organization-admin/organization-admin.service'; import { Observable, map, of } from 'rxjs'; import { Permission, @@ -11,9 +9,9 @@ import { import { Organization } from 'src/app/organization/organization.model'; import { Event } from 'src/app/event/event.model'; import { profileResolver } from 'src/app/profile/profile.resolver'; -import { organizationResolver } from 'src/app/organization/organization.resolver'; import { EventService } from 'src/app/event/event.service'; import { eventResolver } from '../event.resolver'; +import { OrganizationService } from 'src/app/organization/organization.service'; @Component({ selector: 'app-event-list-admin', @@ -34,10 +32,8 @@ export class EventListAdminComponent implements OnInit { path: 'admin', component: EventListAdminComponent, title: 'Event Administration', - canActivate: [OrganizationAdminPermissionGuard()], resolve: { profile: profileResolver, - organizations: organizationResolver, events: eventResolver } }; @@ -46,7 +42,7 @@ export class EventListAdminComponent implements OnInit { private route: ActivatedRoute, private router: Router, private snackBar: MatSnackBar, - private organizationAdminService: OrganizationAdminService, + private organizationAdminService: OrganizationService, private eventService: EventService ) { this.displayedEvents$ = eventService.getEvents(); diff --git a/frontend/src/app/event/event-page/event-page.component.ts b/frontend/src/app/event/event-page/event-page.component.ts index 5e2614231..9d164f3db 100644 --- a/frontend/src/app/event/event-page/event-page.component.ts +++ b/frontend/src/app/event/event-page/event-page.component.ts @@ -133,7 +133,7 @@ export class EventPageComponent implements OnInit, OnDestroy { if (userPermissions.length !== 0) { /** Admin user, no need to check further */ if (userPermissions[0].resource === '*') { - this.gearService.showAdminGear( + this.gearService.showAdminGearByPermissionCheck( 'organizations.*', '*', '', @@ -146,7 +146,7 @@ export class EventPageComponent implements OnInit, OnDestroy { ); /** If they do, show admin gear */ if (organizationPermissions.length !== 0) { - this.gearService.showAdminGear( + this.gearService.showAdminGearByPermissionCheck( 'organizations.*', organizationPermissions[0].resource, '', diff --git a/frontend/src/app/navigation/navigation-admin-gear.service.ts b/frontend/src/app/navigation/navigation-admin-gear.service.ts index bdda0ba70..44b663b2c 100644 --- a/frontend/src/app/navigation/navigation-admin-gear.service.ts +++ b/frontend/src/app/navigation/navigation-admin-gear.service.ts @@ -1,18 +1,18 @@ -import { Injectable } from '@angular/core'; +import { Injectable, Signal, WritableSignal, signal } from '@angular/core'; import { PermissionService } from '../permission.service'; import { ReplaySubject } from 'rxjs'; import { AdminSettingsNavigationData } from './navigation.service'; -import { Organization } from '../organization/organization.model'; @Injectable({ providedIn: 'root' }) export class NagivationAdminGearService { - private adminSettingsData: ReplaySubject = - new ReplaySubject(1); - public adminSettingsData$ = this.adminSettingsData.asObservable(); + public adminSettingsData: WritableSignal = + signal(null); - public adminView: boolean = false; + // private adminSettingsData: ReplaySubject = + // new ReplaySubject(1); + // public adminSettingsData$ = this.adminSettingsData.asObservable(); constructor(private permissionService: PermissionService) {} @@ -25,7 +25,7 @@ export class NagivationAdminGearService { * @param tooltip Tooltip to display when hovering over the settings gear icon. * @param targetUrl URL for the admin page for the button to redirect to. */ - public showAdminGear( + public showAdminGearByPermissionCheck( permissionAction: string, permissionResource: string, tooltip: string, @@ -38,20 +38,34 @@ export class NagivationAdminGearService { // If the user has the permission, then update the settings // navigation data so that it shows. If not, clear the data. if (hasPermission) { - this.adminView = true; // Update the settings data - this.adminSettingsData.next({ + this.adminSettingsData.set({ tooltip: tooltip, url: targetUrl }); } else { // Reset the settings data - this.adminSettingsData.next(null); + this.adminSettingsData.set(null); } }); } + /** + * This function updates an internal reactive object setup to manage when to show the admin + * page gear icon or not. + * + * @param conditionFunction Function that must return true for the gear to appear. + * @param tooltip Tooltip to display when hovering over the settings gear icon. + * @param targetUrl URL for the admin page for the button to redirect to. + */ + public showAdminGear(tooltip: string, targetUrl: string) { + this.adminSettingsData.set({ + tooltip: tooltip, + url: targetUrl + }); + } + public resetAdminSettingsNavigation() { - this.adminSettingsData.next(null); + this.adminSettingsData.set(null); } } diff --git a/frontend/src/app/navigation/navigation.component.html b/frontend/src/app/navigation/navigation.component.html index c98eb5565..bb857c60f 100644 --- a/frontend/src/app/navigation/navigation.component.html +++ b/frontend/src/app/navigation/navigation.component.html @@ -85,18 +85,16 @@ menu

{{ navigationService.title$ | async }}

+ + @if(navigationAdminGearService.adminSettingsData()) { + } -
- - - - - - - -
-
- Organizations - -
-
-
-

{{ element.name }}

-
- - -
-
-
-
diff --git a/frontend/src/app/organization/organization-admin/list/organization-list-admin.component.ts b/frontend/src/app/organization/organization-admin/list/organization-list-admin.component.ts deleted file mode 100644 index f3bd910ce..000000000 --- a/frontend/src/app/organization/organization-admin/list/organization-list-admin.component.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * The Admin Organization List page retrieves and displays a list of - * CS organizations and provides functionality to create/delete them. - * - * @author Ajay Gandecha, Jade Keegan, Brianna Ta, Audrey Toney - * @copyright 2023 - * @license MIT - */ - -import { Component, OnInit } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; -import { OrganizationAdminPermissionGuard } from 'src/app/organization/organization-admin/organization-admin-permission.guard'; -import { Organization } from '../../organization.model'; -import { MatSnackBar } from '@angular/material/snack-bar'; -import { OrganizationAdminService } from '../organization-admin.service'; -import { Observable, map } from 'rxjs'; -import { - Permission, - Profile -} from '/workspace/frontend/src/app/profile/profile.service'; -import { profileResolver } from 'src/app/profile/profile.resolver'; -import { organizationResolver } from '../../organization.resolver'; - -@Component({ - selector: 'app-organization-list-admin', - templateUrl: './organization-list-admin.component.html', - styleUrls: ['./organization-list-admin.component.css'] -}) -export class OrganizationListAdminComponent implements OnInit { - /** Organizations List */ - public organizations$: Observable; - - public displayedColumns: string[] = ['name']; - /** Profile of signed in user */ - protected profile: Profile; - /** List of displayed organizations for the signed in user */ - protected displayedOrganizations$: Observable; - - /** Route information to be used in Organization Routing Module */ - public static Route = { - path: 'admin', - component: OrganizationListAdminComponent, - title: 'Organization Administration', - canActivate: [OrganizationAdminPermissionGuard()], - resolve: { profile: profileResolver, organizations: organizationResolver } - }; - - constructor( - private route: ActivatedRoute, - private router: Router, - private snackBar: MatSnackBar, - private organizationAdminService: OrganizationAdminService - ) { - this.organizations$ = organizationAdminService.organizations$; - organizationAdminService.list(); - this.displayedOrganizations$ = this.organizations$; - - /** Get the profile data of the signed in user */ - const data = this.route.snapshot.data as { - profile: Profile; - }; - this.profile = data.profile; - } - - ngOnInit() { - let profilePermissions: Permission[] = this.profile.permissions; - if (profilePermissions[0].resource !== '*') { - /** Filter and return the slug of the users organization permissions */ - let userOrganizationPermissions: string[] = profilePermissions - .filter((element) => element.resource.includes('organization')) - .map((element) => { - return element.resource.substring(13); - }); - /** Update displayedOrganizations$ to only include the organizations the user has permissions for */ - this.displayedOrganizations$ = this.organizations$.pipe( - map((organizations) => - organizations.filter((organization) => - userOrganizationPermissions.includes(organization.slug) - ) - ) - ); - } - } - - /** Resposible for generating delete and create buttons in HTML code when admin signed in */ - adminPermissions(): boolean { - return this.profile.permissions[0].resource === '*'; - } - - /** Event handler to open Organization Editor for the selected organization. - * @param organization: organization to be edited - * @returns void - */ - editOrganization(organization: Organization): void { - this.router.navigate(['organizations', organization.slug, 'edit']); - } - - /** Event handler to open the Organization Editor to create a new organization */ - createOrganization(): void { - // Navigate to the org editor for a new organization (slug = create) - this.router.navigate(['organizations', 'new', 'edit']); - } - - /** Delete an organization object from the backend database table using the backend HTTP post request. - * @param organization_id: unique number representing the updated organization - * @returns void - */ - deleteOrganization(organization: Organization): void { - let confirmDelete = this.snackBar.open( - 'Are you sure you want to delete this organization?', - 'Delete', - { duration: 15000 } - ); - confirmDelete.onAction().subscribe(() => { - this.organizationAdminService - .deleteOrganization(organization) - .subscribe(() => { - this.snackBar.open('This organization has been deleted.', '', { - duration: 2000 - }); - }); - }); - } -} diff --git a/frontend/src/app/organization/organization-admin/organization-admin-permission.guard.ts b/frontend/src/app/organization/organization-admin/organization-admin-permission.guard.ts deleted file mode 100644 index 779085661..000000000 --- a/frontend/src/app/organization/organization-admin/organization-admin-permission.guard.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { inject } from '@angular/core'; -import { CanActivateFn, Router } from '@angular/router'; -import { map } from 'rxjs'; -import { OrganizationAdminPermissionService } from '/workspace/frontend/src/app/organization/organization-admin/organization-admin-permission.service'; - -export const OrganizationAdminPermissionGuard = (): CanActivateFn => { - return (_route, _state) => { - const permission = inject(OrganizationAdminPermissionService); - const router = inject(Router); - return permission.checkForOrganizationPermissions().pipe( - map((isAuthenticated) => { - if (isAuthenticated) { - return true; - } else { - return router.createUrlTree(['']); - } - }) - ); - }; -}; diff --git a/frontend/src/app/organization/organization-admin/organization-admin-permission.service.ts b/frontend/src/app/organization/organization-admin/organization-admin-permission.service.ts deleted file mode 100644 index ba99c4f1e..000000000 --- a/frontend/src/app/organization/organization-admin/organization-admin-permission.service.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Injectable } from '@angular/core'; -import { map, Observable } from 'rxjs'; -import { - Profile, - ProfileService, - Permission -} from '/workspace/frontend/src/app/profile/profile.service'; - -@Injectable({ - providedIn: 'root' -}) -export class OrganizationAdminPermissionService { - private profile$: Observable; - - constructor(profileService: ProfileService) { - this.profile$ = profileService.profile$; - } - /** Check to see if the currently signed in user has any organization permissions */ - checkForOrganizationPermissions(): Observable { - return this.profile$.pipe( - map((profile) => { - if (profile === undefined) { - return false; - } else if (profile.permissions.length !== 0) { - return this.hasOrganizationPermissions(profile.permissions); - } else { - return false; - } - }) - ); - } - - private hasOrganizationPermissions(permissions: Permission[]): boolean { - if (permissions[0].resource === '*') { - return true; - } else { - let organization_index = permissions.findIndex((element) => - element.resource.includes('organization') - ); - /** If they have any organization permissions, return true, else false */ - return organization_index !== -1; - } - } -} diff --git a/frontend/src/app/organization/organization-admin/organization-admin.component.css b/frontend/src/app/organization/organization-admin/organization-admin.component.css index 802b36fec..2284d548a 100644 --- a/frontend/src/app/organization/organization-admin/organization-admin.component.css +++ b/frontend/src/app/organization/organization-admin/organization-admin.component.css @@ -1,21 +1,35 @@ +/** +* admin-organization-list.component.css +* +* The admin organization list page should provide +* a simple, easily readable form for users to view +* all organizations. +* +*/ + .mat-mdc-row .mat-mdc-cell { - border-bottom: 1px solid transparent; - border-top: 1px solid transparent; - cursor: pointer; + border-bottom: 1px solid transparent; + border-top: 1px solid transparent; + cursor: pointer; } .mat-mdc-row:hover .mat-mdc-cell { - border-color: white; + border-color: white; } .header { - display: flex; - align-items: center; - justify-content: space-between; + display: flex; + align-items: center; + justify-content: space-between; } .row { - display: flex; - justify-content: space-between; - align-items: center; + display: flex; + align-items: center; + justify-content: space-between; } + +.button-container button { + justify-content: space-between; + margin: 5px; +} \ No newline at end of file diff --git a/frontend/src/app/organization/organization-admin/organization-admin.component.html b/frontend/src/app/organization/organization-admin/organization-admin.component.html index 16315fb5c..c513a4637 100644 --- a/frontend/src/app/organization/organization-admin/organization-admin.component.html +++ b/frontend/src/app/organization/organization-admin/organization-admin.component.html @@ -1,20 +1,39 @@ -
- +
+
-
-
Organizations
+
+ Organizations @if (adminPermissions() | async) { + + } +

{{ element.name }}

- +
+ @if(adminPermissions() | async) { + + } + + +
diff --git a/frontend/src/app/organization/organization-admin/organization-admin.component.ts b/frontend/src/app/organization/organization-admin/organization-admin.component.ts index abc31de50..7c7d95774 100644 --- a/frontend/src/app/organization/organization-admin/organization-admin.component.ts +++ b/frontend/src/app/organization/organization-admin/organization-admin.component.ts @@ -1,10 +1,21 @@ -import { Component } from '@angular/core'; -import { Router } from '@angular/router'; -import { permissionGuard } from 'src/app/permission.guard'; +/** + * The Admin Organization List page retrieves and displays a list of + * CS organizations and provides functionality to create/delete them. + * + * @author Ajay Gandecha, Jade Keegan, Brianna Ta, Audrey Toney + * @copyright 2024 + * @license MIT + */ + +import { Component, Signal } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; import { Organization } from '../organization.model'; import { MatSnackBar } from '@angular/material/snack-bar'; -import { OrganizationAdminService } from './organization-admin.service'; import { Observable } from 'rxjs'; +import { Profile } from '../../profile/profile.service'; +import { profileResolver } from '../../profile/profile.resolver'; +import { OrganizationService } from '../organization.service'; +import { PermissionService } from '../../permission.service'; @Component({ selector: 'app-organization-admin', @@ -13,29 +24,76 @@ import { Observable } from 'rxjs'; }) export class OrganizationAdminComponent { /** Organizations List */ - public organizations$: Observable; + public organizations: Signal; public displayedColumns: string[] = ['name']; + /** Profile of signed in user */ + protected profile: Profile; + /** List of displayed organizations for the signed in user */ + protected displayedOrganizations: Signal; - /** Route information to be used in Admin Routing Module */ + /** Route information to be used in Organization Routing Module */ public static Route = { path: 'admin', component: OrganizationAdminComponent, - title: 'Organization Administration' - // canActivate: [permissionGuard('organization.*', 'organization/cads')] + title: 'Organization Administration', + resolve: { profile: profileResolver } }; constructor( + private route: ActivatedRoute, private router: Router, private snackBar: MatSnackBar, - private organizationAdminService: OrganizationAdminService + private organizationService: OrganizationService, + private permissionService: PermissionService ) { - this.organizations$ = organizationAdminService.organizations$; - organizationAdminService.list(); + this.organizations = organizationService.organizations; + this.displayedOrganizations = organizationService.adminOrganizations; + + /** Get the profile data of the signed in user */ + const data = this.route.snapshot.data as { + profile: Profile; + }; + this.profile = data.profile; + } + + /** Resposible for generating delete and create buttons in HTML code when admin signed in. + * @returns {Observable} + */ + adminPermissions(): Observable { + return this.permissionService.check('organization.create', '*'); } - /** Event handler to open the Organization Editor to edit an existing organization */ + /** Event handler to open Organization Editor for the selected organization. + * @param organization: organization to be edited + */ editOrganization(organization: Organization): void { this.router.navigate(['organizations', organization.slug, 'edit']); } + + /** Event handler to open the Organization Editor to create a new organization */ + createOrganization(): void { + // Navigate to the org editor for a new organization (slug = create) + this.router.navigate(['organizations', 'new', 'edit']); + } + + /** Delete an organization object from the backend database table using the backend HTTP post request. + * @param organization_id: unique number representing the updated organization + */ + deleteOrganization(organization: Organization): void { + let confirmDelete = this.snackBar.open( + 'Are you sure you want to delete this organization?', + 'Delete', + { duration: 15000 } + ); + confirmDelete.onAction().subscribe(() => { + this.organizationService + .deleteOrganization(organization) + .subscribe(() => { + this.snackBar.open('This organization has been deleted.', '', { + duration: 2000 + }); + }); + }); + } } diff --git a/frontend/src/app/organization/organization-admin/organization-admin.service.ts b/frontend/src/app/organization/organization-admin/organization-admin.service.ts deleted file mode 100644 index 550f92f3f..000000000 --- a/frontend/src/app/organization/organization-admin/organization-admin.service.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * The Admin Organization Service abstracts backend calls from the - * Admin organization List Component. - * - * @author Ajay Gandecha, Jade Keegan, Brianna Ta, Audrey Toney - * @copyright 2023 - * @license MIT - */ - -import { Injectable } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; -import { Observable, tap } from 'rxjs'; -import { RxOrganization } from '../rx-organization'; -import { Organization } from '../organization.model'; - -@Injectable({ providedIn: 'root' }) -export class OrganizationAdminService { - private organizations: RxOrganization = new RxOrganization(); - public organizations$: Observable = this.organizations.value$; - - constructor(protected http: HttpClient) {} - - /** Returns a list of all Organizations - * @returns {Observable} - */ - list(): void { - this.http - .get('/api/organizations') - .subscribe((organizations) => this.organizations.set(organizations)); - } - - /** Creates an organization - * @param newOrganization: Organization object that you want to add to the database - * @returns {Observable} - */ - createOrganization(newOrganization: Organization): Observable { - return this.http - .post('/api/organizations', newOrganization) - .pipe( - tap((organization) => this.organizations.pushOrganization(organization)) - ); - } - - /** Deletes an organization - * @param organization_id: id of the organization object to delete - * @returns {Observable} - */ - deleteOrganization( - organizationToRemove: Organization - ): Observable { - return this.http - .delete(`/api/organizations/${organizationToRemove.slug}`) - .pipe( - tap((_) => { - this.organizations.removeOrganization(organizationToRemove); - }) - ); - } -} diff --git a/frontend/src/app/organization/organization-details/organization-details.component.html b/frontend/src/app/organization/organization-details/organization-details.component.html index 5f4042283..4e9c91d58 100644 --- a/frontend/src/app/organization/organization-details/organization-details.component.html +++ b/frontend/src/app/organization/organization-details/organization-details.component.html @@ -1,28 +1,19 @@ -
- --> - - - - - - -
- -
-
+} diff --git a/frontend/src/app/organization/organization-details/organization-details.component.ts b/frontend/src/app/organization/organization-details/organization-details.component.ts index 434153fdf..147d3d3f6 100644 --- a/frontend/src/app/organization/organization-details/organization-details.component.ts +++ b/frontend/src/app/organization/organization-details/organization-details.component.ts @@ -3,7 +3,7 @@ * UNC CS organizations. * * @author Ajay Gandecha, Jade Keegan, Brianna Ta, Audrey Toney - * @copyright 2023 + * @copyright 2024 * @license MIT */ @@ -15,17 +15,16 @@ import { Route } from '@angular/router'; import { MatSnackBar } from '@angular/material/snack-bar'; -import { profileResolver } from '/workspace/frontend/src/app/profile/profile.resolver'; import { Organization } from '../organization.model'; -import { Profile } from '/workspace/frontend/src/app/profile/profile.service'; +import { Profile, ProfileService } from '../../profile/profile.service'; import { - organizationDetailResolver, + organizationResolver, organizationEventsResolver } from '../organization.resolver'; -import { EventService } from 'src/app/event/event.service'; -import { Event } from 'src/app/event/event.model'; +import { EventService } from '../../event/event.service'; +import { Event } from '../../event/event.model'; import { Observable } from 'rxjs'; -import { PermissionService } from 'src/app/permission.service'; +import { PermissionService } from '../../permission.service'; /** Injects the organization's name to adjust the title. */ let titleResolver: ResolveFn = (route: ActivatedRouteSnapshot) => { @@ -43,8 +42,7 @@ export class OrganizationDetailsComponent { path: ':slug', component: OrganizationDetailsComponent, resolve: { - profile: profileResolver, - organization: organizationDetailResolver, + organization: organizationResolver, events: organizationEventsResolver }, children: [ @@ -60,11 +58,13 @@ export class OrganizationDetailsComponent { public profile: Profile; /** The organization to show */ - public organization: Organization; + public organization: Organization | undefined; + // TODO: Refactor once the event feature is refactored. /** Store a map of days to a list of events for that day */ public eventsPerDay: [string, Event[]][]; + // TODO: Refactor once the event feature is refactored. /** Whether or not the user has permission to update events. */ public eventCreationPermission$: Observable; @@ -72,21 +72,22 @@ export class OrganizationDetailsComponent { constructor( private route: ActivatedRoute, protected snackBar: MatSnackBar, + private profileService: ProfileService, protected eventService: EventService, private permission: PermissionService ) { - /** Initialize data from resolvers. */ + this.profile = this.profileService.profile()!; + const data = this.route.snapshot.data as { - profile: Profile; organization: Organization; events: Event[]; }; - this.profile = data.profile; + this.organization = data.organization; this.eventsPerDay = eventService.groupEventsByDate(data.events ?? []); this.eventCreationPermission$ = this.permission.check( 'organization.*', - `organization/${this.organization.slug}` + `organization/${this.organization?.slug ?? '*'}` ); } } diff --git a/frontend/src/app/organization/organization-editor/organization-editor.component.html b/frontend/src/app/organization/organization-editor/organization-editor.component.html index d07fe61df..0a09c8d2c 100644 --- a/frontend/src/app/organization/organization-editor/organization-editor.component.html +++ b/frontend/src/app/organization/organization-editor/organization-editor.component.html @@ -5,12 +5,9 @@ - - Create Organization + + {{ this.isNew() ? 'Create' : 'Update' }} Organization - - Update Organization - @@ -86,9 +83,11 @@ Email - + @if (organizationForm.controls['email'].invalid) { + {{ getEmailErrorMessage() }} + } diff --git a/frontend/src/app/organization/organization-editor/organization-editor.component.ts b/frontend/src/app/organization/organization-editor/organization-editor.component.ts index ab18b5b50..7e4083994 100644 --- a/frontend/src/app/organization/organization-editor/organization-editor.component.ts +++ b/frontend/src/app/organization/organization-editor/organization-editor.component.ts @@ -2,52 +2,21 @@ * The Organization Editor Component allows organization managers to edit information * about their organization which is publically displayed on the organizations page. * - * @author Ajay Gandecha, Jade Keegan, Brianna Ta, Audrey Toney, Yuvraj Jain - * @copyright 2023 + * @author Ajay Gandecha, Jade Keegan, Brianna Ta, Audrey Toney + * @copyright 2024 * @license MIT */ -import { Component, inject } from '@angular/core'; -import { - ActivatedRoute, - ActivatedRouteSnapshot, - CanActivateFn, - Route, - Router, - RouterStateSnapshot -} from '@angular/router'; +import { Component } from '@angular/core'; +import { ActivatedRoute, Route, Router } from '@angular/router'; import { FormBuilder, FormControl, Validators } from '@angular/forms'; import { MatSnackBar } from '@angular/material/snack-bar'; -import { profileResolver } from 'src/app/profile/profile.resolver'; -import { PermissionService } from 'src/app/permission.service'; import { Organization } from '../organization.model'; +import { Profile, ProfileService } from '../../profile/profile.service'; +import { organizationResolver } from '../organization.resolver'; import { OrganizationService } from '../organization.service'; -import { RoleAdminService } from 'src/app/admin/roles/role-admin.service'; -import { Profile } from 'src/app/profile/profile.service'; -import { organizationDetailResolver } from '../organization.resolver'; -import { Role } from 'src/app/role'; -import { NavigationService } from 'src/app/navigation/navigation.service'; - -const canActivateEditor: CanActivateFn = ( - route: ActivatedRouteSnapshot, - state: RouterStateSnapshot -) => { - /** Determine if page is viewable by user based on permissions */ - - let slug: string = route.params['slug']; - - if (slug === 'new') { - return inject(PermissionService).check( - 'organization.create', - 'organization' - ); - } else { - return inject(PermissionService).check( - 'organization.update', - `organization/${slug}` - ); - } -}; +import { organizationEditorGuard } from './organization-editor.guard'; + @Component({ selector: 'app-organization-editor', templateUrl: './organization-editor.component.html', @@ -59,44 +28,32 @@ export class OrganizationEditorComponent { path: ':slug/edit', component: OrganizationEditorComponent, title: 'Organization Editor', - canActivate: [canActivateEditor], + canActivate: [organizationEditorGuard], resolve: { - profile: profileResolver, - organization: organizationDetailResolver + organization: organizationResolver } }; - /** Store the organization. */ + /** Stores the organization. */ public organization: Organization; /** Store the currently-logged-in user's profile. */ - public profile: Profile | null = null; - - /** Store the organization id. */ - organization_slug: string = 'new'; - - /** Add validators to the form */ - name = new FormControl('', [Validators.required]); - slug = new FormControl('', [ - Validators.required, - Validators.pattern('^(?!new$)[a-z0-9-]+$') - ]); - logo = new FormControl('', [Validators.required]); - email = new FormControl('', [Validators.email]); - shortDescription = new FormControl('', [ - Validators.required, - Validators.maxLength(150) - ]); - longDescription = new FormControl('', [Validators.maxLength(2000)]); + public profile: Profile; /** Organization Editor Form */ public organizationForm = this.formBuilder.group({ - name: this.name, - slug: this.slug, - logo: this.logo, - short_description: this.shortDescription, - long_description: this.longDescription, - email: this.email, + name: new FormControl('', [Validators.required]), + slug: new FormControl('', [ + Validators.required, + Validators.pattern('^(?!new$)[a-z0-9-]+$') + ]), + logo: new FormControl('', [Validators.required]), + short_description: new FormControl('', [ + Validators.required, + Validators.maxLength(150) + ]), + long_description: new FormControl('', [Validators.maxLength(2000)]), + email: new FormControl('', [Validators.email]), shorthand: '', website: '', instagram: '', @@ -106,11 +63,6 @@ export class OrganizationEditorComponent { public: false }); - /** Retreives an error message if an email is invalid */ - getEmailErrorMessage() { - return this.email.hasError('email') ? 'Not a valid email' : ''; - } - /** Constructs the organization editor component */ constructor( private route: ActivatedRoute, @@ -118,38 +70,20 @@ export class OrganizationEditorComponent { protected formBuilder: FormBuilder, protected snackBar: MatSnackBar, private organizationService: OrganizationService, - private roleService: RoleAdminService, - private navService: NavigationService + private profileService: ProfileService ) { + /** Initialize the profile. */ + this.profile = this.profileService.profile()!; + /** Initialize data from resolvers. */ const data = this.route.snapshot.data as { - profile: Profile; organization: Organization; - role: Role; }; - this.profile = data.profile; + this.organization = data.organization; /** Set organization form data */ - this.organizationForm.setValue({ - name: this.organization.name, - slug: this.organization.slug, - shorthand: this.organization.shorthand, - logo: this.organization.logo, - short_description: this.organization.short_description, - long_description: this.organization.long_description, - email: this.organization.email, - website: this.organization.website, - instagram: this.organization.instagram, - linked_in: this.organization.linked_in, - youtube: this.organization.youtube, - heel_life: this.organization.heel_life, - public: this.organization.public - }); - - /** Get id from the url */ - let organization_slug = this.route.snapshot.params['slug']; - this.organization_slug = organization_slug; + this.organizationForm.patchValue(this.organization); } /** Event handler to handle submitting the Update Organization Form. @@ -158,24 +92,15 @@ export class OrganizationEditorComponent { onSubmit(): void { if (this.organizationForm.valid) { Object.assign(this.organization, this.organizationForm.value); - if (this.organization_slug == 'new') { - this.roleService.create(this.organization.slug).subscribe({ - error: (err) => this.navService.error(err) - }); - this.organizationService - .createOrganization(this.organization) - .subscribe({ - next: (organization) => this.onSuccess(organization), - error: (err) => this.onError(err) - }); - } else { - this.organizationService - .updateOrganization(this.organization) - .subscribe({ - next: (organization) => this.onSuccess(organization), - error: (err) => this.onError(err) - }); - } + + let submittedOrganization = this.isNew() + ? this.organizationService.createOrganization(this.organization) + : this.organizationService.updateOrganization(this.organization); + + submittedOrganization.subscribe({ + next: (organization) => this.onSuccess(organization), + error: (err) => this.onError(err) + }); } } @@ -184,7 +109,24 @@ export class OrganizationEditorComponent { * @returns {void} */ onCancel(): void { - this.router.navigate([`organizations/${this.organization_slug}`]); + this.router.navigate([`organizations/${this.organization.slug}`]); + } + + /** Opens a confirmation snackbar when an organization is successfully updated. + * @returns {void} + */ + private onSuccess(organization: Organization): void { + this.router.navigate(['/organizations/', organization.slug]); + this.snackBar.open(`Organization ${this.action()}`, '', { duration: 2000 }); + } + + /** Opens a snackbar when there is an error updating an organization. + * @returns {void} + */ + private onError(err: any): void { + this.snackBar.open(`Error: Organization Not ${this.action()}`, '', { + duration: 2000 + }); } /** Event handler to handle the first change in the organization name field @@ -200,31 +142,26 @@ export class OrganizationEditorComponent { } } - /** Opens a confirmation snackbar when an organization is successfully updated. - * @returns {void} + /** Retreives an error message if an email is invalid + * @returns {string} */ - private onSuccess(organization: Organization): void { - this.router.navigate(['/organizations/', organization.slug]); - - let message: string = - this.organization_slug === 'new' - ? 'Organization Created' - : 'Organization Updated'; - - this.snackBar.open(message, '', { duration: 2000 }); + getEmailErrorMessage() { + return this.organizationForm.controls['email'].hasError('email') + ? 'Not a valid email' + : ''; } - /** Opens a snackbar when there is an error updating an organization. - * @returns {void} + /** Shorthand for whether an organization is new or not. + * @returns {boolean} */ - private onError(err: any): void { - let message: string = - this.organization_slug === 'new' - ? 'Error: Organization Not Created' - : 'Error: Organization Not Updated'; + isNew(): boolean { + return this.organization.slug === 'new'; + } - this.snackBar.open(message, '', { - duration: 2000 - }); + /** Shorthand for determining the action being performed on the organization. + * @returns {string} + */ + action(): string { + return this.isNew() ? 'Created' : 'Updated'; } } diff --git a/frontend/src/app/organization/organization-editor/organization-editor.guard.ts b/frontend/src/app/organization/organization-editor/organization-editor.guard.ts new file mode 100644 index 000000000..ed5f29849 --- /dev/null +++ b/frontend/src/app/organization/organization-editor/organization-editor.guard.ts @@ -0,0 +1,34 @@ +/** + * The Organization Editor Guard ensures that the page can open if the user has either + * create or edit permissions. + * + * @author Ajay Gandecha + * @copyright 2024 + * @license MIT + */ + +import { inject } from '@angular/core'; +import { CanActivateFn } from '@angular/router'; +import { PermissionService } from 'src/app/permission.service'; + +/** Determines whether the user can access the organization editor. + * @param route Active route when the user enters the component. + * @returns {CanActivateFn} + */ +export const organizationEditorGuard: CanActivateFn = (route, _) => { + /** Determine if page is viewable by user based on permissions */ + + let slug: string = route.params['slug']; + + if (slug === 'new') { + return inject(PermissionService).check( + 'organization.create', + 'organization' + ); + } else { + return inject(PermissionService).check( + 'organization.update', + `organization/${slug}` + ); + } +}; diff --git a/frontend/src/app/organization/organization-page/organization-page.component.html b/frontend/src/app/organization/organization-page/organization-page.component.html index f00964053..5fef57852 100644 --- a/frontend/src/app/organization/organization-page/organization-page.component.html +++ b/frontend/src/app/organization/organization-page/organization-page.component.html @@ -7,13 +7,12 @@
+ @for (organization of organizations() | organizationFilter: searchBarQuery; + track organization.id) { + [profile]="profile" /> + }
diff --git a/frontend/src/app/organization/organization-page/organization-page.component.ts b/frontend/src/app/organization/organization-page/organization-page.component.ts index 8b1c27f75..9d0878d29 100644 --- a/frontend/src/app/organization/organization-page/organization-page.component.ts +++ b/frontend/src/app/organization/organization-page/organization-page.component.ts @@ -4,90 +4,59 @@ * based on interests, and access social media pages of organizations to stay up-to-date. * * @author Ajay Gandecha, Jade Keegan, Brianna Ta, Audrey Toney - * @copyright 2023 + * @copyright 2024 * @license MIT */ -import { Component, OnInit } from '@angular/core'; -import { profileResolver } from '/workspace/frontend/src/app/profile/profile.resolver'; +import { Component, Signal, effect } from '@angular/core'; +import { profileResolver } from '../../profile/profile.resolver'; import { Organization } from '../organization.model'; -import { ActivatedRoute } from '@angular/router'; import { MatSnackBar } from '@angular/material/snack-bar'; -import { Profile } from '/workspace/frontend/src/app/profile/profile.service'; -import { organizationResolver } from '../organization.resolver'; -import { NagivationAdminGearService } from 'src/app/navigation/navigation-admin-gear.service'; +import { Profile, ProfileService } from '../../profile/profile.service'; +import { NagivationAdminGearService } from '../../navigation/navigation-admin-gear.service'; +import { OrganizationService } from '../organization.service'; @Component({ selector: 'app-organization-page', templateUrl: './organization-page.component.html', styleUrls: ['./organization-page.component.css'] }) -export class OrganizationPageComponent implements OnInit { +export class OrganizationPageComponent { /** Route information to be used in Organization Routing Module */ public static Route = { path: '', title: 'CS Organizations', component: OrganizationPageComponent, canActivate: [], - resolve: { profile: profileResolver, organizations: organizationResolver } + resolve: { profile: profileResolver } }; - /** Store Observable list of Organizations */ - public organizations: Organization[]; - - /** Store searchBarQuery */ + /** Current search bar query on the organization page. */ public searchBarQuery = ''; /** Store the currently-logged-in user's profile. */ public profile: Profile; - /** Stores the user permission value for current organization. */ - public permValues: Map = new Map(); + /** Stores a reactive organizations list. */ + public organizations: Signal; constructor( - private route: ActivatedRoute, protected snackBar: MatSnackBar, + private organizationService: OrganizationService, + private profileService: ProfileService, private gearService: NagivationAdminGearService ) { - /** Initialize data from resolvers. */ - const data = this.route.snapshot.data as { - profile: Profile; - organizations: Organization[]; - }; - this.profile = data.profile; - this.organizations = data.organizations; + this.profile = this.profileService.profile()!; + this.organizations = this.organizationService.organizations; } - ngOnInit() { - /** Ensure there is a currently signed in user before testing permissions */ - if (this.profile !== undefined) { - let userPermissions = this.profile.permissions; - /** Ensure that the signed in user has permissions before looking at the resource */ - if (userPermissions.length !== 0) { - /** Admin user, no need to check further */ - if (userPermissions[0].resource === '*') { - this.gearService.showAdminGear( - 'organizations.*', - '*', - '', - 'organizations/admin' - ); - } else { - /** Find if the signed in user has any organization permissions */ - let organizationPermissions = userPermissions.filter((element) => - element.resource.includes('organization') - ); - /** If they do, show admin gear */ - if (organizationPermissions.length !== 0) { - this.gearService.showAdminGear( - 'organizations.*', - organizationPermissions[0].resource, - '', - 'organizations/admin' - ); - } - } + /** Effect that shows the organization admin gear if the user is an admin for at least one organization.*/ + organizationGearEffect = effect( + () => { + if (this.organizationService.adminOrganizations().length > 0) { + this.gearService.showAdminGear('', 'organizations/admin'); } - } - } + }, + { allowSignalWrites: true } // Needed to update the gear signal. + ); } diff --git a/frontend/src/app/organization/organization-routing.module.ts b/frontend/src/app/organization/organization-routing.module.ts index 1f6df9adb..e3a2c836d 100644 --- a/frontend/src/app/organization/organization-routing.module.ts +++ b/frontend/src/app/organization/organization-routing.module.ts @@ -12,10 +12,10 @@ import { RouterModule, Routes } from '@angular/router'; import { OrganizationPageComponent } from './organization-page/organization-page.component'; import { OrganizationDetailsComponent } from './organization-details/organization-details.component'; import { OrganizationEditorComponent } from './organization-editor/organization-editor.component'; -import { OrganizationListAdminComponent } from './organization-admin/list/organization-list-admin.component'; +import { OrganizationAdminComponent } from './organization-admin/organization-admin.component'; const routes: Routes = [ - OrganizationListAdminComponent.Route, + OrganizationAdminComponent.Route, OrganizationPageComponent.Route, OrganizationDetailsComponent.Route, OrganizationEditorComponent.Route diff --git a/frontend/src/app/organization/organization.module.ts b/frontend/src/app/organization/organization.module.ts index 0fa47fdd7..c0eda4082 100644 --- a/frontend/src/app/organization/organization.module.ts +++ b/frontend/src/app/organization/organization.module.ts @@ -5,7 +5,7 @@ * in the application. * * @author Ajay Gandecha, Jade Keegan, Brianna Ta, Audrey Toney - * @copyright 2023 + * @copyright 2024 * @license MIT */ @@ -32,9 +32,9 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { OrganizationPageComponent } from './organization-page/organization-page.component'; import { OrganizationRoutingModule } from './organization-routing.module'; import { OrganizationDetailsComponent } from './organization-details/organization-details.component'; -import { OrganizationListAdminComponent } from './organization-admin/list/organization-list-admin.component'; +import { OrganizationAdminComponent } from './organization-admin/organization-admin.component'; -import { OrganizationFilterPipe } from './organization-filter/organization-filter.pipe'; +import { OrganizationFilterPipe } from './pipes/organization-filter.pipe'; /* UI Widgets */ import { OrganizationCard } from './widgets/organization-card/organization-card.widget'; @@ -43,15 +43,13 @@ import { SharedModule } from '../shared/shared.module'; import { OrganizationDetailsInfoCard } from './widgets/organization-details-info-card/organization-details-info-card.widget'; import { OrganizationEditorComponent } from '/workspace/frontend/src/app/organization/organization-editor/organization-editor.component'; import { OrganizationNotFoundCard } from './widgets/organization-not-found-card/organization-not-found-card.widget'; -import { OrganizationAdminComponent } from './organization-admin/organization-admin.component'; @NgModule({ declarations: [ OrganizationPageComponent, - OrganizationAdminComponent, OrganizationDetailsComponent, OrganizationEditorComponent, - OrganizationListAdminComponent, + OrganizationAdminComponent, // Pipes OrganizationFilterPipe, diff --git a/frontend/src/app/organization/organization.resolver.ts b/frontend/src/app/organization/organization.resolver.ts index c0e42816b..b3aa2ca91 100644 --- a/frontend/src/app/organization/organization.resolver.ts +++ b/frontend/src/app/organization/organization.resolver.ts @@ -8,25 +8,18 @@ */ import { inject } from '@angular/core'; -import { ResolveFn } from '@angular/router'; +import { Resolve, ResolveFn } from '@angular/router'; import { Organization } from './organization.model'; -import { OrganizationService } from './organization.service'; import { EventService } from '../event/event.service'; import { Event } from '../event/event.model'; -import { catchError, map, of } from 'rxjs'; - -/** This resolver injects the list of organizations into the organization component. */ -export const organizationResolver: ResolveFn = ( - route, - state -) => { - return inject(OrganizationService).getOrganizations(); -}; +import { catchError, of } from 'rxjs'; +import { OrganizationService } from './organization.service'; +// TODO: Explore if this can be replaced by a signal. /** This resolver injects an organization into the organization detail component. */ -export const organizationDetailResolver: ResolveFn = ( +export const organizationResolver: ResolveFn = ( route, - state + _state ) => { // If the organization is new, return a blank one if (route.paramMap.get('slug')! == 'new') { @@ -61,10 +54,11 @@ export const organizationDetailResolver: ResolveFn = ( ); }; +// TODO: Refactor once the event feature is refactored. /** This resolver injects the events for a given organization into the organization component. */ export const organizationEventsResolver: ResolveFn = ( route, - state + _state ) => { return inject(EventService).getEventsByOrganization( route.paramMap.get('slug')! diff --git a/frontend/src/app/organization/organization.service.ts b/frontend/src/app/organization/organization.service.ts index f09de7c64..13d4e1db3 100644 --- a/frontend/src/app/organization/organization.service.ts +++ b/frontend/src/app/organization/organization.service.ts @@ -3,64 +3,112 @@ * from the components. * * @author Ajay Gandecha, Jade Keegan, Brianna Ta, Audrey Toney - * @copyright 2023 + * @copyright 2024 * @license MIT */ -import { Injectable } from '@angular/core'; +import { Injectable, WritableSignal, computed, signal } from '@angular/core'; + import { HttpClient } from '@angular/common/http'; import { AuthenticationService } from '../authentication.service'; import { MatSnackBar } from '@angular/material/snack-bar'; -import { Observable } from 'rxjs'; +import { Observable, tap } from 'rxjs'; import { Organization } from './organization.model'; -import { Role } from '../role'; +import { PermissionService } from '../permission.service'; @Injectable({ providedIn: 'root' }) export class OrganizationService { + /** Organizations signal */ + private organizationsSignal: WritableSignal = signal([]); + organizations = this.organizationsSignal.asReadonly(); + adminOrganizations = computed(() => { + return this.organizations().filter((organization) => { + return this.permissionService.checkSignal( + 'organization.*', + 'organization/' + organization.slug + ); + }); + }); + + /** Constructor */ constructor( protected http: HttpClient, protected auth: AuthenticationService, - protected snackBar: MatSnackBar - ) {} + protected snackBar: MatSnackBar, + protected permissionService: PermissionService + ) { + this.getOrganizations(); + } - /** Returns all organization entries from the backend database table using the backend HTTP get request. - * @returns {Observable} - */ - getOrganizations(): Observable { - return this.http.get('/api/organizations'); + /** Refreshes the organization data emitted by the organizations signal. */ + getOrganizations() { + this.http + .get('/api/organizations') + .subscribe((organizations) => { + this.organizationsSignal.set(organizations); + }); } - /** Returns the organization object from the backend database table using the backend HTTP get request. + /** Gets an organization based on its slug. * @param slug: String representing the organization slug - * @returns {Observable} + * @returns {Observable} */ - getOrganization(slug: string): Observable { + getOrganization(slug: string): Observable { return this.http.get('/api/organizations/' + slug); } - /** Returns the new organization object from the backend database table using the backend HTTP post request. - * @param organization: OrganizationSummary representing the new organization + /** Returns the new organization object from the backend database table using the backend HTTP post request + * and updates the organizations signal to include the new organization. + * @param organization: Organization to add * @returns {Observable} */ createOrganization(organization: Organization): Observable { - return this.http.post('/api/organizations', organization); + return this.http + .post('/api/organizations', organization) + .pipe( + tap((organization) => + this.organizationsSignal.update((organizations) => [ + ...organizations, + organization + ]) + ) + ); } - /** Returns the updated organization object from the backend database table using the backend HTTP put request. - * @param organization: OrganizationSummary representing the updated organization + /** Returns the updated organization object from the backend database table using the backend HTTP put request + * and update the organizations signal to include the updated organization. + * @param organization: Represents the updated organization * @returns {Observable} */ updateOrganization(organization: Organization): Observable { - return this.http.put('/api/organizations', organization); + return this.http + .put('/api/organizations', organization) + .pipe( + tap((updatedOrganization) => + this.organizationsSignal.update((organizations) => [ + ...organizations.filter((o) => o.id != updatedOrganization.id), + updatedOrganization + ]) + ) + ); } - /** Returns the new role object from the backend database table using the backend HTTP post request. - * @param role: Role representing the new role - * @returns {Observable} + /** Returns the deleted organization object from the backend database table using the backend HTTP put request + * and updates the organizations signal to exclude the deleted organization. + * @param organization: Represents the deleted organization + * @returns {Observable} */ - createRole(role: Role): Observable { - return this.http.post('/api/admin/roles', role); + deleteOrganization(organization: Organization): Observable { + return this.http + .delete(`/api/organizations/${organization.slug}`) + .pipe( + tap((deletedOrganization) => { + this.organizationsSignal.update((organizations) => + organizations.filter((o) => o.id != deletedOrganization.id) + ); + }) + ); } } diff --git a/frontend/src/app/organization/organization-filter/organization-filter.pipe.ts b/frontend/src/app/organization/pipes/organization-filter.pipe.ts similarity index 100% rename from frontend/src/app/organization/organization-filter/organization-filter.pipe.ts rename to frontend/src/app/organization/pipes/organization-filter.pipe.ts diff --git a/frontend/src/app/organization/rx-organization.ts b/frontend/src/app/organization/rx-organization.ts deleted file mode 100644 index 77464ebaf..000000000 --- a/frontend/src/app/organization/rx-organization.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * The RxOrganization object is used to ensure proper updating and - * retrieval of the list of all organizations in the database. - * - * @author Jade Keegan - * @copyright 2023 - * @license MIT - */ - -import { RxObject } from '../rx-object'; -import { Organization } from './organization.model'; - -export class RxOrganization extends RxObject { - pushOrganization(organization: Organization): void { - this.value.push(organization); - this.notify(); - } - - updateOrganization(organization: Organization): void { - this.value = this.value.map((o) => { - return o.id !== organization.id ? o : organization; - }); - this.notify(); - } - - removeOrganization(organizationToRemove: Organization): void { - this.value = this.value.filter( - (organization) => organizationToRemove.slug !== organization.slug - ); - this.notify(); - } -} diff --git a/frontend/src/app/organization/widgets/organization-card/organization-card.widget.html b/frontend/src/app/organization/widgets/organization-card/organization-card.widget.html index 661b47cc9..6528e6a9a 100644 --- a/frontend/src/app/organization/widgets/organization-card/organization-card.widget.html +++ b/frontend/src/app/organization/widgets/organization-card/organization-card.widget.html @@ -21,10 +21,7 @@ -

+

{{ organization.short_description }}

@@ -33,26 +30,27 @@ ; - - /** - * Determines whether or not the tooltip on the card is disabled - * @param element: The HTML element - * @returns {boolean} - */ - isTooltipDisabled(element: HTMLElement): boolean { - return element.scrollHeight <= element.clientHeight; - } constructor() {} } diff --git a/frontend/src/app/organization/widgets/organization-details-info-card/organization-details-info-card.widget.html b/frontend/src/app/organization/widgets/organization-details-info-card/organization-details-info-card.widget.html index 8a2cf71a1..699be050b 100644 --- a/frontend/src/app/organization/widgets/organization-details-info-card/organization-details-info-card.widget.html +++ b/frontend/src/app/organization/widgets/organization-details-info-card/organization-details-info-card.widget.html @@ -4,67 +4,66 @@ appearance="outlined"> -
+ @if (!isTablet && !isHandset) { +
- +
+ }
+ @if (isTablet || isHandset) { + }
- {{ organization!.name }} + {{ organization.name }}
-

- {{ organization!.long_description }} +

+ {{ organization.long_description || organization.short_description }}

- - {{ organization!.short_description }} -
diff --git a/frontend/src/app/organization/widgets/organization-details-info-card/organization-details-info-card.widget.ts b/frontend/src/app/organization/widgets/organization-details-info-card/organization-details-info-card.widget.ts index 2b46603db..47b13271e 100644 --- a/frontend/src/app/organization/widgets/organization-details-info-card/organization-details-info-card.widget.ts +++ b/frontend/src/app/organization/widgets/organization-details-info-card/organization-details-info-card.widget.ts @@ -7,15 +7,13 @@ * @license MIT */ -import { Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { Component, Input, OnDestroy, OnInit, input } from '@angular/core'; import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout'; import { Subscription } from 'rxjs'; import { map } from 'rxjs/operators'; import { Organization } from '../../organization.model'; -import { Profile } from 'src/app/profile/profile.service'; -import { PermissionService } from 'src/app/permission.service'; -import { Observable } from 'rxjs'; -import { NagivationAdminGearService } from 'src/app/navigation/navigation-admin-gear.service'; +import { Profile } from '../../../profile/profile.service'; +import { NagivationAdminGearService } from '../../../navigation/navigation-admin-gear.service'; @Component({ selector: 'organization-details-info-card', @@ -24,7 +22,7 @@ import { NagivationAdminGearService } from 'src/app/navigation/navigation-admin- }) export class OrganizationDetailsInfoCard implements OnInit, OnDestroy { /** The organization to show */ - @Input() organization?: Organization; + @Input() organization: Organization | undefined; /** The currently logged in user */ @Input() profile?: Profile; @@ -39,22 +37,14 @@ export class OrganizationDetailsInfoCard implements OnInit, OnDestroy { /** Constructs the organization detail info card widget */ constructor( private breakpointObserver: BreakpointObserver, - private permission: PermissionService, private gearService: NagivationAdminGearService ) {} - checkPermissions(): Observable { - return this.permission.check( - 'organization.update', - `organization/${this.organization?.slug}` - ); - } - /** Runs whenever the view is rendered initally on the screen */ ngOnInit(): void { this.isHandsetSubscription = this.initHandset(); this.isTabletSubscription = this.initTablet(); - this.gearService.showAdminGear( + this.gearService.showAdminGearByPermissionCheck( 'organization.*', `organization/${this.organization?.slug}`, '', diff --git a/frontend/src/app/permission.guard.ts b/frontend/src/app/permission.guard.ts index 56ec8e4c4..36cf6926b 100644 --- a/frontend/src/app/permission.guard.ts +++ b/frontend/src/app/permission.guard.ts @@ -3,7 +3,6 @@ import { CanActivateFn, Router } from '@angular/router'; import { map } from 'rxjs'; import { PermissionService } from './permission.service'; -// TODO: #4 Allow resource patterns such as role/{id} and replace {id} with _route fragment export const permissionGuard = ( action: string, resource: string diff --git a/frontend/src/app/permission.service.ts b/frontend/src/app/permission.service.ts index d76ee0af9..02df37fc3 100644 --- a/frontend/src/app/permission.service.ts +++ b/frontend/src/app/permission.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { Injectable, Signal, signal } from '@angular/core'; import { map, Observable, ReplaySubject } from 'rxjs'; import { Profile, ProfileService, Permission } from './profile/profile.service'; import { AdminSettingsNavigationData } from './navigation/navigation.service'; @@ -7,12 +7,21 @@ import { AdminSettingsNavigationData } from './navigation/navigation.service'; providedIn: 'root' }) export class PermissionService { + private profile: Signal = signal(undefined); private profile$: Observable; constructor(profileService: ProfileService) { + this.profile = profileService.profile; this.profile$ = profileService.profile$; } + checkSignal(action: string, resource: string): boolean { + return ( + this.profile && + this.hasPermission(this.profile()!.permissions, action, resource) + ); + } + check(action: string, resource: string): Observable { return this.profile$.pipe( map((profile) => { diff --git a/frontend/src/app/profile/profile.service.ts b/frontend/src/app/profile/profile.service.ts index 7c7d9d7cb..980fddf15 100644 --- a/frontend/src/app/profile/profile.service.ts +++ b/frontend/src/app/profile/profile.service.ts @@ -1,5 +1,5 @@ import { HttpClient } from '@angular/common/http'; -import { Injectable } from '@angular/core'; +import { Injectable, Signal, WritableSignal, signal } from '@angular/core'; import { Observable, ReplaySubject, Subject, tap } from 'rxjs'; import { AuthenticationService } from '../authentication.service'; @@ -39,9 +39,14 @@ export interface PublicProfile { providedIn: 'root' }) export class ProfileService { - private profile: Subject = new ReplaySubject(1); + // TODO: Fully complete signal migration for profile service + private profileSignal: WritableSignal = + signal(undefined); + public profile = this.profileSignal.asReadonly(); + + private profileSubject: Subject = new ReplaySubject(1); public profile$: Observable = - this.profile.asObservable(); + this.profileSubject.asObservable(); constructor( protected http: HttpClient, @@ -55,18 +60,28 @@ export class ProfileService { private refreshProfile(isAuthenticated: boolean) { if (isAuthenticated) { this.http.get('/api/profile').subscribe({ - next: (profile) => this.profile.next(profile), - error: () => this.profile.next(undefined) + next: (profile) => { + this.profileSubject.next(profile); + this.profileSignal.set(profile); + }, + error: () => { + this.profileSubject.next(undefined); + this.profileSignal.set(undefined); + } }); } else { - this.profile.next(undefined); + this.profileSubject.next(undefined); + this.profileSignal.set(undefined); } } put(profile: Profile) { - return this.http - .put('/api/profile', profile) - .pipe(tap((profile) => this.profile.next(profile))); + return this.http.put('/api/profile', profile).pipe( + tap((profile) => { + this.profileSubject.next(profile); + this.profileSignal.set(profile); + }) + ); } search(query: string) { diff --git a/frontend/src/app/shared/event-list/event-list.widget.ts b/frontend/src/app/shared/event-list/event-list.widget.ts index 2c09b3cd7..04f48ef7a 100644 --- a/frontend/src/app/shared/event-list/event-list.widget.ts +++ b/frontend/src/app/shared/event-list/event-list.widget.ts @@ -24,7 +24,7 @@ export class EventList { @Input() eventsPerDay: [string, Event[]][] = []; /** The organization associated with the Event List for the Organization Details Page */ - @Input() organization: Organization | null = null; + @Input() organization: Organization | undefined = undefined; /** Store the selected Event */ @Input() selectedEvent: Event | null = null; From 097ade96300761f544e538546c1d46eb8be5c506 Mon Sep 17 00:00:00 2001 From: Ajay Gandecha Date: Thu, 23 May 2024 15:32:45 -0400 Subject: [PATCH 2/3] Create Frontend Pagination Abstraction (#456) * Port Pagination Abstraction to Angular v17 * Fix Syntax in Documentation (oops) * Refactor the Pagination Operator Function Input * Refactor Pagination API * Add Documentation * Clean up some unused documentation * Add Return Statement * Add Return to Docstring * Update Documentation --- .../users/list/admin-users-list.component.ts | 8 +- .../src/app/admin/users/user-admin.service.ts | 2 +- .../event/event-page/event-page.component.ts | 6 +- frontend/src/app/event/event.service.ts | 15 +- .../event-users-list.widget.ts | 4 +- frontend/src/app/pagination.ts | 133 ++++++++++++++++-- 6 files changed, 145 insertions(+), 23 deletions(-) diff --git a/frontend/src/app/admin/users/list/admin-users-list.component.ts b/frontend/src/app/admin/users/list/admin-users-list.component.ts index b681bd95e..03174cc82 100644 --- a/frontend/src/app/admin/users/list/admin-users-list.component.ts +++ b/frontend/src/app/admin/users/list/admin-users-list.component.ts @@ -4,7 +4,7 @@ import { Profile } from 'src/app/profile/profile.service'; import { UserAdminService } from 'src/app/admin/users/user-admin.service'; import { permissionGuard } from 'src/app/permission.guard'; -import { Paginated } from 'src/app/pagination'; +import { Paginated, PaginationParams } from 'src/app/pagination'; import { PageEvent } from '@angular/material/paginator'; @Component({ @@ -13,7 +13,7 @@ import { PageEvent } from '@angular/material/paginator'; styleUrls: [] }) export class AdminUsersListComponent { - public page: Paginated; + public page: Paginated; public displayedColumns: string[] = [ 'first_name', @@ -45,7 +45,9 @@ export class AdminUsersListComponent { private router: Router, route: ActivatedRoute ) { - let data = route.snapshot.data as { page: Paginated }; + let data = route.snapshot.data as { + page: Paginated; + }; this.page = data.page; } diff --git a/frontend/src/app/admin/users/user-admin.service.ts b/frontend/src/app/admin/users/user-admin.service.ts index 12800f33b..41bc6b616 100644 --- a/frontend/src/app/admin/users/user-admin.service.ts +++ b/frontend/src/app/admin/users/user-admin.service.ts @@ -15,7 +15,7 @@ export class UserAdminService { filter: params.filter }; let query = new URLSearchParams(paramStrings); - return this.http.get>( + return this.http.get>( '/api/admin/users?' + query.toString() ); } diff --git a/frontend/src/app/event/event-page/event-page.component.ts b/frontend/src/app/event/event-page/event-page.component.ts index 9d164f3db..f52501267 100644 --- a/frontend/src/app/event/event-page/event-page.component.ts +++ b/frontend/src/app/event/event-page/event-page.component.ts @@ -22,7 +22,7 @@ import { DatePipe } from '@angular/common'; import { EventService } from '../event.service'; import { NagivationAdminGearService } from 'src/app/navigation/navigation-admin-gear.service'; -import { PaginatedEvent } from 'src/app/pagination'; +import { EventPaginationParams, Paginated } from 'src/app/pagination'; import { Subject, Subscription, @@ -38,7 +38,7 @@ import { styleUrls: ['./event-page.component.css'] }) export class EventPageComponent implements OnInit, OnDestroy { - public page: PaginatedEvent; + public page: Paginated; public startDate = new Date(); public endDate = new Date(new Date().setMonth(new Date().getMonth() + 1)); public today: boolean = true; @@ -99,7 +99,7 @@ export class EventPageComponent implements OnInit, OnDestroy { // Initialize data from resolvers const data = this.route.snapshot.data as { profile: Profile; - page: PaginatedEvent; + page: Paginated; }; this.profile = data.profile; this.page = data.page; diff --git a/frontend/src/app/event/event.service.ts b/frontend/src/app/event/event.service.ts index 2f4abed6b..994d02eaf 100644 --- a/frontend/src/app/event/event.service.ts +++ b/frontend/src/app/event/event.service.ts @@ -17,9 +17,12 @@ import { parseEventJson } from './event.model'; import { DatePipe } from '@angular/common'; -import { EventPaginationParams, PaginatedEvent } from 'src/app/pagination'; import { Profile, ProfileService } from '../profile/profile.service'; -import { Paginated, PaginationParams } from '../pagination'; +import { + Paginated, + PaginationParams, + TimeRangePaginationParams +} from '../pagination'; import { RxEvent } from './rx-event'; @Injectable({ @@ -53,7 +56,7 @@ export class EventService { filter: params.filter }; let query = new URLSearchParams(paramStrings); - return this.http.get>( + return this.http.get>( `/api/events/${event_id}/registrations/users?` + query.toString() ); } @@ -214,7 +217,7 @@ export class EventService { ); } - list(params: EventPaginationParams) { + list(params: TimeRangePaginationParams) { let paramStrings = { order_by: params.order_by, ascending: params.ascending, @@ -225,7 +228,7 @@ export class EventService { let query = new URLSearchParams(paramStrings); if (this.profile) { return this.http - .get>( + .get>( '/api/events/paginate?' + query.toString() ) .pipe( @@ -237,7 +240,7 @@ export class EventService { } else { // if a user isn't logged in, return the normal endpoint without registration statuses return this.http - .get>( + .get>( '/api/events/paginate/unauthenticated?' + query.toString() ) .pipe( diff --git a/frontend/src/app/event/widgets/event-users-list/event-users-list.widget.ts b/frontend/src/app/event/widgets/event-users-list/event-users-list.widget.ts index ac5604e06..99bc7c9c6 100644 --- a/frontend/src/app/event/widgets/event-users-list/event-users-list.widget.ts +++ b/frontend/src/app/event/widgets/event-users-list/event-users-list.widget.ts @@ -9,7 +9,7 @@ import { Component, Input, OnInit } from '@angular/core'; import { PageEvent } from '@angular/material/paginator'; -import { Paginated } from 'src/app/pagination'; +import { Paginated, PaginationParams } from 'src/app/pagination'; import { Profile } from 'src/app/models.module'; import { EventService } from '../../event.service'; import { Event } from '../../event.model'; @@ -21,7 +21,7 @@ import { Event } from '../../event.model'; }) export class EventUsersList implements OnInit { @Input() event!: Event; - page!: Paginated; + page!: Paginated; public displayedColumns: string[] = ['name', 'pronouns', 'email']; diff --git a/frontend/src/app/pagination.ts b/frontend/src/app/pagination.ts index e24a2e5db..bde671cd9 100644 --- a/frontend/src/app/pagination.ts +++ b/frontend/src/app/pagination.ts @@ -1,11 +1,29 @@ -export interface PaginationParams { +/** + * The functionality on this page abstracts out the process of pagination in the frontend so that + * working with pagination in services and in components is significantly easier. The abstraction + * supports simple pagination, as well as pagination where the HTTP response model does not match + * the type of model stored in each page. + * + * The abstraction makes strong use of generic types so that the feature is flexible to be used + * across the site with minimal localization. + * + * @author Ajay Gandecha + */ + +import { HttpClient } from '@angular/common/http'; +import { WritableSignal, signal } from '@angular/core'; +import { Observable, map, tap } from 'rxjs'; + +/** Defines the general model for the pagination parameters expected by the backend. */ +export interface PaginationParams extends URLSearchParams { page: number; page_size: number; order_by: string; filter: string; } -export interface EventPaginationParams { +/** Defines the general model for the time range pagination parameters expected by the backend. */ +export interface TimeRangePaginationParams extends URLSearchParams { order_by: string; ascending: string; filter: string; @@ -13,14 +31,113 @@ export interface EventPaginationParams { range_end: string; } -export interface Paginated { +/** + * Interface that defines a page returned from a paginator. + * + * @template T: Type of data stored in the page. + * @template ParamType: The shape of the parameters used for this page. + */ +export interface Paginated { items: T[]; length: number; - params: PaginationParams; + params: ParamType; } -export interface PaginatedEvent { - items: T[]; - length: number; - params: EventPaginationParams; +/** + * This class abstracts the functionality of pagination to be significantly easier to work with across the + * entire frontend. The paginator works across all different object types and pagination param types with + * generics, contains functionality to convert between HTTP response models (such as `EventJson`) to regular + * models (such as `Event`). The `page` emitted by the paginator is reactive using a signal, so data automatically + * refreshes, simplifying data handlng in frontend components. + * + * @template T: Type of data stored in the paginator's pages. + * @template Params: Type of the pagination params used by the type of object being paginated. + */ +abstract class PaginatorAbstraction { + /** Stores the previously used parameters for reference. */ + previousParams: Params | null = null; + + /** Internal writeable signal that updates when a new page is loaded. */ + private pageSignal: WritableSignal | undefined> = + signal(undefined); + /** Signal that exposes the currently active pagination page to frontend services and components. */ + public page = this.pageSignal.asReadonly(); + + /** + * Constructs a paginator object. + * + * @param api: The string of the API endpoint to be called when a new page is loaded. + */ + constructor( + protected api: string, + protected http: HttpClient + ) { + this.api = api; + } + + /** + * Loads a new pagination page based on the API endpoint provided in the constructor and provided + * pagination parameters. + * + * Usage: + * ``` + * paginator.loadPage<>(params); + * ``` + * + * This method also supports a operator function in the case that the API endpoint returns + * a model that is different than the provided type `T` for the paginator. This is to be most commonly + * used with converting `Json` repsonse models to the regular typed response models. To support this, + * the .loadPage method supports a optional generic type for the API response type. + * + * Usage: + * ``` + * paginator.loadPage(params, parseEventJson); + * ``` + * + * @template APIType: (Optional) Response model from the API call, if it is different than `T`. + * @param paramStrings: Pagination parameters. + * @param operator: (Optional) Function to convert data from `Paginated` to `Paginated`. + * @returns {Observable>} + */ + loadPage( + paramStrings: Params, + operator?: ((_: APIType) => T) | null + ): Observable> { + // Stpres the previous pagination parameters used + this.previousParams = paramStrings; + + // Determines the query for the URL based on the new paramateres. + let query = new URLSearchParams(paramStrings); + let route = this.api + '?' + query.toString(); + + // Determine if an operator function is necessary + if (operator) { + // If so, call the API, pipe it through the operator, and update the signal. + return this.http.get>(route).pipe( + map((paginatedResponse) => { + let paginated: Paginated = { + items: paginatedResponse.items.map(operator), + length: paginatedResponse.length, + params: paginatedResponse.params + }; + return paginated; + }), + tap((pageData) => this.pageSignal.set(pageData)) + ); + } else { + // Otherwise, just call the API and update the signal. + return this.http + .get>(route) + .pipe(tap((pageData) => this.pageSignal.set(pageData))); + } + } } + +/** Default paginator implementation. */ +export class Paginator extends PaginatorAbstraction {} + +/** Paginator implementation for working with time ranges. */ +export class TimeRangePaginator extends PaginatorAbstraction< + T, + TimeRangePaginationParams +> {} From 16fcd10b592bce3415c188ecf33fddc81cbf2993 Mon Sep 17 00:00:00 2001 From: Ajay Gandecha Date: Tue, 28 May 2024 14:51:28 -0400 Subject: [PATCH 3/3] Angular v17 Upgrade: Events Feature Refactor (#462) Refactors the entire events feature to the new standards of Angular v17. This pull request also rewrites most of the functionality to be more concise and readable, following the best conventions for future students in COMP 423 and 393, using everything we have learned so far. This refactor also fully utilizes the new frontend pagination abstraction created in #456. --- backend/services/event.py | 13 +- .../users/list/admin-users-list.component.ts | 4 +- frontend/src/app/app.module.ts | 1 - .../event-details.component.html | 8 +- .../event-details/event-details.component.ts | 50 +-- .../event-editor/event-editor.component.html | 20 +- .../event-editor/event-editor.component.ts | 188 +++++------ .../event/event-editor/event-editor.guard.ts | 51 +++ .../event-list-admin.component.css | 35 -- .../event-list-admin.component.html | 25 -- .../event-list-admin.component.ts | 94 ------ .../event-page/event-page.component.html | 34 +- .../event/event-page/event-page.component.ts | 315 ++++++------------ .../src/app/event/event-routing.module.ts | 2 - frontend/src/app/event/event.model.ts | 8 +- frontend/src/app/event/event.module.ts | 11 +- frontend/src/app/event/event.resolver.ts | 13 +- frontend/src/app/event/event.service.ts | 262 ++++----------- .../src/app/event/pipes/group-events.pipe.ts | 36 ++ frontend/src/app/event/rx-event.ts | 25 -- .../event-detail-card.widget.html | 60 ++-- .../event-detail-card.widget.ts | 40 +-- .../event-users-list.widget.html | 4 +- .../event-users-list.widget.ts | 9 +- .../organization-admin.component.ts | 17 +- .../organization-details.component.html | 10 - .../organization-details.component.ts | 4 +- .../organization-page.component.ts | 5 +- .../app/organization/organization.resolver.ts | 11 +- .../app/organization/organization.service.ts | 4 +- frontend/src/app/pagination.ts | 16 +- 31 files changed, 487 insertions(+), 888 deletions(-) create mode 100644 frontend/src/app/event/event-editor/event-editor.guard.ts delete mode 100644 frontend/src/app/event/event-list-admin/event-list-admin.component.css delete mode 100644 frontend/src/app/event/event-list-admin/event-list-admin.component.html delete mode 100644 frontend/src/app/event/event-list-admin/event-list-admin.component.ts create mode 100644 frontend/src/app/event/pipes/group-events.pipe.ts delete mode 100644 frontend/src/app/event/rx-event.ts diff --git a/backend/services/event.py b/backend/services/event.py index f0bfa4200..8272e8137 100644 --- a/backend/services/event.py +++ b/backend/services/event.py @@ -77,9 +77,8 @@ def get_paginated_events( range_start = pagination_params.range_start range_end = pagination_params.range_end criteria = and_( - EventEntity.time - >= datetime.strptime(range_start, "%d/%m/%Y, %H:%M:%S"), - EventEntity.time <= datetime.strptime(range_end, "%d/%m/%Y, %H:%M:%S"), + EventEntity.time >= datetime.fromisoformat(range_start), + EventEntity.time <= datetime.fromisoformat(range_end), ) statement = statement.where(criteria) length_statement = length_statement.where(criteria) @@ -106,11 +105,13 @@ def get_paginated_events( limit = pagination_params.page_size if pagination_params.order_by != "": - statement = statement.order_by( - getattr(EventEntity, pagination_params.order_by) - ) if pagination_params.ascending else statement.order_by( + statement = ( + statement.order_by(getattr(EventEntity, pagination_params.order_by)) + if pagination_params.ascending + else statement.order_by( getattr(EventEntity, pagination_params.order_by).desc() ) + ) statement = statement.offset(offset).limit(limit) diff --git a/frontend/src/app/admin/users/list/admin-users-list.component.ts b/frontend/src/app/admin/users/list/admin-users-list.component.ts index 03174cc82..e5eab52ae 100644 --- a/frontend/src/app/admin/users/list/admin-users-list.component.ts +++ b/frontend/src/app/admin/users/list/admin-users-list.component.ts @@ -36,7 +36,9 @@ export class AdminUsersListComponent { canActivate: [permissionGuard('user.list', 'user/')], resolve: { page: () => - inject(UserAdminService).list(AdminUsersListComponent.PaginationParams) + inject(UserAdminService).list( + AdminUsersListComponent.PaginationParams as PaginationParams + ) } }; diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index a1cc95580..01d2bf24d 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -40,7 +40,6 @@ import { ErrorDialogComponent } from './navigation/error-dialog/error-dialog.com import { HomeComponent } from './home/home.component'; import { AboutComponent } from './about/about.component'; import { GateComponent } from './gate/gate.component'; -import { ProfileEditorComponent } from './profile/profile-editor/profile-editor.component'; import { SharedModule } from './shared/shared.module'; @NgModule({ diff --git a/frontend/src/app/event/event-details/event-details.component.html b/frontend/src/app/event/event-details/event-details.component.html index 5d7cf7c4f..56094aad2 100644 --- a/frontend/src/app/event/event-details/event-details.component.html +++ b/frontend/src/app/event/event-details/event-details.component.html @@ -1,9 +1,9 @@
- + - + @if ((this.canViewEvent() | async) || this.event?.is_organizer ?? false) { + + }
diff --git a/frontend/src/app/event/event-details/event-details.component.ts b/frontend/src/app/event/event-details/event-details.component.ts index 42c1593c4..899b0e442 100644 --- a/frontend/src/app/event/event-details/event-details.component.ts +++ b/frontend/src/app/event/event-details/event-details.component.ts @@ -3,14 +3,13 @@ * any given event. * * @author Ajay Gandecha, Jade Keegan, Brianna Ta, Audrey Toney - * @copyright 2023 + * @copyright 2024 * @license MIT */ -import { Component, inject } from '@angular/core'; -import { profileResolver } from 'src/app/profile/profile.resolver'; -import { eventDetailResolver } from '../event.resolver'; -import { Profile } from 'src/app/profile/profile.service'; +import { Component, OnInit } from '@angular/core'; +import { eventResolver } from '../event.resolver'; +import { Profile, ProfileService } from 'src/app/profile/profile.service'; import { ActivatedRoute, ActivatedRouteSnapshot, @@ -31,46 +30,51 @@ let titleResolver: ResolveFn = (route: ActivatedRouteSnapshot) => { templateUrl: './event-details.component.html', styleUrls: ['./event-details.component.css'] }) -export class EventDetailsComponent { +export class EventDetailsComponent implements OnInit { /** Route information to be used in Event Routing Module */ public static Route = { path: ':id', title: 'Event Details', component: EventDetailsComponent, resolve: { - profile: profileResolver, - event: eventDetailResolver + event: eventResolver }, children: [ { path: '', title: titleResolver, component: EventDetailsComponent } ] }; - /** Store Event */ - public event!: Event; - /** Store the currently-logged-in user's profile. */ public profile: Profile; - public adminPermission$: Observable; + /** The event to show */ + public event: Event | undefined; + + /** + * Determines whether or not a user can view the event. + * @returns {Observable} + */ + canViewEvent(): Observable { + return this.permissionService.check( + 'organization.events.view', + `organization/${this.event?.organization!?.id ?? '*'}` + ); + } + + /** Constructs the Event Detail component. */ constructor( private route: ActivatedRoute, - private permission: PermissionService, + private permissionService: PermissionService, + private profileService: ProfileService, private gearService: NagivationAdminGearService ) { - /** Initialize data from resolvers. */ + this.profile = this.profileService.profile()!; + const data = this.route.snapshot.data as { - profile: Profile; event: Event; }; - this.profile = data.profile; - this.event = data.event; - // Admin Permission if has the actual permission or is event organizer - this.adminPermission$ = this.permission.check( - 'organization.events.view', - `organization/${this.event.organization!.id}` - ); + this.event = data.event; } ngOnInit() { @@ -78,7 +82,7 @@ export class EventDetailsComponent { 'events.*', '*', '', - `events/organizations/${this.event.organization?.slug}/events/${this.event.id}/edit` + `events/${this.event?.organization_id}/${this.event?.id}/edit` ); } } diff --git a/frontend/src/app/event/event-editor/event-editor.component.html b/frontend/src/app/event/event-editor/event-editor.component.html index 583cb128f..798198530 100644 --- a/frontend/src/app/event/event-editor/event-editor.component.html +++ b/frontend/src/app/event/event-editor/event-editor.component.html @@ -1,13 +1,11 @@ -
+ - Create Event - Update Event + + {{ this.isNew() ? 'Create' : 'Update' }} Event + @@ -66,10 +64,7 @@ - + @@ -80,8 +75,3 @@
- - - - You do not have permission to view this page - diff --git a/frontend/src/app/event/event-editor/event-editor.component.ts b/frontend/src/app/event/event-editor/event-editor.component.ts index 3bdab28c9..b1d7128c9 100644 --- a/frontend/src/app/event/event-editor/event-editor.component.ts +++ b/frontend/src/app/event/event-editor/event-editor.component.ts @@ -3,7 +3,7 @@ * about events which are publically displayed on the Events page. * * @author Ajay Gandecha, Jade Keegan, Brianna Ta, Audrey Toney - * @copyright 2023 + * @copyright 2024 * @license MIT */ @@ -12,16 +12,16 @@ import { ActivatedRoute, Route, Router } from '@angular/router'; import { FormBuilder, FormControl, Validators } from '@angular/forms'; import { MatSnackBar } from '@angular/material/snack-bar'; import { EventService } from '../event.service'; -import { profileResolver } from '../../profile/profile.resolver'; -import { Profile, PublicProfile } from '../../profile/profile.service'; -import { Observable, map } from 'rxjs'; -import { eventDetailResolver } from '../event.resolver'; -import { PermissionService } from 'src/app/permission.service'; -import { organizationResolver } from 'src/app/organization/organization.resolver'; -import { Organization } from 'src/app/organization/organization.model'; -import { Event, RegistrationType } from '../event.model'; +import { + Profile, + ProfileService, + PublicProfile +} from '../../profile/profile.service'; +import { eventResolver } from '../event.resolver'; +import { Event } from '../event.model'; import { DatePipe } from '@angular/common'; import { OrganizationService } from 'src/app/organization/organization.service'; +import { eventEditorGuard } from './event-editor.guard'; @Component({ selector: 'app-event-editor', @@ -29,52 +29,43 @@ import { OrganizationService } from 'src/app/organization/organization.service'; styleUrls: ['./event-editor.component.css'] }) export class EventEditorComponent { + /** Route information to be used in Event Routing Module */ public static Route: Route = { - path: 'organizations/:slug/events/:id/edit', + path: ':orgid/:id/edit', component: EventEditorComponent, title: 'Event Editor', + canActivate: [eventEditorGuard], resolve: { - profile: profileResolver, - organization: organizationResolver, - event: eventDetailResolver + event: eventResolver } }; - /** Store the event to be edited or created */ - public event: Event; - public organization_slug: string; - public organization: Organization; - - public profile: Profile | null = null; + /** Store the currently-logged-in user's profile. */ + public profile: Profile; - /** Stores whether the user has admin permission over the current organization. */ - public enabled$: Observable; + /** Stores the event. */ + public event: Event; /** Store organizers */ - public organizers: PublicProfile[] = []; - - /** Add validators to the form */ - name = new FormControl('', [Validators.required]); - time = new FormControl('', [Validators.required]); - location = new FormControl('', [Validators.required]); - description = new FormControl('', [ - Validators.required, - Validators.maxLength(2000) - ]); - public = new FormControl('', [Validators.required]); - registration_limit = new FormControl(0, [ - Validators.required, - Validators.min(0) - ]); - - /** Create a form group */ + public organizers: PublicProfile[]; + + /** Event Editor Form */ public eventForm = this.formBuilder.group({ - name: this.name, - time: this.datePipe.transform(new Date(), 'yyyy-MM-ddTHH:mm'), - location: this.location, - description: this.description, - public: this.public.value! == 'true', - registration_limit: this.registration_limit, + name: new FormControl('', [Validators.required]), + time: new FormControl( + this.datePipe.transform(new Date(), 'yyyy-MM-ddTHH:mm'), + [Validators.required] + ), + location: new FormControl('', [Validators.required]), + description: new FormControl('', [ + Validators.required, + Validators.maxLength(2000) + ]), + public: new FormControl(false, [Validators.required]), + registration_limit: new FormControl(0, [ + Validators.required, + Validators.min(0) + ]), userLookup: '' }); @@ -85,89 +76,54 @@ export class EventEditorComponent { protected organizationService: OrganizationService, protected snackBar: MatSnackBar, private eventService: EventService, - private permission: PermissionService, + private profileService: ProfileService, private datePipe: DatePipe ) { - // Get currently-logged-in user - const data = route.snapshot.data as { - profile: Profile; - organization: Organization; + this.profile = this.profileService.profile()!; + + const data = this.route.snapshot.data as { event: Event; }; - this.profile = data.profile; - - // Initialize event - this.organization = data.organization; this.event = data.event; - this.event.organization_id = this.organization.id; - - // Get ids from the url - let organization_slug = this.route.snapshot.params['slug']; - this.organization_slug = organization_slug; // Set values for form group - this.eventForm.setValue({ - name: this.event.name, - time: this.datePipe.transform(this.event.time, 'yyyy-MM-ddTHH:mm'), - location: this.event.location, - description: this.event.description, - public: this.event.public, - registration_limit: this.event.registration_limit, - userLookup: '' - }); + this.eventForm.patchValue( + Object.assign({}, this.event, { + time: this.datePipe.transform(this.event.time, 'yyyy-MM-ddTHH:mm'), + userLookup: '' + }) + ); // Add validator for registration_limit - this.registration_limit.addValidators( + this.eventForm.controls['registration_limit'].addValidators( Validators.min(this.event.registration_count) ); - this.enabled$ = this.permission - .check( - 'organization.events.update', - `organization/${this.organization!.id}` - ) - .pipe(map((permission) => permission || this.event.is_organizer)); - // Set the organizers // If no organizers already, set current user as organizer - if (this.event.id == null) { - let organizer: PublicProfile = { - id: this.profile.id!, - first_name: this.profile.first_name!, - last_name: this.profile.last_name!, - pronouns: this.profile.pronouns!, - email: this.profile.email!, - github_avatar: this.profile.github_avatar - }; - this.organizers.push(organizer); - } else { - // Set organizers to current organizers - this.organizers = this.event.organizers; - } + this.organizers = this.isNew() + ? [this.profile as PublicProfile] + : this.event.organizers; } - /** Event handler to handle submitting the Create Event Form. + /** Event handler to handle submitting the event form. * @returns {void} */ onSubmit() { if (this.eventForm.valid) { Object.assign(this.event, this.eventForm.value); - - // Set fields not explicitly in form this.event.organizers = this.organizers; - if (this.event.id == null) { - this.eventService.createEvent(this.event).subscribe({ - next: (event) => this.onSuccess(event), - error: (err) => this.onError(err) - }); - } else { - this.eventService.updateEvent(this.event).subscribe({ - next: (event) => this.onSuccess(event), - error: (err) => this.onError(err) - }); - } - this.router.navigate(['/organizations/', this.organization_slug]); + let submittedEvent = this.isNew() + ? this.eventService.createEvent(this.event) + : this.eventService.updateEvent(this.event); + + submittedEvent.subscribe({ + next: (event) => this.onSuccess(event), + error: (err) => this.onError(err) + }); + + this.router.navigate(['/organizations/', this.event.organization?.slug]); } } @@ -183,17 +139,29 @@ export class EventEditorComponent { */ private onSuccess(event: Event): void { this.router.navigate(['/events/', event.id]); - if (this.event.id == null) { - this.snackBar.open('Event Created', '', { duration: 2000 }); - } else { - this.snackBar.open('Event Edited', '', { duration: 2000 }); - } + this.snackBar.open(`Event ${this.action()}`, '', { duration: 2000 }); } /** Opens a confirmation snackbar when there is an error creating an event. * @returns {void} */ private onError(err: any): void { - this.snackBar.open('Error: Event Not Created', '', { duration: 2000 }); + this.snackBar.open(`Error: Event Not ${this.action()}`, '', { + duration: 2000 + }); + } + + /** Shorthand for whether an event is new or not. + * @returns {boolean} + */ + isNew(): boolean { + return this.event.id == null; + } + + /** Shorthand for determining the action being performed on the event. + * @returns {string} + */ + action(): string { + return this.isNew() ? 'Created' : 'Updated'; } } diff --git a/frontend/src/app/event/event-editor/event-editor.guard.ts b/frontend/src/app/event/event-editor/event-editor.guard.ts new file mode 100644 index 000000000..08ed56a6c --- /dev/null +++ b/frontend/src/app/event/event-editor/event-editor.guard.ts @@ -0,0 +1,51 @@ +/** + * The Event Editor Guard ensures that the page can open if the user has + * the correct permissions. + * + * @author Ajay Gandecha + * @copyright 2024 + * @license MIT + */ + +import { inject } from '@angular/core'; +import { CanActivateFn } from '@angular/router'; +import { PermissionService } from 'src/app/permission.service'; +import { Event } from '../event.model'; +import { combineLatest, map } from 'rxjs'; +import { EventService } from '../event.service'; + +// TODO: Refactor with a new event permission API so that we do not +// duplicate calls to the event API here. + +/** Determines whether the user can access the event editor. + * @param route Active route when the user enters the component. + * @returns {CanActivateFn} + */ +export const eventEditorGuard: CanActivateFn = (route, _) => { + /** Determine if page is viewable by user based on permissions */ + + // Load IDs from the route + let organizationId: string = route.params['orgid']; + let eventId: string = route.params['id']; + + // Create two observables for each check + + // Checks if the user has permissions to update events for + // the organization hosting this event + const permissionCheck$ = inject(PermissionService).check( + 'organization.events.update', + `organization/${organizationId}` + ); + + // Checks if the user is the organizer for the event + const isOrganizerCheck$ = inject(EventService) + .getEvent(+eventId) + .pipe(map((event) => event?.is_organizer ?? false)); + + // Since only one check has to be true for the user to see the page, + // we combine the results of these observables into a single + // observable that returns true if either were true. + return combineLatest([permissionCheck$, isOrganizerCheck$]).pipe( + map(([hasPermission, isOrganizer]) => hasPermission || isOrganizer) + ); +}; diff --git a/frontend/src/app/event/event-list-admin/event-list-admin.component.css b/frontend/src/app/event/event-list-admin/event-list-admin.component.css deleted file mode 100644 index 2284d548a..000000000 --- a/frontend/src/app/event/event-list-admin/event-list-admin.component.css +++ /dev/null @@ -1,35 +0,0 @@ -/** -* admin-organization-list.component.css -* -* The admin organization list page should provide -* a simple, easily readable form for users to view -* all organizations. -* -*/ - -.mat-mdc-row .mat-mdc-cell { - border-bottom: 1px solid transparent; - border-top: 1px solid transparent; - cursor: pointer; -} - -.mat-mdc-row:hover .mat-mdc-cell { - border-color: white; -} - -.header { - display: flex; - align-items: center; - justify-content: space-between; -} - -.row { - display: flex; - align-items: center; - justify-content: space-between; -} - -.button-container button { - justify-content: space-between; - margin: 5px; -} \ No newline at end of file diff --git a/frontend/src/app/event/event-list-admin/event-list-admin.component.html b/frontend/src/app/event/event-list-admin/event-list-admin.component.html deleted file mode 100644 index cc0229657..000000000 --- a/frontend/src/app/event/event-list-admin/event-list-admin.component.html +++ /dev/null @@ -1,25 +0,0 @@ - -
- - - - - - - -
-
Events
-
-
-

{{ element.name }}

-
- -
-
-
-
diff --git a/frontend/src/app/event/event-list-admin/event-list-admin.component.ts b/frontend/src/app/event/event-list-admin/event-list-admin.component.ts deleted file mode 100644 index a61a2a99a..000000000 --- a/frontend/src/app/event/event-list-admin/event-list-admin.component.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { Component, OnInit } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; -import { MatSnackBar } from '@angular/material/snack-bar'; -import { Observable, map, of } from 'rxjs'; -import { - Permission, - Profile -} from '/workspace/frontend/src/app/profile/profile.service'; -import { Organization } from 'src/app/organization/organization.model'; -import { Event } from 'src/app/event/event.model'; -import { profileResolver } from 'src/app/profile/profile.resolver'; -import { EventService } from 'src/app/event/event.service'; -import { eventResolver } from '../event.resolver'; -import { OrganizationService } from 'src/app/organization/organization.service'; - -@Component({ - selector: 'app-event-list-admin', - templateUrl: './event-list-admin.component.html', - styleUrls: ['./event-list-admin.component.css'] -}) -export class EventListAdminComponent implements OnInit { - /** Events List */ - protected displayedEvents$: Observable; - - public displayedColumns: string[] = ['name']; - - /** Profile of signed in user */ - protected profile: Profile; - - /** Route information to be used in Organization Routing Module */ - public static Route = { - path: 'admin', - component: EventListAdminComponent, - title: 'Event Administration', - resolve: { - profile: profileResolver, - events: eventResolver - } - }; - - constructor( - private route: ActivatedRoute, - private router: Router, - private snackBar: MatSnackBar, - private organizationAdminService: OrganizationService, - private eventService: EventService - ) { - this.displayedEvents$ = eventService.getEvents(); - - /** Get the profile data of the signed in user */ - const data = this.route.snapshot.data as { - profile: Profile; - }; - this.profile = data.profile; - } - - ngOnInit() { - if (this.profile.permissions[0].resource !== '*') { - let userOrganizationPermissions: string[] = this.profile.permissions - .filter((permission) => permission.resource.includes('organization')) - .map((permission) => permission.resource.substring(13)); - - this.displayedEvents$ = this.displayedEvents$.pipe( - map((events) => - events.filter( - (event) => - event.organization && - userOrganizationPermissions.includes(event.organization.slug) - ) - ) - ); - } - } - - /** Resposible for generating delete and create buttons in HTML code when admin signed in */ - adminPermissions(): boolean { - return this.profile.permissions[0].resource === '*'; - } - - /** Event handler to open Event Editor for the selected event. - * @param event: event to be edited - * @returns void - */ - editEvent(event: Event): void { - this.router.navigate([ - 'events', - 'organizations', - event.organization?.slug, - 'events', - event.id, - 'edit' - ]); - } -} diff --git a/frontend/src/app/event/event-page/event-page.component.html b/frontend/src/app/event/event-page/event-page.component.html index 56a65af2d..d79c6871c 100644 --- a/frontend/src/app/event/event-page/event-page.component.html +++ b/frontend/src/app/event/event-page/event-page.component.html @@ -3,46 +3,30 @@ + (searchBarQueryChange)="onSearchBarQueryChange($event)" />
- - -

- Today {{ endDate | date: 'mediumDate' }} +

+ {{ startDate() | date: 'mediumDate' }} + {{ endDate() | date: 'mediumDate' }}

- -

- {{ startDate | date: 'mediumDate' }} - - {{ endDate | date: 'mediumDate' }} -

-
-
- -
- -
diff --git a/frontend/src/app/event/event-page/event-page.component.ts b/frontend/src/app/event/event-page/event-page.component.ts index f52501267..e6a374f8c 100644 --- a/frontend/src/app/event/event-page/event-page.component.ts +++ b/frontend/src/app/event/event-page/event-page.component.ts @@ -3,268 +3,159 @@ * events hosted by CS Organizations at UNC. * * @author Ajay Gandecha, Jade Keegan, Brianna Ta, Audrey Toney - * @copyright 2023 + * @copyright 2024 * @license MIT */ import { Component, - HostListener, - OnInit, - inject, - OnDestroy + Signal, + signal, + effect, + WritableSignal, + computed } from '@angular/core'; -import { profileResolver } from 'src/app/profile/profile.resolver'; -import { ActivatedRoute, ActivationEnd, Params, Router } from '@angular/router'; -import { Profile } from 'src/app/profile/profile.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Profile, ProfileService } from 'src/app/profile/profile.service'; import { Event } from '../event.model'; import { DatePipe } from '@angular/common'; -import { EventService } from '../event.service'; -import { NagivationAdminGearService } from 'src/app/navigation/navigation-admin-gear.service'; -import { EventPaginationParams, Paginated } from 'src/app/pagination'; import { - Subject, - Subscription, - debounceTime, - distinctUntilChanged, - filter, - tap -} from 'rxjs'; + DEFAULT_TIME_RANGE_PARAMS, + Paginated, + TimeRangePaginationParams +} from 'src/app/pagination'; +import { EventService } from '../event.service'; +import { GroupEventsPipe } from '../pipes/group-events.pipe'; @Component({ selector: 'app-event-page', templateUrl: './event-page.component.html', styleUrls: ['./event-page.component.css'] }) -export class EventPageComponent implements OnInit, OnDestroy { - public page: Paginated; - public startDate = new Date(); - public endDate = new Date(new Date().setMonth(new Date().getMonth() + 1)); - public today: boolean = true; - - private static EventPaginationParams = { - order_by: 'time', - ascending: 'true', - filter: '', - range_start: new Date().toLocaleString('en-GB'), - range_end: new Date( - new Date().setMonth(new Date().getMonth() + 1) - ).toLocaleString('en-GB') - }; - +export class EventPageComponent { /** Route information to be used in App Routing Module */ public static Route = { path: '', title: 'Events', component: EventPageComponent, - canActivate: [], - resolve: { - profile: profileResolver, - page: () => - inject(EventService).list(EventPageComponent.EventPaginationParams) - } + canActivate: [] }; - /** Store the content of the search bar */ - public searchBarQuery = ''; + /** Stores a reactive event pagination page. */ + public page: WritableSignal< + Paginated | undefined + > = signal(undefined); + private previousParams: TimeRangePaginationParams = DEFAULT_TIME_RANGE_PARAMS; + + /** Stores a reactive mapping of days to events on the active page. */ + protected eventsByDate: Signal<[string, Event[]][]> = computed(() => { + return this.groupEventsPipe.transform(this.page()?.items ?? []); + }); - /** Store a map of days to a list of events for that day */ - public eventsPerDay: [string, Event[]][]; + /** Stores reactive date signals for the bounds of pagination. */ + public startDate: WritableSignal = signal(new Date()); + public endDate: WritableSignal = signal( + new Date(new Date().setMonth(new Date().getMonth() + 1)) + ); + public filterQuery: WritableSignal = signal(''); - /** Store the selected Event */ - public selectedEvent: Event | null = null; + /** Store the content of the search bar */ + public searchBarQuery = ''; /** Store the currently-logged-in user's profile. */ public profile: Profile; - /** Stores the width of the window. */ - public innerWidth: any; - - /** Search bar query string */ - public query: string = ''; - - public searchUpdate = new Subject(); - - private routeSubscription!: Subscription; - /** Constructor for the events page. */ constructor( private route: ActivatedRoute, private router: Router, public datePipe: DatePipe, public eventService: EventService, - private gearService: NagivationAdminGearService + private profileService: ProfileService, + protected groupEventsPipe: GroupEventsPipe ) { - // Initialize data from resolvers - const data = this.route.snapshot.data as { - profile: Profile; - page: Paginated; - }; - this.profile = data.profile; - this.page = data.page; - this.today = - this.startDate.setHours(0, 0, 0, 0) == new Date().setHours(0, 0, 0, 0); - - // Group events by their dates - this.eventsPerDay = eventService.groupEventsByDate(this.page.items); - - // Initialize the initially selected event - if (data.page.items.length > 0) { - this.selectedEvent = this.page.items[0]; - } - - this.searchUpdate - .pipe( - filter((search: string) => search.length > 2 || search.length == 0), - debounceTime(500), - distinctUntilChanged() - ) - .subscribe((query) => { - this.onSearchBarQueryChange(query); - }); + this.profile = this.profileService.profile()!; } - /** Runs when the frontend UI loads */ - ngOnInit() { - if (this.profile !== undefined) { - let userPermissions = this.profile.permissions; - /** Ensure that the signed in user has permissions before looking at the resource */ - if (userPermissions.length !== 0) { - /** Admin user, no need to check further */ - if (userPermissions[0].resource === '*') { - this.gearService.showAdminGearByPermissionCheck( - 'organizations.*', - '*', - '', - 'events/admin' - ); - } else { - /** Find if the signed in user has any organization permissions */ - let organizationPermissions = userPermissions.filter((element) => - element.resource.includes('organization') - ); - /** If they do, show admin gear */ - if (organizationPermissions.length !== 0) { - this.gearService.showAdminGearByPermissionCheck( - 'organizations.*', - organizationPermissions[0].resource, - '', - 'events/admin' - ); - } - } - } - } - // Keep track of the initial width of the browser window - this.innerWidth = window.innerWidth; - - // Watch current route's query params - this.route.queryParams.subscribe((params: Params): void => { - this.startDate = params['start_date'] - ? new Date(Date.parse(params['start_date'])) - : new Date(); - this.endDate = params['end_date'] - ? new Date(Date.parse(params['end_date'])) - : new Date(new Date().setMonth(new Date().getMonth() + 1)); - }); - - const today = new Date(); - if (this.startDate.getTime() < today.setHours(0, 0, 0, 0)) { - this.page.params.ascending = 'false'; - } - - let paginationParams = this.page.params; - paginationParams.range_start = this.startDate.toLocaleString('en-GB'); - paginationParams.range_end = this.endDate.toLocaleString('en-GB'); - this.eventService.list(paginationParams).subscribe((page) => { - this.eventsPerDay = this.eventService.groupEventsByDate(page.items); + /** + * Effect that refreshes the event pagination when the time range changes. This effect + * is also called when the page initially loads. + * + * This effect also reloads the query parameters in the URL so that the URL in the + * browser reflects the newly changed start and end date ranges. + */ + paginationTimeRangeEffect = effect(() => { + // Update the parameters with the new date range + let params = this.previousParams; + params.range_start = this.startDate().toISOString(); + params.range_end = this.endDate().toISOString(); + params.filter = this.filterQuery(); + // Refresh the data + this.eventService.getEvents(params).subscribe((events) => { + this.page.set(events); + this.previousParams = events.params; + this.reloadQueryParams(); }); - - let prevUrl = ''; - this.routeSubscription = this.router.events - .pipe( - filter((e) => e instanceof ActivationEnd), - distinctUntilChanged(() => this.router.url === prevUrl), - tap(() => (prevUrl = this.router.url)) - ) - .subscribe((_) => { - this.page.params.ascending = ( - this.startDate.getTime() > today.setHours(0, 0, 0, 0) - ).toString(); - let paginationParams = this.page.params; - paginationParams.range_start = this.startDate.toLocaleString('en-GB'); - paginationParams.range_end = this.endDate.toLocaleString('en-GB'); - this.eventService.list(paginationParams).subscribe((page) => { - this.eventsPerDay = this.eventService.groupEventsByDate(page.items); - }); - }); + }); + + /** Reloads the page and its query parameters to adjust to the next month. */ + nextPage() { + this.startDate.set( + new Date(this.startDate().setMonth(this.startDate().getMonth() + 1)) + ); + this.endDate.set( + new Date(this.endDate().setMonth(this.endDate().getMonth() + 1)) + ); } - ngOnDestroy() { - this.routeSubscription.unsubscribe(); + /** Reloads the page and its query parameters to adjust to the previous month. */ + previousPage() { + this.startDate.set( + new Date(this.startDate().setMonth(this.startDate().getMonth() - 1)) + ); + this.endDate.set( + new Date(this.endDate().setMonth(this.endDate().getMonth() - 1)) + ); } - /** Handler that runs when the window resizes */ - @HostListener('window:resize', ['$event']) - onResize(_: UIEvent) { - // Update the browser window width - this.innerWidth = window.innerWidth; + /** + * Reloads the page to update the query parameters and reload the data. + * This is required so that the correct query parameters are reflected in the + * browser's URL field. + * @param startDate: The new start date + * @param endDate: The new end date + */ + reloadQueryParams() { + this.router.navigate([], { + relativeTo: this.route, + queryParams: { + start_date: this.startDate().toISOString(), + end_date: this.endDate().toISOString() + }, + queryParamsHandling: 'merge' + }); } + // TODO: Refactor this method to remove manual +/- 100 year range on query filtering. + /** Handler that runs when the search bar query changes. * @param query: Search bar query to filter the items */ onSearchBarQueryChange(query: string) { - this.query = query; - let paginationParams = this.page.params; - paginationParams.ascending = 'true'; - if (query == '') { - paginationParams.range_start = this.startDate.toLocaleString('en-GB'); - paginationParams.range_end = this.endDate.toLocaleString('en-GB'); + if (query === '') { + this.startDate.set(new Date()); + this.endDate.set( + new Date(new Date().setMonth(new Date().getMonth() + 1)) + ); } else { - paginationParams.range_start = new Date( - new Date().setFullYear(new Date().getFullYear() - 100) - ).toLocaleString('en-GB'); - paginationParams.range_end = new Date( - new Date().setFullYear(new Date().getFullYear() + 100) - ).toLocaleString('en-GB'); - paginationParams.filter = this.query; - } - this.eventService.list(paginationParams).subscribe((page) => { - this.eventsPerDay = this.eventService.groupEventsByDate(page.items); - paginationParams.filter = ''; - }); - } - - /** Handler that runs when an event card is clicked. - * This function selects the event to display on the sidebar. - * @param event: Event pressed - */ - onEventCardClicked(event: Event) { - this.selectedEvent = event; - } - - showEvents(isPrevious: boolean) { - //let paginationParams = this.page.params; - this.startDate = isPrevious - ? new Date(this.startDate.setMonth(this.startDate.getMonth() - 1)) - : new Date(this.startDate.setMonth(this.startDate.getMonth() + 1)); - this.endDate = isPrevious - ? new Date(this.endDate.setMonth(this.endDate.getMonth() - 1)) - : new Date(this.endDate.setMonth(this.endDate.getMonth() + 1)); - if (isPrevious === true) { - this.page.params.ascending = 'false'; + this.startDate.set( + new Date(new Date().setMonth(new Date().getFullYear() - 100)) + ); + this.endDate.set( + new Date(new Date().setMonth(new Date().getFullYear() + 100)) + ); } - this.today = - this.startDate.setHours(0, 0, 0, 0) == new Date().setHours(0, 0, 0, 0); - this.router.navigate([], { - relativeTo: this.route, - queryParams: { - start_date: this.startDate.toISOString(), - end_date: this.endDate.toISOString() - }, - queryParamsHandling: 'merge' - }); + this.filterQuery.set(query); } } diff --git a/frontend/src/app/event/event-routing.module.ts b/frontend/src/app/event/event-routing.module.ts index f8dd50367..8cc9ea5cd 100644 --- a/frontend/src/app/event/event-routing.module.ts +++ b/frontend/src/app/event/event-routing.module.ts @@ -12,10 +12,8 @@ import { RouterModule, Routes } from '@angular/router'; import { EventDetailsComponent } from './event-details/event-details.component'; import { EventPageComponent } from './event-page/event-page.component'; import { EventEditorComponent } from './event-editor/event-editor.component'; -import { EventListAdminComponent } from './event-list-admin/event-list-admin.component'; const routes: Routes = [ - EventListAdminComponent.Route, EventPageComponent.Route, EventDetailsComponent.Route, EventEditorComponent.Route diff --git a/frontend/src/app/event/event.model.ts b/frontend/src/app/event/event.model.ts index 9ada9851c..0d0d12efe 100644 --- a/frontend/src/app/event/event.model.ts +++ b/frontend/src/app/event/event.model.ts @@ -3,7 +3,7 @@ * the Event Service and the API. * * @author Ajay Gandecha, Jade Keegan, Brianna Ta, Audrey Toney - * @copyright 2023 + * @copyright 2024 * @license MIT */ @@ -54,8 +54,10 @@ export interface EventJson { * objects (such as `Date`s) as strings. We need to convert this to * TypeScript objects ourselves. */ -export const parseEventJson = (eventJson: EventJson): Event => { - return Object.assign({}, eventJson, { time: new Date(eventJson.time) }); +export const parseEventJson = (responseModel: EventJson): Event => { + return Object.assign({}, responseModel, { + time: new Date(responseModel.time) + }); }; export enum RegistrationType { diff --git a/frontend/src/app/event/event.module.ts b/frontend/src/app/event/event.module.ts index 91ec7892f..e667ce47a 100644 --- a/frontend/src/app/event/event.module.ts +++ b/frontend/src/app/event/event.module.ts @@ -4,7 +4,7 @@ * application and decouples this feature from other features in the application. * * @author Ajay Gandecha, Jade Keegan, Brianna Ta, Audrey Toney - * @copyright 2023 + * @copyright 2024 * @license MIT */ @@ -38,7 +38,7 @@ import { EventDetailsComponent } from './event-details/event-details.component'; import { EventPageComponent } from './event-page/event-page.component'; import { EventEditorComponent } from './event-editor/event-editor.component'; import { EventUsersList } from './widgets/event-users-list/event-users-list.widget'; -import { EventListAdminComponent } from './event-list-admin/event-list-admin.component'; +import { GroupEventsPipe } from './pipes/group-events.pipe'; @NgModule({ declarations: [ @@ -46,8 +46,8 @@ import { EventListAdminComponent } from './event-list-admin/event-list-admin.com EventDetailsComponent, EventPageComponent, EventEditorComponent, - EventListAdminComponent, - EventUsersList + EventUsersList, + GroupEventsPipe ], imports: [ CommonModule, @@ -70,6 +70,7 @@ import { EventListAdminComponent } from './event-list-admin/event-list-admin.com RouterModule, SharedModule, EventRoutingModule - ] + ], + providers: [GroupEventsPipe] }) export class EventModule {} diff --git a/frontend/src/app/event/event.resolver.ts b/frontend/src/app/event/event.resolver.ts index 8f5fc211f..9db6d8235 100644 --- a/frontend/src/app/event/event.resolver.ts +++ b/frontend/src/app/event/event.resolver.ts @@ -3,7 +3,7 @@ * of components. * * @author Ajay Gandecha, Jade Keegan, Brianna Ta, Audrey Toney - * @copyright 2023 + * @copyright 2024 * @license MIT */ @@ -12,17 +12,8 @@ import { ResolveFn } from '@angular/router'; import { Event } from './event.model'; import { EventService } from './event.service'; -/** This resolver injects the list of events into the events component. */ -export const eventResolver: ResolveFn = (route, state) => { - return inject(EventService).getEvents(); -}; - /** This resolver injects an event into the events detail component. */ -export const eventDetailResolver: ResolveFn = ( - route, - state -) => { - console.log(route.paramMap); +export const eventResolver: ResolveFn = (route, state) => { if (route.paramMap.get('id') != 'new') { return inject(EventService).getEvent(+route.paramMap.get('id')!); } else { diff --git a/frontend/src/app/event/event.service.ts b/frontend/src/app/event/event.service.ts index 994d02eaf..4f539c6da 100644 --- a/frontend/src/app/event/event.service.ts +++ b/frontend/src/app/event/event.service.ts @@ -3,252 +3,132 @@ * from the components. * * @author Ajay Gandecha, Jade Keegan, Brianna Ta, Audrey Toney - * @copyright 2023 + * @copyright 2024 * @license MIT */ import { Injectable } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; -import { Observable, Subscription, map, tap } from 'rxjs'; +import { + DEFAULT_TIME_RANGE_PARAMS, + Paginated, + PaginationParams, + Paginator, + TimeRangePaginationParams, + TimeRangePaginator +} from '../pagination'; import { Event, EventJson, EventRegistration, parseEventJson } from './event.model'; -import { DatePipe } from '@angular/common'; -import { Profile, ProfileService } from '../profile/profile.service'; -import { - Paginated, - PaginationParams, - TimeRangePaginationParams -} from '../pagination'; -import { RxEvent } from './rx-event'; +import { Observable, map } from 'rxjs'; +import { HttpClient } from '@angular/common/http'; +import { Profile } from '../models.module'; @Injectable({ providedIn: 'root' }) export class EventService { - private profile: Profile | undefined; - private profileSubscription!: Subscription; + /** Encapsulated paginators */ + private eventsPaginator: TimeRangePaginator = + new TimeRangePaginator('/api/events/paginate'); - private events: RxEvent = new RxEvent(); - public events$: Observable = this.events.value$; + /** Constructor */ + constructor(protected http: HttpClient) {} - constructor( - protected http: HttpClient, - protected profileSvc: ProfileService, - public datePipe: DatePipe - ) { - this.profileSubscription = this.profileSvc.profile$.subscribe( - (profile) => (this.profile = profile) - ); - } + // Methods for event data. - /** Returns paginated user entries from the backend database table using the backend HTTP get request. - * @returns {Observable>} + /** + * Retrieves a page of events based on pagination parameters. + * @param params: Pagination parameters. + * @returns {Observable>} */ - getRegisteredUsersForEvent(event_id: number, params: PaginationParams) { - let paramStrings = { - page: params.page.toString(), - page_size: params.page_size.toString(), - order_by: params.order_by, - filter: params.filter - }; - let query = new URLSearchParams(paramStrings); - return this.http.get>( - `/api/events/${event_id}/registrations/users?` + query.toString() - ); + getEvents(params: TimeRangePaginationParams = DEFAULT_TIME_RANGE_PARAMS) { + return this.eventsPaginator.loadPage(params, parseEventJson); } - /** Returns all event entries from the backend database table using the backend HTTP get request. - * @returns {Observable} + /** + * Gets an event based on its id. + * @param id: ID for the event. + * @returns {Observable} */ - getEvents(): Observable { - if (this.profile) { - return this.http - .get('/api/events/range') - .pipe(map((eventJsons) => eventJsons.map(parseEventJson))); - } else { - // if a user isn't logged in, return the normal endpoint without registration statuses - return this.http - .get('/api/events/range/unauthenticated') - .pipe(map((eventJsons) => eventJsons.map(parseEventJson))); - } + getEvent(id: number): Observable { + return this.http + .get('/api/events/' + id) + .pipe(map((eventJson) => parseEventJson(eventJson))); } - /** Returns the event object from the backend database table using the backend HTTP get request. - * @param id: ID of the event to retrieve - * @returns {Observable} - */ - getEvent(id: number): Observable { - if (this.profile) { - return this.http - .get('/api/events/' + id) - .pipe(map((eventJson) => parseEventJson(eventJson))); - } else { - return this.http - .get('/api/events/' + id + '/unauthenticated') - .pipe(map((eventJson) => parseEventJson(eventJson))); - } - } - - /** Returns the event object from the backend database table using the backend HTTP get request. - * @param slug: Slug of the organization to retrieve - * @returns {Observable} - */ - getEventsByOrganization(slug: string): Observable { - if (this.profile) { - return this.http - .get('/api/events/organization/' + slug) - .pipe(map((eventJsons) => eventJsons.map(parseEventJson))); - } else { - return this.http - .get( - '/api/events/organization/' + slug + '/unauthenticated' - ) - .pipe(map((eventJsons) => eventJsons.map(parseEventJson))); - } - } - - /** Returns the new event object from the backend database table using the backend HTTP get request. - * @param event: model of the event to be created + /** + * Returns the new event from the backend database table using the HTTP post request + * and refreshes the current paginated events page. + * @param event Event to add * @returns {Observable} */ createEvent(event: Event): Observable { return this.http.post('/api/events', event); } - /** Returns the updated event object from the backend database table using the backend HTTP put request. - * @param event: Event representing the updated event + /** + * Returns the updated event from the backend database table using the HTTP put request + * and refreshes the current paginated events page. + * @param event Event to update * @returns {Observable} */ updateEvent(event: Event): Observable { return this.http.put('/api/events', event); } - /** Delete the given event object using the backend HTTP delete request. W - * @param event: Event representing the updated event - * @returns void + /** + * Returns the deleted event from the backend database table using the HTTP delete request + * and refreshes the current paginated events page. + * @param event Event to delete + * @returns {Observable} */ deleteEvent(event: Event): Observable { return this.http.delete('/api/events/' + event.id); } - /** Helper function to group a list of events by date, - * filtered based on the input query string. - * @param events: List of the input events - * @param query: Search bar query to filter the items - */ - groupEventsByDate(events: Event[], query: string = ''): [string, Event[]][] { - // Initialize an empty map - let groups: Map = new Map(); + // Methods for event registration data. - // Transform the list of events based on the event filter pipe and query - events.forEach((event) => { - // Find the date to group by - let dateString = - this.datePipe.transform(event.time, 'EEEE, MMMM d, y') ?? ''; - // Add the event - let newEventsList = groups.get(dateString) ?? []; - newEventsList.push(event); - groups.set(dateString, newEventsList); - }); + // TODO: Refactor to remove, load event registrations instead. - // Return the groups - return [...groups.entries()]; - } - - // Event Registration Methods - /** Return an event registration if the user is registered for an event using the backend HTTP get request. - * @param event_id: number representing the Event ID - * @returns Observable + /** + * Loads a paginated list of registered users for a given event. + * @param event: Event to load registrations for. + * @param params: Pagination parameters. + * @returns {Observable>} */ - getEventRegistrationOfUser(event_id: number): Observable { - return this.http.get( - `/api/events/${event_id}/registration` + getRegisteredUsersForEvent( + event: Event, + params: PaginationParams + ): Observable> { + const paginator: Paginator = new Paginator( + `/api/events/${event.id}/registrations/users` ); + return paginator.loadPage(params); } - /** Return all event registrations an event using the backend HTTP get request. - * @param event_id: number representing the Event ID - * @returns Observable + /** + * Registers the current user to an event. + * @param event: Event to register to. + * @returns {Observable} */ - getEventRegistrations(event_id: number): Observable { - return this.http.get( - `/api/events/${event_id}/registrations` - ); - } - - /** Return number of event registrations for an event - * @param event_id: number representing the Event ID - * @returns Observable - */ - getEventRegistrationCount(event_id: number): Observable { - return this.http.get(`/api/events/${event_id}/registration/count`); - } - - /** Create a new registration for an event using the backend HTTP create request. - * @param event_id: number representing the Event ID - * @returns Observable - */ - registerForEvent(event_id: number): Observable { - if (this.profile === undefined) { - throw new Error('Only allowed for logged in users.'); - } - + registerForEvent(event: Event): Observable { return this.http.post( - `/api/events/${event_id}/registration`, + `/api/events/${event.id}/registration`, {} ); } - /** Delete an existing registration for an event using the backend HTTP delete request. - * @param event_registration_id: number representing the Event Registration ID - * @returns void + /** + * Unregisters the current user from an event. + * @param event: Event to unregister from. + * @returns {Observable} */ - unregisterForEvent(event_id: number) { - if (this.profile === undefined) { - throw new Error('Only allowed for logged in users.'); - } - + unregisterForEvent(event: Event): Observable { return this.http.delete( - `/api/events/${event_id}/registration` + `/api/events/${event.id}/registration` ); } - - list(params: TimeRangePaginationParams) { - let paramStrings = { - order_by: params.order_by, - ascending: params.ascending, - filter: params.filter, - range_start: params.range_start, - range_end: params.range_end - }; - let query = new URLSearchParams(paramStrings); - if (this.profile) { - return this.http - .get>( - '/api/events/paginate?' + query.toString() - ) - .pipe( - map((paginated) => ({ - ...paginated, - items: paginated.items.map(parseEventJson) - })) - ); - } else { - // if a user isn't logged in, return the normal endpoint without registration statuses - return this.http - .get>( - '/api/events/paginate/unauthenticated?' + query.toString() - ) - .pipe( - map((paginated) => ({ - ...paginated, - items: paginated.items.map(parseEventJson) - })) - ); - } - } } diff --git a/frontend/src/app/event/pipes/group-events.pipe.ts b/frontend/src/app/event/pipes/group-events.pipe.ts new file mode 100644 index 000000000..4ef3330df --- /dev/null +++ b/frontend/src/app/event/pipes/group-events.pipe.ts @@ -0,0 +1,36 @@ +/** + * This is the pipe used to group events in a page by day. + * @author Ajay Gandecha + * @copyright 2024 + * @license MIT + */ + +import { DatePipe } from '@angular/common'; +import { Pipe, PipeTransform, inject } from '@angular/core'; +import { Event } from '../event.model'; + +@Pipe({ + name: 'groupEvents' +}) +export class GroupEventsPipe implements PipeTransform { + datePipe = inject(DatePipe); + + transform(events: Event[]): [string, Event[]][] { + // Initialize an empty map + let groups: Map = new Map(); + + // Transform the list of events based on the event filter pipe and query + events.forEach((event) => { + // Find the date to group by + let dateString = + this.datePipe.transform(event.time, 'EEEE, MMMM d, y') ?? ''; + // Add the event + let newEventsList = groups.get(dateString) ?? []; + newEventsList.push(event); + groups.set(dateString, newEventsList); + }); + + // Return the groups + return [...groups.entries()]; + } +} diff --git a/frontend/src/app/event/rx-event.ts b/frontend/src/app/event/rx-event.ts deleted file mode 100644 index 45f07eff4..000000000 --- a/frontend/src/app/event/rx-event.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * The RxEvent object is used to ensure proper updating and - * retrieval of the list of all events in the database. - * - * @author Ben Goulet - * @copyright 2024 - * @license MIT - */ - -import { RxObject } from '../rx-object'; -import { Event } from './event.model'; - -export class RxEvent extends RxObject { - pushEvent(event: Event): void { - this.value.push(event); - this.notify(); - } - - updateEvent(event: Event): void { - this.value = this.value.map((o) => { - return o.id !== event.id ? o : event; - }); - this.notify(); - } -} diff --git a/frontend/src/app/event/widgets/event-detail-card/event-detail-card.widget.html b/frontend/src/app/event/widgets/event-detail-card/event-detail-card.widget.html index 0ad6ee763..37ec7de3a 100644 --- a/frontend/src/app/event/widgets/event-detail-card/event-detail-card.widget.html +++ b/frontend/src/app/event/widgets/event-detail-card/event-detail-card.widget.html @@ -11,19 +11,14 @@
- + } - -
@@ -55,50 +50,43 @@ -
- - If you have any questions or concerns about this event, please contact one - of the organizers below: - - + @if (profile && event.organizers.length > 0) { +
+ If you have any questions or concerns about this event, please contact the - organizer: - + organizer{{ event.organizers.length > 1 && 's' }} below: +
-
+ } -
+ @if (profile && event.registration_limit > 0) { +

Seats Remaining: - {{ event.registration_limit - event.registration_count }} / {{ - event.registration_limit }} + {{ event.registration_limit - event.registration_count }} / + {{ event.registration_limit }}

+ @if (event.is_attendee || event.is_organizer) { - - - + } @else { + + }
+ } diff --git a/frontend/src/app/event/widgets/event-detail-card/event-detail-card.widget.ts b/frontend/src/app/event/widgets/event-detail-card/event-detail-card.widget.ts index e8ca2e576..71b39bc0c 100644 --- a/frontend/src/app/event/widgets/event-detail-card/event-detail-card.widget.ts +++ b/frontend/src/app/event/widgets/event-detail-card/event-detail-card.widget.ts @@ -3,12 +3,12 @@ * detail event card from the whole event page. * * @author Ajay Gandecha, Jade Keegan - * @copyright 2023 + * @copyright 2024 * @license MIT */ import { Component, Input, OnInit } from '@angular/core'; -import { Event, EventRegistration } from '../../event.model'; +import { Event } from '../../event.model'; import { MatSnackBar } from '@angular/material/snack-bar'; import { EventService } from '../../event.service'; import { Observable } from 'rxjs'; @@ -75,48 +75,42 @@ export class EventDetailCard implements OnInit { }); } - /** Registers a user for the given event - * @param event_id: number representing the id of the Event to register the User for - */ - registerForEvent(event_id: number) { + /** Registers a user for the event. */ + registerForEvent() { let confirmRegistration = this.snackBar.open( 'Are you sure you want to register for this event?', 'Register' ); confirmRegistration.onAction().subscribe(() => { - this.eventService.registerForEvent(event_id).subscribe({ - next: (event_registration) => this.onSuccess(event_registration), - error: (err) => this.onError(err) + this.eventService.registerForEvent(this.event).subscribe({ + next: () => this.onSuccess(), + error: () => this.onError() }); }); } - /** Registers a user for the given event - * @param event_id: number representing the id of the Event to register the User for - */ - unregisterForEvent(event_registration_id: number) { + /** Unregisters the user for the event. */ + unregisterForEvent() { let confirmUnregistration = this.snackBar.open( 'Are you sure you want to unregister for this event?', 'Unregister', { duration: 15000 } ); confirmUnregistration.onAction().subscribe(() => { - this.eventService - .unregisterForEvent(event_registration_id) - .subscribe(() => { - this.event.is_attendee = false; - this.event.registration_count -= 1; - this.snackBar.open('Successfully Unregistered!', '', { - duration: 2000 - }); + this.eventService.unregisterForEvent(this.event).subscribe(() => { + this.event.is_attendee = false; + this.event.registration_count -= 1; + this.snackBar.open('Successfully Unregistered!', '', { + duration: 2000 }); + }); }); } /** Opens a confirmation snackbar when an event is successfully created. * @returns {void} */ - private onSuccess(event_registration: EventRegistration): void { + private onSuccess(): void { this.event.is_attendee = true; this.event.registration_count += 1; this.snackBar.open('Thanks for registering!', '', { duration: 2000 }); @@ -125,7 +119,7 @@ export class EventDetailCard implements OnInit { /** Opens a confirmation snackbar when there is an error creating an event. * @returns {void} */ - private onError(err: any): void { + private onError(): void { this.snackBar.open('Error: Event Not Registered For', '', { duration: 2000 }); diff --git a/frontend/src/app/event/widgets/event-users-list/event-users-list.widget.html b/frontend/src/app/event/widgets/event-users-list/event-users-list.widget.html index 792a07e69..cb455abf1 100644 --- a/frontend/src/app/event/widgets/event-users-list/event-users-list.widget.html +++ b/frontend/src/app/event/widgets/event-users-list/event-users-list.widget.html @@ -17,7 +17,8 @@ -
+ @if (page) { +
@@ -45,4 +46,5 @@ [pageIndex]="page.params.page" (page)="handlePageEvent($event)"> + } diff --git a/frontend/src/app/event/widgets/event-users-list/event-users-list.widget.ts b/frontend/src/app/event/widgets/event-users-list/event-users-list.widget.ts index 99bc7c9c6..1f38a586b 100644 --- a/frontend/src/app/event/widgets/event-users-list/event-users-list.widget.ts +++ b/frontend/src/app/event/widgets/event-users-list/event-users-list.widget.ts @@ -14,6 +14,9 @@ import { Profile } from 'src/app/models.module'; import { EventService } from '../../event.service'; import { Event } from '../../event.model'; +// TODO: This component is to be deleted anyway, so it was +// not included in the refactor. + @Component({ selector: 'event-users-list', templateUrl: './event-users-list.widget.html', @@ -37,8 +40,8 @@ export class EventUsersList implements OnInit { ngOnInit() { this.eventService .getRegisteredUsersForEvent( - this.event.id!, - EventUsersList.PaginationParams + this.event, + EventUsersList.PaginationParams as PaginationParams ) .subscribe((page) => (this.page = page)); } @@ -48,7 +51,7 @@ export class EventUsersList implements OnInit { paginationParams.page = e.pageIndex; paginationParams.page_size = e.pageSize; this.eventService - .getRegisteredUsersForEvent(this.event.id!, paginationParams) + .getRegisteredUsersForEvent(this.event, paginationParams) .subscribe((page) => (this.page = page)); } } diff --git a/frontend/src/app/organization/organization-admin/organization-admin.component.ts b/frontend/src/app/organization/organization-admin/organization-admin.component.ts index 7c7d95774..dae08a1f8 100644 --- a/frontend/src/app/organization/organization-admin/organization-admin.component.ts +++ b/frontend/src/app/organization/organization-admin/organization-admin.component.ts @@ -8,12 +8,11 @@ */ import { Component, Signal } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; +import { Router } from '@angular/router'; import { Organization } from '../organization.model'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Observable } from 'rxjs'; -import { Profile } from '../../profile/profile.service'; -import { profileResolver } from '../../profile/profile.resolver'; +import { Profile, ProfileService } from '../../profile/profile.service'; import { OrganizationService } from '../organization.service'; import { PermissionService } from '../../permission.service'; @@ -36,25 +35,19 @@ export class OrganizationAdminComponent { public static Route = { path: 'admin', component: OrganizationAdminComponent, - title: 'Organization Administration', - resolve: { profile: profileResolver } + title: 'Organization Administration' }; constructor( - private route: ActivatedRoute, private router: Router, private snackBar: MatSnackBar, + private profileService: ProfileService, private organizationService: OrganizationService, private permissionService: PermissionService ) { + this.profile = this.profileService.profile()!; this.organizations = organizationService.organizations; this.displayedOrganizations = organizationService.adminOrganizations; - - /** Get the profile data of the signed in user */ - const data = this.route.snapshot.data as { - profile: Profile; - }; - this.profile = data.profile; } /** Resposible for generating delete and create buttons in HTML code when admin signed in. diff --git a/frontend/src/app/organization/organization-details/organization-details.component.html b/frontend/src/app/organization/organization-details/organization-details.component.html index 4e9c91d58..b7100b5a5 100644 --- a/frontend/src/app/organization/organization-details/organization-details.component.html +++ b/frontend/src/app/organization/organization-details/organization-details.component.html @@ -6,14 +6,4 @@ - -
- -
} diff --git a/frontend/src/app/organization/organization-details/organization-details.component.ts b/frontend/src/app/organization/organization-details/organization-details.component.ts index 147d3d3f6..4e6f00b15 100644 --- a/frontend/src/app/organization/organization-details/organization-details.component.ts +++ b/frontend/src/app/organization/organization-details/organization-details.component.ts @@ -25,6 +25,7 @@ import { EventService } from '../../event/event.service'; import { Event } from '../../event/event.model'; import { Observable } from 'rxjs'; import { PermissionService } from '../../permission.service'; +import { GroupEventsPipe } from '../../event/pipes/group-events.pipe'; /** Injects the organization's name to adjust the title. */ let titleResolver: ResolveFn = (route: ActivatedRouteSnapshot) => { @@ -74,6 +75,7 @@ export class OrganizationDetailsComponent { protected snackBar: MatSnackBar, private profileService: ProfileService, protected eventService: EventService, + protected groupEventsPipe: GroupEventsPipe, private permission: PermissionService ) { this.profile = this.profileService.profile()!; @@ -84,7 +86,7 @@ export class OrganizationDetailsComponent { }; this.organization = data.organization; - this.eventsPerDay = eventService.groupEventsByDate(data.events ?? []); + this.eventsPerDay = this.groupEventsPipe.transform(data.events ?? []); this.eventCreationPermission$ = this.permission.check( 'organization.*', `organization/${this.organization?.slug ?? '*'}` diff --git a/frontend/src/app/organization/organization-page/organization-page.component.ts b/frontend/src/app/organization/organization-page/organization-page.component.ts index 9d0878d29..71c62c830 100644 --- a/frontend/src/app/organization/organization-page/organization-page.component.ts +++ b/frontend/src/app/organization/organization-page/organization-page.component.ts @@ -9,7 +9,6 @@ */ import { Component, Signal, effect } from '@angular/core'; -import { profileResolver } from '../../profile/profile.resolver'; import { Organization } from '../organization.model'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Profile, ProfileService } from '../../profile/profile.service'; @@ -26,9 +25,7 @@ export class OrganizationPageComponent { public static Route = { path: '', title: 'CS Organizations', - component: OrganizationPageComponent, - canActivate: [], - resolve: { profile: profileResolver } + component: OrganizationPageComponent }; /** Current search bar query on the organization page. */ diff --git a/frontend/src/app/organization/organization.resolver.ts b/frontend/src/app/organization/organization.resolver.ts index b3aa2ca91..92357f702 100644 --- a/frontend/src/app/organization/organization.resolver.ts +++ b/frontend/src/app/organization/organization.resolver.ts @@ -8,11 +8,10 @@ */ import { inject } from '@angular/core'; -import { Resolve, ResolveFn } from '@angular/router'; +import { ResolveFn } from '@angular/router'; import { Organization } from './organization.model'; -import { EventService } from '../event/event.service'; import { Event } from '../event/event.model'; -import { catchError, of } from 'rxjs'; +import { catchError, map, of } from 'rxjs'; import { OrganizationService } from './organization.service'; // TODO: Explore if this can be replaced by a signal. @@ -60,7 +59,7 @@ export const organizationEventsResolver: ResolveFn = ( route, _state ) => { - return inject(EventService).getEventsByOrganization( - route.paramMap.get('slug')! - ); + return inject(OrganizationService) + .getOrganization(route.paramMap.get('slug')!) + .pipe(map((organization) => organization?.events ?? [])); }; diff --git a/frontend/src/app/organization/organization.service.ts b/frontend/src/app/organization/organization.service.ts index 13d4e1db3..ae38d1bb9 100644 --- a/frontend/src/app/organization/organization.service.ts +++ b/frontend/src/app/organization/organization.service.ts @@ -23,6 +23,8 @@ export class OrganizationService { /** Organizations signal */ private organizationsSignal: WritableSignal = signal([]); organizations = this.organizationsSignal.asReadonly(); + + /** Computed organization signals */ adminOrganizations = computed(() => { return this.organizations().filter((organization) => { return this.permissionService.checkSignal( @@ -95,7 +97,7 @@ export class OrganizationService { ); } - /** Returns the deleted organization object from the backend database table using the backend HTTP put request + /** Returns the deleted organization object from the backend database table using the backend HTTP delete request * and updates the organizations signal to exclude the deleted organization. * @param organization: Represents the deleted organization * @returns {Observable} diff --git a/frontend/src/app/pagination.ts b/frontend/src/app/pagination.ts index bde671cd9..a83ef9d82 100644 --- a/frontend/src/app/pagination.ts +++ b/frontend/src/app/pagination.ts @@ -11,7 +11,7 @@ */ import { HttpClient } from '@angular/common/http'; -import { WritableSignal, signal } from '@angular/core'; +import { WritableSignal, inject, signal } from '@angular/core'; import { Observable, map, tap } from 'rxjs'; /** Defines the general model for the pagination parameters expected by the backend. */ @@ -31,6 +31,16 @@ export interface TimeRangePaginationParams extends URLSearchParams { range_end: string; } +export const DEFAULT_TIME_RANGE_PARAMS = { + order_by: 'time', + ascending: 'true', + filter: '', + range_start: new Date().toISOString(), + range_end: new Date( + new Date().setMonth(new Date().getMonth() + 1) + ).toISOString() +} as TimeRangePaginationParams; + /** * Interface that defines a page returned from a paginator. * @@ -70,7 +80,7 @@ abstract class PaginatorAbstraction { */ constructor( protected api: string, - protected http: HttpClient + protected http: HttpClient = inject(HttpClient) ) { this.api = api; } @@ -101,7 +111,7 @@ abstract class PaginatorAbstraction { */ loadPage( paramStrings: Params, - operator?: ((_: APIType) => T) | null + operator?: ((responseModel: APIType) => T) | null ): Observable> { // Stpres the previous pagination parameters used this.previousParams = paramStrings;
Name