@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.
- 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/index.d.ts +1 -1
- package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +8 -0
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js +22 -12
- package/dist/esm/modules/appCatalog/ui/components/SubResourcesSection.js.map +1 -1
- 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 +54 -1
- 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 +20 -4
- package/dist/esm/modules/extensions/index.js +10 -4
- package/dist/esm/modules/extensions/index.js.map +1 -1
- package/dist/esm/modules/extensions/types.d.ts +1 -0
- 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/components/SubResourcesSection.adminColumn.test.tsx +41 -4
- package/src/__tests__/modules/appCatalog/ui/components/SubResourcesSection.test.tsx +34 -0
- 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/index.tsx +2 -0
- package/src/modules/appCatalog/ui/components/SubResourcesSection.tsx +60 -36
- 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 +45 -1
- package/src/modules/extensions/index.ts +39 -2
- package/src/modules/extensions/types.ts +1 -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
package/src/__tests__/modules/appCatalog/ui/components/SubResourcesSection.adminColumn.test.tsx
CHANGED
|
@@ -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
|
-
//
|
|
64
|
-
|
|
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(
|
|
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', '
|
|
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/
|
|
32
|
-
|
|
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
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
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
|
-
|
|
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/
|
|
40
|
-
|
|
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
|
|
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: '
|
|
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/
|
|
33
|
-
|
|
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')
|