fix: improvements in the dashboard
This commit is contained in:
@@ -3,16 +3,34 @@ import { ProjectLink } from '@/components/links';
|
||||
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { pushModal } from '@/modals';
|
||||
import { formatDateTime, formatTime } from '@/utils/date';
|
||||
import { formatDateTime, formatTimeAgoOrDateTime } from '@/utils/date';
|
||||
import { getProfileName } from '@/utils/getters';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { isToday } from 'date-fns';
|
||||
|
||||
import { KeyValueGrid } from '@/components/ui/key-value-grid';
|
||||
import type { IServiceEvent } from '@openpanel/db';
|
||||
|
||||
export function useColumns() {
|
||||
const number = useNumber();
|
||||
const columns: ColumnDef<IServiceEvent>[] = [
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: 'Created at',
|
||||
size: 140,
|
||||
cell: ({ row }) => {
|
||||
const session = row.original;
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 opacity-0 group-hover/row:opacity-100 transition-opacity duration-100">
|
||||
{formatDateTime(session.createdAt)}
|
||||
</div>
|
||||
<div className="text-muted-foreground group-hover/row:opacity-0 transition-opacity duration-100">
|
||||
{formatTimeAgoOrDateTime(session.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
size: 300,
|
||||
accessorKey: 'name',
|
||||
@@ -85,17 +103,6 @@ export function useColumns() {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: 'Created at',
|
||||
size: 170,
|
||||
cell({ row }) {
|
||||
const date = row.original.createdAt;
|
||||
return (
|
||||
<div>{isToday(date) ? formatTime(date) : formatDateTime(date)}</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'profileId',
|
||||
header: 'Profile',
|
||||
@@ -141,11 +148,17 @@ export function useColumns() {
|
||||
accessorKey: 'sessionId',
|
||||
header: 'Session ID',
|
||||
size: 320,
|
||||
meta: {
|
||||
hidden: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'deviceId',
|
||||
header: 'Device ID',
|
||||
size: 320,
|
||||
meta: {
|
||||
hidden: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'country',
|
||||
@@ -193,6 +206,9 @@ export function useColumns() {
|
||||
accessorKey: 'properties',
|
||||
header: 'Properties',
|
||||
size: 400,
|
||||
meta: {
|
||||
hidden: true,
|
||||
},
|
||||
cell({ row }) {
|
||||
const { properties } = row.original;
|
||||
const filteredProperties = Object.fromEntries(
|
||||
@@ -201,19 +217,23 @@ export function useColumns() {
|
||||
),
|
||||
);
|
||||
const items = Object.entries(filteredProperties);
|
||||
return (
|
||||
<div className="row flex-wrap gap-x-4 gap-y-1 overflow-hidden text-sm">
|
||||
{items.slice(0, 4).map(([key, value]) => (
|
||||
<div key={key} className="row items-center gap-1 min-w-0">
|
||||
<span className="text-muted-foreground">{key}</span>
|
||||
<span className="truncate font-medium">{String(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
{items.length > 5 && (
|
||||
<span className="truncate">{items.length - 5} more</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
const limit = 1;
|
||||
const data = items.slice(0, limit).map(([key, value]) => ({
|
||||
name: key,
|
||||
value: value,
|
||||
}));
|
||||
if (items.length > limit) {
|
||||
data.push({
|
||||
name: '',
|
||||
value: `${items.length - limit} more item${items.length - limit === 1 ? '' : 's'}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <KeyValueGrid className="w-full" data={data} />;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,30 +1,22 @@
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverPortal,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Check, ChevronsUpDown, Settings2Icon } from 'lucide-react';
|
||||
|
||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
|
||||
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
|
||||
import { Skeleton } from '@/components/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useDataTableColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
|
||||
import { DataTableToolbarContainer } from '@/components/ui/data-table/data-table-toolbar';
|
||||
import { DataTableViewOptions } from '@/components/ui/data-table/data-table-view-options';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { pushModal } from '@/modals';
|
||||
import type { RouterInputs, RouterOutputs } from '@/trpc/client';
|
||||
import { arePropsEqual } from '@/utils/are-props-equal';
|
||||
import { cn } from '@/utils/cn';
|
||||
import type { IServiceEvent } from '@openpanel/db';
|
||||
import type { UseInfiniteQueryResult } from '@tanstack/react-query';
|
||||
import type { Table } from '@tanstack/react-table';
|
||||
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||
import { Updater } from '@tanstack/react-table';
|
||||
import { ColumnOrderState } from '@tanstack/react-table';
|
||||
import { useWindowVirtualizer } from '@tanstack/react-virtual';
|
||||
import type { TRPCInfiniteData } from '@trpc/tanstack-react-query';
|
||||
import { format } from 'date-fns';
|
||||
@@ -32,20 +24,11 @@ import throttle from 'lodash.throttle';
|
||||
import { CalendarIcon, Loader2Icon } from 'lucide-react';
|
||||
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
|
||||
import { last } from 'ramda';
|
||||
import { memo, useEffect, useRef, useState } from 'react';
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useInViewport } from 'react-in-viewport';
|
||||
import { useLocalStorage } from 'usehooks-ts';
|
||||
import EventListener from '../event-listener';
|
||||
import { EventItem, EventItemSkeleton } from './item';
|
||||
|
||||
export const useEventsViewOptions = () => {
|
||||
return useLocalStorage<Record<string, boolean | undefined>>(
|
||||
'@op:events-table-view-options',
|
||||
{
|
||||
properties: false,
|
||||
},
|
||||
);
|
||||
};
|
||||
import { useColumns } from './columns';
|
||||
|
||||
type Props = {
|
||||
query: UseInfiniteQueryResult<
|
||||
@@ -57,137 +40,263 @@ type Props = {
|
||||
>;
|
||||
};
|
||||
|
||||
export const EventsTable = memo(
|
||||
({ query }: Props) => {
|
||||
const [viewOptions] = useEventsViewOptions();
|
||||
const { isLoading } = query;
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const [scrollMargin, setScrollMargin] = useState(0);
|
||||
const inViewportRef = useRef<HTMLDivElement>(null);
|
||||
const { inViewport, enterCount } = useInViewport(inViewportRef, undefined, {
|
||||
disconnectOnLeave: true,
|
||||
});
|
||||
const LOADING_DATA = [{}, {}, {}, {}, {}, {}, {}, {}, {}] as IServiceEvent[];
|
||||
const ROW_HEIGHT = 40;
|
||||
|
||||
const data = query.data?.pages?.flatMap((p) => p.data) ?? [];
|
||||
interface VirtualizedEventsTableProps {
|
||||
table: Table<IServiceEvent>;
|
||||
data: IServiceEvent[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const virtualizer = useWindowVirtualizer({
|
||||
count: data.length,
|
||||
estimateSize: () => 55,
|
||||
scrollMargin,
|
||||
overscan: 10,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const updateScrollMargin = throttle(() => {
|
||||
if (parentRef.current) {
|
||||
setScrollMargin(
|
||||
parentRef.current.getBoundingClientRect().top + window.scrollY,
|
||||
);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// Initial calculation
|
||||
updateScrollMargin();
|
||||
|
||||
// Listen for resize events
|
||||
window.addEventListener('resize', updateScrollMargin);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateScrollMargin);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
virtualizer.measure();
|
||||
}, [viewOptions, virtualizer]);
|
||||
|
||||
const hasNextPage = last(query.data?.pages ?? [])?.meta.next;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
hasNextPage &&
|
||||
data.length > 0 &&
|
||||
inViewport &&
|
||||
enterCount > 0 &&
|
||||
query.isFetchingNextPage === false
|
||||
) {
|
||||
query.fetchNextPage();
|
||||
}
|
||||
}, [inViewport, enterCount, hasNextPage]);
|
||||
|
||||
const visibleItems = virtualizer.getVirtualItems();
|
||||
interface VirtualRowProps {
|
||||
row: any;
|
||||
virtualRow: any;
|
||||
headerColumns: any[];
|
||||
scrollMargin: number;
|
||||
isLoading: boolean;
|
||||
headerColumnsHash: string;
|
||||
}
|
||||
|
||||
const VirtualRow = memo(
|
||||
function VirtualRow({
|
||||
row,
|
||||
virtualRow,
|
||||
headerColumns,
|
||||
scrollMargin,
|
||||
isLoading,
|
||||
}: VirtualRowProps) {
|
||||
return (
|
||||
<>
|
||||
<EventsTableToolbar query={query} />
|
||||
<div ref={parentRef} className="w-full">
|
||||
{isLoading && (
|
||||
<div className="w-full gap-2 col">
|
||||
<EventItemSkeleton />
|
||||
<EventItemSkeleton />
|
||||
<EventItemSkeleton />
|
||||
<EventItemSkeleton />
|
||||
<EventItemSkeleton />
|
||||
<EventItemSkeleton />
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualRow.measureElement}
|
||||
className="absolute top-0 left-0 w-full border-b hover:bg-muted/50 transition-colors group/row"
|
||||
style={{
|
||||
transform: `translateY(${virtualRow.start - scrollMargin}px)`,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: headerColumns
|
||||
.map((col) => `${col.getSize()}px`)
|
||||
.join(' '),
|
||||
minWidth: 'fit-content',
|
||||
minHeight: ROW_HEIGHT,
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell: any) => {
|
||||
const width = `${cell.column.getSize()}px`;
|
||||
return (
|
||||
<div
|
||||
key={cell.id}
|
||||
className="flex items-center p-2 px-4 align-middle whitespace-nowrap"
|
||||
style={{
|
||||
width,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-4 w-3/5" />
|
||||
) : cell.column.columnDef.cell ? (
|
||||
typeof cell.column.columnDef.cell === 'function' ? (
|
||||
cell.column.columnDef.cell(cell.getContext())
|
||||
) : (
|
||||
cell.column.columnDef.cell
|
||||
)
|
||||
) : (
|
||||
(cell.getValue() as React.ReactNode)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && data.length === 0 && (
|
||||
<FullPageEmptyState
|
||||
title="No events"
|
||||
description={"Start sending events and you'll see them here"}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{visibleItems.map((virtualRow) => (
|
||||
<div
|
||||
key={virtualRow.index}
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${
|
||||
virtualRow.start - virtualizer.options.scrollMargin
|
||||
}px)`,
|
||||
paddingBottom: '8px', // Gap between items
|
||||
}}
|
||||
>
|
||||
<EventItem
|
||||
event={data[virtualRow.index]!}
|
||||
viewOptions={viewOptions}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-10 center-center pt-4" ref={inViewportRef}>
|
||||
<div
|
||||
className={cn(
|
||||
'size-8 bg-background rounded-full center-center border opacity-0 transition-opacity',
|
||||
query.isFetchingNextPage && 'opacity-100',
|
||||
)}
|
||||
>
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.row.id === nextProps.row.id &&
|
||||
prevProps.virtualRow.index === nextProps.virtualRow.index &&
|
||||
prevProps.virtualRow.start === nextProps.virtualRow.start &&
|
||||
prevProps.virtualRow.size === nextProps.virtualRow.size &&
|
||||
prevProps.isLoading === nextProps.isLoading &&
|
||||
prevProps.headerColumnsHash === nextProps.headerColumnsHash
|
||||
);
|
||||
},
|
||||
arePropsEqual(['query.isLoading', 'query.data', 'query.isFetchingNextPage']),
|
||||
);
|
||||
|
||||
const VirtualizedEventsTable = ({
|
||||
table,
|
||||
data,
|
||||
isLoading,
|
||||
}: VirtualizedEventsTableProps) => {
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const headerColumns = table.getAllLeafColumns().filter((col) => {
|
||||
return table.getState().columnVisibility[col.id] !== false;
|
||||
});
|
||||
|
||||
const rowVirtualizer = useWindowVirtualizer({
|
||||
count: data.length,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: 10,
|
||||
scrollMargin: parentRef.current?.offsetTop ?? 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
rowVirtualizer.measure();
|
||||
}, [headerColumns.length]);
|
||||
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
const headerColumnsHash = headerColumns.map((col) => col.id).join(',');
|
||||
return (
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="w-full overflow-x-auto border rounded-md bg-card"
|
||||
>
|
||||
{/* Table Header */}
|
||||
<div
|
||||
className="sticky top-0 z-10 bg-card border-b"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: headerColumns
|
||||
.map((col) => `${col.getSize()}px`)
|
||||
.join(' '),
|
||||
minWidth: 'fit-content',
|
||||
}}
|
||||
>
|
||||
{headerColumns.map((column) => {
|
||||
const header = column.columnDef.header;
|
||||
const width = `${column.getSize()}px`;
|
||||
return (
|
||||
<div
|
||||
key={column.id}
|
||||
className="flex items-center h-10 px-4 text-left text-[10px] uppercase text-foreground font-semibold whitespace-nowrap"
|
||||
style={{
|
||||
width,
|
||||
}}
|
||||
>
|
||||
{typeof header === 'function' ? header({} as any) : header}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!isLoading && data.length === 0 && (
|
||||
<FullPageEmptyState
|
||||
title="No events"
|
||||
description={"Start sending events and you'll see them here"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Table Body */}
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
minHeight: 'fit-content',
|
||||
minWidth: 'fit-content',
|
||||
}}
|
||||
>
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const row = table.getRowModel().rows[virtualRow.index];
|
||||
if (!row) return null;
|
||||
|
||||
return (
|
||||
<VirtualRow
|
||||
key={virtualRow.key}
|
||||
row={row}
|
||||
virtualRow={{
|
||||
...virtualRow,
|
||||
measureElement: rowVirtualizer.measureElement,
|
||||
}}
|
||||
headerColumns={headerColumns}
|
||||
headerColumnsHash={headerColumnsHash}
|
||||
scrollMargin={rowVirtualizer.options.scrollMargin}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const EventsTable = ({ query }: Props) => {
|
||||
const { isLoading } = query;
|
||||
const columns = useColumns();
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (isLoading) {
|
||||
return LOADING_DATA;
|
||||
}
|
||||
|
||||
return query.data?.pages?.flatMap((p) => p.data) ?? [];
|
||||
}, [query.data, isLoading]);
|
||||
|
||||
const { columnVisibility, setColumnVisibility, columnOrder, setColumnOrder } =
|
||||
useDataTableColumnVisibility(columns, 'events');
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
manualFiltering: true,
|
||||
manualSorting: true,
|
||||
columns,
|
||||
rowCount: 50,
|
||||
pageCount: 1,
|
||||
filterFns: {
|
||||
isWithinRange: () => true,
|
||||
},
|
||||
state: {
|
||||
columnVisibility,
|
||||
columnOrder,
|
||||
},
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onColumnOrderChange: setColumnOrder,
|
||||
});
|
||||
|
||||
const inViewportRef = useRef<HTMLDivElement>(null);
|
||||
const { inViewport, enterCount } = useInViewport(inViewportRef, undefined, {
|
||||
disconnectOnLeave: true,
|
||||
});
|
||||
|
||||
const hasNextPage = last(query.data?.pages ?? [])?.meta.next;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
hasNextPage &&
|
||||
data.length > 0 &&
|
||||
inViewport &&
|
||||
enterCount > 0 &&
|
||||
query.isFetchingNextPage === false
|
||||
) {
|
||||
query.fetchNextPage();
|
||||
}
|
||||
}, [inViewport, enterCount, hasNextPage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EventsTableToolbar query={query} table={table} />
|
||||
<VirtualizedEventsTable table={table} data={data} isLoading={isLoading} />
|
||||
<div className="w-full h-10 center-center pt-4" ref={inViewportRef}>
|
||||
<div
|
||||
className={cn(
|
||||
'size-8 bg-background rounded-full center-center border opacity-0 transition-opacity',
|
||||
query.isFetchingNextPage && 'opacity-100',
|
||||
)}
|
||||
>
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function EventsTableToolbar({
|
||||
query,
|
||||
table,
|
||||
}: {
|
||||
query: Props['query'];
|
||||
table: Table<IServiceEvent>;
|
||||
}) {
|
||||
const { projectId } = useAppParams();
|
||||
const [startDate, setStartDate] = useQueryState(
|
||||
@@ -195,6 +304,7 @@ function EventsTableToolbar({
|
||||
parseAsIsoDateTime,
|
||||
);
|
||||
const [endDate, setEndDate] = useQueryState('endDate', parseAsIsoDateTime);
|
||||
|
||||
return (
|
||||
<DataTableToolbarContainer>
|
||||
<div className="flex flex-1 flex-wrap items-center gap-2">
|
||||
@@ -225,74 +335,7 @@ function EventsTableToolbar({
|
||||
/>
|
||||
<OverviewFiltersButtons className="justify-end p-0" />
|
||||
</div>
|
||||
<EventsViewOptions />
|
||||
<DataTableViewOptions table={table} />
|
||||
</DataTableToolbarContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function EventsViewOptions() {
|
||||
const [viewOptions, setViewOptions] = useEventsViewOptions();
|
||||
const columns = {
|
||||
origin: 'Show origin',
|
||||
queryString: 'Show query string',
|
||||
referrer: 'Referrer',
|
||||
country: 'Country',
|
||||
os: 'OS',
|
||||
browser: 'Browser',
|
||||
profileId: 'Profile',
|
||||
createdAt: 'Created at',
|
||||
properties: 'Properties',
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
aria-label="Toggle columns"
|
||||
role="combobox"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto hidden h-8 lg:flex"
|
||||
>
|
||||
<Settings2Icon className="size-4 mr-2" />
|
||||
View
|
||||
<ChevronsUpDown className="opacity-50 ml-2 size-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverPortal>
|
||||
<PopoverContent align="end" className="w-44 p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search columns..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No columns found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{Object.entries(columns).map(([column, label]) => (
|
||||
<CommandItem
|
||||
key={column}
|
||||
onSelect={() =>
|
||||
setViewOptions({
|
||||
...viewOptions,
|
||||
// biome-ignore lint/complexity/noUselessTernary: we need this this viewOptions[column] can be undefined
|
||||
[column]: viewOptions[column] === false ? true : false,
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="truncate">{label}</span>
|
||||
<Check
|
||||
className={cn(
|
||||
'ml-auto size-4 shrink-0',
|
||||
viewOptions[column] !== false
|
||||
? 'opacity-100'
|
||||
: 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user