Skip to content

Commit

Permalink
feat(popup-menu): sort matches by location
Browse files Browse the repository at this point in the history
  • Loading branch information
philippfromme committed Jun 5, 2024
1 parent aeb84c6 commit d36bc82
Show file tree
Hide file tree
Showing 4 changed files with 256 additions and 38 deletions.
64 changes: 49 additions & 15 deletions lib/features/popup-menu/PopupMenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ import {
isDefined
} from 'min-dash';

import {
compareStrings,
compareTokens,
findMatches,
hasMatch
} from '../../util/SearchUtil';

import PopupMenuComponent from './PopupMenuComponent';

/**
Expand Down Expand Up @@ -568,7 +575,7 @@ PopupMenu.prototype._getFindEntries = function(providers) {
provider => isFunction(provider.findPopupMenuEntries)
);

return provider ? provider.findPopupMenuEntries() : defaultFindEntries;
return provider ? provider.findPopupMenuEntries : defaultFindEntries;
};


Expand Down Expand Up @@ -639,18 +646,45 @@ PopupMenu.prototype._getEntry = function(entryId) {
return entry;
};

function defaultFindEntries(entries, value) {
return entries.filter(entry => {
const searchableFields = [
entry.description || '',
entry.label || '',
entry.search || ''
].map(string => string.toLowerCase());

// every word of `value` should be included in one of the searchable fields
return value
.toLowerCase()
.split(/\s/g)
.every(word => searchableFields.some(field => field.includes(word)));
});
export function defaultFindEntries(entries, pattern) {
return entries
.reduce(function(results, entry) {
var label = entry.label || '',
description = entry.description || '',
search = entry.search || '';

var primaryTokens = findMatches(label, pattern),
secondaryTokens = findMatches(description, pattern),
tertiaryTokens = findMatches(search, pattern);

if (hasMatch(primaryTokens) || hasMatch(secondaryTokens) || hasMatch(tertiaryTokens)) {
return [
...results,
{
primaryTokens,
secondaryTokens,
tertiaryTokens,
entry
}
];
}

return results;
}, [])
.sort(function(a, b) {
var aLabel = a.entry.label || '',
aDescription = a.entry.description || '';

var bLabel = b.entry.label || '',
bDescription = b.entry.description || '';

return compareTokens(a.primaryTokens, b.primaryTokens)
|| compareTokens(a.secondaryTokens, b.secondaryTokens)
|| compareTokens(a.tertiaryTokens, b.tertiaryTokens)
|| compareStrings(aLabel, bLabel)
|| compareStrings(aDescription, bDescription);
})
.map(function(result) {
return result.entry;
});
}
122 changes: 122 additions & 0 deletions lib/util/SearchUtil.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* @typedef { {
* normal?: string;
* matched?: string;
* } } Token
*/

/**
* @param {Token} token
*
* @return {boolean}
*/
export function isMatch(token) {
return 'matched' in token;
}

/**
* @param {Token[]} tokens
*
* @return {boolean}
*/
export function hasMatch(tokens) {
return tokens.find(isMatch);
}

/**
* Compares two token arrays.
*
* @param {Token[]} tokensA
* @param {Token[]} tokensB
*
* @returns {number}
*/
export function compareTokens(tokensA, tokensB) {
const tokensAHasMatch = hasMatch(tokensA),
tokensBHasMatch = hasMatch(tokensB);

if (tokensAHasMatch && !tokensBHasMatch) {
return -1;
}

if (!tokensAHasMatch && tokensBHasMatch) {
return 1;
}

if (!tokensAHasMatch && !tokensBHasMatch) {
return 0;
}

const tokensAFirstMatch = tokensA.find(isMatch),
tokensBFirstMatch = tokensB.find(isMatch);

if (tokensAFirstMatch.index < tokensBFirstMatch.index) {
return -1;

Check warning on line 54 in lib/util/SearchUtil.js

View check run for this annotation

Codecov / codecov/patch

lib/util/SearchUtil.js#L54

Added line #L54 was not covered by tests
}

if (tokensAFirstMatch.index > tokensBFirstMatch.index) {
return 1;
}

return 0;
}

/**
* Compares two strings.
*
* @param {string} a
* @param {string} b
*
* @returns {number}
*/
export function compareStrings(a, b) {
return a.localeCompare(b);
}

