Merge branch 'main' into banish-ds_store

This commit is contained in:
Andy Chan 2024-10-22 00:32:17 -04:00 committed by GitHub
commit 51c40684fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
150 changed files with 15570 additions and 5461 deletions

View File

@ -37,6 +37,16 @@ RUN add-apt-repository ppa:deadsnakes/ppa \
&& unlink /usr/bin/python3 \
&& ln -s /usr/bin/python3.11 /usr/bin/python3
# Install Node.js 18 from https://github.com/nodesource
ENV NODE_MAJOR 18
RUN mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list \
&& apt-get update \
&& apt-get install nodejs -y \
&& npm install -g npm@latest \
&& rm -rf /var/lib/apt/lists/*
# Use a non-root user per https://code.visualstudio.com/remote/advancedcontainers/add-nonroot-user
ARG USERNAME=vscode
ARG USER_UID=1000
@ -66,7 +76,7 @@ RUN curl https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.
# Set Locale for Functional Autocompletion in zsh
RUN sudo locale-gen en_US.UTF-8
# Install Database Dependencies
# Install Backend Dependencies
COPY backend/requirements.txt /workspace/backend/requirements.txt
WORKDIR /workspace/backend
RUN python3 -m pip install -r requirements.txt

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
/backend/.env
__pycache__
.DS_Store
node_modules

4
.husky/pre-commit Normal file
View File

@ -0,0 +1,4 @@
cd compass
npm run lint
npm run prettier
git add .

7
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
"python.testing.pytestArgs": [
"backend"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
}

View File

@ -10,6 +10,14 @@
## 📁 File Setup
```
\backend
\api // Define API routes
\entities // Define entities in database
\models // How objects are represented in Python
\script // Scripts for init and demo
\services // Main business logic
\test // Testing suite
\compass
\components // Components organized in folders related to specific pages
\pages // Store all pages here
@ -21,24 +29,24 @@
## 🚀 To Start
Follow these steps to set up your local environment:
Follow these steps to set up your local environment (Dev Container):
```
\\ Clone this repository
git clone https://github.com/cssgunc/compass.git
\\ Go into main folder
\\ Create .env file for frontend
cd compass
\\ Install dependencies
npm install
\\ Run local environment
npm run dev
touch .env
\\ Create .env file for backend
cd ../backend
touch .env
```
Also add following variables inside of a .env file inside of the backend directory
**Backend .env** Contents:
```
\\ .env file contents
POSTGRES_DATABASE=compass
POSTGRES_USER=postgres
POSTGRES_PASSWORD=admin
@ -47,19 +55,50 @@ POSTGRES_PORT=5432
HOST=localhost
```
## Backend Starter
**Frontend (compass) .env** Contents:
- Please open the VS Code Command Palette
```
NEXT_PUBLIC_SUPABASE_URL=[ASK_TECH_LEAD]
NEXT_PUBLIC_SUPABASE_ANON_KEY=[ASK_TECH_LEAD]
NEXT_PUBLIC_API_HOST=http://localhost:8000
NEXT_PUBLIC_HOST=http://localhost:3000
```
## Dev Container Setup
- Please open the VS Code Command Palette (Mac - Cmd+Shift+P and Windows - Ctrl+Shift+P)
- Run the command **Dev Containers: Rebuild and Reopen in Container**
- This should open the dev container with the same file directory mounted so any changes in the dev container will be seen in the local repo
- The dev container is sucessfully opened once you can see file directory getting populated
### In Dev Container
### In Dev Container Setup
Open a new terminal and run this script in sequence to setup the dependencies and database
Run this to reset the database and populate it with the approprate tables that reflect the entities folder
```
python3 -m backend.script.reset_demo
./start.sh
```
## Starting up website and backend
Open a terminal and run these commands:
```
cd backend
fastapi dev main.py
```
Open another terminal and run these commands:
```
cd compass
npm run dev
```
1. Go to [localhost:3000/auth/login](localhost:3000/auth/login)
2. Login with username: root@compass.com, password: compass123
3. Explore website
### Possible Dev Container Errors
- Sometimes the ports allocated to our services will be allocated (5432 for Postgres and 5050 for PgAdmin4)

BIN
backend/.DS_Store vendored Normal file

Binary file not shown.

1
backend/api/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Expose API routes via FastAPI routers from this package."""

19
backend/api/health.py Normal file
View File

@ -0,0 +1,19 @@
"""Confirm system health via monitorable API end points.
Production systems monitor these end points upon deployment, and at regular intervals, to ensure the service is running.
"""
from fastapi import APIRouter, Depends
from ..services.health import HealthService
openapi_tags = {
"name": "System Health",
"description": "Production systems monitor these end points upon deployment, and at regular intervals, to ensure the service is running.",
}
api = APIRouter(prefix="/api/health")
@api.get("", tags=["System Health"])
def health_check(health_svc: HealthService = Depends()) -> str:
return health_svc.check()

26
backend/api/resource.py Normal file
View File

@ -0,0 +1,26 @@
from fastapi import APIRouter, Depends
from ..services import ResourceService, UserService
from ..models.resource_model import Resource
from typing import List
api = APIRouter(prefix="/api/resource")
openapi_tags = {
"name": "Resource",
"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.get("", response_model=List[Resource], tags=["Resource"])
def get_all(
user_id: str,
resource_svc: ResourceService = Depends(),
user_svc: UserService = Depends(),
):
subject = user_svc.get_user_by_uuid(user_id)
return resource_svc.get_resource_by_user(subject)

26
backend/api/service.py Normal file
View File

@ -0,0 +1,26 @@
from fastapi import APIRouter, Depends
from ..services import ServiceService, UserService
from ..models.service_model import Service
from typing import List
api = APIRouter(prefix="/api/service")
openapi_tags = {
"name": "Service",
"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.get("", response_model=List[Service], tags=["Service"])
def get_all(
user_id: str,
service_svc: ServiceService = Depends(),
user_svc: UserService = Depends(),
):
subject = user_svc.get_user_by_uuid(user_id)
return service_svc.get_service_by_user(subject)

30
backend/api/user.py Normal file
View File

@ -0,0 +1,30 @@
from fastapi import APIRouter, Depends
from ..services import UserService
from ..models.user_model import User, UserTypeEnum
from typing import List
api = APIRouter(prefix="/api/user")
openapi_tags = {
"name": "Users",
"description": "User profile 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.get("/all", response_model=List[User], tags=["Users"])
def get_all(user_id: str, user_svc: UserService = Depends()):
subject = user_svc.get_user_by_uuid(user_id)
if subject.role != UserTypeEnum.ADMIN:
raise Exception(f"Insufficient permissions for user {subject.uuid}")
return user_svc.all()
@api.get("/{user_id}", response_model=User, tags=["Users"])
def get_by_uuid(user_id: str, user_svc: UserService = Depends()):
return user_svc.get_user_by_uuid(user_id)

View File

@ -21,7 +21,6 @@ engine = sqlalchemy.create_engine(_engine_str(), echo=True)
def db_session():
"""Generator function offering dependency injection of SQLAlchemy Sessions."""
print("ran")
session = Session(engine)
try:
yield session

View File

@ -1,10 +1,9 @@
from .entity_base import EntityBase
from .sample_entity import SampleEntity
from .tag_entity import TagEntity
from .user_entity import UserEntity
from .resource_entity import ResourceEntity
from .resource_tag_entity import ResourceTagEntity
from .service_entity import ServiceEntity
from .service_tag_entity import ServiceTagEntity
from .program_enum import ProgramEnum
from .user_enum import RoleEnum
from .program_enum import Program_Enum
from .user_enum import Role_Enum

View File

@ -1,10 +1,7 @@
from sqlalchemy import Enum
from enum import Enum
class ProgramEnum(Enum):
ECONOMIC = "economic"
DOMESTIC = "domestic"
COMMUNITY = "community"
def __init__(self):
super().__init__(name="program_enum")
class Program_Enum(Enum):
ECONOMIC = "ECONOMIC"
DOMESTIC = "DOMESTIC"
COMMUNITY = "COMMUNITY"

View File

@ -1,7 +1,7 @@
""" Defines the table for storing resources """
# Import our mapped SQL types from SQLAlchemy
from sqlalchemy import Integer, String, DateTime
from sqlalchemy import Integer, String, DateTime, Enum
# Import mapping capabilities from the SQLAlchemy ORM
from sqlalchemy.orm import Mapped, mapped_column, relationship
@ -14,7 +14,8 @@ from datetime import datetime
# Import self for to model
from typing import Self
from backend.entities.program_enum import ProgramEnum
from backend.entities.program_enum import Program_Enum
from ..models.resource_model import Resource
class ResourceEntity(EntityBase):
@ -25,44 +26,42 @@ class ResourceEntity(EntityBase):
# set fields
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
name: Mapped[str] = mapped_column(String(32), nullable=False)
name: Mapped[str] = mapped_column(String(64), nullable=False)
summary: Mapped[str] = mapped_column(String(100), nullable=False)
link: Mapped[str] = mapped_column(String, nullable=False)
program: Mapped[ProgramEnum] = mapped_column(ProgramEnum, nullable=False)
program: Mapped[Program_Enum] = mapped_column(Enum(Program_Enum), nullable=False)
# relationships
resourceTags: Mapped[list["ResourceTagEntity"]] = relationship(
back_populates="resource", cascade="all,delete"
)
#
# @classmethod
# def from_model(cls, model: user_model) -> Self:
# """
# Create a UserEntity from a User model.
@classmethod
def from_model(cls, model: Resource) -> Self:
"""
Create a UserEntity from a User model.
# Args:
# model (User): The model to create the entity from.
Args:
model (User): The model to create the entity from.
# Returns:
# Self: The entity (not yet persisted).
# """
Returns:
Self: The entity (not yet persisted).
"""
# return cls (
# id = model.id,
# created_at = model.created_at,
# name = model.name,
# summary = model.summary,
# link = model.link,
# program = model.program,
# )
return cls(
id=model.id,
created_at=model.created_at,
name=model.name,
summary=model.summary,
link=model.link,
program=model.program,
)
# def to_model(self) -> user_model:
# return user_model (
# id = self.id,
# created_at = self.created_at,
# name = self.name,
# summary = self.summary,
# link = self.link,
# program = self.program,
# )
def to_model(self) -> Resource:
return Resource(
id=self.id,
created_at=self.created_at,
name=self.name,
summary=self.summary,
link=self.link,
program=self.program,
)

View File

@ -19,7 +19,7 @@ from typing import Self
class ResourceTagEntity(EntityBase):
# set table name to user in the database
__tablename__ = "resourceTag"
__tablename__ = "resource_tag"
# set fields or 'columns' for the user table
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)

View File

@ -1,12 +1,12 @@
from sqlalchemy import create_engine, Column, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .entity_base import EntityBase
class SampleEntity(EntityBase):
__tablename__ = "persons"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String, nullable=False)
age: Mapped[int] = mapped_column(Integer)
email: Mapped[str] = mapped_column(String, unique=True, nullable=False)
from sqlalchemy import create_engine, Column, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .entity_base import EntityBase
class SampleEntity(EntityBase):
__tablename__ = "persons"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String, nullable=False)
age: Mapped[int] = mapped_column(Integer)
email: Mapped[str] = mapped_column(String, unique=True, nullable=False)

View File

@ -16,14 +16,9 @@ from datetime import datetime
import enum
from sqlalchemy import Enum
class ProgramEnum(enum.Enum):
"""Determine program for Service"""
DOMESTIC = "DOMESTIC"
ECONOMIC = "ECONOMIC"
COMMUNITY = "COMMUNITY"
from backend.models.service_model import Service
from typing import Self
from backend.models.enum_for_models import ProgramTypeEnum
class ServiceEntity(EntityBase):
@ -34,11 +29,19 @@ class ServiceEntity(EntityBase):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
name: Mapped[str] = mapped_column(String(32), nullable=False)
status: Mapped[str] = mapped_column(String(32), nullable=False)
summary: Mapped[str] = mapped_column(String(100), nullable=False)
requirements: Mapped[list[str]] = mapped_column(ARRAY(String))
program: Mapped[ProgramEnum] = mapped_column(Enum(ProgramEnum), nullable=False)
program: Mapped[ProgramTypeEnum] = mapped_column(Enum(ProgramTypeEnum), nullable=False)
# relationships
serviceTags: Mapped[list["ServiceTagEntity"]] = relationship(
back_populates="service", cascade="all,delete"
)
def to_model(self) -> Service:
return Service(id=self.id, name=self.name, status=self.status, summary=self.summary, requirements=self.requirements, program=self.program)
@classmethod
def from_model(cls, model:Service) -> Self:
return cls(id=model.id, name=model.name, status=model.status, summary=model.summary, requirements=model.requirements, program=model.program)

View File

@ -13,7 +13,7 @@ from .entity_base import EntityBase
class ServiceTagEntity(EntityBase):
# set table name to user in the database
__tablename__ = "serviceTag"
__tablename__ = "service_tag"
# set fields or 'columns' for the user table
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
@ -21,5 +21,5 @@ class ServiceTagEntity(EntityBase):
tagId: Mapped[int] = mapped_column(ForeignKey("tag.id"))
# relationships
service: Mapped["ServiceEntity"] = relationship(back_populates="resourceTags")
tag: Mapped["TagEntity"] = relationship(back_populates="resourceTags")
service: Mapped["ServiceEntity"] = relationship(back_populates="serviceTags")
tag: Mapped["TagEntity"] = relationship(back_populates="serviceTags")

View File

@ -12,6 +12,10 @@ from .entity_base import EntityBase
# Import datetime for created_at type
from datetime import datetime
from ..models.tag_model import Tag
from typing import Self
class TagEntity(EntityBase):
#set table name
@ -27,17 +31,17 @@ class TagEntity(EntityBase):
serviceTags: Mapped[list["ServiceTagEntity"]] = relationship(back_populates="tag", cascade="all,delete")
"""
@classmethod
def from_model(cls, model: Tag) -> Self:
"""
Create a user entity from model
Args: model (User): the model to create the entity from
Returns:
self: The entity
"""
return cls(
id=model.id,
@ -45,18 +49,17 @@ class TagEntity(EntityBase):
)
def to_model(self) -> Tag:
"""
Create a user model from entity
Returns:
User: A User model for API usage
"""
return Tag(
id=self.id,
content=self.id,
content=self.content,
)
"""

View File

@ -1,24 +1,24 @@
""" Defines the table for storing users """
# Import our mapped SQL types from SQLAlchemy
from sqlalchemy import Integer, String, DateTime, ARRAY
from sqlalchemy import Integer, String, DateTime, ARRAY, Enum
# Import mapping capabilities from the SQLAlchemy ORM
from sqlalchemy.orm import Mapped, mapped_column
# Import the EntityBase that we are extending
from .entity_base import EntityBase
# Import datetime for created_at type
from datetime import datetime
# Import enums for Role and Program
from backend.entities.program_enum import ProgramEnum
from .user_enum import RoleEnum
from backend.models.enum_for_models import UserTypeEnum, ProgramTypeEnum
# Import models for User methods
from ..models.user_model import User
from typing import Self
class UserEntity(EntityBase):
@ -33,58 +33,56 @@ class UserEntity(EntityBase):
username: Mapped[str] = mapped_column(
String(32), nullable=False, default="", unique=True
)
role: Mapped[RoleEnum] = mapped_column(RoleEnum, nullable=False)
username: Mapped[str] = mapped_column(
String(32), nullable=False, default="", unique=True
)
role: Mapped[RoleEnum] = mapped_column(RoleEnum, nullable=False)
role: Mapped[UserTypeEnum] = mapped_column(Enum(UserTypeEnum), nullable=False)
email: Mapped[str] = mapped_column(String(50), nullable=False, unique=True)
program: Mapped[list[ProgramEnum]] = mapped_column(
ARRAY(ProgramEnum), nullable=False
)
program: Mapped[list[ProgramEnum]] = mapped_column(
ARRAY(ProgramEnum), nullable=False
program: Mapped[list[ProgramTypeEnum]] = mapped_column(
ARRAY(Enum(ProgramTypeEnum)), nullable=False
)
experience: Mapped[int] = mapped_column(Integer, nullable=False)
group: Mapped[str] = mapped_column(String(50))
uuid: Mapped[str] = mapped_column(String, nullable=True)
"""
@classmethod
def from_model(cls, model: User) -> Self:
"""
Create a user entity from model
Args: model (User): the model to create the entity from
Returns:
self: The entity
"""
return cls(
id=model.id,
created_at=model.created_at,
username=model.username,
role=model.role,
email=model.email,
program=model.program,
experience=model.experience,
group=model.group,
uuid=model.uuid,
)
def to_model(self) -> User:
"""
Create a user model from entity
Returns:
User: A User model for API usage
"""
return User(
id=self.id,
username=self.id,
role=self.role,
username=self.username,
email=self.email,
program=self.program,
experience=self.experience,
group=self.group,
program=self.program,
role=self.role,
created_at=self.created_at,
uuid=self.uuid,
)
"""

View File

@ -1,12 +1,9 @@
from sqlalchemy import Enum
from enum import Enum
class RoleEnum(Enum):
class Role_Enum(Enum):
"""Determine role for User"""
ADMIN = "ADMIN"
EMPLOYEE = "EMPLOYEE"
VOLUNTEER = "VOLUNTEER"
def __init__(self):
super().__init__(name="role_enum")

View File

@ -0,0 +1,34 @@
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.middleware.gzip import GZipMiddleware
from .api import user, health, service, resource
description = """
Welcome to the **COMPASS** RESTful Application Programming Interface.
"""
app = FastAPI(
title="Compass API",
version="0.0.1",
description=description,
openapi_tags=[
user.openapi_tags,
health.openapi_tags,
service.openapi_tags,
resource.openapi_tags,
],
)
app.add_middleware(GZipMiddleware)
feature_apis = [user, health, service, resource]
for feature_api in feature_apis:
app.include_router(feature_api.api)
# Add application-wide exception handling middleware for commonly encountered API Exceptions
@app.exception_handler(Exception)
def permission_exception_handler(request: Request, e: Exception):
return JSONResponse(status_code=403, content={"message": str(e)})

View File

@ -1,8 +1,4 @@
from pydantic import BaseModel, Field
from enum import Enum
from typing import List
from datetime import datetime
from typing import Optional
class ProgramTypeEnum(str, Enum):

View File

@ -4,7 +4,6 @@ from typing import List
from datetime import datetime
from typing import Optional
from .enum_for_models import ProgramTypeEnum
from .resource_model import Resource
class Resource(BaseModel):
@ -12,5 +11,5 @@ class Resource(BaseModel):
name: str = Field(..., max_length=150, description="The name of the resource")
summary: str = Field(..., max_length=300, description="The summary of the resource")
link: str = Field(..., max_length=150, description="link to the resource")
programtype: ProgramTypeEnum
program: ProgramTypeEnum
created_at: Optional[datetime]

View File

@ -12,6 +12,7 @@ class User(BaseModel):
email: str = Field(..., description="The e-mail of the user")
experience: int = Field(..., description="Years of Experience of the User")
group: str
programtype: List[ProgramTypeEnum]
usertype: UserTypeEnum
program: List[ProgramTypeEnum]
role: UserTypeEnum
created_at: Optional[datetime]
uuid: str | None = None

View File

@ -1,4 +1,5 @@
fastapi[all] >=0.100.0, <0.101.0
fastapi[standard] >=0.100.0, <0.101.0
fastapi-cli
sqlalchemy >=2.0.4, <2.1.0
psycopg2 >=2.9.5, <2.10.0
alembic >=1.10.2, <1.11.0

View File

@ -6,9 +6,7 @@ engine = create_engine(_engine_str(database=""), echo=True)
"""Application-level SQLAlchemy database engine."""
with engine.connect() as connection:
connection.execute(
text("COMMIT")
)
connection.execute(text("COMMIT"))
database = getenv("POSTGRES_DATABASE")
stmt = text(f"CREATE DATABASE {database}")
connection.execute(stmt)
connection.execute(stmt)

View File

@ -6,9 +6,7 @@ engine = create_engine(_engine_str(database=""), echo=True)
"""Application-level SQLAlchemy database engine."""
with engine.connect() as connection:
connection.execute(
text("COMMIT")
)
connection.execute(text("COMMIT"))
database = getenv("POSTGRES_DATABASE")
stmt = text(f"DROP DATABASE IF EXISTS {database}")
connection.execute(stmt)
connection.execute(stmt)

View File

@ -1,10 +1,13 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
import subprocess
from ..database import engine, _engine_str
from ..env import getenv
from .. import entities
from ..test.services import user_test_data, service_test_data, resource_test_data
database = getenv("POSTGRES_DATABASE")
engine = create_engine(_engine_str(), echo=True)
@ -16,3 +19,9 @@ subprocess.run(["python3", "-m", "backend.script.create_database"])
entities.EntityBase.metadata.drop_all(engine)
entities.EntityBase.metadata.create_all(engine)
with Session(engine) as session:
user_test_data.insert_test_data(session)
service_test_data.insert_test_data(session)
resource_test_data.insert_test_data(session)
session.commit()

View File

@ -0,0 +1,4 @@
from .user import UserService
from .resource import ResourceService
from .tag import TagService
from .service import ServiceService

View File

@ -0,0 +1,25 @@
"""
This file contains exceptions found in the service layer.
These custom exceptions can then be handled peoperly
at the API level.
"""
class ResourceNotFoundException(Exception):
"""ResourceNotFoundException is raised when a user attempts to access a resource that does not exist."""
class UserPermissionException(Exception):
"""UserPermissionException is raised when a user attempts to perform an action they are not authorized to perform."""
def __init__(self, action: str, resource: str):
super().__init__(f"Not authorized to perform `{action}` on `{resource}`")
class ServiceNotFoundException(Exception):
"""Exception for when the service being requested is not in the table."""
class ProgramNotAssignedException(Exception):
"""Exception for when the user does not have correct access for requested services."""

View File

@ -0,0 +1,27 @@
"""
Verify connectivity to the database from the service layer for health check purposes.
The production system will regularly check the health of running containers via accessing an API endpoint.
The API endpoint is backed by this service which executes a simple statement against our backing database.
In more complex deployments, where multiple backing services may be depended upon, the health check process
would necessarily also become more complex to reflect the health of all subsystems.
In this context health does not refer to correctness as much as running, connected, and responsive.
"""
from fastapi import Depends
from sqlalchemy import text
from ..database import Session, db_session
class HealthService:
_session: Session
def __init__(self, session: Session = Depends(db_session)):
self._session = session
def check(self):
stmt = text("SELECT 'OK', NOW()")
result = self._session.execute(stmt)
row = result.all()[0]
return str(f"{row[0]} @ {row[1]}")

View File

@ -0,0 +1,37 @@
from fastapi import Depends
from ..database import db_session
from sqlalchemy.orm import Session
from ..models.user_model import User
from ..entities.user_entity import UserEntity
from exceptions import ResourceNotFoundException, UserPermissionException
from ..models.enum_for_models import UserTypeEnum
class PermissionsService:
def __init__(self, session: Session = Depends(db_session)):
self._session = session
def get_role_permissions(self, user: User) -> str:
"""
Gets a str group based on the user
Returns:
str
"""
# Query the resource table with id
obj = (
self._session.query(UserEntity)
.filter(UserEntity.id == user.id)
.one_or_none()
)
# Check if result is null
if obj is None:
raise ResourceNotFoundException(
f"No user permissions found for user with id: {user.id}"
)
return obj.role

View File

@ -1,9 +0,0 @@
from fastapi import Depends
from ..database import db_session
from sqlalchemy.orm import Session
class ResourceService:
def __init__(self, session: Session = Depends(db_session)):
self._session = session

View File

@ -0,0 +1,165 @@
from fastapi import Depends
from ..database import db_session
from sqlalchemy.orm import Session
from sqlalchemy import select
from ..models.resource_model import Resource
from ..entities.resource_entity import ResourceEntity
from ..models.user_model import User, UserTypeEnum
from .exceptions import ResourceNotFoundException
class ResourceService:
def __init__(self, session: Session = Depends(db_session)):
self._session = session
def get_resource_by_user(self, subject: User):
"""Resource method getting all of the resources that a user has access to based on role"""
if subject.role != UserTypeEnum.VOLUNTEER:
query = select(ResourceEntity)
entities = self._session.scalars(query).all()
return [resource.to_model() for resource in entities]
else:
programs = subject.program
resources = []
for program in programs:
query = select(ResourceEntity).filter(ResourceEntity.program == program)
entities = self._session.scalars(query).all()
for entity in entities:
resources.append(entity)
return [resource.to_model() for resource in resources]
def create(self, user: 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
"""
if resource.role != user.role or resource.group != user.group:
raise PermissionError(
"User does not have permission to add resources in this role or group."
)
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, user: User, resource: ResourceEntity) -> 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 != user.role or resource.group != user.group:
raise PermissionError(
"User does not have permission to update this resource."
)
obj = self._session.get(ResourceEntity, resource.id) if resource.id else None
if obj is None:
raise ResourceNotFoundException(
f"No resource found with matching id: {resource.id}"
)
obj.update_from_model(resource) # Assuming an update method exists
self._session.commit()
return obj.to_model()
def delete(self, user: User, id: int) -> 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
"""
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 matching id: {id}")
self._session.delete(resource)
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,
)
entities = self._session.scalars(query).all()
return [entity.to_model() for entity in entities]

View File

@ -1,9 +1,127 @@
from fastapi import Depends
from ..database import db_session
from sqlalchemy.orm import Session
from sqlalchemy import func, select, and_, func, or_, exists, or_
from backend.models.service_model import Service
from backend.models.user_model import User
from backend.entities.service_entity import ServiceEntity
from backend.models.enum_for_models import ProgramTypeEnum, UserTypeEnum
from backend.services.exceptions import (
ServiceNotFoundException,
ProgramNotAssignedException,
)
class ServiceService:
def __init__(self, session: Session = Depends(db_session)):
self._session = session
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:
"""Service method getting services by id."""
query = select(ServiceEntity).filter(ServiceEntity.id == id)
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")
return entity.to_model()
def get_service_by_user(self, subject: User):
"""Service method getting all of the services that a user has access to based on role"""
if subject.role != UserTypeEnum.VOLUNTEER:
query = select(ServiceEntity)
entities = self._session.scalars(query).all()
return [service.to_model() for service in entities]
else:
programs = subject.program
services = []
for program in programs:
query = select(ServiceEntity).filter(ServiceEntity.program == program)
entities = self._session.scalars(query).all()
for entity in entities:
services.append(entity)
return [service.to_model() for service in services]
def get_all(self, subject: User) -> list[Service]:
"""Service method retrieving all of the services in the table."""
if subject.role == UserTypeEnum.VOLUNTEER:
raise ProgramNotAssignedException(
f"User is not {UserTypeEnum.ADMIN} or {UserTypeEnum.VOLUNTEER}, cannot get all"
)
query = select(ServiceEntity)
entities = self._session.scalars(query).all()
return [service.to_model() for service in entities]
def create(self, subject: User, service: Service) -> Service:
"""Creates/adds a service to the table."""
if subject.role != UserTypeEnum.ADMIN:
raise ProgramNotAssignedException(
f"User is not {UserTypeEnum.ADMIN}, cannot create service"
)
service_entity = ServiceEntity.from_model(service)
self._session.add(service_entity)
self._session.commit()
return service_entity.to_model()
def update(self, subject: User, service: Service) -> Service:
"""Updates a service if in the table."""
if subject.role != UserTypeEnum.ADMIN:
raise ProgramNotAssignedException(
f"User is not {UserTypeEnum.ADMIN}, cannot update service"
)
service_entity = self._session.get(ServiceEntity, service.id)
if service_entity is None:
raise ServiceNotFoundException(
"The service you are searching for does not exist."
)
service_entity.name = service.name
service_entity.status = service.status
service_entity.summary = service.summary
service_entity.requirements = service.requirements
service_entity.program = service.program
self._session.commit()
return service_entity.to_model()
def delete(self, subject: User, service: Service) -> None:
"""Deletes a service from the table."""
if subject.role != UserTypeEnum.ADMIN:
raise ProgramNotAssignedException(f"User is not {UserTypeEnum.ADMIN}")
service_entity = self._session.get(ServiceEntity, service.id)
if service_entity is None:
raise ServiceNotFoundException(
"The service you are searching for does not exist."
)
self._session.delete(service_entity)
self._session.commit()

View File

@ -1,9 +1,20 @@
from fastapi import Depends
from ..database import db_session
from sqlalchemy.orm import Session
from ..models.tag_model import Tag
from ..entities.tag_entity import TagEntity
from sqlalchemy import select
class TagService:
def __init__(self, session: Session = Depends(db_session)):
self._session = session
def all(self) -> list[Tag]:
"""Returns a list of all Tags"""
query = select(TagEntity)
entities = self._session.scalars(query).all()
return [entity.to_model() for entity in entities]

View File

@ -1,9 +1,119 @@
from fastapi import Depends
from ..database import db_session
from sqlalchemy.orm import Session
from ..entities.user_entity import UserEntity
from ..models.user_model import User
from sqlalchemy import select
class UserService:
def __init__(self, session: Session = Depends(db_session)):
self._session = session
def get_user_by_id(self, id: int) -> User:
"""
Gets a user by id from the database
Returns: A User Pydantic model
"""
query = select(UserEntity).where(UserEntity.id == id)
user_entity: UserEntity | None = self._session.scalar(query)
if user_entity is None:
raise Exception(f"No user found with matching id: {id}")
return user_entity.to_model()
def get_user_by_uuid(self, uuid: str) -> User:
"""
Gets a user by uuid from the database
Returns: A User Pydantic model
"""
query = select(UserEntity).where(UserEntity.uuid == uuid)
user_entity: UserEntity | None = self._session.scalar(query)
if user_entity is None:
raise Exception(f"No user found with matching uuid: {uuid}")
return user_entity.to_model()
def all(self) -> list[User]:
"""
Returns a list of all Users
"""
query = select(UserEntity)
entities = self._session.scalars(query).all()
return [entity.to_model() for entity in entities]
def create(self, user: User) -> User:
"""
Creates a new User Entity and adds to database
Args: User model
Returns: User model
"""
try:
if (user.id != None):
user = self.get_user_by_id(user.id)
except:
# if does not exist, create new object
user_entity = UserEntity.from_model(user)
# add new user to table
self._session.add(user_entity)
self._session.commit()
finally:
# return added object
return user
def delete(self, user: User) -> None:
"""
Delete a user
Args: the user to delete
Returns: none
"""
obj = self._session.get(UserEntity, user.id)
if obj is None:
raise Exception(f"No matching user found")
self._session.delete(obj)
self._session.commit()
def update(self, user: User) -> User:
"""
Updates a user
Args: User to be updated
Returns: The updated User
"""
obj = self._session.get(UserEntity, user.id)
if obj is None:
raise Exception(f"No matching user found")
obj.username = user.username
obj.role = user.role
obj.email = user.email
obj.program = user.program
obj.experience = user.experience
obj.group = user.group
self._session.commit()
return obj.to_model()

View File

@ -4,14 +4,16 @@ 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 ...database import _engine_str
from ...env import getenv
from ... import entities
from ..database import _engine_str
from ..env import getenv
from .. import entities
POSTGRES_DATABASE = f'{getenv("POSTGRES_DATABASE")}_test'
POSTGRES_USER = getenv("POSTGRES_USER")
def reset_database():
engine = create_engine(_engine_str(database=""))
with engine.connect() as connection:
@ -48,3 +50,12 @@ def session(test_engine: Engine):
yield session
finally:
session.close()
@pytest.fixture(autouse=True)
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)
session.commit()
yield

View File

@ -1,21 +0,0 @@
"""Sample Test File"""
from sqlalchemy import Engine, select
from ... import entities
from ...entities.sample_entity import SampleEntity
def test_entity_count():
"""Checks the number of entities to be inserted"""
print(entities.EntityBase.metadata.tables.keys())
assert len(entities.EntityBase.metadata.tables.keys()) == 7
def test_add_sample_data(session: Engine):
"""Inserts a sample data point and verifies it is in the database"""
entity = SampleEntity(name="Praj", age=19, email="pmoha@unc.edu")
session.add(entity)
session.commit()
data = session.get(SampleEntity, 1)
assert data.name == "Praj"

View File

@ -1,19 +1,4 @@
""" Testing Tag Entity """
from sqlalchemy import Engine
from ... import entities
from ...entities.tag_entity import TagEntity
def test_add_sample_data_tag(session: Engine):
"""Inserts a sample data point and verifies it is in the database"""
entity = TagEntity(content="Test tag")
session.add(entity)
session.commit()
data = session.get(TagEntity, 1)
assert data.id == 1
assert data.content == "Test tag"

View File

@ -1,24 +0,0 @@
""" Testing User Entity """
from sqlalchemy import Engine
from ... import entities
from ...entities.user_entity import UserEntity
from ...entities.user_entity import RoleEnum
from ...entities.user_entity import ProgramEnum
def test_add_sample_data_user(session: Engine):
"""Inserts a sample data point and verifies it is in the database"""
entity = UserEntity(id=1, username="emmalynf", role=RoleEnum.ADMIN, email="efoster@unc.edu", program=[ProgramEnum.COMMUNITY, ProgramEnum.DOMESTIC, ProgramEnum.ECONOMIC], experience=10, group="group")
session.add(entity)
session.commit()
data = session.get(UserEntity, 1)
assert data.id == 1
assert data.username == "emmalynf"
assert data.email == "efoster@unc.edu"
assert data.experience == 10
assert data.role == RoleEnum.ADMIN
assert data.program == [ProgramEnum.COMMUNITY, ProgramEnum.DOMESTIC, ProgramEnum.ECONOMIC]

View File

View File

@ -0,0 +1,26 @@
"""Fixtures used for testing the core services."""
import pytest
from unittest.mock import create_autospec
from sqlalchemy.orm import Session
from ...services import UserService
from ...services import TagService
from ...services import ServiceService
@pytest.fixture()
def user_svc(session: Session):
"""This fixture is used to test the UserService class"""
return UserService(session)
@pytest.fixture()
def tag_svc(session: Session):
"""This fixture is used to test the TagService class"""
return TagService(session)
@pytest.fixture()
def service_svc(session: Session):
"""This fixture is used to test the ServiceService class"""
return ServiceService(session)

View File

@ -0,0 +1,315 @@
from sqlalchemy.orm import Session
from datetime import datetime
from ...entities import ResourceEntity
from ...models.enum_for_models import ProgramTypeEnum
from ...models.resource_model import Resource
resource1 = Resource(
id=1,
name="Resource 1",
summary="Helpful information for victims of domestic violence",
link="https://example.com/resource1",
program=ProgramTypeEnum.DOMESTIC,
created_at=datetime(2023, 6, 1, 10, 0, 0),
)
resource2 = Resource(
id=2,
name="Resource 2",
summary="Legal assistance resources",
link="https://example.com/resource2",
program=ProgramTypeEnum.COMMUNITY,
created_at=datetime(2023, 6, 2, 12, 30, 0),
)
resource3 = Resource(
id=3,
name="Resource 3",
summary="Financial aid resources",
link="https://example.com/resource3",
program=ProgramTypeEnum.ECONOMIC,
created_at=datetime(2023, 6, 3, 15, 45, 0),
)
resource4 = Resource(
id=4,
name="Resource 4",
summary="Counseling and support groups",
link="https://example.com/resource4",
program=ProgramTypeEnum.DOMESTIC,
created_at=datetime(2023, 6, 4, 9, 15, 0),
)
resource5 = Resource(
id=5,
name="Resource 5",
summary="Shelter and housing resources",
link="https://example.com/resource5",
program=ProgramTypeEnum.DOMESTIC,
created_at=datetime(2023, 6, 5, 11, 30, 0),
)
resources = [resource1, resource2, resource3, resource4, resource5]
resource_1 = Resource(
id=1,
name="National Domestic Violence Hotline",
summary="24/7 confidential support for victims of domestic violence",
link="https://www.thehotline.org",
program=ProgramTypeEnum.DOMESTIC,
created_at=datetime(2023, 6, 1, 10, 0, 0),
)
resource_2 = Resource(
id=2,
name="Legal Aid Society",
summary="Free legal assistance for low-income individuals",
link="https://www.legalaidnyc.org",
program=ProgramTypeEnum.COMMUNITY,
created_at=datetime(2023, 6, 2, 12, 30, 0),
)
resource_3 = Resource(
id=3,
name="Financial Empowerment Center",
summary="Free financial counseling and education services",
link="https://www1.nyc.gov/site/dca/consumers/get-free-financial-counseling.page",
program=ProgramTypeEnum.ECONOMIC,
created_at=datetime(2023, 6, 3, 15, 45, 0),
)
resource_4 = Resource(
id=4,
name="National Coalition Against Domestic Violence",
summary="Resources and support for victims of domestic violence",
link="https://ncadv.org",
program=ProgramTypeEnum.DOMESTIC,
created_at=datetime(2023, 6, 4, 9, 15, 0),
)
resource_5 = Resource(
id=5,
name="Safe Horizon",
summary="Shelter and support services for victims of violence",
link="https://www.safehorizon.org",
program=ProgramTypeEnum.DOMESTIC,
created_at=datetime(2023, 6, 5, 11, 30, 0),
)
resource_6 = Resource(
id=6,
name="National Sexual Assault Hotline",
summary="24/7 confidential support for survivors of sexual assault",
link="https://www.rainn.org",
program=ProgramTypeEnum.COMMUNITY,
created_at=datetime(2023, 6, 6, 14, 0, 0),
)
resource_7 = Resource(
id=7,
name="Victim Compensation Fund",
summary="Financial assistance for victims of crime",
link="https://ovc.ojp.gov/program/victim-compensation",
program=ProgramTypeEnum.ECONOMIC,
created_at=datetime(2023, 6, 7, 16, 45, 0),
)
resource_8 = Resource(
id=8,
name="Battered Women's Justice Project",
summary="Legal and technical assistance for victims of domestic violence",
link="https://www.bwjp.org",
program=ProgramTypeEnum.DOMESTIC,
created_at=datetime(2023, 6, 8, 10, 30, 0),
)
resource_9 = Resource(
id=9,
name="National Network to End Domestic Violence",
summary="Advocacy and resources for ending domestic violence",
link="https://nnedv.org",
program=ProgramTypeEnum.COMMUNITY,
created_at=datetime(2023, 6, 9, 13, 0, 0),
)
resource_10 = Resource(
id=10,
name="Economic Justice Project",
summary="Promoting economic security for survivors of domestic violence",
link="https://www.njep.org",
program=ProgramTypeEnum.ECONOMIC,
created_at=datetime(2023, 6, 10, 15, 15, 0),
)
resource_11 = Resource(
id=11,
name="Domestic Violence Legal Hotline",
summary="Free legal advice for victims of domestic violence",
link="https://www.womenslaw.org/find-help/national/hotlines",
program=ProgramTypeEnum.DOMESTIC,
created_at=datetime(2023, 6, 11, 9, 0, 0),
)
resource_12 = Resource(
id=12,
name="National Resource Center on Domestic Violence",
summary="Comprehensive information and resources on domestic violence",
link="https://nrcdv.org",
program=ProgramTypeEnum.COMMUNITY,
created_at=datetime(2023, 6, 12, 11, 30, 0),
)
resource_13 = Resource(
id=13,
name="Financial Assistance for Victims of Crime",
summary="Funding for expenses related to victimization",
link="https://ovc.ojp.gov/program/victim-assistance-funding",
program=ProgramTypeEnum.ECONOMIC,
created_at=datetime(2023, 6, 13, 14, 45, 0),
)
resource_14 = Resource(
id=14,
name="National Clearinghouse for the Defense of Battered Women",
summary="Legal resources and support for battered women",
link="https://www.ncdbw.org",
program=ProgramTypeEnum.DOMESTIC,
created_at=datetime(2023, 6, 14, 10, 0, 0),
)
resource_15 = Resource(
id=15,
name="Victim Connect Resource Center",
summary="Referral helpline for crime victims",
link="https://victimconnect.org",
program=ProgramTypeEnum.COMMUNITY,
created_at=datetime(2023, 6, 15, 13, 15, 0),
)
resource_16 = Resource(
id=16,
name="Economic Empowerment Program",
summary="Financial literacy and job readiness training for survivors",
link="https://www.purplepurse.com",
program=ProgramTypeEnum.ECONOMIC,
created_at=datetime(2023, 6, 16, 16, 30, 0),
)
resource_17 = Resource(
id=17,
name="National Domestic Violence Law Project",
summary="Legal information and resources for domestic violence survivors",
link="https://www.womenslaw.org",
program=ProgramTypeEnum.DOMESTIC,
created_at=datetime(2023, 6, 17, 9, 45, 0),
)
resource_18 = Resource(
id=18,
name="Victim Rights Law Center",
summary="Free legal services for victims of sexual assault",
link="https://victimrights.org",
program=ProgramTypeEnum.COMMUNITY,
created_at=datetime(2023, 6, 18, 12, 0, 0),
)
resource_19 = Resource(
id=19,
name="Financial Justice Project",
summary="Advocating for economic justice for survivors of violence",
link="https://www.financialjusticeproject.org",
program=ProgramTypeEnum.ECONOMIC,
created_at=datetime(2023, 6, 19, 15, 30, 0),
)
resource_20 = Resource(
id=20,
name="National Center on Domestic and Sexual Violence",
summary="Training and resources to end domestic and sexual violence",
link="http://www.ncdsv.org",
program=ProgramTypeEnum.DOMESTIC,
created_at=datetime(2023, 6, 20, 10, 15, 0),
)
resources1 = [
resource_1,
resource_2,
resource_3,
resource_4,
resource_5,
resource_6,
resource_7,
resource_8,
resource_9,
resource_10,
resource_11,
resource_12,
resource_13,
resource_14,
resource_15,
resource_16,
resource_17,
resource_18,
resource_19,
resource_20,
]
from sqlalchemy import text
from sqlalchemy.orm import Session, DeclarativeBase, InstrumentedAttribute
def reset_table_id_seq(
session: Session,
entity: type[DeclarativeBase],
entity_id_column: InstrumentedAttribute[int],
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__
id_column_name = entity_id_column.name
sql = text(f"ALTER SEQUENCe {table}_{id_column_name}_seq RESTART WITH {next_id}")
session.execute(sql)
def insert_test_data(session: Session):
"""Inserts fake resource data into the test session."""
global resources1
# Create entities for test resource data
entities = []
for resource in resources1:
entity = ResourceEntity.from_model(resource)
session.add(entity)
entities.append(entity)
# Reset table IDs to prevent ID conflicts
reset_table_id_seq(session, ResourceEntity, ResourceEntity.id, len(resources1) + 1)
# Commit all changes
session.commit()
def insert_fake_data(session: Session):
"""Inserts fake resource data into the test session."""
global resources
# Create entities for test resource data
entities = []
for resource in resources:
entity = ResourceEntity.from_model(resource)
session.add(entity)
entities.append(entity)
# Reset table IDs to prevent ID conflicts
reset_table_id_seq(session, ResourceEntity, ResourceEntity.id, len(resources) + 1)
# Commit all changes
session.commit()

View File

@ -0,0 +1,78 @@
from backend.models.user_model import User
from backend.entities.service_entity import ServiceEntity
from ...models.enum_for_models import ProgramTypeEnum
from backend.services.service import ServiceService
from backend.services.exceptions import ServiceNotFoundException
from . import service_test_data
from . import user_test_data
from .fixtures import service_svc, user_svc
from backend.models.service_model import Service
import pytest
def test_list(service_svc: ServiceService):
service = service_svc.get_all(user_test_data.admin)
assert len(service) == len(service_test_data.services)
assert isinstance(service[0], Service)
def test_get_by_name(service_svc: ServiceService):
service = service_svc.get_service_by_name("service 1")
assert service.name == service_test_data.service1.name
assert isinstance(service, Service)
def test_get_by_name_not_found(service_svc: ServiceService):
with pytest.raises(ServiceNotFoundException):
service = service_svc.get_service_by_name("service 12")
pytest.fail()
def test_get_service_by_user_admin(service_svc: ServiceService):
service = service_svc.get_service_by_user(user_test_data.admin)
assert len(service) == len(service_test_data.services)
def test_get_service_by_user_volun(service_svc: ServiceService):
service = service_svc.get_service_by_user(user_test_data.volunteer)
assert len(service) == 4
def test_get_by_program(service_svc: ServiceService):
services = service_svc.get_service_by_program(ProgramTypeEnum.COMMUNITY)
for service in services:
assert service.program == ProgramTypeEnum.COMMUNITY
assert isinstance(service, Service)
def test_create(service_svc: ServiceService):
service = service_svc.create(user_test_data.admin, service_test_data.service7)
assert service.name == service_test_data.service7.name
assert isinstance(service, Service)
def test_update(service_svc: ServiceService):
service = service_svc.update(user_test_data.admin, service_test_data.service_6_edit)
assert service.status == service_test_data.service_6_edit.status
assert service.requirements == service_test_data.service_6_edit.requirements
assert isinstance(service, Service)
def test_update_not_found(service_svc: ServiceService):
with pytest.raises(ServiceNotFoundException):
service = service_svc.update(
user_test_data.admin, service_test_data.new_service
)
pytest.fail()
def test_delete(service_svc: ServiceService):
service_svc.delete(user_test_data.admin, service_test_data.service_6)
services = service_svc.get_all(user_test_data.admin)
assert len(services) == len(service_test_data.services) - 1
"""def test_delete_not_found(service_svc: ServiceService):
with pytest.raises(ServiceNotFoundException):
service_svc.delete(user_test_data.admin, service_test_data.service_10)
pytest.fail()"""

View File

@ -0,0 +1,353 @@
import pytest
from sqlalchemy.orm import Session
from ...entities import ServiceEntity
from ...models.enum_for_models import ProgramTypeEnum
from ...models.service_model import Service
service1 = Service(
id=1,
name="service 1",
status="open",
summary="presentation educating community on domestic violence",
requirements=[""],
program=ProgramTypeEnum.COMMUNITY,
)
service2 = Service(
id=2,
name="service 2",
status="closed",
summary="service finding safe places to stay",
requirements=[""],
program=ProgramTypeEnum.DOMESTIC,
)
service3 = Service(
id=3,
name="service 3",
status="open",
summary="",
requirements=[""],
program=ProgramTypeEnum.DOMESTIC,
)
service4 = Service(
id=4,
name="service 4",
status="waitlist",
summary="community event",
requirements=[""],
program=ProgramTypeEnum.COMMUNITY,
)
service5 = Service(
id=5,
name="service 5",
status="open",
summary="talk circle for victims of domestic violence",
requirements=["18+"],
program=ProgramTypeEnum.COMMUNITY,
)
service6 = Service(
id=6,
name="service 6",
status="waitlist",
summary="program offering economic assistance",
requirements=[""],
program=ProgramTypeEnum.ECONOMIC,
)
service_6_edit = Service(
id=6,
name="service 6",
status="open",
summary="program offering economic assistance",
requirements=["18+"],
program=ProgramTypeEnum.ECONOMIC,
)
service7 = Service(
id=7,
name="service 7",
status="waitlist",
summary="insert generic description",
requirements=[""],
program=ProgramTypeEnum.ECONOMIC,
)
new_service = Service(
id=8,
name="new service",
status="open",
summary="insert other generic description",
requirements=[""],
program=ProgramTypeEnum.DOMESTIC,
)
services = [service1, service2, service3, service4, service5, service6]
service_1 = Service(
id=1,
name="Crisis Hotline",
status="open",
summary="24/7 support for individuals in crisis",
requirements=["Anonymous", "Confidential"],
program=ProgramTypeEnum.DOMESTIC,
)
service_2 = Service(
id=2,
name="Shelter Placement",
status="open",
summary="Emergency shelter for victims of domestic violence",
requirements=["Referral required", "Safety assessment"],
program=ProgramTypeEnum.DOMESTIC,
)
service_3 = Service(
id=3,
name="Legal Advocacy",
status="waitlist",
summary="Legal support and representation for survivors",
requirements=["Intake required", "Income eligibility"],
program=ProgramTypeEnum.COMMUNITY,
)
service_4 = Service(
id=4,
name="Counseling Services",
status="open",
summary="Individual and group therapy for survivors",
requirements=["Initial assessment", "Insurance accepted"],
program=ProgramTypeEnum.DOMESTIC,
)
service_5 = Service(
id=5,
name="Financial Assistance",
status="open",
summary="Emergency funds for survivors in need",
requirements=["Application required", "Proof of income"],
program=ProgramTypeEnum.ECONOMIC,
)
service_6 = Service(
id=6,
name="Housing Assistance",
status="waitlist",
summary="Support for finding safe and affordable housing",
requirements=["Referral required", "Background check"],
program=ProgramTypeEnum.ECONOMIC,
)
service_7 = Service(
id=7,
name="Job Training",
status="open",
summary="Employment skills training for survivors",
requirements=["Enrollment required", "18+"],
program=ProgramTypeEnum.ECONOMIC,
)
service_8 = Service(
id=8,
name="Support Groups",
status="open",
summary="Peer support groups for survivors",
requirements=["Registration required", "Confidential"],
program=ProgramTypeEnum.COMMUNITY,
)
service_9 = Service(
id=9,
name="Children's Services",
status="open",
summary="Specialized services for children exposed to domestic violence",
requirements=["Parental consent", "Age-appropriate"],
program=ProgramTypeEnum.DOMESTIC,
)
service_10 = Service(
id=10,
name="Safety Planning",
status="open",
summary="Personalized safety planning for survivors",
requirements=["Confidential", "Collaborative"],
program=ProgramTypeEnum.DOMESTIC,
)
service_11 = Service(
id=11,
name="Community Education",
status="open",
summary="Workshops and training on domestic violence prevention",
requirements=["Open to the public", "Registration preferred"],
program=ProgramTypeEnum.COMMUNITY,
)
service_12 = Service(
id=12,
name="Healthcare Services",
status="open",
summary="Medical care and support for survivors",
requirements=["Referral required", "Insurance accepted"],
program=ProgramTypeEnum.DOMESTIC,
)
service_13 = Service(
id=13,
name="Transportation Assistance",
status="waitlist",
summary="Help with transportation for survivors",
requirements=["Eligibility assessment", "Limited availability"],
program=ProgramTypeEnum.ECONOMIC,
)
service_14 = Service(
id=14,
name="Court Accompaniment",
status="open",
summary="Support and advocacy during court proceedings",
requirements=["Legal case", "Scheduling required"],
program=ProgramTypeEnum.COMMUNITY,
)
service_15 = Service(
id=15,
name="Relocation Assistance",
status="waitlist",
summary="Support for relocating to a safe environment",
requirements=["Referral required", "Safety assessment"],
program=ProgramTypeEnum.ECONOMIC,
)
service_16 = Service(
id=16,
name="Parenting Classes",
status="open",
summary="Education and support for parents",
requirements=["Open to parents", "Pre-registration required"],
program=ProgramTypeEnum.COMMUNITY,
)
service_17 = Service(
id=17,
name="Life Skills Training",
status="open",
summary="Workshops on various life skills for survivors",
requirements=["Enrollment required", "Commitment to attend"],
program=ProgramTypeEnum.ECONOMIC,
)
service_18 = Service(
id=18,
name="Advocacy Services",
status="open",
summary="Individual advocacy and support for survivors",
requirements=["Intake required", "Confidential"],
program=ProgramTypeEnum.DOMESTIC,
)
service_19 = Service(
id=19,
name="Volunteer Opportunities",
status="open",
summary="Various volunteer roles supporting the organization",
requirements=["Background check", "Training required"],
program=ProgramTypeEnum.COMMUNITY,
)
service_20 = Service(
id=20,
name="Referral Services",
status="open",
summary="Referrals to community resources and partner agencies",
requirements=["Intake required", "Based on individual needs"],
program=ProgramTypeEnum.DOMESTIC,
)
services1 = [
service_1,
service_2,
service_3,
service_4,
service_5,
service_6,
service_7,
service_8,
service_9,
service_10,
service_11,
service_12,
service_13,
service_14,
service_15,
service_16,
service_17,
service_18,
service_19,
service_20,
]
from sqlalchemy import text
from sqlalchemy.orm import Session, DeclarativeBase, InstrumentedAttribute
def reset_table_id_seq(
session: Session,
entity: type[DeclarativeBase],
entity_id_column: InstrumentedAttribute[int],
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__
id_column_name = entity_id_column.name
sql = text(f"ALTER SEQUENCe {table}_{id_column_name}_seq RESTART WITH {next_id}")
session.execute(sql)
def insert_test_data(session: Session):
"""Inserts fake service data into the test session."""
global services1
# Create entities for test organization data
entities = []
for service in services1:
entity = ServiceEntity.from_model(service)
session.add(entity)
entities.append(entity)
# Reset table IDs to prevent ID conflicts
reset_table_id_seq(session, ServiceEntity, ServiceEntity.id, len(services1) + 1)
# Commit all changes
session.commit()
def insert_fake_data(session: Session):
"""Inserts fake service data into the test session."""
global services
# Create entities for test organization data
entities = []
for service in services:
entity = ServiceEntity.from_model(service)
session.add(entity)
entities.append(entity)
# Reset table IDs to prevent ID conflicts
reset_table_id_seq(session, ServiceEntity, ServiceEntity.id, len(services) + 1)
# Commit all changes
session.commit()

View File

@ -0,0 +1,14 @@
"""Tests for the TagService class."""
# PyTest
import pytest
from ...services.tag import TagService
from .fixtures import tag_svc
from .tag_test_data import tag1, tag2, tag3
from . import tag_test_data
def test_get_all(tag_svc: TagService):
"""Test that all tags can be retrieved."""
tags = tag_svc.all()
assert len(tags) == 3

View File

@ -0,0 +1,72 @@
import pytest
from sqlalchemy.orm import Session
from ...models.tag_model import Tag
from ...entities.tag_entity import TagEntity
from datetime import datetime
tag1 = Tag(id=1, content="Tag 1", created_at=datetime.now())
tag2 = Tag(id=2, content="Tag 2", created_at=datetime.now())
tag3 = Tag(id=3, content="Tag 3", created_at=datetime.now())
tagToCreate = Tag(id=4, content="Tag 4", created_at=datetime.now())
tags = [tag1, tag2, tag3]
from sqlalchemy import text
from sqlalchemy.orm import Session, DeclarativeBase, InstrumentedAttribute
def reset_table_id_seq(
session: Session,
entity: type[DeclarativeBase],
entity_id_column: InstrumentedAttribute[int],
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__
id_column_name = entity_id_column.name
sql = text(f"ALTER SEQUENCe {table}_{id_column_name}_seq RESTART WITH {next_id}")
session.execute(sql)
def insert_fake_data(session: Session):
"""Inserts fake organization data into the test session."""
global tags
# Create entities for test organization data
entities = []
for tag in tags:
entity = TagEntity.from_model(tag)
session.add(entity)
entities.append(entity)
# Reset table IDs to prevent ID conflicts
reset_table_id_seq(session, TagEntity, TagEntity.id, len(tags) + 1)
# Commit all changes
session.commit()
@pytest.fixture(autouse=True)
def fake_data_fixture(session: Session):
"""Insert fake data the session automatically when test is run.
Note:
This function runs automatically due to the fixture property `autouse=True`.
"""
insert_fake_data(session)
session.commit()
yield

View File

@ -0,0 +1,82 @@
"""Tests for the UserService class."""
# PyTest
import pytest
from ...services import UserService
from .fixtures import user_svc
from ...models.user_model import User
from ...models.enum_for_models import ProgramTypeEnum
from .user_test_data import employee, volunteer, admin, newUser, toDelete
from . import user_test_data
def test_create(user_svc: UserService):
"""Test creating a user"""
user1 = user_svc.create(admin)
print(user1)
assert user1 is not None
assert user1.id is not None
def test_create_id_exists(user_svc: UserService):
"""Test creating a user with id conflict"""
user1 = user_svc.create(volunteer)
assert user1 is not None
assert user1.id is not None
def test_get_all(user_svc: UserService):
"""Test that all users can be retrieved."""
users = user_svc.all()
assert len(users) == 4
def test_get_user_by_id(user_svc: UserService):
"""Test getting a user by an id"""
if volunteer.id != None:
user = user_svc.get_user_by_id(volunteer.id)
assert user is not None
assert user.id is not None
def test_get_user_by_id_nonexistent(user_svc: UserService):
"""Test getting a user by id that does not exist"""
with pytest.raises(Exception):
user_svc.get_by_id(100)
def test_delete_user(user_svc: UserService):
"""Test deleting a user"""
user_svc.delete(toDelete)
with pytest.raises(Exception):
user_svc.get_user_by_id(toDelete.id)
def test_delete_user_nonexistent(user_svc: UserService):
"""Test deleting a user that does not exist"""
with pytest.raises(Exception):
user_svc.delete(newUser)
def test_update_user(user_svc: UserService):
"""Test updating a user
Updating volunteer
"""
user = user_svc.get_user_by_id(volunteer.id)
assert user is not None
user.username = "volunteer 1"
user.email = "newemail@compass.com"
updated_user = user_svc.update(user)
assert updated_user is not None
assert updated_user.id == user.id
assert updated_user.username == "volunteer 1"
assert updated_user.email == "newemail@compass.com"
def test_update_user_nonexistent(user_svc: UserService):
"""Test updated a user that does not exist"""
with pytest.raises(Exception):
user_svc.update(newUser)

View File

@ -0,0 +1,196 @@
import pytest
from sqlalchemy.orm import Session
from ...models.user_model import User
# import model enums instead
from ...models.enum_for_models import UserTypeEnum, ProgramTypeEnum
from ...entities.user_entity import UserEntity
from datetime import datetime
programs = ProgramTypeEnum
roles = UserTypeEnum
volunteer = User(
id=1,
uuid="test1",
username="volunteer",
email="volunteer@compass.com",
experience=1,
group="volunteers",
program=[programs.COMMUNITY, programs.ECONOMIC],
created_at=datetime.now(),
role=UserTypeEnum.VOLUNTEER,
)
employee = User(
id=2,
uuid="test2",
username="employee",
email="employee@compass.com",
experience=5,
group="employees",
program=[programs.DOMESTIC, programs.ECONOMIC],
created_at=datetime.now(),
role=roles.EMPLOYEE,
)
admin = User(
id=3,
uuid="test3",
username="admin",
email="admin@compass.com",
experience=10,
group="admin",
program=[
programs.ECONOMIC,
programs.DOMESTIC,
programs.COMMUNITY,
],
created_at=datetime.now(),
role=roles.ADMIN,
)
newUser = User(
id=4,
username="new",
uuid="test4",
email="new@compass.com",
experience=1,
group="volunteer",
program=[programs.ECONOMIC],
created_at=datetime.now(),
role=roles.VOLUNTEER,
)
toDelete = User(
id=5,
username="delete",
email="delete@compass.com",
experience=0,
group="none",
program=[programs.COMMUNITY],
created_at=datetime.now(),
role=roles.VOLUNTEER,
)
users = [volunteer, employee, admin, toDelete]
admin1 = User(
username="Prajwal Moharana",
uuid="acc6e112-d296-4739-a80c-b89b2933e50b",
email="root@compass.com",
experience=10,
group="admin",
program=[programs.ECONOMIC, programs.DOMESTIC, programs.COMMUNITY],
created_at=datetime.now(),
role=roles.ADMIN,
)
employee1 = User(
username="Mel Ho",
uuid="c5fcff86-3deb-4d09-9f60-9b529e40161a",
email="employee@compass.com",
experience=5,
group="employee",
program=[programs.ECONOMIC, programs.DOMESTIC, programs.COMMUNITY],
created_at=datetime.now(),
role=roles.EMPLOYEE,
)
volunteer1 = User(
username="Pranav Wagh",
uuid="1d2e114f-b286-4464-8528-d177dc226b09",
email="volunteer1@compass.com",
experience=2,
group="volunteer",
program=[programs.DOMESTIC],
created_at=datetime.now(),
role=roles.VOLUNTEER,
)
volunteer2 = User(
username="Yashu Singhai",
uuid="13888204-1bae-4be4-8192-1ca46be4fc7d",
email="volunteer2@compass.com",
experience=1,
group="volunteer",
program=[programs.COMMUNITY, programs.ECONOMIC],
created_at=datetime.now(),
role=roles.VOLUNTEER,
)
users1 = [admin1, employee1, volunteer1, volunteer2]
from sqlalchemy import text
from sqlalchemy.orm import Session, DeclarativeBase, InstrumentedAttribute
def reset_table_id_seq(
session: Session,
entity: type[DeclarativeBase],
entity_id_column: InstrumentedAttribute[int],
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__
id_column_name = entity_id_column.name
sql = text(f"ALTER SEQUENCe {table}_{id_column_name}_seq RESTART WITH {next_id}")
session.execute(sql)
def insert_fake_data(session: Session):
"""Inserts fake organization data into the test session."""
global users
# Create entities for test organization data
entities = []
for user in users:
entity = UserEntity.from_model(user)
session.add(entity)
entities.append(entity)
# Reset table IDs to prevent ID conflicts
reset_table_id_seq(session, UserEntity, UserEntity.id, len(users) + 1)
# Commit all changes
session.commit()
def insert_test_data(session: Session):
"""Inserts fake organization data into the test session."""
global users1
# Create entities for test organization data
for user in users1:
entity = UserEntity.from_model(user)
session.add(entity)
# Reset table IDs to prevent ID conflicts
reset_table_id_seq(session, UserEntity, UserEntity.id, len(users1) + 1)
# Commit all changes
session.commit()
@pytest.fixture(autouse=True)
def fake_data_fixture(session: Session):
"""Insert fake data the session automatically when test is run.
Note:
This function runs automatically due to the fixture property `autouse=True`.
"""
insert_fake_data(session)
session.commit()
yield

View File

@ -1,3 +1,3 @@
{
"extends": "next/core-web-vitals"
}
{
"extends": "next/core-web-vitals"
}

3
compass/.gitignore vendored
View File

@ -33,3 +33,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# environment variables
.env

6
compass/.prettierrc Normal file
View File

@ -0,0 +1,6 @@
{
"trailingComma": "es5",
"tabWidth": 4,
"semi": true,
"singleQuote": false
}

View File

@ -1,36 +1,36 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

View File

@ -0,0 +1,100 @@
"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 { useRouter } from "next/navigation";
import { useEffect } from "react";
import User, { Role } from "@/utils/models/User";
import Loading from "@/components/auth/Loading";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const router = useRouter();
const [user, setUser] = useState<User>();
useEffect(() => {
async function getUser() {
const supabase = createClient();
const { data, error } = await supabase.auth.getUser();
console.log(data, error);
if (error) {
console.log("Accessed admin page but not logged in");
router.push("/auth/login");
return;
}
const userData = await fetch(
`${process.env.NEXT_PUBLIC_HOST}/api/user?uuid=${data.user.id}`
);
const user: User = await userData.json();
if (user.role !== Role.ADMIN) {
console.log(
`Accessed admin page but incorrect permissions: ${user.username} ${user.role}`
);
router.push("/home");
return;
}
setUser(user);
}
getUser();
}, [router]);
return (
<div className="flex-row">
{user ? (
<div>
{/* button to open sidebar */}
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className={`fixed z-20 p-2 text-gray-500 hover:text-gray-800 left-0`}
aria-label={"Open sidebar"}
>
{
!isSidebarOpen && (
<ChevronDoubleRightIcon className="h-5 w-5" />
) // Icon for closing the sidebar
}
</button>
{/* sidebar */}
<div
className={`absolute inset-y-0 left-0 transform ${
isSidebarOpen
? "translate-x-0"
: "-translate-x-full"
} w-64 transition duration-300 ease-in-out`}
>
<Sidebar
setIsSidebarOpen={setIsSidebarOpen}
name={user.username}
email={user.email}
isAdmin={user.role === Role.ADMIN}
/>
</div>
{/* page ui */}
<div
className={`flex-1 transition duration-300 ease-in-out ${
isSidebarOpen ? "ml-64" : "ml-0"
}`}
>
{children}
</div>
</div>
) : (
<Loading />
)}
</div>
);
}

