@igstack/app-catalog-frontend-core 6.8.4 → 6.10.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 (88) 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/index.d.ts +1 -1
  7. package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +8 -0
  8. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js +22 -12
  9. package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -1
  10. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +254 -232
  11. package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
  12. package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.d.ts +17 -0
  13. package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.js +187 -0
  14. package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.js.map +1 -0
  15. package/dist/esm/modules/appCatalog/ui/detail/CommentsHelp.d.ts +9 -0
  16. package/dist/esm/modules/appCatalog/ui/detail/CommentsHelp.js +91 -0
  17. package/dist/esm/modules/appCatalog/ui/detail/CommentsHelp.js.map +1 -0
  18. package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.d.ts +17 -0
  19. package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.js +328 -0
  20. package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.js.map +1 -0
  21. package/dist/esm/modules/appCatalog/ui/detail/CommunitySection.d.ts +15 -0
  22. package/dist/esm/modules/appCatalog/ui/detail/CommunitySection.js +26 -0
  23. package/dist/esm/modules/appCatalog/ui/detail/CommunitySection.js.map +1 -0
  24. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.d.ts +8 -19
  25. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js +49 -37
  26. package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js.map +1 -1
  27. package/dist/esm/modules/appCatalog/ui/detail/communityDraft.d.ts +30 -0
  28. package/dist/esm/modules/appCatalog/ui/detail/communityDraft.js +50 -0
  29. package/dist/esm/modules/appCatalog/ui/detail/communityDraft.js.map +1 -0
  30. package/dist/esm/modules/appCatalog/ui/detail/communityError.d.ts +6 -0
  31. package/dist/esm/modules/appCatalog/ui/detail/communityError.js +8 -0
  32. package/dist/esm/modules/appCatalog/ui/detail/communityError.js.map +1 -0
  33. package/dist/esm/modules/appCatalog/ui/detail/composeComments.d.ts +27 -0
  34. package/dist/esm/modules/appCatalog/ui/detail/composeComments.js +66 -0
  35. package/dist/esm/modules/appCatalog/ui/detail/composeComments.js.map +1 -0
  36. package/dist/esm/modules/appCatalog/ui/detail/usePendingCount.d.ts +11 -0
  37. package/dist/esm/modules/appCatalog/ui/detail/usePendingCount.js +15 -0
  38. package/dist/esm/modules/appCatalog/ui/detail/usePendingCount.js.map +1 -0
  39. package/dist/esm/modules/extensions/features/resourceDetail.d.ts +54 -1
  40. package/dist/esm/modules/extensions/features/resourceDetail.js +4 -0
  41. package/dist/esm/modules/extensions/features/resourceDetail.js.map +1 -1
  42. package/dist/esm/modules/extensions/index.d.ts +20 -4
  43. package/dist/esm/modules/extensions/index.js +10 -4
  44. package/dist/esm/modules/extensions/index.js.map +1 -1
  45. package/dist/esm/modules/extensions/types.d.ts +1 -0
  46. package/dist/index.css +525 -49
  47. package/package.json +4 -4
  48. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.appSwitch.test.tsx +5 -2
  49. package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.enter.test.tsx +5 -2
  50. package/src/__tests__/modules/appCatalog/ui/components/SubResourcesSection.adminColumn.test.tsx +41 -4
  51. package/src/__tests__/modules/appCatalog/ui/components/SubResourcesSection.test.tsx +34 -0
  52. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.background.test.tsx +9 -10
  53. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.behindThisCard.test.tsx +402 -0
  54. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.doorAndTabs.test.tsx +8 -4
  55. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.escape.test.tsx +5 -2
  56. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.knowMore.test.tsx +175 -0
  57. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.sectionOrder.test.tsx +5 -2
  58. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.tsx +5 -2
  59. package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.tags.test.tsx +5 -2
  60. package/src/__tests__/modules/appCatalog/ui/detail/CommunitySection.feed.test.tsx +281 -0
  61. package/src/__tests__/modules/appCatalog/ui/detail/CommunitySection.help.test.tsx +179 -0
  62. package/src/__tests__/modules/appCatalog/ui/detail/CommunitySection.input.test.tsx +423 -0
  63. package/src/__tests__/modules/appCatalog/ui/detail/composeComments.test.ts +170 -0
  64. package/src/__tests__/ui/detail/CommunityInput.test.tsx +39 -0
  65. package/src/index.css +525 -49
  66. package/src/index.tsx +2 -0
  67. package/src/modules/appCatalog/ui/components/SubResourcesSection.tsx +60 -36
  68. package/src/modules/appCatalog/ui/detail/AppDetails.tsx +310 -295
  69. package/src/modules/appCatalog/ui/detail/CommentsFeed.tsx +254 -0
  70. package/src/modules/appCatalog/ui/detail/CommentsHelp.tsx +102 -0
  71. package/src/modules/appCatalog/ui/detail/CommunityInput.tsx +419 -0
  72. package/src/modules/appCatalog/ui/detail/CommunitySection.tsx +41 -0
  73. package/src/modules/appCatalog/ui/detail/DetailTabs.tsx +77 -71
  74. package/src/modules/appCatalog/ui/detail/communityDraft.ts +80 -0
  75. package/src/modules/appCatalog/ui/detail/communityError.ts +10 -0
  76. package/src/modules/appCatalog/ui/detail/composeComments.ts +103 -0
  77. package/src/modules/appCatalog/ui/detail/usePendingCount.ts +26 -0
  78. package/src/modules/extensions/features/resourceDetail.ts +45 -1
  79. package/src/modules/extensions/index.ts +39 -2
  80. package/src/modules/extensions/types.ts +1 -0
  81. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.d.ts +0 -13
  82. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js +0 -433
  83. package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js.map +0 -1
  84. package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.emptyState.test.tsx +0 -181
  85. package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.reviewStatus.test.tsx +0 -131
  86. package/src/modules/appCatalog/ui/detail/FeedbackSection.tsx +0 -605
  87. /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{FeedbackSection.emptyState.test.d.ts → AppDetails.behindThisCard.test.d.ts} +0 -0
  88. /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{FeedbackSection.reviewStatus.test.d.ts → AppDetails.knowMore.test.d.ts} +0 -0
