@igstack/app-catalog-frontend-core 3.0.0 → 4.0.0-alpha-20260919004140
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__/modules/appCatalog/utils/quickJump.test.d.ts +1 -0
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/index.js +2 -1
- package/dist/esm/modules/appCatalog/context/AppCatalogContext.js +0 -1
- package/dist/esm/modules/appCatalog/context/AppCatalogContext.js.map +1 -1
- package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +5 -0
- package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js +1 -4
- package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js +0 -1
- package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/QuickJumpSection.d.ts +9 -0
- package/dist/esm/modules/appCatalog/ui/components/QuickJumpSection.js +191 -0
- package/dist/esm/modules/appCatalog/ui/components/QuickJumpSection.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js +0 -1
- package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +3 -2
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
- package/dist/esm/modules/appCatalog/utils/areaGrouping.d.ts +10 -3
- package/dist/esm/modules/appCatalog/utils/areaGrouping.js +4 -7
- package/dist/esm/modules/appCatalog/utils/areaGrouping.js.map +1 -1
- package/dist/esm/modules/appCatalog/utils/quickJump.d.ts +24 -0
- package/dist/esm/modules/appCatalog/utils/quickJump.js +25 -0
- package/dist/esm/modules/appCatalog/utils/quickJump.js.map +1 -0
- package/dist/esm/types/uiSettings.d.ts +0 -5
- package/dist/esm/ui/checkbox.js +32 -0
- package/dist/esm/ui/checkbox.js.map +1 -0
- package/dist/esm/ui/components/header/Header.js +0 -1
- package/dist/esm/ui/components/header/Header.js.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/modules/appCatalog/utils/areaGrouping.test.ts +19 -7
- package/src/__tests__/modules/appCatalog/utils/quickJump.test.ts +84 -0
- package/src/index.tsx +4 -1
- package/src/modules/appCatalog/ui/catalog/AppCatalogGrid.tsx +1 -4
- package/src/modules/appCatalog/ui/components/QuickJumpSection.tsx +253 -0
- package/src/modules/appCatalog/ui/detail/AppDetails.tsx +8 -2
- package/src/modules/appCatalog/utils/areaGrouping.ts +16 -16
- package/src/modules/appCatalog/utils/quickJump.ts +57 -0
- package/src/types/uiSettings.ts +0 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@igstack/app-catalog-frontend-core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0-alpha-20260919004140",
|
|
4
4
|
"description": "Frontend core library for App Catalog",
|
|
5
5
|
"homepage": "https://github.com/lislon/app-catalog",
|
|
6
6
|
"repository": {
|
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
"tailwindcss": "^4.1.7",
|
|
114
114
|
"tiptap-markdown": "^0.8.0",
|
|
115
115
|
"tslib": "^2.3.0",
|
|
116
|
-
"@igstack/app-catalog-shared-core": "
|
|
116
|
+
"@igstack/app-catalog-shared-core": "4.0.0-alpha-20260919004140"
|
|
117
117
|
},
|
|
118
118
|
"devDependencies": {
|
|
119
119
|
"@tailwindcss/vite": "^4.0.5",
|
|
@@ -140,7 +140,7 @@
|
|
|
140
140
|
"vite-plugin-static-copy": "^3.1.4",
|
|
141
141
|
"vite-plugin-svgr": "^4.2.0",
|
|
142
142
|
"vitest": "^4.1.2",
|
|
143
|
-
"@igstack/app-catalog-backend-core": "
|
|
143
|
+
"@igstack/app-catalog-backend-core": "4.0.0-alpha-20260919004140"
|
|
144
144
|
},
|
|
145
145
|
"peerDependencies": {
|
|
146
146
|
"react": "19.1.2",
|
|
@@ -149,7 +149,7 @@
|
|
|
149
149
|
"vite": "^6.3.5",
|
|
150
150
|
"vite-plugin-svgr": "^4.2.0"
|
|
151
151
|
},
|
|
152
|
-
"gitHead": "
|
|
152
|
+
"gitHead": "34a5c6bcfd90191f91c4bb520787aa126559e293",
|
|
153
153
|
"scripts": {
|
|
154
154
|
"build": "vite build",
|
|
155
155
|
"build:lenient": "vite build --mode lenient",
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
} from '@igstack/app-catalog-backend-core'
|
|
6
6
|
import {
|
|
7
7
|
DAY_TO_DAY_AREA_KEY,
|
|
8
|
+
DAY_TO_DAY_TAG,
|
|
8
9
|
areaLabel,
|
|
9
10
|
categoryOf,
|
|
10
11
|
groupByArea,
|
|
@@ -37,21 +38,32 @@ describe('groupByArea', () => {
|
|
|
37
38
|
app('f1', ['category:finance']),
|
|
38
39
|
app('s1', ['category:security']),
|
|
39
40
|
app('f2', ['category:finance']),
|
|
40
|
-
app('
|
|
41
|
+
app('shelved', ['category:security', DAY_TO_DAY_TAG]),
|
|
41
42
|
])
|
|
42
43
|
expect(groups.map(([key, list]) => [key, list.length])).toEqual([
|
|
43
44
|
[DAY_TO_DAY_AREA_KEY, 1],
|
|
44
45
|
['finance', 2],
|
|
45
|
-
['security',
|
|
46
|
+
['security', 2],
|
|
46
47
|
])
|
|
47
48
|
})
|
|
48
49
|
|
|
49
|
-
it('
|
|
50
|
-
const groups = groupByArea(
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
it('keeps a shelved app in its own category too', () => {
|
|
51
|
+
const groups = groupByArea([
|
|
52
|
+
app('chat', ['category:finance', DAY_TO_DAY_TAG]),
|
|
53
|
+
])
|
|
54
|
+
expect(groups.map(([key, list]) => [key, list.map((a) => a.slug)])).toEqual(
|
|
55
|
+
[
|
|
56
|
+
[DAY_TO_DAY_AREA_KEY, ['chat']],
|
|
57
|
+
['finance', ['chat']],
|
|
58
|
+
],
|
|
53
59
|
)
|
|
54
|
-
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('ignores a reach facet — only the placement tag shelves an app', () => {
|
|
63
|
+
const groups = groupByArea([
|
|
64
|
+
app('popular', ['category:finance', 'universality:everyone']),
|
|
65
|
+
])
|
|
66
|
+
expect(groups.map(([key]) => key)).toEqual(['finance'])
|
|
55
67
|
})
|
|
56
68
|
|
|
57
69
|
it('omits the day-to-day group when nothing belongs there', () => {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { QuickJump, Resource } from '@igstack/app-catalog-backend-core'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
buildQuickJumpUrl,
|
|
6
|
+
groupQuickJumps,
|
|
7
|
+
quickJumpFieldName,
|
|
8
|
+
} from '~/modules/appCatalog/utils/quickJump'
|
|
9
|
+
|
|
10
|
+
const onHost: QuickJump = {
|
|
11
|
+
identity: 'Case Id',
|
|
12
|
+
title: 'View case',
|
|
13
|
+
url: '{{baseHost}}/app/case?id={{value}}',
|
|
14
|
+
}
|
|
15
|
+
const absolute: QuickJump = {
|
|
16
|
+
identity: 'Case Id',
|
|
17
|
+
title: 'Search the event log',
|
|
18
|
+
url: 'https://events.example.com/search?q={{value}}',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function resource(quickJumps: QuickJump[], appUrl?: string): Resource {
|
|
22
|
+
return {
|
|
23
|
+
id: 'r1',
|
|
24
|
+
slug: 'app',
|
|
25
|
+
displayName: 'App',
|
|
26
|
+
description: '',
|
|
27
|
+
appUrl,
|
|
28
|
+
quickJumps,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('buildQuickJumpUrl', () => {
|
|
33
|
+
it("substitutes the resource's own host, without its trailing slash", () => {
|
|
34
|
+
expect(buildQuickJumpUrl(onHost, '42', 'https://app.example.com/')).toBe(
|
|
35
|
+
'https://app.example.com/app/case?id=42',
|
|
36
|
+
)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('drops a jump that needs a host the resource does not have', () => {
|
|
40
|
+
expect(buildQuickJumpUrl(onHost, '42', undefined)).toBeNull()
|
|
41
|
+
expect(buildQuickJumpUrl(absolute, '42', undefined)).toBe(
|
|
42
|
+
'https://events.example.com/search?q=42',
|
|
43
|
+
)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('encodes the typed value exactly once', () => {
|
|
47
|
+
// env-hopper ships %2540 in places — a genuine double-encode bug.
|
|
48
|
+
expect(buildQuickJumpUrl(absolute, 'a b&c@d')).toBe(
|
|
49
|
+
'https://events.example.com/search?q=a%20b%26c%40d',
|
|
50
|
+
)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('trims the typed value', () => {
|
|
54
|
+
expect(buildQuickJumpUrl(absolute, ' 42\n')).toBe(
|
|
55
|
+
'https://events.example.com/search?q=42',
|
|
56
|
+
)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('groupQuickJumps', () => {
|
|
61
|
+
it('groups by identity in first-appearance order and drops unbuildable jumps', () => {
|
|
62
|
+
const app = resource([
|
|
63
|
+
absolute,
|
|
64
|
+
{ ...onHost, identity: 'Clinic Id' },
|
|
65
|
+
{ ...absolute, title: 'Second case link' },
|
|
66
|
+
])
|
|
67
|
+
|
|
68
|
+
expect(groupQuickJumps(app)).toEqual([
|
|
69
|
+
{
|
|
70
|
+
identity: 'Case Id',
|
|
71
|
+
jumps: [absolute, { ...absolute, title: 'Second case link' }],
|
|
72
|
+
},
|
|
73
|
+
])
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('quickJumpFieldName', () => {
|
|
78
|
+
it('derives one stable autofill key per identity label', () => {
|
|
79
|
+
expect(quickJumpFieldName('Case Id')).toBe('qj-case-id')
|
|
80
|
+
expect(quickJumpFieldName('Sequencing Sample Id')).toBe(
|
|
81
|
+
'qj-sequencing-sample-id',
|
|
82
|
+
)
|
|
83
|
+
})
|
|
84
|
+
})
|
package/src/index.tsx
CHANGED
|
@@ -2,6 +2,9 @@ export { appPropsFactory } from './appPropsFactory.js'
|
|
|
2
2
|
export { App } from './App'
|
|
3
3
|
export type { AppProps } from './App'
|
|
4
4
|
export type { AreaIcon, AreasSettings, UiSettings } from './types/uiSettings'
|
|
5
|
-
export {
|
|
5
|
+
export {
|
|
6
|
+
DAY_TO_DAY_AREA_KEY,
|
|
7
|
+
DAY_TO_DAY_TAG,
|
|
8
|
+
} from './modules/appCatalog/utils/areaGrouping'
|
|
6
9
|
export { PwaAutoUpdateProvider, usePwaAutoUpdate } from './modules/pwa'
|
|
7
10
|
export type { PwaAutoUpdateOptions, PwaUpdateHandle } from './modules/pwa'
|
|
@@ -660,10 +660,7 @@ export function AppCatalogGrid({
|
|
|
660
660
|
|
|
661
661
|
// Grouped: day-to-day tools first, then areas biggest-first so the page opens
|
|
662
662
|
// on the areas most people need. Within an area, alpha (from browse).
|
|
663
|
-
const browseGroups = useMemo(
|
|
664
|
-
() => groupByArea(browse, areas?.dayToDayCategories),
|
|
665
|
-
[browse, areas?.dayToDayCategories],
|
|
666
|
-
)
|
|
663
|
+
const browseGroups = useMemo(() => groupByArea(browse), [browse])
|
|
667
664
|
|
|
668
665
|
const isSearching = searchValue.trim() !== ''
|
|
669
666
|
const searchRef = useRef<HTMLInputElement>(null)
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import type { QuickJump, Resource } from '@igstack/app-catalog-backend-core'
|
|
2
|
+
import { Pin, Rabbit, Settings2 } from 'lucide-react'
|
|
3
|
+
import React from 'react'
|
|
4
|
+
|
|
5
|
+
import { cn } from '~/lib/utils'
|
|
6
|
+
import { Checkbox } from '~/ui/checkbox'
|
|
7
|
+
import { Input } from '~/ui/input'
|
|
8
|
+
import { Popover, PopoverContent, PopoverTrigger } from '~/ui/popover'
|
|
9
|
+
import {
|
|
10
|
+
buildQuickJumpUrl,
|
|
11
|
+
groupQuickJumps,
|
|
12
|
+
quickJumpFieldName,
|
|
13
|
+
} from '~/modules/appCatalog/utils/quickJump'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Per-user, per-app preferences. Which identities to render and whether to hoist
|
|
17
|
+
* the section to the top of the card — a browser-local display choice, so it
|
|
18
|
+
* lives in localStorage rather than costing a table and an authenticated route.
|
|
19
|
+
*/
|
|
20
|
+
interface QuickJumpPrefs {
|
|
21
|
+
/** Keep the section at the top of the app card. */
|
|
22
|
+
pinned?: boolean
|
|
23
|
+
/** Identity labels to render. Absent = the first identity only. */
|
|
24
|
+
shown?: string[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function prefsKey(slug: string) {
|
|
28
|
+
return `ac:quickjump:${slug}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readPrefs(slug: string): QuickJumpPrefs {
|
|
32
|
+
try {
|
|
33
|
+
const raw = localStorage.getItem(prefsKey(slug))
|
|
34
|
+
return raw ? (JSON.parse(raw) as QuickJumpPrefs) : {}
|
|
35
|
+
} catch {
|
|
36
|
+
return {}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function writePrefs(slug: string, prefs: QuickJumpPrefs) {
|
|
41
|
+
try {
|
|
42
|
+
localStorage.setItem(prefsKey(slug), JSON.stringify(prefs))
|
|
43
|
+
} catch {
|
|
44
|
+
// Private mode / full quota: the session still works, just doesn't persist.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* One jump button. Disabled until its identity has a value: greyed and dashed,
|
|
50
|
+
* so the empty input above reads as the thing to do first. The green JUMP tell
|
|
51
|
+
* only shows on hover — one per row is repetition, not signal.
|
|
52
|
+
*/
|
|
53
|
+
function JumpButton({
|
|
54
|
+
jump,
|
|
55
|
+
value,
|
|
56
|
+
appUrl,
|
|
57
|
+
}: {
|
|
58
|
+
jump: QuickJump
|
|
59
|
+
value: string
|
|
60
|
+
appUrl?: string
|
|
61
|
+
}) {
|
|
62
|
+
const trimmed = value.trim()
|
|
63
|
+
const href = trimmed ? buildQuickJumpUrl(jump, trimmed, appUrl) : null
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<a
|
|
67
|
+
href={href ?? undefined}
|
|
68
|
+
target="_blank"
|
|
69
|
+
rel="noopener noreferrer"
|
|
70
|
+
aria-disabled={href ? undefined : true}
|
|
71
|
+
title={href ?? `Fill in ${jump.identity} first`}
|
|
72
|
+
className={cn(
|
|
73
|
+
'group/jump flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-left text-xs font-semibold transition-colors',
|
|
74
|
+
href
|
|
75
|
+
? 'animate-button-pop bg-card shadow-sm hover:bg-accent/40'
|
|
76
|
+
: 'pointer-events-none border-dashed bg-muted text-muted-foreground opacity-70',
|
|
77
|
+
)}
|
|
78
|
+
>
|
|
79
|
+
<span className="min-w-0 flex-1 wrap-anywhere">
|
|
80
|
+
{jump.title}
|
|
81
|
+
{trimmed && <span className="ml-1 font-mono">{trimmed}</span>}
|
|
82
|
+
</span>
|
|
83
|
+
<span
|
|
84
|
+
className={cn(
|
|
85
|
+
'flex shrink-0 origin-bottom items-center gap-1 text-hopper opacity-0 transition-[opacity,transform]',
|
|
86
|
+
'group-hover/jump:-translate-x-0.5 group-hover/jump:translate-y-0.5 group-hover/jump:rotate-2 group-hover/jump:scale-y-90',
|
|
87
|
+
'group-hover/jump:opacity-100 group-focus-visible/jump:opacity-100 motion-reduce:transform-none',
|
|
88
|
+
)}
|
|
89
|
+
>
|
|
90
|
+
<span className="text-[0.65rem] font-extrabold tracking-widest">
|
|
91
|
+
JUMP
|
|
92
|
+
</span>
|
|
93
|
+
<Rabbit className="size-4" />
|
|
94
|
+
</span>
|
|
95
|
+
</a>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Quick Jump: paste an id, open the matching page. One column per identity the
|
|
101
|
+
* app's jumps ask for; only the identities the user keeps are rendered, the rest
|
|
102
|
+
* are one click away behind Configure.
|
|
103
|
+
*/
|
|
104
|
+
export function QuickJumpSection({ app }: { app: Resource }) {
|
|
105
|
+
const identities = React.useMemo(() => groupQuickJumps(app), [app])
|
|
106
|
+
const [prefs, setPrefs] = React.useState<QuickJumpPrefs>(() =>
|
|
107
|
+
readPrefs(app.slug),
|
|
108
|
+
)
|
|
109
|
+
const [values, setValues] = React.useState<Record<string, string>>({})
|
|
110
|
+
|
|
111
|
+
// Re-read when the panel moves to another app — one key per slug.
|
|
112
|
+
React.useEffect(() => {
|
|
113
|
+
setPrefs(readPrefs(app.slug))
|
|
114
|
+
setValues({})
|
|
115
|
+
}, [app.slug])
|
|
116
|
+
|
|
117
|
+
if (identities.length === 0) return null
|
|
118
|
+
|
|
119
|
+
const savePrefs = (next: QuickJumpPrefs) => {
|
|
120
|
+
setPrefs(next)
|
|
121
|
+
writePrefs(app.slug, next)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const shown = prefs.shown ?? identities.slice(0, 1).map((i) => i.identity)
|
|
125
|
+
const visible = identities.filter((i) => shown.includes(i.identity))
|
|
126
|
+
|
|
127
|
+
const toggleShown = (identity: string) =>
|
|
128
|
+
savePrefs({
|
|
129
|
+
...prefs,
|
|
130
|
+
shown: shown.includes(identity)
|
|
131
|
+
? shown.filter((s) => s !== identity)
|
|
132
|
+
: [...shown, identity],
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<div className={cn('mt-6', prefs.pinned && 'order-[-1]')}>
|
|
137
|
+
<div className="mb-2 flex items-center gap-2">
|
|
138
|
+
<Rabbit className="size-4 shrink-0 text-hopper" />
|
|
139
|
+
<h3 className="text-sm font-medium">Quick Jump</h3>
|
|
140
|
+
<button
|
|
141
|
+
type="button"
|
|
142
|
+
onClick={() => savePrefs({ ...prefs, pinned: !prefs.pinned })}
|
|
143
|
+
title={
|
|
144
|
+
prefs.pinned
|
|
145
|
+
? 'Unpin section'
|
|
146
|
+
: 'Pin section — keep Quick Jump at the top of this card'
|
|
147
|
+
}
|
|
148
|
+
aria-pressed={prefs.pinned ?? false}
|
|
149
|
+
className={cn(
|
|
150
|
+
'rounded-sm p-0.5 transition-opacity hover:bg-muted',
|
|
151
|
+
prefs.pinned
|
|
152
|
+
? 'text-primary'
|
|
153
|
+
: 'text-muted-foreground opacity-50 hover:opacity-100',
|
|
154
|
+
)}
|
|
155
|
+
>
|
|
156
|
+
<Pin className="size-3.5" />
|
|
157
|
+
</button>
|
|
158
|
+
{prefs.pinned && (
|
|
159
|
+
<span className="text-xs font-semibold text-primary">
|
|
160
|
+
pinned to top
|
|
161
|
+
</span>
|
|
162
|
+
)}
|
|
163
|
+
|
|
164
|
+
{/* Nothing to configure with a single identity. */}
|
|
165
|
+
{identities.length > 1 && (
|
|
166
|
+
<Popover>
|
|
167
|
+
<PopoverTrigger className="ml-auto inline-flex items-center gap-1.5 rounded-full border border-primary/45 bg-accent/45 px-2.5 py-1 text-xs font-bold shadow-sm transition-colors hover:border-primary hover:bg-accent/80">
|
|
168
|
+
<Settings2 className="size-3.5 text-primary" />
|
|
169
|
+
Configure ({visible.length}/{identities.length})
|
|
170
|
+
</PopoverTrigger>
|
|
171
|
+
<PopoverContent align="end" className="w-64 p-1.5">
|
|
172
|
+
<p className="px-1.5 pt-0.5 pb-1.5 text-[0.7rem] text-muted-foreground">
|
|
173
|
+
Tick an ID to show it on this card.
|
|
174
|
+
</p>
|
|
175
|
+
{identities.map(({ identity, jumps }) => (
|
|
176
|
+
<label
|
|
177
|
+
key={identity}
|
|
178
|
+
className="flex cursor-pointer items-center gap-2 rounded-sm px-1.5 py-1.5 text-sm hover:bg-muted"
|
|
179
|
+
>
|
|
180
|
+
<Checkbox
|
|
181
|
+
checked={shown.includes(identity)}
|
|
182
|
+
onCheckedChange={() => toggleShown(identity)}
|
|
183
|
+
/>
|
|
184
|
+
{identity}
|
|
185
|
+
<span className="ml-auto font-mono text-[0.7rem] text-muted-foreground">
|
|
186
|
+
{jumps.length}
|
|
187
|
+
</span>
|
|
188
|
+
</label>
|
|
189
|
+
))}
|
|
190
|
+
</PopoverContent>
|
|
191
|
+
</Popover>
|
|
192
|
+
)}
|
|
193
|
+
</div>
|
|
194
|
+
<p className="mb-3 text-xs text-muted-foreground">
|
|
195
|
+
Paste an ID and open the matching page.
|
|
196
|
+
</p>
|
|
197
|
+
|
|
198
|
+
{visible.length === 0 ? (
|
|
199
|
+
<p className="text-xs text-muted-foreground">
|
|
200
|
+
No IDs shown — pick one under Configure.
|
|
201
|
+
</p>
|
|
202
|
+
) : (
|
|
203
|
+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
204
|
+
{visible.map(({ identity, jumps }) => {
|
|
205
|
+
const value = values[identity] ?? ''
|
|
206
|
+
return (
|
|
207
|
+
<div key={identity} className="flex min-w-0 flex-col gap-2">
|
|
208
|
+
<label
|
|
209
|
+
className="truncate text-xs font-bold"
|
|
210
|
+
htmlFor={`qj-${app.slug}-${identity}`}
|
|
211
|
+
>
|
|
212
|
+
{identity}
|
|
213
|
+
</label>
|
|
214
|
+
<Input
|
|
215
|
+
id={`qj-${app.slug}-${identity}`}
|
|
216
|
+
// Namespaced per identity: the browser remembers values typed
|
|
217
|
+
// for this identifier and offers them on every app using it.
|
|
218
|
+
name={quickJumpFieldName(identity)}
|
|
219
|
+
autoComplete="on"
|
|
220
|
+
placeholder={identity}
|
|
221
|
+
className="bg-card font-mono"
|
|
222
|
+
value={value}
|
|
223
|
+
onChange={(e) =>
|
|
224
|
+
setValues((v) => ({ ...v, [identity]: e.target.value }))
|
|
225
|
+
}
|
|
226
|
+
onKeyDown={(e) => {
|
|
227
|
+
const first = jumps[0]
|
|
228
|
+
if (e.key !== 'Enter' || !first) return
|
|
229
|
+
if (!e.currentTarget.value.trim()) return
|
|
230
|
+
const url = buildQuickJumpUrl(
|
|
231
|
+
first,
|
|
232
|
+
e.currentTarget.value,
|
|
233
|
+
app.appUrl,
|
|
234
|
+
)
|
|
235
|
+
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
|
236
|
+
}}
|
|
237
|
+
/>
|
|
238
|
+
{jumps.map((jump) => (
|
|
239
|
+
<JumpButton
|
|
240
|
+
key={`${jump.title}:${jump.url}`}
|
|
241
|
+
jump={jump}
|
|
242
|
+
value={value}
|
|
243
|
+
appUrl={app.appUrl}
|
|
244
|
+
/>
|
|
245
|
+
))}
|
|
246
|
+
</div>
|
|
247
|
+
)
|
|
248
|
+
})}
|
|
249
|
+
</div>
|
|
250
|
+
)}
|
|
251
|
+
</div>
|
|
252
|
+
)
|
|
253
|
+
}
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from '~/ui/accordion'
|
|
21
21
|
import { AccessRequestSection } from '../components/AccessRequestSection'
|
|
22
22
|
import { AccessPrerequisiteChain } from '../components/AccessPrerequisiteChain'
|
|
23
|
+
import { QuickJumpSection } from '../components/QuickJumpSection'
|
|
23
24
|
import { useAppCatalogFilters } from '../context/AppCatalogFiltersContext'
|
|
24
25
|
import { PersonBadge } from '../components/PersonBadge'
|
|
25
26
|
import { useUser } from '~/modules/auth'
|
|
@@ -215,8 +216,9 @@ export function AppDetails({
|
|
|
215
216
|
return (
|
|
216
217
|
<>
|
|
217
218
|
<div className="flex h-full flex-col p-6">
|
|
218
|
-
{/* Icon and Title
|
|
219
|
-
|
|
219
|
+
{/* Icon and Title — always first: a pinned Quick Jump takes order -1,
|
|
220
|
+
so the header claims -2 rather than being pushed below it. */}
|
|
221
|
+
<div className="-order-2 border-b pb-6">
|
|
220
222
|
<div className="flex items-center gap-3">
|
|
221
223
|
<AppIcon app={app} className="size-16" />
|
|
222
224
|
<div className="-mx-3 flex-1 min-w-0">
|
|
@@ -352,6 +354,10 @@ export function AppDetails({
|
|
|
352
354
|
{/* Access Request Section — hero of the detail, shown before description */}
|
|
353
355
|
<AccessRequestSection app={app} approvalMethods={approvalMethods} />
|
|
354
356
|
|
|
357
|
+
{/* Quick Jump — paste an id, open the matching page. Hoists itself to
|
|
358
|
+
the top of the card (flex order) when the user pins it. */}
|
|
359
|
+
<QuickJumpSection app={app} />
|
|
360
|
+
|
|
355
361
|
{/* Description */}
|
|
356
362
|
<div className="mt-6">
|
|
357
363
|
<h3 className="mb-2 text-sm font-medium">Description</h3>
|
|
@@ -6,14 +6,20 @@ import type {
|
|
|
6
6
|
/**
|
|
7
7
|
* Grouping of the full resource list into areas.
|
|
8
8
|
*
|
|
9
|
-
* The vocabulary itself is NOT here: which `category:<value>` tags exist
|
|
10
|
-
* they are called
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* The vocabulary itself is NOT here: which `category:<value>` tags exist and
|
|
10
|
+
* what they are called is deployment-specific. Labels come from the catalog's
|
|
11
|
+
* own tag definitions and icons from `UiSettings.areas`, so the core only knows
|
|
12
|
+
* the mechanism.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
const CATEGORY_PREFIX = 'category:'
|
|
16
|
-
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Opt-in to the day-to-day shelf. Placement only — it says where a resource is
|
|
19
|
+
* surfaced, never what it is or how many people use it, so it is independent of
|
|
20
|
+
* both `category:` and any reach/audience facet the catalog may declare.
|
|
21
|
+
*/
|
|
22
|
+
export const DAY_TO_DAY_TAG = 'placement:day-to-day'
|
|
17
23
|
|
|
18
24
|
/**
|
|
19
25
|
* Synthetic key for the merged first group. Underscored so it cannot collide
|
|
@@ -38,23 +44,17 @@ const humanize = (key: string): string => {
|
|
|
38
44
|
* Day-to-day tools first, then one area per category, biggest area first.
|
|
39
45
|
* Input order is preserved inside each group.
|
|
40
46
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
47
|
+
* The shelf is **additive**: a `placement:day-to-day` resource is a shortcut at
|
|
48
|
+
* the top *and* still listed under its own category. Hoisting it out instead
|
|
49
|
+
* silently emptied categories of their best-known members.
|
|
43
50
|
*/
|
|
44
|
-
export function groupByArea(
|
|
45
|
-
apps: Resource[],
|
|
46
|
-
dayToDayCategories: readonly string[] = [],
|
|
47
|
-
): [string, Resource[]][] {
|
|
48
|
-
const folded = new Set(dayToDayCategories)
|
|
51
|
+
export function groupByArea(apps: Resource[]): [string, Resource[]][] {
|
|
49
52
|
const dayToDay: Resource[] = []
|
|
50
53
|
const byCategory = new Map<string, Resource[]>()
|
|
51
54
|
|
|
52
55
|
for (const app of apps) {
|
|
56
|
+
if (app.tags?.includes(DAY_TO_DAY_TAG)) dayToDay.push(app)
|
|
53
57
|
const key = categoryOf(app)
|
|
54
|
-
if (app.tags?.includes(EVERYONE_TAG) || folded.has(key)) {
|
|
55
|
-
dayToDay.push(app)
|
|
56
|
-
continue
|
|
57
|
-
}
|
|
58
58
|
const list = byCategory.get(key)
|
|
59
59
|
if (list) list.push(app)
|
|
60
60
|
else byCategory.set(key, [app])
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { QuickJump, Resource } from '@igstack/app-catalog-backend-core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build a jump's target url. The whole template "engine": two placeholders,
|
|
5
|
+
* `{{baseHost}}` substituted raw (it is a url prefix) and `{{value}}` encoded
|
|
6
|
+
* exactly once. Returns null when the jump needs a host the resource does not
|
|
7
|
+
* have — the caller drops it instead of rendering a broken link.
|
|
8
|
+
*/
|
|
9
|
+
export function buildQuickJumpUrl(
|
|
10
|
+
jump: QuickJump,
|
|
11
|
+
value: string,
|
|
12
|
+
appUrl?: string,
|
|
13
|
+
): string | null {
|
|
14
|
+
const needsHost = jump.url.includes('{{baseHost}}')
|
|
15
|
+
const host = (appUrl ?? '').replace(/\/+$/, '')
|
|
16
|
+
if (needsHost && !host) return null
|
|
17
|
+
|
|
18
|
+
return jump.url
|
|
19
|
+
.replaceAll('{{baseHost}}', host)
|
|
20
|
+
.replaceAll('{{value}}', encodeURIComponent(value.trim()))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface QuickJumpIdentity {
|
|
24
|
+
/** Human label, e.g. "Case Id" — also the column heading. */
|
|
25
|
+
identity: string
|
|
26
|
+
jumps: QuickJump[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Group an app's jumps by identity, keeping first-appearance order (so company
|
|
31
|
+
* config controls the column order) and dropping jumps this app cannot build.
|
|
32
|
+
*/
|
|
33
|
+
export function groupQuickJumps(app: Resource): QuickJumpIdentity[] {
|
|
34
|
+
const groups: QuickJumpIdentity[] = []
|
|
35
|
+
|
|
36
|
+
for (const jump of app.quickJumps ?? []) {
|
|
37
|
+
if (buildQuickJumpUrl(jump, 'probe', app.appUrl) === null) continue
|
|
38
|
+
|
|
39
|
+
const group = groups.find((g) => g.identity === jump.identity)
|
|
40
|
+
if (group) group.jumps.push(jump)
|
|
41
|
+
else groups.push({ identity: jump.identity, jumps: [jump] })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return groups
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Autofill key for an identity's input. Namespaced and derived from the label,
|
|
49
|
+
* so the same identifier autofills from history across apps — the browser gives
|
|
50
|
+
* us per-identity value history for free.
|
|
51
|
+
*/
|
|
52
|
+
export function quickJumpFieldName(identity: string): string {
|
|
53
|
+
return `qj-${identity
|
|
54
|
+
.toLowerCase()
|
|
55
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
56
|
+
.replace(/^-+|-+$/g, '')}`
|
|
57
|
+
}
|
package/src/types/uiSettings.ts
CHANGED
|
@@ -19,11 +19,6 @@ export interface AreasSettings {
|
|
|
19
19
|
* merged first group. Areas without an icon render title-only.
|
|
20
20
|
*/
|
|
21
21
|
icons?: Record<string, AreaIcon>
|
|
22
|
-
/**
|
|
23
|
-
* Category values that fold into the day-to-day group instead of getting an
|
|
24
|
-
* area of their own (e.g. perks, office services).
|
|
25
|
-
*/
|
|
26
|
-
dayToDayCategories?: string[]
|
|
27
22
|
/** Title of the merged first group. Defaults to "Day-to-day tools". */
|
|
28
23
|
dayToDayLabel?: string
|
|
29
24
|
}
|