Compare commits

..

2 Commits

Author SHA1 Message Date
Emma Foster
99e43c7b30
Resource Service and Tests (#41)
* Implement Resource service, tests, and test data with 97% coverage.

* Update slug service to return empty list and corresponding tests

* Update resource update tests to grab resource by id from the db to check
2024-11-04 15:10:58 -05:00
Nicolas Asanov
2e0dd3b987
Refactored Table Components (#43)
* Created mock/test table and resource page to see if implementation works

* Fixed typing for TagsInput

* cleaned up imports

* Started moving data manipulation into Table

* moved data manipulation logic into Table

* added useTagsHandler custom hook to consolidate getTagColor and presetOptions state into one function

* Fixed type errors for RowOpenAction

* Refactored ServiceIndex

* Refactored user table

* Updated imports and client facing routes

* added documentation for table components

* Added documentation for TagsHandler

* small changes for cleaner code

* refactored typing for tables. More work needs to be done to ensure tables are overall working properly

* added todo

* updated client paths with new table props

* alterned handleRowUpdate to only use setData

* diverted responsibility of handleRowChange to Drawer instead of Table to remove repetition

* updated documentation

* added sorting util function to Table.tsx to reduce repetition

* Edited sorting func to be more comaptible and edited hideData to be more concise

* formatting

* updated imports

* updated tags for all tables

* removed DataPoint dependecy from User, Service, and Resource models as it was unnecesary

* Added inline documentation to table components

* added documentation for DataPoint model

* Update package-lock.json
2024-11-04 15:10:13 -05:00
20 changed files with 1038 additions and 1240 deletions

View File

@ -35,17 +35,15 @@ class ResourceService:
def create(self, user: User, resource: Resource) -> Resource: 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. Creates a resource based on the input object and adds it to the table if the user has the right permissions.
Parameters: Parameters:
user: a valid User model representing the currently logged in User user: a valid User model representing the currently logged in User
resource: Resource object to add to table resource: Resource object to add to table
Returns: Returns:
Resource: Object added to table Resource: Object added to table
""" """
if resource.role != user.role or resource.group != user.group: if user.role != UserTypeEnum.ADMIN:
raise PermissionError( raise PermissionError(
"User does not have permission to add resources in this role or group." "User does not have permission to add resources in this program."
) )
resource_entity = ResourceEntity.from_model(resource) resource_entity = ResourceEntity.from_model(resource)
@ -57,14 +55,11 @@ class ResourceService:
def get_by_id(self, user: User, id: int) -> Resource: def get_by_id(self, user: User, id: int) -> Resource:
""" """
Gets a resource based on the resource id that the user has access to Gets a resource based on the resource id that the user has access to
Parameters: Parameters:
user: a valid User model representing the currently logged in User user: a valid User model representing the currently logged in User
id: int, the id of the resource id: int, the id of the resource
Returns: Returns:
Resource Resource
Raises: Raises:
ResourceNotFoundException: If no resource is found with id ResourceNotFoundException: If no resource is found with id
""" """
@ -72,8 +67,7 @@ class ResourceService:
self._session.query(ResourceEntity) self._session.query(ResourceEntity)
.filter( .filter(
ResourceEntity.id == id, ResourceEntity.id == id,
ResourceEntity.role == user.role, ResourceEntity.program.in_(user.program),
ResourceEntity.group == user.group,
) )
.one_or_none() .one_or_none()
) )
@ -86,18 +80,15 @@ class ResourceService:
def update(self, user: User, resource: ResourceEntity) -> Resource: def update(self, user: User, resource: ResourceEntity) -> Resource:
""" """
Update the resource if the user has access Update the resource if the user has access
Parameters: Parameters:
user: a valid User model representing the currently logged in User user: a valid User model representing the currently logged in User
resource (ResourceEntity): Resource to update resource (ResourceEntity): Resource to update
Returns: Returns:
Resource: Updated resource object Resource: Updated resource object
Raises: Raises:
ResourceNotFoundException: If no resource is found with the corresponding ID ResourceNotFoundException: If no resource is found with the corresponding ID
""" """
if resource.role != user.role or resource.group != user.group: if user.role != UserTypeEnum.ADMIN:
raise PermissionError( raise PermissionError(
"User does not have permission to update this resource." "User does not have permission to update this resource."
) )
@ -109,7 +100,11 @@ class ResourceService:
f"No resource found with matching id: {resource.id}" f"No resource found with matching id: {resource.id}"
) )
obj.update_from_model(resource) # Assuming an update method exists obj.name = resource.name
obj.summary = resource.summary
obj.link = resource.link
obj.program = resource.program
self._session.commit() self._session.commit()
return obj.to_model() return obj.to_model()
@ -117,20 +112,21 @@ class ResourceService:
def delete(self, user: User, id: int) -> None: def delete(self, user: User, id: int) -> None:
""" """
Delete resource based on id that the user has access to Delete resource based on id that the user has access to
Parameters: Parameters:
user: a valid User model representing the currently logged in User user: a valid User model representing the currently logged in User
id: int, a unique resource id id: int, a unique resource id
Raises: Raises:
ResourceNotFoundException: If no resource is found with the corresponding id ResourceNotFoundException: If no resource is found with the corresponding id
""" """
if user.role != UserTypeEnum.ADMIN:
raise PermissionError(
"User does not have permission to delete this resource."
)
resource = ( resource = (
self._session.query(ResourceEntity) self._session.query(ResourceEntity)
.filter( .filter(
ResourceEntity.id == id, ResourceEntity.id == id,
ResourceEntity.role == user.role,
ResourceEntity.group == user.group,
) )
.one_or_none() .one_or_none()
) )
@ -144,22 +140,21 @@ class ResourceService:
def get_by_slug(self, user: User, search_string: str) -> list[Resource]: 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 Get a list of resources given a search string that the user has access to
Parameters: Parameters:
user: a valid User model representing the currently logged in User user: a valid User model representing the currently logged in User
search_string: a string to search resources by search_string: a string to search resources by
Returns: Returns:
list[Resource]: list of resources relating to the string list[Resource]: list of resources relating to the string
Raises: Raises:
ResourceNotFoundException if no resource is found with the corresponding slug ResourceNotFoundException if no resource is found with the corresponding slug
""" """
query = select(ResourceEntity).where( query = select(ResourceEntity).where(
ResourceEntity.title.ilike(f"%{search_string}%"), ResourceEntity.name.ilike(f"%{search_string}%"),
ResourceEntity.role == user.role, ResourceEntity.program.in_(user.program)
ResourceEntity.group == user.group,
) )
entities = self._session.scalars(query).all() entities = self._session.scalars(query).all()
return [entity.to_model() for entity in entities] if not entities:
return []
return [entity.to_model() for entity in entities]

