Skip to content

Commit 1740bd8

Browse files
committed
feat(ui): add slash-key search and keyboard navigation to app dropdown
Signed-off-by: Shine <i@nowtime.cc>
1 parent 5331d66 commit 1740bd8

4 files changed

Lines changed: 385 additions & 45 deletions

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import {KeybindingProvider} from 'argo-ui/v2';
2+
import * as React from 'react';
3+
import * as renderer from 'react-test-renderer';
4+
5+
import {Context} from '../../../shared/context';
6+
import {ApplicationsDetailsAppDropdown} from './application-details-app-dropdown';
7+
8+
// react-test-renderer cannot host real DOM nodes; collapse the portal so the
9+
// panel is rendered inline as part of the test tree.
10+
jest.mock('react-dom', () => {
11+
const actual = jest.requireActual('react-dom');
12+
return {
13+
...actual,
14+
createPortal: (node: React.ReactNode) => node
15+
};
16+
});
17+
18+
jest.mock('../../../shared/services', () => ({
19+
services: {
20+
applications: {
21+
list: jest.fn(() =>
22+
Promise.resolve({
23+
items: [
24+
{metadata: {name: 'app-one', namespace: 'argocd'}, kind: 'Application'},
25+
{metadata: {name: 'app-two', namespace: 'argocd'}, kind: 'Application'}
26+
]
27+
})
28+
)
29+
},
30+
viewPreferences: {
31+
getPreferences: jest.fn(() => ({
32+
subscribe: (cb: (p: {theme: string}) => void) => {
33+
cb({theme: 'light'});
34+
return {unsubscribe: () => undefined};
35+
}
36+
}))
37+
}
38+
}
39+
}));
40+
41+
const renderDropdown = (appName = 'app-one') => {
42+
const navigation = {goto: jest.fn(), history: {} as any};
43+
const ctx = {
44+
navigation,
45+
popup: {} as any,
46+
notifications: {} as any,
47+
baseHref: '/',
48+
history: {} as any
49+
};
50+
let tree: renderer.ReactTestRenderer;
51+
renderer.act(() => {
52+
tree = renderer.create(
53+
<Context.Provider value={ctx as any}>
54+
<KeybindingProvider>
55+
<ApplicationsDetailsAppDropdown appName={appName} objectListKind='Application' />
56+
</KeybindingProvider>
57+
</Context.Provider>
58+
);
59+
});
60+
return {tree: tree!, navigation};
61+
};
62+
63+
const findByClass = (node: renderer.ReactTestInstance, className: string) =>
64+
node.findAll(n => typeof n.type === 'string' && typeof n.props.className === 'string' && (n.props.className as string).split(' ').includes(className));
65+
66+
describe('ApplicationsDetailsAppDropdown', () => {
67+
it('renders the current app name in the anchor and keeps the panel closed by default', () => {
68+
const {tree} = renderDropdown('app-one');
69+
const anchor = findByClass(tree.root, 'application-details-app-dropdown__anchor');
70+
expect(anchor).toHaveLength(1);
71+
expect(JSON.stringify(tree.toJSON())).toContain('app-one');
72+
expect(findByClass(tree.root, 'application-details-app-dropdown__panel')).toHaveLength(0);
73+
});
74+
75+
it('opens the panel and queries the applications service when the anchor is clicked', () => {
76+
const {services} = require('../../../shared/services');
77+
(services.applications.list as jest.Mock).mockClear();
78+
const {tree} = renderDropdown();
79+
const anchor = findByClass(tree.root, 'application-details-app-dropdown__anchor')[0];
80+
renderer.act(() => {
81+
(anchor.props.onClick as () => void)();
82+
});
83+
expect(findByClass(tree.root, 'application-details-app-dropdown__panel')).toHaveLength(1);
84+
expect(services.applications.list).toHaveBeenCalledTimes(1);
85+
expect(services.applications.list).toHaveBeenCalledWith([], 'Application', {fields: ['items.metadata.name', 'items.metadata.namespace']});
86+
});
87+
88+
it('closes the panel when the anchor is clicked again', () => {
89+
const {tree} = renderDropdown();
90+
const anchor = findByClass(tree.root, 'application-details-app-dropdown__anchor')[0];
91+
renderer.act(() => (anchor.props.onClick as () => void)());
92+
expect(findByClass(tree.root, 'application-details-app-dropdown__panel')).toHaveLength(1);
93+
renderer.act(() => (anchor.props.onClick as () => void)());
94+
expect(findByClass(tree.root, 'application-details-app-dropdown__panel')).toHaveLength(0);
95+
});
96+
});
Lines changed: 198 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,210 @@
1-
import {DataLoader, DropDown} from 'argo-ui';
1+
import {DataLoader} from 'argo-ui';
2+
import {Key, KeybindingContext} from 'argo-ui/v2';
3+
import classNames from 'classnames';
24
import * as React from 'react';
5+
import * as ReactDOM from 'react-dom';
36

