Fixed empty website select. Converted session profile to popover.

This commit is contained in:
Mike Cao 2025-10-05 14:11:25 -07:00
parent d23ad5f272
commit 3496952769
16 changed files with 146 additions and 99 deletions

View file

@ -1,14 +1,5 @@
import { Text } from '@umami/react-zen';
import {
Eye,
User,
Clock,
Sheet,
Tag,
ChartPie,
UserPlus,
GitCompareArrows,
} from '@/components/icons';
import { Eye, User, Clock, Ungroup, Tag, ChartPie, UserPlus, GitCompare } from '@/components/icons';
import { Lightning, Path, Money, Target, Funnel, Magnet, Network } from '@/components/svg';
import { useMessages, useNavigation } from '@/components/hooks';
import { SideMenu } from '@/components/common/SideMenu';
@ -56,13 +47,13 @@ export function WebsiteNav({ websiteId }: { websiteId: string }) {
{
id: 'compare',
label: formatMessage(labels.compare),
icon: <GitCompareArrows />,
icon: <GitCompare />,
path: renderPath('/compare'),
},
{
id: 'breakdown',
label: formatMessage(labels.breakdown),
icon: <Sheet />,
icon: <Ungroup />,
path: renderPath('/breakdown'),
},
],

View file

@ -47,7 +47,7 @@ export function SessionActivity({
return (
<Column key={eventId} gap>
{showHeader && <Heading size="2">{formatTimezoneDate(createdAt, 'PPPP')}</Heading>}
{showHeader && <Heading size="1">{formatTimezoneDate(createdAt, 'PPPP')}</Heading>}
<Row alignItems="center" gap="6" height="40px">
<StatusLight color={`#${visitId?.substring(0, 6)}`}>
{formatTimezoneDate(createdAt, 'pp')}

View file

@ -1,5 +1,5 @@
import { ReactNode } from 'react';
import { Icon, TextField, Column, Row, Label, Text } from '@umami/react-zen';
import { Icon, TextField, Column, Row, Label } from '@umami/react-zen';
import { useFormat, useLocale, useMessages, useRegionNames } from '@/components/hooks';
import { TypeIcon } from '@/components/common/TypeIcon';
import { KeyRound, Calendar } from '@/components/icons';
@ -15,7 +15,7 @@ export function SessionInfo({ data }) {
return (
<Column gap="6">
<Info label="ID">
<TextField value={data?.id} allowCopy />
<TextField value={data?.id} style={{ width: '100%' }} allowCopy />
</Info>
<Info label={formatMessage(labels.distinctId)} icon={<KeyRound />}>
@ -83,7 +83,7 @@ const Info = ({
<Label>{label}</Label>
<Row alignItems="center" gap>
{icon && <Icon>{icon}</Icon>}
<Text>{children || '—'}</Text>
{children || '—'}
</Row>
</Column>
);

View file

@ -1,28 +1,43 @@
'use client';
import { Grid, Row, Column, Tabs, TabList, Tab, TabPanel } from '@umami/react-zen';
import { Grid, Row, Column, Tabs, TabList, Tab, TabPanel, Icon, Button } from '@umami/react-zen';
import { Avatar } from '@/components/common/Avatar';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { X } from '@/components/icons';
import { useMessages, useWebsiteSessionQuery } from '@/components/hooks';
import { SessionActivity } from './SessionActivity';
import { SessionData } from './SessionData';
import { SessionInfo } from './SessionInfo';
import { SessionStats } from './SessionStats';
import { Panel } from '@/components/common/Panel';
export function SessionDetailsPage({
export function SessionProfile({
websiteId,
sessionId,
onClose,
}: {
websiteId: string;
sessionId: string;
onClose?: () => void;
}) {
const { data, isLoading, error } = useWebsiteSessionQuery(websiteId, sessionId);
const { formatMessage, labels } = useMessages();
return (
<LoadingPanel data={data} isLoading={isLoading} error={error}>
<LoadingPanel
data={data}
isLoading={isLoading}
error={error}
loadingIcon="spinner"
loadingPlacement="absolute"
>
{data && (
<Grid columns="260px 1fr" gap>
<Column gap>
<Row alignItems="center" justifyContent="flex-end">
<Button onPress={onClose} variant="quiet">
<Icon>
<X />
</Icon>
</Button>
</Row>
<Grid columns="340px 1fr" gap="6">
<Column gap="6">
<Row justifyContent="center">
<Avatar seed={data?.id} size={128} />
@ -31,7 +46,7 @@ export function SessionDetailsPage({
</Column>
<Column gap>
<SessionStats data={data} />
<Panel>
<Tabs>
<TabList>
<Tab id="activity">{formatMessage(labels.activity)}</Tab>
@ -49,9 +64,9 @@ export function SessionDetailsPage({
<SessionData sessionId={sessionId} websiteId={websiteId} />
</TabPanel>
</Tabs>
</Panel>
</Column>
</Grid>
</Column>
)}
</LoadingPanel>
);

View file

@ -1,5 +1,5 @@
import { useMemo, useState } from 'react';
import { Select, ListItem, Grid } from '@umami/react-zen';
import { Select, ListItem, Grid, Column } from '@umami/react-zen';
import {
useMessages,
useSessionDataPropertiesQuery,
@ -24,8 +24,8 @@ export function SessionProperties({ websiteId }: { websiteId: string }) {
data={data}
error={error}
minHeight="300px"
gap="6"
>
<Column gap="6">
{data && (
<Grid columns="repeat(auto-fill, minmax(300px, 1fr))" gap>
<Select
@ -43,6 +43,7 @@ export function SessionProperties({ websiteId }: { websiteId: string }) {
</Grid>
)}
{propertyName && <SessionValues websiteId={websiteId} propertyName={propertyName} />}
</Column>
</LoadingPanel>
);
}
@ -84,7 +85,6 @@ const SessionValues = ({ websiteId, propertyName }) => {
data={data}
error={error}
minHeight="300px"
gap="6"
>
{data && (
<Grid columns="1fr 1fr" gap>

View file

@ -1,18 +1,35 @@
'use client';
import { Key, useState } from 'react';
import { TabList, Tab, Tabs, TabPanel, Column } from '@umami/react-zen';
import { TabList, Tab, Tabs, TabPanel, Column, Modal, Dialog } from '@umami/react-zen';
import { SessionsDataTable } from './SessionsDataTable';
import { SessionProperties } from './SessionProperties';
import { useMessages } from '@/components/hooks';
import { useMessages, useNavigation } from '@/components/hooks';
import { Panel } from '@/components/common/Panel';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { getItem, setItem } from '@/lib/storage';
import { SessionProfile } from '@/app/(main)/websites/[websiteId]/sessions/SessionProfile';
const KEY_NAME = 'umami.sessions.tab';
export function SessionsPage({ websiteId }) {
const [tab, setTab] = useState(getItem(KEY_NAME) || 'activity');
const { formatMessage, labels } = useMessages();
const {
router,
query: { session },
updateParams,
} = useNavigation();
const handleClose = (close: () => void) => {
router.push(updateParams({ session: undefined }));
close();
};
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
router.push(updateParams({ session: undefined }));
}
};
const handleSelect = (value: Key) => {
setItem(KEY_NAME, value);
@ -36,6 +53,26 @@ export function SessionsPage({ websiteId }) {
</TabPanel>
</Tabs>
</Panel>
<Modal isOpen={!!session} onOpenChange={handleOpenChange} isDismissable>
<Dialog
style={{
maxWidth: 1320,
width: '100vw',
minHeight: '300px',
height: 'calc(100vh - 40px)',
}}
>
{({ close }) => {
return (
<SessionProfile
websiteId={websiteId}
sessionId={session}
onClose={() => handleClose(close)}
/>
);
}}
</Dialog>
</Modal>
</Column>
);
}

View file

@ -1,6 +1,6 @@
import Link from 'next/link';
import { DataColumn, DataTable } from '@umami/react-zen';
import { useFormat, useMessages } from '@/components/hooks';
import { useFormat, useMessages, useNavigation } from '@/components/hooks';
import { Avatar } from '@/components/common/Avatar';
import { TypeIcon } from '@/components/common/TypeIcon';
import { DateDistance } from '@/components/common/DateDistance';
@ -8,12 +8,13 @@ import { DateDistance } from '@/components/common/DateDistance';
export function SessionsTable({ data = [] }: { data: any[]; showDomain?: boolean }) {
const { formatMessage, labels } = useMessages();
const { formatValue } = useFormat();
const { updateParams } = useNavigation();
return (
<DataTable data={data}>
<DataColumn id="id" label={formatMessage(labels.session)} width="100px">
{(row: any) => (
<Link href={`sessions/${row.id}`}>
<Link href={updateParams({ session: row.id })}>
<Avatar seed={row.id} size={32} />
</Link>
)}

View file

@ -1,16 +0,0 @@
import { SessionDetailsPage } from './SessionDetailsPage';
import { Metadata } from 'next';
export default async function WebsitePage({
params,
}: {
params: Promise<{ websiteId: string; sessionId: string }>;
}) {
const { websiteId, sessionId } = await params;
return <SessionDetailsPage websiteId={websiteId} sessionId={sessionId} />;
}
export const metadata: Metadata = {
title: 'Websites',
};

View file

@ -29,27 +29,33 @@ export function LoadingPanel({
}: LoadingPanelProps) {
const empty = isEmpty ?? checkEmpty(data);
// Show loading spinner only if no data exists
if (isLoading || isFetching) {
return (
<>
{/* Show loading spinner only if no data exists */}
{(isLoading || isFetching) && (
<Column position="relative" height="100%" {...props}>
<Column position="relative" height="100%" width="100%" {...props}>
<Loading icon={loadingIcon} placement={loadingPlacement} />
</Column>
)}
{/* Show error */}
{error && <ErrorMessage />}
{/* Show empty state (once loaded) */}
{!error && !isLoading && !isFetching && empty && renderEmpty()}
{/* Show main content when data exists */}
{!isLoading && !isFetching && !error && !empty && children}
</>
);
}
// Show error
if (error) {
return <ErrorMessage />;
}
// Show empty state (once loaded)
if (!error && !isLoading && !isFetching && empty) {
return renderEmpty();
}
// Show main content when data exists
if (!isLoading && !isFetching && !error && !empty) {
return children;
}
return null;
}
function checkEmpty(data: any) {
if (!data) return false;

View file

@ -8,5 +8,6 @@ export function useWebsiteSessionQuery(websiteId: string, sessionId: string) {
queryFn: () => {
return get(`/websites/${websiteId}/sessions/${sessionId}`);
},
enabled: Boolean(websiteId && sessionId),
});
}

View file

@ -6,7 +6,7 @@ import { DEFAULT_DATE_RANGE_VALUE } from '@/lib/constants';
export function useDateRange(options: { ignoreOffset?: boolean } = {}) {
const {
query: { date = DEFAULT_DATE_RANGE_VALUE, offset = 0, compare = 'prev', all },
query: { date = DEFAULT_DATE_RANGE_VALUE, offset = 0, compare = 'prev' },
} = useNavigation();
const { locale } = useLocale();
@ -24,7 +24,7 @@ export function useDateRange(options: { ignoreOffset?: boolean } = {}) {
date,
offset,
compare,
isAllTime: !!all,
isAllTime: date.endsWith(`:all`),
isCustomRange: date.startsWith('range:'),
dateRange,
dateCompare,

View file

@ -99,11 +99,13 @@ export function DateFilter({
);
};
const selectedValue = value.endsWith(':all') ? 'all' : value;
return (
<>
<Select
{...props}
value={value}
value={selectedValue}
placeholder={formatMessage(labels.selectDate)}
onChange={handleChange}
renderValue={renderValue}

View file

@ -35,13 +35,12 @@ export function WebsiteDateFilter({
if (date === 'all') {
router.push(
updateParams({
date: getDateRangeValue(websiteDateRange.startDate, websiteDateRange.endDate),
date: `${getDateRangeValue(websiteDateRange.startDate, websiteDateRange.endDate)}:all`,
offset: undefined,
all: 1,
}),
);
} else {
router.push(updateParams({ date, offset: undefined, all: undefined }));
router.push(updateParams({ date, offset: undefined }));
}
};

View file

@ -1,6 +1,6 @@
import { useState } from 'react';
import { Select, SelectProps, ListItem } from '@umami/react-zen';
import { useUserWebsitesQuery, useMessages, useLoginQuery } from '@/components/hooks';
import { Select, SelectProps, ListItem, Text, Row } from '@umami/react-zen';
import { useUserWebsitesQuery, useMessages, useLoginQuery, useWebsite } from '@/components/hooks';
import { Empty } from '@/components/common/Empty';
export function WebsiteSelect({
@ -14,12 +14,13 @@ export function WebsiteSelect({
teamId?: string;
includeTeams?: boolean;
} & SelectProps) {
const website = useWebsite();
const { formatMessage, messages } = useMessages();
const [search, setSearch] = useState('');
const { user } = useLoginQuery();
const { data, isLoading } = useUserWebsitesQuery(
{ userId: user?.id, teamId },
{ search, pageSize: 5, includeTeams },
{ search, pageSize: 10, includeTeams },
);
const handleSearch = (value: string) => {
@ -30,6 +31,14 @@ export function WebsiteSelect({
setSearch('');
};
const renderValue = () => {
return (
<Row maxWidth="160px">
<Text truncate>{website.name}</Text>
</Row>
);
};
return (
<Select
{...props}
@ -41,8 +50,10 @@ export function WebsiteSelect({
onSearch={handleSearch}
onChange={onChange}
onOpenChange={handleOpenChange}
renderValue={renderValue}
listProps={{
renderEmptyState: () => <Empty message={formatMessage(messages.noResultsFound)} />,
style: { maxHeight: '400px' },
}}
>
{({ id, name }: any) => <ListItem key={id}>{name}</ListItem>}