From debb6a1de173e3df5eb05842c14b2f33b638237b Mon Sep 17 00:00:00 2001 From: Jade Keegan <97476936+jadekeegan@users.noreply.github.com> Date: Fri, 5 Jan 2024 14:05:09 -0500 Subject: [PATCH] Convert Reusable Code to Widgets & Refactor Event/Organizer Models (#237) * Convert event editor user lookup to a reusable widget This converts the event editor user lookup form field to a widget so it can be used throughout the application. Additional Changes to Be Made: Create a new model to represent public users/info so the form field can be used for ANY user. * Create new `PublicUser` model This commit creates a PublicUser model to represent public information about a user. This is surfaced on the frontend as "PublicProfile" to match referencing the User as "Profile". This PublicUser model replaces the EventOrganizer and EventMember models. * Remove attendees from Event model This commit removes the list of attendees from the Event model to avoid exposing the attendee information when retrieving events. This list is never actually used, so it does not impact the code. * Create user chip list widget This commit turns the user chip list on the event details card into a widget so it can be reused to display user information. It also adds an "enableMailTo" field so users may be listed without allowing other users to email them. --- backend/api/events/events.py | 8 +- backend/entities/event_entity.py | 4 +- backend/entities/event_registration_entity.py | 39 ++++----- backend/models/__init__.py | 2 +- backend/models/event.py | 5 +- backend/models/event_member.py | 36 --------- backend/models/public_user.py | 24 ++++++ backend/services/event.py | 31 +++---- backend/test/services/event/event_test.py | 3 - .../test/services/event/event_test_data.py | 28 +++---- .../event-editor/event-editor.component.html | 46 +---------- .../event-editor/event-editor.component.ts | 73 +++-------------- frontend/src/app/event/event.model.ts | 18 +---- .../event-detail-card.widget.css | 17 ---- .../event-detail-card.widget.html | 16 +--- .../event-detail-card.widget.ts | 4 +- frontend/src/app/profile/profile.service.ts | 9 +++ frontend/src/app/shared/shared.module.ts | 24 +++++- .../user-chip-list/user-chip-list.widget.css | 17 ++++ .../user-chip-list/user-chip-list.widget.html | 14 ++++ .../user-chip-list/user-chip-list.widget.ts | 23 ++++++ .../shared/user-lookup/user-lookup.widget.css | 3 + .../user-lookup/user-lookup.widget.html | 38 +++++++++ .../shared/user-lookup/user-lookup.widget.ts | 81 +++++++++++++++++++ 24 files changed, 301 insertions(+), 262 deletions(-) delete mode 100644 backend/models/event_member.py create mode 100644 backend/models/public_user.py create mode 100644 frontend/src/app/shared/user-chip-list/user-chip-list.widget.css create mode 100644 frontend/src/app/shared/user-chip-list/user-chip-list.widget.html create mode 100644 frontend/src/app/shared/user-chip-list/user-chip-list.widget.ts create mode 100644 frontend/src/app/shared/user-lookup/user-lookup.widget.css create mode 100644 frontend/src/app/shared/user-lookup/user-lookup.widget.html create mode 100644 frontend/src/app/shared/user-lookup/user-lookup.widget.ts diff --git a/backend/api/events/events.py b/backend/api/events/events.py index a525c1669..cc7aae234 100644 --- a/backend/api/events/events.py +++ b/backend/api/events/events.py @@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException from datetime import datetime, timedelta from typing import Sequence -from backend.models.event_member import EventMember +from backend.models.public_user import PublicUser from backend.models.pagination import Paginated, PaginationParams from backend.services.organization import OrganizationService @@ -284,7 +284,7 @@ def register_for_event( subject: User = Depends(registered_user), event_service: EventService = Depends(), user_service: UserService = Depends(), -) -> EventMember: +) -> PublicUser: """ Register a user event based on the event ID. @@ -315,7 +315,7 @@ def get_event_registration_of_user( event_id: int, subject: User = Depends(registered_user), event_service: EventService = Depends(), -) -> EventMember: +) -> PublicUser: """ Check the registration status of a user for an event, raise ResourceNotFound if unregistered. @@ -337,7 +337,7 @@ def get_event_registrations( event_id: int, subject: User = Depends(registered_user), event_service: EventService = Depends(), -) -> Sequence[EventMember]: +) -> Sequence[PublicUser]: """ Get the registrations of an event. diff --git a/backend/entities/event_entity.py b/backend/entities/event_entity.py index 945a2edca..74914e720 100644 --- a/backend/entities/event_entity.py +++ b/backend/entities/event_entity.py @@ -111,7 +111,7 @@ def to_model(self, subject: User | None = None) -> Event: # Hide organizer info for unauthenticated users organizers = [ - registration.to_flat_organizer_model() + registration.to_flat_model() for registration in self.registrations if registration.registration_type == RegistrationType.ORGANIZER ] @@ -134,7 +134,6 @@ def to_model(self, subject: User | None = None) -> Event: organization_id=self.organization_id, registration_count=len(attendees), is_attendee=is_attendee, - attendees=attendees, is_organizer=is_organizer, organizers=organizers, ) @@ -160,7 +159,6 @@ def to_details_model(self, subject: User | None = None) -> EventDetails: organization_id=self.organization_id, organization=self.organization.to_model(), is_attendee=event.is_attendee, - attendees=event.attendees, is_organizer=event.is_organizer, organizers=event.organizers, ) diff --git a/backend/entities/event_registration_entity.py b/backend/entities/event_registration_entity.py index 50e402c9b..47af8978b 100644 --- a/backend/entities/event_registration_entity.py +++ b/backend/entities/event_registration_entity.py @@ -3,9 +3,11 @@ from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column, relationship -from backend.models.event_member import EventOrganizer +from backend.entities.event_entity import EventEntity +from backend.entities.user_entity import UserEntity -from ..models import RegistrationType, EventMember +from ..models import RegistrationType +from ..models.public_user import PublicUser from .entity_base import EntityBase from typing import Self from ..models.event_registration import EventRegistration, NewEventRegistration @@ -58,8 +60,8 @@ def from_model(cls, model: EventRegistration) -> Self: return cls( event_id=model.event_id, user_id=model.user_id, - event=model.event, - user=model.user, + event=EventEntity.from_model(model.event), + user=UserEntity.from_model(model.user), registration_type=model.registration_type, ) @@ -79,7 +81,7 @@ def from_new_model(cls, model: NewEventRegistration) -> Self: registration_type=model.registration_type, ) - def to__model(self) -> EventRegistration: + def to_model(self) -> EventRegistration: """ Converts an `EventRegistrationEntity` into an `EventRegistration` model object to store registration information. @@ -89,36 +91,25 @@ def to__model(self) -> EventRegistration: """ return EventRegistration( event_id=self.event_id, - event=self.event, + event=self.event.to_model(), user_id=self.user_id, - user=self.user, + user=self.user.to_model(), registration_type=self.registration_type, ) - def to_flat_model(self) -> EventMember: + def to_flat_model(self) -> PublicUser: """ - Converts an `EventRegistrationEntity` into an `EventMember` model object - to store user ID. + Converts an `EventRegistrationEntity` into an `PublicUser` model object + to store public user information. Returns: - EventMember: `EventMember` object from the entity + PublicUser: `PublicUser` object from the entity """ - return EventMember(id=self.user_id, registration_type=self.registration_type) - - def to_flat_organizer_model(self) -> EventMember: - """ - Converts an `EventRegistrationEntity` into an `EventMember` model object - to store user ID. - - Returns: - EventMember: `EventMember` object from the entity - """ - return EventOrganizer( + return PublicUser( id=self.user_id, - registration_type=self.registration_type, first_name=self.user.first_name, last_name=self.user.last_name, pronouns=self.user.pronouns, email=self.user.email, - github_avatar=self.user.github_avatar + github_avatar=self.user.github_avatar, ) diff --git a/backend/models/__init__.py b/backend/models/__init__.py index def1f76fe..856f34839 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -9,7 +9,7 @@ from .role_details import RoleDetails from .organization import Organization from .event import Event -from .event_member import EventMember +from .public_user import PublicUser from .event_details import EventDetails from .room import Room from .room_details import RoomDetails diff --git a/backend/models/event.py b/backend/models/event.py index a4a153371..662f8f44c 100644 --- a/backend/models/event.py +++ b/backend/models/event.py @@ -1,7 +1,7 @@ from pydantic import BaseModel from datetime import datetime -from .event_member import EventMember, EventOrganizer +from .public_user import PublicUser __authors__ = ["Ajay Gandecha", "Jade Keegan", "Brianna Ta", "Audrey Toney"] __copyright__ = "Copyright 2023" @@ -23,7 +23,7 @@ class DraftEvent(BaseModel): public: bool registration_limit: int organization_id: int - organizers: list[EventOrganizer] = [] + organizers: list[PublicUser] = [] class Event(DraftEvent): @@ -37,5 +37,4 @@ class Event(DraftEvent): id: int registration_count: int = 0 is_attendee: bool = False - attendees: list[EventMember] = [] is_organizer: bool = False diff --git a/backend/models/event_member.py b/backend/models/event_member.py deleted file mode 100644 index 102180623..000000000 --- a/backend/models/event_member.py +++ /dev/null @@ -1,36 +0,0 @@ -from pydantic import BaseModel -from .registration_type import RegistrationType - - -__authors__ = ["Ajay Gandecha"] -__copyright__ = "Copyright 2023" -__license__ = "MIT" - - -class EventMember(BaseModel): - """ - Pydantic model to represent the information about a user who is - registered for an event. - - This model is based on the `UserEntity` model, which defines the shape - of the `User` database in the PostgreSQL database - """ - - id: int | None - registration_type: RegistrationType - - -class EventOrganizer(EventMember): - """ - Pydantic model to represent the information about a user who is - registered for an event. - - This model is based on the `UserEntity` model, which defines the shape - of the `User` database in the PostgreSQL database - """ - - first_name: str - last_name: str - pronouns: str - email: str - github_avatar: str | None = None diff --git a/backend/models/public_user.py b/backend/models/public_user.py new file mode 100644 index 000000000..22ad25850 --- /dev/null +++ b/backend/models/public_user.py @@ -0,0 +1,24 @@ +from pydantic import BaseModel +from .registration_type import RegistrationType + + +__authors__ = ["Ajay Gandecha"] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + + +class PublicUser(BaseModel): + """ + Pydantic model to represent public information about users to avoid + exposing sensitive information about them. + + This model is based on the `UserEntity` model, which defines the shape + of the `User` database in the PostgreSQL database + """ + + id: int | None + first_name: str + last_name: str + pronouns: str + email: str + github_avatar: str | None = None diff --git a/backend/services/event.py b/backend/services/event.py index 9247bc959..e3f46590c 100644 --- a/backend/services/event.py +++ b/backend/services/event.py @@ -8,7 +8,8 @@ from sqlalchemy import func, select, or_ from sqlalchemy.orm import Session, aliased from backend.entities.user_entity import UserEntity -from backend.models.event_member import EventMember +from backend.models.event_registration import EventRegistration +from ..models.public_user import PublicUser from backend.models.organization_details import OrganizationDetails from backend.models.pagination import Paginated, PaginationParams from backend.models.registration_type import RegistrationType @@ -296,7 +297,7 @@ def delete(self, subject: User, id: int) -> None: def get_registration( self, subject: User, attendee: User, event: EventDetails - ) -> EventMember | None: + ) -> EventRegistration | None: """ Get a registration of an attendee for an Event. @@ -306,7 +307,7 @@ def get_registration( event: EventDetails of the event seeking registration for Returns: - EventMember or None if no registration found + PublicUser or None if no registration found Raises: UserPermissionException if subject does not have permission @@ -329,13 +330,13 @@ def get_registration( # Return EventRegistration model or None if event_registration_entity is not None: - return event_registration_entity.to_flat_model() + return event_registration_entity.to_model() else: return None def get_registrations_of_event( self, subject: User, event: EventDetails - ) -> list[EventMember]: + ) -> list[PublicUser]: """ List the registrations of an event. @@ -348,7 +349,7 @@ def get_registrations_of_event( event: The event whose registrations are being queried. Returns: - list[EventMember] + list[PublicUser] Raises: UserPermissionException if user is not an event organizer or admin. @@ -370,7 +371,7 @@ def get_registrations_of_event( def set_event_organizer( self, subject: User, user_id: int, event: EventDetails - ) -> EventMember: + ) -> PublicUser: """ Set the organizer of an event. @@ -379,7 +380,7 @@ def set_event_organizer( event: The EventDetails being registered for Returns: - EventMember + PublicUser """ @@ -400,11 +401,11 @@ def set_event_organizer( self._session.commit() # Return registration - return event_registration_entity.to_flat_organizer_model() + return event_registration_entity.to_flat_model() def register( self, subject: User, attendee: User, event: EventDetails - ) -> EventMember: + ) -> PublicUser: """ Register a user for an event. @@ -414,7 +415,7 @@ def register( event: The EventDetails being registered for Returns: - EventMember + PublicUser Raises: UserPermissionException if subject does not have permission to register user @@ -440,7 +441,9 @@ def register( # Permission to manage / read registration is enforced in EventService#get_registration existing_registration = self.get_registration(subject, attendee, event) if existing_registration: - return existing_registration + return EventRegistrationEntity.from_model( + existing_registration + ).to_flat_model() # Add new object to table and commit changes event_registration_entity = EventRegistrationEntity( @@ -492,7 +495,7 @@ def unregister(self, subject: User, attendee: User, event: EventDetails) -> None def get_registrations_of_user( self, subject: User, user: User, time_range: TimeRange - ) -> Sequence[EventMember]: + ) -> Sequence[PublicUser]: """ Get a user's registrations to events falling within a given time range. @@ -502,7 +505,7 @@ def get_registrations_of_user( time_range: The period over which to search for event registrations. Returns: - Sequence[EventMember] event registrations + Sequence[PublicUser] event registrations Raises: UserPermissionException when the user is requesting the registrations diff --git a/backend/test/services/event/event_test.py b/backend/test/services/event/event_test.py index 379a89c5a..4846ae6e0 100644 --- a/backend/test/services/event/event_test.py +++ b/backend/test/services/event/event_test.py @@ -4,7 +4,6 @@ import pytest from unittest.mock import create_autospec from backend.models.pagination import PaginationParams -from backend.models.registration_type import RegistrationType from backend.services.exceptions import ( EventRegistrationException, @@ -114,7 +113,6 @@ def test_create_event_as_root(event_svc_integration: EventService): assert created_event.organizers[0].id == root.id assert created_event.is_organizer == True - assert len(created_event.attendees) == 0 assert created_event.is_attendee == False @@ -270,7 +268,6 @@ def test_register_for_event_as_user(event_svc_integration: EventService): event_details = event_svc_integration.get_by_id(event_one.id, root) # type: ignore created_registration = event_svc_integration.register(root, root, event_details) # type: ignore assert created_registration is not None - assert created_registration.registration_type == RegistrationType.ATTENDEE def test_register_for_event_as_user_twice(event_svc_integration: EventService): diff --git a/backend/test/services/event/event_test_data.py b/backend/test/services/event/event_test_data.py index 2be0080ee..043d46b43 100644 --- a/backend/test/services/event/event_test_data.py +++ b/backend/test/services/event/event_test_data.py @@ -3,9 +3,9 @@ import pytest from sqlalchemy.orm import Session -from backend.models.event_member import EventMember, EventOrganizer +from ....models.public_user import PublicUser from ....models.event import DraftEvent, Event -from ....models.event_registration import EventRegistration, NewEventRegistration +from ....models.event_registration import NewEventRegistration from ....models.registration_type import RegistrationType from ....entities.event_entity import EventEntity from ....entities.event_registration_entity import EventRegistrationEntity @@ -65,13 +65,12 @@ registration_limit=50, organization_id=cads.id | 0, organizers=[ - EventOrganizer( + PublicUser( id=root.id, first_name=root.first_name, last_name=root.last_name, pronouns=root.pronouns, email=root.email, - registration_type=RegistrationType.ORGANIZER, ) ], ) @@ -97,13 +96,12 @@ registration_limit=50, organization_id=cssg.id | 0, organizers=[ - EventOrganizer( + PublicUser( id=user.id, first_name=user.first_name, last_name=user.last_name, pronouns=user.pronouns, email=user.email, - registration_type=RegistrationType.ORGANIZER, ), ], ) @@ -118,21 +116,19 @@ registration_limit=50, organization_id=cssg.id | 0, organizers=[ - EventOrganizer( + PublicUser( id=user.id, first_name=user.first_name, last_name=user.last_name, pronouns=user.pronouns, email=user.email, - registration_type=RegistrationType.ORGANIZER, ), - EventOrganizer( + PublicUser( id=ambassador.id, first_name=ambassador.first_name, last_name=ambassador.last_name, pronouns=ambassador.pronouns, email=ambassador.email, - registration_type=RegistrationType.ORGANIZER, ), ], ) @@ -158,29 +154,26 @@ registration_limit=1, organization_id=cssg.id | 0, organizers=[ - EventOrganizer( + PublicUser( id=user.id, first_name=user.first_name, last_name=user.last_name, pronouns=user.pronouns, email=user.email, - registration_type=RegistrationType.ORGANIZER, ), - EventOrganizer( + PublicUser( id=ambassador.id, first_name=ambassador.first_name, last_name=ambassador.last_name, pronouns=ambassador.pronouns, email=ambassador.email, - registration_type=RegistrationType.ORGANIZER, ), - EventOrganizer( + PublicUser( id=root.id, first_name=root.first_name, last_name=root.last_name, pronouns=root.pronouns, email=root.email, - registration_type=RegistrationType.ORGANIZER, ), ], ) @@ -195,13 +188,12 @@ registration_limit=1, organization_id=cssg.id | 0, organizers=[ - EventOrganizer( + PublicUser( id=user.id, first_name=user.first_name, last_name=user.last_name, pronouns=user.pronouns, email=user.email, - registration_type=RegistrationType.ORGANIZER, ), ], ) 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 94fda3a9d..d3892dd54 100644 --- a/frontend/src/app/event/event-editor/event-editor.component.html +++ b/frontend/src/app/event/event-editor/event-editor.component.html @@ -69,48 +69,10 @@ - - Organizers - - - - {{ organizer.first_name + ' ' + organizer.last_name }} - - - - - - {{ option.first_name }} {{ option.last_name }} - - - - + 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 b2d759273..c9966de42 100644 --- a/frontend/src/app/event/event-editor/event-editor.component.ts +++ b/frontend/src/app/event/event-editor/event-editor.component.ts @@ -7,29 +7,21 @@ * @license MIT */ -import { Component, ElementRef, ViewChild } from '@angular/core'; +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 { EventService } from '../event.service'; import { profileResolver } from '../../profile/profile.resolver'; -import { Profile, ProfileService } from '../../profile/profile.service'; +import { Profile, PublicProfile } from '../../profile/profile.service'; import { OrganizationService } from '../../organization/organization.service'; -import { - Observable, - ReplaySubject, - debounceTime, - filter, - mergeMap, - startWith -} from 'rxjs'; +import { Observable } from 'rxjs'; import { eventDetailResolver } from '../event.resolver'; import { PermissionService } from 'src/app/permission.service'; import { organizationDetailResolver } from 'src/app/organization/organization.resolver'; import { Organization } from 'src/app/organization/organization.model'; -import { Event, EventOrganizer, RegistrationType } from '../event.model'; +import { Event, RegistrationType } from '../event.model'; import { DatePipe } from '@angular/common'; -import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; @Component({ selector: 'app-event-editor', @@ -59,7 +51,7 @@ export class EventEditorComponent { public adminPermission$: Observable; /** Store organizers */ - public organizers: EventOrganizer[] = []; + public organizers: PublicProfile[] = []; /** Add validators to the form */ name = new FormControl('', [Validators.required]); @@ -74,12 +66,6 @@ export class EventEditorComponent { Validators.required, Validators.min(0) ]); - userLookup = new FormControl(); - - @ViewChild('organizersInput') organizersInput!: ElementRef; - private filteredUsers: ReplaySubject = new ReplaySubject(); - public filteredUsers$: Observable = - this.filteredUsers.asObservable(); /** Create a form group */ public eventForm = this.formBuilder.group({ @@ -100,8 +86,7 @@ export class EventEditorComponent { protected snackBar: MatSnackBar, private eventService: EventService, private permission: PermissionService, - private datePipe: DatePipe, - private profileService: ProfileService + private datePipe: DatePipe ) { // Get currently-logged-in user const data = route.snapshot.data as { @@ -142,26 +127,11 @@ export class EventEditorComponent { `organization/${this.organization!.id}` ); - this.adminPermission$.subscribe((perm) => { - if (!perm) { - this.userLookup.disable(); - } - }); - - // Configure the filtered users list based on the form - this.filteredUsers$ = this.userLookup.valueChanges.pipe( - startWith(''), - filter((search: string) => search.length > 2), - debounceTime(100), - mergeMap((search) => this.profileService.search(search)) - ); - // Set the organizers // If no organizers already, set current user as organizer if (this.event.id == null) { - let organizer = { + let organizer: PublicProfile = { id: this.profile.id!, - registration_type: RegistrationType.ORGANIZER, first_name: this.profile.first_name!, last_name: this.profile.last_name!, pronouns: this.profile.pronouns!, @@ -175,35 +145,10 @@ export class EventEditorComponent { } } - /** Handler for selecting an option in the who chip grid. */ - public onOptionSelected = (event: MatAutocompleteSelectedEvent) => { - let user = event.option.value as Profile; - if (this.organizers.filter((e) => e.id === user.id).length == 0) { - let organizer: EventOrganizer = { - id: user.id!, - registration_type: RegistrationType.ORGANIZER, - first_name: user.first_name!, - last_name: user.last_name!, - pronouns: user.pronouns!, - email: user.email!, - github_avatar: user.github_avatar - }; - this.organizers.push(organizer); - } - this.organizersInput.nativeElement.value = ''; - this.userLookup.setValue(''); - }; - - /** Handler for selecting an option in the who chip grid. */ - public onOptionDeselected = (person: EventOrganizer) => { - this.organizers.splice(this.organizers.indexOf(person), 1); - this.userLookup.setValue(''); - }; - /** Event handler to handle submitting the Create Event Form. * @returns {void} */ - onSubmit = () => { + onSubmit() { if (this.eventForm.valid) { Object.assign(this.event, this.eventForm.value); @@ -223,7 +168,7 @@ export class EventEditorComponent { } this.router.navigate(['/organizations/', this.organization_slug]); } - }; + } /** Opens a confirmation snackbar when an event is successfully created. * @returns {void} diff --git a/frontend/src/app/event/event.model.ts b/frontend/src/app/event/event.model.ts index 05d4b53cd..9ada9851c 100644 --- a/frontend/src/app/event/event.model.ts +++ b/frontend/src/app/event/event.model.ts @@ -9,6 +9,7 @@ import { Profile } from '../models.module'; import { Organization } from '../organization/organization.model'; +import { PublicProfile } from '../profile/profile.service'; /** Interface for Event Type (used on frontend for event detail) */ export interface Event { @@ -24,7 +25,7 @@ export interface Event { registration_count: number; is_attendee: boolean; is_organizer: boolean; - organizers: EventOrganizer[]; + organizers: PublicProfile[]; } /** Interface for the Event JSON Response model @@ -45,7 +46,7 @@ export interface EventJson { registration_count: number; is_attendee: boolean; is_organizer: boolean; - organizers: EventOrganizer[]; + organizers: PublicProfile[]; } /** Function that converts an EventJSON response model to an Event model. @@ -70,16 +71,3 @@ export interface EventRegistration { user: Profile | null; is_organizer: boolean | null; } - -export interface EventMember { - id: number; - registration_type: RegistrationType; -} - -export interface EventOrganizer extends EventMember { - first_name: string; - last_name: string; - pronouns: string; - email: string; - github_avatar: string | null; -} diff --git a/frontend/src/app/event/widgets/event-detail-card/event-detail-card.widget.css b/frontend/src/app/event/widgets/event-detail-card/event-detail-card.widget.css index 5914598e0..ad3a0349d 100644 --- a/frontend/src/app/event/widgets/event-detail-card/event-detail-card.widget.css +++ b/frontend/src/app/event/widgets/event-detail-card/event-detail-card.widget.css @@ -93,20 +93,3 @@ .organizers-text { margin: 0; } - -.organizer-chips { - margin-bottom: 16px; -} - -.organizer-chip { - margin: 8px 8px 0px 0px; -} - -.organizer-email-link { - text-decoration: none; - color: white; - display: flex; - justify-content: flex-start; - margin: 0; - padding: 0; -} 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 294d7906c..7ccaa0152 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 @@ -84,19 +84,9 @@
- + 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 3a22fb020..e8ca2e576 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 @@ -8,10 +8,10 @@ */ import { Component, Input, OnInit } from '@angular/core'; -import { Event, EventOrganizer, EventRegistration } from '../../event.model'; +import { Event, EventRegistration } from '../../event.model'; import { MatSnackBar } from '@angular/material/snack-bar'; import { EventService } from '../../event.service'; -import { Observable, of } from 'rxjs'; +import { Observable } from 'rxjs'; import { PermissionService } from 'src/app/permission.service'; import { Profile } from 'src/app/models.module'; import { Router } from '@angular/router'; diff --git a/frontend/src/app/profile/profile.service.ts b/frontend/src/app/profile/profile.service.ts index d54ee8e38..ada477db0 100644 --- a/frontend/src/app/profile/profile.service.ts +++ b/frontend/src/app/profile/profile.service.ts @@ -25,6 +25,15 @@ export interface Profile { permissions: Permission[]; } +export interface PublicProfile { + id: number; + first_name: string; + last_name: string; + pronouns: string; + email: string; + github_avatar: string | null; +} + @Injectable({ providedIn: 'root' }) diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index 00c318769..493512316 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -17,6 +17,7 @@ import { FormsModule } from '@angular/forms'; import { ReactiveFormsModule } from '@angular/forms'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatChipsModule } from '@angular/material/chips'; /* UI Widgets */ import { SocialMediaIcon } from '../shared/social-media-icon/social-media-icon.widget'; @@ -24,13 +25,23 @@ import { SearchBar } from './search-bar/search-bar.widget'; import { EventCard } from './event-card/event-card.widget'; import { RouterModule } from '@angular/router'; import { EventList } from './event-list/event-list.widget'; -import { EventFilterPipe } from '../event/event-filter/event-filter.pipe'; +import { UserLookup } from './user-lookup/user-lookup.widget'; + +import { UserChipList } from './user-chip-list/user-chip-list.widget'; @NgModule({ - declarations: [SocialMediaIcon, SearchBar, EventCard, EventList], + declarations: [ + SocialMediaIcon, + SearchBar, + EventCard, + EventList, + UserLookup, + UserChipList + ], imports: [ CommonModule, MatTabsModule, + MatChipsModule, MatTableModule, MatCardModule, MatDialogModule, @@ -47,6 +58,13 @@ import { EventFilterPipe } from '../event/event-filter/event-filter.pipe'; MatTooltipModule, RouterModule ], - exports: [SocialMediaIcon, SearchBar, EventCard, EventList] + exports: [ + SocialMediaIcon, + SearchBar, + EventCard, + EventList, + UserLookup, + UserChipList + ] }) export class SharedModule {} diff --git a/frontend/src/app/shared/user-chip-list/user-chip-list.widget.css b/frontend/src/app/shared/user-chip-list/user-chip-list.widget.css new file mode 100644 index 000000000..bdd5e08ef --- /dev/null +++ b/frontend/src/app/shared/user-chip-list/user-chip-list.widget.css @@ -0,0 +1,17 @@ +.user-chips { + margin-bottom: 16px; +} + +.user-chip { + margin: 8px 8px 0px 0px; + vertical-align: middle; +} + +.user-email-link { + text-decoration: none; + display: flex; + color: inherit; + justify-content: flex-start; + margin: 0; + padding: 0; +} diff --git a/frontend/src/app/shared/user-chip-list/user-chip-list.widget.html b/frontend/src/app/shared/user-chip-list/user-chip-list.widget.html new file mode 100644 index 000000000..7ba93cf17 --- /dev/null +++ b/frontend/src/app/shared/user-chip-list/user-chip-list.widget.html @@ -0,0 +1,14 @@ +
+ + + + {{ user.first_name + ' ' + user.last_name }} + + + {{ user.first_name + ' ' + user.last_name }} + + +
diff --git a/frontend/src/app/shared/user-chip-list/user-chip-list.widget.ts b/frontend/src/app/shared/user-chip-list/user-chip-list.widget.ts new file mode 100644 index 000000000..cf3fa235b --- /dev/null +++ b/frontend/src/app/shared/user-chip-list/user-chip-list.widget.ts @@ -0,0 +1,23 @@ +/** + * The User Chip List Widget displays user names as MatChips with + * an optional "click to contact" feature. + * + * @author Jade Keegan + * @copyright 2024 + * @license MIT + */ + +import { Component, Input } from '@angular/core'; +import { PublicProfile } from 'src/app/profile/profile.service'; + +@Component({ + selector: 'user-chip-list', + templateUrl: './user-chip-list.widget.html', + styleUrls: ['./user-chip-list.widget.css'] +}) +export class UserChipList { + @Input() users!: PublicProfile[]; + @Input() enableMailTo!: boolean; + + constructor() {} +} diff --git a/frontend/src/app/shared/user-lookup/user-lookup.widget.css b/frontend/src/app/shared/user-lookup/user-lookup.widget.css new file mode 100644 index 000000000..8aff6f5a5 --- /dev/null +++ b/frontend/src/app/shared/user-lookup/user-lookup.widget.css @@ -0,0 +1,3 @@ +.form-field { + width: 100%; +} diff --git a/frontend/src/app/shared/user-lookup/user-lookup.widget.html b/frontend/src/app/shared/user-lookup/user-lookup.widget.html new file mode 100644 index 000000000..e965205d8 --- /dev/null +++ b/frontend/src/app/shared/user-lookup/user-lookup.widget.html @@ -0,0 +1,38 @@ + + Organizers + + + + {{ user.first_name + ' ' + user.last_name }} + + + + + + {{ option.first_name }} {{ option.last_name }} + + + + diff --git a/frontend/src/app/shared/user-lookup/user-lookup.widget.ts b/frontend/src/app/shared/user-lookup/user-lookup.widget.ts new file mode 100644 index 000000000..0c3c92104 --- /dev/null +++ b/frontend/src/app/shared/user-lookup/user-lookup.widget.ts @@ -0,0 +1,81 @@ +/** + * The User Lookup Widget allows users to search for users + * in the XL. + * + * @author Ajay Gandecha, Jade Keegan, Brianna Ta, Audrey Toney + * @copyright 2023 + * @license MIT + */ + +import { Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; +import { + Observable, + ReplaySubject, + debounceTime, + filter, + mergeMap, + startWith +} from 'rxjs'; +import { Profile } from 'src/app/models.module'; +import { ProfileService, PublicProfile } from 'src/app/profile/profile.service'; + +@Component({ + selector: 'user-lookup', + templateUrl: './user-lookup.widget.html', + styleUrls: ['./user-lookup.widget.css'] +}) +export class UserLookup implements OnInit { + @Input() profile!: Profile | null; + @Input() users!: PublicProfile[]; + @Input() adminPermission!: boolean | null; + + userLookup = new FormControl(); + + @ViewChild('usersInput') usersInput!: ElementRef; + private filteredUsers: ReplaySubject = new ReplaySubject(); + public filteredUsers$: Observable = + this.filteredUsers.asObservable(); + + constructor(private profileService: ProfileService) { + // Configure the filtered users list based on the form + this.filteredUsers$ = this.userLookup.valueChanges.pipe( + startWith(''), + filter((search: string) => search.length > 2), + debounceTime(100), + mergeMap((search) => this.profileService.search(search)) + ); + } + + ngOnInit() { + if (!this.adminPermission) { + this.userLookup.disable(); + } + } + + /** Handler for selecting an option in the who chip grid. */ + public onUserAdded(event: MatAutocompleteSelectedEvent) { + let user = event.option.value as Profile; + if (this.users.filter((e) => e.id === user.id).length == 0) { + let organizer: PublicProfile = { + id: user.id!, + first_name: user.first_name!, + last_name: user.last_name!, + pronouns: user.pronouns!, + email: user.email!, + github_avatar: user.github_avatar + }; + this.users.push(organizer); + } + + this.usersInput.nativeElement.value = ''; + this.userLookup.setValue(''); + } + + /** Handler for selecting an option in the who chip grid. */ + public onUserRemoved(person: PublicProfile) { + this.users.splice(this.users.indexOf(person), 1); + this.userLookup.setValue(''); + } +}