View File

@ -0,0 +1,45 @@
"use client";
import { PageLayout } from "@/components/PageLayout";
import { Table } from "@/components/Table/Index";
import User from "@/utils/models/User";
import { createClient } from "@/utils/supabase/client";
import { UsersIcon } from "@heroicons/react/24/solid";
import { useEffect, useState } from "react";
export default function Page() {
const [users, setUsers] = useState<User[]>([]);
useEffect(() => {
async function getUser() {
const supabase = createClient();
const { data, error } = await supabase.auth.getUser();
if (error) {
console.log("Accessed admin page but not logged in");
return;
}
const userListData = await fetch(
`${process.env.NEXT_PUBLIC_HOST}/api/user/all?uuid=${data.user.id}`
);
const users: User[] = await userListData.json();
setUsers(users);
}
getUser();
}, []);
return (
<div className="min-h-screen flex flex-col">
{/* icon + title */}
<PageLayout title="Users" icon={<UsersIcon />}>
<Table users={users} />
</PageLayout>
</div>
);
}

View File

@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
export async function GET() {
const apiEndpoint = `${process.env.NEXT_PUBLIC_API_HOST}/api/health`;
const result = await fetch(apiEndpoint);
return NextResponse.json(await result.json(), { status: result.status });
}

View File

@ -0,0 +1,24 @@
import Resource from "@/utils/models/Resource";
import { NextResponse } from "next/server";
export async function GET(request: Request) {
const apiEndpoint = `${process.env.NEXT_PUBLIC_API_HOST}/api/resource`;
console.log(apiEndpoint);
const { searchParams } = new URL(request.url);
const uuid = searchParams.get("uuid");
const data = await fetch(`${apiEndpoint}?user_id=${uuid}`);
const resourceData: Resource[] = await data.json();
// TODO: Remove make every resource visible
const resources = resourceData.map((resource: Resource) => {
resource.visible = true;
return resource;
});
return NextResponse.json(resources, { status: data.status });
}

