Fleet UI: Tighten up matchLoosePrefixKey to be stricter for sourcing software icons (#41598)

## 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
<img width="522" height="595" alt="Screenshot 2026-03-12 at 4 42 13 PM"
src="https://github.com/user-attachments/assets/9f805678-c08a-4959-ab6a-3b29c4b1f382"
/>


## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually
This commit is contained in:
RachelElysia
2026-03-12 16:56:01 -05:00
committed by GitHub
parent 85af52667d
commit 5f2dc44cf7
3 changed files with 91 additions and 20 deletions
@@ -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 = <T extends Record<string, unknown>>(
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
@@ -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");
});
});
});
+28
View File
@@ -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 = <T extends Record<string, unknown>>(
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,
};