mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-28 09:08:54 -04:00
feat(ui): label the pin control on app tiles and in the detail view
The pin was a small icon-only button and hard to find. Each app tile now shows a button reading Pin, or Pinned with a filled pin, and the detail view has the same control in its header. Both toggle the same stored pin. The Apps grid lists pinned apps first, in pin order, then the others. The sidebar list and the server pins API are unchanged. Assisted-by: Claude Code:claude-sonnet-5-5 node playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
7 files changed
+79
-17
No files matched your search
@@ -92,7 +92,10 @@ test.describe('Add-on navigation', () => {
|
||||
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')
|
||||
for (const toggle of await page.getByTestId('pin-toggle').all()) {
|
||||
await expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
||||
await expect(toggle).toHaveText('Pinned')
|
||||
}
|
||||
await page.locator('[data-testid="addon-tile"][data-app="studio"]').getByTestId('pin-later').click()
|
||||
await expect.poll(() => server.saved()).toEqual(['talk', 'studio'])
|
||||
})
|
||||
@@ -106,12 +109,39 @@ test.describe('Add-on navigation', () => {
|
||||
const toggle = page.getByTestId('pin-toggle').first()
|
||||
await expect(toggle).toHaveAttribute('aria-pressed', 'false')
|
||||
await expect(toggle).toHaveAccessibleName(/Pin Notes/)
|
||||
await expect(toggle).toHaveText('Pin')
|
||||
await toggle.focus()
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
||||
await expect(toggle).toHaveText('Pinned')
|
||||
expect(server.saved()).toEqual(['notes'])
|
||||
})
|
||||
|
||||
test('pinned apps come first in the grid, in pin order, and the detail view has the same pin control', async ({ page }) => {
|
||||
const server = await setup(page, {
|
||||
pins: ['notes'],
|
||||
apps: [app({ id: 'notes', title: 'Notes', state: 'running', mainPage: '/app/addons/notes/home' }), app({ id: 'zeta', title: 'Zeta', enabled: false, state: 'stopped' })],
|
||||
})
|
||||
await page.setViewportSize({ width: 1280, height: 800 })
|
||||
await page.goto('/app/addons')
|
||||
const tiles = page.getByTestId('addon-tile')
|
||||
await expect(tiles.first()).toHaveAttribute('data-app', 'notes')
|
||||
await expect(tiles.first().getByTestId('pin-toggle')).toHaveText('Pinned')
|
||||
await expect(tiles.nth(1).getByTestId('pin-toggle')).toHaveText('Pin')
|
||||
// The tile pin and the detail pin are one state.
|
||||
await page.goto('/app/addons?detail=zeta')
|
||||
const detail = page.getByTestId('addon-detail')
|
||||
const head = detail.getByTestId('pin-toggle')
|
||||
await expect(head).toHaveText('Pin')
|
||||
await head.click()
|
||||
await expect(head).toHaveText('Pinned')
|
||||
await expect(head).toHaveAttribute('aria-pressed', 'true')
|
||||
await expect.poll(() => server.saved()).toEqual(['notes', 'zeta'])
|
||||
await head.click()
|
||||
await expect(head).toHaveText('Pin')
|
||||
await expect.poll(() => 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')
|
||||
|
||||
@@ -104,6 +104,8 @@
|
||||
"moveEarlier": "Move {{name}} earlier",
|
||||
"moveLater": "Move {{name}} later",
|
||||
"pin": "Pin {{name}}",
|
||||
"pinLabel": "Pin",
|
||||
"pinned": "Pinned",
|
||||
"running": "Running",
|
||||
"search": "Search add-ons",
|
||||
"status": {
|
||||
|
||||
@@ -8,10 +8,11 @@ import { pushLayer } from '../utils/modalLayers'
|
||||
// click on the veil to close. The body scrolls; the footer holds the actions.
|
||||
//
|
||||
// title, description the head
|
||||
// headActions controls drawn in the head, before the close button
|
||||
// footer buttons for the foot (primary action last)
|
||||
// wide the 720 px width
|
||||
// closeLabel accessible name of the close button
|
||||
export default function SideSheet({ title, description, footer, onClose, wide = false, closeLabel = 'Close', testId, labelId = 'side-sheet-title', children }) {
|
||||
export default function SideSheet({ title, description, footer, headActions, onClose, wide = false, closeLabel = 'Close', testId, labelId = 'side-sheet-title', children }) {
|
||||
const sheetRef = useRef(null)
|
||||
const closeRef = useRef(onClose)
|
||||
closeRef.current = onClose
|
||||
@@ -59,6 +60,7 @@ export default function SideSheet({ title, description, footer, onClose, wide =
|
||||
<h2 className="dk-sheet-title" id={labelId}>{title}</h2>
|
||||
{description && <p className="dk-sheet-desc">{description}</p>}
|
||||
</div>
|
||||
{headActions}
|
||||
<button type="button" className="dk-btn dk-btn--ghost dk-btn--icon dk-btn--sm" aria-label={closeLabel} onClick={onClose}><Icon name="close" /></button>
|
||||
</div>
|
||||
<div className="dk-sheet-body">{children}</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import Icon from '../Icon'
|
||||
import { apiUrl } from '../../utils/basePath'
|
||||
import { AppIcon } from './AddonBits'
|
||||
import PinControl from './PinControl'
|
||||
|
||||
// One tile of the drawer: icon, name, state and the pin control. A running
|
||||
// tile opens the add-on; every other tile opens the detail view. The pin
|
||||
@@ -36,16 +37,7 @@ export default function AddonTile({ tile, pinned, onPin, onMove, onOpenDetail })
|
||||
<button type="button" className="ad-tile__move" data-testid="pin-later" aria-label={t('drawer.moveLater', { name: title })} onClick={() => onMove(tile.id, 1)}><Icon name="chevron-down" aria-hidden="true" /></button>
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="ad-tile__pin"
|
||||
data-testid="pin-toggle"
|
||||
aria-pressed={pinned}
|
||||
aria-label={pinned ? t('drawer.unpin', { name: title }) : t('drawer.pin', { name: title })}
|
||||
onClick={() => onPin(tile.id)}
|
||||
>
|
||||
<Icon name="star" aria-hidden="true" />
|
||||
</button>
|
||||
<PinControl name={title} pinned={pinned} onToggle={() => onPin(tile.id)} />
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import SideSheet from '../SideSheet'
|
||||
import AddonSlot from '../../addons/AddonSlot'
|
||||
import { useHubAuth } from '../hub/useHubAuth'
|
||||
import AddonTile from './AddonTile'
|
||||
import PinControl from './PinControl'
|
||||
import { useNavigation } from '../../context/NavigationContext'
|
||||
import { buildTiles, filterTiles, resolvePins } from '../../utils/addonTiles'
|
||||
import { apiUrl } from '../../utils/basePath'
|
||||
@@ -107,8 +108,14 @@ export default function AppsView({ api }) {
|
||||
|
||||
const tiles = useMemo(() => buildTiles(state.apps, nav.builtin, { language: i18n.language }), [state.apps, nav.builtin, i18n.language])
|
||||
const titleOf = useCallback((tile) => (tile.titleKey ? tn(tile.titleKey) : tile.title), [tn])
|
||||
const shown = useMemo(() => filterTiles(tiles, query, titleOf), [tiles, query, titleOf])
|
||||
const pinnedIds = useMemo(() => new Set(resolvePins(nav.pinIds, tiles).map(x => x.id)), [nav.pinIds, tiles])
|
||||
const pinOrder = useMemo(() => resolvePins(nav.pinIds, tiles).map(x => x.id), [nav.pinIds, tiles])
|
||||
const pinnedIds = useMemo(() => new Set(pinOrder), [pinOrder])
|
||||
// Pinned apps come first, in pin order; the rest keep the catalog order.
|
||||
const shown = useMemo(() => {
|
||||
const found = filterTiles(tiles, query, titleOf)
|
||||
const rank = new Map(pinOrder.map((id, i) => [id, i]))
|
||||
return [...found.filter(x => rank.has(x.id)).sort((a, b) => rank.get(a.id) - rank.get(b.id)), ...found.filter(x => !rank.has(x.id))]
|
||||
}, [tiles, query, titleOf, pinOrder])
|
||||
const detail = tiles.find(x => x.id === detailId) || null
|
||||
|
||||
const onPin = async (id) => {
|
||||
@@ -182,7 +189,7 @@ export default function AppsView({ api }) {
|
||||
)}
|
||||
{!legacy && <div className="ad-nav-slot"><AddonSlot slot="app.nav" context={null} /></div>}
|
||||
{detail && (
|
||||
<SideSheet title={titleOf(detail)} onClose={closeDetail} closeLabel={t('drawer.close')} testId="addon-detail">
|
||||
<SideSheet title={titleOf(detail)} onClose={closeDetail} headActions={<PinControl variant="head" name={titleOf(detail)} pinned={pinnedIds.has(detail.id)} onToggle={() => onPin(detail.id)} />} closeLabel={t('drawer.close')} testId="addon-detail">
|
||||
{detail.app
|
||||
? (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
// The pin control of an add-on, on its tile and in its detail view: a button
|
||||
// with a visible word and a pin that is filled when the add-on is pinned. The
|
||||
// accessible name keeps the add-on's name so a list of them stays unambiguous.
|
||||
export default function PinControl({ name, pinned, onToggle, variant = 'tile' }) {
|
||||
const { t } = useTranslation('addons')
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={variant === 'tile' ? 'ad-tile__pin' : 'dk-btn dk-btn--sm ad-pin'}
|
||||
data-testid="pin-toggle"
|
||||
data-pinned={pinned ? 'true' : 'false'}
|
||||
aria-pressed={pinned}
|
||||
aria-label={pinned ? t('drawer.unpin', { name }) : t('drawer.pin', { name })}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<svg className="ad-pin__icon lai-icon" viewBox="0 0 24 24" focusable="false" aria-hidden="true"
|
||||
fill={pinned ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 3h6l-1 6 3 3v2H7v-2l3-3zM12 14v7" />
|
||||
</svg>
|
||||
<span className="ad-pin__label">{pinned ? t('drawer.pinned') : t('drawer.pinLabel')}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -213,8 +213,12 @@
|
||||
.ad-tile__order { flex: none; display: flex; flex-direction: column; justify-content: center; }
|
||||
.ad-tile__move { background: none; border: 0; padding: 0.125rem 0.5rem; color: var(--dk-text-muted, var(--dk-text)); cursor: pointer; }
|
||||
.ad-tile__move:focus-visible { outline: 2px solid var(--dk-accent); outline-offset: -2px; }
|
||||
.ad-tile__pin { flex: none; width: 2.75rem; background: none; border: 0; border-inline-start: 1px solid var(--dk-edge); color: var(--dk-text-muted, var(--dk-text)); cursor: pointer; }
|
||||
.ad-tile__pin[aria-pressed='true'] { color: var(--dk-accent); }
|
||||
.ad-tile__pin { flex: none; width: 4.25rem; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.25rem; padding: 0 0.25rem; font: inherit; font-size: 0.75rem; background: none; border: 0; border-inline-start: 1px solid var(--dk-edge); color: var(--dk-text-muted, var(--dk-text)); cursor: pointer; }
|
||||
.ad-tile__pin:hover { background: var(--dk-hover); }
|
||||
.ad-pin__icon { width: 1.125rem; height: 1.125rem; flex: none; }
|
||||
.ad-pin__label { white-space: nowrap; }
|
||||
.ad-tile__pin[aria-pressed='true'], .ad-pin[aria-pressed='true'] { color: var(--dk-accent); }
|
||||
.ad-pin { gap: 0.375rem; }
|
||||
.ad-tile__open:focus-visible, .ad-tile__pin:focus-visible { outline: 2px solid var(--dk-accent); outline-offset: -2px; }
|
||||
.nav-item--muted { opacity: 0.55; }
|
||||
.nav-item--sub { padding-inline-start: 2.25rem; }
|
||||
|
||||
Reference in new issue
Block a user