diff --git a/core/http/react-ui/e2e/addon-navigation.spec.js b/core/http/react-ui/e2e/addon-navigation.spec.js index 9eda5b19b..1ccf91734 100644 --- a/core/http/react-ui/e2e/addon-navigation.spec.js +++ b/core/http/react-ui/e2e/addon-navigation.spec.js @@ -26,21 +26,34 @@ async function setup(page, { apps = [], pins = ['studio', 'talk'], addons = true } test.describe('Add-on navigation', () => { - test('the sidebar shows Chat, Add-ons and the Pinned section', async ({ page }) => { + test('the sidebar shows Chat and one Apps entry, with no Pinned section', async ({ page }) => { await setup(page) await page.goto('/app') const nav = page.getByRole('navigation') await expect(nav.getByRole('link', { name: 'Chat' })).toBeVisible() - await expect(nav.getByRole('link', { name: 'Add-ons' })).toBeVisible() - const pinned = page.getByTestId('pinned-nav') - await expect(pinned.getByRole('link', { name: 'Studio' })).toBeVisible() - await expect(pinned.getByRole('link', { name: 'Talk' })).toBeVisible() + // Apps, then Show all: both open the drawer, and Show all comes last in the group. + const apps = nav.locator('a.nav-item[href="/app/addons"]') + await expect(apps).toHaveCount(2) + await expect(apps.nth(0)).toHaveText('Apps') + await expect(apps.nth(1)).toHaveText('Show all') + await expect(nav.getByRole('link', { name: 'Apps' })).toHaveCount(1) + await expect(page.getByTestId('pinned-nav')).toHaveCount(0) + await expect(page.locator('.sidebar-section-title', { hasText: 'Pinned' })).toHaveCount(0) + // Pinned add-ons are not links in the sidebar. + await expect(nav.getByRole('link', { name: 'Studio' })).toHaveCount(0) + await expect(nav.getByRole('link', { name: 'Talk' })).toHaveCount(0) }) - test('an empty Pinned section is hidden', async ({ page }) => { - await setup(page, { pins: [] }) + test('the Apps entry opens the drawer, where pinned add-ons are shown and their order can be changed', async ({ page }) => { + const server = await setup(page, { pins: ['studio', 'talk'] }) await page.goto('/app') - await expect(page.getByTestId('pinned-nav')).toHaveCount(0) + await page.getByRole('navigation').getByRole('link', { name: 'Apps' }).click() + await expect(page).toHaveURL(/\/app\/addons$/) + await expect(page.getByTestId('addon-tile')).toHaveCount(2) + await expect(page.getByTestId('pin-toggle')).toHaveCount(2) + for (const toggle of await page.getByTestId('pin-toggle').all()) await expect(toggle).toHaveAttribute('aria-pressed', 'true') + await page.locator('[data-testid="addon-tile"][data-app="studio"]').getByTestId('pin-later').click() + await expect.poll(() => server.saved()).toEqual(['talk', 'studio']) }) test('the drawer lists tiles, searches them and pins from the keyboard', async ({ page }) => { @@ -117,14 +130,14 @@ test.describe('Add-on navigation', () => { await setup(page, { addons: false }) await page.goto('/app/addons') await expect(page).toHaveURL(/\/app$/) - await expect(page.getByRole('navigation').getByRole('link', { name: 'Add-ons' })).toHaveCount(0) + await expect(page.getByRole('navigation').locator('a.nav-item[href="/app/addons"]')).toHaveCount(0) }) test('the legacy flag brings the old sidebar back', async ({ page }) => { await setup(page) await page.route('**/api/features', route => route.fulfill({ json: { addon_navigation: false } })) await page.goto('/app') - await expect(page.getByTestId('pinned-nav')).toHaveCount(0) + await expect(page.getByTestId('apps-show-all')).toHaveCount(0) // Home, Models and Chat head the old sidebar; Studio and Talk are add-ons and are not in it. await expect(page.getByRole('navigation').getByRole('link', { name: 'Chat' })).toBeVisible() await expect(page.getByRole('navigation').getByRole('link', { name: 'Studio' })).toHaveCount(0) @@ -158,7 +171,7 @@ test.describe('Add-on navigation', () => { await expect(toggle).toHaveAccessibleName(/(Pin|Unpin) /) }) - test('a pin of an add-on that is no longer listed is not drawn and shows no error', async ({ page }) => { + test('a pin of an add-on that is no longer listed is not drawn in the drawer and shows no error', async ({ page }) => { await setup(page, { pins: ['studio', 'removed-addon'] }) // The Home page raises its own alert when the model list or a staging // operation fails. Both come from the shared test server, so a slow answer @@ -173,22 +186,21 @@ test.describe('Add-on navigation', () => { await route.fulfill({ json: { apps: [packagedApp('studio')] } }) }) const listed = page.waitForResponse(res => /\/api\/addons\/apps$/.test(res.url())) - await page.goto('/app') + await page.goto('/app/addons') await listed - // Two frames: the list reaches React and the sidebar redraws from it. + // Two frames: the list reaches React and the drawer redraws from it. await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))) - const pinned = page.getByTestId('pinned-nav') - await expect(pinned.getByRole('link')).toHaveCount(1) + await expect(page.getByTestId('addon-tile')).toHaveCount(1) await expect(page.getByRole('alert')).toHaveCount(0) }) - test('a stopped pinned add-on is muted and opens the detail view', async ({ page }) => { + test('a stopped pinned add-on in the drawer opens the detail view', async ({ page }) => { await setup(page, { pins: ['notes'], apps: [app({ id: 'notes', title: 'Notes', enabled: false, state: 'stopped' })] }) await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor({ ...MEMBER, role: 'admin' }, { addons: true }) })) - await page.goto('/app') - const link = page.getByTestId('pinned-nav').getByRole('link', { name: /Notes/ }) - await expect(link).toHaveClass(/nav-item--muted/) - await link.click() + await page.goto('/app/addons') + const tile = page.locator('[data-testid="addon-tile"][data-app="notes"]') + await expect(tile).toHaveAttribute('data-state', 'stopped') + await tile.locator('.ad-tile__open').click() await expect(page).toHaveURL(/\/app\/addons\?detail=notes$/) await expect(page.getByTestId('addon-detail')).toBeVisible() }) diff --git a/core/http/react-ui/e2e/navigation.spec.js b/core/http/react-ui/e2e/navigation.spec.js index b7a707c8a..68752fc9d 100644 --- a/core/http/react-ui/e2e/navigation.spec.js +++ b/core/http/react-ui/e2e/navigation.spec.js @@ -20,11 +20,11 @@ test.describe('Navigation', () => { await expect(models.locator('.nav-label')).toHaveText('Models') }) - test('the sidebar has Chat, Models and Add-ons, no Settings and no Create tier', async ({ page }) => { + test('the sidebar has Chat, Models and Apps, no Settings and no Create tier', async ({ page }) => { await page.goto('/app') await expect(page.locator('.sidebar-section-title', { hasText: 'Create' })).toHaveCount(0) for (const href of ['/app/chat', '/app/models', '/app/addons']) { - await expect(page.locator(`.sidebar-nav a.nav-item[href="${href}"]`)).toBeVisible() + await expect(page.locator(`.sidebar-nav a.nav-item[href="${href}"]`).first()).toBeVisible() } // Settings lives under the Operate hub, not in the sidebar. await expect(page.locator('.sidebar-nav a.nav-item[href="/app/settings"]')).toHaveCount(0) @@ -35,7 +35,7 @@ test.describe('Navigation', () => { await settingsTab.click() await expect(page).toHaveURL(/\/app\/settings/) await expect(page.locator('.dk-hubtabs [data-hub-tab="settings"]')).toHaveAttribute('aria-current', 'page') - // Studio and Talk are add-ons: the sidebar lists them only as pins, and only while they are installed. + // Studio and Talk are add-ons: the sidebar does not list them; they are in the drawer while installed. for (const href of ['/app/studio', '/app/talk']) { await expect(page.locator(`.sidebar-nav a.nav-item[href="${href}"]`)).toHaveCount(0) } diff --git a/core/http/react-ui/e2e/not-found.spec.js b/core/http/react-ui/e2e/not-found.spec.js index 2ee6f85a6..6c3c7cbb0 100644 --- a/core/http/react-ui/e2e/not-found.spec.js +++ b/core/http/react-ui/e2e/not-found.spec.js @@ -9,7 +9,7 @@ test.describe('404', () => { await expect(nf).toContainText('/app/no-such-page') await expect(nf.getByRole('link', { name: 'Go home' })).toHaveAttribute('href', '/app') const places = nf.getByRole('navigation', { name: 'Where to go' }) - for (const name of ['Home', 'Models', 'Chat', 'Add-ons', 'Build', 'Operate']) { + for (const name of ['Home', 'Models', 'Chat', 'Apps', 'Build', 'Operate']) { await expect(places.getByRole('link', { name })).toBeVisible() } // Settings is a tab of the Operate hub, so the places do not list it. diff --git a/core/http/react-ui/public/locales/en/nav.json b/core/http/react-ui/public/locales/en/nav.json index e57397aa0..c17375216 100644 --- a/core/http/react-ui/public/locales/en/nav.json +++ b/core/http/react-ui/public/locales/en/nav.json @@ -16,8 +16,7 @@ "recognition": "Recognition", "build": "Build", "operate": "Operate", - "workspace": "Workspace", - "pinned": "Pinned" + "workspace": "Workspace" }, "operate": { "inference": "Inference", @@ -67,7 +66,8 @@ "trafficModels": "Models", "gpuHost": "GPU and host", "prometheus": "Prometheus", - "addons": "Add-ons" + "addons": "Apps", + "showAll": "Show all" }, "footer": { "github": "GitHub", diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css index 980dd7492..502a504e1 100644 --- a/core/http/react-ui/src/App.css +++ b/core/http/react-ui/src/App.css @@ -9692,3 +9692,6 @@ button.lane:hover { @media (prefers-reduced-motion: reduce) { .lai-icon--beat { animation: none; } } + +/* The "Show all" line under the Apps entry: its label lines up with the labels above it. */ +.sidebar-nav .nav-item.nav-item--sub { padding-inline-start: 36px; } diff --git a/core/http/react-ui/src/components/PinnedNav.jsx b/core/http/react-ui/src/components/PinnedNav.jsx deleted file mode 100644 index 3bd4f80d3..000000000 --- a/core/http/react-ui/src/components/PinnedNav.jsx +++ /dev/null @@ -1,65 +0,0 @@ -/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */ -import { useEffect, useMemo } from 'react' -import { NavLink } from 'react-router-dom' -import { useTranslation } from 'react-i18next' -import Icon from './Icon' -import { useNavigation } from '../context/NavigationContext' -import { useAddonRegistry } from '../addons/useAddonRegistry' -import { navigationItems } from '../addons/registryState' -import { buildTiles, resolvePins } from '../utils/addonTiles' -import { apiUrl } from '../utils/basePath' - -// The Pinned section of the sidebar: the caller's pins in the chosen order. -// Each pin is resolved against the built-in catalog and the Apps list at -// render time, so a stopped add-on is muted, and a removed or hidden one is -// not drawn at all. An empty section is not drawn. -export default function PinnedNav({ onClose, collapsed }) { - const { t } = useTranslation('nav') - const { t: ta, i18n } = useTranslation('addons') - const { builtin, pinIds } = useNavigation() - const registry = useAddonRegistry() - const { acquire } = registry - const needsApps = pinIds.some(id => !builtin.some(b => b.id === id)) - // Resolving an add-on pin needs the Apps list once; the registry keeps it - // current while an add-on contributes UI. - useEffect(() => (needsApps ? acquire() : undefined), [needsApps, acquire]) - - const tiles = useMemo(() => buildTiles(registry.apps, builtin, { language: i18n.language }), [registry.apps, builtin, i18n.language]) - const pinned = useMemo(() => resolvePins(pinIds, tiles), [pinIds, tiles]) - const extra = useMemo(() => navigationItems(registry.apps, { language: i18n.language }), [registry.apps, i18n.language]) - if (pinned.length === 0) return null - - return ( -