@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.
Files changed (38) hide show
  1. package/dist/esm/__tests__/modules/appCatalog/utils/quickJump.test.d.ts +1 -0
  2. package/dist/esm/index.d.ts +1 -1
  3. package/dist/esm/index.js +2 -1
  4. package/dist/esm/modules/appCatalog/context/AppCatalogContext.js +0 -1
  5. package/dist/esm/modules/appCatalog/context/AppCatalogContext.js.map +1 -1
  6. package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +5 -0
  7. package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js +1 -4
  8. package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js.map +1 -1
  9. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js +0 -1
  10. package/dist/esm/modules/appCatalog/ui/components/PersonBadge.js.map +1 -1
  11. package/dist/esm/modules/appCatalog/ui/components/QuickJumpSection.d.ts +9 -0
  12. package/dist/esm/modules/appCatalog/ui/components/QuickJumpSection.js +191 -0
  13. package/dist/esm/modules/appCatalog/ui/components/QuickJumpSection.js.map +1 -0
  14. package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js +0 -1
  15. package/dist/esm/modules/appCatalog/ui/components/TierVariantsSection.js.map +1 -1
  16. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +3 -2
  17. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
  18. package/dist/esm/modules/appCatalog/utils/areaGrouping.d.ts +10 -3
  19. package/dist/esm/modules/appCatalog/utils/areaGrouping.js +4 -7
  20. package/dist/esm/modules/appCatalog/utils/areaGrouping.js.map +1 -1
  21. package/dist/esm/modules/appCatalog/utils/quickJump.d.ts +24 -0
  22. package/dist/esm/modules/appCatalog/utils/quickJump.js +25 -0
  23. package/dist/esm/modules/appCatalog/utils/quickJump.js.map +1 -0
  24. package/dist/esm/types/uiSettings.d.ts +0 -5
  25. package/dist/esm/ui/checkbox.js +32 -0
  26. package/dist/esm/ui/checkbox.js.map +1 -0
  27. package/dist/esm/ui/components/header/Header.js +0 -1
  28. package/dist/esm/ui/components/header/Header.js.map +1 -1
  29. package/package.json +4 -4
  30. package/src/__tests__/modules/appCatalog/utils/areaGrouping.test.ts +19 -7
  31. package/src/__tests__/modules/appCatalog/utils/quickJump.test.ts +84 -0
  32. package/src/index.tsx +4 -1
  33. package/src/modules/appCatalog/ui/catalog/AppCatalogGrid.tsx +1 -4
  34. package/src/modules/appCatalog/ui/components/QuickJumpSection.tsx +253 -0
  35. package/src/modules/appCatalog/ui/detail/AppDetails.tsx +8 -2
  36. package/src/modules/appCatalog/utils/areaGrouping.ts +16 -16
  37. package/src/modules/appCatalog/utils/quickJump.ts +57 -0
  38. 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.0.0",
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": "3.0.0"
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": "3.0.0"
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": "9762dd89f7623b15f0507f9936308720b58527ab",
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('everyone', ['category:security', 'universality:everyone']),
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', 1],
46
+ ['security', 2],
46
47
  ])
47
48
  })
48
49
 
49
- it('folds a configured category into the day-to-day group', () => {
50
- const groups = groupByArea(
51
- [app('lunch', ['category:perks']), app('bank', ['category:finance'])],
52
- ['perks'],
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
- expect(groups.map(([key]) => key)).toEqual([DAY_TO_DAY_AREA_KEY, 'finance'])
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 { DAY_TO_DAY_AREA_KEY } from './modules/appCatalog/utils/areaGrouping'
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
- <div className="border-b pb-6">
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, what
10
- * they are called and which of them belong on the day-to-day shelf is
11
- * deployment-specific. Labels come from the catalog's own tag definitions and
12
- * icons from `UiSettings.areas`, so the core only knows the mechanism.
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
- const EVERYONE_TAG = 'universality:everyone'
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
- * `dayToDayCategories` are categories that fold into the day-to-day group
42
- * instead of getting an area of their own (perks, office services, …).
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
+ }
@@ -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
  }