@igstack/app-catalog-frontend-core 0.10.0 → 0.11.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/__tests__/integration/harness/given.d.ts +1 -0
- package/dist/esm/__tests__/integration/skewWarning.integration.test.d.ts +0 -0
- package/dist/esm/__tests__/integration/tools/AppDetailTools.d.ts +5 -0
- package/dist/esm/__tests__/integration/tools/CatalogTools.d.ts +15 -1
- package/dist/esm/__tests__/modules/appCatalog/utils/resolveHelpers.test.d.ts +1 -0
- package/dist/esm/modules/appCatalog/hooks/useSessionSyncedState.d.ts +40 -0
- package/dist/esm/modules/appCatalog/hooks/useSessionSyncedState.js +40 -0
- package/dist/esm/modules/appCatalog/hooks/useSessionSyncedState.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.d.ts +17 -0
- package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js +61 -0
- package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js +9 -2
- package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/OnboardingCard.js +1 -1
- package/dist/esm/modules/appCatalog/ui/components/OnboardingCard.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.d.ts +9 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js +12 -3
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/context/AppCatalogFiltersContext.js +3 -2
- package/dist/esm/modules/appCatalog/ui/context/AppCatalogFiltersContext.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.d.ts +5 -0
- package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js +56 -10
- package/dist/esm/modules/appCatalog/ui/grid/AppCatalogGrid.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/launcher/LauncherDetailPanel.d.ts +12 -0
- package/dist/esm/modules/appCatalog/ui/launcher/LauncherDetailPanel.js +48 -0
- package/dist/esm/modules/appCatalog/ui/launcher/LauncherDetailPanel.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.d.ts +19 -0
- package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.js +355 -0
- package/dist/esm/modules/appCatalog/ui/launcher/LauncherHome.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/launcher/ResourceIcon.d.ts +13 -0
- package/dist/esm/modules/appCatalog/ui/launcher/ResourceIcon.js +45 -0
- package/dist/esm/modules/appCatalog/ui/launcher/ResourceIcon.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/launcher/monogram.d.ts +4 -0
- package/dist/esm/modules/appCatalog/ui/launcher/monogram.js +41 -0
- package/dist/esm/modules/appCatalog/ui/launcher/monogram.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +40 -6
- package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.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/modules/appCatalog/utils/resolveHelpers.d.ts +10 -0
- package/dist/esm/modules/appCatalog/utils/resolveHelpers.js +17 -0
- package/dist/esm/modules/appCatalog/utils/resolveHelpers.js.map +1 -1
- package/dist/esm/routeTree.gen.d.ts +1 -1
- package/dist/esm/routes/_layout/app.$slug.d.ts +1 -1
- package/dist/esm/routes/_layout/app._slug.js +4 -3
- package/dist/esm/routes/_layout/app._slug.js.map +1 -1
- package/dist/esm/routes/_layout/catalog.apps.index.js +2 -1
- package/dist/esm/routes/_layout/catalog.apps.index.js.map +1 -1
- package/dist/esm/routes/_layout/index.js +2 -1
- package/dist/esm/routes/_layout/index.js.map +1 -1
- package/dist/esm/routes/_layout/service-desks.js +2 -1
- package/dist/esm/routes/_layout/service-desks.js.map +1 -1
- package/dist/esm/ui/components/header/Header.js +1 -1
- package/dist/esm/ui/components/header/Header.js.map +1 -1
- package/dist/index.css +73 -66
- package/package.json +4 -4
- package/src/__tests__/integration/appCatalog.integration.test.ts +106 -0
- package/src/__tests__/integration/appDeepLink.integration.test.ts +50 -24
- package/src/__tests__/integration/crossRefLinkListView.integration.test.ts +8 -8
- package/src/__tests__/integration/harness/given.tsx +9 -1
- package/src/__tests__/integration/replacementLink.integration.test.ts +6 -2
- package/src/__tests__/integration/searchDeprecatedFallback.integration.test.ts +21 -16
- package/src/__tests__/integration/setup/testSetup.ts +1 -0
- package/src/__tests__/integration/skewWarning.integration.test.ts +56 -0
- package/src/__tests__/integration/tools/AppDetailTools.ts +13 -0
- package/src/__tests__/integration/tools/CatalogTools.ts +60 -11
- package/src/__tests__/modules/appCatalog/utils/markdownToPlainText.test.ts +5 -5
- package/src/__tests__/modules/appCatalog/utils/resolveHelpers.test.ts +79 -0
- package/src/index.css +73 -66
- package/src/modules/appCatalog/hooks/useSessionSyncedState.ts +83 -0
- package/src/modules/appCatalog/ui/components/AccessPrerequisiteChain.tsx +89 -0
- package/src/modules/appCatalog/ui/components/AccessRequestSection.tsx +31 -2
- package/src/modules/appCatalog/ui/components/OnboardingCard.tsx +1 -1
- package/src/modules/appCatalog/ui/components/SubResourcesSection.tsx +22 -1
- package/src/modules/appCatalog/ui/context/AppCatalogFiltersContext.tsx +11 -5
- package/src/modules/appCatalog/ui/grid/AppCatalogGrid.tsx +68 -10
- package/src/modules/appCatalog/ui/launcher/LauncherDetailPanel.tsx +55 -0
- package/src/modules/appCatalog/ui/launcher/LauncherHome.tsx +497 -0
- package/src/modules/appCatalog/ui/launcher/ResourceIcon.tsx +54 -0
- package/src/modules/appCatalog/ui/launcher/monogram.ts +41 -0
- package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +75 -13
- package/src/modules/appCatalog/utils/markdownToPlainText.ts +1 -1
- package/src/modules/appCatalog/utils/resolveHelpers.ts +31 -0
- package/src/routes/_layout/app.$slug.tsx +7 -4
- package/src/routes/_layout/catalog.apps.index.tsx +4 -1
- package/src/routes/_layout/index.tsx +5 -1
- package/src/routes/_layout/service-desks.tsx +4 -1
- package/src/ui/components/header/Header.tsx +3 -1
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { Resource } from '@igstack/app-catalog-backend-core'
|
|
3
|
+
import {
|
|
4
|
+
getAccessPrerequisiteChain,
|
|
5
|
+
getParentResource,
|
|
6
|
+
} from '~/modules/appCatalog/utils/resolveHelpers'
|
|
7
|
+
|
|
8
|
+
const r = (over: Partial<Resource> & { slug: string }): Resource =>
|
|
9
|
+
({ id: over.slug, displayName: over.slug, ...over }) as Resource
|
|
10
|
+
|
|
11
|
+
describe('getParentResource', () => {
|
|
12
|
+
it('returns undefined for a root resource', () => {
|
|
13
|
+
const root = r({ slug: 'aws-console' })
|
|
14
|
+
expect(getParentResource([root], root)).toBeUndefined()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('finds the immediate parent by slug', () => {
|
|
18
|
+
const parent = r({ slug: 'aws-console' })
|
|
19
|
+
const child = r({ slug: 'acct-prod', parentSlug: 'aws-console' })
|
|
20
|
+
expect(getParentResource([parent, child], child)?.slug).toBe('aws-console')
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('getAccessPrerequisiteChain (#38 two-step access)', () => {
|
|
25
|
+
it('is empty for a root resource', () => {
|
|
26
|
+
const root = r({
|
|
27
|
+
slug: 'gitlab',
|
|
28
|
+
accessRequest: { approvalMethodSlug: 'x' },
|
|
29
|
+
})
|
|
30
|
+
expect(getAccessPrerequisiteChain([root], root)).toEqual([])
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('is empty when the parent has NO access policy (nothing to require first)', () => {
|
|
34
|
+
const parent = r({ slug: 'aws-console' }) // no accessRequest
|
|
35
|
+
const child = r({ slug: 'acct', parentSlug: 'aws-console' })
|
|
36
|
+
expect(getAccessPrerequisiteChain([parent, child], child)).toEqual([])
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('returns the access-bearing parent for a nested resource', () => {
|
|
40
|
+
const parent = r({
|
|
41
|
+
slug: 'aws-console',
|
|
42
|
+
accessRequest: { approvalMethodSlug: 'it-helpdesk' },
|
|
43
|
+
})
|
|
44
|
+
const child = r({ slug: 'acct-prod', parentSlug: 'aws-console' })
|
|
45
|
+
const chain = getAccessPrerequisiteChain([parent, child], child)
|
|
46
|
+
expect(chain.map((c) => c.slug)).toEqual(['aws-console'])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('orders multi-level chains root → nearest parent', () => {
|
|
50
|
+
const root = r({
|
|
51
|
+
slug: 'cloud',
|
|
52
|
+
accessRequest: { approvalMethodSlug: 'a' },
|
|
53
|
+
})
|
|
54
|
+
const mid = r({
|
|
55
|
+
slug: 'aws-console',
|
|
56
|
+
parentSlug: 'cloud',
|
|
57
|
+
accessRequest: { approvalMethodSlug: 'b' },
|
|
58
|
+
})
|
|
59
|
+
const leaf = r({ slug: 'acct', parentSlug: 'aws-console' })
|
|
60
|
+
const chain = getAccessPrerequisiteChain([root, mid, leaf], leaf)
|
|
61
|
+
expect(chain.map((c) => c.slug)).toEqual(['cloud', 'aws-console'])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('does not loop on a cyclic parent reference', () => {
|
|
65
|
+
const a = r({
|
|
66
|
+
slug: 'a',
|
|
67
|
+
parentSlug: 'b',
|
|
68
|
+
accessRequest: { approvalMethodSlug: 'x' },
|
|
69
|
+
})
|
|
70
|
+
const b = r({
|
|
71
|
+
slug: 'b',
|
|
72
|
+
parentSlug: 'a',
|
|
73
|
+
accessRequest: { approvalMethodSlug: 'y' },
|
|
74
|
+
})
|
|
75
|
+
// must terminate
|
|
76
|
+
const chain = getAccessPrerequisiteChain([a, b], a)
|
|
77
|
+
expect(chain.length).toBeLessThanOrEqual(2)
|
|
78
|
+
})
|
|
79
|
+
})
|
package/src/index.css
CHANGED
|
@@ -1,52 +1,59 @@
|
|
|
1
1
|
/* @import url(./modules/resourceJump/ui/JumpMainButton.css); */
|
|
2
2
|
@import 'tailwindcss';
|
|
3
|
+
/* Warm, crafted type to match the hand-drawn logo (see issue #31 redesign):
|
|
4
|
+
Fraunces = characterful display serif, Nunito Sans = friendly readable body. */
|
|
5
|
+
@import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600;9..144,700&family=Nunito+Sans:wght@400;500;600;700;800&display=swap');
|
|
3
6
|
/* Other module imports this css, and we need to tell tailwind to scan our tsx files */
|
|
4
7
|
@source ".";
|
|
5
8
|
|
|
6
9
|
/* @import 'tw-animate-css'; */
|
|
7
10
|
|
|
8
11
|
@layer base {
|
|
12
|
+
/* Warm "crayon" palette derived from the hand-drawn app-catalog logo
|
|
13
|
+
(orange #dc6827, amber #eca832, coral #e4724a, blue #3782c2, green #65a443)
|
|
14
|
+
on a cream paper background. Replaces the previous generic purple theme
|
|
15
|
+
that read as an AI template. See issue #31. */
|
|
9
16
|
:root {
|
|
10
|
-
--background: oklch(0.
|
|
11
|
-
--foreground: oklch(0.
|
|
12
|
-
--card: oklch(
|
|
13
|
-
--card-foreground: oklch(0.
|
|
14
|
-
--popover: oklch(
|
|
15
|
-
--popover-foreground: oklch(0.
|
|
16
|
-
--primary: oklch(0.
|
|
17
|
-
--primary-foreground: oklch(
|
|
18
|
-
--secondary: oklch(0.
|
|
19
|
-
--secondary-foreground: oklch(0.
|
|
20
|
-
--muted: oklch(0.
|
|
21
|
-
--muted-foreground: oklch(0.
|
|
22
|
-
--accent: oklch(0.
|
|
23
|
-
--accent-foreground: oklch(0.
|
|
17
|
+
--background: oklch(0.9825 0.0125 84); /* warm cream paper */
|
|
18
|
+
--foreground: oklch(0.28 0.02 55); /* warm near-black ink */
|
|
19
|
+
--card: oklch(0.995 0.004 84);
|
|
20
|
+
--card-foreground: oklch(0.28 0.02 55);
|
|
21
|
+
--popover: oklch(0.995 0.004 84);
|
|
22
|
+
--popover-foreground: oklch(0.28 0.02 55);
|
|
23
|
+
--primary: oklch(0.62 0.17 45); /* logo orange #dc6827 */
|
|
24
|
+
--primary-foreground: oklch(0.99 0.01 84);
|
|
25
|
+
--secondary: oklch(0.94 0.018 80); /* warm sunk panel */
|
|
26
|
+
--secondary-foreground: oklch(0.36 0.03 55);
|
|
27
|
+
--muted: oklch(0.955 0.012 82);
|
|
28
|
+
--muted-foreground: oklch(0.52 0.025 60);
|
|
29
|
+
--accent: oklch(0.93 0.05 70); /* soft amber accent */
|
|
30
|
+
--accent-foreground: oklch(0.36 0.04 55);
|
|
24
31
|
--destructive: oklch(0.6368 0.2078 25.3313);
|
|
25
32
|
--destructive-foreground: oklch(1 0 0);
|
|
26
|
-
--border: oklch(0.
|
|
27
|
-
--input: oklch(0.
|
|
28
|
-
--ring: oklch(0.
|
|
29
|
-
--chart-1: oklch(0.
|
|
30
|
-
--chart-2: oklch(0.
|
|
31
|
-
--chart-3: oklch(0.
|
|
32
|
-
--chart-4: oklch(0.
|
|
33
|
-
--chart-5: oklch(0.
|
|
34
|
-
--sidebar: oklch(0.
|
|
35
|
-
--sidebar-foreground: oklch(0.
|
|
36
|
-
--sidebar-primary: oklch(0.
|
|
37
|
-
--sidebar-primary-foreground: oklch(
|
|
38
|
-
--sidebar-accent: oklch(0.
|
|
39
|
-
--sidebar-accent-foreground: oklch(0.
|
|
40
|
-
--sidebar-border: oklch(0.
|
|
41
|
-
--sidebar-ring: oklch(0.
|
|
33
|
+
--border: oklch(0.9 0.018 80); /* warm hairline */
|
|
34
|
+
--input: oklch(0.9 0.018 80);
|
|
35
|
+
--ring: oklch(0.62 0.17 45);
|
|
36
|
+
--chart-1: oklch(0.62 0.17 45); /* orange */
|
|
37
|
+
--chart-2: oklch(0.62 0.14 230); /* blue #3782c2 */
|
|
38
|
+
--chart-3: oklch(0.68 0.14 135); /* green #65a443 */
|
|
39
|
+
--chart-4: oklch(0.75 0.14 75); /* amber #eca832 */
|
|
40
|
+
--chart-5: oklch(0.66 0.15 20); /* coral #e4724a */
|
|
41
|
+
--sidebar: oklch(0.955 0.012 82);
|
|
42
|
+
--sidebar-foreground: oklch(0.28 0.02 55);
|
|
43
|
+
--sidebar-primary: oklch(0.62 0.17 45);
|
|
44
|
+
--sidebar-primary-foreground: oklch(0.99 0.01 84);
|
|
45
|
+
--sidebar-accent: oklch(0.93 0.05 70);
|
|
46
|
+
--sidebar-accent-foreground: oklch(0.36 0.04 55);
|
|
47
|
+
--sidebar-border: oklch(0.9 0.018 80);
|
|
48
|
+
--sidebar-ring: oklch(0.62 0.17 45);
|
|
42
49
|
--font-sans:
|
|
43
|
-
ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
|
|
50
|
+
'Nunito Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
|
|
44
51
|
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
|
45
|
-
--font-serif:
|
|
52
|
+
--font-serif: 'Fraunces', Georgia, serif;
|
|
46
53
|
--font-mono:
|
|
47
54
|
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
|
|
48
55
|
'Courier New', monospace;
|
|
49
|
-
--radius: 0.
|
|
56
|
+
--radius: 0.875rem; /* rounder corners to match the friendly logo */
|
|
50
57
|
--shadow-2xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
|
|
51
58
|
--shadow-xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.05);
|
|
52
59
|
--shadow-sm:
|
|
@@ -73,44 +80,44 @@
|
|
|
73
80
|
--eh-context-border: #ff7926;
|
|
74
81
|
}
|
|
75
82
|
|
|
76
|
-
/*
|
|
83
|
+
/* Warm-dark equivalent of the crayon palette (see issue #31). */
|
|
77
84
|
.dark {
|
|
78
|
-
--background:
|
|
79
|
-
--foreground: oklch(0.
|
|
80
|
-
--card: oklch(0.
|
|
81
|
-
--card-foreground: oklch(0.
|
|
82
|
-
--popover: oklch(0.
|
|
83
|
-
--popover-foreground: oklch(0.
|
|
84
|
-
--primary: oklch(0.
|
|
85
|
-
--primary-foreground: oklch(0.
|
|
86
|
-
--secondary: oklch(0.
|
|
87
|
-
--secondary-foreground: oklch(0.
|
|
88
|
-
--muted: oklch(0.
|
|
89
|
-
--muted-foreground: oklch(0.
|
|
90
|
-
--accent: oklch(0.
|
|
91
|
-
--accent-foreground: oklch(0.
|
|
85
|
+
--background: oklch(0.19 0.012 60); /* warm charcoal, not blue-gray */
|
|
86
|
+
--foreground: oklch(0.93 0.012 84);
|
|
87
|
+
--card: oklch(0.23 0.014 58);
|
|
88
|
+
--card-foreground: oklch(0.93 0.012 84);
|
|
89
|
+
--popover: oklch(0.23 0.014 58);
|
|
90
|
+
--popover-foreground: oklch(0.93 0.012 84);
|
|
91
|
+
--primary: oklch(0.7 0.16 48); /* brightened logo orange for dark */
|
|
92
|
+
--primary-foreground: oklch(0.2 0.012 55);
|
|
93
|
+
--secondary: oklch(0.3 0.016 58);
|
|
94
|
+
--secondary-foreground: oklch(0.9 0.012 84);
|
|
95
|
+
--muted: oklch(0.26 0.014 58);
|
|
96
|
+
--muted-foreground: oklch(0.72 0.018 70);
|
|
97
|
+
--accent: oklch(0.34 0.03 60);
|
|
98
|
+
--accent-foreground: oklch(0.9 0.012 84);
|
|
92
99
|
--destructive: oklch(0.6368 0.2078 25.3313);
|
|
93
100
|
--destructive-foreground: oklch(0.2077 0.0398 265.7549);
|
|
94
|
-
--border: oklch(0.
|
|
95
|
-
--input: oklch(0.
|
|
96
|
-
--ring: oklch(0.
|
|
97
|
-
--chart-1: oklch(0.
|
|
98
|
-
--chart-2: oklch(0.
|
|
99
|
-
--chart-3: oklch(0.
|
|
100
|
-
--chart-4: oklch(0.
|
|
101
|
-
--chart-5: oklch(0.
|
|
102
|
-
--sidebar: oklch(0.
|
|
103
|
-
--sidebar-foreground: oklch(0.
|
|
104
|
-
--sidebar-primary: oklch(0.
|
|
105
|
-
--sidebar-primary-foreground: oklch(0.
|
|
106
|
-
--sidebar-accent: oklch(0.
|
|
107
|
-
--sidebar-accent-foreground: oklch(0.
|
|
108
|
-
--sidebar-border: oklch(0.
|
|
109
|
-
--sidebar-ring: oklch(0.
|
|
101
|
+
--border: oklch(0.36 0.016 58);
|
|
102
|
+
--input: oklch(0.36 0.016 58);
|
|
103
|
+
--ring: oklch(0.7 0.16 48);
|
|
104
|
+
--chart-1: oklch(0.7 0.16 48);
|
|
105
|
+
--chart-2: oklch(0.66 0.13 230);
|
|
106
|
+
--chart-3: oklch(0.7 0.14 135);
|
|
107
|
+
--chart-4: oklch(0.78 0.14 75);
|
|
108
|
+
--chart-5: oklch(0.68 0.15 20);
|
|
109
|
+
--sidebar: oklch(0.23 0.014 58);
|
|
110
|
+
--sidebar-foreground: oklch(0.93 0.012 84);
|
|
111
|
+
--sidebar-primary: oklch(0.7 0.16 48);
|
|
112
|
+
--sidebar-primary-foreground: oklch(0.2 0.012 55);
|
|
113
|
+
--sidebar-accent: oklch(0.34 0.03 60);
|
|
114
|
+
--sidebar-accent-foreground: oklch(0.9 0.012 84);
|
|
115
|
+
--sidebar-border: oklch(0.36 0.016 58);
|
|
116
|
+
--sidebar-ring: oklch(0.7 0.16 48);
|
|
110
117
|
--font-sans:
|
|
111
|
-
ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
|
|
118
|
+
'Nunito Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
|
|
112
119
|
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
|
113
|
-
--font-serif:
|
|
120
|
+
--font-serif: 'Fraunces', Georgia, serif;
|
|
114
121
|
--font-mono:
|
|
115
122
|
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
|
|
116
123
|
'Courier New', monospace;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Options for useSessionSyncedState hook
|
|
5
|
+
*/
|
|
6
|
+
export interface UseSessionSyncedStateOptions<T> {
|
|
7
|
+
/** The sessionStorage key */
|
|
8
|
+
key: string
|
|
9
|
+
/** Default value when the stored value is absent */
|
|
10
|
+
defaultValue: T
|
|
11
|
+
/** Optional decoder to transform the stored string to a state value */
|
|
12
|
+
decode?: (storedValue: string) => T
|
|
13
|
+
/** Optional encoder to transform the state value to a string (return undefined to clear) */
|
|
14
|
+
encode?: (stateValue: T) => string | undefined
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Hook for state that persists in `sessionStorage` instead of the URL.
|
|
19
|
+
*
|
|
20
|
+
* Why this exists (see #27): the catalog search value used to be
|
|
21
|
+
* synced to the URL (`?q=`) so it survived the per-route remount of
|
|
22
|
+
* `AppCatalogFiltersProvider` (#10). But `q` leaked into every shared/bookmarked
|
|
23
|
+
* app link. Backing the value with `sessionStorage` keeps the #10 guarantee —
|
|
24
|
+
* the value survives the remount and the auto-navigate to a single match — while
|
|
25
|
+
* keeping the URL clean.
|
|
26
|
+
*
|
|
27
|
+
* Key features vs {@link useUrlSyncedState}:
|
|
28
|
+
* - Initializes synchronously from `sessionStorage` on mount.
|
|
29
|
+
* - Writes synchronously in the setter (no async effect), which also removes the
|
|
30
|
+
* effect-ordering race #10 fought (a child auto-navigate effect could run
|
|
31
|
+
* before the provider's async state->URL sync).
|
|
32
|
+
* - Never navigates, so it never touches the URL.
|
|
33
|
+
*
|
|
34
|
+
* Defensive against environments without a functional `sessionStorage`
|
|
35
|
+
* (some CI jsdom setups) — falls back to plain in-memory state.
|
|
36
|
+
*/
|
|
37
|
+
export function useSessionSyncedState<T>({
|
|
38
|
+
key,
|
|
39
|
+
defaultValue,
|
|
40
|
+
decode,
|
|
41
|
+
encode,
|
|
42
|
+
}: UseSessionSyncedStateOptions<T>): [T, (value: T) => void] {
|
|
43
|
+
const [state, setStateInternal] = useState<T>(() => {
|
|
44
|
+
try {
|
|
45
|
+
// The try/catch is the real guard against a missing/throwing
|
|
46
|
+
// sessionStorage (SSR, locked-down jsdom); the access itself is typed
|
|
47
|
+
// non-null, so no optional chaining is needed here.
|
|
48
|
+
const stored = globalThis.sessionStorage.getItem(key)
|
|
49
|
+
if (stored !== null) {
|
|
50
|
+
return decode ? decode(stored) : (stored as T)
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
// sessionStorage unavailable — fall through to the default
|
|
54
|
+
}
|
|
55
|
+
return defaultValue
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const setState = useCallback(
|
|
59
|
+
(value: T) => {
|
|
60
|
+
setStateInternal(value)
|
|
61
|
+
try {
|
|
62
|
+
const encoded = encode ? encode(value) : (value as string | undefined)
|
|
63
|
+
if (encoded === undefined) {
|
|
64
|
+
globalThis.sessionStorage.removeItem(key)
|
|
65
|
+
} else {
|
|
66
|
+
globalThis.sessionStorage.setItem(key, encoded)
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
// sessionStorage unavailable — state still works in-memory
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
[key, encode],
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return [state, setState]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* sessionStorage key backing the catalog search value (#27). Exported so tests
|
|
80
|
+
* (and any tooling that needs to seed a "returning user" search) reference the
|
|
81
|
+
* same key as the provider.
|
|
82
|
+
*/
|
|
83
|
+
export const SEARCH_STORAGE_KEY = 'app-catalog:search'
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { Resource } from '@igstack/app-catalog-backend-core'
|
|
2
|
+
import { ArrowRight, KeyRound } from 'lucide-react'
|
|
3
|
+
import { useAppCatalogContext } from '../../context/AppCatalogContext'
|
|
4
|
+
import { getAccessPrerequisiteChain } from '../../utils/resolveHelpers'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Two-step (prerequisite) access banner (#38 item D). When the viewed resource
|
|
8
|
+
* is a child whose ancestor(s) carry an access policy, the domain model says
|
|
9
|
+
* the user must obtain the parent's access FIRST. That chain is invisible
|
|
10
|
+
* otherwise, so surface it explicitly as ordered steps:
|
|
11
|
+
*
|
|
12
|
+
* Step 1 — get access to <parent> (e.g. AWS Console via IT Helpdesk)
|
|
13
|
+
* Step 2 — get access to THIS resource (contact its owner / access contacts)
|
|
14
|
+
*
|
|
15
|
+
* Renders nothing for root resources or children with no access-bearing parent.
|
|
16
|
+
* `onOpenParent` lets the user jump to the parent's full access instructions.
|
|
17
|
+
*/
|
|
18
|
+
export function AccessPrerequisiteChain({
|
|
19
|
+
resource,
|
|
20
|
+
onOpenParent,
|
|
21
|
+
}: {
|
|
22
|
+
resource: Resource
|
|
23
|
+
onOpenParent?: (parent: Resource) => void
|
|
24
|
+
}) {
|
|
25
|
+
const { resources } = useAppCatalogContext()
|
|
26
|
+
const chain = getAccessPrerequisiteChain(resources, resource)
|
|
27
|
+
if (chain.length === 0) return null
|
|
28
|
+
|
|
29
|
+
// Steps = each prerequisite ancestor (root→nearest), then this resource last.
|
|
30
|
+
const steps = [...chain, resource]
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div
|
|
34
|
+
className="mt-6 rounded-lg border-[1.5px] border-primary/40 bg-primary/[0.03] p-4"
|
|
35
|
+
role="note"
|
|
36
|
+
aria-label="Access requires prerequisite steps"
|
|
37
|
+
>
|
|
38
|
+
<h3 className="mb-1 flex items-center gap-1.5 text-sm font-semibold text-foreground">
|
|
39
|
+
<KeyRound className="size-4 text-primary" />
|
|
40
|
+
Two-step access
|
|
41
|
+
</h3>
|
|
42
|
+
<p className="mb-3 text-xs text-muted-foreground">
|
|
43
|
+
This resource is nested — you need access to its parent first, then to
|
|
44
|
+
the resource itself.
|
|
45
|
+
</p>
|
|
46
|
+
<ol className="space-y-2">
|
|
47
|
+
{steps.map((r, i) => {
|
|
48
|
+
const isLast = i === steps.length - 1
|
|
49
|
+
return (
|
|
50
|
+
<li key={r.slug} className="flex items-start gap-2.5 text-sm">
|
|
51
|
+
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-[11px] font-bold text-primary-foreground">
|
|
52
|
+
{i + 1}
|
|
53
|
+
</span>
|
|
54
|
+
<span className="min-w-0">
|
|
55
|
+
<span className="text-muted-foreground">Get access to </span>
|
|
56
|
+
{isLast ? (
|
|
57
|
+
<span className="font-semibold">this resource</span>
|
|
58
|
+
) : onOpenParent ? (
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
onClick={() => onOpenParent(r)}
|
|
62
|
+
className="inline-flex items-center gap-1 font-semibold text-primary hover:underline"
|
|
63
|
+
>
|
|
64
|
+
{r.displayName}
|
|
65
|
+
<ArrowRight className="size-3" />
|
|
66
|
+
</button>
|
|
67
|
+
) : (
|
|
68
|
+
<span className="font-semibold">{r.displayName}</span>
|
|
69
|
+
)}
|
|
70
|
+
{!isLast && (
|
|
71
|
+
<span className="text-muted-foreground">
|
|
72
|
+
{' '}
|
|
73
|
+
(see its access instructions)
|
|
74
|
+
</span>
|
|
75
|
+
)}
|
|
76
|
+
{isLast && (
|
|
77
|
+
<span className="text-muted-foreground">
|
|
78
|
+
{' '}
|
|
79
|
+
— details below.
|
|
80
|
+
</span>
|
|
81
|
+
)}
|
|
82
|
+
</span>
|
|
83
|
+
</li>
|
|
84
|
+
)
|
|
85
|
+
})}
|
|
86
|
+
</ol>
|
|
87
|
+
</div>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
@@ -153,9 +153,38 @@ export function AccessRequestSection({
|
|
|
153
153
|
// Early return if no access request
|
|
154
154
|
if (!accessRequest) return null
|
|
155
155
|
|
|
156
|
+
// Access UX (#31): some methods carry no clickable target — `noAccessRequired`
|
|
157
|
+
// (open), `unknown` (undocumented), and bare `custom` with no comments. The
|
|
158
|
+
// old UI rendered nothing for `custom`, leaving a blank/dead section. Instead
|
|
159
|
+
// surface an explicit, actionable line so the user is never stranded:
|
|
160
|
+
// - open → "No request needed — just open it."
|
|
161
|
+
// - unknown / undocumented custom → route them to the owner (shown below).
|
|
162
|
+
const isOpen = approvalMethod?.type === 'noAccessRequired'
|
|
163
|
+
const hasWrittenSteps = Boolean(
|
|
164
|
+
accessRequest.requestPrompt || accessRequest.comments,
|
|
165
|
+
)
|
|
166
|
+
const isUndocumented =
|
|
167
|
+
approvalMethod?.type === 'unknown' ||
|
|
168
|
+
(approvalMethod?.type === 'custom' && !hasWrittenSteps)
|
|
169
|
+
|
|
156
170
|
return (
|
|
157
|
-
<div className="mt-6 space-y-4">
|
|
158
|
-
<h3 className="text-sm font-
|
|
171
|
+
<div className="mt-6 rounded-lg border-[1.5px] border-primary/40 bg-primary/[0.03] p-4 space-y-4">
|
|
172
|
+
<h3 className="text-sm font-semibold text-foreground">
|
|
173
|
+
How to get access
|
|
174
|
+
</h3>
|
|
175
|
+
|
|
176
|
+
{isOpen && (
|
|
177
|
+
<p className="text-sm font-medium text-primary">
|
|
178
|
+
✓ Open to everyone — no request needed. Just open it.
|
|
179
|
+
</p>
|
|
180
|
+
)}
|
|
181
|
+
|
|
182
|
+
{isUndocumented && (
|
|
183
|
+
<p className="text-sm text-muted-foreground">
|
|
184
|
+
Access process not documented yet — contact the owner below to find
|
|
185
|
+
out.
|
|
186
|
+
</p>
|
|
187
|
+
)}
|
|
159
188
|
|
|
160
189
|
{/* Approval Method */}
|
|
161
190
|
{approvalMethod && approvalMethod.type !== 'custom' && (
|
|
@@ -53,7 +53,7 @@ export function OnboardingCard({
|
|
|
53
53
|
</Button>
|
|
54
54
|
|
|
55
55
|
<div className="pr-10">
|
|
56
|
-
<h2 className="text-lg font-
|
|
56
|
+
<h2 className="font-serif text-lg font-semibold mb-2">{title}</h2>
|
|
57
57
|
<p className="text-muted-foreground mb-3 text-sm">{description}</p>
|
|
58
58
|
|
|
59
59
|
<div className="bg-muted/50 rounded-lg p-3">
|
|
@@ -25,6 +25,14 @@ import { markdownToPlainText } from '~/modules/appCatalog/utils/markdownToPlainT
|
|
|
25
25
|
|
|
26
26
|
interface SubResourcesSectionProps {
|
|
27
27
|
subResources: Resource[]
|
|
28
|
+
/**
|
|
29
|
+
* Initial filter text (#38 item C). When the user reached this app by
|
|
30
|
+
* searching a term that matched a sub-resource, seed the sub-resource filter
|
|
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
|
|
33
|
+
* account is pre-filtered). Only applied when it actually matches a child.
|
|
34
|
+
*/
|
|
35
|
+
initialSearch?: string
|
|
28
36
|
}
|
|
29
37
|
|
|
30
38
|
function getTierBadgeVariant(
|
|
@@ -90,9 +98,22 @@ function CopyAccountIdButton({ accountId }: { accountId: string }) {
|
|
|
90
98
|
|
|
91
99
|
export function SubResourcesSection({
|
|
92
100
|
subResources,
|
|
101
|
+
initialSearch,
|
|
93
102
|
}: SubResourcesSectionProps) {
|
|
94
103
|
const { groups } = useAppCatalogContext()
|
|
95
|
-
|
|
104
|
+
// Seed the filter with the incoming query ONLY if it matches a child, so we
|
|
105
|
+
// reveal the matched sub-resource without hiding everything on a non-match.
|
|
106
|
+
const seededSearch = useMemo(() => {
|
|
107
|
+
const q = initialSearch?.trim().toLowerCase()
|
|
108
|
+
if (!q) return ''
|
|
109
|
+
const hit = subResources.some(
|
|
110
|
+
(sr) =>
|
|
111
|
+
sr.displayName.toLowerCase().includes(q) ||
|
|
112
|
+
(sr.aliases ?? []).some((a) => a.toLowerCase().includes(q)),
|
|
113
|
+
)
|
|
114
|
+
return hit ? initialSearch!.trim() : ''
|
|
115
|
+
}, [initialSearch, subResources])
|
|
116
|
+
const [search, setSearch] = useState(seededSearch)
|
|
96
117
|
const [tierFilter, setTierFilter] = useState<string>('all')
|
|
97
118
|
|
|
98
119
|
const uniqueTiers = useMemo(() => {
|
|
@@ -2,6 +2,10 @@ import type { ReactNode } from 'react'
|
|
|
2
2
|
import { createContext, use, useMemo } from 'react'
|
|
3
3
|
import { useAppCatalogContext } from '../../context/AppCatalogContext'
|
|
4
4
|
import { useUrlSyncedState } from '../../hooks/useUrlSyncedState'
|
|
5
|
+
import {
|
|
6
|
+
SEARCH_STORAGE_KEY,
|
|
7
|
+
useSessionSyncedState,
|
|
8
|
+
} from '../../hooks/useSessionSyncedState'
|
|
5
9
|
import {
|
|
6
10
|
decodeFiltersParam,
|
|
7
11
|
encodeFiltersParam,
|
|
@@ -100,11 +104,13 @@ export function AppCatalogFiltersProvider({
|
|
|
100
104
|
encode: encodeFiltersParam,
|
|
101
105
|
})
|
|
102
106
|
|
|
103
|
-
// Search value
|
|
104
|
-
// e.g. auto-opening an app's detail page
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
107
|
+
// Search value persists in sessionStorage (not the URL) so it survives the
|
|
108
|
+
// per-route remount of this provider — e.g. auto-opening an app's detail page
|
|
109
|
+
// when the query narrows to one match (#10) — WITHOUT leaking `?q=` into every
|
|
110
|
+
// shared/bookmarked app link (#27). The synchronous store read/write also
|
|
111
|
+
// removes the effect-ordering race the URL sync had (#10).
|
|
112
|
+
const [searchValue, setSearchValue] = useSessionSyncedState<string>({
|
|
113
|
+
key: SEARCH_STORAGE_KEY,
|
|
108
114
|
defaultValue: '',
|
|
109
115
|
decode: (value) => value,
|
|
110
116
|
encode: (value) => (value === '' ? undefined : value),
|