github-issue-tower-defence-management 1.143.5 → 1.144.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 +14 -0
- package/bin/adapter/entry-points/cli/index.js +2 -0
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/console/consoleItemUrlLookup.js +88 -0
- package/bin/adapter/entry-points/console/consoleItemUrlLookup.js.map +1 -0
- package/bin/adapter/entry-points/console/consoleOperationApi.js +44 -1
- package/bin/adapter/entry-points/console/consoleOperationApi.js.map +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/index-y6LyqXlu.js +82 -0
- package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/bin/adapter/entry-points/console/webServer.js +3 -0
- package/bin/adapter/entry-points/console/webServer.js.map +1 -1
- package/bin/adapter/repositories/LocalCommandIssueAttachmentRepository.js +75 -0
- package/bin/adapter/repositories/LocalCommandIssueAttachmentRepository.js.map +1 -0
- package/bin/domain/usecases/adapter-interfaces/IssueAttachmentRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/IssueAttachmentRepository.js.map +1 -0
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/index.ts +4 -0
- package/src/adapter/entry-points/console/consoleItemUrlLookup.test.ts +63 -0
- package/src/adapter/entry-points/console/consoleItemUrlLookup.ts +66 -0
- package/src/adapter/entry-points/console/consoleOperationApi.test.ts +155 -0
- package/src/adapter/entry-points/console/consoleOperationApi.ts +56 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCommentComposer.stories.tsx +17 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCommentComposer.test.tsx +149 -1
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCommentComposer.tsx +107 -1
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.ts +22 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleTabData.test.ts +41 -2
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleTabData.ts +44 -28
- package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.ts +35 -0
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.test.tsx +1 -0
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.tsx +1 -0
- package/src/adapter/entry-points/console/ui-dist/assets/index-y6LyqXlu.js +82 -0
- package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/src/adapter/entry-points/console/webServer.ts +6 -0
- package/src/adapter/repositories/LocalCommandIssueAttachmentRepository.test.ts +136 -0
- package/src/adapter/repositories/LocalCommandIssueAttachmentRepository.ts +96 -0
- package/src/domain/usecases/adapter-interfaces/IssueAttachmentRepository.ts +9 -0
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/entry-points/console/consoleItemUrlLookup.d.ts +2 -0
- package/types/adapter/entry-points/console/consoleItemUrlLookup.d.ts.map +1 -0
- package/types/adapter/entry-points/console/consoleOperationApi.d.ts +3 -0
- package/types/adapter/entry-points/console/consoleOperationApi.d.ts.map +1 -1
- package/types/adapter/entry-points/console/webServer.d.ts +2 -0
- package/types/adapter/entry-points/console/webServer.d.ts.map +1 -1
- package/types/adapter/repositories/LocalCommandIssueAttachmentRepository.d.ts +15 -0
- package/types/adapter/repositories/LocalCommandIssueAttachmentRepository.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/IssueAttachmentRepository.d.ts +9 -0
- package/types/domain/usecases/adapter-interfaces/IssueAttachmentRepository.d.ts.map +1 -0
- package/bin/adapter/entry-points/console/ui-dist/assets/index-fTEmrrJ-.js +0 -81
- package/src/adapter/entry-points/console/ui-dist/assets/index-fTEmrrJ-.js +0 -81
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { fireEvent, render, waitFor } from '@testing-library/react';
|
|
2
2
|
import type { ConsoleComment } from '../../logic/types';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
appendAttachmentMarkdown,
|
|
5
|
+
ConsoleCommentComposer,
|
|
6
|
+
} from './ConsoleCommentComposer';
|
|
4
7
|
|
|
5
8
|
jest.mock('../../lib/mermaidLoader', () => ({
|
|
6
9
|
renderMermaidToSvg: jest.fn(async () => '<svg></svg>'),
|
|
@@ -78,4 +81,149 @@ describe('ConsoleCommentComposer', () => {
|
|
|
78
81
|
const alert = await findByRole('alert');
|
|
79
82
|
expect(alert.textContent).toContain('HTTP 500');
|
|
80
83
|
});
|
|
84
|
+
|
|
85
|
+
it('does not render the attach control when no upload handler is given', () => {
|
|
86
|
+
const { queryByText } = render(
|
|
87
|
+
<ConsoleCommentComposer
|
|
88
|
+
isPr={false}
|
|
89
|
+
now={now}
|
|
90
|
+
onSubmit={async (body) => ({
|
|
91
|
+
author: 'HiromiShikata',
|
|
92
|
+
body,
|
|
93
|
+
createdAt: '2026-06-19T11:58:00.000Z',
|
|
94
|
+
})}
|
|
95
|
+
/>,
|
|
96
|
+
);
|
|
97
|
+
expect(queryByText('📎 Attach files')).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('uploads a selected file and inserts the returned markdown into the draft', async () => {
|
|
101
|
+
const onUploadFile = jest.fn(
|
|
102
|
+
async () => '',
|
|
103
|
+
);
|
|
104
|
+
const { getByPlaceholderText, getByLabelText } = render(
|
|
105
|
+
<ConsoleCommentComposer
|
|
106
|
+
isPr={false}
|
|
107
|
+
now={now}
|
|
108
|
+
onSubmit={async (body) => ({
|
|
109
|
+
author: 'HiromiShikata',
|
|
110
|
+
body,
|
|
111
|
+
createdAt: '2026-06-19T11:58:00.000Z',
|
|
112
|
+
})}
|
|
113
|
+
onUploadFile={onUploadFile}
|
|
114
|
+
/>,
|
|
115
|
+
);
|
|
116
|
+
const textarea = getByPlaceholderText(
|
|
117
|
+
'Leave a comment…',
|
|
118
|
+
) as HTMLTextAreaElement;
|
|
119
|
+
fireEvent.change(textarea, { target: { value: 'See the screen:' } });
|
|
120
|
+
const file = new File(['binary'], 'shot.png', { type: 'image/png' });
|
|
121
|
+
fireEvent.change(getByLabelText('Attach files'), {
|
|
122
|
+
target: { files: [file] },
|
|
123
|
+
});
|
|
124
|
+
await waitFor(() => {
|
|
125
|
+
expect(textarea.value).toBe(
|
|
126
|
+
'See the screen:\n\n',
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
expect(onUploadFile).toHaveBeenCalledWith(file);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('uploads a pasted file', async () => {
|
|
133
|
+
const onUploadFile = jest.fn(
|
|
134
|
+
async () => '',
|
|
135
|
+
);
|
|
136
|
+
const { getByPlaceholderText } = render(
|
|
137
|
+
<ConsoleCommentComposer
|
|
138
|
+
isPr={false}
|
|
139
|
+
now={now}
|
|
140
|
+
onSubmit={async (body) => ({
|
|
141
|
+
author: 'HiromiShikata',
|
|
142
|
+
body,
|
|
143
|
+
createdAt: '2026-06-19T11:58:00.000Z',
|
|
144
|
+
})}
|
|
145
|
+
onUploadFile={onUploadFile}
|
|
146
|
+
/>,
|
|
147
|
+
);
|
|
148
|
+
const textarea = getByPlaceholderText(
|
|
149
|
+
'Leave a comment…',
|
|
150
|
+
) as HTMLTextAreaElement;
|
|
151
|
+
const file = new File(['binary'], 'pasted.png', { type: 'image/png' });
|
|
152
|
+
fireEvent.paste(textarea, { clipboardData: { files: [file] } });
|
|
153
|
+
await waitFor(() => {
|
|
154
|
+
expect(textarea.value).toBe(
|
|
155
|
+
'\n',
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
expect(onUploadFile).toHaveBeenCalledWith(file);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('uploads a dropped file', async () => {
|
|
162
|
+
const onUploadFile = jest.fn(
|
|
163
|
+
async () => '',
|
|
164
|
+
);
|
|
165
|
+
const { getByPlaceholderText } = render(
|
|
166
|
+
<ConsoleCommentComposer
|
|
167
|
+
isPr={false}
|
|
168
|
+
now={now}
|
|
169
|
+
onSubmit={async (body) => ({
|
|
170
|
+
author: 'HiromiShikata',
|
|
171
|
+
body,
|
|
172
|
+
createdAt: '2026-06-19T11:58:00.000Z',
|
|
173
|
+
})}
|
|
174
|
+
onUploadFile={onUploadFile}
|
|
175
|
+
/>,
|
|
176
|
+
);
|
|
177
|
+
const textarea = getByPlaceholderText(
|
|
178
|
+
'Leave a comment…',
|
|
179
|
+
) as HTMLTextAreaElement;
|
|
180
|
+
const file = new File(['binary'], 'dropped.png', { type: 'image/png' });
|
|
181
|
+
fireEvent.drop(textarea, { dataTransfer: { files: [file] } });
|
|
182
|
+
await waitFor(() => {
|
|
183
|
+
expect(textarea.value).toBe(
|
|
184
|
+
'\n',
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
expect(onUploadFile).toHaveBeenCalledWith(file);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('shows the upload failure reason when the upload rejects', async () => {
|
|
191
|
+
const { getByLabelText, findByRole } = render(
|
|
192
|
+
<ConsoleCommentComposer
|
|
193
|
+
isPr={false}
|
|
194
|
+
now={now}
|
|
195
|
+
onSubmit={async (body) => ({
|
|
196
|
+
author: 'HiromiShikata',
|
|
197
|
+
body,
|
|
198
|
+
createdAt: '2026-06-19T11:58:00.000Z',
|
|
199
|
+
})}
|
|
200
|
+
onUploadFile={async () => {
|
|
201
|
+
throw new Error('No GitHub web session is available');
|
|
202
|
+
}}
|
|
203
|
+
/>,
|
|
204
|
+
);
|
|
205
|
+
fireEvent.change(getByLabelText('Attach files'), {
|
|
206
|
+
target: { files: [new File(['x'], 'shot.png', { type: 'image/png' })] },
|
|
207
|
+
});
|
|
208
|
+
const alert = await findByRole('alert');
|
|
209
|
+
expect(alert.textContent).toContain('No GitHub web session is available');
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe('appendAttachmentMarkdown', () => {
|
|
214
|
+
it('returns the markdown alone when the draft is empty', () => {
|
|
215
|
+
expect(appendAttachmentMarkdown('', '')).toBe('\n');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('adds a separating newline when the draft does not end with one', () => {
|
|
219
|
+
expect(appendAttachmentMarkdown('hello', '')).toBe(
|
|
220
|
+
'hello\n\n',
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('does not add a second newline when the draft already ends with one', () => {
|
|
225
|
+
expect(appendAttachmentMarkdown('hello\n', '')).toBe(
|
|
226
|
+
'hello\n\n',
|
|
227
|
+
);
|
|
228
|
+
});
|
|
81
229
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
1
|
+
import { useRef, useState } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
formatFullTimestamp,
|
|
4
4
|
formatRelativeTime,
|
|
@@ -10,6 +10,7 @@ export type ConsoleCommentComposerProps = {
|
|
|
10
10
|
isPr: boolean;
|
|
11
11
|
now: number;
|
|
12
12
|
onSubmit: (body: string) => Promise<ConsoleComment>;
|
|
13
|
+
onUploadFile?: (file: File) => Promise<string>;
|
|
13
14
|
};
|
|
14
15
|
|
|
15
16
|
type ComposerStatus =
|
|
@@ -17,15 +18,38 @@ type ComposerStatus =
|
|
|
17
18
|
| { kind: 'posting' }
|
|
18
19
|
| { kind: 'error'; message: string };
|
|
19
20
|
|
|
21
|
+
type UploadStatus =
|
|
22
|
+
| { kind: 'idle' }
|
|
23
|
+
| { kind: 'uploading'; fileName: string }
|
|
24
|
+
| { kind: 'error'; message: string };
|
|
25
|
+
|
|
26
|
+
export const appendAttachmentMarkdown = (
|
|
27
|
+
draft: string,
|
|
28
|
+
markdown: string,
|
|
29
|
+
): string => {
|
|
30
|
+
if (draft.length === 0) {
|
|
31
|
+
return `${markdown}\n`;
|
|
32
|
+
}
|
|
33
|
+
if (draft.endsWith('\n')) {
|
|
34
|
+
return `${draft}${markdown}\n`;
|
|
35
|
+
}
|
|
36
|
+
return `${draft}\n${markdown}\n`;
|
|
37
|
+
};
|
|
38
|
+
|
|
20
39
|
export const ConsoleCommentComposer = ({
|
|
21
40
|
isPr,
|
|
22
41
|
now,
|
|
23
42
|
onSubmit,
|
|
43
|
+
onUploadFile,
|
|
24
44
|
}: ConsoleCommentComposerProps) => {
|
|
25
45
|
const [open, setOpen] = useState<boolean>(!isPr);
|
|
26
46
|
const [draft, setDraft] = useState<string>('');
|
|
27
47
|
const [status, setStatus] = useState<ComposerStatus>({ kind: 'idle' });
|
|
48
|
+
const [uploadStatus, setUploadStatus] = useState<UploadStatus>({
|
|
49
|
+
kind: 'idle',
|
|
50
|
+
});
|
|
28
51
|
const [posted, setPosted] = useState<ConsoleComment[]>([]);
|
|
52
|
+
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
29
53
|
|
|
30
54
|
const submit = async (): Promise<void> => {
|
|
31
55
|
const body = draft.trim();
|
|
@@ -46,6 +70,27 @@ export const ConsoleCommentComposer = ({
|
|
|
46
70
|
}
|
|
47
71
|
};
|
|
48
72
|
|
|
73
|
+
const uploadFiles = async (files: File[]): Promise<void> => {
|
|
74
|
+
if (onUploadFile === undefined || files.length === 0) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
for (const file of files) {
|
|
78
|
+
setUploadStatus({ kind: 'uploading', fileName: file.name });
|
|
79
|
+
try {
|
|
80
|
+
const markdown = await onUploadFile(file);
|
|
81
|
+
setDraft((previous) => appendAttachmentMarkdown(previous, markdown));
|
|
82
|
+
setUploadStatus({ kind: 'idle' });
|
|
83
|
+
} catch (error) {
|
|
84
|
+
setUploadStatus({
|
|
85
|
+
kind: 'error',
|
|
86
|
+
message:
|
|
87
|
+
error instanceof Error ? error.message : 'failed to upload file',
|
|
88
|
+
});
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
49
94
|
return (
|
|
50
95
|
<div className="console-composer">
|
|
51
96
|
<button
|
|
@@ -87,7 +132,68 @@ export const ConsoleCommentComposer = ({
|
|
|
87
132
|
placeholder="Leave a comment…"
|
|
88
133
|
value={draft}
|
|
89
134
|
onChange={(event) => setDraft(event.target.value)}
|
|
135
|
+
onDragOver={(event) => {
|
|
136
|
+
if (onUploadFile === undefined) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
event.preventDefault();
|
|
140
|
+
}}
|
|
141
|
+
onDrop={(event) => {
|
|
142
|
+
if (onUploadFile === undefined) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
event.preventDefault();
|
|
146
|
+
void uploadFiles(Array.from(event.dataTransfer.files));
|
|
147
|
+
}}
|
|
148
|
+
onPaste={(event) => {
|
|
149
|
+
if (onUploadFile === undefined) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const files = Array.from(event.clipboardData.files);
|
|
153
|
+
if (files.length === 0) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
event.preventDefault();
|
|
157
|
+
void uploadFiles(files);
|
|
158
|
+
}}
|
|
90
159
|
/>
|
|
160
|
+
{onUploadFile !== undefined && (
|
|
161
|
+
<div className="console-composer-attach">
|
|
162
|
+
<input
|
|
163
|
+
ref={fileInputRef}
|
|
164
|
+
type="file"
|
|
165
|
+
multiple
|
|
166
|
+
className="console-composer-file-input"
|
|
167
|
+
aria-label="Attach files"
|
|
168
|
+
onChange={(event) => {
|
|
169
|
+
const files = Array.from(event.target.files ?? []);
|
|
170
|
+
event.target.value = '';
|
|
171
|
+
void uploadFiles(files);
|
|
172
|
+
}}
|
|
173
|
+
/>
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
className="console-composer-attach-button"
|
|
177
|
+
disabled={uploadStatus.kind === 'uploading'}
|
|
178
|
+
onClick={() => fileInputRef.current?.click()}
|
|
179
|
+
>
|
|
180
|
+
📎 Attach files
|
|
181
|
+
</button>
|
|
182
|
+
{uploadStatus.kind === 'uploading' && (
|
|
183
|
+
<span className="console-composer-status">
|
|
184
|
+
Uploading {uploadStatus.fileName}…
|
|
185
|
+
</span>
|
|
186
|
+
)}
|
|
187
|
+
{uploadStatus.kind === 'error' && (
|
|
188
|
+
<span
|
|
189
|
+
role="alert"
|
|
190
|
+
className="console-composer-status console-composer-error"
|
|
191
|
+
>
|
|
192
|
+
Upload failed: {uploadStatus.message}
|
|
193
|
+
</span>
|
|
194
|
+
)}
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
91
197
|
<div className="console-composer-row">
|
|
92
198
|
{status.kind === 'posting' && (
|
|
93
199
|
<span className="console-composer-status">Posting…</span>
|
package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.ts
CHANGED
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
type ConsoleReviewCommentSide,
|
|
5
5
|
type ConsoleReviewRequest,
|
|
6
6
|
type ConsoleTriageRequest,
|
|
7
|
+
encodeAttachmentContent,
|
|
8
|
+
postConsoleAttachment,
|
|
7
9
|
postConsoleComment,
|
|
8
10
|
postConsoleOperation,
|
|
9
11
|
postConsoleReviewComment,
|
|
@@ -59,6 +61,7 @@ export type ConsoleOperationsApi = {
|
|
|
59
61
|
action: ConsoleCloseAction,
|
|
60
62
|
) => Promise<void>;
|
|
61
63
|
addComment: (item: ConsoleListItem, body: string) => Promise<ConsoleComment>;
|
|
64
|
+
uploadAttachment: (item: ConsoleListItem, file: File) => Promise<string>;
|
|
62
65
|
addInlineReviewComment: (
|
|
63
66
|
prUrl: string,
|
|
64
67
|
path: string,
|
|
@@ -286,6 +289,24 @@ export const useConsoleOperations = (
|
|
|
286
289
|
[pjcode, invalidateItemContent],
|
|
287
290
|
);
|
|
288
291
|
|
|
292
|
+
const uploadAttachment = useCallback(
|
|
293
|
+
async (item: ConsoleListItem, file: File) => {
|
|
294
|
+
if (pjcode === null) {
|
|
295
|
+
throw new Error('no project in the URL path');
|
|
296
|
+
}
|
|
297
|
+
const contentBase64 = encodeAttachmentContent(
|
|
298
|
+
new Uint8Array(await file.arrayBuffer()),
|
|
299
|
+
);
|
|
300
|
+
return postConsoleAttachment({
|
|
301
|
+
pjcode,
|
|
302
|
+
url: item.url,
|
|
303
|
+
fileName: file.name,
|
|
304
|
+
contentBase64,
|
|
305
|
+
});
|
|
306
|
+
},
|
|
307
|
+
[pjcode],
|
|
308
|
+
);
|
|
309
|
+
|
|
289
310
|
const addInlineReviewComment = useCallback(
|
|
290
311
|
async (
|
|
291
312
|
prUrl: string,
|
|
@@ -317,6 +338,7 @@ export const useConsoleOperations = (
|
|
|
317
338
|
setInTmuxByHuman,
|
|
318
339
|
closeIssue,
|
|
319
340
|
addComment,
|
|
341
|
+
uploadAttachment,
|
|
320
342
|
addInlineReviewComment,
|
|
321
343
|
};
|
|
322
344
|
};
|
package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleTabData.test.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import { renderHook, waitFor } from '@testing-library/react';
|
|
1
|
+
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
2
2
|
import { CONSOLE_TABS } from '../logic/types';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
CONSOLE_TAB_REFRESH_INTERVAL_MS,
|
|
5
|
+
useConsoleTabData,
|
|
6
|
+
} from './useConsoleTabData';
|
|
4
7
|
|
|
5
8
|
describe('useConsoleTabData', () => {
|
|
6
9
|
beforeEach(() => {
|
|
@@ -38,6 +41,42 @@ describe('useConsoleTabData', () => {
|
|
|
38
41
|
);
|
|
39
42
|
});
|
|
40
43
|
|
|
44
|
+
it('drops an item that disappeared from the snapshot after the refresh interval', async () => {
|
|
45
|
+
jest.useFakeTimers();
|
|
46
|
+
let closed = false;
|
|
47
|
+
const fetchMock = jest.fn(async (url: string) => ({
|
|
48
|
+
ok: true,
|
|
49
|
+
status: 200,
|
|
50
|
+
json: async () => ({
|
|
51
|
+
pjcode: 'umino',
|
|
52
|
+
generatedAt: '2026-06-19T00:00:00.000Z',
|
|
53
|
+
statusOptions: [],
|
|
54
|
+
storyColors: {},
|
|
55
|
+
items:
|
|
56
|
+
url.includes('/prs/') && !closed
|
|
57
|
+
? [{ number: 1, itemId: 'PVTI_1', projectItemId: 'PVTI_1' }]
|
|
58
|
+
: [],
|
|
59
|
+
}),
|
|
60
|
+
}));
|
|
61
|
+
global.fetch = fetchMock as unknown as typeof fetch;
|
|
62
|
+
|
|
63
|
+
const { result } = renderHook(() => useConsoleTabData('umino'));
|
|
64
|
+
await waitFor(() => {
|
|
65
|
+
expect(result.current.snapshots.prs?.items.length).toBe(1);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
closed = true;
|
|
69
|
+
await act(async () => {
|
|
70
|
+
jest.advanceTimersByTime(CONSOLE_TAB_REFRESH_INTERVAL_MS);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await waitFor(() => {
|
|
74
|
+
expect(result.current.snapshots.prs?.items.length).toBe(0);
|
|
75
|
+
});
|
|
76
|
+
expect(fetchMock.mock.calls.length).toBe(CONSOLE_TABS.length * 2);
|
|
77
|
+
jest.useRealTimers();
|
|
78
|
+
});
|
|
79
|
+
|
|
41
80
|
it('surfaces an error when a tab fetch fails', async () => {
|
|
42
81
|
const fetchMock = jest.fn(async () => ({
|
|
43
82
|
ok: false,
|
|
@@ -66,6 +66,28 @@ const emptySnapshots = (): Record<
|
|
|
66
66
|
const buildListUrl = (pjcode: string, tab: ConsoleTabName): string =>
|
|
67
67
|
`/projects/${pjcode}/${tab}/list.json`;
|
|
68
68
|
|
|
69
|
+
export const CONSOLE_TAB_REFRESH_INTERVAL_MS = 60000;
|
|
70
|
+
|
|
71
|
+
const fetchSnapshots = async (
|
|
72
|
+
pjcode: string,
|
|
73
|
+
): Promise<Record<ConsoleTabName, ConsoleTabSnapshot | null>> => {
|
|
74
|
+
const entries = await Promise.all(
|
|
75
|
+
CONSOLE_TABS.map(async (tab) => {
|
|
76
|
+
const response = await fetch(buildListUrl(pjcode, tab.name));
|
|
77
|
+
if (!response.ok) {
|
|
78
|
+
throw new Error(`HTTP ${response.status}`);
|
|
79
|
+
}
|
|
80
|
+
const payload: unknown = await response.json();
|
|
81
|
+
return [tab.name, parseSnapshot(payload)] as const;
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
const next = emptySnapshots();
|
|
85
|
+
for (const [name, snapshot] of entries) {
|
|
86
|
+
next[name] = snapshot;
|
|
87
|
+
}
|
|
88
|
+
return next;
|
|
89
|
+
};
|
|
90
|
+
|
|
69
91
|
export const useConsoleTabData = (
|
|
70
92
|
pjcode: string | null,
|
|
71
93
|
): ConsoleTabDataState => {
|
|
@@ -88,37 +110,31 @@ export const useConsoleTabData = (
|
|
|
88
110
|
};
|
|
89
111
|
}
|
|
90
112
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
.catch((cause: unknown) => {
|
|
113
|
-
if (cancelled) {
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
setError(cause instanceof Error ? cause.message : String(cause));
|
|
117
|
-
setIsLoading(false);
|
|
118
|
-
});
|
|
113
|
+
const load = (): void => {
|
|
114
|
+
fetchSnapshots(pjcode)
|
|
115
|
+
.then((next) => {
|
|
116
|
+
if (cancelled) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
setSnapshots(next);
|
|
120
|
+
setError(null);
|
|
121
|
+
setIsLoading(false);
|
|
122
|
+
})
|
|
123
|
+
.catch((cause: unknown) => {
|
|
124
|
+
if (cancelled) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
setError(cause instanceof Error ? cause.message : String(cause));
|
|
128
|
+
setIsLoading(false);
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
load();
|
|
133
|
+
const timer = setInterval(load, CONSOLE_TAB_REFRESH_INTERVAL_MS);
|
|
119
134
|
|
|
120
135
|
return () => {
|
|
121
136
|
cancelled = true;
|
|
137
|
+
clearInterval(timer);
|
|
122
138
|
};
|
|
123
139
|
}, [pjcode]);
|
|
124
140
|
|
|
@@ -288,6 +288,41 @@ export const postConsoleComment = async (
|
|
|
288
288
|
return parsePostedComment(await response.json());
|
|
289
289
|
};
|
|
290
290
|
|
|
291
|
+
export const ATTACHMENT_UPLOAD_OPERATION_PATH = '/api/upload';
|
|
292
|
+
|
|
293
|
+
export type ConsoleAttachmentUploadRequest = {
|
|
294
|
+
pjcode: string;
|
|
295
|
+
url: string;
|
|
296
|
+
fileName: string;
|
|
297
|
+
contentBase64: string;
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
export const encodeAttachmentContent = (bytes: Uint8Array): string => {
|
|
301
|
+
let binary = '';
|
|
302
|
+
for (const byte of bytes) {
|
|
303
|
+
binary += String.fromCharCode(byte);
|
|
304
|
+
}
|
|
305
|
+
return btoa(binary);
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
export const postConsoleAttachment = async (
|
|
309
|
+
request: ConsoleAttachmentUploadRequest,
|
|
310
|
+
): Promise<string> => {
|
|
311
|
+
const response = await fetch(ATTACHMENT_UPLOAD_OPERATION_PATH, {
|
|
312
|
+
method: 'POST',
|
|
313
|
+
headers: { 'Content-Type': 'application/json' },
|
|
314
|
+
body: JSON.stringify(request),
|
|
315
|
+
});
|
|
316
|
+
if (!response.ok) {
|
|
317
|
+
throw new Error(await readOperationErrorReason(response));
|
|
318
|
+
}
|
|
319
|
+
const payload: unknown = await response.json();
|
|
320
|
+
if (!isRecord(payload) || typeof payload.markdown !== 'string') {
|
|
321
|
+
throw new Error('markdown was not returned');
|
|
322
|
+
}
|
|
323
|
+
return payload.markdown;
|
|
324
|
+
};
|
|
325
|
+
|
|
291
326
|
export const REVIEW_COMMENT_OPERATION_PATH = '/api/reviewcomment';
|
|
292
327
|
|
|
293
328
|
export const postConsoleReviewComment = async (
|