5
compass/app/api/route.ts Normal file
View File

@ -0,0 +1,5 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({ message: "Hello World!" }, { status: 200 });
}

View File

@ -0,0 +1,24 @@
import Service from "@/utils/models/Service";
import { NextResponse } from "next/server";
export async function GET(request: Request) {
const apiEndpoint = `${process.env.NEXT_PUBLIC_API_HOST}/api/service`;
console.log(apiEndpoint);
const { searchParams } = new URL(request.url);
const uuid = searchParams.get("uuid");
const data = await fetch(`${apiEndpoint}?user_id=${uuid}`);
const serviceData: Service[] = await data.json();
// TODO: Remove make every service visible
const services = serviceData.map((service: Service) => {
service.visible = true;
return service;
});
return NextResponse.json(services, { status: data.status });
}

View File

@ -0,0 +1,24 @@
import User from "@/utils/models/User";
import { NextResponse } from "next/server";
export async function GET(request: Request) {
const apiEndpoint = `${process.env.NEXT_PUBLIC_API_HOST}/api/user/all`;
console.log(apiEndpoint);
const { searchParams } = new URL(request.url);
const uuid = searchParams.get("uuid");
const data = await fetch(`${apiEndpoint}?user_id=${uuid}`);
const userData: User[] = await data.json();
// TODO: Remove make every user visible
const users = userData.map((user: User) => {
user.visible = true;
return user;
});
return NextResponse.json(users, { status: data.status });
}