View File

@ -4,7 +4,7 @@ import pytest
from sqlalchemy import Engine, create_engine, text from sqlalchemy import Engine, create_engine, text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.exc import OperationalError from sqlalchemy.exc import OperationalError
from .services import user_test_data, tag_test_data, service_test_data from .services import user_test_data, tag_test_data, service_test_data, resource_test_data
from ..database import _engine_str from ..database import _engine_str
from ..env import getenv from ..env import getenv
@ -57,5 +57,6 @@ def setup_insert_data_fixture(session: Session):
user_test_data.insert_fake_data(session) user_test_data.insert_fake_data(session)
tag_test_data.insert_fake_data(session) tag_test_data.insert_fake_data(session)
service_test_data.insert_fake_data(session) service_test_data.insert_fake_data(session)
resource_test_data.insert_fake_data(session)
session.commit() session.commit()
yield yield

View File

@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
from ...services import UserService from ...services import UserService
from ...services import TagService from ...services import TagService
from ...services import ServiceService from ...services import ServiceService
from ...services import ResourceService
@ -23,4 +24,9 @@ def tag_svc(session: Session):
@pytest.fixture() @pytest.fixture()
def service_svc(session: Session): def service_svc(session: Session):
"""This fixture is used to test the ServiceService class""" """This fixture is used to test the ServiceService class"""
return ServiceService(session) return ServiceService(session)
@pytest.fixture()
def resource_svc(session: Session):
"""This fixture is used to test the ResourceService class"""
return ResourceService(session)

View File

