@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.
Files changed (26) hide show
  1. package/dist/esm/modules/appCatalog/context/AppCatalogContext.js +0 -1
  2. package/dist/esm/modules/appCatalog/context/AppCatalogContext.js.map +1 -1
  3. package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +1 -0
  4. package/dist/esm/modules/appCatalog/ui/components/Highlight.d.ts +8 -0
  5. package/dist/esm/modules/appCatalog/ui/components/Highlight.js +20 -0
  6. package/dist/esm/modules/appCatalog/ui/components/Highlight.js.map +1 -0
  7. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js +0 -1
  8. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js.map +1 -1
  9. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js +0 -1
  10. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -1
  11. package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js +0 -1
  12. package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js.map +1 -1
  13. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js +33 -19
  14. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js.map +1 -1
  15. package/dist/esm/modules/appCatalog/ui/launcher/LauncherDetailPanel.js +22 -9
  16. package/dist/esm/modules/appCatalog/ui/launcher/LauncherDetailPanel.js.map +1 -1
  17. package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.js +26 -9
  18. package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.js.map +1 -1
  19. package/package.json +4 -4
  20. package/src/__tests__/integration/appCatalog.integration.test.ts +43 -0
  21. package/src/__tests__/integration/appDeepLink.integration.test.ts +13 -1
  22. package/src/__tests__/integration/appFreshness.integration.test.ts +15 -0
  23. package/src/modules/appCatalog/ui/components/Highlight.tsx +26 -0
  24. package/src/modules/appCatalog/ui/grid/AppCatalogGrid.tsx +40 -21
  25. package/src/modules/appCatalog/ui/launcher/LauncherDetailPanel.tsx +9 -0
  26. 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.13.3",
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-backend-core": "0.13.3",
138
- "@igstack/app-catalog-shared-core": "0.13.3"
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": "5e900c4f7cda2501c01164155098fc3f46a58b5c",
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 { AppWindow, Clock, ExternalLink, Plus, Trash2, X } from 'lucide-react'
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
- {/* Freshness date near the header (this is the same signal that
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 names = kids.map((k) => k.displayName)
300
- const shown = names.slice(0, 3).join(', ')
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'}: {shown}
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
- {!isSearching && (
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')