Add link and pixel selectors to top nav

This commit is contained in:
Mike Cao 2026-02-12 17:01:37 -08:00
parent dd2d6bca45
commit a1f3ad89f6
6 changed files with 255 additions and 17 deletions

View file

@ -2,16 +2,26 @@
import { Icon, Row } from '@umami/react-zen';
import { useNavigation } from '@/components/hooks';
import { Slash } from '@/components/icons';
import { LinkSelect } from '@/components/input/LinkSelect';
import { PixelSelect } from '@/components/input/PixelSelect';
import { TeamsButton } from '@/components/input/TeamsButton';
import { WebsiteSelect } from '@/components/input/WebsiteSelect';
export function TopNav() {
const { websiteId, teamId, router, renderUrl } = useNavigation();
const { websiteId, linkId, pixelId, teamId, router, renderUrl } = useNavigation();
const handleWebsiteChange = (value: string) => {
router.push(renderUrl(`/websites/${value}`));
};
const handleLinkChange = (value: string) => {
router.push(renderUrl(`/links/${value}`));
};
const handlePixelChange = (value: string) => {
router.push(renderUrl(`/pixels/${value}`));
};
return (
<Row
position="sticky"
@ -26,20 +36,44 @@ export function TopNav() {
>
<Row alignItems="center" backgroundColor="surface-raised" borderRadius>
<TeamsButton />
{websiteId && (
{(websiteId || linkId || pixelId) && (
<>
<Icon size="sm" color="muted" style={{ opacity: 0.7, margin: '0 6px' }}>
<Slash />
</Icon>
<WebsiteSelect
websiteId={websiteId}
teamId={teamId}
onChange={handleWebsiteChange}
buttonProps={{
variant: 'quiet',
style: { minHeight: 40, minWidth: 200, maxWidth: 200 },
}}
/>
{websiteId && (
<WebsiteSelect
websiteId={websiteId}
teamId={teamId}
onChange={handleWebsiteChange}
buttonProps={{
variant: 'quiet',
style: { minHeight: 40, minWidth: 200, maxWidth: 200 },
}}
/>
)}
{linkId && (
<LinkSelect
linkId={linkId}
teamId={teamId}
onChange={handleLinkChange}
buttonProps={{
variant: 'quiet',
style: { minHeight: 40, minWidth: 200, maxWidth: 200 },
}}
/>
)}
{pixelId && (
<PixelSelect
pixelId={pixelId}
teamId={teamId}
onChange={handlePixelChange}
buttonProps={{
variant: 'quiet',
style: { minHeight: 40, minWidth: 200, maxWidth: 200 },
}}
/>
)}
</>
)}
</Row>

View file

@ -3,14 +3,21 @@ import { useApi } from '../useApi';
import { useModified } from '../useModified';
import { usePagedQuery } from '../usePagedQuery';
export function useLinksQuery({ teamId }: { teamId?: string }, options?: ReactQueryOptions) {
export function useLinksQuery(
{ teamId }: { teamId?: string },
params?: Record<string, any>,
options?: ReactQueryOptions,
) {
const { modified } = useModified('links');
const { get } = useApi();
return usePagedQuery({
queryKey: ['links', { teamId, modified }],
queryKey: ['links', { teamId, modified, ...params }],
queryFn: pageParams => {
return get(teamId ? `/teams/${teamId}/links` : '/links', pageParams);
return get(teamId ? `/teams/${teamId}/links` : '/links', {
...pageParams,
...params,
});
},
...options,
});

View file

@ -3,14 +3,21 @@ import { useApi } from '../useApi';
import { useModified } from '../useModified';
import { usePagedQuery } from '../usePagedQuery';
export function usePixelsQuery({ teamId }: { teamId?: string }, options?: ReactQueryOptions) {
export function usePixelsQuery(
{ teamId }: { teamId?: string },
params?: Record<string, any>,
options?: ReactQueryOptions,
) {
const { modified } = useModified('pixels');
const { get } = useApi();
return usePagedQuery({
queryKey: ['pixels', { teamId, modified }],
queryKey: ['pixels', { teamId, modified, ...params }],
queryFn: pageParams => {
return get(teamId ? `/teams/${teamId}/pixels` : '/pixels', pageParams);
return get(teamId ? `/teams/${teamId}/pixels` : '/pixels', {
...pageParams,
...params,
});
},
...options,
});

View file

@ -8,6 +8,8 @@ export function useNavigation() {
const searchParams = useSearchParams();
const [, teamId] = pathname.match(/\/teams\/([a-f0-9-]+)/) || [];
const [, websiteId] = pathname.match(/\/websites\/([a-f0-9-]+)/) || [];
const [, linkId] = pathname.match(/\/links\/([a-f0-9-]+)/) || [];
const [, pixelId] = pathname.match(/\/pixels\/([a-f0-9-]+)/) || [];
const [queryParams, setQueryParams] = useState(Object.fromEntries(searchParams));
const updateParams = (params?: Record<string, string | number>) => {
@ -36,6 +38,8 @@ export function useNavigation() {
query: queryParams,
teamId,
websiteId,
linkId,
pixelId,
updateParams,
replaceParams,
renderUrl,

View file

@ -0,0 +1,93 @@
import { Icon, ListItem, Row, Select, type SelectProps, Text } from '@umami/react-zen';
import { useEffect, useState } from 'react';
import { Empty } from '@/components/common/Empty';
import { useLinkQuery, useLinksQuery, useMessages } from '@/components/hooks';
import { Link } from '@/components/icons';
export function LinkSelect({
linkId,
teamId,
onChange,
isCollapsed,
buttonProps,
listProps,
...props
}: {
linkId?: string;
teamId?: string;
isCollapsed?: boolean;
} & SelectProps) {
const { t, messages } = useMessages();
const { data: link } = useLinkQuery(linkId);
const [name, setName] = useState<string>(link?.name);
const [search, setSearch] = useState('');
const { data, isLoading } = useLinksQuery({ teamId }, { search, pageSize: 20 });
const listItems: { id: string; name: string }[] = data?.data || [];
useEffect(() => {
setName(link?.name);
}, [link?.name]);
const handleOpenChange = () => {
setSearch('');
};
const handleChange = (id: string) => {
setName(listItems.find(item => item.id === id)?.name);
onChange?.(id);
};
const renderValue = () => {
if (isCollapsed) {
return '';
}
return (
<Row alignItems="center" gap>
<Icon>
<Link />
</Icon>
<Text truncate>{name}</Text>
</Row>
);
};
return (
<Select
{...props}
value={linkId}
isLoading={isLoading}
allowSearch={true}
searchValue={search}
onSearch={setSearch}
onChange={handleChange}
onOpenChange={handleOpenChange}
renderValue={renderValue}
buttonProps={{
...buttonProps,
style: {
minHeight: 40,
gap: 0,
justifyContent: isCollapsed ? 'start' : undefined,
...buttonProps?.style,
},
}}
listProps={{
...listProps,
renderEmptyState:
listProps?.renderEmptyState || (() => <Empty message={t(messages.noResultsFound)} />),
style: {
maxHeight: 'calc(42vh - 65px)',
width: 280,
...listProps?.style,
},
}}
>
{listItems.map(({ id, name }) => (
<ListItem key={id} id={id}>
{name}
</ListItem>
))}
</Select>
);
}

View file

@ -0,0 +1,93 @@
import { Icon, ListItem, Row, Select, type SelectProps, Text } from '@umami/react-zen';
import { useEffect, useState } from 'react';
import { Empty } from '@/components/common/Empty';
import { useMessages, usePixelQuery, usePixelsQuery } from '@/components/hooks';
import { Grid2x2 } from '@/components/icons';
export function PixelSelect({
pixelId,
teamId,
onChange,
isCollapsed,
buttonProps,
listProps,
...props
}: {
pixelId?: string;
teamId?: string;
isCollapsed?: boolean;
} & SelectProps) {
const { t, messages } = useMessages();
const { data: pixel } = usePixelQuery(pixelId);
const [name, setName] = useState<string>(pixel?.name);
const [search, setSearch] = useState('');
const { data, isLoading } = usePixelsQuery({ teamId }, { search, pageSize: 20 });
const listItems: { id: string; name: string }[] = data?.data || [];
useEffect(() => {
setName(pixel?.name);
}, [pixel?.name]);
const handleOpenChange = () => {
setSearch('');
};
const handleChange = (id: string) => {
setName(listItems.find(item => item.id === id)?.name);
onChange?.(id);
};
const renderValue = () => {
if (isCollapsed) {
return '';
}
return (
<Row alignItems="center" gap>
<Icon>
<Grid2x2 />
</Icon>
<Text truncate>{name}</Text>
</Row>
);
};
return (
<Select
{...props}
value={pixelId}
isLoading={isLoading}
allowSearch={true}
searchValue={search}
onSearch={setSearch}
onChange={handleChange}
onOpenChange={handleOpenChange}
renderValue={renderValue}
buttonProps={{
...buttonProps,
style: {
minHeight: 40,
gap: 0,
justifyContent: isCollapsed ? 'start' : undefined,
...buttonProps?.style,
},
}}
listProps={{
...listProps,
renderEmptyState:
listProps?.renderEmptyState || (() => <Empty message={t(messages.noResultsFound)} />),
style: {
maxHeight: 'calc(42vh - 65px)',
width: 280,
...listProps?.style,
},
}}
>
{listItems.map(({ id, name }) => (
<ListItem key={id} id={id}>
{name}
</ListItem>
))}
</Select>
);
}