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

View file

@ -3,14 +3,21 @@ import { useApi } from '../useApi';
import { useModified } from '../useModified'; import { useModified } from '../useModified';
import { usePagedQuery } from '../usePagedQuery'; 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 { modified } = useModified('links');
const { get } = useApi(); const { get } = useApi();
return usePagedQuery({ return usePagedQuery({
queryKey: ['links', { teamId, modified }], queryKey: ['links', { teamId, modified, ...params }],
queryFn: pageParams => { queryFn: pageParams => {
return get(teamId ? `/teams/${teamId}/links` : '/links', pageParams); return get(teamId ? `/teams/${teamId}/links` : '/links', {
...pageParams,
...params,
});
}, },
...options, ...options,
}); });

View file

@ -3,14 +3,21 @@ import { useApi } from '../useApi';
import { useModified } from '../useModified'; import { useModified } from '../useModified';
import { usePagedQuery } from '../usePagedQuery'; 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 { modified } = useModified('pixels');
const { get } = useApi(); const { get } = useApi();
return usePagedQuery({ return usePagedQuery({
queryKey: ['pixels', { teamId, modified }], queryKey: ['pixels', { teamId, modified, ...params }],
queryFn: pageParams => { queryFn: pageParams => {
return get(teamId ? `/teams/${teamId}/pixels` : '/pixels', pageParams); return get(teamId ? `/teams/${teamId}/pixels` : '/pixels', {
...pageParams,
...params,
});
}, },
...options, ...options,
}); });

View file

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