Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: workspace search on UI #461

Merged
merged 8 commits into from
Jun 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions internal/http/html/static/css/workspace_list.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#filters-container {
display: flex;
flex-direction: row;
gap: 1em;
align-items: center;
}

input[type="search"] {
padding-left: 30px;
background-image: url("https://upload.wikimedia.org/wikipedia/commons/5/55/Magnifying_glass_icon.svg");
background-size: 13px;
background-repeat: no-repeat;
background-position: 10px center;
}
25 changes: 11 additions & 14 deletions internal/http/html/static/templates/content/organization_list.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,16 @@
{{ end }}

{{ define "content" }}
<div id="content-list" class="content-list">
{{ range .Items }}
<div class="item">
<div class="item-heading">
<a class="status" href="{{ organizationPath .Name }}">{{ .Name }}</a>
</div>
<div class="item-content">
{{ template "identifier" . }}
</div>
</div>
{{ else }}
No organizations found.
{{ end }}
{{ template "page-navigation-links" . }}
{{ template "content-list" . }}
{{ end }}

{{ define "content-list-item" }}
<div class="item">
<div class="item-heading">
<a class="status" href="{{ organizationPath .Name }}">{{ .Name }}</a>
</div>
<div class="item-content">
{{ template "identifier" . }}
</div>
</div>
{{ end }}
2 changes: 1 addition & 1 deletion internal/http/html/static/templates/content/run_list.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@

{{ define "content-header-links" }}{{ template "workspace-header-links" . }}{{ end }}

{{ define "content" }}{{ template "run_listing" . }}{{ end }}
{{ define "content" }}{{ template "run-listing" . }}{{ end }}
24 changes: 12 additions & 12 deletions internal/http/html/static/templates/content/workspace_list.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

{{ define "pre-content" }}
<link rel="stylesheet" href="{{ addHash "/static/css/workspace_tags.css" }}">
<link rel="stylesheet" href="{{ addHash "/static/css/workspace_list.css" }}">
{{ end }}

{{ define "content-header-title" }}workspaces{{ end }}
Expand All @@ -16,7 +17,11 @@
{{ end }}

{{ define "content" }}
<form id="tag-filter-form" action="{{ workspacesPath .Organization }}" method="GET">
<form method="GET">
<div id="filters-container">
<div class="field">
<input type="search" name="search[name]" value="{{ .Params.Search }}" placeholder="search workspaces" hx-get="" hx-trigger="keyup changed delay:500ms, search" hx-target="#workspace-listing-container">
</div>
<div class="workspace-tags-list">
{{ range $k, $v := .TagFilters }}
<div>
Expand All @@ -27,18 +32,13 @@
</div>
{{ end }}
</div>
</form>
{{ template "content-list" . }}
</div>
</form>
<div id="workspace-listing-container">
{{ template "content-list" . }}
</div>
{{ end }}

{{ define "content-list-item" }}
<div class="item" id="item-workspace-{{ .Name }}">
<div class="item-heading">
<a class="status" href="{{ workspacePath .ID }}">{{ .Name }}</a>
{{ with .LatestRun }}
{{ template "run-status" . }}
{{ end }}
</div>
{{ template "identifier" . }}
</div>
{{ template "workspace-item" . }}
{{ end }}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{{ template "content-list" . }}

{{ define "content-list-item" }}
{{ template "workspace-item" . }}
{{ end }}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{{ define "run_listing" }}
{{ define "run-listing" }}
{{/* watch for updates to listed runs as well as newly created runs */}}
<div hx-ext="sse" sse-connect="{{ watchWorkspacePath .Workspace.ID }}">
{{/* if a new run is created then reload entire run listing */}}
Expand Down
11 changes: 11 additions & 0 deletions internal/http/html/static/templates/partials/workspace_item.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{{ define "workspace-item" }}
<div class="item" id="item-workspace-{{ .Name }}">
<div class="item-heading">
<a class="status" href="{{ workspacePath .ID }}">{{ .Name }}</a>
{{ with .LatestRun }}
{{ template "run-status" . }}
{{ end }}
</div>
{{ template "identifier" . }}
</div>
{{ end }}
4 changes: 4 additions & 0 deletions internal/integration/test_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ func workspaceURL(hostname, org, name string) string {
return "https://" + hostname + "/app/organizations/" + org + "/workspaces/" + name
}

