I use nextjs14 and I have a page.tsx
file which is my server component file as such:
import Pagination from '@/app/ui/inventory/pagination';
import Table from '@/app/ui/inventory/table';
import { inter } from '@/app/ui/fonts';
import { fetchTotalPages } from '@/app/lib/data';
import BoxWrapper from '@/app/ui/inventory/vcomboboxwrap';
import { InvoicesTableSkeleton } from '@/app/ui/skeletons';
import { Suspense } from 'react';
import { CreateInvoice } from '@/app/ui/button';
export default async function Page({
searchParams,
}: {
searchParams?: {
page?: string;
service?: string;
org_6?: string;
org_7?: string;
org_8?: string;
software_product_version_name?: string;
service_owner?: string;
eg_status?: string;
eim_scope?: string;
};
}) {
const currentPage = Number(searchParams?.page) || 1;
const service = searchParams?.service;
const org_6 = searchParams?.org_6;
const org_7 = searchParams?.org_7;
const org_8 = searchParams?.org_8;
const software_product_version_name =
searchParams?.software_product_version_name;
const service_owner = searchParams?.service_owner;
const eg_status = searchParams?.eg_status;
const eim_scope =
searchParams?.eim_scope !== undefined
? searchParams?.eim_scope.toLowerCase() === 'true'
: undefined;
const totalpages = await fetchTotalPages({
it_service: service,
it_org_6: org_6,
it_org_7: org_7,
it_org_8: org_8,
software_product_version_name: software_product_version_name,
service_owner: service_owner,
eg_status: eg_status,
eim_scope: eim_scope,
});
return (
<div className="w-full">
<div className="flex w-full items-center justify-between">
<h1 className={`${inter.className} text-2xl`}>Inventory</h1>
</div>
<div className="mt-6 flex flex-wrap gap-6 md:flex-wrap">
<BoxWrapper />
<CreateInvoice />
</div>
<Suspense key={currentPage} fallback={<InvoicesTableSkeleton />}>
<Table
service={service}
org_6={org_6}
org_7={org_7}
org_8={org_8}
tech_version={software_product_version_name}
service_owner={service_owner}
eg_status={eg_status}
currentPage={currentPage}
eim_scope={eim_scope}
/>
</Suspense>
<div className="mt-5 flex w-full justify-center">
<Pagination totalPages={totalpages} />
</div>
</div>
);
}
Important points on the code above is to the use of searchParams
https://nextjs.org/docs/app/api-reference/functions/use-search-params#examples to handle url sharing between different users, that will keep also the table I have synchronized and ready to take the different selection arguments form execute query. I also have a server component called BoxWrapper
which will execute the database query, it looks like this:
import { fetchComboboxData } from '@/app/lib/data';
import { VirtualizedCombobox } from '@/components/ui/virtualized-combobox';
export default async function BoxWrapper() {
const {
it_service,
org_6,
org_7,
org_8,
tech_version,
itso,
egstatus,
eimscope,
} = await fetchComboboxData();
return (
<>
<VirtualizedCombobox
options={it_service}
searchPlaceholder="IT Service"
paramsUpdate="service"
/>
<VirtualizedCombobox
options={org_6}
searchPlaceholder="Org 6"
paramsUpdate="org_6"
/>
<VirtualizedCombobox
options={org_7}
searchPlaceholder="Org 7"
paramsUpdate="org_7"
/>
<VirtualizedCombobox
options={org_8}
searchPlaceholder="Org 8"
paramsUpdate="org_8"
/>
<VirtualizedCombobox
options={tech_version}
searchPlaceholder="Technology Version"
paramsUpdate="software_product_version_name"
/>
<VirtualizedCombobox
options={itso}
searchPlaceholder="IT Service Owner"
paramsUpdate="service_owner"
/>
<VirtualizedCombobox
options={egstatus}
searchPlaceholder="Evergreen Status"
paramsUpdate="eg_status"
/>
<VirtualizedCombobox
options={eimscope}
searchPlaceholder="EIM Scope"
paramsUpdate="eim_scope"
height="100"
/>
</>
);
}
This is code is essentially a wrap for the client component, which is a combobox from shadcn, and thuis a client component:
'use client';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/app/lib/utils';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Check, ChevronsUpDown } from 'lucide-react';
import { CrossCircledIcon } from '@radix-ui/react-icons';
import * as React from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
type Option = {
value: string;
label: string;
};
interface VirtualizedCommandProps {
height: string;
options: Option[];
placeholder: string;
selectedOption: string;
onSelectOption?: (option: string) => void;
}
export const VirtualizedCommand = ({
height,
options,
placeholder,
selectedOption,
onSelectOption,
}: VirtualizedCommandProps) => {
const [filteredOptions, setFilteredOptions] =
React.useState<Option[]>(options);
const parentRef = React.useRef(null);
const virtualizer = useVirtualizer({
count: filteredOptions.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 35,
overscan: 5,
});
const virtualOptions = virtualizer.getVirtualItems();
const handleSearch = (search: string) => {
setFilteredOptions(
options.filter((option) =>
option.value.toLowerCase().includes(search.toLowerCase() ?? []),
),
);
};
return (
<Command shouldFilter={false}>
<CommandInput onValueChange={handleSearch} placeholder={placeholder} />
<CommandList className="overflow-y-hidden">
<CommandEmpty>No item found.</CommandEmpty>
<CommandGroup
ref={parentRef}
style={{
height: height,
width: '100%',
overflow: 'auto',
}}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualOptions.map((virtualOption, i) => (
<CommandItem
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualOption.size}px`,
transform: `translateY(${virtualOption.start}px)`,
}}
key={filteredOptions[virtualOption.index].value}
value={filteredOptions[virtualOption.index].value}
onSelect={onSelectOption}
className={cn(
'py-2',
i !== virtualOptions.length - 1 &&
'border-b border-gray-200 py-2',
)}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedOption ===
filteredOptions[virtualOption.index].value
? 'opacity-100'
: 'opacity-0',
)}
/>
{filteredOptions[virtualOption.index].label}
</CommandItem>
))}
</div>
</CommandGroup>
</CommandList>
</Command>
);
};
interface VirtualizedComboboxProps {
options: string[];
searchPlaceholder?: string;
width?: string;
height?: string;
paramsUpdate: string;
}
export function VirtualizedCombobox({
options,
searchPlaceholder = 'Search items...',
width = '384px',
height = '208px',
paramsUpdate,
}: VirtualizedComboboxProps) {
const [open, setOpen] = React.useState<boolean>(false);
const [selectedOption, setSelectedOption] = React.useState<string>('');
const searchParams = useSearchParams();
const { replace } = useRouter();
const pathname = usePathname();
const updateURLParams = (q: string) => {
const params = new URLSearchParams(searchParams);
params.set('page', '1');
if (q) {
params.set(paramsUpdate, q);
} else {
params.delete(paramsUpdate);
}
replace(`${pathname}?${params.toString()}`);
};
return (
<div className="w-96">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="flex w-96 items-center justify-between text-wrap"
style={{
width: width,
}}
>
{selectedOption
? options.find((option) => option === selectedOption)
: searchPlaceholder}
<div className="flex space-x-2">
<CrossCircledIcon
className="ml-2 h-4 w-4 shrink-0 opacity-50 hover:opacity-100"
onClick={() => {
setSelectedOption('');
updateURLParams('');
}}
/>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: width }}>
<VirtualizedCommand
height={height}
options={options.map((option) => ({
value: option,
label: option,
}))}
placeholder={searchPlaceholder}
selectedOption={selectedOption}
onSelectOption={(currentValue) => {
console.log(currentValue);
setSelectedOption(currentValue);
updateURLParams(currentValue);
setOpen(false);
}}
/>
</PopoverContent>
</Popover>
</div>
);
}
From the code above important points is both the use of useSearchParams()
to router push the new URL
when the user selects a different value and the React.usestate
which will handle the population of the value in the combobox itself. The only problem with this code is that I am not managing to keep the React.usestate
whenever I copy/paste the url to a different tab of my browser or when sharing the url with another person. The combobox value is empty but the url is populated , this will cause the user to get confused on which values are already being filtered in the table, since the value is not present in the combobox itself.