github-issue-tower-defence-management 1.92.1 → 1.93.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.
Files changed (42) hide show
  1. package/.github/workflows/console-ui.yml +3 -0
  2. package/.github/workflows/publish.yml +4 -0
  3. package/CHANGELOG.md +14 -0
  4. package/bin/adapter/entry-points/console/ui-dist/assets/index-C5nxEEOu.css +1 -0
  5. package/bin/adapter/entry-points/console/ui-dist/assets/index-DoJ05EuW.js +101 -0
  6. package/bin/adapter/entry-points/console/ui-dist/index.html +2 -2
  7. package/package.json +1 -1
  8. package/scripts/verifyConsoleUiDist.mjs +96 -0
  9. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleChangedFileList.test.tsx +49 -1
  10. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleChangedFileList.tsx +36 -20
  11. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleFileDiff.stories.tsx +19 -0
  12. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleFileDiff.test.tsx +24 -0
  13. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleFileDiff.tsx +30 -0
  14. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsolePullRequestDetail.tsx +30 -24
  15. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleUndoToast.stories.tsx +47 -0
  16. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleUndoToast.test.tsx +54 -0
  17. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleUndoToast.tsx +36 -0
  18. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.test.ts +117 -0
  19. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.ts +105 -0
  20. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleSwipeNavigation.test.ts +115 -0
  21. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleSwipeNavigation.ts +115 -0
  22. package/src/adapter/entry-points/console/ui/src/features/console/logic/actionToast.test.ts +153 -0
  23. package/src/adapter/entry-points/console/ui/src/features/console/logic/actionToast.ts +103 -0
  24. package/src/adapter/entry-points/console/ui/src/features/console/logic/diff.test.ts +81 -0
  25. package/src/adapter/entry-points/console/ui/src/features/console/logic/diff.ts +61 -0
  26. package/src/adapter/entry-points/console/ui/src/features/console/logic/navigation.test.ts +53 -0
  27. package/src/adapter/entry-points/console/ui/src/features/console/logic/navigation.ts +33 -0
  28. package/src/adapter/entry-points/console/ui/src/features/console/logic/swipe.test.ts +34 -0
  29. package/src/adapter/entry-points/console/ui/src/features/console/logic/swipe.ts +28 -0
  30. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.test.tsx +9 -1
  31. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.tsx +51 -6
  32. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +263 -17
  33. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +85 -3
  34. package/src/adapter/entry-points/console/ui/src/features/console/testing/fixtures.ts +19 -3
  35. package/src/adapter/entry-points/console/ui/src/index.css +205 -1
  36. package/src/adapter/entry-points/console/ui-dist/assets/index-C5nxEEOu.css +1 -0
  37. package/src/adapter/entry-points/console/ui-dist/assets/index-DoJ05EuW.js +101 -0
  38. package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
  39. package/bin/adapter/entry-points/console/ui-dist/assets/index-BJixkRxv.css +0 -1
  40. package/bin/adapter/entry-points/console/ui-dist/assets/index-BSNMvjcB.js +0 -100
  41. package/src/adapter/entry-points/console/ui-dist/assets/index-BJixkRxv.css +0 -1
  42. package/src/adapter/entry-points/console/ui-dist/assets/index-BSNMvjcB.js +0 -100
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>TDPM Console</title>
7
- <script type="module" crossorigin src="/assets/index-BSNMvjcB.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-BJixkRxv.css">
7
+ <script type="module" crossorigin src="/assets/index-DoJ05EuW.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-C5nxEEOu.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github-issue-tower-defence-management",
3
- "version": "1.92.1",
3
+ "version": "1.93.1",
4
4
  "description": "",
5
5
  "main": "bin/index.js",
