aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--bridge/github/import.go2
-rw-r--r--bridge/github/import_query.go129
-rw-r--r--bridge/github/iterator.go633
-rw-r--r--bridge/gitlab/import.go4
-rw-r--r--go.mod2
-rw-r--r--go.sum3
-rw-r--r--webui/Readme.md24
-rw-r--r--webui/package-lock.json12
-rw-r--r--webui/package.json2
-rw-r--r--webui/src/App.tsx4
-rw-r--r--webui/src/components/Author.tsx2
-rw-r--r--webui/src/components/BugTitleForm/BugTitleForm.tsx202
-rw-r--r--webui/src/components/BugTitleForm/SetTitle.graphql7
-rw-r--r--webui/src/components/CloseBugButton/CloseBug.graphql8
-rw-r--r--webui/src/components/CloseBugButton/CloseBugButton.tsx55
-rw-r--r--webui/src/components/CommentInput/CommentInput.tsx107
-rw-r--r--webui/src/components/CurrentIdentity/CurrentIdentity.graphql (renamed from webui/src/layout/CurrentIdentity.graphql)0
-rw-r--r--webui/src/components/CurrentIdentity/CurrentIdentity.tsx (renamed from webui/src/layout/CurrentIdentity.tsx)0
-rw-r--r--webui/src/components/Header/Header.tsx (renamed from webui/src/layout/Header.tsx)2
-rw-r--r--webui/src/components/Header/index.tsx (renamed from webui/src/layout/index.tsx)0
-rw-r--r--webui/src/components/IfLoggedIn/IfLoggedIn.tsx (renamed from webui/src/layout/IfLoggedIn.tsx)2
-rw-r--r--webui/src/components/Label.tsx3
-rw-r--r--webui/src/components/ReopenBugButton/OpenBug.graphql7
-rw-r--r--webui/src/components/ReopenBugButton/ReopenBugButton.tsx55
-rw-r--r--webui/src/graphql/fragments.graphql (renamed from webui/src/components/fragments.graphql)0
-rw-r--r--webui/src/pages/bug/Bug.tsx30
-rw-r--r--webui/src/pages/bug/CommentForm.tsx110
-rw-r--r--webui/src/pages/list/ListQuery.tsx67
-rw-r--r--webui/src/pages/new/NewBug.graphql7
-rw-r--r--webui/src/pages/new/NewBugPage.tsx118
30 files changed, 1053 insertions, 544 deletions
diff --git a/bridge/github/import.go b/bridge/github/import.go
index 78e93436..e8a4d3cb 100644
--- a/bridge/github/import.go
+++ b/bridge/github/import.go
@@ -100,7 +100,7 @@ func (gi *githubImporter) ImportAll(ctx context.Context, repo *cache.RepoCache,
return out, nil
}
-func (gi *githubImporter) ensureIssue(repo *cache.RepoCache, issue issueTimeline) (*cache.BugCache, error) {
+func (gi *githubImporter) ensureIssue(repo *cache.RepoCache, issue issue) (*cache.BugCache, error) {
// ensure issue author
author, err := gi.ensurePerson(repo, issue.Author)
if err != nil {
diff --git a/bridge/github/import_query.go b/bridge/github/import_query.go
index 58f6d95e..228d204a 100644
--- a/bridge/github/import_query.go
+++ b/bridge/github/import_query.go
@@ -47,14 +47,9 @@ type userContentEdit struct {
}
type issueComment struct {
- authorEvent
- Body githubv4.String
- Url githubv4.URI
-
- UserContentEdits struct {
- Nodes []userContentEdit
- PageInfo pageInfo
- } `graphql:"userContentEdits(last: $commentEditLast, before: $commentEditBefore)"`
+ authorEvent // NOTE: contains Id
+ Body githubv4.String
+ Url githubv4.URI
}
type timelineItem struct {
@@ -96,70 +91,6 @@ type timelineItem struct {
} `graphql:"... on RenamedTitleEvent"`
}
-type issueTimeline struct {
- authorEvent
- Title string
- Body githubv4.String
- Url githubv4.URI
-
- TimelineItems struct {
- Edges []struct {
- Cursor githubv4.String
- Node timelineItem
- }
- PageInfo pageInfo
- } `graphql:"timelineItems(first: $timelineFirst, after: $timelineAfter)"`
-
- UserContentEdits struct {
- Nodes []userContentEdit
- PageInfo pageInfo
- } `graphql:"userContentEdits(last: $issueEditLast, before: $issueEditBefore)"`
-}
-
-type issueEdit struct {
- UserContentEdits struct {
- Nodes []userContentEdit
- PageInfo pageInfo
- } `graphql:"userContentEdits(last: $issueEditLast, before: $issueEditBefore)"`
-}
-
-type issueTimelineQuery struct {
- Repository struct {
- Issues struct {
- Nodes []issueTimeline
- PageInfo pageInfo
- } `graphql:"issues(first: $issueFirst, after: $issueAfter, orderBy: {field: CREATED_AT, direction: ASC}, filterBy: {since: $issueSince})"`
- } `graphql:"repository(owner: $owner, name: $name)"`
-}
-
-type issueEditQuery struct {
- Repository struct {
- Issues struct {
- Nodes []issueEdit
- PageInfo pageInfo
- } `graphql:"issues(first: $issueFirst, after: $issueAfter, orderBy: {field: CREATED_AT, direction: ASC}, filterBy: {since: $issueSince})"`
- } `graphql:"repository(owner: $owner, name: $name)"`
-}
-
-type commentEditQuery struct {
- Repository struct {
- Issues struct {
- Nodes []struct {
- Timeline struct {
- Nodes []struct {
- IssueComment struct {
- UserContentEdits struct {
- Nodes []userContentEdit
- PageInfo pageInfo
- } `graphql:"userContentEdits(last: $commentEditLast, before: $commentEditBefore)"`
- } `graphql:"... on IssueComment"`
- }
- } `graphql:"timeline(first: $timelineFirst, after: $timelineAfter)"`
- }
- } `graphql:"issues(first: $issueFirst, after: $issueAfter, orderBy: {field: CREATED_AT, direction: ASC}, filterBy: {since: $issueSince})"`
- } `graphql:"repository(owner: $owner, name: $name)"`
-}
-
type ghostQuery struct {
User struct {
Login githubv4.String
@@ -187,3 +118,57 @@ type loginQuery struct {
Login string `graphql:"login"`
} `graphql:"viewer"`
}
+
+type issueQuery struct {
+ Repository struct {
+ Issues struct {
+ Nodes []issue
+ PageInfo pageInfo
+ } `graphql:"issues(first: $issueFirst, after: $issueAfter, orderBy: {field: CREATED_AT, direction: ASC}, filterBy: {since: $issueSince})"`
+ } `graphql:"repository(owner: $owner, name: $name)"`
+}
+
+type issue struct {
+ authorEvent
+ Title string
+ Number githubv4.Int
+ Body githubv4.String
+ Url githubv4.URI
+}
+
+type issueEditQuery struct {
+ Node struct {
+ Typename githubv4.String `graphql:"__typename"`
+ Issue struct {
+ UserContentEdits struct {
+ Nodes []userContentEdit
+ TotalCount githubv4.Int
+ PageInfo pageInfo
+ } `graphql:"userContentEdits(last: $issueEditLast, before: $issueEditBefore)"`
+ } `graphql:"... on Issue"`
+ } `graphql:"node(id: $gqlNodeId)"`
+}
+
+type timelineQuery struct {
+ Node struct {
+ Typename githubv4.String `graphql:"__typename"`
+ Issue struct {
+ TimelineItems struct {
+ Nodes []timelineItem
+ PageInfo pageInfo
+ } `graphql:"timelineItems(first: $timelineFirst, after: $timelineAfter)"`
+ } `graphql:"... on Issue"`
+ } `graphql:"node(id: $gqlNodeId)"`
+}
+
+type commentEditQuery struct {
+ Node struct {
+ Typename githubv4.String `graphql:"__typename"`
+ IssueComment struct {
+ UserContentEdits struct {
+ Nodes []userContentEdit
+ PageInfo pageInfo
+ } `graphql:"userContentEdits(last: $commentEditLast, before: $commentEditBefore)"`
+ } `graphql:"... on IssueComment"`
+ } `graphql:"node(id: $gqlNodeId)"`
+}
diff --git a/bridge/github/iterator.go b/bridge/github/iterator.go
index 40b00292..d21faae8 100644
--- a/bridge/github/iterator.go
+++ b/bridge/github/iterator.go
@@ -4,398 +4,371 @@ import (
"context"
"time"
+ "github.com/pkg/errors"
"github.com/shurcooL/githubv4"
)
-type indexer struct{ index int }
+type iterator struct {
+ // Github graphql client
+ gc *githubv4.Client
-type issueEditIterator struct {
- index int
- query issueEditQuery
- variables map[string]interface{}
-}
+ // The iterator will only query issues updated or created after the date given in
+ // the variable since.
+ since time.Time
-type commentEditIterator struct {
- index int
- query commentEditQuery
- variables map[string]interface{}
-}
+ // Shared context, which is used for all graphql queries.
+ ctx context.Context
-type timelineIterator struct {
- index int
- query issueTimelineQuery
- variables map[string]interface{}
+ // Sticky error
+ err error
- issueEdit indexer
- commentEdit indexer
+ // Issue iterator
+ issueIter issueIter
+}
- // lastEndCursor cache the timeline end cursor for one iteration
- lastEndCursor githubv4.String
+type issueIter struct {
+ iterVars
+ query issueQuery
+ issueEditIter []issueEditIter
+ timelineIter []timelineIter
}
-type iterator struct {
- // github graphql client
- gc *githubv4.Client
+type issueEditIter struct {
+ iterVars
+ query issueEditQuery
+}
- // if since is given the iterator will query only the updated
- // and created issues after this date
- since time.Time
+type timelineIter struct {
+ iterVars
+ query timelineQuery
+ commentEditIter []commentEditIter
+}
- // number of timelines/userEditcontent/issueEdit to query
- // at a time, more capacity = more used memory = less queries
- // to make
- capacity int
+type commentEditIter struct {
+ iterVars
+ query commentEditQuery
+}
- // shared context used for all graphql queries
- ctx context.Context
+type iterVars struct {
+ // Iterator index
+ index int
- // sticky error
- err error
+ // capacity is the number of elements (issues, issue edits, timeline items, or
+ // comment edits) to query at a time. More capacity = more used memory =
+ // less queries to make.
+ capacity int
- // timeline iterator
- timeline timelineIterator
+ // Variable assignments for graphql query
+ variables varmap
+}
- // issue edit iterator
- issueEdit issueEditIterator
+type varmap map[string]interface{}
- // comment edit iterator
- commentEdit commentEditIterator
+func newIterVars(capacity int) iterVars {
+ return iterVars{
+ index: -1,
+ capacity: capacity,
+ variables: varmap{},
+ }
}
-// NewIterator create and initialize a new iterator
+// NewIterator creates and initialize a new iterator.
func NewIterator(ctx context.Context, client *githubv4.Client, capacity int, owner, project string, since time.Time) *iterator {
i := &iterator{
- gc: client,
- since: since,
- capacity: capacity,
- ctx: ctx,
- timeline: timelineIterator{
- index: -1,
- issueEdit: indexer{-1},
- commentEdit: indexer{-1},
- variables: map[string]interface{}{
- "owner": githubv4.String(owner),
- "name": githubv4.String(project),
- },
- },
- commentEdit: commentEditIterator{
- index: -1,
- variables: map[string]interface{}{
- "owner": githubv4.String(owner),
- "name": githubv4.String(project),
- },
- },
- issueEdit: issueEditIterator{
- index: -1,
- variables: map[string]interface{}{
- "owner": githubv4.String(owner),
- "name": githubv4.String(project),
- },
+ gc: client,
+ since: since,
+ ctx: ctx,
+ issueIter: issueIter{
+ iterVars: newIterVars(capacity),
+ timelineIter: make([]timelineIter, capacity),
+ issueEditIter: make([]issueEditIter, capacity),
},
}
-
- i.initTimelineQueryVariables()
- return i
-}
-
-// init issue timeline variables
-func (i *iterator) initTimelineQueryVariables() {
- i.timeline.variables["issueFirst"] = githubv4.Int(1)
- i.timeline.variables["issueAfter"] = (*githubv4.String)(nil)
- i.timeline.variables["issueSince"] = githubv4.DateTime{Time: i.since}
- i.timeline.variables["timelineFirst"] = githubv4.Int(i.capacity)
- i.timeline.variables["timelineAfter"] = (*githubv4.String)(nil)
- // Fun fact, github provide the comment edition in reverse chronological
- // order, because haha. Look at me, I'm dying of laughter.
- i.timeline.variables["issueEditLast"] = githubv4.Int(i.capacity)
- i.timeline.variables["issueEditBefore"] = (*githubv4.String)(nil)
- i.timeline.variables["commentEditLast"] = githubv4.Int(i.capacity)
- i.timeline.variables["commentEditBefore"] = (*githubv4.String)(nil)
-}
-
-// init issue edit variables
-func (i *iterator) initIssueEditQueryVariables() {
- i.issueEdit.variables["issueFirst"] = githubv4.Int(1)
- i.issueEdit.variables["issueAfter"] = i.timeline.variables["issueAfter"]
- i.issueEdit.variables["issueSince"] = githubv4.DateTime{Time: i.since}
- i.issueEdit.variables["issueEditLast"] = githubv4.Int(i.capacity)
- i.issueEdit.variables["issueEditBefore"] = (*githubv4.String)(nil)
-}
-
-// init issue comment variables
-func (i *iterator) initCommentEditQueryVariables() {
- i.commentEdit.variables["issueFirst"] = githubv4.Int(1)
- i.commentEdit.variables["issueAfter"] = i.timeline.variables["issueAfter"]
- i.commentEdit.variables["issueSince"] = githubv4.DateTime{Time: i.since}
- i.commentEdit.variables["timelineFirst"] = githubv4.Int(1)
- i.commentEdit.variables["timelineAfter"] = (*githubv4.String)(nil)
- i.commentEdit.variables["commentEditLast"] = githubv4.Int(i.capacity)
- i.commentEdit.variables["commentEditBefore"] = (*githubv4.String)(nil)
-}
-
-// reverse UserContentEdits arrays in both of the issue and
-// comment timelines
-func (i *iterator) reverseTimelineEditNodes() {
- node := i.timeline.query.Repository.Issues.Nodes[0]
- reverseEdits(node.UserContentEdits.Nodes)
- for index, ce := range node.TimelineItems.Edges {
- if ce.Node.Typename == "IssueComment" && len(node.TimelineItems.Edges) != 0 {
- reverseEdits(node.TimelineItems.Edges[index].Node.IssueComment.UserContentEdits.Nodes)
+ i.issueIter.variables.setOwnerProject(owner, project)
+ for idx := range i.issueIter.issueEditIter {
+ ie := &i.issueIter.issueEditIter[idx]
+ ie.iterVars = newIterVars(capacity)
+ }
+ for i1 := range i.issueIter.timelineIter {
+ tli := &i.issueIter.timelineIter[i1]
+ tli.iterVars = newIterVars(capacity)
+ tli.commentEditIter = make([]commentEditIter, capacity)
+ for i2 := range tli.commentEditIter {
+ cei := &tli.commentEditIter[i2]
+ cei.iterVars = newIterVars(capacity)
}
}
+ i.resetIssueVars()
+ return i
}
-// Error return last encountered error
-func (i *iterator) Error() error {
- return i.err
+func (v *varmap) setOwnerProject(owner, project string) {
+ (*v)["owner"] = githubv4.String(owner)
+ (*v)["name"] = githubv4.String(project)
}
-func (i *iterator) queryIssue() bool {
- ctx, cancel := context.WithTimeout(i.ctx, defaultTimeout)
- defer cancel()
-
- if err := i.gc.Query(ctx, &i.timeline.query, i.timeline.variables); err != nil {
- i.err = err
- return false
- }
+func (i *iterator) resetIssueVars() {
+ vars := &i.issueIter.variables
+ (*vars)["issueFirst"] = githubv4.Int(i.issueIter.capacity)
+ (*vars)["issueAfter"] = (*githubv4.String)(nil)
+ (*vars)["issueSince"] = githubv4.DateTime{Time: i.since}
+ i.issueIter.query.Repository.Issues.PageInfo.HasNextPage = true
+ i.issueIter.query.Repository.Issues.PageInfo.EndCursor = ""
+}
- issues := i.timeline.query.Repository.Issues.Nodes
- if len(issues) == 0 {
- return false
+func (i *iterator) resetIssueEditVars() {
+ for idx := range i.issueIter.issueEditIter {
+ ie := &i.issueIter.issueEditIter[idx]
+ ie.variables["issueEditLast"] = githubv4.Int(ie.capacity)
+ ie.variables["issueEditBefore"] = (*githubv4.String)(nil)
+ ie.query.Node.Issue.UserContentEdits.PageInfo.HasNextPage = true
+ ie.query.Node.Issue.UserContentEdits.PageInfo.EndCursor = ""
}
-
- i.reverseTimelineEditNodes()
- return true
}
-// NextIssue try to query the next issue and return true. Only one issue is
-// queried at each call.
-func (i *iterator) NextIssue() bool {
- if i.err != nil {
- return false
+func (i *iterator) resetTimelineVars() {
+ for idx := range i.issueIter.timelineIter {
+ ip := &i.issueIter.timelineIter[idx]
+ ip.variables["timelineFirst"] = githubv4.Int(ip.capacity)
+ ip.variables["timelineAfter"] = (*githubv4.String)(nil)
+ ip.query.Node.Issue.TimelineItems.PageInfo.HasNextPage = true
+ ip.query.Node.Issue.TimelineItems.PageInfo.EndCursor = ""
}
+}
- // if $issueAfter variable is nil we can directly make the first query
- if i.timeline.variables["issueAfter"] == (*githubv4.String)(nil) {
- nextIssue := i.queryIssue()
- // prevent from infinite loop by setting a non nil cursor
- issues := i.timeline.query.Repository.Issues
- i.timeline.variables["issueAfter"] = issues.PageInfo.EndCursor
- return nextIssue
+func (i *iterator) resetCommentEditVars() {
+ for i1 := range i.issueIter.timelineIter {
+ for i2 := range i.issueIter.timelineIter[i1].commentEditIter {
+ ce := &i.issueIter.timelineIter[i1].commentEditIter[i2]
+ ce.variables["commentEditLast"] = githubv4.Int(ce.capacity)
+ ce.variables["commentEditBefore"] = (*githubv4.String)(nil)
+ ce.query.Node.IssueComment.UserContentEdits.PageInfo.HasNextPage = true
+ ce.query.Node.IssueComment.UserContentEdits.PageInfo.EndCursor = ""
+ }
}
+}
- issues := i.timeline.query.Repository.Issues
- if !issues.PageInfo.HasNextPage {
- return false
+// Error return last encountered error
+func (i *iterator) Error() error {
+ if i.err != nil {
+ return i.err
}
+ return i.ctx.Err() // might return nil
+}
- // if we have more issues, query them
- i.timeline.variables["timelineAfter"] = (*githubv4.String)(nil)
- i.timeline.index = -1
-
- timelineEndCursor := issues.Nodes[0].TimelineItems.PageInfo.EndCursor
- // store cursor for future use
- i.timeline.lastEndCursor = timelineEndCursor
+func (i *iterator) HasError() bool {
+ return i.err != nil || i.ctx.Err() != nil
+}
- // query issue block
- nextIssue := i.queryIssue()
- i.timeline.variables["issueAfter"] = issues.PageInfo.EndCursor
+func (i *iterator) currIssueItem() *issue {
+ return &i.issueIter.query.Repository.Issues.Nodes[i.issueIter.index]
+}
- return nextIssue
+func (i *iterator) currIssueEditIter() *issueEditIter {
+ return &i.issueIter.issueEditIter[i.issueIter.index]
}
-// IssueValue return the actual issue value
-func (i *iterator) IssueValue() issueTimeline {
- issues := i.timeline.query.Repository.Issues
- return issues.Nodes[0]
+func (i *iterator) currTimelineIter() *timelineIter {
+ return &i.issueIter.timelineIter[i.issueIter.index]
}
-// NextTimelineItem return true if there is a next timeline item and increments the index by one.
-// It is used iterates over all the timeline items. Extra queries are made if it is necessary.
-func (i *iterator) NextTimelineItem() bool {
- if i.err != nil {
- return false
- }
+func (i *iterator) currCommentEditIter() *commentEditIter {
+ timelineIter := i.currTimelineIter()
+ return &timelineIter.commentEditIter[timelineIter.index]
+}
- if i.ctx.Err() != nil {
- return false
- }
+func (i *iterator) currIssueGqlNodeId() githubv4.ID {
+ return i.currIssueItem().Id
+}
- timelineItems := i.timeline.query.Repository.Issues.Nodes[0].TimelineItems
- // after NextIssue call it's good to check wether we have some timelineItems items or not
- if len(timelineItems.Edges) == 0 {
+// NextIssue returns true if there exists a next issue and advances the iterator by one.
+// It is used to iterate over all issues. Queries to github are made when necessary.
+func (i *iterator) NextIssue() bool {
+ if i.HasError() {
return false
}
-
- if i.timeline.index < len(timelineItems.Edges)-1 {
- i.timeline.index++
+ index := &i.issueIter.index
+ issues := &i.issueIter.query.Repository.Issues
+ issueItems := &issues.Nodes
+ if 0 <= *index && *index < len(*issueItems)-1 {
+ *index += 1
return true
}
- if !timelineItems.PageInfo.HasNextPage {
+ if !issues.PageInfo.HasNextPage {
return false
}
+ nextIssue := i.queryIssue()
+ return nextIssue
+}
- i.timeline.lastEndCursor = timelineItems.PageInfo.EndCursor
-
- // more timelines, query them
- i.timeline.variables["timelineAfter"] = timelineItems.PageInfo.EndCursor
+// IssueValue returns the actual issue value.
+func (i *iterator) IssueValue() issue {
+ return *i.currIssueItem()
+}
+func (i *iterator) queryIssue() bool {
ctx, cancel := context.WithTimeout(i.ctx, defaultTimeout)
defer cancel()
-
- if err := i.gc.Query(ctx, &i.timeline.query, i.timeline.variables); err != nil {
+ if endCursor := i.issueIter.query.Repository.Issues.PageInfo.EndCursor; endCursor != "" {
+ i.issueIter.variables["issueAfter"] = endCursor
+ }
+ if err := i.gc.Query(ctx, &i.issueIter.query, i.issueIter.variables); err != nil {
i.err = err
return false
}
-
- timelineItems = i.timeline.query.Repository.Issues.Nodes[0].TimelineItems
- // (in case github returns something weird) just for safety: better return a false than a panic
- if len(timelineItems.Edges) == 0 {
+ i.resetIssueEditVars()
+ i.resetTimelineVars()
+ issueItems := &i.issueIter.query.Repository.Issues.Nodes
+ if len(*issueItems) <= 0 {
+ i.issueIter.index = -1
return false
}
-
- i.reverseTimelineEditNodes()
- i.timeline.index = 0
+ i.issueIter.index = 0
return true
}
-// TimelineItemValue return the actual timeline item value
-func (i *iterator) TimelineItemValue() timelineItem {
- timelineItems := i.timeline.query.Repository.Issues.Nodes[0].TimelineItems
- return timelineItems.Edges[i.timeline.index].Node
-}
-
-func (i *iterator) queryIssueEdit() bool {
- ctx, cancel := context.WithTimeout(i.ctx, defaultTimeout)
- defer cancel()
-
- if err := i.gc.Query(ctx, &i.issueEdit.query, i.issueEdit.variables); err != nil {
- i.err = err
- //i.timeline.issueEdit.index = -1
+// NextIssueEdit returns true if there exists a next issue edit and advances the iterator
+// by one. It is used to iterate over all the issue edits. Queries to github are made when
+// necessary.
+func (i *iterator) NextIssueEdit() bool {
+ if i.HasError() {
return false
}
-
- issueEdits := i.issueEdit.query.Repository.Issues.Nodes[0].UserContentEdits
- // reverse issue edits because github
- reverseEdits(issueEdits.Nodes)
-
- // this is not supposed to happen
- if len(issueEdits.Nodes) == 0 {
- i.timeline.issueEdit.index = -1
+ ieIter := i.currIssueEditIter()
+ ieIdx := &ieIter.index
+ ieItems := ieIter.query.Node.Issue.UserContentEdits
+ if 0 <= *ieIdx && *ieIdx < len(ieItems.Nodes)-1 {
+ *ieIdx += 1
+ return i.nextValidIssueEdit()
+ }
+ if !ieItems.PageInfo.HasNextPage {
+ return false
+ }
+ querySucc := i.queryIssueEdit()
+ if !querySucc {
return false
}
-
- i.issueEdit.index = 0
- i.timeline.issueEdit.index = -2
return i.nextValidIssueEdit()
}
func (i *iterator) nextValidIssueEdit() bool {
- // issueEdit.Diff == nil happen if the event is older than early 2018, Github doesn't have the data before that.
- // Best we can do is to ignore the event.
+ // issueEdit.Diff == nil happen if the event is older than early 2018, Github doesn't have
+ // the data before that. Best we can do is to ignore the event.
if issueEdit := i.IssueEditValue(); issueEdit.Diff == nil || string(*issueEdit.Diff) == "" {
return i.NextIssueEdit()
}
return true
}
-// NextIssueEdit return true if there is a next issue edit and increments the index by one.
-// It is used iterates over all the issue edits. Extra queries are made if it is necessary.
-func (i *iterator) NextIssueEdit() bool {
- if i.err != nil {
+// IssueEditValue returns the actual issue edit value.
+func (i *iterator) IssueEditValue() userContentEdit {
+ iei := i.currIssueEditIter()
+ return iei.query.Node.Issue.UserContentEdits.Nodes[iei.index]
+}
+
+func (i *iterator) queryIssueEdit() bool {
+ ctx, cancel := context.WithTimeout(i.ctx, defaultTimeout)
+ defer cancel()
+ iei := i.currIssueEditIter()
+ if endCursor := iei.query.Node.Issue.UserContentEdits.PageInfo.EndCursor; endCursor != "" {
+ iei.variables["issueEditBefore"] = endCursor
+ }
+ iei.variables["gqlNodeId"] = i.currIssueGqlNodeId()
+ if err := i.gc.Query(ctx, &iei.query, iei.variables); err != nil {
+ i.err = err
return false
}
-
- if i.ctx.Err() != nil {
+ issueEditItems := iei.query.Node.Issue.UserContentEdits.Nodes
+ if len(issueEditItems) <= 0 {
+ iei.index = -1
return false
}
+ // The UserContentEditConnection in the Github API serves its elements in reverse chronological
+ // order. For our purpose we have to reverse the edits.
+ reverseEdits(issueEditItems)
+ iei.index = 0
+ return true
+}
- // this mean we looped over all available issue edits in the timeline.
- // now we have to use i.issueEditQuery
- if i.timeline.issueEdit.index == -2 {
- issueEdits := i.issueEdit.query.Repository.Issues.Nodes[0].UserContentEdits
- if i.issueEdit.index < len(issueEdits.Nodes)-1 {
- i.issueEdit.index++
- return i.nextValidIssueEdit()
- }
-
- if !issueEdits.PageInfo.HasPreviousPage {
- i.timeline.issueEdit.index = -1
- i.issueEdit.index = -1
- return false
- }
-
- // if there is more edits, query them
- i.issueEdit.variables["issueEditBefore"] = issueEdits.PageInfo.StartCursor
- return i.queryIssueEdit()
- }
-
- issueEdits := i.timeline.query.Repository.Issues.Nodes[0].UserContentEdits
- // if there is no edit, the UserContentEdits given by github is empty. That
- // means that the original message is given by the issue message.
- //
- // if there is edits, the UserContentEdits given by github contains both the
- // original message and the following edits. The issue message give the last
- // version so we don't care about that.
- //
- // the tricky part: for an issue older than the UserContentEdits API, github
- // doesn't have the previous message version anymore and give an edition
- // with .Diff == nil. We have to filter them.
- if len(issueEdits.Nodes) == 0 {
+// NextTimelineItem returns true if there exists a next timeline item and advances the iterator
+// by one. It is used to iterate over all the timeline items. Queries to github are made when
+// necessary.
+func (i *iterator) NextTimelineItem() bool {
+ if i.HasError() {
return false
}
-
- // loop over them timeline comment edits
- if i.timeline.issueEdit.index < len(issueEdits.Nodes)-1 {
- i.timeline.issueEdit.index++
- return i.nextValidIssueEdit()
+ tlIter := &i.issueIter.timelineIter[i.issueIter.index]
+ tlIdx := &tlIter.index
+ tlItems := tlIter.query.Node.Issue.TimelineItems
+ if 0 <= *tlIdx && *tlIdx < len(tlItems.Nodes)-1 {
+ *tlIdx += 1
+ return true
}
-
- if !issueEdits.PageInfo.HasPreviousPage {
- i.timeline.issueEdit.index = -1
+ if !tlItems.PageInfo.HasNextPage {
return false
}
-
- // if there is more edits, query them
- i.initIssueEditQueryVariables()
- i.issueEdit.variables["issueEditBefore"] = issueEdits.PageInfo.StartCursor
- return i.queryIssueEdit()
+ nextTlItem := i.queryTimeline()
+ return nextTlItem
}
-// IssueEditValue return the actual issue edit value
-func (i *iterator) IssueEditValue() userContentEdit {
- // if we are using issue edit query
- if i.timeline.issueEdit.index == -2 {
- issueEdits := i.issueEdit.query.Repository.Issues.Nodes[0].UserContentEdits
- return issueEdits.Nodes[i.issueEdit.index]
- }
-
- issueEdits := i.timeline.query.Repository.Issues.Nodes[0].UserContentEdits
- // else get it from timeline issue edit query
- return issueEdits.Nodes[i.timeline.issueEdit.index]
+// TimelineItemValue returns the actual timeline item value.
+func (i *iterator) TimelineItemValue() timelineItem {
+ tli := i.currTimelineIter()
+ return tli.query.Node.Issue.TimelineItems.Nodes[tli.index]
}
-func (i *iterator) queryCommentEdit() bool {
+func (i *iterator) queryTimeline() bool {
ctx, cancel := context.WithTimeout(i.ctx, defaultTimeout)
defer cancel()
-
- if err := i.gc.Query(ctx, &i.commentEdit.query, i.commentEdit.variables); err != nil {
+ tli := i.currTimelineIter()
+ if endCursor := tli.query.Node.Issue.TimelineItems.PageInfo.EndCursor; endCursor != "" {
+ tli.variables["timelineAfter"] = endCursor
+ }
+ tli.variables["gqlNodeId"] = i.currIssueGqlNodeId()
+ if err := i.gc.Query(ctx, &tli.query, tli.variables); err != nil {
i.err = err
return false
}
+ i.resetCommentEditVars()
+ timelineItems := &tli.query.Node.Issue.TimelineItems
+ if len(timelineItems.Nodes) <= 0 {
+ tli.index = -1
+ return false
+ }
+ tli.index = 0
+ return true
+}
- commentEdits := i.commentEdit.query.Repository.Issues.Nodes[0].Timeline.Nodes[0].IssueComment.UserContentEdits
- // this is not supposed to happen
- if len(commentEdits.Nodes) == 0 {
- i.timeline.commentEdit.index = -1
+// NextCommentEdit returns true if there exists a next comment edit and advances the iterator
+// by one. It is used to iterate over all issue edits. Queries to github are made when
+// necessary.
+func (i *iterator) NextCommentEdit() bool {
+ if i.HasError() {
return false
}
- reverseEdits(commentEdits.Nodes)
+ tmlnVal := i.TimelineItemValue()
+ if tmlnVal.Typename != "IssueComment" {
+ // The timeline iterator does not point to a comment.
+ i.err = errors.New("Call to NextCommentEdit() while timeline item is not a comment")
+ return false
+ }
- i.commentEdit.index = 0
- i.timeline.commentEdit.index = -2
+ cei := i.currCommentEditIter()
+ ceIdx := &cei.index
+ ceItems := &cei.query.Node.IssueComment.UserContentEdits
+ if 0 <= *ceIdx && *ceIdx < len(ceItems.Nodes)-1 {
+ *ceIdx += 1
+ return i.nextValidCommentEdit()
+ }
+ if !ceItems.PageInfo.HasNextPage {
+ return false
+ }
+ querySucc := i.queryCommentEdit()
+ if !querySucc {
+ return false
+ }
return i.nextValidCommentEdit()
}
@@ -407,72 +380,40 @@ func (i *iterator) nextValidCommentEdit() bool {
return true
}
-// NextCommentEdit return true if there is a next comment edit and increments the index by one.
-// It is used iterates over all the comment edits. Extra queries are made if it is necessary.
-func (i *iterator) NextCommentEdit() bool {
- if i.err != nil {
- return false
- }
-
- if i.ctx.Err() != nil {
- return false
- }
-
- // same as NextIssueEdit
- if i.timeline.commentEdit.index == -2 {
- commentEdits := i.commentEdit.query.Repository.Issues.Nodes[0].Timeline.Nodes[0].IssueComment.UserContentEdits
- if i.commentEdit.index < len(commentEdits.Nodes)-1 {
- i.commentEdit.index++
- return i.nextValidCommentEdit()
- }
+// CommentEditValue returns the actual comment edit value.
+func (i *iterator) CommentEditValue() userContentEdit {
+ cei := i.currCommentEditIter()
+ return cei.query.Node.IssueComment.UserContentEdits.Nodes[cei.index]
+}
- if !commentEdits.PageInfo.HasPreviousPage {
- i.timeline.commentEdit.index = -1
- i.commentEdit.index = -1
- return false
- }
+func (i *iterator) queryCommentEdit() bool {
+ ctx, cancel := context.WithTimeout(i.ctx, defaultTimeout)
+ defer cancel()
+ cei := i.currCommentEditIter()
- // if there is more comment edits, query them
- i.commentEdit.variables["commentEditBefore"] = commentEdits.PageInfo.StartCursor
- return i.queryCommentEdit()
+ if endCursor := cei.query.Node.IssueComment.UserContentEdits.PageInfo.EndCursor; endCursor != "" {
+ cei.variables["commentEditBefore"] = endCursor
}
-
- commentEdits := i.timeline.query.Repository.Issues.Nodes[0].TimelineItems.Edges[i.timeline.index].Node.IssueComment
- // if there is no comment edits
- if len(commentEdits.UserContentEdits.Nodes) == 0 {
+ tmlnVal := i.TimelineItemValue()
+ if tmlnVal.Typename != "IssueComment" {
+ i.err = errors.New("Call to queryCommentEdit() while timeline item is not a comment")
return false
}
-
- // loop over them timeline comment edits
- if i.timeline.commentEdit.index < len(commentEdits.UserContentEdits.Nodes)-1 {
- i.timeline.commentEdit.index++
- return i.nextValidCommentEdit()
- }
-
- if !commentEdits.UserContentEdits.PageInfo.HasPreviousPage {
- i.timeline.commentEdit.index = -1
+ cei.variables["gqlNodeId"] = tmlnVal.IssueComment.Id
+ if err := i.gc.Query(ctx, &cei.query, cei.variables); err != nil {
+ i.err = err
return false
}
-
- i.initCommentEditQueryVariables()
- if i.timeline.index == 0 {
- i.commentEdit.variables["timelineAfter"] = i.timeline.lastEndCursor
- } else {
- i.commentEdit.variables["timelineAfter"] = i.timeline.query.Repository.Issues.Nodes[0].TimelineItems.Edges[i.timeline.index-1].Cursor
- }
-
- i.commentEdit.variables["commentEditBefore"] = commentEdits.UserContentEdits.PageInfo.StartCursor
-
- return i.queryCommentEdit()
-}
-
-// CommentEditValue return the actual comment edit value
-func (i *iterator) CommentEditValue() userContentEdit {
- if i.timeline.commentEdit.index == -2 {
- return i.commentEdit.query.Repository.Issues.Nodes[0].Timeline.Nodes[0].IssueComment.UserContentEdits.Nodes[i.commentEdit.index]
+ ceItems := cei.query.Node.IssueComment.UserContentEdits.Nodes
+ if len(ceItems) <= 0 {
+ cei.index = -1
+ return false
}
-
- return i.timeline.query.Repository.Issues.Nodes[0].TimelineItems.Edges[i.timeline.index].Node.IssueComment.UserContentEdits.Nodes[i.timeline.commentEdit.index]
+ // The UserContentEditConnection in the Github API serves its elements in reverse chronological
+ // order. For our purpose we have to reverse the edits.
+ reverseEdits(ceItems)
+ cei.index = 0
+ return true
}
func reverseEdits(edits []userContentEdit) {
diff --git a/bridge/gitlab/import.go b/bridge/gitlab/import.go
index 897d65de..cf4f0039 100644
--- a/bridge/gitlab/import.go
+++ b/bridge/gitlab/import.go
@@ -127,8 +127,8 @@ func (gi *gitlabImporter) ensureIssue(repo *cache.RepoCache, issue *gitlab.Issue
b, err := repo.ResolveBugMatcher(func(excerpt *cache.BugExcerpt) bool {
return excerpt.CreateMetadata[core.MetaKeyOrigin] == target &&
excerpt.CreateMetadata[metaKeyGitlabId] == parseID(issue.IID) &&
- excerpt.CreateMetadata[metaKeyGitlabBaseUrl] == gi.conf[confKeyProjectID] &&
- excerpt.CreateMetadata[metaKeyGitlabProject] == gi.conf[confKeyGitlabBaseUrl]
+ excerpt.CreateMetadata[metaKeyGitlabBaseUrl] == gi.conf[confKeyGitlabBaseUrl] &&
+ excerpt.CreateMetadata[metaKeyGitlabProject] == gi.conf[confKeyProjectID]
})
if err == nil {
return b, nil
diff --git a/go.mod b/go.mod
index 8ece3fd1..d67bea3f 100644
--- a/go.mod
+++ b/go.mod
@@ -5,7 +5,7 @@ go 1.13
require (
github.com/99designs/gqlgen v0.10.3-0.20200209012558-b7a58a1c0e4b
github.com/99designs/keyring v1.1.6
- github.com/MichaelMure/go-term-text v0.2.9
+ github.com/MichaelMure/go-term-text v0.2.10
github.com/araddon/dateparse v0.0.0-20190622164848-0fb0a474d195
github.com/awesome-gocui/gocui v0.6.1-0.20191115151952-a34ffb055986
github.com/blang/semver v3.5.1+incompatible
diff --git a/go.sum b/go.sum
index 18f9066d..082484c1 100644
--- a/go.sum
+++ b/go.sum
@@ -43,6 +43,7 @@ github.com/Masterminds/semver v1.4.2/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF0
github.com/Masterminds/vcs v1.13.0/go.mod h1:N09YCmOQr6RLxC6UNHzuVwAdodYbbnycGHSmwVJjcKA=
github.com/MichaelMure/go-term-text v0.2.9 h1:jUxInT3rDhl4WoJgLnmMS3hR79zigyJS1TqKFDTI6xE=
github.com/MichaelMure/go-term-text v0.2.9/go.mod h1:2QSU/Nn2u41Tqoar+90RlYuhjngJPYgod7evnsYwkWc=
+github.com/MichaelMure/go-term-text v0.2.10/go.mod h1:DrWFodEEZsSgK1PQY9dqTn+pw3zGeYDmVF5PA8ECZhs=
github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU=
github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
@@ -363,6 +364,7 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky
github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
+github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
@@ -420,6 +422,7 @@ github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40T
github.com/rcrowley/go-metrics v0.0.0-20190826022208-cac0b30c2563/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rs/cors v1.6.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
diff --git a/webui/Readme.md b/webui/Readme.md
index 2ba94b5d..50d7afa6 100644
--- a/webui/Readme.md
+++ b/webui/Readme.md
@@ -2,13 +2,25 @@
## How to develop
-1. Compile the go binary
- - run `make` in the **root** directory
-2. Run the GraphQL backend on the port 3001
- - `./git-bug webui -p 3001`
-3. Run the hot-reloadable development WebUI
+### Run GraphQL backend
- - run `npm start` in the **webui** directory
+1. Download a git-bug stable binary or compile your own by running `make` in the **root** directory:
+
+2. Run the git-bug binary inside your git repository. It will manage issues and start the API:
+ - `git-bug webui -p 3001`
+
+### Run ReactJS front-end
+
+1. If you haven't already, clone the git-bug repository:
+
+2. Enter the `webui` directory and install the needed libraries:
+ - `make install` or `npm install`
+
+3. Generate the TS code from the GrapQL files and run the webui in development mode:
+ - `make start` or `npm start`
+ - If you get some lint errors, run the lint command below and start again:
+ - `make fix-lint` or `npm run lint -- --fix`
+ - `make start` or `npm start`
The development version of the WebUI is configured to query the backend on the port 3001. You can now live edit the js code and use the normal backend.
diff --git a/webui/package-lock.json b/webui/package-lock.json
index 2d1f4a51..7336b2bf 100644
--- a/webui/package-lock.json
+++ b/webui/package-lock.json
@@ -13342,9 +13342,9 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"ini": {
- "version": "1.3.5",
- "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz",
- "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw=="
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="
},
"inline-style-parser": {
"version": "0.1.1",
@@ -18024,9 +18024,9 @@
"integrity": "sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw="
},
"prettier": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.1.2.tgz",
- "integrity": "sha512-16c7K+x4qVlJg9rEbXl7HEGmQyZlG4R9AgP+oHKRMsMsuk8s+ATStlf1NpDqyBI1HpVyfjLOeMhH2LvuNvV5Vg==",
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.2.1.tgz",
+ "integrity": "sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q==",
"dev": true
},
"prettier-linter-helpers": {
diff --git a/webui/package.json b/webui/package.json
index 4f2a8da4..39696a25 100644
--- a/webui/package.json
+++ b/webui/package.json
@@ -38,7 +38,7 @@
"eslint-config-prettier": "^6.12.0",
"eslint-plugin-graphql": "^4.0.0",
"eslint-plugin-prettier": "^3.1.4",
- "prettier": "^2.1.2"
+ "prettier": "^2.2.1"
},
"scripts": {
"start": "npm run generate && react-scripts start",
diff --git a/webui/src/App.tsx b/webui/src/App.tsx
index 16663870..b9ade974 100644
--- a/webui/src/App.tsx
+++ b/webui/src/App.tsx
@@ -1,15 +1,17 @@
import React from 'react';
import { Route, Switch } from 'react-router';
-import Layout from './layout';
+import Layout from './components/Header';
import BugPage from './pages/bug';
import ListPage from './pages/list';
+import NewBugPage from './pages/new/NewBugPage';
export default function App() {
return (
<Layout>
<Switch>
<Route path="/" exact component={ListPage} />
+ <Route path="/new" exact component={NewBugPage} />
<Route path="/bug/:id" exact component={BugPage} />
</Switch>
</Layout>
diff --git a/webui/src/components/Author.tsx b/webui/src/components/Author.tsx
index 9ac1da52..d60e8969 100644
--- a/webui/src/components/Author.tsx
+++ b/webui/src/components/Author.tsx
@@ -3,7 +3,7 @@ import React from 'react';
import MAvatar from '@material-ui/core/Avatar';
import Tooltip from '@material-ui/core/Tooltip/Tooltip';
-import { AuthoredFragment } from './fragments.generated';
+import { AuthoredFragment } from '../graphql/fragments.generated';
type Props = AuthoredFragment & {
className?: string;
diff --git a/webui/src/components/BugTitleForm/BugTitleForm.tsx b/webui/src/components/BugTitleForm/BugTitleForm.tsx
new file mode 100644
index 00000000..c47eab31
--- /dev/null
+++ b/webui/src/components/BugTitleForm/BugTitleForm.tsx
@@ -0,0 +1,202 @@
+import React, { useState } from 'react';
+
+import {
+ Button,
+ fade,
+ makeStyles,
+ TextField,
+ Typography,
+} from '@material-ui/core';
+
+import { TimelineDocument } from '../../pages/bug/TimelineQuery.generated';
+import IfLoggedIn from '../IfLoggedIn/IfLoggedIn';
+import Author from 'src/components/Author';
+import Date from 'src/components/Date';
+import { BugFragment } from 'src/pages/bug/Bug.generated';
+
+import { useSetTitleMutation } from './SetTitle.generated';
+
+/**
+ * Css in JS styles
+ */
+const useStyles = makeStyles((theme) => ({
+ header: {
+ display: 'flex',
+ flexDirection: 'column',
+ },
+ headerTitle: {
+ display: 'flex',
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ },
+ readOnlyTitle: {
+ ...theme.typography.h5,
+ },
+ readOnlyId: {
+ ...theme.typography.subtitle1,
+ marginLeft: theme.spacing(1),
+ },
+ editButtonContainer: {
+ display: 'flex',
+ flexDirection: 'row',
+ justifyContent: 'flex-start',
+ alignItems: 'center',
+ minWidth: 200,
+ marginLeft: theme.spacing(2),
+ },
+ greenButton: {
+ marginLeft: '8px',
+ backgroundColor: '#2ea44fd9',
+ color: '#fff',
+ '&:hover': {
+ backgroundColor: '#2ea44f',
+ },
+ },
+ titleInput: {
+ borderRadius: theme.shape.borderRadius,
+ borderColor: fade(theme.palette.primary.main, 0.2),
+ borderStyle: 'solid',
+ borderWidth: '1px',
+ backgroundColor: fade(theme.palette.primary.main, 0.05),
+ padding: theme.spacing(0, 0),
+ minWidth: 336,
+ transition: theme.transitions.create([
+ 'width',
+ 'borderColor',
+ 'backgroundColor',
+ ]),
+ },
+}));
+
+interface Props {
+ bug: BugFragment;
+}
+
+/**
+ * Component for bug title change
+ * @param bug Selected bug in list page
+ */
+function BugTitleForm({ bug }: Props) {
+ const [bugTitleEdition, setbugTitleEdition] = useState(false);
+ const [setTitle, { loading, error }] = useSetTitleMutation();
+ const [issueTitle, setIssueTitle] = useState(bug.title);
+ const classes = useStyles();
+ let issueTitleInput: any;
+
+ function isFormValid() {
+ if (issueTitleInput) {
+ return issueTitleInput.value.length > 0 ? true : false;
+ } else {
+ return false;
+ }
+ }
+
+ function submitNewTitle() {
+ if (!isFormValid()) return;
+ setTitle({
+ variables: {
+ input: {
+ prefix: bug.humanId,
+ title: issueTitleInput.value,
+ },
+ },
+ refetchQueries: [
+ // TODO: update the cache instead of refetching
+ {
+ query: TimelineDocument,
+ variables: {
+ id: bug.id,
+ first: 100,
+ },
+ },
+ ],
+ awaitRefetchQueries: true,
+ }).then(() => setbugTitleEdition(false));
+ }
+
+ function cancelChange() {
+ setIssueTitle(bug.title);
+ setbugTitleEdition(false);
+ }
+
+ function editableBugTitle() {
+ return (
+ <form className={classes.headerTitle} onSubmit={submitNewTitle}>
+ <TextField
+ inputRef={(node) => {
+ issueTitleInput = node;
+ }}
+ className={classes.titleInput}
+ variant="outlined"
+ fullWidth
+ margin="dense"
+ value={issueTitle}
+ onChange={(event: any) => setIssueTitle(event.target.value)}
+ />
+ <div className={classes.editButtonContainer}>
+ <Button
+ size="small"
+ variant="contained"
+ type="submit"
+ disabled={issueTitle.length === 0}
+ >
+ Save
+ </Button>
+ <Button size="small" onClick={() => cancelChange()}>
+ Cancel
+ </Button>
+ </div>
+ </form>
+ );
+ }
+
+ function readonlyBugTitle() {
+ return (
+ <div className={classes.headerTitle}>
+ <div>
+ <span className={classes.readOnlyTitle}>{bug.title}</span>
+ <span className={classes.readOnlyId}>{bug.humanId}</span>
+ </div>
+ <IfLoggedIn>
+ {() => (
+ <div className={classes.editButtonContainer}>
+ <Button
+ size="small"
+ variant="contained"
+ onClick={() => setbugTitleEdition(!bugTitleEdition)}
+ >
+ Edit
+ </Button>
+ <Button
+ className={classes.greenButton}
+ size="small"
+ variant="contained"
+ href="/new"
+ >
+ New issue
+ </Button>
+ </div>
+ )}
+ </IfLoggedIn>
+ </div>
+ );
+ }
+
+ if (loading) return <div>Loading...</div>;
+ if (error) return <div>Error</div>;
+
+ return (
+ <div className={classes.header}>
+ {bugTitleEdition ? editableBugTitle() : readonlyBugTitle()}
+ <div className="classes.headerSubtitle">
+ <Typography color={'textSecondary'}>
+ <Author author={bug.author} />
+ {' opened this bug '}
+ <Date date={bug.createdAt} />
+ </Typography>
+ </div>
+ </div>
+ );
+}
+
+export default BugTitleForm;
diff --git a/webui/src/components/BugTitleForm/SetTitle.graphql b/webui/src/components/BugTitleForm/SetTitle.graphql
new file mode 100644
index 00000000..b96af155
--- /dev/null
+++ b/webui/src/components/BugTitleForm/SetTitle.graphql
@@ -0,0 +1,7 @@
+mutation setTitle($input: SetTitleInput!) {
+ setTitle(input: $input) {
+ bug {
+ id
+ }
+ }
+} \ No newline at end of file
diff --git a/webui/src/components/CloseBugButton/CloseBug.graphql b/webui/src/components/CloseBugButton/CloseBug.graphql
new file mode 100644
index 00000000..e2f4bff2
--- /dev/null
+++ b/webui/src/components/CloseBugButton/CloseBug.graphql
@@ -0,0 +1,8 @@
+# Write your query or mutation here
+mutation closeBug($input: CloseBugInput!) {
+ closeBug(input: $input) {
+ bug {
+ id
+ }
+ }
+} \ No newline at end of file
diff --git a/webui/src/components/CloseBugButton/CloseBugButton.tsx b/webui/src/components/CloseBugButton/CloseBugButton.tsx
new file mode 100644
index 00000000..19f56cab
--- /dev/null
+++ b/webui/src/components/CloseBugButton/CloseBugButton.tsx
@@ -0,0 +1,55 @@
+import React from 'react';
+
+import Button from '@material-ui/core/Button';
+
+import { BugFragment } from 'src/pages/bug/Bug.generated';
+import { TimelineDocument } from 'src/pages/bug/TimelineQuery.generated';
+
+import { useCloseBugMutation } from './CloseBug.generated';
+
+interface Props {
+ bug: BugFragment;
+ disabled: boolean;
+}
+
+function CloseBugButton({ bug, disabled }: Props) {
+ const [closeBug, { loading, error }] = useCloseBugMutation();
+
+ function closeBugAction() {
+ closeBug({
+ variables: {
+ input: {
+ prefix: bug.id,
+ },
+ },
+ refetchQueries: [
+ // TODO: update the cache instead of refetching
+ {
+ query: TimelineDocument,
+ variables: {
+ id: bug.id,
+ first: 100,
+ },
+ },
+ ],
+ awaitRefetchQueries: true,
+ });
+ }
+
+ if (loading) return <div>Loading...</div>;
+ if (error) return <div>Error</div>;
+
+ return (
+ <div>
+ <Button
+ variant="contained"
+ onClick={() => closeBugAction()}
+ disabled={bug.status === 'CLOSED' || disabled}
+ >
+ Close issue
+ </Button>
+ </div>
+ );
+}
+
+export default CloseBugButton;
diff --git a/webui/src/components/CommentInput/CommentInput.tsx b/webui/src/components/CommentInput/CommentInput.tsx
new file mode 100644
index 00000000..86cc7dbb
--- /dev/null
+++ b/webui/src/components/CommentInput/CommentInput.tsx
@@ -0,0 +1,107 @@
+import React, { useState, useEffect } from 'react';
+
+import Tab from '@material-ui/core/Tab';
+import Tabs from '@material-ui/core/Tabs';
+import TextField from '@material-ui/core/TextField';
+import { makeStyles } from '@material-ui/core/styles';
+
+import Content from 'src/components/Content';
+
+/**
+ * Styles
+ */
+const useStyles = makeStyles((theme) => ({
+ container: {
+ margin: theme.spacing(2, 0),
+ padding: theme.spacing(0, 2, 2, 2),
+ },
+ textarea: {},
+ tabContent: {
+ margin: theme.spacing(2, 0),
+ },
+ preview: {
+ borderBottom: `solid 3px ${theme.palette.grey['200']}`,
+ minHeight: '5rem',
+ },
+}));
+
+type TabPanelProps = {
+ children: React.ReactNode;
+ value: number;
+ index: number;
+} & React.HTMLProps<HTMLDivElement>;
+function TabPanel({ children, value, index, ...props }: TabPanelProps) {
+ return (
+ <div
+ role="tabpanel"
+ hidden={value !== index}
+ id={`editor-tabpanel-${index}`}
+ aria-labelledby={`editor-tab-${index}`}
+ {...props}
+ >
+ {value === index && children}
+ </div>
+ );
+}
+
+const a11yProps = (index: number) => ({
+ id: `editor-tab-${index}`,
+ 'aria-controls': `editor-tabpanel-${index}`,
+});
+
+type Props = {
+ inputProps?: any;
+ loading: boolean;
+ onChange: (comment: string) => void;
+};
+
+/**
+ * Component for issue comment input
+ *
+ * @param inputProps Reset input value
+ * @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>('');
+ const [tab, setTab] = useState(0);
+ const classes = useStyles();
+
+ useEffect(() => {
+ if (inputProps) setInput(inputProps.value);
+ }, [inputProps]);
+
+ useEffect(() => {
+ onChange(input);
+ }, [input, onChange]);
+
+ return (
+ <div>
+ <Tabs value={tab} onChange={(_, t) => setTab(t)}>
+ <Tab label="Write" {...a11yProps(0)} />
+ <Tab label="Preview" {...a11yProps(1)} />
+ </Tabs>
+ <div className={classes.tabContent}>
+ <TabPanel value={tab} index={0}>
+ <TextField
+ fullWidth
+ label="Comment"
+ placeholder="Leave a comment"
+ className={classes.textarea}
+ multiline
+ value={input}
+ variant="filled"
+ rows="4" // TODO: rowsMin support
+ onChange={(e: any) => setInput(e.target.value)}
+ disabled={loading}
+ />
+ </TabPanel>
+ <TabPanel value={tab} index={1} className={classes.preview}>
+ <Content markdown={input} />
+ </TabPanel>
+ </div>
+ </div>
+ );
+}
+
+export default CommentInput;
diff --git a/webui/src/layout/CurrentIdentity.graphql b/webui/src/components/CurrentIdentity/CurrentIdentity.graphql
index 2794a40f..2794a40f 100644
--- a/webui/src/layout/CurrentIdentity.graphql
+++ b/webui/src/components/CurrentIdentity/CurrentIdentity.graphql
diff --git a/webui/src/layout/CurrentIdentity.tsx b/webui/src/components/CurrentIdentity/CurrentIdentity.tsx
index 8cd3585b..8cd3585b 100644
--- a/webui/src/layout/CurrentIdentity.tsx
+++ b/webui/src/components/CurrentIdentity/CurrentIdentity.tsx
diff --git a/webui/src/layout/Header.tsx b/webui/src/components/Header/Header.tsx
index b0fae3cc..3e39b5f3 100644
--- a/webui/src/layout/Header.tsx
+++ b/webui/src/components/Header/Header.tsx
@@ -5,7 +5,7 @@ import AppBar from '@material-ui/core/AppBar';
import Toolbar from '@material-ui/core/Toolbar';
import { makeStyles } from '@material-ui/core/styles';
-import CurrentIdentity from './CurrentIdentity';
+import CurrentIdentity from '../CurrentIdentity/CurrentIdentity';
const useStyles = makeStyles((theme) => ({
offset: {
diff --git a/webui/src/layout/index.tsx b/webui/src/components/Header/index.tsx
index 42a0cfc1..42a0cfc1 100644
--- a/webui/src/layout/index.tsx
+++ b/webui/src/components/Header/index.tsx
diff --git a/webui/src/layout/IfLoggedIn.tsx b/webui/src/components/IfLoggedIn/IfLoggedIn.tsx
index 9f4a7576..2476aad8 100644
--- a/webui/src/layout/IfLoggedIn.tsx
+++ b/webui/src/components/IfLoggedIn/IfLoggedIn.tsx
@@ -1,6 +1,6 @@
import React from 'react';
-import { useCurrentIdentityQuery } from './CurrentIdentity.generated';
+import { useCurrentIdentityQuery } from '../CurrentIdentity/CurrentIdentity.generated';
type Props = { children: () => React.ReactNode };
const IfLoggedIn = ({ children }: Props) => {
diff --git a/webui/src/components/Label.tsx b/webui/src/components/Label.tsx
index 4aaa6bb6..111f6d7f 100644
--- a/webui/src/components/Label.tsx
+++ b/webui/src/components/Label.tsx
@@ -7,10 +7,9 @@ import {
darken,
} from '@material-ui/core/styles/colorManipulator';
+import { LabelFragment } from '../graphql/fragments.generated';
import { Color } from 'src/gqlTypes';
-import { LabelFragment } from './fragments.generated';
-
// Minimum contrast between the background and the text color
const contrastThreshold = 2.5;
diff --git a/webui/src/components/ReopenBugButton/OpenBug.graphql b/webui/src/components/ReopenBugButton/OpenBug.graphql
new file mode 100644
index 00000000..cf9e49e5
--- /dev/null
+++ b/webui/src/components/ReopenBugButton/OpenBug.graphql
@@ -0,0 +1,7 @@
+mutation openBug($input: OpenBugInput!) {
+ openBug(input: $input) {
+ bug {
+ id
+ }
+ }
+} \ No newline at end of file
diff --git a/webui/src/components/ReopenBugButton/ReopenBugButton.tsx b/webui/src/components/ReopenBugButton/ReopenBugButton.tsx
new file mode 100644
index 00000000..195ca512
--- /dev/null
+++ b/webui/src/components/ReopenBugButton/ReopenBugButton.tsx
@@ -0,0 +1,55 @@
+import React from 'react';
+
+import Button from '@material-ui/core/Button';
+
+import { BugFragment } from 'src/pages/bug/Bug.generated';
+import { TimelineDocument } from 'src/pages/bug/TimelineQuery.generated';
+
+import { useOpenBugMutation } from './OpenBug.generated';
+
+interface Props {
+ bug: BugFragment;
+ disabled: boolean;
+}
+
+function ReopenBugButton({ bug, disabled }: Props) {
+ const [openBug, { loading, error }] = useOpenBugMutation();
+
+ function openBugAction() {
+ openBug({
+ variables: {
+ input: {
+ prefix: bug.id,
+ },
+ },
+ refetchQueries: [
+ // TODO: update the cache instead of refetching
+ {
+ query: TimelineDocument,
+ variables: {
+ id: bug.id,
+ first: 100,
+ },
+ },
+ ],
+ awaitRefetchQueries: true,
+ });
+ }
+
+ if (loading) return <div>Loading...</div>;
+ if (error) return <div>Error</div>;
+
+ return (
+ <div>
+ <Button
+ variant="contained"
+ onClick={() => openBugAction()}
+ disabled={bug.status === 'OPEN' || disabled}
+ >
+ Reopen issue
+ </Button>
+ </div>
+ );
+}
+
+export default ReopenBugButton;
diff --git a/webui/src/components/fragments.graphql b/webui/src/graphql/fragments.graphql
index 03a235f9..03a235f9 100644
--- a/webui/src/components/fragments.graphql
+++ b/webui/src/graphql/fragments.graphql
diff --git a/webui/src/pages/bug/Bug.tsx b/webui/src/pages/bug/Bug.tsx
index 8d6d11cc..d85c5296 100644
--- a/webui/src/pages/bug/Bug.tsx
+++ b/webui/src/pages/bug/Bug.tsx
@@ -1,32 +1,28 @@
import React from 'react';
-import Typography from '@material-ui/core/Typography/Typography';
import { makeStyles } from '@material-ui/core/styles';
-import Author from 'src/components/Author';
-import Date from 'src/components/Date';
+import BugTitleForm from 'src/components/BugTitleForm/BugTitleForm';
+import IfLoggedIn from 'src/components/IfLoggedIn/IfLoggedIn';
import Label from 'src/components/Label';
-import IfLoggedIn from 'src/layout/IfLoggedIn';
import { BugFragment } from './Bug.generated';
import CommentForm from './CommentForm';
import TimelineQuery from './TimelineQuery';
+/**
+ * Css in JS Styles
+ */
const useStyles = makeStyles((theme) => ({
main: {
maxWidth: 1000,
margin: 'auto',
marginTop: theme.spacing(4),
+ overflow: 'hidden',
},
header: {
marginLeft: theme.spacing(3) + 40,
- },
- title: {
- ...theme.typography.h5,
- },
- id: {
- ...theme.typography.subtitle1,
- marginLeft: theme.spacing(1),
+ marginRight: theme.spacing(2),
},
container: {
display: 'flex',
@@ -73,17 +69,11 @@ type Props = {
function Bug({ bug }: Props) {
const classes = useStyles();
+
return (
<main className={classes.main}>
<div className={classes.header}>
- <span className={classes.title}>{bug.title}</span>
- <span className={classes.id}>{bug.humanId}</span>
-
- <Typography color={'textSecondary'}>
- <Author author={bug.author} />
- {' opened this bug '}
- <Date date={bug.createdAt} />
- </Typography>
+ <BugTitleForm bug={bug} />
</div>
<div className={classes.container}>
@@ -92,7 +82,7 @@ function Bug({ bug }: Props) {
<IfLoggedIn>
{() => (
<div className={classes.commentForm}>
- <CommentForm bugId={bug.id} />
+ <CommentForm bug={bug} />
</div>
)}
</IfLoggedIn>
diff --git a/webui/src/pages/bug/CommentForm.tsx b/webui/src/pages/bug/CommentForm.tsx
index f2a2eb6c..0b97e133 100644
--- a/webui/src/pages/bug/CommentForm.tsx
+++ b/webui/src/pages/bug/CommentForm.tsx
@@ -2,13 +2,13 @@ import React, { useState, useRef } from 'react';
import Button from '@material-ui/core/Button';
import Paper from '@material-ui/core/Paper';
-import Tab from '@material-ui/core/Tab';
-import Tabs from '@material-ui/core/Tabs';
-import TextField from '@material-ui/core/TextField';
import { makeStyles, Theme } from '@material-ui/core/styles';
-import Content from 'src/components/Content';
+import CommentInput from '../../components/CommentInput/CommentInput';
+import CloseBugButton from 'src/components/CloseBugButton/CloseBugButton';
+import ReopenBugButton from 'src/components/ReopenBugButton/ReopenBugButton';
+import { BugFragment } from './Bug.generated';
import { useAddCommentMutation } from './CommentForm.generated';
import { TimelineDocument } from './TimelineQuery.generated';
@@ -30,40 +30,24 @@ const useStyles = makeStyles<Theme, StyleProps>((theme) => ({
display: 'flex',
justifyContent: 'flex-end',
},
+ greenButton: {
+ marginLeft: '8px',
+ backgroundColor: '#2ea44fd9',
+ color: '#fff',
+ '&:hover': {
+ backgroundColor: '#2ea44f',
+ },
+ },
}));
-type TabPanelProps = {
- children: React.ReactNode;
- value: number;
- index: number;
-} & React.HTMLProps<HTMLDivElement>;
-function TabPanel({ children, value, index, ...props }: TabPanelProps) {
- return (
- <div
- role="tabpanel"
- hidden={value !== index}
- id={`editor-tabpanel-${index}`}
- aria-labelledby={`editor-tab-${index}`}
- {...props}
- >
- {value === index && children}
- </div>
- );
-}
-
-const a11yProps = (index: number) => ({
- id: `editor-tab-${index}`,
- 'aria-controls': `editor-tabpanel-${index}`,
-});
-
type Props = {
- bugId: string;
+ bug: BugFragment;
};
-function CommentForm({ bugId }: Props) {
+function CommentForm({ bug }: Props) {
const [addComment, { loading }] = useAddCommentMutation();
- const [input, setInput] = useState<string>('');
- const [tab, setTab] = useState(0);
+ const [issueComment, setIssueComment] = useState('');
+ const [inputProp, setInputProp] = useState<any>('');
const classes = useStyles({ loading });
const form = useRef<HTMLFormElement>(null);
@@ -71,8 +55,8 @@ function CommentForm({ bugId }: Props) {
addComment({
variables: {
input: {
- prefix: bugId,
- message: input,
+ prefix: bug.id,
+ message: issueComment,
},
},
refetchQueries: [
@@ -80,60 +64,50 @@ function CommentForm({ bugId }: Props) {
{
query: TimelineDocument,
variables: {
- id: bugId,
+ id: bug.id,
first: 100,
},
},
],
awaitRefetchQueries: true,
- }).then(() => setInput(''));
+ }).then(() => resetForm());
};
+ function resetForm() {
+ setInputProp({
+ value: '',
+ });
+ }
+
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
- submit();
+ if (issueComment.length > 0) submit();
};
- const handleKeyDown = (e: React.KeyboardEvent<HTMLElement>) => {
- // Submit on cmd/ctrl+enter
- if ((e.metaKey || e.altKey) && e.keyCode === 13) {
- submit();
- }
- };
+ function getCloseButton() {
+ return <CloseBugButton bug={bug} disabled={issueComment.length > 0} />;
+ }
+
+ function getReopenButton() {
+ return <ReopenBugButton bug={bug} disabled={issueComment.length > 0} />;
+ }
return (
<Paper className={classes.container}>
<form onSubmit={handleSubmit} ref={form}>
- <Tabs value={tab} onChange={(_, t) => setTab(t)}>
- <Tab label="Write" {...a11yProps(0)} />
- <Tab label="Preview" {...a11yProps(1)} />
- </Tabs>
- <div className={classes.tabContent}>
- <TabPanel value={tab} index={0}>
- <TextField
- onKeyDown={handleKeyDown}
- fullWidth
- label="Comment"
- placeholder="Leave a comment"
- className={classes.textarea}
- multiline
- value={input}
- variant="filled"
- rows="4" // TODO: rowsMin support
- onChange={(e: any) => setInput(e.target.value)}
- disabled={loading}
- />
- </TabPanel>
- <TabPanel value={tab} index={1} className={classes.preview}>
- <Content markdown={input} />
- </TabPanel>
- </div>
+ <CommentInput
+ inputProps={inputProp}
+ loading={loading}
+ onChange={(comment: string) => setIssueComment(comment)}
+ />
<div className={classes.actions}>
+ {bug.status === 'OPEN' ? getCloseButton() : getReopenButton()}
<Button
+ className={classes.greenButton}
variant="contained"
color="primary"
type="submit"
- disabled={loading}
+ disabled={loading || issueComment.length === 0}
>
Comment
</Button>
diff --git a/webui/src/pages/list/ListQuery.tsx b/webui/src/pages/list/ListQuery.tsx
index 7eb6f4c5..87c21e3c 100644
--- a/webui/src/pages/list/ListQuery.tsx
+++ b/webui/src/pages/list/ListQuery.tsx
@@ -2,6 +2,7 @@ import { ApolloError } from '@apollo/client';
import React, { useState, useEffect, useRef } from 'react';
import { useLocation, useHistory, Link } from 'react-router-dom';
+import { Button } from '@material-ui/core';
import IconButton from '@material-ui/core/IconButton';
import InputBase from '@material-ui/core/InputBase';
import Paper from '@material-ui/core/Paper';
@@ -11,6 +12,8 @@ import KeyboardArrowLeft from '@material-ui/icons/KeyboardArrowLeft';
import KeyboardArrowRight from '@material-ui/icons/KeyboardArrowRight';
import Skeleton from '@material-ui/lab/Skeleton';
+import IfLoggedIn from 'src/components/IfLoggedIn/IfLoggedIn';
+
import FilterToolbar from './FilterToolbar';
import List from './List';
import { useListBugsQuery } from './ListQuery.generated';
@@ -40,6 +43,17 @@ const useStyles = makeStyles<Theme, StylesProps>((theme) => ({
alignItems: 'center',
justifyContent: 'space-between',
},
+ filterissueLabel: {
+ fontSize: '14px',
+ fontWeight: 'bold',
+ paddingRight: '12px',
+ },
+ filterissueContainer: {
+ display: 'flex',
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ justifyContents: 'left',
+ },
search: {
borderRadius: theme.shape.borderRadius,
borderColor: fade(theme.palette.primary.main, 0.2),
@@ -95,6 +109,13 @@ const useStyles = makeStyles<Theme, StylesProps>((theme) => ({
padding: theme.spacing(2, 3),
},
},
+ greenButton: {
+ backgroundColor: '#2ea44fd9',
+ color: '#fff',
+ '&:hover': {
+ backgroundColor: '#2ea44f',
+ },
+ },
}));
function editParams(
@@ -271,21 +292,37 @@ function ListQuery() {
return (
<Paper className={classes.main}>
<header className={classes.header}>
- <h1>Issues</h1>
- <form onSubmit={formSubmit}>
- <InputBase
- placeholder="Filter"
- value={input}
- onInput={(e: any) => setInput(e.target.value)}
- classes={{
- root: classes.search,
- focused: classes.searchFocused,
- }}
- />
- <button type="submit" hidden>
- Search
- </button>
- </form>
+ <div className="filterissueContainer">
+ <form onSubmit={formSubmit}>
+ <label className={classes.filterissueLabel} htmlFor="issuefilter">
+ Filter
+ </label>
+ <InputBase
+ id="issuefilter"
+ placeholder="Filter"
+ value={input}
+ onInput={(e: any) => setInput(e.target.value)}
+ classes={{
+ root: classes.search,
+ focused: classes.searchFocused,
+ }}
+ />
+ <button type="submit" hidden>
+ Search
+ </button>
+ </form>
+ </div>
+ <IfLoggedIn>
+ {() => (
+ <Button
+ className={classes.greenButton}
+ variant="contained"
+ href="/new"
+ >
+ New issue
+ </Button>
+ )}
+ </IfLoggedIn>
</header>
<FilterToolbar query={query} queryLocation={queryLocation} />
{content}
diff --git a/webui/src/pages/new/NewBug.graphql b/webui/src/pages/new/NewBug.graphql
new file mode 100644
index 00000000..92df016e
--- /dev/null
+++ b/webui/src/pages/new/NewBug.graphql
@@ -0,0 +1,7 @@
+mutation newBug($input: NewBugInput!) {
+ newBug(input: $input) {
+ bug {
+ humanId
+ }
+ }
+} \ No newline at end of file
diff --git a/webui/src/pages/new/NewBugPage.tsx b/webui/src/pages/new/NewBugPage.tsx
new file mode 100644
index 00000000..c9e268b6
--- /dev/null
+++ b/webui/src/pages/new/NewBugPage.tsx
@@ -0,0 +1,118 @@
+import React, { FormEvent, useState } from 'react';
+
+import { Button } from '@material-ui/core';
+import Paper from '@material-ui/core/Paper';
+import TextField from '@material-ui/core/TextField/TextField';
+import { fade, makeStyles, Theme } from '@material-ui/core/styles';
+
+import CommentInput from '../../components/CommentInput/CommentInput';
+
+import { useNewBugMutation } from './NewBug.generated';
+
+/**
+ * Css in JS styles
+ */
+const useStyles = makeStyles((theme: Theme) => ({
+ main: {
+ maxWidth: 800,
+ margin: 'auto',
+ marginTop: theme.spacing(4),
+ marginBottom: theme.spacing(4),
+ padding: theme.spacing(2),
+ overflow: 'hidden',
+ },
+ titleInput: {
+ borderRadius: theme.shape.borderRadius,
+ borderColor: fade(theme.palette.primary.main, 0.2),
+ borderStyle: 'solid',
+ borderWidth: '1px',
+ backgroundColor: fade(theme.palette.primary.main, 0.05),
+ padding: theme.spacing(0, 0),
+ transition: theme.transitions.create([
+ 'width',
+ 'borderColor',
+ 'backgroundColor',
+ ]),
+ },
+ form: {
+ display: 'flex',
+ flexDirection: 'column',
+ },
+ actions: {
+ display: 'flex',
+ justifyContent: 'flex-end',
+ },
+ greenButton: {
+ backgroundColor: '#2ea44fd9',
+ color: '#fff',
+ '&:hover': {
+ backgroundColor: '#2ea44f',
+ },
+ },
+}));
+
+/**
+ * Form to create a new issue
+ */
+function NewBugPage() {
+ const [newBug, { loading, error }] = useNewBugMutation();
+ const [issueTitle, setIssueTitle] = useState('');
+ const [issueComment, setIssueComment] = useState('');
+ const classes = useStyles();
+ let issueTitleInput: any;
+
+ function submitNewIssue(e: FormEvent) {
+ e.preventDefault();
+ if (!isFormValid()) return;
+ newBug({
+ variables: {
+ input: {
+ title: issueTitle,
+ message: issueComment,
+ },
+ },
+ });
+ issueTitleInput.value = '';
+ }
+
+ function isFormValid() {
+ return issueTitle.length > 0 && issueComment.length > 0 ? true : false;
+ }
+
+ if (loading) return <div>Loading...</div>;
+ if (error) return <div>Error</div>;
+
+ return (
+ <Paper className={classes.main}>
+ <form className={classes.form} onSubmit={submitNewIssue}>
+ <TextField
+ inputRef={(node) => {
+ issueTitleInput = node;
+ }}
+ label="Title"
+ className={classes.titleInput}
+ variant="outlined"
+ fullWidth
+ margin="dense"
+ onChange={(event: any) => setIssueTitle(event.target.value)}
+ />
+ <CommentInput
+ loading={false}
+ onChange={(comment: string) => setIssueComment(comment)}
+ />
+ <div className={classes.actions}>
+ <Button
+ className={classes.greenButton}
+ variant="contained"
+ type="submit"
+ disabled={isFormValid() ? false : true}
+ >
+ Submit new issue
+ </Button>
+ </div>
+ </form>
+ </Paper>
+ );
+}
+
+export default NewBugPage;