Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ bld/
[Ll]ogs/
# Artifact paths with Windows-style backslash separators (created by dotnet tools on Linux)
src/Anything.API/bin\Debug/
src/Anything.API/bin\

# .NET Core
project.lock.json
Expand Down
68 changes: 68 additions & 0 deletions anything-frontend/src/app/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { Button } from "@/components/ui/button";
import { useCreateInvite, useCurrentUser } from "@/hooks/useAuth";
import { usePendingRecommendations, useApproveRecommendation, useDeleteRecommendation } from "@/hooks/useRecommendations";
import { isAdmin } from "@/lib/roles";
import { useState } from "react";
import { toast } from "sonner";
Expand All @@ -13,6 +14,9 @@ export default function AdminPage() {
const createInvite = useCreateInvite();
const { data: user } = useCurrentUser();
const router = useRouter();
const { data: pendingRecommendations } = usePendingRecommendations();
const approveRecommendation = useApproveRecommendation();
const deleteRecommendation = useDeleteRecommendation();

// Check if user is admin
if (user && !isAdmin(user.role)) {
Expand Down Expand Up @@ -57,6 +61,24 @@ export default function AdminPage() {
toast.success("Invite URL copied to clipboard!");
};

const handleApproveRecommendation = async (id: number) => {
try {
await approveRecommendation.mutateAsync(id);
toast.success("Recommendation approved!");
} catch {
toast.error("Failed to approve recommendation.");
}
};

const handleDeleteRecommendation = async (id: number) => {
try {
await deleteRecommendation.mutateAsync(id);
toast.success("Recommendation rejected.");
} catch {
toast.error("Failed to reject recommendation.");
}
};

return (
<div className="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800">
<main className="container mx-auto px-4 py-8 max-w-4xl">
Expand Down Expand Up @@ -138,6 +160,52 @@ export default function AdminPage() {
</ul>
</div>
</div>

<div className="mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Shopping List Recommendations
</h2>
<p className="text-gray-600 dark:text-gray-300 mb-6">
Review and approve item recommendations added by users. Approved items will appear as autocomplete suggestions.
</p>

{pendingRecommendations && pendingRecommendations.length === 0 && (
<p className="text-gray-500 dark:text-gray-400 text-sm">
No pending recommendations.
</p>
)}

{pendingRecommendations && pendingRecommendations.length > 0 && (
<ul className="space-y-2">
{pendingRecommendations.map((rec) => (
<li
key={rec.id}
className="flex items-center justify-between p-3 border border-gray-200 dark:border-gray-700 rounded-md"
>
<span className="text-gray-900 dark:text-white">{rec.name}</span>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => handleApproveRecommendation(rec.id!)}
disabled={approveRecommendation.isPending}
className="bg-green-600 hover:bg-green-700 text-white"
>
Approve
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleDeleteRecommendation(rec.id!)}
disabled={deleteRecommendation.isPending}
>
Reject
</Button>
</div>
</li>
))}
</ul>
)}
</div>
</main>
</div>
);
Expand Down
70 changes: 60 additions & 10 deletions anything-frontend/src/app/shopping-lists/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,26 @@ import {
useRemoveShoppingListItem,
useCompleteShoppingList,
} from "@/hooks/useShoppingLists";
import { useApprovedRecommendations } from "@/hooks/useRecommendations";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useState, useRef } from "react";
import { toast } from "sonner";
import type { ShoppingListItem } from "@/lib/api-client/models/index";
import { apiClient } from "@/lib/apiClient";
import { useQuery } from "@tanstack/react-query";
import type { ShoppingList } from "@/lib/api-client/models/index";

export default function ShoppingListDetailPage() {
const SUGGESTION_CLOSE_DELAY_MS = 150;
const params = useParams();
const router = useRouter();
const listId = Number(params.id);

const [isEditMode, setIsEditMode] = useState(false);
const [newItemName, setNewItemName] = useState("");
const [editingItem, setEditingItem] = useState<{ id: number; name: string } | null>(null);
const [showSuggestions, setShowSuggestions] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);

