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 { useNavigation } from '@/components/hooks';
import { Slash } from '@/components/icons';
import { BoardSelect } from '@/components/input/BoardSelect';
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, linkId, pixelId, teamId, router, renderUrl } = useNavigation();
const { websiteId, linkId, pixelId, boardId, teamId, router, renderUrl } = useNavigation();
const handleWebsiteChange = (value: string) => {
router.push(renderUrl(`/websites/${value}`));
@ -22,6 +23,10 @@ export function TopNav() {
router.push(renderUrl(`/pixels/${value}`));
};
const handleBoardChange = (value: string) => {
router.push(renderUrl(`/boards/${value}`));
};
return (
<Row
position="sticky"
@ -36,7 +41,7 @@ export function TopNav() {
>
<Row alignItems="center" backgroundColor="surface-raised" borderRadius>
<TeamsButton />
{(websiteId || linkId || pixelId) && (
{(websiteId || linkId || pixelId || boardId) && (
<>
<Icon size="sm" color="muted" style={{ opacity: 0.7, margin: '0 6px' }}>
<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>

View file

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

View file

@ -10,6 +10,7 @@ export function useNavigation() {
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 [, boardId] = pathname.match(/\/boards\/([a-f0-9-]+)/) || [];
const [queryParams, setQueryParams] = useState(Object.fromEntries(searchParams));
const updateParams = (params?: Record<string, string | number>) => {
@ -40,6 +41,7 @@ export function useNavigation() {
websiteId,
linkId,
pixelId,
boardId,
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 { 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 { useState } from 'react';
import { useEffect, useState } from 'react';
import { Empty } from '@/components/common/Empty';
import {
useLoginQuery,
@ -35,6 +35,10 @@ export function WebsiteSelect({
);
const listItems: { id: string; name: string }[] = data?.data || [];
useEffect(() => {
setName(website?.name);
}, [website?.name]);
const handleSearch = (value: string) => {
setSearch(value);
};