@igstack/app-catalog-frontend-core 6.9.0 → 6.10.1

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 (80) hide show
  1. package/dist/esm/__tests__/modules/appCatalog/ui/detail/CommunitySection.feed.test.d.ts +0 -0
  2. package/dist/esm/__tests__/modules/appCatalog/ui/detail/CommunitySection.help.test.d.ts +0 -0
  3. package/dist/esm/__tests__/modules/appCatalog/ui/detail/CommunitySection.input.test.d.ts +0 -0
  4. package/dist/esm/__tests__/modules/appCatalog/ui/detail/composeComments.test.d.ts +1 -0
  5. package/dist/esm/__tests__/ui/detail/CommunityInput.test.d.ts +1 -0
  6. package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +8 -0
  7. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js +1 -0
  8. package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js.map +1 -1
  9. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +237 -218
  10. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
  11. package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.d.ts +17 -0
  12. package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.js +187 -0
  13. package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.js.map +1 -0
  14. package/dist/esm/modules/appCatalog/ui/detail/CommentsHelp.d.ts +9 -0
  15. package/dist/esm/modules/appCatalog/ui/detail/CommentsHelp.js +91 -0
  16. package/dist/esm/modules/appCatalog/ui/detail/CommentsHelp.js.map +1 -0
  17. package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.d.ts +17 -0
  18. package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.js +328 -0
  19. package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.js.map +1 -0
  20. package/dist/esm/modules/appCatalog/ui/detail/CommunitySection.d.ts +15 -0
  21. package/dist/esm/modules/appCatalog/ui/detail/CommunitySection.js +26 -0
  22. package/dist/esm/modules/appCatalog/ui/detail/CommunitySection.js.map +1 -0
  23. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.d.ts +8 -19
  24. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js +49 -37
  25. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js.map +1 -1
  26. package/dist/esm/modules/appCatalog/ui/detail/communityDraft.d.ts +30 -0
  27. package/dist/esm/modules/appCatalog/ui/detail/communityDraft.js +50 -0
  28. package/dist/esm/modules/appCatalog/ui/detail/communityDraft.js.map +1 -0
  29. package/dist/esm/modules/appCatalog/ui/detail/communityError.d.ts +6 -0
  30. package/dist/esm/modules/appCatalog/ui/detail/communityError.js +8 -0
  31. package/dist/esm/modules/appCatalog/ui/detail/communityError.js.map +1 -0
  32. package/dist/esm/modules/appCatalog/ui/detail/composeComments.d.ts +27 -0
  33. package/dist/esm/modules/appCatalog/ui/detail/composeComments.js +66 -0
  34. package/dist/esm/modules/appCatalog/ui/detail/composeComments.js.map +1 -0
  35. package/dist/esm/modules/appCatalog/ui/detail/usePendingCount.d.ts +11 -0
  36. package/dist/esm/modules/appCatalog/ui/detail/usePendingCount.js +15 -0
  37. package/dist/esm/modules/appCatalog/ui/detail/usePendingCount.js.map +1 -0
  38. package/dist/esm/modules/extensions/features/resourceDetail.d.ts +38 -0
  39. package/dist/esm/modules/extensions/features/resourceDetail.js +4 -0
  40. package/dist/esm/modules/extensions/features/resourceDetail.js.map +1 -1
  41. package/dist/esm/modules/extensions/index.d.ts +1 -1
  42. package/dist/esm/modules/extensions/index.js.map +1 -1
  43. package/dist/index.css +501 -49
  44. package/package.json +4 -4
  45. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.appSwitch.test.tsx +5 -2
  46. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.enter.test.tsx +5 -2
  47. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.background.test.tsx +9 -10
  48. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.behindThisCard.test.tsx +402 -0
  49. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.doorAndTabs.test.tsx +8 -4
  50. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.escape.test.tsx +5 -2
  51. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.knowMore.test.tsx +175 -0
  52. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.sectionOrder.test.tsx +5 -2
  53. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.tsx +5 -2
  54. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.tags.test.tsx +5 -2
  55. package/src/__tests__/modules/appCatalog/ui/detail/CommunitySection.feed.test.tsx +281 -0
  56. package/src/__tests__/modules/appCatalog/ui/detail/CommunitySection.help.test.tsx +179 -0
  57. package/src/__tests__/modules/appCatalog/ui/detail/CommunitySection.input.test.tsx +423 -0
  58. package/src/__tests__/modules/appCatalog/ui/detail/composeComments.test.ts +170 -0
  59. package/src/__tests__/ui/detail/CommunityInput.test.tsx +39 -0
  60. package/src/index.css +501 -49
  61. package/src/modules/appCatalog/ui/detail/AppDetails.tsx +292 -289
  62. package/src/modules/appCatalog/ui/detail/CommentsFeed.tsx +254 -0
  63. package/src/modules/appCatalog/ui/detail/CommentsHelp.tsx +102 -0
  64. package/src/modules/appCatalog/ui/detail/CommunityInput.tsx +419 -0
  65. package/src/modules/appCatalog/ui/detail/CommunitySection.tsx +41 -0
  66. package/src/modules/appCatalog/ui/detail/DetailTabs.tsx +77 -71
  67. package/src/modules/appCatalog/ui/detail/communityDraft.ts +80 -0
  68. package/src/modules/appCatalog/ui/detail/communityError.ts +10 -0
  69. package/src/modules/appCatalog/ui/detail/composeComments.ts +103 -0
  70. package/src/modules/appCatalog/ui/detail/usePendingCount.ts +26 -0
  71. package/src/modules/extensions/features/resourceDetail.ts +28 -0
  72. package/src/modules/extensions/index.ts +2 -0
  73. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.d.ts +0 -13
  74. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js +0 -433
  75. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js.map +0 -1
  76. package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.emptyState.test.tsx +0 -181
  77. package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.reviewStatus.test.tsx +0 -131
  78. package/src/modules/appCatalog/ui/detail/FeedbackSection.tsx +0 -605
  79. /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{FeedbackSection.emptyState.test.d.ts → AppDetails.behindThisCard.test.d.ts} +0 -0
  80. /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{FeedbackSection.reviewStatus.test.d.ts → AppDetails.knowMore.test.d.ts} +0 -0
