From c0d07747d6e9a7065d2dcda007fad269e0d97a16 Mon Sep 17 00:00:00 2001 From: InfiniteStash <117855276+InfiniteStash@users.noreply.github.com> Date: Sun, 1 Sep 2024 10:27:36 +0000 Subject: [PATCH] TagSearch: Add highlighting of exact matches --- .../src/components/tagFilter/TagFilter.tsx | 34 +++- .../src/components/tagSelect/TagSelect.tsx | 34 +++- frontend/src/graphql/queries/SearchTags.gql | 19 ++- frontend/src/graphql/types.ts | 101 +++++++++++- graphql/schema/schema.graphql | 2 + pkg/api/resolver_query_find_tag.go | 7 + pkg/models/generated_exec.go | 146 ++++++++++++++++++ 7 files changed, 325 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/tagFilter/TagFilter.tsx b/frontend/src/components/tagFilter/TagFilter.tsx index aaaa2bcd1..af2825c06 100644 --- a/frontend/src/components/tagFilter/TagFilter.tsx +++ b/frontend/src/components/tagFilter/TagFilter.tsx @@ -8,7 +8,7 @@ import SearchTagsGQL from "src/graphql/queries/SearchTags.gql"; import { SearchTagsQuery, SearchTagsQueryVariables, useTag } from "src/graphql"; -type Tag = NonNullable; +type Tag = NonNullable; interface TagFilterProps { tag: string; @@ -54,15 +54,43 @@ const TagFilter: FC = ({ }, }); - return data.searchTag + const { exact, query } = data; + + const exactResult = exact + ? { + label: exact.name, + value: exact, + sublabel: exact.description ?? "", + } + : undefined; + + const queryResult = query .filter( - (tag) => !excludeTags.includes(tag.id) && (allowDeleted || !tag.deleted) + (tag) => + !excludeTags.includes(tag.id) && + (allowDeleted || !tag.deleted) && + tag.id !== exact?.id ) .map((tag) => ({ label: tag.name, value: tag, sublabel: tag.description ?? "", })); + + return [ + ...(exactResult + ? [ + { + label: + exactResult.label.toLowerCase() === term.toLowerCase() + ? "Exact Match" + : "Alias Match", + options: [exactResult], + }, + ] + : []), + ...(queryResult ? [{ label: "Tags", options: queryResult }] : []), + ]; }; const debouncedLoadOptions = debounce(handleSearch, 400); diff --git a/frontend/src/components/tagSelect/TagSelect.tsx b/frontend/src/components/tagSelect/TagSelect.tsx index 8972e4c41..78f21da64 100644 --- a/frontend/src/components/tagSelect/TagSelect.tsx +++ b/frontend/src/components/tagSelect/TagSelect.tsx @@ -11,7 +11,7 @@ import { TagLink } from "src/components/fragments"; import { tagHref } from "src/utils/route"; import { compareByName } from "src/utils"; -type Tag = NonNullable; +type Tag = NonNullable; type TagSlim = { id: string; @@ -91,15 +91,43 @@ const TagSelect: FC = ({ }, }); - return data.searchTag + const { exact, query } = data; + + const exactResult = exact + ? { + label: exact.name, + value: exact, + sublabel: exact.description ?? "", + } + : undefined; + + const queryResult = query .filter( - (tag) => !excluded.includes(tag.id) && (allowDeleted || !tag.deleted) + (tag) => + !excluded.includes(tag.id) && + (allowDeleted || !tag.deleted) && + tag.id !== exact?.id ) .map((tag) => ({ label: tag.name, value: tag, sublabel: tag.description ?? "", })); + + return [ + ...(exactResult + ? [ + { + label: + exactResult.label.toLowerCase() === term.toLowerCase() + ? "Exact Match" + : "Alias Match", + options: [exactResult], + }, + ] + : []), + ...(queryResult ? [{ label: "Tags", options: queryResult }] : []), + ]; }; const debouncedLoadOptions = debounce(handleSearch, 400); diff --git a/frontend/src/graphql/queries/SearchTags.gql b/frontend/src/graphql/queries/SearchTags.gql index e92c593ae..fb74259bc 100644 --- a/frontend/src/graphql/queries/SearchTags.gql +++ b/frontend/src/graphql/queries/SearchTags.gql @@ -1,9 +1,16 @@ +fragment SearchTagFragment on Tag { + deleted + id + name + description + aliases +} + query SearchTags($term: String!, $limit: Int = 5) { - searchTag(term: $term, limit: $limit) { - deleted - id - name - description - aliases + exact: findTagOrAlias(name: $term) { + ...SearchTagFragment + } + query: searchTag(term: $term, limit: $limit) { + ...SearchTagFragment } } diff --git a/frontend/src/graphql/types.ts b/frontend/src/graphql/types.ts index f3b57789b..f7522697b 100644 --- a/frontend/src/graphql/types.ts +++ b/frontend/src/graphql/types.ts @@ -1069,6 +1069,8 @@ export type Query = { findTag?: Maybe; /** Find a tag category by ID */ findTagCategory?: Maybe; + /** Find a tag with a matching name or alias */ + findTagOrAlias?: Maybe; /** Find user by ID or username */ findUser?: Maybe; getConfig: StashBoxConfig; @@ -1151,6 +1153,11 @@ export type QueryFindTagCategoryArgs = { id: Scalars["ID"]; }; +/** The query root for this schema */ +export type QueryFindTagOrAliasArgs = { + name: Scalars["String"]; +}; + /** The query root for this schema */ export type QueryFindUserArgs = { id?: InputMaybe; @@ -18292,6 +18299,15 @@ export type SearchPerformersQuery = { }>; }; +export type SearchTagFragment = { + __typename: "Tag"; + deleted: boolean; + id: string; + name: string; + description?: string | null; + aliases: Array; +}; + export type SearchTagsQueryVariables = Exact<{ term: Scalars["String"]; limit?: InputMaybe; @@ -18299,7 +18315,15 @@ export type SearchTagsQueryVariables = Exact<{ export type SearchTagsQuery = { __typename: "Query"; - searchTag: Array<{ + exact?: { + __typename: "Tag"; + deleted: boolean; + id: string; + name: string; + description?: string | null; + aliases: Array; + } | null; + query: Array<{ __typename: "Tag"; deleted: boolean; id: string; @@ -20950,6 +20974,29 @@ export const SearchPerformerFragmentDoc = { }, ], } as unknown as DocumentNode; +export const SearchTagFragmentDoc = { + kind: "Document", + definitions: [ + { + kind: "FragmentDefinition", + name: { kind: "Name", value: "SearchTagFragment" }, + typeCondition: { + kind: "NamedType", + name: { kind: "Name", value: "Tag" }, + }, + selectionSet: { + kind: "SelectionSet", + selections: [ + { kind: "Field", name: { kind: "Name", value: "deleted" } }, + { kind: "Field", name: { kind: "Name", value: "id" } }, + { kind: "Field", name: { kind: "Name", value: "name" } }, + { kind: "Field", name: { kind: "Name", value: "description" } }, + { kind: "Field", name: { kind: "Name", value: "aliases" } }, + ], + }, + }, + ], +} as unknown as DocumentNode; export const ActivateNewUserDocument = { kind: "Document", definitions: [ @@ -48107,6 +48154,31 @@ export const SearchTagsDocument = { selections: [ { kind: "Field", + alias: { kind: "Name", value: "exact" }, + name: { kind: "Name", value: "findTagOrAlias" }, + arguments: [ + { + kind: "Argument", + name: { kind: "Name", value: "name" }, + value: { + kind: "Variable", + name: { kind: "Name", value: "term" }, + }, + }, + ], + selectionSet: { + kind: "SelectionSet", + selections: [ + { + kind: "FragmentSpread", + name: { kind: "Name", value: "SearchTagFragment" }, + }, + ], + }, + }, + { + kind: "Field", + alias: { kind: "Name", value: "query" }, name: { kind: "Name", value: "searchTag" }, arguments: [ { @@ -48129,17 +48201,34 @@ export const SearchTagsDocument = { selectionSet: { kind: "SelectionSet", selections: [ - { kind: "Field", name: { kind: "Name", value: "deleted" } }, - { kind: "Field", name: { kind: "Name", value: "id" } }, - { kind: "Field", name: { kind: "Name", value: "name" } }, - { kind: "Field", name: { kind: "Name", value: "description" } }, - { kind: "Field", name: { kind: "Name", value: "aliases" } }, + { + kind: "FragmentSpread", + name: { kind: "Name", value: "SearchTagFragment" }, + }, ], }, }, ], }, }, + { + kind: "FragmentDefinition", + name: { kind: "Name", value: "SearchTagFragment" }, + typeCondition: { + kind: "NamedType", + name: { kind: "Name", value: "Tag" }, + }, + selectionSet: { + kind: "SelectionSet", + selections: [ + { kind: "Field", name: { kind: "Name", value: "deleted" } }, + { kind: "Field", name: { kind: "Name", value: "id" } }, + { kind: "Field", name: { kind: "Name", value: "name" } }, + { kind: "Field", name: { kind: "Name", value: "description" } }, + { kind: "Field", name: { kind: "Name", value: "aliases" } }, + ], + }, + }, ], } as unknown as DocumentNode; export const SiteDocument = { diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 88df6f4f6..139df3da6 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -19,6 +19,8 @@ type Query { # tag names will be unique """Find a tag by ID or name""" findTag(id: ID, name: String): Tag @hasRole(role: READ) + """Find a tag with a matching name or alias""" + findTagOrAlias(name: String!): Tag @hasRole(role: READ) queryTags(input: TagQueryInput!): QueryTagsResultType! @hasRole(role: READ) """Find a tag category by ID""" diff --git a/pkg/api/resolver_query_find_tag.go b/pkg/api/resolver_query_find_tag.go index e47be8c74..a27d8042b 100644 --- a/pkg/api/resolver_query_find_tag.go +++ b/pkg/api/resolver_query_find_tag.go @@ -21,6 +21,13 @@ func (r *queryResolver) FindTag(ctx context.Context, id *uuid.UUID, name *string return nil, nil } +func (r *queryResolver) FindTagOrAlias(ctx context.Context, name string) (*models.Tag, error) { + fac := r.getRepoFactory(ctx) + qb := fac.Tag() + + return qb.FindByNameOrAlias(name) +} + func (r *queryResolver) QueryTags(ctx context.Context, input models.TagQueryInput) (*models.QueryTagsResultType, error) { fac := r.getRepoFactory(ctx) qb := fac.Tag() diff --git a/pkg/models/generated_exec.go b/pkg/models/generated_exec.go index ddabfae1e..61608cd7d 100644 --- a/pkg/models/generated_exec.go +++ b/pkg/models/generated_exec.go @@ -347,6 +347,7 @@ type ComplexityRoot struct { FindStudio func(childComplexity int, id *uuid.UUID, name *string) int FindTag func(childComplexity int, id *uuid.UUID, name *string) int FindTagCategory func(childComplexity int, id uuid.UUID) int + FindTagOrAlias func(childComplexity int, name string) int FindUser func(childComplexity int, id *uuid.UUID, username *string) int GetConfig func(childComplexity int) int Me func(childComplexity int) int @@ -751,6 +752,7 @@ type QueryResolver interface { FindStudio(ctx context.Context, id *uuid.UUID, name *string) (*Studio, error) QueryStudios(ctx context.Context, input StudioQueryInput) (*QueryStudiosResultType, error) FindTag(ctx context.Context, id *uuid.UUID, name *string) (*Tag, error) + FindTagOrAlias(ctx context.Context, name string) (*Tag, error) QueryTags(ctx context.Context, input TagQueryInput) (*QueryTagsResultType, error) FindTagCategory(ctx context.Context, id uuid.UUID) (*TagCategory, error) QueryTagCategories(ctx context.Context) (*QueryTagCategoriesResultType, error) @@ -2729,6 +2731,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Query.FindTagCategory(childComplexity, args["id"].(uuid.UUID)), true + case "Query.findTagOrAlias": + if e.complexity.Query.FindTagOrAlias == nil { + break + } + + args, err := ec.field_Query_findTagOrAlias_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Query.FindTagOrAlias(childComplexity, args["name"].(string)), true + case "Query.findUser": if e.complexity.Query.FindUser == nil { break @@ -5600,6 +5614,8 @@ type Query { # tag names will be unique """Find a tag by ID or name""" findTag(id: ID, name: String): Tag @hasRole(role: READ) + """Find a tag with a matching name or alias""" + findTagOrAlias(name: String!): Tag @hasRole(role: READ) queryTags(input: TagQueryInput!): QueryTagsResultType! @hasRole(role: READ) """Find a tag category by ID""" @@ -6790,6 +6806,21 @@ func (ec *executionContext) field_Query_findTagCategory_args(ctx context.Context return args, nil } +func (ec *executionContext) field_Query_findTagOrAlias_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 string + if tmp, ok := rawArgs["name"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("name")) + arg0, err = ec.unmarshalNString2string(ctx, tmp) + if err != nil { + return nil, err + } + } + args["name"] = arg0 + return args, nil +} + func (ec *executionContext) field_Query_findTag_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -19070,6 +19101,102 @@ func (ec *executionContext) fieldContext_Query_findTag(ctx context.Context, fiel return fc, nil } +func (ec *executionContext) _Query_findTagOrAlias(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Query_findTagOrAlias(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + directive0 := func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Query().FindTagOrAlias(rctx, fc.Args["name"].(string)) + } + directive1 := func(ctx context.Context) (interface{}, error) { + role, err := ec.unmarshalNRoleEnum2githubᚗcomᚋstashappᚋstashᚑboxᚋpkgᚋmodelsᚐRoleEnum(ctx, "READ") + if err != nil { + return nil, err + } + if ec.directives.HasRole == nil { + return nil, errors.New("directive hasRole is not implemented") + } + return ec.directives.HasRole(ctx, nil, directive0, role) + } + + tmp, err := directive1(rctx) + if err != nil { + return nil, graphql.ErrorOnPath(ctx, err) + } + if tmp == nil { + return nil, nil + } + if data, ok := tmp.(*Tag); ok { + return data, nil + } + return nil, fmt.Errorf(`unexpected type %T from directive, should be *github.com/stashapp/stash-box/pkg/models.Tag`, tmp) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*Tag) + fc.Result = res + return ec.marshalOTag2ᚖgithubᚗcomᚋstashappᚋstashᚑboxᚋpkgᚋmodelsᚐTag(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Query_findTagOrAlias(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_Tag_id(ctx, field) + case "name": + return ec.fieldContext_Tag_name(ctx, field) + case "description": + return ec.fieldContext_Tag_description(ctx, field) + case "aliases": + return ec.fieldContext_Tag_aliases(ctx, field) + case "deleted": + return ec.fieldContext_Tag_deleted(ctx, field) + case "edits": + return ec.fieldContext_Tag_edits(ctx, field) + case "category": + return ec.fieldContext_Tag_category(ctx, field) + case "created": + return ec.fieldContext_Tag_created(ctx, field) + case "updated": + return ec.fieldContext_Tag_updated(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Tag", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query_findTagOrAlias_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Query_queryTags(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Query_queryTags(ctx, field) if err != nil { @@ -39420,6 +39547,25 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "findTagOrAlias": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_findTagOrAlias(ctx, field) + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) case "queryTags": field := field