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

This commit is contained in:
魏风
2026-03-13 11:46:15 +08:00
parent ef93d4e5c2
commit 3c9a0343e9
19 changed files with 9 additions and 1069 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
),
},
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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