@ -0,0 +1,126 @@
from backend.models.user_model import User
from backend.entities.resource_entity import ResourceEntity
from ...models.enum_for_models import ProgramTypeEnum
from backend.services.resource import ResourceService
from backend.services.tag import TagService
from backend.services.exceptions import ResourceNotFoundException
from . import resource_test_data
from . import user_test_data
from .fixtures import resource_svc, user_svc, tag_svc
from backend.models.resource_model import Resource
import pytest
def test_get_resource_by_user_volunteer(resource_svc: ResourceService):
""" Test getting resources by a volunteer """
resources = resource_svc.get_resource_by_user(user_test_data.volunteer)
assert len(resources) == 2
assert isinstance(resources[0], Resource)
def test_get_resources_admin(resource_svc: ResourceService):
""" Test getting resources by an admin """
resources = resource_svc.get_resource_by_user(user_test_data.admin)
assert len(resources) == len(resource_test_data.resources)
assert isinstance(resources[0], Resource)
def test_get_resources_employee(resource_svc: ResourceService):
""" Test getting by an employee """
resources = resource_svc.get_resource_by_user(user_test_data.employee)
assert len(resources) == 5
assert isinstance(resources[0], Resource)
def test_create_resource_admin(resource_svc: ResourceService):
""" Test creating resources as an admin """
resource = resource_svc.create(user_test_data.admin, resource_test_data.resource6)
assert resource.name == resource_test_data.resource6.name
assert isinstance(resource, Resource)
def test_create_not_permitted(resource_svc: ResourceService):
""" Test creating resources without permission """
with pytest.raises(PermissionError):
resource = resource_svc.create(user_test_data.volunteer, resource_test_data.resource1)
pytest.fail()
def test_get_by_id(resource_svc: ResourceService):
""" Test getting a resource by id as an admin """
test_resource = resource_test_data.resource1
resource = resource_svc.get_by_id(user_test_data.admin, test_resource.id)
assert resource is not None
assert resource.id == test_resource.id
assert resource.name == test_resource.name
def test_get_by_id_no_access(resource_svc: ResourceService):
""" Test getting a resourced with an id no accessible to an employee """
test_resource = resource_test_data.resource2
with pytest.raises(ResourceNotFoundException):
resource = resource_svc.get_by_id(user_test_data.employee, test_resource.id)
pytest.fail()
def test_update(resource_svc: ResourceService):
""" Test updating a resource by an admin """
updated_resource = resource_test_data.resource5_new
resource = resource_svc.update(user_test_data.admin, updated_resource)
db_resource = resource_svc.get_by_id(user_test_data.admin, resource.id)
assert resource.id == updated_resource.id
assert resource.name == updated_resource.name
assert resource.summary == updated_resource.summary
assert resource.link == updated_resource.link
assert resource.program == updated_resource.program
assert db_resource.id == updated_resource.id
assert db_resource.name == updated_resource.name
assert db_resource.summary == updated_resource.summary
assert db_resource.link == updated_resource.link
assert db_resource.program == updated_resource.program
def test_update_no_permission(resource_svc: ResourceService):
""" Test updating a resource without permission """
with pytest.raises(PermissionError):
resource = resource_svc.update(user_test_data.employee, resource_test_data.resource5_new)
pytest.fail()
def test_delete(resource_svc: ResourceService):
""" Test deleting a resource as an admin """
resource_svc.delete(user_test_data.admin, resource_test_data.resource5.id)
resources = resource_svc.get_resource_by_user(user_test_data.admin)
assert len(resources) == len(resource_test_data.resources) - 1
def test_delete_no_permission(resource_svc: ResourceService):
""" Test deleting a resource with no permission """
with pytest.raises(PermissionError):
resource = resource_svc.delete(user_test_data.employee, resource_test_data.resource5.id)
pytest.fail()
def test_get_1_by_slug(resource_svc: ResourceService):
""" Test getting 1 resource with a specific search """
resource_to_test = resource_test_data.resource1
slug = "Resource 1"
resources = resource_svc.get_by_slug(user_test_data.admin, slug)
assert len(resources) == 1
resource = resources[0]
assert resource.id == resource_to_test.id
assert resource.name == resource_to_test.name
assert resource.summary == resource_to_test.summary
assert resource.link == resource_to_test.link
assert resource.program == resource_to_test.program
def test_get_by_slug(resource_svc: ResourceService):
""" Test a generic search to get all resources """
slug = "Resource"
resources = resource_svc.get_by_slug(user_test_data.admin, slug)
assert len(resources) == 5
def test_get_by_slug_not_found(resource_svc: ResourceService):
""" Test getting a resource that does not exist """
slug = "Not Found"
resources = resource_svc.get_by_slug(user_test_data.admin, slug)
assert len(resources) == 0
assert resources == []
def test_get_by_slug_no_permission(resource_svc: ResourceService):
""" Test getting a resource the user does not have access to """
slug = "Resource 2"
resources = resource_svc.get_by_slug(user_test_data.employee, slug)
assert len(resources) == 0
assert resources == []

View File

