Remove item management feature from frontend and backend, and add a pending skeleton component.
All checks were successful
Deploy to Production / deploy (push) Successful in 1m34s
All checks were successful
Deploy to Production / deploy (push) Successful in 1m34s
This commit is contained in:
@@ -1,13 +1,12 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.routes import items, locations, login, private, users, utils
|
||||
from app.api.routes import locations, login, private, users, utils
|
||||
from app.core.config import settings
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(login.router)
|
||||
api_router.include_router(users.router)
|
||||
api_router.include_router(utils.router)
|
||||
api_router.include_router(items.router)
|
||||
api_router.include_router(locations.router)
|
||||
|
||||
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from sqlmodel import col, func, select
|
||||
|
||||
from app.api.deps import CurrentUser, SessionDep
|
||||
from app.models import Item, ItemCreate, ItemPublic, ItemsPublic, ItemUpdate, Message
|
||||
|
||||
router = APIRouter(prefix="/items", tags=["items"])
|
||||
|
||||
|
||||
@router.get("/", response_model=ItemsPublic)
|
||||
def read_items(
|
||||
session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100
|
||||
) -> Any:
|
||||
"""
|
||||
Retrieve items.
|
||||
"""
|
||||
|
||||
if current_user.is_superuser:
|
||||
count_statement = select(func.count()).select_from(Item)
|
||||
count = session.exec(count_statement).one()
|
||||
statement = (
|
||||
select(Item).order_by(col(Item.created_at).desc()).offset(skip).limit(limit)
|
||||
)
|
||||
items = session.exec(statement).all()
|
||||
else:
|
||||
count_statement = (
|
||||
select(func.count())
|
||||
.select_from(Item)
|
||||
.where(Item.owner_id == current_user.id)
|
||||
)
|
||||
count = session.exec(count_statement).one()
|
||||
statement = (
|
||||
select(Item)
|
||||
.where(Item.owner_id == current_user.id)
|
||||
.order_by(col(Item.created_at).desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
items = session.exec(statement).all()
|
||||
|
||||
return ItemsPublic(data=items, count=count)
|
||||
|
||||
|
||||
@router.get("/{id}", response_model=ItemPublic)
|
||||
def read_item(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any:
|
||||
"""
|
||||
Get item by ID.
|
||||
"""
|
||||
item = session.get(Item, id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
if not current_user.is_superuser and (item.owner_id != current_user.id):
|
||||
raise HTTPException(status_code=403, detail="Not enough permissions")
|
||||
return item
|
||||
|
||||
|
||||
@router.post("/", response_model=ItemPublic)
|
||||
def create_item(
|
||||
*, session: SessionDep, current_user: CurrentUser, item_in: ItemCreate
|
||||
) -> Any:
|
||||
"""
|
||||
Create new item.
|
||||
"""
|
||||
item = Item.model_validate(item_in, update={"owner_id": current_user.id})
|
||||
session.add(item)
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
@router.put("/{id}", response_model=ItemPublic)
|
||||
def update_item(
|
||||
*,
|
||||
session: SessionDep,
|
||||
current_user: CurrentUser,
|
||||
id: uuid.UUID,
|
||||
item_in: ItemUpdate,
|
||||
) -> Any:
|
||||
"""
|
||||
Update an item.
|
||||
"""
|
||||
item = session.get(Item, id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
if not current_user.is_superuser and (item.owner_id != current_user.id):
|
||||
raise HTTPException(status_code=403, detail="Not enough permissions")
|
||||
update_dict = item_in.model_dump(exclude_unset=True)
|
||||
item.sqlmodel_update(update_dict)
|
||||
session.add(item)
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
@router.delete("/{id}")
|
||||
def delete_item(
|
||||
session: SessionDep, current_user: CurrentUser, id: uuid.UUID
|
||||
) -> Message:
|
||||
"""
|
||||
Delete an item.
|
||||
"""
|
||||
item = session.get(Item, id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
if not current_user.is_superuser and (item.owner_id != current_user.id):
|
||||
raise HTTPException(status_code=403, detail="Not enough permissions")
|
||||
session.delete(item)
|
||||
session.commit()
|
||||
return Message(message="Item deleted successfully")
|
||||
@@ -13,7 +13,7 @@ from app.api.deps import (
|
||||
from app.core.config import settings
|
||||
from app.core.security import get_password_hash, verify_password
|
||||
from app.models import (
|
||||
Item,
|
||||
Location,
|
||||
Message,
|
||||
UpdatePassword,
|
||||
User,
|
||||
@@ -224,7 +224,7 @@ def delete_user(
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Super users are not allowed to delete themselves"
|
||||
)
|
||||
statement = delete(Item).where(col(Item.owner_id) == user_id)
|
||||
statement = delete(Location).where(col(Location.owner_id) == user_id)
|
||||
session.exec(statement)
|
||||
session.delete(user)
|
||||
session.commit()
|
||||
|
||||
@@ -5,8 +5,6 @@ from sqlmodel import Session, select
|
||||
|
||||
from app.core.security import get_password_hash, verify_password
|
||||
from app.models import (
|
||||
Item,
|
||||
ItemCreate,
|
||||
Location,
|
||||
LocationCreate,
|
||||
User,
|
||||
@@ -68,14 +66,6 @@ def authenticate(*, session: Session, email: str, password: str) -> User | None:
|
||||
return db_user
|
||||
|
||||
|
||||
def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) -> Item:
|
||||
db_item = Item.model_validate(item_in, update={"owner_id": owner_id})
|
||||
session.add(db_item)
|
||||
session.commit()
|
||||
session.refresh(db_item)
|
||||
return db_item
|
||||
|
||||
|
||||
def create_location(*, session: Session, location_in: LocationCreate, owner_id: uuid.UUID) -> Location:
|
||||
db_location = Location.model_validate(location_in, update={"owner_id": owner_id})
|
||||
session.add(db_location)
|
||||
|
||||
@@ -53,7 +53,6 @@ class User(UserBase, table=True):
|
||||
default_factory=get_datetime_utc,
|
||||
sa_type=DateTime(timezone=True), # type: ignore
|
||||
)
|
||||
items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True)
|
||||
locations: list["Location"] = Relationship(back_populates="owner", cascade_delete=True)
|
||||
|
||||
|
||||
@@ -68,47 +67,6 @@ class UsersPublic(SQLModel):
|
||||
count: int
|
||||
|
||||
|
||||
# Shared properties
|
||||
class ItemBase(SQLModel):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
# Properties to receive on item creation
|
||||
class ItemCreate(ItemBase):
|
||||
pass
|
||||
|
||||
|
||||
# Properties to receive on item update
|
||||
class ItemUpdate(ItemBase):
|
||||
title: str | None = Field(default=None, min_length=1, max_length=255) # type: ignore
|
||||
|
||||
|
||||
# Database model, database table inferred from class name
|
||||
class Item(ItemBase, table=True):
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
created_at: datetime | None = Field(
|
||||
default_factory=get_datetime_utc,
|
||||
sa_type=DateTime(timezone=True), # type: ignore
|
||||
)
|
||||
owner_id: uuid.UUID = Field(
|
||||
foreign_key="user.id", nullable=False, ondelete="CASCADE"
|
||||
)
|
||||
owner: User | None = Relationship(back_populates="items")
|
||||
|
||||
|
||||
# Properties to return via API, id is always required
|
||||
class ItemPublic(ItemBase):
|
||||
id: uuid.UUID
|
||||
owner_id: uuid.UUID
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class ItemsPublic(SQLModel):
|
||||
data: list[ItemPublic]
|
||||
count: int
|
||||
|
||||
|
||||
# Shared properties
|
||||
class LocationBase(SQLModel):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import uuid
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from tests.utils.item import create_random_item
|
||||
|
||||
|
||||
def test_create_item(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
data = {"title": "Foo", "description": "Fighters"}
|
||||
response = client.post(
|
||||
f"{settings.API_V1_STR}/items/",
|
||||
headers=superuser_token_headers,
|
||||
json=data,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert content["title"] == data["title"]
|
||||
assert content["description"] == data["description"]
|
||||
assert "id" in content
|
||||
assert "owner_id" in content
|
||||
|
||||
|
||||
def test_read_item(
|
||||
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
||||
) -> None:
|
||||
item = create_random_item(db)
|
||||
response = client.get(
|
||||
f"{settings.API_V1_STR}/items/{item.id}",
|
||||
headers=superuser_token_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert content["title"] == item.title
|
||||
assert content["description"] == item.description
|
||||
assert content["id"] == str(item.id)
|
||||
assert content["owner_id"] == str(item.owner_id)
|
||||
|
||||
|
||||
def test_read_item_not_found(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
response = client.get(
|
||||
f"{settings.API_V1_STR}/items/{uuid.uuid4()}",
|
||||
headers=superuser_token_headers,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
content = response.json()
|
||||
assert content["detail"] == "Item not found"
|
||||
|
||||
|
||||
def test_read_item_not_enough_permissions(
|
||||
client: TestClient, normal_user_token_headers: dict[str, str], db: Session
|
||||
) -> None:
|
||||
item = create_random_item(db)
|
||||
response = client.get(
|
||||
f"{settings.API_V1_STR}/items/{item.id}",
|
||||
headers=normal_user_token_headers,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
content = response.json()
|
||||
assert content["detail"] == "Not enough permissions"
|
||||
|
||||
|
||||
def test_read_items(
|
||||
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
||||
) -> None:
|
||||
create_random_item(db)
|
||||
create_random_item(db)
|
||||
response = client.get(
|
||||
f"{settings.API_V1_STR}/items/",
|
||||
headers=superuser_token_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert len(content["data"]) >= 2
|
||||
|
||||
|
||||
def test_update_item(
|
||||
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
||||
) -> None:
|
||||
item = create_random_item(db)
|
||||
data = {"title": "Updated title", "description": "Updated description"}
|
||||
response = client.put(
|
||||
f"{settings.API_V1_STR}/items/{item.id}",
|
||||
headers=superuser_token_headers,
|
||||
json=data,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert content["title"] == data["title"]
|
||||
assert content["description"] == data["description"]
|
||||
assert content["id"] == str(item.id)
|
||||
assert content["owner_id"] == str(item.owner_id)
|
||||
|
||||
|
||||
def test_update_item_not_found(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
data = {"title": "Updated title", "description": "Updated description"}
|
||||
response = client.put(
|
||||
f"{settings.API_V1_STR}/items/{uuid.uuid4()}",
|
||||
headers=superuser_token_headers,
|
||||
json=data,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
content = response.json()
|
||||
assert content["detail"] == "Item not found"
|
||||
|
||||
|
||||
def test_update_item_not_enough_permissions(
|
||||
client: TestClient, normal_user_token_headers: dict[str, str], db: Session
|
||||
) -> None:
|
||||
item = create_random_item(db)
|
||||
data = {"title": "Updated title", "description": "Updated description"}
|
||||
response = client.put(
|
||||
f"{settings.API_V1_STR}/items/{item.id}",
|
||||
headers=normal_user_token_headers,
|
||||
json=data,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
content = response.json()
|
||||
assert content["detail"] == "Not enough permissions"
|
||||
|
||||
|
||||
def test_delete_item(
|
||||
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
||||
) -> None:
|
||||
item = create_random_item(db)
|
||||
response = client.delete(
|
||||
f"{settings.API_V1_STR}/items/{item.id}",
|
||||
headers=superuser_token_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert content["message"] == "Item deleted successfully"
|
||||
|
||||
|
||||
def test_delete_item_not_found(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
response = client.delete(
|
||||
f"{settings.API_V1_STR}/items/{uuid.uuid4()}",
|
||||
headers=superuser_token_headers,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
content = response.json()
|
||||
assert content["detail"] == "Item not found"
|
||||
|
||||
|
||||
def test_delete_item_not_enough_permissions(
|
||||
client: TestClient, normal_user_token_headers: dict[str, str], db: Session
|
||||
) -> None:
|
||||
item = create_random_item(db)
|
||||
response = client.delete(
|
||||
f"{settings.API_V1_STR}/items/{item.id}",
|
||||
headers=normal_user_token_headers,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
content = response.json()
|
||||
assert content["detail"] == "Not enough permissions"
|
||||
@@ -7,7 +7,7 @@ from sqlmodel import Session, delete
|
||||
from app.core.config import settings
|
||||
from app.core.db import engine, init_db
|
||||
from app.main import app
|
||||
from app.models import Item, Location, User
|
||||
from app.models import Location, User
|
||||
from tests.utils.user import authentication_token_from_email
|
||||
from tests.utils.utils import get_superuser_token_headers
|
||||
|
||||
@@ -17,8 +17,6 @@ def db() -> Generator[Session, None, None]:
|
||||
with Session(engine) as session:
|
||||
init_db(session)
|
||||
yield session
|
||||
statement = delete(Item)
|
||||
session.execute(statement)
|
||||
statement = delete(Location)
|
||||
session.execute(statement)
|
||||
statement = delete(User)
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
from sqlmodel import Session
|
||||
|
||||
from app import crud
|
||||
from app.models import Item, ItemCreate
|
||||
from tests.utils.user import create_random_user
|
||||
from tests.utils.utils import random_lower_string
|
||||
|
||||
|
||||
def create_random_item(db: Session) -> Item:
|
||||
user = create_random_user(db)
|
||||
owner_id = user.id
|
||||
assert owner_id is not None
|
||||
title = random_lower_string()
|
||||
description = random_lower_string()
|
||||
item_in = ItemCreate(title=title, description=description)
|
||||
return crud.create_item(session=db, item_in=item_in, owner_id=owner_id)
|
||||
@@ -1,144 +0,0 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { Plus } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { z } from "zod"
|
||||
|
||||
import { type ItemCreate, ItemsService } from "@/client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { LoadingButton } from "@/components/ui/loading-button"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { handleError } from "@/utils"
|
||||
|
||||
const formSchema = z.object({
|
||||
title: z.string().min(1, { message: "Title is required" }),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
type FormData = z.infer<typeof formSchema>
|
||||
|
||||
const AddItem = () => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
const { showSuccessToast, showErrorToast } = useCustomToast()
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
title: "",
|
||||
description: "",
|
||||
},
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: ItemCreate) =>
|
||||
ItemsService.createItem({ requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showSuccessToast("Item created successfully")
|
||||
form.reset()
|
||||
setIsOpen(false)
|
||||
},
|
||||
onError: handleError.bind(showErrorToast),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["items"] })
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = (data: FormData) => {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="my-4">
|
||||
<Plus className="mr-2" />
|
||||
Add Item
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Item</DialogTitle>
|
||||
<DialogDescription>
|
||||
Fill in the details to add a new item.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Title <span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Title"
|
||||
type="text"
|
||||
{...field}
|
||||
required
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Description" type="text" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" disabled={mutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<LoadingButton type="submit" loading={mutation.isPending}>
|
||||
Save
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddItem
|
||||
@@ -1,94 +0,0 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { Trash2 } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
|
||||
import { ItemsService } from "@/client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu"
|
||||
import { LoadingButton } from "@/components/ui/loading-button"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { handleError } from "@/utils"
|
||||
|
||||
interface DeleteItemProps {
|
||||
id: string
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
const DeleteItem = ({ id, onSuccess }: DeleteItemProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
const { showSuccessToast, showErrorToast } = useCustomToast()
|
||||
const { handleSubmit } = useForm()
|
||||
|
||||
const deleteItem = async (id: string) => {
|
||||
await ItemsService.deleteItem({ id: id })
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: deleteItem,
|
||||
onSuccess: () => {
|
||||
showSuccessToast("The item was deleted successfully")
|
||||
setIsOpen(false)
|
||||
onSuccess()
|
||||
},
|
||||
onError: handleError.bind(showErrorToast),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries()
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async () => {
|
||||
mutation.mutate(id)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete Item
|
||||
</DropdownMenuItem>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Item</DialogTitle>
|
||||
<DialogDescription>
|
||||
This item will be permanently deleted. Are you sure? You will not
|
||||
be able to undo this action.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" disabled={mutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<LoadingButton
|
||||
variant="destructive"
|
||||
type="submit"
|
||||
loading={mutation.isPending}
|
||||
>
|
||||
Delete
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default DeleteItem
|
||||
@@ -1,145 +0,0 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { Pencil } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { z } from "zod"
|
||||
|
||||
import { type ItemPublic, ItemsService } from "@/client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { LoadingButton } from "@/components/ui/loading-button"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { handleError } from "@/utils"
|
||||
|
||||
const formSchema = z.object({
|
||||
title: z.string().min(1, { message: "Title is required" }),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
type FormData = z.infer<typeof formSchema>
|
||||
|
||||
interface EditItemProps {
|
||||
item: ItemPublic
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
const EditItem = ({ item, onSuccess }: EditItemProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
const { showSuccessToast, showErrorToast } = useCustomToast()
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
title: item.title,
|
||||
description: item.description ?? undefined,
|
||||
},
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: FormData) =>
|
||||
ItemsService.updateItem({ id: item.id, requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showSuccessToast("Item updated successfully")
|
||||
setIsOpen(false)
|
||||
onSuccess()
|
||||
},
|
||||
onError: handleError.bind(showErrorToast),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["items"] })
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = (data: FormData) => {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<Pencil />
|
||||
Edit Item
|
||||
</DropdownMenuItem>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Item</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update the item details below.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Title <span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Title" type="text" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Description" type="text" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" disabled={mutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<LoadingButton type="submit" loading={mutation.isPending}>
|
||||
Save
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditItem
|
||||
@@ -1,34 +0,0 @@
|
||||
import { EllipsisVertical } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
import type { ItemPublic } from "@/client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import DeleteItem from "../Items/DeleteItem"
|
||||
import EditItem from "../Items/EditItem"
|
||||
|
||||
interface ItemActionsMenuProps {
|
||||
item: ItemPublic
|
||||
}
|
||||
|
||||
export const ItemActionsMenu = ({ item }: ItemActionsMenuProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<EllipsisVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<EditItem item={item} onSuccess={() => setOpen(false)} />
|
||||
<DeleteItem id={item.id} onSuccess={() => setOpen(false)} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { Check, Copy } from "lucide-react"
|
||||
|
||||
import type { ItemPublic } from "@/client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ItemActionsMenu } from "./ItemActionsMenu"
|
||||
|
||||
function CopyId({ id }: { id: string }) {
|
||||
const [copiedText, copy] = useCopyToClipboard()
|
||||
const isCopied = copiedText === id
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 group">
|
||||
<span className="font-mono text-xs text-muted-foreground">{id}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() => copy(id)}
|
||||
>
|
||||
{isCopied ? (
|
||||
<Check className="size-3 text-green-500" />
|
||||
) : (
|
||||
<Copy className="size-3" />
|
||||
)}
|
||||
<span className="sr-only">Copy ID</span>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const columns: ColumnDef<ItemPublic>[] = [
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: "ID",
|
||||
cell: ({ row }) => <CopyId id={row.original.id} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: "Title",
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.original.title}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
header: "Description",
|
||||
cell: ({ row }) => {
|
||||
const description = row.original.description
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"max-w-xs truncate block text-muted-foreground",
|
||||
!description && "italic",
|
||||
)}
|
||||
>
|
||||
{description || "No description"}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end">
|
||||
<ItemActionsMenu item={row.original} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
|
||||
const PendingItems = () => (
|
||||
const PendingSkeleton = () => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -43,4 +43,4 @@ const PendingItems = () => (
|
||||
</Table>
|
||||
)
|
||||
|
||||
export default PendingItems
|
||||
export default PendingSkeleton
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Briefcase, Home, MapPin, Users } from "lucide-react"
|
||||
import { Home, MapPin, Users } from "lucide-react"
|
||||
|
||||
import { SidebarAppearance } from "@/components/Common/Appearance"
|
||||
import { Logo } from "@/components/Common/Logo"
|
||||
@@ -15,7 +15,6 @@ import { User } from "./User"
|
||||
const baseItems: Item[] = [
|
||||
{ icon: Home, title: "Dashboard", path: "/" },
|
||||
{ icon: MapPin, title: "Locations", path: "/locations" },
|
||||
{ icon: Briefcase, title: "Items", path: "/items" },
|
||||
]
|
||||
|
||||
export function AppSidebar() {
|
||||
|
||||
@@ -17,7 +17,6 @@ import { Route as LayoutRouteImport } from './routes/_layout'
|
||||
import { Route as LayoutIndexRouteImport } from './routes/_layout/index'
|
||||
import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings'
|
||||
import { Route as LayoutLocationsRouteImport } from './routes/_layout/locations'
|
||||
import { Route as LayoutItemsRouteImport } from './routes/_layout/items'
|
||||
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
|
||||
|
||||
const SignupRoute = SignupRouteImport.update({
|
||||
@@ -59,11 +58,6 @@ const LayoutLocationsRoute = LayoutLocationsRouteImport.update({
|
||||
path: '/locations',
|
||||
getParentRoute: () => LayoutRoute,
|
||||
} as any)
|
||||
const LayoutItemsRoute = LayoutItemsRouteImport.update({
|
||||
id: '/items',
|
||||
path: '/items',
|
||||
getParentRoute: () => LayoutRoute,
|
||||
} as any)
|
||||
const LayoutAdminRoute = LayoutAdminRouteImport.update({
|
||||
id: '/admin',
|
||||
path: '/admin',
|
||||
@@ -77,7 +71,6 @@ export interface FileRoutesByFullPath {
|
||||
'/reset-password': typeof ResetPasswordRoute
|
||||
'/signup': typeof SignupRoute
|
||||
'/admin': typeof LayoutAdminRoute
|
||||
'/items': typeof LayoutItemsRoute
|
||||
'/locations': typeof LayoutLocationsRoute
|
||||
'/settings': typeof LayoutSettingsRoute
|
||||
}
|
||||
@@ -87,7 +80,6 @@ export interface FileRoutesByTo {
|
||||
'/reset-password': typeof ResetPasswordRoute
|
||||
'/signup': typeof SignupRoute
|
||||
'/admin': typeof LayoutAdminRoute
|
||||
'/items': typeof LayoutItemsRoute
|
||||
'/locations': typeof LayoutLocationsRoute
|
||||
'/settings': typeof LayoutSettingsRoute
|
||||
'/': typeof LayoutIndexRoute
|
||||
@@ -100,7 +92,6 @@ export interface FileRoutesById {
|
||||
'/reset-password': typeof ResetPasswordRoute
|
||||
'/signup': typeof SignupRoute
|
||||
'/_layout/admin': typeof LayoutAdminRoute
|
||||
'/_layout/items': typeof LayoutItemsRoute
|
||||
'/_layout/locations': typeof LayoutLocationsRoute
|
||||
'/_layout/settings': typeof LayoutSettingsRoute
|
||||
'/_layout/': typeof LayoutIndexRoute
|
||||
@@ -114,7 +105,6 @@ export interface FileRouteTypes {
|
||||
| '/reset-password'
|
||||
| '/signup'
|
||||
| '/admin'
|
||||
| '/items'
|
||||
| '/locations'
|
||||
| '/settings'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
@@ -124,7 +114,6 @@ export interface FileRouteTypes {
|
||||
| '/reset-password'
|
||||
| '/signup'
|
||||
| '/admin'
|
||||
| '/items'
|
||||
| '/locations'
|
||||
| '/settings'
|
||||
| '/'
|
||||
@@ -136,7 +125,6 @@ export interface FileRouteTypes {
|
||||
| '/reset-password'
|
||||
| '/signup'
|
||||
| '/_layout/admin'
|
||||
| '/_layout/items'
|
||||
| '/_layout/locations'
|
||||
| '/_layout/settings'
|
||||
| '/_layout/'
|
||||
@@ -208,13 +196,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof LayoutLocationsRouteImport
|
||||
parentRoute: typeof LayoutRoute
|
||||
}
|
||||
'/_layout/items': {
|
||||
id: '/_layout/items'
|
||||
path: '/items'
|
||||
fullPath: '/items'
|
||||
preLoaderRoute: typeof LayoutItemsRouteImport
|
||||
parentRoute: typeof LayoutRoute
|
||||
}
|
||||
'/_layout/admin': {
|
||||
id: '/_layout/admin'
|
||||
path: '/admin'
|
||||
@@ -227,7 +208,6 @@ declare module '@tanstack/react-router' {
|
||||
|
||||
interface LayoutRouteChildren {
|
||||
LayoutAdminRoute: typeof LayoutAdminRoute
|
||||
LayoutItemsRoute: typeof LayoutItemsRoute
|
||||
LayoutLocationsRoute: typeof LayoutLocationsRoute
|
||||
LayoutSettingsRoute: typeof LayoutSettingsRoute
|
||||
LayoutIndexRoute: typeof LayoutIndexRoute
|
||||
@@ -235,7 +215,6 @@ interface LayoutRouteChildren {
|
||||
|
||||
const LayoutRouteChildren: LayoutRouteChildren = {
|
||||
LayoutAdminRoute: LayoutAdminRoute,
|
||||
LayoutItemsRoute: LayoutItemsRoute,
|
||||
LayoutLocationsRoute: LayoutLocationsRoute,
|
||||
LayoutSettingsRoute: LayoutSettingsRoute,
|
||||
LayoutIndexRoute: LayoutIndexRoute,
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useSuspenseQuery } from "@tanstack/react-query"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Search } from "lucide-react"
|
||||
import { Suspense } from "react"
|
||||
|
||||
import { ItemsService } from "@/client"
|
||||
import { DataTable } from "@/components/Common/DataTable"
|
||||
import AddItem from "@/components/Items/AddItem"
|
||||
import { columns } from "@/components/Items/columns"
|
||||
import PendingItems from "@/components/Pending/PendingItems"
|
||||
|
||||
function getItemsQueryOptions() {
|
||||
return {
|
||||
queryFn: () => ItemsService.readItems({ skip: 0, limit: 100 }),
|
||||
queryKey: ["items"],
|
||||
}
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/_layout/items")({
|
||||
component: Items,
|
||||
head: () => ({
|
||||
meta: [
|
||||
{
|
||||
title: "Items - FastAPI Template",
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
function ItemsTableContent() {
|
||||
const { data: items } = useSuspenseQuery(getItemsQueryOptions())
|
||||
|
||||
if (items.data.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-center py-12">
|
||||
<div className="rounded-full bg-muted p-4 mb-4">
|
||||
<Search className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">You don't have any items yet</h3>
|
||||
<p className="text-muted-foreground">Add a new item to get started</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <DataTable columns={columns} data={items.data} />
|
||||
}
|
||||
|
||||
function ItemsTable() {
|
||||
return (
|
||||
<Suspense fallback={<PendingItems />}>
|
||||
<ItemsTableContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function Items() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Items</h1>
|
||||
<p className="text-muted-foreground">Create and manage your items</p>
|
||||
</div>
|
||||
<AddItem />
|
||||
</div>
|
||||
<ItemsTable />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { LocationsService } from "@/client"
|
||||
import { DataTable } from "@/components/Common/DataTable"
|
||||
import AddLocation from "@/components/Locations/AddLocation"
|
||||
import { columns } from "@/components/Locations/columns"
|
||||
import PendingItems from "@/components/Pending/PendingItems"
|
||||
import PendingSkeleton from "@/components/Pending/PendingSkeleton"
|
||||
|
||||
function getLocationsQueryOptions() {
|
||||
return {
|
||||
@@ -47,7 +47,7 @@ function LocationsTableContent() {
|
||||
|
||||
function LocationsTable() {
|
||||
return (
|
||||
<Suspense fallback={<PendingItems />}>
|
||||
<Suspense fallback={<PendingSkeleton />}>
|
||||
<LocationsTableContent />
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { createUser } from "./utils/privateApi"
|
||||
import {
|
||||
randomEmail,
|
||||
randomItemDescription,
|
||||
randomItemTitle,
|
||||
randomPassword,
|
||||
} from "./utils/random"
|
||||
import { logInUser } from "./utils/user"
|
||||
|
||||
test("Items page is accessible and shows correct title", async ({ page }) => {
|
||||
await page.goto("/items")
|
||||
await expect(page.getByRole("heading", { name: "Items" })).toBeVisible()
|
||||
await expect(page.getByText("Create and manage your items")).toBeVisible()
|
||||
})
|
||||
|
||||
test("Add Item button is visible", async ({ page }) => {
|
||||
await page.goto("/items")
|
||||
await expect(page.getByRole("button", { name: "Add Item" })).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe("Items management", () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } })
|
||||
let email: string
|
||||
const password = randomPassword()
|
||||
|
||||
test.beforeAll(async () => {
|
||||
email = randomEmail()
|
||||
await createUser({ email, password })
|
||||
})
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await logInUser(page, email, password)
|
||||
await page.goto("/items")
|
||||
})
|
||||
|
||||
test("Create a new item successfully", async ({ page }) => {
|
||||
const title = randomItemTitle()
|
||||
const description = randomItemDescription()
|
||||
|
||||
await page.getByRole("button", { name: "Add Item" }).click()
|
||||
await page.getByLabel("Title").fill(title)
|
||||
await page.getByLabel("Description").fill(description)
|
||||
await page.getByRole("button", { name: "Save" }).click()
|
||||
|
||||
await expect(page.getByText("Item created successfully")).toBeVisible()
|
||||
await expect(page.getByText(title)).toBeVisible()
|
||||
})
|
||||
|
||||
test("Create item with only required fields", async ({ page }) => {
|
||||
const title = randomItemTitle()
|
||||
|
||||
await page.getByRole("button", { name: "Add Item" }).click()
|
||||
await page.getByLabel("Title").fill(title)
|
||||
await page.getByRole("button", { name: "Save" }).click()
|
||||
|
||||
await expect(page.getByText("Item created successfully")).toBeVisible()
|
||||
await expect(page.getByText(title)).toBeVisible()
|
||||
})
|
||||
|
||||
test("Cancel item creation", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "Add Item" }).click()
|
||||
await page.getByLabel("Title").fill("Test Item")
|
||||
await page.getByRole("button", { name: "Cancel" }).click()
|
||||
|
||||
await expect(page.getByRole("dialog")).not.toBeVisible()
|
||||
})
|
||||
|
||||
test("Title is required", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "Add Item" }).click()
|
||||
await page.getByLabel("Title").fill("")
|
||||
await page.getByLabel("Title").blur()
|
||||
|
||||
await expect(page.getByText("Title is required")).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe("Edit and Delete", () => {
|
||||
let itemTitle: string
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
itemTitle = randomItemTitle()
|
||||
|
||||
await page.getByRole("button", { name: "Add Item" }).click()
|
||||
await page.getByLabel("Title").fill(itemTitle)
|
||||
await page.getByRole("button", { name: "Save" }).click()
|
||||
await expect(page.getByText("Item created successfully")).toBeVisible()
|
||||
await expect(page.getByRole("dialog")).not.toBeVisible()
|
||||
})
|
||||
|
||||
test("Edit an item successfully", async ({ page }) => {
|
||||
const itemRow = page.getByRole("row").filter({ hasText: itemTitle })
|
||||
await itemRow.getByRole("button").last().click()
|
||||
await page.getByRole("menuitem", { name: "Edit Item" }).click()
|
||||
|
||||
const updatedTitle = randomItemTitle()
|
||||
await page.getByLabel("Title").fill(updatedTitle)
|
||||
await page.getByRole("button", { name: "Save" }).click()
|
||||
|
||||
await expect(page.getByText("Item updated successfully")).toBeVisible()
|
||||
await expect(page.getByText(updatedTitle)).toBeVisible()
|
||||
})
|
||||
|
||||
test("Delete an item successfully", async ({ page }) => {
|
||||
const itemRow = page.getByRole("row").filter({ hasText: itemTitle })
|
||||
await itemRow.getByRole("button").last().click()
|
||||
await page.getByRole("menuitem", { name: "Delete Item" }).click()
|
||||
|
||||
await page.getByRole("button", { name: "Delete" }).click()
|
||||
|
||||
await expect(
|
||||
page.getByText("The item was deleted successfully"),
|
||||
).toBeVisible()
|
||||
await expect(page.getByText(itemTitle)).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Items empty state", () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } })
|
||||
|
||||
test("Shows empty state message when no items exist", async ({ page }) => {
|
||||
const email = randomEmail()
|
||||
const password = randomPassword()
|
||||
await createUser({ email, password })
|
||||
await logInUser(page, email, password)
|
||||
|
||||
await page.goto("/items")
|
||||
|
||||
await expect(page.getByText("You don't have any items yet")).toBeVisible()
|
||||
await expect(page.getByText("Add a new item to get started")).toBeVisible()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user