@igstack/app-catalog-frontend-core 0.17.0 → 0.17.1-alpha-20260821043201

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 (52) hide show
  1. package/dist/esm/__tests__/integration/mock-backend/magazines.d.ts +1 -1
  2. package/dist/esm/__tests__/integration/tools/AppDetailTools.d.ts +16 -0
  3. package/dist/esm/__tests__/integration/tools/CatalogTools.d.ts +12 -0
  4. package/dist/esm/api/infra/trpc.d.ts +24 -0
  5. package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +1 -0
  6. package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.d.ts +1 -1
  7. package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js.map +1 -1
  8. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.d.ts +9 -0
  9. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js +90 -0
  10. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js.map +1 -0
  11. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.d.ts +1 -1
  12. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -1
  13. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.d.ts +7 -1
  14. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js +108 -31
  15. package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js.map +1 -1
  16. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +31 -4
  17. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
  18. package/dist/esm/modules/appCatalog/ui/pages/ServiceDesksPage.js +41 -20
  19. package/dist/esm/modules/appCatalog/ui/pages/ServiceDesksPage.js.map +1 -1
  20. package/dist/esm/modules/appCatalog/utils/markdownToPlainText.d.ts +1 -1
  21. package/dist/esm/modules/appCatalog/utils/markdownToPlainText.js.map +1 -1
  22. package/dist/esm/routeTree.gen.d.ts +2 -0
  23. package/dist/esm/routes/_layout/app.$slug.d.ts +1 -0
  24. package/dist/esm/routes/_layout/app._slug.js +2 -1
  25. package/dist/esm/routes/_layout/app._slug.js.map +1 -1
  26. package/dist/esm/routes/_layout/catalog.apps.index.d.ts +1 -0
  27. package/dist/esm/routes/_layout/catalog.apps.index.js +2 -1
  28. package/dist/esm/routes/_layout/catalog.apps.index.js.map +1 -1
  29. package/package.json +5 -7
  30. package/src/__tests__/integration/appCatalog.integration.test.ts +264 -5
  31. package/src/__tests__/integration/appDeepLink.integration.test.ts +46 -19
  32. package/src/__tests__/integration/crossRefLink.integration.test.ts +8 -8
  33. package/src/__tests__/integration/crossRefLinkListView.integration.test.ts +8 -8
  34. package/src/__tests__/integration/mock-backend/MockBackendConfigurer.ts +2 -0
  35. package/src/__tests__/integration/mock-backend/magazines.ts +24 -24
  36. package/src/__tests__/integration/replacementLink.integration.test.ts +2 -2
  37. package/src/__tests__/integration/searchDeprecatedFallback.integration.test.ts +2 -2
  38. package/src/__tests__/integration/serviceDesks.integration.test.ts +31 -24
  39. package/src/__tests__/integration/tools/AppDetailTools.ts +57 -0
  40. package/src/__tests__/integration/tools/CatalogTools.ts +77 -4
  41. package/src/__tests__/modules/appCatalog/utils/markdownToPlainText.test.ts +6 -6
  42. package/src/__tests__/modules/appCatalog/utils/resolveHelpers.test.ts +1 -1
  43. package/src/__tests__/modules/appCatalog/utils/searchApps.test.ts +11 -7
  44. package/src/modules/appCatalog/ui/components/AccessPrerequisiteChain.tsx +1 -1
  45. package/src/modules/appCatalog/ui/components/SubResourceDetailPanel.tsx +124 -0
  46. package/src/modules/appCatalog/ui/components/SubResourcesSection.tsx +1 -1
  47. package/src/modules/appCatalog/ui/grid/AppCatalogGrid.tsx +128 -34
  48. package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +49 -8
  49. package/src/modules/appCatalog/ui/pages/ServiceDesksPage.tsx +37 -9
  50. package/src/modules/appCatalog/utils/markdownToPlainText.ts +1 -1
  51. package/src/routes/_layout/app.$slug.tsx +1 -0
  52. package/src/routes/_layout/catalog.apps.index.tsx +1 -0
