github-issue-tower-defence-management 1.92.0 → 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 +14 -0
- package/bin/adapter/entry-points/console/consoleServer.js +1 -0
- package/bin/adapter/entry-points/console/consoleServer.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/console/consoleServer.test.ts +23 -0
- package/src/adapter/entry-points/console/consoleServer.ts +1 -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/types/adapter/entry-points/console/consoleServer.d.ts.map +1 -1
- 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/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
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { renderHook } from '@testing-library/react';
|
|
2
|
+
import { useConsoleSwipeNavigation } from './useConsoleSwipeNavigation';
|
|
3
|
+
|
|
4
|
+
const touchEvent = (
|
|
5
|
+
type: string,
|
|
6
|
+
point: { clientX: number; clientY: number },
|
|
7
|
+
property: 'touches' | 'changedTouches',
|
|
8
|
+
): TouchEvent => {
|
|
9
|
+
const event = new Event(type, { bubbles: true }) as TouchEvent;
|
|
10
|
+
Object.defineProperty(event, property, {
|
|
11
|
+
value: [point],
|
|
12
|
+
configurable: true,
|
|
13
|
+
});
|
|
14
|
+
return event;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const swipe = (
|
|
18
|
+
element: HTMLElement,
|
|
19
|
+
from: { clientX: number; clientY: number },
|
|
20
|
+
to: { clientX: number; clientY: number },
|
|
21
|
+
): void => {
|
|
22
|
+
element.dispatchEvent(touchEvent('touchstart', from, 'touches'));
|
|
23
|
+
element.dispatchEvent(touchEvent('touchmove', to, 'touches'));
|
|
24
|
+
element.dispatchEvent(touchEvent('touchend', to, 'changedTouches'));
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe('useConsoleSwipeNavigation', () => {
|
|
28
|
+
it('reports next for a dominant left swipe', () => {
|
|
29
|
+
const element = document.createElement('div');
|
|
30
|
+
document.body.appendChild(element);
|
|
31
|
+
const onSwipe = jest.fn();
|
|
32
|
+
const { result } = renderHook(() => useConsoleSwipeNavigation(onSwipe));
|
|
33
|
+
result.current(element);
|
|
34
|
+
swipe(
|
|
35
|
+
element,
|
|
36
|
+
{ clientX: 200, clientY: 100 },
|
|
37
|
+
{ clientX: 60, clientY: 110 },
|
|
38
|
+
);
|
|
39
|
+
expect(onSwipe).toHaveBeenCalledWith('next');
|
|
40
|
+
document.body.removeChild(element);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('reports previous for a dominant right swipe', () => {
|
|
44
|
+
const element = document.createElement('div');
|
|
45
|
+
document.body.appendChild(element);
|
|
46
|
+
const onSwipe = jest.fn();
|
|
47
|
+
const { result } = renderHook(() => useConsoleSwipeNavigation(onSwipe));
|
|
48
|
+
result.current(element);
|
|
49
|
+
swipe(
|
|
50
|
+
element,
|
|
51
|
+
{ clientX: 60, clientY: 100 },
|
|
52
|
+
{ clientX: 200, clientY: 110 },
|
|
53
|
+
);
|
|
54
|
+
expect(onSwipe).toHaveBeenCalledWith('previous');
|
|
55
|
+
document.body.removeChild(element);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('ignores a mostly vertical drag', () => {
|
|
59
|
+
const element = document.createElement('div');
|
|
60
|
+
document.body.appendChild(element);
|
|
61
|
+
const onSwipe = jest.fn();
|
|
62
|
+
const { result } = renderHook(() => useConsoleSwipeNavigation(onSwipe));
|
|
63
|
+
result.current(element);
|
|
64
|
+
swipe(
|
|
65
|
+
element,
|
|
66
|
+
{ clientX: 100, clientY: 50 },
|
|
67
|
+
{ clientX: 110, clientY: 200 },
|
|
68
|
+
);
|
|
69
|
+
expect(onSwipe).not.toHaveBeenCalled();
|
|
70
|
+
document.body.removeChild(element);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('does not navigate when the gesture starts in a horizontally scrollable element', () => {
|
|
74
|
+
const element = document.createElement('div');
|
|
75
|
+
const scroller = document.createElement('div');
|
|
76
|
+
Object.defineProperty(scroller, 'scrollWidth', {
|
|
77
|
+
value: 500,
|
|
78
|
+
configurable: true,
|
|
79
|
+
});
|
|
80
|
+
Object.defineProperty(scroller, 'clientWidth', {
|
|
81
|
+
value: 100,
|
|
82
|
+
configurable: true,
|
|
83
|
+
});
|
|
84
|
+
scroller.style.overflowX = 'auto';
|
|
85
|
+
element.appendChild(scroller);
|
|
86
|
+
document.body.appendChild(element);
|
|
87
|
+
const onSwipe = jest.fn();
|
|
88
|
+
const { result } = renderHook(() => useConsoleSwipeNavigation(onSwipe));
|
|
89
|
+
result.current(element);
|
|
90
|
+
scroller.dispatchEvent(
|
|
91
|
+
touchEvent('touchstart', { clientX: 200, clientY: 100 }, 'touches'),
|
|
92
|
+
);
|
|
93
|
+
scroller.dispatchEvent(
|
|
94
|
+
touchEvent('touchend', { clientX: 60, clientY: 100 }, 'changedTouches'),
|
|
95
|
+
);
|
|
96
|
+
expect(onSwipe).not.toHaveBeenCalled();
|
|
97
|
+
document.body.removeChild(element);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('detaches listeners when the element is unmounted', () => {
|
|
101
|
+
const element = document.createElement('div');
|
|
102
|
+
document.body.appendChild(element);
|
|
103
|
+
const onSwipe = jest.fn();
|
|
104
|
+
const { result } = renderHook(() => useConsoleSwipeNavigation(onSwipe));
|
|
105
|
+
result.current(element);
|
|
106
|
+
result.current(null);
|
|
107
|
+
swipe(
|
|
108
|
+
element,
|
|
109
|
+
{ clientX: 200, clientY: 100 },
|
|
110
|
+
{ clientX: 60, clientY: 110 },
|
|
111
|
+
);
|
|
112
|
+
expect(onSwipe).not.toHaveBeenCalled();
|
|
113
|
+
document.body.removeChild(element);
|
|
114
|
+
});
|
|
115
|
+
});
|
package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleSwipeNavigation.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
type ConsoleSwipeDirection,
|
|
4
|
+
isVerticallyDominant,
|
|
5
|
+
resolveSwipeDirection,
|
|
6
|
+
} from '../logic/swipe';
|
|
7
|
+
|
|
8
|
+
const isHorizontallyScrollable = (start: EventTarget | null): boolean => {
|
|
9
|
+
let node = start instanceof Element ? start : null;
|
|
10
|
+
while (node !== null && node !== document.body) {
|
|
11
|
+
const style = window.getComputedStyle(node);
|
|
12
|
+
const overflowX = style.overflowX;
|
|
13
|
+
if (
|
|
14
|
+
(overflowX === 'auto' || overflowX === 'scroll') &&
|
|
15
|
+
node.scrollWidth > node.clientWidth
|
|
16
|
+
) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
node = node.parentElement;
|
|
20
|
+
}
|
|
21
|
+
return false;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const useConsoleSwipeNavigation = (
|
|
25
|
+
onSwipe: (direction: ConsoleSwipeDirection) => void,
|
|
26
|
+
): ((element: HTMLElement | null) => void) => {
|
|
27
|
+
const onSwipeRef = useRef(onSwipe);
|
|
28
|
+
onSwipeRef.current = onSwipe;
|
|
29
|
+
|
|
30
|
+
const detachRef = useRef<(() => void) | null>(null);
|
|
31
|
+
|
|
32
|
+
const attach = useCallback((element: HTMLElement): (() => void) => {
|
|
33
|
+
let startX = 0;
|
|
34
|
+
let startY = 0;
|
|
35
|
+
let tracking = false;
|
|
36
|
+
|
|
37
|
+
const onTouchStart = (event: TouchEvent): void => {
|
|
38
|
+
if (event.touches.length !== 1) {
|
|
39
|
+
tracking = false;
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (isHorizontallyScrollable(event.target)) {
|
|
43
|
+
tracking = false;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const touch = event.touches[0];
|
|
47
|
+
startX = touch.clientX;
|
|
48
|
+
startY = touch.clientY;
|
|
49
|
+
tracking = true;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const onTouchMove = (event: TouchEvent): void => {
|
|
53
|
+
if (!tracking) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const touch = event.touches[0];
|
|
57
|
+
if (
|
|
58
|
+
isVerticallyDominant(touch.clientX - startX, touch.clientY - startY)
|
|
59
|
+
) {
|
|
60
|
+
tracking = false;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const onTouchEnd = (event: TouchEvent): void => {
|
|
65
|
+
if (!tracking) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
tracking = false;
|
|
69
|
+
if (event.changedTouches.length !== 1) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const touch = event.changedTouches[0];
|
|
73
|
+
const direction = resolveSwipeDirection(
|
|
74
|
+
touch.clientX - startX,
|
|
75
|
+
touch.clientY - startY,
|
|
76
|
+
);
|
|
77
|
+
if (direction !== null) {
|
|
78
|
+
onSwipeRef.current(direction);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
element.addEventListener('touchstart', onTouchStart, { passive: true });
|
|
83
|
+
element.addEventListener('touchmove', onTouchMove, { passive: true });
|
|
84
|
+
element.addEventListener('touchend', onTouchEnd, { passive: true });
|
|
85
|
+
return () => {
|
|
86
|
+
element.removeEventListener('touchstart', onTouchStart);
|
|
87
|
+
element.removeEventListener('touchmove', onTouchMove);
|
|
88
|
+
element.removeEventListener('touchend', onTouchEnd);
|
|
89
|
+
};
|
|
90
|
+
}, []);
|
|
91
|
+
|
|
92
|
+
const swipeRef = useCallback(
|
|
93
|
+
(element: HTMLElement | null): void => {
|
|
94
|
+
if (detachRef.current !== null) {
|
|
95
|
+
detachRef.current();
|
|
96
|
+
detachRef.current = null;
|
|
97
|
+
}
|
|
98
|
+
if (element !== null) {
|
|
99
|
+
detachRef.current = attach(element);
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
[attach],
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
return () => {
|
|
107
|
+
if (detachRef.current !== null) {
|
|
108
|
+
detachRef.current();
|
|
109
|
+
detachRef.current = null;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}, []);
|
|
113
|
+
|
|
114
|
+
return swipeRef;
|
|
115
|
+
};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { consoleListItemsFixture } from '../testing/fixtures';
|
|
2
|
+
import {
|
|
3
|
+
ACTION_TOAST_DELAY_MS,
|
|
4
|
+
actionAdvances,
|
|
5
|
+
actionToastColor,
|
|
6
|
+
actionToastMessage,
|
|
7
|
+
type ConsoleActionKind,
|
|
8
|
+
formatActionToast,
|
|
9
|
+
itemToastLabel,
|
|
10
|
+
} from './actionToast';
|
|
11
|
+
|
|
12
|
+
const prItem = consoleListItemsFixture[0];
|
|
13
|
+
const issueItem = consoleListItemsFixture[2];
|
|
14
|
+
|
|
15
|
+
describe('ACTION_TOAST_DELAY_MS', () => {
|
|
16
|
+
it('is the five second cancellable window', () => {
|
|
17
|
+
expect(ACTION_TOAST_DELAY_MS).toBe(5000);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('itemToastLabel', () => {
|
|
22
|
+
it('prefixes pull requests with PR #', () => {
|
|
23
|
+
expect(itemToastLabel(prItem)).toBe(`PR #${prItem.number}`);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('prefixes issues with #', () => {
|
|
27
|
+
expect(itemToastLabel(issueItem)).toBe(`#${issueItem.number}`);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('actionToastMessage', () => {
|
|
32
|
+
it('labels review actions', () => {
|
|
33
|
+
expect(
|
|
34
|
+
actionToastMessage({ type: 'review', action: 'approve' }, 'prs'),
|
|
35
|
+
).toBe('Approved');
|
|
36
|
+
expect(
|
|
37
|
+
actionToastMessage({ type: 'review', action: 'request_changes' }, 'prs'),
|
|
38
|
+
).toBe('Rejected');
|
|
39
|
+
expect(
|
|
40
|
+
actionToastMessage({ type: 'review', action: 'totally_wrong' }, 'prs'),
|
|
41
|
+
).toBe('Marked totally wrong');
|
|
42
|
+
expect(
|
|
43
|
+
actionToastMessage({ type: 'review', action: 'unnecessary' }, 'prs'),
|
|
44
|
+
).toBe('Marked unnecessary');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('labels the +1 week snooze differently on the todo-by-human tab', () => {
|
|
48
|
+
const kind: ConsoleActionKind = {
|
|
49
|
+
type: 'next_action_date',
|
|
50
|
+
action: 'snooze_1week',
|
|
51
|
+
};
|
|
52
|
+
expect(actionToastMessage(kind, 'prs')).toBe('Next Action Date +1 week');
|
|
53
|
+
expect(actionToastMessage(kind, 'todo-by-human')).toBe(
|
|
54
|
+
'Next Action Date +1 week and skip',
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('labels the +1 day snooze the same on every tab', () => {
|
|
59
|
+
const kind: ConsoleActionKind = {
|
|
60
|
+
type: 'next_action_date',
|
|
61
|
+
action: 'snooze_1day',
|
|
62
|
+
};
|
|
63
|
+
expect(actionToastMessage(kind, 'prs')).toBe('Next Action Date +1 day');
|
|
64
|
+
expect(actionToastMessage(kind, 'todo-by-human')).toBe(
|
|
65
|
+
'Next Action Date +1 day',
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('labels close actions', () => {
|
|
70
|
+
expect(actionToastMessage({ type: 'close', action: 'close' }, 'prs')).toBe(
|
|
71
|
+
'Closed',
|
|
72
|
+
);
|
|
73
|
+
expect(
|
|
74
|
+
actionToastMessage({ type: 'close', action: 'close_not_planned' }, 'prs'),
|
|
75
|
+
).toBe('Closed as not planned');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('labels the in-tmux-by-human action', () => {
|
|
79
|
+
expect(
|
|
80
|
+
actionToastMessage(
|
|
81
|
+
{ type: 'set_in_tmux_by_human', optionName: 'In Tmux by human' },
|
|
82
|
+
'prs',
|
|
83
|
+
),
|
|
84
|
+
).toBe('Added to In Tmux by human');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('labels field updates with the chosen option name', () => {
|
|
88
|
+
expect(
|
|
89
|
+
actionToastMessage(
|
|
90
|
+
{ type: 'set_status', optionName: 'Awaiting Workspace' },
|
|
91
|
+
'prs',
|
|
92
|
+
),
|
|
93
|
+
).toBe('Status → Awaiting Workspace');
|
|
94
|
+
expect(
|
|
95
|
+
actionToastMessage(
|
|
96
|
+
{ type: 'set_story', optionName: 'Move to Okinawa' },
|
|
97
|
+
'triage',
|
|
98
|
+
),
|
|
99
|
+
).toBe('Story → Move to Okinawa');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('actionToastColor', () => {
|
|
104
|
+
it('colors each action group like the reference', () => {
|
|
105
|
+
expect(actionToastColor({ type: 'review', action: 'approve' })).toBe(
|
|
106
|
+
'green',
|
|
107
|
+
);
|
|
108
|
+
expect(
|
|
109
|
+
actionToastColor({ type: 'review', action: 'request_changes' }),
|
|
110
|
+
).toBe('amber');
|
|
111
|
+
expect(actionToastColor({ type: 'review', action: 'totally_wrong' })).toBe(
|
|
112
|
+
'red',
|
|
113
|
+
);
|
|
114
|
+
expect(actionToastColor({ type: 'review', action: 'unnecessary' })).toBe(
|
|
115
|
+
'gray',
|
|
116
|
+
);
|
|
117
|
+
expect(
|
|
118
|
+
actionToastColor({ type: 'set_status', optionName: 'Todo by human' }),
|
|
119
|
+
).toBe('blue');
|
|
120
|
+
expect(
|
|
121
|
+
actionToastColor({ type: 'next_action_date', action: 'snooze_1day' }),
|
|
122
|
+
).toBe('amber');
|
|
123
|
+
expect(actionToastColor({ type: 'close', action: 'close' })).toBe('red');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('actionAdvances', () => {
|
|
128
|
+
it('advances every non-snooze action in every tab', () => {
|
|
129
|
+
expect(actionAdvances({ type: 'review', action: 'approve' }, 'prs')).toBe(
|
|
130
|
+
true,
|
|
131
|
+
);
|
|
132
|
+
expect(actionAdvances({ type: 'set_status', optionName: 'x' }, 'prs')).toBe(
|
|
133
|
+
true,
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('does not advance a snooze except on the todo-by-human tab', () => {
|
|
138
|
+
const kind: ConsoleActionKind = {
|
|
139
|
+
type: 'next_action_date',
|
|
140
|
+
action: 'snooze_1week',
|
|
141
|
+
};
|
|
142
|
+
expect(actionAdvances(kind, 'prs')).toBe(false);
|
|
143
|
+
expect(actionAdvances(kind, 'todo-by-human')).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('formatActionToast', () => {
|
|
148
|
+
it('combines the message and the item label', () => {
|
|
149
|
+
expect(
|
|
150
|
+
formatActionToast({ type: 'review', action: 'approve' }, prItem, 'prs'),
|
|
151
|
+
).toBe(`Approved — PR #${prItem.number}`);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ConsoleCloseAction,
|
|
3
|
+
ConsoleNextActionDateAction,
|
|
4
|
+
ConsoleReviewAction,
|
|
5
|
+
} from './operations';
|
|
6
|
+
import type { ConsoleListItem, ConsoleTabName } from './types';
|
|
7
|
+
|
|
8
|
+
export type ConsoleToastColor =
|
|
9
|
+
| 'green'
|
|
10
|
+
| 'amber'
|
|
11
|
+
| 'red'
|
|
12
|
+
| 'gray'
|
|
13
|
+
| 'blue'
|
|
14
|
+
| 'error';
|
|
15
|
+
|
|
16
|
+
export type ConsoleActionKind =
|
|
17
|
+
| { type: 'review'; action: ConsoleReviewAction }
|
|
18
|
+
| { type: 'next_action_date'; action: ConsoleNextActionDateAction }
|
|
19
|
+
| { type: 'set_story'; optionName: string }
|
|
20
|
+
| { type: 'set_status'; optionName: string }
|
|
21
|
+
| { type: 'set_in_tmux_by_human'; optionName: string }
|
|
22
|
+
| { type: 'close'; action: ConsoleCloseAction };
|
|
23
|
+
|
|
24
|
+
export const ACTION_TOAST_DELAY_MS = 5000;
|
|
25
|
+
|
|
26
|
+
export const itemToastLabel = (item: ConsoleListItem): string =>
|
|
27
|
+
`${item.isPr ? 'PR #' : '#'}${item.number}`;
|
|
28
|
+
|
|
29
|
+
export const actionToastMessage = (
|
|
30
|
+
kind: ConsoleActionKind,
|
|
31
|
+
tab: ConsoleTabName,
|
|
32
|
+
): string => {
|
|
33
|
+
switch (kind.type) {
|
|
34
|
+
case 'review':
|
|
35
|
+
if (kind.action === 'approve') {
|
|
36
|
+
return 'Approved';
|
|
37
|
+
}
|
|
38
|
+
if (kind.action === 'request_changes') {
|
|
39
|
+
return 'Rejected';
|
|
40
|
+
}
|
|
41
|
+
if (kind.action === 'totally_wrong') {
|
|
42
|
+
return 'Marked totally wrong';
|
|
43
|
+
}
|
|
44
|
+
return 'Marked unnecessary';
|
|
45
|
+
case 'next_action_date':
|
|
46
|
+
if (kind.action === 'snooze_1day') {
|
|
47
|
+
return 'Next Action Date +1 day';
|
|
48
|
+
}
|
|
49
|
+
return tab === 'todo-by-human'
|
|
50
|
+
? 'Next Action Date +1 week and skip'
|
|
51
|
+
: 'Next Action Date +1 week';
|
|
52
|
+
case 'set_story':
|
|
53
|
+
return `Story → ${kind.optionName}`;
|
|
54
|
+
case 'set_status':
|
|
55
|
+
return `Status → ${kind.optionName}`;
|
|
56
|
+
case 'set_in_tmux_by_human':
|
|
57
|
+
return 'Added to In Tmux by human';
|
|
58
|
+
case 'close':
|
|
59
|
+
return kind.action === 'close' ? 'Closed' : 'Closed as not planned';
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const actionToastColor = (
|
|
64
|
+
kind: ConsoleActionKind,
|
|
65
|
+
): ConsoleToastColor => {
|
|
66
|
+
switch (kind.type) {
|
|
67
|
+
case 'review':
|
|
68
|
+
if (kind.action === 'approve') {
|
|
69
|
+
return 'green';
|
|
70
|
+
}
|
|
71
|
+
if (kind.action === 'request_changes') {
|
|
72
|
+
return 'amber';
|
|
73
|
+
}
|
|
74
|
+
if (kind.action === 'totally_wrong') {
|
|
75
|
+
return 'red';
|
|
76
|
+
}
|
|
77
|
+
return 'gray';
|
|
78
|
+
case 'next_action_date':
|
|
79
|
+
return 'amber';
|
|
80
|
+
case 'set_story':
|
|
81
|
+
case 'set_status':
|
|
82
|
+
case 'set_in_tmux_by_human':
|
|
83
|
+
return 'blue';
|
|
84
|
+
case 'close':
|
|
85
|
+
return 'red';
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const actionAdvances = (
|
|
90
|
+
kind: ConsoleActionKind,
|
|
91
|
+
tab: ConsoleTabName,
|
|
92
|
+
): boolean => {
|
|
93
|
+
if (kind.type === 'next_action_date') {
|
|
94
|
+
return tab === 'todo-by-human';
|
|
95
|
+
}
|
|
96
|
+
return true;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export const formatActionToast = (
|
|
100
|
+
kind: ConsoleActionKind,
|
|
101
|
+
item: ConsoleListItem,
|
|
102
|
+
tab: ConsoleTabName,
|
|
103
|
+
): string => `${actionToastMessage(kind, tab)} — ${itemToastLabel(item)}`;
|