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.
Files changed (54) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +1 -1
  3. package/bin/adapter/entry-points/console/consoleOperationApi.js +22 -3
  4. package/bin/adapter/entry-points/console/consoleOperationApi.js.map +1 -1
  5. package/bin/adapter/entry-points/console/ui-dist/assets/{index-DT98Xk6Y.js → index-CHUtr_4X.js} +6 -6
  6. package/bin/adapter/entry-points/console/ui-dist/assets/{index-BbUBeonj.css → index-U8I8ESZj.css} +1 -1
  7. package/bin/adapter/entry-points/console/ui-dist/index.html +2 -2
  8. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +41 -0
  9. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  10. package/bin/domain/entities/WorkflowStatus.js +5 -1
  11. package/bin/domain/entities/WorkflowStatus.js.map +1 -1
  12. package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js +3 -2
  13. package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js.map +1 -1
  14. package/package.json +1 -1
  15. package/src/adapter/entry-points/console/consoleOperationApi.test.ts +136 -8
  16. package/src/adapter/entry-points/console/consoleOperationApi.ts +25 -3
  17. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +13 -1
  18. package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMermaidDiagram.test.tsx +37 -1
  19. package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMermaidDiagram.tsx +20 -1
  20. package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMermaidDiagramModalScreen.stories.tsx +27 -0
  21. package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMermaidDiagramModalScreen.test.tsx +85 -0
  22. package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMermaidDiagramModalScreen.tsx +97 -0
  23. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleOperationMenu.test.tsx +4 -4
  24. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsolePullRequestReviewActions.test.tsx +4 -4
  25. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsolePullRequestReviewActions.tsx +1 -1
  26. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.test.ts +3 -3
  27. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.ts +2 -2
  28. package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.test.ts +2 -2
  29. package/src/adapter/entry-points/console/ui/src/features/console/lib/mermaidZoom.test.ts +88 -0
  30. package/src/adapter/entry-points/console/ui/src/features/console/lib/mermaidZoom.ts +34 -0
  31. package/src/adapter/entry-points/console/ui/src/features/console/logic/actionToast.test.ts +17 -10
  32. package/src/adapter/entry-points/console/ui/src/features/console/logic/actionToast.ts +3 -3
  33. package/src/adapter/entry-points/console/ui/src/features/console/logic/operations.ts +1 -1
  34. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.test.tsx +7 -7
  35. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +17 -17
  36. package/src/adapter/entry-points/console/ui/src/index.css +65 -0
  37. package/src/adapter/entry-points/console/ui-dist/assets/{index-DT98Xk6Y.js → index-CHUtr_4X.js} +6 -6
  38. package/src/adapter/entry-points/console/ui-dist/assets/{index-BbUBeonj.css → index-U8I8ESZj.css} +1 -1
  39. package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
  40. package/src/adapter/entry-points/console/webServer.test.ts +66 -5
  41. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +57 -0
  42. package/src/domain/entities/WorkflowStatus.ts +5 -0
  43. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +2 -0
  44. package/src/domain/usecases/console/GenerateConsoleListsUseCase.test.ts +31 -0
  45. package/src/domain/usecases/console/GenerateConsoleListsUseCase.ts +8 -2
  46. package/types/adapter/entry-points/console/consoleOperationApi.d.ts.map +1 -1
  47. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +2 -0
  48. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  49. package/types/domain/entities/WorkflowStatus.d.ts +1 -0
  50. package/types/domain/entities/WorkflowStatus.d.ts.map +1 -1
  51. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +2 -0
  52. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
  53. package/types/domain/usecases/console/GenerateConsoleListsUseCase.d.ts +1 -0
  54. 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: 'approve',
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: 'approve',
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: 'approve',
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: 'approve',
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('approves using the request project item id without a GraphQL item fetch', async () => {
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: 'approve',
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: 'approve',
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 === 'approve') {
327
- await context.resolveIssueRepository(prUrl).approvePullRequest(prUrl);
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
- context.resolveIssueRepository(prUrl),
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
- ? awaitingQualityCheckPullRequest
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 <div ref={containerRef} className="console-mermaid-rendered" />;
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
- 'approve',
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: 'approve', label: 'Approve', variant: 'approve' },
16
+ { action: 'approve_and_merge', label: 'Approve & Merge', variant: 'approve' },
17
17
  ];
18
18
 
19
19
  const REJECT_REQUIREMENT_MESSAGE =