Add board selector to top navigation

This commit is contained in:
Mike Cao 2026-02-12 17:26:52 -08:00
parent a1f3ad89f6
commit e2b391af28
5 changed files with 128 additions and 6 deletions

View file

@ -2,13 +2,14 @@
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 { BoardSelect } from '@/components/input/BoardSelect';
import { LinkSelect } from '@/components/input/LinkSelect'; import { LinkSelect } from '@/components/input/LinkSelect';
import { PixelSelect } from '@/components/input/PixelSelect'; 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, linkId, pixelId, teamId, router, renderUrl } = useNavigation(); const { websiteId, linkId, pixelId, boardId, teamId, router, renderUrl } = useNavigation();
const handleWebsiteChange = (value: string) => { const handleWebsiteChange = (value: string) => {
router.push(renderUrl(`/websites/${value}`)); router.push(renderUrl(`/websites/${value}`));
@ -22,6 +23,10 @@ export function TopNav() {
router.push(renderUrl(`/pixels/${value}`)); router.push(renderUrl(`/pixels/${value}`));
}; };
const handleBoardChange = (value: string) => {
router.push(renderUrl(`/boards/${value}`));
};
return ( return (
<Row <Row
position="sticky" position="sticky"
@ -36,7 +41,7 @@ export function TopNav() {
> >
<Row alignItems="center" backgroundColor="surface-raised" borderRadius> <Row alignItems="center" backgroundColor="surface-raised" borderRadius>
<TeamsButton /> <TeamsButton />
{(websiteId || linkId || pixelId) && ( {(websiteId || linkId || pixelId || boardId) && (
<> <>
<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 />
@ -74,6 +79,17 @@ export function TopNav() {
}} }}
/> />
)} )}
{boardId && (
<BoardSelect
boardId={boardId}
teamId={teamId}
onChange={handleBoardChange}
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 useBoardsQuery({ teamId }: { teamId?: string }, options?: ReactQueryOptions) { export function useBoardsQuery(
{ teamId }: { teamId?: string },
params?: Record<string, any>,
options?: ReactQueryOptions,
) {
const { modified } = useModified('boards'); const { modified } = useModified('boards');
const { get } = useApi(); const { get } = useApi();
return usePagedQuery({ return usePagedQuery({
queryKey: ['boards', { teamId, modified }], queryKey: ['boards', { teamId, modified, ...params }],
queryFn: pageParams => { queryFn: pageParams => {
return get(teamId ? `/teams/${teamId}/boards` : '/boards', pageParams); return get(teamId ? `/teams/${teamId}/boards` : '/boards', {
...pageParams,
...params,
});
}, },
...options, ...options,
}); });

View file

@ -10,6 +10,7 @@ export function useNavigation() {
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 [, linkId] = pathname.match(/\/links\/([a-f0-9-]+)/) || [];
const [, pixelId] = pathname.match(/\/pixels\/([a-f0-9-]+)/) || []; const [, pixelId] = pathname.match(/\/pixels\/([a-f0-9-]+)/) || [];
const [, boardId] = pathname.match(/\/boards\/([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>) => {
@ -40,6 +41,7 @@ export function useNavigation() {
websiteId, websiteId,
linkId, linkId,
pixelId, pixelId,
boardId,
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 { useBoardQuery, useBoardsQuery, useMessages } from '@/components/hooks';
import { LayoutDashboard } from '@/components/icons';
export function BoardSelect({
boardId,
teamId,
onChange,
isCollapsed,
buttonProps,
listProps,
...props
}: {
boardId?: string;
teamId?: string;
isCollapsed?: boolean;
} & SelectProps) {
const { t, messages } = useMessages();
const { data: board } = useBoardQuery(boardId);
const [name, setName] = useState<string>(board?.name);
const [search, setSearch] = useState('');
const { data, isLoading } = useBoardsQuery({ teamId }, { search, pageSize: 20 });
const listItems: { id: string; name: string }[] = data?.data || [];
useEffect(() => {
setName(board?.name);
}, [board?.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>
<LayoutDashboard />
</Icon>
<Text truncate>{name}</Text>
</Row>
);
};
return (
<Select
{...props}
value={boardId}
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

@ -1,5 +1,5 @@
import { Icon, ListItem, Row, Select, type SelectProps, Text } from '@umami/react-zen'; import { Icon, ListItem, Row, Select, type SelectProps, Text } from '@umami/react-zen';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { Empty } from '@/components/common/Empty'; import { Empty } from '@/components/common/Empty';
import { import {
useLoginQuery, useLoginQuery,
@ -35,6 +35,10 @@ export function WebsiteSelect({
); );
const listItems: { id: string; name: string }[] = data?.data || []; const listItems: { id: string; name: string }[] = data?.data || [];
useEffect(() => {
setName(website?.name);
}, [website?.name]);
const handleSearch = (value: string) => { const handleSearch = (value: string) => {
setSearch(value); setSearch(value);
}; };