Improve search performance, with pre-filter (#2156)

This commit is contained in:
Alicia Sykes
2026-05-22 12:48:35 +01:00
parent cdf9b3f40c
commit deb25d4b59
8 changed files with 64 additions and 46 deletions

View File

@@ -44,6 +44,7 @@ export default {
input: '', // Users current search term
akn: new ArrowKeyNavigation(), // Class that manages arrow key naviagtion
getCustomKeyShortcuts,
emitFrame: null, // Pending requestAnimationFrame id, for coalescing keystrokes
};
},
computed: {
@@ -67,6 +68,7 @@ export default {
},
beforeUnmount() {
window.removeEventListener('keydown', this.handleKeyPress);
if (this.emitFrame != null) cancelAnimationFrame(this.emitFrame);
},
methods: {
/* Call correct function dependending on which key is pressed */
@@ -93,14 +95,22 @@ export default {
},
/* Emmits users's search term up to parent */
userIsTypingSomething() {
this.$emit('user-is-searchin', this.input);
if (this.emitFrame != null) return;
this.emitFrame = requestAnimationFrame(() => {
this.emitFrame = null;
this.$emit('user-is-searchin', this.input);
});
},
/* Resets everything to initial state, when user is finished */
clearFilterInput() {
this.input = ''; // Clear input model
this.userIsTypingSomething(); // Emmit new empty value
document.activeElement.blur(); // Remove focus
this.akn.resetIndex(); // Reset current element index
this.input = '';
if (this.emitFrame != null) {
cancelAnimationFrame(this.emitFrame);
this.emitFrame = null;
}
this.$emit('user-is-searchin', '');
document.activeElement.blur();
this.akn.resetIndex();
},
/* If configured, launch specific app when hotkey pressed */
handleHotKey(key) {

View File

@@ -36,7 +36,8 @@ import SideBarItem from '@/components/Workspace/SideBarItem.vue';
import SideBarSection from '@/components/Workspace/SideBarSection.vue';
import IconHome from '@/assets/interface-icons/application-home.svg';
import IconMinimalView from '@/assets/interface-icons/application-minimal.svg';
import { checkItemVisibility } from '@/utils/CheckItemVisibility';
import { getCurrentUser, isLoggedInAsGuest } from '@/utils/auth/Auth';
import { isVisibleToUser } from '@/utils/IsVisibleToUser';
import { makeRoutePath, resolveRouteIntent } from '@/utils/config/ConfigHelpers';
export default {
@@ -100,11 +101,11 @@ export default {
},
/* Return a list with visible items on a section to the user or guest */
filterTiles(allTiles) {
if (!allTiles) {
return [];
}
return allTiles.filter((tile) => checkItemVisibility(tile)
&& !tile.displayData?.hideFromWorkspace);
if (!allTiles) return [];
const currentUser = getCurrentUser();
const isGuest = isLoggedInAsGuest();
return allTiles.filter((tile) => !tile.displayData?.hideFromWorkspace
&& isVisibleToUser(tile.displayData || {}, currentUser, isGuest));
},
/* Build a URL for the given view, preserving the current sub-page and section */
pathFor(view) {

View File

@@ -5,7 +5,8 @@
import Defaults, { localStorageKeys, iconCdns } from '@/utils/config/defaults';
import Keys from '@/utils/StoreMutations';
import { searchTiles } from '@/utils/Search';
import { checkItemVisibility } from '@/utils/CheckItemVisibility';
import { getCurrentUser, isLoggedInAsGuest } from '@/utils/auth/Auth';
import { isVisibleToUser } from '@/utils/IsVisibleToUser';
import { resolveRouteIntent, PAGE_STATUS } from '@/utils/config/ConfigHelpers';
const HomeMixin = {
@@ -88,10 +89,12 @@ const HomeMixin = {
},
/* Returns only the tiles that match the users search query */
filterTiles(allTiles) {
if (!allTiles) {
return [];
}
const visibleTiles = allTiles.filter((tile) => checkItemVisibility(tile));
if (!allTiles) return [];
const currentUser = getCurrentUser();
const isGuest = isLoggedInAsGuest();
const visibleTiles = allTiles.filter(
(tile) => isVisibleToUser(tile.displayData || {}, currentUser, isGuest),
);
return searchTiles(visibleTiles, this.searchValue);
},
/* Checks if any sections or items use icons from a given CDN */

View File

@@ -25,9 +25,9 @@ const determineIntersection = (source = [], target = []) => {
};
/* Returns false if the displayData of a section/item
should not be rendered for the current user/ guest */
export const isVisibleToUser = (displayData, currentUser) => {
const isGuest = isLoggedInAsGuest(); // Check if current user is a guest
* says it should not be rendered for current user/guest */
export const isVisibleToUser = (displayData, currentUser, isGuest) => {
if (isGuest === undefined) isGuest = isLoggedInAsGuest();
// Checks if user explicitly has access to a certain section
const checkVisibility = () => {

View File

@@ -15,17 +15,28 @@ const getDomainFromUrl = (url) => {
return domainPattern ? domainPattern[1] : '';
};
/**
* Compares search term to a given data attribute
* Ignores case, special characters and order
* @param {string or other} compareStr The value to compare to
* @param {string} searchStr The users search term
* @returns {boolean} true if a match, otherwise false
*/
const filterHelper = (compareStr, searchStr) => {
if (!compareStr) return false;
const process = (input) => input?.toString().toLowerCase().replace(/[^\w\s\p{Alpha}]/giu, '');
return process(searchStr).split(/\s/).every(word => process(compareStr).includes(word));
/* Normalize a string for case/punctuation-insensitive matching */
const NORMALIZE_RE = /[^\w\s\p{Alpha}]/giu;
const normalize = (input) => (input == null ? '' : input.toString().toLowerCase().replace(NORMALIZE_RE, ''));
/* Per-tile cache of the concatenated searchable text */
const haystackCache = new WeakMap();
const buildHaystack = (tile) => {
const {
title, description, provider, url, tags,
} = tile;
const tagsStr = Array.isArray(tags) ? tags.join(' ') : (tags || '');
return normalize(`${title || ''} ${provider || ''} ${description || ''} ${tagsStr} ${getDomainFromUrl(url)}`);
};
const getHaystack = (tile) => {
let h = haystackCache.get(tile);
if (h === undefined) {
h = buildHaystack(tile);
haystackCache.set(tile, h);
}
return h;
};
/**
@@ -37,17 +48,13 @@ const filterHelper = (compareStr, searchStr) => {
* @returns A filtered array of tiles
*/
export const searchTiles = (allTiles, searchTerm) => {
if (!searchTerm) return allTiles; // If no search term, then return all
if (!allTiles) return []; // If no data, then skip
if (!searchTerm) return allTiles;
if (!allTiles) return [];
const words = normalize(searchTerm).split(/\s+/).filter(Boolean);
if (!words.length) return allTiles;
return allTiles.filter((tile) => {
const {
title, description, provider, url, tags,
} = tile;
return filterHelper(title, searchTerm)
|| filterHelper(provider, searchTerm)
|| filterHelper(description, searchTerm)
|| filterHelper(tags, searchTerm)
|| filterHelper(getDomainFromUrl(url), searchTerm);
const haystack = getHaystack(tile);
return words.every((word) => haystack.includes(word));
});
};

View File

@@ -8,8 +8,7 @@ import { isOidcEnabled } from '@/utils/auth/OidcAuth';
/* Uses config accumulator to get and return app config */
const getAppConfig = () => {
const Accumulator = new ConfigAccumulator();
const config = Accumulator.config();
return config.appConfig || {};
return Accumulator.appConfig() || {};
};
/**

View File

@@ -7,8 +7,7 @@ import { toast } from '@/utils/Toast';
const getAppConfig = () => {
const Accumulator = new ConfigAccumulator();
const config = Accumulator.config();
return config.appConfig || {};
return Accumulator.appConfig() || {};
};
const isKeycloakGuestAccessEnabled = () => {

View File

@@ -13,8 +13,7 @@ const SIGNIN_GUARD_THRESHOLD_MS = 5 * 1000;
const getAppConfig = () => {
const Accumulator = new ConfigAccumulator();
const config = Accumulator.config();
return config.appConfig || {};
return Accumulator.appConfig() || {};
};
const isOidcGuestAccessEnabled = () => {