Skip to content

Commit

Permalink
cmd/relui: enable creation of mock workflow
Browse files Browse the repository at this point in the history
This change introduces mock data, multiple templates, and an in-memory
store to the release automation webserver. The goal of this change is to
introduce a basic UI structure. The underlying data infrastructure is
only for mock purposes, and will be replaced in a future CL.

This change enables creation and viewing of an in-memory workflow, with
very minor data stored.

List screenshot:
https://storage.googleapis.com/screen.toothrot.net/pub/2020-07-17-workflow-list.png

New workflow screenshot:
https://storage.googleapis.com/screen.toothrot.net/pub/2020-07-01-11_23_17-workflows-new.png

For golang/go#40279

Change-Id: Id9dfcc01cb2aba1df3e36d7a6301bbf8b47476da
Reviewed-on: https://go-review.googlesource.com/c/build/+/243339
Run-TryBot: Alexander Rakoczy <[email protected]>
TryBot-Result: Gobot Gobot <[email protected]>
Reviewed-by: Andrew Bonventre <[email protected]>
  • Loading branch information
toothrot committed Jul 29, 2020
1 parent add5b11 commit 0afb23e
Show file tree
Hide file tree
Showing 8 changed files with 352 additions and 33 deletions.
5 changes: 4 additions & 1 deletion cmd/relui/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import (
)

