Compare commits

...

4 commits

Author SHA1 Message Date
Francis Cao
f073fb1996 Add DialogTrigger to overflow menus
Some checks are pending
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
2025-10-29 12:38:52 -07:00
Francis Cao
72fba187db separate Admin/Settings Nav and add to MobileNav 2025-10-29 11:32:52 -07:00
Francis Cao
ef55b63a3b Fix admin layout and data refresh after update/delete 2025-10-29 11:04:54 -07:00
Francis Cao
c81b1c16c8 fix geteventdatavalues query 2025-10-29 10:10:46 -07:00
15 changed files with 176 additions and 135 deletions

View file

@ -1,15 +1,19 @@
import { Row, NavMenu, NavMenuItem, IconLabel, Text, Grid } from '@umami/react-zen';
import { Globe, Grid2x2, LinkIcon } from '@/components/icons';
import { useMessages, useNavigation } from '@/components/hooks';
import Link from 'next/link';
import { WebsiteNav } from '@/app/(main)/websites/[websiteId]/WebsiteNav'; import { WebsiteNav } from '@/app/(main)/websites/[websiteId]/WebsiteNav';
import { Logo } from '@/components/svg'; import { useMessages, useNavigation } from '@/components/hooks';
import { NavButton } from '@/components/input/NavButton'; import { Globe, Grid2x2, LinkIcon } from '@/components/icons';
import { MobileMenuButton } from '@/components/input/MobileMenuButton'; import { MobileMenuButton } from '@/components/input/MobileMenuButton';
import { NavButton } from '@/components/input/NavButton';
import { Logo } from '@/components/svg';
import { Grid, IconLabel, NavMenu, NavMenuItem, Row, Text } from '@umami/react-zen';
import Link from 'next/link';
import { AdminNav } from './admin/AdminNav';
import { SettingsNav } from './settings/SettingsNav';
export function MobileNav() { export function MobileNav() {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { websiteId, renderUrl } = useNavigation(); const { pathname, websiteId, renderUrl } = useNavigation();
const isAdmin = pathname.includes('/admin');
const isSettings = pathname.includes('/settings');
const links = [ const links = [
{ {
@ -51,6 +55,8 @@ export function MobileNav() {
})} })}
</NavMenu> </NavMenu>
{websiteId && <WebsiteNav websiteId={websiteId} onItemClick={close} />} {websiteId && <WebsiteNav websiteId={websiteId} onItemClick={close} />}
{isAdmin && <AdminNav onItemClick={close} />}
{isSettings && <SettingsNav onItemClick={close} />}
</> </>
); );
}} }}

View file

@ -1,61 +1,32 @@
'use client'; 'use client';
import { ReactNode } from 'react';
import { Grid, Column } from '@umami/react-zen';
import { useLoginQuery, useMessages, useNavigation } from '@/components/hooks';
import { User, Users, Globe } from '@/components/icons';
import { SideMenu } from '@/components/common/SideMenu';
import { PageBody } from '@/components/common/PageBody'; import { PageBody } from '@/components/common/PageBody';
import { useLoginQuery } from '@/components/hooks';
import { Column, Grid } from '@umami/react-zen';
import { ReactNode } from 'react';
import { AdminNav } from './AdminNav';
export function AdminLayout({ children }: { children: ReactNode }) { export function AdminLayout({ children }: { children: ReactNode }) {
const { user } = useLoginQuery(); const { user } = useLoginQuery();
const { formatMessage, labels } = useMessages();
const { pathname } = useNavigation();
if (!user.isAdmin || process.env.cloudMode) { if (!user.isAdmin || process.env.cloudMode) {
return null; return null;
} }
const items = [
{
label: formatMessage(labels.manage),
items: [
{
id: 'users',
label: formatMessage(labels.users),
path: '/admin/users',
icon: <User />,
},
{
id: 'websites',
label: formatMessage(labels.websites),
path: '/admin/websites',
icon: <Globe />,
},
{
id: 'teams',
label: formatMessage(labels.teams),
path: '/admin/teams',
icon: <Users />,
},
],
},
];
const selectedKey = items
.flatMap(e => e.items)
?.find(({ path }) => path && pathname.startsWith(path))?.id;
return ( return (
<Grid columns="auto 1fr" width="100%" height="100%"> <Grid columns={{ xs: '1fr', lg: 'auto 1fr' }} width="100%" height="100%">
<Column height="100%" border="right" backgroundColor> <Column
<SideMenu display={{ xs: 'none', lg: 'flex' }}
items={items} width="240px"
title={formatMessage(labels.admin)} height="100%"
selectedKey={selectedKey} border="right"
allowMinimize={false} backgroundColor
/> marginRight="2"
>
<AdminNav />
</Column>
<Column gap="6" margin="2">
<PageBody>{children}</PageBody>
</Column> </Column>
<PageBody>{children}</PageBody>
</Grid> </Grid>
); );
} }

