mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-28 09:08:54 -04:00
feat(ui): show a first-run card on Apps when nothing is installed
An administrator who opens Apps with no add-on installed now sees a short card with three steps: choose a library, browse and install, start the add-on. Its button opens the Browse tab. The card is hidden once an add-on is installed and for anyone who cannot install. Assisted-by: Claude Code:claude-sonnet-5-5 node playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
6 files changed
+90
-4
No files matched your search
@@ -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')
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }) {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{shown.length === 0 ? (
|
||||
{showFirstRun({ apps: state.apps, isAdmin, canList }) ? (
|
||||
<section className="ad-card ad-first" data-testid="addons-first-run" aria-labelledby="ad-first-title">
|
||||
<h2 className="ad-card__title" id="ad-first-title">{t('apps.firstRun.title')}</h2>
|
||||
<ol className="ad-first__steps">
|
||||
<li><strong>{t('apps.firstRun.libraryTitle')}</strong> {t('apps.firstRun.libraryBody')}</li>
|
||||
<li><strong>{t('apps.firstRun.browseTitle')}</strong> {t('apps.firstRun.browseBody')}</li>
|
||||
<li><strong>{t('apps.firstRun.startTitle')}</strong> {t('apps.firstRun.startBody')}</li>
|
||||
</ol>
|
||||
<div className="ad-section__acts">
|
||||
<Link className="dk-btn dk-btn--primary" to="/app/addons?tab=browse" data-testid="addons-first-run-browse"><Icon name="store" /> {t('apps.firstRun.browse')}</Link>
|
||||
</div>
|
||||
</section>
|
||||
) : shown.length === 0 ? (
|
||||
<CatalogEmpty icon="puzzle" title={t('apps.emptyTitle')} text={t('apps.emptyBody')} />
|
||||
) : (
|
||||
<ul className="ad-tiles" aria-label={t('apps.listLabel')}>
|
||||
|
||||
@@ -15,6 +15,12 @@ export function visibleTabs(isAdmin) {
|
||||
return TABS.filter(tab => isAdmin || !ADMIN_TABS.has(tab))
|
||||
}
|
||||
|
||||
// The first-run card: shown to someone who can install add-ons, once the list
|
||||
// has loaded and nothing is installed. It goes away with the first install.
|
||||
export function showFirstRun({ apps, isAdmin, canList = true }) {
|
||||
return Boolean(isAdmin) && Boolean(canList) && Array.isArray(apps) && apps.length === 0
|
||||
}
|
||||
|
||||
// The management link of an app opens the Installed tab on that add-on.
|
||||
export function resolveManage(params, isAdmin) {
|
||||
if (!isAdmin) return ''
|
||||
|
||||
@@ -61,6 +61,8 @@
|
||||
border-radius: var(--dk-radius-3);
|
||||
background: var(--dk-card);
|
||||
}
|
||||
.ad-first__steps { margin: 0; padding-left: 22px; display: flex; flex-direction: column; gap: var(--dk-space-2); font-size: var(--dk-text-13); line-height: 1.5; }
|
||||
.ad-first__steps li::marker { color: var(--dk-muted); font-family: var(--font-mono); }
|
||||
.ad-card__head { display: flex; align-items: flex-start; gap: var(--dk-space-3); min-width: 0; }
|
||||
.ad-card__chips { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--dk-space-1); }
|
||||
.ad-reasons { margin: var(--dk-space-1) 0 0; padding: 0; list-style: none; }
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
PIN_CAP, sidebarPinLinks, tileFromApp, tileFromBuiltin, buildTiles, filterTiles, resolvePins, togglePin, movePin,
|
||||
} from '../../src/utils/addonTiles.js'
|
||||
import { primaryDestinations, visiblePrimary } from '../../src/utils/navigationModel.js'
|
||||
import { showFirstRun } from '../../src/components/addons/addonState.js'
|
||||
import { createPinsApi } from '../../src/utils/pinsApi.js'
|
||||
|
||||
const app = (over = {}) => ({
|
||||
@@ -143,3 +144,10 @@ test('sidebarPinLinks: a stopped app opens its detail view and is marked stopped
|
||||
assert.equal(link.to, '/app/addons?detail=notes')
|
||||
assert.equal(link.stopped, true)
|
||||
})
|
||||
|
||||
test('the first-run card shows only to an installer when nothing is installed', () => {
|
||||
assert.equal(showFirstRun({ apps: [], isAdmin: true }), true)
|
||||
assert.equal(showFirstRun({ apps: [app()], isAdmin: true }), false)
|
||||
assert.equal(showFirstRun({ apps: [], isAdmin: false }), false)
|
||||
assert.equal(showFirstRun({ apps: [], isAdmin: true, canList: false }), false)
|
||||
})
|
||||
Reference in new issue
Block a user