From deb25d4b5936fd1c2a7e625faf7cf140ae5044aa Mon Sep 17 00:00:00 2001 From: Alicia Sykes Date: Fri, 22 May 2026 12:48:35 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=20Improve=20search=20performance,=20w?= =?UTF-8?q?ith=20pre-filter=20(#2156)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Settings/SearchBar.vue | 20 ++++++++--- src/components/Workspace/SideBar.vue | 13 +++---- src/mixins/HomeMixin.js | 13 ++++--- src/utils/IsVisibleToUser.js | 6 ++-- src/utils/Search.js | 49 +++++++++++++++------------ src/utils/auth/Auth.js | 3 +- src/utils/auth/KeycloakAuth.js | 3 +- src/utils/auth/OidcAuth.js | 3 +- 8 files changed, 64 insertions(+), 46 deletions(-) diff --git a/src/components/Settings/SearchBar.vue b/src/components/Settings/SearchBar.vue index 19501337..4a98726c 100644 --- a/src/components/Settings/SearchBar.vue +++ b/src/components/Settings/SearchBar.vue @@ -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) { diff --git a/src/components/Workspace/SideBar.vue b/src/components/Workspace/SideBar.vue index c0899b77..e93ade61 100644 --- a/src/components/Workspace/SideBar.vue +++ b/src/components/Workspace/SideBar.vue @@ -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) { diff --git a/src/mixins/HomeMixin.js b/src/mixins/HomeMixin.js index 37c6bfc9..e1fa44e1 100644 --- a/src/mixins/HomeMixin.js +++ b/src/mixins/HomeMixin.js @@ -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 */ diff --git a/src/utils/IsVisibleToUser.js b/src/utils/IsVisibleToUser.js index 97c2f030..0ad9f740 100644 --- a/src/utils/IsVisibleToUser.js +++ b/src/utils/IsVisibleToUser.js @@ -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 = () => { diff --git a/src/utils/Search.js b/src/utils/Search.js index d2f61001..9debfeeb 100644 --- a/src/utils/Search.js +++ b/src/utils/Search.js @@ -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)); }); }; diff --git a/src/utils/auth/Auth.js b/src/utils/auth/Auth.js index d517e8b4..37ad29db 100644 --- a/src/utils/auth/Auth.js +++ b/src/utils/auth/Auth.js @@ -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() || {}; }; /** diff --git a/src/utils/auth/KeycloakAuth.js b/src/utils/auth/KeycloakAuth.js index 6083066d..f83cc243 100644 --- a/src/utils/auth/KeycloakAuth.js +++ b/src/utils/auth/KeycloakAuth.js @@ -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 = () => { diff --git a/src/utils/auth/OidcAuth.js b/src/utils/auth/OidcAuth.js index 222a989e..c9dbafe2 100644 --- a/src/utils/auth/OidcAuth.js +++ b/src/utils/auth/OidcAuth.js @@ -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 = () => {