@igstack/app-catalog-frontend-core 6.9.0 → 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.
- package/dist/esm/__tests__/modules/appCatalog/ui/detail/CommunitySection.feed.test.d.ts +0 -0
- package/dist/esm/__tests__/modules/appCatalog/ui/detail/CommunitySection.help.test.d.ts +0 -0
- package/dist/esm/__tests__/modules/appCatalog/ui/detail/CommunitySection.input.test.d.ts +0 -0
- package/dist/esm/__tests__/modules/appCatalog/ui/detail/composeComments.test.d.ts +1 -0
- package/dist/esm/__tests__/ui/detail/CommunityInput.test.d.ts +1 -0
- package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +8 -0
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +254 -232
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.d.ts +17 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.js +187 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommentsHelp.d.ts +9 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommentsHelp.js +91 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommentsHelp.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.d.ts +17 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.js +328 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommunitySection.d.ts +15 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommunitySection.js +26 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommunitySection.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.d.ts +8 -19
- package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js +49 -37
- package/dist/esm/modules/appCatalog/ui/detail/DetailTabs.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/communityDraft.d.ts +30 -0
- package/dist/esm/modules/appCatalog/ui/detail/communityDraft.js +50 -0
- package/dist/esm/modules/appCatalog/ui/detail/communityDraft.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/communityError.d.ts +6 -0
- package/dist/esm/modules/appCatalog/ui/detail/communityError.js +8 -0
- package/dist/esm/modules/appCatalog/ui/detail/communityError.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/composeComments.d.ts +27 -0
- package/dist/esm/modules/appCatalog/ui/detail/composeComments.js +66 -0
- package/dist/esm/modules/appCatalog/ui/detail/composeComments.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/usePendingCount.d.ts +11 -0
- package/dist/esm/modules/appCatalog/ui/detail/usePendingCount.js +15 -0
- package/dist/esm/modules/appCatalog/ui/detail/usePendingCount.js.map +1 -0
- package/dist/esm/modules/extensions/features/resourceDetail.d.ts +38 -0
- package/dist/esm/modules/extensions/features/resourceDetail.js +4 -0
- package/dist/esm/modules/extensions/features/resourceDetail.js.map +1 -1
- package/dist/esm/modules/extensions/index.d.ts +1 -1
- package/dist/esm/modules/extensions/index.js.map +1 -1
- package/dist/index.css +525 -49
- package/package.json +4 -4
- package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.appSwitch.test.tsx +5 -2
- package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.enter.test.tsx +5 -2
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.background.test.tsx +9 -10
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.behindThisCard.test.tsx +402 -0
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.doorAndTabs.test.tsx +8 -4
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.escape.test.tsx +5 -2
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.knowMore.test.tsx +175 -0
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.sectionOrder.test.tsx +5 -2
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.tsx +5 -2
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.tags.test.tsx +5 -2
- package/src/__tests__/modules/appCatalog/ui/detail/CommunitySection.feed.test.tsx +281 -0
- package/src/__tests__/modules/appCatalog/ui/detail/CommunitySection.help.test.tsx +179 -0
- package/src/__tests__/modules/appCatalog/ui/detail/CommunitySection.input.test.tsx +423 -0
- package/src/__tests__/modules/appCatalog/ui/detail/composeComments.test.ts +170 -0
- package/src/__tests__/ui/detail/CommunityInput.test.tsx +39 -0
- package/src/index.css +525 -49
- package/src/modules/appCatalog/ui/detail/AppDetails.tsx +310 -295
- package/src/modules/appCatalog/ui/detail/CommentsFeed.tsx +254 -0
- package/src/modules/appCatalog/ui/detail/CommentsHelp.tsx +102 -0
- package/src/modules/appCatalog/ui/detail/CommunityInput.tsx +419 -0
- package/src/modules/appCatalog/ui/detail/CommunitySection.tsx +41 -0
- package/src/modules/appCatalog/ui/detail/DetailTabs.tsx +77 -71
- package/src/modules/appCatalog/ui/detail/communityDraft.ts +80 -0
- package/src/modules/appCatalog/ui/detail/communityError.ts +10 -0
- package/src/modules/appCatalog/ui/detail/composeComments.ts +103 -0
- package/src/modules/appCatalog/ui/detail/usePendingCount.ts +26 -0
- package/src/modules/extensions/features/resourceDetail.ts +28 -0
- package/src/modules/extensions/index.ts +2 -0
- package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.d.ts +0 -13
- package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js +0 -433
- package/dist/esm/modules/appCatalog/ui/detail/FeedbackSection.js.map +0 -1
- package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.emptyState.test.tsx +0 -181
- package/src/__tests__/modules/appCatalog/ui/detail/FeedbackSection.reviewStatus.test.tsx +0 -131
- package/src/modules/appCatalog/ui/detail/FeedbackSection.tsx +0 -605
- /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{FeedbackSection.emptyState.test.d.ts → AppDetails.behindThisCard.test.d.ts} +0 -0
- /package/dist/esm/__tests__/modules/appCatalog/ui/detail/{FeedbackSection.reviewStatus.test.d.ts → AppDetails.knowMore.test.d.ts} +0 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import '@testing-library/jest-dom/vitest'
|
|
5
|
+
|
|
6
|
+
const state = vi.hoisted(() => ({
|
|
7
|
+
sent: [] as unknown[],
|
|
8
|
+
fail: false,
|
|
9
|
+
failMessage: '',
|
|
10
|
+
invalidated: 0,
|
|
11
|
+
/** When set, uploads wait until `release()` is called. */
|
|
12
|
+
hold: false,
|
|
13
|
+
release: () => {},
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
vi.mock('@tanstack/react-query', async () => {
|
|
17
|
+
const React = await import('react')
|
|
18
|
+
return {
|
|
19
|
+
useQuery: vi.fn(() => ({
|
|
20
|
+
data: { items: [], openCount: 0 },
|
|
21
|
+
isLoading: false,
|
|
22
|
+
})),
|
|
23
|
+
// A real error state, so the component's failure branch is reachable.
|
|
24
|
+
useMutation: vi.fn((options?: { onSuccess?: () => void }) => {
|
|
25
|
+
const [isError, setIsError] = React.useState(false)
|
|
26
|
+
return {
|
|
27
|
+
mutate: (vars: unknown) => {
|
|
28
|
+
state.sent.push(vars)
|
|
29
|
+
if (state.fail) setIsError(true)
|
|
30
|
+
else options?.onSuccess?.()
|
|
31
|
+
},
|
|
32
|
+
isPending: false,
|
|
33
|
+
isError,
|
|
34
|
+
error: isError ? new Error(state.failMessage) : null,
|
|
35
|
+
}
|
|
36
|
+
}),
|
|
37
|
+
useQueryClient: vi.fn(() => ({
|
|
38
|
+
invalidateQueries: () => {
|
|
39
|
+
state.invalidated++
|
|
40
|
+
},
|
|
41
|
+
})),
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
vi.mock('~/api/infra/trpc', () => ({
|
|
45
|
+
useTRPC: vi.fn(() => ({
|
|
46
|
+
feedback: {
|
|
47
|
+
add: { mutationOptions: (o: unknown) => o },
|
|
48
|
+
edit: { mutationOptions: (o: unknown) => o },
|
|
49
|
+
dismiss: { mutationOptions: (o: unknown) => o },
|
|
50
|
+
list: {
|
|
51
|
+
queryOptions: () => ({ queryKey: ['feedback', 'list'] }),
|
|
52
|
+
queryKey: () => ['feedback', 'list'],
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
})),
|
|
56
|
+
}))
|
|
57
|
+
|
|
58
|
+
const { CommunitySection } =
|
|
59
|
+
await import('~/modules/appCatalog/ui/detail/CommunitySection')
|
|
60
|
+
|
|
61
|
+
/** The upload endpoint answers with one id per file, in order. */
|
|
62
|
+
let uploads = 0
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
state.sent = []
|
|
65
|
+
state.fail = false
|
|
66
|
+
state.failMessage = ''
|
|
67
|
+
state.invalidated = 0
|
|
68
|
+
state.hold = false
|
|
69
|
+
uploads = 0
|
|
70
|
+
sessionStorage.clear()
|
|
71
|
+
vi.stubGlobal(
|
|
72
|
+
'fetch',
|
|
73
|
+
vi.fn((_url: string, init: { body: FormData }) => {
|
|
74
|
+
const ids = init.body.getAll('image').map(() => `img-${++uploads}`)
|
|
75
|
+
const reply = () => new Response(JSON.stringify({ ids }), { status: 201 })
|
|
76
|
+
if (!state.hold) return Promise.resolve(reply())
|
|
77
|
+
return new Promise<Response>((resolve) => {
|
|
78
|
+
state.release = () => resolve(reply())
|
|
79
|
+
})
|
|
80
|
+
}),
|
|
81
|
+
)
|
|
82
|
+
})
|
|
83
|
+
afterEach(() => {
|
|
84
|
+
vi.unstubAllGlobals()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const mount = () =>
|
|
88
|
+
render(<CommunitySection appSlug="team-board" appName="Team Board" />)
|
|
89
|
+
const field = () =>
|
|
90
|
+
screen.getByRole('textbox', { name: 'Know more about Team Board?' })
|
|
91
|
+
const send = () => screen.getByText('Send')
|
|
92
|
+
const png = (name: string) =>
|
|
93
|
+
new File([new Uint8Array([1])], name, { type: 'image/png' })
|
|
94
|
+
const pasteImage = (...names: string[]) =>
|
|
95
|
+
fireEvent.paste(field(), {
|
|
96
|
+
clipboardData: { files: (names.length ? names : ['shot.png']).map(png) },
|
|
97
|
+
})
|
|
98
|
+
const dropImage = (name: string) =>
|
|
99
|
+
fireEvent.drop(field().closest('form')!, {
|
|
100
|
+
dataTransfer: { files: [png(name)], types: ['Files'] },
|
|
101
|
+
})
|
|
102
|
+
const thumbnails = () =>
|
|
103
|
+
screen.queryAllByRole('button', { name: /^Remove / }).length
|
|
104
|
+
|
|
105
|
+
describe('CommunitySection — the input', () => {
|
|
106
|
+
it('shows Send at rest, disabled until there is text or an image', async () => {
|
|
107
|
+
mount()
|
|
108
|
+
expect(send()).toBeVisible()
|
|
109
|
+
expect(send()).not.toHaveAttribute('aria-hidden')
|
|
110
|
+
expect(send()).not.toHaveAttribute('tabindex', '-1')
|
|
111
|
+
expect(send()).toBeDisabled()
|
|
112
|
+
await userEvent.type(field(), ' ')
|
|
113
|
+
expect(send()).toBeDisabled()
|
|
114
|
+
await userEvent.type(field(), 'A fact')
|
|
115
|
+
expect(send()).toBeEnabled()
|
|
116
|
+
await userEvent.clear(field())
|
|
117
|
+
expect(send()).toBeDisabled()
|
|
118
|
+
pasteImage()
|
|
119
|
+
await screen.findByRole('img', { name: 'shot.png' })
|
|
120
|
+
expect(send()).toBeEnabled()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('treats whitespace as empty: Enter sends nothing', async () => {
|
|
124
|
+
mount()
|
|
125
|
+
await userEvent.type(field(), ' {Enter}')
|
|
126
|
+
expect(state.sent).toEqual([])
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('shows the hint only while there is text (fine pointer)', async () => {
|
|
130
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
131
|
+
writable: true,
|
|
132
|
+
value: vi.fn(() => ({
|
|
133
|
+
matches: false,
|
|
134
|
+
addEventListener: vi.fn(),
|
|
135
|
+
removeEventListener: vi.fn(),
|
|
136
|
+
})),
|
|
137
|
+
})
|
|
138
|
+
mount()
|
|
139
|
+
const hint = screen.getByText(
|
|
140
|
+
'Enter sends · Shift+Enter new line · Esc clears · paste or drop a screenshot',
|
|
141
|
+
)
|
|
142
|
+
await userEvent.click(field())
|
|
143
|
+
expect(hint).not.toHaveClass('is-on')
|
|
144
|
+
await userEvent.type(field(), 'Text')
|
|
145
|
+
expect(hint).toHaveClass('is-on')
|
|
146
|
+
await userEvent.type(field(), '{Escape}')
|
|
147
|
+
expect(hint).not.toHaveClass('is-on')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('hides the hint line on coarse pointers (touch)', async () => {
|
|
151
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
152
|
+
writable: true,
|
|
153
|
+
value: vi.fn(() => ({
|
|
154
|
+
matches: true,
|
|
155
|
+
addEventListener: vi.fn(),
|
|
156
|
+
removeEventListener: vi.fn(),
|
|
157
|
+
})),
|
|
158
|
+
})
|
|
159
|
+
mount()
|
|
160
|
+
await userEvent.click(field())
|
|
161
|
+
await userEvent.type(field(), 'Text')
|
|
162
|
+
expect(
|
|
163
|
+
screen.queryByText(
|
|
164
|
+
'Enter sends · Shift+Enter new line · Esc clears · paste or drop a screenshot',
|
|
165
|
+
),
|
|
166
|
+
).toBeNull()
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('asks for a fact, a fix or a question, with Enter, on fine pointers', async () => {
|
|
170
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
171
|
+
writable: true,
|
|
172
|
+
value: vi.fn(() => ({
|
|
173
|
+
matches: false,
|
|
174
|
+
addEventListener: vi.fn(),
|
|
175
|
+
removeEventListener: vi.fn(),
|
|
176
|
+
})),
|
|
177
|
+
})
|
|
178
|
+
mount()
|
|
179
|
+
expect(field()).toHaveAttribute(
|
|
180
|
+
'placeholder',
|
|
181
|
+
'Add a fact, a fix or a question — press Enter',
|
|
182
|
+
)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('drops the Enter part on coarse pointers (touch)', async () => {
|
|
186
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
187
|
+
writable: true,
|
|
188
|
+
value: vi.fn(() => ({
|
|
189
|
+
matches: true,
|
|
190
|
+
addEventListener: vi.fn(),
|
|
191
|
+
removeEventListener: vi.fn(),
|
|
192
|
+
})),
|
|
193
|
+
})
|
|
194
|
+
mount()
|
|
195
|
+
expect(field()).toHaveAttribute(
|
|
196
|
+
'placeholder',
|
|
197
|
+
'Add a fact, a fix or a question',
|
|
198
|
+
)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('Enter sends the trimmed text in place and clears the field', async () => {
|
|
202
|
+
mount()
|
|
203
|
+
await userEvent.type(field(), ' Lead approves access. {Enter}')
|
|
204
|
+
expect(state.sent).toEqual([
|
|
205
|
+
{ resourceSlug: 'team-board', body: 'Lead approves access.' },
|
|
206
|
+
])
|
|
207
|
+
expect(field()).toHaveValue('')
|
|
208
|
+
expect(screen.getByRole('status')).toHaveTextContent(/Thanks — sent/)
|
|
209
|
+
expect(state.invalidated).toBe(1)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('Shift+Enter adds a line instead of sending', async () => {
|
|
213
|
+
mount()
|
|
214
|
+
await userEvent.type(field(), 'one{Shift>}{Enter}{/Shift}two')
|
|
215
|
+
expect(state.sent).toEqual([])
|
|
216
|
+
expect(field()).toHaveValue('one\ntwo')
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('does not send while an IME composition is open', async () => {
|
|
220
|
+
mount()
|
|
221
|
+
await userEvent.type(field(), 'kanji')
|
|
222
|
+
field().dispatchEvent(
|
|
223
|
+
new KeyboardEvent('keydown', {
|
|
224
|
+
key: 'Enter',
|
|
225
|
+
bubbles: true,
|
|
226
|
+
isComposing: true,
|
|
227
|
+
}),
|
|
228
|
+
)
|
|
229
|
+
expect(state.sent).toEqual([])
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('keeps the text and says so when the send fails', async () => {
|
|
233
|
+
state.fail = true
|
|
234
|
+
mount()
|
|
235
|
+
await userEvent.type(field(), 'Lead approves access.{Enter}')
|
|
236
|
+
expect(field()).toHaveValue('Lead approves access.')
|
|
237
|
+
expect(screen.getByRole('alert')).toHaveTextContent(/Could not send/)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('shows the server’s reason when a send fails with one', async () => {
|
|
241
|
+
state.fail = true
|
|
242
|
+
state.failMessage = 'Up to 3 images'
|
|
243
|
+
mount()
|
|
244
|
+
await userEvent.type(field(), 'Lead approves access.{Enter}')
|
|
245
|
+
expect(screen.getByRole('alert')).toHaveTextContent(
|
|
246
|
+
'Could not send: Up to 3 images. Your note is still here.',
|
|
247
|
+
)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('does not clear the draft on Esc during an IME composition', async () => {
|
|
251
|
+
mount()
|
|
252
|
+
await userEvent.type(field(), 'kanji')
|
|
253
|
+
// fireEvent wraps the dispatch in act, so a clear would have rendered.
|
|
254
|
+
fireEvent.keyDown(field(), { key: 'Escape', isComposing: true })
|
|
255
|
+
expect(field()).toHaveValue('kanji')
|
|
256
|
+
fireEvent.keyDown(field(), { key: 'Escape' })
|
|
257
|
+
expect(field()).toHaveValue('')
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('leaves focus in the field after a successful send', async () => {
|
|
261
|
+
mount()
|
|
262
|
+
await userEvent.type(field(), 'Lead approves access.{Enter}')
|
|
263
|
+
expect(field()).toHaveFocus()
|
|
264
|
+
})
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
describe('CommunitySection — screenshots', () => {
|
|
268
|
+
it('has a paperclip button that opens the file picker', () => {
|
|
269
|
+
mount()
|
|
270
|
+
expect(
|
|
271
|
+
screen.getByRole('button', { name: 'Attach a screenshot' }),
|
|
272
|
+
).toBeInTheDocument()
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('adds a thumbnail for a pasted image', async () => {
|
|
276
|
+
mount()
|
|
277
|
+
pasteImage('pasted.png')
|
|
278
|
+
expect(await screen.findByRole('img', { name: 'pasted.png' })).toBeVisible()
|
|
279
|
+
expect(
|
|
280
|
+
screen.getByRole('button', { name: 'Remove pasted.png' }),
|
|
281
|
+
).toBeInTheDocument()
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('adds a thumbnail for a dropped image', async () => {
|
|
285
|
+
mount()
|
|
286
|
+
dropImage('dropped.png')
|
|
287
|
+
expect(
|
|
288
|
+
await screen.findByRole('img', { name: 'dropped.png' }),
|
|
289
|
+
).toBeVisible()
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('sends an image-only note: attachment ids and no body', async () => {
|
|
293
|
+
mount()
|
|
294
|
+
pasteImage()
|
|
295
|
+
await screen.findByRole('img', { name: 'shot.png' })
|
|
296
|
+
expect(send()).toBeEnabled()
|
|
297
|
+
await userEvent.click(send())
|
|
298
|
+
expect(state.sent).toEqual([
|
|
299
|
+
{ resourceSlug: 'team-board', attachmentIds: ['img-1'] },
|
|
300
|
+
])
|
|
301
|
+
expect(screen.queryByRole('img', { name: 'shot.png' })).toBeNull()
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('drops a removed thumbnail from the payload', async () => {
|
|
305
|
+
mount()
|
|
306
|
+
pasteImage('a.png')
|
|
307
|
+
await screen.findByRole('img', { name: 'a.png' })
|
|
308
|
+
pasteImage('b.png')
|
|
309
|
+
await screen.findByRole('img', { name: 'b.png' })
|
|
310
|
+
|
|
311
|
+
await userEvent.click(screen.getByRole('button', { name: 'Remove a.png' }))
|
|
312
|
+
expect(screen.queryByRole('img', { name: 'a.png' })).toBeNull()
|
|
313
|
+
|
|
314
|
+
await userEvent.type(field(), 'With one{Enter}')
|
|
315
|
+
expect(state.sent).toEqual([
|
|
316
|
+
{
|
|
317
|
+
resourceSlug: 'team-board',
|
|
318
|
+
body: 'With one',
|
|
319
|
+
attachmentIds: ['img-2'],
|
|
320
|
+
},
|
|
321
|
+
])
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('keeps text and images across an unmount, as a tab switch does', async () => {
|
|
325
|
+
const first = mount()
|
|
326
|
+
await userEvent.type(field(), 'half a thought')
|
|
327
|
+
pasteImage()
|
|
328
|
+
await screen.findByRole('img', { name: 'shot.png' })
|
|
329
|
+
first.unmount()
|
|
330
|
+
|
|
331
|
+
mount()
|
|
332
|
+
expect(field()).toHaveValue('half a thought')
|
|
333
|
+
expect(screen.getByRole('img', { name: 'shot.png' })).toBeVisible()
|
|
334
|
+
|
|
335
|
+
await userEvent.type(field(), '{Enter}')
|
|
336
|
+
expect(state.sent).toEqual([
|
|
337
|
+
{
|
|
338
|
+
resourceSlug: 'team-board',
|
|
339
|
+
body: 'half a thought',
|
|
340
|
+
attachmentIds: ['img-1'],
|
|
341
|
+
},
|
|
342
|
+
])
|
|
343
|
+
expect(sessionStorage.getItem('ac.feedback.draft.team-board')).toBeNull()
|
|
344
|
+
expect(sessionStorage.getItem('ac.feedback.images.team-board')).toBeNull()
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('does not leak a draft between entries', async () => {
|
|
348
|
+
const first = mount()
|
|
349
|
+
await userEvent.type(field(), 'about this one')
|
|
350
|
+
first.unmount()
|
|
351
|
+
|
|
352
|
+
render(<CommunitySection appSlug="other-app" appName="Other" />)
|
|
353
|
+
expect(
|
|
354
|
+
screen.getByRole('textbox', { name: 'Know more about Other?' }),
|
|
355
|
+
).toHaveValue('')
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('Esc clears images along with the text', async () => {
|
|
359
|
+
mount()
|
|
360
|
+
pasteImage()
|
|
361
|
+
await screen.findByRole('img', { name: 'shot.png' })
|
|
362
|
+
fireEvent.keyDown(field(), { key: 'Escape' })
|
|
363
|
+
await waitFor(() =>
|
|
364
|
+
expect(screen.queryByRole('img', { name: 'shot.png' })).toBeNull(),
|
|
365
|
+
)
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it('ignores a second paste while the first is uploading, and never exceeds the cap', async () => {
|
|
369
|
+
mount()
|
|
370
|
+
pasteImage('a.png', 'b.png')
|
|
371
|
+
pasteImage('c.png', 'd.png')
|
|
372
|
+
await screen.findByRole('img', { name: 'b.png' })
|
|
373
|
+
expect(fetch).toHaveBeenCalledTimes(1)
|
|
374
|
+
expect(thumbnails()).toBe(2)
|
|
375
|
+
|
|
376
|
+
pasteImage('e.png', 'f.png')
|
|
377
|
+
await screen.findByRole('img', { name: 'e.png' })
|
|
378
|
+
expect(thumbnails()).toBe(3)
|
|
379
|
+
expect(screen.getByRole('alert')).toHaveTextContent(
|
|
380
|
+
'Only the first 1 were attached',
|
|
381
|
+
)
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
it('ignores a drop during an upload and says it is attaching', async () => {
|
|
385
|
+
state.hold = true
|
|
386
|
+
mount()
|
|
387
|
+
pasteImage('first.png')
|
|
388
|
+
expect(screen.getByText('Attaching…')).toBeInTheDocument()
|
|
389
|
+
expect(
|
|
390
|
+
screen.getByRole('button', { name: 'Attach a screenshot' }),
|
|
391
|
+
).toHaveAttribute('aria-busy', 'true')
|
|
392
|
+
|
|
393
|
+
dropImage('second.png')
|
|
394
|
+
expect(fetch).toHaveBeenCalledTimes(1)
|
|
395
|
+
|
|
396
|
+
state.release()
|
|
397
|
+
await screen.findByRole('img', { name: 'first.png' })
|
|
398
|
+
expect(screen.queryByText('Attaching…')).toBeNull()
|
|
399
|
+
expect(thumbnails()).toBe(1)
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
it('leaves a drag of plain text to the field', () => {
|
|
403
|
+
mount()
|
|
404
|
+
const form = field().closest('form')!
|
|
405
|
+
const text = { dataTransfer: { types: ['text/plain'] } }
|
|
406
|
+
fireEvent.dragEnter(form, text)
|
|
407
|
+
expect(fireEvent.dragOver(form, text)).toBe(true) // not default-prevented
|
|
408
|
+
expect(form).not.toHaveAttribute('data-drop')
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('keeps the drop highlight while a file drag crosses the form’s children', () => {
|
|
412
|
+
mount()
|
|
413
|
+
const form = field().closest('form')!
|
|
414
|
+
const files = { dataTransfer: { types: ['Files'] } }
|
|
415
|
+
fireEvent.dragEnter(form, files)
|
|
416
|
+
expect(fireEvent.dragOver(form, files)).toBe(false) // drop accepted
|
|
417
|
+
fireEvent.dragEnter(field(), files)
|
|
418
|
+
fireEvent.dragLeave(form, files)
|
|
419
|
+
expect(form).toHaveAttribute('data-drop')
|
|
420
|
+
fireEvent.dragLeave(field(), files)
|
|
421
|
+
expect(form).not.toHaveAttribute('data-drop')
|
|
422
|
+
})
|
|
423
|
+
})
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type { Claim, FeedbackView } from '@igstack/app-catalog-backend-core'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import {
|
|
4
|
+
composeComments,
|
|
5
|
+
countPending,
|
|
6
|
+
} from '~/modules/appCatalog/ui/detail/composeComments'
|
|
7
|
+
|
|
8
|
+
const note = (over: Partial<FeedbackView> = {}): FeedbackView => ({
|
|
9
|
+
id: 'n1',
|
|
10
|
+
authorName: 'Merry Mole',
|
|
11
|
+
body: 'Leads approve access.',
|
|
12
|
+
subject: null,
|
|
13
|
+
resourceSlug: 'example-app',
|
|
14
|
+
resourceName: 'Example App',
|
|
15
|
+
attachmentIds: [],
|
|
16
|
+
createdAt: '2026-10-10T10:00:00.000Z',
|
|
17
|
+
reviewedAt: null,
|
|
18
|
+
editedAt: null,
|
|
19
|
+
isMine: false,
|
|
20
|
+
canEditUntil: null,
|
|
21
|
+
status: null,
|
|
22
|
+
reviewerReply: null,
|
|
23
|
+
...over,
|
|
24
|
+
})
|
|
25
|
+
const claim = (over: Partial<Claim> = {}): Claim => ({
|
|
26
|
+
text: 'Team leads approve access.',
|
|
27
|
+
addedAt: '2026-10-11',
|
|
28
|
+
origin: 'feedback:n1',
|
|
29
|
+
status: 'active',
|
|
30
|
+
...over,
|
|
31
|
+
})
|
|
32
|
+
const shape = (notes: FeedbackView[], claims: Claim[]) =>
|
|
33
|
+
composeComments(notes, claims).map(({ who, text, badge }) => ({
|
|
34
|
+
who,
|
|
35
|
+
text,
|
|
36
|
+
badge,
|
|
37
|
+
}))
|
|
38
|
+
|
|
39
|
+
describe('composeComments — one entry per visitor note', () => {
|
|
40
|
+
it('an open note is Pending, in the visitor’s words', () => {
|
|
41
|
+
expect(shape([note()], [])).toEqual([
|
|
42
|
+
{ who: 'Merry Mole', text: 'Leads approve access.', badge: 'pending' },
|
|
43
|
+
])
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('an applied note whose claim is active is Used as source, once', () => {
|
|
47
|
+
expect(shape([note({ status: 'applied' })], [claim()])).toEqual([
|
|
48
|
+
{ who: 'Merry Mole', text: 'Leads approve access.', badge: 'used' },
|
|
49
|
+
])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('an applied note whose claim is still in review is Pending', () => {
|
|
53
|
+
expect(
|
|
54
|
+
shape([note({ status: 'applied' })], [claim({ status: 'review' })]),
|
|
55
|
+
).toEqual([
|
|
56
|
+
{ who: 'Merry Mole', text: 'Leads approve access.', badge: 'pending' },
|
|
57
|
+
])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('an open note with a review claim is one Pending entry', () => {
|
|
61
|
+
expect(shape([note()], [claim({ status: 'review' })])).toEqual([
|
|
62
|
+
{ who: 'Merry Mole', text: 'Leads approve access.', badge: 'pending' },
|
|
63
|
+
])
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('an acknowledged note with a review claim is Pending', () => {
|
|
67
|
+
expect(
|
|
68
|
+
shape([note({ status: 'acknowledged' })], [claim({ status: 'review' })]),
|
|
69
|
+
).toEqual([
|
|
70
|
+
{ who: 'Merry Mole', text: 'Leads approve access.', badge: 'pending' },
|
|
71
|
+
])
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('an applied note with no live claim has no badge', () => {
|
|
75
|
+
expect(
|
|
76
|
+
shape(
|
|
77
|
+
[note({ status: 'applied' })],
|
|
78
|
+
[claim({ status: 'archived', archiveReason: 'gone' })],
|
|
79
|
+
),
|
|
80
|
+
).toEqual([
|
|
81
|
+
{ who: 'Merry Mole', text: 'Leads approve access.', badge: null },
|
|
82
|
+
])
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('composeComments — claims with no note in the list', () => {
|
|
87
|
+
it('shows the claim text, badged by its status', () => {
|
|
88
|
+
expect(
|
|
89
|
+
shape(
|
|
90
|
+
[],
|
|
91
|
+
[
|
|
92
|
+
claim({
|
|
93
|
+
text: 'Active.',
|
|
94
|
+
origin: 'feedback:gone',
|
|
95
|
+
addedAt: '2026-10-02',
|
|
96
|
+
}),
|
|
97
|
+
claim({
|
|
98
|
+
text: 'In review.',
|
|
99
|
+
origin: 'feedback:gone2',
|
|
100
|
+
status: 'review',
|
|
101
|
+
addedAt: '2026-10-01',
|
|
102
|
+
}),
|
|
103
|
+
],
|
|
104
|
+
),
|
|
105
|
+
).toEqual([
|
|
106
|
+
{ who: 'A visitor', text: 'Active.', badge: 'used' },
|
|
107
|
+
{ who: 'A visitor', text: 'In review.', badge: 'pending' },
|
|
108
|
+
])
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('names a person or team origin, and a URL by its host', () => {
|
|
112
|
+
expect(
|
|
113
|
+
shape(
|
|
114
|
+
[],
|
|
115
|
+
[
|
|
116
|
+
claim({ origin: 'Tools team', addedAt: '2026-10-03' }),
|
|
117
|
+
claim({
|
|
118
|
+
origin: 'https://wiki.example.com/page/1',
|
|
119
|
+
addedAt: '2026-10-02',
|
|
120
|
+
}),
|
|
121
|
+
claim({ origin: undefined, addedAt: '2026-10-01' }),
|
|
122
|
+
],
|
|
123
|
+
).map((e) => e.who),
|
|
124
|
+
).toEqual(['Tools team', 'wiki.example.com', null])
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('hides archived claims', () => {
|
|
128
|
+
expect(
|
|
129
|
+
shape([], [claim({ status: 'archived', origin: 'Tools team' })]),
|
|
130
|
+
).toEqual([])
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('composeComments — order and count', () => {
|
|
135
|
+
it('is newest first across notes and claims', () => {
|
|
136
|
+
const texts = composeComments(
|
|
137
|
+
[
|
|
138
|
+
note({ id: 'a', body: 'Old note.', createdAt: '2026-10-01T09:00:00Z' }),
|
|
139
|
+
note({ id: 'b', body: 'New note.', createdAt: '2026-10-12T09:00:00Z' }),
|
|
140
|
+
],
|
|
141
|
+
[
|
|
142
|
+
claim({
|
|
143
|
+
text: 'Middle claim.',
|
|
144
|
+
origin: 'Tools team',
|
|
145
|
+
addedAt: '2026-10-05',
|
|
146
|
+
}),
|
|
147
|
+
],
|
|
148
|
+
).map((e) => e.text)
|
|
149
|
+
expect(texts).toEqual(['New note.', 'Middle claim.', 'Old note.'])
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('counts exactly the Pending entries', () => {
|
|
153
|
+
const notes = [
|
|
154
|
+
note({ id: 'a' }),
|
|
155
|
+
note({ id: 'b', status: 'applied' }),
|
|
156
|
+
note({ id: 'c', status: 'acknowledged' }),
|
|
157
|
+
]
|
|
158
|
+
const claims = [
|
|
159
|
+
claim({ origin: 'feedback:a', status: 'review' }),
|
|
160
|
+
claim({ origin: 'feedback:b', status: 'review' }),
|
|
161
|
+
claim({ origin: 'Tools team', status: 'review' }),
|
|
162
|
+
claim({ origin: 'Tools team', status: 'active' }),
|
|
163
|
+
]
|
|
164
|
+
// a (open + review claim) is one, b is one, the team's review claim is one.
|
|
165
|
+
expect(countPending(notes, claims)).toBe(3)
|
|
166
|
+
expect(
|
|
167
|
+
composeComments(notes, claims).filter((e) => e.badge === 'pending'),
|
|
168
|
+
).toHaveLength(3)
|
|
169
|
+
})
|
|
170
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
readAttachments,
|
|
4
|
+
writeAttachments,
|
|
5
|
+
} from '~/modules/appCatalog/ui/detail/communityDraft'
|
|
6
|
+
|
|
7
|
+
describe('CommunityInput upload unmount recovery', () => {
|
|
8
|
+
it('persists images to storage so unmount restores them', () => {
|
|
9
|
+
const slug = 'test-app'
|
|
10
|
+
const images = [
|
|
11
|
+
{ id: 'img-1', name: 'screenshot.png' },
|
|
12
|
+
{ id: 'img-2', name: 'diagram.jpg' },
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
// Write images
|
|
16
|
+
writeAttachments(slug, images)
|
|
17
|
+
|
|
18
|
+
// Simulate unmount by clearing in-memory state, then remount reads storage
|
|
19
|
+
const restored = readAttachments(slug)
|
|
20
|
+
|
|
21
|
+
expect(restored).toEqual(images)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('keeps draft cleared through an upload: generation check prevents re-add', () => {
|
|
25
|
+
// The generation counter is internal to CommunityInput, but the behavior
|
|
26
|
+
// is observable: Esc increments it, upload checks it on completion.
|
|
27
|
+
// This test documents the contract: once cleared, storage stays cleared.
|
|
28
|
+
const slug = 'test-app'
|
|
29
|
+
|
|
30
|
+
// Simulate clearing: empty draft
|
|
31
|
+
writeAttachments(slug, [])
|
|
32
|
+
|
|
33
|
+
// If an upload completed after clear, it would try to write its images.
|
|
34
|
+
// The generation guard prevents that, so storage should still be empty.
|
|
35
|
+
const afterClear = readAttachments(slug)
|
|
36
|
+
|
|
37
|
+
expect(afterClear).toEqual([])
|
|
38
|
+
})
|
|
39
|
+
})
|