@igstack/app-catalog-frontend-core 6.3.1-alpha-20261003051755 → 6.5.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.
Files changed (73) hide show
  1. package/dist/esm/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.d.ts +0 -0
  2. package/dist/esm/__tests__/modules/appCatalog/ui/detail/FeedbackSection.emptyState.test.d.ts +0 -0
  3. package/dist/esm/__tests__/modules/appCatalog/ui/detail/FeedbackSection.reviewStatus.test.d.ts +0 -0
  4. package/dist/esm/__tests__/modules/appCatalog/utils/accessTabLabel.test.d.ts +1 -0
  5. package/dist/esm/api/infra/trpc.d.ts +42 -21
  6. package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +1 -0
  7. package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js +19 -0
  8. package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js.map +1 -1
  9. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js +17 -2
  10. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js.map +1 -1
  11. package/dist/esm/modules/appCatalog/ui/catalog/RequestAppDialog.d.ts +14 -0
  12. package/dist/esm/modules/appCatalog/ui/catalog/RequestAppDialog.js +115 -0
  13. package/dist/esm/modules/appCatalog/ui/catalog/RequestAppDialog.js.map +1 -0
  14. package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js +1 -0
  15. package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js.map +1 -1
  16. package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js +11 -4
  17. package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js.map +1 -1
  18. package/dist/esm/modules/appCatalog/ui/components/QuickJumpBar.js +0 -4
  19. package/dist/esm/modules/appCatalog/ui/components/QuickJumpBar.js.map +1 -1
  20. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js +4 -1
  21. package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js.map +1 -1
  22. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +377 -385
  23. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
  24. package/dist/esm/modules/appCatalog/ui/detail/AppDoor.d.ts +20 -0
  25. package/dist/esm/modules/appCatalog/ui/detail/AppDoor.js +59 -0
  26. package/dist/esm/modules/appCatalog/ui/detail/AppDoor.js.map +1 -0
  27. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.d.ts +64 -0
  28. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js +90 -0
  29. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js.map +1 -0
  30. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.d.ts +13 -0
  31. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js +433 -0
  32. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js.map +1 -0
  33. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js +18 -1
  34. package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
  35. package/dist/esm/modules/appCatalog/utils/accessTabLabel.d.ts +30 -0
  36. package/dist/esm/modules/appCatalog/utils/accessTabLabel.js +34 -0
  37. package/dist/esm/modules/appCatalog/utils/accessTabLabel.js.map +1 -0
  38. package/dist/index.css +269 -5
  39. package/package.json +4 -4
  40. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.appSwitch.test.tsx +17 -3
  41. package/src/__tests__/modules/appCatalog/ui/catalog/RequestAppDialog.test.tsx +113 -0
  42. package/src/__tests__/modules/appCatalog/ui/components/AccessRequestSection.test.tsx +6 -4
  43. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.background.test.tsx +25 -10
  44. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.doorAndTabs.test.tsx +193 -0
  45. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.escape.test.tsx +2 -2
  46. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.sectionOrder.test.tsx +32 -9
  47. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.tsx +125 -0
  48. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.tags.test.tsx +2 -2
  49. package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.emptyState.test.tsx +181 -0
  50. package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.reviewStatus.test.tsx +131 -0
  51. package/src/__tests__/modules/appCatalog/utils/accessTabLabel.test.ts +206 -0
  52. package/src/__tests__/ui/components/header/Header.loginHidden.test.tsx +1 -1
  53. package/src/index.css +269 -5
  54. package/src/modules/appCatalog/ui/catalog/AppCatalogGrid.tsx +24 -0
  55. package/src/modules/appCatalog/ui/catalog/AppDetailPanel.tsx +46 -2
  56. package/src/modules/appCatalog/ui/catalog/RequestAppDialog.tsx +169 -0
  57. package/src/modules/appCatalog/ui/components/AccessPrerequisiteChain.tsx +3 -0
  58. package/src/modules/appCatalog/ui/components/AccessRequestSection.tsx +9 -4
  59. package/src/modules/appCatalog/ui/components/SubResourceDetailPanel.tsx +4 -1
  60. package/src/modules/appCatalog/ui/detail/AppDetails.tsx +574 -554
  61. package/src/modules/appCatalog/ui/detail/AppDoor.tsx +97 -0
  62. package/src/modules/appCatalog/ui/detail/DetailTabs.tsx +195 -0
  63. package/src/modules/appCatalog/ui/detail/FeedbackSection.tsx +605 -0
  64. package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +20 -1
  65. package/src/modules/appCatalog/utils/accessTabLabel.ts +95 -0
  66. package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.d.ts +0 -3
  67. package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.js +0 -228
  68. package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.js.map +0 -1
  69. package/src/__tests__/modules/appCatalog/ui/detail/CommentsSection.emptyState.test.tsx +0 -64
  70. package/src/__tests__/modules/appCatalog/ui/detail/CommentsSection.reviewStatus.test.tsx +0 -93
  71. package/src/modules/appCatalog/ui/detail/CommentsSection.tsx +0 -296
  72. /package/dist/esm/__tests__/modules/appCatalog/ui/{detail/CommentsSection.emptyState.test.d.ts → catalog/RequestAppDialog.test.d.ts} +0 -0
  73. /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{CommentsSection.reviewStatus.test.d.ts → AppDetails.doorAndTabs.test.d.ts} +0 -0