@@ -0,0 +1,402 @@
1
+ import type { Resource } from '@igstack/app-catalog-backend-core'
2
+ import { act, fireEvent, render, screen, within } from '@testing-library/react'
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
4
+ import '@testing-library/jest-dom/vitest'
5
+ import type { AcPlugin } from '~/modules/extensions'
6
+ import { ExtensionsContext } from '~/modules/extensions'
7
+
8
+ const search: Record<string, unknown> = {}
9
+ const feedback = vi.hoisted(() => ({
10
+ items: [] as unknown[],
11
+ error: null as Error | null,
12
+ hasData: true,
13
+ }))
14
+
15
+ vi.mock('@tanstack/react-router', () => ({
16
+ useSearch: vi.fn(() => search),
17
+ useNavigate: vi.fn(() => vi.fn()),
18
+ useRouter: vi.fn(() => ({
19
+ state: { location: { pathname: '/app/thing', search } },
20
+ })),
21
+ Link: ({ children, ...rest }: { children?: React.ReactNode }) => (
22
+ <a {...rest}>{children}</a>
23
+ ),
24
+ }))
25
+ vi.mock('~/modules/appCatalog/context/AppCatalogContext', () => ({
26
+ useAppCatalogContext: vi.fn(() => ({ approvalMethods: [], resources: [] })),
27
+ }))
28
+ vi.mock('~/modules/appCatalog/hooks/useAppClickHistory', () => ({
29
+ useAppClickHistory: vi.fn(() => ({ recordClick: vi.fn() })),
30
+ }))
31
+ vi.mock('~/modules/appCatalog/hooks/useUpdateApp', () => ({
32
+ useUpdateApp: vi.fn(() => ({ mutate: vi.fn() })),
33
+ }))
34
+ vi.mock('~/modules/appCatalog/ui/context/AppCatalogFiltersContext', () => ({
35
+ useAppCatalogFilters: vi.fn(() => ({
36
+ state: { searchValue: '' },
37
+ actions: {},
38
+ })),
39
+ }))
40
+ vi.mock('~/modules/auth', () => ({ useUser: vi.fn(() => null) }))
41
+ // CommunitySection and the count are real: the Share button's whole job is to
42
+ // focus the input, and the count has to agree with the feed's Pending badges.
43
+ vi.mock('@tanstack/react-query', () => ({
44
+ useQuery: vi.fn(() => ({
45
+ data: feedback.hasData
46
+ ? { items: feedback.items, openCount: 0 }
47
+ : undefined,
48
+ error: feedback.error,
49
+ })),
50
+ useMutation: vi.fn(() => ({
51
+ mutate: vi.fn(),
52
+ isPending: false,
53
+ isError: false,
54
+ })),
55
+ useQueryClient: vi.fn(() => ({ invalidateQueries: vi.fn() })),
56
+ }))
57
+ vi.mock('~/api/infra/trpc', () => ({
58
+ useTRPC: vi.fn(() => ({
59
+ feedback: {
60
+ add: { mutationOptions: (o: unknown) => o },
61
+ edit: { mutationOptions: (o: unknown) => o },
62
+ dismiss: { mutationOptions: (o: unknown) => o },
63
+ list: {
64
+ queryOptions: () => ({ queryKey: ['feedback', 'list'] }),
65
+ queryKey: () => ['feedback', 'list'],
66
+ },
67
+ },
68
+ })),
69
+ }))
70
+
71
+ const { AppDetails } = await import('~/modules/appCatalog/ui/detail/AppDetails')
72
+
73
+ const app = {
74
+ id: 'thing',
75
+ slug: 'thing',
76
+ displayName: 'Thing',
77
+ description: 'Does a thing.',
78
+ links: [{ title: 'User guide', url: 'https://docs.example.com/guide' }],
79
+ background: 'Built to replace the spreadsheet.',
80
+ sources: ['https://wiki.example.com/thing'],
81
+ createdAt: '2026-01-01T00:00:00.000Z',
82
+ aiPrompt: 'Keep the scope explicit.',
83
+ aiMemory: 'Maps to the legacy portal.',
84
+ freshness: {
85
+ lastCheckedAt: '2026-10-19T00:00:00.000Z',
86
+ lastContentChangeAt: null,
87
+ isStale: false,
88
+ },
89
+ } as Resource
90
+
91
+ const tabNames = () =>
92
+ screen.getAllByRole('tab').map((tab) => tab.textContent.trim())
93
+
94
+ const behindTab = () => screen.getByRole('tab', { name: /^Behind this card/ })
95
+ const input = () =>
96
+ screen.getByRole('textbox', { name: 'Know more about Thing?' })
97
+
98
+ beforeEach(() => {
99
+ feedback.items = []
100
+ feedback.error = null
101
+ feedback.hasData = true
102
+ for (const key of Object.keys(search)) delete search[key]
103
+ })
104
+
105
+ describe('the tab strip', () => {
106
+ it('is Overview, Access, Documentation, Behind this card', () => {
107
+ render(<AppDetails app={app} />)
108
+ expect(tabNames()).toEqual([
109
+ 'Overview',
110
+ expect.stringMatching(/^Access/),
111
+ 'Documentation',
112
+ 'Behind this card',
113
+ ])
114
+ })
115
+
116
+ it('drops Documentation when the entry has neither links nor background', () => {
117
+ render(<AppDetails app={{ ...app, links: [], background: undefined }} />)
118
+ expect(screen.queryByRole('tab', { name: 'Documentation' })).toBeNull()
119
+ expect(behindTab()).toBeInTheDocument()
120
+ })
121
+
122
+ it('sends ?tab=documentation to Behind this card when the entry has no Documentation', () => {
123
+ search.tab = 'documentation'
124
+ render(<AppDetails app={{ ...app, links: [], background: undefined }} />)
125
+ expect(behindTab()).toHaveAttribute('aria-selected', 'true')
126
+ })
127
+
128
+ it('sends an old ?tab=notes link to Behind this card', () => {
129
+ search.tab = 'notes'
130
+ render(<AppDetails app={app} />)
131
+ expect(behindTab()).toHaveAttribute('aria-selected', 'true')
132
+ })
133
+ })
134
+
135
+ describe('Documentation', () => {
136
+ it('holds the links and Background, and no provenance', () => {
137
+ search.tab = 'documentation'
138
+ render(<AppDetails app={app} />)
139
+ const panel = screen.getByRole('tabpanel')
140
+ expect(within(panel).getByText('User guide')).toBeVisible()
141
+ expect(
142
+ within(panel).getByRole('heading', { name: 'Background' }),
143
+ ).toBeVisible()
144
+ expect(within(panel).queryByRole('heading', { name: /Sources/ })).toBeNull()
145
+ expect(within(panel).queryByText(/Keep the scope explicit/)).toBeNull()
146
+ })
147
+ })
148
+
149
+ describe('Behind this card', () => {
150
+ it('holds the three groups in order', () => {
151
+ search.tab = 'behind'
152
+ render(<AppDetails app={app} />)
153
+ const panel = screen.getByRole('tabpanel')
154
+ const groups = within(panel)
155
+ .getAllByRole('heading', { level: 2 })
156
+ .map((h) => h.textContent)
157
+ expect(groups).toEqual([
158
+ 'Comments',
159
+ 'Kept up to date from',
160
+ 'How the AI maintains this card',
161
+ ])
162
+ expect(within(panel).getByText('Keep the scope explicit.')).toBeVisible()
163
+ expect(within(panel).getByText('Maps to the legacy portal.')).toBeVisible()
164
+ expect(within(panel).getByText(/wiki\.example\.com\/thing/)).toBeVisible()
165
+ expect(
166
+ within(panel).getByText(/^Last checked .+ · added .+$/),
167
+ ).toBeVisible()
168
+ })
169
+
170
+ it('has no AI group when the entry has neither prompt nor memory', () => {
171
+ search.tab = 'behind'
172
+ render(
173
+ <AppDetails app={{ ...app, aiPrompt: undefined, aiMemory: undefined }} />,
174
+ )
175
+ expect(
176
+ screen.queryByRole('heading', { name: 'How the AI maintains this card' }),
177
+ ).toBeNull()
178
+ })
179
+
180
+ it('puts the input above the Comments heading and the feed', () => {
181
+ search.tab = 'behind'
182
+ render(
183
+ <AppDetails
184
+ app={{
185
+ ...app,
186
+ claims: [
187
+ { text: 'Lead approves.', addedAt: '2026-10-01', status: 'active' },
188
+ ],
189
+ }}
190
+ />,
191
+ )
192
+ const field = input()
193
+ const heading = screen.getByRole('heading', { name: 'Comments' })
194
+ const fact = screen.getByText('Lead approves.')
195
+ expect(fact).toBeVisible()
196
+ expect(
197
+ field.compareDocumentPosition(heading) & Node.DOCUMENT_POSITION_FOLLOWING,
198
+ ).toBeTruthy()
199
+ expect(
200
+ heading.compareDocumentPosition(fact) & Node.DOCUMENT_POSITION_FOLLOWING,
201
+ ).toBeTruthy()
202
+ })
203
+
204
+ it('has no input on the other tabs', () => {
205
+ render(<AppDetails app={app} />)
206
+ for (const name of ['Overview', /^Access/, 'Documentation']) {
207
+ fireEvent.click(screen.getByRole('tab', { name }))
208
+ expect(screen.queryByRole('textbox', { name: /Know more/ })).toBeNull()
209
+ }
210
+ })
211
+ })
212
+
213
+ describe('Share what you know', () => {
214
+ it('opens Behind this card with the input focused and highlighted, every time', () => {
215
+ vi.useFakeTimers()
216
+ try {
217
+ render(<AppDetails app={app} />)
218
+ const share = screen.getByRole('button', { name: 'Share what you know' })
219
+
220
+ fireEvent.click(share)
221
+ expect(behindTab()).toHaveAttribute('aria-selected', 'true')
222
+ expect(input()).toHaveFocus()
223
+ expect(input().closest('form')).toHaveClass('is-flash')
224
+
225
+ act(() => vi.advanceTimersByTime(1000))
226
+ expect(input().closest('form')).not.toHaveClass('is-flash')
227
+
228
+ // Again, from another tab.
229
+ fireEvent.click(screen.getByRole('tab', { name: 'Overview' }))
230
+ fireEvent.click(share)
231
+ expect(behindTab()).toHaveAttribute('aria-selected', 'true')
232
+ expect(input()).toHaveFocus()
233
+
234
+ // And again while already there: re-focuses and re-highlights.
235
+ act(() => vi.advanceTimersByTime(1000))
236
+ input().blur()
237
+ fireEvent.click(share)
238
+ expect(input()).toHaveFocus()
239
+ expect(input().closest('form')).toHaveClass('is-flash')
240
+ } finally {
241
+ vi.useRealTimers()
242
+ }
243
+ })
244
+
245
+ it('does not steal focus when the reader opens the tab themselves', () => {
246
+ render(<AppDetails app={app} />)
247
+ fireEvent.click(screen.getByRole('button', { name: 'Share what you know' }))
248
+ fireEvent.click(screen.getByRole('tab', { name: 'Overview' }))
249
+ fireEvent.click(behindTab())
250
+ expect(input()).not.toHaveFocus()
251
+ })
252
+
253
+ it('keeps accessible name "Share what you know" in compact variant', () => {
254
+ render(<AppDetails app={app} />)
255
+ const share = screen.getByRole('button', { name: 'Share what you know' })
256
+ expect(share).toBeInTheDocument()
257
+ })
258
+
259
+ it('scrolls the active tab into view when selecting via Share', async () => {
260
+ const scrollIntoView = vi.fn()
261
+ Element.prototype.scrollIntoView = scrollIntoView
262
+ render(<AppDetails app={app} />)
263
+ fireEvent.click(screen.getByRole('button', { name: 'Share what you know' }))
264
+ // scrollIntoView is called in a requestAnimationFrame callback.
265
+ await new Promise((resolve) => requestAnimationFrame(resolve))
266
+ expect(scrollIntoView).toHaveBeenCalledWith({
267
+ behavior: 'smooth',
268
+ block: 'nearest',
269
+ inline: 'center',
270
+ })
271
+ })
272
+ })
273
+
274
+ const pendingNote = {
275
+ id: 'n1',
276
+ authorName: 'Merry Mole',
277
+ body: 'Leads approve.',
278
+ subject: null,
279
+ resourceSlug: 'thing',
280
+ resourceName: 'Thing',
281
+ attachmentIds: [],
282
+ createdAt: '2026-10-10T10:00:00.000Z',
283
+ reviewedAt: null,
284
+ editedAt: null,
285
+ isMine: false,
286
+ canEditUntil: null,
287
+ status: null,
288
+ reviewerReply: null,
289
+ }
290
+
291
+ describe('the Behind this card count', () => {
292
+ it('is the number of Pending badges in the feed', () => {
293
+ search.tab = 'behind'
294
+ feedback.items = [
295
+ pendingNote,
296
+ { ...pendingNote, id: 'n2', status: 'acknowledged' },
297
+ ]
298
+ render(
299
+ <AppDetails
300
+ app={{
301
+ ...app,
302
+ claims: [
303
+ // The open note's own claim: one entry, one Pending.
304
+ {
305
+ text: 'A.',
306
+ addedAt: '2026-10-11',
307
+ origin: 'feedback:n1',
308
+ status: 'review',
309
+ },
310
+ { text: 'B.', addedAt: '2026-10-01', status: 'review' },
311
+ { text: 'C.', addedAt: '2026-10-01', status: 'active' },
312
+ ],
313
+ }}
314
+ />,
315
+ )
316
+ expect(screen.getAllByText('Pending')).toHaveLength(2)
317
+ expect(behindTab()).toHaveTextContent(/^Behind this card2$/)
318
+ })
319
+
320
+ it('is hidden at zero', () => {
321
+ render(<AppDetails app={app} />)
322
+ expect(behindTab().textContent).toBe('Behind this card')
323
+ })
324
+
325
+ it('does not show standalone claims before the list arrives', () => {
326
+ search.tab = 'behind'
327
+ feedback.hasData = false
328
+ render(
329
+ <AppDetails
330
+ app={{
331
+ ...app,
332
+ claims: [
333
+ {
334
+ text: 'Early claim.',
335
+ addedAt: '2026-10-01',
336
+ origin: 'feedback:gone',
337
+ status: 'review',
338
+ },
339
+ ],
340
+ }}
341
+ />,
342
+ )
343
+ expect(screen.queryByText('Early claim.')).toBeNull()
344
+ expect(behindTab().textContent).toBe('Behind this card')
345
+ })
346
+
347
+ it('on list error, the badge count equals Pending entries from claims only', () => {
348
+ search.tab = 'behind'
349
+ feedback.hasData = false
350
+ feedback.error = new Error('Network')
351
+ render(
352
+ <AppDetails
353
+ app={{
354
+ ...app,
355
+ claims: [
356
+ { text: 'A.', addedAt: '2026-10-01', status: 'review' },
357
+ { text: 'B.', addedAt: '2026-10-02', status: 'active' },
358
+ ],
359
+ }}
360
+ />,
361
+ )
362
+ const panel = screen.getByRole('tabpanel')
363
+ expect(within(panel).getByText('A.')).toBeVisible()
364
+ expect(within(panel).getByText('B.')).toBeVisible()
365
+ expect(within(panel).getAllByText('Pending')).toHaveLength(1)
366
+ expect(within(panel).getAllByText('Used as source')).toHaveLength(1)
367
+ expect(behindTab()).toHaveTextContent(/^Behind this card1$/)
368
+ })
369
+ })
370
+
371
+ describe('the card-source slot', () => {
372
+ it("renders a plugin's content on Behind this card", () => {
373
+ search.tab = 'behind'
374
+ let received: unknown = null
375
+ const plugin: AcPlugin = {
376
+ name: 'card-source',
377
+ slots: {
378
+ resourceDetailCardSource: (props) => {
379
+ received = props
380
+ return <a href="https://example.com/src">View this card's source</a>
381
+ },
382
+ },
383
+ }
384
+ render(
385
+ <ExtensionsContext value={[plugin]}>
386
+ <AppDetails app={app} />
387
+ </ExtensionsContext>,
388
+ )
389
+ expect(
390
+ within(screen.getByRole('tabpanel')).getByRole('link', {
391
+ name: "View this card's source",
392
+ }),
393
+ ).toBeVisible()
394
+ expect(received).toEqual({ appSlug: 'thing', appName: 'Thing' })
395
+ })
396
+
397
+ it('renders nothing by default', () => {
398
+ search.tab = 'behind'
399
+ render(<AppDetails app={app} />)
400
+ expect(screen.queryByRole('link', { name: /source/i })).toBeNull()
401
+ })
402
+ })
@@ -36,8 +36,11 @@ vi.mock('~/modules/appCatalog/ui/context/AppCatalogFiltersContext', () => ({
36
36
  })),