View File

@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
export async function GET(request: Request) {
const apiEndpoint = `${process.env.NEXT_PUBLIC_API_HOST}/api/user`;
console.log(apiEndpoint);
const { searchParams } = new URL(request.url);
const uuid = searchParams.get("uuid");
const data = await fetch(`${apiEndpoint}/${uuid}`);
return NextResponse.json(await data.json(), { status: data.status });
}

View File

@ -0,0 +1,58 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { createClient } from "@/utils/supabase/server";
import User, { Role } from "@/utils/models/User";
export async function login(email: string, password: string) {
const supabase = createClient();
// type-casting here for convenience
// in practice, you should validate your inputs
const data = {
email,
password,
};
const { error } = await supabase.auth.signInWithPassword(data);
if (error) {
return "Incorrect email/password";
}
const supabaseUser = await supabase.auth.getUser();
if (!supabaseUser.data.user) {
revalidatePath("/home", "layout");
redirect("/home");
}
const apiData = await fetch(
`${process.env.NEXT_PUBLIC_HOST}/api/user?uuid=${supabaseUser.data.user.id}`
);
const user: User = await apiData.json();
console.log(user);
if (user.role === Role.ADMIN) {
redirect("/admin");
}
revalidatePath("/home", "layout");
redirect("/home");
}
export async function signOut() {
const supabase = createClient();
const { data, error } = await supabase.auth.getUser();
if (error || !data?.user) {
redirect("auth/login");
}
console.log(`Signed out ${data.user.email}!`);
await supabase.auth.signOut();
revalidatePath("/auth/login", "layout");
redirect("/auth/login");
}