@ -50,6 +50,24 @@ resource5 = Resource(
created_at=datetime(2023, 6, 5, 11, 30, 0), created_at=datetime(2023, 6, 5, 11, 30, 0),
) )
resource6 = Resource(
id=6,
name="Resource 6",
summary="New Financial Resource",
link="https://example.com/resource6",
program=ProgramTypeEnum.ECONOMIC,
created_at=datetime(2024, 6, 5, 11, 30, 0),
)
resource5_new = Resource(
id=5,
name="Resource 5",
summary = "Updated shelter and housing resources",
link="https://example.com/resource5/new",
program=ProgramTypeEnum.DOMESTIC,
created_at=datetime(2023, 6, 5, 11, 30, 0),
)
resources = [resource1, resource2, resource3, resource4, resource5] resources = [resource1, resource2, resource3, resource4, resource5]
resource_1 = Resource( resource_1 = Resource(
@ -266,13 +284,11 @@ def reset_table_id_seq(
next_id: int, next_id: int,
) -> None: ) -> None:
"""Reset the ID sequence of an entity table. """Reset the ID sequence of an entity table.
Args: Args:
session (Session) - A SQLAlchemy Session session (Session) - A SQLAlchemy Session
entity (DeclarativeBase) - The SQLAlchemy Entity table to target entity (DeclarativeBase) - The SQLAlchemy Entity table to target
entity_id_column (MappedColumn) - The ID column (should be an int column) entity_id_column (MappedColumn) - The ID column (should be an int column)
next_id (int) - Where the next inserted, autogenerated ID should begin next_id (int) - Where the next inserted, autogenerated ID should begin
Returns: Returns:
None""" None"""
table = entity.__table__ table = entity.__table__
@ -312,4 +328,4 @@ def insert_fake_data(session: Session):
reset_table_id_seq(session, ResourceEntity, ResourceEntity.id, len(resources) + 1) reset_table_id_seq(session, ResourceEntity, ResourceEntity.id, len(resources) + 1)
# Commit all changes # Commit all changes
session.commit() session.commit()

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { PageLayout } from "@/components/PageLayout"; import { PageLayout } from "@/components/PageLayout";
import { Table } from "@/components/Table/Index"; import UserTable from "@/components/Table/UserTable";
import User from "@/utils/models/User"; import User from "@/utils/models/User";
import { createClient } from "@/utils/supabase/client"; import { createClient } from "@/utils/supabase/client";
@ -38,7 +38,7 @@ export default function Page() {
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">
{/* icon + title */} {/* icon + title */}
<PageLayout title="Users" icon={<UsersIcon />}> <PageLayout title="Users" icon={<UsersIcon />}>
<Table users={users} /> <UserTable data={users} setData={setUsers} />
</PageLayout> </PageLayout>
</div> </div>
); );

View File

@ -1,8 +1,8 @@
"use client"; "use client";
import { PageLayout } from "@/components/PageLayout"; import { PageLayout } from "@/components/PageLayout";
import { ResourceTable } from "@/components/Table/ResourceIndex";
import Resource from "@/utils/models/Resource"; import Resource from "@/utils/models/Resource";
import ResourceTable from "@/components/Table/ResourceTable";
import { createClient } from "@/utils/supabase/client"; import { createClient } from "@/utils/supabase/client";
import { BookmarkIcon } from "@heroicons/react/24/solid"; import { BookmarkIcon } from "@heroicons/react/24/solid";
@ -27,7 +27,7 @@ export default function Page() {
); );
const resourcesAPI: Resource[] = await userListData.json(); const resourcesAPI: Resource[] = await userListData.json();
setResources(resourcesAPI); setResources(resourcesAPI);
} }
@ -38,7 +38,7 @@ export default function Page() {
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">
{/* icon + title */} {/* icon + title */}
<PageLayout title="Resources" icon={<BookmarkIcon />}> <PageLayout title="Resources" icon={<BookmarkIcon />}>
<ResourceTable users={resources} /> <ResourceTable data={resources} setData={setResources} />
</PageLayout> </PageLayout>
</div> </div>
); );

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { PageLayout } from "@/components/PageLayout"; import { PageLayout } from "@/components/PageLayout";
import { ServiceTable } from "@/components/Table/ServiceIndex"; import ServiceTable from "@/components/Table/ServiceTable";
import Service from "@/utils/models/Service"; import Service from "@/utils/models/Service";
import { createClient } from "@/utils/supabase/client"; import { createClient } from "@/utils/supabase/client";
@ -9,7 +9,7 @@ import { ClipboardIcon } from "@heroicons/react/24/solid";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
export default function Page() { export default function Page() {
const [services, setUsers] = useState<Service[]>([]); const [services, setServices] = useState<Service[]>([]);
useEffect(() => { useEffect(() => {
async function getServices() { async function getServices() {
@ -27,7 +27,7 @@ export default function Page() {
); );
const servicesAPI: Service[] = await serviceListData.json(); const servicesAPI: Service[] = await serviceListData.json();
setUsers(servicesAPI); setServices(servicesAPI);
} }
getServices(); getServices();
@ -37,7 +37,7 @@ export default function Page() {
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">
{/* icon + title */} {/* icon + title */}
<PageLayout title="Services" icon={<ClipboardIcon />}> <PageLayout title="Services" icon={<ClipboardIcon />}>
<ServiceTable users={services} /> <ServiceTable data={services} setData={setServices} />
</PageLayout> </PageLayout>
</div> </div>
); );

View File

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

View File

@ -1,306 +0,0 @@
// for showcasing to compass
import users from "./users.json";
import {
Cell,
ColumnDef,
Row,
createColumnHelper,
flexRender,
getCoreRowModel,
getFilteredRowModel,
sortingFns,
useReactTable,
} from "@tanstack/react-table";
import {
ChangeEvent,
useState,
useEffect,
FunctionComponent,
useRef,
ChangeEventHandler,
Key,
} from "react";
import { RowOptionMenu } from "./RowOptionMenu";
import { RowOpenAction } from "./RowOpenAction";
import { TableAction } from "./TableAction";
import {
AtSymbolIcon,
Bars2Icon,
ArrowDownCircleIcon,
PlusIcon,
} from "@heroicons/react/24/solid";
import TagsInput from "../TagsInput/Index";
import { rankItem } from "@tanstack/match-sorter-utils";
import User from "@/utils/models/User";
// For search
const fuzzyFilter = (
row: Row<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,89 @@
import { Bars2Icon } from "@heroicons/react/24/solid";
import { Dispatch, SetStateAction, useState } from "react";
import useTagsHandler from "@/components/TagsInput/TagsHandler";
import { ColumnDef, createColumnHelper } from "@tanstack/react-table";
import { RowOpenAction } from "@/components/Table/RowOpenAction";
import Table from "@/components/Table/Table";
import TagsInput from "@/components/TagsInput/Index";
import Resource from "@/utils/models/Resource";
type ResourceTableProps = {
data: Resource[],
setData: Dispatch<SetStateAction<Resource[]>>
}
/**
* Table componenet used for displaying resources
* @param props.data Stateful list of resources to be displayed by the table
* @param props.setData State setter for the list of resources
*/
export default function ResourceTable({ data, setData }: ResourceTableProps ) {
const columnHelper = createColumnHelper<Resource>();
// Set up tag handling
const programProps = useTagsHandler([
"community",
"domestic",
"economic",
])
// Define Tanstack columns
const columns: ColumnDef<Resource, any>[] = [
columnHelper.accessor("name", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Name
</>
),
cell: (info) => (
<RowOpenAction
title={info.getValue()}
rowData={info.row.original}
setData={setData}
/>
),
}),
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()}
{...programProps}
/>
),
}),
columnHelper.accessor("summary", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Summary
</>
),
cell: (info) => (
<span className="ml-2 text-gray-500">{info.getValue()}</span>
),
}),
];
return <Table data={data} setData={setData} columns={columns}/>
}

