Improve sorting of results when entering text in select fields (#4528)

* Sort select results by relevance
* Apply relevance sorting to studio select
* Apply relevance sorting to filter select
This commit is contained in:
WithoutPants
2024-02-07 10:32:19 +11:00
committed by GitHub
parent 9284ede0fb
commit 8770e81ec5
7 changed files with 412 additions and 51 deletions

View File

@@ -2,6 +2,7 @@ import React, { useMemo } from "react";
import { PerformersCriterion } from "src/models/list-filter/criteria/performers";
import { useFindPerformersQuery } from "src/core/generated-graphql";
import { ObjectsFilter } from "./SelectableFilter";
import { sortByRelevance } from "src/utils/query";
interface IPerformersFilter {
criterion: PerformersCriterion;
@@ -18,16 +19,18 @@ function usePerformerQuery(query: string) {
},
});
const results = useMemo(
() =>
data?.findPerformers.performers.map((p) => {
const results = useMemo(() => {
return sortByRelevance(
query,
data?.findPerformers.performers ?? [],
(p) => p.alias_list
).map((p) => {
return {
id: p.id,
label: p.name,
};
}) ?? [],
[data]
);
});
}, [data, query]);
return { results, loading };
}

View File

@@ -2,6 +2,7 @@ import React, { useMemo } from "react";
import { useFindStudiosQuery } from "src/core/generated-graphql";
import { HierarchicalObjectsFilter } from "./SelectableFilter";
import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
import { sortByRelevance } from "src/utils/query";
interface IStudiosFilter {
criterion: StudiosCriterion;
@@ -18,16 +19,18 @@ function useStudioQuery(query: string) {
},
});
const results = useMemo(
() =>
data?.findStudios.studios.map((p) => {
const results = useMemo(() => {
return sortByRelevance(
query,
data?.findStudios.studios ?? [],
(s) => s.aliases
).map((p) => {
return {
id: p.id,
label: p.name,
};
}) ?? [],
[data]
);
});
}, [data, query]);
return { results, loading };
}

View File

@@ -2,6 +2,7 @@ import React, { useMemo } from "react";
import { useFindTagsQuery } from "src/core/generated-graphql";
import { HierarchicalObjectsFilter } from "./SelectableFilter";
import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
import { sortByRelevance } from "src/utils/query";
interface ITagsFilter {
criterion: StudiosCriterion;
@@ -18,16 +19,18 @@ function useTagQuery(query: string) {
},
});
const results = useMemo(
() =>
data?.findTags.tags.map((p) => {
const results = useMemo(() => {
return sortByRelevance(
query,
data?.findTags.tags ?? [],
(t) => t.aliases
).map((p) => {
return {
id: p.id,
label: p.name,
};
}) ?? [],
[data]
);
});
}, [data, query]);
return { results, loading };
}

View File

