diff options
-rw-r--r-- | api/graphql/graph/gen_graph.go | 340 | ||||
-rw-r--r-- | api/graphql/models/gen_models.go | 24 | ||||
-rw-r--r-- | api/graphql/resolvers/mutation.go | 29 | ||||
-rw-r--r-- | api/graphql/schema/mutations.graphql | 24 | ||||
-rw-r--r-- | api/graphql/schema/root.graphql | 2 | ||||
-rw-r--r-- | go.sum | 2 | ||||
-rw-r--r-- | webui/src/components/CommentInput/CommentInput.tsx | 5 | ||||
-rw-r--r-- | webui/src/pages/bug/Bug.tsx | 3 | ||||
-rw-r--r-- | webui/src/pages/bug/CommentForm.tsx | 1 | ||||
-rw-r--r-- | webui/src/pages/bug/EditCommentForm.graphql | 16 | ||||
-rw-r--r-- | webui/src/pages/bug/EditCommentForm.tsx | 123 | ||||
-rw-r--r-- | webui/src/pages/bug/Message.tsx | 131 | ||||
-rw-r--r-- | webui/src/pages/bug/MessageCommentFragment.graphql | 5 | ||||
-rw-r--r-- | webui/src/pages/bug/MessageCreateFragment.graphql | 5 | ||||
-rw-r--r-- | webui/src/pages/bug/MessageHistory.graphql | 15 | ||||
-rw-r--r-- | webui/src/pages/bug/MessageHistoryDialog.tsx | 235 | ||||
-rw-r--r-- | webui/src/pages/bug/Timeline.tsx | 8 | ||||
-rw-r--r-- | webui/src/pages/bug/TimelineQuery.tsx | 9 |
18 files changed, 956 insertions, 21 deletions
diff --git a/api/graphql/graph/gen_graph.go b/api/graphql/graph/gen_graph.go index 3ff86c3f..b70e70d8 100644 --- a/api/graphql/graph/gen_graph.go +++ b/api/graphql/graph/gen_graph.go @@ -193,6 +193,12 @@ type ComplexityRoot struct { Target func(childComplexity int) int } + EditCommentPayload struct { + Bug func(childComplexity int) int + ClientMutationID func(childComplexity int) int + Operation func(childComplexity int) int + } + Identity struct { AvatarUrl func(childComplexity int) int DisplayName func(childComplexity int) int @@ -258,6 +264,7 @@ type ComplexityRoot struct { AddComment func(childComplexity int, input models.AddCommentInput) int ChangeLabels func(childComplexity int, input *models.ChangeLabelInput) int CloseBug func(childComplexity int, input models.CloseBugInput) int + EditComment func(childComplexity int, input models.EditCommentInput) int NewBug func(childComplexity int, input models.NewBugInput) int OpenBug func(childComplexity int, input models.OpenBugInput) int SetTitle func(childComplexity int, input models.SetTitleInput) int @@ -433,6 +440,7 @@ type LabelChangeTimelineItemResolver interface { type MutationResolver interface { NewBug(ctx context.Context, input models.NewBugInput) (*models.NewBugPayload, error) AddComment(ctx context.Context, input models.AddCommentInput) (*models.AddCommentPayload, error) + EditComment(ctx context.Context, input models.EditCommentInput) (*models.EditCommentPayload, error) ChangeLabels(ctx context.Context, input *models.ChangeLabelInput) (*models.ChangeLabelPayload, error) OpenBug(ctx context.Context, input models.OpenBugInput) (*models.OpenBugPayload, error) CloseBug(ctx context.Context, input models.CloseBugInput) (*models.CloseBugPayload, error) @@ -1059,6 +1067,27 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.EditCommentOperation.Target(childComplexity), true + case "EditCommentPayload.bug": + if e.complexity.EditCommentPayload.Bug == nil { + break + } + + return e.complexity.EditCommentPayload.Bug(childComplexity), true + + case "EditCommentPayload.clientMutationId": + if e.complexity.EditCommentPayload.ClientMutationID == nil { + break + } + + return e.complexity.EditCommentPayload.ClientMutationID(childComplexity), true + + case "EditCommentPayload.operation": + if e.complexity.EditCommentPayload.Operation == nil { + break + } + + return e.complexity.EditCommentPayload.Operation(childComplexity), true + case "Identity.avatarUrl": if e.complexity.Identity.AvatarUrl == nil { break @@ -1333,6 +1362,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.CloseBug(childComplexity, args["input"].(models.CloseBugInput)), true + case "Mutation.editComment": + if e.complexity.Mutation.EditComment == nil { + break + } + + args, err := ec.field_Mutation_editComment_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.EditComment(childComplexity, args["input"].(models.EditCommentInput)), true + case "Mutation.newBug": if e.complexity.Mutation.NewBug == nil { break @@ -2034,6 +2075,30 @@ type AddCommentPayload { operation: AddCommentOperation! } +input EditCommentInput { + """A unique identifier for the client performing the mutation.""" + clientMutationId: String + """"The name of the repository. If not set, the default repository is used.""" + repoRef: String + """The bug ID's prefix.""" + prefix: String! + """The target.""" + target: String! + """The new message to be set.""" + message: String! + """The collection of file's hash required for the first message.""" + files: [Hash!] +} + +type EditCommentPayload { + """A unique identifier for the client performing the mutation.""" + clientMutationId: String + """The affected bug.""" + bug: Bug! + """The resulting operation.""" + operation: EditCommentOperation! +} + input ChangeLabelInput { """A unique identifier for the client performing the mutation.""" clientMutationId: String @@ -2290,6 +2355,8 @@ type Mutation { newBug(input: NewBugInput!): NewBugPayload! """Add a new comment to a bug""" addComment(input: AddCommentInput!): AddCommentPayload! + """Change a comment of a bug""" + editComment(input: EditCommentInput!): EditCommentPayload! """Add or remove a set of label on a bug""" changeLabels(input: ChangeLabelInput): ChangeLabelPayload! """Change a bug's status to open""" @@ -2657,6 +2724,20 @@ func (ec *executionContext) field_Mutation_closeBug_args(ctx context.Context, ra return args, nil } +func (ec *executionContext) field_Mutation_editComment_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 models.EditCommentInput + if tmp, ok := rawArgs["input"]; ok { + arg0, err = ec.unmarshalNEditCommentInput2githubᚗcomᚋMichaelMureᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐEditCommentInput(ctx, tmp) + if err != nil { + return nil, err + } + } + args["input"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation_newBug_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -5591,6 +5672,105 @@ func (ec *executionContext) _EditCommentOperation_files(ctx context.Context, fie return ec.marshalNHash2ᚕgithubᚗcomᚋMichaelMureᚋgitᚑbugᚋrepositoryᚐHashᚄ(ctx, field.Selections, res) } +func (ec *executionContext) _EditCommentPayload_clientMutationId(ctx context.Context, field graphql.CollectedField, obj *models.EditCommentPayload) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "EditCommentPayload", + Field: field, + Args: nil, + IsMethod: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.ClientMutationID, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) _EditCommentPayload_bug(ctx context.Context, field graphql.CollectedField, obj *models.EditCommentPayload) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "EditCommentPayload", + Field: field, + Args: nil, + IsMethod: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Bug, nil + }) + 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.(models.BugWrapper) + fc.Result = res + return ec.marshalNBug2githubᚗcomᚋMichaelMureᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐBugWrapper(ctx, field.Selections, res) +} + +func (ec *executionContext) _EditCommentPayload_operation(ctx context.Context, field graphql.CollectedField, obj *models.EditCommentPayload) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "EditCommentPayload", + Field: field, + Args: nil, + IsMethod: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Operation, nil + }) + 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.(*bug.EditCommentOperation) + fc.Result = res + return ec.marshalNEditCommentOperation2ᚖgithubᚗcomᚋMichaelMureᚋgitᚑbugᚋbugᚐEditCommentOperation(ctx, field.Selections, res) +} + func (ec *executionContext) _Identity_id(ctx context.Context, field graphql.CollectedField, obj models.IdentityWrapper) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -6817,6 +6997,47 @@ func (ec *executionContext) _Mutation_addComment(ctx context.Context, field grap return ec.marshalNAddCommentPayload2ᚖgithubᚗcomᚋMichaelMureᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐAddCommentPayload(ctx, field.Selections, res) } +func (ec *executionContext) _Mutation_editComment(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Mutation", + Field: field, + Args: nil, + IsMethod: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + rawArgs := field.ArgumentMap(ec.Variables) + args, err := ec.field_Mutation_editComment_args(ctx, rawArgs) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + fc.Args = args + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().EditComment(rctx, args["input"].(models.EditCommentInput)) + }) + 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.(*models.EditCommentPayload) + fc.Result = res + return ec.marshalNEditCommentPayload2ᚖgithubᚗcomᚋMichaelMureᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐEditCommentPayload(ctx, field.Selections, res) +} + func (ec *executionContext) _Mutation_changeLabels(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -9971,6 +10192,54 @@ func (ec *executionContext) unmarshalInputCloseBugInput(ctx context.Context, obj return it, nil } +func (ec *executionContext) unmarshalInputEditCommentInput(ctx context.Context, obj interface{}) (models.EditCommentInput, error) { + var it models.EditCommentInput + var asMap = obj.(map[string]interface{}) + + for k, v := range asMap { + switch k { + case "clientMutationId": + var err error + it.ClientMutationID, err = ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + case "repoRef": + var err error + it.RepoRef, err = ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + case "prefix": + var err error + it.Prefix, err = ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + case "target": + var err error + it.Target, err = ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + case "message": + var err error + it.Message, err = ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + case "files": + var err error + it.Files, err = ec.unmarshalOHash2ᚕgithubᚗcomᚋMichaelMureᚋgitᚑbugᚋrepositoryᚐHashᚄ(ctx, v) + if err != nil { + return it, err + } + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputNewBugInput(ctx context.Context, obj interface{}) (models.NewBugInput, error) { var it models.NewBugInput var asMap = obj.(map[string]interface{}) @@ -11254,6 +11523,40 @@ func (ec *executionContext) _EditCommentOperation(ctx context.Context, sel ast.S return out } +var editCommentPayloadImplementors = []string{"EditCommentPayload"} + +func (ec *executionContext) _EditCommentPayload(ctx context.Context, sel ast.SelectionSet, obj *models.EditCommentPayload) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, editCommentPayloadImplementors) + + out := graphql.NewFieldSet(fields) + var invalids uint32 + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("EditCommentPayload") + case "clientMutationId": + out.Values[i] = ec._EditCommentPayload_clientMutationId(ctx, field, obj) + case "bug": + out.Values[i] = ec._EditCommentPayload_bug(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + case "operation": + out.Values[i] = ec._EditCommentPayload_operation(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch() + if invalids > 0 { + return graphql.Null + } + return out +} + var identityImplementors = []string{"Identity"} func (ec *executionContext) _Identity(ctx context.Context, sel ast.SelectionSet, obj models.IdentityWrapper) graphql.Marshaler { @@ -11734,6 +12037,11 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { invalids++ } + case "editComment": + out.Values[i] = ec._Mutation_editComment(ctx, field) + if out.Values[i] == graphql.Null { + invalids++ + } case "changeLabels": out.Values[i] = ec._Mutation_changeLabels(ctx, field) if out.Values[i] == graphql.Null { @@ -13130,6 +13438,38 @@ func (ec *executionContext) marshalNCreateOperation2ᚖgithubᚗcomᚋMichaelMur return ec._CreateOperation(ctx, sel, v) } +func (ec *executionContext) unmarshalNEditCommentInput2githubᚗcomᚋMichaelMureᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐEditCommentInput(ctx context.Context, v interface{}) (models.EditCommentInput, error) { + return ec.unmarshalInputEditCommentInput(ctx, v) +} + +func (ec *executionContext) marshalNEditCommentOperation2githubᚗcomᚋMichaelMureᚋgitᚑbugᚋbugᚐEditCommentOperation(ctx context.Context, sel ast.SelectionSet, v bug.EditCommentOperation) graphql.Marshaler { + return ec._EditCommentOperation(ctx, sel, &v) +} + +func (ec *executionContext) marshalNEditCommentOperation2ᚖgithubᚗcomᚋMichaelMureᚋgitᚑbugᚋbugᚐEditCommentOperation(ctx context.Context, sel ast.SelectionSet, v *bug.EditCommentOperation) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + return ec._EditCommentOperation(ctx, sel, v) +} + +func (ec *executionContext) marshalNEditCommentPayload2githubᚗcomᚋMichaelMureᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐEditCommentPayload(ctx context.Context, sel ast.SelectionSet, v models.EditCommentPayload) graphql.Marshaler { + return ec._EditCommentPayload(ctx, sel, &v) +} + +func (ec *executionContext) marshalNEditCommentPayload2ᚖgithubᚗcomᚋMichaelMureᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐEditCommentPayload(ctx context.Context, sel ast.SelectionSet, v *models.EditCommentPayload) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + return ec._EditCommentPayload(ctx, sel, v) +} + func (ec *executionContext) unmarshalNHash2githubᚗcomᚋMichaelMureᚋgitᚑbugᚋrepositoryᚐHash(ctx context.Context, v interface{}) (repository.Hash, error) { var res repository.Hash return res, res.UnmarshalGQL(v) diff --git a/api/graphql/models/gen_models.go b/api/graphql/models/gen_models.go index 675c4b6b..1046d11a 100644 --- a/api/graphql/models/gen_models.go +++ b/api/graphql/models/gen_models.go @@ -111,6 +111,30 @@ type CommentEdge struct { Node *bug.Comment `json:"node"` } +type EditCommentInput struct { + // A unique identifier for the client performing the mutation. + ClientMutationID *string `json:"clientMutationId"` + // "The name of the repository. If not set, the default repository is used. + RepoRef *string `json:"repoRef"` + // The bug ID's prefix. + Prefix string `json:"prefix"` + // The target. + Target string `json:"target"` + // The new message to be set. + Message string `json:"message"` + // The collection of file's hash required for the first message. + Files []repository.Hash `json:"files"` +} + +type EditCommentPayload struct { + // A unique identifier for the client performing the mutation. + ClientMutationID *string `json:"clientMutationId"` + // The affected bug. + Bug BugWrapper `json:"bug"` + // The resulting operation. + Operation *bug.EditCommentOperation `json:"operation"` +} + type IdentityConnection struct { Edges []*IdentityEdge `json:"edges"` Nodes []IdentityWrapper `json:"nodes"` diff --git a/api/graphql/resolvers/mutation.go b/api/graphql/resolvers/mutation.go index 642a4fb9..9cd936a6 100644 --- a/api/graphql/resolvers/mutation.go +++ b/api/graphql/resolvers/mutation.go @@ -5,6 +5,7 @@ import ( "time" "github.com/MichaelMure/git-bug/api/auth" + "github.com/MichaelMure/git-bug/entity" "github.com/MichaelMure/git-bug/api/graphql/graph" "github.com/MichaelMure/git-bug/api/graphql/models" "github.com/MichaelMure/git-bug/bug" @@ -89,6 +90,34 @@ func (r mutationResolver) AddComment(ctx context.Context, input models.AddCommen }, nil } +func (r mutationResolver) EditComment(ctx context.Context, input models.EditCommentInput) (*models.EditCommentPayload, error) { + repo, b, err := r.getBug(input.RepoRef, input.Prefix) + if err != nil { + return nil, err + } + + author, err := auth.UserFromCtx(ctx, repo) + if err != nil { + return nil, err + } + + op, err := b.EditCommentRaw(author, time.Now().Unix(), entity.Id(input.Target), input.Message, nil) + if err != nil { + return nil, err + } + + err = b.Commit() + if err != nil { + return nil, err + } + + return &models.EditCommentPayload{ + ClientMutationID: input.ClientMutationID, + Bug: models.NewLoadedBug(b.Snapshot()), + Operation: op, + }, nil +} + func (r mutationResolver) ChangeLabels(ctx context.Context, input *models.ChangeLabelInput) (*models.ChangeLabelPayload, error) { repo, b, err := r.getBug(input.RepoRef, input.Prefix) if err != nil { diff --git a/api/graphql/schema/mutations.graphql b/api/graphql/schema/mutations.graphql index e6b70faf..d7adde1e 100644 --- a/api/graphql/schema/mutations.graphql +++ b/api/graphql/schema/mutations.graphql @@ -42,6 +42,30 @@ type AddCommentPayload { operation: AddCommentOperation! } +input EditCommentInput { + """A unique identifier for the client performing the mutation.""" + clientMutationId: String + """"The name of the repository. If not set, the default repository is used.""" + repoRef: String + """The bug ID's prefix.""" + prefix: String! + """The ID of the comment to be changed.""" + target: String! + """The new message to be set.""" + message: String! + """The collection of file's hash required for the first message.""" + files: [Hash!] +} + +type EditCommentPayload { + """A unique identifier for the client performing the mutation.""" + clientMutationId: String + """The affected bug.""" + bug: Bug! + """The resulting operation.""" + operation: EditCommentOperation! +} + input ChangeLabelInput { """A unique identifier for the client performing the mutation.""" clientMutationId: String diff --git a/api/graphql/schema/root.graphql b/api/graphql/schema/root.graphql index 94a0b530..884fd98d 100644 --- a/api/graphql/schema/root.graphql +++ b/api/graphql/schema/root.graphql @@ -8,6 +8,8 @@ type Mutation { newBug(input: NewBugInput!): NewBugPayload! """Add a new comment to a bug""" addComment(input: AddCommentInput!): AddCommentPayload! + """Change a comment of a bug""" + editComment(input: EditCommentInput!): EditCommentPayload! """Add or remove a set of label on a bug""" changeLabels(input: ChangeLabelInput): ChangeLabelPayload! """Change a bug's status to open""" @@ -535,6 +535,7 @@ golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -687,6 +688,7 @@ golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200825202427-b303f430e36d h1:W07d4xkoAUSNOkOzdzXCdFGxT7o2rW4q8M34tB2i//k= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/webui/src/components/CommentInput/CommentInput.tsx b/webui/src/components/CommentInput/CommentInput.tsx index 86cc7dbb..c574538e 100644 --- a/webui/src/components/CommentInput/CommentInput.tsx +++ b/webui/src/components/CommentInput/CommentInput.tsx @@ -51,6 +51,7 @@ const a11yProps = (index: number) => ({ type Props = { inputProps?: any; + inputText?: string; loading: boolean; onChange: (comment: string) => void; }; @@ -62,8 +63,8 @@ type Props = { * @param loading Disable input when component not ready yet * @param onChange Callback to return input value changes */ -function CommentInput({ inputProps, loading, onChange }: Props) { - const [input, setInput] = useState<string>(''); +function CommentInput({ inputProps, inputText, loading, onChange }: Props) { + const [input, setInput] = useState<string>(inputText ? inputText : ''); const [tab, setTab] = useState(0); const classes = useStyles(); diff --git a/webui/src/pages/bug/Bug.tsx b/webui/src/pages/bug/Bug.tsx index 3b6b61e0..25281f96 100644 --- a/webui/src/pages/bug/Bug.tsx +++ b/webui/src/pages/bug/Bug.tsx @@ -65,6 +65,7 @@ const useStyles = makeStyles((theme) => ({ ...theme.typography.body2, }, commentForm: { + marginTop: theme.spacing(2), marginLeft: 48, }, })); @@ -83,7 +84,7 @@ function Bug({ bug }: Props) { </div> <div className={classes.container}> <div className={classes.timeline}> - <TimelineQuery id={bug.id} /> + <TimelineQuery bug={bug} /> <IfLoggedIn> {() => ( <div className={classes.commentForm}> diff --git a/webui/src/pages/bug/CommentForm.tsx b/webui/src/pages/bug/CommentForm.tsx index 6d27e398..e70348a6 100644 --- a/webui/src/pages/bug/CommentForm.tsx +++ b/webui/src/pages/bug/CommentForm.tsx @@ -15,7 +15,6 @@ import { TimelineDocument } from './TimelineQuery.generated'; type StyleProps = { loading: boolean }; const useStyles = makeStyles<Theme, StyleProps>((theme) => ({ container: { - margin: theme.spacing(2, 0), padding: theme.spacing(0, 2, 2, 2), }, textarea: {}, diff --git a/webui/src/pages/bug/EditCommentForm.graphql b/webui/src/pages/bug/EditCommentForm.graphql new file mode 100644 index 00000000..4765b75c --- /dev/null +++ b/webui/src/pages/bug/EditCommentForm.graphql @@ -0,0 +1,16 @@ +#import "./MessageCommentFragment.graphql" +#import "./MessageCreateFragment.graphql" + +mutation EditComment($input: EditCommentInput!) { + editComment(input: $input) { + bug { + id + timeline { + comments: nodes { + ...Create + ...AddComment + } + } + } + } +} diff --git a/webui/src/pages/bug/EditCommentForm.tsx b/webui/src/pages/bug/EditCommentForm.tsx new file mode 100644 index 00000000..8fa659b3 --- /dev/null +++ b/webui/src/pages/bug/EditCommentForm.tsx @@ -0,0 +1,123 @@ +import React, { useState, useRef } from 'react'; + +import Button from '@material-ui/core/Button'; +import Paper from '@material-ui/core/Paper'; +import { makeStyles, Theme } from '@material-ui/core/styles'; + +import CommentInput from '../../components/CommentInput/CommentInput'; + +import { BugFragment } from './Bug.generated'; +import { useEditCommentMutation } from './EditCommentForm.generated'; +import { AddCommentFragment } from './MessageCommentFragment.generated'; +import { CreateFragment } from './MessageCreateFragment.generated'; + +type StyleProps = { loading: boolean }; +const useStyles = makeStyles<Theme, StyleProps>((theme) => ({ + container: { + padding: theme.spacing(0, 2, 2, 2), + }, + textarea: {}, + tabContent: { + margin: theme.spacing(2, 0), + }, + preview: { + borderBottom: `solid 3px ${theme.palette.grey['200']}`, + minHeight: '5rem', + }, + actions: { + display: 'flex', + justifyContent: 'flex-end', + }, + greenButton: { + marginLeft: '8px', + backgroundColor: theme.palette.success.main, + color: theme.palette.success.contrastText, + '&:hover': { + backgroundColor: theme.palette.success.dark, + color: theme.palette.success.contrastText, + }, + }, +})); + +type Props = { + bug: BugFragment; + comment: AddCommentFragment | CreateFragment; + onCancel?: () => void; + onPostSubmit?: (comments: any) => void; +}; + +function EditCommentForm({ bug, comment, onCancel, onPostSubmit }: Props) { + const [editComment, { loading }] = useEditCommentMutation(); + const [message, setMessage] = useState<string>(comment.message); + const [inputProp, setInputProp] = useState<any>(''); + const classes = useStyles({ loading }); + const form = useRef<HTMLFormElement>(null); + + const submit = () => { + editComment({ + variables: { + input: { + prefix: bug.id, + message: message, + target: comment.id, + }, + }, + }).then((result) => { + const comments = result.data?.editComment.bug.timeline.comments as ( + | AddCommentFragment + | CreateFragment + )[]; + // NOTE Searching for the changed comment could be dropped if GraphQL get + // filter by id argument for timelineitems + const modifiedComment = comments.find((elem) => elem.id === comment.id); + if (onPostSubmit) onPostSubmit(modifiedComment); + }); + resetForm(); + }; + + function resetForm() { + setInputProp({ + value: '', + }); + } + + const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault(); + if (message.length > 0) submit(); + }; + + function getCancelButton() { + return ( + <Button onClick={onCancel} variant="contained"> + Cancel + </Button> + ); + } + + return ( + <Paper className={classes.container}> + <form onSubmit={handleSubmit} ref={form}> + <CommentInput + inputProps={inputProp} + loading={loading} + onChange={(message: string) => setMessage(message)} + inputText={comment.message} + /> + <div className={classes.actions}> + {onCancel && getCancelButton()} + <Button + className={classes.greenButton} + variant="contained" + color="primary" + type="submit" + disabled={loading || message.length === 0} + > + Update Comment + </Button> + </div> + </form> + </Paper> + ); +} + +export default EditCommentForm; diff --git a/webui/src/pages/bug/Message.tsx b/webui/src/pages/bug/Message.tsx index faff5356..2f4cbc59 100644 --- a/webui/src/pages/bug/Message.tsx +++ b/webui/src/pages/bug/Message.tsx @@ -1,14 +1,22 @@ -import React from 'react'; +import React, { useState } from 'react'; +import IconButton from '@material-ui/core/IconButton'; import Paper from '@material-ui/core/Paper'; +import Tooltip from '@material-ui/core/Tooltip/Tooltip'; import { makeStyles } from '@material-ui/core/styles'; +import EditIcon from '@material-ui/icons/Edit'; +import HistoryIcon from '@material-ui/icons/History'; import Author, { Avatar } from 'src/components/Author'; import Content from 'src/components/Content'; import Date from 'src/components/Date'; +import IfLoggedIn from 'src/components/IfLoggedIn/IfLoggedIn'; +import { BugFragment } from './Bug.generated'; +import EditCommentForm from './EditCommentForm'; import { AddCommentFragment } from './MessageCommentFragment.generated'; import { CreateFragment } from './MessageCreateFragment.generated'; +import MessageHistoryDialog from './MessageHistoryDialog'; const useStyles = makeStyles((theme) => ({ author: { @@ -51,30 +59,133 @@ const useStyles = makeStyles((theme) => ({ ...theme.typography.body2, padding: '0.5rem', }, + headerActions: { + color: theme.palette.info.contrastText, + padding: '0rem', + marginLeft: theme.spacing(1), + fontSize: '0.75rem', + '&:hover': { + backgroundColor: 'inherit', + }, + }, })); +type HistBtnProps = { + bugId: string; + commentId: string; +}; +function HistoryMenuToggleButton({ bugId, commentId }: HistBtnProps) { + const classes = useStyles(); + const [open, setOpen] = React.useState(false); + + const handleClickOpen = () => { + setOpen(true); + }; + + const handleClose = () => { + setOpen(false); + }; + + return ( + <div> + <IconButton + aria-label="more" + aria-controls="long-menu" + aria-haspopup="true" + onClick={handleClickOpen} + className={classes.headerActions} + > + <HistoryIcon /> + </IconButton> + { + // Render CustomizedDialogs on open to prevent fetching the history + // before opening the history menu. + open && ( + <MessageHistoryDialog + bugId={bugId} + commentId={commentId} + open={open} + onClose={handleClose} + /> + ) + } + </div> + ); +} + type Props = { + bug: BugFragment; op: AddCommentFragment | CreateFragment; }; - -function Message({ op }: Props) { +function Message({ bug, op }: Props) { const classes = useStyles(); - return ( - <article className={classes.container}> - <Avatar author={op.author} className={classes.avatar} /> + const [editMode, switchToEditMode] = useState(false); + const [comment, setComment] = useState(op); + + const editComment = (id: String) => { + switchToEditMode(true); + }; + + function readMessageView() { + return ( <Paper elevation={1} className={classes.bubble}> <header className={classes.header}> <div className={classes.title}> - <Author className={classes.author} author={op.author} /> + <Author className={classes.author} author={comment.author} /> <span> commented </span> - <Date date={op.createdAt} /> + <Date date={comment.createdAt} /> </div> - {op.edited && <div className={classes.tag}>Edited</div>} + {comment.edited && ( + <HistoryMenuToggleButton bugId={bug.id} commentId={comment.id} /> + )} + <IfLoggedIn> + {() => ( + <Tooltip title="Edit Message" placement="top" arrow={true}> + <IconButton + disableRipple + className={classes.headerActions} + aria-label="edit message" + onClick={() => editComment(comment.id)} + > + <EditIcon /> + </IconButton> + </Tooltip> + )} + </IfLoggedIn> </header> <section className={classes.body}> - <Content markdown={op.message} /> + <Content markdown={comment.message} /> </section> </Paper> + ); + } + + function editMessageView() { + const cancelEdition = () => { + switchToEditMode(false); + }; + + const onPostSubmit = (comment: AddCommentFragment | CreateFragment) => { + setComment(comment); + switchToEditMode(false); + }; + + return ( + <div className={classes.bubble}> + <EditCommentForm + bug={bug} + onCancel={cancelEdition} + onPostSubmit={onPostSubmit} + comment={comment} + /> + </div> + ); + } + + return ( + <article className={classes.container}> + <Avatar author={comment.author} className={classes.avatar} /> + {editMode ? editMessageView() : readMessageView()} </article> ); } diff --git a/webui/src/pages/bug/MessageCommentFragment.graphql b/webui/src/pages/bug/MessageCommentFragment.graphql index 00f8342d..c852b4b0 100644 --- a/webui/src/pages/bug/MessageCommentFragment.graphql +++ b/webui/src/pages/bug/MessageCommentFragment.graphql @@ -1,8 +1,13 @@ #import "../../components/fragments.graphql" fragment AddComment on AddCommentTimelineItem { + id createdAt ...authored edited message + history { + message + date + } } diff --git a/webui/src/pages/bug/MessageCreateFragment.graphql b/webui/src/pages/bug/MessageCreateFragment.graphql index 4cae819d..1f4647b6 100644 --- a/webui/src/pages/bug/MessageCreateFragment.graphql +++ b/webui/src/pages/bug/MessageCreateFragment.graphql @@ -1,8 +1,13 @@ #import "../../components/fragments.graphql" fragment Create on CreateTimelineItem { + id createdAt ...authored edited message + history { + message + date + } } diff --git a/webui/src/pages/bug/MessageHistory.graphql b/webui/src/pages/bug/MessageHistory.graphql new file mode 100644 index 00000000..e90eb459 --- /dev/null +++ b/webui/src/pages/bug/MessageHistory.graphql @@ -0,0 +1,15 @@ +#import "./MessageCommentFragment.graphql" +#import "./MessageCreateFragment.graphql" + +query MessageHistory($bugIdPrefix: String!) { + repository { + bug(prefix: $bugIdPrefix) { + timeline { + comments: nodes { + ...Create + ...AddComment + } + } + } + } +} diff --git a/webui/src/pages/bug/MessageHistoryDialog.tsx b/webui/src/pages/bug/MessageHistoryDialog.tsx new file mode 100644 index 00000000..0ed33642 --- /dev/null +++ b/webui/src/pages/bug/MessageHistoryDialog.tsx @@ -0,0 +1,235 @@ +import moment from 'moment'; +import React from 'react'; +import Moment from 'react-moment'; + +import MuiAccordion from '@material-ui/core/Accordion'; +import MuiAccordionDetails from '@material-ui/core/AccordionDetails'; +import MuiAccordionSummary from '@material-ui/core/AccordionSummary'; +import CircularProgress from '@material-ui/core/CircularProgress'; +import Dialog from '@material-ui/core/Dialog'; +import MuiDialogContent from '@material-ui/core/DialogContent'; +import MuiDialogTitle from '@material-ui/core/DialogTitle'; +import Grid from '@material-ui/core/Grid'; +import IconButton from '@material-ui/core/IconButton'; +import Tooltip from '@material-ui/core/Tooltip/Tooltip'; +import Typography from '@material-ui/core/Typography'; +import { + createStyles, + Theme, + withStyles, + WithStyles, +} from '@material-ui/core/styles'; +import CloseIcon from '@material-ui/icons/Close'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; + +import { AddCommentFragment } from './MessageCommentFragment.generated'; +import { CreateFragment } from './MessageCreateFragment.generated'; +import { useMessageHistoryQuery } from './MessageHistory.generated'; + +const styles = (theme: Theme) => + createStyles({ + root: { + margin: 0, + padding: theme.spacing(2), + }, + closeButton: { + position: 'absolute', + right: theme.spacing(1), + top: theme.spacing(1), + }, + }); + +export interface DialogTitleProps extends WithStyles<typeof styles> { + id: string; + children: React.ReactNode; + onClose: () => void; +} + +const DialogTitle = withStyles(styles)((props: DialogTitleProps) => { + const { children, classes, onClose, ...other } = props; + return ( + <MuiDialogTitle disableTypography className={classes.root} {...other}> + <Typography variant="h6">{children}</Typography> + {onClose ? ( + <IconButton + aria-label="close" + className={classes.closeButton} + onClick={onClose} + > + <CloseIcon /> + </IconButton> + ) : null} + </MuiDialogTitle> + ); +}); + +const DialogContent = withStyles((theme: Theme) => ({ + root: { + padding: theme.spacing(2), + }, +}))(MuiDialogContent); + +const Accordion = withStyles({ + root: { + border: '1px solid rgba(0, 0, 0, .125)', + boxShadow: 'none', + '&:not(:last-child)': { + borderBottom: 0, + }, + '&:before': { + display: 'none', + }, + '&$expanded': { + margin: 'auto', + }, + }, + expanded: {}, +})(MuiAccordion); + +const AccordionSummary = withStyles((theme) => ({ + root: { + backgroundColor: theme.palette.primary.light, + borderBottomWidth: '1px', + borderBottomStyle: 'solid', + borderBottomColor: theme.palette.divider, + marginBottom: -1, + minHeight: 56, + '&$expanded': { + minHeight: 56, + }, + }, + content: { + '&$expanded': { + margin: '12px 0', + }, + }, + expanded: {}, +}))(MuiAccordionSummary); + +const AccordionDetails = withStyles((theme) => ({ + root: { + padding: theme.spacing(2), + }, +}))(MuiAccordionDetails); + +type Props = { + bugId: string; + commentId: string; + open: boolean; + onClose: () => void; +}; +function MessageHistoryDialog({ bugId, commentId, open, onClose }: Props) { + const [expanded, setExpanded] = React.useState<string | false>('panel0'); + + const { loading, error, data } = useMessageHistoryQuery({ + variables: { bugIdPrefix: bugId }, + }); + if (loading) { + return ( + <Dialog + onClose={onClose} + aria-labelledby="customized-dialog-title" + open={open} + fullWidth + maxWidth="sm" + > + <DialogTitle id="customized-dialog-title" onClose={onClose}> + Loading... + </DialogTitle> + <DialogContent dividers> + <Grid container justify="center"> + <CircularProgress /> + </Grid> + </DialogContent> + </Dialog> + ); + } + if (error) { + return ( + <Dialog + onClose={onClose} + aria-labelledby="customized-dialog-title" + open={open} + fullWidth + maxWidth="sm" + > + <DialogTitle id="customized-dialog-title" onClose={onClose}> + Something went wrong... + </DialogTitle> + <DialogContent dividers> + <p>Error: {error}</p> + </DialogContent> + </Dialog> + ); + } + + const comments = data?.repository?.bug?.timeline.comments as ( + | AddCommentFragment + | CreateFragment + )[]; + // NOTE Searching for the changed comment could be dropped if GraphQL get + // filter by id argument for timelineitems + const comment = comments.find((elem) => elem.id === commentId); + // Sort by most recent edit. Must create a copy of constant history as + // reverse() modifies inplace. + const history = comment?.history.slice().reverse(); + const editCount = history?.length === undefined ? 0 : history?.length - 1; + + const handleChange = (panel: string) => ( + event: React.ChangeEvent<{}>, + newExpanded: boolean + ) => { + setExpanded(newExpanded ? panel : false); + }; + + const getSummary = (index: number, date: Date) => { + const desc = + index === editCount ? 'Created ' : `#${editCount - index} • Edited `; + const mostRecent = index === 0 ? ' (most recent)' : ''; + return ( + <> + <Tooltip title={moment(date).format('LLLL')}> + <span> + {desc} + <Moment date={date} format="on ll" /> + {mostRecent} + </span> + </Tooltip> + </> + ); + }; + + return ( + <Dialog + onClose={onClose} + aria-labelledby="customized-dialog-title" + open={open} + fullWidth + maxWidth="md" + > + <DialogTitle id="customized-dialog-title" onClose={onClose}> + {`Edited ${editCount} ${editCount > 1 ? 'times' : 'time'}.`} + </DialogTitle> + <DialogContent dividers> + {history?.map((edit, index) => ( + <Accordion + square + expanded={expanded === 'panel' + index} + onChange={handleChange('panel' + index)} + > + <AccordionSummary + expandIcon={<ExpandMoreIcon />} + aria-controls="panel1d-content" + id="panel1d-header" + > + <Typography>{getSummary(index, edit.date)}</Typography> + </AccordionSummary> + <AccordionDetails>{edit.message}</AccordionDetails> + </Accordion> + ))} + </DialogContent> + </Dialog> + ); +} + +export default MessageHistoryDialog; diff --git a/webui/src/pages/bug/Timeline.tsx b/webui/src/pages/bug/Timeline.tsx index 6e1d242e..60459a53 100644 --- a/webui/src/pages/bug/Timeline.tsx +++ b/webui/src/pages/bug/Timeline.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { makeStyles } from '@material-ui/core/styles'; +import { BugFragment } from './Bug.generated'; import LabelChange from './LabelChange'; import Message from './Message'; import SetStatus from './SetStatus'; @@ -18,9 +19,10 @@ const useStyles = makeStyles((theme) => ({ type Props = { ops: Array<TimelineItemFragment>; + bug: BugFragment; }; -function Timeline({ ops }: Props) { +function Timeline({ bug, ops }: Props) { const classes = useStyles(); return ( @@ -28,9 +30,9 @@ function Timeline({ ops }: Props) { {ops.map((op, index) => { switch (op.__typename) { case 'CreateTimelineItem': - return <Message key={index} op={op} />; + return <Message key={index} op={op} bug={bug} />; case 'AddCommentTimelineItem': - return <Message key={index} op={op} />; + return <Message key={index} op={op} bug={bug} />; case 'LabelChangeTimelineItem': return <LabelChange key={index} op={op} />; case 'SetTitleTimelineItem': diff --git a/webui/src/pages/bug/TimelineQuery.tsx b/webui/src/pages/bug/TimelineQuery.tsx index 74eed52b..d66c665b 100644 --- a/webui/src/pages/bug/TimelineQuery.tsx +++ b/webui/src/pages/bug/TimelineQuery.tsx @@ -2,17 +2,18 @@ import React from 'react'; import CircularProgress from '@material-ui/core/CircularProgress'; +import { BugFragment } from './Bug.generated'; import Timeline from './Timeline'; import { useTimelineQuery } from './TimelineQuery.generated'; type Props = { - id: string; + bug: BugFragment; }; -const TimelineQuery = ({ id }: Props) => { +const TimelineQuery = ({ bug }: Props) => { const { loading, error, data } = useTimelineQuery({ variables: { - id, + id: bug.id, first: 100, }, }); @@ -25,7 +26,7 @@ const TimelineQuery = ({ id }: Props) => { return null; } - return <Timeline ops={nodes} />; + return <Timeline ops={nodes} bug={bug} />; }; export default TimelineQuery; |