diff --git a/core/http/react-ui/e2e/addon-navigation.spec.js b/core/http/react-ui/e2e/addon-navigation.spec.js index 6454c6135..122c9405c 100644 --- a/core/http/react-ui/e2e/addon-navigation.spec.js +++ b/core/http/react-ui/e2e/addon-navigation.spec.js @@ -9,9 +9,9 @@ import { FACE_MODEL, clipFile, faces, match, mockIdentity, people, photoFile } f // Studio and Talk are packaged add-ons, not built-in tiles: the specs install // the real packages. No page is built in today, so `builtin` is empty unless a // spec makes one up to check the built-in rules. -async function setup(page, { apps = [], pins = ['studio', 'talk'], addons = true, builtin = [], studio = true } = {}) { +async function setup(page, { apps = [], pins = ['studio', 'talk'], addons = true, builtin = [], studio = true, user = MEMBER } = {}) { let saved = pins - await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(MEMBER, { addons }) })) + await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(user, { addons }) })) await page.route('**/api/features', route => route.fulfill({ json: { addon_navigation: true } })) await page.route('**/api/addons/builtin', route => route.fulfill({ json: { builtin } })) // mockAddons installs the Apps routes with the real response shape ({ apps }). @@ -64,6 +64,27 @@ test.describe('Add-on navigation', () => { await expect(nav.getByTestId('apps-pinned-link')).toHaveCount(0) }) + test('the icon rail shows Apps only, centred, with no pinned app', async ({ page }) => { + await setup(page) + for (const size of [{ width: 1280, height: 800, collapsed: true }, { width: 1000, height: 800, collapsed: false }]) { + await page.setViewportSize({ width: size.width, height: size.height }) + await page.addInitScript(collapsed => { + try { localStorage.setItem('localai_sidebar_collapsed', String(collapsed)) } catch (_) { /* ignore */ } + }, size.collapsed) + await page.goto('/app') + const nav = page.getByRole('navigation') + await expect(nav.locator('a.nav-item[href="/app/addons"]')).toBeVisible() + await expect(nav.getByTestId('apps-pinned-link').filter({ visible: true })).toHaveCount(0) + const rail = await page.locator('.sidebar').boundingBox() + const icons = await nav.locator('a.nav-item:visible .nav-icon').evaluateAll(els => els.map(e => { + const r = e.getBoundingClientRect() + return (r.left + r.right) / 2 + })) + expect(icons.length).toBeGreaterThan(0) + for (const x of icons) expect(Math.abs(x - (rail.x + rail.width / 2))).toBeLessThan(2) + } + }) + 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') @@ -91,6 +112,33 @@ test.describe('Add-on navigation', () => { expect(server.saved()).toEqual(['notes']) }) + test('the first-run card shows to an administrator with nothing installed and leads to Browse', async ({ page }) => { + await setup(page, { user: ADMIN, studio: false, pins: [] }) + await page.goto('/app/addons') + const card = page.getByTestId('addons-first-run') + await expect(card).toBeVisible() + await expect(card.getByRole('listitem')).toHaveCount(3) + await expect(card).toContainText('official library is registered by default') + await expect(page.getByTestId('addon-tile')).toHaveCount(0) + await card.getByTestId('addons-first-run-browse').click() + await expect(page).toHaveURL(/tab=browse/) + await expect(page.getByTestId('addons-first-run')).toHaveCount(0) + }) + + test('the first-run card is hidden once an add-on is installed', async ({ page }) => { + await setup(page, { user: ADMIN, pins: [] }) + await page.goto('/app/addons') + await expect(page.getByTestId('addon-tile').first()).toBeVisible() + await expect(page.getByTestId('addons-first-run')).toHaveCount(0) + }) + + test('the first-run card is hidden from a caller who cannot install add-ons', async ({ page }) => { + await setup(page, { studio: false, pins: [] }) + await page.goto('/app/addons') + await expect(page.getByRole('heading', { name: 'No apps yet' })).toBeVisible() + await expect(page.getByTestId('addons-first-run')).toHaveCount(0) + }) + test('a stopped tile opens the detail view and never the main page', async ({ page }) => { await setup(page, { apps: [app({ id: 'notes', title: 'Notes', enabled: false, state: 'stopped', mainPage: undefined })] }) await page.goto('/app/addons') diff --git a/core/http/react-ui/public/locales/en/addons.json b/core/http/react-ui/public/locales/en/addons.json index 3201f05ec..85ee3fab9 100644 --- a/core/http/react-ui/public/locales/en/addons.json +++ b/core/http/react-ui/public/locales/en/addons.json @@ -35,6 +35,16 @@ "apps": { "emptyBody": "When an administrator installs and enables an add-on, it appears here.", "emptyTitle": "No apps yet", + "firstRun": { + "browse": "Browse add-ons", + "browseBody": "Pick an add-on from a library and install it.", + "browseTitle": "Browse and install.", + "libraryBody": "The official library is registered by default. You can add more under Libraries.", + "libraryTitle": "Choose a library.", + "startBody": "Start an installed add-on from its tile. Add-ons that were running start again with the server.", + "startTitle": "Start it.", + "title": "Welcome to Apps" + }, "headless": "Runs in the background and has no page of its own.", "listLabel": "Apps", "loading": "Loading apps", diff --git a/core/http/react-ui/src/components/addons/AppsView.jsx b/core/http/react-ui/src/components/addons/AppsView.jsx index f8bd5c793..391fa3e9f 100644 --- a/core/http/react-ui/src/components/addons/AppsView.jsx +++ b/core/http/react-ui/src/components/addons/AppsView.jsx @@ -18,7 +18,7 @@ import { useNavigation } from '../../context/NavigationContext' import { buildTiles, filterTiles, resolvePins } from '../../utils/addonTiles' import { apiUrl } from '../../utils/basePath' import { StateChip, AppIcon, UnknownNotice, UpdateChip } from './AddonBits' -import { appActions, localizedText, toolSummary } from './addonState' +import { appActions, localizedText, showFirstRun, toolSummary } from './addonState' import { describeError, describeOutcome } from './errors' import { useAddonActions } from './useAddonActions' @@ -159,7 +159,19 @@ export default function AppsView({ api }) { )} - {shown.length === 0 ? ( + {showFirstRun({ apps: state.apps, isAdmin, canList }) ? ( +
+

{t('apps.firstRun.title')}

+
    +
  1. {t('apps.firstRun.libraryTitle')} {t('apps.firstRun.libraryBody')}
  2. +
  3. {t('apps.firstRun.browseTitle')} {t('apps.firstRun.browseBody')}
  4. +
  5. {t('apps.firstRun.startTitle')} {t('apps.firstRun.startBody')}
  6. +
+
+ {t('apps.firstRun.browse')} +
+
+ ) : shown.length === 0 ? ( ) : (