Card mode for tables.

This commit is contained in:
Mike Cao 2025-10-13 13:01:01 -07:00
parent df3ae72ab7
commit d9b08d9491
14 changed files with 81 additions and 80 deletions

View file

@ -1,4 +1 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged npx lint-staged

View file

@ -78,7 +78,7 @@
"@react-spring/web": "^10.0.3", "@react-spring/web": "^10.0.3",
"@svgr/cli": "^8.1.0", "@svgr/cli": "^8.1.0",
"@tanstack/react-query": "^5.90.2", "@tanstack/react-query": "^5.90.2",
"@umami/react-zen": "^0.189.0", "@umami/react-zen": "^0.195.0",
"@umami/redis-client": "^0.29.0", "@umami/redis-client": "^0.29.0",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"chalk": "^5.6.2", "chalk": "^5.6.2",

18
pnpm-lock.yaml generated
View file

@ -45,8 +45,8 @@ importers:
specifier: ^5.90.2 specifier: ^5.90.2
version: 5.90.2(react@19.1.1) version: 5.90.2(react@19.1.1)
'@umami/react-zen': '@umami/react-zen':
specifier: ^0.189.0 specifier: ^0.195.0
version: 0.189.0(@babel/core@7.28.3)(@types/react@19.1.16)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@10.1.3)(use-sync-external-store@1.6.0(react@19.1.1)) version: 0.195.0(@babel/core@7.28.3)(@types/react@19.1.16)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@10.1.3)(use-sync-external-store@1.6.0(react@19.1.1))
'@umami/redis-client': '@umami/redis-client':
specifier: ^0.29.0 specifier: ^0.29.0
version: 0.29.0 version: 0.29.0
@ -2756,8 +2756,8 @@ packages:
resolution: {integrity: sha512-qsaFBA3e09MIDAGFUrTk+dzqtfv1XPVz8t8d1f0ybTzrCY7BKiMC5cjrl1O/P7UmHsNyW90EYSkU/ZWpmXelag==} resolution: {integrity: sha512-qsaFBA3e09MIDAGFUrTk+dzqtfv1XPVz8t8d1f0ybTzrCY7BKiMC5cjrl1O/P7UmHsNyW90EYSkU/ZWpmXelag==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@umami/react-zen@0.189.0': '@umami/react-zen@0.195.0':
resolution: {integrity: sha512-E5t5HvMrGfuilrnF6LJV+jeooC4qXpwUC4VGhnTPV24B1vdMC2W9ByzZreNaomgZy8XOVAk1wZf8QX1elloUjA==} resolution: {integrity: sha512-DI/o0AOwq6wfWEx+PgXFQ8xV0NJFP9xY1qd0+cv2Bme9Bho0U5+vxyFPkHawJyC7bfMDi3BgC7JHgTqeCqE99A==}
'@umami/redis-client@0.29.0': '@umami/redis-client@0.29.0':
resolution: {integrity: sha512-Jaqh++jskqDB7ny75pfC02OvKp1JTS4asGDsFrRL3qy8sxL3PAl9+/mybCJe4/6vWrXDJKqpgkSfUDJq2bFjyw==} resolution: {integrity: sha512-Jaqh++jskqDB7ny75pfC02OvKp1JTS4asGDsFrRL3qy8sxL3PAl9+/mybCJe4/6vWrXDJKqpgkSfUDJq2bFjyw==}
@ -6350,8 +6350,8 @@ packages:
peerDependencies: peerDependencies:
react: '>=16.13.1' react: '>=16.13.1'
react-hook-form@7.64.0: react-hook-form@7.65.0:
resolution: {integrity: sha512-fnN+vvTiMLnRqKNTVhDysdrUay0kUUAymQnFIznmgDvapjveUWOOPqMNzPg+A+0yf9DuE2h6xzBjN1s+Qx8wcg==} resolution: {integrity: sha512-xtOzDz063WcXvGWaHgLNrNzlsdFgtUWcb32E6WFaGTd7kPZG3EeDusjdZfUsPwKCKVXy1ZlntifaHZ4l8pAsmw==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19 react: ^16.8.0 || ^17 || ^18 || ^19
@ -10387,7 +10387,7 @@ snapshots:
'@typescript-eslint/types': 8.45.0 '@typescript-eslint/types': 8.45.0
eslint-visitor-keys: 4.2.1 eslint-visitor-keys: 4.2.1
'@umami/react-zen@0.189.0(@babel/core@7.28.3)(@types/react@19.1.16)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@10.1.3)(use-sync-external-store@1.6.0(react@19.1.1))': '@umami/react-zen@0.195.0(@babel/core@7.28.3)(@types/react@19.1.16)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@10.1.3)(use-sync-external-store@1.6.0(react@19.1.1))':
dependencies: dependencies:
'@fontsource/jetbrains-mono': 5.2.8 '@fontsource/jetbrains-mono': 5.2.8
'@internationalized/date': 3.10.0 '@internationalized/date': 3.10.0
@ -10401,7 +10401,7 @@ snapshots:
react: 19.1.1 react: 19.1.1
react-aria-components: 1.13.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1) react-aria-components: 1.13.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
react-dom: 19.1.1(react@19.1.1) react-dom: 19.1.1(react@19.1.1)
react-hook-form: 7.64.0(react@19.1.1) react-hook-form: 7.65.0(react@19.1.1)
react-icons: 5.5.0(react@19.1.1) react-icons: 5.5.0(react@19.1.1)
thenby: 1.3.4 thenby: 1.3.4
zustand: 5.0.8(@types/react@19.1.16)(immer@10.1.3)(react@19.1.1)(use-sync-external-store@1.6.0(react@19.1.1)) zustand: 5.0.8(@types/react@19.1.16)(immer@10.1.3)(react@19.1.1)(use-sync-external-store@1.6.0(react@19.1.1))
@ -14534,7 +14534,7 @@ snapshots:
'@babel/runtime': 7.28.3 '@babel/runtime': 7.28.3
react: 19.1.1 react: 19.1.1
react-hook-form@7.64.0(react@19.1.1): react-hook-form@7.65.0(react@19.1.1):
dependencies: dependencies:
react: 19.1.1 react: 19.1.1

View file

@ -28,9 +28,9 @@ export function App({ children }) {
} }
return ( return (
<Grid columns={{ xs: '1fr', md: 'auto 1fr' }} height="100vh" width="100%" backgroundColor="2"> <Grid columns={{ xs: '1fr', lg: 'auto 1fr' }} height="100vh" width="100%" backgroundColor="2">
<Row display={{ xs: 'flex', md: 'none' }} alignItems="center" gap></Row> <Row display={{ xs: 'flex', lg: 'none' }} alignItems="center" gap></Row>
<Column display={{ xs: 'none', md: 'flex' }}> <Column display={{ xs: 'none', lg: 'flex' }}>
<SideNav /> <SideNav />
</Column> </Column>
<Column alignItems="center" overflowY="auto" overflowX="hidden" position="relative"> <Column alignItems="center" overflowY="auto" overflowX="hidden" position="relative">

View file

@ -1,23 +1,18 @@
import Link from 'next/link'; import Link from 'next/link';
import { DataTable, DataColumn, Row } from '@umami/react-zen'; import { DataTable, DataColumn, Row, DataTableProps } from '@umami/react-zen';
import { useMessages, useNavigation, useSlug } from '@/components/hooks'; import { useMessages, useNavigation, useSlug } from '@/components/hooks';
import { Empty } from '@/components/common/Empty';
import { DateDistance } from '@/components/common/DateDistance'; import { DateDistance } from '@/components/common/DateDistance';
import { ExternalLink } from '@/components/common/ExternalLink'; import { ExternalLink } from '@/components/common/ExternalLink';
import { LinkEditButton } from './LinkEditButton'; import { LinkEditButton } from './LinkEditButton';
import { LinkDeleteButton } from './LinkDeleteButton'; import { LinkDeleteButton } from './LinkDeleteButton';
export function LinksTable({ data = [] }) { export function LinksTable(props: DataTableProps) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { websiteId, renderUrl } = useNavigation(); const { websiteId, renderUrl } = useNavigation();
const { getSlugUrl } = useSlug('link'); const { getSlugUrl } = useSlug('link');
if (data.length === 0) {
return <Empty />;
}
return ( return (
<DataTable data={data}> <DataTable {...props}>
<DataColumn id="name" label={formatMessage(labels.name)}> <DataColumn id="name" label={formatMessage(labels.name)}>
{({ id, name }: any) => { {({ id, name }: any) => {
return <Link href={renderUrl(`/links/${id}`)}>{name}</Link>; return <Link href={renderUrl(`/links/${id}`)}>{name}</Link>;

View file

@ -1,23 +1,18 @@
import Link from 'next/link'; import Link from 'next/link';
import { DataTable, DataColumn, Row } from '@umami/react-zen'; import { DataTable, DataColumn, Row, DataTableProps } from '@umami/react-zen';
import { useMessages, useNavigation, useSlug } from '@/components/hooks'; import { useMessages, useNavigation, useSlug } from '@/components/hooks';
import { Empty } from '@/components/common/Empty';
import { DateDistance } from '@/components/common/DateDistance'; import { DateDistance } from '@/components/common/DateDistance';
import { PixelEditButton } from './PixelEditButton'; import { PixelEditButton } from './PixelEditButton';
import { PixelDeleteButton } from './PixelDeleteButton'; import { PixelDeleteButton } from './PixelDeleteButton';
import { ExternalLink } from '@/components/common/ExternalLink'; import { ExternalLink } from '@/components/common/ExternalLink';
export function PixelsTable({ data = [] }) { export function PixelsTable(props: DataTableProps) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { renderUrl } = useNavigation(); const { renderUrl } = useNavigation();
const { getSlugUrl } = useSlug('pixel'); const { getSlugUrl } = useSlug('pixel');
if (data.length === 0) {
return <Empty />;
}
return ( return (
<DataTable data={data}> <DataTable {...props}>
<DataColumn id="name" label={formatMessage(labels.name)}> <DataColumn id="name" label={formatMessage(labels.name)}>
{({ id, name }: any) => { {({ id, name }: any) => {
return <Link href={renderUrl(`/pixels/${id}`)}>{name}</Link>; return <Link href={renderUrl(`/pixels/${id}`)}>{name}</Link>;

View file

@ -1,19 +1,17 @@
import { DataColumn, DataTable } from '@umami/react-zen'; import { DataColumn, DataTable, DataTableProps } from '@umami/react-zen';
import { useMessages } from '@/components/hooks'; import { useMessages } from '@/components/hooks';
import { ROLES } from '@/lib/constants'; import { ROLES } from '@/lib/constants';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
export function TeamsTable({ export interface TeamsTableProps extends DataTableProps {
data = [],
renderLink,
}: {
data: any[];
renderLink?: (row: any) => ReactNode; renderLink?: (row: any) => ReactNode;
}) { }
export function TeamsTable({ renderLink, ...props }: TeamsTableProps) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
return ( return (
<DataTable data={data}> <DataTable {...props}>
<DataColumn id="name" label={formatMessage(labels.name)}> <DataColumn id="name" label={formatMessage(labels.name)}>
{renderLink} {renderLink}
</DataColumn> </DataColumn>

View file

@ -1,30 +1,22 @@
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { Icon, DataTable, DataColumn } from '@umami/react-zen'; import { Icon, DataTable, DataColumn, DataTableProps } from '@umami/react-zen';
import { LinkButton } from '@/components/common/LinkButton'; import { LinkButton } from '@/components/common/LinkButton';
import { useMessages, useNavigation } from '@/components/hooks'; import { useMessages, useNavigation } from '@/components/hooks';
import { SquarePen } from '@/components/icons'; import { SquarePen } from '@/components/icons';
import { Empty } from '@/components/common/Empty';
export function WebsitesTable({ export interface WebsitesTableProps extends DataTableProps {
data = [],
showActions,
renderLink,
}: {
data: Record<string, any>[];
showActions?: boolean; showActions?: boolean;
allowEdit?: boolean; allowEdit?: boolean;
allowView?: boolean; allowView?: boolean;
renderLink?: (row: any) => ReactNode; renderLink?: (row: any) => ReactNode;
}) { }
export function WebsitesTable({ showActions, renderLink, ...props }: WebsitesTableProps) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { renderUrl } = useNavigation(); const { renderUrl } = useNavigation();
if (data.length === 0) {
return <Empty />;
}
return ( return (
<DataTable data={data}> <DataTable {...props}>
<DataColumn id="name" label={formatMessage(labels.name)}> <DataColumn id="name" label={formatMessage(labels.name)}>
{renderLink} {renderLink}
</DataColumn> </DataColumn>

View file

@ -1,6 +1,5 @@
import { DataTable, DataColumn, Icon, Row, Text } from '@umami/react-zen'; import { DataTable, DataColumn, Row, Text, DataTableProps, IconLabel } from '@umami/react-zen';
import { useFormat, useMessages, useNavigation } from '@/components/hooks'; import { useFormat, useMessages, useNavigation } from '@/components/hooks';
import { Empty } from '@/components/common/Empty';
import { Avatar } from '@/components/common/Avatar'; import { Avatar } from '@/components/common/Avatar';
import Link from 'next/link'; import Link from 'next/link';
import { Eye } from '@/components/icons'; import { Eye } from '@/components/icons';
@ -8,35 +7,44 @@ import { Lightning } from '@/components/svg';
import { DateDistance } from '@/components/common/DateDistance'; import { DateDistance } from '@/components/common/DateDistance';
import { TypeIcon } from '@/components/common/TypeIcon'; import { TypeIcon } from '@/components/common/TypeIcon';
export function EventsTable({ data = [] }) { export function EventsTable(props: DataTableProps) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { updateParams } = useNavigation(); const { updateParams } = useNavigation();
const { formatValue } = useFormat(); const { formatValue } = useFormat();
if (data.length === 0) {
return <Empty />;
}
return ( return (
<DataTable data={data}> <DataTable {...props}>
<DataColumn id="event" label={formatMessage(labels.event)} width="2fr"> <DataColumn id="event" label={formatMessage(labels.event)} width="2fr">
{(row: any) => { {(row: any) => {
return ( return (
<Row alignItems="center" gap="2"> <Row alignItems="center" wrap="wrap" gap>
<Link href={updateParams({ session: row.sessionId })}> <Row>
<Avatar seed={row.sessionId} size={32} /> <IconLabel
</Link> icon={row.eventName ? <Lightning /> : <Eye />}
<Icon>{row.eventName ? <Lightning /> : <Eye />}</Icon> label={formatMessage(row.eventName ? labels.triggeredEvent : labels.viewedPage)}
<Text> />
{formatMessage(row.eventName ? labels.triggeredEvent : labels.viewedPage)} </Row>
</Text> <Text
<Text weight="bold" style={{ maxWidth: '300px' }} truncate> weight="bold"
style={{ maxWidth: '300px' }}
title={row.eventName || row.urlPath}
truncate
>
{row.eventName || row.urlPath} {row.eventName || row.urlPath}
</Text> </Text>
</Row> </Row>
); );
}} }}
</DataColumn> </DataColumn>
<DataColumn id="session" label={formatMessage(labels.session)} width="80px">
{(row: any) => {
return (
<Link href={updateParams({ session: row.sessionId })}>
<Avatar seed={row.sessionId} size={32} />
</Link>
);
}}
</DataColumn>
<DataColumn id="location" label={formatMessage(labels.location)}> <DataColumn id="location" label={formatMessage(labels.location)}>
{(row: any) => ( {(row: any) => (
<TypeIcon type="country" value={row.country}> <TypeIcon type="country" value={row.country}>

View file

@ -8,7 +8,7 @@ export function SessionsDataTable({ websiteId }: { websiteId?: string; teamId?:
return ( return (
<DataGrid query={queryResult} allowPaging allowSearch> <DataGrid query={queryResult} allowPaging allowSearch>
{({ data }) => { {({ data }) => {
return <SessionsTable data={data} showDomain={!websiteId} />; return <SessionsTable data={data} />;
}} }}
</DataGrid> </DataGrid>
); );

View file

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

View file

@ -1,5 +1,12 @@
import { ReactNode, useState, useCallback } from 'react'; import {
import { SearchField, Row, Column } from '@umami/react-zen'; ReactNode,
useState,
useCallback,
ReactElement,
cloneElement,
isValidElement,
} from 'react';
import { SearchField, Row, Column, useBreakpoint } from '@umami/react-zen';
import { UseQueryResult } from '@tanstack/react-query'; import { UseQueryResult } from '@tanstack/react-query';
import { useMessages, useNavigation } from '@/components/hooks'; import { useMessages, useNavigation } from '@/components/hooks';
import { Pager } from '@/components/common/Pager'; import { Pager } from '@/components/common/Pager';
@ -35,6 +42,8 @@ export function DataGrid({
const { router, updateParams, query: queryParams } = useNavigation(); const { router, updateParams, query: queryParams } = useNavigation();
const [search, setSearch] = useState(queryParams?.search || data?.search || ''); const [search, setSearch] = useState(queryParams?.search || data?.search || '');
const showPager = allowPaging && data && data.count > data.pageSize; const showPager = allowPaging && data && data.count > data.pageSize;
const breakpoint = useBreakpoint();
const displayMode = ['xs', 'sm', 'md', 'lg'].includes(breakpoint) ? 'cards' : undefined;
const handleSearch = (value: string) => { const handleSearch = (value: string) => {
if (value !== search) { if (value !== search) {
@ -50,6 +59,8 @@ export function DataGrid({
[search], [search],
); );
const child = data ? (typeof children === 'function' ? children(data) : children) : null;
return ( return (
<Column gap="4" minHeight="300px"> <Column gap="4" minHeight="300px">
{allowSearch && ( {allowSearch && (
@ -73,7 +84,11 @@ export function DataGrid({
> >
{data && ( {data && (
<> <>
<Column>{typeof children === 'function' ? children(data) : children}</Column> <Column>
{isValidElement(child)
? cloneElement(child as ReactElement<any>, { displayMode })
: child}
</Column>
{showPager && ( {showPager && (
<Row marginTop="6"> <Row marginTop="6">
<Pager <Pager

View file

@ -31,9 +31,9 @@ export function PageBody({
<Column <Column
{...props} {...props}
width="100%" width="100%"
paddingBottom="9" paddingBottom="6"
maxWidth={maxWidth} maxWidth={maxWidth}
paddingX="4" paddingX={{ xs: '3', md: '6' }}
style={{ margin: '0 auto' }} style={{ margin: '0 auto' }}
> >
{children} {children}

View file

@ -61,6 +61,7 @@ export function NavButton({ showText = true }: TeamsButtonProps) {
borderRadius borderRadius
shadow="1" shadow="1"
maxHeight="40px" maxHeight="40px"
role="button"
style={{ cursor: 'pointer', textWrap: 'nowrap', outline: 'none' }} style={{ cursor: 'pointer', textWrap: 'nowrap', outline: 'none' }}
> >
<Row alignItems="center" position="relative" gap maxHeight="40px"> <Row alignItems="center" position="relative" gap maxHeight="40px">