View File

@ -0,0 +1,3 @@
export default function ErrorPage() {
return <p>Sorry, something went wrong</p>;
}

View File

@ -1,25 +1,23 @@
// pages/forgot-password.tsx
"use client";
import React, { useState } from 'react';
import Input from '@/components/Input';
import Button from '@/components/Button';
import InlineLink from '@/components/InlineLink';
import ErrorBanner from '@/components/auth/ErrorBanner';
import React, { useState } from "react";
import Input from "@/components/Input";
import Button from "@/components/Button";
import InlineLink from "@/components/InlineLink";
import ErrorBanner from "@/components/auth/ErrorBanner";
export default function ForgotPasswordPage() {
const [confirmEmail, setConfirmEmail] = useState("");
const [emailError, setEmailError] = useState<string | null>(null);
function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (email.trim() === '') {
setEmailError('Email cannot be empty');
if (email.trim() === "") {
setEmailError("Email cannot be empty");
return false;
} else if (!emailRegex.test(email)) {
setEmailError('Invalid email format');
setEmailError("Invalid email format");
return false;
}
return true; // No error
@ -27,31 +25,30 @@ export default function ForgotPasswordPage() {
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
isValidEmail(confirmEmail);
event.preventDefault();
}
};
return (
<>
<h1 className="font-bold text-xl text-purple-800">Forgot Password</h1>
<div className="mb-6">
<Input
type='email'
valid={emailError == null}
title="Enter your email address"
placeholder="janedoe@gmail.com"
value={confirmEmail}
onChange={(e) => setConfirmEmail(e.target.value)}
/>
</div>
{emailError && <ErrorBanner heading={emailError} />}
<div className="flex flex-col items-left space-y-4">
<InlineLink href="/auth/login">
Back to Sign In
</InlineLink>
<Button type="submit" onClick={handleClick}>
Send
</Button>
</div>
<h1 className="font-bold text-xl text-purple-800">
Forgot Password
</h1>
<div className="mb-6">
<Input
type="email"
valid={emailError == null}
title="Enter your email address"
placeholder="janedoe@gmail.com"
value={confirmEmail}
onChange={(e) => setConfirmEmail(e.target.value)}
/>
</div>
{emailError && <ErrorBanner heading={emailError} />}
<div className="flex flex-col items-left space-y-4">
<InlineLink href="/auth/login">Back to Sign In</InlineLink>
<Button type="submit" onClick={handleClick}>
Send
</Button>
</div>
</>
);
}

View File

@ -1,22 +1,20 @@
import Paper from '@/components/auth/Paper';
import Paper from "@/components/auth/Paper";
export default function RootLayout({
// Layouts must accept a children prop.
// This will be populated with nested layouts or pages
children,
// Layouts must accept a children prop.
// This will be populated with nested layouts or pages
children,
}: {
children: React.ReactNode
children: React.ReactNode;
}) {
return (
<Paper>
<form className="mb-0 m-auto mt-6 space-y-4 border border-gray-200 rounded-lg p-4 shadow-lg sm:p-6 lg:p-8 bg-white max-w-xl">
{children}
</form>
<p className="text-center mt-6 text-gray-500 text-xs">
&copy; 2024 Compass Center
</p>
</Paper>
)
}
return (
<Paper>
<form className="mb-0 m-auto mt-6 space-y-4 border border-gray-200 rounded-lg p-4 shadow-lg sm:p-6 lg:p-8 bg-white max-w-xl">
{children}
</form>
<p className="text-center mt-6 text-gray-500 text-xs">
&copy; 2024 Compass Center
</p>
</Paper>
);
}

View File

@ -1,81 +1,118 @@
// pages/index.tsx
"use client";
import Button from '@/components/Button';
import Input from '@/components/Input'
import InlineLink from '@/components/InlineLink';
import Image from 'next/image';
import { useState } from "react";
import PasswordInput from '@/components/auth/PasswordInput';
import ErrorBanner from '@/components/auth/ErrorBanner';
import Button from "@/components/Button";
import Input from "@/components/Input";
import InlineLink from "@/components/InlineLink";
import Image from "next/image";
import { useEffect, useState } from "react";
import PasswordInput from "@/components/auth/PasswordInput";
import ErrorBanner from "@/components/auth/ErrorBanner";
import { login } from "../actions";
import { createClient } from "@/utils/supabase/client";
import { useRouter } from "next/navigation";
export default function Page() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [emailError, setEmailError] = useState("");
const [passwordError, setPasswordError] = useState("");
const [loginError, setLoginError] = useState("");
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const supabase = createClient();
async function checkUser() {
const { data } = await supabase.auth.getUser();
if (data.user) {
router.push("/home");
}
}
checkUser();
}, [router]);
const handleEmailChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setEmail(event.currentTarget.value);
}
};
const handlePasswordChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const handlePasswordChange = (
event: React.ChangeEvent<HTMLInputElement>
) => {
setPassword(event.currentTarget.value);
}
};
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
// Priority: Incorrect combo > Missing email > Missing password
const handleClick = async (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (email.trim().length === 0) {
setEmailError("Please enter your email.");
return;
}
if (!emailRegex.test(email)) {
setEmailError("Please enter a valid email address.");
return;
}
setEmailError("");
if (password.trim().length === 0) {
setEmailError("Please enter your password.")
event.preventDefault();
setPasswordError("Please enter your password.");
return;
}
// This shouldn't happen, <input type="email"> already provides validation, but just in case.
if (email.trim().length === 0) {
setPasswordError("Please enter your email.")
event.preventDefault();
setPasswordError("");
setIsLoading(true);
const error = await login(email, password);
setIsLoading(false);
if (error) {
setLoginError(error);
}
// Placeholder for incorrect email + password combo.
if (email === "incorrect@gmail.com" && password) {
setPasswordError("Incorrect password.")
event.preventDefault();
}
}
};
return (
<>
<Image
src="/logo.png"
alt='Compass Center logo.'
width={100}
height={91}
/>
<h1 className='font-bold text-2xl text-purple-800'>Login</h1>
<div className="mb-6">
<Input type='email' valid={emailError == ""} title="Email" placeholder="janedoe@gmail.com" onChange={handleEmailChange} required />
<Image
src="/logo.png"
alt="Compass Center logo."
width={100}
height={91}
/>
<h1 className="font-bold text-2xl text-purple-800">Login</h1>
<div className="mb-6">
<Input
type="email"
valid={emailError === ""}
title="Email"
placeholder="Enter Email"
onChange={handleEmailChange}
required
/>
</div>
{emailError && <ErrorBanner heading={emailError} />}
<div className="mb-6">
<PasswordInput
title="Password"
placeholder="Enter Password"
valid={passwordError === ""}
onChange={handlePasswordChange}
/>
</div>
{passwordError && <ErrorBanner heading={passwordError} />}
<div className="flex flex-col items-left space-y-4">
<InlineLink href="/auth/forgot_password">
Forgot password?
</InlineLink>
<Button onClick={handleClick} disabled={isLoading}>
<div className="flex items-center justify-center">
{isLoading && (
<div className="w-4 h-4 border-2 border-white border-t-purple-500 rounded-full animate-spin mr-2"></div>
)}
{isLoading ? "Logging in..." : "Login"}
</div>
{emailError && <ErrorBanner heading={emailError} />}
<div className="mb-6">
<PasswordInput title="Password" valid={passwordError == ""} onChange={handlePasswordChange} />
</div>
{passwordError && <ErrorBanner heading={passwordError} />}
<div className="flex flex-col items-left space-y-4">
<InlineLink href="/auth/forgot_password">
Forgot password?
</InlineLink>
<Button onClick={handleClick}>
Login
</Button>
</div>
</Button>
</div>
{loginError && <ErrorBanner heading={loginError} />}
</>
);
};
}

View File

@ -1,62 +1,79 @@
// pages/index.tsx
"use client";
import { useState, useEffect } from 'react';
import Button from '@/components/Button';
import PasswordInput from '@/components/auth/PasswordInput';
import ErrorBanner from '@/components/auth/ErrorBanner';
import { useState, useEffect } from "react";
import Button from "@/components/Button";
import PasswordInput from "@/components/auth/PasswordInput";
import ErrorBanner from "@/components/auth/ErrorBanner";
function isStrongPassword(password: string): boolean {
const strongPasswordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*]).{8,}$/;
return strongPasswordRegex.test(password);
const strongPasswordRegex =
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*]).{8,}$/;
return strongPasswordRegex.test(password);
}
export default function Page() {
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isButtonDisabled, setIsButtonDisabled] = useState(true);
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [isButtonDisabled, setIsButtonDisabled] = useState(true);
useEffect(() => {
setIsButtonDisabled(newPassword === '' || confirmPassword === '' || newPassword !== confirmPassword|| !isStrongPassword(newPassword));
}, [newPassword, confirmPassword]);
useEffect(() => {
setIsButtonDisabled(
newPassword === "" ||
confirmPassword === "" ||
newPassword !== confirmPassword ||
!isStrongPassword(newPassword)
);
}, [newPassword, confirmPassword]);
return (
<>
<div className="text-center sm:text-left">
<h1 className="font-bold text-xl text-purple-800">New Password</h1>
</div>
<div className="mb-4">
<PasswordInput
title="Enter New Password"
value={newPassword}
valid={!isButtonDisabled || isStrongPassword(newPassword)}
onChange={(e) => {
setNewPassword(e.target.value);
}}
/>
</div>
{isStrongPassword(newPassword) || newPassword === '' ? null : <ErrorBanner heading="Password is not strong enough." description="Tip: Use a mix of letters, numbers, and symbols for a strong password. Aim for at least 8 characters!" />}
<div className="mb-6">
<PasswordInput
title="Confirm Password"
value={confirmPassword}
valid={!isButtonDisabled || (newPassword === confirmPassword && confirmPassword !== '')}
onChange={(e) => {
setConfirmPassword(e.target.value);
}}
/>
</div>
{newPassword === confirmPassword || confirmPassword === '' ? null : <ErrorBanner heading="Passwords do not match." description="Please make sure both passwords are the exact same!"/>}
<div className="flex flex-col items-left space-y-4">
<Button type="submit" disabled={isButtonDisabled} >
Send
</Button>
</div>
</>
);
return (
<>
<div className="text-center sm:text-left">
<h1 className="font-bold text-xl text-purple-800">
New Password
</h1>
</div>
<div className="mb-4">
<PasswordInput
title="Enter New Password"
value={newPassword}
valid={!isButtonDisabled || isStrongPassword(newPassword)}
onChange={(e) => {
setNewPassword(e.target.value);
}}
/>
</div>
{isStrongPassword(newPassword) || newPassword === "" ? null : (
<ErrorBanner
heading="Password is not strong enough."
description="Tip: Use a mix of letters, numbers, and symbols for a strong password. Aim for at least 8 characters!"
/>
)}
<div className="mb-6">
<PasswordInput
title="Confirm Password"
value={confirmPassword}
valid={
!isButtonDisabled ||
(newPassword === confirmPassword &&
confirmPassword !== "")
}
onChange={(e) => {
setConfirmPassword(e.target.value);
}}
/>
</div>
{newPassword === confirmPassword ||
confirmPassword === "" ? null : (
<ErrorBanner
heading="Passwords do not match."
description="Please make sure both passwords are the exact same!"
/>
)}
<div className="flex flex-col items-left space-y-4">
<Button type="submit" disabled={isButtonDisabled}>
Send
</Button>
</div>
</>
);
}

View File

@ -0,0 +1,89 @@
"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";
import User, { Role } from "@/utils/models/User";
import Loading from "@/components/auth/Loading";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [user, setUser] = useState<User>();
const router = useRouter();
useEffect(() => {
async function getUser() {
const supabase = createClient();
const { data, error } = await supabase.auth.getUser();
console.log(data, error);
if (error) {
console.log("Accessed home page but not logged in");
router.push("/auth/login");
return;
}
const userData = await fetch(
`${process.env.NEXT_PUBLIC_HOST}/api/user?uuid=${data.user.id}`
);
setUser(await userData.json());
}
getUser();
}, [router]);
return (
<div className="flex-row">
{user ? (
<div>
{/* button to open sidebar */}
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className={`fixed z-20 p-2 text-gray-500 hover:text-gray-800 left-0`}
aria-label={"Open sidebar"}
>
{
!isSidebarOpen && (
<ChevronDoubleRightIcon className="h-5 w-5" />
) // Icon for closing the sidebar
}
</button>
{/* sidebar */}
<div
className={`absolute inset-y-0 left-0 transform ${
isSidebarOpen
? "translate-x-0"
: "-translate-x-full"
} w-64 transition duration-300 ease-in-out`}
>
<Sidebar
name={user.username}
email={user.email}
setIsSidebarOpen={setIsSidebarOpen}
isAdmin={user.role === Role.ADMIN}
/>
</div>
{/* page ui */}
<div
className={`flex-1 transition duration-300 ease-in-out ${
isSidebarOpen ? "ml-64" : "ml-0"
}`}
>
{children}
</div>
</div>
) : (
<Loading />
)}
</div>
);
}

