From 5f2dc44cf74e83fb19bc28bb20908380b7d63996 Mon Sep 17 00:00:00 2001
From: RachelElysia <71795832+RachelElysia@users.noreply.github.com>
Date: Thu, 12 Mar 2026 17:56:01 -0400
Subject: [PATCH] Fleet UI: Tighten up matchLoosePrefixKey to be stricter for
sourcing software icons (#41598)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Issue
Closes #41548
## Description
- Improve string util we use for matching icons
> Note: Lots of retros how this came about
## Screenshot of fix
Arc vs. Archaeology
## Testing
- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually
---
.../SoftwarePage/components/icons/index.ts | 21 +------
.../utilities/strings/stringUtils.tests.ts | 62 +++++++++++++++++++
frontend/utilities/strings/stringUtils.ts | 28 +++++++++
3 files changed, 91 insertions(+), 20 deletions(-)
diff --git a/frontend/pages/SoftwarePage/components/icons/index.ts b/frontend/pages/SoftwarePage/components/icons/index.ts
index cf46f24449..64e8183347 100644
--- a/frontend/pages/SoftwarePage/components/icons/index.ts
+++ b/frontend/pages/SoftwarePage/components/icons/index.ts
@@ -3,6 +3,7 @@
import { HOST_LINUX_PLATFORMS } from "interfaces/platform";
import { ISoftware } from "interfaces/software";
+import { matchLoosePrefixToKey } from "utilities/strings/stringUtils";
import Backblaze from "./Backblaze";
import BetterDisplay from "./BetterDisplay";
@@ -535,26 +536,6 @@ export const SOFTWARE_SOURCE_TO_ICON_MAP = {
jetbrains_plugins: Extension,
} as const;
-/**
- * This attempts to loosely match the provided string to a key in a provided dictionary, returning the key if the
- * provided string starts with the key or undefined otherwise. Keys are sorted by length (longest first) to ensure
- * more specific matches are checked before shorter, more general ones (e.g., "archaeology" before "arc").
- */
-const matchLoosePrefixToKey = >(
- dict: T,
- s: string
-) => {
- s = s.trim().toLowerCase();
- if (!s) {
- return undefined;
- }
- // Sort keys by length (longest first) to prioritize more specific matches
- const sortedKeys = Object.keys(dict).sort((a, b) => b.length - a.length);
- const match = sortedKeys.find((k) => s.startsWith(k.trim().toLowerCase()));
-
- return match ? (match as keyof T) : undefined;
-};
-
/**
* This strictly matches the provided name and source to a software icon, returning the icon if a match is found or
* null otherwise. It is intended to be used for special cases where a strict match is required
diff --git a/frontend/utilities/strings/stringUtils.tests.ts b/frontend/utilities/strings/stringUtils.tests.ts
index 93b5ef4f66..47a1ece3ff 100644
--- a/frontend/utilities/strings/stringUtils.tests.ts
+++ b/frontend/utilities/strings/stringUtils.tests.ts
@@ -5,6 +5,7 @@ import {
stripQuotes,
isIncompleteQuoteQuery,
hyphenateString,
+ matchLoosePrefixToKey,
} from "./stringUtils";
describe("string utilities", () => {
@@ -142,4 +143,65 @@ describe("string utilities", () => {
);
});
});
+
+ describe("matchLoosePrefixToKey", () => {
+ const MAP = {
+ arc: "Arc",
+ code: "VisualStudioCode",
+ archaeology: "Archaeology",
+ "visual studio code": "VisualStudioCode",
+ "windows app": "WindowsApp",
+ "windows app remote": "WindowsAppRemote",
+ } as const;
+
+ it("matches exact key", () => {
+ expect(matchLoosePrefixToKey(MAP, "Arc")).toBe("arc");
+ });
+
+ it("matches key followed by space and suffix", () => {
+ expect(matchLoosePrefixToKey(MAP, "Visual Studio Code - Insiders")).toBe(
+ "visual studio code"
+ );
+ expect(matchLoosePrefixToKey(MAP, "Code 2")).toBe("code");
+ expect(
+ matchLoosePrefixToKey(MAP, "Visual Studio Code 2025 Edition")
+ ).toBe("visual studio code");
+ });
+
+ it("does not match non-word prefix (Archive vs arc)", () => {
+ expect(matchLoosePrefixToKey(MAP, "Archive")).toBeUndefined();
+ });
+
+ it("matches 'code' as a whole word but not 'Codex'", () => {
+ expect(matchLoosePrefixToKey(MAP, "Code")).toBe("code");
+ expect(matchLoosePrefixToKey(MAP, "Code Helper")).toBe("code");
+ expect(matchLoosePrefixToKey(MAP, "Codex")).toBeUndefined();
+ });
+
+ it("matches variants that use a space before another variant correctly by matching the longest match key", () => {
+ // Exact matches
+ expect(matchLoosePrefixToKey(MAP, "Windows App")).toBe("windows app");
+ expect(matchLoosePrefixToKey(MAP, "Windows App Remote")).toBe(
+ "windows app remote"
+ );
+
+ // With suffixes
+ expect(matchLoosePrefixToKey(MAP, "Windows App - Something")).toBe(
+ "windows app"
+ );
+ expect(
+ matchLoosePrefixToKey(MAP, "Windows App Remote - Something Else")
+ ).toBe("windows app remote");
+ });
+
+ it("is case-insensitive and trims surrounding whitespace", () => {
+ expect(matchLoosePrefixToKey(MAP, " arc ")).toBe("arc");
+ expect(matchLoosePrefixToKey(MAP, "VISUAL STUDIO CODE")).toBe(
+ "visual studio code"
+ );
+ expect(
+ matchLoosePrefixToKey(MAP, " Visual Studio Code - Insiders ")
+ ).toBe("visual studio code");
+ });
+ });
});
diff --git a/frontend/utilities/strings/stringUtils.ts b/frontend/utilities/strings/stringUtils.ts
index fc6c1ad541..291a1e56b9 100644
--- a/frontend/utilities/strings/stringUtils.ts
+++ b/frontend/utilities/strings/stringUtils.ts
@@ -103,6 +103,33 @@ export const hyphenateString = (str: string): string => {
return str.trim().toLowerCase().replace(/\s+/g, "-");
};
+/**
+ * This attempts to loosely match the provided string to a key in a provided dictionary, returning the key if the
+ * provided string starts with the key or undefined otherwise. Keys are sorted by length (longest first) to ensure
+ * more specific matches are checked before shorter, more general ones (e.g., "archaeology" before "arc").
+ */
+export const matchLoosePrefixToKey = >(
+ dict: T,
+ s: string
+) => {
+ s = s.trim().toLowerCase();
+ if (!s) {
+ return undefined;
+ }
+
+ const sortedKeys = Object.keys(dict).sort((a, b) => b.length - a.length);
+
+ const match = sortedKeys.find((rawKey) => {
+ const key = rawKey.trim().toLowerCase();
+ if (!key) return false;
+
+ // Treat keys as whole words at the start: exact match or followed by space
+ return s === key || s.startsWith(`${key} `);
+ });
+
+ return match ? (match as keyof T) : undefined;
+};
+
export default {
capitalize,
capitalizeRole,
@@ -110,4 +137,5 @@ export default {
strToBool,
stripQuotes,
isIncompleteQuoteQuery,
+ matchLoosePrefixToKey,
};