Fixed editing and navigation issues.

This commit is contained in:
Mike Cao 2025-07-13 00:37:43 -07:00
parent bf6c9395c6
commit 8c26e310f7
52 changed files with 118 additions and 122 deletions

View file

@ -199,18 +199,6 @@ export default {
typescript: { typescript: {
ignoreBuildErrors: true, ignoreBuildErrors: true,
}, },
functions: {
'app/api/**/*.js': {
maxDuration: 30,
},
},
outputFileTracing: {
include: [
'src/generated/prisma/**/*',
'node_modules/@prisma/client/**/*',
'node_modules/.prisma/client/**/*',
],
},
async headers() { async headers() {
return headers; return headers;
}, },

View file

@ -4,7 +4,7 @@ import Script from 'next/script';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { UpdateNotice } from './UpdateNotice'; import { UpdateNotice } from './UpdateNotice';
import { SideNav } from '@/app/(main)/SideNav'; import { SideNav } from '@/app/(main)/SideNav';
import { MenuBar } from '@/app/(main)/MenuBar'; import { TopNav } from '@/app/(main)/TopNav';
import { useLoginQuery, useConfig } from '@/components/hooks'; import { useLoginQuery, useConfig } from '@/components/hooks';
export function App({ children }) { export function App({ children }) {
@ -35,7 +35,7 @@ export function App({ children }) {
<SideNav /> <SideNav />
</Column> </Column>
<Row gridColumn="2 / 3" gridRow="1 / 2"> <Row gridColumn="2 / 3" gridRow="1 / 2">
<MenuBar /> <TopNav />
</Row> </Row>
<Column <Column
gridColumn="2 / 3" gridColumn="2 / 3"

View file

@ -6,11 +6,10 @@ import { WebsiteSelect } from '@/components/input/WebsiteSelect';
import { PanelLeft, Slash } from '@/components/icons'; import { PanelLeft, Slash } from '@/components/icons';
import { useNavigation, useGlobalState } from '@/components/hooks'; import { useNavigation, useGlobalState } from '@/components/hooks';
export function MenuBar() { export function TopNav() {
const [isCollapsed, setCollapsed] = useGlobalState('sidenav-collapsed'); const [isCollapsed, setCollapsed] = useGlobalState('sidenav-collapsed');
const { teamId, websiteId } = useNavigation(); const { teamId, websiteId, pathname } = useNavigation();
const isSettings = pathname.includes('/settings');
const handleSelect = () => {};
return ( return (
<Row <Row
@ -30,17 +29,12 @@ export function MenuBar() {
</Button> </Button>
<Row alignItems="center" gap="1"> <Row alignItems="center" gap="1">
<TeamsButton /> <TeamsButton />
{websiteId && ( {websiteId && !isSettings && (
<> <>
<Icon strokeColor="7" rotate={-25}> <Icon strokeColor="7" rotate={-25}>
<Slash /> <Slash />
</Icon> </Icon>
<WebsiteSelect <WebsiteSelect variant="quiet" websiteId={websiteId} teamId={teamId} />
variant="quiet"
websiteId={websiteId}
teamId={teamId}
onSelect={handleSelect}
/>
</> </>
)} )}
</Row> </Row>

View file

@ -21,6 +21,7 @@ export function UserDeleteForm({
mutate(null, { mutate(null, {
onSuccess: async () => { onSuccess: async () => {
touch('users'); touch('users');
touch(`users:${userId}`);
onSave?.(); onSave?.();
onClose?.(); onClose?.();
}, },
@ -35,9 +36,7 @@ export function UserDeleteForm({
confirmLabel={formatMessage(labels.delete)} confirmLabel={formatMessage(labels.delete)}
isDanger isDanger
> >
<Row gap="1"> <Row gap="1">{formatMessage(messages.confirmDelete, { target: username })}</Row>
{formatMessage(messages.confirmDelete, { target: <b key={username}>{username}</b> })}
</Row>
</AlertDialog> </AlertDialog>
); );
} }

View file

@ -1,15 +1,16 @@
import { useState } from 'react'; import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Row, Text, Icon, DataTable, DataColumn, MenuItem, Modal } from '@umami/react-zen'; import { Row, Text, Icon, DataTable, DataColumn, MenuItem, Modal, Dialog } from '@umami/react-zen';
import { Trash, Users } from '@/components/icons'; import { Trash, Users } from '@/components/icons';
import { useMessages } from '@/components/hooks'; import { useMessages } from '@/components/hooks';
import { Edit } from '@/components/icons'; import { Edit } from '@/components/icons';
import { MenuButton } from '@/components/input/MenuButton'; import { MenuButton } from '@/components/input/MenuButton';
import { DateDistance } from '@/components/common/DateDistance'; import { DateDistance } from '@/components/common/DateDistance';
import { WebsiteDeleteForm } from '@/app/(main)/settings/websites/[websiteId]/WebsiteDeleteForm';
export function AdminWebsitesTable({ data = [] }: { data: any[] }) { export function AdminWebsitesTable({ data = [] }: { data: any[] }) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const [deleteUser, setDeleteUser] = useState(null); const [deleteWebsite, setDeleteWebsite] = useState(null);
return ( return (
<> <>
@ -64,7 +65,7 @@ export function AdminWebsitesTable({ data = [] }: { data: any[] }) {
</MenuItem> </MenuItem>
<MenuItem <MenuItem
id="delete" id="delete"
onAction={() => setDeleteUser(row)} onAction={() => setDeleteWebsite(id)}
data-test="link-button-delete" data-test="link-button-delete"
> >
<Row alignItems="center" gap> <Row alignItems="center" gap>
@ -79,7 +80,11 @@ export function AdminWebsitesTable({ data = [] }: { data: any[] }) {
}} }}
</DataColumn> </DataColumn>
</DataTable> </DataTable>
<Modal isOpen={!!deleteUser}></Modal> <Modal isOpen={!!deleteWebsite}>
<Dialog style={{ width: 400 }}>
<WebsiteDeleteForm websiteId={deleteWebsite} onClose={() => setDeleteWebsite(null)} />
</Dialog>
</Modal>
</> </>
); );
} }

View file

@ -42,7 +42,7 @@ export function SettingsLayout({ children }: { children: ReactNode }) {
<SideMenu items={items} selectedKey={value} /> <SideMenu items={items} selectedKey={value} />
</Column> </Column>
<Column> <Column>
<Panel>{children}</Panel> <Panel minHeight="300px">{children}</Panel>
</Column> </Column>
</Grid> </Grid>
</Column> </Column>

View file

@ -1,7 +1,7 @@
import { useContext, useState } from 'react'; import { useContext, useState } from 'react';
import { Column, Tabs, TabList, Tab, TabPanel } from '@umami/react-zen'; import { Column, Tabs, TabList, Tab, TabPanel } from '@umami/react-zen';
import { TeamContext } from '@/app/(main)/teams/[teamId]/TeamProvider'; import { TeamContext } from '@/app/(main)/teams/[teamId]/TeamProvider';
import { useLoginQuery, useMessages } from '@/components/hooks'; import { useLoginQuery, useMessages, useNavigation } from '@/components/hooks';
import { SectionHeader } from '@/components/common/SectionHeader'; import { SectionHeader } from '@/components/common/SectionHeader';
import { ROLES } from '@/lib/constants'; import { ROLES } from '@/lib/constants';
import { Users } from '@/components/icons'; import { Users } from '@/components/icons';
@ -15,7 +15,10 @@ export function TeamDetails({ teamId }: { teamId: string }) {
const team = useContext(TeamContext); const team = useContext(TeamContext);
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { user } = useLoginQuery(); const { user } = useLoginQuery();
const [tab, setTab] = useState('details'); const { query, pathname } = useNavigation();
const [tab, setTab] = useState(query?.tab || 'details');
const isAdmin = pathname.includes('/admin');
const isTeamOwner = const isTeamOwner =
!!team?.teamUser?.find(({ userId, role }) => role === ROLES.teamOwner && userId === user.id) && !!team?.teamUser?.find(({ userId, role }) => role === ROLES.teamOwner && userId === user.id) &&
@ -32,7 +35,7 @@ export function TeamDetails({ teamId }: { teamId: string }) {
return ( return (
<Column gap> <Column gap>
<SectionHeader title={team?.name} icon={<Users />}> <SectionHeader title={team?.name} icon={<Users />}>
{!isTeamOwner && <TeamLeaveButton teamId={team.id} teamName={team.name} />} {!isTeamOwner && !isAdmin && <TeamLeaveButton teamId={team.id} teamName={team.name} />}
</SectionHeader> </SectionHeader>
<Tabs selectedKey={tab} onSelectionChange={(value: any) => setTab(value)}> <Tabs selectedKey={tab} onSelectionChange={(value: any) => setTab(value)}>
<TabList> <TabList>

View file

@ -33,6 +33,10 @@ export function TeamMembersTable({
{allowEdit && ( {allowEdit && (
<DataColumn id="action" align="end"> <DataColumn id="action" align="end">
{(row: any) => { {(row: any) => {
if (row?.role === ROLES.teamOwner) {
return null;
}
return ( return (
<Row alignItems="center"> <Row alignItems="center">
<TeamMemberEditButton teamId={teamId} userId={row?.user?.id} role={row?.role} /> <TeamMemberEditButton teamId={teamId} userId={row?.user?.id} role={row?.role} />

View file

@ -57,7 +57,7 @@ export function WebsitesTable({
</MenuItem> </MenuItem>
)} )}
{allowEdit && ( {allowEdit && (
<MenuItem href={renderUrl(`/settings/websites/${websiteId}`)}> <MenuItem href={`/settings/websites/${websiteId}`}>
<Row alignItems="center" gap> <Row alignItems="center" gap>
<Icon data-test="link-button-edit"> <Icon data-test="link-button-edit">
<SquarePen /> <SquarePen />

View file

@ -1,4 +1,4 @@
import { useApi, useMessages } from '@/components/hooks'; import { useApi, useMessages, useModified } from '@/components/hooks';
import { TypeConfirmationForm } from '@/components/common/TypeConfirmationForm'; import { TypeConfirmationForm } from '@/components/common/TypeConfirmationForm';
const CONFIRM_VALUE = 'DELETE'; const CONFIRM_VALUE = 'DELETE';
@ -17,10 +17,13 @@ export function WebsiteDeleteForm({
const { mutate, isPending, error } = useMutation({ const { mutate, isPending, error } = useMutation({
mutationFn: () => del(`/websites/${websiteId}`), mutationFn: () => del(`/websites/${websiteId}`),
}); });
const { touch } = useModified();
const handleConfirm = async () => { const handleConfirm = async () => {
mutate(null, { mutate(null, {
onSuccess: async () => { onSuccess: async () => {
touch('websites');
touch(`websites:${websiteId}`);
onSave?.(); onSave?.();
onClose?.(); onClose?.();
}, },

View file

@ -73,7 +73,7 @@ export function WebsiteShareForm({ websiteId, shareId, onSave, onClose }: Websit
<Row> <Row>
{id && <Button onPress={handleGenerate}>{formatMessage(labels.regenerate)}</Button>} {id && <Button onPress={handleGenerate}>{formatMessage(labels.regenerate)}</Button>}
</Row> </Row>
<Row> <Row alignItems="center" gap>
{onClose && <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>} {onClose && <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>}
<FormSubmitButton isDisabled={false} isLoading={isPending}> <FormSubmitButton isDisabled={false} isLoading={isPending}>
{formatMessage(labels.save)} {formatMessage(labels.save)}

View file

@ -18,7 +18,7 @@ export function WebsitesPage() {
<WebsiteAddButton teamId={teamId} /> <WebsiteAddButton teamId={teamId} />
</PageHeader> </PageHeader>
<Panel> <Panel>
<WebsitesDataTable teamId={teamId} allowEdit={false} /> <WebsitesDataTable teamId={teamId} />
</Panel> </Panel>
</Column> </Column>
</PageBody> </PageBody>

View file

@ -5,12 +5,11 @@ import { Share, Edit } from '@/components/icons';
import { Favicon } from '@/components/common/Favicon'; import { Favicon } from '@/components/common/Favicon';
import { ActiveUsers } from '@/components/metrics/ActiveUsers'; import { ActiveUsers } from '@/components/metrics/ActiveUsers';
import { WebsiteShareForm } from '@/app/(main)/settings/websites/[websiteId]/WebsiteShareForm'; import { WebsiteShareForm } from '@/app/(main)/settings/websites/[websiteId]/WebsiteShareForm';
import { useMessages, useNavigation } from '@/components/hooks'; import { useMessages } from '@/components/hooks';
import { LinkButton } from '@/components/common/LinkButton'; import { LinkButton } from '@/components/common/LinkButton';
export function WebsiteHeader() { export function WebsiteHeader() {
const website = useWebsite(); const website = useWebsite();
const { renderUrl } = useNavigation();
return ( return (
<PageHeader title={website.name} icon={<Favicon domain={website.domain} />} showBorder={false}> <PageHeader title={website.name} icon={<Favicon domain={website.domain} />} showBorder={false}>
@ -18,7 +17,7 @@ export function WebsiteHeader() {
<ActiveUsers websiteId={website.id} /> <ActiveUsers websiteId={website.id} />
<Row alignItems="center" gap> <Row alignItems="center" gap>
<ShareButton websiteId={website.id} shareId={website.shareId} /> <ShareButton websiteId={website.id} shareId={website.shareId} />
<LinkButton href={renderUrl(`/settings/websites/${website.id}`)}> <LinkButton href={`/settings/websites/${website.id}`}>
<Icon> <Icon>
<Edit /> <Edit />
</Icon> </Icon>
@ -42,7 +41,7 @@ const ShareButton = ({ websiteId, shareId }) => {
<Text>Share</Text> <Text>Share</Text>
</Button> </Button>
<Modal> <Modal>
<Dialog title={formatMessage(labels.share)} style={{ width: 400 }}> <Dialog title={formatMessage(labels.share)} style={{ width: 600 }}>
{({ close }) => { {({ close }) => {
return <WebsiteShareForm websiteId={websiteId} shareId={shareId} onClose={close} />; return <WebsiteShareForm websiteId={websiteId} shareId={shareId} onClose={close} />;
}} }}

View file

@ -21,7 +21,7 @@ export async function GET(
return unauthorized(); return unauthorized();
} }
const filters = getQueryFilters({ const filters = await getQueryFilters({
...query, ...query,
websiteId, websiteId,
startAt: subMinutes(startOfMinute(new Date()), REALTIME_RANGE).getTime(), startAt: subMinutes(startOfMinute(new Date()), REALTIME_RANGE).getTime(),

View file

@ -18,7 +18,7 @@ export async function POST(request: Request) {
} }
const parameters = await setWebsiteDate(websiteId, body.parameters); const parameters = await setWebsiteDate(websiteId, body.parameters);
const filters = getQueryFilters(body.filters); const filters = await getQueryFilters(body.filters);
const data = await getAttribution(websiteId, parameters as AttributionParameters, filters); const data = await getAttribution(websiteId, parameters as AttributionParameters, filters);

View file

@ -18,7 +18,7 @@ export async function POST(request: Request) {
} }
const parameters = await setWebsiteDate(websiteId, body.parameters); const parameters = await setWebsiteDate(websiteId, body.parameters);
const filters = getQueryFilters(body.filters); const filters = await getQueryFilters(body.filters);
const data = await getBreakdown(websiteId, parameters as BreakdownParameters, filters); const data = await getBreakdown(websiteId, parameters as BreakdownParameters, filters);

View file

@ -18,7 +18,7 @@ export async function POST(request: Request) {
} }
const parameters = await setWebsiteDate(websiteId, body.parameters); const parameters = await setWebsiteDate(websiteId, body.parameters);
const filters = getQueryFilters(body.filters); const filters = await getQueryFilters(body.filters);
const data = await getFunnel(websiteId, parameters as FunnelParameters, filters); const data = await getFunnel(websiteId, parameters as FunnelParameters, filters);

View file

@ -18,7 +18,7 @@ export async function POST(request: Request) {
} }
const parameters = await setWebsiteDate(websiteId, body.parameters); const parameters = await setWebsiteDate(websiteId, body.parameters);
const filters = getQueryFilters(body.filters); const filters = await getQueryFilters(body.filters);
const data = await getGoal(websiteId, parameters as GoalParameters, filters); const data = await getGoal(websiteId, parameters as GoalParameters, filters);

View file

@ -17,7 +17,7 @@ export async function POST(request: Request) {
return unauthorized(); return unauthorized();
} }
const queryFilters = await setWebsiteDate(websiteId, getQueryFilters(filters)); const queryFilters = await setWebsiteDate(websiteId, await getQueryFilters(filters));
const data = await getJourney(websiteId, parameters, queryFilters); const data = await getJourney(websiteId, parameters, queryFilters);

View file

@ -17,7 +17,7 @@ export async function POST(request: Request) {
return unauthorized(); return unauthorized();
} }
const filters = getQueryFilters(body.filters); const filters = await getQueryFilters(body.filters);
const parameters = await setWebsiteDate(websiteId, body.parameters); const parameters = await setWebsiteDate(websiteId, body.parameters);
const data = await getRetention(websiteId, parameters as RetentionParameters, filters); const data = await getRetention(websiteId, parameters as RetentionParameters, filters);

View file

@ -18,7 +18,7 @@ export async function POST(request: Request) {
} }
const parameters = await setWebsiteDate(websiteId, body.parameters); const parameters = await setWebsiteDate(websiteId, body.parameters);
const filters = getQueryFilters(body.filters); const filters = await getQueryFilters(body.filters);
const data = await getRevenue(websiteId, parameters as RevenuParameters, filters); const data = await getRevenue(websiteId, parameters as RevenuParameters, filters);

View file

@ -18,7 +18,7 @@ export async function POST(request: Request) {
} }
const parameters = await setWebsiteDate(websiteId, body.parameters); const parameters = await setWebsiteDate(websiteId, body.parameters);
const filters = getQueryFilters(body.filters); const filters = await getQueryFilters(body.filters);
const data = await getUTM(websiteId, parameters as UTMParameters, filters); const data = await getUTM(websiteId, parameters as UTMParameters, filters);

View file

@ -23,7 +23,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ team
return unauthorized('You must be the owner of this team.'); return unauthorized('You must be the owner of this team.');
} }
const filters = getQueryFilters(query); const filters = await getQueryFilters(query);
const users = await getTeamUsers( const users = await getTeamUsers(
{ {

View file

@ -22,7 +22,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ user
} }
const { userId } = await params; const { userId } = await params;
const filters = getQueryFilters(query); const filters = await getQueryFilters(query);
const websites = await getAllUserWebsitesIncludingTeamOwner(userId); const websites = await getAllUserWebsitesIncludingTeamOwner(userId);

View file

@ -26,7 +26,7 @@ export async function GET(
} }
const { event } = query; const { event } = query;
const filters = getQueryFilters(query); const filters = await getQueryFilters(query);
const data = await getEventDataEvents(websiteId, { const data = await getEventDataEvents(websiteId, {
...filters, ...filters,

View file

@ -25,7 +25,7 @@ export async function GET(
return unauthorized(); return unauthorized();
} }
const filters = getQueryFilters(query); const filters = await getQueryFilters(query);
const data = await getEventDataFields(websiteId, filters); const data = await getEventDataFields(websiteId, filters);

View file

@ -27,7 +27,7 @@ export async function GET(
} }
const { propertyName } = query; const { propertyName } = query;
const filters = getQueryFilters(query); const filters = await getQueryFilters(query);
const data = await getEventDataProperties(websiteId, { ...filters, propertyName }); const data = await getEventDataProperties(websiteId, { ...filters, propertyName });

View file

@ -26,7 +26,7 @@ export async function GET(
return unauthorized(); return unauthorized();
} }
const filters = getQueryFilters(query); const filters = await getQueryFilters(query);
const data = await getEventDataStats(websiteId, filters); const data = await getEventDataStats(websiteId, filters);

View file

@ -28,7 +28,7 @@ export async function GET(
} }
const { eventName, propertyName } = query; const { eventName, propertyName } = query;
const filters = getQueryFilters(query); const filters = await getQueryFilters(query);
const data = await getEventDataValues(websiteId, { const data = await getEventDataValues(websiteId, {
...filters, ...filters,

View file

@ -27,7 +27,7 @@ export async function GET(
return unauthorized(); return unauthorized();
} }
const filters = getQueryFilters(query); const filters = await getQueryFilters(query);
const data = await getWebsiteEvents(websiteId, filters); const data = await getWebsiteEvents(websiteId, filters);

View file

@ -29,7 +29,7 @@ export async function GET(
return unauthorized(); return unauthorized();
} }
const filters = await setWebsiteDate(websiteId, getQueryFilters(query)); const filters = await setWebsiteDate(websiteId, await getQueryFilters(query));
const data = await getEventMetrics(websiteId, filters); const data = await getEventMetrics(websiteId, filters);

View file

@ -11,10 +11,10 @@ import {
VIDEO_DOMAINS, VIDEO_DOMAINS,
PAID_AD_PARAMS, PAID_AD_PARAMS,
} from '@/lib/constants'; } from '@/lib/constants';
import { parseRequest, getQueryFilters, setWebsiteDate } from '@/lib/request'; import { parseRequest, getQueryFilters } from '@/lib/request';
import { json, unauthorized, badRequest } from '@/lib/response'; import { json, unauthorized, badRequest } from '@/lib/response';
import { getPageviewMetrics, getSessionMetrics, getChannelMetrics } from '@/queries'; import { getPageviewMetrics, getSessionMetrics, getChannelMetrics } from '@/queries';
import { filterParams } from '@/lib/schema'; import { dateRangeParams, filterParams, searchParams } from '@/lib/schema';
export async function GET( export async function GET(
request: Request, request: Request,
@ -22,11 +22,10 @@ export async function GET(
) { ) {
const schema = z.object({ const schema = z.object({
type: z.string(), type: z.string(),
startAt: z.coerce.number().int(),
endAt: z.coerce.number().int(),
limit: z.coerce.number().optional(), limit: z.coerce.number().optional(),
offset: z.coerce.number().optional(), offset: z.coerce.number().optional(),
search: z.string().optional(), ...dateRangeParams,
...searchParams,
...filterParams, ...filterParams,
}); });
@ -37,13 +36,13 @@ export async function GET(
} }
const { websiteId } = await params; const { websiteId } = await params;
const { type, limit, offset, search } = query;
if (!(await canViewWebsite(auth, websiteId))) { if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized(); return unauthorized();
} }
const filters = await setWebsiteDate(websiteId, getQueryFilters(query)); const { type, limit, offset, search } = query;
const filters = await getQueryFilters(query, websiteId);
if (search) { if (search) {
filters[type] = `c.${search}`; filters[type] = `c.${search}`;

View file

@ -1,6 +1,6 @@
import { z } from 'zod'; import { z } from 'zod';
import { canViewWebsite } from '@/lib/auth'; import { canViewWebsite } from '@/lib/auth';
import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request'; import { getQueryFilters, parseRequest } from '@/lib/request';
import { dateRangeParams, filterParams } from '@/lib/schema'; import { dateRangeParams, filterParams } from '@/lib/schema';
import { getCompareDate } from '@/lib/date'; import { getCompareDate } from '@/lib/date';
import { unauthorized, json } from '@/lib/response'; import { unauthorized, json } from '@/lib/response';
@ -27,7 +27,7 @@ export async function GET(
return unauthorized(); return unauthorized();
} }
const filters = await setWebsiteDate(websiteId, getQueryFilters(query)); const filters = await getQueryFilters(query, websiteId);
const [pageviews, sessions] = await Promise.all([ const [pageviews, sessions] = await Promise.all([
getPageviewStats(websiteId, filters), getPageviewStats(websiteId, filters),

View file

@ -22,7 +22,7 @@ export async function GET(
const { websiteId } = await params; const { websiteId } = await params;
const { propertyName } = query; const { propertyName } = query;
const filters = getQueryFilters(query); const filters = await getQueryFilters(query);
if (!(await canViewWebsite(auth, websiteId))) { if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized(); return unauthorized();

View file

@ -22,7 +22,7 @@ export async function GET(
const { propertyName } = query; const { propertyName } = query;
const { websiteId } = await params; const { websiteId } = await params;
const filters = await setWebsiteDate(websiteId, getQueryFilters(query)); const filters = await setWebsiteDate(websiteId, await getQueryFilters(query));
if (!(await canViewWebsite(auth, websiteId))) { if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized(); return unauthorized();

View file

@ -25,7 +25,7 @@ export async function GET(
return unauthorized(); return unauthorized();
} }
const filters = getQueryFilters(query); const filters = await getQueryFilters(query);
const data = await getSessionActivity(websiteId, sessionId, filters); const data = await getSessionActivity(websiteId, sessionId, filters);

View file

@ -27,7 +27,7 @@ export async function GET(
return unauthorized(); return unauthorized();
} }
const filters = await setWebsiteDate(websiteId, getQueryFilters(query)); const filters = await setWebsiteDate(websiteId, await getQueryFilters(query));
const data = await getWebsiteSessions(websiteId, filters); const data = await getWebsiteSessions(websiteId, filters);

View file

@ -27,7 +27,7 @@ export async function GET(
return unauthorized(); return unauthorized();
} }
const filters = await setWebsiteDate(websiteId, getQueryFilters(query)); const filters = await setWebsiteDate(websiteId, await getQueryFilters(query));
const metrics = await getWebsiteSessionStats(websiteId, filters); const metrics = await getWebsiteSessionStats(websiteId, filters);

View file

@ -28,7 +28,7 @@ export async function GET(
return unauthorized(); return unauthorized();
} }
const filters = getQueryFilters(query); const filters = await getQueryFilters(query);
const data = await getWebsiteSessionsWeekly(websiteId, filters); const data = await getWebsiteSessionsWeekly(websiteId, filters);

View file

@ -1,8 +1,8 @@
import { z } from 'zod'; import { z } from 'zod';
import { parseRequest, getQueryFilters, setWebsiteDate } from '@/lib/request'; import { parseRequest, getQueryFilters } from '@/lib/request';
import { unauthorized, json } from '@/lib/response'; import { unauthorized, json } from '@/lib/response';
import { canViewWebsite } from '@/lib/auth'; import { canViewWebsite } from '@/lib/auth';
import { filterParams } from '@/lib/schema'; import { dateRangeParams, filterParams } from '@/lib/schema';
import { getWebsiteStats } from '@/queries'; import { getWebsiteStats } from '@/queries';
import { getCompareDate } from '@/lib/date'; import { getCompareDate } from '@/lib/date';
@ -11,9 +11,8 @@ export async function GET(
{ params }: { params: Promise<{ websiteId: string }> }, { params }: { params: Promise<{ websiteId: string }> },
) { ) {
const schema = z.object({ const schema = z.object({
startAt: z.coerce.number().int(),
endAt: z.coerce.number().int(),
compare: z.string().optional(), compare: z.string().optional(),
...dateRangeParams,
...filterParams, ...filterParams,
}); });
@ -29,7 +28,7 @@ export async function GET(
return unauthorized(); return unauthorized();
} }
const filters = await setWebsiteDate(websiteId, getQueryFilters(query)); const filters = await getQueryFilters(query, websiteId);
const data = await getWebsiteStats(websiteId, filters); const data = await getWebsiteStats(websiteId, filters);

View file

@ -39,7 +39,7 @@ export async function GET(
if (FILTER_GROUPS[type]) { if (FILTER_GROUPS[type]) {
values = (await getWebsiteSegments(websiteId, type)).map(segment => ({ value: segment.name })); values = (await getWebsiteSegments(websiteId, type)).map(segment => ({ value: segment.name }));
} else { } else {
const filters = getQueryFilters(query); const filters = await getQueryFilters(query);
values = await getValues(websiteId, FILTER_COLUMNS[type], filters); values = await getValues(websiteId, FILTER_COLUMNS[type], filters);
} }

View file

@ -42,16 +42,18 @@ export function Pager({ page, pageSize, count, onPageChange }: PagerProps) {
total: maxPage.toLocaleString(), total: maxPage.toLocaleString(),
})} })}
</Text> </Text>
<Button onPress={() => handlePageChange(-1)} isDisabled={firstPage}> <Row gap="1">
<Icon size="sm" rotate={180}> <Button variant="outline" onPress={() => handlePageChange(-1)} isDisabled={firstPage}>
<Chevron /> <Icon size="sm" rotate={180}>
</Icon> <Chevron />
</Button> </Icon>
<Button onPress={() => handlePageChange(1)} isDisabled={lastPage}> </Button>
<Icon size="sm"> <Button variant="outline" onPress={() => handlePageChange(1)} isDisabled={lastPage}>
<Chevron /> <Icon size="sm">
</Icon> <Chevron />
</Button> </Icon>
</Button>
</Row>
</Row> </Row>
</Row> </Row>
); );

View file

@ -11,8 +11,8 @@ export function useTeamsQuery(params?: Record<string, any>, options?: ReactQuery
queryKey: ['teams:admin', { modified, ...params }], queryKey: ['teams:admin', { modified, ...params }],
queryFn: pageParams => { queryFn: pageParams => {
return get(`/admin/teams`, { return get(`/admin/teams`, {
...params,
...pageParams, ...pageParams,
...params,
}); });
}, },
...options, ...options,

View file

@ -17,8 +17,8 @@ export function useUserWebsitesQuery(
queryKey: ['websites', { userId, teamId, modified, ...params }], queryKey: ['websites', { userId, teamId, modified, ...params }],
queryFn: pageParams => { queryFn: pageParams => {
return get(teamId ? `/teams/${teamId}/websites` : `/users/${userId || user.id}/websites`, { return get(teamId ? `/teams/${teamId}/websites` : `/users/${userId || user.id}/websites`, {
...params,
...pageParams, ...pageParams,
...params,
}); });
}, },
...options, ...options,

View file

@ -1,28 +1,24 @@
import { useState } from 'react'; import { useState } from 'react';
import { Select, SelectProps, ListItem } from '@umami/react-zen'; import { Select, SelectProps, ListItem } from '@umami/react-zen';
import { useUserWebsitesQuery, useMessages } from '@/components/hooks'; import { useUserWebsitesQuery, useWebsiteQuery, useNavigation } from '@/components/hooks';
export function WebsiteSelect({ export function WebsiteSelect({
websiteId, websiteId,
teamId, teamId,
variant, variant,
onSelect,
...props ...props
}: { }: {
websiteId?: string; websiteId?: string;
teamId?: string; teamId?: string;
variant?: 'primary' | 'outline' | 'quiet' | 'danger' | 'zero'; variant?: 'primary' | 'outline' | 'quiet' | 'danger' | 'zero';
onSelect?: (key: any) => void;
} & SelectProps) { } & SelectProps) {
const { formatMessage, labels } = useMessages(); const { router, renderUrl } = useNavigation();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState(websiteId); const { data: website } = useWebsiteQuery(websiteId);
const { data, isLoading } = useUserWebsitesQuery({ teamId }, { search, pageSize: 5 }); const { data, isLoading } = useUserWebsitesQuery({ teamId }, { search, pageSize: 5 });
const handleSelect = (value: any) => { const handleSelect = (value: any) => {
setSelectedId(value); router.push(renderUrl(`/websites/${value}`));
onSelect?.(value);
}; };
const handleSearch = (value: string) => { const handleSearch = (value: string) => {
@ -33,14 +29,14 @@ export function WebsiteSelect({
<Select <Select
{...props} {...props}
items={data?.['data'] || []} items={data?.['data'] || []}
value={selectedId} value={websiteId}
placeholder={formatMessage(labels.selectWebsite)}
isLoading={isLoading} isLoading={isLoading}
buttonProps={{ variant }} buttonProps={{ variant }}
allowSearch={true} allowSearch={true}
searchValue={search} searchValue={search}
onSearch={handleSearch} onSearch={handleSearch}
onChange={handleSelect} onChange={handleSelect}
renderValue={() => website?.name}
> >
{({ id, name }: any) => <ListItem key={id}>{name}</ListItem>} {({ id, name }: any) => <ListItem key={id}>{name}</ListItem>}
</Select> </Select>

View file

@ -66,7 +66,7 @@ export function getRequestDateRange(query: Record<string, string>) {
}; };
} }
export async function getRequestFilters(query: Record<string, any>, websiteId?: string) { export function getRequestFilters(query: Record<string, any>) {
const result: Record<string, any> = {}; const result: Record<string, any> = {};
for (const key of Object.keys(FILTER_COLUMNS)) { for (const key of Object.keys(FILTER_COLUMNS)) {
@ -76,20 +76,17 @@ export async function getRequestFilters(query: Record<string, any>, websiteId?:
} }
} }
return result;
}
export async function getRequestSegments(websiteId: string, query: Record<string, any>) {
for (const key of Object.keys(FILTER_GROUPS)) { for (const key of Object.keys(FILTER_GROUPS)) {
const value = query[key]; const value = query[key];
if (value !== undefined) { if (value !== undefined) {
const segment = await getWebsiteSegment(websiteId, key, value); return getWebsiteSegment(websiteId, key, value);
if (key === 'segment') {
// merge filters into result
Object.assign(result, segment.parameters);
} else {
result[key] = segment.parameters;
}
} }
} }
return result;
} }
export async function setWebsiteDate(websiteId: string, data: Record<string, any>) { export async function setWebsiteDate(websiteId: string, data: Record<string, any>) {
@ -102,13 +99,18 @@ export async function setWebsiteDate(websiteId: string, data: Record<string, any
return data; return data;
} }
export function getQueryFilters(params: Record<string, any>): QueryFilters { export async function getQueryFilters(
const dateRange = getRequestDateRange(params); params: Record<string, any>,
websiteId?: string,
): Promise<QueryFilters> {
const dateRange = await setWebsiteDate(websiteId, getRequestDateRange(params));
const filters = getRequestFilters(params); const filters = getRequestFilters(params);
const segments = await getRequestSegments(websiteId, params);
return { return {
...dateRange, ...dateRange,
...filters, ...filters,
...segments,
page: params?.page, page: params?.page,
pageSize: params?.page ? params?.pageSize || DEFAULT_PAGE_SIZE : undefined, pageSize: params?.page ? params?.pageSize || DEFAULT_PAGE_SIZE : undefined,
orderBy: params?.orderBy, orderBy: params?.orderBy,

View file

@ -71,6 +71,9 @@ export interface FilterParams {
search?: string; search?: string;
tag?: string; tag?: string;
eventType?: number; eventType?: number;
segment?: string;
cohort?: string;
compare?: string;
} }
export interface SortParams { export interface SortParams {

View file

@ -85,7 +85,7 @@ async function clickhouseQuery(
from ( from (
select arrayJoin(event_name) as event_name, select arrayJoin(event_name) as event_name,
created_at created_at
from website_event_stats_hourly website_event from website_event_stats_hourly as website_event
where website_id = {websiteId:UUID} where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64} and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and event_type = {eventType:UInt32} and event_type = {eventType:UInt32}

View file

@ -81,7 +81,7 @@ async function clickhouseQuery(
select select
${getDateSQL('website_event.created_at', unit, timezone)} as t, ${getDateSQL('website_event.created_at', unit, timezone)} as t,
sum(views) as y sum(views) as y
from website_event_stats_hourly website_event from website_event_stats_hourly as website_event
${cohortQuery} ${cohortQuery}
where website_id = {websiteId:UUID} where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64} and created_at between {startDate:DateTime64} and {endDate:DateTime64}

View file

@ -103,7 +103,7 @@ async function clickhouseQuery(
${column} x, ${column} x,
uniq(session_id) y uniq(session_id) y
${includeCountry ? ', country' : ''} ${includeCountry ? ', country' : ''}
from website_event_stats_hourly website_event from website_event_stats_hourly as website_event
${cohortQuery} ${cohortQuery}
where website_id = {websiteId:UUID} where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64} and created_at between {startDate:DateTime64} and {endDate:DateTime64}

View file

@ -81,7 +81,7 @@ async function clickhouseQuery(
select select
${getDateSQL('website_event.created_at', unit, timezone)} as t, ${getDateSQL('website_event.created_at', unit, timezone)} as t,
uniq(session_id) as y uniq(session_id) as y
from website_event_stats_hourly website_event from website_event_stats_hourly as website_event
where website_id = {websiteId:UUID} where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64} and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and event_type = {eventType:UInt32} and event_type = {eventType:UInt32}

View file

@ -94,7 +94,7 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters) {
uniq(visit_id) as visits, uniq(visit_id) as visits,
sumIf(views, event_type = 1) as views, sumIf(views, event_type = 1) as views,
lastAt as createdAt lastAt as createdAt
from website_event_stats_hourly website_event from website_event_stats_hourly as website_event
${cohortQuery} ${cohortQuery}
where website_id = {websiteId:UUID} where website_id = {websiteId:UUID}
${dateQuery} ${dateQuery}