View file

@ -0,0 +1,48 @@
import { SideMenu } from '@/components/common/SideMenu';
import { useMessages, useNavigation } from '@/components/hooks';
import { Globe, User, Users } from '@/components/icons';
export function AdminNav({ onItemClick }: { onItemClick?: () => void }) {
const { formatMessage, labels } = useMessages();
const { pathname } = useNavigation();
const items = [
{
label: formatMessage(labels.manage),
items: [
{
id: 'users',
label: formatMessage(labels.users),
path: '/admin/users',
icon: <User />,
},
{
id: 'websites',
label: formatMessage(labels.websites),
path: '/admin/websites',
icon: <Globe />,
},
{
id: 'teams',
label: formatMessage(labels.teams),
path: '/admin/teams',
icon: <Users />,
},
],
},
];
const selectedKey = items
.flatMap(e => e.items)
?.find(({ path }) => path && pathname.startsWith(path))?.id;
return (
<SideMenu
items={items}
title={formatMessage(labels.admin)}
selectedKey={selectedKey}
allowMinimize={false}
onItemClick={onItemClick}
/>
);
}

View file

@ -1,11 +1,11 @@
import { useState } from 'react';
import { Row, Text, Icon, DataTable, DataColumn, MenuItem, Modal } from '@umami/react-zen';
import Link from 'next/link';
import { Trash } from '@/components/icons';
import { useMessages } from '@/components/hooks';
import { Edit } from '@/components/icons';
import { MenuButton } from '@/components/input/MenuButton';
import { DateDistance } from '@/components/common/DateDistance'; import { DateDistance } from '@/components/common/DateDistance';
import { useMessages } from '@/components/hooks';
import { Edit, Trash } from '@/components/icons';
import { MenuButton } from '@/components/input/MenuButton';
import { DataColumn, DataTable, Dialog, Icon, MenuItem, Modal, Row, Text } from '@umami/react-zen';
import { TeamDeleteForm } from '../../teams/[teamId]/TeamDeleteForm';
import Link from 'next/link';
import { useState } from 'react';
export function AdminTeamsTable({ export function AdminTeamsTable({
data = [], data = [],
@ -15,7 +15,7 @@ export function AdminTeamsTable({
showActions?: boolean; showActions?: boolean;
}) { }) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const [deleteUser, setDeleteUser] = useState(null); const [deleteTeam, setDeleteTeam] = useState(null);
return ( return (
<> <>
@ -60,7 +60,7 @@ export function AdminTeamsTable({
</MenuItem> </MenuItem>
<MenuItem <MenuItem
id="delete" id="delete"
onAction={() => setDeleteUser(row)} onAction={() => setDeleteTeam(id)}
data-test="link-button-delete" data-test="link-button-delete"
> >
<Row alignItems="center" gap> <Row alignItems="center" gap>
@ -76,7 +76,11 @@ export function AdminTeamsTable({
</DataColumn> </DataColumn>
)} )}
</DataTable> </DataTable>
<Modal isOpen={!!deleteUser}></Modal> <Modal isOpen={!!deleteTeam}>
<Dialog style={{ width: 400 }}>
<TeamDeleteForm teamId={deleteTeam} onClose={() => setDeleteTeam(null)} />
</Dialog>
</Modal>
</> </>
); );
} }

View file

@ -22,6 +22,7 @@ export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () =
await mutateAsync(data, { await mutateAsync(data, {
onSuccess: async () => { onSuccess: async () => {
toast(formatMessage(messages.saved)); toast(formatMessage(messages.saved));
touch('users');
touch(`user:${user.id}`); touch(`user:${user.id}`);
onSave?.(); onSave?.();
}, },

View file

@ -1,50 +1,10 @@
'use client'; 'use client';
import { PageBody } from '@/components/common/PageBody'; import { PageBody } from '@/components/common/PageBody';
import { SideMenu } from '@/components/common/SideMenu';
import { useMessages, useNavigation } from '@/components/hooks';
import { Settings2, UserCircle, Users } from '@/components/icons';
import { Column, Grid } from '@umami/react-zen'; import { Column, Grid } from '@umami/react-zen';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { SettingsNav } from './SettingsNav';
export function SettingsLayout({ children }: { children: ReactNode }) { export function SettingsLayout({ children }: { children: ReactNode }) {
const { formatMessage, labels } = useMessages();
const { renderUrl, pathname } = useNavigation();
const items = [
{
label: formatMessage(labels.application),
items: [
{
id: 'preferences',
label: formatMessage(labels.preferences),
path: renderUrl('/settings/preferences'),
icon: <Settings2 />,
},
],
},
{
label: formatMessage(labels.account),
items: [
{
id: 'profile',
label: formatMessage(labels.profile),
path: renderUrl('/settings/profile'),
icon: <UserCircle />,
},
{
id: 'teams',
label: formatMessage(labels.teams),
path: renderUrl('/settings/teams'),
icon: <Users />,
},
],
},
];
const selectedKey = items
.flatMap(e => e.items)
.find(({ path }) => path && pathname.includes(path.split('?')[0]))?.id;
return ( return (
<Grid columns={{ xs: '1fr', lg: 'auto 1fr' }} width="100%" height="100%"> <Grid columns={{ xs: '1fr', lg: 'auto 1fr' }} width="100%" height="100%">
<Column <Column
@ -55,12 +15,7 @@ export function SettingsLayout({ children }: { children: ReactNode }) {
backgroundColor backgroundColor
marginRight="2" marginRight="2"
> >
<SideMenu <SettingsNav />
items={items}
title={formatMessage(labels.settings)}
selectedKey={selectedKey}
allowMinimize={false}
/>
</Column> </Column>
<Column gap="6" margin="2"> <Column gap="6" margin="2">
<PageBody>{children}</PageBody> <PageBody>{children}</PageBody>

View file

@ -0,0 +1,53 @@
import { SideMenu } from '@/components/common/SideMenu';
import { useMessages, useNavigation } from '@/components/hooks';
import { Settings2, UserCircle, Users } from '@/components/icons';
export function SettingsNav({ onItemClick }: { onItemClick?: () => void }) {
const { formatMessage, labels } = useMessages();
const { renderUrl, pathname } = useNavigation();
const items = [
{
label: formatMessage(labels.application),
items: [
{
id: 'preferences',
label: formatMessage(labels.preferences),
path: renderUrl('/settings/preferences'),
icon: <Settings2 />,
},
],
},
{
label: formatMessage(labels.account),
items: [
{
id: 'profile',
label: formatMessage(labels.profile),
path: renderUrl('/settings/profile'),
icon: <UserCircle />,
},
{
id: 'teams',
label: formatMessage(labels.teams),
path: renderUrl('/settings/teams'),
icon: <Users />,
},
],
},
];
const selectedKey = items
.flatMap(e => e.items)
.find(({ path }) => path && pathname.includes(path.split('?')[0]))?.id;
return (
<SideMenu
items={items}
title={formatMessage(labels.settings)}
selectedKey={selectedKey}
allowMinimize={false}
onItemClick={onItemClick}
/>
);
}

View file

@ -19,6 +19,7 @@ export function TeamDeleteForm({
await mutateAsync(null, { await mutateAsync(null, {
onSuccess: async () => { onSuccess: async () => {
touch('teams'); touch('teams');
touch(`teams:${teamId}`);
onSave?.(); onSave?.();
onClose?.(); onClose?.();
}, },

View file

@ -1,23 +1,27 @@
'use client'; 'use client';
import { useState } from 'react';
import { Button, Column, Box, DialogTrigger, Popover, Dialog, IconLabel } from '@umami/react-zen';
import { useDateRange, useMessages } from '@/components/hooks';
import { ListCheck } from '@/components/icons';
import { Panel } from '@/components/common/Panel';
import { Breakdown } from './Breakdown';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { FieldSelectForm } from '@/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm'; import { FieldSelectForm } from '@/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { Panel } from '@/components/common/Panel';
import { useDateRange, useMessages, useMobile } from '@/components/hooks';
import { ListCheck } from '@/components/icons';
import { DialogButton } from '@/components/input/DialogButton';
import { Column, Row } from '@umami/react-zen';
import { useState } from 'react';
import { Breakdown } from './Breakdown';
export function BreakdownPage({ websiteId }: { websiteId: string }) { export function BreakdownPage({ websiteId }: { websiteId: string }) {
const { const {
dateRange: { startDate, endDate }, dateRange: { startDate, endDate },
} = useDateRange(); } = useDateRange();
const [fields, setFields] = useState(['path']); const [fields, setFields] = useState(['path']);
const { isMobile } = useMobile();
return ( return (
<Column gap> <Column gap>
<WebsiteControls websiteId={websiteId} /> <WebsiteControls websiteId={websiteId} />
<FieldsButton value={fields} onChange={setFields} /> <Row alignItems="center" justifyContent={isMobile ? 'flex-end' : 'flex-start'}>
<FieldsButton value={fields} onChange={setFields} />
</Row>
<Panel height="900px" overflow="auto" allowFullscreen> <Panel height="900px" overflow="auto" allowFullscreen>
<Breakdown <Breakdown
websiteId={websiteId} websiteId={websiteId}
@ -34,19 +38,15 @@ const FieldsButton = ({ value, onChange }) => {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
return ( return (
<Box> <DialogButton
<DialogTrigger> icon={<ListCheck />}
<Button> label={formatMessage(labels.fields)}
<IconLabel icon={<ListCheck />}>{formatMessage(labels.fields)}</IconLabel> width="800px"
</Button> minHeight="300px"
<Popover> >
<Dialog title={formatMessage(labels.fields)} style={{ width: 400 }}> {({ close }) => {
{({ close }) => ( return <FieldSelectForm selectedFields={value} onChange={onChange} onClose={close} />;
<FieldSelectForm selectedFields={value} onChange={onChange} onClose={close} /> }}
)} </DialogButton>
</Dialog>
</Popover>
</DialogTrigger>
</Box>
); );
}; };

View file

@ -12,7 +12,7 @@ export function CohortAddButton({ websiteId }: { websiteId: string }) {
label={formatMessage(labels.cohort)} label={formatMessage(labels.cohort)}
variant="primary" variant="primary"
width="800px" width="800px"
minHeight="300px" height="calc(100dvh - 40px)"
> >
{({ close }) => { {({ close }) => {
return <CohortEditForm websiteId={websiteId} onClose={close} />; return <CohortEditForm websiteId={websiteId} onClose={close} />;

View file

@ -21,7 +21,7 @@ export function CohortEditButton({
variant="quiet" variant="quiet"
title={formatMessage(labels.cohort)} title={formatMessage(labels.cohort)}
width="800px" width="800px"
minHeight="300px" height="calc(100dvh - 40px)"
> >
{({ close }) => { {({ close }) => {
return ( return (

View file

@ -12,6 +12,7 @@ export function SegmentAddButton({ websiteId }: { websiteId: string }) {
label={formatMessage(labels.segment)} label={formatMessage(labels.segment)}
variant="primary" variant="primary"
width="800px" width="800px"
height="calc(100dvh - 40px)"
> >
{({ close }) => { {({ close }) => {
return <SegmentEditForm websiteId={websiteId} onClose={close} />; return <SegmentEditForm websiteId={websiteId} onClose={close} />;

View file

@ -21,6 +21,7 @@ export function SegmentEditButton({
title={formatMessage(labels.segment)} title={formatMessage(labels.segment)}
variant="quiet" variant="quiet"
width="800px" width="800px"
height="calc(100dvh - 40px)"
> >
{({ close }) => { {({ close }) => {
return ( return (

View file

@ -11,6 +11,7 @@ export function WebsiteEditForm({ websiteId, onSave }: { websiteId: string; onSa
await mutateAsync(data, { await mutateAsync(data, {
onSuccess: async () => { onSuccess: async () => {
toast(formatMessage(messages.saved)); toast(formatMessage(messages.saved));
touch('websites');
touch(`website:${website.id}`); touch(`website:${website.id}`);
onSave?.(); onSave?.();
}, },

View file

@ -47,7 +47,6 @@ async function relationalQuery(
where event_data.website_id = {{websiteId::uuid}} where event_data.website_id = {{websiteId::uuid}}
and event_data.created_at between {{startDate}} and {{endDate}} and event_data.created_at between {{startDate}} and {{endDate}}
and event_data.data_key = {{propertyName}} and event_data.data_key = {{propertyName}}
and website_event.event_name = {{eventName}}
${filterQuery} ${filterQuery}
group by value group by value
order by 2 desc order by 2 desc