@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,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
|
+
})
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ApprovalMethod,
|
|
3
|
+
Resource,
|
|
4
|
+
} from '@igstack/app-catalog-backend-core'
|
|
5
|
+
import { describe, expect, it } from 'vitest'
|
|
6
|
+
import {
|
|
7
|
+
ROUTE_FREE,
|
|
8
|
+
ROUTE_REQUEST,
|
|
9
|
+
ROUTE_UNDOCUMENTED,
|
|
10
|
+
accessTabLabel,
|
|
11
|
+
} from '~/modules/appCatalog/utils/accessTabLabel'
|
|
12
|
+
|
|
13
|
+
const methods: ApprovalMethod[] = [
|
|
14
|
+
{
|
|
15
|
+
slug: 'access-bot',
|
|
16
|
+
type: 'service',
|
|
17
|
+
displayName: 'Access Bot',
|
|
18
|
+
shortName: 'Bot',
|
|
19
|
+
config: { url: 'https://chat.example/bot' },
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
slug: 'long-desk',
|
|
23
|
+
type: 'service',
|
|
24
|
+
// No shortName — the tab has to fall back to this, however long it is.
|
|
25
|
+
displayName: 'Enterprise Business Service Organization',
|
|
26
|
+
config: {},
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
slug: 'open',
|
|
30
|
+
type: 'noAccessRequired',
|
|
31
|
+
displayName: 'No Approval Needed',
|
|
32
|
+
config: {},
|
|
33
|
+
},
|
|
34
|
+
{ slug: 'custom', type: 'custom', displayName: 'Custom', config: {} },
|
|
35
|
+
{ slug: 'unknown', type: 'unknown', displayName: 'Unknown', config: {} },
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
const res = (over: Partial<Resource>): Resource => ({
|
|
39
|
+
id: 'x',
|
|
40
|
+
slug: 'x',
|
|
41
|
+
displayName: 'X',
|
|
42
|
+
...over,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('accessTabLabel', () => {
|
|
46
|
+
it('names a service desk by its shortName', () => {
|
|
47
|
+
const label = accessTabLabel(
|
|
48
|
+
res({ accessRequest: { approvalMethodSlug: 'access-bot' } }),
|
|
49
|
+
methods,
|
|
50
|
+
)
|
|
51
|
+
expect(label.route).toBe('Bot')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('falls back to displayName when no shortName is set', () => {
|
|
55
|
+
const label = accessTabLabel(
|
|
56
|
+
res({ accessRequest: { approvalMethodSlug: 'long-desk' } }),
|
|
57
|
+
methods,
|
|
58
|
+
)
|
|
59
|
+
expect(label.route).toBe('Enterprise Business Service Organization')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('says Free when no request is needed', () => {
|
|
63
|
+
const label = accessTabLabel(
|
|
64
|
+
res({ accessRequest: { approvalMethodSlug: 'open' } }),
|
|
65
|
+
methods,
|
|
66
|
+
)
|
|
67
|
+
expect(label.route).toBe(ROUTE_FREE)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('says not documented for an entry with no accessRequest at all', () => {
|
|
71
|
+
expect(accessTabLabel(res({}), methods).route).toBe(ROUTE_UNDOCUMENTED)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('says not documented for an explicitly unknown method', () => {
|
|
75
|
+
const label = accessTabLabel(
|
|
76
|
+
res({ accessRequest: { approvalMethodSlug: 'unknown' } }),
|
|
77
|
+
methods,
|
|
78
|
+
)
|
|
79
|
+
expect(label.route).toBe(ROUTE_UNDOCUMENTED)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// A bare `custom` names no channel and writes down no steps, so it is the
|
|
83
|
+
// same dead end as `unknown` under a different slug.
|
|
84
|
+
it('says not documented for a bare custom method', () => {
|
|
85
|
+
const label = accessTabLabel(
|
|
86
|
+
res({ accessRequest: { approvalMethodSlug: 'custom' } }),
|
|
87
|
+
methods,
|
|
88
|
+
)
|
|
89
|
+
expect(label.route).toBe(ROUTE_UNDOCUMENTED)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// A slug that resolves to no registered method. The access section renders an
|
|
93
|
+
// essentially empty box in that case, so the tab must not advertise a route.
|
|
94
|
+
it('says not documented when the method slug resolves to nothing', () => {
|
|
95
|
+
const label = accessTabLabel(
|
|
96
|
+
res({ accessRequest: { approvalMethodSlug: 'gone-away' } }),
|
|
97
|
+
methods,
|
|
98
|
+
)
|
|
99
|
+
expect(label.route).toBe(ROUTE_UNDOCUMENTED)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('still says request for a dangling slug that does write its steps down', () => {
|
|
103
|
+
const label = accessTabLabel(
|
|
104
|
+
res({
|
|
105
|
+
accessRequest: {
|
|
106
|
+
approvalMethodSlug: 'gone-away',
|
|
107
|
+
comments: 'Ask whoever owns it; the channel moved.',
|
|
108
|
+
},
|
|
109
|
+
}),
|
|
110
|
+
methods,
|
|
111
|
+
)
|
|
112
|
+
expect(label.route).toBe(ROUTE_REQUEST)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('says request for a custom method that does write its steps down', () => {
|
|
116
|
+
const label = accessTabLabel(
|
|
117
|
+
res({
|
|
118
|
+
accessRequest: {
|
|
119
|
+
approvalMethodSlug: 'custom',
|
|
120
|
+
comments: 'Ask the team that owns it.',
|
|
121
|
+
},
|
|
122
|
+
}),
|
|
123
|
+
methods,
|
|
124
|
+
)
|
|
125
|
+
expect(label.route).toBe(ROUTE_REQUEST)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('says 2 steps for a custom method with follow-up work after approval', () => {
|
|
129
|
+
const label = accessTabLabel(
|
|
130
|
+
res({
|
|
131
|
+
accessRequest: {
|
|
132
|
+
approvalMethodSlug: 'custom',
|
|
133
|
+
comments: 'Ask the owning team.',
|
|
134
|
+
postApprovalInstructions: 'Then install the client.',
|
|
135
|
+
},
|
|
136
|
+
}),
|
|
137
|
+
methods,
|
|
138
|
+
)
|
|
139
|
+
expect(label.route).toBe('2 steps')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
// Children keep access as top-level fields rather than an accessRequest; the
|
|
143
|
+
// card's access section already falls back to them, so the tab must agree or
|
|
144
|
+
// the label contradicts the panel underneath it.
|
|
145
|
+
it('treats a child with only contacts as a request, not as undocumented', () => {
|
|
146
|
+
const label = accessTabLabel(
|
|
147
|
+
res({ parentSlug: 'p', approverSlugs: ['team-a'] }),
|
|
148
|
+
methods,
|
|
149
|
+
)
|
|
150
|
+
expect(label.route).toBe(ROUTE_REQUEST)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
describe('the chip', () => {
|
|
154
|
+
it('counts roles', () => {
|
|
155
|
+
const label = accessTabLabel(
|
|
156
|
+
res({
|
|
157
|
+
accessRequest: {
|
|
158
|
+
approvalMethodSlug: 'access-bot',
|
|
159
|
+
roles: [{ displayName: 'Read' }, { displayName: 'Write' }],
|
|
160
|
+
},
|
|
161
|
+
}),
|
|
162
|
+
methods,
|
|
163
|
+
)
|
|
164
|
+
expect(label.chip).toBe('2 roles')
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('singularises one role', () => {
|
|
168
|
+
const label = accessTabLabel(
|
|
169
|
+
res({
|
|
170
|
+
accessRequest: {
|
|
171
|
+
approvalMethodSlug: 'access-bot',
|
|
172
|
+
roles: [{ displayName: 'Read' }],
|
|
173
|
+
},
|
|
174
|
+
}),
|
|
175
|
+
methods,
|
|
176
|
+
)
|
|
177
|
+
expect(label.chip).toBe('1 role')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
// The named channel stays as the route and the shape rides in the chip:
|
|
181
|
+
// "there is more to do after approval" is the thing worth knowing on an
|
|
182
|
+
// entry that already tells you where to ask.
|
|
183
|
+
it('prefers the step count over the role count when both apply', () => {
|
|
184
|
+
const label = accessTabLabel(
|
|
185
|
+
res({
|
|
186
|
+
accessRequest: {
|
|
187
|
+
approvalMethodSlug: 'access-bot',
|
|
188
|
+
requestPrompt: 'Requesting access.',
|
|
189
|
+
postApprovalInstructions: 'Then run the CLI login.',
|
|
190
|
+
roles: [{ displayName: 'Read' }, { displayName: 'Write' }],
|
|
191
|
+
},
|
|
192
|
+
}),
|
|
193
|
+
methods,
|
|
194
|
+
)
|
|
195
|
+
expect(label).toEqual({ route: 'Bot', chip: '2 steps' })
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('is absent when there is nothing to count', () => {
|
|
199
|
+
const label = accessTabLabel(
|
|
200
|
+
res({ accessRequest: { approvalMethodSlug: 'access-bot' } }),
|
|
201
|
+
methods,
|
|
202
|
+
)
|
|
203
|
+
expect(label.chip).toBeUndefined()
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
})
|
|
@@ -81,7 +81,7 @@ describe('Header login affordance', () => {
|
|
|
81
81
|
|
|
82
82
|
it('still shows the user menu once authenticated', () => {
|
|
83
83
|
state.isAuthenticated = true
|
|
84
|
-
state.user = { name: 'Igor Golovin', email: '
|
|
84
|
+
state.user = { name: 'Igor Golovin', email: 'igor@example.com' }
|
|
85
85
|
render(<Header />)
|
|
86
86
|
|
|
87
87
|
expect(screen.getByTestId('user-avatar-button')).toBeInTheDocument()
|
package/src/index.css
CHANGED
|
@@ -249,9 +249,16 @@
|
|
|
249
249
|
padding-left: 1.25rem;
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
-
/*
|
|
253
|
-
|
|
254
|
-
|
|
252
|
+
/* Review labels. Plain CSS rather than utilities: there is no success token in the
|
|
253
|
+
palette, and a tinted chip needs a colour the theme does not name. `--chart-3` is
|
|
254
|
+
the palette's green and already shifts for dark mode.
|
|
255
|
+
|
|
256
|
+
Mix percentages are low on purpose. At 75%/72% these measured 3.55:1 and 3.24:1
|
|
257
|
+
against the cream page — under the 4.5:1 floor for 11px text — and the old 600/700
|
|
258
|
+
weights were compensating for that, which is why they shouted and were still hard
|
|
259
|
+
to read. Lowering the mix raises contrast in both themes, so there is no .dark
|
|
260
|
+
override. Verify any change by measurement, not by eye: 60%/55% looks fine and
|
|
261
|
+
still fails. */
|
|
255
262
|
.comment-chip {
|
|
256
263
|
display: inline-flex;
|
|
257
264
|
align-items: center;
|
|
@@ -259,12 +266,13 @@
|
|
|
259
266
|
border-radius: 999px;
|
|
260
267
|
padding: 0.05rem 0.5rem;
|
|
261
268
|
font-size: 0.6875rem;
|
|
262
|
-
font-weight:
|
|
269
|
+
font-weight: 500;
|
|
263
270
|
line-height: 1.5;
|
|
264
271
|
white-space: nowrap;
|
|
265
272
|
}
|
|
266
273
|
.comment-chip-applied {
|
|
267
|
-
|
|
274
|
+
/* 5.46:1 light, 7.67:1 dark */
|
|
275
|
+
color: color-mix(in oklab, var(--chart-3) 50%, var(--foreground));
|
|
268
276
|
background: color-mix(in oklab, var(--chart-3) 16%, var(--card));
|
|
269
277
|
border: 1px solid color-mix(in oklab, var(--chart-3) 35%, var(--card));
|
|
270
278
|
}
|
|
@@ -275,6 +283,262 @@
|
|
|
275
283
|
font-weight: 400;
|
|
276
284
|
}
|
|
277
285
|
|
|
286
|
+
/* One register for every section label inside the detail card.
|
|
287
|
+
Measured: the old heading was 13px/500 above 13.5px/400 body — a single
|
|
288
|
+
100-unit weight step as the ONLY distinction, with no size, tracking or case
|
|
289
|
+
change. It read as "too pale" while already measuring 14.45:1 against the
|
|
290
|
+
card, so darkening was never available; the register was the problem.
|
|
291
|
+
Four signals now instead of one. `--foreground` deliberately: muting drops it
|
|
292
|
+
to 5.46:1 on a label whose complaint was paleness. Margins carry the rest
|
|
293
|
+
(28px between sections, 6px to the label's own body) and there are no rules
|
|
294
|
+
per section — a tab panel's edge already states that boundary. */
|
|
295
|
+
.section-label {
|
|
296
|
+
font-size: 0.6875rem;
|
|
297
|
+
font-weight: 700;
|
|
298
|
+
letter-spacing: 0.06em;
|
|
299
|
+
text-transform: uppercase;
|
|
300
|
+
color: var(--foreground);
|
|
301
|
+
line-height: 1.4;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/* The way into the app: icon, name and address fused into ONE pressable
|
|
305
|
+
surface at the top of the detail card. Plain CSS, not utilities — every
|
|
306
|
+
colour here is a measured `color-mix`, and the arbitrary-value classes that
|
|
307
|
+
would be needed are exactly what the JIT can miss.
|
|
308
|
+
|
|
309
|
+
Padding is always present and cancelled by an equal negative margin, so the
|
|
310
|
+
surface arriving on hover never nudges the text inside it. Only colour,
|
|
311
|
+
border, shadow and a 2px lift animate. */
|
|
312
|
+
.app-door {
|
|
313
|
+
display: inline-flex;
|
|
314
|
+
align-items: center;
|
|
315
|
+
gap: 0.9375rem;
|
|
316
|
+
max-width: calc(100% + 2.125rem);
|
|
317
|
+
margin: -0.6875rem -1.0625rem;
|
|
318
|
+
padding: 0.6875rem 1.0625rem;
|
|
319
|
+
border: 1px solid var(--border);
|
|
320
|
+
border-radius: 0.875rem;
|
|
321
|
+
/* A whisper at rest, so the block reads as a surface — therefore pressable —
|
|
322
|
+
before anyone hovers it. Most first-time visitors scan rather than hover. */
|
|
323
|
+
background: color-mix(in oklab, var(--primary) 2.5%, var(--card));
|
|
324
|
+
text-decoration: none;
|
|
325
|
+
color: inherit;
|
|
326
|
+
cursor: pointer;
|
|
327
|
+
transition:
|
|
328
|
+
background-color 0.14s ease-out,
|
|
329
|
+
border-color 0.14s ease-out,
|
|
330
|
+
box-shadow 0.14s ease-out,
|
|
331
|
+
translate 0.14s ease-out;
|
|
332
|
+
}
|
|
333
|
+
.app-door:hover,
|
|
334
|
+
.app-door:focus-visible {
|
|
335
|
+
background: color-mix(in oklab, var(--primary) 7%, var(--card));
|
|
336
|
+
border-color: color-mix(in oklab, var(--primary) 38%, var(--border));
|
|
337
|
+
box-shadow: 0 5px 16px -7px
|
|
338
|
+
color-mix(in oklab, var(--primary) 45%, transparent);
|
|
339
|
+
translate: 0 -2px;
|
|
340
|
+
outline: none;
|
|
341
|
+
}
|
|
342
|
+
.app-door:focus-visible {
|
|
343
|
+
box-shadow: 0 0 0 3px color-mix(in oklab, var(--primary) 25%, transparent);
|
|
344
|
+
}
|
|
345
|
+
.app-door:active {
|
|
346
|
+
translate: 0 0;
|
|
347
|
+
}
|
|
348
|
+
/* The icon lifts WITH the surface, so the logo reads as part of the press
|
|
349
|
+
rather than something parked beside it. Shadow only, deliberately: the icon
|
|
350
|
+
is either an <img> (no background to tint) or a monogram whose colour is set
|
|
351
|
+
as an INLINE style, which a class cannot override — and recolouring a brand
|
|
352
|
+
logo on hover would be wrong even where it worked. */
|
|
353
|
+
.app-door__icon {
|
|
354
|
+
transition: box-shadow 0.14s ease-out;
|
|
355
|
+
}
|
|
356
|
+
.app-door:hover .app-door__icon,
|
|
357
|
+
.app-door:focus-visible .app-door__icon {
|
|
358
|
+
box-shadow: 0 2px 8px -3px
|
|
359
|
+
color-mix(in oklab, var(--primary) 55%, transparent);
|
|
360
|
+
}
|
|
361
|
+
/* The title must NOT turn brand-coloured on hover. Measured against the hover
|
|
362
|
+
tint: `--foreground` holds 13.35:1 light / 12.60:1 dark, where the darkened
|
|
363
|
+
brand colour manages only 5.17:1 / 3.65:1 — so tinting it DROPPED contrast on
|
|
364
|
+
interaction. Hover, active and focus have to increase contrast, never reduce
|
|
365
|
+
it. The surface, border, shadow, icon tile and glyph already carry the state
|
|
366
|
+
change; the brand tint was the only cue that worked by going paler. */
|
|
367
|
+
.app-door__title {
|
|
368
|
+
display: block;
|
|
369
|
+
font-family: var(--font-serif);
|
|
370
|
+
font-size: 1.5625rem;
|
|
371
|
+
font-weight: 600;
|
|
372
|
+
line-height: 1.2;
|
|
373
|
+
}
|
|
374
|
+
/* One size step past the point where the longest names run the surface past
|
|
375
|
+
the card. Only a handful of entries are this long, but they include some of
|
|
376
|
+
the most-opened ones. */
|
|
377
|
+
.app-door__title--long {
|
|
378
|
+
font-size: 1.3125rem;
|
|
379
|
+
}
|
|
380
|
+
.app-door__url {
|
|
381
|
+
display: block;
|
|
382
|
+
margin-top: 0.25rem;
|
|
383
|
+
font-family: var(--font-mono, ui-monospace, monospace);
|
|
384
|
+
font-size: 0.75rem;
|
|
385
|
+
line-height: 1.4;
|
|
386
|
+
color: var(--muted-foreground);
|
|
387
|
+
overflow: hidden;
|
|
388
|
+
text-overflow: ellipsis;
|
|
389
|
+
white-space: nowrap;
|
|
390
|
+
transition: color 0.14s ease-out;
|
|
391
|
+
}
|
|
392
|
+
.app-door:hover .app-door__url,
|
|
393
|
+
.app-door:focus-visible .app-door__url {
|
|
394
|
+
color: var(--foreground);
|
|
395
|
+
}
|
|
396
|
+
.app-door__glyph {
|
|
397
|
+
flex: none;
|
|
398
|
+
color: var(--muted-foreground);
|
|
399
|
+
opacity: 0.75;
|
|
400
|
+
transition:
|
|
401
|
+
opacity 0.14s ease-out,
|
|
402
|
+
color 0.14s ease-out,
|
|
403
|
+
translate 0.14s ease-out;
|
|
404
|
+
}
|
|
405
|
+
.app-door:hover .app-door__glyph,
|
|
406
|
+
.app-door:focus-visible .app-door__glyph {
|
|
407
|
+
opacity: 1;
|
|
408
|
+
color: color-mix(in oklab, var(--primary) 85%, black);
|
|
409
|
+
translate: 2px -2px;
|
|
410
|
+
}
|
|
411
|
+
/* No hover on a touch screen, so a hover-only affordance is invisible there.
|
|
412
|
+
Show the surface at full strength instead. */
|
|
413
|
+
@media (hover: none) {
|
|
414
|
+
.app-door {
|
|
415
|
+
background: color-mix(in oklab, var(--primary) 6%, var(--card));
|
|
416
|
+
border-color: color-mix(in oklab, var(--primary) 30%, var(--border));
|
|
417
|
+
}
|
|
418
|
+
.app-door__glyph {
|
|
419
|
+
opacity: 1;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/* The detail card's hero band: identity, the way in, and the typed-id jumps,
|
|
424
|
+
with the tab strip seated on its bottom edge so the two read as one unit and
|
|
425
|
+
the panel below reads as contents. The tint is faint on purpose — the access
|
|
426
|
+
section carries a stronger one, and two competing tinted panels in the first
|
|
427
|
+
screen was the objection that nearly sank this layout. */
|
|
428
|
+
.detail-band {
|
|
429
|
+
background: color-mix(in oklab, var(--primary) 4.5%, var(--card));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/* The tab strip. Horizontally scrollable rather than wrapping: a wrapped strip
|
|
433
|
+
changes the band's height from one entry to the next. */
|
|
434
|
+
.detail-tabs {
|
|
435
|
+
display: flex;
|
|
436
|
+
gap: 2px;
|
|
437
|
+
border-bottom: 1px solid var(--border);
|
|
438
|
+
overflow-x: auto;
|
|
439
|
+
scrollbar-width: none;
|
|
440
|
+
}
|
|
441
|
+
.detail-tabs::-webkit-scrollbar {
|
|
442
|
+
display: none;
|
|
443
|
+
}
|
|
444
|
+
.detail-tab {
|
|
445
|
+
position: relative;
|
|
446
|
+
display: inline-flex;
|
|
447
|
+
align-items: center;
|
|
448
|
+
gap: 0.4375rem;
|
|
449
|
+
padding: 0.625rem 0.875rem 0.6875rem;
|
|
450
|
+
border: 0;
|
|
451
|
+
border-radius: 0.5rem 0.5rem 0 0;
|
|
452
|
+
background: none;
|
|
453
|
+
font-size: 0.84375rem;
|
|
454
|
+
font-weight: 600;
|
|
455
|
+
white-space: nowrap;
|
|
456
|
+
color: var(--muted-foreground);
|
|
457
|
+
cursor: pointer;
|
|
458
|
+
transition:
|
|
459
|
+
color 0.14s ease-out,
|
|
460
|
+
background-color 0.14s ease-out;
|
|
461
|
+
}
|
|
462
|
+
.detail-tab:hover {
|
|
463
|
+
color: var(--foreground);
|
|
464
|
+
background: color-mix(in oklab, var(--primary) 5%, transparent);
|
|
465
|
+
}
|
|
466
|
+
/* The marker sits ON the strip's own border, so the active tab reads as
|
|
467
|
+
attached to the panel below rather than underlined. */
|
|
468
|
+
.detail-tab::after {
|
|
469
|
+
content: '';
|
|
470
|
+
position: absolute;
|
|
471
|
+
left: 0.625rem;
|
|
472
|
+
right: 0.625rem;
|
|
473
|
+
bottom: -1px;
|
|
474
|
+
height: 2.5px;
|
|
475
|
+
border-radius: 2px;
|
|
476
|
+
background: transparent;
|
|
477
|
+
transition: background-color 0.14s ease-out;
|
|
478
|
+
}
|
|
479
|
+
.detail-tab[aria-selected='true'] {
|
|
480
|
+
color: color-mix(in oklab, var(--primary) 85%, black);
|
|
481
|
+
}
|
|
482
|
+
.detail-tab[aria-selected='true']::after {
|
|
483
|
+
background: color-mix(in oklab, var(--primary) 85%, black);
|
|
484
|
+
}
|
|
485
|
+
.detail-tab:focus-visible {
|
|
486
|
+
outline: 2px solid color-mix(in oklab, var(--primary) 85%, black);
|
|
487
|
+
outline-offset: -2px;
|
|
488
|
+
}
|
|
489
|
+
/* Measured: `--muted-foreground` on `--secondary` at this size is 4.65:1 light
|
|
490
|
+
— over the 4.5:1 floor by 0.15, which is not a margin. Mixed toward
|
|
491
|
+
`--foreground` instead: 8.16:1 light, 8.24:1 dark. The active chip formerly
|
|
492
|
+
used the brand colour on a brand tint, which measured 4.46:1 light and
|
|
493
|
+
3.00:1 dark — failing — so the tint alone now marks "active" and the text
|
|
494
|
+
stays at full `--foreground`. Weight is for emphasis; it is not contrast. */
|
|
495
|
+
.detail-tab__chip {
|
|
496
|
+
border-radius: 999px;
|
|
497
|
+
padding: 2px 7px;
|
|
498
|
+
font-size: 0.65625rem;
|
|
499
|
+
font-weight: 700;
|
|
500
|
+
line-height: 1.3;
|
|
501
|
+
background: var(--secondary);
|
|
502
|
+
color: color-mix(in oklab, var(--foreground) 55%, var(--muted-foreground));
|
|
503
|
+
}
|
|
504
|
+
.detail-tab[aria-selected='true'] .detail-tab__chip {
|
|
505
|
+
background: color-mix(in oklab, var(--primary) 16%, transparent);
|
|
506
|
+
color: var(--foreground);
|
|
507
|
+
}
|
|
508
|
+
/* "Something here is unreviewed", visible from any tab. Decorative, so it is
|
|
509
|
+
exempt on text-contrast grounds — and never the only carrier: the tab's
|
|
510
|
+
accessible name says it too. */
|
|
511
|
+
.detail-tab__dot {
|
|
512
|
+
width: 7px;
|
|
513
|
+
height: 7px;
|
|
514
|
+
flex: none;
|
|
515
|
+
border-radius: 999px;
|
|
516
|
+
background: var(--chart-4, oklch(0.72 0.16 70));
|
|
517
|
+
box-shadow: 0 0 0 2px
|
|
518
|
+
color-mix(in oklab, var(--chart-4, oklch(0.72 0.16 70)) 25%, transparent);
|
|
519
|
+
|
|
520
|
+
/* How many items are still waiting on a maintainer. Amber rather than red: an entry
|
|
521
|
+
with open feedback is disputed, not broken, and red is already the withdraw
|
|
522
|
+
affordance in the same thread. 5.45:1 light, 8.08:1 dark. */
|
|
523
|
+
.feedback-open-count {
|
|
524
|
+
display: inline-flex;
|
|
525
|
+
align-items: center;
|
|
526
|
+
justify-content: center;
|
|
527
|
+
min-width: 1.15rem;
|
|
528
|
+
border-radius: 999px;
|
|
529
|
+
padding: 0.15rem 0.35rem;
|
|
530
|
+
font-size: 0.6875rem;
|
|
531
|
+
font-weight: 600;
|
|
532
|
+
line-height: 1;
|
|
533
|
+
letter-spacing: normal;
|
|
534
|
+
text-transform: none;
|
|
535
|
+
white-space: nowrap;
|
|
536
|
+
color: color-mix(in oklab, var(--chart-4) 45%, var(--foreground));
|
|
537
|
+
background: color-mix(in oklab, var(--chart-4) 18%, var(--card));
|
|
538
|
+
border: 1px solid color-mix(in oklab, var(--chart-4) 38%, var(--card));
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
278
542
|
/* tw-animate-css ships no reduced-motion rules of its own, so honour the
|
|
279
543
|
preference here — the overlay animations it provides are all decorative. */
|
|
280
544
|
@media (prefers-reduced-motion: reduce) {
|
|
@@ -13,6 +13,7 @@ import { useUiSettings } from '~/context/UiSettingsContext'
|
|
|
13
13
|
import type { AreaIcon } from '~/types/uiSettings'
|
|
14
14
|
import { AppCatalogContext } from '../../context/AppCatalogContext'
|
|
15
15
|
import { areaLabel, groupByArea } from '../../utils/areaGrouping'
|
|
16
|
+
import { RequestAppDialog } from './RequestAppDialog'
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Adaptive-home discovery spine (issue #38, increment 1) — matches the
|
|
@@ -347,6 +348,9 @@ function SearchResultsList({
|
|
|
347
348
|
const [expandedParents, setExpandedParents] = useState<Set<string>>(
|
|
348
349
|
() => new Set(),
|
|
349
350
|
)
|
|
351
|
+
// Asking for something absent. Local state rather than a url param: a half-typed
|
|
352
|
+
// request has no business in a shareable link.
|
|
353
|
+
const [requestOpen, setRequestOpen] = useState(false)
|
|
350
354
|
|
|
351
355
|
const results = useMemo(
|
|
352
356
|
() => searchResources(apps, searchValue),
|
|
@@ -483,6 +487,26 @@ function SearchResultsList({
|
|
|
483
487
|
: `${matchCount} result${matchCount === 1 ? '' : 's'}`}
|
|
484
488
|
</div>
|
|
485
489
|
|
|
490
|
+
{/* A zero-result search is the most informative moment in the catalog: someone
|
|
491
|
+
has said exactly what they expected to find. Showing only "No results"
|
|
492
|
+
throws that away and leaves them with nowhere to go. */}
|
|
493
|
+
{matchCount === 0 && !didDeprecatedFallback && (
|
|
494
|
+
<div className="mb-3 px-1">
|
|
495
|
+
<button
|
|
496
|
+
type="button"
|
|
497
|
+
onClick={() => setRequestOpen(true)}
|
|
498
|
+
className="text-sm underline decoration-dotted underline-offset-2 hover:text-foreground text-muted-foreground"
|
|
499
|
+
>
|
|
500
|
+
Not in the catalog? Ask for it to be added
|
|
501
|
+
</button>
|
|
502
|
+
</div>
|
|
503
|
+
)}
|
|
504
|
+
<RequestAppDialog
|
|
505
|
+
open={requestOpen}
|
|
506
|
+
onOpenChange={setRequestOpen}
|
|
507
|
+
initialSubject={searchValue}
|
|
508
|
+
/>
|
|
509
|
+
|
|
486
510
|
{/* #11: deprecated-only fallback notice */}
|
|
487
511
|
{didDeprecatedFallback && (
|
|
488
512
|
<div
|