61
compass/app/home/page.tsx Normal file
View File

@ -0,0 +1,61 @@
"use client";
import Callout from "@/components/resource/Callout";
import Card from "@/components/resource/Card";
import { LandingSearchBar } from "@/components/resource/LandingSearchBar";
import {
BookOpenIcon,
BookmarkIcon,
ClipboardIcon,
} from "@heroicons/react/24/solid";
import Image from "next/image";
import Link from "next/link";
export default function Page() {
return (
<div className="min-h-screen flex flex-col">
{/* icon + title */}
<div className="pt-16 px-8 pb-4 flex-row">
<div className="mb-4 flex items-center space-x-4">
<Image
src="/logo.png"
alt="Compass Center logo."
width={25}
height={25}
/>
<h1 className="font-bold text-2xl text-purple-800">
Compass Center Advocate Landing Page
</h1>
</div>
<Callout>
Welcome! Below you will find a list of resources for the
Compass Center&apos;s trained advocates. These materials
serve to virtually provide a collection of advocacy,
resource, and hotline manuals and information.
<b>
{" "}
If you are an advocate looking for the contact
information of a particular Compass Center employee,
please directly contact your staff back-up or the person
in charge of your training.
</b>
</Callout>
</div>
<div className="p-8 flex-grow border-t border-gray-200 bg-gray-50">
{/* link to different pages */}
<div className="grid grid-cols-3 gap-6 pb-6">
<Link href="/resource">
<Card icon={<BookmarkIcon />} text="Resources" />
</Link>
<Link href="/service">
<Card icon={<ClipboardIcon />} text="Services" />
</Link>
<Link href="/training-manual">
<Card icon={<BookOpenIcon />} text="Training Manuals" />
</Link>
</div>
{/* search bar */}
<LandingSearchBar />
</div>
</div>
);
}

View File

@ -1,16 +1,20 @@
import '../styles/globals.css';
export default function RootLayout({
// Layouts must accept a children prop.
// This will be populated with nested layouts or pages
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
"use client";
import "../styles/globals.css";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { createClient } from "@/utils/supabase/client";
export default function RootLayout({
// Layouts must accept a children prop.
// This will be populated with nested layouts or pages
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}

View File

@ -1,86 +1,11 @@
// pages/index.tsx
"use client";
import Button from '@/components/Button';
import Input from '@/components/Input'
import InlineLink from '@/components/InlineLink';
import Paper from '@/components/auth/Paper';
// import { Metadata } from 'next'
import Image from 'next/image';
import {ChangeEvent, useState} from "react";
import { useRouter } from "next/navigation";
// export const metadata: Metadata = {
// title: 'Login',
// }
export default function Page() {
const router = useRouter();
export default function Page() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
router.push("/auth/login");
const handleEmailChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setEmail(event.currentTarget.value);
console.log("email " + email);
}
const handlePasswordChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setPassword(event.currentTarget.value);
console.log("password " + password)
}
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
// Priority: Incorrect combo > Missing email > Missing password
if (password.trim().length === 0) {
setError("Please enter your password.")
}
// This shouldn't happen, <input type="email"> already provides validation, but just in case.
if (email.trim().length === 0) {
setError("Please enter your email.")
}
// Placeholder for incorrect email + password combo.
if (email === "incorrect@gmail.com" && password) {
setError("Incorrect password.")
}
}
return (
<>
<Paper>
<form className="mb-0 m-auto mt-6 space-y-4 rounded-lg p-4 shadow-lg sm:p-6 lg:p-8 bg-white max-w-xl">
<Image
src="/logo.png"
alt='Compass Center logo.'
width={100}
height={91}
/>
<h1 className='font-bold text-xl text-purple-800'>Login</h1>
<div className="mb-4">
<Input type='email' title="Email" placeholder="janedoe@gmail.com" onChange={handleEmailChange} />
</div>
<div className="mb-6">
<Input type='password' title="Password" onChange={handlePasswordChange} />
</div>
<div className="flex flex-col items-left space-y-4">
<InlineLink href="/forgot_password">
Forgot password?
</InlineLink>
<Button onClick={handleClick}>
Login
</Button>
<div className="text-center text-red-600" hidden={!error}>
<p>{error}</p>
</div>
</div>
</form>
<p className="text-center mt-6 text-gray-500 text-xs">
&copy; 2024 Compass Center
</p>
</Paper>
</>
);
};
return <h1>GO TO LOGIN PAGE (/auth/login)</h1>;
}

View File

@ -1,37 +1,92 @@
"use client"
"use client";
import Sidebar from '@/components/resource/Sidebar';
import React, { useState } from 'react';
import { ChevronDoubleRightIcon } from '@heroicons/react/24/outline';
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";
import User, { Role } from "@/utils/models/User";
import Loading from "@/components/auth/Loading";
export default function RootLayout({
children,
}: {
children: React.ReactNode
children: React.ReactNode;
}) {
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const router = useRouter();
const [user, setUser] = useState<User>();
useEffect(() => {
async function getUser() {
const supabase = createClient();
const { data, error } = await supabase.auth.getUser();
console.log(data, error);
if (error) {
console.log("Accessed resource page but not logged in");
router.push("/auth/login");
return;
}
const userData = await fetch(
`${process.env.NEXT_PUBLIC_HOST}/api/user?uuid=${data.user.id}`
);
const user: User = await userData.json();
setUser(user);
}
getUser();
}, [router]);
return (
<div className="flex-row">
{/* button to open sidebar */}
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className={`fixed z-20 p-2 text-gray-500 hover:text-gray-800 left-0`}
aria-label={'Open sidebar'}
>
{!isSidebarOpen &&
<ChevronDoubleRightIcon className="h-5 w-5" /> // Icon for closing the sidebar
}
</button>
{/* sidebar */}
<div className={`absolute inset-y-0 left-0 transform ${isSidebarOpen ? 'translate-x-0' : '-translate-x-full'} w-64 transition duration-300 ease-in-out`}>
<Sidebar setIsSidebarOpen={setIsSidebarOpen} />
</div>
{/* page ui */}
<div className={`flex-1 transition duration-300 ease-in-out ${isSidebarOpen ? 'ml-64' : 'ml-0'}`}>
{children}
</div>
{user ? (
<div>
{/* button to open sidebar */}
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className={`fixed z-20 p-2 text-gray-500 hover:text-gray-800 left-0`}
aria-label={"Open sidebar"}
>
{
!isSidebarOpen && (
<ChevronDoubleRightIcon className="h-5 w-5" />
) // Icon for closing the sidebar
}
</button>
{/* sidebar */}
<div
className={`absolute inset-y-0 left-0 transform ${
isSidebarOpen
? "translate-x-0"
: "-translate-x-full"
} w-64 transition duration-300 ease-in-out`}
>
<Sidebar
setIsSidebarOpen={setIsSidebarOpen}
name={user.username}
email={user.email}
isAdmin={user.role === Role.ADMIN}
/>
</div>
{/* page ui */}
<div
className={`flex-1 transition duration-300 ease-in-out ${
isSidebarOpen ? "ml-64" : "ml-0"
}`}
>
{children}
</div>
</div>
) : (
<Loading />
)}
</div>
)
}
);
}

View File

@ -1,39 +1,45 @@
"use client"
import Callout from "@/components/resource/Callout";
import Card from "@/components/resource/Card";
import { LandingSearchBar } from "@/components/resource/LandingSearchBar";
import { BookOpenIcon, BookmarkIcon, ClipboardIcon } from "@heroicons/react/24/solid";
import Image from 'next/image';
"use client";
import { PageLayout } from "@/components/PageLayout";
import { ResourceTable } from "@/components/Table/ResourceIndex";
import Resource from "@/utils/models/Resource";
import { createClient } from "@/utils/supabase/client";
import { BookmarkIcon } from "@heroicons/react/24/solid";
import { useEffect, useState } from "react";
export default function Page() {
const [resources, setResources] = useState<Resource[]>([]);
useEffect(() => {
async function getResources() {
const supabase = createClient();
const { data, error } = await supabase.auth.getUser();
if (error) {
console.log("Accessed admin page but not logged in");
return;
}
const userListData = await fetch(
`${process.env.NEXT_PUBLIC_HOST}/api/resource/all?uuid=${data.user.id}`
);
const resourcesAPI: Resource[] = await userListData.json();
setResources(resourcesAPI);
}
getResources();
}, []);
return (
<div className="min-h-screen flex flex-col">
{/* icon + title */}
<div className="pt-16 px-8 pb-4 flex-grow">
<div className="mb-4 flex items-center space-x-4">
<Image
src="/logo.png"
alt='Compass Center logo.'
width={25}
height={25}
/>
<h1 className='font-bold text-2xl text-purple-800'>Compass Center Advocate Landing Page</h1>
</div>
<Callout>
Welcome! Below you will find a list of resources for the Compass Center's trained advocates. These materials serve to virtually provide a collection of advocacy, resource, and hotline manuals and information.
<b> If you are an advocate looking for the contact information of a particular Compass Center employee, please directly contact your staff back-up or the person in charge of your training.</b>
</Callout>
</div>
<div className="p-8 flex-grow border-t border-gray-200 bg-gray-50">
{/* link to different pages */}
<div className="grid grid-cols-3 gap-6 pb-6">
<Card icon={<BookmarkIcon />} text="Resources" />
<Card icon={<ClipboardIcon />} text="Services" />
<Card icon={<BookOpenIcon />} text="Training Manuals" />
</div>
{/* search bar */}
<LandingSearchBar />
</div>
<PageLayout title="Resources" icon={<BookmarkIcon />}>
<ResourceTable users={resources} />
</PageLayout>
</div>
)
);
}

View File

@ -0,0 +1,92 @@
"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 { useRouter } from "next/navigation";
import { useEffect } from "react";
import User, { Role } from "@/utils/models/User";
import Loading from "@/components/auth/Loading";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const router = useRouter();
const [user, setUser] = useState<User>();
useEffect(() => {
async function getUser() {
const supabase = createClient();
const { data, error } = await supabase.auth.getUser();
console.log(data, error);
if (error) {
console.log("Accessed service page but not logged in");
router.push("/auth/login");
return;
}
const userData = await fetch(
`${process.env.NEXT_PUBLIC_HOST}/api/user?uuid=${data.user.id}`
);
const user: User = await userData.json();
setUser(user);
}
getUser();
}, [router]);
return (
<div className="flex-row">
{user ? (
<div>
{/* button to open sidebar */}
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className={`fixed z-20 p-2 text-gray-500 hover:text-gray-800 left-0`}
aria-label={"Open sidebar"}
>
{
!isSidebarOpen && (
<ChevronDoubleRightIcon className="h-5 w-5" />
) // Icon for closing the sidebar
}
</button>
{/* sidebar */}
<div
className={`absolute inset-y-0 left-0 transform ${
isSidebarOpen
? "translate-x-0"
: "-translate-x-full"
} w-64 transition duration-300 ease-in-out`}
>
<Sidebar
setIsSidebarOpen={setIsSidebarOpen}
name={user.username}
email={user.email}
isAdmin={user.role === Role.ADMIN}
/>
</div>
{/* page ui */}
<div
className={`flex-1 transition duration-300 ease-in-out ${
isSidebarOpen ? "ml-64" : "ml-0"
}`}
>
{children}
</div>
</div>
) : (
<Loading />
)}
</div>
);
}

View File

@ -0,0 +1,44 @@
"use client";
import { PageLayout } from "@/components/PageLayout";
import { ServiceTable } from "@/components/Table/ServiceIndex";
import Service from "@/utils/models/Service";
import { createClient } from "@/utils/supabase/client";
import { ClipboardIcon } from "@heroicons/react/24/solid";
import { useEffect, useState } from "react";
export default function Page() {
const [services, setUsers] = useState<Service[]>([]);
useEffect(() => {
async function getServices() {
const supabase = createClient();
const { data, error } = await supabase.auth.getUser();
if (error) {
console.log("Accessed admin page but not logged in");
return;
}
const serviceListData = await fetch(
`${process.env.NEXT_PUBLIC_HOST}/api/service/all?uuid=${data.user.id}`
);
const servicesAPI: Service[] = await serviceListData.json();
setUsers(servicesAPI);
}
getServices();
}, []);
return (
<div className="min-h-screen flex flex-col">
{/* icon + title */}
<PageLayout title="Services" icon={<ClipboardIcon />}>
<ServiceTable users={services} />
</PageLayout>
</div>
);
}

View File

@ -0,0 +1,20 @@
import Paper from "@/components/auth/Paper";
export default function RootLayout({
// Layouts must accept a children prop.
// This will be populated with nested layouts or pages
children,
}: {
children: React.ReactNode;
}) {
return (
<Paper>
<form className="mb-0 m-auto mt-6 space-y-4 border border-gray-200 rounded-lg p-4 shadow-lg sm:p-6 lg:p-8 bg-white max-w-xl">
{children}
</form>
<p className="text-center mt-6 text-gray-500 text-xs">
&copy; 2024 Compass Center
</p>
</Paper>
);
}

116
compass/app/test/page.tsx Normal file
View File

@ -0,0 +1,116 @@
"use client";
import Button from "@/components/Button";
import Input from "@/components/Input";
import InlineLink from "@/components/InlineLink";
import Image from "next/image";
import { useEffect, useState } from "react";
import PasswordInput from "@/components/auth/PasswordInput";
import ErrorBanner from "@/components/auth/ErrorBanner";
import { login } from "../auth/actions";
import { createClient } from "@/utils/supabase/client";
import { useRouter } from "next/navigation";
export default function Page() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [emailError, setEmailError] = useState("");
const [passwordError, setPasswordError] = useState("");
const [loginError, setLoginError] = useState("");
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const supabase = createClient();
async function checkUser() {
const { data } = await supabase.auth.getUser();
if (data.user) {
router.push("/home");
}
}
checkUser();
}, [router]);
const handleEmailChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setEmail(event.currentTarget.value);
};
const handlePasswordChange = (
event: React.ChangeEvent<HTMLInputElement>
) => {
setPassword(event.currentTarget.value);
};
const handleClick = async (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (email.trim().length === 0) {
setEmailError("Please enter your email.");
return;
}
if (!emailRegex.test(email)) {
setEmailError("Please enter a valid email address.");
return;
}
setEmailError("");
if (password.trim().length === 0) {
setPasswordError("Please enter your password.");
return;
}
setPasswordError("");
setIsLoading(true);
const error = await login(email, password);
setIsLoading(false);
if (error) {
setLoginError(error);
}
};
return (
<>
<Image
src="/logo.png"
alt="Compass Center logo."
width={100}
height={91}
/>
<h1 className="font-bold text-2xl text-purple-800">Login</h1>
<div className="mb-6">
<Input
type="email"
valid={emailError === ""}
title="Email"
placeholder="Enter Email"
required
/>
</div>
{emailError && <ErrorBanner heading={emailError} />}
<div className="mb-6">
<PasswordInput
title="Password"
placeholder="Enter Password"
valid={passwordError === ""}
onChange={handlePasswordChange}
/>
</div>
{passwordError && <ErrorBanner heading={passwordError} />}
<div className="flex flex-col items-left space-y-4">
<InlineLink href="/auth/forgot_password">
Forgot password?
</InlineLink>
<Button onClick={handleClick} disabled={isLoading}>
<div className="flex items-center justify-center">
{isLoading && (
<div className="w-4 h-4 border-2 border-white border-t-purple-500 rounded-full animate-spin mr-2"></div>
)}
{isLoading ? "Logging in..." : "Login"}
</div>
</Button>
</div>
{loginError && <ErrorBanner heading={loginError} />}
</>
);
}

View File

