github-issue-tower-defence-management 1.92.1 → 1.93.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 +7 -0
- package/package.json +1 -1
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleChangedFileList.test.tsx +49 -1
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleChangedFileList.tsx +36 -20
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleFileDiff.stories.tsx +19 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleFileDiff.test.tsx +24 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleFileDiff.tsx +30 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsolePullRequestDetail.tsx +30 -24
- package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleUndoToast.stories.tsx +47 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleUndoToast.test.tsx +54 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleUndoToast.tsx +36 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.test.ts +117 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.ts +105 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleSwipeNavigation.test.ts +115 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleSwipeNavigation.ts +115 -0
- package/src/adapter/entry-points/console/ui/src/features/console/logic/actionToast.test.ts +153 -0
- package/src/adapter/entry-points/console/ui/src/features/console/logic/actionToast.ts +103 -0
- package/src/adapter/entry-points/console/ui/src/features/console/logic/diff.test.ts +81 -0
- package/src/adapter/entry-points/console/ui/src/features/console/logic/diff.ts +61 -0
- package/src/adapter/entry-points/console/ui/src/features/console/logic/navigation.test.ts +53 -0
- package/src/adapter/entry-points/console/ui/src/features/console/logic/navigation.ts +33 -0
- package/src/adapter/entry-points/console/ui/src/features/console/logic/swipe.test.ts +34 -0
- package/src/adapter/entry-points/console/ui/src/features/console/logic/swipe.ts +28 -0
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.test.tsx +9 -1
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.tsx +51 -6
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +263 -17
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +85 -3
- package/src/adapter/entry-points/console/ui/src/features/console/testing/fixtures.ts +19 -3
- package/src/adapter/entry-points/console/ui/src/index.css +205 -1
- package/src/adapter/entry-points/console/ui-dist/assets/index-C5nxEEOu.css +1 -0
- package/src/adapter/entry-points/console/ui-dist/assets/index-DoJ05EuW.js +101 -0
- package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
- package/src/adapter/entry-points/console/ui-dist/assets/index-BJixkRxv.css +0 -1
- package/src/adapter/entry-points/console/ui-dist/assets/index-BSNMvjcB.js +0 -100
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [1.93.0](https://github.com/HiromiShikata/npm-cli-github-issue-tower-defence-management/compare/v1.92.1...v1.93.0) (2026-06-22)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **console:** cancellable action toast, auto-advance, file diff, and swipe navigation for reference parity ([#893](https://github.com/HiromiShikata/npm-cli-github-issue-tower-defence-management/issues/893)) ([9f983af](https://github.com/HiromiShikata/npm-cli-github-issue-tower-defence-management/commit/9f983af0dfff353ff22c9d126b18aff31bbcb082)), closes [#891](https://github.com/HiromiShikata/npm-cli-github-issue-tower-defence-management/issues/891)
|
|
7
|
+
|
|
1
8
|
## [1.92.1](https://github.com/HiromiShikata/npm-cli-github-issue-tower-defence-management/compare/v1.92.0...v1.92.1) (2026-06-21)
|
|
2
9
|
|
|
3
10
|
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
39
|
-
<
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
+
);
|
package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.test.ts
ADDED
|
@@ -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
|
+
});
|
package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
ACTION_TOAST_DELAY_MS,
|
|
4
|
+
type ConsoleToastColor,
|
|
5
|
+
} from '../logic/actionToast';
|
|
6
|
+
|
|
7
|
+
export type ConsoleQueuedAction = {
|
|
8
|
+
message: string;
|
|
9
|
+
color: ConsoleToastColor;
|
|
10
|
+
commit: () => void;
|
|
11
|
+
advance: () => void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type ConsolePendingActionView = {
|
|
15
|
+
message: string;
|
|
16
|
+
color: ConsoleToastColor;
|
|
17
|
+
remainingSeconds: number;
|
|
18
|
+
progress: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const COUNTDOWN_TICK_MS = 100;
|
|
22
|
+
|
|
23
|
+
const computeRemainingSeconds = (elapsedMs: number): number =>
|
|
24
|
+
Math.max(0, Math.ceil((ACTION_TOAST_DELAY_MS - elapsedMs) / 1000));
|
|
25
|
+
|
|
26
|
+
const computeProgress = (elapsedMs: number): number =>
|
|
27
|
+
Math.max(0, 1 - elapsedMs / ACTION_TOAST_DELAY_MS);
|
|
28
|
+
|
|
29
|
+
export type ConsoleActionQueue = {
|
|
30
|
+
pending: ConsolePendingActionView | null;
|
|
31
|
+
enqueue: (action: ConsoleQueuedAction) => void;
|
|
32
|
+
undo: () => void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const useConsoleActionQueue = (): ConsoleActionQueue => {
|
|
36
|
+
const [pending, setPending] = useState<ConsolePendingActionView | null>(null);
|
|
37
|
+
const actionRef = useRef<ConsoleQueuedAction | null>(null);
|
|
38
|
+
const startRef = useRef<number>(0);
|
|
39
|
+
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
40
|
+
const committedRef = useRef<boolean>(false);
|
|
41
|
+
|
|
42
|
+
const clearTimer = useCallback((): void => {
|
|
43
|
+
if (timerRef.current !== null) {
|
|
44
|
+
clearInterval(timerRef.current);
|
|
45
|
+
timerRef.current = null;
|
|
46
|
+
}
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
const commitPending = useCallback((): void => {
|
|
50
|
+
const action = actionRef.current;
|
|
51
|
+
clearTimer();
|
|
52
|
+
actionRef.current = null;
|
|
53
|
+
setPending(null);
|
|
54
|
+
if (action !== null && !committedRef.current) {
|
|
55
|
+
committedRef.current = true;
|
|
56
|
+
action.commit();
|
|
57
|
+
}
|
|
58
|
+
}, [clearTimer]);
|
|
59
|
+
|
|
60
|
+
const undo = useCallback((): void => {
|
|
61
|
+
clearTimer();
|
|
62
|
+
actionRef.current = null;
|
|
63
|
+
committedRef.current = true;
|
|
64
|
+
setPending(null);
|
|
65
|
+
}, [clearTimer]);
|
|
66
|
+
|
|
67
|
+
const enqueue = useCallback(
|
|
68
|
+
(action: ConsoleQueuedAction): void => {
|
|
69
|
+
if (actionRef.current !== null && !committedRef.current) {
|
|
70
|
+
const previous = actionRef.current;
|
|
71
|
+
clearTimer();
|
|
72
|
+
committedRef.current = true;
|
|
73
|
+
previous.commit();
|
|
74
|
+
}
|
|
75
|
+
committedRef.current = false;
|
|
76
|
+
actionRef.current = action;
|
|
77
|
+
startRef.current = Date.now();
|
|
78
|
+
setPending({
|
|
79
|
+
message: action.message,
|
|
80
|
+
color: action.color,
|
|
81
|
+
remainingSeconds: computeRemainingSeconds(0),
|
|
82
|
+
progress: computeProgress(0),
|
|
83
|
+
});
|
|
84
|
+
action.advance();
|
|
85
|
+
timerRef.current = setInterval(() => {
|
|
86
|
+
const elapsed = Date.now() - startRef.current;
|
|
87
|
+
if (elapsed >= ACTION_TOAST_DELAY_MS) {
|
|
88
|
+
commitPending();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
setPending({
|
|
92
|
+
message: action.message,
|
|
93
|
+
color: action.color,
|
|
94
|
+
remainingSeconds: computeRemainingSeconds(elapsed),
|
|
95
|
+
progress: computeProgress(elapsed),
|
|
96
|
+
});
|
|
97
|
+
}, COUNTDOWN_TICK_MS);
|
|
98
|
+
},
|
|
99
|
+
[clearTimer, commitPending],
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
useEffect(() => clearTimer, [clearTimer]);
|
|
103
|
+
|
|
104
|
+
return { pending, enqueue, undo };
|
|
105
|
+
};
|