github-issue-tower-defence-management 2.12.1 → 2.13.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/bin/adapter/entry-points/console/ui-dist/assets/{index-D6QOQvoN.js → index-BTFgj8n5.js} +31 -31
- package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleOfflinePendingActionsPanel.stories.tsx +115 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleOfflinePendingActionsPanel.test.tsx +262 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleOfflinePendingActionsPanel.tsx +123 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.test.ts +283 -0
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.ts +146 -4
- package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.ts +28 -1
- package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.ts +2 -2
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.tsx +103 -1
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +128 -0
- package/src/adapter/entry-points/console/ui-dist/assets/{index-D6QOQvoN.js → index-BTFgj8n5.js} +31 -31
- package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.test.ts
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
import { act, renderHook } from '@testing-library/react';
|
|
2
2
|
import { useConsoleActionQueue } from './useConsoleActionQueue';
|
|
3
3
|
|
|
4
|
+
const OFFLINE_QUEUE_STORAGE_KEY = 'console-offline-action-queue';
|
|
5
|
+
|
|
6
|
+
const offlinePayload = {
|
|
7
|
+
itemUrl: 'https://github.com/o/r/pull/851',
|
|
8
|
+
projectItemId: 'PVTI_1',
|
|
9
|
+
itemNumber: 851,
|
|
10
|
+
repo: 'o/r',
|
|
11
|
+
isPr: true,
|
|
12
|
+
apiPath: '/api/review',
|
|
13
|
+
requestBody: {
|
|
14
|
+
pjcode: 'acme',
|
|
15
|
+
action: 'approve',
|
|
16
|
+
prUrl: 'https://github.com/o/r/pull/851',
|
|
17
|
+
projectItemId: 'PVTI_1',
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
4
21
|
const makeAction = (
|
|
5
22
|
overrides: Partial<
|
|
6
23
|
Parameters<ReturnType<typeof useConsoleActionQueue>['enqueue']>[0]
|
|
@@ -19,10 +36,12 @@ const flushMicrotasks = (): Promise<void> =>
|
|
|
19
36
|
describe('useConsoleActionQueue', () => {
|
|
20
37
|
beforeEach(() => {
|
|
21
38
|
jest.useFakeTimers();
|
|
39
|
+
localStorage.clear();
|
|
22
40
|
});
|
|
23
41
|
|
|
24
42
|
afterEach(() => {
|
|
25
43
|
jest.useRealTimers();
|
|
44
|
+
localStorage.clear();
|
|
26
45
|
});
|
|
27
46
|
|
|
28
47
|
it('advances immediately but only commits after the five second window', () => {
|
|
@@ -212,4 +231,268 @@ describe('useConsoleActionQueue', () => {
|
|
|
212
231
|
});
|
|
213
232
|
expect(result.current.pending).toBeNull();
|
|
214
233
|
});
|
|
234
|
+
it('adds the action to the offline queue when the commit rejects with a network error', async () => {
|
|
235
|
+
const { result } = renderHook(() => useConsoleActionQueue());
|
|
236
|
+
const action = makeAction({
|
|
237
|
+
commit: jest
|
|
238
|
+
.fn<Promise<void>, []>()
|
|
239
|
+
.mockRejectedValue(new TypeError('Failed to fetch')),
|
|
240
|
+
offline: offlinePayload,
|
|
241
|
+
});
|
|
242
|
+
act(() => {
|
|
243
|
+
result.current.enqueue(action);
|
|
244
|
+
});
|
|
245
|
+
await act(async () => {
|
|
246
|
+
jest.advanceTimersByTime(5000);
|
|
247
|
+
await flushMicrotasks();
|
|
248
|
+
});
|
|
249
|
+
expect(result.current.error).toBeNull();
|
|
250
|
+
expect(result.current.offlineActions).toHaveLength(1);
|
|
251
|
+
expect(result.current.offlineActions[0].message).toBe('Approved — PR #851');
|
|
252
|
+
expect(result.current.offlineActions[0].apiPath).toBe('/api/review');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('surfaces a server error as an error rather than queuing it offline', async () => {
|
|
256
|
+
const { result } = renderHook(() => useConsoleActionQueue());
|
|
257
|
+
const action = makeAction({
|
|
258
|
+
commit: jest
|
|
259
|
+
.fn<Promise<void>, []>()
|
|
260
|
+
.mockRejectedValue(new Error('HTTP 422 review cannot be requested')),
|
|
261
|
+
offline: offlinePayload,
|
|
262
|
+
});
|
|
263
|
+
act(() => {
|
|
264
|
+
result.current.enqueue(action);
|
|
265
|
+
});
|
|
266
|
+
await act(async () => {
|
|
267
|
+
jest.advanceTimersByTime(5000);
|
|
268
|
+
await flushMicrotasks();
|
|
269
|
+
});
|
|
270
|
+
expect(result.current.error).toEqual({
|
|
271
|
+
message: 'Approved — PR #851',
|
|
272
|
+
reason: 'HTTP 422 review cannot be requested',
|
|
273
|
+
});
|
|
274
|
+
expect(result.current.offlineActions).toHaveLength(0);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('keeps the offline queue in localStorage so it survives a remount', async () => {
|
|
278
|
+
const { result, unmount } = renderHook(() => useConsoleActionQueue());
|
|
279
|
+
const action = makeAction({
|
|
280
|
+
commit: jest
|
|
281
|
+
.fn<Promise<void>, []>()
|
|
282
|
+
.mockRejectedValue(new TypeError('Failed to fetch')),
|
|
283
|
+
offline: offlinePayload,
|
|
284
|
+
});
|
|
285
|
+
act(() => {
|
|
286
|
+
result.current.enqueue(action);
|
|
287
|
+
});
|
|
288
|
+
await act(async () => {
|
|
289
|
+
jest.advanceTimersByTime(5000);
|
|
290
|
+
await flushMicrotasks();
|
|
291
|
+
});
|
|
292
|
+
expect(result.current.offlineActions).toHaveLength(1);
|
|
293
|
+
unmount();
|
|
294
|
+
|
|
295
|
+
const { result: result2 } = renderHook(() => useConsoleActionQueue());
|
|
296
|
+
expect(result2.current.offlineActions).toHaveLength(1);
|
|
297
|
+
expect(result2.current.offlineActions[0].message).toBe(
|
|
298
|
+
'Approved — PR #851',
|
|
299
|
+
);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('does not send anything before confirmOfflineAction is called', async () => {
|
|
303
|
+
const fetchMock = jest.fn();
|
|
304
|
+
global.fetch = fetchMock as unknown as typeof fetch;
|
|
305
|
+
const { result } = renderHook(() => useConsoleActionQueue());
|
|
306
|
+
const action = makeAction({
|
|
307
|
+
commit: jest
|
|
308
|
+
.fn<Promise<void>, []>()
|
|
309
|
+
.mockRejectedValue(new TypeError('Failed to fetch')),
|
|
310
|
+
offline: offlinePayload,
|
|
311
|
+
});
|
|
312
|
+
act(() => {
|
|
313
|
+
result.current.enqueue(action);
|
|
314
|
+
});
|
|
315
|
+
await act(async () => {
|
|
316
|
+
jest.advanceTimersByTime(5000);
|
|
317
|
+
await flushMicrotasks();
|
|
318
|
+
});
|
|
319
|
+
expect(result.current.offlineActions).toHaveLength(1);
|
|
320
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('sends the stored request and removes the action when confirmOfflineAction is called', async () => {
|
|
324
|
+
const fetchMock = jest.fn(async () => ({
|
|
325
|
+
ok: true,
|
|
326
|
+
status: 200,
|
|
327
|
+
json: async () => ({}),
|
|
328
|
+
}));
|
|
329
|
+
global.fetch = fetchMock as unknown as typeof fetch;
|
|
330
|
+
const { result } = renderHook(() => useConsoleActionQueue());
|
|
331
|
+
const action = makeAction({
|
|
332
|
+
commit: jest
|
|
333
|
+
.fn<Promise<void>, []>()
|
|
334
|
+
.mockRejectedValue(new TypeError('Failed to fetch')),
|
|
335
|
+
offline: offlinePayload,
|
|
336
|
+
});
|
|
337
|
+
act(() => {
|
|
338
|
+
result.current.enqueue(action);
|
|
339
|
+
});
|
|
340
|
+
await act(async () => {
|
|
341
|
+
jest.advanceTimersByTime(5000);
|
|
342
|
+
await flushMicrotasks();
|
|
343
|
+
});
|
|
344
|
+
const id = result.current.offlineActions[0].id;
|
|
345
|
+
await act(async () => {
|
|
346
|
+
await result.current.confirmOfflineAction(id);
|
|
347
|
+
});
|
|
348
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
349
|
+
'/api/review',
|
|
350
|
+
expect.objectContaining({ method: 'POST' }),
|
|
351
|
+
);
|
|
352
|
+
expect(result.current.offlineActions).toHaveLength(0);
|
|
353
|
+
expect(result.current.error).toBeNull();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('sets an error and keeps the action when confirmOfflineAction encounters a network error', async () => {
|
|
357
|
+
const fetchMock = jest
|
|
358
|
+
.fn()
|
|
359
|
+
.mockRejectedValue(new TypeError('Failed to fetch'));
|
|
360
|
+
global.fetch = fetchMock as unknown as typeof fetch;
|
|
361
|
+
const { result } = renderHook(() => useConsoleActionQueue());
|
|
362
|
+
const action = makeAction({
|
|
363
|
+
commit: jest
|
|
364
|
+
.fn<Promise<void>, []>()
|
|
365
|
+
.mockRejectedValue(new TypeError('Failed to fetch')),
|
|
366
|
+
offline: offlinePayload,
|
|
367
|
+
});
|
|
368
|
+
act(() => {
|
|
369
|
+
result.current.enqueue(action);
|
|
370
|
+
});
|
|
371
|
+
await act(async () => {
|
|
372
|
+
jest.advanceTimersByTime(5000);
|
|
373
|
+
await flushMicrotasks();
|
|
374
|
+
});
|
|
375
|
+
const id = result.current.offlineActions[0].id;
|
|
376
|
+
await act(async () => {
|
|
377
|
+
await result.current.confirmOfflineAction(id);
|
|
378
|
+
});
|
|
379
|
+
expect(result.current.error).toEqual({
|
|
380
|
+
message: 'Approved — PR #851',
|
|
381
|
+
reason: 'Still offline — action is still held in the queue',
|
|
382
|
+
});
|
|
383
|
+
expect(result.current.offlineActions).toHaveLength(1);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('surfaces a server rejection from confirmOfflineAction as an error and removes the action', async () => {
|
|
387
|
+
const fetchMock = jest.fn(async () => ({
|
|
388
|
+
ok: false,
|
|
389
|
+
status: 422,
|
|
390
|
+
text: async () => 'review cannot be requested',
|
|
391
|
+
}));
|
|
392
|
+
global.fetch = fetchMock as unknown as typeof fetch;
|
|
393
|
+
const { result } = renderHook(() => useConsoleActionQueue());
|
|
394
|
+
const action = makeAction({
|
|
395
|
+
commit: jest
|
|
396
|
+
.fn<Promise<void>, []>()
|
|
397
|
+
.mockRejectedValue(new TypeError('Failed to fetch')),
|
|
398
|
+
offline: offlinePayload,
|
|
399
|
+
});
|
|
400
|
+
act(() => {
|
|
401
|
+
result.current.enqueue(action);
|
|
402
|
+
});
|
|
403
|
+
await act(async () => {
|
|
404
|
+
jest.advanceTimersByTime(5000);
|
|
405
|
+
await flushMicrotasks();
|
|
406
|
+
});
|
|
407
|
+
const id = result.current.offlineActions[0].id;
|
|
408
|
+
await act(async () => {
|
|
409
|
+
await result.current.confirmOfflineAction(id);
|
|
410
|
+
});
|
|
411
|
+
expect(result.current.error).not.toBeNull();
|
|
412
|
+
expect(result.current.offlineActions).toHaveLength(0);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('generates a stable action id in non-secure contexts where crypto.randomUUID is unavailable', async () => {
|
|
416
|
+
const originalDescriptor = Object.getOwnPropertyDescriptor(
|
|
417
|
+
crypto,
|
|
418
|
+
'randomUUID',
|
|
419
|
+
);
|
|
420
|
+
Object.defineProperty(crypto, 'randomUUID', {
|
|
421
|
+
value: undefined,
|
|
422
|
+
configurable: true,
|
|
423
|
+
});
|
|
424
|
+
try {
|
|
425
|
+
const { result } = renderHook(() => useConsoleActionQueue());
|
|
426
|
+
const action = makeAction({
|
|
427
|
+
commit: jest
|
|
428
|
+
.fn<Promise<void>, []>()
|
|
429
|
+
.mockRejectedValue(new TypeError('Failed to fetch')),
|
|
430
|
+
offline: offlinePayload,
|
|
431
|
+
});
|
|
432
|
+
act(() => {
|
|
433
|
+
result.current.enqueue(action);
|
|
434
|
+
});
|
|
435
|
+
await act(async () => {
|
|
436
|
+
jest.advanceTimersByTime(5000);
|
|
437
|
+
await flushMicrotasks();
|
|
438
|
+
});
|
|
439
|
+
expect(result.current.offlineActions).toHaveLength(1);
|
|
440
|
+
expect(typeof result.current.offlineActions[0].id).toBe('string');
|
|
441
|
+
expect(result.current.offlineActions[0].id.length).toBeGreaterThan(0);
|
|
442
|
+
} finally {
|
|
443
|
+
if (originalDescriptor !== undefined) {
|
|
444
|
+
Object.defineProperty(crypto, 'randomUUID', originalDescriptor);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('skips malformed entries when loading the offline queue from localStorage', () => {
|
|
450
|
+
const malformed = [
|
|
451
|
+
{ id: 'a', message: 'ok' },
|
|
452
|
+
{
|
|
453
|
+
id: 'b',
|
|
454
|
+
message: 'valid',
|
|
455
|
+
color: 'green',
|
|
456
|
+
enqueuedAt: 1,
|
|
457
|
+
itemUrl: 'https://github.com/o/r/pull/1',
|
|
458
|
+
projectItemId: 'PVTI_1',
|
|
459
|
+
itemNumber: 1,
|
|
460
|
+
repo: 'o/r',
|
|
461
|
+
isPr: true,
|
|
462
|
+
apiPath: '/api/review',
|
|
463
|
+
requestBody: { action: 'approve' },
|
|
464
|
+
},
|
|
465
|
+
null,
|
|
466
|
+
];
|
|
467
|
+
localStorage.setItem(OFFLINE_QUEUE_STORAGE_KEY, JSON.stringify(malformed));
|
|
468
|
+
const { result } = renderHook(() => useConsoleActionQueue());
|
|
469
|
+
expect(result.current.offlineActions).toHaveLength(1);
|
|
470
|
+
expect(result.current.offlineActions[0].id).toBe('b');
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('removes the action from localStorage when discardOfflineAction is called', async () => {
|
|
474
|
+
const { result } = renderHook(() => useConsoleActionQueue());
|
|
475
|
+
const action = makeAction({
|
|
476
|
+
commit: jest
|
|
477
|
+
.fn<Promise<void>, []>()
|
|
478
|
+
.mockRejectedValue(new TypeError('Failed to fetch')),
|
|
479
|
+
offline: offlinePayload,
|
|
480
|
+
});
|
|
481
|
+
act(() => {
|
|
482
|
+
result.current.enqueue(action);
|
|
483
|
+
});
|
|
484
|
+
await act(async () => {
|
|
485
|
+
jest.advanceTimersByTime(5000);
|
|
486
|
+
await flushMicrotasks();
|
|
487
|
+
});
|
|
488
|
+
const id = result.current.offlineActions[0].id;
|
|
489
|
+
act(() => {
|
|
490
|
+
result.current.discardOfflineAction(id);
|
|
491
|
+
});
|
|
492
|
+
expect(result.current.offlineActions).toHaveLength(0);
|
|
493
|
+
const stored: unknown[] = JSON.parse(
|
|
494
|
+
localStorage.getItem(OFFLINE_QUEUE_STORAGE_KEY) ?? '[]',
|
|
495
|
+
);
|
|
496
|
+
expect(stored).toHaveLength(0);
|
|
497
|
+
});
|
|
215
498
|
});
|
package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.ts
CHANGED
|
@@ -1,14 +1,33 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { postConsoleOperation } from '../lib/consoleApi';
|
|
2
3
|
import {
|
|
3
4
|
ACTION_TOAST_DELAY_MS,
|
|
4
5
|
type ConsoleToastColor,
|
|
5
6
|
} from '../logic/actionToast';
|
|
6
7
|
|
|
8
|
+
export type ConsoleOfflinePayload = {
|
|
9
|
+
itemUrl: string;
|
|
10
|
+
projectItemId: string;
|
|
11
|
+
itemNumber: number;
|
|
12
|
+
repo: string;
|
|
13
|
+
isPr: boolean;
|
|
14
|
+
apiPath: string;
|
|
15
|
+
requestBody: Record<string, unknown>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type ConsoleOfflineQueuedAction = {
|
|
19
|
+
id: string;
|
|
20
|
+
message: string;
|
|
21
|
+
color: ConsoleToastColor;
|
|
22
|
+
enqueuedAt: number;
|
|
23
|
+
} & ConsoleOfflinePayload;
|
|
24
|
+
|
|
7
25
|
export type ConsoleQueuedAction = {
|
|
8
26
|
message: string;
|
|
9
27
|
color: ConsoleToastColor;
|
|
10
28
|
commit: () => Promise<void>;
|
|
11
29
|
advance: () => void;
|
|
30
|
+
offline?: ConsoleOfflinePayload;
|
|
12
31
|
};
|
|
13
32
|
|
|
14
33
|
export type ConsolePendingActionView = {
|
|
@@ -23,6 +42,57 @@ export type ConsoleActionError = {
|
|
|
23
42
|
reason: string;
|
|
24
43
|
};
|
|
25
44
|
|
|
45
|
+
const OFFLINE_QUEUE_STORAGE_KEY = 'console-offline-action-queue';
|
|
46
|
+
|
|
47
|
+
const isConsoleOfflineQueuedAction = (
|
|
48
|
+
item: unknown,
|
|
49
|
+
): item is ConsoleOfflineQueuedAction => {
|
|
50
|
+
if (typeof item !== 'object' || item === null) return false;
|
|
51
|
+
const a = item as Record<string, unknown>;
|
|
52
|
+
return (
|
|
53
|
+
typeof a.id === 'string' &&
|
|
54
|
+
typeof a.message === 'string' &&
|
|
55
|
+
typeof a.color === 'string' &&
|
|
56
|
+
typeof a.enqueuedAt === 'number' &&
|
|
57
|
+
typeof a.itemUrl === 'string' &&
|
|
58
|
+
typeof a.projectItemId === 'string' &&
|
|
59
|
+
typeof a.itemNumber === 'number' &&
|
|
60
|
+
typeof a.repo === 'string' &&
|
|
61
|
+
typeof a.isPr === 'boolean' &&
|
|
62
|
+
typeof a.apiPath === 'string' &&
|
|
63
|
+
typeof a.requestBody === 'object' &&
|
|
64
|
+
a.requestBody !== null
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const loadOfflineQueue = (): ConsoleOfflineQueuedAction[] => {
|
|
69
|
+
try {
|
|
70
|
+
const raw = localStorage.getItem(OFFLINE_QUEUE_STORAGE_KEY);
|
|
71
|
+
if (raw === null) return [];
|
|
72
|
+
const parsed: unknown = JSON.parse(raw);
|
|
73
|
+
if (!Array.isArray(parsed)) return [];
|
|
74
|
+
return parsed.filter(isConsoleOfflineQueuedAction);
|
|
75
|
+
} catch {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const saveOfflineQueue = (actions: ConsoleOfflineQueuedAction[]): void => {
|
|
81
|
+
localStorage.setItem(OFFLINE_QUEUE_STORAGE_KEY, JSON.stringify(actions));
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const generateId = (): string => {
|
|
85
|
+
if (
|
|
86
|
+
typeof crypto !== 'undefined' &&
|
|
87
|
+
typeof crypto.randomUUID === 'function'
|
|
88
|
+
) {
|
|
89
|
+
return crypto.randomUUID();
|
|
90
|
+
}
|
|
91
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const isNetworkError = (error: unknown): boolean => error instanceof TypeError;
|
|
95
|
+
|
|
26
96
|
const errorReason = (error: unknown): string => {
|
|
27
97
|
if (error instanceof Error && error.message.length > 0) {
|
|
28
98
|
return error.message;
|
|
@@ -41,15 +111,24 @@ const computeProgress = (elapsedMs: number): number =>
|
|
|
41
111
|
export type ConsoleActionQueue = {
|
|
42
112
|
pending: ConsolePendingActionView | null;
|
|
43
113
|
error: ConsoleActionError | null;
|
|
114
|
+
offlineActions: ConsoleOfflineQueuedAction[];
|
|
44
115
|
enqueue: (action: ConsoleQueuedAction) => void;
|
|
45
116
|
showError: (message: string, reason: string) => void;
|
|
46
117
|
undo: () => void;
|
|
47
118
|
dismissError: () => void;
|
|
119
|
+
confirmOfflineAction: (id: string) => Promise<void>;
|
|
120
|
+
discardOfflineAction: (id: string) => void;
|
|
48
121
|
};
|
|
49
122
|
|
|
50
123
|
export const useConsoleActionQueue = (): ConsoleActionQueue => {
|
|
51
124
|
const [pending, setPending] = useState<ConsolePendingActionView | null>(null);
|
|
52
125
|
const [error, setError] = useState<ConsoleActionError | null>(null);
|
|
126
|
+
const [offlineActions, setOfflineActions] =
|
|
127
|
+
useState<ConsoleOfflineQueuedAction[]>(loadOfflineQueue);
|
|
128
|
+
const offlineActionsRef =
|
|
129
|
+
useRef<ConsoleOfflineQueuedAction[]>(offlineActions);
|
|
130
|
+
offlineActionsRef.current = offlineActions;
|
|
131
|
+
|
|
53
132
|
const actionRef = useRef<ConsoleQueuedAction | null>(null);
|
|
54
133
|
const startRef = useRef<number>(0);
|
|
55
134
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
@@ -62,12 +141,35 @@ export const useConsoleActionQueue = (): ConsoleActionQueue => {
|
|
|
62
141
|
}
|
|
63
142
|
}, []);
|
|
64
143
|
|
|
65
|
-
const
|
|
66
|
-
action.
|
|
67
|
-
|
|
144
|
+
const addToOfflineQueue = useCallback((action: ConsoleQueuedAction): void => {
|
|
145
|
+
if (action.offline === undefined) return;
|
|
146
|
+
const offlineAction: ConsoleOfflineQueuedAction = {
|
|
147
|
+
id: generateId(),
|
|
148
|
+
message: action.message,
|
|
149
|
+
color: action.color,
|
|
150
|
+
enqueuedAt: Date.now(),
|
|
151
|
+
...action.offline,
|
|
152
|
+
};
|
|
153
|
+
setOfflineActions((prev) => {
|
|
154
|
+
const next = [...prev, offlineAction];
|
|
155
|
+
saveOfflineQueue(next);
|
|
156
|
+
return next;
|
|
68
157
|
});
|
|
69
158
|
}, []);
|
|
70
159
|
|
|
160
|
+
const runCommit = useCallback(
|
|
161
|
+
(action: ConsoleQueuedAction): void => {
|
|
162
|
+
action.commit().catch((cause: unknown) => {
|
|
163
|
+
if (isNetworkError(cause) && action.offline !== undefined) {
|
|
164
|
+
addToOfflineQueue(action);
|
|
165
|
+
} else {
|
|
166
|
+
setError({ message: action.message, reason: errorReason(cause) });
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
},
|
|
170
|
+
[addToOfflineQueue],
|
|
171
|
+
);
|
|
172
|
+
|
|
71
173
|
const commitPending = useCallback((): void => {
|
|
72
174
|
const action = actionRef.current;
|
|
73
175
|
clearTimer();
|
|
@@ -94,6 +196,36 @@ export const useConsoleActionQueue = (): ConsoleActionQueue => {
|
|
|
94
196
|
setError({ message, reason });
|
|
95
197
|
}, []);
|
|
96
198
|
|
|
199
|
+
const discardOfflineAction = useCallback((id: string): void => {
|
|
200
|
+
setOfflineActions((prev) => {
|
|
201
|
+
const next = prev.filter((a) => a.id !== id);
|
|
202
|
+
saveOfflineQueue(next);
|
|
203
|
+
return next;
|
|
204
|
+
});
|
|
205
|
+
}, []);
|
|
206
|
+
|
|
207
|
+
const confirmOfflineAction = useCallback(
|
|
208
|
+
async (id: string): Promise<void> => {
|
|
209
|
+
const action = offlineActionsRef.current.find((a) => a.id === id);
|
|
210
|
+
if (action === undefined) return;
|
|
211
|
+
try {
|
|
212
|
+
await postConsoleOperation(action.apiPath, action.requestBody);
|
|
213
|
+
discardOfflineAction(id);
|
|
214
|
+
} catch (cause: unknown) {
|
|
215
|
+
if (isNetworkError(cause)) {
|
|
216
|
+
setError({
|
|
217
|
+
message: action.message,
|
|
218
|
+
reason: 'Still offline — action is still held in the queue',
|
|
219
|
+
});
|
|
220
|
+
} else {
|
|
221
|
+
setError({ message: action.message, reason: errorReason(cause) });
|
|
222
|
+
discardOfflineAction(id);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
[discardOfflineAction],
|
|
227
|
+
);
|
|
228
|
+
|
|
97
229
|
const enqueue = useCallback(
|
|
98
230
|
(action: ConsoleQueuedAction): void => {
|
|
99
231
|
if (actionRef.current !== null && !committedRef.current) {
|
|
@@ -131,5 +263,15 @@ export const useConsoleActionQueue = (): ConsoleActionQueue => {
|
|
|
131
263
|
|
|
132
264
|
useEffect(() => clearTimer, [clearTimer]);
|
|
133
265
|
|
|
134
|
-
return {
|
|
266
|
+
return {
|
|
267
|
+
pending,
|
|
268
|
+
error,
|
|
269
|
+
offlineActions,
|
|
270
|
+
enqueue,
|
|
271
|
+
showError,
|
|
272
|
+
undo,
|
|
273
|
+
dismissError,
|
|
274
|
+
confirmOfflineAction,
|
|
275
|
+
discardOfflineAction,
|
|
276
|
+
};
|
|
135
277
|
};
|
package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.ts
CHANGED
|
@@ -76,7 +76,7 @@ export type ConsoleOperationsApi = {
|
|
|
76
76
|
) => Promise<void>;
|
|
77
77
|
};
|
|
78
78
|
|
|
79
|
-
const reviewRequest = (
|
|
79
|
+
export const reviewRequest = (
|
|
80
80
|
pjcode: string,
|
|
81
81
|
item: ConsoleListItem,
|
|
82
82
|
prUrl: string,
|
|
@@ -129,6 +129,33 @@ const reviewRequest = (
|
|
|
129
129
|
};
|
|
130
130
|
};
|
|
131
131
|
|
|
132
|
+
export const buildTriageRequest = (
|
|
133
|
+
pjcode: string,
|
|
134
|
+
item: ConsoleListItem,
|
|
135
|
+
action:
|
|
136
|
+
| ConsoleNextActionDateAction
|
|
137
|
+
| ConsoleCloseAction
|
|
138
|
+
| 'set_story'
|
|
139
|
+
| 'set_status',
|
|
140
|
+
extra?: { statusName?: string; storyOptionId?: string },
|
|
141
|
+
): ConsoleTriageRequest => ({
|
|
142
|
+
pjcode,
|
|
143
|
+
action,
|
|
144
|
+
issueUrl: item.url,
|
|
145
|
+
projectItemId: item.projectItemId,
|
|
146
|
+
...(extra ?? {}),
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
export const buildIntmuxRequest = (
|
|
150
|
+
pjcode: string,
|
|
151
|
+
item: ConsoleListItem,
|
|
152
|
+
): ConsoleIntmuxRequest => ({
|
|
153
|
+
pjcode,
|
|
154
|
+
action: 'set_intmux',
|
|
155
|
+
issueUrl: item.url,
|
|
156
|
+
projectItemId: item.projectItemId,
|
|
157
|
+
});
|
|
158
|
+
|
|
132
159
|
const missingPjcodeError = (): Error =>
|
|
133
160
|
new Error('No project specified in the URL path.');
|
|
134
161
|
|
|
@@ -293,9 +293,9 @@ const readOperationErrorReason = async (
|
|
|
293
293
|
return raw;
|
|
294
294
|
};
|
|
295
295
|
|
|
296
|
-
export const postConsoleOperation = async (
|
|
296
|
+
export const postConsoleOperation = async <T extends Record<string, unknown>>(
|
|
297
297
|
apiPath: string,
|
|
298
|
-
body:
|
|
298
|
+
body: T,
|
|
299
299
|
): Promise<void> => {
|
|
300
300
|
const response = await fetch(apiPath, {
|
|
301
301
|
method: 'POST',
|