#591 - sites [wip]

This commit is contained in:
Saeed Vaziry
2025-05-25 22:17:19 +02:00
parent ff11fb44e0
commit f5fdbae4ac
77 changed files with 2156 additions and 414 deletions

View File

@ -8,12 +8,11 @@ import React from 'react';
import { ApiKey } from '@/types/api-key';
import { columns } from '@/pages/api-keys/components/columns';
import CreateApiKey from '@/pages/api-keys/components/create-api-key';
import { PaginatedData } from '@/types';
export default function ApiKeys() {
const page = usePage<{
apiKeys: {
data: ApiKey[];
};
apiKeys: PaginatedData<ApiKey>;
}>();
return (
<SettingsLayout>
@ -30,7 +29,7 @@ export default function ApiKeys() {
</CreateApiKey>
</div>
</div>
<DataTable columns={columns} data={page.props.apiKeys.data} />
<DataTable columns={columns} paginatedData={page.props.apiKeys} />
</Container>
</SettingsLayout>
);

View File

@ -12,13 +12,12 @@ import {
} from '@/components/ui/dialog';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { useForm } from '@inertiajs/react';
import { Link, useForm } from '@inertiajs/react';
import { LoaderCircleIcon, MoreVerticalIcon } from 'lucide-react';
import FormSuccessful from '@/components/form-successful';
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { Backup } from '@/types/backup';
import BackupFiles from '@/pages/backups/components/files';
import EditBackup from '@/pages/backups/components/edit-backup';
function Delete({ backup }: { backup: Backup }) {
@ -127,9 +126,9 @@ export const columns: ColumnDef<Backup>[] = [
<EditBackup backup={row.original}>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>Edit</DropdownMenuItem>
</EditBackup>
<BackupFiles backup={row.original}>
<Link href={route('backup-files', { server: row.original.server_id, backup: row.original.id })}>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>Files</DropdownMenuItem>
</BackupFiles>
</Link>
<DropdownMenuSeparator />
<Delete backup={row.original} />
</DropdownMenuContent>

View File

@ -0,0 +1,135 @@
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import { LoaderCircleIcon, MoreVerticalIcon } from 'lucide-react';
import { useForm } from '@inertiajs/react';
import { BackupFile } from '@/types/backup-file';
import { ColumnDef } from '@tanstack/react-table';
import DateTime from '@/components/date-time';
import { Badge } from '@/components/ui/badge';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import RestoreBackup from '@/pages/backups/components/restore-backup';
function Delete({ file, onDeleted }: { file: BackupFile; onDeleted?: (file: BackupFile) => void }) {
const [open, setOpen] = useState(false);
const form = useForm();
const submit = () => {
form.delete(
route('backup-files.destroy', {
server: file.server_id,
backup: file.backup_id,
backupFile: file.id,
}),
{
onSuccess: () => {
setOpen(false);
if (onDeleted) {
onDeleted(file);
}
},
},
);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<DropdownMenuItem variant="destructive" onSelect={(e) => e.preventDefault()}>
Delete
</DropdownMenuItem>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete backup file</DialogTitle>
<DialogDescription className="sr-only">Delete backup file</DialogDescription>
</DialogHeader>
<p className="p-4">Are you sure you want to this backup file?</p>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button variant="destructive" disabled={form.processing} onClick={submit}>
{form.processing && <LoaderCircleIcon className="animate-spin" />}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export const columns: ColumnDef<BackupFile>[] = [
{
accessorKey: 'name',
header: 'Name',
enableColumnFilter: true,
enableSorting: true,
},
{
accessorKey: 'created_at',
header: 'Created at',
enableColumnFilter: true,
enableSorting: true,
cell: ({ row }) => {
return <DateTime date={row.original.created_at} />;
},
},
{
accessorKey: 'restored_to',
header: 'Restored to',
enableColumnFilter: true,
enableSorting: true,
},
{
accessorKey: 'restored_at',
header: 'Restored at',
enableColumnFilter: true,
enableSorting: true,
cell: ({ row }) => {
return row.original.restored_at ? <DateTime date={row.original.restored_at} /> : '';
},
},
{
accessorKey: 'status',
header: 'Status',
enableColumnFilter: true,
enableSorting: true,
cell: ({ row }) => {
return <Badge variant={row.original.status_color}>{row.original.status}</Badge>;
},
},
{
id: 'actions',
enableColumnFilter: false,
enableSorting: false,
cell: ({ row }) => {
return (
<div className="flex items-center justify-end">
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreVerticalIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<RestoreBackup backup={row.original.backup} file={row.original}>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>Restore</DropdownMenuItem>
</RestoreBackup>
<Delete file={row.original} />
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];

View File

@ -1,193 +0,0 @@
import { Backup } from '@/types/backup';
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
import React, { ReactNode, useState } from 'react';
import { Button } from '@/components/ui/button';
import { LoaderCircle, LoaderCircleIcon, MoreVerticalIcon } from 'lucide-react';
import { useForm } from '@inertiajs/react';
import { BackupFile } from '@/types/backup-file';
import { ColumnDef } from '@tanstack/react-table';
import DateTime from '@/components/date-time';
import { Badge } from '@/components/ui/badge';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { DataTable } from '@/components/data-table';
import axios from 'axios';
import { useQuery } from '@tanstack/react-query';
import { TableSkeleton } from '@/components/table-skeleton';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import RestoreBackup from '@/pages/backups/components/restore-backup';
function Delete({ file, onDeleted }: { file: BackupFile; onDeleted?: (file: BackupFile) => void }) {
const [open, setOpen] = useState(false);
const form = useForm();
const submit = () => {
form.delete(
route('backup-files.destroy', {
server: file.server_id,
backup: file.backup_id,
backupFile: file.id,
}),
{
onSuccess: () => {
setOpen(false);
if (onDeleted) {
onDeleted(file);
}
},
},
);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<DropdownMenuItem variant="destructive" onSelect={(e) => e.preventDefault()}>
Delete
</DropdownMenuItem>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete backup file</DialogTitle>
<DialogDescription className="sr-only">Delete backup file</DialogDescription>
</DialogHeader>
<p className="p-4">Are you sure you want to this backup file?</p>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button variant="destructive" disabled={form.processing} onClick={submit}>
{form.processing && <LoaderCircleIcon className="animate-spin" />}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default function BackupFiles({ backup, children }: { backup: Backup; children: ReactNode }) {
const [open, setOpen] = useState(false);
const fetchFilesQuery = useQuery({
queryKey: ['fetchFiles'],
queryFn: async () => {
const res = await axios.get(
route('backup-files', {
server: backup.server_id,
backup: backup.id,
}),
);
return res.data;
},
enabled: open,
});
const runBackupForm = useForm();
const runBackup = () => {
runBackupForm.post(route('backups.run', { server: backup.server_id, backup: backup.id }), {
onSuccess: () => {
fetchFilesQuery.refetch();
},
});
};
const columns: ColumnDef<BackupFile>[] = [
{
accessorKey: 'name',
header: 'Name',
enableColumnFilter: true,
enableSorting: true,
},
{
accessorKey: 'created_at',
header: 'Created at',
enableColumnFilter: true,
enableSorting: true,
cell: ({ row }) => {
return <DateTime date={row.original.created_at} />;
},
},
{
accessorKey: 'restored_to',
header: 'Restored to',
enableColumnFilter: true,
enableSorting: true,
},
{
accessorKey: 'restored_at',
header: 'Restored at',
enableColumnFilter: true,
enableSorting: true,
cell: ({ row }) => {
return row.original.restored_at ? <DateTime date={row.original.restored_at} /> : '';
},
},
{
accessorKey: 'status',
header: 'Status',
enableColumnFilter: true,
enableSorting: true,
cell: ({ row }) => {
return <Badge variant={row.original.status_color}>{row.original.status}</Badge>;
},
},
{
id: 'actions',
enableColumnFilter: false,
enableSorting: false,
cell: ({ row }) => {
return (
<div className="flex items-center justify-end">
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreVerticalIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<RestoreBackup backup={backup} file={row.original} onBackupRestored={() => fetchFilesQuery.refetch()}>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>Restore</DropdownMenuItem>
</RestoreBackup>
<Delete file={row.original} onDeleted={() => fetchFilesQuery.refetch()} />
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>{children}</SheetTrigger>
<SheetContent className="sm:max-w-4xl">
<SheetHeader>
<SheetTitle>Backup files of [{backup.database.name}]</SheetTitle>
<SheetDescription className="sr-only">Backup files</SheetDescription>
</SheetHeader>
{fetchFilesQuery.isLoading && <TableSkeleton modal />}
{fetchFilesQuery.isSuccess && !fetchFilesQuery.isLoading && <DataTable columns={columns} data={fetchFilesQuery.data.data} modal />}
<SheetFooter>
<div className="flex items-center gap-2">
<Button type="button" onClick={runBackup} disabled={runBackupForm.processing}>
{runBackupForm.processing && <LoaderCircle className="animate-spin" />}
Run backup
</Button>
<SheetClose asChild>
<Button variant="outline">Close</Button>
</SheetClose>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
);
}

View File

@ -0,0 +1,48 @@
import { Head, useForm, usePage } from '@inertiajs/react';
import { Server } from '@/types/server';
import Container from '@/components/container';
import HeaderContainer from '@/components/header-container';
import Heading from '@/components/heading';
import { Button } from '@/components/ui/button';
import ServerLayout from '@/layouts/server/layout';
import { CloudUploadIcon, LoaderCircleIcon } from 'lucide-react';
import { Backup } from '@/types/backup';
import { DataTable } from '@/components/data-table';
import { PaginatedData } from '@/types';
import { BackupFile } from '@/types/backup-file';
import { columns } from '@/pages/backups/components/file-columns';
type Page = {
server: Server;
backup: Backup;
files: PaginatedData<BackupFile>;
};
export default function Files() {
const page = usePage<Page>();
const runBackupForm = useForm();
const runBackup = () => {
runBackupForm.post(route('backups.run', { server: page.props.server.id, backup: page.props.backup.id }));
};
return (
<ServerLayout>
<Head title={`Backup files - ${page.props.server.name}`} />
<Container className="max-w-5xl">
<HeaderContainer>
<Heading title={`Backup files of ${page.props.backup.database.name}`} description="Here you can manage the backups of your database" />
<div className="flex items-center gap-2">
<Button onClick={runBackup}>
{runBackupForm.processing ? <LoaderCircleIcon className="animate-spin" /> : <CloudUploadIcon />}
<span className="hidden lg:block">Run backup</span>
</Button>
</div>
</HeaderContainer>
<DataTable columns={columns} paginatedData={page.props.files} />
</Container>
</ServerLayout>
);
}

View File

@ -5,25 +5,23 @@ import HeaderContainer from '@/components/header-container';
import Heading from '@/components/heading';
import { Button } from '@/components/ui/button';
import ServerLayout from '@/layouts/server/layout';
import React from 'react';
import { BookOpenIcon, PlusIcon } from 'lucide-react';
import { Backup } from '@/types/backup';
import { DataTable } from '@/components/data-table';
import { columns } from '@/pages/backups/components/columns';
import CreateBackup from '@/pages/backups/components/create-backup';
import { PaginatedData } from '@/types';
type Page = {
server: Server;
backups: {
data: Backup[];
};
backups: PaginatedData<Backup>;
};
export default function Backups() {
const page = usePage<Page>();
return (
<ServerLayout server={page.props.server}>
<ServerLayout>
<Head title={`Backups - ${page.props.server.name}`} />
<Container className="max-w-5xl">
@ -45,7 +43,7 @@ export default function Backups() {
</div>
</HeaderContainer>
<DataTable columns={columns} data={page.props.backups.data} />
<DataTable columns={columns} paginatedData={page.props.backups} />
</Container>
</ServerLayout>
);

View File

@ -6,25 +6,23 @@ import Heading from '@/components/heading';
import { Button } from '@/components/ui/button';
import ServerLayout from '@/layouts/server/layout';
import { DataTable } from '@/components/data-table';
import React from 'react';
import { BookOpenIcon, PlusIcon } from 'lucide-react';
import CreateDatabaseUser from '@/pages/database-users/components/create-database-user';
import SyncUsers from '@/pages/database-users/components/sync-users';
import { DatabaseUser } from '@/types/database-user';
import { columns } from '@/pages/database-users/components/columns';
import { PaginatedData } from '@/types';
type Page = {
server: Server;
databaseUsers: {
data: DatabaseUser[];
};
databaseUsers: PaginatedData<DatabaseUser>;
};
export default function Databases() {
const page = usePage<Page>();
return (
<ServerLayout server={page.props.server}>
<ServerLayout>
<Head title={`Users - ${page.props.server.name}`} />
<Container className="max-w-5xl">
@ -47,7 +45,7 @@ export default function Databases() {
</div>
</HeaderContainer>
<DataTable columns={columns} data={page.props.databaseUsers.data} />
<DataTable columns={columns} paginatedData={page.props.databaseUsers} />
</Container>
</ServerLayout>
);

View File

@ -9,22 +9,20 @@ import { Button } from '@/components/ui/button';
import ServerLayout from '@/layouts/server/layout';
import { DataTable } from '@/components/data-table';
import { columns } from '@/pages/databases/components/columns';
import React from 'react';
import { BookOpenIcon, PlusIcon } from 'lucide-react';
import SyncDatabases from '@/pages/databases/components/sync-databases';
import { PaginatedData } from '@/types';
type Page = {
server: Server;
databases: {
data: Database[];
};
databases: PaginatedData<Database>;
};
export default function Databases() {
const page = usePage<Page>();
return (
<ServerLayout server={page.props.server}>
<ServerLayout>
<Head title={`Databases - ${page.props.server.name}`} />
<Container className="max-w-5xl">
@ -47,7 +45,7 @@ export default function Databases() {
</div>
</HeaderContainer>
<DataTable columns={columns} data={page.props.databases.data} />
<DataTable columns={columns} paginatedData={page.props.databases} />
</Container>
</ServerLayout>
);

View File

@ -3,17 +3,14 @@ import { Head, usePage } from '@inertiajs/react';
import Container from '@/components/container';
import Heading from '@/components/heading';
import { Button } from '@/components/ui/button';
import React from 'react';
import ConnectNotificationChannel from '@/pages/notification-channels/components/connect-notification-channel';
import { DataTable } from '@/components/data-table';
import { columns } from '@/pages/notification-channels/components/columns';
import { NotificationChannel } from '@/types/notification-channel';
import { Configs } from '@/types';
import { Configs, PaginatedData } from '@/types';
type Page = {
notificationChannels: {
data: NotificationChannel[];
};
notificationChannels: PaginatedData<NotificationChannel>;
configs: Configs;
};
@ -33,7 +30,7 @@ export default function NotificationChannels() {
</div>
</div>
<DataTable columns={columns} data={page.props.notificationChannels.data} />
<DataTable columns={columns} paginatedData={page.props.notificationChannels} />
</Container>
</SettingsLayout>
);

View File

@ -17,7 +17,7 @@ import { Button } from '@/components/ui/button';
import { Form, FormField, FormFields } from '@/components/ui/form';
import { Label } from '@/components/ui/label';
import InputError from '@/components/ui/input-error';
import UserSelect from '@/components/user-select';
import UserSelect from '@/pages/users/components/user-select';
import { useForm } from '@inertiajs/react';
import { LoaderCircleIcon, TrashIcon } from 'lucide-react';
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
@ -52,7 +52,7 @@ function AddUser({ project }: { project: Project }) {
<FormFields>
<FormField>
<Label htmlFor="user">User</Label>
<UserSelect onChange={(user: User) => form.setData('user', user.id)} />
<UserSelect value={form.data.user.toString()} onValueChange={(user: User) => form.setData('user', user.id)} />
<InputError message={form.errors.user} />
</FormField>
</FormFields>

View File

@ -5,15 +5,13 @@ import { columns } from '@/pages/projects/components/columns';
import { Project } from '@/types/project';
import Container from '@/components/container';
import Heading from '@/components/heading';
import React from 'react';
import ProjectForm from '@/pages/projects/components/project-form';
import { Button } from '@/components/ui/button';
import { PaginatedData } from '@/types';
export default function Projects() {
const page = usePage<{
projects: {
data: Project[];
};
projects: PaginatedData<Project>;
}>();
return (
@ -29,7 +27,7 @@ export default function Projects() {
</ProjectForm>
</div>
</div>
<DataTable columns={columns} data={page.props.projects.data} />
<DataTable columns={columns} paginatedData={page.props.projects} />
</Container>
</SettingsLayout>
);

View File

@ -44,7 +44,7 @@ const LogActionCell = ({ row }: { row: Row<ServerLog> }) => {
<DialogTitle>View Log</DialogTitle>
<DialogDescription className="sr-only">This is all content of the log</DialogDescription>
</DialogHeader>
<ScrollArea className="bg-accent text-accent-foreground relative h-[500px] w-full p-4 font-mono text-sm whitespace-pre-line">
<ScrollArea className="bg-accent/50 text-accent-foreground relative h-[500px] w-full p-4 font-mono text-sm whitespace-pre-line">
{content}
<ScrollBar orientation="vertical" />
</ScrollArea>

View File

@ -0,0 +1,41 @@
import { Server } from '@/types/server';
import { Site } from '@/types/site';
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import React, { useState } from 'react';
import { TableSkeleton } from '@/components/table-skeleton';
import { DataTable } from '@/components/data-table';
import { columns } from '@/pages/server-logs/components/columns';
export default function Logs({ server, site }: { server: Server; site?: Site }) {
const [currentPage, setCurrentPage] = useState(1);
const query = useQuery({
queryKey: ['serverLogs', currentPage],
queryFn: async () => {
return (
await axios.get(route('logs.json', { server: server.id, site: site?.id }), {
params: { page: currentPage },
})
).data;
},
placeholderData: (prev) => prev,
refetchInterval: 5000,
});
return (
<>
{query.isLoading ? (
<TableSkeleton rows={5} cells={3} />
) : (
<DataTable
columns={columns}
paginatedData={query.data}
onPageChange={setCurrentPage}
isFetching={query.isFetching}
isLoading={query.isLoading}
/>
)}
</>
);
}

View File

@ -8,11 +8,10 @@ import ConnectServerProvider from '@/pages/server-providers/components/connect-s
import { DataTable } from '@/components/data-table';
import { columns } from '@/pages/server-providers/components/columns';
import { ServerProvider } from '@/types/server-provider';
import { PaginatedData } from '@/types';
type Page = {
serverProviders: {
data: ServerProvider[];
};
serverProviders: PaginatedData<ServerProvider>;
configs: {
server_providers: string[];
};
@ -34,7 +33,7 @@ export default function ServerProviders() {
</div>
</div>
<DataTable columns={columns} data={page.props.serverProviders.data} />
<DataTable columns={columns} paginatedData={page.props.serverProviders} />
</Container>
</SettingsLayout>
);

View File

@ -1,19 +1,21 @@
import { Server } from '@/types/server';
import { CloudIcon, IdCardIcon, LoaderCircleIcon, MapPinIcon, SlashIcon } from 'lucide-react';
import { CloudIcon, LoaderCircleIcon, MapPinIcon, MousePointerClickIcon, SlashIcon } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import ServerStatus from '@/pages/servers/components/status';
import ServerActions from '@/pages/servers/components/actions';
import { cn } from '@/lib/utils';
import { Site } from '@/types/site';
import { StatusRipple } from '@/components/status-ripple';
import { Badge } from '@/components/ui/badge';
export default function ServerHeader({ server }: { server: Server }) {
export default function ServerHeader({ server, site }: { server: Server; site?: Site }) {
return (
<div className="flex items-center justify-between border-b px-4 py-2">
<div className="space-y-2">
<div className="flex items-center space-x-2 text-xs">
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center space-x-1">
<IdCardIcon className="size-4" />
<div className="flex items-center space-x-2">
<StatusRipple variant={server.status_color} />
<div className="hidden lg:inline-flex">{server.name}</div>
</div>
</TooltipTrigger>
@ -58,16 +60,55 @@ export default function ServerHeader({ server }: { server: Server }) {
<div className="flex items-center space-x-1">
<LoaderCircleIcon className={cn('size-4', server.status === 'installing' ? 'animate-spin' : '')} />
<div>%{parseInt(server.progress || '0')}</div>
{server.status === 'installation_failed' && (
<Badge className="ml-1" variant={server.status_color}>
{server.status}
</Badge>
)}
</div>
</TooltipTrigger>
<TooltipContent side="bottom">Installation Progress</TooltipContent>
<TooltipContent side="bottom">Status</TooltipContent>
</Tooltip>
</>
)}
{site && (
<>
<SlashIcon className="size-3" />
<Tooltip>
<TooltipTrigger asChild>
<a href={site.url} target="_blank" className="flex items-center space-x-1 truncate">
<MousePointerClickIcon className="size-4" />
<div className="hidden max-w-[150px] overflow-x-hidden overflow-ellipsis lg:block">{site.domain}</div>
</a>
</TooltipTrigger>
<TooltipContent side="bottom">
<span>{site.domain}</span>
</TooltipContent>
</Tooltip>
</>
)}
{site && ['installing', 'installation_failed'].includes(site.status) && (
<>
<SlashIcon className="size-3" />
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center space-x-1">
<LoaderCircleIcon className={cn('size-4', site.status === 'installing' ? 'animate-spin' : '')} />
<div>%{parseInt(site.progress.toString() || '0')}</div>
{site.status === 'installation_failed' && (
<Badge className="ml-1" variant={site.status_color}>
{site.status}
</Badge>
)}
</div>
</TooltipTrigger>
<TooltipContent side="bottom">Status</TooltipContent>
</Tooltip>
</>
)}
</div>
</div>
<div className="flex items-center space-x-1">
<ServerStatus server={server} />
<ServerActions server={server} />
</div>
</div>

View File

@ -0,0 +1,93 @@
import { Server } from '@/types/server';
import { useState, useEffect } from 'react';
import { useQuery } 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, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { cn } from '@/lib/utils';
import axios from 'axios';
export default function ServerSelect({ value, onValueChange }: { value: string; onValueChange: (selectedServer: Server) => void }) {
const [query, setQuery] = useState('');
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<string>(value);
useEffect(() => {
setSelected(value);
}, [value]);
const {
data: servers = [],
isFetching,
refetch,
} = useQuery<Server[]>({
queryKey: ['servers', query],
queryFn: async () => {
const response = await axios.get(route('servers.json', { query: query }));
return response.data;
},
enabled: false,
});
const onOpenChange = (open: boolean) => {
setOpen(open);
if (open) {
refetch();
}
};
useEffect(() => {
if (open && query !== '') {
const timeoutId = setTimeout(() => {
refetch();
}, 300); // Debounce search
return () => clearTimeout(timeoutId);
}
}, [query, open, refetch]);
const selectedServer = servers.find((server) => server.id === parseInt(selected));
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open} className="w-full justify-between">
{selectedServer ? selectedServer.name : 'Select server...'}
<ChevronsUpDownIcon className="opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command shouldFilter={false}>
<CommandInput placeholder="Search server..." value={query} onValueChange={setQuery} />
<CommandList>
<CommandEmpty>{isFetching ? 'Searching...' : query === '' ? 'Start typing to search servers' : 'No servers found.'}</CommandEmpty>
<CommandGroup>
{servers.map((server) => (
<CommandItem
key={`server-select-${server.id}`}
value={server.id.toString()}
onSelect={(currentValue) => {
const newSelected = currentValue === selected ? '' : currentValue;
setSelected(newSelected);
setOpen(false);
if (newSelected) {
const server = servers.find((s) => s.id.toString() === newSelected);
if (server) {
onValueChange(server);
}
}
}}
className="truncate"
>
{server.name} ({server.ip})
<CheckIcon className={cn('ml-auto', selected && parseInt(selected) === server.id ? 'opacity-100' : 'opacity-0')} />
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@ -1,6 +1,6 @@
import { Head, usePage } from '@inertiajs/react';
import { type Configs } from '@/types';
import { PaginatedData, type Configs } from '@/types';
import { DataTable } from '@/components/data-table';
import { columns } from '@/pages/servers/components/columns';
@ -13,16 +13,14 @@ import React from 'react';
import Layout from '@/layouts/app/layout';
import { PlusIcon } from 'lucide-react';
type Response = {
servers: {
data: Server[];
};
type Page = {
servers: PaginatedData<Server>;
public_key: string;
configs: Configs;
};
export default function Servers() {
const page = usePage<Response>();
const page = usePage<Page>();
return (
<Layout>
<Head title="Servers" />
@ -39,7 +37,7 @@ export default function Servers() {
</CreateServer>
</div>
</div>
<DataTable columns={columns} data={page.props.servers.data} />
<DataTable columns={columns} paginatedData={page.props.servers} />
</Container>
</Layout>
);

View File

@ -5,19 +5,18 @@ import { DataTable } from '@/components/data-table';
import { columns } from '@/pages/server-logs/components/columns';
import { usePage } from '@inertiajs/react';
import Heading from '@/components/heading';
import { PaginatedData } from '@/types';
export default function InstallingServer() {
const page = usePage<{
server: Server;
logs: {
data: ServerLog[];
};
logs: PaginatedData<ServerLog>;
}>();
return (
<Container className="max-w-5xl">
<Heading title="Installing" description="Here you can see the installation logs" />
<DataTable columns={columns} data={page.props.logs.data} />{' '}
<DataTable columns={columns} paginatedData={page.props.logs} />{' '}
</Container>
);
}

View File

@ -5,19 +5,18 @@ import { columns } from '@/pages/server-logs/components/columns';
import { usePage } from '@inertiajs/react';
import Container from '@/components/container';
import Heading from '@/components/heading';
import { PaginatedData } from '@/types';
export default function ServerOverview() {
const page = usePage<{
server: Server;
logs: {
data: ServerLog[];
};
logs: PaginatedData<ServerLog>;
}>();
return (
<Container className="max-w-5xl">
<Heading title="Overview" description="Here you can see an overview of your server" />
<DataTable columns={columns} data={page.props.logs.data} />
<DataTable columns={columns} paginatedData={page.props.logs} />
</Container>
);
}

View File

@ -23,7 +23,7 @@ type Response = {
export default function ShowServer() {
const page = usePage<Response>();
return (
<ServerLayout server={page.props.server}>
<ServerLayout>
<Head title={`Overview - ${page.props.server.name}`} />
{['installing', 'installation_failed'].includes(page.props.server.status) ? <InstallingServer /> : <ServerOverview />}

View File

@ -0,0 +1,43 @@
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import React from 'react';
import { SelectTriggerProps } from '@radix-ui/react-select';
export default function ServiceVersionSelect({
serverId,
service,
value,
onValueChange,
...props
}: {
serverId: number;
service: string;
value: string;
onValueChange: (value: string) => void;
} & SelectTriggerProps) {
const query = useQuery<string[]>({
queryKey: ['service'],
queryFn: async () => {
return (await axios.get(route('services.versions', { server: serverId, service: service }))).data;
},
});
return (
<Select value={value} onValueChange={onValueChange} disabled={query.isFetching}>
<SelectTrigger {...props}>
<SelectValue placeholder={query.isFetching ? 'Loading...' : 'Select a version'} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{query.isSuccess &&
query.data.map((version: string) => (
<SelectItem key={`service-v-${version}`} value={version}>
{version}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
);
}

View File

@ -0,0 +1,89 @@
import { ColumnDef } from '@tanstack/react-table';
import { Server } from '@/types/server';
import { Link } from '@inertiajs/react';
import DateTime from '@/components/date-time';
import { Site } from '@/types/site';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { EyeIcon } from 'lucide-react';
export default function getColumns(server?: Server): ColumnDef<Site>[] {
let columns: ColumnDef<Site>[] = [
{
accessorKey: 'id',
header: 'ID',
enableColumnFilter: true,
enableSorting: true,
enableHiding: true,
},
{
accessorKey: 'domain',
header: 'Domain',
enableColumnFilter: true,
enableSorting: true,
},
{
accessorKey: 'type',
header: 'Type',
enableColumnFilter: true,
enableSorting: true,
cell: ({ row }) => {
return <Badge variant="outline">{row.original.type}</Badge>;
},
},
{
accessorKey: 'created_at',
header: 'Created at',
enableColumnFilter: true,
enableSorting: true,
cell: ({ row }) => {
return <DateTime date={row.original.created_at} />;
},
},
{
accessorKey: 'status',
header: 'Status',
enableColumnFilter: true,
enableSorting: true,
cell: ({ row }) => {
return <Badge variant={row.original.status_color}>{row.original.status}</Badge>;
},
},
{
id: 'actions',
enableColumnFilter: false,
enableSorting: false,
cell: ({ row }) => {
return (
<div className="flex items-center justify-end">
<Link href={route('sites.show', { server: row.original.server_id, site: row.original.id })} prefetch>
<Button variant="outline" size="sm">
<EyeIcon />
</Button>
</Link>
</div>
);
},
},
];
if (!server) {
// add column to the first
columns = [
{
id: 'server',
header: 'Server',
cell: ({ row }) => {
return (
<Link href={route('servers.show', { server: row.original.server_id })} prefetch>
{row.original.server?.name}
</Link>
);
},
},
...columns,
];
}
return columns;
}

View File

@ -0,0 +1,189 @@
import { ReactNode, useState, FormEventHandler } from 'react';
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
import { Form, FormField, FormFields } from '@/components/ui/form';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { LoaderCircle } from 'lucide-react';
import { useForm, usePage } from '@inertiajs/react';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import InputError from '@/components/ui/input-error';
import type { SharedData } from '@/types';
import SourceControlSelect from '@/pages/source-controls/components/source-control-select';
import { Server } from '@/types/server';
import ServerSelect from '@/pages/servers/components/server-select';
import ServiceVersionSelect from '@/pages/services/components/service-version-select';
import { DynamicFieldConfig } from '@/types/dynamic-field-config';
import DynamicField from '@/components/ui/dynamic-field';
import { TagsInput } from '@/components/ui/tags-input';
type CreateSiteForm = {
server: string;
type: string;
domain: string;
aliases: string[];
php_version: string;
source_control: string;
user: string;
};
export default function CreateSite({ server, children }: { server?: Server; children: ReactNode }) {
const page = usePage<SharedData>();
const [open, setOpen] = useState(false);
const form = useForm<CreateSiteForm>({
server: server?.id.toString() || '',
type: 'php',
domain: '',
aliases: [],
php_version: '',
source_control: '',
user: '',
});
const submit: FormEventHandler = (e) => {
e.preventDefault();
form.post(route('sites.store', { server: form.data.server }));
};
const getFormField = (field: DynamicFieldConfig) => {
if (field.name === 'source_control') {
return (
<FormField key={`field-${field.name}`}>
<Label htmlFor="source_control">Source Control</Label>
<SourceControlSelect
id="source_control"
value={form.data.source_control}
onValueChange={(value) => form.setData('source_control', value)}
/>
<InputError message={form.errors.source_control} />
</FormField>
);
}
if (field.name === 'php_version') {
return (
<FormField key={`field-${field.name}`}>
<Label htmlFor="php_version">PHP Version</Label>
<ServiceVersionSelect
id="php_version"
serverId={parseInt(form.data.server)}
service="php"
value={form.data.php_version}
onValueChange={(value) => form.setData('php_version', value)}
/>
<InputError message={form.errors.php_version} />
</FormField>
);
}
return (
<DynamicField
key={`field-${field.name}`}
/*@ts-expect-error dynamic types*/
value={form.data[field.name]}
/*@ts-expect-error dynamic types*/
onChange={(value) => form.setData(field.name, value)}
config={field}
/*@ts-expect-error dynamic types*/
error={form.errors[field.name]}
/>
);
};
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>{children}</SheetTrigger>
<SheetContent className="w-full lg:max-w-3xl">
<SheetHeader>
<SheetTitle>Create site</SheetTitle>
<SheetDescription>Fill in the details to create a new site.</SheetDescription>
</SheetHeader>
<Form id="create-site-form" className="p-4" onSubmit={submit}>
<FormFields>
{server === undefined && (
<FormField>
<Label htmlFor="server">Server</Label>
<ServerSelect value={form.data.server} onValueChange={(value) => form.setData('server', value.id.toString())} />
<InputError message={form.errors.server} />
</FormField>
)}
{form.data.server && (
<>
<FormField>
<Label htmlFor="type">Site Type</Label>
<Select value={form.data.type} onValueChange={(value) => form.setData('type', value)}>
<SelectTrigger id="type">
<SelectValue placeholder="Select site type" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{page.props.configs.site_types.map((type) => (
<SelectItem key={`type-${type}`} value={type}>
{type}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<InputError message={form.errors.type} />
</FormField>
<FormField>
<Label htmlFor="domain">Domain</Label>
<Input
id="domain"
type="text"
value={form.data.domain}
onChange={(e) => form.setData('domain', e.target.value)}
placeholder="vitodeploy.com"
/>
<InputError message={form.errors.domain} />
</FormField>
<FormField>
<Label htmlFor="aliases">Aliases</Label>
<TagsInput
id="aliases"
type="text"
value={form.data.aliases}
placeholder="Add aliases"
onValueChange={(value) => form.setData('aliases', value)}
/>
<InputError message={form.errors.aliases} />
</FormField>
{page.props.configs.site_types_custom_fields[form.data.type].map((config) => getFormField(config))}
<FormField>
<Label htmlFor="user">Isolated User (Optional)</Label>
<Input
id="user"
type="text"
value={form.data.user}
onChange={(e) => form.setData('user', e.target.value)}
placeholder="Leave empty for using server's default user"
/>
<InputError message={form.errors.user} />
</FormField>
</>
)}
</FormFields>
</Form>
<SheetFooter>
<div className="flex items-center gap-2">
<Button type="submit" form="create-site-form" disabled={form.processing}>
{form.processing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />} Create
</Button>
<SheetClose asChild>
<Button variant="outline" disabled={form.processing}>
Cancel
</Button>
</SheetClose>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
);
}

View File

@ -0,0 +1,52 @@
import { Head, usePage } from '@inertiajs/react';
import { Server } from '@/types/server';
import { Site } from '@/types/site';
import ServerLayout from '@/layouts/server/layout';
import Layout from '@/layouts/app/layout';
import Container from '@/components/container';
import HeaderContainer from '@/components/header-container';
import Heading from '@/components/heading';
import { Button } from '@/components/ui/button';
import { BookOpenIcon, PlusIcon } from 'lucide-react';
import { DataTable } from '@/components/data-table';
import getColumns from '@/pages/sites/components/columns';
import { PaginatedData } from '@/types';
import CreateSite from '@/pages/sites/components/create-site';
type Page = {
server?: Server;
sites: PaginatedData<Site>;
};
export default function Sites() {
const page = usePage<Page>();
const Comp = page.props.server ? ServerLayout : Layout;
return (
<Comp>
<Head title={`Sites ${page.props.server ? ' - ' + page.props.server.name : ''}`} />
<Container className="max-w-5xl">
<HeaderContainer>
<Heading title="Sites" description="Here you can manage websites" />
<div className="flex items-center gap-2">
<a href="https://vitodeploy.com/docs/sites/application" target="_blank">
<Button variant="outline">
<BookOpenIcon />
<span className="hidden lg:block">Docs</span>
</Button>
</a>
<CreateSite server={page.props.server}>
<Button>
<PlusIcon />
<span className="hidden lg:block">Create site</span>
</Button>
</CreateSite>
</div>
</HeaderContainer>
<DataTable columns={getColumns(page.props.server)} paginatedData={page.props.sites} />
</Container>
</Comp>
);
}

View File

@ -0,0 +1,46 @@
import { Head, usePage } from '@inertiajs/react';
import { Site } from '@/types/site';
import ServerLayout from '@/layouts/server/layout';
import { Server } from '@/types/server';
import Container from '@/components/container';
import HeaderContainer from '@/components/header-container';
import Heading from '@/components/heading';
import { Button } from '@/components/ui/button';
import { BookOpenIcon } from 'lucide-react';
import React from 'react';
import { PaginatedData } from '@/types';
import { ServerLog } from '@/types/server-log';
import { DataTable } from '@/components/data-table';
import { columns } from '@/pages/server-logs/components/columns';
type Page = {
server: Server;
site: Site;
logs: PaginatedData<ServerLog>;
};
export default function ShowSite() {
const page = usePage<Page>();
return (
<ServerLayout>
<Head title={`${page.props.site.domain} - ${page.props.server.name}`} />
<Container className="max-w-5xl">
<HeaderContainer>
<Heading title="Application" description="Here you can manage the deployed application" />
<div className="flex items-center gap-2">
<a href="https://vitodeploy.com/docs/sites/application" target="_blank">
<Button variant="outline">
<BookOpenIcon />
<span className="hidden lg:block">Docs</span>
</Button>
</a>
</div>
</HeaderContainer>
<DataTable columns={columns} paginatedData={page.props.logs} />
</Container>
</ServerLayout>
);
}

View File

@ -27,12 +27,10 @@ type SourceControlForm = {
};
export default function ConnectSourceControl({
providers,
defaultProvider,
onProviderAdded,
children,
}: {
providers: string[];
defaultProvider?: string;
onProviderAdded?: () => void;
children: ReactNode;
@ -83,7 +81,7 @@ export default function ConnectSourceControl({
</SelectTrigger>
<SelectContent>
<SelectGroup>
{providers.map((provider) => (
{page.props.configs.source_control_providers.map((provider) => (
<SelectItem key={provider} value={provider}>
{provider}
</SelectItem>

View File

@ -0,0 +1,50 @@
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import React from 'react';
import { SelectTriggerProps } from '@radix-ui/react-select';
import { SourceControl } from '@/types/source-control';
import ConnectSourceControl from '@/pages/source-controls/components/connect-source-control';
import { Button } from '@/components/ui/button';
import { WifiIcon } from 'lucide-react';
export default function SourceControlSelect({
value,
onValueChange,
...props
}: {
value: string;
onValueChange: (value: string) => void;
} & SelectTriggerProps) {
const query = useQuery<SourceControl[]>({
queryKey: ['sourceControl'],
queryFn: async () => {
return (await axios.get(route('source-controls.json'))).data;
},
});
return (
<div className="flex items-center gap-2">
<Select value={value} onValueChange={onValueChange} disabled={query.isFetching}>
<SelectTrigger {...props}>
<SelectValue placeholder={query.isFetching ? 'Loading...' : 'Select a provider'} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{query.isSuccess &&
query.data.map((sourceControl: SourceControl) => (
<SelectItem key={`db-${sourceControl.name}`} value={sourceControl.id.toString()}>
{sourceControl.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<ConnectSourceControl onProviderAdded={() => query.refetch()}>
<Button variant="outline">
<WifiIcon />
</Button>
</ConnectSourceControl>
</div>
);
}

View File

@ -3,17 +3,14 @@ import { Head, usePage } from '@inertiajs/react';
import Container from '@/components/container';
import Heading from '@/components/heading';
import { Button } from '@/components/ui/button';
import React from 'react';
import ConnectSourceControl from '@/pages/source-controls/components/connect-source-control';
import { DataTable } from '@/components/data-table';
import { columns } from '@/pages/source-controls/components/columns';
import { SourceControl } from '@/types/source-control';
import { Configs } from '@/types';
import { Configs, PaginatedData } from '@/types';
type Page = {
sourceControls: {
data: SourceControl[];
};
sourceControls: PaginatedData<SourceControl>;
configs: Configs;
};
@ -27,12 +24,12 @@ export default function SourceControls() {
<div className="flex items-start justify-between">
<Heading title="Source Controls" description="Here you can manage all of the source control connectinos" />
<div className="flex items-center gap-2">
<ConnectSourceControl providers={page.props.configs.source_control_providers}>
<ConnectSourceControl>
<Button>Connect</Button>
</ConnectSourceControl>
</div>
</div>
<DataTable columns={columns} data={page.props.sourceControls.data} />
<DataTable columns={columns} paginatedData={page.props.sourceControls} />
</Container>
</SettingsLayout>
);

View File

@ -4,15 +4,13 @@ import Container from '@/components/container';
import Heading from '@/components/heading';
import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/data-table';
import React from 'react';
import { SSHKey } from '@/types/ssh-key';
import { columns } from '@/pages/ssh-keys/components/columns';
import AddSshKey from '@/pages/ssh-keys/components/add-ssh-key';
import { PaginatedData } from '@/types';
type Page = {
sshKeys: {
data: SSHKey[];
};
sshKeys: PaginatedData<SSHKey>;
};
export default function SshKeys() {
@ -31,7 +29,7 @@ export default function SshKeys() {
</div>
</div>
<DataTable columns={columns} data={page.props.sshKeys.data} />
<DataTable columns={columns} paginatedData={page.props.sshKeys} />
</Container>
</SettingsLayout>
);

View File

@ -3,16 +3,14 @@ import { Head, usePage } from '@inertiajs/react';
import Container from '@/components/container';
import Heading from '@/components/heading';
import { Button } from '@/components/ui/button';
import React from 'react';
import ConnectStorageProvider from '@/pages/storage-providers/components/connect-storage-provider';
import { DataTable } from '@/components/data-table';
import { columns } from '@/pages/storage-providers/components/columns';
import { StorageProvider } from '@/types/storage-provider';
import { PaginatedData } from '@/types';
type Page = {
storageProviders: {
data: StorageProvider[];
};
storageProviders: PaginatedData<StorageProvider>;
configs: {
storage_providers: string[];
};
@ -34,7 +32,7 @@ export default function StorageProviders() {
</div>
</div>
<DataTable columns={columns} data={page.props.storageProviders.data} />
<DataTable columns={columns} paginatedData={page.props.storageProviders} />
</Container>
</SettingsLayout>
);

View File

@ -6,13 +6,11 @@ import { DataTable } from '@/components/data-table';
import { Tag } from '@/types/tag';
import { columns } from '@/pages/tags/components/columns';
import { Button } from '@/components/ui/button';
import React from 'react';
import CreateTag from '@/pages/tags/components/create-tag';
import { PaginatedData } from '@/types';
type Page = {
tags: {
data: Tag[];
};
tags: PaginatedData<Tag>;
};
export default function Tags() {
@ -29,7 +27,7 @@ export default function Tags() {
</CreateTag>
</div>
</div>
<DataTable columns={columns} data={page.props.tags.data} />
<DataTable columns={columns} paginatedData={page.props.tags} />
</Container>
</SettingsLayout>
);

View File

@ -4,6 +4,7 @@ import { DataTable } from '@/components/data-table';
import { usePage } from '@inertiajs/react';
import UserActions from '@/pages/users/components/actions';
import DateTime from '@/components/date-time';
import { PaginatedData } from '@/types';
const columns: ColumnDef<User>[] = [
{
@ -35,13 +36,11 @@ const columns: ColumnDef<User>[] = [
];
type Page = {
users: {
data: User[];
};
users: PaginatedData<User>;
};
export default function UsersList() {
const page = usePage<Page>();
return <DataTable columns={columns} data={page.props.users.data} />;
return <DataTable columns={columns} paginatedData={page.props.users} />;
}

View File

@ -0,0 +1,93 @@
import { User } from '@/types/user';
import { useState, useEffect } from 'react';
import { useQuery } 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, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { cn } from '@/lib/utils';
import axios from 'axios';
export default function UserSelect({ value, onValueChange }: { value: string; onValueChange: (selectedUser: User) => void }) {
const [query, setQuery] = useState('');
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<string>(value);
useEffect(() => {
setSelected(value);
}, [value]);
const {
data: users = [],
isFetching,
refetch,
} = useQuery<User[]>({
queryKey: ['users', query],
queryFn: async () => {
const response = await axios.get(route('users.json', { query: query }));
return response.data;
},
enabled: false,
});
const onOpenChange = (open: boolean) => {
setOpen(open);
if (open) {
refetch();
}
};
useEffect(() => {
if (open && query !== '') {
const timeoutId = setTimeout(() => {
refetch();
}, 300); // Debounce search
return () => clearTimeout(timeoutId);
}
}, [query, open, refetch]);
const selectedUser = users.find((user) => user.id === parseInt(selected));
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open} className="w-full justify-between">
{selectedUser ? selectedUser.name : 'Select user...'}
<ChevronsUpDownIcon className="opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command shouldFilter={false}>
<CommandInput placeholder="Search user..." value={query} onValueChange={setQuery} />
<CommandList>
<CommandEmpty>{isFetching ? 'Searching...' : query === '' ? 'Start typing to search users' : 'No users found.'}</CommandEmpty>
<CommandGroup>
{users.map((user) => (
<CommandItem
key={`user-select-${user.id}`}
value={user.id.toString()}
onSelect={(currentValue) => {
const newSelected = currentValue === selected ? '' : currentValue;
setSelected(newSelected);
setOpen(false);
if (newSelected) {
const user = users.find((s) => s.id.toString() === newSelected);
if (user) {
onValueChange(user);
}
}
}}
className="truncate"
>
{user.name} ({user.email})
<CheckIcon className={cn('ml-auto', selected && parseInt(selected) === user.id ? 'opacity-100' : 'opacity-0')} />
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}