37
37
  }))
38
38
  vi.mock('~/modules/auth', () => ({ useUser: vi.fn(() => null) }))
39
- vi.mock('~/modules/appCatalog/ui/detail/FeedbackSection', () => ({
40
- FeedbackSection: () => <div />,
39
+ vi.mock('~/modules/appCatalog/ui/detail/CommunitySection', () => ({
40
+ CommunitySection: () => <div />,
41
+ }))
42
+ vi.mock('~/modules/appCatalog/ui/detail/usePendingCount', () => ({
43
+ usePendingCount: () => 0,
41
44
  }))
42
45
 
43
46
  const { AppDetails } = await import('~/modules/appCatalog/ui/detail/AppDetails')
@@ -48,6 +51,7 @@ const withUrl = {
48
51
  displayName: 'Thing',
49
52
  description: 'Does a thing.',
50
53
  appUrl: 'https://thing.example.com/portal',
54
+ links: [{ title: 'Guide', url: 'https://thing.example.com/guide' }],
51
55
  } as Resource
52
56
 
53
57
  const withoutUrl = {
@@ -88,7 +92,7 @@ describe('the door', () => {
88
92
 
89
93
  describe('the tab strip', () => {
90
94
  // The reason the active tab lives in the url at all: a card is linkable
91
- // mid-conversation, and a maintainer's reply on the notes tab is addressable.
95
+ // mid-conversation, and a maintainer's reply on Behind this card is addressable.
92
96
  it('writes the selected tab to the url', () => {
93
97
  render(<AppDetails app={withUrl} />)
94
98
 
@@ -187,7 +191,7 @@ describe('the tab strip', () => {
187
191
  })
188
192
 
189
193
  expect(
190
- screen.getByRole('tab', { name: 'Notes & requests' }),
194
+ screen.getByRole('tab', { name: 'Behind this card' }),
191
195
  ).toHaveAttribute('aria-selected', 'true')
192
196
  })
193
197
  })
@@ -29,8 +29,11 @@ vi.mock('~/modules/appCatalog/ui/context/AppCatalogFiltersContext', () => ({
29
29
  useAppCatalogFilters: vi.fn(() => ({ selectedTags: [], toggleTag: vi.fn() })),
30
30
  }))
31
31
  vi.mock('~/modules/auth', () => ({ useUser: vi.fn(() => null) }))
32
- vi.mock('~/modules/appCatalog/ui/detail/FeedbackSection', () => ({
33
- FeedbackSection: () => <div />,
32
+ vi.mock('~/modules/appCatalog/ui/detail/CommunitySection', () => ({
33
+ CommunitySection: () => <div />,
34
+ }))
35
+ vi.mock('~/modules/appCatalog/ui/detail/usePendingCount', () => ({
36
+ usePendingCount: () => 0,
34
37
  }))
35
38
 
36
39
  const { AppDetails } = await import('~/modules/appCatalog/ui/detail/AppDetails')
@@ -0,0 +1,175 @@
1
+ import type { Resource } from '@igstack/app-catalog-backend-core'
2
+ import { fireEvent, render, screen, within } from '@testing-library/react'
3
+ import { useRef } from 'react'
4
+ import { describe, expect, it, vi } from 'vitest'
5
+ import '@testing-library/jest-dom/vitest'
6
+
7
+ // Same stubs as the escape test: AppDetails drags the whole panel data layer in
8
+ // and none of it decides where the input sits or what Esc does.
9
+ vi.mock('@tanstack/react-router', () => ({
10
+ useSearch: vi.fn(() => ({})),
11
+ useNavigate: vi.fn(() => vi.fn()),
12
+ useRouter: vi.fn(() => ({
13
+ state: { location: { pathname: '/', search: {} } },
14
+ })),
15
+ Link: ({ children, ...rest }: { children?: React.ReactNode }) => (
16
+ <a {...rest}>{children}</a>
17
+ ),
18
+ }))
19
+ vi.mock('~/modules/appCatalog/context/AppCatalogContext', () => ({
20
+ useAppCatalogContext: vi.fn(() => ({ approvalMethods: [], resources: [] })),
21
+ }))
22
+ vi.mock('~/modules/appCatalog/hooks/useAppClickHistory', () => ({
23
+ useAppClickHistory: vi.fn(() => ({ recordClick: vi.fn() })),
24
+ }))
25
+ vi.mock('~/modules/appCatalog/hooks/useUpdateApp', () => ({
26
+ useUpdateApp: vi.fn(() => ({ mutate: vi.fn() })),
27
+ }))
28
+ vi.mock('~/modules/appCatalog/ui/context/AppCatalogFiltersContext', () => ({
29
+ useAppCatalogFilters: vi.fn(() => ({ selectedTags: [], toggleTag: vi.fn() })),
30
+ }))
31
+ vi.mock('~/modules/auth', () => ({ useUser: vi.fn(() => null) }))
32
+ vi.mock('~/modules/appCatalog/ui/detail/usePendingCount', () => ({
33
+ usePendingCount: () => 0,
34
+ }))
35
+
36
+ // CommunitySection is real — the Esc contract lives in it, so mocking it would
37
+ // test the mock. Only its data layer is stubbed.
38
+ const state = vi.hoisted(() => ({
39
+ sent: [] as unknown[],
40
+ fail: false,
41
+ invalidated: 0,
42
+ }))
43
+
44
+ vi.mock('@tanstack/react-query', async () => {
45
+ const React = await import('react')
46
+ return {
47
+ useQuery: vi.fn(() => ({ data: { items: [], openCount: 0 } })),
48
+ // A real error state, so the component's failure branch is reachable.
49
+ useMutation: vi.fn((options?: { onSuccess?: () => void }) => {
50
+ const [isError, setIsError] = React.useState(false)
51
+ return {
52
+ mutate: (vars: unknown) => {
53
+ state.sent.push(vars)
54
+ if (state.fail) setIsError(true)
55
+ else options?.onSuccess?.()
56
+ },
57
+ isPending: false,
58
+ isError,
59
+ }
60
+ }),
61
+ useQueryClient: vi.fn(() => ({
62
+ invalidateQueries: () => {
63
+ state.invalidated++
64
+ },
65
+ })),
66
+ }
67
+ })
68
+ vi.mock('~/api/infra/trpc', () => ({
69
+ useTRPC: vi.fn(() => ({
70
+ feedback: {
71
+ add: { mutationOptions: (o: unknown) => o },
72
+ edit: { mutationOptions: (o: unknown) => o },
73
+ dismiss: { mutationOptions: (o: unknown) => o },
74
+ list: {
75
+ queryOptions: () => ({ queryKey: ['feedback', 'list'] }),
76
+ queryKey: () => ['feedback', 'list'],
77
+ },
78
+ },
79
+ })),
80
+ }))
81
+
82
+ const { AppDetails } = await import('~/modules/appCatalog/ui/detail/AppDetails')
83
+ const { useDismissable } = await import('~/modules/dismiss/DismissStack')
84
+
85
+ /** Stands in for the card: the layer AppDetails is rendered inside. */
86
+ function CardLayer({
87
+ onClose,
88
+ children,
89
+ }: {
90
+ onClose: () => void
91
+ children: React.ReactNode
92
+ }) {
93
+ const ref = useRef<HTMLDivElement>(null)
94
+ useDismissable(ref, onClose)
95
+ return (
96
+ <div ref={ref} role="dialog" aria-label="card">
97
+ {children}
98
+ </div>
99
+ )
100
+ }
101
+
102
+ const app = {
103
+ id: 'example-app',
104
+ slug: 'example-app',
105
+ displayName: 'Example App',
106
+ description: 'Does things.',
107
+ } as Resource
108
+
109
+ const input = () =>
110
+ screen.getByRole('textbox', {
111
+ name: 'Know more about Example App?',
112
+ })
113
+
114
+ const openBehind = () =>
115
+ fireEvent.click(screen.getByRole('tab', { name: /^Behind this card/ }))
116
+
117
+ describe('AppDetails — Know more?', () => {
118
+ it('sits on Behind this card and on no other tab', () => {
119
+ render(<AppDetails app={app} />)
120
+ const withField = screen.getAllByRole('tab').filter((tab) => {
121
+ fireEvent.click(tab)
122
+ return !!within(screen.getByRole('tabpanel')).queryByRole('textbox', {
123
+ name: /Know more/,
124
+ })
125
+ })
126
+ expect(withField.map((tab) => tab.id)).toEqual(['detail-tab-behind'])
127
+ })
128
+
129
+ it('is gone from the band', () => {
130
+ render(<AppDetails app={app} />)
131
+ expect(
132
+ screen.queryByRole('button', { name: /Suggest a change/ }),
133
+ ).toBeNull()
134
+ })
135
+
136
+ it('Esc with a draft clears it and keeps the card open', () => {
137
+ const onClose = vi.fn()
138
+ render(
139
+ <CardLayer onClose={onClose}>
140
+ <AppDetails app={app} />
141
+ </CardLayer>,
142
+ )
143
+ openBehind()
144
+ fireEvent.change(input(), { target: { value: 'draft' } })
145
+ fireEvent.keyDown(input(), { key: 'Escape', code: 'Escape' })
146
+ expect(input()).toHaveValue('')
147
+ expect(onClose).not.toHaveBeenCalled()
148
+ })
149
+
150
+ it('Esc on an empty field still closes the card', () => {
151
+ const onClose = vi.fn()
152
+ render(
153
+ <CardLayer onClose={onClose}>
154
+ <AppDetails app={app} />
155
+ </CardLayer>,
156
+ )
157
+ openBehind()
158
+ fireEvent.keyDown(input(), { key: 'Escape', code: 'Escape' })
159
+ expect(onClose).toHaveBeenCalledTimes(1)
160
+ })
161
+
162
+ it('A send creates feedback without leaving Behind this card', () => {
163
+ render(<AppDetails app={app} />)
164
+ openBehind()
165
+ const behind = screen.getByRole('tab', { name: /^Behind this card/ })
166
+ const panel = screen.getByRole('tabpanel')
167
+ fireEvent.change(input(), { target: { value: 'approval detail' } })
168
+ fireEvent.keyDown(input(), { key: 'Enter' })
169
+ expect(state.sent).toEqual([
170
+ { resourceSlug: 'example-app', body: 'approval detail' },
171
+ ])
172
+ expect(behind).toHaveAttribute('aria-selected', 'true')
173
+ expect(screen.getByRole('tabpanel')).toBe(panel)
174
+ })
175
+ })
@@ -30,8 +30,11 @@ vi.mock('~/modules/appCatalog/ui/context/AppCatalogFiltersContext', () => ({
30
30
  useAppCatalogFilters: vi.fn(() => ({ selectedTags: [], toggleTag: vi.fn() })),
31
31
  }))
32
32
  vi.mock('~/modules/auth', () => ({ useUser: vi.fn(() => null) }))
33
- vi.mock('~/modules/appCatalog/ui/detail/FeedbackSection', () => ({
34
- FeedbackSection: () => <div />,
33
+ vi.mock('~/modules/appCatalog/ui/detail/CommunitySection', () => ({
34
+ CommunitySection: () => <div />,
35
+ }))
36
+ vi.mock('~/modules/appCatalog/ui/detail/usePendingCount', () => ({
37
+ usePendingCount: () => 0,
35
38
  }))
36
39
 
37
40
  const { AppDetails } = await import('~/modules/appCatalog/ui/detail/AppDetails')
@@ -66,8 +66,11 @@ vi.mock('~/modules/appCatalog/ui/context/AppCatalogFiltersContext', () => ({
66
66
  })),
67
67
  }))
68
68
  vi.mock('~/modules/auth', () => ({ useUser: vi.fn(() => null) }))
69
- vi.mock('~/modules/appCatalog/ui/detail/FeedbackSection', () => ({
70
- FeedbackSection: () => <div />,
69
+ vi.mock('~/modules/appCatalog/ui/detail/CommunitySection', () => ({
70
+ CommunitySection: () => <div />,
71
+ }))
72
+ vi.mock('~/modules/appCatalog/ui/detail/usePendingCount', () => ({
73
+ usePendingCount: () => 0,
71
74
  }))
72
75
 
73
76
  const { AppDetails } = await import('~/modules/appCatalog/ui/detail/AppDetails')
@@ -28,8 +28,11 @@ vi.mock('~/modules/appCatalog/ui/context/AppCatalogFiltersContext', () => ({
28
28
  useAppCatalogFilters: vi.fn(() => ({ selectedTags: [], toggleTag: vi.fn() })),
29
29
  }))
30
30
  vi.mock('~/modules/auth', () => ({ useUser: vi.fn(() => null) }))
31
- vi.mock('~/modules/appCatalog/ui/detail/FeedbackSection', () => ({
32
- FeedbackSection: () => <div />,
31
+ vi.mock('~/modules/appCatalog/ui/detail/CommunitySection', () => ({
32
+ CommunitySection: () => <div />,
33
+ }))
34
+ vi.mock('~/modules/appCatalog/ui/detail/usePendingCount', () => ({
35
+ usePendingCount: () => 0,
33
36
  }))
34
37
 
35
38
  const { AppDetails } = await import('~/modules/appCatalog/ui/detail/AppDetails')