diff --git a/app/Actions/Projects/GetProjects.php b/app/Actions/Projects/GetProjects.php new file mode 100644 index 000000000..c507f3382 --- /dev/null +++ b/app/Actions/Projects/GetProjects.php @@ -0,0 +1,43 @@ +validate($input); + + $projectsQuery = $user->allProjects(); + + if (! empty($validated['query'])) { + $projectsQuery->where('name', 'like', "%{$validated['query']}%"); + } + + $page = $validated['page'] ?? 1; + + return $projectsQuery + ->skip(($page - 1) * $perPage) + ->take($perPage) + ->get(); + } + + private function validate(array $input): array + { + return Validator::make($input, [ + 'query' => [ + 'nullable', + 'string', + ], + 'page' => [ + 'nullable', + 'integer', + 'min:1', + ], + ])->validate(); + } +} diff --git a/app/Http/Controllers/Project/ProjectController.php b/app/Http/Controllers/Project/ProjectController.php index 93008212b..3bc3b6b06 100644 --- a/app/Http/Controllers/Project/ProjectController.php +++ b/app/Http/Controllers/Project/ProjectController.php @@ -4,6 +4,7 @@ use App\Actions\Projects\CreateProject; use App\Actions\Projects\DeleteProject; +use App\Actions\Projects\GetProjects; use App\Actions\Projects\UpdateProject; use App\Http\Controllers\Controller; use App\Http\Resources\ProjectResource; @@ -12,6 +13,7 @@ use App\Models\UserProject; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Http\Resources\Json\ResourceCollection; use Inertia\Inertia; use Inertia\Response; use Spatie\RouteAttributes\Attributes\Delete; @@ -46,6 +48,16 @@ public function index(): Response ]); } + #[Get('/json', name: 'projects.json')] + public function json(Request $request): ResourceCollection + { + $this->authorize('viewAny', Project::class); + + $projects = app(GetProjects::class)->get(user(), $request->input(), 10); + + return ProjectResource::collection($projects); + } + #[Post('/', name: 'projects.store')] public function store(Request $request): RedirectResponse { diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 4753b6601..79c3373cd 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -98,8 +98,6 @@ public function share(Request $request): array 'quote' => ['message' => trim($message), 'author' => trim($author)], 'auth' => $user ? [ 'user' => UserResource::make($user->load('projects')), - // TODO: limit projects - 'projects' => ProjectResource::collection($user->projects()->get()), 'currentProject' => ProjectResource::make($currentProject), ] : null, 'public_key_text' => __('servers.create.public_key_text', ['public_key' => get_public_key_content()]), diff --git a/resources/js/components/project-select.tsx b/resources/js/components/project-select.tsx new file mode 100644 index 000000000..e31e40671 --- /dev/null +++ b/resources/js/components/project-select.tsx @@ -0,0 +1,177 @@ +import { type Project } from '@/types/project'; +import { useState, useEffect, useRef, useCallback } from 'react'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Button } from '@/components/ui/button'; +import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react'; +import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'; +import { cn } from '@/lib/utils'; +import axios from 'axios'; +import { ReactNode } from 'react'; + +interface ProjectSelectProps { + value?: string; + onValueChange: (value: string, project: Project) => void; + placeholder?: string; + trigger?: ReactNode; + className?: string; + open?: boolean; + onOpenChange?: (open: boolean) => void; + footer?: ReactNode; + onRefetch?: (refetch: () => void) => void; +} + +export function ProjectSelect({ + value, + onValueChange, + placeholder = 'Select project...', + trigger, + className, + open: controlledOpen, + onOpenChange: controlledOnOpenChange, + footer, + onRefetch, +}: ProjectSelectProps) { + const [internalOpen, setInternalOpen] = useState(false); + const [query, setQuery] = useState(''); + const loadMoreRef = useRef(null); + + const open = controlledOpen !== undefined ? controlledOpen : internalOpen; + const setOpen = controlledOnOpenChange || setInternalOpen; + + const { data, isFetching, refetch, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ + queryKey: ['projects', query], + queryFn: async ({ pageParam = 1 }) => { + const response = await axios.get(route('projects.json', { query: query || '', page: pageParam })); + return response.data; + }, + enabled: open, + staleTime: Infinity, + gcTime: 1000 * 60 * 5, + refetchOnMount: false, + refetchOnWindowFocus: false, + initialPageParam: 1, + getNextPageParam: (lastPage, allPages) => { + return lastPage.length === 10 ? allPages.length + 1 : undefined; + }, + }); + + const projects = data?.pages.flat() ?? []; + const selectedProject = projects.find((project) => project.id.toString() === value); + const refetchRef = useRef<(() => void) | null>(null); + + const safeRefetch = useCallback(() => { + if (refetchRef.current) { + refetchRef.current(); + } + }, []); + + useEffect(() => { + if (refetch) { + refetchRef.current = refetch; + } + }, [refetch]); + + useEffect(() => { + if (onRefetch && open) { + onRefetch(safeRefetch); + } + }, [onRefetch, open, safeRefetch]); + + useEffect(() => { + if (!open || !hasNextPage) return; + + let observer: IntersectionObserver | null = null; + const timeoutId = setTimeout(() => { + if (!loadMoreRef.current) return; + + observer = new IntersectionObserver( + (entries) => { + const [entry] = entries; + if (entry.isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + { threshold: 0.1 }, + ); + + observer.observe(loadMoreRef.current); + }, 100); + + return () => { + clearTimeout(timeoutId); + if (observer) { + observer.disconnect(); + } + }; + }, [open, hasNextPage, isFetchingNextPage, fetchNextPage, query, projects.length]); + + const handleClose = () => { + const commandList = document.querySelector('[data-slot="command-list"]'); + if (commandList instanceof HTMLElement) { + commandList.scrollTop = 0; + } + setQuery(''); + }; + + const handleOpenChange = (isOpen: boolean) => { + setOpen(isOpen); + if (!isOpen) { + handleClose(); + } + }; + + const handleSelect = (project: Project) => { + onValueChange(project.id.toString(), project); + setOpen(false); + }; + + const defaultTrigger = ( + + ); + + return ( + + {trigger || defaultTrigger} + + + + e.stopPropagation()}> + {projects.length === 0 ? ( +
+ {isFetching ? 'Searching...' : query === '' ? 'Start typing to search projects' : 'No projects found.'} +
+ ) : ( + + {projects.map((project: Project) => ( + handleSelect(project)} + className="truncate" + > + {project.name} + + + ))} + {hasNextPage && ( +
+ {isFetchingNextPage ? ( + Loading more... + ) : ( + Scroll for more + )} +
+ )} +
+ )} +
+ {footer &&
{footer}
} +
+
+
+ ); +} diff --git a/resources/js/components/project-switch.tsx b/resources/js/components/project-switch.tsx index 87164f83c..514d4d0dd 100644 --- a/resources/js/components/project-switch.tsx +++ b/resources/js/components/project-switch.tsx @@ -1,68 +1,81 @@ import { type SharedData } from '@/types'; +import { type Project } from '@/types/project'; import { useForm, usePage } from '@inertiajs/react'; -import { useState } from 'react'; -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; +import { useState, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { ChevronsUpDownIcon, PlusIcon } from 'lucide-react'; import { useInitials } from '@/hooks/use-initials'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import ProjectForm from '@/pages/projects/components/project-form'; +import { ProjectSelect } from '@/components/project-select'; +import { CommandGroup, CommandItem } from '@/components/ui/command'; export function ProjectSwitch() { const page = usePage(); const { auth } = page.props; - const [selectedProject, setSelectedProject] = useState(auth.currentProject?.id?.toString() ?? ''); + const [open, setOpen] = useState(false); + const [projectFormOpen, setProjectFormOpen] = useState(false); + const [selected, setSelected] = useState(auth.currentProject?.id?.toString() ?? ''); + const [refetchFn, setRefetchFn] = useState<(() => void) | null>(null); const initials = useInitials(); const form = useForm(); - const handleProjectChange = (projectId: string) => { - const selectedProject = auth.projects.find((project) => project.id.toString() === projectId); - if (selectedProject) { - setSelectedProject(selectedProject.id.toString()); - form.patch(route('projects.switch', { project: projectId, currentPath: window.location.pathname })); + useEffect(() => { + setSelected(auth.currentProject?.id?.toString() ?? ''); + }, [auth.currentProject?.id]); + + useEffect(() => { + if (!projectFormOpen && open && refetchFn) { + refetchFn(); } + }, [projectFormOpen, open, refetchFn]); + + const handleProjectChange = (value: string, project: Project) => { + setSelected(value); + setOpen(false); + form.patch(route('projects.switch', { project: project.id, currentPath: window.location.pathname })); }; + const footer = ( + + + { + setProjectFormOpen(true); + }} + className="gap-0" + > +
+ + Create new project +
+
+
+
+ ); + + const trigger = ( + + ); + return (
- - - - - - {auth.projects.map((project) => ( - handleProjectChange(project.id.toString())} - > - {project.name} - - ))} - - - e.preventDefault()}> -
- - Create new project -
-
-
-
-
+
); } diff --git a/resources/js/pages/projects/components/project-form.tsx b/resources/js/pages/projects/components/project-form.tsx index 191a36b66..d97f38682 100644 --- a/resources/js/pages/projects/components/project-form.tsx +++ b/resources/js/pages/projects/components/project-form.tsx @@ -31,10 +31,8 @@ export default function ProjectForm({ }) { const [open, setOpen] = useState(defaultOpen || false); useEffect(() => { - if (defaultOpen) { - setOpen(defaultOpen); - } - }, [setOpen, defaultOpen]); + setOpen(defaultOpen || false); + }, [defaultOpen]); const handleOpenChange = (open: boolean) => { setOpen(open); diff --git a/resources/js/pages/servers/components/transfer-server.tsx b/resources/js/pages/servers/components/transfer-server.tsx index 5ca3306a4..5ed8a40ce 100644 --- a/resources/js/pages/servers/components/transfer-server.tsx +++ b/resources/js/pages/servers/components/transfer-server.tsx @@ -11,17 +11,15 @@ import { DialogTrigger, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; -import { useForm, usePage } from '@inertiajs/react'; +import { useForm } from '@inertiajs/react'; import { Form, FormField, FormFields } from '@/components/ui/form'; import { Label } from '@/components/ui/label'; import InputError from '@/components/ui/input-error'; import { LoaderCircleIcon } from 'lucide-react'; -import type { SharedData } from '@/types'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { ProjectSelect } from '@/components/project-select'; export default function TransferServer({ server, children }: { server: Server; children: ReactNode }) { const [open, setOpen] = useState(false); - const page = usePage(); const form = useForm({ project_id: server.project_id.toString(), @@ -37,6 +35,10 @@ export default function TransferServer({ server, children }: { server: Server; c }); }; + const handleProjectChange = (value: string) => { + form.setData('project_id', value); + }; + return ( {children} @@ -50,18 +52,7 @@ export default function TransferServer({ server, children }: { server: Server; c - + diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index 51721e048..b43671f24 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -8,7 +8,6 @@ import { DynamicFieldConfig } from './dynamic-field-config'; export interface Auth { user: User; - projects: Project[]; currentProject?: Project; }