@ -0,0 +1,40 @@
// page.tsx
import React from "react";
import Head from "next/head";
import Link from "next/link";
const ComingSoonPage: React.FC = () => {
return (
<>
<Head>
<title>Training Manuals - Coming Soon</title>
<meta
name="description"
content="Our training manuals page is coming soon. Stay tuned for updates!"
/>
</Head>
<div className="min-h-screen bg-gradient-to-r from-purple-600 to-blue-500 flex items-center justify-center">
<div className="text-center">
<h1 className="text-4xl font-bold text-white mb-4">
Training Manuals
</h1>
<p className="text-xl text-white mb-8">
Our training manuals page is under construction.
</p>
<p className="text-lg text-white">
Stay tuned for updates!
</p>
<div className="mt-8">
<Link href="/home">
<button className="bg-white text-purple-600 font-semibold py-2 px-4 rounded-full shadow-md hover:bg-purple-100 transition duration-300">
Notify Me
</button>
</Link>
</div>
</div>
</div>
</>
);
};
export default ComingSoonPage;

View File

@ -1,15 +1,23 @@
import { FunctionComponent, ReactNode } from 'react';
import { FunctionComponent, ReactNode } from "react";
type ButtonProps = {
children: ReactNode;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
type?: "button" | "submit" | "reset"; // specify possible values for type
type?: "button" | "submit" | "reset";
disabled?: boolean;
};
const Button: FunctionComponent<ButtonProps> = ({ children, type, disabled, onClick}) => {
const buttonClassName = `inline-block rounded border ${disabled ? 'bg-gray-400 text-gray-600 cursor-not-allowed' : 'border-purple-600 bg-purple-600 text-white hover:bg-transparent hover:text-purple-600 focus:outline-none focus:ring active:text-purple-500'} px-4 py-1 text-md font-semibold w-20 h-10 text-center`;
const Button: FunctionComponent<ButtonProps> = ({
children,
type,
disabled,
onClick,
}) => {
const buttonClassName = `inline-flex items-center justify-center rounded border ${
disabled
? "bg-gray-400 text-gray-600 cursor-not-allowed"
: "border-purple-600 bg-purple-600 text-white hover:bg-transparent hover:text-purple-600 focus:outline-none focus:ring active:text-purple-500"
} px-4 py-2 text-md font-semibold w-full sm:w-auto`;
return (
<button
@ -18,7 +26,9 @@ const Button: FunctionComponent<ButtonProps> = ({ children, type, disabled, onCl
type={type}
disabled={disabled}
>
{children}
<div className="flex items-center justify-center space-x-2">
{children}
</div>
</button>
);
};

View File

@ -0,0 +1,247 @@
import { FunctionComponent, ReactNode } from "react";
import React, { useState } from "react";
import { ChevronDoubleLeftIcon } from "@heroicons/react/24/solid";
import {
StarIcon as SolidStarIcon,
EnvelopeIcon,
UserIcon,
} from "@heroicons/react/24/solid";
import {
ArrowsPointingOutIcon,
ArrowsPointingInIcon,
StarIcon as OutlineStarIcon,
ListBulletIcon,
} from "@heroicons/react/24/outline";
import TagsInput from "../TagsInput/Index";
type DrawerProps = {
title: string;
children: ReactNode;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
type?: "button" | "submit" | "reset"; // specify possible values for type
disabled?: boolean;
editableContent?: any;
onSave?: (content: any) => void;
rowContent?: any;
onRowUpdate?: (content: any) => void;
};
interface EditContent {
content: string;
isEditing: boolean;
}
const Drawer: FunctionComponent<DrawerProps> = ({
title,
children,
onSave,
editableContent,
rowContent,
onRowUpdate,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [isFull, setIsFull] = useState(false);
const [isFavorite, setIsFavorite] = useState(false);
const [tempRowContent, setTempRowContent] = useState(rowContent);
const handleTempRowContentChange = (e) => {
const { name, value } = e.target;
console.log(name);
console.log(value);
setTempRowContent((prevContent) => ({
...prevContent,
[name]: value,
}));
};
const handleEnterPress = (e) => {
if (e.key === "Enter") {
e.preventDefault();
// Update the rowContent with the temporaryRowContent
if (onRowUpdate) {
onRowUpdate(tempRowContent);
}
}
};
const toggleDrawer = () => {
setIsOpen(!isOpen);
if (isFull) {
setIsFull(!isFull);
}
};
const toggleDrawerFullScreen = () => setIsFull(!isFull);
const toggleFavorite = () => setIsFavorite(!isFavorite);
const drawerClassName = `fixed top-0 right-0 w-1/2 h-full bg-white transform ease-in-out duration-300 z-20 ${
isOpen ? "translate-x-0 shadow-xl" : "translate-x-full"
} ${isFull ? "w-full" : "w-1/2"}`;
const iconComponent = isFull ? (
<ArrowsPointingInIcon className="h-5 w-5" />
) : (
<ArrowsPointingOutIcon className="h-5 w-5" />
);
const favoriteIcon = isFavorite ? (
<SolidStarIcon className="h-5 w-5" />
) : (
<OutlineStarIcon className="h-5 w-5" />
);
const [presetOptions, setPresetOptions] = useState([
"administrator",
"volunteer",
"employee",
]);
const [rolePresetOptions, setRolePresetOptions] = useState([
"domestic",
"community",
"economic",
]);
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);
};
return (
<div>
<button
className={
"ml-2 text-xs uppercase opacity-0 group-hover:opacity-100 text-gray-500 font-medium border border-gray-200 bg-white shadow hover:bg-gray-50 p-2 rounded-md"
}
onClick={toggleDrawer}
>
Open
</button>
<div className={drawerClassName}></div>
<div className={drawerClassName}>
<div className="flex items-center justify-between p-4">
<div className="flex flex-row items-center justify-between space-x-2">
<span className="h-5 text-purple-200 w-5">
<UserIcon />
</span>
<h2 className="text-lg text-gray-800 font-semibold">
{rowContent.username}
</h2>
</div>
<div>
<button
onClick={toggleFavorite}
className="py-2 text-gray-500 hover:text-gray-800 mr-2"
>
{favoriteIcon}
</button>
<button
onClick={toggleDrawerFullScreen}
className="py-2 text-gray-500 hover:text-gray-800 mr-2"
>
{iconComponent}
</button>
<button
onClick={toggleDrawer}
className="py-2 text-gray-500 hover:text-gray-800"
>
<ChevronDoubleLeftIcon className="h-5 w-5" />
</button>
</div>
</div>
<div className="p-4">
<table className="p-4">
<tbody className="items-center">
<tr className="w-full text-xs items-center flex flex-row justify-between">
<div className="flex flex-row space-x-2 text-gray-500 items-center">
<td>
<UserIcon className="h-4 w-4" />
</td>
<td className="w-32">Username</td>
</div>
<td className="w-3/4 w-3/4 p-2 pl-0">
<input
type="text"
name="username"
value={tempRowContent.username}
onChange={handleTempRowContentChange}
onKeyDown={handleEnterPress}
className="ml-2 w-full p-1 focus:outline-gray-200 hover:bg-gray-50"
/>
</td>
</tr>
<tr className="w-full text-xs items-center flex flex-row justify-between">
<div className="flex flex-row space-x-2 text-gray-500 items-center">
<td>
<ListBulletIcon className="h-4 w-4" />
</td>
<td className="w-32">Role</td>
</div>
<td className="w-3/4 hover:bg-gray-50">
<TagsInput
presetValue={tempRowContent.role}
presetOptions={presetOptions}
setPresetOptions={setPresetOptions}
getTagColor={getTagColor}
setTagColors={setTagColors}
/>
</td>
</tr>
<tr className="w-full text-xs items-center flex flex-row justify-between">
<div className="flex flex-row space-x-2 text-gray-500 items-center">
<td>
<EnvelopeIcon className="h-4 w-4" />
</td>
<td className="w-32">Email</td>
</div>
<td className="w-3/4 p-2 pl-0">
<input
type="text"
name="email"
value={tempRowContent.email}
onChange={handleTempRowContentChange}
onKeyDown={handleEnterPress}
className="ml-2 w-80 p-1 font-normal hover:text-gray-400 focus:outline-gray-200 hover:bg-gray-50 underline text-gray-500"
/>
</td>
</tr>
<tr className="w-full text-xs items-center flex flex-row justify-between">
<div className="flex flex-row space-x-2 text-gray-500 items-center">
<td>
<ListBulletIcon className="h-4 w-4" />
</td>
<td className="w-32">Type of Program</td>
</div>
<td className="w-3/4 hover:bg-gray-50">
{/* {rowContent.program} */}
<TagsInput
presetValue={tempRowContent.program}
presetOptions={rolePresetOptions}
setPresetOptions={setRolePresetOptions}
getTagColor={getTagColor}
setTagColors={setTagColors}
/>
</td>
</tr>
</tbody>
</table>
<br />
</div>
</div>
</div>
);
};
export default Drawer;

View File

@ -0,0 +1,53 @@
import { useState } from "react";
import { ChevronDownIcon } from "@heroicons/react/24/solid";
const mockTags = ["food relief", "period poverty", "nutrition education"];
type FilterType = "contains" | "does not contain" | "is empty" | "is not empty";
export const ContainsDropdown = ({
isDropdownOpen,
setIsDropdownOpen,
filterType,
setFilterType,
}) => {
const handleFilterTypeChange = (type: FilterType) => {
setFilterType(type);
setIsDropdownOpen(false);
};
return (
<div className="relative">
<div
className={`absolute z-10 mt-8 -top-28 bg-white border border-gray-300 rounded-md shadow-md p-2 ${
isDropdownOpen ? "block" : "hidden"
}`}
>
<div
className="cursor-pointer hover:bg-gray-100 rounded"
onClick={() => handleFilterTypeChange("contains")}
>
Contains
</div>
<div
className="cursor-pointer hover:bg-gray-100 rounded"
onClick={() => handleFilterTypeChange("does not contain")}
>
Does not contain
</div>
<div
className="cursor-pointer hover:bg-gray-100 rounded"
onClick={() => handleFilterTypeChange("is empty")}
>
Is empty
</div>
<div
className="cursor-pointer hover:bg-gray-100 rounded"
onClick={() => handleFilterTypeChange("is not empty")}
>
Is not empty
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,95 @@
// FilterBox.tsx
import { useState } from "react";
import { ChevronDownIcon } from "@heroicons/react/24/solid";
import { ContainsDropdown } from "./ContainsDropdown";
const mockTags = ["food relief", "period poverty", "nutrition education"];
type FilterType = "contains" | "does not contain" | "is empty" | "is not empty";
export const FilterBox = () => {
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [showContainsDropdown, setShowContainsDropdown] = useState(false);
const [filterType, setFilterType] = useState<FilterType>("contains");
const handleTagChange = (tag: string) => {
setSelectedTags((prevTags) =>
prevTags.includes(tag)
? prevTags.filter((t) => t !== tag)
: [...prevTags, tag]
);
};
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(e.target.value);
};
const renderSelectedTags = () =>
selectedTags.map((tag) => (
<div
key={tag}
className="bg-purple-100 text-purple-800 px-2 py-1 rounded-md flex items-center mr-2"
>
<span>{tag}</span>
<span
className="ml-2 cursor-pointer"
onClick={() => handleTagChange(tag)}
>
&times;
</span>
</div>
));
return (
<div className="text-xs bg-white border border-gray-300 z-50 rounded-md p-2 shadow absolute right-5 top-[200px]">
<div className="mb-2">
<span className="font-semibold">
Tags{" "}
<button
onClick={() =>
setShowContainsDropdown((prevState) => !prevState)
}
className="hover:bg-gray-50 text-gray-500 hover:text-gray-700"
>
{filterType} <ChevronDownIcon className="inline h-3" />
</button>
</span>
</div>
<div className="flex flex-wrap mb-2 px-2 py-1 border border-gray-300 rounded w-full">
{selectedTags.length > 0 && renderSelectedTags()}
<input
type="text"
value={searchTerm}
onChange={handleSearchChange}
placeholder="Search tags..."
/>
</div>
<div className="max-h-48 overflow-y-auto">
{mockTags
.filter((tag) =>
tag.toLowerCase().includes(searchTerm.toLowerCase())
)
.map((tag) => (
<div key={tag} className="flex items-center">
<input
type="checkbox"
checked={selectedTags.includes(tag)}
onChange={() => handleTagChange(tag)}
className="mr-2 accent-purple-500"
/>
<label>{tag}</label>
</div>
))}
</div>
{showContainsDropdown && (
<ContainsDropdown
isDropdownOpen={showContainsDropdown}
setIsDropdownOpen={setShowContainsDropdown}
filterType={filterType}
setFilterType={setFilterType}
/>
)}
</div>
);
};

View File

@ -1,16 +1,21 @@
import React, { ReactNode } from 'react';
import React, { ReactNode } from "react";
interface Link {
href?: string;
children: ReactNode;
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
href?: string;
children: ReactNode;
}
const InlineLink: React.FC<Link> = ({href = '#', children}) => {
const InlineLink: React.FC<Link> = ({ href = "#", children, onClick }) => {
return (
<a href={href} className='text-sm text-purple-600 hover:underline font-semibold'>
<a
onClick={onClick}
href={href}
className="text-sm text-purple-600 hover:underline font-semibold"
>
{children}
</a>
)
}
);
};
export default InlineLink;
export default InlineLink;

View File

@ -1,37 +1,57 @@
import React, { FunctionComponent, InputHTMLAttributes, ReactNode, ChangeEvent } from 'react';
type InputProps = InputHTMLAttributes<HTMLInputElement> & {
icon?: ReactNode;
title?: ReactNode;
type?:ReactNode;
placeholder?:ReactNode
valid?:boolean;
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
};
const Input: FunctionComponent<InputProps> = ({ icon, type, title, placeholder, onChange, valid = true, ...rest }) => {
return (
<div>
<label
htmlFor={title}
className={valid ? "block overflow-hidden rounded-md border border-gray-200 px-3 py-2 shadow-sm focus-within:border-purple-600 focus-within:ring-1 focus-within:ring-purple-600" : "block overflow-hidden rounded-md border border-gray-200 px-3 py-2 shadow-sm focus-within:border-red-600 focus-within:ring-1 focus-within:ring-red-600"}
>
<span className="text-xs font-semibold text-gray-700"> {title} </span>
<div className="mt-1 flex items-center">
<input
type={type}
id={title}
placeholder={placeholder}
onChange={onChange}
className="w-full border-none p-0 focus:border-transparent focus:outline-none focus:ring-0 sm:text-sm"
/>
<span className="inline-flex items-center px-3 text-gray-500">
{icon}
</span>
</div>
</label>
</div>
);
};
export default Input;
import React, {
FunctionComponent,
InputHTMLAttributes,
ReactNode,
ChangeEvent,
} from "react";
type InputProps = InputHTMLAttributes<HTMLInputElement> & {
icon?: ReactNode;
title?: ReactNode;
type?: ReactNode;
placeholder?: ReactNode;
valid?: boolean;
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
};
const Input: FunctionComponent<InputProps> = ({
icon,
type,
title,
placeholder,
onChange,
valid = true,
...rest
}) => {
return (
<div>
<label
htmlFor={title}
className={
valid
? "block overflow-hidden rounded-md border border-gray-200 px-3 py-2 shadow-sm focus-within:border-purple-600 focus-within:ring-1 focus-within:ring-purple-600"
: "block overflow-hidden rounded-md border border-gray-200 px-3 py-2 shadow-sm focus-within:border-red-600 focus-within:ring-1 focus-within:ring-red-600"
}
>
<span className="text-xs font-semibold text-gray-700">
{" "}
{title}{" "}
</span>
<div className="mt-1 flex items-center">
<input
type={type}
id={title}
placeholder={placeholder}
onChange={onChange}
className="w-full border-none p-0 focus:border-transparent focus:outline-none focus:ring-0 sm:text-sm"
/>
<span className="inline-flex items-center px-3 text-gray-500">
{icon}
</span>
</div>
</label>
</div>
);
};
export default Input;

View File

@ -0,0 +1,27 @@
interface PageLayoutProps {
icon: React.ReactElement;
title: string;
children: React.ReactElement;
}
export const PageLayout: React.FC<PageLayoutProps> = ({
icon,
title,
children,
}) => {
return (
<div className="min-h-screen flex flex-col">
{/* icon + title */}
<div className="pt-16 px-8 pb-4 flex-row">
<div className="mb-4 flex items-center space-x-4">
<span className="w-6 h-6 text-purple-200">{icon}</span>
<h1 className="font-bold text-2xl text-purple-800">
{title}
</h1>
</div>
</div>
{/* data */}
<div className="px-8 py-8">{children}</div>
</div>
);
};

View File

@ -0,0 +1,89 @@
import React from "react";
import {
HomeIcon,
ChevronDoubleLeftIcon,
BookmarkIcon,
ClipboardIcon,
BookOpenIcon,
LockClosedIcon,
} from "@heroicons/react/24/solid";
import { SidebarItem } from "./SidebarItem";
import { UserProfile } from "../resource/UserProfile";
interface SidebarProps {
setIsSidebarOpen: React.Dispatch<React.SetStateAction<boolean>>;
name: string;
email: string;
isAdmin: boolean;
}
const Sidebar: React.FC<SidebarProps> = ({
setIsSidebarOpen,
name,
email,
isAdmin: admin,
}) => {
return (
<div className="w-64 h-full border border-gray-200 bg-gray-50 px-4">
{/* button to close sidebar */}
<div className="flex justify-end">
<button
onClick={() => setIsSidebarOpen(false)}
className="py-2 text-gray-500 hover:text-gray-800"
aria-label="Close sidebar"
>
<ChevronDoubleLeftIcon className="h-5 w-5" />
</button>
</div>
<div className="flex flex-col space-y-8">
{/* user + logout button */}
<div className="flex items-center p-4 space-x-2 border border-gray-200 rounded-md ">
<UserProfile name={name} email={email} />
</div>
{/* navigation menu */}
<div className="flex flex-col space-y-2">
<h4 className="text-xs font-semibold text-gray-500">
Pages
</h4>
<nav className="flex flex-col">
{admin && (
<SidebarItem
icon={<LockClosedIcon />}
text="Admin"
active={true}
redirect="/admin"
/>
)}
<SidebarItem
icon={<HomeIcon />}
text="Home"
active={true}
redirect="/home"
/>
<SidebarItem
icon={<BookmarkIcon />}
text="Resources"
active={true}
redirect="/resource"
/>
<SidebarItem
icon={<ClipboardIcon />}
text="Services"
active={true}
redirect="/service"
/>
<SidebarItem
icon={<BookOpenIcon />}
text="Training Manuals"
active={true}
redirect="/training-manuals"
/>
</nav>
</div>
</div>
</div>
);
};
export default Sidebar;

View File

@ -0,0 +1,31 @@
import Link from "next/link";
interface SidebarItemProps {
icon: React.ReactElement;
text: string;
active: boolean;
redirect: string;
}
export const SidebarItem: React.FC<SidebarItemProps> = ({
icon,
text,
active,
redirect,
}) => {
return (
<Link
href={redirect}
className={
active
? "flex items-center p-2 my-1 space-x-2 bg-gray-200 rounded-md"
: "flex items-center p-2 my-1 space-x-2 hover:bg-gray-200 rounded-md"
}
>
<span className="h-5 text-gray-500 w-5">{icon}</span>
<span className="flex-grow font-medium text-xs text-gray-500">
{text}
</span>
</Link>
);
};

View File

@ -0,0 +1,306 @@
// 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<any>,
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<User>();
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) => (
<RowOptionMenu
onDelete={() => deleteUser(props.row.original.id)}
onHide={() => hideUser(props.row.original.id)}
/>
),
}),
columnHelper.accessor("username", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Username
</>
),
cell: (info) => (
<RowOpenAction
title={info.getValue()}
rowData={info.row.original}
onRowUpdate={handleRowUpdate}
/>
),
}),
columnHelper.accessor("role", {
header: () => (
<>
<ArrowDownCircleIcon className="inline align-top h-4" />{" "}
Role
</>
),
cell: (info) => (
<TagsInput
presetValue={info.getValue()}
presetOptions={presetOptions}
setPresetOptions={setPresetOptions}
getTagColor={getTagColor}
setTagColors={setTagColors}
/>
),
}),
columnHelper.accessor("email", {
header: () => (
<>
<AtSymbolIcon className="inline align-top h-4" /> Email
</>
),
cell: (info) => (
<span className="ml-2 text-gray-500 underline hover:text-gray-400">
{info.getValue()}
</span>
),
}),
columnHelper.accessor("program", {
header: () => (
<>
<ArrowDownCircleIcon className="inline align-top h-4" />{" "}
Program
</>
),
cell: (info) => <TagsInput presetValue={info.getValue()} />,
}),
];
const [data, setData] = useState<User[]>([...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 (
<div className="flex flex-col">
<div className="flex flex-row justify-end">
<TableAction query={query} handleChange={handleSearchChange} />
</div>
<table className="w-full text-xs text-left rtl:text-right">
<thead className="text-xs text-gray-500 capitalize">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header, i) => (
<th
scope="col"
className={
"p-2 border-gray-200 border-y font-medium " +
(1 < i && i < columns.length - 1
? "border-x"
: "")
}
key={header.id}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{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 (
<tr className={rowClassNames} key={row.id}>
{row.getVisibleCells().map((cell, i) => (
<td
key={cell.id}
className={
"[&:nth-child(n+3)]:border-x relative first:text-left first:px-0 last:border-none"
}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
);
})}
</tbody>
<tfoot>
<tr>
<td
className="p-3 border-y border-gray-200 text-gray-600 hover:bg-gray-50"
colSpan={100}
onClick={addUser}
>
<span className="flex ml-1 text-gray-500">
<PlusIcon className="inline h-4 mr-1" />
New
</span>
</td>
</tr>
</tfoot>
</table>
</div>
);
};

View File

@ -0,0 +1,33 @@
/* An extension of TableCell.tsx that includes an "open" button and the drawer.
For cells in the "primary" (or first) column of the table. */
import Drawer from "@/components/Drawer/Drawer";
import { TableCell } from "./TableCell";
import { SetStateAction, useState } from "react";
export const PrimaryTableCell = ({ getValue, row, column, table }) => {
const [pageContent, setPageContent] = useState("");
const handleDrawerContentChange = (newContent: SetStateAction<string>) => {
setPageContent(newContent);
};
return (
<div className="font-semibold group">
<TableCell
getValue={getValue}
row={row}
column={column}
table={table}
/>
<span className="absolute right-1 top-1">
<Drawer
title={getValue()}
editableContent={pageContent}
onSave={handleDrawerContentChange}
>
{pageContent}
</Drawer>
</span>
</div>
);
};

View File

@ -0,0 +1,304 @@
// 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 Resource from "@/utils/models/Resource";
// For search
const fuzzyFilter = (
row: Row<any>,
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 resources
export const ResourceTable = ({ users }: { users: Resource[] }) => {
const columnHelper = createColumnHelper<Resource>();
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) => (
<RowOptionMenu
onDelete={() => {}}
onHide={() => hideUser(props.row.original.id)}
/>
),
}),
columnHelper.accessor("name", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Name
</>
),
cell: (info) => (
<RowOpenAction
title={info.getValue()}
rowData={info.row.original}
onRowUpdate={handleRowUpdate}
/>
),
}),
columnHelper.accessor("link", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Link
</>
),
cell: (info) => (
<a
href={info.getValue()}
target={"_blank"}
className="ml-2 text-gray-500 underline hover:text-gray-400"
>
{info.getValue()}
</a>
),
}),
columnHelper.accessor("program", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Program
</>
),
cell: (info) => <TagsInput presetValue={info.getValue()} />,
}),
columnHelper.accessor("summary", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Summary
</>
),
cell: (info) => (
<span className="ml-2 text-gray-500">{info.getValue()}</span>
),
}),
];
const [data, setData] = useState<Resource[]>([...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: Resource) => {
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 (
<div className="flex flex-col">
<div className="flex flex-row justify-end">
<TableAction query={query} handleChange={handleSearchChange} />
</div>
<table className="w-full text-xs text-left rtl:text-right">
<thead className="text-xs text-gray-500 capitalize">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header, i) => (
<th
scope="col"
className={
"p-2 border-gray-200 border-y font-medium " +
(1 < i && i < columns.length - 1
? "border-x"
: "")
}
key={header.id}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{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 (
<tr className={rowClassNames} key={row.id}>
{row.getVisibleCells().map((cell, i) => (
<td
key={cell.id}
className={
"[&:nth-child(n+3)]:border-x relative first:text-left first:px-0 last:border-none"
}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
);
})}
</tbody>
<tfoot>
<tr>
<td
className="p-3 border-y border-gray-200 text-gray-600 hover:bg-gray-50"
colSpan={100}
onClick={addUser}
>
<span className="flex ml-1 text-gray-500">
<PlusIcon className="inline h-4 mr-1" />
New
</span>
</td>
</tr>
</tfoot>
</table>
</div>
);
};

View File

@ -0,0 +1,28 @@
import Drawer from "@/components/Drawer/Drawer";
import { ChangeEvent, useState } from "react";
export const RowOpenAction = ({ title, rowData, onRowUpdate }) => {
const [pageContent, setPageContent] = useState("");
const handleDrawerContentChange = (newContent) => {
setPageContent(newContent);
};
return (
<div className="font-semibold group flex flex-row items-center justify-between pr-2">
{title}
<span>
{/* Added OnRowUpdate to drawer */}
<Drawer
title="My Drawer Title"
editableContent={pageContent}
rowContent={rowData}
onSave={handleDrawerContentChange}
onRowUpdate={onRowUpdate}
>
{pageContent}
</Drawer>
</span>
</div>
);
};

View File

@ -0,0 +1,18 @@
import React from "react";
import {
TrashIcon,
DocumentDuplicateIcon,
ArrowUpRightIcon,
EyeSlashIcon,
} from "@heroicons/react/24/solid";
export const RowOption = ({ icon: Icon, label, onClick }) => {
return (
<button
onClick={onClick}
className="hover:bg-gray-100 flex items-center gap-2 p-2 w-full"
>
<Icon className="inline h-4" /> {label}
</button>
);
};

View File

@ -0,0 +1,46 @@
//delete, duplicate, open
import {
TrashIcon,
DocumentDuplicateIcon,
ArrowUpRightIcon,
EllipsisVerticalIcon,
EyeSlashIcon,
} from "@heroicons/react/24/solid";
import Button from "../Button";
import { useState, useEffect, useRef } from "react";
import { RowOption } from "./RowOption";
export const RowOptionMenu = ({ onDelete, onHide }) => {
const [menuOpen, setMenuOpen] = useState(false);
const openMenu = () => setMenuOpen(true);
const closeMenu = () => setMenuOpen(false);
// TODO: Hide menu if clicked elsewhere
return (
<>
<button
className="items-end"
onClick={() => setMenuOpen(!menuOpen)}
>
<EllipsisVerticalIcon className="h-4" />
</button>
<div
className={
"justify-start border border-gray-200 shadow-lg flex flex-col absolute bg-white w-auto p-2 rounded [&>*]:rounded z-10" +
(!menuOpen ? " invisible" : "")
}
>
<RowOption icon={TrashIcon} label="Delete" onClick={onDelete} />
<RowOption
icon={ArrowUpRightIcon}
label="Open"
onClick={() => {
/* handle open */
}}
/>
<RowOption icon={EyeSlashIcon} label="Hide" onClick={onHide} />
</div>
</>
);
};

View File

@ -0,0 +1,312 @@
// 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<any>,
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<Service>();
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) => (
<RowOptionMenu
onDelete={() => {}}
onHide={() => hideUser(props.row.original.id)}
/>
),
}),
columnHelper.accessor("name", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Name
</>
),
cell: (info) => (
<RowOpenAction
title={info.getValue()}
rowData={info.row.original}
onRowUpdate={handleRowUpdate}
/>
),
}),
columnHelper.accessor("status", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Status
</>
),
cell: (info) => (
<span className="ml-2 text-gray-500">{info.getValue()}</span>
),
}),
columnHelper.accessor("program", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Program
</>
),
cell: (info) => <TagsInput presetValue={info.getValue()} />,
}),
columnHelper.accessor("requirements", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Requirements
</>
),
cell: (info) => (
<TagsInput
presetValue={
info.getValue()[0] !== "" ? info.getValue() : ["N/A"]
}
/>
),
}),
columnHelper.accessor("summary", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Summary
</>
),
cell: (info) => (
<span className="ml-2 text-gray-500">{info.getValue()}</span>
),
}),
];
const [data, setData] = useState<Service[]>([...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 (
<div className="flex flex-col">
<div className="flex flex-row justify-end">
<TableAction query={query} handleChange={handleSearchChange} />
</div>
<table className="w-full text-xs text-left rtl:text-right">
<thead className="text-xs text-gray-500 capitalize">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header, i) => (
<th
scope="col"
className={
"p-2 border-gray-200 border-y font-medium " +
(1 < i && i < columns.length - 1
? "border-x"
: "")
}
key={header.id}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{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 (
<tr className={rowClassNames} key={row.id}>
{row.getVisibleCells().map((cell, i) => (
<td
key={cell.id}
className={
"[&:nth-child(n+3)]:border-x relative first:text-left first:px-0 last:border-none"
}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
);
})}
</tbody>
<tfoot>
<tr>
<td
className="p-3 border-y border-gray-200 text-gray-600 hover:bg-gray-50"
colSpan={100}
onClick={addUser}
>
<span className="flex ml-1 text-gray-500">
<PlusIcon className="inline h-4 mr-1" />
New
</span>
</td>
</tr>
</tfoot>
</table>
</div>
);
};

View File

@ -0,0 +1,69 @@
// TableAction.tsx
import { MagnifyingGlassIcon } from "@heroicons/react/24/solid";
import { ChangeEventHandler, FunctionComponent, useRef, useState } from "react";
import { FilterBox } from "../FilterBox";
type TableActionProps = {
query: string;
handleChange: ChangeEventHandler<HTMLInputElement>;
};
export const TableAction: FunctionComponent<TableActionProps> = ({
query,
handleChange,
}) => {
const searchInput = useRef<HTMLInputElement>(null);
const [searchActive, setSearchActive] = useState(false);
const [showFilterBox, setShowFilterBox] = useState(false);
const activateSearch = () => {
setSearchActive(true);
if (searchInput.current === null) {
return;
}
searchInput.current.focus();
searchInput.current.addEventListener("focusout", () => {
if (searchInput.current?.value.trim() === "") {
searchInput.current.value = "";
deactivateSearch();
}
});
};
const deactivateSearch = () => setSearchActive(false);
const toggleFilterBox = () => setShowFilterBox((prev) => !prev);
return (
<div className="w-auto flex flex-row gap-x-0.5 items-center justify-between text-xs font-medium text-gray-500 p-2">
<span
className="p-1 rounded hover:text-purple-700 focus:bg-purple-50 hover:bg-purple-50"
onClick={toggleFilterBox}
>
Filter
</span>
{showFilterBox && <FilterBox />}
<span className="p-1 rounded hover:text-purple-700 focus:bg-purple-50 hover:bg-purple-50 hover:bg-gray-100">
Sort
</span>
<span
className="p-1 rounded hover:text-purple-700 focus:bg-purple-50 hover:bg-purple-50 hover:bg-gray-100"
onClick={activateSearch}
>
<MagnifyingGlassIcon className="w-4 h-4 inline" />
</span>
<input
ref={searchInput}
className={
"outline-none transition-all duration-300 " +
(searchActive ? "w-48" : "w-0")
}
type="text"
name="search"
placeholder="Type to search..."
value={query ?? ""}
onChange={handleChange}
/>
</div>
);
};

View File

@ -0,0 +1,29 @@
/* A lone table cell. Passed in for "cell" for a TanStack Table. */
import { useState, useEffect } from "react";
export const TableCell = ({ getValue, row, column, table }) => {
const initialValue = getValue();
const [value, setValue] = useState(initialValue);
useEffect(() => {
setValue(initialValue);
}, [initialValue]);
const onBlur = () => {
table.options.meta?.updateData(row.index, column.id, value);
};
// focus:border focus:border-gray-200
const className =
"w-full p-3 bg-inherit rounded-md outline-none border border-transparent relative " +
"focus:shadow-md focus:border-gray-200 focus:bg-white focus:z-20 focus:p-4 focus:-m-1 " +
"focus:w-[calc(100%+0.5rem)]";
return (
<input
className={className}
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={onBlur}
/>
);
};

Some files were not shown because too many files have changed in this diff Show More