@igstack/app-catalog-frontend-core 6.3.1-alpha-20261003051755 → 6.4.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__/modules/appCatalog/ui/detail/FeedbackSection.emptyState.test.d.ts +0 -0
- package/dist/esm/__tests__/modules/appCatalog/ui/detail/FeedbackSection.reviewStatus.test.d.ts +0 -0
- package/dist/esm/__tests__/modules/appCatalog/utils/accessTabLabel.test.d.ts +1 -0
- package/dist/esm/api/infra/trpc.d.ts +42 -21
- package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +1 -0
- package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js +17 -2
- package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js +1 -0
- package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js +11 -4
- package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/QuickJumpBar.js +0 -4
- package/dist/esm/modules/appCatalog/ui/components/QuickJumpBar.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js +4 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +377 -385
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/AppDoor.d.ts +20 -0
- package/dist/esm/modules/appCatalog/ui/detail/AppDoor.js +59 -0
- package/dist/esm/modules/appCatalog/ui/detail/AppDoor.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.d.ts +64 -0
- package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js +88 -0
- package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.d.ts +13 -0
- package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js +433 -0
- package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js.map +1 -0
- package/dist/esm/modules/appCatalog/utils/accessTabLabel.d.ts +30 -0
- package/dist/esm/modules/appCatalog/utils/accessTabLabel.js +34 -0
- package/dist/esm/modules/appCatalog/utils/accessTabLabel.js.map +1 -0
- package/dist/index.css +269 -5
- package/package.json +4 -4
- package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.appSwitch.test.tsx +17 -3
- package/src/__tests__/modules/appCatalog/ui/components/AccessRequestSection.test.tsx +6 -4
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.background.test.tsx +25 -10
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.doorAndTabs.test.tsx +169 -0
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.escape.test.tsx +2 -2
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.sectionOrder.test.tsx +32 -9
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.tsx +125 -0
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.tags.test.tsx +2 -2
- package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.emptyState.test.tsx +181 -0
- package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.reviewStatus.test.tsx +131 -0
- package/src/__tests__/modules/appCatalog/utils/accessTabLabel.test.ts +206 -0
- package/src/__tests__/ui/components/header/Header.loginHidden.test.tsx +1 -1
- package/src/index.css +269 -5
- package/src/modules/appCatalog/ui/catalog/AppDetailPanel.tsx +46 -2
- package/src/modules/appCatalog/ui/components/AccessPrerequisiteChain.tsx +3 -0
- package/src/modules/appCatalog/ui/components/AccessRequestSection.tsx +9 -4
- package/src/modules/appCatalog/ui/components/SubResourceDetailPanel.tsx +4 -1
- package/src/modules/appCatalog/ui/detail/AppDetails.tsx +574 -554
- package/src/modules/appCatalog/ui/detail/AppDoor.tsx +97 -0
- package/src/modules/appCatalog/ui/detail/DetailTabs.tsx +188 -0
- package/src/modules/appCatalog/ui/detail/FeedbackSection.tsx +605 -0
- package/src/modules/appCatalog/utils/accessTabLabel.ts +95 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.d.ts +0 -3
- package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.js +0 -228
- package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.js.map +0 -1
- package/src/__tests__/modules/appCatalog/ui/detail/CommentsSection.emptyState.test.tsx +0 -64
- package/src/__tests__/modules/appCatalog/ui/detail/CommentsSection.reviewStatus.test.tsx +0 -93
- package/src/modules/appCatalog/ui/detail/CommentsSection.tsx +0 -296
- /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{CommentsSection.emptyState.test.d.ts → AppDetails.doorAndTabs.test.d.ts} +0 -0
- /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{CommentsSection.reviewStatus.test.d.ts → AppDetails.subResourceTab.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,188 @@
|
|
|
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
|
+
const [urlTab, setUrlTab] = useUrlSyncedState<string>({
|
|
77
|
+
key: 'tab',
|
|
78
|
+
defaultValue: '',
|
|
79
|
+
encode: (value) => value || undefined,
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const ids = tabs.map((t) => t.id)
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Tab ids are public — they are in `?tab=` in links people have already shared.
|
|
86
|
+
* Renaming one would silently drop those onto the first tab, so a retired id
|
|
87
|
+
* keeps resolving to its replacement instead.
|
|
88
|
+
*/
|
|
89
|
+
const RETIRED: Record<string, string> = { metadata: 'documentation' }
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* `forcedTab` picks the tab you ARRIVE on, not the tab you are stuck on.
|
|
93
|
+
*
|
|
94
|
+
* It used to win over `?tab=` for as long as it was set, and `?sub=` keeps it set
|
|
95
|
+
* for the whole life of the card — so on any entry reached by a sub-resource
|
|
96
|
+
* search (667 accounts on one of them) the strip was dead: every click wrote
|
|
97
|
+
* nothing and the tab never moved. The same override also silently killed the
|
|
98
|
+
* band's "Suggest a change", which navigates by writing `?tab=`.
|
|
99
|
+
*
|
|
100
|
+
* So it applies until the user expresses a preference, and then gets out of the
|
|
101
|
+
* way. `userChose` is what distinguishes "no opinion yet" from "chose the tab
|
|
102
|
+
* that happens to be the forced one".
|
|
103
|
+
*/
|
|
104
|
+
const [userChose, setUserChose] = React.useState(false)
|
|
105
|
+
|
|
106
|
+
// Applied when it changes, through the state that actually owns the tab.
|
|
107
|
+
React.useEffect(() => {
|
|
108
|
+
if (!requestTab) return
|
|
109
|
+
setUserChose(true)
|
|
110
|
+
setUrlTab(requestTab)
|
|
111
|
+
}, [requestTab, requestNonce, setUrlTab])
|
|
112
|
+
const asked = userChose || !forcedTab ? urlTab : forcedTab
|
|
113
|
+
const requested = RETIRED[asked] ?? asked
|
|
114
|
+
const activeId = ids.includes(requested) ? requested : (ids[0] ?? '')
|
|
115
|
+
const active = tabs.find((t) => t.id === activeId)
|
|
116
|
+
|
|
117
|
+
// Arrow keys move between tabs, per the tablist pattern. Selection follows
|
|
118
|
+
// focus, which is correct here because every panel is already loaded.
|
|
119
|
+
const onKeyDown = (event: React.KeyboardEvent) => {
|
|
120
|
+
const delta =
|
|
121
|
+
event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0
|
|
122
|
+
if (!delta) return
|
|
123
|
+
event.preventDefault()
|
|
124
|
+
const from = ids.indexOf(activeId)
|
|
125
|
+
const next = ids[(from + delta + ids.length) % ids.length]
|
|
126
|
+
if (!next) return
|
|
127
|
+
setUserChose(true)
|
|
128
|
+
setUrlTab(next)
|
|
129
|
+
// Focus follows selection, or the arrow keys would walk the visual
|
|
130
|
+
// selection away from the keyboard's position. By id rather than by
|
|
131
|
+
// position in a NodeList: each tab already carries one, and an id lookup
|
|
132
|
+
// says "missing" with a null instead of an out-of-range index.
|
|
133
|
+
document.getElementById(`detail-tab-${next}`)?.focus()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<>
|
|
138
|
+
<div className={cn('detail-band', bandClassName)}>
|
|
139
|
+
{band}
|
|
140
|
+
<div role="tablist" className="detail-tabs mt-5" onKeyDown={onKeyDown}>
|
|
141
|
+
{tabs.map((tab) => {
|
|
142
|
+
const selected = tab.id === activeId
|
|
143
|
+
return (
|
|
144
|
+
<button
|
|
145
|
+
key={tab.id}
|
|
146
|
+
type="button"
|
|
147
|
+
role="tab"
|
|
148
|
+
id={`detail-tab-${tab.id}`}
|
|
149
|
+
aria-selected={selected}
|
|
150
|
+
aria-controls={`detail-panel-${tab.id}`}
|
|
151
|
+
// Only the selected tab stays in the tab order; the rest are
|
|
152
|
+
// reached with the arrow keys, as the pattern requires.
|
|
153
|
+
tabIndex={selected ? 0 : -1}
|
|
154
|
+
aria-label={tab.ariaLabel}
|
|
155
|
+
className="detail-tab"
|
|
156
|
+
onClick={() => {
|
|
157
|
+
setUserChose(true)
|
|
158
|
+
setUrlTab(tab.id)
|
|
159
|
+
}}
|
|
160
|
+
>
|
|
161
|
+
{tab.label}
|
|
162
|
+
{tab.chip && (
|
|
163
|
+
<span className="detail-tab__chip">{tab.chip}</span>
|
|
164
|
+
)}
|
|
165
|
+
{tab.dot && (
|
|
166
|
+
<span className="detail-tab__dot" aria-hidden="true" />
|
|
167
|
+
)}
|
|
168
|
+
</button>
|
|
169
|
+
)
|
|
170
|
+
})}
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
{active && (
|
|
174
|
+
<div
|
|
175
|
+
role="tabpanel"
|
|
176
|
+
id={`detail-panel-${active.id}`}
|
|
177
|
+
aria-labelledby={`detail-tab-${active.id}`}
|
|
178
|
+
// Focusable so that tabbing out of the strip lands in the content it
|
|
179
|
+
// controls rather than skipping past it.
|
|
180
|
+
tabIndex={0}
|
|
181
|
+
className="pt-5 outline-none"
|
|
182
|
+
>
|
|
183
|
+
{active.render()}
|
|
184
|
+
</div>
|
|
185
|
+
)}
|
|
186
|
+
</>
|
|
187
|
+
)
|
|
188
|
+
}
|