diff --git a/go.work.sum b/go.work.sum index a783035229..a141312d3c 100644 --- a/go.work.sum +++ b/go.work.sum @@ -2598,6 +2598,12 @@ 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/reearth/reearthx v0.0.0-20241121115830-a7f7b61afe26 h1:epJj+4FT1OkHr4uOugcGU70pT3SKK1VquLi1z8aE2wk= +github.com/reearth/reearthx v0.0.0-20241121115830-a7f7b61afe26/go.mod h1:/ByvE9o0WANHL2nhOyZjOXWwY8cCgze0OmwyNzxcYoA= 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 cf242a8a8a..48995dfd2f 100644 --- a/server/e2e/integration_schema_test.go +++ b/server/e2e/integration_schema_test.go @@ -43,10 +43,10 @@ func TestIntegrationSchemaFilterAPI(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..c080eaf6b5 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-20241121115830-a7f7b61afe26 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..6024a7424b 100644 --- a/server/go.sum +++ b/server/go.sum @@ -365,8 +365,8 @@ 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/reearth/reearthx v0.0.0-20241121115830-a7f7b61afe26 h1:epJj+4FT1OkHr4uOugcGU70pT3SKK1VquLi1z8aE2wk= +github.com/reearth/reearthx v0.0.0-20241121115830-a7f7b61afe26/go.mod h1:/ByvE9o0WANHL2nhOyZjOXWwY8cCgze0OmwyNzxcYoA= 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/internal/infrastructure/mongo/mongogit/collection.go b/server/internal/infrastructure/mongo/mongogit/collection.go index 42d9261ef8..39e9e9f1fc 100644 --- a/server/internal/infrastructure/mongo/mongogit/collection.go +++ b/server/internal/infrastructure/mongo/mongogit/collection.go @@ -46,7 +46,11 @@ func (c *Collection) Count(ctx context.Context, filter any, q version.Query) (in } func (c *Collection) PaginateAggregation(ctx context.Context, pipeline []any, q version.Query, s *usecasex.Sort, p *usecasex.Pagination, consumer mongox.Consumer) (*usecasex.PageInfo, error) { - return c.client.PaginateAggregation(ctx, applyToPipeline(q, pipeline), s, p, consumer) + opt := options.Aggregate().SetCollation(&options.Collation{ + Locale: "simple", + Strength: 1, + }) + return c.client.PaginateAggregation(ctx, applyToPipeline(q, pipeline), s, p, consumer, opt) } func (c *Collection) CountAggregation(ctx context.Context, pipeline []any, q version.Query) (int64, error) { 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/e2e/general/project.spec.ts b/web/e2e/general/project.spec.ts index f06c0155cb..4867c0d1b8 100644 --- a/web/e2e/general/project.spec.ts +++ b/web/e2e/general/project.spec.ts @@ -47,7 +47,7 @@ test("Project CRUD and searching has succeeded", async ({ reearth, page }) => { await page.getByRole("button", { name: "Save changes" }).nth(1).click(); await expect(page.getByRole("row", { name: "Owner" }).getByRole("switch")).toHaveAttribute( "aria-checked", - "false", + "true", ); await closeNotification(page); diff --git a/web/e2e/project/accessibility.spec.ts b/web/e2e/project/accessibility.spec.ts index ab9d7bf67d..a3f911c0fd 100644 --- a/web/e2e/project/accessibility.spec.ts +++ b/web/e2e/project/accessibility.spec.ts @@ -27,3 +27,24 @@ test("Update settings on Accesibility page has succeeded", async ({ page }) => { await expect(page.locator("tbody")).toContainText(`http://localhost:8080/api/p/${alias}/assets`); await expect(page.getByRole("button", { name: "Save changes" })).toBeDisabled(); }); + +test("Setting public scope to Limited has succeeded", async ({ page }) => { + await page.getByText("Accessibility").click(); + await page.getByText("Private").click(); + await page.getByText("Limited", { exact: true }).click(); + await expect(page.locator('input[type="password"]')).toBeHidden(); + await page.getByRole("button", { name: "Save changes" }).click(); + await closeNotification(page); + await expect(page.locator("form")).toContainText("Limited"); + await expect(page.locator('input[type="password"]')).toHaveValue(/^secret_/); + const token = await page.locator('input[type="password"]').inputValue(); + await page.getByRole("button", { name: "Re-generate" }).click(); + await closeNotification(page); + await expect(page.locator('input[type="password"]')).toHaveValue(/^secret_/); + await expect(page.locator('input[type="password"]')).not.toHaveValue(token); + await page.getByText("Limited").first().click(); + await page.getByText("Private", { exact: true }).click(); + await page.getByRole("button", { name: "Save changes" }).click(); + await closeNotification(page); + await expect(page.locator('input[type="password"]')).toBeHidden(); +}); diff --git a/web/e2e/project/asset.spec.ts b/web/e2e/project/asset.spec.ts index e0d8667bbb..a5b9357510 100644 --- a/web/e2e/project/asset.spec.ts +++ b/web/e2e/project/asset.spec.ts @@ -72,7 +72,7 @@ test("Donwloading asset has succeeded", async ({ page }) => { test("Comment CRUD on edit page has succeeded", async ({ page }) => { await page.getByRole("cell", { name: "edit" }).locator("svg").click(); - await page.getByLabel("message").click(); + await page.getByLabel("comment").click(); await expect(page.getByText("Comments0 / 1000Comment")).toBeVisible(); await crudComment(page); }); diff --git a/web/e2e/project/content/content.spec.ts b/web/e2e/project/content/content.spec.ts index bd7c4ca5bd..2efb09b5f9 100644 --- a/web/e2e/project/content/content.spec.ts +++ b/web/e2e/project/content/content.spec.ts @@ -48,38 +48,52 @@ test("Item CRUD and searching has succeeded", async ({ page }) => { await expect(page.getByRole("cell", { name: "new text" })).toBeHidden(); }); -test("Publishing and Unpublishing item has succeeded", async ({ page }) => { +test("Publishing and Unpublishing item from edit page has succeeded", async ({ page }) => { await page.locator("li").filter({ hasText: "Text" }).locator("div").first().click(); await handleFieldForm(page, "text"); - await page.getByText("Settings").first().click(); - await page.getByRole("row", { name: "Owner" }).getByRole("switch").click(); - await page.getByRole("button", { name: "Save changes" }).last().click(); - await closeNotification(page); await page.getByText("Content").first().click(); await page.getByRole("button", { name: "plus New Item" }).click(); await page.getByLabel("text").click(); await page.getByLabel("text").fill("text"); await page.getByRole("button", { name: "Save" }).click(); await closeNotification(page); + await expect(page.getByText("DRAFT")).toBeVisible(); await page.getByRole("button", { name: "Publish" }).click(); await closeNotification(page); await expect(page.getByText("PUBLIC")).toBeVisible(); await page.getByLabel("Back").click(); await expect(page.getByText("PUBLIC")).toBeVisible(); - await page.getByLabel("", { exact: true }).check(); + await page.getByRole("cell").getByLabel("edit").locator("svg").click(); + await expect(page.getByText("PUBLIC")).toBeVisible(); + await page.getByRole("button", { name: "ellipsis" }).click(); await page.getByText("Unpublish").click(); await closeNotification(page); await expect(page.getByText("DRAFT")).toBeVisible(); - await page.getByRole("cell").getByLabel("edit").locator("svg").click(); + await page.getByLabel("Back").click(); await expect(page.getByText("DRAFT")).toBeVisible(); - await page.getByRole("button", { name: "Publish" }).click(); +}); + +test("Publishing and Unpublishing item from table has succeeded", async ({ page }) => { + await page.locator("li").filter({ hasText: "Text" }).locator("div").first().click(); + await handleFieldForm(page, "text"); + await page.getByText("Content").first().click(); + await page.getByRole("button", { name: "plus New Item" }).click(); + await page.getByLabel("text").click(); + await page.getByLabel("text").fill("text"); + await page.getByRole("button", { name: "Save" }).click(); + await closeNotification(page); + await expect(page.getByText("DRAFT")).toBeVisible(); + await page.getByLabel("Back").click(); + await expect(page.getByText("DRAFT")).toBeVisible(); + await page.getByLabel("", { exact: true }).check(); + await page.getByText("Publish", { exact: true }).click(); + await page.getByRole("button", { name: "Yes" }).click(); await closeNotification(page); await expect(page.getByText("PUBLIC")).toBeVisible(); - await page.getByRole("button", { name: "ellipsis" }).click(); await page.getByText("Unpublish").click(); await closeNotification(page); await expect(page.getByText("DRAFT")).toBeVisible(); - await page.getByLabel("Back").click(); + await page.getByRole("cell").getByLabel("edit").locator("svg").click(); await expect(page.getByText("DRAFT")).toBeVisible(); }); @@ -148,7 +162,7 @@ test("Comment CRUD on edit page has succeeded", async ({ page }) => { await page.getByLabel("text").fill("text"); await page.getByRole("button", { name: "Save" }).click(); await closeNotification(page); - await page.getByLabel("message").click(); + await page.getByLabel("comment").click(); await expect(page.getByText("Comments0 / 1000Comment")).toBeVisible(); await crudComment(page); }); diff --git a/web/e2e/project/item/fields/asset.spec.ts b/web/e2e/project/item/fields/asset.spec.ts index 1406a5fc24..a34a65747f 100644 --- a/web/e2e/project/item/fields/asset.spec.ts +++ b/web/e2e/project/item/fields/asset.spec.ts @@ -124,7 +124,7 @@ test("Asset field editing has succeeded", async ({ page }) => { await page.getByLabel("Description(optional)").click(); await page.getByLabel("Description(optional)").fill("new asset1 description"); await page.getByLabel("Support multiple values").check(); - await page.getByLabel("Use as title").check(); + await expect(page.getByLabel("Use as title")).toBeHidden(); await page.getByRole("tab", { name: "Validation" }).click(); await page.getByLabel("Make field required").check(); await page.getByLabel("Set field as unique").check(); @@ -148,12 +148,12 @@ test("Asset field editing has succeeded", async ({ page }) => { await page.getByRole("button", { name: "OK" }).click(); await closeNotification(page); await expect(page.getByLabel("Fields").getByRole("paragraph")).toContainText( - "new asset1 *#new-asset1(unique)Title", + "new asset1 *#new-asset1(unique)", ); await page.getByText("Content").click(); await expect(page.locator("thead")).toContainText("new asset1"); await page.getByRole("button", { name: "plus New Item" }).click(); - await expect(page.locator("label")).toContainText("new asset1(unique)Title"); + await expect(page.locator("label")).toContainText("new asset1(unique)"); await expect(page.getByRole("main")).toContainText("new asset1 description"); await expect(page.locator(".css-7g0azd").nth(0)).toContainText(uploadFileName_2); await expect(page.locator(".css-7g0azd").nth(1)).toContainText(uploadFileName_1); diff --git a/web/e2e/project/item/fields/boolean.spec.ts b/web/e2e/project/item/fields/boolean.spec.ts index b3c6cc7aec..5f79fe9145 100644 --- a/web/e2e/project/item/fields/boolean.spec.ts +++ b/web/e2e/project/item/fields/boolean.spec.ts @@ -73,7 +73,7 @@ test("Boolean field editing has succeeded", async ({ page }) => { await page.getByLabel("Description(optional)").click(); await page.getByLabel("Description(optional)").fill("new boolean1 description"); await page.getByLabel("Support multiple values").check(); - await page.getByLabel("Use as title").check(); + await expect(page.getByLabel("Use as title")).toBeHidden(); await page.getByRole("tab", { name: "Validation" }).click(); await expect( page.locator("label").filter({ hasText: "Make field required" }).locator("span").nth(1), @@ -90,7 +90,7 @@ test("Boolean field editing has succeeded", async ({ page }) => { await expect(page.getByRole("switch").nth(1)).toHaveAttribute("aria-checked", "true"); await page.getByRole("button", { name: "OK" }).click(); await closeNotification(page); - await expect(page.getByText("new boolean1#new-boolean1Title")).toBeVisible(); + await expect(page.getByText("new boolean1#new-boolean1")).toBeVisible(); await page.getByText("Content").click(); await expect(page.locator("thead")).toContainText("new boolean1"); await expect(page.getByRole("switch", { name: "check" })).toBeVisible(); diff --git a/web/e2e/project/item/fields/date.spec.ts b/web/e2e/project/item/fields/date.spec.ts index 0bf830278b..fc5246af9c 100644 --- a/web/e2e/project/item/fields/date.spec.ts +++ b/web/e2e/project/item/fields/date.spec.ts @@ -76,7 +76,7 @@ test("Date field editing has succeeded", async ({ page }) => { await page.getByLabel("Description(optional)").click(); await page.getByLabel("Description(optional)").fill("new date1 description"); await page.getByLabel("Support multiple values").check(); - await page.getByLabel("Use as title").check(); + await expect(page.getByLabel("Use as title")).toBeHidden(); await page.getByRole("tab", { name: "Validation" }).click(); await page.getByLabel("Make field required").check(); await page.getByLabel("Set field as unique").check(); @@ -97,7 +97,7 @@ test("Date field editing has succeeded", async ({ page }) => { await expect(page.locator("thead")).toContainText("new date1"); await expect(page.locator("tbody")).toContainText("2024-01-01"); await page.getByRole("button", { name: "plus New Item" }).click(); - await expect(page.locator("label")).toContainText("new date1(unique)Title"); + await expect(page.locator("label")).toContainText("new date1(unique)"); await expect(page.getByRole("textbox").nth(0)).toHaveValue("2024-01-03"); await expect(page.getByRole("textbox").nth(1)).toHaveValue("2024-01-02"); await page.getByRole("button", { name: "plus New" }).click(); diff --git a/web/e2e/project/item/fields/float.spec.ts b/web/e2e/project/item/fields/float.spec.ts new file mode 100644 index 0000000000..bef1901a1a --- /dev/null +++ b/web/e2e/project/item/fields/float.spec.ts @@ -0,0 +1,123 @@ +import { closeNotification } from "@reearth-cms/e2e/common/notification"; +import { createModel } from "@reearth-cms/e2e/project/utils/model"; +import { createProject, deleteProject } from "@reearth-cms/e2e/project/utils/project"; +import { expect, test } from "@reearth-cms/e2e/utils"; + +test.beforeEach(async ({ reearth, page }) => { + await reearth.goto("/", { waitUntil: "domcontentloaded" }); + await createProject(page); + await createModel(page); +}); + +test.afterEach(async ({ page }) => { + await deleteProject(page); +}); + +test("Float field creating and updating has succeeded", async ({ page }) => { + await page.locator("li").filter({ hasText: "Float" }).locator("div").first().click(); + await page.getByLabel("Display name").click(); + await page.getByLabel("Display name").fill("float1"); + await page.getByLabel("Settings").locator("#key").click(); + await page.getByLabel("Settings").locator("#key").fill("float1"); + await page.getByLabel("Settings").locator("#description").click(); + await page.getByLabel("Settings").locator("#description").fill("float1 description"); + + await page.getByRole("button", { name: "OK" }).click(); + await closeNotification(page); + + await expect(page.getByLabel("Fields").getByRole("paragraph")).toContainText("float1#float1"); + await page.getByText("Content").click(); + await page.getByRole("button", { name: "plus New Item" }).click(); + await expect(page.locator("label")).toContainText("float1"); + await expect(page.getByRole("main")).toContainText("float1 description"); + await page.getByLabel("float1").click(); + await page.getByLabel("float1").fill("1.1"); + await page.getByRole("button", { name: "Save" }).click(); + await closeNotification(page); + await page.getByLabel("Back").click(); + await expect(page.getByRole("cell", { name: "1.1", exact: true })).toBeVisible(); + + await page.getByRole("cell").getByLabel("edit").locator("svg").click(); + await expect(page.getByLabel("float1")).toHaveValue("1.1"); + await page.getByLabel("float1").click(); + await page.getByLabel("float1").fill("2.2"); + await page.getByRole("button", { name: "Save" }).click(); + await closeNotification(page); + await page.getByLabel("Back").click(); + await expect(page.getByRole("cell", { name: "2.2", exact: true })).toBeVisible(); +}); + +test("Float field editing has succeeded", async ({ page }) => { + await page.locator("li").filter({ hasText: "Float" }).locator("div").first().click(); + await page.getByLabel("Display name").click(); + await page.getByLabel("Display name").fill("float1"); + await page.getByLabel("Settings").locator("#key").click(); + await page.getByLabel("Settings").locator("#key").fill("float1"); + await page.getByLabel("Settings").locator("#description").click(); + await page.getByLabel("Settings").locator("#description").fill("float1 description"); + await page.getByRole("tab", { name: "Default value" }).click(); + await page.getByLabel("Set default value").click(); + await page.getByLabel("Set default value").fill("1.1"); + await page.getByRole("button", { name: "OK" }).click(); + await closeNotification(page); + + await page.getByText("Content").click(); + await expect(page.locator("thead")).toContainText("float1"); + await page.getByRole("button", { name: "plus New Item" }).click(); + await page.getByRole("button", { name: "Save" }).click(); + await closeNotification(page); + await page.getByLabel("Back").click(); + await expect(page.getByRole("cell", { name: "1.1", exact: true })).toBeVisible(); + + await page.getByText("Schema").click(); + await page.getByRole("img", { name: "ellipsis" }).locator("svg").click(); + await page.getByRole("tab", { name: "Settings" }).click(); + await page.getByLabel("Display name").click(); + await page.getByLabel("Display name").fill("new float1"); + await page.getByLabel("Field Key").click(); + await page.getByLabel("Field Key").fill("new-float1"); + await page.getByLabel("Description(optional)").click(); + await page.getByLabel("Description(optional)").fill("new float1 description"); + await page.getByLabel("Support multiple values").check(); + await page.getByLabel("Use as title").check(); + await page.getByRole("tab", { name: "Validation" }).click(); + await page.getByLabel("Set minimum value").click(); + await page.getByLabel("Set minimum value").fill("10.1"); + await page.getByLabel("Set maximum value").click(); + await page.getByLabel("Set maximum value").fill("2.1"); + await expect(page.getByRole("button", { name: "OK" })).toBeDisabled(); + await page.getByLabel("Set minimum value").click(); + await page.getByLabel("Set minimum value").fill("2.1"); + await page.getByLabel("Set maximum value").click(); + await page.getByLabel("Set maximum value").fill("10.1"); + await page.getByLabel("Make field required").check(); + await page.getByLabel("Set field as unique").check(); + await page.getByRole("tab", { name: "Default value" }).click(); + await expect(page.getByLabel("Set default value")).toBeVisible(); + await expect(page.getByLabel("Set default value")).toHaveValue("1.1"); + await expect(page.getByRole("button", { name: "OK" })).toBeDisabled(); + await page.getByLabel("Set default value").click(); + await page.getByLabel("Set default value").fill("11.1"); + await expect(page.getByRole("button", { name: "OK" })).toBeDisabled(); + await page.getByLabel("Set default value").click(); + await page.getByLabel("Set default value").fill("2.2"); + await page.getByRole("button", { name: "plus New" }).click(); + await page.locator("#defaultValue").nth(1).click(); + await page.locator("#defaultValue").nth(1).fill("3.3"); + await page.getByRole("button", { name: "OK" }).click(); + await closeNotification(page); + + await expect(page.getByText("new float1 *#new-float1(unique)")).toBeVisible(); + await page.getByText("Content").click(); + await expect(page.locator("thead")).toContainText("new float1"); + await expect(page.getByRole("cell", { name: "1.1", exact: true })).toBeVisible(); + await page.getByRole("button", { name: "plus New Item" }).click(); + await expect(page.getByText("new float1(unique)Title")).toBeVisible(); + await expect(page.getByRole("spinbutton").nth(0)).toHaveValue("2.2"); + await expect(page.getByRole("spinbutton").nth(1)).toHaveValue("3.3"); + await page.getByRole("button", { name: "Save" }).click(); + await closeNotification(page); + await page.getByLabel("Back").click(); + await page.getByRole("button", { name: "x2" }).click(); + await expect(page.getByRole("tooltip")).toContainText("new float12.23.3"); +}); diff --git a/web/e2e/project/item/fields/group.spec.ts b/web/e2e/project/item/fields/group.spec.ts index db71283ff0..5c8af97d05 100644 --- a/web/e2e/project/item/fields/group.spec.ts +++ b/web/e2e/project/item/fields/group.spec.ts @@ -41,7 +41,7 @@ test("Group field creating and updating has succeeded", async ({ page }) => { await page.getByLabel("Settings").locator("#key").fill("group1"); await page.getByLabel("Settings").locator("#description").click(); await page.getByLabel("Settings").locator("#description").fill("group1 description"); - await page.getByLabel("Select Group").click(); + await page.locator(".ant-select-selector").click(); await page.getByText("e2e group name #e2e-group-key").click(); await expect(page.getByLabel("Settings")).toContainText("e2e group name #e2e-group-key"); await page.getByRole("tab", { name: "Validation" }).click(); @@ -171,7 +171,7 @@ test("Group field editing has succeeded", async ({ page }) => { await page.getByLabel("Settings").locator("#key").fill("group1"); await page.getByLabel("Settings").locator("#description").click(); await page.getByLabel("Settings").locator("#description").fill("group1 description"); - await page.getByLabel("Select Group").click(); + await page.locator(".ant-select-selector").click(); await page.getByText("e2e group name #e2e-group-key").click(); await expect(page.getByLabel("Settings")).toContainText("e2e group name #e2e-group-key"); await page.getByRole("tab", { name: "Validation" }).click(); diff --git a/web/e2e/project/item/fields/int.spec.ts b/web/e2e/project/item/fields/int.spec.ts index 1b46ac512a..516d280edb 100644 --- a/web/e2e/project/item/fields/int.spec.ts +++ b/web/e2e/project/item/fields/int.spec.ts @@ -79,7 +79,7 @@ test("Int field editing has succeeded", async ({ page }) => { await page.getByLabel("Description(optional)").click(); await page.getByLabel("Description(optional)").fill("new int1 description"); await page.getByLabel("Support multiple values").check(); - await page.getByLabel("Use as title").check(); + await expect(page.getByLabel("Use as title")).toBeHidden(); await page.getByRole("tab", { name: "Validation" }).click(); await page.getByLabel("Set minimum value").click(); await page.getByLabel("Set minimum value").fill("10"); @@ -112,7 +112,7 @@ test("Int field editing has succeeded", async ({ page }) => { await expect(page.locator("thead")).toContainText("new int1"); await expect(page.getByRole("cell", { name: "1", exact: true })).toBeVisible(); await page.getByRole("button", { name: "plus New Item" }).click(); - await expect(page.getByText("new int1(unique)Title")).toBeVisible(); + await expect(page.getByText("new int1(unique)")).toBeVisible(); await expect(page.getByRole("spinbutton").nth(0)).toHaveValue("2"); await expect(page.getByRole("spinbutton").nth(1)).toHaveValue("3"); await page.getByRole("button", { name: "Save" }).click(); diff --git a/web/e2e/project/item/fields/option.spec.ts b/web/e2e/project/item/fields/option.spec.ts index 604fa019fd..946dfd6b33 100644 --- a/web/e2e/project/item/fields/option.spec.ts +++ b/web/e2e/project/item/fields/option.spec.ts @@ -25,7 +25,12 @@ test("Option field creating and updating has succeeded", async ({ page }) => { await page.locator("#values").nth(0).click(); await page.locator("#values").nth(0).fill("first"); await page.getByRole("button", { name: "plus New" }).click(); + await expect(page.getByText("Empty values are not allowed")).toBeVisible(); + await expect(page.getByRole("button", { name: "OK" })).toBeDisabled(); await page.locator("#values").nth(1).click(); + await page.locator("#values").nth(1).fill("first"); + await expect(page.getByText("Option must be unique")).toBeVisible(); + await expect(page.getByRole("button", { name: "OK" })).toBeDisabled(); await page.locator("#values").nth(1).fill("second"); await page.getByRole("button", { name: "OK" }).click(); await closeNotification(page); @@ -122,7 +127,7 @@ test("Option field editing has succeeded", async ({ page }) => { await page.locator("#values").nth(3).click(); await page.locator("#values").nth(3).fill("fifth"); await page.getByLabel("Support multiple values").check(); - await page.getByLabel("Use as title").check(); + await expect(page.getByLabel("Use as title")).toBeHidden(); await page.getByRole("tab", { name: "Validation" }).click(); await page.getByLabel("Make field required").check(); await page.getByLabel("Set field as unique").check(); @@ -167,7 +172,7 @@ test("Option field editing has succeeded", async ({ page }) => { await expect(page.locator("thead")).toContainText("option1"); await expect(page.getByText("third")).toBeVisible(); await page.getByRole("button", { name: "plus New Item" }).click(); - await expect(page.getByText("new option1(unique)Title")).toBeVisible(); + await expect(page.getByText("new option1(unique)")).toBeVisible(); await expect(page.getByText("new first")).toBeVisible(); await expect(page.getByText("new third")).toBeVisible(); await page.getByRole("button", { name: "Save" }).click(); diff --git a/web/e2e/project/item/fields/reference.spec.ts b/web/e2e/project/item/fields/reference.spec.ts index ab38bc2229..3328e67c5e 100644 --- a/web/e2e/project/item/fields/reference.spec.ts +++ b/web/e2e/project/item/fields/reference.spec.ts @@ -127,7 +127,7 @@ test("One-way reference field creating and updating has succeeded", async ({ pag await expect(page.getByRole("cell", { name: "text1" }).locator("span").first()).toBeVisible(); await page.getByRole("cell").getByLabel("edit").locator("svg").click(); await expect(page.locator("#root").getByText("text1")).toBeVisible(); - await page.getByRole("button", { name: "Refer to item" }).click(); + await page.getByRole("button", { name: "Replace item" }).click(); await page.getByRole("row").getByRole("button").nth(1).hover(); await page.getByRole("row").getByRole("button").nth(1).click(); await expect(page.locator("#root").getByText("text2")).toBeVisible(); diff --git a/web/e2e/project/item/fields/url.spec.ts b/web/e2e/project/item/fields/url.spec.ts index a3dff8a3b2..234cfd5aea 100644 --- a/web/e2e/project/item/fields/url.spec.ts +++ b/web/e2e/project/item/fields/url.spec.ts @@ -79,7 +79,7 @@ test("URL field editing has succeeded", async ({ page }) => { await page.getByLabel("Description(optional)").click(); await page.getByLabel("Description(optional)").fill("new url1 description"); await page.getByLabel("Support multiple values").check(); - await page.getByLabel("Use as title").check(); + await expect(page.getByLabel("Use as title")).toBeHidden(); await page.getByRole("tab", { name: "Validation" }).click(); await page.getByLabel("Make field required").check(); await page.getByLabel("Set field as unique").check(); @@ -96,7 +96,7 @@ test("URL field editing has succeeded", async ({ page }) => { await expect(page.locator("thead")).toContainText("new url1"); await expect(page.getByRole("cell", { name: "http://test1.com", exact: true })).toBeVisible(); await page.getByRole("button", { name: "plus New Item" }).click(); - await expect(page.getByText("new url1(unique)Title")).toBeVisible(); + await expect(page.getByText("new url1(unique)")).toBeVisible(); await expect(page.getByRole("textbox").nth(0)).toHaveValue("http://test1.com"); await expect(page.getByRole("textbox").nth(1)).toHaveValue("http://test2.com"); await page.getByRole("button", { name: "Save" }).click(); diff --git a/web/e2e/project/item/metadata/tag.spec.ts b/web/e2e/project/item/metadata/tag.spec.ts index 0c5194dfd6..009b3d5b12 100644 --- a/web/e2e/project/item/metadata/tag.spec.ts +++ b/web/e2e/project/item/metadata/tag.spec.ts @@ -27,6 +27,12 @@ test("Tag metadata creating and updating has succeeded", async ({ page }) => { await page.getByLabel("Set Tags").fill("Tag1"); await page.getByRole("button", { name: "plus New" }).click(); await page.locator("div").filter({ hasText: /^Tag$/ }).click(); + await page.locator("#tags").nth(1).fill(""); + await expect(page.getByText("Empty values are not allowed")).toBeVisible(); + await expect(page.getByRole("button", { name: "OK" })).toBeDisabled(); + await page.locator("#tags").nth(1).fill("Tag1"); + await expect(page.getByText("Labels must be unique")).toBeVisible(); + await expect(page.getByRole("button", { name: "OK" })).toBeDisabled(); await page.locator("#tags").nth(1).fill("Tag2"); await page.getByRole("button", { name: "OK" }).click(); await closeNotification(page); diff --git a/web/e2e/project/overview.spec.ts b/web/e2e/project/overview.spec.ts index a3126b104c..6e54c65fc4 100644 --- a/web/e2e/project/overview.spec.ts +++ b/web/e2e/project/overview.spec.ts @@ -13,7 +13,9 @@ test.afterEach(async ({ page }) => { }); test("Model CRUD on Overview page has succeeded", async ({ page }) => { - await page.getByRole("button", { name: "plus New Model" }).click(); + await expect(page.getByText("No Models yet")).toBeVisible(); + await page.getByRole("button", { name: "plus New Model" }).first().click(); + await expect(page.getByLabel("New Model").getByText("New Model")).toBeVisible(); await page.getByLabel("Model name").click(); await page.getByLabel("Model name").fill("model name"); await page.getByLabel("Model description").click(); @@ -43,4 +45,17 @@ test("Model CRUD on Overview page has succeeded", async ({ page }) => { await page.getByRole("button", { name: "Delete Model" }).click(); await closeNotification(page); await expect(page.locator("#root")).not.toContainText("new model name"); + await expect(page.getByText("No Models yet")).toBeVisible(); +}); + +test("Creating Model by using the button on placeholder has succeeded", async ({ page }) => { + await page.getByRole("button", { name: "plus New Model" }).last().click(); + await expect(page.getByLabel("New Model").getByText("New Model")).toBeVisible(); + await page.getByLabel("Model name").click(); + await page.getByLabel("Model name").fill("model name"); + await page.getByRole("button", { name: "OK" }).click(); + await closeNotification(page); + await expect(page.getByTitle("model name")).toBeVisible(); + await expect(page.getByText("#model-name")).toBeVisible(); + await expect(page.getByRole("menuitem", { name: "model name" }).locator("span")).toBeVisible(); }); diff --git a/web/e2e/project/request.spec.ts b/web/e2e/project/request.spec.ts index d19c84bd69..b077df4dbc 100644 --- a/web/e2e/project/request.spec.ts +++ b/web/e2e/project/request.spec.ts @@ -2,21 +2,20 @@ import { closeNotification } from "@reearth-cms/e2e/common/notification"; import { expect, test } from "@reearth-cms/e2e/utils"; import { crudComment } from "./utils/comment"; -import { createRequest } from "./utils/item"; -import { createModel } from "./utils/model"; +import { createTitleField, itemTitle, titleFieldName } from "./utils/field"; +import { createItem, createRequest, requestTitle } from "./utils/item"; +import { createModel, modelName } from "./utils/model"; import { createProject, deleteProject } from "./utils/project"; import { createWorkspace, deleteWorkspace } from "./utils/workspace"; -const requestTitle = "title"; - test.beforeEach(async ({ reearth, page }) => { await reearth.goto("/", { waitUntil: "domcontentloaded" }); - const username = await page.locator("a").nth(1).locator("div").nth(2).locator("p").innerText(); - await createWorkspace(page); await createProject(page); await createModel(page); - await createRequest(page, username, requestTitle); + await createTitleField(page); + await createItem(page); + await createRequest(page); }); test.afterEach(async ({ page }) => { @@ -78,7 +77,7 @@ test("Request closing and reopening has succeeded", async ({ page }) => { await page.getByRole("button", { name: "Reopen" }).click(); await closeNotification(page); await page.getByLabel("Back").click(); - await expect(page.locator("tbody").getByText("title", { exact: true })).toBeVisible(); + await expect(page.locator("tbody").getByText(requestTitle, { exact: true })).toBeVisible(); await expect(page.locator("tbody").getByText("WAITING")).toBeVisible(); await page.getByLabel("", { exact: true }).check(); await page.getByText("Close").click(); @@ -137,15 +136,30 @@ test("Creating a new request and adding to request has succeeded", async ({ page await expect(page.getByRole("button", { name: "collapsed e2e model name" }).nth(1)).toBeVisible(); }); -test("Navigating from request to item has succeeded", async ({ page }) => { - await page.getByText("Request", { exact: true }).click(); - await page.getByLabel("edit").locator("svg").click(); - const itemLink = page - .getByRole("button", { name: "collapsed e2e model name /" }) - .getByRole("button"); - const itemId = await itemLink.innerText(); - await itemLink.click(); - await expect(page.getByRole("main")).toContainText("Content"); - await expect(page.getByRole("main")).toContainText("e2e model name"); - await expect(page.getByRole("main")).toContainText(itemId); +test("Navigating between item and request has succeeded", async ({ page }) => { + await page.getByRole("button", { name: requestTitle }).first().click(); + await expect(page.getByText(`Request / ${requestTitle}`)).toBeVisible(); + await expect(page.getByRole("heading", { name: requestTitle })).toBeVisible(); + await page.getByRole("button", { name: itemTitle }).last().click(); + await page.getByLabel(`${titleFieldName}Title`).click(); + await page.getByLabel(`${titleFieldName}Title`).fill(""); + await page.getByRole("button", { name: "Save" }).click(); + await closeNotification(page); + const itemId = await page + .getByRole("main") + .locator("p") + .filter({ hasText: "ID" }) + .locator("div > span") + .innerText(); + await expect(page.getByText(`${modelName} / ${itemId}`)).toBeVisible(); + const newRequestTitle = "newRequestTitle"; + await createRequest(page, newRequestTitle); + await page.getByLabel(`${titleFieldName}Title`).click(); + await page.getByLabel(`${titleFieldName}Title`).fill("newItemTitle"); + await page.getByRole("button", { name: "Save" }).click(); + await closeNotification(page); + await page.getByRole("button", { name: newRequestTitle }).first().click(); + await expect( + page.getByRole("button", { name: `collapsed ${modelName} / ${itemId}` }), + ).toBeVisible(); }); diff --git a/web/e2e/project/schema.spec.ts b/web/e2e/project/schema.spec.ts index 8f07702ab1..dd1abfde27 100644 --- a/web/e2e/project/schema.spec.ts +++ b/web/e2e/project/schema.spec.ts @@ -93,8 +93,8 @@ test("Group creating from adding field has succeeded", async ({ page }) => { await handleFieldForm(page, "text"); await page.getByText("e2e model name").click(); await page.locator("li").getByText("Group", { exact: true }).click(); - await expect(page.getByRole("heading", { name: "Create Group" })).toBeVisible(); - await page.getByLabel("Select Group").click(); + await expect(page.getByText("Create Group Field")).toBeVisible(); + await page.locator(".ant-select-selector").click(); await expect(page.getByText("e2e group name #e2e-group-key")).toBeVisible(); await page.getByRole("button", { name: "Cancel" }).click(); }); diff --git a/web/e2e/project/utils/field.ts b/web/e2e/project/utils/field.ts index 317b61ab0b..22b7fe7d5f 100644 --- a/web/e2e/project/utils/field.ts +++ b/web/e2e/project/utils/field.ts @@ -12,3 +12,18 @@ export async function handleFieldForm(page: Page, name: string, key = name) { await expect(page.getByText(`${name}#${key}`)).toBeVisible(); await closeNotification(page); } + +export const titleFieldName = "titleFieldName"; +export const itemTitle = "itemTitle"; + +export async function createTitleField(page: Page) { + await page.locator("li").filter({ hasText: "Text" }).locator("div").first().click(); + await page.getByLabel("Display name").click(); + await page.getByLabel("Display name").fill(titleFieldName); + await page.getByLabel("Use as title").check(); + await page.getByRole("tab", { name: "Default value" }).click(); + await page.getByLabel("Set default value").click(); + await page.getByLabel("Set default value").fill(itemTitle); + await page.getByRole("button", { name: "OK" }).click(); + await closeNotification(page); +} diff --git a/web/e2e/project/utils/item.ts b/web/e2e/project/utils/item.ts index fe07b3fa4b..2ababb5e08 100644 --- a/web/e2e/project/utils/item.ts +++ b/web/e2e/project/utils/item.ts @@ -2,16 +2,28 @@ import { Page } from "@playwright/test"; import { closeNotification } from "@reearth-cms/e2e/common/notification"; -export async function createRequest(page: Page, reviewerName: string, title: string) { +export const requestTitle = "requestTitle"; + +export async function createItem(page: Page) { await page.getByText("Content").click(); await page.getByRole("button", { name: "plus New Item" }).click(); await page.getByRole("button", { name: "Save" }).click(); await closeNotification(page); - await page.getByRole("button", { name: "New Request" }).click(); - await page.getByLabel("Title").click(); - await page.getByLabel("Title").fill(title); - await page.locator(".ant-select-selection-overflow").click(); +} +export async function createRequest(page: Page, title = requestTitle) { + await page.getByRole("button", { name: "ellipsis" }).click(); + await page.getByRole("menuitem", { name: "New Request" }).click(); + await page.getByLabel("Title").last().click(); + await page.getByLabel("Title").last().fill(title); + await page.locator(".ant-select-selection-overflow").click(); + const reviewerName = await page + .locator("a") + .nth(1) + .locator("div") + .nth(2) + .locator("p") + .innerText(); await page.getByTitle(reviewerName).locator("div").click(); await page.locator(".ant-select-selection-overflow").click(); await page.getByRole("button", { name: "OK" }).click(); diff --git a/web/e2e/project/utils/model.ts b/web/e2e/project/utils/model.ts index 4740e0a1f2..4927cf7916 100644 --- a/web/e2e/project/utils/model.ts +++ b/web/e2e/project/utils/model.ts @@ -3,7 +3,9 @@ import { Page } from "@playwright/test"; import { closeNotification } from "@reearth-cms/e2e/common/notification"; import { expect } from "@reearth-cms/e2e/utils"; -export async function createModel(page: Page, name = "e2e model name", key = "e2e-model-key") { +export const modelName = "e2e model name"; + +export async function createModel(page: Page, name = modelName, key = "e2e-model-key") { await page.getByText("Schema").first().click(); await page.getByRole("button", { name: "plus Add" }).first().click(); await page.getByLabel("Model name").click(); 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/atoms/CopyButton/index.tsx b/web/src/components/atoms/CopyButton/index.tsx new file mode 100644 index 0000000000..db6ade4b77 --- /dev/null +++ b/web/src/components/atoms/CopyButton/index.tsx @@ -0,0 +1,36 @@ +import styled from "@emotion/styled"; +import { Typography } from "antd"; +import type { CopyConfig } from "antd/lib/typography/Base"; +import { RefAttributes } from "react"; + +import { useT } from "@reearth-cms/i18n"; + +const { Text } = Typography; + +type Props = { + copyable: CopyConfig; +} & RefAttributes; + +const CopyButton: React.FC = ({ copyable, ...props }) => { + const t = useT(); + return ; +}; + +const StyledCopyButton = styled(CopyButton)<{ color?: string; hoverColor?: string }>` + display: inline-flex; + .ant-typography-copy { + transition: all 0.3s; + color: ${({ color }) => color || "#00000073"}; + :focus { + color: ${({ color }) => color || "#00000073"}; + } + :active { + color: ${({ hoverColor }) => hoverColor || "#000000e0"}; + } + :hover { + color: ${({ hoverColor }) => hoverColor || "#000000e0"}; + } + } +`; + +export default StyledCopyButton; diff --git a/web/src/components/atoms/Icon/Icons/infinity.svg b/web/src/components/atoms/Icon/Icons/infinity.svg new file mode 100644 index 0000000000..1c31b2cb0d --- /dev/null +++ b/web/src/components/atoms/Icon/Icons/infinity.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/src/components/atoms/Icon/icons.ts b/web/src/components/atoms/Icon/icons.ts index 065fe50fa6..7e63494fc6 100644 --- a/web/src/components/atoms/Icon/icons.ts +++ b/web/src/components/atoms/Icon/icons.ts @@ -47,6 +47,7 @@ import { FileTwoTone, PictureTwoTone, LoadingOutlined, + EyeOutlined, EyeInvisibleOutlined, CopyOutlined, ReloadOutlined, @@ -69,6 +70,7 @@ import Date from "./Icons/date.svg"; import Dot from "./Icons/dot.svg"; import EditorCopy from "./Icons/editorCopy.svg"; import Group from "./Icons/group.svg"; +import InfinityIcon from "./Icons/infinity.svg"; import Key from "./Icons/key.svg"; import LineSegments from "./Icons/lineSegments.svg"; import LineString from "./Icons/lineString.svg"; @@ -128,6 +130,7 @@ export default { listBullets: ListBullets, arrowUpRight: ArrowUpRight, arrowUpRightSlash: ArrowUpRightSlash, + infinity: InfinityIcon, numberNine: NumberNine, link: Link, linkSolid: LinkSolid, @@ -159,6 +162,7 @@ export default { loading: LoadingOutlined, linked: Linked, unzip: Unzip, + eye: EyeOutlined, eyeInvisible: EyeInvisibleOutlined, copy: CopyOutlined, terminalWindow: TerminalWindow, diff --git a/web/src/components/atoms/InnerContents/basic.tsx b/web/src/components/atoms/InnerContents/basic.tsx index 430c664844..5f8eee5053 100644 --- a/web/src/components/atoms/InnerContents/basic.tsx +++ b/web/src/components/atoms/InnerContents/basic.tsx @@ -20,7 +20,6 @@ const BasicInnerContents: React.FC = ({ title, subtitle, flexChildren, ch {subtitle && {subtitle}} )} - {childrenArray.map((child, idx) => (
{child} @@ -42,16 +41,22 @@ const Header = styled.div` margin-bottom: 16px; `; -const Title = styled.p` +const Title = styled.h2` font-weight: 500; font-size: 20px; line-height: 28px; margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; `; const Subtitle = styled.p` margin: 16px 0 0 0; color: rgba(0, 0, 0, 0.45); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; `; const Section = styled.div<{ flex?: boolean; lastChild?: boolean }>` diff --git a/web/src/components/atoms/Input/index.tsx b/web/src/components/atoms/Input/index.tsx index e86df62641..0e2f5da9ad 100644 --- a/web/src/components/atoms/Input/index.tsx +++ b/web/src/components/atoms/Input/index.tsx @@ -6,14 +6,15 @@ export type { SearchProps } from "antd/lib/input"; type Props = { value?: string; + isError?: boolean; } & InputProps; -const Input = forwardRef(({ value, maxLength, ...props }, ref) => { +const Input = forwardRef(({ value, isError, maxLength, ...props }, ref) => { const status = useMemo(() => { - if (maxLength && value && runes(value).length > maxLength) { + if (isError || (maxLength && value && runes(value).length > maxLength)) { return "error"; } - }, [maxLength, value]); + }, [isError, maxLength, value]); return ( ( props: React.PropsWithChildren> & React.RefAttributes, ) => React.ReactElement = ({ value, ...props }) => { const status = useMemo(() => { - if (value) { - if (props.max && Number(value) > Number(props.max)) { + if (typeof value === "number") { + if (typeof props.max === "number" && value > props.max) { return "error"; - } else if (props.min && Number(value) < Number(props.min)) { + } else if (typeof props.min === "number" && value < props.min) { return "error"; } } }, [props.max, props.min, value]); - return ; + return ; }; export default InputNumber; diff --git a/web/src/components/molecules/Accessibility/index.tsx b/web/src/components/molecules/Accessibility/index.tsx index 5986f6828a..c5347853f0 100644 --- a/web/src/components/molecules/Accessibility/index.tsx +++ b/web/src/components/molecules/Accessibility/index.tsx @@ -1,17 +1,18 @@ import styled from "@emotion/styled"; -import React, { useCallback, useMemo } from "react"; +import { useMemo, useState } from "react"; import Button from "@reearth-cms/components/atoms/Button"; -import Form from "@reearth-cms/components/atoms/Form"; +import CopyButton from "@reearth-cms/components/atoms/CopyButton"; +import Form, { FormInstance } from "@reearth-cms/components/atoms/Form"; import Icon from "@reearth-cms/components/atoms/Icon"; import InnerContent from "@reearth-cms/components/atoms/InnerContents/basic"; import ContentSection from "@reearth-cms/components/atoms/InnerContents/ContentSection"; import Input from "@reearth-cms/components/atoms/Input"; +import Password from "@reearth-cms/components/atoms/Password"; import Select from "@reearth-cms/components/atoms/Select"; import Switch from "@reearth-cms/components/atoms/Switch"; import Table, { TableColumnsType } from "@reearth-cms/components/atoms/Table"; -import Tooltip from "@reearth-cms/components/atoms/Tooltip"; -import { PublicScope } from "@reearth-cms/components/molecules/Accessibility/types"; +import { FormType } from "@reearth-cms/components/molecules/Accessibility/types"; import { Model } from "@reearth-cms/components/molecules/Model/types"; import { useT } from "@reearth-cms/i18n"; @@ -20,158 +21,171 @@ type ModelDataType = { name: string; public: JSX.Element; publicState: boolean; - key?: string; + key: string; }; type Props = { - models?: Model[]; - scope?: PublicScope; - alias?: string; - aliasState?: string; - assetState?: boolean; + form: FormInstance; + models: Model[]; + modelsState: Record; + assetState: boolean; isSaveDisabled: boolean; hasPublishRight: boolean; - handlePublicUpdate: () => Promise; - handleUpdatedAssetState: (state: boolean) => void; - handleUpdatedModels: (model: Model) => void; - handleSetScope: (projectScope: PublicScope) => void; + updateLoading: boolean; + regenerateLoading: boolean; + apiUrl: string; + alias: string; + token: string; + onValuesChange: (changedValues: Partial, values: FormType) => void; + onPublicUpdate: () => Promise; + onRegenerateToken: () => Promise; }; const Accessibility: React.FC = ({ + form, models, - scope, - alias, - aliasState, + modelsState, assetState, isSaveDisabled, hasPublishRight, - handlePublicUpdate, - handleUpdatedAssetState, - handleUpdatedModels, - handleSetScope, + updateLoading, + regenerateLoading, + apiUrl, + alias, + token, + onValuesChange, + onPublicUpdate, + onRegenerateToken, }) => { const t = useT(); - const [form] = Form.useForm(); - - const columns: TableColumnsType = [ - { - title: t("Model"), - dataIndex: "name", - key: "name", - width: 220, - }, - { - title: t("Switch"), - dataIndex: "public", - key: "public", - align: "center", - width: 90, - }, - { - title: t("End point"), - dataIndex: "endpoint", - key: "endpoint", - render: (_, modelData: ModelDataType) => { - return ( - modelData.publicState && - modelData.key && ( - - {window.REEARTH_CONFIG?.api}/p/{alias}/{modelData.key} + const [visible, setVisible] = useState(false); + + const columns: TableColumnsType = useMemo( + () => [ + { + title: t("Model"), + dataIndex: "name", + key: "name", + width: 220, + }, + { + title: t("Switch"), + dataIndex: "public", + key: "public", + align: "center", + width: 90, + }, + { + title: t("End point"), + dataIndex: "endpoint", + key: "endpoint", + render: (_, modelData) => { + const url = apiUrl + modelData.key; + return ( + + {url} - ) - ); + ); + }, }, - }, - ]; + ], + [apiUrl, t], + ); - const dataSource: ModelDataType[] = useMemo(() => { - let columns: ModelDataType[] = [ - { - id: "assets", - name: t("Assets"), - key: "assets", - publicState: assetState ?? false, + const dataSource = useMemo(() => { + const columns: ModelDataType[] = []; + models.forEach(m => { + columns.push({ + id: m.id, + name: m.name, + key: m.key, + publicState: modelsState[m.id], public: ( - handleUpdatedAssetState(publicState)} - /> + + + ), - }, - ]; - - if (models) { - columns = [ - ...models.map(m => { - return { - id: m.id, - name: m.name ?? "", - key: m.key, - publicState: m.public, - public: ( - - handleUpdatedModels({ ...m, public: publicState }) - } - /> - ), - }; - }), - ...columns, - ]; - } + }); + }); + columns.push({ + id: "assets", + name: t("Assets"), + key: "assets", + publicState: assetState, + public: ( + + + + ), + }); return columns; - }, [t, assetState, hasPublishRight, models, handleUpdatedAssetState, handleUpdatedModels]); + }, [assetState, models, modelsState, t, hasPublishRight]); - const publicScopeList = [ - { id: 1, name: t("Private"), value: "private" }, - { id: 2, name: t("Public"), value: "public" }, - ]; - - const handleCopy = useCallback(() => { - if (aliasState) navigator.clipboard.writeText(aliasState); - }, [aliasState]); + const publicScopeList = useMemo( + () => [ + { name: t("Private"), value: "PRIVATE" }, + { name: t("Limited"), value: "LIMITED" }, + { name: t("Public"), value: "PUBLIC" }, + ], + [t], + ); return ( - + -
- - - - - - - - + + + + {publicScopeList.map(({ value, name }) => ( + + {name} + + ))} + + + + } disabled /> + + {token && ( + + } + prefix={ + { + setVisible(prev => !prev); + }} + /> } - contentEditable={false} /> - - + + + )} - @@ -182,8 +196,37 @@ const Accessibility: React.FC = ({ export default Accessibility; -const ItemsWrapper = styled.div` - max-width: 304px; +const maxWidth = "316px"; + +const StyledSelect = styled(Select)` + max-width: ${maxWidth}; +`; + +const StyledInput = styled(Input)` + max-width: ${maxWidth}; +`; + +const TokenFormItem = styled(Form.Item)` + .ant-form-item-control-input-content { + display: flex; + gap: 4px; + } +`; + +const StyledTokenInput = styled(Password)` + max-width: ${maxWidth}; + .ant-input-prefix { + order: 1; + margin-left: 4px; + color: rgb(0, 0, 0, 0.45); + transition: all 0.3s; + :hover { + color: rgba(0, 0, 0, 0.88); + } + } + .ant-input-suffix { + order: 2; + } `; const TableWrapper = styled.div` @@ -195,10 +238,6 @@ const StyledAnchor = styled.a` color: #000000d9; `; -const StyledIcon = styled(Icon)` - transition: all 0.3s; - color: rgb(0, 0, 0, 0.45); - :hover { - color: rgba(0, 0, 0, 0.88); - } +const StyledFormItem = styled(Form.Item)` + margin: 0; `; diff --git a/web/src/components/molecules/Accessibility/types.ts b/web/src/components/molecules/Accessibility/types.ts index edb9cbc2e7..690deeba4e 100644 --- a/web/src/components/molecules/Accessibility/types.ts +++ b/web/src/components/molecules/Accessibility/types.ts @@ -1 +1,7 @@ -export type PublicScope = "private" | "public"; // Add "limited" when functionality becomes available +export type PublicScope = "PRIVATE" | "LIMITED" | "PUBLIC"; +export type FormType = { + scope: PublicScope; + alias: string; + assetPublic: boolean; + models: Record; +}; diff --git a/web/src/components/molecules/Asset/Asset/AssetBody/Asset.tsx b/web/src/components/molecules/Asset/Asset/AssetBody/Asset.tsx index 001853e977..d7f405d858 100644 --- a/web/src/components/molecules/Asset/Asset/AssetBody/Asset.tsx +++ b/web/src/components/molecules/Asset/Asset/AssetBody/Asset.tsx @@ -3,10 +3,10 @@ import { Viewer as CesiumViewer } from "cesium"; import { useCallback, useState } from "react"; import Button from "@reearth-cms/components/atoms/Button"; +import CopyButton from "@reearth-cms/components/atoms/CopyButton"; import DownloadButton from "@reearth-cms/components/atoms/DownloadButton"; import Icon from "@reearth-cms/components/atoms/Icon"; import Space from "@reearth-cms/components/atoms/Space"; -import Tooltip from "@reearth-cms/components/atoms/Tooltip"; import UserAvatar from "@reearth-cms/components/atoms/UserAvatar"; import Card from "@reearth-cms/components/molecules/Asset/Asset/AssetBody/card"; import PreviewToolbar from "@reearth-cms/components/molecules/Asset/Asset/AssetBody/previewToolbar"; @@ -125,20 +125,16 @@ const AssetMolecule: React.FC = ({ } }, [assetFileExt, assetUrl, svgRender, viewerType, workspaceSettings]); - const handleCopy = useCallback(() => { - navigator.clipboard.writeText(asset.url); - }, [asset.url]); - return ( - {asset.fileName} - - - + {asset.fileName} + } toolbar={ @@ -211,13 +207,9 @@ const AssetMolecule: React.FC = ({ ); }; -const CopyIcon = styled(Icon)` - margin-left: 10px; - transition: all 0.3s; - color: rgb(0, 0, 0, 0.45); - :hover { - color: rgba(0, 0, 0, 0.88); - } +const AssetName = styled.span` + min-width: 0; + word-wrap: break-word; `; const UnzipButton = styled(Button)` diff --git a/web/src/components/molecules/Asset/Asset/AssetBody/UnzipFileList/index.tsx b/web/src/components/molecules/Asset/Asset/AssetBody/UnzipFileList/index.tsx index 2673273199..24aa32da28 100644 --- a/web/src/components/molecules/Asset/Asset/AssetBody/UnzipFileList/index.tsx +++ b/web/src/components/molecules/Asset/Asset/AssetBody/UnzipFileList/index.tsx @@ -2,9 +2,9 @@ import styled from "@emotion/styled"; import { Key } from "rc-table/lib/interface"; import { useCallback, useEffect, useState } from "react"; +import CopyButton from "@reearth-cms/components/atoms/CopyButton"; import Icon from "@reearth-cms/components/atoms/Icon"; import Spin from "@reearth-cms/components/atoms/Spin"; -import Tooltip from "@reearth-cms/components/atoms/Tooltip"; import Tree, { TreeProps } from "@reearth-cms/components/atoms/Tree"; import { ArchiveExtractionStatus, AssetFile } from "@reearth-cms/components/molecules/Asset/types"; import { useT } from "@reearth-cms/i18n"; @@ -51,13 +51,6 @@ const UnzipFileList: React.FC = ({ [previewFile, selectedKeys], ); - const handleCopy = useCallback( - (path: string) => { - navigator.clipboard.writeText(assetBaseUrl + path); - }, - [assetBaseUrl], - ); - return ( {archiveExtractionStatus === "IN_PROGRESS" || archiveExtractionStatus === "PENDING" ? ( @@ -84,16 +77,21 @@ const UnzipFileList: React.FC = ({ multiple={false} showLine={{ showLeafIcon: true }} titleRender={({ title, key, path }) => { - return ( - <> - {title} - {selectedKeys[0] === key && ( - - handleCopy(path)} /> - - )} - - ); + if (typeof title !== "function") { + return ( + + {title} + {selectedKeys[0] === key && ( + + )} + + ); + } }} /> ) @@ -106,6 +104,12 @@ const UnzipFileListWrapper = styled.div` height: 250px; overflow-y: scroll; background-color: #f5f5f5; + .ant-tree-treenode { + max-width: 100%; + } + .ant-tree-node-content-wrapper { + min-width: 0; + } `; const ExtractionInProgressWrapper = styled.div` @@ -137,13 +141,15 @@ const ExtractionFailedText = styled.p` color: rgba(0, 0, 0, 0.85); `; -const CopyIcon = styled(Icon)` - margin-left: 6px; - transition: all 0.3s; - color: rgb(0, 0, 0, 0.45); - :hover { - color: rgba(0, 0, 0, 0.88); - } +const TitleWrapper = styled.span` + display: flex; + gap: 6px; +`; + +const Title = styled.span` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; `; const SwitcherIcon = styled(Icon)` diff --git a/web/src/components/molecules/Asset/Asset/AssetBody/card.tsx b/web/src/components/molecules/Asset/Asset/AssetBody/card.tsx index 7df70a6308..9b9f1e9206 100644 --- a/web/src/components/molecules/Asset/Asset/AssetBody/card.tsx +++ b/web/src/components/molecules/Asset/Asset/AssetBody/card.tsx @@ -39,6 +39,9 @@ const Title = styled.p` margin: 0; font-size: 14px; font-weight: 500; + display: flex; + gap: 10px; + overflow: hidden; `; const Toolbar = styled.div` diff --git a/web/src/components/molecules/Common/Form/GroupItem/index.tsx b/web/src/components/molecules/Common/Form/GroupItem/index.tsx index eb4ddedc29..324395366f 100644 --- a/web/src/components/molecules/Common/Form/GroupItem/index.tsx +++ b/web/src/components/molecules/Common/Form/GroupItem/index.tsx @@ -10,10 +10,9 @@ import { AssetField, ReferenceField, } from "@reearth-cms/components/molecules/Content/Form/fields/ComplexFieldComponents"; -import { DefaultField } from "@reearth-cms/components/molecules/Content/Form/fields/FieldComponents"; import { FIELD_TYPE_COMPONENT_MAP } from "@reearth-cms/components/molecules/Content/Form/fields/FieldTypesMap"; import { FormItem, ItemAsset } from "@reearth-cms/components/molecules/Content/types"; -import { Field, Group } from "@reearth-cms/components/molecules/Schema/types"; +import { Field, Group, GroupField } from "@reearth-cms/components/molecules/Schema/types"; type Props = { value?: string; @@ -117,7 +116,7 @@ const GroupItem: React.FC = ({ }) => { const { Panel } = Collapse; - const [fields, setFields] = useState(); + const [fields, setFields] = useState(); useEffect(() => { const handleFieldsSet = async (id: string) => { @@ -176,7 +175,7 @@ const GroupItem: React.FC = ({ ) }>
- {fields?.map((field: Field) => { + {fields?.map(field => { if (field.type === "Asset") { return ( @@ -232,31 +231,12 @@ const GroupItem: React.FC = ({ /> ); - } else if (field.type === "GeometryObject" || field.type === "GeometryEditor") { - const FieldComponent = FIELD_TYPE_COMPONENT_MAP[field.type]; - - return ( - - - - ); } else { - const FieldComponent = - FIELD_TYPE_COMPONENT_MAP[ - field.type as - | "Select" - | "Date" - | "Tag" - | "Bool" - | "Checkbox" - | "URL" - | "TextArea" - | "MarkdownText" - | "Integer" - ] || DefaultField; - + const FieldComponent = FIELD_TYPE_COMPONENT_MAP[field.type]; return ( - + ); @@ -278,8 +258,7 @@ const IconWrapper = styled.span<{ disabled?: boolean }>` `; const StyledFormItemWrapper = styled.div<{ isFullWidth?: boolean }>` - width: ${({ isFullWidth }) => (isFullWidth ? undefined : "468px")}; - max-width: 100%; + max-width: ${({ isFullWidth }) => (isFullWidth ? undefined : "468px")}; word-wrap: break-word; `; diff --git a/web/src/components/molecules/Common/Header/index.tsx b/web/src/components/molecules/Common/Header/index.tsx index 3870592e87..98f7e4963f 100644 --- a/web/src/components/molecules/Common/Header/index.tsx +++ b/web/src/components/molecules/Common/Header/index.tsx @@ -133,13 +133,14 @@ const HeaderMolecule: React.FC = ({ items={WorkspacesItems} personal={currentIsPersonal} /> - {currentProject?.name && ( - - / - {currentProject.name} - - )} - + + {currentProject?.name && ( + <> + / + {currentProject.name} + + )} + {url && ( @@ -181,10 +182,6 @@ const LogoIcon = styled.img` cursor: pointer; `; -const Spacer = styled.div` - flex: 1; -`; - const VerticalDivider = styled.div` display: inline-block; height: 32px; @@ -206,6 +203,9 @@ const AccountDropdown = styled(HeaderDropdown)` const ProjectText = styled.p` margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; `; const Break = styled.p` @@ -218,6 +218,8 @@ const CurrentProject = styled.div` display: flex; align-items: center; color: #dbdbdb; + flex: 1; + min-width: 0; `; const MenuText = styled.p` diff --git a/web/src/components/molecules/Common/MultiValueField/MultValueColoredTag/index.tsx b/web/src/components/molecules/Common/MultiValueField/MultValueColoredTag/index.tsx index 3f3f619e0e..887d10e309 100644 --- a/web/src/components/molecules/Common/MultiValueField/MultValueColoredTag/index.tsx +++ b/web/src/components/molecules/Common/MultiValueField/MultValueColoredTag/index.tsx @@ -30,10 +30,16 @@ type TagColor = (typeof colors)[number]; type Props = { value?: { id?: string; name: string; color: TagColor }[]; onChange?: (value: { id?: string; name: string; color: TagColor }[]) => void; + errorIndexes: Set; } & TextAreaProps & InputProps; -const MultiValueColoredTag: React.FC = ({ value = [], onChange, ...props }) => { +const MultiValueColoredTag: React.FC = ({ + value = [], + onChange, + errorIndexes, + ...props +}) => { const t = useT(); const [lastColorIndex, setLastColorIndex] = useState(0); const [focusedTagIndex, setFocusedTagIndex] = useState(null); // New State to hold the focused tag index @@ -140,11 +146,13 @@ const MultiValueColoredTag: React.FC = ({ value = [], onChange, ...props onChange={(e: ChangeEvent) => handleInput(e, key)} value={valueItem.name} onBlur={() => handleInputBlur()} + isError={errorIndexes?.has(key)} /> @@ -188,9 +196,9 @@ const StyledInput = styled(Input)` flex: 1; `; -const StyledTagContainer = styled.div` +const StyledTagContainer = styled.div<{ isError?: boolean }>` cursor: pointer; - border: 1px solid #d9d9d9; + border: 1px solid ${({ isError }) => (isError ? "#ff4d4f" : "#d9d9d9")}; padding: 4px 11px; overflow: auto; height: 100%; diff --git a/web/src/components/molecules/Common/MultiValueField/index.tsx b/web/src/components/molecules/Common/MultiValueField/index.tsx index 4eeaccf5ed..11488b0d66 100644 --- a/web/src/components/molecules/Common/MultiValueField/index.tsx +++ b/web/src/components/molecules/Common/MultiValueField/index.tsx @@ -16,6 +16,7 @@ type Props = { onBlur?: () => Promise; // eslint-disable-next-line @typescript-eslint/no-explicit-any FieldInput: React.FunctionComponent; + errorIndexes?: Set; } & TextAreaProps & InputProps; @@ -24,6 +25,7 @@ const MultiValueField: React.FC = ({ onChange, onBlur, FieldInput, + errorIndexes, ...props }) => { const t = useT(); @@ -91,6 +93,7 @@ const MultiValueField: React.FC = ({ onChange={(e: ChangeEvent) => handleInput(e, key)} onBlur={() => onBlur?.()} value={valueItem} + isError={errorIndexes?.has(key)} /> {!props.disabled && ( = ({ const [isDisabled, setIsDisabled] = useState(true); const prevAlias = useRef<{ alias: string; isSuccess: boolean }>(); - const values = Form.useWatch([], form); + const timeout = useRef | null>(null); + const values = Form.useWatch([], form); useEffect(() => { - if (form.getFieldValue("name") && form.getFieldValue("alias")) { + if (timeout.current) { + clearTimeout(timeout.current); + timeout.current = null; + } + if (!values?.name && !values?.alias) { + setIsDisabled(true); + return; + } + const validate = () => { form .validateFields() .then(() => setIsDisabled(false)) .catch(() => setIsDisabled(true)); - } else { - setIsDisabled(true); - } + }; + timeout.current = setTimeout(validate, 300); + return () => { + if (timeout.current) { + clearTimeout(timeout.current); + } + }; }, [form, values]); const handleNameChange = useCallback( @@ -119,7 +132,7 @@ const ProjectCreationModal: React.FC = ({ {t("OK")} , ]}> -
+ = ({ item, onNavigateToRequest }) = onClick={() => { onNavigateToRequest(request.id); }}> - {request.id} + {request.title} } /> diff --git a/web/src/components/molecules/Content/Form/fields/FieldComponents/IntegerField.tsx b/web/src/components/molecules/Content/Form/fields/FieldComponents/NumberField.tsx similarity index 79% rename from web/src/components/molecules/Content/Form/fields/FieldComponents/IntegerField.tsx rename to web/src/components/molecules/Content/Form/fields/FieldComponents/NumberField.tsx index fedea84610..ba91bfbe2e 100644 --- a/web/src/components/molecules/Content/Form/fields/FieldComponents/IntegerField.tsx +++ b/web/src/components/molecules/Content/Form/fields/FieldComponents/NumberField.tsx @@ -14,10 +14,16 @@ type DefaultFieldProps = { disabled: boolean; }; -const IntegerField: React.FC = ({ field, itemGroupId, disabled }) => { +const NumberField: React.FC = ({ field, itemGroupId, disabled }) => { const t = useT(); - const min = useMemo(() => field.typeProperty?.min, [field.typeProperty?.min]); - const max = useMemo(() => field.typeProperty?.max, [field.typeProperty?.max]); + const min = useMemo( + () => field?.typeProperty?.min ?? field?.typeProperty?.numberMin, + [field?.typeProperty?.min, field?.typeProperty?.numberMin], + ); + const max = useMemo( + () => field?.typeProperty?.max ?? field?.typeProperty?.numberMax, + [field?.typeProperty?.max, field?.typeProperty?.numberMax], + ); const validate = useCallback( (value: unknown) => { if (typeof value === "number") { @@ -66,4 +72,4 @@ const IntegerField: React.FC = ({ field, itemGroupId, disable ); }; -export default IntegerField; +export default NumberField; diff --git a/web/src/components/molecules/Content/Form/fields/FieldComponents/index.ts b/web/src/components/molecules/Content/Form/fields/FieldComponents/index.ts index 56f607fac3..cf1e1bc5be 100644 --- a/web/src/components/molecules/Content/Form/fields/FieldComponents/index.ts +++ b/web/src/components/molecules/Content/Form/fields/FieldComponents/index.ts @@ -3,8 +3,4 @@ export { default as CheckboxField } from "./CheckboxField"; export { default as URLField } from "./URLField"; export { default as DateField } from "./DateField"; export { default as BoolField } from "./BoolField"; -export { default as IntegerField } from "./IntegerField"; -export { default as MarkdownField } from "./MarkdownField"; -export { default as SelectField } from "./SelectField"; -export { default as TextAreaField } from "./TextareaField"; export { default as DefaultField } from "./DefaultField"; diff --git a/web/src/components/molecules/Content/Form/fields/FieldTypesMap.ts b/web/src/components/molecules/Content/Form/fields/FieldTypesMap.ts index c4658df843..d748e4370f 100644 --- a/web/src/components/molecules/Content/Form/fields/FieldTypesMap.ts +++ b/web/src/components/molecules/Content/Form/fields/FieldTypesMap.ts @@ -1,11 +1,19 @@ -import { TagField, DateField, BoolField, CheckboxField, URLField } from "./FieldComponents"; +import { + DefaultField, + TagField, + DateField, + BoolField, + CheckboxField, + URLField, +} from "./FieldComponents"; import GeometryField from "./FieldComponents/GeometryField"; -import IntegerField from "./FieldComponents/IntegerField"; import MarkdownField from "./FieldComponents/MarkdownField"; +import NumberField from "./FieldComponents/NumberField"; import SelectField from "./FieldComponents/SelectField"; import TextareaField from "./FieldComponents/TextareaField"; export const FIELD_TYPE_COMPONENT_MAP = { + Text: DefaultField, Tag: TagField, Date: DateField, Bool: BoolField, @@ -13,7 +21,8 @@ export const FIELD_TYPE_COMPONENT_MAP = { URL: URLField, TextArea: TextareaField, MarkdownText: MarkdownField, - Integer: IntegerField, + Integer: NumberField, + Number: NumberField, Select: SelectField, GeometryObject: GeometryField, GeometryEditor: GeometryField, diff --git a/web/src/components/molecules/Content/Form/index.tsx b/web/src/components/molecules/Content/Form/index.tsx index 5369aeee37..80f6fbcc00 100644 --- a/web/src/components/molecules/Content/Form/index.tsx +++ b/web/src/components/molecules/Content/Form/index.tsx @@ -36,7 +36,6 @@ import { useT } from "@reearth-cms/i18n"; import { transformDayjsToString } from "@reearth-cms/utils/format"; import { AssetField, GroupField, ReferenceField } from "./fields/ComplexFieldComponents"; -import { DefaultField } from "./fields/FieldComponents"; import { FIELD_TYPE_COMPONENT_MAP } from "./fields/FieldTypesMap"; type Props = { @@ -643,31 +642,12 @@ const ContentForm: React.FC = ({ />
); - } else if (field.type === "GeometryObject" || field.type === "GeometryEditor") { - const FieldComponent = FIELD_TYPE_COMPONENT_MAP[field.type]; - - return ( - - - - ); } else { - const FieldComponent = - FIELD_TYPE_COMPONENT_MAP[ - field.type as - | "Select" - | "Date" - | "Tag" - | "Bool" - | "Checkbox" - | "URL" - | "TextArea" - | "MarkdownText" - | "Integer" - ] || DefaultField; - + const FieldComponent = FIELD_TYPE_COMPONENT_MAP[field.type]; return ( - + ); @@ -679,10 +659,7 @@ const ContentForm: React.FC = ({ {model?.metadataSchema?.fields?.map(field => { - const FieldComponent = - FIELD_TYPE_COMPONENT_MAP[ - field.type as "Tag" | "Date" | "Bool" | "Checkbox" | "URL" - ] || DefaultField; + const FieldComponent = FIELD_TYPE_COMPONENT_MAP[field.type]; return ( = ({ }; const StyledFormItemWrapper = styled.div<{ isFullWidth?: boolean }>` - width: ${({ isFullWidth }) => (isFullWidth ? undefined : "500px")}; + max-width: ${({ isFullWidth }) => (isFullWidth ? undefined : "500px")}; word-wrap: break-word; `; const StyledForm = styled(Form)` - width: 100%; + flex: 1; + min-width: 0; height: 100%; background: #fff; label { @@ -759,7 +737,7 @@ const FormItemsWrapper = styled.div` const SideBarWrapper = styled.div` background-color: #fafafa; padding: 8px; - width: 400px; + min-width: 272px; max-height: 100%; overflow-y: auto; `; diff --git a/web/src/components/molecules/Content/Table/DropdownRender/hooks.ts b/web/src/components/molecules/Content/Table/DropdownRender/hooks.ts index 9ca30a16ae..ae8b982497 100644 --- a/web/src/components/molecules/Content/Table/DropdownRender/hooks.ts +++ b/web/src/components/molecules/Content/Table/DropdownRender/hooks.ts @@ -136,7 +136,7 @@ export default ( ); break; case "Integer": - // case "Float": + case "Number": result.push( { operatorType: "basic", value: BasicOperator.Equals, label: t("is") }, { operatorType: "basic", value: BasicOperator.NotEquals, label: t("is not") }, @@ -312,7 +312,7 @@ export default ( if (typeof value !== "boolean") { value = value === "true"; } - } else if (filter.type === "Integer" /*|| filter.type === "Float"*/) { + } else if (filter.type === "Integer" || filter.type === "Number") { value = Number(value); } else if (filter.type === "Date") { value = value ? new Date(value) : new Date(); diff --git a/web/src/components/molecules/Content/Table/DropdownRender/index.tsx b/web/src/components/molecules/Content/Table/DropdownRender/index.tsx index b6b4f7accd..cc776b57fa 100644 --- a/web/src/components/molecules/Content/Table/DropdownRender/index.tsx +++ b/web/src/components/molecules/Content/Table/DropdownRender/index.tsx @@ -101,13 +101,8 @@ const DropdownRender: React.FC = ({ ))} - ) : filter.type === "Integer" /*|| filter.type === "Float"*/ ? ( - + ) : filter.type === "Integer" || filter.type === "Number" ? ( + ) : filter.type === "Date" ? ( ; updatedBy?: Partial; @@ -39,7 +40,7 @@ export type Item = { threadId: string; comments: Comment[]; assets: ItemAsset[]; - requests: Pick[]; + requests: Pick[]; }; export type FormItem = { diff --git a/web/src/components/molecules/Integration/IntegrationTable/index.tsx b/web/src/components/molecules/Integration/IntegrationTable/index.tsx index 9aa800d505..d9ae1951aa 100644 --- a/web/src/components/molecules/Integration/IntegrationTable/index.tsx +++ b/web/src/components/molecules/Integration/IntegrationTable/index.tsx @@ -15,7 +15,7 @@ import Space from "@reearth-cms/components/atoms/Space"; import UserAvatar from "@reearth-cms/components/atoms/UserAvatar"; import ResizableProTable from "@reearth-cms/components/molecules/Common/ResizableProTable"; import { IntegrationMember } from "@reearth-cms/components/molecules/Integration/types"; -import { useT } from "@reearth-cms/i18n"; +import { useT, Trans } from "@reearth-cms/i18n"; type Props = { integrationMembers?: IntegrationMember[]; @@ -204,7 +204,7 @@ const IntegrationTable: React.FC = ({ - {t("Or read")} {t("how to use Re:Earth CMS")} {t("first")} + }} /> )}> 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} -