@@ -26,6 +26,7 @@ import {
} from "../Shared/FilterSelect";
import { useCompare } from "src/hooks/state";
import { Link } from "react-router-dom";
import { sortByRelevance } from "src/utils/query";
export type SelectObject = {
id: string;
@@ -59,7 +60,11 @@ export const PerformerSelect: React.FC<
filter.sortBy = "name";
filter.sortDirection = GQL.SortDirectionEnum.Asc;
const query = await queryFindPerformersForSelect(filter);
return query.data.findPerformers.performers.map((performer) => ({
return sortByRelevance(
input,
query.data.findPerformers.performers,
(p) => p.alias_list
).map((performer) => ({
value: performer.id,
object: performer,
}));

View File

@@ -26,6 +26,7 @@ import {
} from "../Shared/FilterSelect";
import { useCompare } from "src/hooks/state";
import { Placement } from "react-bootstrap/esm/Overlay";
import { sortByRelevance } from "src/utils/query";
export type SelectObject = {
id: string;
@@ -62,13 +63,13 @@ export const StudioSelect: React.FC<
filter.sortBy = "name";
filter.sortDirection = GQL.SortDirectionEnum.Asc;
const query = await queryFindStudiosForSelect(filter);
return query.data.findStudios.studios
.filter((studio) => {
let ret = query.data.findStudios.studios.filter((studio) => {
// HACK - we should probably exclude these in the backend query, but
// this will do in the short-term
return !exclude.includes(studio.id.toString());
})
.map((studio) => ({
});
return sortByRelevance(input, ret, (o) => o.aliases).map((studio) => ({
value: studio.id,
object: studio,
}));

View File

@@ -27,6 +27,7 @@ import {
import { useCompare } from "src/hooks/state";
import { TagPopover } from "./TagPopover";
import { Placement } from "react-bootstrap/esm/Overlay";
import { sortByRelevance } from "src/utils/query";
export type SelectObject = {
id: string;
@@ -63,13 +64,13 @@ export const TagSelect: React.FC<
filter.sortBy = "name";
filter.sortDirection = GQL.SortDirectionEnum.Asc;
const query = await queryFindTagsForSelect(filter);
return query.data.findTags.tags
.filter((tag) => {
let ret = query.data.findTags.tags.filter((tag) => {
// HACK - we should probably exclude these in the backend query, but
// this will do in the short-term
return !exclude.includes(tag.id.toString());
})
.map((tag) => ({
});
return sortByRelevance(input, ret, (o) => o.aliases).map((tag) => ({
value: tag.id,
object: tag,
}));

345
ui/v2.5/src/utils/query.ts Normal file
View File

@@ -0,0 +1,345 @@
interface ISortable {
id: string;
name: string;
}
// sortByRelevance is a function that sorts an array of objects by relevance to a query string.
// It uses the following priorities:
// 1. Exact matches
// 2. Starts with
// 3. Word matches
// 4. Word starts with
// 5. Includes
// If aliases are provided, they are also checked in the same order, but with lower priority than
// the name of the object.
export function sortByRelevance<T extends ISortable>(
query: string,
value: T[],
getAliases?: (o: T) => string[] | undefined
) {
if (!query) {
return value;
}
query = query.toLowerCase();
interface ICacheEntry {
aliases?: string[];
aliasMatch?: boolean;
aliasStartsWith?: boolean;
wordIndex?: number;
wordStartsWithIndex?: number;
aliasWordIndex?: number;
aliasWordStartsWithIndex?: number;
aliasIncludesIndex?: number;
}
const cache: Record<string, ICacheEntry> = {};
function setCache(tag: T, partial: Partial<ICacheEntry>) {
cache[tag.id] = {
...cache[tag.id],
...partial,
};
}
function getObjectAliases(o: T) {
const cached = cache[o.id]?.aliases;
if (cached !== undefined) {
return cached;
}
if (!getAliases) {
return [];
}
const aliases = getAliases(o)?.map((a) => a.toLowerCase()) ?? [];
setCache(o, { aliases });
return aliases;
}
function aliasMatches(o: T) {
const cached = cache[o.id]?.aliasMatch;
if (cached !== undefined) {
return cached;
}
const aliases = getObjectAliases(o);
const aliasMatch = aliases.some((a) => a === query);
setCache(o, { aliasMatch });
return aliasMatch;
}
function aliasStartsWith(o: T) {
const cached = cache[o.id]?.aliasStartsWith;
if (cached !== undefined) {
return cached;
}
const aliases = getObjectAliases(o);
const startsWith = aliases.some((a) => a.startsWith(query));
setCache(o, { aliasStartsWith: startsWith });
return startsWith;
}
function getWords(o: T) {
return o.name.toLowerCase().split(" ");
}
function getAliasWords(tag: T) {
const aliases = getObjectAliases(tag);
return aliases.map((a) => a.split(" ")).flat();
}
function getWordIndex(o: T) {
const cached = cache[o.id]?.wordIndex;
if (cached !== undefined) {
return cached;
}
const words = getWords(o);
const wordIndex = words.findIndex((w) => w === query);
setCache(o, { wordIndex });
return wordIndex;
}
function getAliasWordIndex(o: T) {
const cached = cache[o.id]?.aliasWordIndex;
if (cached !== undefined) {
return cached;
}
const aliasWords = getAliasWords(o);
const aliasWordIndex = aliasWords.findIndex((w) => w === query);
setCache(o, { aliasWordIndex });
return aliasWordIndex;
}
function getWordStartsWithIndex(o: T) {
const cached = cache[o.id]?.wordStartsWithIndex;
if (cached !== undefined) {
return cached;
}
const words = getWords(o);
const wordStartsWithIndex = words.findIndex((w) => w.startsWith(query));
setCache(o, { wordStartsWithIndex });
return wordStartsWithIndex;
}
function getAliasWordStartsWithIndex(o: T) {
const cached = cache[o.id]?.aliasWordStartsWithIndex;
if (cached !== undefined) {
return cached;
}
const aliasWords = getAliasWords(o);
const aliasWordStartsWithIndex = aliasWords.findIndex((w) =>
w.startsWith(query)
);
setCache(o, { aliasWordStartsWithIndex });
return aliasWordStartsWithIndex;
}
function getAliasIncludesIndex(o: T) {
const cached = cache[o.id]?.aliasIncludesIndex;
if (cached !== undefined) {
return cached;
}
const aliases = getObjectAliases(o);
const aliasIncludesIndex = aliases.findIndex((a) => a.includes(query));
setCache(o, { aliasIncludesIndex });
return aliasIncludesIndex;
}
function compare(a: T, b: T) {
const aName = a.name.toLowerCase();
const bName = b.name.toLowerCase();
const aAlias = aliasMatches(a);
const bAlias = aliasMatches(b);
// exact matches first
if (aName === query && bName !== query) {
return -1;
}
if (aName !== query && bName === query) {
return 1;
}
if (aAlias && !bAlias) {
return -1;
}
if (!aAlias && bAlias) {
return 1;
}
// then starts with
if (aName.startsWith(query) && !bName.startsWith(query)) {
return -1;
}
if (!aName.startsWith(query) && bName.startsWith(query)) {
return 1;
}
const aAliasStartsWith = aliasStartsWith(a);
const bAliasStartsWith = aliasStartsWith(b);
if (aAliasStartsWith && !bAliasStartsWith) {
return -1;
}
if (!aAliasStartsWith && bAliasStartsWith) {
return 1;
}
// only check words if the query is a single word
if (!query.includes(" ")) {
// word matches
{
const aWord = getWordIndex(a);
const bWord = getWordIndex(b);
if (aWord !== -1 && bWord === -1) {
return -1;
}
if (aWord === -1 && bWord !== -1) {
return 1;
}
if (aWord !== -1 && bWord !== -1) {
if (aWord === bWord) {
return aName.localeCompare(bName);
}
return aWord - bWord;
}
const aAliasWord = getAliasWordIndex(a);
const bAliasWord = getAliasWordIndex(b);
if (aAliasWord !== -1 && bAliasWord === -1) {
return -1;
}
if (aAliasWord === -1 && bAliasWord !== -1) {
return 1;
}
if (aAliasWord !== -1 && bAliasWord !== -1) {
if (aAliasWord === bAliasWord) {
return aName.localeCompare(bName);
}
return aAliasWord - bAliasWord;
}
}
// then start of word
{
const aWord = getWordStartsWithIndex(a);
const bWord = getWordStartsWithIndex(b);
if (aWord !== -1 && bWord === -1) {
return -1;
}
if (aWord === -1 && bWord !== -1) {
return 1;
}
if (aWord !== -1 && bWord !== -1) {
if (aWord === bWord) {
return aName.localeCompare(bName);
}
return aWord - bWord;
}
const aAliasWord = getAliasWordStartsWithIndex(a);
const bAliasWord = getAliasWordStartsWithIndex(b);
if (aAliasWord !== -1 && bAliasWord === -1) {
return -1;
}
if (aAliasWord === -1 && bAliasWord !== -1) {
return 1;
}
if (aAliasWord !== -1 && bAliasWord !== -1) {
if (aAliasWord === bAliasWord) {
return aName.localeCompare(bName);
}
return aAliasWord - bAliasWord;
}
}
}
// then contains
// performance of this is presumably fast enough to not require caching
const aNameIncludeIndex = aName.indexOf(query);
const bNameIncludeIndex = bName.indexOf(query);
if (aNameIncludeIndex !== -1 && bNameIncludeIndex === -1) {
return -1;
}
if (aNameIncludeIndex === -1 && bNameIncludeIndex !== -1) {
return 1;
}
if (aNameIncludeIndex !== -1 && bNameIncludeIndex !== -1) {
if (aNameIncludeIndex === bNameIncludeIndex) {
return aName.localeCompare(bName);
}
return aNameIncludeIndex - bNameIncludeIndex;
}
const aAliasIncludes = getAliasIncludesIndex(a);
const bAliasIncludes = getAliasIncludesIndex(b);
if (aAliasIncludes !== -1 && bAliasIncludes === -1) {
return -1;
}
if (aAliasIncludes === -1 && bAliasIncludes !== -1) {
return 1;
}
if (aAliasIncludes !== -1 && bAliasIncludes !== -1) {
if (aAliasIncludes === bAliasIncludes) {
return aName.localeCompare(bName);
}
return aAliasIncludes - bAliasIncludes;
}
return aName.localeCompare(bName);
}
return value.slice().sort(compare);
}