View File

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

View File

@ -1,312 +0,0 @@
// for showcasing to compass
import users from "./users.json";
import {
Cell,
ColumnDef,
Row,
createColumnHelper,
flexRender,
getCoreRowModel,
getFilteredRowModel,
sortingFns,
useReactTable,
} from "@tanstack/react-table";
import {
ChangeEvent,
useState,
useEffect,
FunctionComponent,
useRef,
ChangeEventHandler,
Key,
} from "react";
import { RowOptionMenu } from "./RowOptionMenu";
import { RowOpenAction } from "./RowOpenAction";
import { TableAction } from "./TableAction";
import {
AtSymbolIcon,
Bars2Icon,
ArrowDownCircleIcon,
PlusIcon,
} from "@heroicons/react/24/solid";
import TagsInput from "../TagsInput/Index";
import { rankItem } from "@tanstack/match-sorter-utils";
import Service from "@/utils/models/Service";
// For search
const fuzzyFilter = (
row: Row<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,108 @@
import { Bars2Icon } from "@heroicons/react/24/solid";
import { Dispatch, SetStateAction } from "react";
import useTagsHandler from "@/components/TagsInput/TagsHandler";
import { ColumnDef, createColumnHelper } from "@tanstack/react-table";
import Table from "@/components/Table/Table";
import { RowOpenAction } from "@/components/Table/RowOpenAction";
import TagsInput from "@/components/TagsInput/Index";
import Service from "@/utils/models/Service";
type ServiceTableProps = {
data: Service[],
setData: Dispatch<SetStateAction<Service[]>>
}
/**
* Table componenet used for displaying services
* @param props.data Stateful list of services to be displayed by the table
* @param props.setData State setter for the list of services
*/
export default function ServiceTable({ data, setData }: ServiceTableProps ) {
const columnHelper = createColumnHelper<Service>();
// Set up tag handling
const programProps = useTagsHandler([
"community",
"domestic",
"economic",
])
// TODO: Dynamically or statically get full list of preset requirement tag options
const requirementProps = useTagsHandler([
'anonymous',
'confidential',
'referral required',
'safety assessment',
'intake required',
'income eligibility',
'initial assessment',
])
// Define Tanstack columns
const columns: ColumnDef<Service, any>[] = [
columnHelper.accessor("name", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Name
</>
),
cell: (info) => (
<RowOpenAction
title={info.getValue()}
rowData={info.row.original}
setData={setData}
/>
),
}),
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()}
{...programProps}
/>
),
}),
columnHelper.accessor("requirements", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Requirements
</>
),
cell: (info) => (
// TODO: Setup different tag handler for requirements
<TagsInput
presetValue={info.getValue()[0] !== "" ? info.getValue() : ["N/A"]}
{...requirementProps}
/>
),
}),
columnHelper.accessor("summary", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Summary
</>
),
cell: (info) => (
<span className="ml-2 text-gray-500">{info.getValue()}</span>
),
}),
];
return <Table data={data} setData={setData} columns={columns} />
};

View File