47
import {Context} from '../../../shared/context';
8+
import * as models from '../../../shared/models';
59
import {services} from '../../../shared/services';
10+
import {getTheme} from '../../../shared/utils';
611
import {getAppUrl} from '../utils';
712

813
export const ApplicationsDetailsAppDropdown = (props: {appName: string; objectListKind: string}) => {
914
const [opened, setOpened] = React.useState(false);
10-
const [appFilter, setAppFilter] = React.useState('');
15+
const [filter, setFilter] = React.useState('');
16+
const [highlight, setHighlight] = React.useState(0);
1117
const ctx = React.useContext(Context);
18+
const {useKeybinding} = React.useContext(KeybindingContext);
19+
const containerRef = React.useRef<HTMLDivElement>(null);
20+
const panelRef = React.useRef<HTMLDivElement>(null);
21+
const inputRef = React.useRef<HTMLInputElement>(null);
22+
const listRef = React.useRef<HTMLUListElement>(null);
23+
// Viewport coordinates for the portal-rendered panel; recomputed from the anchor's
24+
// bounding rect whenever the dropdown opens or the page scrolls/resizes.
25+
const [panelPos, setPanelPos] = React.useState<{top: number; left: number}>({top: 0, left: 0});
26+
// Latest filtered result, refreshed during render of the DataLoader child. Read by the
27+
// input's onKeyDown closure for Enter / Arrow navigation without re-computing the filter.
28+
const filteredRef = React.useRef<models.AbstractApplication[]>([]);
29+
30+
const openDropdown = () => {
31+
setFilter('');
32+
setHighlight(0);
33+
setOpened(true);
34+
};
35+
36+
React.useEffect(() => {
37+
if (opened && inputRef.current) {
38+
inputRef.current.focus();
39+
}
40+
}, [opened]);
41+
42+
// Keep the keyboard-highlighted row visible when navigating past the scroll boundary.
43+
React.useEffect(() => {
44+
const active = listRef.current?.querySelector<HTMLLIElement>('.application-details-app-dropdown__item--active');
45+
active?.scrollIntoView({block: 'nearest'});
46+
}, [highlight, filter]);
47+
48+
// Recompute panel position from the anchor's bounding rect. The panel is portal-rendered
49+
// into document.body to escape any ancestor `overflow: hidden`, so it needs absolute
50+
// viewport coordinates.
51+
const updatePanelPos = React.useCallback(() => {
52+
if (!containerRef.current) {
53+
return;
54+
}
55+
const rect = containerRef.current.getBoundingClientRect();
56+
setPanelPos({top: rect.bottom + 4, left: rect.left});
57+
}, []);
58+
59+
// The portal panel sits at document.body, outside Layout's `.theme-*` wrapper, so
60+
// themify()'s descendant selectors don't match — re-apply the class on the portal root.
61+
const [theme, setTheme] = React.useState<string>('');
62+
React.useEffect(() => {
63+
const sub = services.viewPreferences.getPreferences().subscribe(p => setTheme(p.theme));
64+
return () => sub.unsubscribe();
65+
}, []);
66+
const themeClass = theme ? `theme-${getTheme(theme)}` : '';
67+
68+
React.useLayoutEffect(() => {
69+
if (!opened) {
70+
return;
71+
}
72+
updatePanelPos();
73+
// Coalesce scroll/resize bursts into one update per frame to avoid
74+
// re-rendering the whole details view on every pixel of scroll.
75+
let raf = 0;
76+
const onScroll = () => {
77+
if (raf) {
78+
return;
79+
}
80+
raf = requestAnimationFrame(() => {
81+
raf = 0;
82+
updatePanelPos();
83+
});
84+
};
85+
window.addEventListener('scroll', onScroll, true);
86+
window.addEventListener('resize', onScroll);
87+
return () => {
88+
if (raf) {
89+
cancelAnimationFrame(raf);
90+
}
91+
window.removeEventListener('scroll', onScroll, true);
92+
window.removeEventListener('resize', onScroll);
93+
};
94+
}, [opened, updatePanelPos]);
95+
96+
React.useEffect(() => {
97+
if (!opened) {
98+
return;
99+
}
100+
const handler = (e: MouseEvent) => {
101+
const target = e.target as Node;
102+
const insideAnchor = containerRef.current?.contains(target);
103+
const insidePanel = panelRef.current?.contains(target);
104+
if (!insideAnchor && !insidePanel) {
105+
setOpened(false);
106+
}
107+
};
108+
document.addEventListener('mousedown', handler);
109+
return () => document.removeEventListener('mousedown', handler);
110+
}, [opened]);
111+
112+
useKeybinding({
113+
keys: Key.SLASH,
114+
action: () => {
115+
if (!opened) {
116+
openDropdown();
117+
return true;
118+
}
119+
return false;
120+
}
121+
});
122+
123+
useKeybinding({
124+
keys: Key.ESCAPE,
125+
action: () => {
126+
if (opened) {
127+
setOpened(false);
128+
return true;
129+
}
130+
return false;
131+
}
132+
});
133+
134+
const renderItems = (apps: models.AbstractApplication[]) => {
135+
const filtered = apps.filter(app => filter.length === 0 || app.metadata.name.toLowerCase().includes(filter.toLowerCase())).slice(0, 100); // take top 100 results after filtering to avoid performance issues
136+
filteredRef.current = filtered;
137+
const activeIndex = Math.min(highlight, Math.max(0, filtered.length - 1));
138+
if (filtered.length === 0) {
139+
return <li className='application-details-app-dropdown__empty'>No matches</li>;
140+
}
141+
return filtered.map((app, idx) => (
142+
<li
143+
key={`${app.metadata.namespace}/${app.metadata.name}`}
144+
className={classNames('application-details-app-dropdown__item', {
145+
'application-details-app-dropdown__item--active': idx === activeIndex
146+
})}
147+
onMouseEnter={() => setHighlight(idx)}
148+
onClick={() => {
149+
ctx.navigation.goto(`/${getAppUrl(app)}`);
150+
setOpened(false);
151+
}}>
152+
{app.metadata.name}
153+
{app.metadata.name === props.appName && ' (current)'}
154+
</li>
155+
));
156+
};
157+
12158
return (
13-
<DropDown
14-
onOpenStateChange={setOpened}
15-
isMenu={true}
16-
anchor={() => (
17-
<>
18-
<i className='fa fa-search' /> <span>{props.appName}</span>
19-
</>
20-
)}>
21-
{opened && (
22-
<ul>
23-
<li>
24-
<input
25-
className='argo-field'
26-
value={appFilter}
27-
onChange={e => setAppFilter(e.target.value)}
28-
ref={el =>
29-
el &&
30-
setTimeout(() => {
31-
if (el) {
32-
el.focus();
33-
}
34-
}, 100)
35-
}
36-
/>
37-
</li>
38-
<DataLoader load={() => services.applications.list([], props.objectListKind, {fields: ['items.metadata.name', 'items.metadata.namespace']})}>
39-
{apps =>
40-
apps.items
41-
.filter(app => {
42-
return appFilter.length === 0 || app.metadata.name.toLowerCase().includes(appFilter.toLowerCase());
43-
})
44-
.slice(0, 100) // take top 100 results after filtering to avoid performance issues
45-
.map(app => (
46-
<li key={app.metadata.name} onClick={() => ctx.navigation.goto(`/${getAppUrl(app)}`)}>
47-
{app.metadata.name} {app.metadata.name === props.appName && ' (current)'}
48-
</li>
49-
))
50-
}
51-
</DataLoader>
52-
</ul>
53-
)}
54-
</DropDown>
159+
<div className='application-details-app-dropdown' ref={containerRef}>
160+
<div className='application-details-app-dropdown__anchor' onClick={() => (opened ? setOpened(false) : openDropdown())}>
161+
<i className='fa fa-search' /> <span>{props.appName}</span>
162+
</div>
163+
{opened &&
164+
ReactDOM.createPortal(
165+
<div className={themeClass}>
166+
<div className='application-details-app-dropdown__panel' ref={panelRef} style={{top: panelPos.top, left: panelPos.left}}>
167+
<div className='application-details-app-dropdown__search'>
168+
<input
169+
ref={inputRef}
170+
className='argo-field'
171+
value={filter}
172+
placeholder='Filter applications...'
173+
onChange={e => {
174+
setFilter(e.target.value);
175+
setHighlight(0);
176+
}}
177+
onKeyDown={e => {
178+
const filtered = filteredRef.current;
179+
if (e.key === 'ArrowDown') {
180+
e.preventDefault();
181+
setHighlight(h => Math.min(h + 1, Math.max(0, filtered.length - 1)));
182+
} else if (e.key === 'ArrowUp') {
183+
e.preventDefault();
184+
setHighlight(h => Math.max(h - 1, 0));
185+
} else if (e.key === 'Enter' && filtered.length > 0) {
186+
e.preventDefault();
187+
const activeIndex = Math.min(highlight, filtered.length - 1);
188+
ctx.navigation.goto(`/${getAppUrl(filtered[activeIndex])}`);
189+
setOpened(false);
190+
} else if (e.key === 'Escape') {
191+
e.preventDefault();
192+
setOpened(false);
193+
}
194+
}}
195+
/>
196+
</div>
197+
<ul className='application-details-app-dropdown__list' ref={listRef}>
198+
<DataLoader
199+
load={() => services.applications.list([], props.objectListKind, {fields: ['items.metadata.name', 'items.metadata.namespace']})}
200+
loadingRenderer={() => <li className='application-details-app-dropdown__empty'>Loading...</li>}>
201+
{apps => renderItems(apps.items)}
202+
</DataLoader>
203+
</ul>
204+
</div>
205+
</div>,
206+
document.body
207+
)}
208+
</div>
55209
);
56210
};

0 commit comments

Comments
 (0)