Delete Button and Update QoL changes (#60)

* Edit visibility for options depending on administrator status

* Connect delete frontend route to delete backend

* small readme.md changes

* Fix table lines and update bug

* Add clicking outside of drawer to close
This commit is contained in:
Prajwal Moharana 2025-04-25 12:10:36 -04:00 committed by GitHub
parent bdc6600a3f
commit 469236cb04
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 367 additions and 84 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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))

View File

@ -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

View File

@ -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"}

View File

@ -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

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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<DrawerProps> = ({
const [isFull, setIsFull] = useState(false);
const [isFavorite, setIsFavorite] = useState(false);
const [tempRowContent, setTempRowContent] = useState(rowContent);
const drawerRef = useRef<HTMLDivElement>(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<HTMLInputElement | HTMLTextAreaElement>
@ -114,8 +155,7 @@ const Drawer: FunctionComponent<DrawerProps> = ({
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"
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<DrawerProps> = ({
>
Open
</button>
<div className={drawerClassName}>
<div ref={drawerRef} 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">

View File

@ -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"}
/>
);

View File

@ -27,7 +27,7 @@ export function RowOpenAction<T extends DataPoint>({
updateRoute,
}: RowOpenActionProps<T>) {
return (
<div className="font-semibold group flex flex-row items-center justify-between pr-2">
<div className="font-semibold group flex flex-row items-center justify-between px-2">
{title}
<span>
<Drawer

View File

@ -5,41 +5,78 @@ import {
ArrowUpRightIcon,
EllipsisVerticalIcon,
EyeSlashIcon,
EyeIcon,
} 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);
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<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(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 (
<>
<button
ref={buttonRef}
className="items-end"
onClick={() => setMenuOpen(!menuOpen)}
>
<EllipsisVerticalIcon className="h-4" />
</button>
<div
ref={menuRef}
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 */
}}
icon={TrashIcon}
label="Delete"
onClick={handleDelete}
/>
<RowOption
icon={visible ? EyeSlashIcon : EyeIcon}
label={visible ? "Hide" : "Show"}
onClick={onHide}
/>
<RowOption icon={EyeSlashIcon} label="Hide" onClick={onHide} />
</div>
</>
);

View File

@ -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"}
/>
);

View File

@ -28,6 +28,7 @@ type TableProps<T extends DataPoint> = {
columns: ColumnDef<T, any>[];
details: Details[];
createEndpoint: string;
deleteEndpoint: string;
isAdmin?: boolean;
};
@ -79,12 +80,24 @@ export default function Table<T extends DataPoint>({
columns,
details,
createEndpoint,
deleteEndpoint,
isAdmin = false,
}: TableProps<T>) {
console.log(data);
const offset = isAdmin ? 1 : 0;
const columnHelper = createColumnHelper<T>();
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<T extends DataPoint>({
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<T extends DataPoint>({
// );
// };
// 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
if (isAdmin) {
columns.unshift(
columnHelper.display({
id: "options",
cell: (props) => (
<RowOptionMenu
onDelete={() => {}}
onHide={() => {}}
// onDelete={() => deleteData(props.row.original.id)}
// onHide={() => hideData(props.row.original.id)}
onDelete={() => {
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<T extends DataPoint>({
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"
: "")
}
@ -207,8 +243,7 @@ export default function Table<T extends DataPoint>({
{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 (
<tr className={rowClassNames} key={row.id}>
@ -216,7 +251,7 @@ export default function Table<T extends DataPoint>({
<td
key={cell.id}
className={
"[&:nth-child(n+3)]:border-x relative first:text-left first:px-0 last:border-none"
`[&:nth-child(n+${2 + offset})]:border-x relative first:text-left first:px-0 last:border-none`
}
>
{flexRender(
@ -248,10 +283,13 @@ export default function Table<T extends DataPoint>({
createRow(newItem).then((response) => {
if (response.ok) {
newItem.visible = true;
response.json().then((data) => {
newItem.id = data.id;
setData((prev) => [
...prev,
newItem,
]);
});
}
});

View File

@ -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"}
/>
);