@ -1,304 +1,224 @@
// for showcasing to compass import {
Row,
import users from "./users.json"; ColumnDef,
import { useReactTable,
Cell, getCoreRowModel,
ColumnDef, flexRender,
Row, createColumnHelper
createColumnHelper, } from "@tanstack/react-table";
flexRender, import {
getCoreRowModel, ChangeEvent,
getFilteredRowModel, useState,
sortingFns, useEffect,
useReactTable, Key,
} from "@tanstack/react-table"; Dispatch,
import { SetStateAction
ChangeEvent, } from "react";
useState, import { TableAction } from "./TableAction";
useEffect, import { PlusIcon } from "@heroicons/react/24/solid";
FunctionComponent, import { rankItem } from "@tanstack/match-sorter-utils";
useRef, import { RowOptionMenu } from "./RowOptionMenu";
ChangeEventHandler, import DataPoint from "@/utils/models/DataPoint";
Key,
} from "react"; type TableProps<T extends DataPoint> = {
import { RowOptionMenu } from "./RowOptionMenu"; data: T[],
import { RowOpenAction } from "./RowOpenAction"; setData: Dispatch<SetStateAction<T[]>>,
import { TableAction } from "./TableAction"; columns: ColumnDef<T, any>[]
import { };
AtSymbolIcon,
Bars2Icon, /** Fuzzy search function */
ArrowDownCircleIcon, const fuzzyFilter = (
PlusIcon, row: Row<any>,
} from "@heroicons/react/24/solid"; columnId: string,
import TagsInput from "../TagsInput/Index"; value: any,
import { rankItem } from "@tanstack/match-sorter-utils"; addMeta: (meta: any) => void
import Resource from "@/utils/models/Resource"; ) => {
// Rank the item
// For search const itemRank = rankItem(row.getValue(columnId), value);
const fuzzyFilter = (
row: Row<any>, // Store the ranking info
columnId: string, addMeta(itemRank);
value: any,
addMeta: (meta: any) => void // Return if the item should be filtered in/out
) => { return itemRank.passed;
// Rank the item };
const itemRank = rankItem(row.getValue(columnId), value);
/**
// Store the ranking info * General componenet that holds shared functionality for any data table component
addMeta(itemRank); * @param props.data Stateful list of data to be held in the table
* @param props.setData State setter for the list of data
// Return if the item should be filtered in/out * @param props.columns Column definitions made with Tanstack columnHelper
return itemRank.passed; */
}; export default function Table<T extends DataPoint>({ data, setData, columns }: TableProps<T>) {
const columnHelper = createColumnHelper<T>();
// TODO: Rename everything to resources
export const ResourceTable = ({ users }: { users: Resource[] }) => { /** Sorting function based on visibility */
const columnHelper = createColumnHelper<Resource>(); const visibilitySort = (a: T, b: T) => (
a.visible === b.visible
useEffect(() => { ? 0
const sortedUsers = [...users].sort((a, b) => : a.visible ? -1 : 1
a.visible === b.visible ? 0 : a.visible ? -1 : 1 )
);
setData(sortedUsers); // Sort data on load
}, [users]); useEffect(() => {
setData(prevData => prevData.sort(visibilitySort))
const deleteUser = (userId: number) => { }, [setData]);
console.log(data);
setData((currentData) => // Data manipulation methods
currentData.filter((user) => user.id !== userId) // TODO: Connect data manipulation methods to the database (deleteData, hideData, addData)
); const deleteData = (dataId: number) => {
}; console.log(data);
setData((currentData) =>
const hideUser = (userId: number) => { currentData.filter((data) => data.id !== dataId)
console.log(`Toggling visibility for user with ID: ${userId}`); );
setData((currentData) => { };
const newData = currentData
.map((user) => { const hideData = (dataId: number) => {
if (user.id === userId) { console.log(`Toggling visibility for data with ID: ${dataId}`);
return { ...user, visible: !user.visible }; setData(currentData => {
} const newData = currentData
return user; .map(data => (
}) data.id === dataId
.sort((a, b) => ? { ...data, visible: !data.visible }
a.visible === b.visible ? 0 : a.visible ? -1 : 1 : data
); ))
.sort(visibilitySort);
console.log(newData);
return newData; console.log(newData);
}); return newData;
}; });
const [presetOptions, setPresetOptions] = useState([ };
"administrator",
"volunteer", const addData = () => {
"employee", setData([...data]);
]); };
const [tagColors, setTagColors] = useState(new Map());
// Add data manipulation options to the first column
const getTagColor = (tag: string) => { columns.unshift(
if (!tagColors.has(tag)) { columnHelper.display({
const colors = [ id: "options",
"bg-cyan-100", cell: (props) => (
"bg-blue-100", <RowOptionMenu
"bg-green-100", onDelete={() => deleteData(props.row.original.id)}
"bg-yellow-100", onHide={() => hideData(props.row.original.id)}
"bg-purple-100", />
]; ),
const randomColor = })
colors[Math.floor(Math.random() * colors.length)]; )
setTagColors(new Map(tagColors).set(tag, randomColor));
} // Searching
return tagColors.get(tag); const [query, setQuery] = useState("");
}; const handleSearchChange = (e: ChangeEvent) => {
const target = e.target as HTMLInputElement;
const columns = [ setQuery(String(target.value));
columnHelper.display({ };
id: "options",
cell: (props) => ( const handleCellChange = (e: ChangeEvent, key: Key) => {
<RowOptionMenu const target = e.target as HTMLInputElement;
onDelete={() => {}} console.log(key);
onHide={() => hideUser(props.row.original.id)} };
/>
), // TODO: Filtering
}),
columnHelper.accessor("name", { // TODO: Sorting
header: () => (
<> // Define Tanstack table
<Bars2Icon className="inline align-top h-4" /> Name const table = useReactTable({
</> columns,
), data,
cell: (info) => ( filterFns: {
<RowOpenAction fuzzy: fuzzyFilter,
title={info.getValue()} },
rowData={info.row.original} state: {
onRowUpdate={handleRowUpdate} globalFilter: query,
/> },
), onGlobalFilterChange: setQuery,
}), globalFilterFn: fuzzyFilter,
columnHelper.accessor("link", { getCoreRowModel: getCoreRowModel(),
header: () => ( });
<>
<Bars2Icon className="inline align-top h-4" /> Link const handleRowData = (row: any) => {
</> const rowData: any = {};
), row.cells.forEach((cell: any) => {
cell: (info) => ( rowData[cell.column.id] = cell.value;
<a });
href={info.getValue()} // Use rowData object containing data from all columns for the current row
target={"_blank"} console.log(rowData);
className="ml-2 text-gray-500 underline hover:text-gray-400" return rowData;
> };
{info.getValue()}
</a> return (
), <div className="flex flex-col">
}), <div className="flex flex-row justify-end">
columnHelper.accessor("program", { <TableAction query={query} handleChange={handleSearchChange} />
header: () => ( </div>
<> <table className="w-full text-xs text-left rtl:text-right">
<Bars2Icon className="inline align-top h-4" /> Program <thead className="text-xs text-gray-500 capitalize">
</> {table.getHeaderGroups().map((headerGroup) => (
), <tr key={headerGroup.id}>
cell: (info) => <TagsInput presetValue={info.getValue()} />, {headerGroup.headers.map((header, i) => (
}), <th
scope="col"
columnHelper.accessor("summary", { className={
header: () => ( "p-2 border-gray-200 border-y font-medium " +
<> (1 < i && i < columns.length - 1
<Bars2Icon className="inline align-top h-4" /> Summary ? "border-x"
</> : "")
), }
cell: (info) => ( key={header.id}
<span className="ml-2 text-gray-500">{info.getValue()}</span> >
), {header.isPlaceholder
}), ? null
]; : flexRender(
header.column.columnDef.header,
const [data, setData] = useState<Resource[]>([...users]); header.getContext()
)}
const addUser = () => { </th>
setData([...data]); ))}
}; </tr>
))}
// Searching </thead>
const [query, setQuery] = useState(""); <tbody>
const handleSearchChange = (e: ChangeEvent) => { {table.getRowModel().rows.map((row) => {
const target = e.target as HTMLInputElement; // Individual row
setQuery(String(target.value)); const isDataVisible = row.original.visible;
}; const rowClassNames = `text-gray-800 border-y lowercase hover:bg-gray-50 ${
!isDataVisible ? "bg-gray-200 text-gray-500" : ""
const handleCellChange = (e: ChangeEvent, key: Key) => { }`;
const target = e.target as HTMLInputElement; return (
console.log(key); <tr className={rowClassNames} key={row.id}>
}; {row.getVisibleCells().map((cell, i) => (
<td
// TODO: Filtering key={cell.id}
className={
// TODO: Sorting "[&:nth-child(n+3)]:border-x relative first:text-left first:px-0 last:border-none"
}
// added this fn for editing rows >
const handleRowUpdate = (updatedRow: Resource) => { {flexRender(
const dataIndex = data.findIndex((row) => row.id === updatedRow.id); cell.column.columnDef.cell,
if (dataIndex !== -1) { cell.getContext()
const updatedData = [...data]; )}
updatedData[dataIndex] = updatedRow; </td>
setData(updatedData); ))}
} </tr>
}; );
})}
const table = useReactTable({ </tbody>
columns, <tfoot>
data, <tr>
filterFns: { <td
fuzzy: fuzzyFilter, className="p-3 border-y border-gray-200 text-gray-600 hover:bg-gray-50"
}, colSpan={100}
state: { onClick={addData}
globalFilter: query, >
}, <span className="flex ml-1 text-gray-500">
onGlobalFilterChange: setQuery, <PlusIcon className="inline h-4 mr-1" />
globalFilterFn: fuzzyFilter, New
getCoreRowModel: getCoreRowModel(), </span>
}); </td>
</tr>
const handleRowData = (row: any) => { </tfoot>
const rowData: any = {}; </table>
row.cells.forEach((cell: any) => { </div>
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,95 @@
import { ArrowDownCircleIcon, AtSymbolIcon, Bars2Icon } from "@heroicons/react/24/solid";
import { Dispatch, SetStateAction } from "react";
import useTagsHandler from "@/components/TagsInput/TagsHandler";
import { ColumnDef, createColumnHelper } from "@tanstack/react-table";
import Table from "@/components/Table/Table";
import { RowOpenAction } from "@/components/Table/RowOpenAction";
import TagsInput from "@/components/TagsInput/Index";
import User from "@/utils/models/User";
type UserTableProps = {
data: User[],
setData: Dispatch<SetStateAction<User[]>>
}
/**
* Table componenet used for displaying users
* @param props.data Stateful list of users to be displayed by the table
* @param props.setData State setter for the list of users
*/
export default function UserTable({ data, setData }: UserTableProps ) {
const columnHelper = createColumnHelper<User>();
// Set up tag handling
const roleProps = useTagsHandler([
"administrator",
"volunteer",
"employee",
])
const programProps = useTagsHandler([
"community",
"domestic",
"economic",
])
// Define Tanstack columns
const columns: ColumnDef<User, any>[] = [
columnHelper.accessor("username", {
header: () => (
<>
<Bars2Icon className="inline align-top h-4" /> Username
</>
),
cell: (info) => (
<RowOpenAction
title={info.getValue()}
rowData={info.row.original}
setData={setData}
/>
),
}),
columnHelper.accessor("role", {
header: () => (
<>
<ArrowDownCircleIcon className="inline align-top h-4" />{" "}
Role
</>
),
cell: (info) => (
<TagsInput
presetValue={info.getValue()}
{...roleProps}
/>
),
}),
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()}
{...programProps}
/>
),
}),
];
return <Table<User> data={data} setData={setData} columns={columns}/>
}

View File

@ -1,4 +1,4 @@
import React, { useState, useRef } from "react"; import React, { useState, useRef, Dispatch, SetStateAction } from "react";
import "tailwindcss/tailwind.css"; import "tailwindcss/tailwind.css";
import { TagsArray } from "./TagsArray"; import { TagsArray } from "./TagsArray";
import { TagDropdown } from "./TagDropdown"; import { TagDropdown } from "./TagDropdown";
@ -7,8 +7,8 @@ import { CreateNewTagAction } from "./CreateNewTagAction";
interface TagsInputProps { interface TagsInputProps {
presetOptions: string[]; presetOptions: string[];
presetValue: string | string[]; presetValue: string | string[];
setPresetOptions: () => {}; setPresetOptions: Dispatch<SetStateAction<string[]>>;
getTagColor: () => {}; getTagColor(tag: string): string;
} }
const TagsInput: React.FC<TagsInputProps> = ({ const TagsInput: React.FC<TagsInputProps> = ({

View File

@ -7,7 +7,7 @@ export interface Tags {
} }
export const TagsArray = ({ tags, handleDelete, active = false }: Tags) => { export const TagsArray = ({ tags, handleDelete, active = false }: Tags) => {
console.log(tags); // console.log(tags);
return ( return (
<div className="flex ml-2 flex-wrap gap-2 items-center"> <div className="flex ml-2 flex-wrap gap-2 items-center">

View File

@ -0,0 +1,35 @@
import { useState } from 'react';
/**
* Custom hook used to handle the state of tag options and colors
* @param initialOptions Initial value for preset options
* @returns An object with three fields intended to be passed into a `TagsInput` component:
* - `presetOptions` - the current state of tag options
* - `setPresetOptions` - the state setter for presetOptions
* - `getTagColor` - function that retrieves the color for the given tag
*/
export default function useTagsHandler(initialOptions: string[]) {
const [presetOptions, setPresetOptions] = useState(initialOptions);
const [tagColors, setTagColors] = useState(new Map<string, string>());
const getTagColor = (tag: string): string => {
if (!tagColors.has(tag)) {
const colors = [
"bg-cyan-100",
"bg-blue-100",
"bg-green-100",
"bg-yellow-100",
"bg-purple-100",
];
const randomColor =
colors[Math.floor(Math.random() * colors.length)];
setTagColors(new Map(tagColors).set(tag, randomColor));
return randomColor;
}
// Since we populate any missing keys, .get will never return undefined,
// so we are safe to typecast to prevent a type error
return tagColors.get(tag) as string;
};
return { presetOptions, setPresetOptions, getTagColor }
}

View File

@ -0,0 +1,9 @@
/**
* Represents metadata of the Resource, Service, and User models to be used in a table
*/
interface DataPoint {
id: number;
visible: boolean;
}
export default DataPoint;