github-issue-tower-defence-management 1.160.0 → 1.162.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/CHANGELOG.md +19 -0
- package/README.md +1 -1
- package/bin/adapter/entry-points/console/consoleOperationApi.js +22 -3
- package/bin/adapter/entry-points/console/consoleOperationApi.js.map +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/{index-DT98Xk6Y.js → index-CHUtr_4X.js} +6 -6
- package/bin/adapter/entry-points/console/ui-dist/assets/{index-BbUBeonj.css → index-U8I8ESZj.css} +1 -1
- package/bin/adapter/entry-points/console/ui-dist/index.html +2 -2
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +41 -0
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
- package/bin/domain/entities/WorkflowStatus.js +5 -1
- package/bin/domain/entities/WorkflowStatus.js.map +1 -1
- package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js +3 -2
- package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/console/consoleOperationApi.test.ts +136 -8
- package/src/adapter/entry-points/console/consoleOperationApi.ts +25 -3
- package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +13 -1
- package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMermaidDiagram.test.tsx +37 -1
- package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMermaidDiagram.tsx +20 -1
- package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMermaidDiagramModalScreen.stories.tsx +27 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMermaidDiagramModalScreen.test.tsx +85 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMermaidDiagramModalScreen.tsx +97 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleOperationMenu.test.tsx +4 -4
- package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsolePullRequestReviewActions.test.tsx +4 -4
- package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsolePullRequestReviewActions.tsx +1 -1
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.test.ts +3 -3
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.ts +2 -2
- package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.test.ts +2 -2
- package/src/adapter/entry-points/console/ui/src/features/console/lib/mermaidZoom.test.ts +88 -0
- package/src/adapter/entry-points/console/ui/src/features/console/lib/mermaidZoom.ts +34 -0
- package/src/adapter/entry-points/console/ui/src/features/console/logic/actionToast.test.ts +17 -10
- package/src/adapter/entry-points/console/ui/src/features/console/logic/actionToast.ts +3 -3
- package/src/adapter/entry-points/console/ui/src/features/console/logic/operations.ts +1 -1
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.test.tsx +7 -7
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +17 -17
- package/src/adapter/entry-points/console/ui/src/index.css +65 -0
- package/src/adapter/entry-points/console/ui-dist/assets/{index-DT98Xk6Y.js → index-CHUtr_4X.js} +6 -6
- package/src/adapter/entry-points/console/ui-dist/assets/{index-BbUBeonj.css → index-U8I8ESZj.css} +1 -1
- package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
- package/src/adapter/entry-points/console/webServer.test.ts +66 -5
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +57 -0
- package/src/domain/entities/WorkflowStatus.ts +5 -0
- package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +2 -0
- package/src/domain/usecases/console/GenerateConsoleListsUseCase.test.ts +31 -0
- package/src/domain/usecases/console/GenerateConsoleListsUseCase.ts +8 -2
- package/types/adapter/entry-points/console/consoleOperationApi.d.ts.map +1 -1
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +2 -0
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
- package/types/domain/entities/WorkflowStatus.d.ts +1 -0
- package/types/domain/entities/WorkflowStatus.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +2 -0
- package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
- package/types/domain/usecases/console/GenerateConsoleListsUseCase.d.ts +1 -0
- package/types/domain/usecases/console/GenerateConsoleListsUseCase.d.ts.map +1 -1
|
@@ -38,6 +38,35 @@ describe('consoleOperationApi', () => {
|
|
|
38
38
|
baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'console-op-'));
|
|
39
39
|
issueRepository = mock<IssueRepository>();
|
|
40
40
|
issueRepository.get.mockResolvedValue(issue);
|
|
41
|
+
issueRepository.getOpenPullRequest.mockResolvedValue({
|
|
42
|
+
url: 'https://github.com/o/r/pull/1',
|
|
43
|
+
branchName: null,
|
|
44
|
+
createdAt: new Date(0),
|
|
45
|
+
isDraft: false,
|
|
46
|
+
isConflicted: false,
|
|
47
|
+
mergeable: 'MERGEABLE',
|
|
48
|
+
isPassedAllCiJob: true,
|
|
49
|
+
isCiStateSuccess: true,
|
|
50
|
+
isResolvedAllReviewComments: true,
|
|
51
|
+
isBranchOutOfDate: false,
|
|
52
|
+
missingRequiredCheckNames: [],
|
|
53
|
+
});
|
|
54
|
+
issueRepository.getPullRequestDetail.mockResolvedValue({
|
|
55
|
+
title: 'Test PR',
|
|
56
|
+
state: 'open',
|
|
57
|
+
merged: false,
|
|
58
|
+
isDraft: false,
|
|
59
|
+
additions: 0,
|
|
60
|
+
deletions: 0,
|
|
61
|
+
changedFiles: 0,
|
|
62
|
+
headRefName: 'feature',
|
|
63
|
+
baseRefName: 'main',
|
|
64
|
+
author: 'other-user',
|
|
65
|
+
files: [],
|
|
66
|
+
});
|
|
67
|
+
issueRepository.getAuthenticatedUserLogin.mockResolvedValue(
|
|
68
|
+
'authenticated-user',
|
|
69
|
+
);
|
|
41
70
|
project = {
|
|
42
71
|
...mock<Project>(),
|
|
43
72
|
id: 'PVT_1',
|
|
@@ -124,10 +153,10 @@ describe('consoleOperationApi', () => {
|
|
|
124
153
|
};
|
|
125
154
|
|
|
126
155
|
describe('handleReview', () => {
|
|
127
|
-
it('approves and sets Awaiting workspace then records done', async () => {
|
|
156
|
+
it('approves and merges, sets Awaiting workspace then records done', async () => {
|
|
128
157
|
const response = await handleReview(context, {
|
|
129
158
|
pjcode: 'acme',
|
|
130
|
-
action: '
|
|
159
|
+
action: 'approve_and_merge',
|
|
131
160
|
prUrl: 'https://github.com/o/r/pull/1',
|
|
132
161
|
projectItemId: 'PVTI_a',
|
|
133
162
|
});
|
|
@@ -135,6 +164,9 @@ describe('consoleOperationApi', () => {
|
|
|
135
164
|
expect(issueRepository.approvePullRequest).toHaveBeenCalledWith(
|
|
136
165
|
'https://github.com/o/r/pull/1',
|
|
137
166
|
);
|
|
167
|
+
expect(issueRepository.mergePullRequest).toHaveBeenCalledWith(
|
|
168
|
+
'https://github.com/o/r/pull/1',
|
|
169
|
+
);
|
|
138
170
|
expect(issueRepository.updateStatus).toHaveBeenCalledWith(
|
|
139
171
|
project,
|
|
140
172
|
expect.objectContaining({
|
|
@@ -159,7 +191,7 @@ describe('consoleOperationApi', () => {
|
|
|
159
191
|
|
|
160
192
|
await handleReview(contextRecordingResolvedUrls, {
|
|
161
193
|
pjcode: 'acme',
|
|
162
|
-
action: '
|
|
194
|
+
action: 'approve_and_merge',
|
|
163
195
|
prUrl: 'https://github.com/acme-labs/acme-portal-mock/pull/178',
|
|
164
196
|
projectItemId: 'PVTI_resolver',
|
|
165
197
|
});
|
|
@@ -374,7 +406,7 @@ describe('consoleOperationApi', () => {
|
|
|
374
406
|
it('rejects a missing prUrl', async () => {
|
|
375
407
|
const response = await handleReview(context, {
|
|
376
408
|
pjcode: 'acme',
|
|
377
|
-
action: '
|
|
409
|
+
action: 'approve_and_merge',
|
|
378
410
|
projectItemId: 'PVTI_c',
|
|
379
411
|
});
|
|
380
412
|
expect(response.statusCode).toBe(400);
|
|
@@ -392,17 +424,17 @@ describe('consoleOperationApi', () => {
|
|
|
392
424
|
it('rejects a missing projectItemId', async () => {
|
|
393
425
|
const response = await handleReview(context, {
|
|
394
426
|
pjcode: 'acme',
|
|
395
|
-
action: '
|
|
427
|
+
action: 'approve_and_merge',
|
|
396
428
|
prUrl: 'https://github.com/o/r/pull/1',
|
|
397
429
|
});
|
|
398
430
|
expect(response.statusCode).toBe(400);
|
|
399
431
|
});
|
|
400
432
|
|
|
401
|
-
it('
|
|
433
|
+
it('merges without approving using the request project item id without a GraphQL item fetch', async () => {
|
|
402
434
|
issueRepository.get.mockResolvedValue(null);
|
|
403
435
|
const response = await handleReview(context, {
|
|
404
436
|
pjcode: 'acme',
|
|
405
|
-
action: '
|
|
437
|
+
action: 'approve_and_merge',
|
|
406
438
|
prUrl: 'https://github.com/o/r/pull/1',
|
|
407
439
|
projectItemId: 'PVTI_c',
|
|
408
440
|
});
|
|
@@ -422,12 +454,108 @@ describe('consoleOperationApi', () => {
|
|
|
422
454
|
});
|
|
423
455
|
const response = await handleReview(contextWithoutStatus, {
|
|
424
456
|
pjcode: 'acme',
|
|
425
|
-
action: '
|
|
457
|
+
action: 'approve_and_merge',
|
|
426
458
|
prUrl: 'https://github.com/o/r/pull/1',
|
|
427
459
|
projectItemId: 'PVTI_c',
|
|
428
460
|
});
|
|
429
461
|
expect(response.statusCode).toBe(400);
|
|
430
462
|
});
|
|
463
|
+
|
|
464
|
+
it('skips approval and merges directly when the pull request author matches the authenticated account', async () => {
|
|
465
|
+
issueRepository.getPullRequestDetail.mockResolvedValue({
|
|
466
|
+
title: 'Self PR',
|
|
467
|
+
state: 'open',
|
|
468
|
+
merged: false,
|
|
469
|
+
isDraft: false,
|
|
470
|
+
additions: 0,
|
|
471
|
+
deletions: 0,
|
|
472
|
+
changedFiles: 0,
|
|
473
|
+
headRefName: 'feature',
|
|
474
|
+
baseRefName: 'main',
|
|
475
|
+
author: 'authenticated-user',
|
|
476
|
+
files: [],
|
|
477
|
+
});
|
|
478
|
+
const response = await handleReview(context, {
|
|
479
|
+
pjcode: 'acme',
|
|
480
|
+
action: 'approve_and_merge',
|
|
481
|
+
prUrl: 'https://github.com/o/r/pull/1',
|
|
482
|
+
projectItemId: 'PVTI_self',
|
|
483
|
+
});
|
|
484
|
+
expect(response.statusCode).toBe(200);
|
|
485
|
+
expect(issueRepository.approvePullRequest).not.toHaveBeenCalled();
|
|
486
|
+
expect(issueRepository.mergePullRequest).toHaveBeenCalledWith(
|
|
487
|
+
'https://github.com/o/r/pull/1',
|
|
488
|
+
);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it('returns 400 when the pull request has a merge conflict', async () => {
|
|
492
|
+
issueRepository.getOpenPullRequest.mockResolvedValue({
|
|
493
|
+
url: 'https://github.com/o/r/pull/1',
|
|
494
|
+
branchName: null,
|
|
495
|
+
createdAt: new Date(0),
|
|
496
|
+
isDraft: false,
|
|
497
|
+
isConflicted: true,
|
|
498
|
+
mergeable: 'CONFLICTING',
|
|
499
|
+
isPassedAllCiJob: true,
|
|
500
|
+
isCiStateSuccess: true,
|
|
501
|
+
isResolvedAllReviewComments: true,
|
|
502
|
+
isBranchOutOfDate: false,
|
|
503
|
+
missingRequiredCheckNames: [],
|
|
504
|
+
});
|
|
505
|
+
const response = await handleReview(context, {
|
|
506
|
+
pjcode: 'acme',
|
|
507
|
+
action: 'approve_and_merge',
|
|
508
|
+
prUrl: 'https://github.com/o/r/pull/1',
|
|
509
|
+
projectItemId: 'PVTI_conflict',
|
|
510
|
+
});
|
|
511
|
+
expect(response.statusCode).toBe(400);
|
|
512
|
+
expect(response.body).toMatchObject({
|
|
513
|
+
error: 'Cannot merge: pull request has a merge conflict',
|
|
514
|
+
});
|
|
515
|
+
expect(issueRepository.mergePullRequest).not.toHaveBeenCalled();
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('returns 400 with check names when required checks are not green', async () => {
|
|
519
|
+
issueRepository.getOpenPullRequest.mockResolvedValue({
|
|
520
|
+
url: 'https://github.com/o/r/pull/1',
|
|
521
|
+
branchName: null,
|
|
522
|
+
createdAt: new Date(0),
|
|
523
|
+
isDraft: false,
|
|
524
|
+
isConflicted: false,
|
|
525
|
+
mergeable: 'MERGEABLE',
|
|
526
|
+
isPassedAllCiJob: false,
|
|
527
|
+
isCiStateSuccess: false,
|
|
528
|
+
isResolvedAllReviewComments: true,
|
|
529
|
+
isBranchOutOfDate: false,
|
|
530
|
+
missingRequiredCheckNames: ['test', 'lint'],
|
|
531
|
+
});
|
|
532
|
+
const response = await handleReview(context, {
|
|
533
|
+
pjcode: 'acme',
|
|
534
|
+
action: 'approve_and_merge',
|
|
535
|
+
prUrl: 'https://github.com/o/r/pull/1',
|
|
536
|
+
projectItemId: 'PVTI_ci',
|
|
537
|
+
});
|
|
538
|
+
expect(response.statusCode).toBe(400);
|
|
539
|
+
expect(response.body).toMatchObject({
|
|
540
|
+
error: 'Cannot merge: required checks are not green: test, lint',
|
|
541
|
+
});
|
|
542
|
+
expect(issueRepository.mergePullRequest).not.toHaveBeenCalled();
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it('returns 400 when the pull request is not found or already closed', async () => {
|
|
546
|
+
issueRepository.getOpenPullRequest.mockResolvedValue(null);
|
|
547
|
+
const response = await handleReview(context, {
|
|
548
|
+
pjcode: 'acme',
|
|
549
|
+
action: 'approve_and_merge',
|
|
550
|
+
prUrl: 'https://github.com/o/r/pull/1',
|
|
551
|
+
projectItemId: 'PVTI_gone',
|
|
552
|
+
});
|
|
553
|
+
expect(response.statusCode).toBe(400);
|
|
554
|
+
expect(response.body).toMatchObject({
|
|
555
|
+
error: 'Cannot merge: pull request not found or already closed',
|
|
556
|
+
});
|
|
557
|
+
expect(issueRepository.mergePullRequest).not.toHaveBeenCalled();
|
|
558
|
+
});
|
|
431
559
|
});
|
|
432
560
|
|
|
433
561
|
describe('handleTriage', () => {
|
|
@@ -323,10 +323,32 @@ export const handleReview = async (
|
|
|
323
323
|
return ok();
|
|
324
324
|
}
|
|
325
325
|
|
|
326
|
-
if (action === '
|
|
327
|
-
|
|
326
|
+
if (action === 'approve_and_merge') {
|
|
327
|
+
const issueRepository = context.resolveIssueRepository(prUrl);
|
|
328
|
+
const prStatus = await issueRepository.getOpenPullRequest(prUrl);
|
|
329
|
+
if (prStatus === null) {
|
|
330
|
+
return badRequest(
|
|
331
|
+
'Cannot merge: pull request not found or already closed',
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
if (prStatus.isConflicted) {
|
|
335
|
+
return badRequest('Cannot merge: pull request has a merge conflict');
|
|
336
|
+
}
|
|
337
|
+
if (!prStatus.isPassedAllCiJob) {
|
|
338
|
+
const missing = prStatus.missingRequiredCheckNames;
|
|
339
|
+
const detail = missing.length > 0 ? `: ${missing.join(', ')}` : '';
|
|
340
|
+
return badRequest(`Cannot merge: required checks are not green${detail}`);
|
|
341
|
+
}
|
|
342
|
+
const prDetail = await issueRepository.getPullRequestDetail(prUrl);
|
|
343
|
+
const authenticatedUser = await issueRepository.getAuthenticatedUserLogin();
|
|
344
|
+
const isSelfAuthored =
|
|
345
|
+
prDetail !== null && prDetail.author === authenticatedUser;
|
|
346
|
+
if (!isSelfAuthored) {
|
|
347
|
+
await issueRepository.approvePullRequest(prUrl);
|
|
348
|
+
}
|
|
349
|
+
await issueRepository.mergePullRequest(prUrl);
|
|
328
350
|
const failure = await updateStatusByName(
|
|
329
|
-
|
|
351
|
+
issueRepository,
|
|
330
352
|
project,
|
|
331
353
|
prUrl,
|
|
332
354
|
projectItemId,
|
|
@@ -379,6 +379,16 @@ const awaitingQualityCheckPullRequest: RelatedPullRequest = {
|
|
|
379
379
|
missingRequiredCheckNames: ['build', 'test'],
|
|
380
380
|
};
|
|
381
381
|
|
|
382
|
+
const awaitingQualityCheckPullRequestMergeReady: RelatedPullRequest = {
|
|
383
|
+
...awaitingQualityCheckPullRequest,
|
|
384
|
+
isConflicted: false,
|
|
385
|
+
mergeable: 'MERGEABLE',
|
|
386
|
+
isPassedAllCiJob: true,
|
|
387
|
+
isCiStateSuccess: true,
|
|
388
|
+
isBranchOutOfDate: false,
|
|
389
|
+
missingRequiredCheckNames: [],
|
|
390
|
+
};
|
|
391
|
+
|
|
382
392
|
const inlineCommentPullRequestDetail: PullRequestDetail = {
|
|
383
393
|
title: 'Add inline review comments on the related pull request diff',
|
|
384
394
|
state: 'open',
|
|
@@ -428,7 +438,7 @@ const createStubIssueRepository = (
|
|
|
428
438
|
url: string,
|
|
429
439
|
): Promise<RelatedPullRequest | null> =>
|
|
430
440
|
url === CONSOLE_E2E_AWAITING_QUALITY_CHECK_PR_URL
|
|
431
|
-
?
|
|
441
|
+
? awaitingQualityCheckPullRequestMergeReady
|
|
432
442
|
: null,
|
|
433
443
|
getOpenPullRequestCiStatus: async (
|
|
434
444
|
url: string,
|
|
@@ -457,7 +467,9 @@ const createStubIssueRepository = (
|
|
|
457
467
|
]),
|
|
458
468
|
),
|
|
459
469
|
getPullRequestChangedFilePaths: async (): Promise<string[]> => [],
|
|
470
|
+
getAuthenticatedUserLogin: async (): Promise<string> => 'test-user',
|
|
460
471
|
approvePullRequest: async (): Promise<void> => undefined,
|
|
472
|
+
mergePullRequest: async (): Promise<void> => undefined,
|
|
461
473
|
requestChangesWithInlineComment: async (
|
|
462
474
|
prUrl: string,
|
|
463
475
|
changedFilePath: string | null,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { render, waitFor } from '@testing-library/react';
|
|
1
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
2
2
|
import { renderMermaidToSvg } from '../../lib/mermaidLoader';
|
|
3
3
|
import { ConsoleMermaidDiagram } from './ConsoleMermaidDiagram';
|
|
4
4
|
|
|
@@ -35,4 +35,40 @@ describe('ConsoleMermaidDiagram', () => {
|
|
|
35
35
|
});
|
|
36
36
|
expect(getByText('bad diagram')).toBeInTheDocument();
|
|
37
37
|
});
|
|
38
|
+
|
|
39
|
+
it('opens the enlarged view carrying the same diagram', async () => {
|
|
40
|
+
mockedRender.mockResolvedValue('<svg id="ok"></svg>');
|
|
41
|
+
const { container } = render(
|
|
42
|
+
<ConsoleMermaidDiagram code="graph TD; A-->B;" />,
|
|
43
|
+
);
|
|
44
|
+
await waitFor(() => {
|
|
45
|
+
expect(container.querySelector('svg#ok')).not.toBeNull();
|
|
46
|
+
});
|
|
47
|
+
expect(screen.queryByRole('dialog')).toBeNull();
|
|
48
|
+
fireEvent.click(screen.getByText('Enlarge diagram'));
|
|
49
|
+
const dialog = screen.getByRole('dialog');
|
|
50
|
+
expect(dialog.querySelector('svg#ok')).not.toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('closes the enlarged view again', async () => {
|
|
54
|
+
mockedRender.mockResolvedValue('<svg id="ok"></svg>');
|
|
55
|
+
const { container } = render(
|
|
56
|
+
<ConsoleMermaidDiagram code="graph TD; A-->B;" />,
|
|
57
|
+
);
|
|
58
|
+
await waitFor(() => {
|
|
59
|
+
expect(container.querySelector('svg#ok')).not.toBeNull();
|
|
60
|
+
});
|
|
61
|
+
fireEvent.click(screen.getByText('Enlarge diagram'));
|
|
62
|
+
fireEvent.click(screen.getByLabelText('Close enlarged diagram'));
|
|
63
|
+
expect(screen.queryByRole('dialog')).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('offers no enlarged view while the diagram failed to render', async () => {
|
|
67
|
+
mockedRender.mockRejectedValue(new Error('parse error'));
|
|
68
|
+
render(<ConsoleMermaidDiagram code="bad diagram" />);
|
|
69
|
+
await waitFor(() => {
|
|
70
|
+
expect(screen.getByText(/Mermaid render error/)).toBeInTheDocument();
|
|
71
|
+
});
|
|
72
|
+
expect(screen.queryByText('Enlarge diagram')).toBeNull();
|
|
73
|
+
});
|
|
38
74
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { renderMermaidToSvg } from '../../lib/mermaidLoader';
|
|
3
|
+
import { ConsoleMermaidDiagramModalScreen } from './ConsoleMermaidDiagramModalScreen';
|
|
3
4
|
|
|
4
5
|
export type ConsoleMermaidDiagramProps = {
|
|
5
6
|
code: string;
|
|
@@ -12,6 +13,7 @@ type MermaidRenderState =
|
|
|
12
13
|
|
|
13
14
|
export const ConsoleMermaidDiagram = ({ code }: ConsoleMermaidDiagramProps) => {
|
|
14
15
|
const [state, setState] = useState<MermaidRenderState>({ status: 'loading' });
|
|
16
|
+
const [isEnlarged, setIsEnlarged] = useState<boolean>(false);
|
|
15
17
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
16
18
|
|
|
17
19
|
useEffect(() => {
|
|
@@ -61,5 +63,22 @@ export const ConsoleMermaidDiagram = ({ code }: ConsoleMermaidDiagramProps) => {
|
|
|
61
63
|
);
|
|
62
64
|
}
|
|
63
65
|
|
|
64
|
-
return
|
|
66
|
+
return (
|
|
67
|
+
<div className="console-mermaid">
|
|
68
|
+
<div ref={containerRef} className="console-mermaid-rendered" />
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
className="console-mermaid-enlarge"
|
|
72
|
+
onClick={() => setIsEnlarged(true)}
|
|
73
|
+
>
|
|
74
|
+
Enlarge diagram
|
|
75
|
+
</button>
|
|
76
|
+
{isEnlarged && (
|
|
77
|
+
<ConsoleMermaidDiagramModalScreen
|
|
78
|
+
svg={state.svg}
|
|
79
|
+
onClose={() => setIsEnlarged(false)}
|
|
80
|
+
/>
|
|
81
|
+
)}
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
65
84
|
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { ConsoleMermaidDiagramModalScreen } from './ConsoleMermaidDiagramModalScreen';
|
|
3
|
+
|
|
4
|
+
const wideDiagramSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 320" width="100%" style="max-width: 1600px;">
|
|
5
|
+
<rect x="0" y="0" width="1600" height="320" fill="#0d1117" />
|
|
6
|
+
<rect x="40" y="60" width="320" height="90" fill="#161b22" stroke="#30363d" />
|
|
7
|
+
<text x="60" y="110" fill="#e6edf3" font-size="20">Deal</text>
|
|
8
|
+
<rect x="640" y="60" width="320" height="90" fill="#161b22" stroke="#30363d" />
|
|
9
|
+
<text x="660" y="110" fill="#e6edf3" font-size="20">InformationMemorandum</text>
|
|
10
|
+
<rect x="1240" y="60" width="320" height="90" fill="#161b22" stroke="#30363d" />
|
|
11
|
+
<text x="1260" y="110" fill="#e6edf3" font-size="20">RequirementCoverage</text>
|
|
12
|
+
<line x1="360" y1="105" x2="640" y2="105" stroke="#8b949e" stroke-width="2" />
|
|
13
|
+
<line x1="960" y1="105" x2="1240" y2="105" stroke="#8b949e" stroke-width="2" />
|
|
14
|
+
</svg>`;
|
|
15
|
+
|
|
16
|
+
const meta: Meta<typeof ConsoleMermaidDiagramModalScreen> = {
|
|
17
|
+
title: 'Console/ConsoleMermaidDiagramModalScreen',
|
|
18
|
+
component: ConsoleMermaidDiagramModalScreen,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export default meta;
|
|
22
|
+
|
|
23
|
+
type Story = StoryObj<typeof ConsoleMermaidDiagramModalScreen>;
|
|
24
|
+
|
|
25
|
+
export const WideDiagram: Story = {
|
|
26
|
+
args: { svg: wideDiagramSvg, onClose: () => {} },
|
|
27
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import { MERMAID_ZOOM_MAX_SCALE } from '../../lib/mermaidZoom';
|
|
3
|
+
import { ConsoleMermaidDiagramModalScreen } from './ConsoleMermaidDiagramModalScreen';
|
|
4
|
+
|
|
5
|
+
const svgFixture = '<svg id="enlarged"></svg>';
|
|
6
|
+
|
|
7
|
+
describe('ConsoleMermaidDiagramModalScreen', () => {
|
|
8
|
+
it('shows the diagram at the minimum scale first', () => {
|
|
9
|
+
const { container } = render(
|
|
10
|
+
<ConsoleMermaidDiagramModalScreen svg={svgFixture} onClose={() => {}} />,
|
|
11
|
+
);
|
|
12
|
+
expect(container.querySelector('svg#enlarged')).not.toBeNull();
|
|
13
|
+
expect(screen.getByText('100%')).toBeInTheDocument();
|
|
14
|
+
const canvas = container.querySelector<HTMLElement>(
|
|
15
|
+
'.console-mermaid-modal-canvas',
|
|
16
|
+
);
|
|
17
|
+
expect(canvas?.style.width).toBe('100%');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('widens the canvas when zooming in', () => {
|
|
21
|
+
const { container } = render(
|
|
22
|
+
<ConsoleMermaidDiagramModalScreen svg={svgFixture} onClose={() => {}} />,
|
|
23
|
+
);
|
|
24
|
+
fireEvent.click(screen.getByLabelText('Zoom in'));
|
|
25
|
+
expect(screen.getByText('150%')).toBeInTheDocument();
|
|
26
|
+
const canvas = container.querySelector<HTMLElement>(
|
|
27
|
+
'.console-mermaid-modal-canvas',
|
|
28
|
+
);
|
|
29
|
+
expect(canvas?.style.width).toBe('150%');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('lets the diagram outgrow the width mermaid fixed on it', () => {
|
|
33
|
+
const { container } = render(
|
|
34
|
+
<ConsoleMermaidDiagramModalScreen
|
|
35
|
+
svg='<svg id="enlarged" style="max-width: 320px;"></svg>'
|
|
36
|
+
onClose={() => {}}
|
|
37
|
+
/>,
|
|
38
|
+
);
|
|
39
|
+
const diagram = container.querySelector<SVGSVGElement>('svg#enlarged');
|
|
40
|
+
expect(diagram?.style.maxWidth).toBe('');
|
|
41
|
+
expect(diagram?.style.width).toBe('100%');
|
|
42
|
+
expect(diagram?.style.height).toBe('auto');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('cannot zoom out below the minimum', () => {
|
|
46
|
+
render(
|
|
47
|
+
<ConsoleMermaidDiagramModalScreen svg={svgFixture} onClose={() => {}} />,
|
|
48
|
+
);
|
|
49
|
+
expect(screen.getByLabelText('Zoom out')).toBeDisabled();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('stops zooming in at the maximum', () => {
|
|
53
|
+
render(
|
|
54
|
+
<ConsoleMermaidDiagramModalScreen svg={svgFixture} onClose={() => {}} />,
|
|
55
|
+
);
|
|
56
|
+
const zoomIn = screen.getByLabelText('Zoom in');
|
|
57
|
+
for (let step = 0; step < 20; step += 1) {
|
|
58
|
+
if (!(zoomIn as HTMLButtonElement).disabled) {
|
|
59
|
+
fireEvent.click(zoomIn);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
expect(
|
|
63
|
+
screen.getByText(`${MERMAID_ZOOM_MAX_SCALE * 100}%`),
|
|
64
|
+
).toBeInTheDocument();
|
|
65
|
+
expect(zoomIn).toBeDisabled();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('closes on the close control', () => {
|
|
69
|
+
const onClose = jest.fn();
|
|
70
|
+
render(
|
|
71
|
+
<ConsoleMermaidDiagramModalScreen svg={svgFixture} onClose={onClose} />,
|
|
72
|
+
);
|
|
73
|
+
fireEvent.click(screen.getByLabelText('Close enlarged diagram'));
|
|
74
|
+
expect(onClose).toHaveBeenCalledTimes(1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('closes on the escape key', () => {
|
|
78
|
+
const onClose = jest.fn();
|
|
79
|
+
render(
|
|
80
|
+
<ConsoleMermaidDiagramModalScreen svg={svgFixture} onClose={onClose} />,
|
|
81
|
+
);
|
|
82
|
+
fireEvent.keyDown(document, { key: 'Escape' });
|
|
83
|
+
expect(onClose).toHaveBeenCalledTimes(1);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
formatMermaidZoomScale,
|
|
4
|
+
isMermaidZoomScaleAtMaximum,
|
|
5
|
+
isMermaidZoomScaleAtMinimum,
|
|
6
|
+
MERMAID_ZOOM_MIN_SCALE,
|
|
7
|
+
mermaidZoomCanvasWidth,
|
|
8
|
+
mermaidZoomScaleIn,
|
|
9
|
+
mermaidZoomScaleOut,
|
|
10
|
+
} from '../../lib/mermaidZoom';
|
|
11
|
+
|
|
12
|
+
export type ConsoleMermaidDiagramModalScreenProps = {
|
|
13
|
+
svg: string;
|
|
14
|
+
onClose: () => void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const ConsoleMermaidDiagramModalScreen = ({
|
|
18
|
+
svg,
|
|
19
|
+
onClose,
|
|
20
|
+
}: ConsoleMermaidDiagramModalScreenProps) => {
|
|
21
|
+
const [scale, setScale] = useState<number>(MERMAID_ZOOM_MIN_SCALE);
|
|
22
|
+
const canvasRef = useRef<HTMLDivElement>(null);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
const canvas = canvasRef.current;
|
|
26
|
+
if (canvas === null) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
canvas.innerHTML = svg;
|
|
30
|
+
const diagram = canvas.querySelector('svg');
|
|
31
|
+
if (diagram !== null) {
|
|
32
|
+
diagram.style.removeProperty('max-width');
|
|
33
|
+
diagram.style.setProperty('width', '100%');
|
|
34
|
+
diagram.style.setProperty('height', 'auto');
|
|
35
|
+
}
|
|
36
|
+
}, [svg]);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
40
|
+
if (event.key === 'Escape') {
|
|
41
|
+
onClose();
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
45
|
+
return () => {
|
|
46
|
+
document.removeEventListener('keydown', handleKeyDown);
|
|
47
|
+
};
|
|
48
|
+
}, [onClose]);
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
className="console-mermaid-modal"
|
|
53
|
+
role="dialog"
|
|
54
|
+
aria-modal="true"
|
|
55
|
+
aria-label="Enlarged diagram"
|
|
56
|
+
>
|
|
57
|
+
<div className="console-mermaid-modal-bar">
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
className="console-mermaid-modal-button"
|
|
61
|
+
aria-label="Zoom out"
|
|
62
|
+
disabled={isMermaidZoomScaleAtMinimum(scale)}
|
|
63
|
+
onClick={() => setScale(mermaidZoomScaleOut(scale))}
|
|
64
|
+
>
|
|
65
|
+
−
|
|
66
|
+
</button>
|
|
67
|
+
<span className="console-mermaid-modal-scale">
|
|
68
|
+
{formatMermaidZoomScale(scale)}
|
|
69
|
+
</span>
|
|
70
|
+
<button
|
|
71
|
+
type="button"
|
|
72
|
+
className="console-mermaid-modal-button"
|
|
73
|
+
aria-label="Zoom in"
|
|
74
|
+
disabled={isMermaidZoomScaleAtMaximum(scale)}
|
|
75
|
+
onClick={() => setScale(mermaidZoomScaleIn(scale))}
|
|
76
|
+
>
|
|
77
|
+
+
|
|
78
|
+
</button>
|
|
79
|
+
<button
|
|
80
|
+
type="button"
|
|
81
|
+
className="console-mermaid-modal-close"
|
|
82
|
+
aria-label="Close enlarged diagram"
|
|
83
|
+
onClick={onClose}
|
|
84
|
+
>
|
|
85
|
+
✕
|
|
86
|
+
</button>
|
|
87
|
+
</div>
|
|
88
|
+
<div className="console-mermaid-modal-viewport">
|
|
89
|
+
<div
|
|
90
|
+
ref={canvasRef}
|
|
91
|
+
className="console-mermaid-modal-canvas"
|
|
92
|
+
style={{ width: mermaidZoomCanvasWidth(scale) }}
|
|
93
|
+
/>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
};
|
|
@@ -32,7 +32,7 @@ describe('ConsoleOperationMenu', () => {
|
|
|
32
32
|
handlers={handlers}
|
|
33
33
|
/>,
|
|
34
34
|
);
|
|
35
|
-
expect(getByText('Approve')).toBeInTheDocument();
|
|
35
|
+
expect(getByText('Approve & Merge')).toBeInTheDocument();
|
|
36
36
|
expect(getByText('+1 day')).toBeInTheDocument();
|
|
37
37
|
expect(getByText('Awaiting Workspace')).toBeInTheDocument();
|
|
38
38
|
expect(getByText('Close')).toBeInTheDocument();
|
|
@@ -100,7 +100,7 @@ describe('ConsoleOperationMenu', () => {
|
|
|
100
100
|
handlers={handlers}
|
|
101
101
|
/>,
|
|
102
102
|
);
|
|
103
|
-
expect(queryByText('Approve')).toBeNull();
|
|
103
|
+
expect(queryByText('Approve & Merge')).toBeNull();
|
|
104
104
|
expect(queryByText('Close')).not.toBeNull();
|
|
105
105
|
});
|
|
106
106
|
|
|
@@ -121,7 +121,7 @@ describe('ConsoleOperationMenu', () => {
|
|
|
121
121
|
expect(getByText('Close as not planned')).toBeInTheDocument();
|
|
122
122
|
expect(getByText('+1 day')).toBeInTheDocument();
|
|
123
123
|
expect(queryByText('Move to Okinawa')).toBeNull();
|
|
124
|
-
expect(queryByText('Approve')).toBeNull();
|
|
124
|
+
expect(queryByText('Approve & Merge')).toBeNull();
|
|
125
125
|
});
|
|
126
126
|
|
|
127
127
|
it('shows the review group on the workflow-blocker tab when the item has a pull request', () => {
|
|
@@ -136,7 +136,7 @@ describe('ConsoleOperationMenu', () => {
|
|
|
136
136
|
handlers={handlers}
|
|
137
137
|
/>,
|
|
138
138
|
);
|
|
139
|
-
expect(getByText('Approve')).toBeInTheDocument();
|
|
139
|
+
expect(getByText('Approve & Merge')).toBeInTheDocument();
|
|
140
140
|
expect(getByText('Close')).toBeInTheDocument();
|
|
141
141
|
});
|
|
142
142
|
|
|
@@ -13,7 +13,7 @@ describe('ConsolePullRequestReviewActions', () => {
|
|
|
13
13
|
'Unnecessary',
|
|
14
14
|
'Totally wrong',
|
|
15
15
|
'Reject',
|
|
16
|
-
'Approve',
|
|
16
|
+
'Approve & Merge',
|
|
17
17
|
]);
|
|
18
18
|
});
|
|
19
19
|
|
|
@@ -25,7 +25,7 @@ describe('ConsolePullRequestReviewActions', () => {
|
|
|
25
25
|
/>,
|
|
26
26
|
);
|
|
27
27
|
expect(getByText('Reject')).toBeDisabled();
|
|
28
|
-
expect(getByText('Approve')).not.toBeDisabled();
|
|
28
|
+
expect(getByText('Approve & Merge')).not.toBeDisabled();
|
|
29
29
|
rerender(
|
|
30
30
|
<ConsolePullRequestReviewActions
|
|
31
31
|
onReview={() => {}}
|
|
@@ -82,12 +82,12 @@ describe('ConsolePullRequestReviewActions', () => {
|
|
|
82
82
|
fireEvent.click(getByText('Unnecessary'));
|
|
83
83
|
fireEvent.click(getByText('Totally wrong'));
|
|
84
84
|
fireEvent.click(getByText('Reject'));
|
|
85
|
-
fireEvent.click(getByText('Approve'));
|
|
85
|
+
fireEvent.click(getByText('Approve & Merge'));
|
|
86
86
|
expect(onReview.mock.calls.map((call) => call[0])).toEqual([
|
|
87
87
|
'unnecessary',
|
|
88
88
|
'totally_wrong',
|
|
89
89
|
'request_changes',
|
|
90
|
-
'
|
|
90
|
+
'approve_and_merge',
|
|
91
91
|
]);
|
|
92
92
|
});
|
|
93
93
|
});
|
|
@@ -13,7 +13,7 @@ const REVIEW_BUTTONS: {
|
|
|
13
13
|
{ action: 'unnecessary', label: 'Unnecessary', variant: 'unneeded' },
|
|
14
14
|
{ action: 'totally_wrong', label: 'Totally wrong', variant: 'wrong' },
|
|
15
15
|
{ action: 'request_changes', label: 'Reject', variant: 'reject' },
|
|
16
|
-
{ action: '
|
|
16
|
+
{ action: 'approve_and_merge', label: 'Approve & Merge', variant: 'approve' },
|
|
17
17
|
];
|
|
18
18
|
|
|
19
19
|
const REJECT_REQUIREMENT_MESSAGE =
|