@igstack/app-catalog-frontend-core 0.13.3 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/modules/appCatalog/context/AppCatalogContext.js +0 -1
- package/dist/esm/modules/appCatalog/context/AppCatalogContext.js.map +1 -1
- package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +1 -0
- package/dist/esm/modules/appCatalog/ui/components/Highlight.d.ts +8 -0
- package/dist/esm/modules/appCatalog/ui/components/Highlight.js +20 -0
- package/dist/esm/modules/appCatalog/ui/components/Highlight.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js +0 -1
- package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js +0 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js +0 -1
- package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js +33 -19
- package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/launcher/LauncherDetailPanel.js +22 -9
- package/dist/esm/modules/appCatalog/ui/launcher/LauncherDetailPanel.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.js +26 -9
- package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.js.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/integration/appCatalog.integration.test.ts +43 -0
- package/src/__tests__/integration/appDeepLink.integration.test.ts +13 -1
- package/src/__tests__/integration/appFreshness.integration.test.ts +15 -0
- package/src/modules/appCatalog/ui/components/Highlight.tsx +26 -0
- package/src/modules/appCatalog/ui/grid/AppCatalogGrid.tsx +40 -21
- package/src/modules/appCatalog/ui/launcher/LauncherDetailPanel.tsx +9 -0
- package/src/modules/appCatalog/ui/launcher/LauncherHome.tsx +26 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@igstack/app-catalog-frontend-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Frontend core library for App Catalog",
|
|
5
5
|
"homepage": "https://github.com/lislon/app-catalog",
|
|
6
6
|
"repository": {
|
|
@@ -134,8 +134,8 @@
|
|
|
134
134
|
"vite-plugin-static-copy": "^3.1.4",
|
|
135
135
|
"vite-plugin-svgr": "^4.2.0",
|
|
136
136
|
"vitest": "^4.1.2",
|
|
137
|
-
"@igstack/app-catalog-
|
|
138
|
-
"@igstack/app-catalog-
|
|
137
|
+
"@igstack/app-catalog-shared-core": "0.14.0",
|
|
138
|
+
"@igstack/app-catalog-backend-core": "0.14.0"
|
|
139
139
|
},
|
|
140
140
|
"peerDependencies": {
|
|
141
141
|
"react": "19.1.2",
|
|
@@ -144,7 +144,7 @@
|
|
|
144
144
|
"vite": "^6.3.5",
|
|
145
145
|
"vite-plugin-svgr": "^4.2.0"
|
|
146
146
|
},
|
|
147
|
-
"gitHead": "
|
|
147
|
+
"gitHead": "f7e30b3da50d80d10ed19ddd3be273329495bdcb",
|
|
148
148
|
"scripts": {
|
|
149
149
|
"build": "vite build",
|
|
150
150
|
"build:lenient": "vite build --mode lenient",
|
|
@@ -328,4 +328,47 @@ describe('App Catalog Integration', () => {
|
|
|
328
328
|
/(?<![\d])0(?![\d]).*clear filters/i,
|
|
329
329
|
)
|
|
330
330
|
})
|
|
331
|
+
|
|
332
|
+
it('highlights matched query text in search result app names (#57)', async () => {
|
|
333
|
+
const { ui } = await given(
|
|
334
|
+
magazine.custom(({ backendCfg }) => {
|
|
335
|
+
backendCfg.withApp({ displayName: 'Kubernetes Platform' })
|
|
336
|
+
backendCfg.withApp({ displayName: 'Other Tool' })
|
|
337
|
+
}),
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
await ui.catalog.search('kube')
|
|
341
|
+
|
|
342
|
+
await waitFor(() => {
|
|
343
|
+
const marks = document.querySelectorAll('mark')
|
|
344
|
+
const markTexts = [...marks].map((m) => String(m.textContent))
|
|
345
|
+
expect(markTexts.some((t) => t.toLowerCase().includes('kube'))).toBe(true)
|
|
346
|
+
})
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
it('clear (×) button appears when search has text and clears on click (#54)', async () => {
|
|
350
|
+
const { ui } = await given(magazine.full())
|
|
351
|
+
|
|
352
|
+
// Before typing: no clear button
|
|
353
|
+
expect(
|
|
354
|
+
document.querySelector('[aria-label="Clear search"]'),
|
|
355
|
+
).not.toBeInTheDocument()
|
|
356
|
+
|
|
357
|
+
// After typing: clear button appears
|
|
358
|
+
await ui.catalog.search('jira')
|
|
359
|
+
await waitFor(() => {
|
|
360
|
+
expect(
|
|
361
|
+
document.querySelector('[aria-label="Clear search"]'),
|
|
362
|
+
).toBeInTheDocument()
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
// Click × — input clears, button disappears
|
|
366
|
+
fireEvent.click(document.querySelector('[aria-label="Clear search"]')!)
|
|
367
|
+
await waitFor(() => {
|
|
368
|
+
expect(ui.catalog.getSearchInput().value).toBe('')
|
|
369
|
+
expect(
|
|
370
|
+
document.querySelector('[aria-label="Clear search"]'),
|
|
371
|
+
).not.toBeInTheDocument()
|
|
372
|
+
})
|
|
373
|
+
})
|
|
331
374
|
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { fireEvent, waitFor } from '@testing-library/react'
|
|
2
|
+
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
|
3
3
|
import '@testing-library/jest-dom/vitest'
|
|
4
4
|
|
|
5
5
|
import { given } from './harness/given'
|
|
@@ -45,6 +45,18 @@ describe('App deep-link routing', () => {
|
|
|
45
45
|
expect(router.state.location.pathname).toBe('/')
|
|
46
46
|
})
|
|
47
47
|
|
|
48
|
+
it('closes the detail panel when the × close button is clicked (#59)', async () => {
|
|
49
|
+
const { ui, router } = await given(magazine.full(), { initialRoute: '/' })
|
|
50
|
+
await ui.catalog.openApp('Jira')
|
|
51
|
+
await waitFor(() => expect(ui.catalog.isDetailPanelOpen()).toBe(true))
|
|
52
|
+
const closeBtn = screen.getByRole('button', { name: /^Close$/i })
|
|
53
|
+
fireEvent.click(closeBtn)
|
|
54
|
+
await waitFor(() => {
|
|
55
|
+
expect(ui.catalog.isDetailPanelOpen()).toBe(false)
|
|
56
|
+
})
|
|
57
|
+
expect(router.state.location.pathname).toBe('/')
|
|
58
|
+
})
|
|
59
|
+
|
|
48
60
|
it('ignores legacy ?app= query param (no detail opens)', async () => {
|
|
49
61
|
const { ui } = await given(magazine.full(), { initialRoute: '/?app=jira' })
|
|
50
62
|
await waitFor(() => {
|
|
@@ -45,6 +45,21 @@ describe('App freshness line', () => {
|
|
|
45
45
|
expect(screen.getByText(/may be out of date/i)).toBeInTheDocument()
|
|
46
46
|
})
|
|
47
47
|
|
|
48
|
+
it('shows "Added" date before Sources when createdAt is present (#55)', async () => {
|
|
49
|
+
const { ui } = await given(
|
|
50
|
+
magazine.full(({ backendCfg }) => {
|
|
51
|
+
backendCfg.withApp({
|
|
52
|
+
slug: 'dated-app',
|
|
53
|
+
displayName: 'Dated App',
|
|
54
|
+
createdAt: iso(30 * DAY),
|
|
55
|
+
})
|
|
56
|
+
}),
|
|
57
|
+
{ initialRoute: '/app/dated-app' },
|
|
58
|
+
)
|
|
59
|
+
await waitFor(() => expect(ui.catalog.isDetailPanelOpen()).toBe(true))
|
|
60
|
+
expect(screen.getByText(/Added/i)).toBeInTheDocument()
|
|
61
|
+
})
|
|
62
|
+
|
|
48
63
|
it('shows no freshness line when the app was never scanned', async () => {
|
|
49
64
|
const { ui } = await given(
|
|
50
65
|
magazine.full(({ backendCfg }) => {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { highlightText } from '../../utils/searchApps'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Highlights all case-insensitive occurrences of `query` in `text`.
|
|
5
|
+
* Renders plain text when query is empty or has no match.
|
|
6
|
+
*/
|
|
7
|
+
export function Highlight({ text, query }: { text: string; query: string }) {
|
|
8
|
+
if (!query.trim()) return <>{text}</>
|
|
9
|
+
const segments = highlightText(text, query)
|
|
10
|
+
return (
|
|
11
|
+
<>
|
|
12
|
+
{segments.map((seg, i) =>
|
|
13
|
+
seg.highlight ? (
|
|
14
|
+
<mark
|
|
15
|
+
key={i}
|
|
16
|
+
className="bg-primary/15 text-primary rounded-[2px] px-[1px] font-semibold not-italic"
|
|
17
|
+
>
|
|
18
|
+
{seg.text}
|
|
19
|
+
</mark>
|
|
20
|
+
) : (
|
|
21
|
+
seg.text
|
|
22
|
+
),
|
|
23
|
+
)}
|
|
24
|
+
</>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
@@ -8,7 +8,15 @@ import {
|
|
|
8
8
|
getCoreRowModel,
|
|
9
9
|
useReactTable,
|
|
10
10
|
} from '@tanstack/react-table'
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
AppWindow,
|
|
13
|
+
CalendarPlus,
|
|
14
|
+
Clock,
|
|
15
|
+
ExternalLink,
|
|
16
|
+
Plus,
|
|
17
|
+
Trash2,
|
|
18
|
+
X,
|
|
19
|
+
} from 'lucide-react'
|
|
12
20
|
import React, { useState } from 'react'
|
|
13
21
|
import { useHotkeys } from 'react-hotkeys-hook'
|
|
14
22
|
import { cn } from '~/lib/utils'
|
|
@@ -356,26 +364,7 @@ export function AppDetails({
|
|
|
356
364
|
<span className="text-muted-foreground">—</span>
|
|
357
365
|
)}
|
|
358
366
|
</div>
|
|
359
|
-
{/*
|
|
360
|
-
drives "New this week"), so the date is visible in the card
|
|
361
|
-
itself, not only in the muted footer. */}
|
|
362
|
-
{app.freshness?.lastCheckedAt && (
|
|
363
|
-
<div className="mt-1 px-3">
|
|
364
|
-
<span
|
|
365
|
-
className="inline-flex items-center gap-1 text-xs text-muted-foreground"
|
|
366
|
-
title={app.freshness.lastCheckedAt}
|
|
367
|
-
>
|
|
368
|
-
<Clock className="size-3" />
|
|
369
|
-
Updated {formatRelativeTime(app.freshness.lastCheckedAt)}
|
|
370
|
-
{app.freshness.isStale && (
|
|
371
|
-
<span className="italic opacity-75">
|
|
372
|
-
{' '}
|
|
373
|
-
· may be out of date
|
|
374
|
-
</span>
|
|
375
|
-
)}
|
|
376
|
-
</span>
|
|
377
|
-
</div>
|
|
378
|
-
)}
|
|
367
|
+
{/* Updated/Added metadata moved to consolidated section before Sources (#55) */}
|
|
379
368
|
</div>
|
|
380
369
|
</div>
|
|
381
370
|
</div>
|
|
@@ -536,6 +525,36 @@ export function AppDetails({
|
|
|
536
525
|
</div>
|
|
537
526
|
)}
|
|
538
527
|
|
|
528
|
+
{/* Metadata: Added / Updated */}
|
|
529
|
+
{(app.createdAt || app.freshness?.lastCheckedAt) && (
|
|
530
|
+
<div className="mt-6 flex flex-wrap gap-x-5 gap-y-1">
|
|
531
|
+
{app.createdAt && (
|
|
532
|
+
<span
|
|
533
|
+
className="inline-flex items-center gap-1 text-xs text-muted-foreground"
|
|
534
|
+
title={app.createdAt}
|
|
535
|
+
>
|
|
536
|
+
<CalendarPlus className="size-3" />
|
|
537
|
+
Added {formatRelativeTime(app.createdAt)}
|
|
538
|
+
</span>
|
|
539
|
+
)}
|
|
540
|
+
{app.freshness?.lastCheckedAt && (
|
|
541
|
+
<span
|
|
542
|
+
className="inline-flex items-center gap-1 text-xs text-muted-foreground"
|
|
543
|
+
title={app.freshness.lastCheckedAt}
|
|
544
|
+
>
|
|
545
|
+
<Clock className="size-3" />
|
|
546
|
+
Updated {formatRelativeTime(app.freshness.lastCheckedAt)}
|
|
547
|
+
{app.freshness.isStale && (
|
|
548
|
+
<span className="italic opacity-75">
|
|
549
|
+
{' '}
|
|
550
|
+
· may be out of date
|
|
551
|
+
</span>
|
|
552
|
+
)}
|
|
553
|
+
</span>
|
|
554
|
+
)}
|
|
555
|
+
</div>
|
|
556
|
+
)}
|
|
557
|
+
|
|
539
558
|
{/* Sources */}
|
|
540
559
|
<div className="mt-6">
|
|
541
560
|
<h3 className="mb-2 text-sm font-medium">Sources</h3>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Resource } from '@igstack/app-catalog-backend-core'
|
|
2
|
+
import { X } from 'lucide-react'
|
|
2
3
|
import { AppDetails } from '../grid/AppCatalogGrid'
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -39,6 +40,14 @@ export function LauncherDetailPanel({
|
|
|
39
40
|
aria-label={`${app.displayName} details`}
|
|
40
41
|
className="relative w-full max-w-[min(1120px,94vw)] my-auto rounded-[var(--radius)] border border-border bg-background shadow-2xl animate-in fade-in zoom-in-95 duration-200"
|
|
41
42
|
>
|
|
43
|
+
<button
|
|
44
|
+
type="button"
|
|
45
|
+
aria-label="Close"
|
|
46
|
+
onClick={onClose}
|
|
47
|
+
className="absolute top-3 right-3 z-10 rounded-full p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
|
48
|
+
>
|
|
49
|
+
<X className="size-4" />
|
|
50
|
+
</button>
|
|
42
51
|
<div className="max-h-[85vh] overflow-y-auto px-6 py-5 sm:px-8 sm:py-7">
|
|
43
52
|
<AppDetails
|
|
44
53
|
app={app}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Resource } from '@igstack/app-catalog-backend-core'
|
|
2
|
-
import { ArrowUpRight, Search } from 'lucide-react'
|
|
2
|
+
import { ArrowUpRight, Search, X } from 'lucide-react'
|
|
3
3
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
4
4
|
import { cn } from '~/lib/utils'
|
|
5
5
|
import { useAppClickHistory } from '../../hooks/useAppClickHistory'
|
|
@@ -7,6 +7,7 @@ import { markdownToPlainText } from '../../utils/markdownToPlainText'
|
|
|
7
7
|
import { AttributionFooter } from './AttributionFooter'
|
|
8
8
|
import { ResourceIcon } from './ResourceIcon'
|
|
9
9
|
import { searchResources } from '../../utils/searchApps'
|
|
10
|
+
import { Highlight } from '../components/Highlight'
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Adaptive-home discovery spine (issue #38, increment 1) — matches the
|
|
@@ -281,10 +282,10 @@ function SearchResultsList({
|
|
|
281
282
|
<ResourceIcon app={app} size={40} />
|
|
282
283
|
<span className="flex-1 min-w-0">
|
|
283
284
|
<span className="block text-[14px] font-semibold truncate">
|
|
284
|
-
{app.displayName}
|
|
285
|
+
<Highlight text={app.displayName} query={searchValue} />
|
|
285
286
|
{app.abbreviation && app.abbreviation !== app.displayName && (
|
|
286
287
|
<span className="ml-1.5 text-[12px] font-normal text-muted-foreground">
|
|
287
|
-
({app.abbreviation})
|
|
288
|
+
(<Highlight text={app.abbreviation} query={searchValue} />)
|
|
288
289
|
</span>
|
|
289
290
|
)}
|
|
290
291
|
</span>
|
|
@@ -296,14 +297,18 @@ function SearchResultsList({
|
|
|
296
297
|
{(() => {
|
|
297
298
|
const kids = matchedChildrenByParent.get(app.slug)
|
|
298
299
|
if (!kids || kids.length === 0) return null
|
|
299
|
-
const
|
|
300
|
-
const
|
|
301
|
-
const extra =
|
|
302
|
-
names.length > 3 ? ` +${names.length - 3} more` : ''
|
|
300
|
+
const shown = kids.slice(0, 3)
|
|
301
|
+
const extra = kids.length > 3 ? ` +${kids.length - 3} more` : ''
|
|
303
302
|
return (
|
|
304
303
|
<span className="mt-0.5 block text-[12px] text-primary truncate">
|
|
305
304
|
Matched {kids.length} sub-resource
|
|
306
|
-
{kids.length === 1 ? '' : 's'}:
|
|
305
|
+
{kids.length === 1 ? '' : 's'}:{' '}
|
|
306
|
+
{shown.map((k, idx) => (
|
|
307
|
+
<span key={k.slug}>
|
|
308
|
+
{idx > 0 && ', '}
|
|
309
|
+
<Highlight text={k.displayName} query={searchValue} />
|
|
310
|
+
</span>
|
|
311
|
+
))}
|
|
307
312
|
{extra}
|
|
308
313
|
</span>
|
|
309
314
|
)
|
|
@@ -433,7 +438,19 @@ export function LauncherHome({
|
|
|
433
438
|
aria-label="Search apps"
|
|
434
439
|
className="flex-1 bg-transparent border-0 outline-none text-[16px] text-foreground placeholder:text-muted-foreground"
|
|
435
440
|
/>
|
|
436
|
-
{
|
|
441
|
+
{isSearching ? (
|
|
442
|
+
<button
|
|
443
|
+
type="button"
|
|
444
|
+
aria-label="Clear search"
|
|
445
|
+
onClick={() => {
|
|
446
|
+
onSearchChange('')
|
|
447
|
+
searchRef.current?.focus()
|
|
448
|
+
}}
|
|
449
|
+
className="shrink-0 rounded-full p-1 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
|
450
|
+
>
|
|
451
|
+
<X className="size-4" />
|
|
452
|
+
</button>
|
|
453
|
+
) : (
|
|
437
454
|
<kbd className="hidden sm:inline-flex items-center gap-0.5 text-[11px] text-muted-foreground/60 border border-muted-foreground/20 rounded px-1.5 py-0.5 font-mono shrink-0">
|
|
438
455
|
{typeof navigator !== 'undefined' &&
|
|
439
456
|
navigator.platform.includes('Mac')
|