@@ -0,0 +1,97 @@
1
+ import type { Resource } from '@igstack/app-catalog-backend-core'
2
+ import { ExternalLink } from 'lucide-react'
3
+ import { cn } from '~/lib/utils'
4
+ import { ResourceIcon } from '../catalog/ResourceIcon'
5
+ import { displayUrl } from '~/modules/appCatalog/utils/displayUrl'
6
+
7
+ /** Past this many characters the name needs the smaller size step. */
8
+ const LONG_TITLE = 28
9
+
10
+ /** Icon size on the door — one number, applied to image and monogram alike. */
11
+ const ICON = 56
12
+
13
+ function titleText(app: Resource): string {
14
+ return app.abbreviation
15
+ ? `${app.displayName} (${app.abbreviation})`
16
+ : app.displayName
17
+ }
18
+
19
+ /**
20
+ * The way into the app: icon, name and address as ONE pressable surface.
21
+ *
22
+ * Deliberately not a button beside a heading. The complaint this answers is
23
+ * people not knowing where to click, and the card's largest, most obvious
24
+ * element was inert while a small labelled control carried the action — so the
25
+ * largest element becomes the action. Hover and keyboard focus get the SAME
26
+ * treatment, because an affordance only a mouse can discover is no affordance
27
+ * for anyone tabbing; see `.app-door` in index.css for the measured states.
28
+ *
29
+ * Renders plain, unpressable text when the resource has no address — a third of
30
+ * the catalog has none, and a surface that looks pressable and is not is worse
31
+ * than one that never offered.
32
+ */
33
+ export function AppDoor({
34
+ app,
35
+ onOpen,
36
+ }: {
37
+ app: Resource
38
+ /** Records the visit. Not navigation — the anchor does that itself. */
39
+ onOpen?: () => void
40
+ }) {
41
+ const title = titleText(app)
42
+ const titleClass = cn(
43
+ 'app-door__title',
44
+ title.length > LONG_TITLE && 'app-door__title--long',
45
+ )
46
+
47
+ if (!app.appUrl) {
48
+ return (
49
+ <div className="inline-flex items-center gap-[0.9375rem]">
50
+ <ResourceIcon app={app} size={ICON} />
51
+ <div className="min-w-0">
52
+ <span className={titleClass}>{title}</span>
53
+ {/* Says which of two situations the reader is in: this app has no
54
+ address on file, as opposed to the card having failed to show one.
55
+ The way in, if there is one, is the access tab. */}
56
+ <span className="mt-1 block text-[0.8125rem] italic text-muted-foreground opacity-75">
57
+ No URL for this app.
58
+ </span>
59
+ </div>
60
+ </div>
61
+ )
62
+ }
63
+
64
+ const pretty = displayUrl(app.appUrl)
65
+
66
+ return (
67
+ <a
68
+ href={app.appUrl}
69
+ target="_blank"
70
+ rel="noopener noreferrer"
71
+ onClick={onOpen}
72
+ className="app-door"
73
+ // Names the destination and the new tab, because the visible text is the
74
+ // app's name and the address on its own line — neither says "opens away
75
+ // from here", and the glyph cannot say it to a screen reader.
76
+ aria-label={`Open ${app.displayName} in a new tab (${pretty})`}
77
+ >
78
+ <ResourceIcon app={app} size={ICON} className="app-door__icon" />
79
+ <span className="min-w-0">
80
+ <span className={titleClass}>{title}</span>
81
+ {/* Truncated, because some of these are 97 characters of Google Sites
82
+ path. Left whole, the longest URL in the catalog set the width of the
83
+ door, pushed the band's own controls onto a second row, and told the
84
+ reader nothing the host had not already told them. The full address is
85
+ on the link's title and in its aria-label, and clicking it is the
86
+ actual way to use it. */}
87
+ <span className="app-door__url truncate" title={pretty}>
88
+ {pretty}
89
+ </span>
90
+ </span>
91
+ <ExternalLink
92
+ className="app-door__glyph size-[18px]"
93
+ aria-hidden="true"
94
+ />
95
+ </a>
96
+ )
97
+ }
@@ -0,0 +1,195 @@
1
+ import React from 'react'
2
+ import { cn } from '~/lib/utils'
3
+ import { useUrlSyncedState } from '~/modules/appCatalog/hooks/useUrlSyncedState'
4
+
5
+ export interface DetailTab {
6
+ /** Stable id — this is what lands in `?tab=`, so treat it as public. */
7
+ id: string
8
+ label: string
9
+ /** One short count beside the label ("430", "55 roles", "2 steps"). */
10
+ chip?: string
11
+ /** Marks unreviewed content. Also stated in the accessible name. */
12
+ dot?: boolean
13
+ /** Spoken instead of `label` when the chip and dot need explaining. */
14
+ ariaLabel?: string
15
+ render: () => React.ReactNode
16
+ }
17
+
18
+ /**
19
+ * The detail card's tabs.
20
+ *
21
+ * Every entry gets them, including thin ones. Two earlier rules were worse:
22
+ * hiding a tab with little in it turned "we have not documented the way in"
23
+ * into a dead end you had to click to discover, and showing them only on heavy
24
+ * entries made the card's shape change between apps, so "where is access?" had
25
+ * two answers and no habit could form across a few hundred entries. Naming the
26
+ * route in the tab itself removes the reason to hide anything — the label tells
27
+ * you whether opening it is worth it.
28
+ *
29
+ * The active tab lives in the url so a card is linkable mid-conversation. A
30
+ * `forcedTab` (today: arriving with `?sub=`) wins over `?tab=`, because a url
31
+ * that selects a child and then shows a different tab is lying about itself.
32
+ *
33
+ * Panels mount only while active, which also resets their internal state on
34
+ * the way out — the same choice the card makes when it navigates between apps.
35
+ */
36
+ export function DetailTabs({
37
+ tabs,
38
+ forcedTab,
39
+ requestTab,
40
+ requestNonce,
41
+ band,
42
+ bandClassName,
43
+ }: {
44
+ tabs: DetailTab[]
45
+ /** Overrides `?tab=` while set. */
46
+ forcedTab?: string
47
+ /**
48
+ * A one-shot "please show this tab" from outside the strip — the band's
49
+ * "Suggest a change" uses it.
50
+ *
51
+ * It has to come through here rather than the caller writing `?tab=` itself:
52
+ * `useUrlSyncedState` keeps its own `useState` seeded once from the URL and only
53
+ * ever pushes state OUT to the URL, so a second instance writing the same key
54
+ * does not update this one. The two then fight — the strip's next render sees the
55
+ * URL disagreeing with its own state and navigates back over it.
56
+ */
57
+ requestTab?: string
58
+ /**
59
+ * Bumped by the caller every time it asks again. Without it a repeat request is
60
+ * indistinguishable from the same standing value, so the effect below does not
61
+ * re-fire and the second ask does nothing — which is what made the band's
62
+ * button work exactly once per card.
63
+ */
64
+ requestNonce?: number
65
+ /**
66
+ * Identity and actions, shown ABOVE the strip and inside the same tinted
67
+ * surface. Owned here rather than by the caller because the band and the
68
+ * strip have to be one visual unit with the panel outside it — render the
69
+ * panel inside the band and it inherits the tint, which is the mistake this
70
+ * prop exists to make impossible.
71
+ */
72
+ band?: React.ReactNode
73
+ /** Cancels the dialog's own padding so the tint reaches the card's edges. */
74
+ bandClassName?: string
75
+ }) {
76
+ /**
77
+ * The first tab is what the card opens on, so naming it in the URL says nothing.
78
+ * `encode` returning undefined drops the param, which keeps `?tab=` out of a link
79
+ * until someone has actually chosen something other than the default — and keeps a
80
+ * shared link honest about what the sender was looking at.
81
+ */
82
+ const defaultTabId = tabs[0]?.id
83
+ const [urlTab, setUrlTab] = useUrlSyncedState<string>({
84
+ key: 'tab',
85
+ defaultValue: '',
86
+ encode: (value) => (value && value !== defaultTabId ? value : undefined),
87
+ })
88
+
89
+ const ids = tabs.map((t) => t.id)
90
+
91
+ /**
92
+ * Tab ids are public — they are in `?tab=` in links people have already shared.
93
+ * Renaming one would silently drop those onto the first tab, so a retired id
94
+ * keeps resolving to its replacement instead.
95
+ */
96
+ const RETIRED: Record<string, string> = { metadata: 'documentation' }
97
+
98
+ /**
99
+ * `forcedTab` picks the tab you ARRIVE on, not the tab you are stuck on.
100
+ *
101
+ * It used to win over `?tab=` for as long as it was set, and `?sub=` keeps it set
102
+ * for the whole life of the card — so on any entry reached by a sub-resource
103
+ * search (667 accounts on one of them) the strip was dead: every click wrote
104
+ * nothing and the tab never moved. The same override also silently killed the
105
+ * band's "Suggest a change", which navigates by writing `?tab=`.
106
+ *
107
+ * So it applies until the user expresses a preference, and then gets out of the
108
+ * way. `userChose` is what distinguishes "no opinion yet" from "chose the tab
109
+ * that happens to be the forced one".
110
+ */
111
+ const [userChose, setUserChose] = React.useState(false)
112
+
113
+ // Applied when it changes, through the state that actually owns the tab.
114
+ React.useEffect(() => {
115
+ if (!requestTab) return
116
+ setUserChose(true)
117
+ setUrlTab(requestTab)
118
+ }, [requestTab, requestNonce, setUrlTab])
119
+ const asked = userChose || !forcedTab ? urlTab : forcedTab
120
+ const requested = RETIRED[asked] ?? asked
121
+ const activeId = ids.includes(requested) ? requested : (ids[0] ?? '')
122
+ const active = tabs.find((t) => t.id === activeId)
123
+
124
+ // Arrow keys move between tabs, per the tablist pattern. Selection follows
125
+ // focus, which is correct here because every panel is already loaded.
126
+ const onKeyDown = (event: React.KeyboardEvent) => {
127
+ const delta =
128
+ event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0
129
+ if (!delta) return
130
+ event.preventDefault()
131
+ const from = ids.indexOf(activeId)
132
+ const next = ids[(from + delta + ids.length) % ids.length]
133
+ if (!next) return
134
+ setUserChose(true)
135
+ setUrlTab(next)
136
+ // Focus follows selection, or the arrow keys would walk the visual
137
+ // selection away from the keyboard's position. By id rather than by
138
+ // position in a NodeList: each tab already carries one, and an id lookup
139
+ // says "missing" with a null instead of an out-of-range index.
140
+ document.getElementById(`detail-tab-${next}`)?.focus()
141
+ }
142
+
143
+ return (
144
+ <>
145
+ <div className={cn('detail-band', bandClassName)}>
146
+ {band}
147
+ <div role="tablist" className="detail-tabs mt-5" onKeyDown={onKeyDown}>
148
+ {tabs.map((tab) => {
149
+ const selected = tab.id === activeId
150
+ return (
151
+ <button
152
+ key={tab.id}
153
+ type="button"
154
+ role="tab"
155
+ id={`detail-tab-${tab.id}`}
156
+ aria-selected={selected}
157
+ aria-controls={`detail-panel-${tab.id}`}
158
+ // Only the selected tab stays in the tab order; the rest are
159
+ // reached with the arrow keys, as the pattern requires.
160
+ tabIndex={selected ? 0 : -1}
161
+ aria-label={tab.ariaLabel}
162
+ className="detail-tab"
163
+ onClick={() => {
164
+ setUserChose(true)
165
+ setUrlTab(tab.id)
166
+ }}
167
+ >
168
+ {tab.label}
169
+ {tab.chip && (
170
+ <span className="detail-tab__chip">{tab.chip}</span>
171
+ )}
172
+ {tab.dot && (
173
+ <span className="detail-tab__dot" aria-hidden="true" />
174
+ )}
175
+ </button>
176
+ )
177
+ })}
178
+ </div>
179
+ </div>
180
+ {active && (
181
+ <div
182
+ role="tabpanel"
183
+ id={`detail-panel-${active.id}`}
184
+ aria-labelledby={`detail-tab-${active.id}`}
185
+ // Focusable so that tabbing out of the strip lands in the content it
186
+ // controls rather than skipping past it.
187
+ tabIndex={0}
188
+ className="pt-5 outline-none"
189
+ >
190
+ {active.render()}
191
+ </div>
192
+ )}
193
+ </>
194
+ )
195
+ }