func workspacesURL(hostname, org string) string {
return "https://" + hostname + "/app/organizations/" + org + "/workspaces"
}

func organizationURL(hostname, org string) string {
return "https://" + hostname + "/app/organizations/" + org
}
Expand Down
8 changes: 4 additions & 4 deletions internal/integration/workspace_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,10 +191,10 @@ func TestWorkspace(t *testing.T) {
},
},
{
name: "filter by prefix",
name: "filter by name regex",
// test workspaces are named `workspace-<random 6 alphanumerals>`, so prefix with 14
// characters to be pretty damn sure only ws1 is selected.
opts: workspace.ListOptions{Organization: internal.String(org.Name), Prefix: ws1.Name[:14]},
opts: workspace.ListOptions{Organization: internal.String(org.Name), Search: ws1.Name[:14]},
want: func(t *testing.T, l *workspace.WorkspaceList) {
assert.Equal(t, 1, len(l.Items))
assert.Equal(t, ws1, l.Items[0])
Expand All @@ -216,8 +216,8 @@ func TestWorkspace(t *testing.T) {
},
},
{
name: "filter by non-existent prefix",
opts: workspace.ListOptions{Organization: internal.String(org.Name), Prefix: "xyz"},
name: "filter by non-existent name regex",
opts: workspace.ListOptions{Organization: internal.String(org.Name), Search: "xyz"},
want: func(t *testing.T, l *workspace.WorkspaceList) {
assert.Equal(t, 0, len(l.Items))
},
Expand Down
49 changes: 49 additions & 0 deletions internal/integration/workspace_ui_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package integration

import (
"context"
"strings"
"testing"

"github.com/chromedp/cdproto/cdp"
"github.com/chromedp/cdproto/input"
"github.com/chromedp/chromedp"
"github.com/chromedp/chromedp/kb"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestIntegration_WorkspaceUI demonstrates management of workspaces via the UI.
func TestIntegration_WorkspaceUI(t *testing.T) {
t.Parallel()

daemon := setup(t, nil)
user, ctx := daemon.createUserCtx(t, ctx)
org := daemon.createOrganization(t, ctx)

var workspaceItems []*cdp.Node
browser := createBrowserCtx(t)
err := chromedp.Run(browser, chromedp.Tasks{
newSession(t, ctx, daemon.Hostname(), user.Username, daemon.Secret),
createWorkspace(t, daemon.Hostname(), org.Name, "workspace-1"),
createWorkspace(t, daemon.Hostname(), org.Name, "workspace-12"),
createWorkspace(t, daemon.Hostname(), org.Name, "workspace-2"),
chromedp.Navigate(workspacesURL(daemon.Hostname(), org.Name)),
// search for 'workspace-1' which should produce two results
chromedp.Focus(`input[type="search"]`, chromedp.NodeVisible),
input.InsertText("workspace-1"),
chromedp.Submit(`input[type="search"]`),
chromedp.Nodes(`//*[@class="item"]`, &workspaceItems, chromedp.BySearch),
chromedp.ActionFunc(func(c context.Context) error {
assert.Equal(t, 2, len(workspaceItems))
return nil
}),
// and workspace-2 should not be visible
chromedp.WaitNotPresent(`//*[@id="item-workspace-workspace-2"]`, chromedp.BySearch),
// clear search term
chromedp.SendKeys(`input[type="search"]`, strings.Repeat(kb.Delete, len("workspace-1")), chromedp.BySearch),
// now workspace-2 should be visible.
chromedp.WaitVisible(`//*[@id="item-workspace-workspace-2"]`, chromedp.BySearch),
})
require.NoError(t, err)
}
2 changes: 1 addition & 1 deletion internal/pagination.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (

const (
DefaultPageNumber = 1
DefaultPageSize = 10
DefaultPageSize = MaxPageSize
MaxPageSize = 100
)

Expand Down
2 changes: 1 addition & 1 deletion internal/pagination_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ func TestPagination(t *testing.T) {
func TestListOptions(t *testing.T) {
opts := ListOptions{}
assert.Equal(t, 0, opts.GetOffset())
assert.Equal(t, 10, opts.GetLimit())
assert.Equal(t, 100, opts.GetLimit())

opts = ListOptions{PageNumber: 1, PageSize: 20}
assert.Equal(t, 0, opts.GetOffset())
Expand Down
16 changes: 8 additions & 8 deletions internal/sql/pggen/workspace.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions internal/sql/queries/workspace.sql
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ SELECT
FROM workspaces w
LEFT JOIN runs r ON w.latest_run_id = r.run_id
LEFT JOIN (workspace_tags wt JOIN tags t USING (tag_id)) ON wt.workspace_id = w.workspace_id
WHERE w.name LIKE pggen.arg('prefix') || '%'
WHERE w.name LIKE '%' || pggen.arg('search') || '%'
AND w.organization_name LIKE ANY(pggen.arg('organization_names'))
GROUP BY w.workspace_id, r.status
HAVING array_agg(t.name) @> pggen.arg('tags')
Expand All @@ -96,7 +96,7 @@ OFFSET pggen.arg('offset')
SELECT count(distinct(w.workspace_id))
FROM workspaces w
LEFT JOIN (workspace_tags wt JOIN tags t USING (tag_id)) ON w.workspace_id = wt.workspace_id
WHERE w.name LIKE pggen.arg('prefix') || '%'
WHERE w.name LIKE '%' || pggen.arg('search') || '%'
AND w.organization_name LIKE ANY(pggen.arg('organization_names'))
AND CASE WHEN cardinality(pggen.arg('tags')::text[]) > 0 THEN t.name LIKE ANY(pggen.arg('tags'))
ELSE 1 = 1
Expand Down
4 changes: 2 additions & 2 deletions internal/workspace/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,13 +206,13 @@ func (db *pgdb) list(ctx context.Context, opts ListOptions) (*WorkspaceList, err

db.FindWorkspacesBatch(batch, pggen.FindWorkspacesParams{
OrganizationNames: []string{organization},
Prefix: sql.String(opts.Prefix),
Search: sql.String(opts.Search),
Tags: tags,
Limit: opts.GetLimit(),
Offset: opts.GetOffset(),
})
db.CountWorkspacesBatch(batch, pggen.CountWorkspacesParams{
Prefix: sql.String(opts.Prefix),
Search: sql.String(opts.Search),
OrganizationNames: []string{organization},
Tags: tags,
})
Expand Down
15 changes: 13 additions & 2 deletions internal/workspace/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,17 +97,28 @@ func (h *webHandlers) listWorkspaces(w http.ResponseWriter, r *http.Request) {
return m
}

h.Render("workspace_list.tmpl", w, struct {
response := struct {
organization.OrganizationPage
CreateWorkspaceAction rbac.Action
*WorkspaceList
TagFilters map[string]bool
Params ListOptions
}{
OrganizationPage: organization.NewPage(r, "workspaces", *params.Organization),
CreateWorkspaceAction: rbac.CreateWorkspaceAction,
WorkspaceList: workspaces,
TagFilters: tagfilters(),
})
Params: params,
}

if isHTMX := r.Header.Get("HX-Request"); isHTMX == "true" {
if err := h.RenderTemplate("workspace_listing.tmpl", w, response); err != nil {
h.Error(w, err.Error(), http.StatusInternalServerError)
return
}
} else {
h.Render("workspace_list.tmpl", w, response)
}
}

func (h *webHandlers) newWorkspace(w http.ResponseWriter, r *http.Request) {
Expand Down
2 changes: 1 addition & 1 deletion internal/workspace/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ type (
// Workspaces
ListOptions struct {
internal.ListOptions // Pagination
Prefix string `schema:"search[name],omitempty"`
Search string `schema:"search[name],omitempty"`
Tags []string `schema:"search[tags],omitempty"`
Organization *string `schema:"organization_name,required"`
}
Expand Down