Merge branch 'feat/auto-mobile-boards' into feat/homarr-assistant

This commit is contained in:
Thomas Camlong committed 2026-08-11 16:59:12 +02:00
commit e2dd68fdbc
36 files changed
+1421 -642

No files matched your search

+27 -26
View File
@@ -1,4 +1,4 @@
import { and, like } from "@homarr/db";
import { getImageMatchRank, normalizeImageName } from "@homarr/common";
import { icons } from "@homarr/db/schema";
import { iconsFindSchema } from "@homarr/validation/icons";
@@ -15,43 +15,44 @@ export const iconsRouter = createTRPCRouter({
})
.input(iconsFindSchema)
.query(async ({ ctx, input }) => {
const term = input.searchText?.toLowerCase().trim() ?? "";
const keywords = (input.searchText ?? "").split(" ").filter((keyword) => keyword.length > 0);
let whereCondition = undefined;
if (term.length > 0) {
whereCondition = and(...keywords.map((keyword) => like(icons.name, `%${keyword}%`)));
}
const term = normalizeImageName(input.searchText ?? "");
const [repositories, countIcons] = await Promise.all([
ctx.db.query.iconRepositories.findMany({
orderBy: (table, { asc, sql }) => [sql`CASE WHEN ${table.slug} = 'local' THEN 0 ELSE 1 END`, asc(table.slug)],
with: {
icons: {
columns: { id: true, name: true, url: true },
where: whereCondition,
orderBy: (table, { asc, sql }) => {
const svgFirst = sql`CASE WHEN ${table.name} LIKE '%.svg' THEN 0 ELSE 1 END`;
const nameAsc = asc(table.name);
if (term.length === 0) {
return [svgFirst, nameAsc];
}
const tier = sql`CASE
WHEN LOWER(${table.name}) = ${term} THEN 0
WHEN LOWER(${table.name}) LIKE ${term + "%"} THEN 1
WHEN LOWER(${table.name}) LIKE ${`%${term}%`} THEN 2
ELSE 3
END`;
return [tier, svgFirst, nameAsc];
},
limit: input.limitPerGroup,
orderBy: (table, { asc, sql }) => [
sql`CASE WHEN ${table.name} LIKE '%.svg' THEN 0 ELSE 1 END`,
asc(table.name),
],
limit: term.length === 0 ? input.limitPerGroup : undefined,
},
},
}),
ctx.db.$count(icons),
]);
const matchedRepositories = repositories.map((repository) => ({
...repository,
icons: repository.icons
.flatMap((icon) => {
const rank = getImageMatchRank(term, icon.name || icon.url);
return rank === null ? [] : [{ icon, rank }];
})
.toSorted((a, b) =>
a.rank !== b.rank
? a.rank - b.rank
: Number(b.icon.name.toLowerCase().endsWith(".svg")) -
Number(a.icon.name.toLowerCase().endsWith(".svg")) || a.icon.name.localeCompare(b.icon.name),
)
.slice(0, input.limitPerGroup)
.map(({ icon }) => icon),
}));
return {
icons: term.length > 0 ? repositories.filter((repository) => repository.icons.length > 0) : repositories,
icons:
term.length === 0 ? repositories : matchedRepositories.filter((repository) => repository.icons.length > 0),
countIcons,
};
}),
@@ -74,4 +74,40 @@ describe("findIcons", () => {
],
});
});
test.each(["homeassistant", "home_assistant", "home assistant.png"])(
"matches normalized search %s before applying the group limit",
async (searchText) => {
const db = createDb();
const caller = iconsRouter.createCaller({ db, deviceType: undefined, session: null });
await db.insert(iconRepositories).values({ id: "repository", slug: "dashboard-icons" });
await db.insert(icons).values([
{
id: "irrelevant",
name: "000-unrelated.svg",
url: "https://example.com/000-unrelated.svg",
checksum: "irrelevant",
iconRepositoryId: "repository",
},
{
id: "home-assistant",
name: "home-assistant.svg",
url: "https://example.com/home-assistant.svg",
checksum: "home-assistant",
iconRepositoryId: "repository",
},
]);
const result = await caller.findIcons({ searchText, limitPerGroup: 1 });
expect(result.icons[0]?.icons).toEqual([
{
id: "home-assistant",
name: "home-assistant.svg",
url: "https://example.com/home-assistant.svg",
},
]);
},
);
});
@@ -113,17 +113,22 @@ async function setup(
describe("Custom JSX v2 board router", () => {
beforeEach(() => mocks.executeRequest.mockClear());
test("allows public queries only through the definition placed on an accessible board", async () => {
test("loads placed widgets and public queries for anonymous visitors on public boards", async () => {
const { db, itemId } = await setup();
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
await expect(caller.getData({ itemId })).resolves.toMatchObject({
template: definition.template,
data: { "load-status": { value: 42 } },
status: { "load-status": { ok: true } },
});
await expect(caller.queryRequest({ itemId, requestId: "status", params: { name: "homarr" } })).resolves.toEqual(
expect.objectContaining({ ok: true, data: { value: 42 } }),
);
await expect(caller.queryRequest({ itemId, requestId: "missing", params: {} })).rejects.toMatchObject({
code: "NOT_FOUND",
});
expect(mocks.executeRequest).toHaveBeenCalledOnce();
expect(mocks.executeRequest).toHaveBeenCalledTimes(2);
});
test("uses current defaults when an updated option no longer accepts the stored value", async () => {
@@ -150,6 +155,7 @@ describe("Custom JSX v2 board router", () => {
await expect(
anonymous.queryRequest({ itemId: privateSetup.itemId, requestId: "status", params: { name: "homarr" } }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
await expect(anonymous.getData({ itemId: privateSetup.itemId })).rejects.toMatchObject({ code: "NOT_FOUND" });
const enabledSetup = await setup();
await enabledSetup.db