6
6
  "scripts": {
@@ -0,0 +1,96 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+ const repoRoot = path.resolve(__dirname, '..');
7
+
8
+ const sourceDir = path.join(
9
+ repoRoot,
10
+ 'src',
11
+ 'adapter',
12
+ 'entry-points',
13
+ 'console',
14
+ 'ui-dist',
15
+ );
16
+ const targetDir = path.join(
17
+ repoRoot,
18
+ 'bin',
19
+ 'adapter',
20
+ 'entry-points',
21
+ 'console',
22
+ 'ui-dist',
23
+ );
24
+
25
+ const collectRelativeFiles = (baseDir) => {
26
+ const walk = (currentDir) => {
27
+ const entries = readdirSync(currentDir);
28
+ return entries.flatMap((entry) => {
29
+ const absolutePath = path.join(currentDir, entry);
30
+ if (statSync(absolutePath).isDirectory()) {
31
+ return walk(absolutePath);
32
+ }
33
+ return [path.relative(baseDir, absolutePath)];
34
+ });
35
+ };
36
+ return walk(baseDir).sort();
37
+ };
38
+
39
+ if (!existsSync(sourceDir)) {
40
+ throw new Error(
41
+ `Console UI source build output not found at ${sourceDir}. Run "npm run build:console-ui" first.`,
42
+ );
43
+ }
44
+
45
+ if (!existsSync(targetDir)) {
46
+ throw new Error(
47
+ `Served console UI bundle not found at ${targetDir}. Run "node scripts/copyConsoleUiDist.mjs" to regenerate it.`,
48
+ );
49
+ }
50
+
51
+ const sourceFiles = collectRelativeFiles(sourceDir);
52
+ const targetFiles = collectRelativeFiles(targetDir);
53
+
54
+ const mismatches = [];
55
+
56
+ const sourceFileSet = new Set(sourceFiles);
57
+ const targetFileSet = new Set(targetFiles);
58
+
59
+ for (const relativePath of sourceFiles) {
60
+ if (!targetFileSet.has(relativePath)) {
61
+ mismatches.push(`missing in served bundle: ${relativePath}`);
62
+ }
63
+ }
64
+
65
+ for (const relativePath of targetFiles) {
66
+ if (!sourceFileSet.has(relativePath)) {
67
+ mismatches.push(`unexpected in served bundle: ${relativePath}`);
68
+ }
69
+ }
70
+
71
+ for (const relativePath of sourceFiles) {
72
+ if (!targetFileSet.has(relativePath)) {
73
+ continue;
74
+ }
75
+ const sourceContent = readFileSync(path.join(sourceDir, relativePath));
76
+ const targetContent = readFileSync(path.join(targetDir, relativePath));
77
+ if (!sourceContent.equals(targetContent)) {
78
+ mismatches.push(`content differs: ${relativePath}`);
79
+ }
80
+ }
81
+
82
+ if (mismatches.length > 0) {
83
+ throw new Error(
84
+ [
85
+ 'Served console UI bundle is stale and does not match the source build.',
86
+ `Source: ${sourceDir}`,
87
+ `Served: ${targetDir}`,
88
+ 'Run "npm run build:console-ui && node scripts/copyConsoleUiDist.mjs" and commit the result.',
89
+ ...mismatches.map((entry) => ` - ${entry}`),
90
+ ].join('\n'),
91
+ );
92
+ }
93
+
94
+ process.stdout.write(
95
+ `Served console UI bundle matches source build (${sourceFiles.length} files).\n`,
96
+ );
@@ -1,4 +1,4 @@
1
- import { render } from '@testing-library/react';
1
+ import { fireEvent, render } from '@testing-library/react';
2
2
  import { consoleChangedFilesFixture } from '../../testing/fixtures';
3
3
  import { ConsoleChangedFileList } from './ConsoleChangedFileList';
4
4
 
@@ -19,6 +19,54 @@ describe('ConsoleChangedFileList', () => {
19
19
  expect(getByText('M')).toBeInTheDocument();
20
20
  });
21
21
 
22
+ it('keeps the diff hidden until the file row is clicked', () => {
23
+ const { container, getByText } = render(
24
+ <ConsoleChangedFileList
25
+ files={consoleChangedFilesFixture}
26
+ isLoading={false}
27
+ error={null}
28
+ />,
29
+ );
30
+ expect(container.querySelector('.console-file-diff')).toBeNull();
31
+ fireEvent.click(
32
+ getByText('src/adapter/entry-points/console/consoleServer.ts'),
33
+ );
34
+ expect(container.querySelector('.console-file-diff')).toBeInTheDocument();
35
+ const codeText = [...container.querySelectorAll('.console-diff-code')].map(
36
+ (cell) => cell.textContent,
37
+ );
38
+ expect(codeText).toContain('+ npm ci');
39
+ });
40
+
41
+ it('collapses the diff again when the file row is clicked twice', () => {
42
+ const { container, getByText } = render(
43
+ <ConsoleChangedFileList
44
+ files={consoleChangedFilesFixture}
45
+ isLoading={false}
46
+ error={null}
47
+ />,
48
+ );
49
+ const path = getByText('src/adapter/entry-points/console/consoleServer.ts');
50
+ fireEvent.click(path);
51
+ expect(container.querySelector('.console-file-diff')).toBeInTheDocument();
52
+ fireEvent.click(path);
53
+ expect(container.querySelector('.console-file-diff')).toBeNull();
54
+ });
55
+
56
+ it('marks the expanded file row via aria-expanded', () => {
57
+ const { getAllByRole } = render(
58
+ <ConsoleChangedFileList
59
+ files={consoleChangedFilesFixture}
60
+ isLoading={false}
61
+ error={null}
62
+ />,
63
+ );
64
+ const firstRow = getAllByRole('button')[0];
65
+ expect(firstRow).toHaveAttribute('aria-expanded', 'false');
66
+ fireEvent.click(firstRow);
67
+ expect(firstRow).toHaveAttribute('aria-expanded', 'true');
68
+ });
69
+
22
70
  it('shows the loading state', () => {
23
71
  const { getByText } = render(
24
72
  <ConsoleChangedFileList files={[]} isLoading error={null} />,
@@ -1,5 +1,7 @@
1
+ import { useState } from 'react';
1
2
  import { fileStatusBadge } from '../../logic/fileStatus';
2
3
  import type { ConsoleChangedFile } from '../../logic/types';
4
+ import { ConsoleFileDiff } from './ConsoleFileDiff';
3
5
 
4
6
  export type ConsoleChangedFileListProps = {
5
7
  files: ConsoleChangedFile[];
@@ -7,6 +9,37 @@ export type ConsoleChangedFileListProps = {
7
9
  error: string | null;
8
10
  };
9
11
 
12
+ const ConsoleChangedFileRow = ({ file }: { file: ConsoleChangedFile }) => {
13
+ const [expanded, setExpanded] = useState<boolean>(false);
14
+ const badge = fileStatusBadge(file.status);
15
+ return (
16
+ <li className="console-file">
17
+ <button
18
+ type="button"
19
+ className="console-file-row"
20
+ aria-expanded={expanded}
21
+ onClick={() => setExpanded((value) => !value)}
22
+ >
23
+ <span className="console-file-caret">{expanded ? '▾' : '▸'}</span>
24
+ <span
25
+ className="console-file-badge"
26
+ style={{ color: badge.color, borderColor: badge.color }}
27
+ >
28
+ {badge.label}
29
+ </span>
30
+ <span className="console-file-path">{file.path}</span>
31
+ <span className="console-file-stat console-file-add">
32
+ +{file.additions}
33
+ </span>
34
+ <span className="console-file-stat console-file-del">
35
+ -{file.deletions}
36
+ </span>
37
+ </button>
38
+ {expanded && <ConsoleFileDiff patch={file.patch} />}
39
+ </li>
40
+ );
41
+ };
42
+
10
43
  export const ConsoleChangedFileList = ({
11
44
  files,
12
45
  isLoading,
@@ -30,26 +63,9 @@ export const ConsoleChangedFileList = ({
30
63
 
31
64
  return (
32
65
  <ul className="console-files">
33
- {files.map((file) => {
34
- const badge = fileStatusBadge(file.status);
35
- return (
36
- <li key={file.path} className="console-file">
37
- <span
38
- className="console-file-badge"
39
- style={{ color: badge.color, borderColor: badge.color }}
40
- >
41
- {badge.label}
42
- </span>
43
- <span className="console-file-path">{file.path}</span>
44
- <span className="console-file-stat console-file-add">
45
- +{file.additions}
46
- </span>
47
- <span className="console-file-stat console-file-del">
48
- -{file.deletions}
49
- </span>
50
- </li>
51
- );
52
- })}
66
+ {files.map((file) => (
67
+ <ConsoleChangedFileRow key={file.path} file={file} />
68
+ ))}
53
69
  </ul>
54
70
  );
55
71
  };
@@ -0,0 +1,19 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { consoleChangedFilesFixture } from '../../testing/fixtures';
3
+ import { ConsoleFileDiff } from './ConsoleFileDiff';
4
+
5
+ const meta: Meta<typeof ConsoleFileDiff> = {
6
+ title: 'Console/ConsoleFileDiff',
7
+ component: ConsoleFileDiff,
8
+ args: { patch: consoleChangedFilesFixture[0].patch },
9
+ };
10
+
11
+ export default meta;
12
+
13
+ type Story = StoryObj<typeof ConsoleFileDiff>;
14
+
15
+ export const WithPatch: Story = {};
16
+
17
+ export const NoPatch: Story = {
18
+ args: { patch: null },
19
+ };
@@ -0,0 +1,24 @@
1
+ import { render } from '@testing-library/react';
2
+ import { consoleChangedFilesFixture } from '../../testing/fixtures';
3
+ import { ConsoleFileDiff } from './ConsoleFileDiff';
4
+
5
+ describe('ConsoleFileDiff', () => {
6
+ it('renders added, removed and context lines from the patch', () => {
7
+ const { container } = render(
8
+ <ConsoleFileDiff patch={consoleChangedFilesFixture[0].patch} />,
9
+ );
10
+ const codeText = [...container.querySelectorAll('.console-diff-code')].map(
11
+ (cell) => cell.textContent,
12
+ );
13
+ expect(codeText).toContain('+ npm ci');
14
+ expect(codeText).toContain('- npm install');
15
+ expect(container.querySelectorAll('.console-diff-add').length).toBe(2);
16
+ expect(container.querySelectorAll('.console-diff-del').length).toBe(1);
17
+ expect(container.querySelectorAll('.console-diff-hunk').length).toBe(1);
18
+ });
19
+
20
+ it('shows a placeholder when there is no patch', () => {
21
+ const { getByText } = render(<ConsoleFileDiff patch={null} />);
22
+ expect(getByText('(no diff / binary or too large)')).toBeInTheDocument();
23
+ });
24
+ });
@@ -0,0 +1,30 @@
1
+ import { parseUnifiedDiff } from '../../logic/diff';
2
+
3
+ export type ConsoleFileDiffProps = {
4
+ patch: string | null;
5
+ };
6
+
7
+ export const ConsoleFileDiff = ({ patch }: ConsoleFileDiffProps) => {
8
+ if (patch === null || patch === '') {
9
+ return (
10
+ <p className="console-file-diff-empty">(no diff / binary or too large)</p>
11
+ );
12
+ }
13
+ const rows = parseUnifiedDiff(patch);
14
+ return (
15
+ <table className="console-file-diff">
16
+ <tbody>
17
+ {rows.map((row) => (
18
+ <tr
19
+ key={`${row.kind}:${row.oldLineNumber ?? 'x'}:${row.newLineNumber ?? 'x'}:${row.content}`}
20
+ className={`console-diff-row console-diff-${row.kind}`}
21
+ >
22
+ <td className="console-diff-ln">{row.oldLineNumber ?? ''}</td>
23
+ <td className="console-diff-ln">{row.newLineNumber ?? ''}</td>
24
+ <td className="console-diff-code">{row.content}</td>
25
+ </tr>
26
+ ))}
27
+ </tbody>
28
+ </table>
29
+ );
30
+ };
@@ -34,9 +34,13 @@ export const ConsolePullRequestDetail = ({
34
34
  now,
35
35
  }: ConsolePullRequestSectionProps) => {
36
36
  const summary = pullRequest.summary;
37
+ const filesCount =
38
+ filesAreLoading || filesError !== null ? null : files.length;
39
+ const commitsCount =
40
+ commitsAreLoading || commitsError !== null ? null : commits.length;
37
41
  return (
38
- <section className="console-pr-section">
39
- <header className="console-pr-section-header">
42
+ <>
43
+ <div className="console-pr-header">
40
44
  <a
41
45
  href={pullRequest.url}
42
46
  className="console-pr-section-title"
@@ -48,34 +52,36 @@ export const ConsolePullRequestDetail = ({
48
52
  {pullRequest.isDraft && (
49
53
  <span className="console-pr-section-state">draft</span>
50
54
  )}
51
- </header>
52
- <div className="console-pr-statbar">
53
- {pullRequest.branchName !== null && (
54
- <span className="console-pr-branch">{pullRequest.branchName}</span>
55
- )}
56
- {summary !== null && (
57
- <>
58
- <span className="console-pr-add">+{summary.additions}</span>
59
- <span className="console-pr-del">-{summary.deletions}</span>
60
- <span className="console-pr-files-count">
61
- {summary.changedFiles} files
62
- </span>
63
- </>
64
- )}
55
+ <div className="console-pr-statbar">
56
+ {pullRequest.branchName !== null && (
57
+ <span className="console-pr-branch">{pullRequest.branchName}</span>
58
+ )}
59
+ {summary !== null && (
60
+ <>
61
+ <span className="console-pr-add">+{summary.additions}</span>
62
+ <span className="console-pr-del">-{summary.deletions}</span>
63
+ <span className="console-pr-files-count">
64
+ {summary.changedFiles} files
65
+ </span>
66
+ </>
67
+ )}
68
+ </div>
65
69
  </div>
66
- {bodyIsLoading ? (
67
- <p className="console-pr-body-loading">Loading description...</p>
68
- ) : (
69
- <ConsoleMarkdownContent body={summary?.body ?? body} />
70
- )}
71
- <ConsolePanel title="Changed files">
70
+ <ConsolePanel title="Description" defaultCollapsed>
71
+ {bodyIsLoading ? (
72
+ <p className="console-pr-body-loading">Loading description...</p>
73
+ ) : (
74
+ <ConsoleMarkdownContent body={summary?.body ?? body} />
75
+ )}
76
+ </ConsolePanel>
77
+ <ConsolePanel title="Changed files" count={filesCount}>
72
78
  <ConsoleChangedFileList
73
79
  files={files}
74
80
  isLoading={filesAreLoading}
75
81
  error={filesError}
76
82
  />
77
83
  </ConsolePanel>
78
- <ConsolePanel title="Commits" defaultCollapsed>
84
+ <ConsolePanel title="Commits" count={commitsCount} defaultCollapsed>
79
85
  <ConsoleCommitList
80
86
  commits={commits}
81
87
  isLoading={commitsAreLoading}
@@ -83,6 +89,6 @@ export const ConsolePullRequestDetail = ({
83
89
  now={now}
84
90
  />
85
91
  </ConsolePanel>
86
- </section>
92
+ </>
87
93
  );
88
94
  };
@@ -0,0 +1,47 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { ConsoleUndoToast } from './ConsoleUndoToast';
3
+
4
+ const meta: Meta<typeof ConsoleUndoToast> = {
5
+ title: 'Console/ConsoleUndoToast',
6
+ component: ConsoleUndoToast,
7
+ args: {
8
+ message: 'Approved — PR #851',
9
+ color: 'green',
10
+ remainingSeconds: 5,
11
+ progress: 1,
12
+ onUndo: () => {},
13
+ },
14
+ };
15
+
16
+ export default meta;
17
+
18
+ type Story = StoryObj<typeof ConsoleUndoToast>;
19
+
20
+ export const Approve: Story = {};
21
+
22
+ export const Reject: Story = {
23
+ args: {
24
+ message: 'Rejected — PR #853',
25
+ color: 'amber',
26
+ remainingSeconds: 3,
27
+ progress: 0.6,
28
+ },
29
+ };
30
+
31
+ export const StatusChange: Story = {
32
+ args: {
33
+ message: 'Status → Awaiting Workspace — #845',
34
+ color: 'blue',
35
+ remainingSeconds: 2,
36
+ progress: 0.4,
37
+ },
38
+ };
39
+
40
+ export const CloseIssue: Story = {
41
+ args: {
42
+ message: 'Closed — #845',
43
+ color: 'red',
44
+ remainingSeconds: 1,
45
+ progress: 0.2,
46
+ },
47
+ };
@@ -0,0 +1,54 @@
1
+ import { fireEvent, render } from '@testing-library/react';
2
+ import { ConsoleUndoToast } from './ConsoleUndoToast';
3
+
4
+ describe('ConsoleUndoToast', () => {
5
+ const baseProps = {
6
+ message: 'Approved — PR #851',
7
+ color: 'green' as const,
8
+ remainingSeconds: 5,
9
+ progress: 1,
10
+ onUndo: () => {},
11
+ };
12
+
13
+ it('shows the action message and the countdown', () => {
14
+ const { getByText } = render(<ConsoleUndoToast {...baseProps} />);
15
+ expect(getByText('Approved — PR #851')).toBeInTheDocument();
16
+ expect(getByText('5s')).toBeInTheDocument();
17
+ });
18
+
19
+ it('renders the color modifier class', () => {
20
+ const { container } = render(<ConsoleUndoToast {...baseProps} />);
21
+ expect(
22
+ container.querySelector('.console-undo-toast-green'),
23
+ ).toBeInTheDocument();
24
+ });
25
+
26
+ it('shrinks the progress bar with the remaining fraction', () => {
27
+ const { container } = render(
28
+ <ConsoleUndoToast {...baseProps} progress={0.4} />,
29
+ );
30
+ const bar = container.querySelector(
31
+ '.console-undo-toast-bar',
32
+ ) as HTMLElement;
33
+ expect(bar.style.width).toBe('40%');
34
+ });
35
+
36
+ it('clamps the progress bar width within 0 and 100 percent', () => {
37
+ const { container } = render(
38
+ <ConsoleUndoToast {...baseProps} progress={1.4} />,
39
+ );
40
+ const bar = container.querySelector(
41
+ '.console-undo-toast-bar',
42
+ ) as HTMLElement;
43
+ expect(bar.style.width).toBe('100%');
44
+ });
45
+
46
+ it('invokes onUndo when the Undo control is clicked', () => {
47
+ const onUndo = jest.fn();
48
+ const { getByText } = render(
49
+ <ConsoleUndoToast {...baseProps} onUndo={onUndo} />,
50
+ );
51
+ fireEvent.click(getByText('Undo'));
52
+ expect(onUndo).toHaveBeenCalledTimes(1);
53
+ });
54
+ });
@@ -0,0 +1,36 @@
1
+ import type { ConsoleToastColor } from '../../logic/actionToast';
2
+
3
+ export type ConsoleUndoToastProps = {
4
+ message: string;
5
+ color: ConsoleToastColor;
6
+ remainingSeconds: number;
7
+ progress: number;
8
+ onUndo: () => void;
9
+ };
10
+
11
+ export const ConsoleUndoToast = ({
12
+ message,
13
+ color,
14
+ remainingSeconds,
15
+ progress,
16
+ onUndo,
17
+ }: ConsoleUndoToastProps) => (
18
+ <div
19
+ className={`console-undo-toast console-undo-toast-${color}`}
20
+ role="status"
21
+ aria-live="polite"
22
+ >
23
+ <span className="console-undo-toast-message">{message}</span>
24
+ <button type="button" className="console-undo-toast-undo" onClick={onUndo}>
25
+ Undo
26
+ </button>
27
+ <span className="console-undo-toast-countdown" aria-hidden="true">
28
+ {remainingSeconds}s
29
+ </span>
30
+ <span
31
+ className="console-undo-toast-bar"
32
+ aria-hidden="true"
33
+ style={{ width: `${Math.max(0, Math.min(100, progress * 100))}%` }}
34
+ />
35
+ </div>
36
+ );
@@ -0,0 +1,117 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { useConsoleActionQueue } from './useConsoleActionQueue';
3
+
4
+ const makeAction = (
5
+ overrides: Partial<
6
+ Parameters<ReturnType<typeof useConsoleActionQueue>['enqueue']>[0]
7
+ > = {},
8
+ ) => ({
9
+ message: 'Approved — PR #851',
10
+ color: 'green' as const,
11
+ commit: jest.fn(),
12
+ advance: jest.fn(),
13
+ ...overrides,
14
+ });
15
+
16
+ describe('useConsoleActionQueue', () => {
17
+ beforeEach(() => {
18
+ jest.useFakeTimers();
19
+ });
20
+
21
+ afterEach(() => {
22
+ jest.useRealTimers();
23
+ });
24
+
25
+ it('advances immediately but only commits after the five second window', () => {
26
+ const { result } = renderHook(() => useConsoleActionQueue());
27
+ const action = makeAction();
28
+ act(() => {
29
+ result.current.enqueue(action);
30
+ });
31
+ expect(action.advance).toHaveBeenCalledTimes(1);
32
+ expect(action.commit).not.toHaveBeenCalled();
33
+ expect(result.current.pending?.message).toBe('Approved — PR #851');
34
+ expect(result.current.pending?.remainingSeconds).toBe(5);
35
+
36
+ act(() => {
37
+ jest.advanceTimersByTime(4900);
38
+ });
39
+ expect(action.commit).not.toHaveBeenCalled();
40
+
41
+ act(() => {
42
+ jest.advanceTimersByTime(200);
43
+ });
44
+ expect(action.commit).toHaveBeenCalledTimes(1);
45
+ expect(result.current.pending).toBeNull();
46
+ });
47
+
48
+ it('counts the remaining seconds down during the window', () => {
49
+ const { result } = renderHook(() => useConsoleActionQueue());
50
+ act(() => {
51
+ result.current.enqueue(makeAction());
52
+ });
53
+ act(() => {
54
+ jest.advanceTimersByTime(2000);
55
+ });
56
+ expect(result.current.pending?.remainingSeconds).toBe(3);
57
+ expect(result.current.pending?.progress).toBeCloseTo(0.6, 1);
58
+ });
59
+
60
+ it('cancels the command when undo is called within the window', () => {
61
+ const { result } = renderHook(() => useConsoleActionQueue());
62
+ const action = makeAction();
63
+ act(() => {
64
+ result.current.enqueue(action);
65
+ });
66
+ act(() => {
67
+ jest.advanceTimersByTime(2000);
68
+ result.current.undo();
69
+ });
70
+ expect(result.current.pending).toBeNull();
71
+ act(() => {
72
+ jest.advanceTimersByTime(5000);
73
+ });
74
+ expect(action.commit).not.toHaveBeenCalled();
75
+ });
76
+
77
+ it('flushes the previous pending action when a new one is enqueued', () => {
78
+ const { result } = renderHook(() => useConsoleActionQueue());
79
+ const first = makeAction({ message: 'Approved — PR #851' });
80
+ const second = makeAction({
81
+ message: 'Rejected — PR #853',
82
+ color: 'amber',
83
+ });
84
+ act(() => {
85
+ result.current.enqueue(first);
86
+ });
87
+ act(() => {
88
+ jest.advanceTimersByTime(1000);
89
+ result.current.enqueue(second);
90
+ });
91
+ expect(first.commit).toHaveBeenCalledTimes(1);
92
+ expect(second.commit).not.toHaveBeenCalled();
93
+ expect(result.current.pending?.message).toBe('Rejected — PR #853');
94
+ act(() => {
95
+ jest.advanceTimersByTime(5000);
96
+ });
97
+ expect(second.commit).toHaveBeenCalledTimes(1);
98
+ });
99
+
100
+ it('does not commit twice if the window elapses after a manual flush', () => {
101
+ const { result } = renderHook(() => useConsoleActionQueue());
102
+ const first = makeAction();
103
+ const second = makeAction({ message: 'Closed — #845', color: 'red' });
104
+ act(() => {
105
+ result.current.enqueue(first);
106
+ });
107
+ act(() => {
108
+ result.current.enqueue(second);
109
+ });
110
+ expect(first.commit).toHaveBeenCalledTimes(1);
111
+ act(() => {
112
+ jest.advanceTimersByTime(6000);
113
+ });
114
+ expect(first.commit).toHaveBeenCalledTimes(1);
115
+ expect(second.commit).toHaveBeenCalledTimes(1);
116
+ });
117
+ });