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:
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user