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.
- package/.github/workflows/console-ui.yml +3 -0
- package/.github/workflows/publish.yml +4 -0
- package/CHANGELOG.md +14 -0
- package/bin/adapter/entry-points/console/ui-dist/assets/index-C5nxEEOu.css +1 -0
- package/bin/adapter/entry-points/console/ui-dist/assets/index-DoJ05EuW.js +101 -0
- package/bin/adapter/entry-points/console/ui-dist/index.html +2 -2
- package/package.json +1 -1
- package/scripts/verifyConsoleUiDist.mjs +96 -0
- 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/bin/adapter/entry-points/console/ui-dist/assets/index-BJixkRxv.css +0 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/index-BSNMvjcB.js +0 -100
- 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
|
@@ -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-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
@@ -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
|
-
|
|
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
|
+
});
|