Skip to content

Commit 177e5b3

Browse files
authored
fix(ui): flickering of Typeahead when selecting images (podman-desktop#18277)
* fix(ui): flickering of Typeahead when selecting images Signed-off-by: Tomáš Bordák <tbordak@redhat.com> * fix(ui): catch rejected promise + use searchFunction() only for the last request (podman-desktop#18277) Signed-off-by: Tomáš Bordák <tbordak@redhat.com> * fix(ui): reset highlightIndex after Typehead component is closed + correct and add unit tests Signed-off-by: Tomáš Bordák <tbordak@redhat.com> * fix(ui): address coderabbit review for CreateContainerFromExistingImage.svelte Signed-off-by: Tomáš Bordák <tbordak@redhat.com> --------- Signed-off-by: Tomáš Bordák <tbordak@redhat.com>
1 parent 0d423bc commit 177e5b3

3 files changed

Lines changed: 82 additions & 22 deletions

File tree

packages/renderer/src/lib/container/CreateContainerFromExistingImage.svelte

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ let values: TypeaheadItem[] = $state([]);
4545
4646
let imageToPull: string = $state('');
4747
let sortResults: ((a: string, b: string) => number) | undefined = $state();
48+
let searchRequestId = 0;
4849
4950
let providerConnections = $derived(
5051
$providerInfos
@@ -188,10 +189,10 @@ function validateImageName(image: string): void {
188189
189190
// allTags is defined if last search was a query to search tags of an image
190191
let allTags: string[] | undefined = undefined;
191-
async function searchImages(value: string): Promise<string[]> {
192+
async function searchImages(value: string): Promise<{ images: string[]; tags: string[] | undefined }> {
192193
if (value.includes(':')) {
193194
if (allTags !== undefined) {
194-
return allTags.filter(i => i.startsWith(value));
195+
return { images: allTags.filter(i => i.startsWith(value)), tags: allTags };
195196
}
196197
const parts = value.split(':');
197198
const originalImage = parts[0];
@@ -200,12 +201,11 @@ async function searchImages(value: string): Promise<string[]> {
200201
image = image.slice(DOCKER_PREFIX_WITH_SLASH.length);
201202
}
202203
const tags = await window.listImageTagsInRegistry({ image });
203-
allTags = tags.map(t => `${originalImage}:${t}`);
204-
return allTags.filter(i => i.startsWith(value));
204+
const computedTags = tags.map(t => `${originalImage}:${t}`);
205+
return { images: computedTags.filter(i => i.startsWith(value)), tags: computedTags };
205206
}
206-
allTags = undefined;
207207
if (value === undefined || value.trim() === '') {
208-
return [];
208+
return { images: [], tags: undefined };
209209
}
210210
const options: ImageSearchOptions = {
211211
query: '',
@@ -218,12 +218,11 @@ async function searchImages(value: string): Promise<string[]> {
218218
options.registry = registry;
219219
options.query = rest.join('/');
220220
}
221-
let result: string[];
222221
const searchResult = await window.searchImageInRegistry(options);
223-
result = searchResult.map(r => {
222+
const result = searchResult.map(r => {
224223
return [options.registry, r.name].join('/');
225224
});
226-
return result;
225+
return { images: result, tags: undefined };
227226
}
228227
229228
async function searchLocalImages(value: string): Promise<string[]> {
@@ -236,8 +235,7 @@ async function searchLocalImages(value: string): Promise<string[]> {
236235
}
237236
return [];
238237
});
239-
matchingLocalImages = localImagesNames.flat().filter(image => image !== '' && image.includes(value));
240-
return matchingLocalImages;
238+
return localImagesNames.flat().filter(image => image !== '' && image.includes(value));
241239
}
242240
243241
let latestTagMessage: string | undefined = $state();
@@ -315,9 +313,16 @@ async function searchFunction(value: string): Promise<void> {
315313
// do not search for images if no connection is selected
316314
if (!selectedProviderConnection) return;
317315
316+
const requestId = ++searchRequestId;
318317
value = value.trim();
319318
const localImagesValues = await searchLocalImages(value);
320-
const remoteImagesValues = await searchImages(value);
319+
const remoteSearchResult = await searchImages(value);
320+
321+
if (requestId !== searchRequestId) return;
322+
323+
matchingLocalImages = localImagesValues;
324+
allTags = remoteSearchResult.tags;
325+
321326
sortResults = (a: string, b: string): number => {
322327
const dockerIoValue = `docker.io/${value}`;
323328
const aStartsWithValue = a.startsWith(value) || a.startsWith(dockerIoValue);
@@ -332,8 +337,8 @@ async function searchFunction(value: string): Promise<void> {
332337
};
333338
334339
values = [
335-
...localImagesValues.map(value => ({ value: value, group: 'Local Images' })),
336-
...remoteImagesValues.map(value => ({ value: value, group: 'Registry Images' })),
340+
...localImagesValues.map(v => ({ value: v, group: 'Local Images' })),
341+
...remoteSearchResult.images.map(v => ({ value: v, group: 'Registry Images' })),
337342
];
338343
}
339344
@@ -359,6 +364,11 @@ function onContainerConnectionChange(): void {
359364
matchingLocalImages = [];
360365
imageToPull = '';
361366
}
367+
368+
// trigger search on mount to populate the typeahead with local images
369+
onMount(() => {
370+
searchFunction('').catch(() => {});
371+
});
362372
</script>
363373

364374
<EngineFormPage title="Select an image">

packages/renderer/src/lib/ui/Typeahead.spec.ts

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -415,11 +415,11 @@ test('should include heading based on given order and searchFunctions order', as
415415
});
416416
});
417417

418-
test('list opens on focus', async () => {
418+
test('list opens on focus without triggering search again', async () => {
419419
let searchResult: TypeaheadItem[] = [];
420-
const searchFunction = async (): Promise<void> => {
420+
const searchFunction = vi.fn(async (): Promise<void> => {
421421
searchResult = ['text1', 'text2', 'text3', 'text4'].map(value => ({ value: value }));
422-
};
422+
});
423423

424424
const { rerender } = render(Typeahead, {
425425
onInputChange: searchFunction,
@@ -429,17 +429,67 @@ test('list opens on focus', async () => {
429429

430430
const input = screen.getByRole('textbox');
431431
await userEvent.click(input);
432+
await userEvent.keyboard('text');
432433

433434
await waitFor(() => expect(searchResult.length > 0).toBeTruthy());
434435
await rerender({ resultItems: searchResult });
435436

437+
const callCountAfterTyping = searchFunction.mock.calls.length;
438+
439+
// click away and then select the input with tab to focus it
440+
await userEvent.click(document.body);
441+
await userEvent.tab();
442+
436443
await waitFor(() => {
437444
const list = screen.getByRole('row');
438445
const items = within(list).getAllByRole('button');
439446
expect(items.length).toBe(4);
440447
expect(items[0].textContent).toBe('text1');
441-
expect(items[1].textContent).toBe('text2');
442-
expect(items[2].textContent).toBe('text3');
443-
expect(items[3].textContent).toBe('text4');
448+
});
449+
450+
expect(searchFunction.mock.calls.length).toBe(callCountAfterTyping);
451+
});
452+
453+
test('highlightIndex resets when reopening list via focus', async () => {
454+
let searchResult: TypeaheadItem[] = [];
455+
const searchFunction = async (s: string): Promise<void> => {
456+
searchResult = s ? [{ value: s + '01' }, { value: s + '02' }, { value: s + '03' }] : [];
457+
};
458+
const { rerender } = render(Typeahead, {
459+
onInputChange: searchFunction,
460+
resultItems: searchResult,
461+
delay: 10,
462+
});
463+
464+
const input = screen.getByRole('textbox');
465+
await userEvent.type(input, 'term');
466+
await waitFor(() => expect(searchResult.length > 0).toBeTruthy());
467+
await rerender({ resultItems: searchResult });
468+
469+
await waitFor(() => {
470+
const items = within(screen.getByRole('row')).getAllByRole('button');
471+
expect(items.length).toBe(3);
472+
});
473+
474+
// navigate down to select an item
475+
await userEvent.keyboard('[ArrowDown]');
476+
await userEvent.keyboard('[ArrowDown]');
477+
478+
await waitFor(async () => {
479+
await tick();
480+
const items = within(screen.getByRole('row')).getAllByRole('button');
481+
assertItemSelected(items, 1);
482+
});
483+
484+
// close by clicking away, then reopen by focusing
485+
await userEvent.click(document.body);
486+
await waitFor(() => assertIsListVisible(false));
487+
488+
await userEvent.tab();
489+
490+
await waitFor(async () => {
491+
await tick();
492+
const items = within(screen.getByRole('row')).getAllByRole('button');
493+
assertItemSelected(items, -1);
444494
});
445495
});

packages/renderer/src/lib/ui/Typeahead.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,6 @@ function onUpKey(e: KeyboardEvent): void {
146146
value = items[highlightIndex];
147147
makeVisible();
148148
} else if (highlightIndex === 0) {
149-
highlightIndex = -1;
150149
value = userValue;
151150
close();
152151
}
@@ -231,6 +230,7 @@ function open(): void {
231230
}
232231
233232
function close(): void {
233+
highlightIndex = -1;
234234
opened = false;
235235
}
236236
@@ -277,7 +277,7 @@ function onWindowClick(e: Event): void {
277277
name={name}
278278
oninput={onInput}
279279
onkeydown={onKeyDown}
280-
onfocus={processInput}
280+
onfocus={open}
281281
use:requestFocus />
282282
{#if loading}
283283
<Spinner size="1em" />

0 commit comments

Comments
 (0)