@n8n/frontend-module-inbox 0.1.0 → 0.2.1
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/LICENSE_EE.md +27 -0
- package/README.md +5 -3
- package/package.json +22 -12
- package/src/components/InboxEmptyState.vue +1 -1
- package/src/components/InboxList.test.ts +76 -16
- package/src/components/InboxListItem.vue +81 -7
- package/src/components/InboxListSection.vue +8 -8
- package/src/inbox.constants.ts +4 -1
- package/src/inbox.module.ts +6 -1
- package/src/inbox.routes.test.ts +55 -26
- package/src/inbox.routes.ts +22 -3
- package/src/inbox.store.test.ts +123 -28
- package/src/inbox.store.ts +3 -3
- package/src/reviews/components/WorkflowReviewChangesList.test.ts +97 -0
- package/src/reviews/components/WorkflowReviewChangesList.vue +143 -0
- package/src/reviews/components/WorkflowReviewChangesSection.test.ts +13 -9
- package/src/reviews/components/WorkflowReviewChangesSection.vue +5 -7
- package/src/reviews/components/WorkflowReviewDetailMetadata.vue +5 -1
- package/src/reviews/components/WorkflowReviewDetailTabs.test.ts +69 -3
- package/src/reviews/components/WorkflowReviewDetailTabs.vue +29 -11
- package/src/self-healing/SelfHealingResultDetail.vue +19 -0
- package/src/views/InboxView.test.ts +53 -10
- package/src/views/InboxView.vue +10 -1
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
} from '@n8n/api-types';
|
|
6
6
|
import { createTestingPinia } from '@pinia/testing';
|
|
7
7
|
import { createComponentRenderer } from '@n8n/frontend-test-utils';
|
|
8
|
+
import userEvent from '@testing-library/user-event';
|
|
8
9
|
|
|
9
10
|
import WorkflowReviewDetailTabs from './WorkflowReviewDetailTabs.vue';
|
|
10
11
|
|
|
@@ -13,7 +14,7 @@ vi.mock('./WorkflowReviewChangesSection.vue', () => ({
|
|
|
13
14
|
name: 'WorkflowReviewChangesSection',
|
|
14
15
|
props: ['workflow', 'state', 'decision'],
|
|
15
16
|
template:
|
|
16
|
-
'<div data-test-id="workflow-review-changes-section" :data-state="state" :data-decision="decision" />',
|
|
17
|
+
'<div data-test-id="workflow-review-changes-section" :data-workflow-id="workflow.workflowId" :data-state="state" :data-decision="decision" />',
|
|
17
18
|
},
|
|
18
19
|
}));
|
|
19
20
|
|
|
@@ -293,7 +294,7 @@ describe('WorkflowReviewDetailTabs', () => {
|
|
|
293
294
|
});
|
|
294
295
|
|
|
295
296
|
describe('changes tab', () => {
|
|
296
|
-
it('renders one
|
|
297
|
+
it('renders one item per workflow and opens only the first', () => {
|
|
297
298
|
const { getAllByTestId } = renderComponent({
|
|
298
299
|
props: {
|
|
299
300
|
review: makeDetail({
|
|
@@ -307,7 +308,72 @@ describe('WorkflowReviewDetailTabs', () => {
|
|
|
307
308
|
},
|
|
308
309
|
});
|
|
309
310
|
|
|
310
|
-
expect(getAllByTestId('workflow-review-changes-
|
|
311
|
+
expect(getAllByTestId('workflow-review-changes-item')).toHaveLength(2);
|
|
312
|
+
expect(getAllByTestId('workflow-review-changes-section')).toHaveLength(1);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('keeps the open workflow when the user switches tabs', async () => {
|
|
316
|
+
const review = makeDetail({
|
|
317
|
+
workflows: [makeWorkflowDetail(), makeWorkflowDetail({ workflowId: 'wf-2' })],
|
|
318
|
+
});
|
|
319
|
+
const { getAllByTestId, getByTestId, rerender } = renderComponent({
|
|
320
|
+
props: { review, tab: 'changes', deciding: false },
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
await userEvent.click(getAllByTestId('workflow-review-changes-item-trigger')[1]);
|
|
324
|
+
await rerender({ review, tab: 'activity', deciding: false });
|
|
325
|
+
await rerender({ review, tab: 'changes', deciding: false });
|
|
326
|
+
|
|
327
|
+
expect(getByTestId('workflow-review-changes-section')).toHaveAttribute(
|
|
328
|
+
'data-workflow-id',
|
|
329
|
+
'wf-2',
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('opens the first workflow again when another review is selected', async () => {
|
|
334
|
+
const workflows = [makeWorkflowDetail(), makeWorkflowDetail({ workflowId: 'wf-2' })];
|
|
335
|
+
const { getAllByTestId, getByTestId, rerender } = renderComponent({
|
|
336
|
+
props: { review: makeDetail({ workflows }), tab: 'changes', deciding: false },
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
await userEvent.click(getAllByTestId('workflow-review-changes-item-trigger')[1]);
|
|
340
|
+
await rerender({
|
|
341
|
+
review: makeDetail({ id: 'req-2', workflows }),
|
|
342
|
+
tab: 'changes',
|
|
343
|
+
deciding: false,
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
expect(getByTestId('workflow-review-changes-section')).toHaveAttribute(
|
|
347
|
+
'data-workflow-id',
|
|
348
|
+
'wf-1',
|
|
349
|
+
);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// The detail lists only the workflows the viewer can read, so a refresh can drop one.
|
|
353
|
+
it('opens the first workflow when a refresh removes the open one', async () => {
|
|
354
|
+
const first = makeWorkflowDetail();
|
|
355
|
+
const third = makeWorkflowDetail({ workflowId: 'wf-3' });
|
|
356
|
+
const { getAllByTestId, findByTestId, rerender } = renderComponent({
|
|
357
|
+
props: {
|
|
358
|
+
review: makeDetail({
|
|
359
|
+
workflows: [first, makeWorkflowDetail({ workflowId: 'wf-2' }), third],
|
|
360
|
+
}),
|
|
361
|
+
tab: 'changes',
|
|
362
|
+
deciding: false,
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
await userEvent.click(getAllByTestId('workflow-review-changes-item-trigger')[1]);
|
|
367
|
+
await rerender({
|
|
368
|
+
review: makeDetail({ workflows: [first, third] }),
|
|
369
|
+
tab: 'changes',
|
|
370
|
+
deciding: false,
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
expect(await findByTestId('workflow-review-changes-section')).toHaveAttribute(
|
|
374
|
+
'data-workflow-id',
|
|
375
|
+
'wf-1',
|
|
376
|
+
);
|
|
311
377
|
});
|
|
312
378
|
|
|
313
379
|
// A closed review keeps its diff: the backend serves the baseline frozen at
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
import type { WorkflowReviewInboxItem, WorkflowReviewRequestDetail } from '@n8n/api-types';
|
|
3
3
|
import { N8nCallout, N8nTabs, N8nText } from '@n8n/design-system';
|
|
4
4
|
import { useI18n } from '@n8n/i18n';
|
|
5
|
-
import { computed, provide, useTemplateRef } from 'vue';
|
|
5
|
+
import { computed, provide, ref, useTemplateRef, watch } from 'vue';
|
|
6
6
|
|
|
7
7
|
import { ReviewDetailScrollContainerKey, ReviewLinkedWorkflowsKey } from '../constants';
|
|
8
8
|
import type { WorkflowReviewDecisionInput } from '../workflowReviews.api';
|
|
9
9
|
import WorkflowReviewActivityFeed from './WorkflowReviewActivityFeed.vue';
|
|
10
|
-
import
|
|
10
|
+
import WorkflowReviewChangesList from './WorkflowReviewChangesList.vue';
|
|
11
11
|
import WorkflowReviewCommentComposer from './WorkflowReviewCommentComposer.vue';
|
|
12
12
|
import WorkflowReviewDecisionPopover from './WorkflowReviewDecisionPopover.vue';
|
|
13
13
|
import WorkflowReviewDetailMetadata from './WorkflowReviewDetailMetadata.vue';
|
|
@@ -33,6 +33,26 @@ const detail = computed<WorkflowReviewRequestDetail | null>(() =>
|
|
|
33
33
|
'workflows' in props.review ? props.review : null,
|
|
34
34
|
);
|
|
35
35
|
|
|
36
|
+
// Held here, not in the list: the detail body remounts on every tab switch.
|
|
37
|
+
const expandedWorkflowId = ref<string>();
|
|
38
|
+
// The review that `expandedWorkflowId` belongs to.
|
|
39
|
+
let expandedReviewId: string | undefined;
|
|
40
|
+
|
|
41
|
+
// Open the first workflow for each new review. A refetch keeps the choice while the
|
|
42
|
+
// open workflow is still listed. The detail lists only readable workflows, so it can drop one.
|
|
43
|
+
watch(
|
|
44
|
+
detail,
|
|
45
|
+
(value) => {
|
|
46
|
+
if (!value?.workflows.length) return;
|
|
47
|
+
const open = expandedWorkflowId.value;
|
|
48
|
+
const isListed = value.workflows.some(({ workflowId }) => workflowId === open);
|
|
49
|
+
if (value.id === expandedReviewId && (open === undefined || isListed)) return;
|
|
50
|
+
expandedReviewId = value.id;
|
|
51
|
+
expandedWorkflowId.value = value.workflows[0].workflowId;
|
|
52
|
+
},
|
|
53
|
+
{ immediate: true },
|
|
54
|
+
);
|
|
55
|
+
|
|
36
56
|
const viewerCanDecide = computed(() => detail.value?.viewerCanDecide ?? false);
|
|
37
57
|
const viewerCanComment = computed(() => detail.value?.viewerCanComment ?? false);
|
|
38
58
|
|
|
@@ -190,15 +210,13 @@ const tabOptions = computed(() => [
|
|
|
190
210
|
>
|
|
191
211
|
{{ i18n.baseText('workflowReviews.changes.unavailable') }}
|
|
192
212
|
</N8nCallout>
|
|
193
|
-
<
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
/>
|
|
201
|
-
</template>
|
|
213
|
+
<WorkflowReviewChangesList
|
|
214
|
+
v-else-if="detail.workflows.length > 0"
|
|
215
|
+
v-model:expanded="expandedWorkflowId"
|
|
216
|
+
:workflows="detail.workflows"
|
|
217
|
+
:state="detail.state"
|
|
218
|
+
:decision="detail.decision"
|
|
219
|
+
/>
|
|
202
220
|
<!-- No rows left: the workflow was deleted, or the requester lost access to it. -->
|
|
203
221
|
<N8nCallout
|
|
204
222
|
v-else
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { N8nEmptyState } from '@n8n/design-system';
|
|
3
|
+
import { useI18n } from '@n8n/i18n';
|
|
4
|
+
|
|
5
|
+
import type { InboxSelection } from '../inbox.constants';
|
|
6
|
+
|
|
7
|
+
// AST-1518 replaces this rollout-gated integration point with the report and actions.
|
|
8
|
+
defineProps<{ selection: Extract<InboxSelection, { type: 'self_healing_result' }> }>();
|
|
9
|
+
const i18n = useI18n();
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<template>
|
|
13
|
+
<N8nEmptyState
|
|
14
|
+
:heading="i18n.baseText('inbox.resultUnavailable.title')"
|
|
15
|
+
:description="i18n.baseText('inbox.resultUnavailable.body')"
|
|
16
|
+
data-test-id="inbox-self-healing-detail"
|
|
17
|
+
:data-result-id="selection.id"
|
|
18
|
+
/>
|
|
19
|
+
</template>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
DecideWorkflowReviewRequestResponse,
|
|
3
|
+
InboxSelfHealingItem,
|
|
3
4
|
InboxWorkflowReviewItem,
|
|
4
5
|
WorkflowReviewRequestDetail,
|
|
5
6
|
} from '@n8n/api-types';
|
|
@@ -54,6 +55,7 @@ const renderOptions = {
|
|
|
54
55
|
<button data-test-id="retry-waiting" @click="$emit('retry', 'waiting')" />
|
|
55
56
|
<button data-test-id="select-review" @click="$emit('select', { type: 'workflow_review', id: 'req-1' })" />
|
|
56
57
|
<button data-test-id="select-other-review" @click="$emit('select', { type: 'workflow_review', id: 'req-2' })" />
|
|
58
|
+
<button data-test-id="select-result" @click="$emit('select', { type: 'self_healing_result', id: 'req-1', projectId: 'p1', workflowId: 'w1' })" />
|
|
57
59
|
<button data-test-id="clear-review" @click="$emit('clear')" />
|
|
58
60
|
<button data-test-id="select-closed-tab" @click="$emit('update:active-tab', 'closed')" />
|
|
59
61
|
</div>`,
|
|
@@ -79,6 +81,10 @@ const renderOptions = {
|
|
|
79
81
|
<slot v-if="!listItem" />
|
|
80
82
|
</div>`,
|
|
81
83
|
}),
|
|
84
|
+
SelfHealingResultDetail: {
|
|
85
|
+
props: ['selection'],
|
|
86
|
+
template: '<div data-test-id="result-detail" :data-id="selection.id" />',
|
|
87
|
+
},
|
|
82
88
|
},
|
|
83
89
|
},
|
|
84
90
|
};
|
|
@@ -93,7 +99,7 @@ beforeEach(async () => {
|
|
|
93
99
|
await router.isReady();
|
|
94
100
|
mockedStore(useSettingsStore).settings.inbox = {
|
|
95
101
|
enabled: true,
|
|
96
|
-
availableTypes: ['workflow_review'],
|
|
102
|
+
availableTypes: ['workflow_review', 'self_healing_result'],
|
|
97
103
|
failedTypes: [],
|
|
98
104
|
};
|
|
99
105
|
store = mockedStore(useInboxStore);
|
|
@@ -113,17 +119,18 @@ it('refreshes the list and summary on mount', async () => {
|
|
|
113
119
|
it('keeps the loaded list when navigating between Inbox routes', async () => {
|
|
114
120
|
const { getByTestId, queryByTestId } = renderRoutedComponent();
|
|
115
121
|
await waitAllPromises();
|
|
116
|
-
store.lists.waiting.items = [reviewItem(),
|
|
122
|
+
store.lists.waiting.items = [reviewItem(), resultItem()];
|
|
117
123
|
store.lists.waiting.nextCursor = 'next-page';
|
|
118
|
-
for (const button of ['select-review', 'select-
|
|
124
|
+
for (const button of ['select-review', 'select-result', 'clear-review']) {
|
|
119
125
|
getByTestId(button).click();
|
|
120
126
|
await waitAllPromises();
|
|
121
127
|
}
|
|
122
128
|
expect(router.currentRoute.value.fullPath).toBe('/inbox');
|
|
123
129
|
expect(queryByTestId('review-detail')).not.toBeInTheDocument();
|
|
130
|
+
expect(queryByTestId('result-detail')).not.toBeInTheDocument();
|
|
124
131
|
expect(store.reset).toHaveBeenCalledOnce();
|
|
125
132
|
expect(store.refreshListAndSummary).toHaveBeenCalledOnce();
|
|
126
|
-
expect(store.lists.waiting.items).toEqual([reviewItem(),
|
|
133
|
+
expect(store.lists.waiting.items).toEqual([reviewItem(), resultItem()]);
|
|
127
134
|
expect(store.lists.waiting.nextCursor).toBe('next-page');
|
|
128
135
|
});
|
|
129
136
|
|
|
@@ -135,8 +142,24 @@ it('passes the selected review and list fallback to its detail entry', async ()
|
|
|
135
142
|
expect(getByTestId('review-detail')).toHaveAttribute('data-title', 'List review');
|
|
136
143
|
});
|
|
137
144
|
|
|
138
|
-
it('
|
|
139
|
-
await router.replace('/inbox/reviews/req-1
|
|
145
|
+
it('selects a result with the same ID without rendering the review entry', async () => {
|
|
146
|
+
await router.replace('/inbox/reviews/req-1');
|
|
147
|
+
const { getByTestId, queryByTestId } = renderComponent();
|
|
148
|
+
getByTestId('select-result').click();
|
|
149
|
+
await waitAllPromises();
|
|
150
|
+
expect(router.currentRoute.value.query).toEqual({
|
|
151
|
+
projectId: 'p1',
|
|
152
|
+
workflowId: 'w1',
|
|
153
|
+
});
|
|
154
|
+
expect(router.currentRoute.value.path).toBe('/inbox/assistant-results/req-1');
|
|
155
|
+
expect(getByTestId('result-detail')).toHaveAttribute('data-id', 'req-1');
|
|
156
|
+
expect(queryByTestId('review-detail')).not.toBeInTheDocument();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('clears source context and keeps the current state and tab', async () => {
|
|
160
|
+
await router.replace(
|
|
161
|
+
'/inbox/assistant-results/r1?projectId=p1&workflowId=w1&state=closed&tab=changes',
|
|
162
|
+
);
|
|
140
163
|
const { getByTestId } = renderComponent();
|
|
141
164
|
getByTestId('clear-review').click();
|
|
142
165
|
await waitAllPromises();
|
|
@@ -237,7 +260,7 @@ it('reloads Closed without replacing the URL when a review closes there', async
|
|
|
237
260
|
expect(store.fetchActiveTab).toHaveBeenCalledOnce();
|
|
238
261
|
});
|
|
239
262
|
|
|
240
|
-
it.each(['select-other-review'])(
|
|
263
|
+
it.each(['select-other-review', 'select-result'])(
|
|
241
264
|
'reconciles a late decision without following it after %s',
|
|
242
265
|
async (target) => {
|
|
243
266
|
await router.replace('/inbox/reviews/req-1');
|
|
@@ -280,6 +303,7 @@ it.each([
|
|
|
280
303
|
['/inbox/reviews/req-1', false],
|
|
281
304
|
['/inbox/reviews/req-1', true],
|
|
282
305
|
['/inbox/reviews/req-2', false],
|
|
306
|
+
['/inbox/assistant-results/result-1?projectId=p1&workflowId=w1', false],
|
|
283
307
|
['/other', false],
|
|
284
308
|
])(
|
|
285
309
|
'reconciles approval after leaving the page for %s (pending detail: %s)',
|
|
@@ -399,6 +423,22 @@ function reviewItem(): InboxWorkflowReviewItem {
|
|
|
399
423
|
updatedAt: '2024-01-01T00:00:00.000Z',
|
|
400
424
|
};
|
|
401
425
|
}
|
|
426
|
+
function resultItem(): InboxSelfHealingItem {
|
|
427
|
+
return {
|
|
428
|
+
type: 'self_healing_result',
|
|
429
|
+
id: 'req-1',
|
|
430
|
+
projectId: 'p1',
|
|
431
|
+
workflowId: 'w1',
|
|
432
|
+
workflowName: 'Workflow',
|
|
433
|
+
state: 'open',
|
|
434
|
+
outcome: 'fix_ready',
|
|
435
|
+
summary: 'Fix ready',
|
|
436
|
+
createdAt: '2024-01-01T00:00:00.000Z',
|
|
437
|
+
updatedAt: '2024-01-01T00:00:00.000Z',
|
|
438
|
+
completedAt: '2024-01-01T00:00:00.000Z',
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
402
442
|
it('routes group pagination and retry to the named slice', async () => {
|
|
403
443
|
const loadAuthored = vi.spyOn(store.lists.authored, 'loadMore').mockResolvedValue(undefined);
|
|
404
444
|
const retryWaiting = vi.spyOn(store.lists.waiting, 'retry').mockResolvedValue(undefined);
|
|
@@ -474,7 +514,7 @@ it('restarts a partial group after a page failure while retaining exact page ret
|
|
|
474
514
|
const request = vi
|
|
475
515
|
.fn()
|
|
476
516
|
.mockResolvedValueOnce({
|
|
477
|
-
data: [
|
|
517
|
+
data: [resultItem()],
|
|
478
518
|
nextCursor: 'partial-next',
|
|
479
519
|
hasMore: true,
|
|
480
520
|
partial: true,
|
|
@@ -484,7 +524,7 @@ it('restarts a partial group after a page failure while retaining exact page ret
|
|
|
484
524
|
.mockRejectedValueOnce(new Error('Page failed'))
|
|
485
525
|
.mockRejectedValueOnce(new Error('Page still failed'))
|
|
486
526
|
.mockResolvedValueOnce({
|
|
487
|
-
data: [reviewItem(),
|
|
527
|
+
data: [reviewItem(), resultItem()],
|
|
488
528
|
nextCursor: null,
|
|
489
529
|
hasMore: false,
|
|
490
530
|
partial: false,
|
|
@@ -503,7 +543,10 @@ it('restarts a partial group after a page failure while retaining exact page ret
|
|
|
503
543
|
getByTestId('refresh-waiting').click();
|
|
504
544
|
await waitAllPromises();
|
|
505
545
|
expect(request).toHaveBeenLastCalledWith();
|
|
506
|
-
expect(waiting.items.map((item) => item.type)).toEqual([
|
|
546
|
+
expect(waiting.items.map((item) => item.type)).toEqual([
|
|
547
|
+
'workflow_review',
|
|
548
|
+
'self_healing_result',
|
|
549
|
+
]);
|
|
507
550
|
expect(waiting.partial).toBe(false);
|
|
508
551
|
expect(waiting.error).toBeNull();
|
|
509
552
|
expect(waiting.nextCursor).toBeNull();
|
package/src/views/InboxView.vue
CHANGED
|
@@ -15,6 +15,7 @@ import { INBOX_VIEW, type InboxItemChange } from '../inbox.constants';
|
|
|
15
15
|
import { inboxItemLocation, isInboxRoute, selectionFromRoute } from '../inbox.routes';
|
|
16
16
|
import { useInboxStore } from '../inbox.store';
|
|
17
17
|
import WorkflowReviewDetail from '../reviews/WorkflowReviewDetail.vue';
|
|
18
|
+
import SelfHealingResultDetail from '../self-healing/SelfHealingResultDetail.vue';
|
|
18
19
|
|
|
19
20
|
const store = useInboxStore();
|
|
20
21
|
const settingsStore = useSettingsStore();
|
|
@@ -92,6 +93,7 @@ function onSelect(item: InboxItem) {
|
|
|
92
93
|
function onClearSelection() {
|
|
93
94
|
if (!isOnInbox()) return;
|
|
94
95
|
const query = { ...route.query };
|
|
96
|
+
for (const key of ['projectId', 'workflowId']) delete query[key];
|
|
95
97
|
void router.replace({ name: INBOX_VIEW, query });
|
|
96
98
|
}
|
|
97
99
|
function onActiveTabChange(tab: 'open' | 'closed') {
|
|
@@ -186,7 +188,14 @@ onBeforeUnmount(() => {
|
|
|
186
188
|
<template v-else>
|
|
187
189
|
<div :class="$style.columnTitle" />
|
|
188
190
|
<div :class="$style.mainBody">
|
|
189
|
-
<
|
|
191
|
+
<SelfHealingResultDetail
|
|
192
|
+
v-if="
|
|
193
|
+
selection?.type === 'self_healing_result' &&
|
|
194
|
+
!store.disabledSources.includes(selection.type)
|
|
195
|
+
"
|
|
196
|
+
:selection="selection"
|
|
197
|
+
/>
|
|
198
|
+
<InboxEmptyState v-else />
|
|
190
199
|
</div>
|
|
191
200
|
</template>
|
|
192
201
|
</div>
|