diff --git a/backend/api/resource.py b/backend/api/resource.py index ff449e2..d698a0d 100644 --- a/backend/api/resource.py +++ b/backend/api/resource.py @@ -60,12 +60,12 @@ def update( return resource_svc.update(subject, resource) -@api.delete("", response_model=None, tags=["Resource"]) +@api.delete("", response_model=dict, tags=["Resource"]) def delete( uuid: str, - resource: Resource, + id: int, user_svc: UserService = Depends(), resource_svc: ResourceService = Depends(), ): subject = user_svc.get_user_by_uuid(uuid) - resource_svc.delete(subject, resource) + return resource_svc.delete(subject, id) diff --git a/backend/api/service.py b/backend/api/service.py index bd3c4dc..844c840 100644 --- a/backend/api/service.py +++ b/backend/api/service.py @@ -19,7 +19,10 @@ openapi_tags = { # TODO: Create custom exceptions @api.post("", response_model=Service, tags=["Service"]) def create( - uuid: str, service: Service, user_svc: UserService = Depends(), service_svc: ServiceService = Depends() + uuid: str, + service: Service, + user_svc: UserService = Depends(), + service_svc: ServiceService = Depends(), ): subject = user_svc.get_user_by_uuid(uuid) return service_svc.create(subject, service) @@ -27,28 +30,42 @@ def create( @api.get("", response_model=List[Service], tags=["Service"]) def get_all( - uuid: str, user_svc: UserService = Depends(), service_svc: ServiceService = Depends() + uuid: str, + user_svc: UserService = Depends(), + service_svc: ServiceService = Depends(), ): subject = user_svc.get_user_by_uuid(uuid) return service_svc.get_service_by_user(subject) + @api.get("/{name}", response_model=Service, tags=["Service"]) def get_by_name( - name: str, uuid: str, user_svc: UserService = Depends(), service_svc: ServiceService = Depends() + name: str, + uuid: str, + user_svc: UserService = Depends(), + service_svc: ServiceService = Depends(), ): subject = user_svc.get_user_by_uuid(uuid) return service_svc.get_service_by_name(name, subject) + @api.put("", response_model=Service, tags=["Service"]) def update( - uuid: str, service: Service, user_svc: UserService = Depends(), service_svc: ServiceService = Depends() + uuid: str, + service: Service, + user_svc: UserService = Depends(), + service_svc: ServiceService = Depends(), ): subject = user_svc.get_user_by_uuid(uuid) return service_svc.update(subject, service) -@api.delete("", response_model=None, tags=["Service"]) + +@api.delete("", response_model=dict, tags=["Service"]) def delete( - uuid: str, service: Service, user_svc: UserService = Depends(), service_svc: ServiceService = Depends() + uuid: str, + id: int, + user_svc: UserService = Depends(), + service_svc: ServiceService = Depends(), ): subject = user_svc.get_user_by_uuid(uuid) - service_svc.delete(subject, service) + return service_svc.delete(subject, id) diff --git a/backend/api/user.py b/backend/api/user.py index bcf0eaa..36e2d15 100644 --- a/backend/api/user.py +++ b/backend/api/user.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from ..services import UserService from ..models.user_model import User, UserTypeEnum @@ -46,3 +46,14 @@ def update_user(uuid: str, user: User, user_svc: UserService = Depends()): raise Exception(f"Insufficient permissions for user {subject.uuid}") return user_svc.update(user) + + +@api.delete("/", response_model=dict, tags=["Users"]) +def delete_user(uuid: str, id: int, user_svc: UserService = Depends()): + subject = user_svc.get_user_by_uuid(uuid) + + try: + user_svc.delete_by_id(id, subject) + return {"message": "User deleted successfully"} + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) diff --git a/backend/services/resource.py b/backend/services/resource.py index eefb3b7..de8e657 100644 --- a/backend/services/resource.py +++ b/backend/services/resource.py @@ -93,7 +93,7 @@ class ResourceService: self._session.commit() return entity.to_model() - def delete(self, subject: User, resource: Resource) -> None: + def delete(self, subject: User, id: int) -> None: """ Delete resource based on id that the user has access to Parameters: @@ -106,15 +106,16 @@ class ResourceService: raise ProgramNotAssignedException( f"User is not {UserTypeEnum.ADMIN}, cannot update service" ) - query = select(ResourceEntity).where(ResourceEntity.id == resource.id) + + query = select(ResourceEntity).where(ResourceEntity.id == id) entity = self._session.scalars(query).one_or_none() if entity is None: - raise ResourceNotFoundException( - f"No resource found with matching id: {resource.id}" - ) + raise ResourceNotFoundException(f"No resource found with matching id: {id}") self._session.delete(entity) self._session.commit() + return {"message": "Resource deleted successfully"} + 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 diff --git a/backend/services/service.py b/backend/services/service.py index e82b305..28ad493 100644 --- a/backend/services/service.py +++ b/backend/services/service.py @@ -90,12 +90,12 @@ class ServiceService: return entity.to_model() - def delete(self, subject: User, service: Service) -> None: + def delete(self, subject: User, id: int) -> None: """Deletes a service from the table.""" if subject.role != UserTypeEnum.ADMIN: raise ProgramNotAssignedException(f"User is not {UserTypeEnum.ADMIN}") - query = select(ServiceEntity).where(ServiceEntity.id == service.id) + query = select(ServiceEntity).where(ServiceEntity.id == id) entity = self._session.scalars(query).one_or_none() if entity is None: @@ -105,3 +105,5 @@ class ServiceService: self._session.delete(entity) self._session.commit() + + return {"message": "Service deleted successfully"} diff --git a/backend/services/user.py b/backend/services/user.py index af91309..119fafa 100644 --- a/backend/services/user.py +++ b/backend/services/user.py @@ -4,6 +4,7 @@ from sqlalchemy.orm import Session from ..entities.user_entity import UserEntity from ..models.user_model import User from sqlalchemy import select +from ..models.enum_for_models import UserTypeEnum class UserService: @@ -89,6 +90,22 @@ class UserService: self._session.delete(obj) self._session.commit() + def delete_by_id(self, id: int, user: User) -> None: + """ + Delete a user by id + + Args: the id of the user to delete + + Returns: none + """ + + if user.role != UserTypeEnum.ADMIN: + raise Exception(f"Insufficient permissions for user {user.uuid}") + + obj = self._session.get(UserEntity, id) + self._session.delete(obj) + self._session.commit() + def update(self, user: User) -> User: """ Updates a user diff --git a/compass/app/api/resource/delete/route.ts b/compass/app/api/resource/delete/route.ts new file mode 100644 index 0000000..5e908e0 --- /dev/null +++ b/compass/app/api/resource/delete/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; + +export async function DELETE(request: Request) { + const apiEndpoint = `${process.env.NEXT_PUBLIC_API_HOST}/api/resource`; + + try { + const { searchParams } = new URL(request.url); + const uuid = searchParams.get("uuid"); + const resource_id = searchParams.get("id"); + + console.log("Resource to be deleted", resource_id); + + // Send the POST request to the backend + const response = await fetch( + `${apiEndpoint}?uuid=${uuid}&id=${resource_id}`, + { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + } + ); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return NextResponse.json( + { message: "Resource deleted successfully" }, + { status: response.status } + ); + } catch (error) { + console.error("Error deleting resource:", error); + return NextResponse.json( + { error: "Failed to delete resource" }, + { status: 500 } + ); + } +} diff --git a/compass/app/api/service/delete/route.ts b/compass/app/api/service/delete/route.ts new file mode 100644 index 0000000..088e5e4 --- /dev/null +++ b/compass/app/api/service/delete/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; + +export async function DELETE(request: Request) { + const apiEndpoint = `${process.env.NEXT_PUBLIC_API_HOST}/api/service`; + + try { + const { searchParams } = new URL(request.url); + const uuid = searchParams.get("uuid"); + const service_id = searchParams.get("id"); + + console.log("Service to be deleted", service_id); + + // Send the POST request to the backend + const response = await fetch( + `${apiEndpoint}?uuid=${uuid}&id=${service_id}`, + { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + } + ); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return NextResponse.json( + { message: "Service deleted successfully" }, + { status: response.status } + ); + } catch (error) { + console.error("Error deleting service:", error); + return NextResponse.json( + { error: "Failed to delete service" }, + { status: 500 } + ); + } +} diff --git a/compass/app/api/user/delete/route.ts b/compass/app/api/user/delete/route.ts new file mode 100644 index 0000000..ac24724 --- /dev/null +++ b/compass/app/api/user/delete/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; + +export async function DELETE(request: Request) { + const apiEndpoint = `${process.env.NEXT_PUBLIC_API_HOST}/api/user`; + + try { + const { searchParams } = new URL(request.url); + const uuid = searchParams.get("uuid"); + const user_id = searchParams.get("id"); + + console.log("User to be deleted", user_id); + + // Send the POST request to the backend + const response = await fetch( + `${apiEndpoint}?uuid=${uuid}&id=${user_id}`, + { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + } + ); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return NextResponse.json( + { message: "User deleted successfully" }, + { status: response.status } + ); + } catch (error) { + console.error("Error deleting user:", error); + return NextResponse.json( + { error: "Failed to delete user" }, + { status: 500 } + ); + } +} diff --git a/compass/components/Drawer/Drawer.tsx b/compass/components/Drawer/Drawer.tsx index 44a9eab..2b67e2f 100644 --- a/compass/components/Drawer/Drawer.tsx +++ b/compass/components/Drawer/Drawer.tsx @@ -1,4 +1,4 @@ -import { Dispatch, FunctionComponent, ReactNode, SetStateAction } from "react"; +import { Dispatch, FunctionComponent, ReactNode, SetStateAction, useEffect, useRef } from "react"; import React, { useState } from "react"; import { ChevronDoubleLeftIcon } from "@heroicons/react/24/solid"; import { StarIcon as SolidStarIcon, UserIcon } from "@heroicons/react/24/solid"; @@ -47,6 +47,47 @@ const Drawer: FunctionComponent = ({ const [isFull, setIsFull] = useState(false); const [isFavorite, setIsFavorite] = useState(false); const [tempRowContent, setTempRowContent] = useState(rowContent); + const drawerRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (drawerRef.current && !drawerRef.current.contains(event.target as Node)) { + if (setRowContent && isOpen) { + // Check if any values have changed + const hasChanges = Object.keys(tempRowContent).some( + (key) => tempRowContent[key] !== rowContent[key] + ); + + if (hasChanges) { + handleUpdate().then((response) => { + if (response.ok) { + setRowContent((prev: any) => { + return prev.map((row: any) => { + if (row.id === tempRowContent.id) { + return tempRowContent; + } + return row; + }); + }); + } + }); + } + } + setIsOpen(false); + if (isFull) { + setIsFull(false); + } + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen, tempRowContent, rowContent]); const handleTempRowContentChangeHTML = ( e: React.ChangeEvent @@ -114,9 +155,8 @@ const Drawer: FunctionComponent = ({ 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 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 ? ( @@ -140,7 +180,7 @@ const Drawer: FunctionComponent = ({ > Open -
+
@@ -186,7 +226,7 @@ const Drawer: FunctionComponent = ({ = ({ } setPresetOptions={ detail.presetOptionsSetter || - (() => {}) + (() => { }) } singleValue={true} onTagsChange={( @@ -236,7 +276,7 @@ const Drawer: FunctionComponent = ({ = ({ } setPresetOptions={ detail.presetOptionsSetter || - (() => {}) + (() => { }) } onTagsChange={( tags: Set @@ -260,7 +300,7 @@ const Drawer: FunctionComponent = ({ ) : (
{value && - value.length > 0 ? ( + value.length > 0 ? ( value.map( ( tag: string, diff --git a/compass/components/Table/ResourceTable.tsx b/compass/components/Table/ResourceTable.tsx index a1e6391..e5076b2 100644 --- a/compass/components/Table/ResourceTable.tsx +++ b/compass/components/Table/ResourceTable.tsx @@ -143,6 +143,7 @@ export default function ResourceTable({ columns={columns} details={resourceDetails} createEndpoint={`/api/resource/create?uuid=${user?.uuid}`} + deleteEndpoint={`/api/resource/delete?uuid=${user?.uuid}`} isAdmin={user?.role === "ADMIN"} /> ); diff --git a/compass/components/Table/RowOpenAction.tsx b/compass/components/Table/RowOpenAction.tsx index 8283516..31a5403 100644 --- a/compass/components/Table/RowOpenAction.tsx +++ b/compass/components/Table/RowOpenAction.tsx @@ -27,7 +27,7 @@ export function RowOpenAction({ updateRoute, }: RowOpenActionProps) { return ( -
+
{title} { - const [menuOpen, setMenuOpen] = useState(false); - const openMenu = () => setMenuOpen(true); - const closeMenu = () => setMenuOpen(false); +interface RowOptionMenuProps { + onDelete: () => void; + onHide: () => void; + visible: boolean; +} - // TODO: Hide menu if clicked elsewhere +export const RowOptionMenu = ({ + onDelete, + onHide, + visible, +}: RowOptionMenuProps) => { + const [menuOpen, setMenuOpen] = useState(false); + const menuRef = useRef(null); + const buttonRef = useRef(null); + + const handleDelete = () => { + if (window.confirm("Are you sure you want to delete this item?")) { + onDelete(); + setMenuOpen(false); + } + }; + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + menuRef.current && + buttonRef.current && + !menuRef.current.contains(event.target as Node) && + !buttonRef.current.contains(event.target as Node) + ) { + setMenuOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); return ( <>
*]:rounded z-10" + (!menuOpen ? " invisible" : "") } > - { - /* handle open */ - }} + icon={TrashIcon} + label="Delete" + onClick={handleDelete} + /> + -
); diff --git a/compass/components/Table/ServiceTable.tsx b/compass/components/Table/ServiceTable.tsx index 237c3e2..8169f7c 100644 --- a/compass/components/Table/ServiceTable.tsx +++ b/compass/components/Table/ServiceTable.tsx @@ -195,6 +195,7 @@ export default function ServiceTable({ columns={columns} details={serviceDetails} createEndpoint={`/api/service/create?uuid=${user?.uuid}`} + deleteEndpoint={`/api/service/delete?uuid=${user?.uuid}`} isAdmin={user?.role === "ADMIN"} /> ); diff --git a/compass/components/Table/Table.tsx b/compass/components/Table/Table.tsx index 538e546..bb6c40a 100644 --- a/compass/components/Table/Table.tsx +++ b/compass/components/Table/Table.tsx @@ -28,6 +28,7 @@ type TableProps = { columns: ColumnDef[]; details: Details[]; createEndpoint: string; + deleteEndpoint: string; isAdmin?: boolean; }; @@ -79,12 +80,24 @@ export default function Table({ columns, details, createEndpoint, + deleteEndpoint, isAdmin = false, }: TableProps) { - console.log(data); + const offset = isAdmin ? 1 : 0; const columnHelper = createColumnHelper(); + const deleteRow = async (id: number) => { + const response = await fetch(`${deleteEndpoint}&id=${id}`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + }); + + return response.ok; + }; + const createRow = async (newItem: any) => { const response = await fetch(createEndpoint, { method: "POST", @@ -97,9 +110,9 @@ export default function Table({ return response; }; - // /** Sorting function based on visibility */ - // const visibilitySort = (a: T, b: T) => - // a.visible === b.visible ? 0 : a.visible ? -1 : 1; + /** Sorting function based on visibility */ + const visibilitySort = (a: T, b: T) => + a.visible === b.visible ? 0 : a.visible ? -1 : 1; // // Sort data on load // useEffect(() => { @@ -115,36 +128,59 @@ export default function Table({ // ); // }; - // const hideData = (dataId: number) => { - // console.log(`Toggling visibility for data with ID: ${dataId}`); - // setData((currentData) => { - // const newData = currentData - // .map((data) => - // data.id === dataId - // ? { ...data, visible: !data.visible } - // : data - // ) - // .sort(visibilitySort); + const hideData = (dataId: number) => { + console.log(`Toggling visibility for data with ID: ${dataId}`); + setData((currentData) => { + const newData = currentData + .map((data) => + data.id === dataId + ? { ...data, visible: !data.visible } + : data + ) + .sort((a, b) => { + // First sort by visibility + const visibilityResult = visibilitySort(a, b); + if (visibilityResult !== 0) return visibilityResult; + // Then sort by id + return a.id - b.id; + }); - // console.log(newData); - // return newData; - // }); - // }; + console.log(newData); + return newData; + }); + }; // Add data manipulation options to the first column - columns.unshift( - columnHelper.display({ - id: "options", - cell: (props) => ( - {}} - onHide={() => {}} - // onDelete={() => deleteData(props.row.original.id)} - // onHide={() => hideData(props.row.original.id)} - /> - ), - }) - ); + if (isAdmin) { + columns.unshift( + columnHelper.display({ + id: "options", + cell: (props) => ( + { + deleteRow(props.row.original.id).then( + (response) => { + if (response) { + setData((prev) => + prev.filter( + (data) => + data.id !== + props.row.original.id + ) + ); + } else { + alert("Failed to delete row!"); + } + } + ); + }} + onHide={() => hideData(props.row.original.id)} + visible={props.row.original.visible} + /> + ), + }) + ); + } // Searching const [query, setQuery] = useState(""); @@ -186,7 +222,7 @@ export default function Table({ scope="col" className={ "p-2 border-gray-200 border-y font-medium " + - (1 < i && i < columns.length - 1 + (0 + offset < i && i < columns.length - 1 ? "border-x" : "") } @@ -195,9 +231,9 @@ export default function Table({ {header.isPlaceholder ? null : flexRender( - header.column.columnDef.header, - header.getContext() - )} + header.column.columnDef.header, + header.getContext() + )} ))} @@ -207,16 +243,15 @@ export default function Table({ {table.getRowModel().rows.map((row) => { // Individual row 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 rowClassNames = `text-gray-800 border-y lowercase hover:bg-gray-50 ${!isDataVisible ? "bg-gray-200 text-gray-500" : "" + }`; return ( {row.getVisibleCells().map((cell, i) => ( {flexRender( @@ -248,10 +283,13 @@ export default function Table({ createRow(newItem).then((response) => { if (response.ok) { newItem.visible = true; - setData((prev) => [ - ...prev, - newItem, - ]); + response.json().then((data) => { + newItem.id = data.id; + setData((prev) => [ + ...prev, + newItem, + ]); + }); } }); diff --git a/compass/components/Table/UserTable.tsx b/compass/components/Table/UserTable.tsx index 3754d83..9665b41 100644 --- a/compass/components/Table/UserTable.tsx +++ b/compass/components/Table/UserTable.tsx @@ -143,6 +143,7 @@ export default function UserTable({ data, setData, user }: UserTableProps) { columns={columns} details={userDetails} createEndpoint={`/api/user/create?uuid=${user?.uuid}`} + deleteEndpoint={`/api/user/delete?uuid=${user?.uuid}`} isAdmin={user?.role === "ADMIN"} /> );