@aleph-alpha/chat-kit 6.5.0 → 6.7.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/{CkHistory.vue_vue_type_script_setup_true_lang-C4XS2QQI.js → CkHistory.vue_vue_type_script_setup_true_lang-DF5yUf7K.js} +619 -152
- package/dist/chat-kit.css +9 -9
- package/dist/components/base/CkCodeEditor/CkCodeEditor.vue.d.ts +4 -4
- package/dist/components/base/CkFeedbackDialog/CkFeedbackDialog.stories.d.ts +5 -0
- package/dist/components/base/CkFeedbackDialog/CkFeedbackDialog.stories.d.ts.map +1 -1
- package/dist/components/base/CkFeedbackDialog/CkFeedbackDialog.vue.d.ts +14 -1
- package/dist/components/base/CkFeedbackDialog/CkFeedbackDialog.vue.d.ts.map +1 -1
- package/dist/components/base/CkMilkdownEditor/CkMilkdownEditor.vue.d.ts +1 -1
- package/dist/components/base/CkToolApproval/ApprovalCard.vue.d.ts +21 -0
- package/dist/components/base/CkToolApproval/ApprovalCard.vue.d.ts.map +1 -0
- package/dist/components/base/CkToolApproval/ArgumentsJson.vue.d.ts +10 -0
- package/dist/components/base/CkToolApproval/ArgumentsJson.vue.d.ts.map +1 -0
- package/dist/components/base/CkToolApproval/BodyBoundary.vue.d.ts +15 -0
- package/dist/components/base/CkToolApproval/BodyBoundary.vue.d.ts.map +1 -0
- package/dist/components/base/CkToolApproval/CardHeading.vue.d.ts +22 -0
- package/dist/components/base/CkToolApproval/CardHeading.vue.d.ts.map +1 -0
- package/dist/components/base/CkToolApproval/CkToolApproval.stories.d.ts +80 -0
- package/dist/components/base/CkToolApproval/CkToolApproval.stories.d.ts.map +1 -0
- package/dist/components/base/CkToolApproval/CkToolApproval.vue.d.ts +18 -0
- package/dist/components/base/CkToolApproval/CkToolApproval.vue.d.ts.map +1 -0
- package/dist/components/base/CkToolApproval/ResultRow.vue.d.ts +20 -0
- package/dist/components/base/CkToolApproval/ResultRow.vue.d.ts.map +1 -0
- package/dist/components/base/CkToolApproval/badge.d.ts +3 -0
- package/dist/components/base/CkToolApproval/badge.d.ts.map +1 -0
- package/dist/components/base/CkToolApproval/index.d.ts +3 -0
- package/dist/components/base/CkToolApproval/index.d.ts.map +1 -0
- package/dist/components/base/CkToolApproval/outcomes.d.ts +28 -0
- package/dist/components/base/CkToolApproval/outcomes.d.ts.map +1 -0
- package/dist/components/base/CkToolApproval/serializeArguments.d.ts +13 -0
- package/dist/components/base/CkToolApproval/serializeArguments.d.ts.map +1 -0
- package/dist/components/base/CkToolApproval/toolTitle.d.ts +7 -0
- package/dist/components/base/CkToolApproval/toolTitle.d.ts.map +1 -0
- package/dist/components/base/CkToolApproval/types.d.ts +102 -0
- package/dist/components/base/CkToolApproval/types.d.ts.map +1 -0
- package/dist/components/base/index.d.ts +2 -0
- package/dist/components/base/index.d.ts.map +1 -1
- package/dist/components/index.d.ts +2 -2
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +4 -3
- package/dist/{createCrepeEditor-BGSlwmKn.js → createCrepeEditor-DSfByw4o.js} +1 -1
- package/dist/handlers/index.js +1 -1
- package/dist/handlers/useChatKitLabels.d.ts +39 -0
- package/dist/handlers/useChatKitLabels.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -5
- package/dist/{useChatKitLabels-DxW0N5U-.js → useChatKitLabels-ky3JI9gf.js} +13 -0
- package/package.json +3 -3
- package/src/components/base/CkFeedbackDialog/CkFeedbackDialog.spec.ts +27 -0
- package/src/components/base/CkFeedbackDialog/CkFeedbackDialog.stories.ts +23 -0
- package/src/components/base/CkFeedbackDialog/CkFeedbackDialog.vue +15 -0
- package/src/components/base/CkToolApproval/ApprovalCard.vue +30 -0
- package/src/components/base/CkToolApproval/ArgumentsJson.vue +48 -0
- package/src/components/base/CkToolApproval/BodyBoundary.vue +26 -0
- package/src/components/base/CkToolApproval/CardHeading.vue +47 -0
- package/src/components/base/CkToolApproval/CkToolApproval.spec.ts +680 -0
- package/src/components/base/CkToolApproval/CkToolApproval.stories.ts +661 -0
- package/src/components/base/CkToolApproval/CkToolApproval.vue +240 -0
- package/src/components/base/CkToolApproval/ResultRow.vue +52 -0
- package/src/components/base/CkToolApproval/badge.ts +2 -0
- package/src/components/base/CkToolApproval/exports.spec.ts +22 -0
- package/src/components/base/CkToolApproval/index.ts +2 -0
- package/src/components/base/CkToolApproval/outcomes.ts +95 -0
- package/src/components/base/CkToolApproval/serializeArguments.ts +18 -0
- package/src/components/base/CkToolApproval/toolTitle.spec.ts +23 -0
- package/src/components/base/CkToolApproval/toolTitle.ts +19 -0
- package/src/components/base/CkToolApproval/types.ts +118 -0
- package/src/components/base/index.ts +11 -0
- package/src/components/index.ts +9 -0
- package/src/handlers/useChatKitLabels.ts +39 -0
- package/src/index.ts +9 -0
- package/src/test-empty.css +1 -0
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
import CkToolApproval from './CkToolApproval.vue';
|
|
2
|
+
import {
|
|
3
|
+
ckToolApprovalStatuses,
|
|
4
|
+
type CkToolApprovalRequest,
|
|
5
|
+
type CkToolApprovalStatus,
|
|
6
|
+
} from './types';
|
|
7
|
+
import type { Meta, StoryObj } from '@storybook/vue3-vite';
|
|
8
|
+
import { expect, fn, userEvent, within } from 'storybook/test';
|
|
9
|
+
import { defineComponent, h, markRaw, ref, type PropType } from 'vue';
|
|
10
|
+
|
|
11
|
+
const onAllow = fn();
|
|
12
|
+
const onDeny = fn();
|
|
13
|
+
|
|
14
|
+
function resetSpies(): void {
|
|
15
|
+
onAllow.mockClear();
|
|
16
|
+
onDeny.mockClear();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const REQUEST_ID = 'mcpr_68d3a1f0c2b4';
|
|
20
|
+
const GROUP_NAME = 'Update work items Polarion';
|
|
21
|
+
// Long enough for the browser to drop focus from the disabled button first.
|
|
22
|
+
const BACKEND_DELAY_MS = 300;
|
|
23
|
+
// Upper bound on Tab presses to get past the JSON block's own controls.
|
|
24
|
+
const MAX_TABS_TO_DENY = 10;
|
|
25
|
+
|
|
26
|
+
const updateWorkItems: CkToolApprovalRequest = {
|
|
27
|
+
id: REQUEST_ID,
|
|
28
|
+
connector: 'polarion',
|
|
29
|
+
connectorLabel: 'Polarion',
|
|
30
|
+
toolName: 'update_work_items',
|
|
31
|
+
arguments: {
|
|
32
|
+
project_id: 'MCPT',
|
|
33
|
+
items: [
|
|
34
|
+
{
|
|
35
|
+
work_item_id: 'MCPT-042',
|
|
36
|
+
title: 'Brake pedal response under 120 ms',
|
|
37
|
+
priority: '70.0',
|
|
38
|
+
},
|
|
39
|
+
{ work_item_id: 'MCPT-043', assignee_ids: ['jdoe', 'asmith'] },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* CkToolApproval asks the user to allow or deny a tool call the agent paused
|
|
46
|
+
* on. While `pending` it shows the call's arguments as JSON with Deny and
|
|
47
|
+
* Allow; once decided it collapses to a one-line result with a status badge.
|
|
48
|
+
* The host converts its backend's approval request into `request`, sends the
|
|
49
|
+
* decision to its backend on `allow` / `deny` (Deny is sent too, so the agent
|
|
50
|
+
* can carry on), and passes the new `status` in.
|
|
51
|
+
*
|
|
52
|
+
* Both buttons lock after the first click so only one answer is sent. If
|
|
53
|
+
* sending fails and the request stays pending, call the exposed `unlock()` so
|
|
54
|
+
* the user can try again.
|
|
55
|
+
*/
|
|
56
|
+
const meta: Meta<typeof CkToolApproval> = {
|
|
57
|
+
title: 'Base/CkToolApproval',
|
|
58
|
+
component: CkToolApproval,
|
|
59
|
+
tags: ['autodocs'],
|
|
60
|
+
argTypes: {
|
|
61
|
+
status: {
|
|
62
|
+
control: 'select',
|
|
63
|
+
options: ckToolApprovalStatuses,
|
|
64
|
+
},
|
|
65
|
+
summary: { control: 'text' },
|
|
66
|
+
},
|
|
67
|
+
args: {
|
|
68
|
+
request: updateWorkItems,
|
|
69
|
+
status: 'pending',
|
|
70
|
+
onAllow,
|
|
71
|
+
onDeny,
|
|
72
|
+
},
|
|
73
|
+
decorators: [
|
|
74
|
+
() => ({
|
|
75
|
+
template:
|
|
76
|
+
'<div style="max-width: 800px; margin: 0 auto; padding: 24px;"><story /></div>',
|
|
77
|
+
}),
|
|
78
|
+
],
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export default meta;
|
|
82
|
+
type Story = StoryObj<typeof meta>;
|
|
83
|
+
|
|
84
|
+
const defaultTemplateSource = `<script setup lang="ts">
|
|
85
|
+
import { ref, useTemplateRef } from 'vue';
|
|
86
|
+
import {
|
|
87
|
+
CkToolApproval,
|
|
88
|
+
type CkToolApprovalRequest,
|
|
89
|
+
type CkToolApprovalStatus,
|
|
90
|
+
} from '@aleph-alpha/chat-kit';
|
|
91
|
+
|
|
92
|
+
// Converted from the backend's approval request by the host.
|
|
93
|
+
const request: CkToolApprovalRequest = {
|
|
94
|
+
id: 'mcpr_68d3a1f0c2b4',
|
|
95
|
+
connector: 'polarion',
|
|
96
|
+
connectorLabel: 'Polarion',
|
|
97
|
+
toolName: 'update_work_items',
|
|
98
|
+
arguments: { project_id: 'MCPT', items: [/* … */] },
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const status = ref<CkToolApprovalStatus>('pending');
|
|
102
|
+
const approval = useTemplateRef('approval');
|
|
103
|
+
|
|
104
|
+
async function answer(id: string, approved: boolean) {
|
|
105
|
+
try {
|
|
106
|
+
// Your call that answers the approval request on the backend.
|
|
107
|
+
await sendDecision(id, approved);
|
|
108
|
+
status.value = approved ? 'allowed' : 'denied';
|
|
109
|
+
} catch {
|
|
110
|
+
// Still pending: let the user click again.
|
|
111
|
+
approval.value?.unlock();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
</script>
|
|
115
|
+
|
|
116
|
+
<template>
|
|
117
|
+
<CkToolApproval
|
|
118
|
+
ref="approval"
|
|
119
|
+
:request="request"
|
|
120
|
+
:status="status"
|
|
121
|
+
@allow="answer($event, true)"
|
|
122
|
+
@deny="answer($event, false)"
|
|
123
|
+
/>
|
|
124
|
+
</template>`;
|
|
125
|
+
|
|
126
|
+
async function expectDecidedRow(
|
|
127
|
+
canvasElement: HTMLElement,
|
|
128
|
+
resultLine: string,
|
|
129
|
+
badge: string,
|
|
130
|
+
): Promise<void> {
|
|
131
|
+
const group = within(canvasElement).getByRole('group', { name: GROUP_NAME });
|
|
132
|
+
await expect(group).toHaveAccessibleDescription(resultLine);
|
|
133
|
+
await expect(within(group).getByText(badge)).toBeInTheDocument();
|
|
134
|
+
await expect(within(group).queryByRole('button')).not.toBeInTheDocument();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Waiting for the user: the header names the action, derived from `toolName`
|
|
139
|
+
* when the request has no `toolTitle`, then the arguments as JSON with Deny
|
|
140
|
+
* and Allow.
|
|
141
|
+
*/
|
|
142
|
+
export const Pending: Story = {
|
|
143
|
+
parameters: { docs: { source: { code: defaultTemplateSource } } },
|
|
144
|
+
play: async ({ canvasElement, step }) => {
|
|
145
|
+
resetSpies();
|
|
146
|
+
const canvas = within(canvasElement);
|
|
147
|
+
|
|
148
|
+
await step(
|
|
149
|
+
'interaction: Allow emits allow with the request id',
|
|
150
|
+
async () => {
|
|
151
|
+
await userEvent.click(canvas.getByRole('button', { name: 'Allow' }));
|
|
152
|
+
await expect(onAllow).toHaveBeenCalledWith(REQUEST_ID);
|
|
153
|
+
await expect(onDeny).not.toHaveBeenCalled();
|
|
154
|
+
},
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
await step(
|
|
158
|
+
'interaction: both buttons lock after the first click',
|
|
159
|
+
async () => {
|
|
160
|
+
await expect(
|
|
161
|
+
canvas.getByRole('button', { name: 'Allow' }),
|
|
162
|
+
).toBeDisabled();
|
|
163
|
+
await expect(
|
|
164
|
+
canvas.getByRole('button', { name: 'Deny' }),
|
|
165
|
+
).toBeDisabled();
|
|
166
|
+
await userEvent.click(canvas.getByRole('button', { name: 'Deny' }), {
|
|
167
|
+
pointerEventsCheck: 0,
|
|
168
|
+
});
|
|
169
|
+
await expect(onDeny).not.toHaveBeenCalled();
|
|
170
|
+
},
|
|
171
|
+
);
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/** Allowed and run, with an outcome summary from the host. */
|
|
176
|
+
export const Allowed: Story = {
|
|
177
|
+
args: { status: 'allowed', summary: '2 work items updated' },
|
|
178
|
+
play: async ({ canvasElement, step }) => {
|
|
179
|
+
const canvas = within(canvasElement);
|
|
180
|
+
|
|
181
|
+
await step(
|
|
182
|
+
'interaction: a decided card has no buttons left to press',
|
|
183
|
+
async () => {
|
|
184
|
+
await expect(canvas.queryByRole('button')).not.toBeInTheDocument();
|
|
185
|
+
},
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
await step(
|
|
189
|
+
'screen-reader: the outcome is read as text inside the named group',
|
|
190
|
+
async () => {
|
|
191
|
+
const group = canvas.getByRole('group', { name: GROUP_NAME });
|
|
192
|
+
await expect(group).toHaveAccessibleDescription(
|
|
193
|
+
'Approved by you · 2 work items updated',
|
|
194
|
+
);
|
|
195
|
+
await expect(group).toHaveTextContent('Allowed');
|
|
196
|
+
},
|
|
197
|
+
);
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
/** Denied by the user. Nothing ran. */
|
|
202
|
+
export const Denied: Story = {
|
|
203
|
+
args: { status: 'denied' },
|
|
204
|
+
play: async ({ canvasElement, step }) => {
|
|
205
|
+
await step(
|
|
206
|
+
'screen-reader: the row is described by its result and has no buttons',
|
|
207
|
+
() =>
|
|
208
|
+
expectDecidedRow(
|
|
209
|
+
canvasElement,
|
|
210
|
+
'Denied by you · Nothing was updated',
|
|
211
|
+
'Denied',
|
|
212
|
+
),
|
|
213
|
+
);
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/** Allowed, but the tool errored, so nothing changed. */
|
|
218
|
+
export const Failed: Story = {
|
|
219
|
+
args: { status: 'failed' },
|
|
220
|
+
play: async ({ canvasElement, step }) => {
|
|
221
|
+
await step(
|
|
222
|
+
'screen-reader: the row is described by its result and has no buttons',
|
|
223
|
+
() =>
|
|
224
|
+
expectDecidedRow(
|
|
225
|
+
canvasElement,
|
|
226
|
+
'Failed · Nothing was updated',
|
|
227
|
+
'Failed',
|
|
228
|
+
),
|
|
229
|
+
);
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/** The user sent a new message instead of answering. Nothing ran. */
|
|
234
|
+
export const Skipped: Story = {
|
|
235
|
+
args: { status: 'skipped' },
|
|
236
|
+
play: async ({ canvasElement, step }) => {
|
|
237
|
+
await step(
|
|
238
|
+
'screen-reader: the row is described by its result and has no buttons',
|
|
239
|
+
() =>
|
|
240
|
+
expectDecidedRow(
|
|
241
|
+
canvasElement,
|
|
242
|
+
'Skipped · Nothing was updated',
|
|
243
|
+
'Skipped',
|
|
244
|
+
),
|
|
245
|
+
);
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* No `connectorLabel` on the request: the chip is hidden, for chats embedded
|
|
251
|
+
* in the connector's own app where the target is obvious.
|
|
252
|
+
*/
|
|
253
|
+
export const WithoutConnectorLabel: Story = {
|
|
254
|
+
args: { request: { ...updateWorkItems, connectorLabel: undefined } },
|
|
255
|
+
play: async ({ canvasElement, step }) => {
|
|
256
|
+
await step(
|
|
257
|
+
'screen-reader: the card is named by its action alone',
|
|
258
|
+
async () => {
|
|
259
|
+
await expect(
|
|
260
|
+
within(canvasElement).getByRole('group', {
|
|
261
|
+
name: 'Update work items',
|
|
262
|
+
}),
|
|
263
|
+
).toBeInTheDocument();
|
|
264
|
+
},
|
|
265
|
+
);
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Another connector, with the tool's own title from its MCP server and a
|
|
271
|
+
* Lucide icon for the chip. Use the `connector-icon` slot for a brand logo.
|
|
272
|
+
*/
|
|
273
|
+
export const OtherConnector: Story = {
|
|
274
|
+
args: {
|
|
275
|
+
request: {
|
|
276
|
+
id: 'mcpr_9f2e7c1d0a3b',
|
|
277
|
+
connector: 'gmail',
|
|
278
|
+
connectorLabel: 'Gmail',
|
|
279
|
+
connectorIcon: 'mail',
|
|
280
|
+
toolName: 'send_message',
|
|
281
|
+
toolTitle: 'Send email',
|
|
282
|
+
arguments: { to: ['team@example.com'], subject: 'Weekly update' },
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
play: async ({ canvasElement, step }) => {
|
|
286
|
+
resetSpies();
|
|
287
|
+
const canvas = within(canvasElement);
|
|
288
|
+
|
|
289
|
+
await step(
|
|
290
|
+
"screen-reader: the tool's own title and connector name the card",
|
|
291
|
+
async () => {
|
|
292
|
+
await expect(
|
|
293
|
+
canvas.getByRole('group', { name: 'Send email Gmail' }),
|
|
294
|
+
).toBeInTheDocument();
|
|
295
|
+
await expect(
|
|
296
|
+
canvasElement.querySelector('.lucide-mail'),
|
|
297
|
+
).not.toBeNull();
|
|
298
|
+
},
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
await step(
|
|
302
|
+
'interaction: Deny emits deny with this request id',
|
|
303
|
+
async () => {
|
|
304
|
+
await userEvent.click(canvas.getByRole('button', { name: 'Deny' }));
|
|
305
|
+
await expect(onDeny).toHaveBeenCalledWith('mcpr_9f2e7c1d0a3b');
|
|
306
|
+
},
|
|
307
|
+
);
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const WorkItemsBody = markRaw(
|
|
312
|
+
defineComponent({
|
|
313
|
+
props: {
|
|
314
|
+
request: {
|
|
315
|
+
type: Object as PropType<CkToolApprovalRequest>,
|
|
316
|
+
required: true,
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
setup(props) {
|
|
320
|
+
return () => {
|
|
321
|
+
const items = props.request.arguments.items;
|
|
322
|
+
const ids = Array.isArray(items)
|
|
323
|
+
? items.map((item: { work_item_id?: unknown }) =>
|
|
324
|
+
String(item.work_item_id),
|
|
325
|
+
)
|
|
326
|
+
: [];
|
|
327
|
+
return h(
|
|
328
|
+
'ul',
|
|
329
|
+
{ class: 'list-disc pl-5 text-sm' },
|
|
330
|
+
ids.map((id) => h('li', `Update ${id}`)),
|
|
331
|
+
);
|
|
332
|
+
};
|
|
333
|
+
},
|
|
334
|
+
}),
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
const customBodySource = `<!-- WorkItemsBody.vue: receives the request as its only prop -->
|
|
338
|
+
<script setup lang="ts">
|
|
339
|
+
import { computed } from 'vue';
|
|
340
|
+
import type { CkToolApprovalBodyProps } from '@aleph-alpha/chat-kit';
|
|
341
|
+
|
|
342
|
+
const props = defineProps<CkToolApprovalBodyProps>();
|
|
343
|
+
|
|
344
|
+
// Arguments arrive untyped from the model; keep only well-formed items.
|
|
345
|
+
function isWorkItem(value: unknown): value is { work_item_id: string } {
|
|
346
|
+
return (
|
|
347
|
+
typeof value === 'object' &&
|
|
348
|
+
value !== null &&
|
|
349
|
+
'work_item_id' in value &&
|
|
350
|
+
typeof value.work_item_id === 'string'
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const workItems = computed(() => {
|
|
355
|
+
const items = props.request.arguments.items;
|
|
356
|
+
return Array.isArray(items) ? items.filter(isWorkItem) : [];
|
|
357
|
+
});
|
|
358
|
+
</script>
|
|
359
|
+
|
|
360
|
+
<template>
|
|
361
|
+
<ul>
|
|
362
|
+
<li v-for="item in workItems" :key="item.work_item_id">
|
|
363
|
+
Update {{ item.work_item_id }}
|
|
364
|
+
</li>
|
|
365
|
+
</ul>
|
|
366
|
+
</template>
|
|
367
|
+
|
|
368
|
+
<!-- Where the card is used -->
|
|
369
|
+
<CkToolApproval
|
|
370
|
+
:request="request"
|
|
371
|
+
status="pending"
|
|
372
|
+
:body-components="{ polarion: { update_work_items: WorkItemsBody } }"
|
|
373
|
+
/>`;
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* A custom body registered for `polarion` + `update_work_items` replaces the
|
|
377
|
+
* JSON view. Requests with no entry keep the JSON view.
|
|
378
|
+
*/
|
|
379
|
+
export const CustomBody: Story = {
|
|
380
|
+
args: {
|
|
381
|
+
bodyComponents: { polarion: { update_work_items: WorkItemsBody } },
|
|
382
|
+
},
|
|
383
|
+
parameters: { docs: { source: { code: customBodySource } } },
|
|
384
|
+
play: async ({ canvasElement, step }) => {
|
|
385
|
+
const group = within(canvasElement).getByRole('group', {
|
|
386
|
+
name: GROUP_NAME,
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
await step(
|
|
390
|
+
'interaction: the custom body replaces the JSON view',
|
|
391
|
+
async () => {
|
|
392
|
+
await expect(within(group).getByText('Update MCPT-042')).toBeVisible();
|
|
393
|
+
await expect(group).not.toHaveTextContent('"project_id"');
|
|
394
|
+
},
|
|
395
|
+
);
|
|
396
|
+
},
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Keyboard users Tab to Deny, then to Allow, and Enter on Allow allows.
|
|
401
|
+
* Hidden from the sidebar; runs in CI.
|
|
402
|
+
*/
|
|
403
|
+
export const KeyboardNavigation: Story = {
|
|
404
|
+
name: 'test - Keyboard Navigation',
|
|
405
|
+
tags: ['!dev', '!autodocs'],
|
|
406
|
+
parameters: { chromatic: { disableSnapshot: true } },
|
|
407
|
+
play: async ({ canvasElement, step }) => {
|
|
408
|
+
resetSpies();
|
|
409
|
+
const canvas = within(canvasElement);
|
|
410
|
+
const deny = canvas.getByRole('button', { name: 'Deny' });
|
|
411
|
+
const allow = canvas.getByRole('button', { name: 'Allow' });
|
|
412
|
+
|
|
413
|
+
await step('keyboard: Tab reaches Deny (WCAG 2.1.1 Keyboard)', async () => {
|
|
414
|
+
// The JSON block's own controls come first; tab past them.
|
|
415
|
+
for (
|
|
416
|
+
let i = 0;
|
|
417
|
+
i < MAX_TABS_TO_DENY && document.activeElement !== deny;
|
|
418
|
+
i++
|
|
419
|
+
) {
|
|
420
|
+
await userEvent.tab();
|
|
421
|
+
}
|
|
422
|
+
await expect(deny).toHaveFocus();
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
await step(
|
|
426
|
+
'keyboard: Tab moves from Deny to Allow (WCAG 2.4.3 Focus Order)',
|
|
427
|
+
async () => {
|
|
428
|
+
await userEvent.tab();
|
|
429
|
+
await expect(allow).toHaveFocus();
|
|
430
|
+
},
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
await step('keyboard: Enter on Allow allows', async () => {
|
|
434
|
+
await userEvent.keyboard('{Enter}');
|
|
435
|
+
await expect(onAllow).toHaveBeenCalledWith(REQUEST_ID);
|
|
436
|
+
});
|
|
437
|
+
},
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* What assistive tech hears: one named group, two named buttons, and
|
|
442
|
+
* decorative icons hidden. Also checks that Space on Deny denies. Hidden
|
|
443
|
+
* from the sidebar; runs in CI.
|
|
444
|
+
*/
|
|
445
|
+
export const ScreenReader: Story = {
|
|
446
|
+
name: 'test - Screen Reader',
|
|
447
|
+
tags: ['!dev', '!autodocs'],
|
|
448
|
+
play: async ({ canvasElement, step }) => {
|
|
449
|
+
const canvas = within(canvasElement);
|
|
450
|
+
|
|
451
|
+
await step(
|
|
452
|
+
'screen-reader: the card is a group named by title and connector (WCAG 1.3.1)',
|
|
453
|
+
async () => {
|
|
454
|
+
await expect(
|
|
455
|
+
canvas.getByRole('group', { name: GROUP_NAME }),
|
|
456
|
+
).toBeInTheDocument();
|
|
457
|
+
},
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
await step(
|
|
461
|
+
'screen-reader: both actions have accessible names (WCAG 4.1.2)',
|
|
462
|
+
async () => {
|
|
463
|
+
await expect(
|
|
464
|
+
canvas.getByRole('button', { name: 'Deny' }),
|
|
465
|
+
).toBeInTheDocument();
|
|
466
|
+
await expect(
|
|
467
|
+
canvas.getByRole('button', { name: 'Allow' }),
|
|
468
|
+
).toBeInTheDocument();
|
|
469
|
+
},
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
await step(
|
|
473
|
+
'screen-reader: the connector icon is hidden from the accessibility tree',
|
|
474
|
+
async () => {
|
|
475
|
+
const icon = canvasElement.querySelector('.lucide-app-window');
|
|
476
|
+
await expect(icon).not.toBeNull();
|
|
477
|
+
await expect(icon?.closest('[aria-hidden="true"]')).not.toBeNull();
|
|
478
|
+
},
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
await step('keyboard: Space on Deny denies', async () => {
|
|
482
|
+
resetSpies();
|
|
483
|
+
canvas.getByRole('button', { name: 'Deny' }).focus();
|
|
484
|
+
await userEvent.keyboard(' ');
|
|
485
|
+
await expect(onDeny).toHaveBeenCalledWith(REQUEST_ID);
|
|
486
|
+
});
|
|
487
|
+
},
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* At 200% zoom the card still fits and both actions stay usable. Hidden from
|
|
492
|
+
* the sidebar; runs in CI.
|
|
493
|
+
*/
|
|
494
|
+
export const ZoomMagnification: Story = {
|
|
495
|
+
name: 'test - Zoom Magnification',
|
|
496
|
+
tags: ['!dev', '!autodocs'],
|
|
497
|
+
decorators: [
|
|
498
|
+
() => ({ template: '<div style="zoom: 2; width: 400px;"><story /></div>' }),
|
|
499
|
+
],
|
|
500
|
+
play: async ({ canvasElement, step }) => {
|
|
501
|
+
resetSpies();
|
|
502
|
+
const canvas = within(canvasElement);
|
|
503
|
+
const card = canvas.getByRole('group', { name: GROUP_NAME });
|
|
504
|
+
|
|
505
|
+
await step(
|
|
506
|
+
'zoom: the card does not overflow sideways at 200% (WCAG 1.4.4 Resize Text)',
|
|
507
|
+
async () => {
|
|
508
|
+
await expect(card.scrollWidth).toBeLessThanOrEqual(
|
|
509
|
+
card.clientWidth + 1,
|
|
510
|
+
);
|
|
511
|
+
},
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
await step('zoom: Allow stays visible and clickable', async () => {
|
|
515
|
+
const allow = canvas.getByRole('button', { name: 'Allow' });
|
|
516
|
+
await expect(allow).toBeVisible();
|
|
517
|
+
await userEvent.click(allow);
|
|
518
|
+
await expect(onAllow).toHaveBeenCalledWith(REQUEST_ID);
|
|
519
|
+
});
|
|
520
|
+
},
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* At a 320px phone width the card reflows without sideways scrolling and the
|
|
525
|
+
* buttons keep a usable touch size. Hidden from the sidebar; runs in CI.
|
|
526
|
+
*/
|
|
527
|
+
export const MobileBreakpoint: Story = {
|
|
528
|
+
name: 'test - Mobile Breakpoint',
|
|
529
|
+
tags: ['!dev', '!autodocs'],
|
|
530
|
+
globals: { viewport: 'mobile' },
|
|
531
|
+
decorators: [
|
|
532
|
+
() => ({ template: '<div style="width: 320px;"><story /></div>' }),
|
|
533
|
+
],
|
|
534
|
+
play: async ({ canvasElement, step }) => {
|
|
535
|
+
const canvas = within(canvasElement);
|
|
536
|
+
const card = canvas.getByRole('group', { name: GROUP_NAME });
|
|
537
|
+
|
|
538
|
+
await step(
|
|
539
|
+
'mobile: the card fits a 320px width (WCAG 1.4.10 Reflow)',
|
|
540
|
+
async () => {
|
|
541
|
+
const width = card.getBoundingClientRect().width;
|
|
542
|
+
await expect(width).toBeGreaterThan(0);
|
|
543
|
+
await expect(width).toBeLessThanOrEqual(320);
|
|
544
|
+
await expect(card.scrollWidth).toBeLessThanOrEqual(
|
|
545
|
+
card.clientWidth + 1,
|
|
546
|
+
);
|
|
547
|
+
},
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
await step(
|
|
551
|
+
'mobile: buttons are at least 24px tall (WCAG 2.5.8 Target Size)',
|
|
552
|
+
async () => {
|
|
553
|
+
for (const name of ['Deny', 'Allow']) {
|
|
554
|
+
const height = canvas
|
|
555
|
+
.getByRole('button', { name })
|
|
556
|
+
.getBoundingClientRect().height;
|
|
557
|
+
await expect(height).toBeGreaterThanOrEqual(24);
|
|
558
|
+
}
|
|
559
|
+
},
|
|
560
|
+
);
|
|
561
|
+
},
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* The host waits for its backend before passing the new status. Focus must
|
|
566
|
+
* still land on the result row, although the browser drops it from the
|
|
567
|
+
* disabled button meanwhile. Hidden from the sidebar; runs in CI.
|
|
568
|
+
*/
|
|
569
|
+
export const FocusAfterSlowDecision: Story = {
|
|
570
|
+
name: 'test - Focus After Slow Decision',
|
|
571
|
+
tags: ['!dev', '!autodocs'],
|
|
572
|
+
render: (args) => ({
|
|
573
|
+
components: { CkToolApproval },
|
|
574
|
+
setup() {
|
|
575
|
+
const status = ref<CkToolApprovalStatus>('pending');
|
|
576
|
+
function allowLater(): void {
|
|
577
|
+
setTimeout(() => {
|
|
578
|
+
status.value = 'allowed';
|
|
579
|
+
}, BACKEND_DELAY_MS);
|
|
580
|
+
}
|
|
581
|
+
return { args, status, allowLater };
|
|
582
|
+
},
|
|
583
|
+
template:
|
|
584
|
+
'<CkToolApproval :request="args.request" :status="status" @allow="allowLater" />',
|
|
585
|
+
}),
|
|
586
|
+
play: async ({ canvasElement, step }) => {
|
|
587
|
+
const canvas = within(canvasElement);
|
|
588
|
+
|
|
589
|
+
await step(
|
|
590
|
+
'keyboard: focus reaches the result row after a slow answer (WCAG 2.4.3 Focus Order)',
|
|
591
|
+
async () => {
|
|
592
|
+
canvas.getByRole('button', { name: 'Allow' }).focus();
|
|
593
|
+
await userEvent.keyboard('{Enter}');
|
|
594
|
+
const row = await canvas.findByText('Allowed');
|
|
595
|
+
await expect(
|
|
596
|
+
row.closest('[role="group"]') as HTMLElement,
|
|
597
|
+
).toHaveFocus();
|
|
598
|
+
},
|
|
599
|
+
);
|
|
600
|
+
},
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
const LONG_CONNECTOR_LABEL = 'Polarion ALM Requirements Engineering Workspace';
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* A long connector name at a 320px phone width: the chip shrinks and cuts its
|
|
607
|
+
* text instead of pushing the card wider or into the status badge, and screen
|
|
608
|
+
* readers still get the full name. Hidden from the sidebar; runs in CI.
|
|
609
|
+
*/
|
|
610
|
+
export const LongConnectorLabel: Story = {
|
|
611
|
+
name: 'test - Long Connector Label',
|
|
612
|
+
tags: ['!dev', '!autodocs'],
|
|
613
|
+
render: () => ({
|
|
614
|
+
components: { CkToolApproval },
|
|
615
|
+
setup() {
|
|
616
|
+
const request = {
|
|
617
|
+
...updateWorkItems,
|
|
618
|
+
connectorLabel: LONG_CONNECTOR_LABEL,
|
|
619
|
+
};
|
|
620
|
+
return { request };
|
|
621
|
+
},
|
|
622
|
+
template: `
|
|
623
|
+
<div style="width: 320px; display: flex; flex-direction: column; gap: 16px;">
|
|
624
|
+
<CkToolApproval :request="request" status="pending" />
|
|
625
|
+
<CkToolApproval :request="request" status="allowed" summary="2 work items updated" />
|
|
626
|
+
</div>`,
|
|
627
|
+
}),
|
|
628
|
+
play: async ({ canvasElement, step }) => {
|
|
629
|
+
const groups = within(canvasElement).getAllByRole('group', {
|
|
630
|
+
name: `Update work items ${LONG_CONNECTOR_LABEL}`,
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
await step(
|
|
634
|
+
'mobile: neither card grows wider than the screen (WCAG 1.4.10 Reflow)',
|
|
635
|
+
async () => {
|
|
636
|
+
await expect(groups).toHaveLength(2);
|
|
637
|
+
for (const group of groups) {
|
|
638
|
+
await expect(group.scrollWidth).toBeLessThanOrEqual(
|
|
639
|
+
group.clientWidth + 1,
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
},
|
|
643
|
+
);
|
|
644
|
+
|
|
645
|
+
await step(
|
|
646
|
+
'mobile: the chip stays clear of the status badge in the result row',
|
|
647
|
+
async () => {
|
|
648
|
+
const row = groups[1];
|
|
649
|
+
const chip = within(row)
|
|
650
|
+
.getByText(LONG_CONNECTOR_LABEL)
|
|
651
|
+
.closest('[data-slot="badge"]') as HTMLElement;
|
|
652
|
+
const status = within(row)
|
|
653
|
+
.getByText('Allowed')
|
|
654
|
+
.closest('[data-slot="badge"]') as HTMLElement;
|
|
655
|
+
await expect(chip.getBoundingClientRect().right).toBeLessThanOrEqual(
|
|
656
|
+
status.getBoundingClientRect().left,
|
|
657
|
+
);
|
|
658
|
+
},
|
|
659
|
+
);
|
|
660
|
+
},
|
|
661
|
+
};
|