diff --git a/go.work.sum b/go.work.sum index ab7fb719c0..cc68f77618 100644 --- a/go.work.sum +++ b/go.work.sum @@ -2598,6 +2598,10 @@ github.com/redis/go-redis/v9 v9.1.0/go.mod h1:urWj3He21Dj5k4TK1y59xH8Uj6ATueP8AH github.com/reearth/reearthx v0.0.0-20221109022045-dd54f4626639/go.mod h1:YZMXO1RhQ5fFL0GIOFvJq2GNskW7w+xoW4Zfu2QUXhw= github.com/reearth/reearthx v0.0.0-20230531092445-3bdc26691898 h1:M9m03h+EBR33vxIfBnen5kavaIRRu7gXFkwqHqHU0l4= github.com/reearth/reearthx v0.0.0-20230531092445-3bdc26691898/go.mod h1:Rh7MJPKq43f+HZ/PwjZ5vEbGPpllNFvUrxn9sBn2b+s= +github.com/reearth/reearthx v0.0.0-20241023075926-e29bdd6c4ae3 h1:aFm6QNDFs08EKlrWJN9IBqdxlDUuCBIIgBIcPkLHOZY= +github.com/reearth/reearthx v0.0.0-20241023075926-e29bdd6c4ae3/go.mod h1:d1WXkdCVzSoc8pl3vW9/9yKfk4fdoZQZhX8Ot8jqgnc= +github.com/reearth/reearthx v0.0.0-20241025125329-f01a05daf443 h1:r3bAWyEVAMX60W70OPeWd0uA+2sLhXgox41rQb2XKDY= +github.com/reearth/reearthx v0.0.0-20241025125329-f01a05daf443/go.mod h1:d1WXkdCVzSoc8pl3vW9/9yKfk4fdoZQZhX8Ot8jqgnc= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/robertkrimen/godocdown v0.0.0-20130622164427-0bfa04905481 h1:jMxcLa+VjJKhpCwbLUXAD15wJ+hhvXMLujCl3MkXpfM= diff --git a/server/Dockerfile b/server/Dockerfile index 777adfe083..1f95de95fd 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.23.2-alpine AS build +FROM golang:1.23.3-alpine AS build ARG TAG=release ARG REV ARG VERSION diff --git a/server/e2e/gql_user_test.go b/server/e2e/gql_user_test.go index 6ae0ced3a7..314d404335 100644 --- a/server/e2e/gql_user_test.go +++ b/server/e2e/gql_user_test.go @@ -207,9 +207,9 @@ func TestMe(t *testing.T) { o.Value("myWorkspaceId").String().IsEqual(wId2.String()) } -func TestSearchUser(t *testing.T) { +func TestUserByNameOrEmail(t *testing.T) { e := StartServer(t, &app.Config{}, true, baseSeederUser) - query := fmt.Sprintf(` { searchUser(nameOrEmail: "%s"){ id name email } }`, "e2e") + query := fmt.Sprintf(` { userByNameOrEmail(nameOrEmail: "%s"){ id name email } }`, "e2e") request := GraphQLRequest{ Query: query, } @@ -221,12 +221,12 @@ func TestSearchUser(t *testing.T) { WithHeader("authorization", "Bearer test"). WithHeader("Content-Type", "application/json"). WithHeader("X-Reearth-Debug-User", uId1.String()). - WithBytes(jsonData).Expect().Status(http.StatusOK).JSON().Object().Value("data").Object().Value("searchUser").Object() + WithBytes(jsonData).Expect().Status(http.StatusOK).JSON().Object().Value("data").Object().Value("userByNameOrEmail").Object() o.Value("id").String().IsEqual(uId1.String()) o.Value("name").String().IsEqual("e2e") o.Value("email").String().IsEqual("e2e@e2e.com") - query = fmt.Sprintf(` { searchUser(nameOrEmail: "%s"){ id name email } }`, "notfound") + query = fmt.Sprintf(` { userByNameOrEmail(nameOrEmail: "%s"){ id name email } }`, "notfound") request = GraphQLRequest{ Query: query, } @@ -239,7 +239,65 @@ func TestSearchUser(t *testing.T) { WithHeader("Content-Type", "application/json"). WithHeader("X-Reearth-Debug-User", uId1.String()). WithBytes(jsonData).Expect().Status(http.StatusOK).JSON().Object(). - Value("data").Object().Value("searchUser").IsNull() + Value("data").Object().Value("userByNameOrEmail").IsNull() +} + +func TestUserSearch(t *testing.T) { + e := StartServer(t, &app.Config{}, true, baseSeederUser) + query := fmt.Sprintf(` { userSearch(keyword: "%s"){ id name email } }`, "e2e") + request := GraphQLRequest{ + Query: query, + } + jsonData, err := json.Marshal(request) + if err != nil { + assert.NoError(t, err) + } + res := e.POST("/api/graphql"). + WithHeader("authorization", "Bearer test"). + WithHeader("Content-Type", "application/json"). + WithHeader("X-Reearth-Debug-User", uId1.String()). + WithBytes(jsonData).Expect().Status(http.StatusOK).JSON().Object() + ul := res.Value("data").Object().Value("userSearch").Array() + ul.Length().IsEqual(4) + o := ul.Value(0).Object() + o.Value("id").String().IsEqual(uId1.String()) + o.Value("name").String().IsEqual("e2e") + o.Value("email").String().IsEqual("e2e@e2e.com") + + query = fmt.Sprintf(` { userSearch(keyword: "%s"){ id name email } }`, "e2e2") + request = GraphQLRequest{ + Query: query, + } + jsonData, err = json.Marshal(request) + if err != nil { + assert.NoError(t, err) + } + res = e.POST("/api/graphql"). + WithHeader("authorization", "Bearer test"). + WithHeader("Content-Type", "application/json"). + WithHeader("X-Reearth-Debug-User", uId1.String()). + WithBytes(jsonData).Expect().Status(http.StatusOK).JSON().Object() + ul = res.Value("data").Object().Value("userSearch").Array() + ul.Length().IsEqual(1) + o = ul.Value(0).Object() + o.Value("id").String().IsEqual(uId2.String()) + o.Value("name").String().IsEqual("e2e2") + o.Value("email").String().IsEqual("e2e2@e2e.com") + + query = fmt.Sprintf(` { userSearch(keyword: "%s"){ id name email } }`, "notfound") + request = GraphQLRequest{ + Query: query, + } + jsonData, err = json.Marshal(request) + if err != nil { + assert.NoError(t, err) + } + e.POST("/api/graphql"). + WithHeader("authorization", "Bearer test"). + WithHeader("Content-Type", "application/json"). + WithHeader("X-Reearth-Debug-User", uId1.String()). + WithBytes(jsonData).Expect().Status(http.StatusOK).JSON().Object(). + Value("data").Object().Value("userSearch").Array().IsEmpty() } func TestNode(t *testing.T) { diff --git a/server/e2e/integration_item_test.go b/server/e2e/integration_item_test.go index ef2a07bbc0..7c26807aec 100644 --- a/server/e2e/integration_item_test.go +++ b/server/e2e/integration_item_test.go @@ -42,6 +42,7 @@ var ( mId2 = id.NewModelID() mId3 = id.NewModelID() mId4 = id.NewModelID() + mId5 = id.NewModelID() dvmId = id.NewModelID() aid1 = id.NewAssetID() aid2 = id.NewAssetID() @@ -52,6 +53,7 @@ var ( itmId3 = id.NewItemID() itmId4 = id.NewItemID() itmId5 = id.NewItemID() + itmId6 = id.NewItemID() fId1 = id.NewFieldID() fId2 = id.NewFieldID() fId3 = id.NewFieldID() @@ -60,18 +62,21 @@ var ( fId6 = id.NewFieldID() fId7 = id.NewFieldID() fId8 = id.NewFieldID() + fId9 = id.NewFieldID() dvsfId = id.NewFieldID() thId1 = id.NewThreadID() thId2 = id.NewThreadID() thId3 = id.NewThreadID() thId4 = id.NewThreadID() thId5 = id.NewThreadID() + thId6 = id.NewThreadID() icId = id.NewCommentID() ikey0 = id.RandomKey() ikey1 = id.RandomKey() ikey2 = id.RandomKey() ikey3 = id.RandomKey() ikey4 = id.RandomKey() + ikey5 = id.RandomKey() pid = id.NewProjectID() sid0 = id.NewSchemaID() sid1 = id.NewSchemaID() @@ -86,6 +91,7 @@ var ( sfKey6 = id.NewKey("group-key") sfKey7 = id.NewKey("geometry-key") sfKey8 = id.NewKey("geometry-editor-key") + sfkey9 = id.NewKey("number-key") gKey1 = id.RandomKey() gId1 = id.NewItemGroupID() gId2 = id.NewItemGroupID() @@ -263,6 +269,28 @@ func baseSeeder(ctx context.Context, r *repo.Container) error { if err := r.Model.Save(ctx, m4); err != nil { return err } + + float1 := float64(1.2) + float2 := float64(123.4) + sn1, _ := schema.NewNumber(&float1, &float2) + sf9 := schema.NewField(sn1.TypeProperty()).ID(fId9).Key(sfkey9).Type(sn1.TypeProperty()).MustBuild() + s8 := schema.New().ID(id.NewSchemaID()).Workspace(w.ID()).Project(p.ID()).Fields([]*schema.Field{sf9}).MustBuild() + if err := r.Schema.Save(ctx, s8); err != nil { + return err + } + m5 := model.New(). + ID((mId5)). + Name("m5"). + Description("m5 desc"). + Public(true). + Key(ikey5). + Project(p.ID()). + Schema(s8.ID()). + MustBuild() + if err := r.Model.Save(ctx, m5); err != nil { + return err + } + // endregion // region items @@ -336,6 +364,22 @@ func baseSeeder(ctx context.Context, r *repo.Container) error { if err := r.Item.Save(ctx, itm5); err != nil { return err } + + itm6 := item.New().ID(itmId6). + Schema(s8.ID()). + Model(m5.ID()). + Project(p.ID()). + Thread(thId6). + IsMetadata(false). + Fields([]*item.Field{ + item.NewField(fId9, value.MultipleFrom(value.TypeNumber, []*value.Value{ + value.TypeNumber.Value(21.2), + }), nil), + }). + MustBuild() + if err := r.Item.Save(ctx, itm6); err != nil { + return err + } // endregion // region thread & comment @@ -611,6 +655,56 @@ func TestIntegrationItemListAPI(t *testing.T) { HasValue("page", 1). HasValue("perPage", 5). HasValue("totalCount", 1) + + r3 := e.POST("/api/models/{modelId}/items", mId5). + WithHeader("authorization", "Bearer "+secret). + WithJSON(map[string]interface{}{ + "fields": []interface{}{ + map[string]any{ + "key": sfkey9.String(), + "type": "number", + "value": float64(21.2), + }, + }, + }). + Expect(). + Status(http.StatusOK). + JSON(). + Object() + + r3. + Value("fields"). + IsEqual([]any{ + map[string]any{ + "id": fId9.String(), + "type": "number", + "value": float64(21.2), + "key": sfkey9.String(), + }, + }) + + e.GET("/api/models/{modelId}/items", mId5). + WithHeader("authorization", "Bearer "+secret). + WithQuery("page", 1). + WithQuery("perPage", 5). + WithQuery("asset", "true"). + Expect(). + Status(http.StatusOK). + JSON(). + Object(). + HasValue("page", 1). + HasValue("perPage", 5). + HasValue("totalCount", 2) + r3. + Value("fields"). + IsEqual([]any{ + map[string]any{ + "id": fId9.String(), + "type": "number", + "value": float64(21.2), + "key": sfkey9.String(), + }, + }) } // GET /models/{modelId}/items @@ -1998,6 +2092,10 @@ func TestIntegrationDeleteItemAPI(t *testing.T) { Status(http.StatusNotFound) } +func TestIntegrationItemForNumberAndIntegerType(t *testing.T) { + +} + func assertItem(v *httpexpect.Value, assetEmbeded bool) { o := v.Object() o.Value("id").IsEqual(itmId1.String()) diff --git a/server/e2e/integration_model_test.go b/server/e2e/integration_model_test.go index 818e03ae6b..f9e687d16d 100644 --- a/server/e2e/integration_model_test.go +++ b/server/e2e/integration_model_test.go @@ -207,10 +207,10 @@ func TestIntegrationModelFilterAPI(t *testing.T) { Object(). HasValue("page", 1). HasValue("perPage", 10). - HasValue("totalCount", 6). + HasValue("totalCount", 7). Value("models"). Array() - models.Length().IsEqual(6) + models.Length().IsEqual(7) obj0 := models.Value(0).Object() obj0. diff --git a/server/e2e/integration_schema_test.go b/server/e2e/integration_schema_test.go index 04ecbf605e..4a9b68f63a 100644 --- a/server/e2e/integration_schema_test.go +++ b/server/e2e/integration_schema_test.go @@ -43,10 +43,10 @@ func TestIntegrationScemaFilterAPI(t *testing.T) { Object(). HasValue("page", 1). HasValue("perPage", 10). - HasValue("totalCount", 6). + HasValue("totalCount", 7). Value("models"). Array() - models.Length().IsEqual(6) + models.Length().IsEqual(7) obj0 := models.Value(0).Object() obj0. diff --git a/server/go.mod b/server/go.mod index a7e2e34aa1..2023246dde 100644 --- a/server/go.mod +++ b/server/go.mod @@ -27,7 +27,7 @@ require ( github.com/oapi-codegen/runtime v1.1.1 github.com/paulmach/go.geojson v1.5.0 github.com/ravilushqa/otelgqlgen v0.17.0 - github.com/reearth/reearthx v0.0.0-20241023075926-e29bdd6c4ae3 + github.com/reearth/reearthx v0.0.0-20241025125329-f01a05daf443 github.com/robbiet480/go.sns v0.0.0-20230523235941-e8d832c79d68 github.com/samber/lo v1.47.0 github.com/sendgrid/sendgrid-go v3.16.0+incompatible diff --git a/server/go.sum b/server/go.sum index 1c19f3f08f..1023c9650f 100644 --- a/server/go.sum +++ b/server/go.sum @@ -365,8 +365,6 @@ github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSg github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/ravilushqa/otelgqlgen v0.17.0 h1:bLwQfKqtj9P24QpjM2sc1ipBm5Fqv2u7DKN5LIpj3g8= github.com/ravilushqa/otelgqlgen v0.17.0/go.mod h1:orOIikuYsay1y3CmLgd5gsHcT9EsnXwNKmkAplzzYXQ= -github.com/reearth/reearthx v0.0.0-20241023075926-e29bdd6c4ae3 h1:aFm6QNDFs08EKlrWJN9IBqdxlDUuCBIIgBIcPkLHOZY= -github.com/reearth/reearthx v0.0.0-20241023075926-e29bdd6c4ae3/go.mod h1:d1WXkdCVzSoc8pl3vW9/9yKfk4fdoZQZhX8Ot8jqgnc= github.com/robbiet480/go.sns v0.0.0-20230523235941-e8d832c79d68 h1:Jknsfy5cqCH6qAuoU1qNZ51hfBJfMSJYwsH9j9mdVnw= github.com/robbiet480/go.sns v0.0.0-20230523235941-e8d832c79d68/go.mod h1:9CDhL7uDVy8vEVDNPJzxq89dPaPBWP6hxQcC8woBHus= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= diff --git a/server/internal/adapter/gql/generated.go b/server/internal/adapter/gql/generated.go index 6284f6f87d..4063110721 100644 --- a/server/internal/adapter/gql/generated.go +++ b/server/internal/adapter/gql/generated.go @@ -560,7 +560,8 @@ type ComplexityRoot struct { Projects func(childComplexity int, workspaceID gqlmodel.ID, pagination *gqlmodel.Pagination) int Requests func(childComplexity int, projectID gqlmodel.ID, key *string, state []gqlmodel.RequestState, createdBy *gqlmodel.ID, reviewer *gqlmodel.ID, pagination *gqlmodel.Pagination, sort *gqlmodel.Sort) int SearchItem func(childComplexity int, input gqlmodel.SearchItemInput) int - SearchUser func(childComplexity int, nameOrEmail string) int + UserByNameOrEmail func(childComplexity int, nameOrEmail string) int + UserSearch func(childComplexity int, keyword string) int VersionsByItem func(childComplexity int, itemID gqlmodel.ID) int View func(childComplexity int, modelID gqlmodel.ID) int } @@ -1025,7 +1026,8 @@ type QueryResolver interface { CheckProjectAlias(ctx context.Context, alias string) (*gqlmodel.ProjectAliasAvailability, error) Requests(ctx context.Context, projectID gqlmodel.ID, key *string, state []gqlmodel.RequestState, createdBy *gqlmodel.ID, reviewer *gqlmodel.ID, pagination *gqlmodel.Pagination, sort *gqlmodel.Sort) (*gqlmodel.RequestConnection, error) Me(ctx context.Context) (*gqlmodel.Me, error) - SearchUser(ctx context.Context, nameOrEmail string) (*gqlmodel.User, error) + UserSearch(ctx context.Context, keyword string) ([]*gqlmodel.User, error) + UserByNameOrEmail(ctx context.Context, nameOrEmail string) (*gqlmodel.User, error) } type RequestResolver interface { Thread(ctx context.Context, obj *gqlmodel.Request) (*gqlmodel.Thread, error) @@ -3502,17 +3504,29 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Query.SearchItem(childComplexity, args["input"].(gqlmodel.SearchItemInput)), true - case "Query.searchUser": - if e.complexity.Query.SearchUser == nil { + case "Query.userByNameOrEmail": + if e.complexity.Query.UserByNameOrEmail == nil { break } - args, err := ec.field_Query_searchUser_args(context.TODO(), rawArgs) + args, err := ec.field_Query_userByNameOrEmail_args(context.TODO(), rawArgs) if err != nil { return 0, false } - return e.complexity.Query.SearchUser(childComplexity, args["nameOrEmail"].(string)), true + return e.complexity.Query.UserByNameOrEmail(childComplexity, args["nameOrEmail"].(string)), true + + case "Query.userSearch": + if e.complexity.Query.UserSearch == nil { + break + } + + args, err := ec.field_Query_userSearch_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Query.UserSearch(childComplexity, args["keyword"].(string)), true case "Query.versionsByItem": if e.complexity.Query.VersionsByItem == nil { @@ -6639,7 +6653,8 @@ input DeleteMeInput { extend type Query { me: Me - searchUser(nameOrEmail: String!): User + userSearch(keyword: String!): [User!]! + userByNameOrEmail(nameOrEmail: String!): User } type UpdateMePayload { @@ -9731,17 +9746,17 @@ func (ec *executionContext) field_Query_searchItem_argsInput( return zeroVal, nil } -func (ec *executionContext) field_Query_searchUser_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { +func (ec *executionContext) field_Query_userByNameOrEmail_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} - arg0, err := ec.field_Query_searchUser_argsNameOrEmail(ctx, rawArgs) + arg0, err := ec.field_Query_userByNameOrEmail_argsNameOrEmail(ctx, rawArgs) if err != nil { return nil, err } args["nameOrEmail"] = arg0 return args, nil } -func (ec *executionContext) field_Query_searchUser_argsNameOrEmail( +func (ec *executionContext) field_Query_userByNameOrEmail_argsNameOrEmail( ctx context.Context, rawArgs map[string]interface{}, ) (string, error) { @@ -9763,6 +9778,38 @@ func (ec *executionContext) field_Query_searchUser_argsNameOrEmail( return zeroVal, nil } +func (ec *executionContext) field_Query_userSearch_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + arg0, err := ec.field_Query_userSearch_argsKeyword(ctx, rawArgs) + if err != nil { + return nil, err + } + args["keyword"] = arg0 + return args, nil +} +func (ec *executionContext) field_Query_userSearch_argsKeyword( + ctx context.Context, + rawArgs map[string]interface{}, +) (string, error) { + // We won't call the directive if the argument is null. + // Set call_argument_directives_with_null to true to call directives + // even if the argument is null. + _, ok := rawArgs["keyword"] + if !ok { + var zeroVal string + return zeroVal, nil + } + + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("keyword")) + if tmp, ok := rawArgs["keyword"]; ok { + return ec.unmarshalNString2string(ctx, tmp) + } + + var zeroVal string + return zeroVal, nil +} + func (ec *executionContext) field_Query_versionsByItem_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -25330,8 +25377,73 @@ func (ec *executionContext) fieldContext_Query_me(_ context.Context, field graph return fc, nil } -func (ec *executionContext) _Query_searchUser(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { - fc, err := ec.fieldContext_Query_searchUser(ctx, field) +func (ec *executionContext) _Query_userSearch(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Query_userSearch(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) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Query().UserSearch(rctx, fc.Args["keyword"].(string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.([]*gqlmodel.User) + fc.Result = res + return ec.marshalNUser2ᚕᚖgithubᚗcomᚋreearthᚋreearthᚑcmsᚋserverᚋinternalᚋadapterᚋgqlᚋgqlmodelᚐUserᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Query_userSearch(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_User_id(ctx, field) + case "name": + return ec.fieldContext_User_name(ctx, field) + case "email": + return ec.fieldContext_User_email(ctx, field) + case "host": + return ec.fieldContext_User_host(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type User", 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_userSearch_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Query_userByNameOrEmail(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Query_userByNameOrEmail(ctx, field) if err != nil { return graphql.Null } @@ -25344,7 +25456,7 @@ func (ec *executionContext) _Query_searchUser(ctx context.Context, field graphql }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Query().SearchUser(rctx, fc.Args["nameOrEmail"].(string)) + return ec.resolvers.Query().UserByNameOrEmail(rctx, fc.Args["nameOrEmail"].(string)) }) if err != nil { ec.Error(ctx, err) @@ -25358,7 +25470,7 @@ func (ec *executionContext) _Query_searchUser(ctx context.Context, field graphql return ec.marshalOUser2ᚖgithubᚗcomᚋreearthᚋreearthᚑcmsᚋserverᚋinternalᚋadapterᚋgqlᚋgqlmodelᚐUser(ctx, field.Selections, res) } -func (ec *executionContext) fieldContext_Query_searchUser(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +func (ec *executionContext) fieldContext_Query_userByNameOrEmail(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ Object: "Query", Field: field, @@ -25385,7 +25497,7 @@ func (ec *executionContext) fieldContext_Query_searchUser(ctx context.Context, f } }() ctx = graphql.WithFieldContext(ctx, fc) - if fc.Args, err = ec.field_Query_searchUser_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + if fc.Args, err = ec.field_Query_userByNameOrEmail_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { ec.Error(ctx, err) return fc, err } @@ -45898,7 +46010,29 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr } out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) - case "searchUser": + case "userSearch": + 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_userSearch(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + 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 "userByNameOrEmail": field := field innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { @@ -45907,7 +46041,7 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr ec.Error(ctx, ec.Recover(ctx, r)) } }() - res = ec._Query_searchUser(ctx, field) + res = ec._Query_userByNameOrEmail(ctx, field) return res } diff --git a/server/internal/adapter/gql/loader_user.go b/server/internal/adapter/gql/loader_user.go index 0bbea235fb..0e7d62fe01 100644 --- a/server/internal/adapter/gql/loader_user.go +++ b/server/internal/adapter/gql/loader_user.go @@ -41,11 +41,22 @@ func (c *UserLoader) Fetch(ctx context.Context, ids []gqlmodel.ID) ([]*gqlmodel. }), nil } -func (c *UserLoader) SearchUser(ctx context.Context, nameOrEmail string) (*gqlmodel.User, error) { - res, err := c.usecase.SearchUser(ctx, nameOrEmail) +func (c *UserLoader) ByNameOrEmail(ctx context.Context, nameOrEmail string) (*gqlmodel.User, error) { + res, err := c.usecase.FetchByNameOrEmail(ctx, nameOrEmail) if err != nil { return nil, err } return gqlmodel.SimpleToUser(res), nil } + +func (c *UserLoader) Search(ctx context.Context, nameOrEmail string) ([]*gqlmodel.User, error) { + res, err := c.usecase.SearchUser(ctx, nameOrEmail) + if err != nil { + return nil, err + } + + return lo.Map(res, func(u *user.Simple, _ int) *gqlmodel.User { + return gqlmodel.SimpleToUser(u) + }), nil +} diff --git a/server/internal/adapter/gql/resolver_user.go b/server/internal/adapter/gql/resolver_user.go index fdf0da64b4..93eb27adf6 100644 --- a/server/internal/adapter/gql/resolver_user.go +++ b/server/internal/adapter/gql/resolver_user.go @@ -83,9 +83,14 @@ func (r *queryResolver) Me(ctx context.Context) (*gqlmodel.Me, error) { return gqlmodel.ToMe(u), nil } -// SearchUser is the resolver for the searchUser field. -func (r *queryResolver) SearchUser(ctx context.Context, nameOrEmail string) (*gqlmodel.User, error) { - return loaders(ctx).User.SearchUser(ctx, nameOrEmail) +// UserSearch is the resolver for the userSearch field. +func (r *queryResolver) UserSearch(ctx context.Context, keyword string) ([]*gqlmodel.User, error) { + return loaders(ctx).User.Search(ctx, keyword) +} + +// UserByNameOrEmail is the resolver for the userByNameOrEmail field. +func (r *queryResolver) UserByNameOrEmail(ctx context.Context, nameOrEmail string) (*gqlmodel.User, error) { + return loaders(ctx).User.ByNameOrEmail(ctx, nameOrEmail) } // Me returns MeResolver implementation. diff --git a/server/pkg/integrationapi/value.go b/server/pkg/integrationapi/value.go index eae2653db0..7026fdc0a6 100644 --- a/server/pkg/integrationapi/value.go +++ b/server/pkg/integrationapi/value.go @@ -31,6 +31,8 @@ func FromValueType(t *ValueType) value.Type { return value.TypeSelect case ValueTypeInteger: return value.TypeInteger + case ValueTypeNumber: + return value.TypeNumber case ValueTypeReference: return value.TypeReference case ValueTypeUrl: @@ -68,6 +70,8 @@ func ToValueType(t value.Type) ValueType { return ValueTypeSelect case value.TypeInteger: return ValueTypeInteger + case value.TypeNumber: + return ValueTypeNumber case value.TypeReference: return ValueTypeReference case value.TypeURL: diff --git a/server/pkg/integrationapi/value_test.go b/server/pkg/integrationapi/value_test.go new file mode 100644 index 0000000000..d7b0dcc14a --- /dev/null +++ b/server/pkg/integrationapi/value_test.go @@ -0,0 +1,222 @@ +package integrationapi + +import ( + "testing" + + "github.com/reearth/reearth-cms/server/pkg/value" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" +) + +func TestFromValueType(t *testing.T) { + tests := []struct { + name string + input *ValueType + expected value.Type + }{ + { + name: "Nil input", + input: nil, + expected: "", + }, + { + name: "Valid ValueTypeText", + input: lo.ToPtr(ValueTypeText), + expected: value.TypeText, + }, + { + name: "Valid ValueTypeMarkdown", + input: lo.ToPtr(ValueTypeMarkdown), + expected: value.TypeMarkdown, + }, + { + name: "Valid ValueTypeTextArea", + input: lo.ToPtr(ValueTypeTextArea), + expected: value.TypeTextArea, + }, + { + name: "Valid ValueTypeRichText", + input: lo.ToPtr(ValueTypeRichText), + expected: value.TypeRichText, + }, + { + name: "Valid ValueTypeAsset", + input: lo.ToPtr(ValueTypeAsset), + expected: value.TypeAsset, + }, + { + name: "Valid ValueTypeDate", + input: lo.ToPtr(ValueTypeDate), + expected: value.TypeDateTime, + }, + { + name: "Valid ValueTypeBool", + input: lo.ToPtr(ValueTypeBool), + expected: value.TypeBool, + }, + { + name: "Valid ValueTypeSelect", + input: lo.ToPtr(ValueTypeSelect), + expected: value.TypeSelect, + }, + { + name: "Valid ValueTypeInteger", + input: lo.ToPtr(ValueTypeInteger), + expected: value.TypeInteger, + }, + { + name: "Valid ValueTypeNumber", + input: lo.ToPtr(ValueTypeNumber), + expected: value.TypeNumber, + }, + { + name: "Valid ValueTypeReference", + input: lo.ToPtr(ValueTypeReference), + expected: value.TypeReference, + }, + { + name: "Valid ValueTypeUrl", + input: lo.ToPtr(ValueTypeUrl), + expected: value.TypeURL, + }, + { + name: "Valid ValueTypeTag", + input: lo.ToPtr(ValueTypeTag), + expected: value.TypeTag, + }, + { + name: "Valid ValueTypeGroup", + input: lo.ToPtr(ValueTypeGroup), + expected: value.TypeGroup, + }, + { + name: "Valid ValueTypeGeometryObject", + input: lo.ToPtr(ValueTypeGeometryObject), + expected: value.TypeGeometryObject, + }, + { + name: "Valid ValueTypeGeometryEditor", + input: lo.ToPtr(ValueTypeGeometryEditor), + expected: value.TypeGeometryEditor, + }, + { + name: "Unknown ValueType", + input: lo.ToPtr(ValueType("Unknown")), + expected: value.TypeUnknown, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.expected, FromValueType(tt.input)) + }) + } +} + +func TestToValueType(t *testing.T) { + tests := []struct { + name string + input value.Type + expected ValueType + }{ + { + name: "TypeText", + input: value.TypeText, + expected: ValueTypeText, + }, + { + name: "TypeTextArea", + input: value.TypeTextArea, + expected: ValueTypeTextArea, + }, + { + name: "TypeRichText", + input: value.TypeRichText, + expected: ValueTypeRichText, + }, + { + name: "TypeMarkdown", + input: value.TypeMarkdown, + expected: ValueTypeMarkdown, + }, + { + name: "TypeAsset", + input: value.TypeAsset, + expected: ValueTypeAsset, + }, + { + name: "TypeDateTime", + input: value.TypeDateTime, + expected: ValueTypeDate, + }, + { + name: "TypeBool", + input: value.TypeBool, + expected: ValueTypeBool, + }, + { + name: "TypeSelect", + input: value.TypeSelect, + expected: ValueTypeSelect, + }, + { + name: "TypeInteger", + input: value.TypeInteger, + expected: ValueTypeInteger, + }, + { + name: "TypeNumber", + input: value.TypeNumber, + expected: ValueTypeNumber, + }, + { + name: "TypeReference", + input: value.TypeReference, + expected: ValueTypeReference, + }, + { + name: "TypeURL", + input: value.TypeURL, + expected: ValueTypeUrl, + }, + { + name: "TypeGroup", + input: value.TypeGroup, + expected: ValueTypeGroup, + }, + { + name: "TypeTag", + input: value.TypeTag, + expected: ValueTypeTag, + }, + { + name: "TypeCheckbox", + input: value.TypeCheckbox, + expected: ValueTypeCheckbox, + }, + { + name: "TypeGeometryObject", + input: value.TypeGeometryObject, + expected: ValueTypeGeometryObject, + }, + { + name: "TypeGeometryEditor", + input: value.TypeGeometryEditor, + expected: ValueTypeGeometryEditor, + }, + { + name: "Unknown Type", + input: value.Type("Unknown"), + expected: "", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.expected, ToValueType(tt.input)) + }) + } +} diff --git a/server/schemas/user.graphql b/server/schemas/user.graphql index abb5c3c9e2..b5738483bb 100644 --- a/server/schemas/user.graphql +++ b/server/schemas/user.graphql @@ -38,7 +38,8 @@ input DeleteMeInput { extend type Query { me: Me - searchUser(nameOrEmail: String!): User + userSearch(keyword: String!): [User!]! + userByNameOrEmail(nameOrEmail: String!): User } type UpdateMePayload { diff --git a/web/Dockerfile b/web/Dockerfile index 5438007a1e..9b7eeb25aa 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20.18.0-slim AS builder +FROM node:22.11.0-slim AS builder WORKDIR /app ARG NODE_OPTIONS="--max-old-space-size=4096" diff --git a/web/src/components/atoms/AutoComplete/index.ts b/web/src/components/atoms/AutoComplete/index.ts index 57677be53e..250c34cc2b 100644 --- a/web/src/components/atoms/AutoComplete/index.ts +++ b/web/src/components/atoms/AutoComplete/index.ts @@ -1,3 +1,4 @@ -import { AutoComplete } from "antd"; +import { AutoComplete, AutoCompleteProps } from "antd"; export default AutoComplete; +export type { AutoCompleteProps }; diff --git a/web/src/components/molecules/Member/MemberAddModal/index.tsx b/web/src/components/molecules/Member/MemberAddModal/index.tsx index 702eed7299..ba0beecc2c 100644 --- a/web/src/components/molecules/Member/MemberAddModal/index.tsx +++ b/web/src/components/molecules/Member/MemberAddModal/index.tsx @@ -1,98 +1,147 @@ import styled from "@emotion/styled"; -import React, { useCallback } from "react"; +import React, { useCallback, useState, useEffect, useRef } from "react"; +import AutoComplete from "@reearth-cms/components/atoms/AutoComplete"; import Button from "@reearth-cms/components/atoms/Button"; import Form from "@reearth-cms/components/atoms/Form"; import Icon from "@reearth-cms/components/atoms/Icon"; import Modal from "@reearth-cms/components/atoms/Modal"; -import Search, { SearchProps } from "@reearth-cms/components/atoms/Search"; +import Search from "@reearth-cms/components/atoms/Search"; +import Select from "@reearth-cms/components/atoms/Select"; import UserAvatar from "@reearth-cms/components/atoms/UserAvatar"; -import { User } from "@reearth-cms/components/molecules/Member/types"; +import { User , Role } from "@reearth-cms/components/molecules/Member/types"; import { MemberInput } from "@reearth-cms/components/molecules/Workspace/types"; import { useT } from "@reearth-cms/i18n"; -type FormValues = { - name: string; - names: User[]; -}; - type Props = { open: boolean; - searchedUser?: User & { isMember: boolean }; - searchedUserList: User[]; + searchedUsers: User[]; + selectedUsers: User[]; + searchLoading: boolean; addLoading: boolean; onUserSearch: (nameOrEmail: string) => Promise; - onUserAdd: () => void; + onUserAdd: (user: User) => void; onClose: () => void; onSubmit: (users: MemberInput[]) => Promise; - changeSearchedUser: (user?: User & { isMember: boolean }) => void; - changeSearchedUserList: React.Dispatch>; + setSearchedUsers: (user: User[]) => void; + setSelectedUsers: React.Dispatch>; }; -const initialValues: FormValues = { - name: "", - names: [], -}; +type FormValues = Record; + +const { Option } = Select; const MemberAddModal: React.FC = ({ open, - searchedUser, - searchedUserList, + searchedUsers, + selectedUsers, + searchLoading, addLoading, onUserSearch, onUserAdd, onClose, onSubmit, - changeSearchedUser, - changeSearchedUserList, + setSearchedUsers, + setSelectedUsers, }) => { const t = useT(); const [form] = Form.useForm(); + const [options, setOptions] = useState< + { + value: string; + user: User; + label: JSX.Element; + }[] + >([]); + const [isResultOpen, setIsResultOpen] = useState(false); + + const resultClear = useCallback(() => { + setIsResultOpen(false); + setOptions([]); + }, []); + + const timeout = useRef | null>(); + + const handleMemberNameChange = useCallback( + (value: string) => { + if (timeout.current) { + clearTimeout(timeout.current); + timeout.current = null; + } + const search = () => { + onUserSearch(value); + setIsResultOpen(true); + }; + if (value) { + timeout.current = setTimeout(search, 300); + } else { + resultClear(); + } + }, + [resultClear, onUserSearch], + ); + + useEffect(() => { + if (searchedUsers.length) { + const options = searchedUsers.map(user => ({ + value: "", + user, + label: ( + + + + {user.name} + {user.email} + + + ), + })); + setOptions(options); + } else { + setOptions([]); + } + }, [searchedUsers]); - const handleMemberNameChange = useCallback>( - (value, event) => { - event?.preventDefault(); - form.setFieldValue("name", value); - onUserSearch(value); + const handleSelect = useCallback( + (user: User) => { + onUserAdd(user); + resultClear(); }, - [onUserSearch, form], + [resultClear, onUserAdd], ); const handleMemberRemove = useCallback( (userId: string) => { - changeSearchedUserList((oldList: User[]) => - oldList.filter((user: User) => user.id !== userId), - ); + setSelectedUsers(prev => prev.filter(user => user.id !== userId)); }, - [changeSearchedUserList], + [setSelectedUsers], ); const handleSubmit = useCallback(async () => { - if (searchedUserList.length === 0) return; + if (selectedUsers.length === 0) return; + const values = form.getFieldsValue(); try { await onSubmit( - searchedUserList.map(user => ({ + selectedUsers.map(user => ({ userId: user.id, - role: "READER", + role: values[user.id] ?? "READER", })), ); - changeSearchedUser(undefined); - changeSearchedUserList([]); + setSearchedUsers([]); + setSelectedUsers([]); onClose(); - form.resetFields(); } catch (error) { console.error(error); } - }, [form, searchedUserList, changeSearchedUser, changeSearchedUserList, onClose, onSubmit]); + }, [setSelectedUsers, form, onClose, onSubmit, selectedUsers, setSearchedUsers]); const handleClose = useCallback(() => { - form.resetFields(); - changeSearchedUser(undefined); + setSearchedUsers([]); onClose(); - }, [onClose, changeSearchedUser, form]); + }, [onClose, setSearchedUsers]); return ( - = ({ type="primary" onClick={handleSubmit} loading={addLoading} - disabled={searchedUserList.length === 0}> + disabled={selectedUsers.length === 0}> {t("Add to workspace")} , ]}> {open && ( -
- - - - {searchedUser && ( - - - - - {searchedUser.name} - {searchedUser.email} -