func main() {
http.Handle("/", fileServerHandler(relativeFile("./static"), http.HandlerFunc(homeHandler)))
s := &server{store: &memoryStore{}}
http.Handle("/workflows/create", http.HandlerFunc(s.createWorkflowHandler))
http.Handle("/workflows/new", http.HandlerFunc(s.newWorkflowHandler))
http.Handle("/", fileServerHandler(relativeFile("./static"), http.HandlerFunc(s.homeHandler)))
port := os.Getenv("PORT")
if port == "" {
port = "8080"
Expand Down
65 changes: 65 additions & 0 deletions cmd/relui/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,68 @@ h6 {
font-size: 1.5rem;
margin: 0;
}
@media only screen and (min-width: 75rem) {
.Workflows,
.NewWorkflow {
width: 74.75rem;
}
}
@media only screen and (min-width: 48rem) {
.Workflows,
.NewWorkflow {
margin: 0 auto;
}
}
.Workflows-header {
align-items: center;
display: flex;
justify-content: space-between;
}
.WorkflowList,
.TaskList {
list-style: none;
margin: 0;
padding: 0;
}
.WorkflowList-sectionTitle {
margin-bottom: 0.5rem;
font-weight: normal;
}
.TaskList {
border: 1px solid #d6d6d6;
border-radius: 0.25rem;
}
.TaskList-item {
display: flex;
align-items: center;
padding: 0.5rem;
justify-content: space-between;
}
.TaskList-item + .TaskList-item {
border-top: 0.0625rem solid #d6d6d6;
}
.Button {
background: #375eab;
border-radius: 0.1875rem;
box-shadow: 0 0.1875rem 0.0625rem -0.125rem rgba(0, 0, 0, 0.2),
0 0.125rem 0.125rem 0 rgba(0, 0, 0, 0.14),
0 0.0625rem 0.3125rem 0 rgba(0, 0, 0, 0.12);
color: #fff;
font-size: 0.875rem;
min-width: 4rem;
padding: 0.5rem 1rem;
text-decoration: none;
}
.Button:hover,
.Button:focus {
background: #3b65b3;
box-shadow: 0 0.125rem 0.25rem -0.0625rem rgba(0, 0, 0, 0.2),
0 0.25rem 0.3125rem 0 rgba(0, 0, 0, 0.14),
0 0.0625rem 0.625rem 0 rgba(0, 0, 0, 0.12);
}
.Button:active {
background: #4373cc;
box-shadow: 0 0.3125rem 0.3125rem -0.1875rem rgba(0, 0, 0, 0.2),
0 0.5rem 0.625rem 0.0625rem rgba(0, 0, 0, 0.14),
0 0.1875rem 0.875rem 0.125rem rgba(0, 0, 0, 0.12);
}
33 changes: 33 additions & 0 deletions cmd/relui/templates/home.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<!--
Copyright 2020 The Go Authors. All rights reserved.
Use of this source code is governed by a BSD-style
license that can be found in the LICENSE file.
-->
{{define "content"}}
<section class="Workflows">
<div class="Workflows-header">
<h2>Workflows</h2>
<a href="/workflows/new" class="Button">New</a>
</div>
<ul class="WorkflowList">
{{range $workflow := .Workflows}}
<li class="WorkflowList-item">
<h3>{{$workflow.Title}}</h3>
<h4 class="WorkflowList-sectionTitle">Tasks</h4>
<ul class="TaskList">
{{range $task := $workflow.Tasks}}
<li class="TaskList-item">
<span class="TaskList-itemTitle">{{$task.Title}}</span>
Status: {{$task.Status}}
</li>
{{end}}
<li class="TaskList-item">
<span class="TaskList-itemTitle">Sample Task</span>
Status: created
</li>
</ul>
</li>
{{end}}
</ul>
</section>
{{end}}
16 changes: 1 addition & 15 deletions cmd/relui/index.html → cmd/relui/templates/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,7 @@ <h1 class="Header-title">Go Releases</h1>
</div>
</header>
<main class="Site-content">
<section class="Workflows">
<h2>Workflows</h2>
<ul class="WorkflowList">
<li class="WorkflowList-item">
<h3>Local Release - 20a838ab</h3>
<form> </form>
<ul class="TaskList">
<li class="TaskList-item">
<h4>Fetch blob - 20a838ab</h4>
Status: created
</li>
</ul>
</li>
</ul>
</section>
{{template "content" .}}
</main>
</body>
</html>
17 changes: 17 additions & 0 deletions cmd/relui/templates/new_workflow.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<!--
Copyright 2020 The Go Authors. All rights reserved.
Use of this source code is governed by a BSD-style
license that can be found in the LICENSE file.
-->
{{define "content"}}
<section class="NewWorkflow">
<h2>New Go Release</h2>
<form action="/workflows/create" method="post">
<label>
Revision
<input name="workflow.revision" value="master" />
</label>
<input name="workflow.create" type="submit" value="Create" />
</form>
</section>
{{end}}
62 changes: 58 additions & 4 deletions cmd/relui/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
package main

import (
"bytes"
"html/template"
"io"
"log"
"mime"
"net/http"
Expand All @@ -14,12 +16,17 @@ import (
"path/filepath"
)

// fileServerHandler returns a http.Handler rooted at root. It will call the next handler provided for requests to "/".
//
// The returned handler sets the appropriate Content-Type and Cache-Control headers for the returned file.
func fileServerHandler(root string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
next.ServeHTTP(w, r)
return
}
// http.FileServer would correctly return a 404, but we need to check that the file exists
// before calculating the Content-Type header.
if _, err := os.Stat(path.Join(root, r.URL.Path)); os.IsNotExist(err) {
http.NotFound(w, r)
return
Expand All @@ -32,13 +39,60 @@ func fileServerHandler(root string, next http.Handler) http.Handler {
})
}

var homeTemplate = template.Must(template.ParseFiles(relativeFile("index.html")))
var (
homeTmpl = template.Must(template.Must(layoutTmpl.Clone()).ParseFiles(relativeFile("templates/home.html")))
layoutTmpl = template.Must(template.ParseFiles(relativeFile("templates/layout.html")))
newWorkflowTmpl = template.Must(template.Must(layoutTmpl.Clone()).ParseFiles(relativeFile("templates/new_workflow.html")))
)

// server implements the http handlers for relui.
type server struct {
store store
}

type homeResponse struct {
Workflows []workflow
}

// homeHandler renders the homepage.
func (s *server) homeHandler(w http.ResponseWriter, _ *http.Request) {
out := bytes.Buffer{}
if err := homeTmpl.Execute(&out, homeResponse{Workflows: s.store.GetWorkflows()}); err != nil {
log.Printf("homeHandler: %v", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
io.Copy(w, &out)
}

// newWorkflowHandler presents a form for creating a new workflow.
func (s *server) newWorkflowHandler(w http.ResponseWriter, _ *http.Request) {
out := bytes.Buffer{}
if err := newWorkflowTmpl.Execute(&out, nil); err != nil {
log.Printf("newWorkflowHandler: %v", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
io.Copy(w, &out)
}

func homeHandler(w http.ResponseWriter, _ *http.Request) {
if err := homeTemplate.Execute(w, nil); err != nil {
log.Printf("homeHandlerFunc: %v", err)
// createWorkflowHandler persists a new workflow in the datastore.
func (s *server) createWorkflowHandler(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
ref := r.Form.Get("workflow.revision")
if ref == "" {
// TODO(golang.org/issue/40279) - render a better error in the form.
http.Error(w, "workflow revision is required", http.StatusBadRequest)
return
}
if err := s.store.AddWorkflow(newLocalGoRelease(ref)); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}

// relativeFile returns the path to the provided file or directory,
Expand Down
99 changes: 86 additions & 13 deletions cmd/relui/web_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,12 @@ import (
"io/ioutil"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)

func TestHomeHandler(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()

homeHandler(w, req)
resp := w.Result()

if resp.StatusCode != http.StatusOK {
t.Errorf("rep.StatusCode = %d, wanted %d", resp.StatusCode, http.StatusOK)
}
}
"github.com/google/go-cmp/cmp"
)

func TestFileServerHandler(t *testing.T) {
h := fileServerHandler("./testing", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -60,7 +52,7 @@ func TestFileServerHandler(t *testing.T) {
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
req := httptest.NewRequest("GET", c.path, nil)
req := httptest.NewRequest(http.MethodGet, c.path, nil)
w := httptest.NewRecorder()

h.ServeHTTP(w, req)
Expand All @@ -85,3 +77,84 @@ func TestFileServerHandler(t *testing.T) {
})
}
}

func TestServerHomeHandler(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()

s := &server{store: &memoryStore{}}
s.homeHandler(w, req)
resp := w.Result()

if resp.StatusCode != http.StatusOK {
t.Errorf("resp.StatusCode = %d, wanted %d", resp.StatusCode, http.StatusOK)
}
}

func TestServerNewWorkflowHandler(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/workflows/new", nil)
w := httptest.NewRecorder()

s := &server{store: &memoryStore{}}
s.newWorkflowHandler(w, req)
resp := w.Result()

if resp.StatusCode != http.StatusOK {
t.Errorf("rep.StatusCode = %d, wanted %d", resp.StatusCode, http.StatusOK)
}
}

func TestServerCreateWorkflowHandler(t *testing.T) {
cases := []struct {
desc string
params url.Values
wantCode int
wantHeaders map[string]string
wantParams map[string]string
}{
{
desc: "bad request",
wantCode: http.StatusBadRequest,
},
{
desc: "successful creation",
params: url.Values{"workflow.revision": []string{"abc"}},
wantCode: http.StatusSeeOther,
wantHeaders: map[string]string{
"Location": "/",
},
wantParams: map[string]string{"GitObject": "abc"},
},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/workflows/create", strings.NewReader(c.params.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()

s := &server{store: &memoryStore{}}
s.createWorkflowHandler(w, req)
resp := w.Result()

if resp.StatusCode != c.wantCode {
t.Errorf("rep.StatusCode = %d, wanted %d", resp.StatusCode, c.wantCode)
}
for k, v := range c.wantHeaders {
if resp.Header.Get(k) != v {
t.Errorf("resp.Header.Get(%q) = %q, wanted %q", k, resp.Header.Get(k), v)
}
}
if len(s.store.GetWorkflows()) != 1 && c.wantParams != nil {
t.Fatalf("len(s.store.GetWorkflows()) = %d, wanted %d", len(s.store.GetWorkflows()), 1)
} else if len(s.store.GetWorkflows()) != 0 && c.wantParams == nil {
t.Fatalf("len(s.store.GetWorkflows()) = %d, wanted %d", len(s.store.GetWorkflows()), 0)
}
if c.wantParams == nil {
return
}
if diff := cmp.Diff(c.wantParams, s.store.GetWorkflows()[0].Params()); diff != "" {
t.Errorf("s.Store.GetWorkflows()[0].Params() mismatch (-want, +got):\n%s", diff)
}
})
}
}
Loading

0 comments on commit 0afb23e

Please sign in to comment.