@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.
- package/dist/esm/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.d.ts +0 -0
- 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/AppCatalogGrid.js +19 -0
- package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js.map +1 -1
- 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/catalog/RequestAppDialog.d.ts +14 -0
- package/dist/esm/modules/appCatalog/ui/catalog/RequestAppDialog.js +115 -0
- package/dist/esm/modules/appCatalog/ui/catalog/RequestAppDialog.js.map +1 -0
- 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 +90 -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/ui/pages/AppCatalogPage.js +18 -1
- package/dist/esm/modules/appCatalog/ui/pages/AppCatalogPage.js.map +1 -1
- 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/catalog/RequestAppDialog.test.tsx +113 -0
- 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 +193 -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/AppCatalogGrid.tsx +24 -0
- package/src/modules/appCatalog/ui/catalog/AppDetailPanel.tsx +46 -2
- package/src/modules/appCatalog/ui/catalog/RequestAppDialog.tsx +169 -0
- 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 +195 -0
- package/src/modules/appCatalog/ui/detail/FeedbackSection.tsx +605 -0
- package/src/modules/appCatalog/ui/pages/AppCatalogPage.tsx +20 -1
- 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 → catalog/RequestAppDialog.test.d.ts} +0 -0
- /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{CommentsSection.reviewStatus.test.d.ts → AppDetails.doorAndTabs.test.d.ts} +0 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import type { Resource } from '@igstack/app-catalog-backend-core'
|
|
2
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import '@testing-library/jest-dom/vitest'
|
|
5
|
+
|
|
6
|
+
// `navigate` is captured rather than ignored. A stub that swallows the call
|
|
7
|
+
// makes the url-writing side of the tabs unreachable from a test, which is how
|
|
8
|
+
// a feature ends up claimed and unguarded — `?tab=` deep-linking is the whole
|
|
9
|
+
// reason the active tab lives in the url, so it gets asserted here.
|
|
10
|
+
const navigate = vi.fn()
|
|
11
|
+
const search: Record<string, unknown> = {}
|
|
12
|
+
|
|
13
|
+
vi.mock('@tanstack/react-router', () => ({
|
|
14
|
+
useSearch: vi.fn(() => search),
|
|
15
|
+
useNavigate: vi.fn(() => navigate),
|
|
16
|
+
useRouter: vi.fn(() => ({
|
|
17
|
+
state: { location: { pathname: '/app/thing', search } },
|
|
18
|
+
})),
|
|
19
|
+
Link: ({ children, ...rest }: { children?: React.ReactNode }) => (
|
|
20
|
+
<a {...rest}>{children}</a>
|
|
21
|
+
),
|
|
22
|
+
}))
|
|
23
|
+
vi.mock('~/modules/appCatalog/context/AppCatalogContext', () => ({
|
|
24
|
+
useAppCatalogContext: vi.fn(() => ({ approvalMethods: [], resources: [] })),
|
|
25
|
+
}))
|
|
26
|
+
vi.mock('~/modules/appCatalog/hooks/useAppClickHistory', () => ({
|
|
27
|
+
useAppClickHistory: vi.fn(() => ({ recordClick: vi.fn() })),
|
|
28
|
+
}))
|
|
29
|
+
vi.mock('~/modules/appCatalog/hooks/useUpdateApp', () => ({
|
|
30
|
+
useUpdateApp: vi.fn(() => ({ mutate: vi.fn() })),
|
|
31
|
+
}))
|
|
32
|
+
vi.mock('~/modules/appCatalog/ui/context/AppCatalogFiltersContext', () => ({
|
|
33
|
+
useAppCatalogFilters: vi.fn(() => ({
|
|
34
|
+
state: { searchValue: '' },
|
|
35
|
+
actions: {},
|
|
36
|
+
})),
|
|
37
|
+
}))
|
|
38
|
+
vi.mock('~/modules/auth', () => ({ useUser: vi.fn(() => null) }))
|
|
39
|
+
vi.mock('~/modules/appCatalog/ui/detail/FeedbackSection', () => ({
|
|
40
|
+
FeedbackSection: () => <div />,
|
|
41
|
+
}))
|
|
42
|
+
|
|
43
|
+
const { AppDetails } = await import('~/modules/appCatalog/ui/detail/AppDetails')
|
|
44
|
+
|
|
45
|
+
const withUrl = {
|
|
46
|
+
id: 'thing',
|
|
47
|
+
slug: 'thing',
|
|
48
|
+
displayName: 'Thing',
|
|
49
|
+
description: 'Does a thing.',
|
|
50
|
+
appUrl: 'https://thing.example.com/portal',
|
|
51
|
+
} as Resource
|
|
52
|
+
|
|
53
|
+
const withoutUrl = {
|
|
54
|
+
id: 'quiet',
|
|
55
|
+
slug: 'quiet',
|
|
56
|
+
displayName: 'Quiet Thing',
|
|
57
|
+
description: 'Has no address on file.',
|
|
58
|
+
} as Resource
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
navigate.mockClear()
|
|
62
|
+
for (const key of Object.keys(search)) delete search[key]
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// A third of the catalog has no `appUrl`. The header used to render a bare em
|
|
66
|
+
// dash there, which reads as "the card failed" rather than "there is no
|
|
67
|
+
// address" — and a surface that looks pressable but is not would be worse.
|
|
68
|
+
describe('the door', () => {
|
|
69
|
+
it('is a link to the app when there is an address', () => {
|
|
70
|
+
render(<AppDetails app={withUrl} onClosePanel={vi.fn()} />)
|
|
71
|
+
|
|
72
|
+
const door = screen.getByRole('link', { name: /^Open Thing in a new tab/ })
|
|
73
|
+
expect(door).toHaveAttribute('href', 'https://thing.example.com/portal')
|
|
74
|
+
expect(door).toHaveAttribute('target', '_blank')
|
|
75
|
+
// The name and the address are INSIDE the one control, which is the whole
|
|
76
|
+
// point — the largest thing on the card is the thing you press.
|
|
77
|
+
expect(door).toHaveTextContent('Thing')
|
|
78
|
+
expect(door).toHaveTextContent('thing.example.com/portal')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('says so plainly when the entry has no address, and offers nothing to press', () => {
|
|
82
|
+
render(<AppDetails app={withoutUrl} onClosePanel={vi.fn()} />)
|
|
83
|
+
|
|
84
|
+
expect(screen.getByText('No URL for this app.')).toBeVisible()
|
|
85
|
+
expect(screen.queryByRole('link', { name: /in a new tab/ })).toBeNull()
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('the tab strip', () => {
|
|
90
|
+
// 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.
|
|
92
|
+
it('writes the selected tab to the url', () => {
|
|
93
|
+
render(<AppDetails app={withUrl} onClosePanel={vi.fn()} />)
|
|
94
|
+
|
|
95
|
+
fireEvent.click(screen.getByRole('tab', { name: /^access/i }))
|
|
96
|
+
|
|
97
|
+
expect(navigate).toHaveBeenCalled()
|
|
98
|
+
const wroteTab = navigate.mock.calls.some(
|
|
99
|
+
([arg]) =>
|
|
100
|
+
(arg as { search?: Record<string, unknown> }).search?.tab === 'access',
|
|
101
|
+
)
|
|
102
|
+
expect(wroteTab).toBe(true)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The card opens on the first tab, so naming it in `?tab=` says nothing — and it
|
|
107
|
+
* made every shared link carry a param the sender never chose. Only a tab that is
|
|
108
|
+
* NOT the default belongs in the url.
|
|
109
|
+
*/
|
|
110
|
+
it('keeps the default tab out of the url', () => {
|
|
111
|
+
render(<AppDetails app={withUrl} onClosePanel={vi.fn()} />)
|
|
112
|
+
|
|
113
|
+
// Go somewhere else, then come back to the first tab.
|
|
114
|
+
fireEvent.click(screen.getByRole('tab', { name: /^access/i }))
|
|
115
|
+
navigate.mockClear()
|
|
116
|
+
fireEvent.click(screen.getByRole('tab', { name: /^overview/i }))
|
|
117
|
+
|
|
118
|
+
const wroteDefault = navigate.mock.calls.some(
|
|
119
|
+
([arg]) =>
|
|
120
|
+
(arg as { search?: Record<string, unknown> }).search?.tab ===
|
|
121
|
+
'overview',
|
|
122
|
+
)
|
|
123
|
+
expect(
|
|
124
|
+
wroteDefault,
|
|
125
|
+
'returning to the default tab must clear ?tab=, not set it',
|
|
126
|
+
).toBe(false)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('opens on the tab named in the url', () => {
|
|
130
|
+
search.tab = 'documentation'
|
|
131
|
+
render(<AppDetails app={withUrl} onClosePanel={vi.fn()} />)
|
|
132
|
+
|
|
133
|
+
expect(screen.getByRole('tab', { name: 'Documentation' })).toHaveAttribute(
|
|
134
|
+
'aria-selected',
|
|
135
|
+
'true',
|
|
136
|
+
)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
// Tab ids travel in links people have already shared, so retiring one has to
|
|
140
|
+
// keep resolving rather than dropping the reader on the first tab.
|
|
141
|
+
it('still honours a retired tab id from an older link', () => {
|
|
142
|
+
search.tab = 'metadata'
|
|
143
|
+
render(<AppDetails app={withUrl} onClosePanel={vi.fn()} />)
|
|
144
|
+
|
|
145
|
+
expect(screen.getByRole('tab', { name: 'Documentation' })).toHaveAttribute(
|
|
146
|
+
'aria-selected',
|
|
147
|
+
'true',
|
|
148
|
+
)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
// A stale link, or an entry that lost its children. Falling back beats
|
|
152
|
+
// rendering a card with no panel at all.
|
|
153
|
+
it('falls back to the first tab when the url names one this entry lacks', () => {
|
|
154
|
+
search.tab = 'resources'
|
|
155
|
+
render(<AppDetails app={withUrl} onClosePanel={vi.fn()} />)
|
|
156
|
+
|
|
157
|
+
expect(screen.queryByRole('tab', { name: /resources/i })).toBeNull()
|
|
158
|
+
expect(screen.getByRole('tab', { name: 'Overview' })).toHaveAttribute(
|
|
159
|
+
'aria-selected',
|
|
160
|
+
'true',
|
|
161
|
+
)
|
|
162
|
+
expect(screen.getByRole('heading', { name: 'Description' })).toBeVisible()
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
// Per the tablist pattern: one stop in the tab order, arrows move within.
|
|
166
|
+
it('moves between tabs with the arrow keys', () => {
|
|
167
|
+
render(<AppDetails app={withUrl} onClosePanel={vi.fn()} />)
|
|
168
|
+
|
|
169
|
+
const overview = screen.getByRole('tab', { name: 'Overview' })
|
|
170
|
+
expect(overview).toHaveAttribute('tabindex', '0')
|
|
171
|
+
|
|
172
|
+
fireEvent.keyDown(overview, { key: 'ArrowRight' })
|
|
173
|
+
|
|
174
|
+
expect(screen.getByRole('tab', { name: /^access/i })).toHaveAttribute(
|
|
175
|
+
'aria-selected',
|
|
176
|
+
'true',
|
|
177
|
+
)
|
|
178
|
+
// Only the selected tab stays reachable by Tab.
|
|
179
|
+
expect(overview).toHaveAttribute('tabindex', '-1')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('wraps from the first tab to the last', () => {
|
|
183
|
+
render(<AppDetails app={withUrl} onClosePanel={vi.fn()} />)
|
|
184
|
+
|
|
185
|
+
fireEvent.keyDown(screen.getByRole('tab', { name: 'Overview' }), {
|
|
186
|
+
key: 'ArrowLeft',
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
expect(
|
|
190
|
+
screen.getByRole('tab', { name: 'Notes & requests' }),
|
|
191
|
+
).toHaveAttribute('aria-selected', 'true')
|
|
192
|
+
})
|
|
193
|
+
})
|
|
@@ -28,8 +28,8 @@ 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/
|
|
32
|
-
|
|
31
|
+
vi.mock('~/modules/appCatalog/ui/detail/FeedbackSection', () => ({
|
|
32
|
+
FeedbackSection: () => <div />,
|
|
33
33
|
}))
|
|
34
34
|
|
|
35
35
|
const { AppDetails } = await import('~/modules/appCatalog/ui/detail/AppDetails')
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Resource } from '@igstack/app-catalog-backend-core'
|
|
2
|
-
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
3
3
|
import { describe, expect, it, vi } from 'vitest'
|
|
4
4
|
import '@testing-library/jest-dom/vitest'
|
|
5
5
|
|
|
@@ -30,8 +30,8 @@ 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/
|
|
34
|
-
|
|
33
|
+
vi.mock('~/modules/appCatalog/ui/detail/FeedbackSection', () => ({
|
|
34
|
+
FeedbackSection: () => <div />,
|
|
35
35
|
}))
|
|
36
36
|
|
|
37
37
|
const { AppDetails } = await import('~/modules/appCatalog/ui/detail/AppDetails')
|
|
@@ -47,16 +47,39 @@ const app = {
|
|
|
47
47
|
// The description says what the app is; "How to get access" only matters once
|
|
48
48
|
// you have decided you want it. Access used to render above the description, so
|
|
49
49
|
// the panel opened on paperwork.
|
|
50
|
+
//
|
|
51
|
+
// That ordering is now a tab order rather than a DOM order — the two are never
|
|
52
|
+
// mounted together — so these assert the same guarantee in the new shape: the
|
|
53
|
+
// card opens on what the app IS, and the paperwork is one deliberate click
|
|
54
|
+
// away. Checking that the access panel is absent until asked for is the part
|
|
55
|
+
// that would have silently regressed if this test had simply been deleted.
|
|
50
56
|
describe('AppDetails — section order', () => {
|
|
51
|
-
it('
|
|
57
|
+
it('opens on the description, with the access instructions not yet mounted', () => {
|
|
52
58
|
render(<AppDetails app={app} onClosePanel={vi.fn()} />)
|
|
53
59
|
|
|
54
|
-
|
|
55
|
-
|
|
60
|
+
expect(screen.getByRole('heading', { name: 'Description' })).toBeVisible()
|
|
61
|
+
expect(
|
|
62
|
+
screen.queryByRole('heading', { name: /how to get access/i }),
|
|
63
|
+
).toBeNull()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('reaches the access instructions from its own tab', () => {
|
|
67
|
+
render(<AppDetails app={app} onClosePanel={vi.fn()} />)
|
|
68
|
+
|
|
69
|
+
fireEvent.click(screen.getByRole('tab', { name: /^access/i }))
|
|
70
|
+
|
|
71
|
+
expect(screen.getByRole('tab', { name: /^access/i })).toBeVisible()
|
|
72
|
+
expect(screen.queryByRole('heading', { name: 'Description' })).toBeNull()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
// The tab has to say what the route is before it is opened. With no matching
|
|
76
|
+
// approval method registered there is nothing to name, and claiming a route
|
|
77
|
+
// we cannot describe is the failure the label exists to prevent.
|
|
78
|
+
it('names the access route in the tab', () => {
|
|
79
|
+
render(<AppDetails app={app} onClosePanel={vi.fn()} />)
|
|
56
80
|
|
|
57
81
|
expect(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
).toBeTruthy()
|
|
82
|
+
screen.getByRole('tab', { name: /access — not documented/i }),
|
|
83
|
+
).toBeVisible()
|
|
61
84
|
})
|
|
62
85
|
})
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { Resource } from '@igstack/app-catalog-backend-core'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import '@testing-library/jest-dom/vitest'
|
|
5
|
+
|
|
6
|
+
// `?sub=` is read by AppDetails (to pick the tab) AND by SubResourcesSection
|
|
7
|
+
// (to single out the row), so it is set on the shared router mock rather than
|
|
8
|
+
// passed in. Everything else in the data layer is stubbed; the chain under test
|
|
9
|
+
// is AppDetails -> DetailTabs -> SubResourcesSection.
|
|
10
|
+
const search = { sub: 'parent-two', tab: undefined as string | undefined }
|
|
11
|
+
|
|
12
|
+
vi.mock('@tanstack/react-router', () => ({
|
|
13
|
+
useSearch: vi.fn(() => search),
|
|
14
|
+
useNavigate: vi.fn(() => vi.fn()),
|
|
15
|
+
useRouter: vi.fn(() => ({
|
|
16
|
+
state: { location: { pathname: '/', search: {} } },
|
|
17
|
+
})),
|
|
18
|
+
Link: ({ children, ...rest }: { children?: React.ReactNode }) => (
|
|
19
|
+
<a {...rest}>{children}</a>
|
|
20
|
+
),
|
|
21
|
+
}))
|
|
22
|
+
|
|
23
|
+
const parent = {
|
|
24
|
+
id: 'parent',
|
|
25
|
+
slug: 'parent',
|
|
26
|
+
displayName: 'Parent',
|
|
27
|
+
description: 'A resource with children.',
|
|
28
|
+
childrenLabel: 'Accounts',
|
|
29
|
+
} as Resource
|
|
30
|
+
|
|
31
|
+
const children: Resource[] = [
|
|
32
|
+
{
|
|
33
|
+
id: 'parent-one',
|
|
34
|
+
slug: 'parent-one',
|
|
35
|
+
displayName: 'first-child',
|
|
36
|
+
parentSlug: 'parent',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'parent-two',
|
|
40
|
+
slug: 'parent-two',
|
|
41
|
+
displayName: 'second-child',
|
|
42
|
+
parentSlug: 'parent',
|
|
43
|
+
},
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
vi.mock('~/modules/appCatalog/context/AppCatalogContext', () => ({
|
|
47
|
+
useAppCatalogContext: vi.fn(() => ({
|
|
48
|
+
approvalMethods: [],
|
|
49
|
+
resources: [parent, ...children],
|
|
50
|
+
})),
|
|
51
|
+
}))
|
|
52
|
+
vi.mock('~/modules/appCatalog/hooks/useAppClickHistory', () => ({
|
|
53
|
+
useAppClickHistory: vi.fn(() => ({ recordClick: vi.fn() })),
|
|
54
|
+
}))
|
|
55
|
+
vi.mock('~/modules/appCatalog/hooks/useUpdateApp', () => ({
|
|
56
|
+
useUpdateApp: vi.fn(() => ({ mutate: vi.fn() })),
|
|
57
|
+
}))
|
|
58
|
+
vi.mock('~/modules/appCatalog/ui/context/AppCatalogFiltersContext', () => ({
|
|
59
|
+
useAppCatalogFilters: vi.fn(() => ({
|
|
60
|
+
state: { searchValue: '' },
|
|
61
|
+
actions: {},
|
|
62
|
+
})),
|
|
63
|
+
}))
|
|
64
|
+
vi.mock('~/modules/auth', () => ({ useUser: vi.fn(() => null) }))
|
|
65
|
+
vi.mock('~/modules/appCatalog/ui/detail/FeedbackSection', () => ({
|
|
66
|
+
FeedbackSection: () => <div />,
|
|
67
|
+
}))
|
|
68
|
+
|
|
69
|
+
const { AppDetails } = await import('~/modules/appCatalog/ui/detail/AppDetails')
|
|
70
|
+
|
|
71
|
+
// Searching the catalog can match a CHILD — an account id, an alias — and
|
|
72
|
+
// clicking that result opens the parent's card with `?sub=<child>`. Landing on
|
|
73
|
+
// Overview and leaving the visitor to find the right tab would waste the one
|
|
74
|
+
// piece of information their click carried.
|
|
75
|
+
describe('AppDetails — arriving with ?sub=', () => {
|
|
76
|
+
it('opens on the children tab, not on Overview', () => {
|
|
77
|
+
render(<AppDetails app={parent} onClosePanel={vi.fn()} />)
|
|
78
|
+
|
|
79
|
+
expect(screen.getByRole('tab', { name: /accounts/i })).toHaveAttribute(
|
|
80
|
+
'aria-selected',
|
|
81
|
+
'true',
|
|
82
|
+
)
|
|
83
|
+
expect(screen.getByRole('tab', { name: 'Overview' })).toHaveAttribute(
|
|
84
|
+
'aria-selected',
|
|
85
|
+
'false',
|
|
86
|
+
)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('singles out the matched child rather than listing its siblings', () => {
|
|
90
|
+
render(<AppDetails app={parent} onClosePanel={vi.fn()} />)
|
|
91
|
+
|
|
92
|
+
expect(screen.getByText(/Showing: second-child/)).toBeInTheDocument()
|
|
93
|
+
expect(screen.queryByText('first-child')).toBeNull()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// A link carrying both must not show one thing and claim another. `?sub=`
|
|
97
|
+
// names a specific child, which is the more specific intent, so it wins.
|
|
98
|
+
it('wins over a ?tab= that disagrees', () => {
|
|
99
|
+
search.tab = 'metadata'
|
|
100
|
+
try {
|
|
101
|
+
render(<AppDetails app={parent} onClosePanel={vi.fn()} />)
|
|
102
|
+
expect(screen.getByRole('tab', { name: /accounts/i })).toHaveAttribute(
|
|
103
|
+
'aria-selected',
|
|
104
|
+
'true',
|
|
105
|
+
)
|
|
106
|
+
} finally {
|
|
107
|
+
search.tab = undefined
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('names the tab from childrenLabel and counts the children', () => {
|
|
112
|
+
render(<AppDetails app={parent} onClosePanel={vi.fn()} />)
|
|
113
|
+
|
|
114
|
+
const tab = screen.getByRole('tab', { name: /accounts/i })
|
|
115
|
+
expect(tab).toHaveTextContent('Accounts')
|
|
116
|
+
expect(tab).toHaveTextContent('2')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('falls back to a generic label when the parent does not name its children', () => {
|
|
120
|
+
const { childrenLabel: _omitted, ...unnamed } = parent
|
|
121
|
+
render(<AppDetails app={unnamed as Resource} onClosePanel={vi.fn()} />)
|
|
122
|
+
|
|
123
|
+
expect(screen.getByRole('tab', { name: /resources/i })).toBeVisible()
|
|
124
|
+
})
|
|
125
|
+
})
|
|
@@ -28,8 +28,8 @@ 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/
|
|
32
|
-
|
|
31
|
+
vi.mock('~/modules/appCatalog/ui/detail/FeedbackSection', () => ({
|
|
32
|
+
FeedbackSection: () => <div />,
|
|
33
33
|
}))
|
|
34
34
|
|
|
35
35
|
const { AppDetails } = await import('~/modules/appCatalog/ui/detail/AppDetails')
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import '@testing-library/jest-dom/vitest'
|
|
5
|
+
|
|
6
|
+
// The section only needs the list query here; the whole tRPC/react-query stack is
|
|
7
|
+
// stubbed down to that so the composer's own behaviour is the only thing under test.
|
|
8
|
+
const state = vi.hoisted(() => ({
|
|
9
|
+
items: [] as unknown[],
|
|
10
|
+
sent: [] as unknown[],
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
vi.mock('@tanstack/react-query', () => ({
|
|
14
|
+
useQuery: vi.fn(() => ({
|
|
15
|
+
data: { items: state.items, openCount: 0 },
|
|
16
|
+
isLoading: false,
|
|
17
|
+
})),
|
|
18
|
+
// `mutate` runs the success callback, so the stub can represent a completed send
|
|
19
|
+
// and not just an attempted one. Without that, anything the component does on
|
|
20
|
+
// success — clearing the stored draft, closing the composer — is unreachable from
|
|
21
|
+
// a test, and a fixture that cannot express success quietly stops guarding it.
|
|
22
|
+
useMutation: vi.fn((options?: { onSuccess?: () => void }) => ({
|
|
23
|
+
mutate: (vars: unknown) => {
|
|
24
|
+
state.sent.push(vars)
|
|
25
|
+
options?.onSuccess?.()
|
|
26
|
+
},
|
|
27
|
+
isPending: false,
|
|
28
|
+
})),
|
|
29
|
+
useQueryClient: vi.fn(() => ({ invalidateQueries: vi.fn() })),
|
|
30
|
+
}))
|
|
31
|
+
vi.mock('~/api/infra/trpc', () => ({
|
|
32
|
+
useTRPC: vi.fn(() => ({
|
|
33
|
+
feedback: {
|
|
34
|
+
list: { queryOptions: () => ({ queryKey: ['feedback'] }) },
|
|
35
|
+
add: { mutationOptions: (o: unknown) => o },
|
|
36
|
+
edit: { mutationOptions: (o: unknown) => o },
|
|
37
|
+
dismiss: { mutationOptions: (o: unknown) => o },
|
|
38
|
+
},
|
|
39
|
+
})),
|
|
40
|
+
}))
|
|
41
|
+
|
|
42
|
+
const { FeedbackSection } =
|
|
43
|
+
await import('~/modules/appCatalog/ui/detail/FeedbackSection')
|
|
44
|
+
|
|
45
|
+
describe('FeedbackSection — composer', () => {
|
|
46
|
+
it('stays closed until something asks for it', () => {
|
|
47
|
+
state.items = []
|
|
48
|
+
render(<FeedbackSection appSlug="example-app" />)
|
|
49
|
+
|
|
50
|
+
expect(screen.getByText(/Nothing here yet/)).toBeInTheDocument()
|
|
51
|
+
expect(screen.queryByPlaceholderText('What should change?')).toBeNull()
|
|
52
|
+
// The affordance lives in the card's band now, not in this panel — down here
|
|
53
|
+
// it was below the fold of a tab, which is why it moved.
|
|
54
|
+
expect(
|
|
55
|
+
screen.queryByRole('button', { name: /Suggest a change/ }),
|
|
56
|
+
).toBeNull()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('opens and takes the caret when the band asks', () => {
|
|
60
|
+
state.items = []
|
|
61
|
+
render(<FeedbackSection appSlug="example-app" openComposer={1} />)
|
|
62
|
+
|
|
63
|
+
const field = screen.getByPlaceholderText('What should change?')
|
|
64
|
+
expect(field).toBeInTheDocument()
|
|
65
|
+
expect(field).toHaveFocus()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('does not auto-open just because feedback exists', () => {
|
|
69
|
+
state.items = [
|
|
70
|
+
{
|
|
71
|
+
id: 'c1',
|
|
72
|
+
authorName: 'Curious Ferret',
|
|
73
|
+
body: 'Useful.',
|
|
74
|
+
subject: null,
|
|
75
|
+
resourceSlug: 'example-app',
|
|
76
|
+
resourceName: 'Example App',
|
|
77
|
+
attachmentIds: [],
|
|
78
|
+
createdAt: new Date().toISOString(),
|
|
79
|
+
reviewedAt: null,
|
|
80
|
+
editedAt: null,
|
|
81
|
+
isMine: false,
|
|
82
|
+
canEditUntil: null,
|
|
83
|
+
status: null,
|
|
84
|
+
reviewerReply: null,
|
|
85
|
+
},
|
|
86
|
+
]
|
|
87
|
+
render(<FeedbackSection appSlug="example-app" />)
|
|
88
|
+
|
|
89
|
+
expect(screen.queryByPlaceholderText('What should change?')).toBeNull()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('sends with nothing typed — a bare flag is a valid signal', async () => {
|
|
93
|
+
state.items = []
|
|
94
|
+
state.sent = []
|
|
95
|
+
render(<FeedbackSection appSlug="example-app" openComposer={1} />)
|
|
96
|
+
|
|
97
|
+
const send = screen.getByRole('button', { name: 'Send' })
|
|
98
|
+
// The whole point: no words, no image, still submittable.
|
|
99
|
+
expect(send).toBeEnabled()
|
|
100
|
+
|
|
101
|
+
await userEvent.click(send)
|
|
102
|
+
expect(state.sent).toEqual([{ resourceSlug: 'example-app', body: '' }])
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The detail card mounts this inside a tab panel, and a panel is unmounted while
|
|
107
|
+
* its tab is inactive — so an unmount is a routine event here, not an edge case.
|
|
108
|
+
* Component state alone would drop whatever someone had typed.
|
|
109
|
+
*/
|
|
110
|
+
it('keeps a half-typed draft across an unmount, and reopens the composer for it', async () => {
|
|
111
|
+
sessionStorage.clear()
|
|
112
|
+
state.items = []
|
|
113
|
+
const first = render(
|
|
114
|
+
<FeedbackSection appSlug="example-app" openComposer={1} />,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
await userEvent.type(
|
|
118
|
+
screen.getByPlaceholderText('What should change?'),
|
|
119
|
+
'half a thought',
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
// Leaving the tab unmounts the panel.
|
|
123
|
+
first.unmount()
|
|
124
|
+
render(<FeedbackSection appSlug="example-app" />)
|
|
125
|
+
|
|
126
|
+
const recovered = screen.getByPlaceholderText('What should change?')
|
|
127
|
+
expect(recovered).toHaveValue('half a thought')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The composer closes itself on send, so the band has to be able to say "again".
|
|
132
|
+
* It used to pass a boolean, which cannot: the second click left the prop at
|
|
133
|
+
* `true`, the effect did not re-run, and the button was dead for the rest of the
|
|
134
|
+
* card's life — you could file one correction per entry and no more. Caught by
|
|
135
|
+
* sending twice in a real browser, which is the only place it showed.
|
|
136
|
+
*/
|
|
137
|
+
it('reopens for a second request after the first one was sent', async () => {
|
|
138
|
+
sessionStorage.clear()
|
|
139
|
+
state.items = []
|
|
140
|
+
state.sent = []
|
|
141
|
+
const view = render(
|
|
142
|
+
<FeedbackSection appSlug="example-app" openComposer={1} />,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
await userEvent.click(screen.getByRole('button', { name: 'Send' }))
|
|
146
|
+
expect(screen.queryByPlaceholderText('What should change?')).toBeNull()
|
|
147
|
+
|
|
148
|
+
// The band asking a second time — a new count, same requested tab.
|
|
149
|
+
view.rerender(<FeedbackSection appSlug="example-app" openComposer={2} />)
|
|
150
|
+
expect(
|
|
151
|
+
screen.getByPlaceholderText('What should change?'),
|
|
152
|
+
).toBeInTheDocument()
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('does not leak a draft between entries, and clears it once sent', async () => {
|
|
156
|
+
sessionStorage.clear()
|
|
157
|
+
state.items = []
|
|
158
|
+
state.sent = []
|
|
159
|
+
const first = render(
|
|
160
|
+
<FeedbackSection appSlug="example-app" openComposer={1} />,
|
|
161
|
+
)
|
|
162
|
+
await userEvent.type(
|
|
163
|
+
screen.getByPlaceholderText('What should change?'),
|
|
164
|
+
'about this one',
|
|
165
|
+
)
|
|
166
|
+
first.unmount()
|
|
167
|
+
|
|
168
|
+
// A different entry starts clean rather than inheriting the neighbour's draft.
|
|
169
|
+
const other = render(<FeedbackSection appSlug="other-app" />)
|
|
170
|
+
expect(screen.queryByPlaceholderText('What should change?')).toBeNull()
|
|
171
|
+
other.unmount()
|
|
172
|
+
|
|
173
|
+
// Back on the original, send it, and the stored draft goes with it.
|
|
174
|
+
render(<FeedbackSection appSlug="example-app" />)
|
|
175
|
+
await userEvent.click(screen.getByRole('button', { name: 'Send' }))
|
|
176
|
+
expect(state.sent).toEqual([
|
|
177
|
+
{ resourceSlug: 'example-app', body: 'about this one' },
|
|
178
|
+
])
|
|
179
|
+
expect(sessionStorage.getItem('ac.feedback.draft.example-app')).toBeNull()
|
|
180
|
+
})
|
|
181
|
+
})
|