@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.
- package/dist/esm/__tests__/integration/mock-backend/magazines.d.ts +1 -1
- package/dist/esm/__tests__/integration/tools/AppDetailTools.d.ts +16 -0
- package/dist/esm/__tests__/integration/tools/CatalogTools.d.ts +12 -0
- package/dist/esm/api/infra/trpc.d.ts +24 -0
- package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +1 -0
- package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.d.ts +1 -1
- package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.d.ts +9 -0
- package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js +90 -0
- package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.d.ts +1 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.d.ts +7 -1
- package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js +108 -31
- package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +31 -4
- package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/pages/ServiceDesksPage.js +41 -20
- package/dist/esm/modules/appCatalog/ui/pages/ServiceDesksPage.js.map +1 -1
- package/dist/esm/modules/appCatalog/utils/markdownToPlainText.d.ts +1 -1
- package/dist/esm/modules/appCatalog/utils/markdownToPlainText.js.map +1 -1
- package/dist/esm/routeTree.gen.d.ts +2 -0
- package/dist/esm/routes/_layout/app.$slug.d.ts +1 -0
- package/dist/esm/routes/_layout/app._slug.js +2 -1
- package/dist/esm/routes/_layout/app._slug.js.map +1 -1
- package/dist/esm/routes/_layout/catalog.apps.index.d.ts +1 -0
- package/dist/esm/routes/_layout/catalog.apps.index.js +2 -1
- package/dist/esm/routes/_layout/catalog.apps.index.js.map +1 -1
- package/package.json +5 -7
- package/src/__tests__/integration/appCatalog.integration.test.ts +264 -5
- package/src/__tests__/integration/appDeepLink.integration.test.ts +46 -19
- package/src/__tests__/integration/crossRefLink.integration.test.ts +8 -8
- package/src/__tests__/integration/crossRefLinkListView.integration.test.ts +8 -8
- package/src/__tests__/integration/mock-backend/MockBackendConfigurer.ts +2 -0
- package/src/__tests__/integration/mock-backend/magazines.ts +24 -24
- package/src/__tests__/integration/replacementLink.integration.test.ts +2 -2
- package/src/__tests__/integration/searchDeprecatedFallback.integration.test.ts +2 -2
- package/src/__tests__/integration/serviceDesks.integration.test.ts +31 -24
- package/src/__tests__/integration/tools/AppDetailTools.ts +57 -0
- package/src/__tests__/integration/tools/CatalogTools.ts +77 -4
- package/src/__tests__/modules/appCatalog/utils/markdownToPlainText.test.ts +6 -6
- package/src/__tests__/modules/appCatalog/utils/resolveHelpers.test.ts +1 -1
- package/src/__tests__/modules/appCatalog/utils/searchApps.test.ts +11 -7
- package/src/modules/appCatalog/ui/components/AccessPrerequisiteChain.tsx +1 -1
- package/src/modules/appCatalog/ui/components/SubResourceDetailPanel.tsx +124 -0
- package/src/modules/appCatalog/ui/components/SubResourcesSection.tsx +1 -1
- package/src/modules/appCatalog/ui/grid/AppCatalogGrid.tsx +128 -34
- package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +49 -8
- package/src/modules/appCatalog/ui/pages/ServiceDesksPage.tsx +37 -9
- package/src/modules/appCatalog/utils/markdownToPlainText.ts +1 -1
- package/src/routes/_layout/app.$slug.tsx +1 -0
- 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 "
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
817
|
-
|
|
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.
|
|
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
|
-
{
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
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
|
-
[
|
|
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
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
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 {
|
|
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
|
|
218
|
-
// old grid — instead the launcher stays as the backdrop and the app
|
|
219
|
-
// renders in a slide-over panel (#38 item B). The legacy grid+split-pane
|
|
220
|
-
//
|
|
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 &&
|
|
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={
|
|
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
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
* `[
|
|
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
|
|
@@ -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/')({
|