@@ -60,8 +60,9 @@ describe('the sub-resources Admin column', () => {
60
60
  expect(
61
61
  screen.queryByRole('columnheader', { name: 'Admin' }),
62
62
  ).not.toBeInTheDocument()
63
- // Five columns, the same as before the slot existed.
64
- expect(screen.getAllByRole('columnheader')).toHaveLength(5)
63
+ // Name, Tier, Owner, Approvers — these children carry no account id, so
64
+ // the AWS Account column is absent too.
65
+ expect(screen.getAllByRole('columnheader')).toHaveLength(4)
65
66
  })
66
67
 
67
68
  it('appears, with one cell per row, once a plugin fills it', () => {
@@ -70,7 +71,7 @@ describe('the sub-resources Admin column', () => {
70
71
  expect(
71
72
  screen.getByRole('columnheader', { name: 'Admin' }),
72
73
  ).toBeInTheDocument()
73
- expect(screen.getAllByRole('columnheader')).toHaveLength(6)
74
+ expect(screen.getAllByRole('columnheader')).toHaveLength(5)
74
75
 
75
76
  // Called once PER ROW and handed that row — the opposite shape to the
76
77
  // header slot, which is called once with the whole list.
@@ -96,7 +97,43 @@ describe('the sub-resources Admin column', () => {
96
97
  )
97
98
 
98
99
  const empty = within(container).getByText(/No resources match your filters/)
99
- expect(empty).toHaveAttribute('colspan', '6')
100
+ expect(empty).toHaveAttribute('colspan', '5')
101
+ })
102
+
103
+ it('is absent on a parent the plugin says it does not serve', () => {
104
+ // A contribution that returns null for this parent would still leave a
105
+ // header over a column of empty cells; `appliesTo` is how the plugin keeps
106
+ // the column off tables it has nothing for.
107
+ mount([
108
+ {
109
+ ...rowActionsPlugin,
110
+ appliesTo: {
111
+ resourceSubResourceRowActions: ({ parent: p }) => p.slug === 'other',
112
+ },
113
+ },
114
+ ])
115
+
116
+ expect(
117
+ screen.queryByRole('columnheader', { name: 'Admin' }),
118
+ ).not.toBeInTheDocument()
119
+ expect(
120
+ screen.queryByRole('button', { name: /Provision/ }),
121
+ ).not.toBeInTheDocument()
122
+ })
123
+
124
+ it('appears on a parent the plugin says it serves', () => {
125
+ mount([
126
+ {
127
+ ...rowActionsPlugin,
128
+ appliesTo: {
129
+ resourceSubResourceRowActions: ({ parent: p }) => p.slug === 'cloud',
130
+ },
131
+ },
132
+ ])
133
+
134
+ expect(
135
+ screen.getByRole('columnheader', { name: 'Admin' }),
136
+ ).toBeInTheDocument()
100
137
  })
101
138
 
102
139
  it('skips the column when the parent is not known', () => {
@@ -96,6 +96,40 @@ describe('SubResourcesSection — account id as deep link', () => {
96
96
  })
97
97
  })
98
98
 
99
+ // The AWS Account column belongs to families of AWS accounts. Anything else —
100
+ // workflow namespaces, data projects — has nothing to put in it, and a column
101
+ // of dashes under that header reads as missing data.
102
+ describe('SubResourcesSection — AWS Account column', () => {
103
+ it('is absent when no child carries an account id', () => {
104
+ render(
105
+ <SubResourcesSection
106
+ parentSlug="workflows"
107
+ subResources={[sub('ns-a', null), sub('ns-b', { region: 'x' })]}
108
+ />,
109
+ )
110
+ expect(
111
+ screen.queryByRole('columnheader', { name: 'AWS Account' }),
112
+ ).not.toBeInTheDocument()
113
+ expect(screen.getAllByRole('columnheader')).toHaveLength(4)
114
+ })
115
+
116
+ it('is present when at least one child carries an account id', () => {
117
+ render(
118
+ <SubResourcesSection
119
+ parentSlug="cloud-console"
120
+ subResources={[
121
+ sub('acct-a', { awsAccountId: '111122223333' }),
122
+ sub('other', null),
123
+ ]}
124
+ />,
125
+ )
126
+ expect(
127
+ screen.getByRole('columnheader', { name: 'AWS Account' }),
128
+ ).toBeInTheDocument()
129
+ expect(screen.getAllByRole('columnheader')).toHaveLength(5)
130
+ })
131
+ })
132
+
99
133
  // A `?sub=` deep link singles out one row; the table must say so and offer a
100
134
  // way back to the full list without editing the URL.
101
135
  describe('SubResourcesSection — clearing the ?sub= selection', () => {
@@ -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')
@@ -66,17 +69,13 @@ describe('AppDetails — Background', () => {
66
69
  ).toBeInTheDocument()
67
70
  })
68
71
 
69
- // Asserted ON the Documentation tab on purpose. Asserting it from Overview would
70
- // pass whether or not the section works, because Background is in another
71
- // panel either way — a test that cannot fail is worse than no test.
72
- it('leaves no empty section when the entry has no history recorded', () => {
72
+ // With neither links nor history the tab has nothing to say, so it is not
73
+ // offered at all — an empty tab is the same dead end as an empty section.
74
+ it('offers no Documentation tab when the entry has no history recorded', () => {
73
75
  const { background: _omitted, ...withoutBackground } = app
74
76
  render(<AppDetails app={withoutBackground as Resource} />)
75
77
 
76
- fireEvent.click(screen.getByRole('tab', { name: /documentation/i }))
77
-
78
+ expect(screen.queryByRole('tab', { name: /documentation/i })).toBeNull()
78
79
  expect(screen.queryByRole('heading', { name: 'Background' })).toBeNull()
79
- // Proves the panel really is open, so the absence above means something.
80
- expect(screen.getByRole('heading', { name: 'Sources' })).toBeVisible()
81
80
  })
82
81
  })
@@ -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')