@@ -0,0 +1,124 @@
1
+ import type {
2
+ AppApprovalMethod,
3
+ Resource,
4
+ } from '@igstack/app-catalog-backend-core'
5
+ import { ArrowLeft } from 'lucide-react'
6
+ import { Button } from '~/ui/button'
7
+ import { Badge } from '~/ui/badge'
8
+ import { AccessRequestSection } from './AccessRequestSection'
9
+
10
+ interface SubResourceDetailPanelProps {
11
+ subResource: Resource
12
+ parent: Resource
13
+ approvalMethods: AppApprovalMethod[]
14
+ onBack: () => void
15
+ }
16
+
17
+ function StepBadge({ step }: { step: number }) {
18
+ return (
19
+ <span className="inline-flex items-center justify-center size-6 rounded-full bg-primary text-primary-foreground text-xs font-semibold shrink-0">
20
+ {step}
21
+ </span>
22
+ )
23
+ }
24
+
25
+ export function SubResourceDetailPanel({
26
+ subResource,
27
+ parent,
28
+ approvalMethods,
29
+ onBack,
30
+ }: SubResourceDetailPanelProps) {
31
+ const hasParentAccess = !!parent.accessRequest
32
+ const hasSubAccess = !!subResource.accessRequest
33
+ const hasTwoStepAccess = hasParentAccess && hasSubAccess
34
+ const hasAnyAccess = hasParentAccess || hasSubAccess
35
+
36
+ return (
37
+ <div className="flex h-full flex-col p-6">
38
+ {/* Back navigation */}
39
+ <div className="mb-4">
40
+ <Button
41
+ variant="ghost"
42
+ size="sm"
43
+ onClick={onBack}
44
+ className="gap-1.5 text-muted-foreground hover:text-foreground -ml-2"
45
+ aria-label={`Back to ${parent.displayName}`}
46
+ >
47
+ <ArrowLeft className="size-4" />
48
+ <span>{parent.displayName}</span>
49
+ </Button>
50
+ </div>
51
+
52
+ {/* Sub-resource title */}
53
+ <div className="border-b pb-4">
54
+ <div className="flex items-center gap-2 flex-wrap">
55
+ <h2 className="text-xl font-semibold">{subResource.displayName}</h2>
56
+ {subResource.tier && (
57
+ <Badge variant="outline" className="text-xs">
58
+ {subResource.tier}
59
+ </Badge>
60
+ )}
61
+ </div>
62
+ {subResource.description && (
63
+ <p className="text-sm text-muted-foreground mt-1">
64
+ {subResource.description}
65
+ </p>
66
+ )}
67
+ {(subResource.aliases ?? []).length > 0 && (
68
+ <p className="text-xs text-muted-foreground mt-1">
69
+ Also known as: {(subResource.aliases ?? []).join(', ')}
70
+ </p>
71
+ )}
72
+ </div>
73
+
74
+ {/* Two-step access section */}
75
+ {hasAnyAccess && (
76
+ <div className="mt-6 space-y-6">
77
+ <h3 className="text-sm font-medium">How to get access</h3>
78
+
79
+ {hasTwoStepAccess ? (
80
+ <>
81
+ {/* Step 1: Parent access */}
82
+ <div className="space-y-2">
83
+ <div className="flex items-center gap-2">
84
+ <StepBadge step={1} />
85
+ <span className="text-sm font-medium text-muted-foreground">
86
+ {parent.displayName}
87
+ </span>
88
+ </div>
89
+ <div className="pl-8">
90
+ <AccessRequestSection
91
+ app={parent}
92
+ approvalMethods={approvalMethods}
93
+ />
94
+ </div>
95
+ </div>
96
+
97
+ {/* Step 2: Sub-resource access */}
98
+ <div className="space-y-2">
99
+ <div className="flex items-center gap-2">
100
+ <StepBadge step={2} />
101
+ <span className="text-sm font-medium text-muted-foreground">
102
+ {subResource.displayName}
103
+ </span>
104
+ </div>
105
+ <div className="pl-8">
106
+ <AccessRequestSection
107
+ app={subResource}
108
+ approvalMethods={approvalMethods}
109
+ />
110
+ </div>
111
+ </div>
112
+ </>
113
+ ) : (
114
+ /* Single-step: only one of them has access info */
115
+ <AccessRequestSection
116
+ app={hasParentAccess ? parent : subResource}
117
+ approvalMethods={approvalMethods}
118
+ />
119
+ )}
120
+ </div>
121
+ )}
122
+ </div>
123
+ )
124
+ }
@@ -29,7 +29,7 @@ interface SubResourcesSectionProps {
29
29
  * Initial filter text (#38 item C). When the user reached this app by
30
30
  * searching a term that matched a sub-resource, seed the sub-resource filter
31
31
  * with that term so the matched child is revealed instead of buried in a
32
- * long list (e.g. searching "biom" → open AWS Console → the biomarker
32
+ * long list (e.g. searching "data" → open Cloud Console → the matching
33
33
  * account is pre-filtered). Only applied when it actually matches a child.
34
34
  */
35
35
  initialSearch?: string
@@ -53,6 +53,7 @@ import { markdownToPlainText } from '../../utils/markdownToPlainText'
53
53
  import { formatRelativeTime } from '../../utils/formatRelativeTime'
54
54
  import { TierVariantsSection } from '../components/TierVariantsSection'
55
55
  import { SubResourcesSection } from '../components/SubResourcesSection'
56
+ import { SubResourceDetailPanel } from '../components/SubResourceDetailPanel'
56
57
  import { getChildResources } from '../../utils/resolveHelpers'
57
58
 
58
59
  export interface AppCatalogGridProps {
@@ -70,6 +71,12 @@ export interface AppCatalogGridProps {
70
71
  onClearFilters?: () => void
71
72
  /** Called when the user closes the detail panel (Esc or X) — e.g. to navigate back to the catalog URL */
72
73
  onClosePanel?: () => void
74
+ /** Currently selected subresource slug */
75
+ selectedSubSlug?: string
76
+ /** Called when user clicks a sub-row */
77
+ onSubClick?: (parentSlug: string, subSlug: string) => void
78
+ /** Called when user clicks Back in sub detail */
79
+ onBackToParent?: () => void
73
80
  }
74
81
 
75
82
  function getIconUrl(iconName: string): string {
@@ -783,16 +790,21 @@ export function AppCatalogGrid({
783
790
  totalAppsCount,
784
791
  onClearFilters,
785
792
  onClosePanel,
793
+ selectedSubSlug,
794
+ onSubClick,
795
+ onBackToParent,
786
796
  }: AppCatalogGridProps) {
787
797
  // Full, unfiltered resource set — the detail panel must resolve the open app
788
798
  // from this, not only from the filtered `apps`. Navigating to /app/<slug>
789
799
  // (e.g. a deprecated app's "View replacement" link, or any deep link) should
790
800
  // render that app like typing the URL in the browser, regardless of the
791
- // active search/filters (#12).
792
- const { resources: allResourcesForDetail } = useAppCatalogContext()
801
+ // active search/filters (#12). Also provides approvalMethods for sub-resource panel.
802
+ const { resources: allResources2, approvalMethods } = useAppCatalogContext()
803
+
793
804
  const selectedApp = selectedAppSlug
794
805
  ? (apps.find((a) => a.slug === selectedAppSlug) ??
795
- allResourcesForDetail.find((a) => a.slug === selectedAppSlug))
806
+ allResources2.find((a) => a.slug === selectedAppSlug && !a.parentSlug) ??
807
+ null)
796
808
  : null
797
809
 
798
810
  const groupedApps = groupApps(apps, groupingDefinition, hasSearch)
@@ -813,10 +825,8 @@ export function AppCatalogGrid({
813
825
  // Launch history for the secondary "open in new tab" action on each row.
814
826
  const { recordClick: recordLaunch } = useAppClickHistory()
815
827
 
816
- // Build a map of parentSlug -> matched child resource displayName for search annotation
817
- const { resources: allResources2 } = useAppCatalogContext()
818
- const matchedSubResourceMap = React.useMemo(() => {
819
- const map = new Map<string, string>()
828
+ const matchedSubResourcesMap = React.useMemo(() => {
829
+ const map = new Map<string, Resource[]>()
820
830
  if (!searchQuery?.trim() || allResources2.length === 0) return map
821
831
  const queryTerms = searchQuery
822
832
  .trim()
@@ -828,7 +838,6 @@ export function AppCatalogGrid({
828
838
 
829
839
  for (const r of allResources2) {
830
840
  if (!r.parentSlug) continue
831
- if (map.has(r.parentSlug)) continue
832
841
  const nameMatch = allTermsMatch(r.displayName.toLowerCase())
833
842
  const aliasMatch = (r.aliases ?? []).some((a) =>
834
843
  allTermsMatch(a.toLowerCase()),
@@ -837,12 +846,23 @@ export function AppCatalogGrid({
837
846
  ? allTermsMatch(r.description.toLowerCase())
838
847
  : false
839
848
  if (nameMatch || aliasMatch || descMatch) {
840
- map.set(r.parentSlug, r.displayName)
849
+ if (!map.has(r.parentSlug)) {
850
+ map.set(r.parentSlug, [])
851
+ }
852
+ map.get(r.parentSlug)!.push(r)
841
853
  }
842
854
  }
843
855
  return map
844
856
  }, [searchQuery, allResources2])
845
857
 
858
+ // Track expanded parent slugs for sub-resource rows; reset when search changes
859
+ const [expandedParentSlugs, setExpandedParentSlugs] = React.useState<
860
+ Set<string>
861
+ >(() => new Set())
862
+ React.useEffect(() => {
863
+ setExpandedParentSlugs(new Set())
864
+ }, [searchQuery])
865
+
846
866
  // Define columns
847
867
  const columns = React.useMemo<ColumnDef<Resource>[]>(
848
868
  () => [
@@ -909,12 +929,55 @@ export function AppCatalogGrid({
909
929
  searchQuery={searchQuery}
910
930
  />
911
931
  </span>
912
- {matchedSubResourceMap.get(row.original.slug) && (
913
- <div className="text-xs text-primary mt-0.5">
914
- Matched sub-resource:{' '}
915
- {matchedSubResourceMap.get(row.original.slug)}
916
- </div>
917
- )}
932
+ {/* matched subs: expandable indented rows */}
933
+ {(matchedSubResourcesMap.get(row.original.slug)?.length ?? 0) > 0 &&
934
+ (() => {
935
+ const matchedSubs = matchedSubResourcesMap.get(
936
+ row.original.slug,
937
+ )!
938
+ const COLLAPSED_LIMIT = 5
939
+ const isExpanded = expandedParentSlugs.has(row.original.slug)
940
+ const visibleSubs = isExpanded
941
+ ? matchedSubs
942
+ : matchedSubs.slice(0, COLLAPSED_LIMIT)
943
+ const hiddenCount = matchedSubs.length - COLLAPSED_LIMIT
944
+ return (
945
+ <div className="mt-1 space-y-0.5">
946
+ {visibleSubs.map((sub) => (
947
+ <button
948
+ key={sub.slug}
949
+ type="button"
950
+ onClick={(e) => {
951
+ e.stopPropagation()
952
+ onSubClick?.(row.original.slug, sub.slug)
953
+ }}
954
+ className={cn(
955
+ 'flex items-center gap-1.5 text-xs pl-3 border-l-2 w-full text-left py-0.5 rounded-sm hover:bg-accent/50 transition-colors',
956
+ selectedSubSlug === sub.slug
957
+ ? 'border-primary text-primary font-medium'
958
+ : 'border-muted-foreground/30 text-muted-foreground hover:text-foreground',
959
+ )}
960
+ >
961
+ <span>{sub.displayName}</span>
962
+ </button>
963
+ ))}
964
+ {!isExpanded && hiddenCount > 0 && (
965
+ <button
966
+ type="button"
967
+ onClick={(e) => {
968
+ e.stopPropagation()
969
+ setExpandedParentSlugs(
970
+ (prev) => new Set([...prev, row.original.slug]),
971
+ )
972
+ }}
973
+ className="text-xs pl-3 border-l-2 border-muted-foreground/30 text-muted-foreground hover:text-primary w-full text-left py-0.5"
974
+ >
975
+ ... {hiddenCount} more
976
+ </button>
977
+ )}
978
+ </div>
979
+ )
980
+ })()}
918
981
  </div>
919
982
  ),
920
983
  },
@@ -947,7 +1010,15 @@ export function AppCatalogGrid({
947
1010
  },
948
1011
  },
949
1012
  ],
950
- [searchQuery, matchedSubResourceMap, recordLaunch],
1013
+ [
1014
+ searchQuery,
1015
+ matchedSubResourcesMap,
1016
+ expandedParentSlugs,
1017
+ selectedSubSlug,
1018
+ onSubClick,
1019
+ setExpandedParentSlugs,
1020
+ recordLaunch,
1021
+ ],
951
1022
  )
952
1023
 
953
1024
  // Create a single table instance with all apps
@@ -1123,24 +1194,47 @@ export function AppCatalogGrid({
1123
1194
  className="overflow-hidden"
1124
1195
  >
1125
1196
  <div className="h-full overflow-y-auto border-l bg-background pl-4">
1126
- {selectedApp ? (
1127
- <div className="relative">
1128
- <Button
1129
- variant="ghost"
1130
- size="icon"
1131
- className="absolute top-4 right-4 z-10 hover:bg-accent"
1132
- onClick={handleClosePanel}
1133
- aria-label="Close details panel"
1134
- >
1135
- <X className="h-5 w-5" />
1136
- </Button>
1137
- <AppDetails
1138
- app={selectedApp}
1139
- onAppClick={onAppClick}
1140
- onClosePanel={handleClosePanel}
1141
- />
1142
- </div>
1143
- ) : null}
1197
+ {/* selectedApp is non-null here; isPanelOpen guards this block */}
1198
+ <div className="relative">
1199
+ <Button
1200
+ variant="ghost"
1201
+ size="icon"
1202
+ className="absolute top-4 right-4 z-10 hover:bg-accent"
1203
+ onClick={handleClosePanel}
1204
+ aria-label="Close details panel"
1205
+ >
1206
+ <X className="h-5 w-5" />
1207
+ </Button>
1208
+ {/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */}
1209
+ {selectedApp &&
1210
+ (() => {
1211
+ const app = selectedApp
1212
+ const selectedSub = selectedSubSlug
1213
+ ? allResources2.find(
1214
+ (r) =>
1215
+ r.slug === selectedSubSlug &&
1216
+ r.parentSlug === app.slug,
1217
+ )
1218
+ : null
1219
+ if (selectedSub != null) {
1220
+ return (
1221
+ <SubResourceDetailPanel
1222
+ subResource={selectedSub}
1223
+ parent={app}
1224
+ approvalMethods={approvalMethods}
1225
+ onBack={onBackToParent ?? (() => undefined)}
1226
+ />
1227
+ )
1228
+ }
1229
+ return (
1230
+ <AppDetails
1231
+ app={selectedApp}
1232
+ onAppClick={onAppClick}
1233
+ onClosePanel={handleClosePanel}
1234
+ />
1235
+ )
1236
+ })()}
1237
+ </div>
1144
1238
  </div>
1145
1239
  </ResizablePanel>
1146
1240
  </>
@@ -1,7 +1,13 @@
1
1
  import type { Resource } from '@igstack/app-catalog-backend-core'
2
- import { useNavigate } from '@tanstack/react-router'
2
+ import { useNavigate, useSearch } from '@tanstack/react-router'
3
3
  import { X } from 'lucide-react'
4
- import { useDeferredValue, useEffect, useMemo, useState } from 'react'
4
+ import {
5
+ useCallback,
6
+ useDeferredValue,
7
+ useEffect,
8
+ useMemo,
9
+ useState,
10
+ } from 'react'
5
11
  import { Button } from '~/ui/button'
6
12
  import {
7
13
  Empty,
@@ -34,6 +40,35 @@ export function AppCatalogPage({
34
40
  const selectedAppSlug = selectedSlug
35
41
 
36
42
  // Search value from context (URL-synced in AppCatalogFiltersContext)
43
+ const search = useSearch({ strict: false })
44
+ const selectedSubSlug = search.sub
45
+
46
+ const handleSubClick = useCallback(
47
+ (parentSlug: string, subSlug: string) => {
48
+ void navigate({
49
+ to: '/app/$slug',
50
+ params: { slug: parentSlug },
51
+ search: (prev) => ({ ...prev, sub: subSlug }),
52
+ })
53
+ },
54
+ [navigate],
55
+ )
56
+
57
+ const handleBackToParent = useCallback(() => {
58
+ // Return to parent app detail, clearing the sub-resource selection.
59
+ // selectedAppSlug is the parent slug (set by the /app/$slug route).
60
+ if (selectedAppSlug) {
61
+ void navigate({
62
+ to: '/app/$slug',
63
+ params: { slug: selectedAppSlug },
64
+ search: (prev) => {
65
+ const { sub: _sub, ...rest } = prev as Record<string, string>
66
+ return rest
67
+ },
68
+ })
69
+ }
70
+ }, [navigate, selectedAppSlug])
71
+
37
72
  const searchValue = filterState.searchValue
38
73
  const setSearchValue = actions.setSearchValue
39
74
 
@@ -214,12 +249,15 @@ export function AppCatalogPage({
214
249
  }
215
250
 
216
251
  // Adaptive-home launcher (#38): shown whenever no structural filter
217
- // (recentMode / tagFilters) is active. A selected app no longer forces the
218
- // old grid — instead the launcher stays as the backdrop and the app detail
219
- // renders in a slide-over panel (#38 item B). The legacy grid+split-pane is
220
- // reserved for the recent/tag filter views.
252
+ // (recentMode / tagFilters / active search) is active. A selected app no longer
253
+ // forces the old grid — instead the launcher stays as the backdrop and the app
254
+ // detail renders in a slide-over panel (#38 item B). The legacy grid+split-pane
255
+ // is used for recent/tag filter views AND for active search (so expandable
256
+ // sub-resource rows and query highlighting in AppCatalogGrid are visible).
221
257
  const showLauncherHome =
222
- !filterState.recentMode && Object.keys(filterState.tagFilters).length === 0
258
+ !filterState.recentMode &&
259
+ Object.keys(filterState.tagFilters).length === 0 &&
260
+ !deferredSearchValue.trim()
223
261
 
224
262
  // The app whose detail slide-over is open over the launcher backdrop.
225
263
  const launcherSelectedApp = useMemo(
@@ -346,11 +384,14 @@ export function AppCatalogPage({
346
384
  <AppCatalogGrid
347
385
  apps={filteredApps}
348
386
  selectedAppSlug={selectedAppSlug}
387
+ selectedSubSlug={selectedSubSlug}
349
388
  groupingDefinition={groupingDefinition}
350
389
  onAppClick={handleAppClick}
351
390
  onClosePanel={() => void navigate({ to: '/' })}
391
+ onSubClick={handleSubClick}
392
+ onBackToParent={handleBackToParent}
352
393
  hasSearch={!!deferredSearchValue}
353
- searchQuery={deferredSearchValue}
394
+ searchQuery={searchValue}
354
395
  totalAppsCount={totalAppsCount}
355
396
  onClearFilters={handleClearFilters}
356
397
  />
@@ -62,31 +62,59 @@ export function ServiceDesksPage() {
62
62
  const url = desk.config.url
63
63
  const description = desk.config.description
64
64
  return (
65
- <TableRow key={desk.slug}>
65
+ <TableRow
66
+ key={desk.slug}
67
+ className={
68
+ url ? 'cursor-pointer hover:bg-muted/50' : undefined
69
+ }
70
+ onClick={
71
+ url
72
+ ? () =>
73
+ window.open(url, '_blank', 'noopener,noreferrer')
74
+ : undefined
75
+ }
76
+ >
66
77
  <TableCell className="font-medium align-top">
67
- <span data-testid="service-desk-name">
68
- {desk.displayName}
69
- </span>
78
+ {url ? (
79
+ <a
80
+ href={url}
81
+ target="_blank"
82
+ rel="noopener noreferrer"
83
+ className="hover:text-primary hover:underline"
84
+ data-testid="service-desk-name"
85
+ onClick={(e) => e.stopPropagation()}
86
+ >
87
+ {desk.displayName}
88
+ </a>
89
+ ) : (
90
+ <span data-testid="service-desk-name">
91
+ {desk.displayName}
92
+ </span>
93
+ )}
70
94
  {description && (
71
95
  <span className="block text-xs font-normal text-muted-foreground">
72
96
  {description}
73
97
  </span>
74
98
  )}
75
- </TableCell>
76
- <TableCell>
77
- {url ? (
99
+ {url && (
78
100
  <a
79
101
  href={url}
80
102
  target="_blank"
81
103
  rel="noopener noreferrer"
82
- className="inline-flex items-center gap-1 text-primary hover:underline break-all"
104
+ className="mt-0.5 inline-flex items-center gap-1 text-xs text-primary/70 hover:text-primary hover:underline"
83
105
  title={url}
106
+ onClick={(e) => e.stopPropagation()}
84
107
  >
85
108
  {url.replace(/^https?:\/\//, '')}
86
109
  <ExternalLink className="size-3 shrink-0" />
87
110
  </a>
111
+ )}
112
+ </TableCell>
113
+ <TableCell className="text-muted-foreground text-xs">
114
+ {url ? (
115
+ <ExternalLink className="size-4 text-primary/50" />
88
116
  ) : (
89
- <span className="text-muted-foreground">—</span>
117
+ <span>—</span>
90
118
  )}
91
119
  </TableCell>
92
120
  </TableRow>
@@ -2,7 +2,7 @@
2
2
  * Reduce a short markdown string to its visible plain text.
3
3
  *
4
4
  * Descriptions may contain markdown — notably cross-reference links like
5
- * `[Signatera Portal](/app/signatera-portal)` (#25). The detail panel renders
5
+ * `[Team Chat](/app/team-chat)` (#25). The detail panel renders
6
6
  * those as interactive links via {@link MarkdownText}, but in compact / clamped
7
7
  * surfaces (grid list preview, table row, filter combobox, sub-resources) an
8
8
  * interactive link would break `line-clamp` and can't be reliably highlighted
@@ -15,6 +15,7 @@ const searchSchema = z.object({
15
15
  filters: z.string().optional(),
16
16
  deprecated: z.string().optional(),
17
17
  q: z.string().optional(),
18
+ sub: z.string().optional(),
18
19
  })
19
20
 
20
21
  export const Route = createFileRoute('/_layout/app/$slug')({
@@ -9,6 +9,7 @@ import { AppCatalogPage } from '~/modules/appCatalog/ui/pages/AppCatalogPage'
9
9
  const searchSchema = z.object({
10
10
  app: z.string().optional(),
11
11
  q: z.string().optional(),
12
+ sub: z.string().optional(),
12
13
  })
13
14
 
14
15
  export const Route = createFileRoute('/_layout/catalog/apps/')({