|
| 1 | +import { LoadingButton } from '@atlaskit/button'; |
| 2 | +import DropdownMenu, { DropdownItem } from '@atlaskit/dropdown-menu'; |
| 3 | +import ChevronDownIcon from '@atlaskit/icon/glyph/chevron-down'; |
| 4 | +import Lozenge from '@atlaskit/lozenge'; |
1 | 5 | import { Status, Transition } from '@atlassianlabs/jira-pi-common-models';
|
2 |
| -import { fireEvent, render } from '@testing-library/react'; |
| 6 | +import { Box } from '@material-ui/core'; |
3 | 7 | import React from 'react';
|
4 | 8 |
|
5 |
| -import { StatusTransitionMenu } from './StatusTransitionMenu'; |
6 |
| - |
7 |
| -describe('StatusTransitionMenu', () => { |
8 |
| - const mockTransitions: Transition[] = [ |
9 |
| - { |
10 |
| - name: 'In Progress', |
11 |
| - to: { |
12 |
| - name: 'In Progress', |
13 |
| - statusCategory: { |
14 |
| - colorName: 'yellow', |
15 |
| - id: 0, |
16 |
| - key: '', |
17 |
| - name: '', |
18 |
| - self: '', |
19 |
| - }, |
20 |
| - description: '', |
21 |
| - iconUrl: '', |
22 |
| - id: '', |
23 |
| - self: '', |
24 |
| - }, |
25 |
| - hasScreen: false, |
26 |
| - id: '0', |
27 |
| - isConditional: false, |
28 |
| - isGlobal: false, |
29 |
| - isInitial: false, |
30 |
| - }, |
31 |
| - { |
32 |
| - name: 'Done', |
33 |
| - to: { |
34 |
| - name: 'Done', |
35 |
| - statusCategory: { |
36 |
| - colorName: 'green', |
37 |
| - id: 0, |
38 |
| - key: '', |
39 |
| - name: '', |
40 |
| - self: '', |
41 |
| - }, |
42 |
| - description: '', |
43 |
| - iconUrl: '', |
44 |
| - id: '', |
45 |
| - self: '', |
46 |
| - }, |
47 |
| - hasScreen: false, |
48 |
| - id: '1', |
49 |
| - isConditional: false, |
50 |
| - isGlobal: false, |
51 |
| - isInitial: false, |
52 |
| - }, |
53 |
| - ]; |
54 |
| - |
55 |
| - const mockCurrentStatus: Status = { |
56 |
| - name: 'To Do', |
57 |
| - statusCategory: { |
58 |
| - colorName: 'blue-gray', |
59 |
| - id: 0, |
60 |
| - key: '', |
61 |
| - name: '', |
62 |
| - self: '', |
63 |
| - }, |
64 |
| - description: '', |
65 |
| - iconUrl: '', |
66 |
| - id: '2', |
67 |
| - self: '', |
68 |
| - }; |
69 |
| - |
70 |
| - const mockOnStatusChange = jest.fn(); |
71 |
| - |
72 |
| - it('renders the current status name', () => { |
73 |
| - const { getByText } = render( |
74 |
| - <StatusTransitionMenu |
75 |
| - transitions={mockTransitions} |
76 |
| - currentStatus={mockCurrentStatus} |
77 |
| - isStatusButtonLoading={false} |
78 |
| - onStatusChange={mockOnStatusChange} |
79 |
| - />, |
80 |
| - ); |
81 |
| - |
82 |
| - expect(getByText('To Do')).toBeTruthy(); |
83 |
| - }); |
84 |
| - |
85 |
| - it('displays the dropdown menu when clicked', () => { |
86 |
| - const { getByText, queryByText } = render( |
87 |
| - <StatusTransitionMenu |
88 |
| - transitions={mockTransitions} |
89 |
| - currentStatus={mockCurrentStatus} |
90 |
| - isStatusButtonLoading={false} |
91 |
| - onStatusChange={mockOnStatusChange} |
92 |
| - />, |
93 |
| - ); |
94 |
| - |
95 |
| - fireEvent.click(getByText('To Do')); |
96 |
| - expect(queryByText('In Progress')).toBeTruthy(); |
97 |
| - expect(queryByText('Done')).toBeTruthy(); |
98 |
| - }); |
99 |
| - |
100 |
| - it('calls onStatusChange when a transition is selected', () => { |
101 |
| - const { getByText } = render( |
102 |
| - <StatusTransitionMenu |
103 |
| - transitions={mockTransitions} |
104 |
| - currentStatus={mockCurrentStatus} |
105 |
| - isStatusButtonLoading={false} |
106 |
| - onStatusChange={mockOnStatusChange} |
107 |
| - />, |
108 |
| - ); |
109 |
| - |
110 |
| - fireEvent.click(getByText('To Do')); |
111 |
| - fireEvent.click(getByText('In Progress')); |
112 |
| - |
113 |
| - expect(mockOnStatusChange).toHaveBeenCalledWith(mockTransitions[0]); |
| 9 | +import { colorToLozengeAppearanceMap } from '../../../colors'; |
| 10 | + |
| 11 | +const statusCategoryOrder: Record<string, number> = { |
| 12 | + new: 1, |
| 13 | + indeterminate: 2, |
| 14 | + done: 3, |
| 15 | +}; |
| 16 | + |
| 17 | +// Sort transitions by status category (new → indeterminate → done) |
| 18 | +const sortTransitionsByStatusCategory = (transitions: Transition[]): Transition[] => |
| 19 | + [...transitions].sort((a, b) => { |
| 20 | + const aOrder = statusCategoryOrder[a.to.statusCategory.key] ?? transitions.length; |
| 21 | + const bOrder = statusCategoryOrder[b.to.statusCategory.key] ?? transitions.length; |
| 22 | + if (aOrder !== bOrder) { |
| 23 | + return aOrder - bOrder; |
| 24 | + } |
| 25 | + return parseInt(a.to.id) - parseInt(b.to.id); |
114 | 26 | });
|
115 | 27 |
|
116 |
| - it('displays the transition name if it differs from the target status name', () => { |
117 |
| - const transitionsWithDifferentNames: Transition[] = [ |
118 |
| - { |
119 |
| - name: 'Start Progress', |
120 |
| - to: { |
121 |
| - name: 'In Progress', |
122 |
| - statusCategory: { |
123 |
| - colorName: 'yellow', |
124 |
| - id: 0, |
125 |
| - key: '', |
126 |
| - name: '', |
127 |
| - self: '', |
| 28 | +const StatusOption = (data: Transition) => ( |
| 29 | + <Box> |
| 30 | + <Lozenge appearance={colorToLozengeAppearanceMap[data.to.statusCategory.colorName]}>{data.to.name}</Lozenge> |
| 31 | + </Box> |
| 32 | +); |
| 33 | + |
| 34 | +const StatusOptionWithTransitionName = (data: Transition) => ( |
| 35 | + <Box> |
| 36 | + {`${data.name} → `} |
| 37 | + <Lozenge appearance={colorToLozengeAppearanceMap[data.to.statusCategory.colorName]}>{data.to.name}</Lozenge> |
| 38 | + </Box> |
| 39 | +); |
| 40 | + |
| 41 | +type Props = { |
| 42 | + transitions: Transition[]; |
| 43 | + currentStatus: Status; |
| 44 | + isStatusButtonLoading: boolean; |
| 45 | + onStatusChange: (item: Transition) => void; |
| 46 | +}; |
| 47 | + |
| 48 | +export const StatusTransitionMenu: React.FC<Props> = (props) => { |
| 49 | + const [isHovered, setIsHovered] = React.useState(false); |
| 50 | + const [isOpen, setIsOpen] = React.useState(false); |
| 51 | + |
| 52 | + const { border, background } = getDynamicStyles(props.currentStatus.statusCategory.colorName); |
| 53 | + const hasTransitions = props?.transitions?.length > 0; |
| 54 | + const transitionsSortedByCategory = sortTransitionsByStatusCategory(props.transitions); |
| 55 | + const shouldShowTransitionName = props.transitions.some((t) => t.name !== t.to.name); |
| 56 | + |
| 57 | + const dropdownContent = hasTransitions ? ( |
| 58 | + <Box |
| 59 | + data-testid="issue.status-transition-menu-dropdown" |
| 60 | + style={{ |
| 61 | + display: 'flex', |
| 62 | + flexDirection: 'column', |
| 63 | + backgroundColor: 'var(--vscode-settings-textInputBackground)', |
| 64 | + paddingTop: '4px', |
| 65 | + paddingBottom: '4px', |
| 66 | + border: '1px solid var(--vscode-list-focusOutline)', |
| 67 | + }} |
| 68 | + > |
| 69 | + {transitionsSortedByCategory.map((t) => ( |
| 70 | + <DropdownItem |
| 71 | + key={t.id} |
| 72 | + css={{ |
| 73 | + ':hover': { |
| 74 | + background: 'var(--vscode-editor-selectionHighlightBackground) !important', |
| 75 | + }, |
| 76 | + }} |
| 77 | + onClick={() => props.onStatusChange(t)} |
| 78 | + > |
| 79 | + {shouldShowTransitionName ? StatusOptionWithTransitionName(t) : StatusOption(t)} |
| 80 | + </DropdownItem> |
| 81 | + ))} |
| 82 | + </Box> |
| 83 | + ) : null; |
| 84 | + |
| 85 | + return ( |
| 86 | + <Box |
| 87 | + style={{ |
| 88 | + display: 'flex', |
| 89 | + }} |
| 90 | + > |
| 91 | + <DropdownMenu<HTMLButtonElement> |
| 92 | + css={{ |
| 93 | + backgroundColor: 'var(--vscode-settings-textInputBackground)!important', |
| 94 | + ':hover': { |
| 95 | + backgroundColor: 'var(--vscode-editor-selectionHighlightBackground)!important', |
128 | 96 | },
|
129 |
| - description: '', |
130 |
| - iconUrl: '', |
131 |
| - id: '', |
132 |
| - self: '', |
133 |
| - }, |
134 |
| - hasScreen: false, |
135 |
| - id: '', |
136 |
| - isConditional: false, |
137 |
| - isGlobal: false, |
138 |
| - isInitial: false, |
139 |
| - }, |
140 |
| - ]; |
141 |
| - |
142 |
| - const { getByText } = render( |
143 |
| - <StatusTransitionMenu |
144 |
| - transitions={transitionsWithDifferentNames} |
145 |
| - currentStatus={mockCurrentStatus} |
146 |
| - isStatusButtonLoading={false} |
147 |
| - onStatusChange={mockOnStatusChange} |
148 |
| - />, |
149 |
| - ); |
150 |
| - |
151 |
| - fireEvent.click(getByText('To Do')); |
152 |
| - expect(getByText('Start Progress →')).toBeTruthy(); |
153 |
| - expect(getByText('In Progress')).toBeTruthy(); |
154 |
| - }); |
155 |
| - |
156 |
| - it('Renders the dropdown arrow icon next to the current status', () => { |
157 |
| - const { getByRole } = render( |
158 |
| - <StatusTransitionMenu |
159 |
| - transitions={mockTransitions} |
160 |
| - currentStatus={mockCurrentStatus} |
161 |
| - isStatusButtonLoading={false} |
162 |
| - onStatusChange={mockOnStatusChange} |
163 |
| - />, |
164 |
| - ); |
165 |
| - |
166 |
| - const statusButton = getByRole('button', { name: 'To Do Status' }); |
167 |
| - expect(statusButton).toHaveProperty('disabled', false); |
168 |
| - expect(getByRole('img', { name: 'Status' })).toBeTruthy(); |
169 |
| - }); |
170 |
| - |
171 |
| - it('disables dropdown when there are no transitions', () => { |
172 |
| - const { getByRole } = render( |
173 |
| - <StatusTransitionMenu |
174 |
| - transitions={[]} |
175 |
| - currentStatus={mockCurrentStatus} |
176 |
| - isStatusButtonLoading={false} |
177 |
| - onStatusChange={mockOnStatusChange} |
178 |
| - />, |
179 |
| - ); |
180 |
| - const statusButton = getByRole('button', { name: 'To Do' }); |
181 |
| - expect(statusButton).toHaveProperty('disabled', true); |
182 |
| - }); |
183 |
| -}); |
| 97 | + }} |
| 98 | + onOpenChange={(open) => setIsOpen(open.isOpen)} |
| 99 | + isLoading={props.isStatusButtonLoading} |
| 100 | + trigger={({ triggerRef, ...properties }) => ( |
| 101 | + <LoadingButton |
| 102 | + isLoading={props.isStatusButtonLoading} |
| 103 | + isDisabled={!hasTransitions} |
| 104 | + onMouseOver={() => setIsHovered(true)} |
| 105 | + onMouseLeave={() => setIsHovered(false)} |
| 106 | + style={{ |
| 107 | + alignContent: 'center', |
| 108 | + border: |
| 109 | + (isOpen || isHovered) && hasTransitions |
| 110 | + ? '1px solid var(--vscode-list-focusOutline)' |
| 111 | + : border, |
| 112 | + backgroundColor: background, |
| 113 | + |
| 114 | + color: 'var(--vscode-editor-foreground)', |
| 115 | + }} |
| 116 | + {...properties} |
| 117 | + ref={triggerRef} |
| 118 | + iconAfter={hasTransitions ? <ChevronDownIcon label="Status" /> : undefined} |
| 119 | + > |
| 120 | + {props.currentStatus.name} |
| 121 | + </LoadingButton> |
| 122 | + )} |
| 123 | + > |
| 124 | + {dropdownContent} |
| 125 | + </DropdownMenu> |
| 126 | + </Box> |
| 127 | + ); |
| 128 | +}; |
| 129 | + |
| 130 | +const getDynamicStyles = (colorName: string) => { |
| 131 | + let fields = { border: '', bg: '' }; |
| 132 | + if (!statusCategoryToColorTokenMap[colorName]) { |
| 133 | + fields = statusCategoryToColorTokenMap['default']; |
| 134 | + } else { |
| 135 | + fields = statusCategoryToColorTokenMap[colorName]; |
| 136 | + } |
| 137 | + return { |
| 138 | + border: `1px solid ${fields.border}`, |
| 139 | + background: fields.bg, |
| 140 | + }; |
| 141 | +}; |
| 142 | + |
| 143 | +const statusCategoryToColorTokenMap: { [key: string]: { border: string; bg: string } } = { |
| 144 | + yellow: { border: '#669DF1', bg: '#669DF133' }, |
| 145 | + green: { border: '#94C748', bg: '#94C74833' }, |
| 146 | + 'blue-gray': { |
| 147 | + border: '#B0BEC5', |
| 148 | + bg: '#B0BEC533', |
| 149 | + }, |
| 150 | + default: { |
| 151 | + border: '#B0BEC5', |
| 152 | + bg: '#B0BEC533', |
| 153 | + }, |
| 154 | +}; |
0 commit comments