/**
* @param {string} text
* @param {string} pattern
*
* @return {Token[]}
*/
export function findMatches(text, pattern) {
var tokens = [],
originalText = text;

if (!text) {
return tokens;
}

text = text.toLowerCase();
pattern = pattern.toLowerCase();

var index = text.indexOf(pattern);

if (index > -1) {
if (index !== 0) {
tokens.push({
normal: originalText.slice(0, index),
index: 0
});
}

tokens.push({
matched: originalText.slice(index, index + pattern.length),
index: index
});

if (pattern.length + index < text.length) {
tokens.push({
normal: originalText.slice(index + pattern.length),
index: index + pattern.length
});
}
} else {
tokens.push({
normal: originalText,
index: 0
});
}

return tokens;
}
3 changes: 3 additions & 0 deletions test/spec/features/popup-menu/PopupMenuComponentSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
queryAll as domQueryAll
} from 'min-dom';

import { defaultFindEntries } from '../../../../lib/features/popup-menu/PopupMenu';


const TEST_IMAGE_URL = `data:image/svg+xml;utf8,${
encodeURIComponent(`
Expand Down Expand Up @@ -727,6 +729,7 @@ describe('features/popup-menu - <PopupMenu>', function() {
const props = {
entries: [],
headerEntries: [],
findEntries: defaultFindEntries,
position() {
return { x: 0, y: 0 };
},
Expand Down
105 changes: 82 additions & 23 deletions test/spec/features/popup-menu/PopupMenuSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1495,28 +1495,6 @@ describe('features/popup-menu', function() {
}));


it('should show search results (matching label & search)', inject(async function(popupMenu) {

// given
popupMenu.registerProvider('test-menu', testMenuProvider);
popupMenu.open({}, 'test-menu', { x: 100, y: 100 }, { search: true });

// when
await triggerSearch('delta search');

// then
var shownEntries;

await waitFor(() => {
shownEntries = queryPopupAll('.entry');

expect(shownEntries).to.have.length(1);
});

expect(shownEntries[0].querySelector('.djs-popup-label').textContent).to.eql('Delta');
}));


describe('ranking', function() {

it('should hide rank < 0 items', inject(async function(popupMenu) {
Expand Down Expand Up @@ -1646,6 +1624,18 @@ describe('features/popup-menu', function() {
c: {
id: 'c',
label: 'Charlie'
},
d: {
id: 'd',
label: 'Delta'
},
e: {
id: 'e',
label: 'Echo'
},
f: {
id: 'f',
label: 'Foxtrot'
}
};
},
Expand All @@ -1668,12 +1658,81 @@ describe('features/popup-menu', function() {
await waitFor(() => {
shownEntries = queryPopupAll('.entry');

expect(shownEntries).to.have.length(1);
expect(shownEntries).to.have.length(6);
});

expect(shownEntries[0].querySelector('.djs-popup-label').textContent).to.eql('Alpha');
}));


describe('sorting', function() {

it('should order entries', inject(async function(popupMenu) {

// given
var testMenuProvider = {
getPopupMenuEntries: function() {
return {
a: {
id: 'a',
label: 'bar foo'
},
b: {
id: 'b',
label: 'foo baz'
},
c: {
id: 'c',
label: 'bar',
description: 'bar foo'
},
d: {
id: 'd',
label: 'bar',
description: 'foo baz'
},
e: {
id: 'e',
label: 'bar',
description: 'foo bar'
},
f: {
id: 'f',
label: 'foo bar'
},
g: {
id: 'g',
label: 'bar'
}
};
}
};

popupMenu.registerProvider('test-menu', testMenuProvider);
popupMenu.open({}, 'test-menu', { x: 100, y: 100 }, { search: true });

// when
await triggerSearch('foo');

// then
var shownEntries;

await waitFor(() => {
shownEntries = queryPopupAll('.entry');

expect(shownEntries).to.have.length(6);
});

expect(shownEntries[0].getAttribute('data-id')).to.eql('f');
expect(shownEntries[1].getAttribute('data-id')).to.eql('b');
expect(shownEntries[2].getAttribute('data-id')).to.eql('a');
expect(shownEntries[3].getAttribute('data-id')).to.eql('e');
expect(shownEntries[4].getAttribute('data-id')).to.eql('d');
expect(shownEntries[5].getAttribute('data-id')).to.eql('c');
}));

});

});


Expand Down

0 comments on commit d36bc82

Please sign in to comment.