mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
feat(mobile): the assistant is an entry of the dock, sorted like the pages
The home assistant sat behind the More button on every page outside a trip, so a dock with free slots showed "..." for a single entry. It is now an entry of the dock list while its addon is on and it can run for the user: an account that never sorted its dock gets it in a slot the addons leave free, and Settings > Appearance > Mobile can move it between the bar and More like any page.
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
// FE-W4NAVH-001 to FE-W4NAVH-005
|
||||
// FE-W4NAVH-001 to FE-W4NAVH-008
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { CalendarDays, Globe, LayoutGrid } from 'lucide-react'
|
||||
import { CalendarDays, Globe, LayoutGrid, Sparkles } from 'lucide-react'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { TranslationProvider } from '../../i18n/TranslationContext'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { usePluginStore } from '../../store/pluginStore'
|
||||
import { useNavItems } from './navItems'
|
||||
import { useAssistantStore } from '../../store/assistantStore'
|
||||
import type { AiStatusResponse } from '@trek/shared'
|
||||
import { useDockNavItems, useNavItems } from './navItems'
|
||||
|
||||
function wrapper({ children }: { children: React.ReactNode }) {
|
||||
return <TranslationProvider>{children}</TranslationProvider>
|
||||
@@ -16,6 +18,7 @@ const render = () => renderHook(() => useNavItems(), { wrapper })
|
||||
beforeEach(() => {
|
||||
useAddonStore.setState({ addons: [] })
|
||||
usePluginStore.setState({ plugins: [] })
|
||||
useAssistantStore.setState({ status: null })
|
||||
})
|
||||
|
||||
describe('useNavItems', () => {
|
||||
@@ -77,3 +80,33 @@ describe('useNavItems', () => {
|
||||
expect(result.current).toBe(first)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useDockNavItems', () => {
|
||||
const renderDock = () => renderHook(() => useDockNavItems(), { wrapper })
|
||||
const assistantAddon = (enabled: boolean) =>
|
||||
useAddonStore.setState({ addons: [{ id: 'ai_assistant', name: 'AI assistant', icon: 'Sparkles', type: 'integration', enabled }] as never })
|
||||
|
||||
it('FE-W4NAVH-006: adds the assistant after the pages while its addon is on and it can run', () => {
|
||||
assistantAddon(true)
|
||||
useAssistantStore.setState({ status: { available: true } as AiStatusResponse })
|
||||
|
||||
const { result } = renderDock()
|
||||
|
||||
expect(result.current.map(i => i.id)).toEqual(['dashboard', 'assistant'])
|
||||
expect(result.current[1]).toMatchObject({ to: '', label: 'Assistant', icon: Sparkles })
|
||||
})
|
||||
|
||||
it('FE-W4NAVH-007: leaves it out while the addon is off', () => {
|
||||
assistantAddon(false)
|
||||
useAssistantStore.setState({ status: { available: true } as AiStatusResponse })
|
||||
|
||||
expect(renderDock().result.current.map(i => i.id)).toEqual(['dashboard'])
|
||||
})
|
||||
|
||||
it('FE-W4NAVH-008: leaves it out while it cannot run for this user', () => {
|
||||
assistantAddon(true)
|
||||
useAssistantStore.setState({ status: { available: false } as AiStatusResponse })
|
||||
|
||||
expect(renderDock().result.current.map(i => i.id)).toEqual(['dashboard'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo } from 'react'
|
||||
import { LayoutGrid, CalendarDays, Globe, Compass, Bookmark, type LucideIcon } from 'lucide-react'
|
||||
import { LayoutGrid, CalendarDays, Globe, Compass, Bookmark, Sparkles, type LucideIcon } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useAssistantStore } from '../../store/assistantStore'
|
||||
import { usePluginStore } from '../../store/pluginStore'
|
||||
import { resolvePluginIcon } from '../shared/PluginIcon'
|
||||
|
||||
@@ -12,16 +13,21 @@ import { resolvePluginIcon } from '../shared/PluginIcon'
|
||||
*
|
||||
* Stable ids used for persistence: `'dashboard'` (pinned), an enabled global
|
||||
* addon's id (vacay|atlas|journey|collections|future), or `'plugin:<id>'` for a
|
||||
* page plugin. Dashboard is always first and immovable.
|
||||
* page plugin. Dashboard is always first and immovable. The mobile dock adds
|
||||
* `'assistant'` (see useDockNavItems).
|
||||
*/
|
||||
export interface NavItemDef {
|
||||
id: string
|
||||
/** The page it opens; empty for the assistant, which opens the home chat instead. */
|
||||
to: string
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
pinned?: boolean
|
||||
}
|
||||
|
||||
/** The home assistant's entry in the mobile dock: a chat it opens, not a page. */
|
||||
export const ASSISTANT_NAV_ID = 'assistant'
|
||||
|
||||
// Signature glyphs for the four catalog global addons, keyed by id so the known
|
||||
// nav items keep their icon regardless of the (string) icon the addon row ships;
|
||||
// any future/unknown global addon resolves through its manifest icon name.
|
||||
@@ -33,7 +39,7 @@ const ADDON_ICONS: Record<string, LucideIcon> = {
|
||||
}
|
||||
|
||||
/** The bar holds Dashboard + at most this many custom items; the rest go to More.
|
||||
* Two keeps the dock at 3 destinations (Dashboard + 2) so the More slot still fits. */
|
||||
* Two keeps the dock at 3 entries (Dashboard + 2) so the More slot still fits. */
|
||||
export const MOBILE_NAV_MAX_BAR = 2
|
||||
/**
|
||||
* The built-in dock next to Dashboard for an un-customised account, in priority
|
||||
@@ -44,9 +50,11 @@ export const MOBILE_NAV_MAX_BAR = 2
|
||||
* to every day, not the map of countries it already visited. Journey ships
|
||||
* disabled though, so an instance that never turned it on keeps the Vacay/Atlas
|
||||
* dock it has today. Listing all four known global addons rather than just the
|
||||
* top two also keeps the dock filled when one of them is switched off.
|
||||
* top two also keeps the dock filled when one of them is switched off. The
|
||||
* assistant comes last: it takes a slot only one of the addons left free, rather
|
||||
* than sitting alone behind the More button.
|
||||
*/
|
||||
export const DEFAULT_DOCK_IDS = ['vacay', 'journey', 'atlas', 'collections']
|
||||
export const DEFAULT_DOCK_IDS = ['vacay', 'journey', 'atlas', 'collections', ASSISTANT_NAV_ID]
|
||||
|
||||
export function buildNavItems(
|
||||
globalAddons: { id: string; name: string; icon: string }[],
|
||||
@@ -90,6 +98,26 @@ export function useNavItems(): NavItemDef[] {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The entries the mobile dock arranges: the nav items, and the home assistant
|
||||
* while its addon is on and it can run for this user. The settings customizer
|
||||
* sorts the same list, so the assistant can go into the bar or under More.
|
||||
*/
|
||||
export function useDockNavItems(): NavItemDef[] {
|
||||
const { t } = useTranslation()
|
||||
const items = useNavItems()
|
||||
const addonOn = useAddonStore((s) => s.isEnabled('ai_assistant'))
|
||||
const available = useAssistantStore((s) => s.status?.available === true)
|
||||
const withAssistant = addonOn && available
|
||||
return useMemo(
|
||||
() =>
|
||||
withAssistant
|
||||
? [...items, { id: ASSISTANT_NAV_ID, to: '', label: t('assistant.launcher.tooltip'), icon: Sparkles }]
|
||||
: items,
|
||||
[items, withAssistant, t],
|
||||
)
|
||||
}
|
||||
|
||||
export interface MobileNavSplit {
|
||||
/** Items shown directly in the dock, Dashboard first. */
|
||||
bar: NavItemDef[]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useNavItems, splitMobileNav, MOBILE_NAV_MAX_BAR, type NavItemDef } from '../Layout/navItems'
|
||||
import { useDockNavItems, splitMobileNav, MOBILE_NAV_MAX_BAR, type NavItemDef } from '../Layout/navItems'
|
||||
|
||||
export interface MobileNavValue {
|
||||
bar: string[]
|
||||
@@ -36,7 +36,7 @@ export function useMobileNavEditor(
|
||||
value: MobileNavValue,
|
||||
onChange: (next: MobileNavValue) => void,
|
||||
): MobileNavEditor {
|
||||
const items = useNavItems()
|
||||
const items = useDockNavItems()
|
||||
const split = splitMobileNav(items, value)
|
||||
const dashboard = split.bar.find((i) => i.id === 'dashboard')
|
||||
const barItems = split.bar.filter((i) => i.id !== 'dashboard')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-MBOTTOMNAV-001 to FE-COMP-MBOTTOMNAV-008
|
||||
// FE-COMP-MBOTTOMNAV-001 to FE-COMP-MBOTTOMNAV-011
|
||||
|
||||
vi.mock('../../api/websocket', () => ({
|
||||
connect: vi.fn(),
|
||||
@@ -23,11 +23,19 @@ import { useAddonStore } from '../../store/addonStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import { useAssistantStore } from '../../store/assistantStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import type { AiStatusResponse } from '@trek/shared';
|
||||
import MBottomNav from './MBottomNav';
|
||||
|
||||
const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@example.com' });
|
||||
|
||||
const vacay = { id: 'vacay', name: 'Vacay', type: 'global', icon: 'calendar', enabled: true };
|
||||
const atlas = { id: 'atlas', name: 'Atlas', type: 'global', icon: 'globe', enabled: true };
|
||||
const assistantAddon = { id: 'ai_assistant', name: 'AI assistant', type: 'integration', icon: 'sparkles', enabled: true };
|
||||
|
||||
const withAssistant = () =>
|
||||
useAssistantStore.setState({ status: { available: true } as AiStatusResponse, window: { open: false, minimized: false } });
|
||||
|
||||
const withCollections = () =>
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'collections', name: 'Collections', type: 'global', icon: 'bookmark', enabled: true }],
|
||||
@@ -40,6 +48,10 @@ beforeEach(() => {
|
||||
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useAssistantStore.setState({ status: null, window: { open: false, minimized: false } });
|
||||
});
|
||||
|
||||
describe('MBottomNav', () => {
|
||||
it('FE-COMP-MBOTTOMNAV-001: the dock "+" creates a trip on an unclaimed route', async () => {
|
||||
const user = userEvent.setup();
|
||||
@@ -100,14 +112,46 @@ describe('MBottomNav', () => {
|
||||
expect(screen.queryByRole('button', { name: 'New Trip' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MBOTTOMNAV-008: the More list leads with the assistant while it can run, and opens the home chat', async () => {
|
||||
it('FE-COMP-MBOTTOMNAV-008: the assistant takes a free dock slot, so More does not hold it alone', async () => {
|
||||
const user = userEvent.setup();
|
||||
useAssistantStore.setState({ status: { available: true } as AiStatusResponse, window: { open: false, minimized: false } });
|
||||
withAssistant();
|
||||
seedStore(useAddonStore, { addons: [atlas, assistantAddon] });
|
||||
render(<MBottomNav />, { initialEntries: ['/atlas'] });
|
||||
expect(screen.queryByRole('button', { name: 'More' })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Assistant' }));
|
||||
expect(useAssistantStore.getState().window).toEqual({ open: true, minimized: false });
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-MBOTTOMNAV-009: with the dock full the assistant waits under More, and opens the home chat from there', async () => {
|
||||
const user = userEvent.setup();
|
||||
withAssistant();
|
||||
seedStore(useAddonStore, { addons: [vacay, atlas, assistantAddon] });
|
||||
render(<MBottomNav />, { initialEntries: ['/atlas'] });
|
||||
await user.click(screen.getByRole('button', { name: 'More' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Assistant' }));
|
||||
expect(useAssistantStore.getState().window).toEqual({ open: true, minimized: false });
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
useAssistantStore.setState({ status: null, window: { open: false, minimized: false } });
|
||||
});
|
||||
|
||||
it('FE-COMP-MBOTTOMNAV-010: the assistant goes where the settings put it', () => {
|
||||
withAssistant();
|
||||
seedStore(useAddonStore, { addons: [vacay, atlas, assistantAddon] });
|
||||
seedStore(useSettingsStore, {
|
||||
settings: { ...useSettingsStore.getState().settings, appearance: { mobileNav: { bar: ['assistant', 'atlas'], more: ['vacay'] } } },
|
||||
});
|
||||
render(<MBottomNav />, { initialEntries: ['/atlas'] });
|
||||
expect(screen.getByRole('button', { name: 'Assistant' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Atlas' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Vacay' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'More' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MBOTTOMNAV-011: no assistant entry while its addon is off', () => {
|
||||
withAssistant();
|
||||
seedStore(useAddonStore, { addons: [atlas, { ...assistantAddon, enabled: false }] });
|
||||
render(<MBottomNav />, { initialEntries: ['/atlas'] });
|
||||
expect(screen.queryByRole('button', { name: 'Assistant' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'More' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,17 +2,14 @@ import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useLocation, matchPath } from 'react-router'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { ChevronRight, MoreHorizontal, Plus, Search, Sparkles, Upload } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { ChevronRight, MoreHorizontal, Plus, Search, Upload } from 'lucide-react'
|
||||
import { normalizeAppearance } from '@trek/shared'
|
||||
import { useNavItems, splitMobileNav } from '../../components/Layout/navItems'
|
||||
import { useDockNavItems, splitMobileNav, ASSISTANT_NAV_ID, type NavItemDef } from '../../components/Layout/navItems'
|
||||
import { isNavItemActive, useCreateAction } from '../../components/Layout/useCreateAction'
|
||||
import MFab from './MFab'
|
||||
import { useAssistantHomeLauncher } from '../../components/Assistant/useAssistantHome'
|
||||
import MDockScrim from './MDockScrim'
|
||||
|
||||
interface NavItem { to: string; label: string; icon: LucideIcon }
|
||||
|
||||
/**
|
||||
* Floating glass dock of the mobile shell. Same tab/gating/"+" logic as the
|
||||
* legacy BottomNav (addons, ?create= contract), redesigned as the demo's icon
|
||||
@@ -28,7 +25,8 @@ export default function MBottomNav() {
|
||||
// the dock yields the slot instead (see screenFabSlot below, #1811).
|
||||
const create = useCreateAction(true)
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
// The home chat leads the More list on every page outside a trip, as the assistant tile leads a trip's.
|
||||
// The home chat, an entry of the dock like the pages (useDockNavItems): sorted
|
||||
// into the bar or under More in the settings, there only while it can run.
|
||||
const assistant = useAssistantHomeLauncher()
|
||||
|
||||
// Close the popover when the route changes underneath it (browser back etc.).
|
||||
@@ -37,20 +35,21 @@ export default function MBottomNav() {
|
||||
// The dock split + order is user-configurable (Settings → Appearance → Mobile);
|
||||
// an un-customised account falls back to the built-in Dashboard + vacay/atlas
|
||||
// dock with everything else under "More".
|
||||
const navItems = useNavItems()
|
||||
const navItems = useDockNavItems()
|
||||
const appearance = useSettingsStore(s => s.settings.appearance)
|
||||
const split = splitMobileNav(navItems, normalizeAppearance(appearance).mobileNav)
|
||||
|
||||
const dockItems: NavItem[] = split.bar
|
||||
const moreItems: NavItem[] = split.more
|
||||
const dockItems = split.bar
|
||||
const moreItems = split.more
|
||||
|
||||
const isActive = (to: string) => isNavItemActive(location.pathname, to)
|
||||
const moreActive = moreItems.some(item => isActive(item.to))
|
||||
const moreRows = [
|
||||
...(assistant.available ? [{ key: 'assistant', label: t('assistant.launcher.tooltip'), icon: Sparkles, go: assistant.open }] : []),
|
||||
...moreItems.map(({ to, label, icon }) => ({ key: to, label, icon, go: () => navigate(to) })),
|
||||
]
|
||||
const hasMore = moreRows.length > 0
|
||||
const isAssistant = (item: NavItemDef) => item.id === ASSISTANT_NAV_ID
|
||||
const isActive = (item: NavItemDef) => !isAssistant(item) && isNavItemActive(location.pathname, item.to)
|
||||
const open = (item: NavItemDef): void => {
|
||||
if (isAssistant(item)) assistant.open()
|
||||
else void navigate(item.to)
|
||||
}
|
||||
const moreActive = moreItems.some(isActive)
|
||||
const hasMore = moreItems.length > 0
|
||||
|
||||
// The FAB gives way to a decorative logo slot on screens without an add
|
||||
// action (settings/admin, demo Z. 1372/1429).
|
||||
@@ -82,16 +81,17 @@ export default function MBottomNav() {
|
||||
// A map runs to the screen's edge on purpose; everything else fades out under the dock.
|
||||
const overMap = location.pathname.startsWith('/atlas') || matchPath('/journey/:id', location.pathname) !== null
|
||||
|
||||
const renderItem = ({ to, label, icon: Icon }: NavItem) => {
|
||||
const active = isActive(to)
|
||||
const renderItem = (item: NavItemDef) => {
|
||||
const { id, label, icon: Icon } = item
|
||||
const active = isActive(item)
|
||||
// Fixed sizes per slot (demo): the dashboard grid is 18/2.1, every other
|
||||
// slot 21/1.9 — independent of the active state.
|
||||
const dash = to === '/dashboard'
|
||||
const dash = id === 'dashboard'
|
||||
return (
|
||||
<button
|
||||
key={to}
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => navigate(to)}
|
||||
onClick={() => open(item)}
|
||||
aria-label={label}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={circleCls(active)}
|
||||
@@ -111,17 +111,17 @@ export default function MBottomNav() {
|
||||
role="presentation"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{moreRows.map(({ key, label, icon: Icon, go }) => (
|
||||
{moreItems.map((item) => (
|
||||
<button
|
||||
key={key}
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => { setMoreOpen(false); go() }}
|
||||
onClick={() => { setMoreOpen(false); open(item) }}
|
||||
className="flex items-center gap-[13px] rounded-[18px] bg-[color:var(--m-ic)] px-4 py-[14px] text-start"
|
||||
>
|
||||
<span className="flex h-10 w-10 flex-none items-center justify-center rounded-xl bg-[color:var(--m-ic)] text-m-ink">
|
||||
<Icon size={20} strokeWidth={2} />
|
||||
<item.icon size={20} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="block min-w-0 flex-1 truncate text-[0.9375rem] font-extrabold text-m-ink">{label}</span>
|
||||
<span className="block min-w-0 flex-1 truncate text-[0.9375rem] font-extrabold text-m-ink">{item.label}</span>
|
||||
<ChevronRight size={17} strokeWidth={2} className="flex-none text-m-faint" />
|
||||
</button>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user