Skip to content

Commit f7fff73

Browse files
committed
Merge remote-tracking branch 'upstream/master'
2 parents 421b7bf + 8af67ab commit f7fff73

File tree

10 files changed

+161
-48
lines changed

10 files changed

+161
-48
lines changed

assets/css/views/tags.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
Uncomment 'Courier New' below to test it.
4141
*/
4242
font-weight: bold;
43-
font-family: /* "Courier New", */ "Consolas", "Droid Sans Mono", "Noto Sans Mono", monospace;
43+
font-family: /* "Courier New" */ "Consolas", "DejaVu Sans Mono", "Droid Sans Mono", "Noto Sans Mono", monospace;
4444
background: var(--autocomplete-background);
4545

4646
/* Borders */

assets/eslint.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ export default tsEslint.config(
294294
'error',
295295
{
296296
// Custom `expectStuff()` functions must also count as assertions.
297-
assertFunctionNames: ['expect*', '*.expect*'],
297+
assertFunctionNames: ['expect*', '*.expect*', 'assert*'],
298298
},
299299
],
300300
},

assets/js/autocomplete/__tests__/context.ts

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { fireEvent } from '@testing-library/dom';
66
import { assertNotNull } from '../../utils/assert';
77
import { TextInputElement } from '../input';
88
import store from '../../utils/store';
9-
import { GetTagSuggestionsResponse } from 'autocomplete/client';
9+
import { GetTagSuggestionsResponse, TagSuggestion } from 'autocomplete/client';
1010

