diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index f27a6ec..40c5bb9 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -47,9 +47,6 @@ RUN mkdir -p /etc/apt/keyrings \ && npm install -g npm@latest \ && rm -rf /var/lib/apt/lists/* -# Install Angular CLI Globally -RUN npm install -g @angular/cli - # Use a non-root user per https://code.visualstudio.com/remote/advancedcontainers/add-nonroot-user ARG USERNAME=vscode ARG USER_UID=1000 diff --git a/backend/api/authentication.py b/backend/api/authentication.py index e586a8e..5e19df4 100644 --- a/backend/api/authentication.py +++ b/backend/api/authentication.py @@ -1,10 +1,11 @@ import jwt from datetime import datetime, timedelta, timezone -from fastapi import Cookie, Depends, HTTPException, Response, status, APIRouter +from fastapi import Depends, HTTPException, Request, Response, status, APIRouter from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials -from backend.env import getenv + from backend.models.user_model import User -from ..services import UserService +from backend.services import UserService +from backend.env import getenv api = APIRouter(prefix="/api/authentication") @@ -19,23 +20,31 @@ ACCESS_TOKEN_EXPIRE_MINUTES = getenv("ACCESS_TOKEN_EXPIRE_MINUTES") REFRESH_TOKEN_EXPIRE_DAYS = getenv("REFRESH_TOKEN_EXPIRE_DAYS") def create_access_token(user_id: str) -> str: - expiration = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + expiration = datetime.now(timezone.utc) + timedelta(minutes=int(ACCESS_TOKEN_EXPIRE_MINUTES)) payload = {"user_id": user_id, "exp": expiration} token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) return token def create_refresh_token(user_id: str) -> str: - expiration = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS) + expiration = datetime.now(timezone.utc) + timedelta(days=int(REFRESH_TOKEN_EXPIRE_DAYS)) payload = {"user_id": user_id, "exp": expiration} token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) return token def registered_user( - token: HTTPAuthorizationCredentials = Depends(HTTPBearer()), + request: Request, user_service: UserService = Depends() ) -> User: + access_token = request.cookies.get("access_token") + + if not access_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing access token" + ) + try: - payload = jwt.decode(token.credentials, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + payload = jwt.decode(access_token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) user_id = payload.get("user_id") user = user_service.get_user_by_id(user_id) @@ -56,7 +65,7 @@ def registered_user( detail="Invalid token" ) -@api.post("", include_in_schema=False, tags=["Authentication"]) +@api.post("", tags=["Authentication"]) def return_bearer_token(user_id: str, response: Response, user_service: UserService = Depends()): user = user_service.get_user_by_id(user_id) if not user: @@ -77,26 +86,6 @@ def return_bearer_token(user_id: str, response: Response, user_service: UserServ return {"message": "Tokens set as cookies"} -@api.post("/refresh", tags=["Authentication"]) -def refresh_access_token(response: Response, refresh_token: str = Depends(Cookie(None))): - try: - payload = jwt.decode(refresh_token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) - user_id = payload.get("user_id") - if not user_id: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token") - - new_access_token = create_access_token(user_id) - - response.set_cookie( - key="access_token", value=new_access_token, httponly=True, secure=True, max_age=ACCESS_TOKEN_EXPIRE_MINUTES * 60 - ) - return {"message": "Access token refreshed"} - - except jwt.ExpiredSignatureError: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token expired") - except jwt.PyJWTError: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token") - -@api.get("", include_in_schema=False, tags=["Authentication"]) +@api.get("", tags=["Authentication"]) def get_user_id(user_service: UserService = Depends()): return user_service.all() \ No newline at end of file diff --git a/backend/api/resource.py b/backend/api/resource.py index c4ada27..b5781ab 100644 --- a/backend/api/resource.py +++ b/backend/api/resource.py @@ -1,10 +1,11 @@ -from fastapi import APIRouter, Depends, HTTPException, status from typing import List +from fastapi import APIRouter, Depends + +from backend.models.user_model import User +from ..services import ResourceService, UserService +from ..models.resource_model import Resource from .authentication import registered_user -from backend.models.user_model import User -from ..services import ResourceService -from ..models.resource_model import Resource api = APIRouter(prefix="/api/resource") @@ -13,33 +14,40 @@ openapi_tags = { "description": "Resource search and related operations.", } + +# TODO: Add security using HTTP Bearer Tokens +# TODO: Enable authorization by passing user uuid to API +# TODO: Create custom exceptions @api.post("", response_model=Resource, tags=["Resource"]) def create( - subject: User = Depends(registered_user), - resource: Resource = Depends(), - resource_svc: ResourceService = Depends(), + resource: Resource, subject: User = Depends(registered_user), resource_svc: ResourceService = Depends() ): return resource_svc.create(subject, resource) + @api.get("", response_model=List[Resource], tags=["Resource"]) def get_all( - subject: User = Depends(registered_user), - resource_svc: ResourceService = Depends() + subject: User = Depends(registered_user), resource_svc: ResourceService = Depends() ): return resource_svc.get_resource_by_user(subject) +@api.get("/{name}", response_model=Resource, tags=["Resource"]) +def get_by_name( + name:str, subject: User = Depends(registered_user), resource_svc: ResourceService = Depends() +): + return resource_svc.get_resource_by_name(name, subject) + + @api.put("", response_model=Resource, tags=["Resource"]) def update( - subject: User = Depends(registered_user), - resource: Resource = Depends(), - resource_svc: ResourceService = Depends(), + uuid: str, resource: Resource, user_svc: UserService = Depends(), resource_svc: ResourceService = Depends() ): + subject = user_svc.get_user_by_uuid(uuid) return resource_svc.update(subject, resource) + @api.delete("", response_model=None, tags=["Resource"]) def delete( - subject: User = Depends(registered_user), - resource: Resource = Depends(), - resource_svc: ResourceService = Depends(), + resource: Resource, subject: User = Depends(registered_user), resource_svc: ResourceService = Depends() ): - resource_svc.delete(subject, resource) \ No newline at end of file + resource_svc.delete(subject, resource) diff --git a/backend/api/service.py b/backend/api/service.py index 8f58006..23bdc75 100644 --- a/backend/api/service.py +++ b/backend/api/service.py @@ -1,10 +1,11 @@ -from fastapi import APIRouter, Depends, HTTPException, status from typing import List +from fastapi import APIRouter, Depends + +from backend.models.user_model import User +from ..services import ServiceService, UserService +from ..models.service_model import Service from .authentication import registered_user -from backend.models.user_model import User -from ..services import ServiceService -from ..models.service_model import Service api = APIRouter(prefix="/api/service") @@ -13,33 +14,37 @@ openapi_tags = { "description": "Service search and related operations.", } + +# TODO: Add security using HTTP Bearer Tokens +# TODO: Enable authorization by passing user uuid to API +# TODO: Create custom exceptions @api.post("", response_model=Service, tags=["Service"]) def create( - subject: User = Depends(registered_user), - service: Service = Depends(), - service_svc: ServiceService = Depends(), + service: Service, subject: User = Depends(registered_user), service_svc: ServiceService = Depends() ): return service_svc.create(subject, service) + @api.get("", response_model=List[Service], tags=["Service"]) def get_all( - subject: User = Depends(registered_user), - service_svc: ServiceService = Depends() + subject: User = Depends(registered_user), service_svc: ServiceService = Depends() ): return service_svc.get_service_by_user(subject) +@api.get("/{name}", response_model=Service, tags=["Service"]) +def get_by_name( + name: str, subject: User = Depends(registered_user), service_svc: ServiceService = Depends() +): + return service_svc.get_service_by_name(name, subject) + @api.put("", response_model=Service, tags=["Service"]) def update( - subject: User = Depends(registered_user), - service: Service = Depends(), - service_svc: ServiceService = Depends(), + service: Service, subject: User = Depends(registered_user), service_svc: ServiceService = Depends() ): return service_svc.update(subject, service) @api.delete("", response_model=None, tags=["Service"]) def delete( - subject: User = Depends(registered_user), - service: Service = Depends(), - service_svc: ServiceService = Depends(), + service: Service, subject: User = Depends(registered_user), service_svc: ServiceService = Depends() ): - service_svc.delete(subject, service) \ No newline at end of file + service_svc.delete(subject, service) diff --git a/backend/api/tag.py b/backend/api/tag.py index 9b6a8c5..e683c12 100644 --- a/backend/api/tag.py +++ b/backend/api/tag.py @@ -1,11 +1,13 @@ from fastapi import APIRouter, Depends -from typing import List -from .authentication import registered_user from backend.models.tag_model import Tag from backend.models.user_model import User from backend.services.tag import TagService +from .authentication import registered_user + +from typing import List + api = APIRouter(prefix="/api/tag") openapi_tags = { @@ -13,33 +15,25 @@ openapi_tags = { "description": "Tag CRUD operations.", } + +# TODO: Add security using HTTP Bearer Tokens +# TODO: Enable authorization by passing user uuid to API +# TODO: Create custom exceptions @api.post("", response_model=Tag, tags=["Tag"]) -def create( - subject: User = Depends(registered_user), - tag: Tag = Depends(), - tag_service: TagService = Depends(), -): +def create(subject: User, tag: Tag, tag_service: TagService = Depends()): return tag_service.create(subject, tag) + @api.get("", response_model=List[Tag], tags=["Tag"]) -def get_all( - subject: User = Depends(registered_user), - tag_svc: TagService = Depends() -): +def get_all(subject: User, tag_svc: TagService = Depends()): return tag_svc.get_all() + @api.put("", response_model=Tag, tags=["Tag"]) -def update( - subject: User = Depends(registered_user), - tag: Tag = Depends(), - tag_svc: TagService = Depends(), -): - return tag_svc.update(subject, tag) +def update(subject: User, tag: Tag, tag_svc: TagService = Depends()): + return tag_svc.delete(subject, tag) + @api.delete("", response_model=None, tags=["Tag"]) -def delete( - subject: User = Depends(registered_user), - tag: Tag = Depends(), - tag_svc: TagService = Depends(), -): - tag_svc.delete(subject, tag) \ No newline at end of file +def delete(subject: User, tag: Tag, tag_svc: TagService = Depends()): + tag_svc.delete(subject, tag) diff --git a/backend/api/test_routes.md b/backend/api/test_routes.md new file mode 100644 index 0000000..64528ba --- /dev/null +++ b/backend/api/test_routes.md @@ -0,0 +1,147 @@ +# Synopsis +Collection of sample curl requests for api routes. + +# Resources +## Get All +Given an admin UUID, gets all of the resources from ResourceEntity. +``` +curl -X 'GET' \ + 'http://127.0.0.1:8000/api/resource?uuid=acc6e112-d296-4739-a80c-b89b2933e50b' \ + -H 'accept: application/json' +``` + +## Get by Name +Given the name of a resource and an admin UUID, gets a resource from ResourceEntity by name. +``` +curl -X 'GET' \ + 'http://127.0.0.1:8000/api/resource/Financial%20Empowerment%20Center?uuid=acc6e112-d296-4739-a80c-b89b2933e50b' \ + -H 'accept: application/json' +``` + +## Create +Given an admin UUID and a new resource object, adds a resource to ResourceEntity. +``` +curl -X 'POST' \ + 'http://127.0.0.1:8000/api/resource?uuid=acc6e112-d296-4739-a80c-b89b2933e50b' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -d '{ + "id": 25, + "name": "algorithms and analysis textbook", + "summary": "textbook written by kevin sun for c550", + "link": "kevinsun.org", + "program": "DOMESTIC", + "created_at": "2024-11-04T20:07:31.875166" +}' +``` + +## Update +Given an admin UUID and a modified resource object, updates the resource with a matching ID if it exists. +``` +curl -X 'PUT' \ + 'http://127.0.0.1:8000/api/resource?uuid=acc6e112-d296-4739-a80c-b89b2933e50b' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -d '{ + "id": 25, + "name": "algorithms and analysis textbook", + "summary": "textbook written by the goat himself, kevin sun, for c550", + "link": "kevinsun.org", + "program": "DOMESTIC", + "created_at": "2024-11-04T20:07:31.875166" +}' +``` + +## Delete +Given an admin UUID and a resource object, deletes the resource with a matching ID if it exists. +``` +curl -X 'DELETE' \ + 'http://127.0.0.1:8000/api/resource?uuid=acc6e112-d296-4739-a80c-b89b2933e50b' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -d '{ + "id": 25, + "name": "algorithms and analysis textbook", + "summary": "textbook written by the goat himself, kevin sun, for c550", + "link": "kevinsun.org", + "program": "DOMESTIC", + "created_at": "2024-11-04T20:07:31.875166" +}' +``` + +# Services +## Get All +Given an admin UUID, gets all of the services from ServiceEntity. +``` +curl -X 'GET' \ + 'http://127.0.0.1:8000/api/service?uuid=acc6e112-d296-4739-a80c-b89b2933e50b' \ + -H 'accept: application/json' +``` + +## Get by Name +Given the name of a service and an admin UUID, gets a service from ServiceEntity by name. +``` +curl -X 'GET' \ + 'http://127.0.0.1:8000/api/service/Shelter%20Placement?uuid=acc6e112-d296-4739-a80c-b89b2933e50b' \ + -H 'accept: application/json' +``` + +## Create +Given an admin UUID and a new service object, adds a service to ServiceEntity. +``` +curl -X 'POST' \ + 'http://127.0.0.1:8000/api/service?uuid=acc6e112-d296-4739-a80c-b89b2933e50b' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -d '{ + "id": 25, + "created_at": "2024-11-04T20:07:31.890412", + "name": "c550 tutoring", + "status": "open", + "summary": "tutoring for kevin sun'\''s c550 class", + "requirements": [ + "must be in c550" + ], + "program": "COMMUNITY" +}' +``` + +## Update +Given an admin UUID and a modified service object, updates the service with a matching ID if it exists. +``` +curl -X 'PUT' \ + 'http://127.0.0.1:8000/api/service?uuid=acc6e112-d296-4739-a80c-b89b2933e50b' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -d '{ + "id": 25, + "created_at": "2024-11-04T20:07:31.890412", + "name": "c550 tutoring", + "status": "closed", + "summary": "tutoring for kevin sun'\''s c550 class", + "requirements": [ + "must be in c550" + ], + "program": "COMMUNITY" +}' +``` + +## Delete +Given an admin UUID and a service object, deletes the service with a matching ID if it exists. +``` +curl -X 'DELETE' \ + 'http://127.0.0.1:8000/api/service?uuid=acc6e112-d296-4739-a80c-b89b2933e50b' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -d '{ + "id": 25, + "created_at": "2024-11-04T20:07:31.890412", + "name": "c550 tutoring", + "status": "closed", + "summary": "tutoring for kevin sun'\''s c550 class", + "requirements": [ + "must be in c550" + ], + "program": "COMMUNITY" +}' +``` \ No newline at end of file diff --git a/backend/models/resource_model.py b/backend/models/resource_model.py index 8c9fde0..81e49cc 100644 --- a/backend/models/resource_model.py +++ b/backend/models/resource_model.py @@ -12,4 +12,4 @@ class Resource(BaseModel): summary: str = Field(..., max_length=300, description="The summary of the resource") link: str = Field(..., max_length=150, description="link to the resource") program: ProgramTypeEnum - created_at: Optional[datetime] + created_at: Optional[datetime] = datetime.now() diff --git a/backend/models/service_model.py b/backend/models/service_model.py index 36c336b..9671974 100644 --- a/backend/models/service_model.py +++ b/backend/models/service_model.py @@ -8,7 +8,7 @@ from .enum_for_models import ProgramTypeEnum class Service(BaseModel): id: int | None = None - created_at: datetime | None = None + created_at: datetime | None = datetime.now() name: str status: str summary: str diff --git a/backend/models/tag_model.py b/backend/models/tag_model.py index 44dcb02..fee566c 100644 --- a/backend/models/tag_model.py +++ b/backend/models/tag_model.py @@ -10,4 +10,4 @@ class Tag(BaseModel): content: str = Field( ..., max_length=600, description="content associated with the tag" ) - created_at: datetime | None = None + created_at: datetime | None = datetime.now() diff --git a/backend/models/user_model.py b/backend/models/user_model.py index d7c1521..e2c25da 100644 --- a/backend/models/user_model.py +++ b/backend/models/user_model.py @@ -14,5 +14,5 @@ class User(BaseModel): group: str program: List[ProgramTypeEnum] role: UserTypeEnum - created_at: Optional[datetime] + created_at: Optional[datetime] = datetime.now() uuid: str | None = None diff --git a/backend/services/exceptions.py b/backend/services/exceptions.py index 5fb87ed..98bfea6 100644 --- a/backend/services/exceptions.py +++ b/backend/services/exceptions.py @@ -20,6 +20,8 @@ class UserPermissionException(Exception): class ServiceNotFoundException(Exception): """Exception for when the service being requested is not in the table.""" +class TagNotFoundException(Exception): + """Exception for when the tag being requested is not in the table.""" class ProgramNotAssignedException(Exception): """Exception for when the user does not have correct access for requested services.""" diff --git a/backend/services/resource.py b/backend/services/resource.py index 2ccbee9..3b0273f 100644 --- a/backend/services/resource.py +++ b/backend/services/resource.py @@ -1,12 +1,12 @@ from fastapi import Depends from ..database import db_session from sqlalchemy.orm import Session -from sqlalchemy import select +from sqlalchemy import and_, select from ..models.resource_model import Resource from ..entities.resource_entity import ResourceEntity from ..models.user_model import User, UserTypeEnum -from .exceptions import ResourceNotFoundException +from .exceptions import ProgramNotAssignedException, ResourceNotFoundException class ResourceService: @@ -24,137 +24,115 @@ class ResourceService: programs = subject.program resources = [] for program in programs: - entities = self._session.query(ResourceEntity).where(ResourceEntity.program == program).all() + entities = ( + self._session.query(ResourceEntity) + .where(ResourceEntity.program == program) + .all() + ) for entity in entities: resources.append(entity.to_model()) return [resource for resource in resources] + def get_resource_by_name(self, name: str, subject: User) -> Resource: + """Get a resource by name.""" + query = select(ResourceEntity).where( + and_( + ResourceEntity.name == name, ResourceEntity.program.in_(subject.program) + ) + ) + entity = self._session.scalars(query).one_or_none() + if entity is None: + raise ResourceNotFoundException( + f"Resource with name: {name} does not exist or program has not been assigned." + ) + return entity.to_model() + def create(self, subject: User, resource: Resource) -> Resource: """ Creates a resource based on the input object and adds it to the table if the user has the right permissions. - Parameters: user: a valid User model representing the currently logged in User resource: Resource object to add to table - Returns: Resource: Object added to table """ - # Ask about what the requirements for making a resource are. - if resource.role != subject.role or resource.group != subject.group: - raise PermissionError( - "User does not have permission to add resources in this role or group." + if subject.role != UserTypeEnum.ADMIN: + raise ProgramNotAssignedException( + f"User is not {UserTypeEnum.ADMIN}, cannot update service" ) - resource_entity = ResourceEntity.from_model(resource) self._session.add(resource_entity) self._session.commit() - return resource_entity.to_model() - def get_by_id(self, user: User, id: int) -> Resource: - """ - Gets a resource based on the resource id that the user has access to - - Parameters: - user: a valid User model representing the currently logged in User - id: int, the id of the resource - - Returns: - Resource - - Raises: - ResourceNotFoundException: If no resource is found with id - """ - resource = ( - self._session.query(ResourceEntity) - .filter( - ResourceEntity.id == id, - ResourceEntity.role == user.role, - ResourceEntity.group == user.group, - ) - .one_or_none() - ) - - if resource is None: - raise ResourceNotFoundException(f"No resource found with id: {id}") - - return resource.to_model() - def update(self, subject: User, resource: Resource) -> Resource: """ Update the resource if the user has access - Parameters: user: a valid User model representing the currently logged in User resource (ResourceEntity): Resource to update - Returns: Resource: Updated resource object - Raises: ResourceNotFoundException: If no resource is found with the corresponding ID """ - if resource.role != subject.role or resource.group != subject.group: - raise PermissionError( - "User does not have permission to update this resource." + if subject.role != UserTypeEnum.ADMIN: + raise ProgramNotAssignedException( + f"User is not {UserTypeEnum.ADMIN}, cannot update service" ) - query = select(ResourceEntity).where(ResourceEntity.id == resource.id) entity = self._session.scalars(query).one_or_none() - if entity is None: raise ResourceNotFoundException( f"No resource found with matching id: {resource.id}" ) - entity.name = resource.name entity.summary = resource.summary entity.link = resource.link entity.program = resource.program self._session.commit() - return entity.to_model() def delete(self, subject: User, resource: Resource) -> None: """ Delete resource based on id that the user has access to - Parameters: user: a valid User model representing the currently logged in User id: int, a unique resource id - Raises: ResourceNotFoundException: If no resource is found with the corresponding id """ + if subject.role != UserTypeEnum.ADMIN: + raise ProgramNotAssignedException( + f"User is not {UserTypeEnum.ADMIN}, cannot update service" + ) query = select(ResourceEntity).where(ResourceEntity.id == resource.id) entity = self._session.scalars(query).one_or_none() - if entity is None: - raise ResourceNotFoundException(f"No resource found with matching id: {resource.id}") - + raise ResourceNotFoundException( + f"No resource found with matching id: {resource.id}" + ) self._session.delete(entity) self._session.commit() def get_by_slug(self, user: User, search_string: str) -> list[Resource]: """ Get a list of resources given a search string that the user has access to - Parameters: user: a valid User model representing the currently logged in User search_string: a string to search resources by - Returns: list[Resource]: list of resources relating to the string - Raises: ResourceNotFoundException if no resource is found with the corresponding slug """ query = select(ResourceEntity).where( - ResourceEntity.title.ilike(f"%{search_string}%"), - ResourceEntity.role == user.role, - ResourceEntity.group == user.group, + ResourceEntity.name.ilike(f"%{search_string}%"), + ResourceEntity.program.in_(user.program), ) entities = self._session.scalars(query).all() + if not entities: + return [] + return [entity.to_model() for entity in entities] diff --git a/backend/services/service.py b/backend/services/service.py index edce109..e10309e 100644 --- a/backend/services/service.py +++ b/backend/services/service.py @@ -27,37 +27,24 @@ class ServiceService: return [service.to_model() for service in entities] else: programs = subject.program - resources = [] + services = [] for program in programs: entities = self._session.query(ServiceEntity).where(ServiceEntity.program == program).all() for entity in entities: - resources.append(entity.to_model()) - return [service for service in resources] + services.append(entity.to_model()) + return [service for service in services] - def get_service_by_program(self, program: ProgramTypeEnum) -> list[Service]: - """Service method getting services belonging to a particular program.""" - query = select(ServiceEntity).filter(ServiceEntity.program == program) - entities = self._session.scalars(query) - - return [entity.to_model() for entity in entities] - - def get_service_by_id(self, id: int) -> Service: + def get_service_by_name(self, name: str, subject: User) -> Service: """Service method getting services by id.""" - query = select(ServiceEntity).filter(ServiceEntity.id == id) + query = select(ServiceEntity).where( + and_( + ServiceEntity.name == name, ServiceEntity.program.in_(subject.program) + ) + ) entity = self._session.scalars(query).one_or_none() if entity is None: - raise ServiceNotFoundException(f"Service with id: {id} does not exist") - - return entity.to_model() - - def get_service_by_name(self, name: str) -> Service: - """Service method getting services by id.""" - query = select(ServiceEntity).filter(ServiceEntity.name == name) - entity = self._session.scalars(query).one_or_none() - - if entity is None: - raise ServiceNotFoundException(f"Service with name: {name} does not exist") + raise ServiceNotFoundException(f"Service with name: {name} does not exist or program has not been assigned") return entity.to_model() diff --git a/backend/test/conftest.py b/backend/test/conftest.py index b91a2ac..f231859 100644 --- a/backend/test/conftest.py +++ b/backend/test/conftest.py @@ -4,7 +4,7 @@ import pytest from sqlalchemy import Engine, create_engine, text from sqlalchemy.orm import Session from sqlalchemy.exc import OperationalError -from .services import user_test_data, tag_test_data, service_test_data +from .services import user_test_data, tag_test_data, service_test_data, resource_test_data from ..database import _engine_str from ..env import getenv @@ -57,5 +57,6 @@ def setup_insert_data_fixture(session: Session): user_test_data.insert_fake_data(session) tag_test_data.insert_fake_data(session) service_test_data.insert_fake_data(session) + resource_test_data.insert_fake_data(session) session.commit() yield diff --git a/backend/test/services/fixtures.py b/backend/test/services/fixtures.py index 9fb349a..213f1bf 100644 --- a/backend/test/services/fixtures.py +++ b/backend/test/services/fixtures.py @@ -6,6 +6,7 @@ from sqlalchemy.orm import Session from ...services import UserService from ...services import TagService from ...services import ServiceService +from ...services import ResourceService @@ -23,4 +24,9 @@ def tag_svc(session: Session): @pytest.fixture() def service_svc(session: Session): """This fixture is used to test the ServiceService class""" - return ServiceService(session) \ No newline at end of file + return ServiceService(session) + +@pytest.fixture() +def resource_svc(session: Session): + """This fixture is used to test the ResourceService class""" + return ResourceService(session) \ No newline at end of file diff --git a/backend/test/services/resource_test.py b/backend/test/services/resource_test.py new file mode 100644 index 0000000..7d9b007 --- /dev/null +++ b/backend/test/services/resource_test.py @@ -0,0 +1,126 @@ +from backend.models.user_model import User +from backend.entities.resource_entity import ResourceEntity +from ...models.enum_for_models import ProgramTypeEnum +from backend.services.resource import ResourceService +from backend.services.tag import TagService +from backend.services.exceptions import ResourceNotFoundException +from . import resource_test_data +from . import user_test_data +from .fixtures import resource_svc, user_svc, tag_svc +from backend.models.resource_model import Resource +import pytest + + +def test_get_resource_by_user_volunteer(resource_svc: ResourceService): + """ Test getting resources by a volunteer """ + resources = resource_svc.get_resource_by_user(user_test_data.volunteer) + assert len(resources) == 2 + assert isinstance(resources[0], Resource) + +def test_get_resources_admin(resource_svc: ResourceService): + """ Test getting resources by an admin """ + resources = resource_svc.get_resource_by_user(user_test_data.admin) + assert len(resources) == len(resource_test_data.resources) + assert isinstance(resources[0], Resource) + +def test_get_resources_employee(resource_svc: ResourceService): + """ Test getting by an employee """ + resources = resource_svc.get_resource_by_user(user_test_data.employee) + assert len(resources) == 5 + assert isinstance(resources[0], Resource) + +def test_create_resource_admin(resource_svc: ResourceService): + """ Test creating resources as an admin """ + resource = resource_svc.create(user_test_data.admin, resource_test_data.resource6) + assert resource.name == resource_test_data.resource6.name + assert isinstance(resource, Resource) + +def test_create_not_permitted(resource_svc: ResourceService): + """ Test creating resources without permission """ + with pytest.raises(PermissionError): + resource = resource_svc.create(user_test_data.volunteer, resource_test_data.resource1) + pytest.fail() + +def test_get_by_id(resource_svc: ResourceService): + """ Test getting a resource by id as an admin """ + test_resource = resource_test_data.resource1 + resource = resource_svc.get_by_id(user_test_data.admin, test_resource.id) + assert resource is not None + assert resource.id == test_resource.id + assert resource.name == test_resource.name + +def test_get_by_id_no_access(resource_svc: ResourceService): + """ Test getting a resourced with an id no accessible to an employee """ + test_resource = resource_test_data.resource2 + with pytest.raises(ResourceNotFoundException): + resource = resource_svc.get_by_id(user_test_data.employee, test_resource.id) + pytest.fail() + +def test_update(resource_svc: ResourceService): + """ Test updating a resource by an admin """ + updated_resource = resource_test_data.resource5_new + resource = resource_svc.update(user_test_data.admin, updated_resource) + db_resource = resource_svc.get_by_id(user_test_data.admin, resource.id) + assert resource.id == updated_resource.id + assert resource.name == updated_resource.name + assert resource.summary == updated_resource.summary + assert resource.link == updated_resource.link + assert resource.program == updated_resource.program + assert db_resource.id == updated_resource.id + assert db_resource.name == updated_resource.name + assert db_resource.summary == updated_resource.summary + assert db_resource.link == updated_resource.link + assert db_resource.program == updated_resource.program + + +def test_update_no_permission(resource_svc: ResourceService): + """ Test updating a resource without permission """ + with pytest.raises(PermissionError): + resource = resource_svc.update(user_test_data.employee, resource_test_data.resource5_new) + pytest.fail() + +def test_delete(resource_svc: ResourceService): + """ Test deleting a resource as an admin """ + resource_svc.delete(user_test_data.admin, resource_test_data.resource5.id) + resources = resource_svc.get_resource_by_user(user_test_data.admin) + assert len(resources) == len(resource_test_data.resources) - 1 + +def test_delete_no_permission(resource_svc: ResourceService): + """ Test deleting a resource with no permission """ + with pytest.raises(PermissionError): + resource = resource_svc.delete(user_test_data.employee, resource_test_data.resource5.id) + pytest.fail() + +def test_get_1_by_slug(resource_svc: ResourceService): + """ Test getting 1 resource with a specific search """ + resource_to_test = resource_test_data.resource1 + slug = "Resource 1" + resources = resource_svc.get_by_slug(user_test_data.admin, slug) + assert len(resources) == 1 + resource = resources[0] + assert resource.id == resource_to_test.id + assert resource.name == resource_to_test.name + assert resource.summary == resource_to_test.summary + assert resource.link == resource_to_test.link + assert resource.program == resource_to_test.program + +def test_get_by_slug(resource_svc: ResourceService): + """ Test a generic search to get all resources """ + slug = "Resource" + resources = resource_svc.get_by_slug(user_test_data.admin, slug) + assert len(resources) == 5 + +def test_get_by_slug_not_found(resource_svc: ResourceService): + """ Test getting a resource that does not exist """ + slug = "Not Found" + resources = resource_svc.get_by_slug(user_test_data.admin, slug) + assert len(resources) == 0 + assert resources == [] + + +def test_get_by_slug_no_permission(resource_svc: ResourceService): + """ Test getting a resource the user does not have access to """ + slug = "Resource 2" + resources = resource_svc.get_by_slug(user_test_data.employee, slug) + assert len(resources) == 0 + assert resources == [] \ No newline at end of file diff --git a/backend/test/services/resource_test_data.py b/backend/test/services/resource_test_data.py index bb39266..7634df7 100644 --- a/backend/test/services/resource_test_data.py +++ b/backend/test/services/resource_test_data.py @@ -50,6 +50,24 @@ resource5 = Resource( created_at=datetime(2023, 6, 5, 11, 30, 0), ) +resource6 = Resource( + id=6, + name="Resource 6", + summary="New Financial Resource", + link="https://example.com/resource6", + program=ProgramTypeEnum.ECONOMIC, + created_at=datetime(2024, 6, 5, 11, 30, 0), +) + +resource5_new = Resource( + id=5, + name="Resource 5", + summary = "Updated shelter and housing resources", + link="https://example.com/resource5/new", + program=ProgramTypeEnum.DOMESTIC, + created_at=datetime(2023, 6, 5, 11, 30, 0), +) + resources = [resource1, resource2, resource3, resource4, resource5] resource_1 = Resource( @@ -266,13 +284,11 @@ def reset_table_id_seq( next_id: int, ) -> None: """Reset the ID sequence of an entity table. - Args: session (Session) - A SQLAlchemy Session entity (DeclarativeBase) - The SQLAlchemy Entity table to target entity_id_column (MappedColumn) - The ID column (should be an int column) next_id (int) - Where the next inserted, autogenerated ID should begin - Returns: None""" table = entity.__table__ @@ -312,4 +328,4 @@ def insert_fake_data(session: Session): reset_table_id_seq(session, ResourceEntity, ResourceEntity.id, len(resources) + 1) # Commit all changes - session.commit() + session.commit() \ No newline at end of file diff --git a/compass/app/admin/layout.tsx b/compass/app/admin/layout.tsx index 78bf6a6..490f601 100644 --- a/compass/app/admin/layout.tsx +++ b/compass/app/admin/layout.tsx @@ -2,7 +2,6 @@ import Sidebar from "@/components/Sidebar/Sidebar"; import React, { useState } from "react"; -import { ChevronDoubleRightIcon } from "@heroicons/react/24/outline"; import { createClient } from "@/utils/supabase/client"; import { useRouter } from "next/navigation"; import { useEffect } from "react"; @@ -14,7 +13,7 @@ export default function RootLayout({ }: { children: React.ReactNode; }) { - const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const [isSidebarOpen, setIsSidebarOpen] = useState(true); const router = useRouter(); const [user, setUser] = useState(); @@ -56,34 +55,13 @@ export default function RootLayout({
{user ? (
- {/* button to open sidebar */} - - {/* sidebar */} -
- -
- {/* page ui */} +
{/* icon + title */} }> - + ); diff --git a/compass/app/api/resource/all/route.ts b/compass/app/api/resource/all/route.ts index 3649278..9daa022 100644 --- a/compass/app/api/resource/all/route.ts +++ b/compass/app/api/resource/all/route.ts @@ -9,7 +9,7 @@ export async function GET(request: Request) { const { searchParams } = new URL(request.url); const uuid = searchParams.get("uuid"); - const data = await fetch(`${apiEndpoint}?user_id=${uuid}`); + const data = await fetch(`${apiEndpoint}?uuid=${uuid}`); const resourceData: Resource[] = await data.json(); // TODO: Remove make every resource visible diff --git a/compass/app/api/service/all/route.ts b/compass/app/api/service/all/route.ts index b164bc1..e7786ea 100644 --- a/compass/app/api/service/all/route.ts +++ b/compass/app/api/service/all/route.ts @@ -9,7 +9,7 @@ export async function GET(request: Request) { const { searchParams } = new URL(request.url); const uuid = searchParams.get("uuid"); - const data = await fetch(`${apiEndpoint}?user_id=${uuid}`); + const data = await fetch(`${apiEndpoint}?uuid=${uuid}`); const serviceData: Service[] = await data.json(); // TODO: Remove make every service visible diff --git a/compass/app/home/layout.tsx b/compass/app/home/layout.tsx index eb054de..236616e 100644 --- a/compass/app/home/layout.tsx +++ b/compass/app/home/layout.tsx @@ -1,7 +1,6 @@ "use client"; import Sidebar from "@/components/Sidebar/Sidebar"; import React, { useState } from "react"; -import { ChevronDoubleRightIcon } from "@heroicons/react/24/outline"; import { createClient } from "@/utils/supabase/client"; import { useEffect } from "react"; import { useRouter } from "next/navigation"; @@ -13,7 +12,7 @@ export default function RootLayout({ }: { children: React.ReactNode; }) { - const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const [isSidebarOpen, setIsSidebarOpen] = useState(true); const [user, setUser] = useState(); const router = useRouter(); @@ -45,34 +44,13 @@ export default function RootLayout({
{user ? (
- {/* button to open sidebar */} - - {/* sidebar */} -
- -
- {/* page ui */} +
(); @@ -48,33 +47,14 @@ export default function RootLayout({
{user ? (
- {/* button to open sidebar */} - - {/* sidebar */} -
- -
+ + {/*
*/} {/* page ui */}
{/* icon + title */} }> - +
); diff --git a/compass/app/service/layout.tsx b/compass/app/service/layout.tsx index 1fff740..7213fa0 100644 --- a/compass/app/service/layout.tsx +++ b/compass/app/service/layout.tsx @@ -2,7 +2,6 @@ import Sidebar from "@/components/Sidebar/Sidebar"; import React, { useState } from "react"; -import { ChevronDoubleRightIcon } from "@heroicons/react/24/outline"; import { createClient } from "@/utils/supabase/client"; import { useRouter } from "next/navigation"; import { useEffect } from "react"; @@ -14,7 +13,7 @@ export default function RootLayout({ }: { children: React.ReactNode; }) { - const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const [isSidebarOpen, setIsSidebarOpen] = useState(true); const router = useRouter(); const [user, setUser] = useState(); @@ -48,34 +47,13 @@ export default function RootLayout({
{user ? (
- {/* button to open sidebar */} - - {/* sidebar */} -
- -
- {/* page ui */} +
([]); + const [services, setServices] = useState([]); useEffect(() => { async function getServices() { @@ -27,7 +27,7 @@ export default function Page() { ); const servicesAPI: Service[] = await serviceListData.json(); - setUsers(servicesAPI); + setServices(servicesAPI); } getServices(); @@ -37,7 +37,7 @@ export default function Page() {
{/* icon + title */} }> - +
); diff --git a/compass/components/Drawer/Drawer.tsx b/compass/components/Drawer/Drawer.tsx index 6879f75..da6fd8c 100644 --- a/compass/components/Drawer/Drawer.tsx +++ b/compass/components/Drawer/Drawer.tsx @@ -1,4 +1,4 @@ -import { FunctionComponent, ReactNode } from "react"; +import { Dispatch, FunctionComponent, ReactNode, SetStateAction } from "react"; import React, { useState } from "react"; import { ChevronDoubleLeftIcon } from "@heroicons/react/24/solid"; import { @@ -23,7 +23,7 @@ type DrawerProps = { editableContent?: any; onSave?: (content: any) => void; rowContent?: any; - onRowUpdate?: (content: any) => void; + setData: Dispatch>; }; interface EditContent { @@ -37,13 +37,21 @@ const Drawer: FunctionComponent = ({ onSave, editableContent, rowContent, - onRowUpdate, + setData, }) => { const [isOpen, setIsOpen] = useState(false); const [isFull, setIsFull] = useState(false); const [isFavorite, setIsFavorite] = useState(false); const [tempRowContent, setTempRowContent] = useState(rowContent); + const onRowUpdate = (updatedRow: any) => { + setData((prevData: any) => + prevData.map((row: any) => + row.id === updatedRow.id ? updatedRow : row + ) + ); + }; + const handleTempRowContentChange = (e) => { const { name, value } = e.target; console.log(name); diff --git a/compass/components/Sidebar/Sidebar.tsx b/compass/components/Sidebar/Sidebar.tsx index 2208035..d82ab69 100644 --- a/compass/components/Sidebar/Sidebar.tsx +++ b/compass/components/Sidebar/Sidebar.tsx @@ -2,6 +2,7 @@ import React from "react"; import { HomeIcon, ChevronDoubleLeftIcon, + ChevronDoubleRightIcon, BookmarkIcon, ClipboardIcon, BookOpenIcon, @@ -12,6 +13,7 @@ import { UserProfile } from "../resource/UserProfile"; interface SidebarProps { setIsSidebarOpen: React.Dispatch>; + isSidebarOpen: boolean; name: string; email: string; isAdmin: boolean; @@ -19,70 +21,96 @@ interface SidebarProps { const Sidebar: React.FC = ({ setIsSidebarOpen, + isSidebarOpen, name, email, isAdmin: admin, }) => { return ( -
- {/* button to close sidebar */} -
- -
-
- {/* user + logout button */} -
- -
- {/* navigation menu */} -
-

- Pages -

- + {/* The sidebar itself. */} +
+ {/* Button to close sidebar */} +
+ +
+ +
+ {/* user + logout button */} +
+ +
+ {/* navigation menu */} +
+

+ Pages +

+ +
-
+ ); }; diff --git a/compass/components/Table/Index.tsx b/compass/components/Table/Index.tsx deleted file mode 100644 index 931b039..0000000 --- a/compass/components/Table/Index.tsx +++ /dev/null @@ -1,306 +0,0 @@ -// for showcasing to compass - -import users from "./users.json"; -import { - Cell, - ColumnDef, - Row, - createColumnHelper, - flexRender, - getCoreRowModel, - getFilteredRowModel, - sortingFns, - useReactTable, -} from "@tanstack/react-table"; -import { - ChangeEvent, - useState, - useEffect, - FunctionComponent, - useRef, - ChangeEventHandler, - Key, -} from "react"; -import { RowOptionMenu } from "./RowOptionMenu"; -import { RowOpenAction } from "./RowOpenAction"; -import { TableAction } from "./TableAction"; -import { - AtSymbolIcon, - Bars2Icon, - ArrowDownCircleIcon, - PlusIcon, -} from "@heroicons/react/24/solid"; -import TagsInput from "../TagsInput/Index"; -import { rankItem } from "@tanstack/match-sorter-utils"; -import User from "@/utils/models/User"; - -// For search -const fuzzyFilter = ( - row: Row, - columnId: string, - value: any, - addMeta: (meta: any) => void -) => { - // Rank the item - const itemRank = rankItem(row.getValue(columnId), value); - - // Store the ranking info - addMeta(itemRank); - - // Return if the item should be filtered in/out - return itemRank.passed; -}; - -export const Table = ({ users }: { users: User[] }) => { - const columnHelper = createColumnHelper(); - - useEffect(() => { - const sortedUsers = [...users].sort((a, b) => - a.visible === b.visible ? 0 : a.visible ? -1 : 1 - ); - setData(sortedUsers); - }, [users]); - - const deleteUser = (userId: number) => { - console.log(data); - setData((currentData) => - currentData.filter((user) => user.id !== userId) - ); - }; - - const hideUser = (userId: number) => { - console.log(`Toggling visibility for user with ID: ${userId}`); - setData((currentData) => { - const newData = currentData - .map((user) => { - if (user.id === userId) { - return { ...user, visible: !user.visible }; - } - return user; - }) - .sort((a, b) => - a.visible === b.visible ? 0 : a.visible ? -1 : 1 - ); - - console.log(newData); - return newData; - }); - }; - const [presetOptions, setPresetOptions] = useState([ - "administrator", - "volunteer", - "employee", - ]); - const [tagColors, setTagColors] = useState(new Map()); - - const getTagColor = (tag: string) => { - if (!tagColors.has(tag)) { - const colors = [ - "bg-cyan-100", - "bg-blue-100", - "bg-green-100", - "bg-yellow-100", - "bg-purple-100", - ]; - const randomColor = - colors[Math.floor(Math.random() * colors.length)]; - setTagColors(new Map(tagColors).set(tag, randomColor)); - } - return tagColors.get(tag); - }; - - const columns = [ - columnHelper.display({ - id: "options", - cell: (props) => ( - deleteUser(props.row.original.id)} - onHide={() => hideUser(props.row.original.id)} - /> - ), - }), - columnHelper.accessor("username", { - header: () => ( - <> - Username - - ), - cell: (info) => ( - - ), - }), - columnHelper.accessor("role", { - header: () => ( - <> - {" "} - Role - - ), - cell: (info) => ( - - ), - }), - columnHelper.accessor("email", { - header: () => ( - <> - Email - - ), - cell: (info) => ( - - {info.getValue()} - - ), - }), - columnHelper.accessor("program", { - header: () => ( - <> - {" "} - Program - - ), - cell: (info) => , - }), - ]; - - const [data, setData] = useState([...users]); - - const addUser = () => { - setData([...data]); - }; - - // Searching - const [query, setQuery] = useState(""); - const handleSearchChange = (e: ChangeEvent) => { - const target = e.target as HTMLInputElement; - setQuery(String(target.value)); - }; - - const handleCellChange = (e: ChangeEvent, key: Key) => { - const target = e.target as HTMLInputElement; - console.log(key); - }; - - // TODO: Filtering - - // TODO: Sorting - - // added this fn for editing rows - const handleRowUpdate = (updatedRow: User) => { - const dataIndex = data.findIndex((row) => row.id === updatedRow.id); - if (dataIndex !== -1) { - const updatedData = [...data]; - updatedData[dataIndex] = updatedRow; - setData(updatedData); - } - }; - - const table = useReactTable({ - columns, - data, - filterFns: { - fuzzy: fuzzyFilter, - }, - state: { - globalFilter: query, - }, - onGlobalFilterChange: setQuery, - globalFilterFn: fuzzyFilter, - getCoreRowModel: getCoreRowModel(), - }); - - const handleRowData = (row: any) => { - const rowData: any = {}; - row.cells.forEach((cell: any) => { - rowData[cell.column.id] = cell.value; - }); - // Use rowData object containing data from all columns for the current row - console.log(rowData); - return rowData; - }; - - return ( -
-
- -
-
- - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header, i) => ( - - ))} - - ))} - - - {table.getRowModel().rows.map((row) => { - // Individual row - const isUserVisible = row.original.visible; - const rowClassNames = `text-gray-800 border-y lowercase hover:bg-gray-50 ${ - !isUserVisible ? "bg-gray-200 text-gray-500" : "" - }`; - return ( - - {row.getVisibleCells().map((cell, i) => ( - - ))} - - ); - })} - - - - - - -
- {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} -
- {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} -
- - - New - -
-
- ); -}; diff --git a/compass/components/Table/ResourceTable.tsx b/compass/components/Table/ResourceTable.tsx new file mode 100644 index 0000000..6979498 --- /dev/null +++ b/compass/components/Table/ResourceTable.tsx @@ -0,0 +1,82 @@ +import { Bars2Icon } from "@heroicons/react/24/solid"; +import { Dispatch, SetStateAction, useState } from "react"; +import useTagsHandler from "@/components/TagsInput/TagsHandler"; +import { ColumnDef, createColumnHelper } from "@tanstack/react-table"; +import { RowOpenAction } from "@/components/Table/RowOpenAction"; +import Table from "@/components/Table/Table"; +import TagsInput from "@/components/TagsInput/Index"; +import Resource from "@/utils/models/Resource"; + +type ResourceTableProps = { + data: Resource[]; + setData: Dispatch>; +}; + +/** + * Table componenet used for displaying resources + * @param props.data Stateful list of resources to be displayed by the table + * @param props.setData State setter for the list of resources + */ +export default function ResourceTable({ data, setData }: ResourceTableProps) { + const columnHelper = createColumnHelper(); + + // Set up tag handling + const programProps = useTagsHandler(["community", "domestic", "economic"]); + + // Define Tanstack columns + const columns: ColumnDef[] = [ + columnHelper.accessor("name", { + header: () => ( + <> + Name + + ), + cell: (info) => ( + + ), + }), + columnHelper.accessor("link", { + header: () => ( + <> + Link + + ), + cell: (info) => ( + + {info.getValue()} + + ), + }), + columnHelper.accessor("program", { + header: () => ( + <> + Program + + ), + cell: (info) => ( + + ), + }), + + columnHelper.accessor("summary", { + header: () => ( + <> + Summary + + ), + cell: (info) => ( + {info.getValue()} + ), + }), + ]; + + return ; +} diff --git a/compass/components/Table/RowOpenAction.tsx b/compass/components/Table/RowOpenAction.tsx index 9a7103c..a6aeb52 100644 --- a/compass/components/Table/RowOpenAction.tsx +++ b/compass/components/Table/RowOpenAction.tsx @@ -1,10 +1,21 @@ import Drawer from "@/components/Drawer/Drawer"; -import { ChangeEvent, useState } from "react"; +import DataPoint from "@/utils/models/DataPoint"; +import { Dispatch, SetStateAction, useState } from "react"; -export const RowOpenAction = ({ title, rowData, onRowUpdate }) => { +type RowOpenActionProps = { + title: string; + rowData: T; + setData: Dispatch>; +}; + +export function RowOpenAction({ + title, + rowData, + setData, +}: RowOpenActionProps) { const [pageContent, setPageContent] = useState(""); - const handleDrawerContentChange = (newContent) => { + const handleDrawerContentChange = (newContent: string) => { setPageContent(newContent); }; @@ -12,17 +23,16 @@ export const RowOpenAction = ({ title, rowData, onRowUpdate }) => {
{title} - {/* Added OnRowUpdate to drawer */} {pageContent}
); -}; +} diff --git a/compass/components/Table/ServiceIndex.tsx b/compass/components/Table/ServiceIndex.tsx deleted file mode 100644 index 6895984..0000000 --- a/compass/components/Table/ServiceIndex.tsx +++ /dev/null @@ -1,312 +0,0 @@ -// for showcasing to compass - -import users from "./users.json"; -import { - Cell, - ColumnDef, - Row, - createColumnHelper, - flexRender, - getCoreRowModel, - getFilteredRowModel, - sortingFns, - useReactTable, -} from "@tanstack/react-table"; -import { - ChangeEvent, - useState, - useEffect, - FunctionComponent, - useRef, - ChangeEventHandler, - Key, -} from "react"; -import { RowOptionMenu } from "./RowOptionMenu"; -import { RowOpenAction } from "./RowOpenAction"; -import { TableAction } from "./TableAction"; -import { - AtSymbolIcon, - Bars2Icon, - ArrowDownCircleIcon, - PlusIcon, -} from "@heroicons/react/24/solid"; -import TagsInput from "../TagsInput/Index"; -import { rankItem } from "@tanstack/match-sorter-utils"; -import Service from "@/utils/models/Service"; - -// For search -const fuzzyFilter = ( - row: Row, - columnId: string, - value: any, - addMeta: (meta: any) => void -) => { - // Rank the item - const itemRank = rankItem(row.getValue(columnId), value); - - // Store the ranking info - addMeta(itemRank); - - // Return if the item should be filtered in/out - return itemRank.passed; -}; - -// TODO: Rename everything to service -export const ServiceTable = ({ users }: { users: Service[] }) => { - const columnHelper = createColumnHelper(); - - useEffect(() => { - const sortedUsers = [...users].sort((a, b) => - a.visible === b.visible ? 0 : a.visible ? -1 : 1 - ); - setData(sortedUsers); - }, [users]); - - const deleteUser = (userId: number) => { - console.log(data); - setData((currentData) => - currentData.filter((user) => user.id !== userId) - ); - }; - - const hideUser = (userId: number) => { - console.log(`Toggling visibility for user with ID: ${userId}`); - setData((currentData) => { - const newData = currentData - .map((user) => { - if (user.id === userId) { - return { ...user, visible: !user.visible }; - } - return user; - }) - .sort((a, b) => - a.visible === b.visible ? 0 : a.visible ? -1 : 1 - ); - - console.log(newData); - return newData; - }); - }; - const [presetOptions, setPresetOptions] = useState([ - "administrator", - "volunteer", - "employee", - ]); - const [tagColors, setTagColors] = useState(new Map()); - - const getTagColor = (tag: string) => { - if (!tagColors.has(tag)) { - const colors = [ - "bg-cyan-100", - "bg-blue-100", - "bg-green-100", - "bg-yellow-100", - "bg-purple-100", - ]; - const randomColor = - colors[Math.floor(Math.random() * colors.length)]; - setTagColors(new Map(tagColors).set(tag, randomColor)); - } - return tagColors.get(tag); - }; - - const columns = [ - columnHelper.display({ - id: "options", - cell: (props) => ( - {}} - onHide={() => hideUser(props.row.original.id)} - /> - ), - }), - columnHelper.accessor("name", { - header: () => ( - <> - Name - - ), - cell: (info) => ( - - ), - }), - columnHelper.accessor("status", { - header: () => ( - <> - Status - - ), - cell: (info) => ( - {info.getValue()} - ), - }), - columnHelper.accessor("program", { - header: () => ( - <> - Program - - ), - cell: (info) => , - }), - columnHelper.accessor("requirements", { - header: () => ( - <> - Requirements - - ), - cell: (info) => ( - - ), - }), - - columnHelper.accessor("summary", { - header: () => ( - <> - Summary - - ), - cell: (info) => ( - {info.getValue()} - ), - }), - ]; - - const [data, setData] = useState([...users]); - - const addUser = () => { - setData([...data]); - }; - - // Searching - const [query, setQuery] = useState(""); - const handleSearchChange = (e: ChangeEvent) => { - const target = e.target as HTMLInputElement; - setQuery(String(target.value)); - }; - - const handleCellChange = (e: ChangeEvent, key: Key) => { - const target = e.target as HTMLInputElement; - console.log(key); - }; - - // TODO: Filtering - - // TODO: Sorting - - // added this fn for editing rows - const handleRowUpdate = (updatedRow: Service) => { - const dataIndex = data.findIndex((row) => row.id === updatedRow.id); - if (dataIndex !== -1) { - const updatedData = [...data]; - updatedData[dataIndex] = updatedRow; - setData(updatedData); - } - }; - - const table = useReactTable({ - columns, - data, - filterFns: { - fuzzy: fuzzyFilter, - }, - state: { - globalFilter: query, - }, - onGlobalFilterChange: setQuery, - globalFilterFn: fuzzyFilter, - getCoreRowModel: getCoreRowModel(), - }); - - const handleRowData = (row: any) => { - const rowData: any = {}; - row.cells.forEach((cell: any) => { - rowData[cell.column.id] = cell.value; - }); - // Use rowData object containing data from all columns for the current row - console.log(rowData); - return rowData; - }; - - return ( -
-
- -
-
- - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header, i) => ( - - ))} - - ))} - - - {table.getRowModel().rows.map((row) => { - // Individual row - const isUserVisible = row.original.visible; - const rowClassNames = `text-gray-800 border-y lowercase hover:bg-gray-50 ${ - !isUserVisible ? "bg-gray-200 text-gray-500" : "" - }`; - return ( - - {row.getVisibleCells().map((cell, i) => ( - - ))} - - ); - })} - - - - - - -
- {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} -
- {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} -
- - - New - -
-
- ); -}; diff --git a/compass/components/Table/ServiceTable.tsx b/compass/components/Table/ServiceTable.tsx new file mode 100644 index 0000000..794452e --- /dev/null +++ b/compass/components/Table/ServiceTable.tsx @@ -0,0 +1,103 @@ +import { Bars2Icon } from "@heroicons/react/24/solid"; +import { Dispatch, SetStateAction } from "react"; +import useTagsHandler from "@/components/TagsInput/TagsHandler"; +import { ColumnDef, createColumnHelper } from "@tanstack/react-table"; +import Table from "@/components/Table/Table"; +import { RowOpenAction } from "@/components/Table/RowOpenAction"; +import TagsInput from "@/components/TagsInput/Index"; +import Service from "@/utils/models/Service"; + +type ServiceTableProps = { + data: Service[]; + setData: Dispatch>; +}; + +/** + * Table componenet used for displaying services + * @param props.data Stateful list of services to be displayed by the table + * @param props.setData State setter for the list of services + */ +export default function ServiceTable({ data, setData }: ServiceTableProps) { + const columnHelper = createColumnHelper(); + + // Set up tag handling + const programProps = useTagsHandler(["community", "domestic", "economic"]); + + // TODO: Dynamically or statically get full list of preset requirement tag options + const requirementProps = useTagsHandler([ + "anonymous", + "confidential", + "referral required", + "safety assessment", + "intake required", + "income eligibility", + "initial assessment", + ]); + + // Define Tanstack columns + const columns: ColumnDef[] = [ + columnHelper.accessor("name", { + header: () => ( + <> + Name + + ), + cell: (info) => ( + + ), + }), + columnHelper.accessor("status", { + header: () => ( + <> + Status + + ), + cell: (info) => ( + {info.getValue()} + ), + }), + columnHelper.accessor("program", { + header: () => ( + <> + Program + + ), + cell: (info) => ( + + ), + }), + columnHelper.accessor("requirements", { + header: () => ( + <> + Requirements + + ), + cell: (info) => ( + // TODO: Setup different tag handler for requirements + + ), + }), + + columnHelper.accessor("summary", { + header: () => ( + <> + Summary + + ), + cell: (info) => ( + {info.getValue()} + ), + }), + ]; + + return ; +} diff --git a/compass/components/Table/ResourceIndex.tsx b/compass/components/Table/Table.tsx similarity index 56% rename from compass/components/Table/ResourceIndex.tsx rename to compass/components/Table/Table.tsx index a714836..b0a3bd1 100644 --- a/compass/components/Table/ResourceIndex.tsx +++ b/compass/components/Table/Table.tsx @@ -1,40 +1,32 @@ -// for showcasing to compass - -import users from "./users.json"; import { - Cell, - ColumnDef, Row, - createColumnHelper, - flexRender, - getCoreRowModel, - getFilteredRowModel, - sortingFns, + ColumnDef, useReactTable, + getCoreRowModel, + flexRender, + createColumnHelper, } from "@tanstack/react-table"; import { ChangeEvent, useState, useEffect, - FunctionComponent, - useRef, - ChangeEventHandler, Key, + Dispatch, + SetStateAction, } from "react"; -import { RowOptionMenu } from "./RowOptionMenu"; -import { RowOpenAction } from "./RowOpenAction"; import { TableAction } from "./TableAction"; -import { - AtSymbolIcon, - Bars2Icon, - ArrowDownCircleIcon, - PlusIcon, -} from "@heroicons/react/24/solid"; -import TagsInput from "../TagsInput/Index"; +import { PlusIcon } from "@heroicons/react/24/solid"; import { rankItem } from "@tanstack/match-sorter-utils"; -import Resource from "@/utils/models/Resource"; +import { RowOptionMenu } from "./RowOptionMenu"; +import DataPoint from "@/utils/models/DataPoint"; -// For search +type TableProps = { + data: T[]; + setData: Dispatch>; + columns: ColumnDef[]; +}; + +/** Fuzzy search function */ const fuzzyFilter = ( row: Row, columnId: string, @@ -51,131 +43,69 @@ const fuzzyFilter = ( return itemRank.passed; }; -// TODO: Rename everything to resources -export const ResourceTable = ({ users }: { users: Resource[] }) => { - const columnHelper = createColumnHelper(); +/** + * General componenet that holds shared functionality for any data table component + * @param props.data Stateful list of data to be held in the table + * @param props.setData State setter for the list of data + * @param props.columns Column definitions made with Tanstack columnHelper + */ +export default function Table({ + data, + setData, + columns, +}: TableProps) { + const columnHelper = createColumnHelper(); + /** Sorting function based on visibility */ + const visibilitySort = (a: T, b: T) => + a.visible === b.visible ? 0 : a.visible ? -1 : 1; + + // Sort data on load useEffect(() => { - const sortedUsers = [...users].sort((a, b) => - a.visible === b.visible ? 0 : a.visible ? -1 : 1 - ); - setData(sortedUsers); - }, [users]); + setData((prevData) => prevData.sort(visibilitySort)); + }, [setData]); - const deleteUser = (userId: number) => { + // Data manipulation methods + // TODO: Connect data manipulation methods to the database (deleteData, hideData, addData) + const deleteData = (dataId: number) => { console.log(data); setData((currentData) => - currentData.filter((user) => user.id !== userId) + currentData.filter((data) => data.id !== dataId) ); }; - const hideUser = (userId: number) => { - console.log(`Toggling visibility for user with ID: ${userId}`); + const hideData = (dataId: number) => { + console.log(`Toggling visibility for data with ID: ${dataId}`); setData((currentData) => { const newData = currentData - .map((user) => { - if (user.id === userId) { - return { ...user, visible: !user.visible }; - } - return user; - }) - .sort((a, b) => - a.visible === b.visible ? 0 : a.visible ? -1 : 1 - ); + .map((data) => + data.id === dataId + ? { ...data, visible: !data.visible } + : data + ) + .sort(visibilitySort); console.log(newData); return newData; }); }; - const [presetOptions, setPresetOptions] = useState([ - "administrator", - "volunteer", - "employee", - ]); - const [tagColors, setTagColors] = useState(new Map()); - const getTagColor = (tag: string) => { - if (!tagColors.has(tag)) { - const colors = [ - "bg-cyan-100", - "bg-blue-100", - "bg-green-100", - "bg-yellow-100", - "bg-purple-100", - ]; - const randomColor = - colors[Math.floor(Math.random() * colors.length)]; - setTagColors(new Map(tagColors).set(tag, randomColor)); - } - return tagColors.get(tag); + const addData = () => { + setData([...data]); }; - const columns = [ + // Add data manipulation options to the first column + columns.unshift( columnHelper.display({ id: "options", cell: (props) => ( {}} - onHide={() => hideUser(props.row.original.id)} + onDelete={() => deleteData(props.row.original.id)} + onHide={() => hideData(props.row.original.id)} /> ), - }), - columnHelper.accessor("name", { - header: () => ( - <> - Name - - ), - cell: (info) => ( - - ), - }), - columnHelper.accessor("link", { - header: () => ( - <> - Link - - ), - cell: (info) => ( - - {info.getValue()} - - ), - }), - columnHelper.accessor("program", { - header: () => ( - <> - Program - - ), - cell: (info) => , - }), - - columnHelper.accessor("summary", { - header: () => ( - <> - Summary - - ), - cell: (info) => ( - {info.getValue()} - ), - }), - ]; - - const [data, setData] = useState([...users]); - - const addUser = () => { - setData([...data]); - }; + }) + ); // Searching const [query, setQuery] = useState(""); @@ -193,16 +123,7 @@ export const ResourceTable = ({ users }: { users: Resource[] }) => { // TODO: Sorting - // added this fn for editing rows - const handleRowUpdate = (updatedRow: Resource) => { - const dataIndex = data.findIndex((row) => row.id === updatedRow.id); - if (dataIndex !== -1) { - const updatedData = [...data]; - updatedData[dataIndex] = updatedRow; - setData(updatedData); - } - }; - + // Define Tanstack table const table = useReactTable({ columns, data, @@ -261,9 +182,9 @@ export const ResourceTable = ({ users }: { users: Resource[] }) => { {table.getRowModel().rows.map((row) => { // Individual row - const isUserVisible = row.original.visible; + const isDataVisible = row.original.visible; const rowClassNames = `text-gray-800 border-y lowercase hover:bg-gray-50 ${ - !isUserVisible ? "bg-gray-200 text-gray-500" : "" + !isDataVisible ? "bg-gray-200 text-gray-500" : "" }`; return ( @@ -289,7 +210,7 @@ export const ResourceTable = ({ users }: { users: Resource[] }) => {
@@ -301,4 +222,4 @@ export const ResourceTable = ({ users }: { users: Resource[] }) => {
); -}; +} diff --git a/compass/components/Table/UserTable.tsx b/compass/components/Table/UserTable.tsx new file mode 100644 index 0000000..a480dc8 --- /dev/null +++ b/compass/components/Table/UserTable.tsx @@ -0,0 +1,89 @@ +import { + ArrowDownCircleIcon, + AtSymbolIcon, + Bars2Icon, +} from "@heroicons/react/24/solid"; +import { Dispatch, SetStateAction } from "react"; +import useTagsHandler from "@/components/TagsInput/TagsHandler"; +import { ColumnDef, createColumnHelper } from "@tanstack/react-table"; +import Table from "@/components/Table/Table"; +import { RowOpenAction } from "@/components/Table/RowOpenAction"; +import TagsInput from "@/components/TagsInput/Index"; +import User from "@/utils/models/User"; + +type UserTableProps = { + data: User[]; + setData: Dispatch>; +}; + +/** + * Table componenet used for displaying users + * @param props.data Stateful list of users to be displayed by the table + * @param props.setData State setter for the list of users + */ +export default function UserTable({ data, setData }: UserTableProps) { + const columnHelper = createColumnHelper(); + + // Set up tag handling + const roleProps = useTagsHandler([ + "administrator", + "volunteer", + "employee", + ]); + + const programProps = useTagsHandler(["community", "domestic", "economic"]); + + // Define Tanstack columns + const columns: ColumnDef[] = [ + columnHelper.accessor("username", { + header: () => ( + <> + Username + + ), + cell: (info) => ( + + ), + }), + columnHelper.accessor("role", { + header: () => ( + <> + {" "} + Role + + ), + cell: (info) => ( + + ), + }), + columnHelper.accessor("email", { + header: () => ( + <> + Email + + ), + cell: (info) => ( + + {info.getValue()} + + ), + }), + columnHelper.accessor("program", { + header: () => ( + <> + {" "} + Program + + ), + cell: (info) => ( + + ), + }), + ]; + + return data={data} setData={setData} columns={columns} />; +} diff --git a/compass/components/TagsInput/Index.tsx b/compass/components/TagsInput/Index.tsx index 19c2a77..f4b021e 100644 --- a/compass/components/TagsInput/Index.tsx +++ b/compass/components/TagsInput/Index.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef } from "react"; +import React, { useState, useRef, Dispatch, SetStateAction } from "react"; import "tailwindcss/tailwind.css"; import { TagsArray } from "./TagsArray"; import { TagDropdown } from "./TagDropdown"; @@ -7,8 +7,8 @@ import { CreateNewTagAction } from "./CreateNewTagAction"; interface TagsInputProps { presetOptions: string[]; presetValue: string | string[]; - setPresetOptions: () => {}; - getTagColor: () => {}; + setPresetOptions: Dispatch>; + getTagColor(tag: string): string; } const TagsInput: React.FC = ({ diff --git a/compass/components/TagsInput/TagsArray.tsx b/compass/components/TagsInput/TagsArray.tsx index c014e7c..845e739 100644 --- a/compass/components/TagsInput/TagsArray.tsx +++ b/compass/components/TagsInput/TagsArray.tsx @@ -7,7 +7,7 @@ export interface Tags { } export const TagsArray = ({ tags, handleDelete, active = false }: Tags) => { - console.log(tags); + // console.log(tags); return (
diff --git a/compass/components/TagsInput/TagsHandler.tsx b/compass/components/TagsInput/TagsHandler.tsx new file mode 100644 index 0000000..179e367 --- /dev/null +++ b/compass/components/TagsInput/TagsHandler.tsx @@ -0,0 +1,35 @@ +import { useState } from "react"; + +/** + * Custom hook used to handle the state of tag options and colors + * @param initialOptions Initial value for preset options + * @returns An object with three fields intended to be passed into a `TagsInput` component: + * - `presetOptions` - the current state of tag options + * - `setPresetOptions` - the state setter for presetOptions + * - `getTagColor` - function that retrieves the color for the given tag + */ +export default function useTagsHandler(initialOptions: string[]) { + const [presetOptions, setPresetOptions] = useState(initialOptions); + const [tagColors, setTagColors] = useState(new Map()); + + const getTagColor = (tag: string): string => { + if (!tagColors.has(tag)) { + const colors = [ + "bg-cyan-100", + "bg-blue-100", + "bg-green-100", + "bg-yellow-100", + "bg-purple-100", + ]; + const randomColor = + colors[Math.floor(Math.random() * colors.length)]; + setTagColors(new Map(tagColors).set(tag, randomColor)); + return randomColor; + } + // Since we populate any missing keys, .get will never return undefined, + // so we are safe to typecast to prevent a type error + return tagColors.get(tag) as string; + }; + + return { presetOptions, setPresetOptions, getTagColor }; +} diff --git a/compass/utils/models/DataPoint.ts b/compass/utils/models/DataPoint.ts new file mode 100644 index 0000000..4a6a321 --- /dev/null +++ b/compass/utils/models/DataPoint.ts @@ -0,0 +1,9 @@ +/** + * Represents metadata of the Resource, Service, and User models to be used in a table + */ +interface DataPoint { + id: number; + visible: boolean; +} + +export default DataPoint;