@igstack/app-catalog-frontend-core 6.3.1-alpha-20261003051755 → 6.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/__tests__/modules/appCatalog/ui/detail/FeedbackSection.emptyState.test.d.ts +0 -0
- package/dist/esm/__tests__/modules/appCatalog/ui/detail/FeedbackSection.reviewStatus.test.d.ts +0 -0
- package/dist/esm/__tests__/modules/appCatalog/utils/accessTabLabel.test.d.ts +1 -0
- package/dist/esm/api/infra/trpc.d.ts +42 -21
- package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +1 -0
- package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js +17 -2
- package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js +1 -0
- package/dist/esm/modules/appCatalog/ui/components/AccessPrerequisiteChain.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js +11 -4
- package/dist/esm/modules/appCatalog/ui/components/AccessRequestSection.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/QuickJumpBar.js +0 -4
- package/dist/esm/modules/appCatalog/ui/components/QuickJumpBar.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js +4 -1
- package/dist/esm/modules/appCatalog/ui/components/SubResourceDetailPanel.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +377 -385
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/AppDoor.d.ts +20 -0
- package/dist/esm/modules/appCatalog/ui/detail/AppDoor.js +59 -0
- package/dist/esm/modules/appCatalog/ui/detail/AppDoor.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.d.ts +64 -0
- package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js +88 -0
- package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.d.ts +13 -0
- package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js +433 -0
- package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js.map +1 -0
- package/dist/esm/modules/appCatalog/utils/accessTabLabel.d.ts +30 -0
- package/dist/esm/modules/appCatalog/utils/accessTabLabel.js +34 -0
- package/dist/esm/modules/appCatalog/utils/accessTabLabel.js.map +1 -0
- package/dist/index.css +269 -5
- package/package.json +4 -4
- package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.appSwitch.test.tsx +17 -3
- package/src/__tests__/modules/appCatalog/ui/components/AccessRequestSection.test.tsx +6 -4
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.background.test.tsx +25 -10
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.doorAndTabs.test.tsx +169 -0
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.escape.test.tsx +2 -2
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.sectionOrder.test.tsx +32 -9
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.tsx +125 -0
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.tags.test.tsx +2 -2
- package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.emptyState.test.tsx +181 -0
- package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.reviewStatus.test.tsx +131 -0
- package/src/__tests__/modules/appCatalog/utils/accessTabLabel.test.ts +206 -0
- package/src/__tests__/ui/components/header/Header.loginHidden.test.tsx +1 -1
- package/src/index.css +269 -5
- package/src/modules/appCatalog/ui/catalog/AppDetailPanel.tsx +46 -2
- package/src/modules/appCatalog/ui/components/AccessPrerequisiteChain.tsx +3 -0
- package/src/modules/appCatalog/ui/components/AccessRequestSection.tsx +9 -4
- package/src/modules/appCatalog/ui/components/SubResourceDetailPanel.tsx +4 -1
- package/src/modules/appCatalog/ui/detail/AppDetails.tsx +574 -554
- package/src/modules/appCatalog/ui/detail/AppDoor.tsx +97 -0
- package/src/modules/appCatalog/ui/detail/DetailTabs.tsx +188 -0
- package/src/modules/appCatalog/ui/detail/FeedbackSection.tsx +605 -0
- package/src/modules/appCatalog/utils/accessTabLabel.ts +95 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.d.ts +0 -3
- package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.js +0 -228
- package/dist/esm/modules/appCatalog/ui/detail/CommentsSection.js.map +0 -1
- package/src/__tests__/modules/appCatalog/ui/detail/CommentsSection.emptyState.test.tsx +0 -64
- package/src/__tests__/modules/appCatalog/ui/detail/CommentsSection.reviewStatus.test.tsx +0 -93
- package/src/modules/appCatalog/ui/detail/CommentsSection.tsx +0 -296
- /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{CommentsSection.emptyState.test.d.ts → AppDetails.doorAndTabs.test.d.ts} +0 -0
- /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{CommentsSection.reviewStatus.test.d.ts → AppDetails.subResourceTab.test.d.ts} +0 -0
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import '@testing-library/jest-dom/vitest'
|
|
4
|
+
|
|
5
|
+
// Same stub shape as the empty-state test: only the list query matters here.
|
|
6
|
+
const state = vi.hoisted(() => ({
|
|
7
|
+
items: [] as unknown[],
|
|
8
|
+
openCount: 0,
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
vi.mock('@tanstack/react-query', () => ({
|
|
12
|
+
useQuery: vi.fn(() => ({
|
|
13
|
+
data: { items: state.items, openCount: state.openCount },
|
|
14
|
+
isLoading: false,
|
|
15
|
+
})),
|
|
16
|
+
useMutation: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
|
|
17
|
+
useQueryClient: vi.fn(() => ({ invalidateQueries: vi.fn() })),
|
|
18
|
+
}))
|
|
19
|
+
vi.mock('~/api/infra/trpc', () => ({
|
|
20
|
+
useTRPC: vi.fn(() => ({
|
|
21
|
+
feedback: {
|
|
22
|
+
list: { queryOptions: () => ({ queryKey: ['feedback'] }) },
|
|
23
|
+
add: { mutationOptions: (o: unknown) => o },
|
|
24
|
+
edit: { mutationOptions: (o: unknown) => o },
|
|
25
|
+
dismiss: { mutationOptions: (o: unknown) => o },
|
|
26
|
+
},
|
|
27
|
+
})),
|
|
28
|
+
}))
|
|
29
|
+
|
|
30
|
+
const { FeedbackSection } =
|
|
31
|
+
await import('~/modules/appCatalog/ui/detail/FeedbackSection')
|
|
32
|
+
|
|
33
|
+
const base = {
|
|
34
|
+
id: 'c1',
|
|
35
|
+
authorName: 'Merry Mole',
|
|
36
|
+
body: 'This portal only covers one business unit.',
|
|
37
|
+
subject: null,
|
|
38
|
+
resourceSlug: 'example-app',
|
|
39
|
+
resourceName: 'Example App',
|
|
40
|
+
attachmentIds: [] as string[],
|
|
41
|
+
createdAt: new Date().toISOString(),
|
|
42
|
+
reviewedAt: null as string | null,
|
|
43
|
+
editedAt: null,
|
|
44
|
+
isMine: false,
|
|
45
|
+
canEditUntil: null,
|
|
46
|
+
status: null as string | null,
|
|
47
|
+
reviewerReply: null as string | null,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('FeedbackSection — review status', () => {
|
|
51
|
+
it('credits applied work with a short label, not a sentence', () => {
|
|
52
|
+
state.items = [{ ...base, status: 'applied' }]
|
|
53
|
+
state.openCount = 0
|
|
54
|
+
render(<FeedbackSection appSlug="example-app" />)
|
|
55
|
+
|
|
56
|
+
// Exactly one: the chip on the row. There is no legend restating it, and the
|
|
57
|
+
// gratitude lives in the reply prose rather than inside the pill.
|
|
58
|
+
expect(screen.getAllByText('Applied')).toHaveLength(1)
|
|
59
|
+
expect(screen.queryByText(/thank you!/)).toBeNull()
|
|
60
|
+
expect(screen.queryByText(/means the catalog now carries/)).toBeNull()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('shows the quieter label for feedback that changed nothing', () => {
|
|
64
|
+
state.items = [{ ...base, status: 'acknowledged' }]
|
|
65
|
+
render(<FeedbackSection appSlug="example-app" />)
|
|
66
|
+
|
|
67
|
+
expect(screen.getAllByText('Seen')).toHaveLength(1)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('renders the maintainer reply under the row', () => {
|
|
71
|
+
state.items = [
|
|
72
|
+
{
|
|
73
|
+
...base,
|
|
74
|
+
status: 'applied',
|
|
75
|
+
reviewerReply: 'Folded into Background — thank you.',
|
|
76
|
+
},
|
|
77
|
+
]
|
|
78
|
+
render(<FeedbackSection appSlug="example-app" />)
|
|
79
|
+
|
|
80
|
+
expect(screen.getByText('Catalog team')).toBeInTheDocument()
|
|
81
|
+
expect(
|
|
82
|
+
screen.getByText('Folded into Background — thank you.'),
|
|
83
|
+
).toBeInTheDocument()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('gives an unreviewed row no chip at all — absence is the signal', () => {
|
|
87
|
+
state.items = [base]
|
|
88
|
+
state.openCount = 1
|
|
89
|
+
render(<FeedbackSection appSlug="example-app" />)
|
|
90
|
+
|
|
91
|
+
expect(screen.queryByText('Applied')).toBeNull()
|
|
92
|
+
expect(screen.queryByText('Seen')).toBeNull()
|
|
93
|
+
expect(screen.queryByText('Open')).toBeNull()
|
|
94
|
+
expect(screen.queryByText('Catalog team')).toBeNull()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('counts unreviewed items on the heading, and hides the count at zero', () => {
|
|
98
|
+
state.items = [base]
|
|
99
|
+
state.openCount = 2
|
|
100
|
+
const { unmount } = render(<FeedbackSection appSlug="example-app" />)
|
|
101
|
+
expect(screen.getByLabelText('2 awaiting review')).toHaveTextContent(
|
|
102
|
+
'2 open',
|
|
103
|
+
)
|
|
104
|
+
unmount()
|
|
105
|
+
|
|
106
|
+
state.openCount = 0
|
|
107
|
+
render(<FeedbackSection appSlug="example-app" />)
|
|
108
|
+
expect(screen.queryByText(/open$/)).toBeNull()
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('offers Dismiss on your own unreviewed row, and not once it is answered', () => {
|
|
112
|
+
state.items = [{ ...base, isMine: true }]
|
|
113
|
+
state.openCount = 1
|
|
114
|
+
const { unmount } = render(<FeedbackSection appSlug="example-app" />)
|
|
115
|
+
// No edit window left, but withdrawing is still allowed while nobody has acted.
|
|
116
|
+
expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument()
|
|
117
|
+
expect(screen.queryByRole('button', { name: 'Edit' })).toBeNull()
|
|
118
|
+
unmount()
|
|
119
|
+
|
|
120
|
+
state.items = [{ ...base, isMine: true, status: 'applied' }]
|
|
121
|
+
render(<FeedbackSection appSlug="example-app" />)
|
|
122
|
+
expect(screen.queryByRole('button', { name: 'Dismiss' })).toBeNull()
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('says so when someone sent a bare flag with no words', () => {
|
|
126
|
+
state.items = [{ ...base, body: null }]
|
|
127
|
+
render(<FeedbackSection appSlug="example-app" />)
|
|
128
|
+
|
|
129
|
+
expect(screen.getByText(/no detail given/)).toBeInTheDocument()
|
|
130
|
+
})
|
|
131
|
+
})
|