1111
/**
1212
* A reusable test environment for autocompletion tests. Note that it does no
@@ -44,27 +44,30 @@ export class TestContext {
4444
}
4545

4646
const url = new URL(request.url);
47-
if (url.searchParams.get('term')?.toLowerCase() !== 'mar') {
48-
const suggestions: GetTagSuggestionsResponse = { suggestions: [] };
49-
return JSON.stringify(suggestions);
50-
}
47+
const term = url.searchParams.get('term');
48+
49+
const termLower = assertNotNull(term).toLowerCase();
50+
51+
const fakeSuggestions: TagSuggestion[] = [
52+
{
53+
alias: 'marvelous',
54+
canonical: 'beautiful',
55+
images: 30,
56+
},
57+
{
58+
canonical: 'mare',
59+
images: 20,
60+
},
61+
{
62+
canonical: 'market',
63+
images: 10,
64+
},
65+
];
5166

5267
const suggestions: GetTagSuggestionsResponse = {
53-
suggestions: [
54-
{
55-
alias: 'marvelous',
56-
canonical: 'beautiful',
57-
images: 30,
58-
},
59-
{
60-
canonical: 'mare',
61-
images: 20,
62-
},
63-
{
64-
canonical: 'market',
65-
images: 10,
66-
},
67-
],
68+
suggestions: fakeSuggestions.filter(suggestion =>
69+
(suggestion.alias || suggestion.canonical).startsWith(termLower),
70+
),
6871
};
6972

7073
return JSON.stringify(suggestions);
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { init } from '../context';
2+
3+
it('ignores the autocompletion results if Escape was pressed', async () => {
4+
const ctx = await init();
5+
6+
// First request for the local autocomplete index was done
7+
expect(fetch).toHaveBeenCalledTimes(1);
8+
9+
await Promise.all([ctx.setInput('mar'), ctx.keyDown('Escape')]);
10+
11+
// The input must be empty because the user typed `mar` and pressed `Escape` right after that
12+
ctx.expectUi().toMatchInlineSnapshot(`
13+
{
14+
"input": "mar<>",
15+
"suggestions": [],
16+
}
17+
`);
18+
19+
// No new requests must've been sent because the input was debounced early
20+
expect(fetch).toHaveBeenCalledTimes(1);
21+
22+
await ctx.setInput('mar');
23+
24+
ctx.expectUi().toMatchInlineSnapshot(`
25+
{
26+
"input": "mar<>",
27+
"suggestions": [
28+
"marvelous → beautiful 30",
29+
"mare 20",
30+
"market 10",
31+
],
32+
}
33+
`);
34+
35+
// Second request for the server-side suggestions.
36+
expect(fetch).toHaveBeenCalledTimes(2);
37+
38+
ctx.setInput('mare');
39+
40+
// After 300 milliseconds the debounce threshold is over, and the server-side
41+
// completions request is issued.
42+
vi.advanceTimersByTime(300);
43+
44+
await ctx.keyDown('Escape');
45+
46+
expect(fetch).toHaveBeenCalledTimes(3);
47+
48+
ctx.expectUi().toMatchInlineSnapshot(`
49+
{
50+
"input": "mare<>",
51+
"suggestions": [],
52+
}
53+
`);
54+
55+
ctx.setInput('mare');
56+
57+
// Make sure that the user gets the results immediately without any debouncing (0 ms)
58+
await vi.advanceTimersByTimeAsync(0);
59+
60+
ctx.expectUi().toMatchInlineSnapshot(`
61+
{
62+
"input": "mare<>",
63+
"suggestions": [
64+
"mare 20",
65+
],
66+
}
67+
`);
68+
69+
// The results must come from the cache, so no new fetch calls must have been made
70+
expect(fetch).toHaveBeenCalledTimes(3);
71+
});

assets/js/autocomplete/__tests__/server-side-completions.spec.ts renamed to assets/js/autocomplete/__tests__/server-side-completions/smoke.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { init } from './context';
1+
import { init } from '../context';
22

33
it('requests server-side autocomplete if local autocomplete returns no results', async () => {
44
const ctx = await init();

assets/js/autocomplete/index.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -229,18 +229,23 @@ class Autocomplete {
229229
// We use this method instead of the `focusout` event because this way it's
230230
// easier to work in the developer tools when you want to inspect the element.
231231
// When you inspect it, a `focusout` happens.
232-
this.popup.hide();
232+
this.hidePopup('The user clicked away');
233233
this.input = null;
234234
}
235235
}
236236

237+
hidePopup(reason: string) {
238+
this.serverSideTagSuggestions.abortLastSchedule(`[Autocomplete] Popup was hidden. ${reason}`);
239+
this.popup.hide();
240+
}
241+
237242
onKeyDown(event: KeyboardEvent) {
238243
if (!this.isActive() || this.input.element !== event.target) {
239244
return;
240245
}
241246
if ((event.key === ',' || event.code === 'Enter') && this.input.type === 'single-tag') {
242-
// Coma means the end of input for the current tag in single-tag mode.
243-
this.popup.hide();
247+
// Comma/Enter mean the end of input for the current tag in single-tag mode.
248+
this.hidePopup(`The user accepted the existing input via key: '${event.key}', code: '${event.code}'`);
244249
return;
245250
}
246251

@@ -265,7 +270,7 @@ class Autocomplete {
265270
return;
266271
}
267272
case 'Escape': {
268-
this.popup.hide();
273+
this.hidePopup('User pressed "Escape"');
269274
return;
270275
}
271276
case 'ArrowLeft':
@@ -340,7 +345,14 @@ class Autocomplete {
340345
// brief moment of silence for the user without the popup before they type
341346
// something else, otherwise we'd show some more completions for the current term.
342347
this.input.element.focus();
343-
this.popup.hide();
348+
349+
// Technically no server-side suggestion request can be in flight at this point.
350+
// If the user managed to accept a suggestion, it means the user already got
351+
// presented with the results of auto-completions, so there is nothing in-flight.
352+
//
353+
// Although, we don't make this a hard assertion just in case, to make sure this
354+
// code is tolerant to any bugs in the described assumption.
355+
this.hidePopup('The user accepted the existing suggestion');
344356
}
345357

346358
updateInputWithSelectedValue(this: ActiveAutocomplete, suggestion: Suggestion) {

assets/js/burger.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -53,19 +53,24 @@ export function setupBurgerMenu() {
5353

5454
copyUserLinksTo(burger);
5555

56-
toggle.addEventListener('click', event => {
57-
event.stopPropagation();
58-
event.preventDefault();
56+
document.addEventListener('click', event => {
57+
if (!(event.target instanceof Node)) {
58+
return;
59+
}
5960

60-
if (content.classList.contains('open')) {
61-
close(burger, content, body, root);
62-
} else {
63-
open(burger, content, body, root);
61+
if (toggle.contains(event.target)) {
62+
event.preventDefault();
63+
64+
if (content.classList.contains('open')) {
65+
close(burger, content, body, root);
66+
} else {
67+
open(burger, content, body, root);
68+
}
69+
70+
return;
6471
}
65-
});
6672

67-
content.addEventListener('click', () => {
68-
if (content.classList.contains('open')) {
73+
if (content.contains(event.target) && content.classList.contains('open')) {
6974
close(burger, content, body, root);
7075
}
7176
});

assets/js/interactions.js

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,13 @@ const endpoints = {
1919

2020
const spoilerDownvoteMsg = 'Neigh! - Remove spoilered tags from your filters to downvote from thumbnails';
2121

22-
/* Quick helper function to less verbosely iterate a QSA */
22+
/**
23+
* Quick helper function to less verbosely iterate a QSA
24+
*
25+
* @param {string} id
26+
* @param {string} selector
27+
* @param {(node: HTMLElement) => void} cb
28+
*/
2329
function onImage(id, selector, cb) {
2430
[].forEach.call(document.querySelectorAll(`${selector}[data-image-id="${id}"]`), cb);
2531
}
@@ -145,14 +151,6 @@ function loadInteractions() {
145151

146152
icon.setAttribute('title', spoilerDownvoteMsg);
147153
a.classList.add('disabled');
148-
a.addEventListener(
149-
'click',
150-
event => {
151-
event.stopPropagation();
152-
event.preventDefault();
153-
},
154-
true,
155-
);
156154
});
157155
});
158156
}
@@ -163,6 +161,10 @@ const targets = {
163161
interact('vote', imageId, 'DELETE').then(() => resetVoted(imageId));
164162
},
165163
'.interaction--downvote.active'(imageId) {
164+
if (window.booru.imagesWithDownvotingDisabled.includes(imageId)) {
165+
return;
166+
}
167+
166168
interact('vote', imageId, 'DELETE').then(() => resetVoted(imageId));
167169
},
168170
'.interaction--fave.active'(imageId) {
@@ -180,6 +182,10 @@ const targets = {
180182
});
181183
},
182184
'.interaction--downvote:not(.active)'(imageId) {
185+
if (window.booru.imagesWithDownvotingDisabled.includes(imageId)) {
186+
return;
187+
}
188+
183189
interact('vote', imageId, 'POST', { up: false }).then(() => {
184190
resetVoted(imageId);
185191
showDownvoted(imageId);

assets/js/utils/__tests__/suggestion-view.spec.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,16 @@ describe('Suggestions', () => {
6161
[popup, input] = mockBaseSuggestionsPopup();
6262

6363
expect(document.querySelector('.autocomplete')).toBeInstanceOf(HTMLElement);
64-
expect(popup.isHidden).toBe(false);
64+
assert(popup.isHidden);
65+
});
66+
67+
it('should hide the popup when there are no suggestions to show', () => {
68+
[popup, input] = mockBaseSuggestionsPopup();
69+
70+
popup.setSuggestions({ history: [], tags: [] });
71+
popup.showForElement(input);
72+
73+
assert(popup.isHidden);
6574
});
6675

6776
it('should render suggestions', () => {

assets/js/utils/suggestions-view.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,13 @@ export class SuggestionsPopupComponent {
321321
}
322322

323323
showForElement(targetElement: HTMLElement) {
324+
if (this.items.length === 0) {
325+
// Hide the popup because there are no suggestions to show. We have to do it
326+
// explicitly, because a border is still rendered even for an empty popup.
327+
this.hide();
328+
return;
329+
}
330+
324331
this.container.style.position = 'absolute';
325332
this.container.style.left = `${targetElement.offsetLeft}px`;
326333

0 commit comments

Comments
 (0)