const { data: list } = useQuery({
queryKey: ["shoppingList", listId],
Expand All @@ -37,6 +41,15 @@ export default function ShoppingListDetailPage() {
const updateItem = useUpdateShoppingListItem(listId);
const removeItem = useRemoveShoppingListItem(listId);
const completeList = useCompleteShoppingList();
const { data: recommendations } = useApprovedRecommendations();

const filteredSuggestions = recommendations?.filter(
(r) =>
r.name &&
newItemName.trim().length > 0 &&
r.name.toLowerCase().includes(newItemName.toLowerCase()) &&
!items?.some((i) => i.name?.toLowerCase() === r.name?.toLowerCase() && !i.isChecked)
) ?? [];

const uncheckedItems = items?.filter((i) => !i.isChecked) ?? [];
const showCompleteButton = uncheckedItems.length > 0 && uncheckedItems.length < 3;
Expand All @@ -61,6 +74,18 @@ export default function ShoppingListDetailPage() {
try {
await addItem.mutateAsync(newItemName);
setNewItemName("");
setShowSuggestions(false);
toast.success("Item added");
} catch {
toast.error("Failed to add item. Please try again.");
}
};

const handleSelectSuggestion = async (name: string) => {
setShowSuggestions(false);
try {
await addItem.mutateAsync(name);
setNewItemName("");
toast.success("Item added");
} catch {
toast.error("Failed to add item. Please try again.");
Expand Down Expand Up @@ -150,15 +175,39 @@ export default function ShoppingListDetailPage() {

{isEditMode && (
<form onSubmit={handleAddItem} className="mb-6">
<div className="flex gap-2">
<input
type="text"
value={newItemName}
onChange={(e) => setNewItemName(e.target.value)}
placeholder="Add an item..."
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
autoFocus
/>
<div className="relative flex gap-2">
<div className="relative flex-1">
<input
ref={inputRef}
type="text"
value={newItemName}
onChange={(e) => {
setNewItemName(e.target.value);
setShowSuggestions(true);
}}
onFocus={() => setShowSuggestions(true)}
onBlur={() => setTimeout(() => setShowSuggestions(false), SUGGESTION_CLOSE_DELAY_MS)}
placeholder="Add an item..."
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
autoFocus
autoComplete="off"
/>
{showSuggestions && filteredSuggestions.length > 0 && (
<ul className="absolute z-10 top-full left-0 right-0 mt-1 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-md shadow-lg max-h-48 overflow-y-auto">
{filteredSuggestions.map((suggestion) => (
<li key={suggestion.id}>
<button
type="button"
onMouseDown={() => handleSelectSuggestion(suggestion.name!)}
className="w-full text-left px-4 py-2 hover:bg-blue-50 dark:hover:bg-gray-600 text-gray-900 dark:text-white"
>
{suggestion.name}
</button>
</li>
))}
</ul>
)}
</div>
<Button type="submit" disabled={addItem.isPending}>
{addItem.isPending ? "Adding..." : "Add"}
</Button>
Expand Down Expand Up @@ -292,3 +341,4 @@ export default function ShoppingListDetailPage() {
</div>
);
}

156 changes: 156 additions & 0 deletions anything-frontend/src/hooks/useRecommendations.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { renderHook, waitFor, act } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactNode } from 'react'
import {
useApprovedRecommendations,
usePendingRecommendations,
useApproveRecommendation,
useDeleteRecommendation,
} from '@/hooks/useRecommendations'

const mockApprovedGet = jest.fn()
const mockPendingGet = jest.fn()
const mockApprovePost = jest.fn()
const mockDeleteFn = jest.fn()
const mockApprove = { post: mockApprovePost }
const mockItemById = jest.fn(() => ({ approve: mockApprove, delete: mockDeleteFn }))
const mockPending = { get: mockPendingGet }

Check warning on line 17 in anything-frontend/src/hooks/useRecommendations.test.tsx

View workflow job for this annotation

GitHub Actions / Lint and Build (Next.js)

'mockPending' is assigned a value but never used

jest.mock('@/lib/apiClient', () => ({
apiClient: {
api: {
shoppingListRecommendations: {
get: (...args: unknown[]) => mockApprovedGet(...args),
pending: { get: (...args: unknown[]) => mockPendingGet(...args) },
byId: (...args: unknown[]) => mockItemById(...args),
},
},
},
}))

function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const Wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
Wrapper.displayName = 'TestQueryClientWrapper'
return Wrapper
}

describe('useRecommendations hooks', () => {
beforeEach(() => {
jest.clearAllMocks()
})

describe('useApprovedRecommendations', () => {
it('fetches approved recommendations', async () => {
const mockRecommendations = [
{ id: 1, name: 'Milk', isApproved: true },
{ id: 2, name: 'Bread', isApproved: true },
]
mockApprovedGet.mockResolvedValueOnce(mockRecommendations)

const { result } = renderHook(() => useApprovedRecommendations(), {
wrapper: createWrapper(),
})

await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toEqual(mockRecommendations)
expect(mockApprovedGet).toHaveBeenCalledTimes(1)
})

it('handles fetch error', async () => {
mockApprovedGet.mockRejectedValueOnce(new Error('Network error'))

const { result } = renderHook(() => useApprovedRecommendations(), {
wrapper: createWrapper(),
})

await waitFor(() => expect(result.current.isError).toBe(true))
})
})

describe('usePendingRecommendations', () => {
it('fetches pending recommendations', async () => {
const mockPending = [
{ id: 3, name: 'Cheese', isApproved: false },
]
mockPendingGet.mockResolvedValueOnce(mockPending)

const { result } = renderHook(() => usePendingRecommendations(), {
wrapper: createWrapper(),
})

await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toEqual(mockPending)
expect(mockPendingGet).toHaveBeenCalledTimes(1)
})
})

describe('useApproveRecommendation', () => {
it('calls approve endpoint with correct id', async () => {
mockApprovePost.mockResolvedValueOnce(undefined)

const { result } = renderHook(() => useApproveRecommendation(), {
wrapper: createWrapper(),
})

await act(async () => {
await result.current.mutateAsync(1)
})

expect(mockItemById).toHaveBeenCalledWith(1)
expect(mockApprovePost).toHaveBeenCalledTimes(1)
})

it('handles approval error', async () => {
mockApprovePost.mockRejectedValueOnce(new Error('Forbidden'))

const { result } = renderHook(() => useApproveRecommendation(), {
wrapper: createWrapper(),
})

await expect(
act(async () => {
await result.current.mutateAsync(1)
})
).rejects.toThrow('Forbidden')
})
})

describe('useDeleteRecommendation', () => {
it('calls delete endpoint with correct id', async () => {
mockDeleteFn.mockResolvedValueOnce(undefined)

const { result } = renderHook(() => useDeleteRecommendation(), {
wrapper: createWrapper(),
})

await act(async () => {
await result.current.mutateAsync(2)
})

expect(mockItemById).toHaveBeenCalledWith(2)
expect(mockDeleteFn).toHaveBeenCalledTimes(1)
})

it('handles delete error', async () => {
mockDeleteFn.mockRejectedValueOnce(new Error('Not Found'))

const { result } = renderHook(() => useDeleteRecommendation(), {
wrapper: createWrapper(),
})

await expect(
act(async () => {
await result.current.mutateAsync(99)
})
).rejects.toThrow('Not Found')
})
})
})
Loading
Loading