mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
feat: add generic table formatter (#3415)
This commit is contained in:
@ -1,9 +1,14 @@
|
|||||||
package cliui
|
package cliui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fatih/structtag"
|
||||||
"github.com/jedib0t/go-pretty/v6/table"
|
"github.com/jedib0t/go-pretty/v6/table"
|
||||||
|
"golang.org/x/xerrors"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Table creates a new table with standardized styles.
|
// Table creates a new table with standardized styles.
|
||||||
@ -41,3 +46,258 @@ func FilterTableColumns(header table.Row, columns []string) []table.ColumnConfig
|
|||||||
}
|
}
|
||||||
return columnConfigs
|
return columnConfigs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DisplayTable renders a table as a string. The input argument must be a slice
|
||||||
|
// of structs. At least one field in the struct must have a `table:""` tag
|
||||||
|
// containing the name of the column in the outputted table.
|
||||||
|
//
|
||||||
|
// Nested structs are processed if the field has the `table:"$NAME,recursive"`
|
||||||
|
// tag and their fields will be named as `$PARENT_NAME $NAME`. If the tag is
|
||||||
|
// malformed or a field is marked as recursive but does not contain a struct or
|
||||||
|
// a pointer to a struct, this function will return an error (even with an empty
|
||||||
|
// input slice).
|
||||||
|
//
|
||||||
|
// If sort is empty, the input order will be used. If filterColumns is empty or
|
||||||
|
// nil, all available columns are included.
|
||||||
|
func DisplayTable(out any, sort string, filterColumns []string) (string, error) {
|
||||||
|
v := reflect.Indirect(reflect.ValueOf(out))
|
||||||
|
|
||||||
|
if v.Kind() != reflect.Slice {
|
||||||
|
return "", xerrors.Errorf("DisplayTable called with a non-slice type")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the list of table column headers.
|
||||||
|
headersRaw, err := typeToTableHeaders(v.Type().Elem())
|
||||||
|
if err != nil {
|
||||||
|
return "", xerrors.Errorf("get table headers recursively for type %q: %w", v.Type().Elem().String(), err)
|
||||||
|
}
|
||||||
|
if len(headersRaw) == 0 {
|
||||||
|
return "", xerrors.New(`no table headers found on the input type, make sure there is at least one "table" struct tag`)
|
||||||
|
}
|
||||||
|
headers := make(table.Row, len(headersRaw))
|
||||||
|
for i, header := range headersRaw {
|
||||||
|
headers[i] = header
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the given sort column and filter columns are valid.
|
||||||
|
if sort != "" || len(filterColumns) != 0 {
|
||||||
|
headersMap := make(map[string]string, len(headersRaw))
|
||||||
|
for _, header := range headersRaw {
|
||||||
|
headersMap[strings.ToLower(header)] = header
|
||||||
|
}
|
||||||
|
|
||||||
|
if sort != "" {
|
||||||
|
sort = strings.ToLower(strings.ReplaceAll(sort, "_", " "))
|
||||||
|
h, ok := headersMap[sort]
|
||||||
|
if !ok {
|
||||||
|
return "", xerrors.Errorf("specified sort column %q not found in table headers, available columns are %q", sort, strings.Join(headersRaw, `", "`))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Autocorrect
|
||||||
|
sort = h
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, column := range filterColumns {
|
||||||
|
column := strings.ToLower(strings.ReplaceAll(column, "_", " "))
|
||||||
|
h, ok := headersMap[column]
|
||||||
|
if !ok {
|
||||||
|
return "", xerrors.Errorf("specified filter column %q not found in table headers, available columns are %q", sort, strings.Join(headersRaw, `", "`))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Autocorrect
|
||||||
|
filterColumns[i] = h
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the given sort column is valid.
|
||||||
|
if sort != "" {
|
||||||
|
sort = strings.ReplaceAll(sort, "_", " ")
|
||||||
|
found := false
|
||||||
|
for _, header := range headersRaw {
|
||||||
|
if strings.EqualFold(sort, header) {
|
||||||
|
found = true
|
||||||
|
sort = header
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return "", xerrors.Errorf("specified sort column %q not found in table headers, available columns are %q", sort, strings.Join(headersRaw, `", "`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup the table formatter.
|
||||||
|
tw := Table()
|
||||||
|
tw.AppendHeader(headers)
|
||||||
|
tw.SetColumnConfigs(FilterTableColumns(headers, filterColumns))
|
||||||
|
if sort != "" {
|
||||||
|
tw.SortBy([]table.SortBy{{
|
||||||
|
Name: sort,
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write each struct to the table.
|
||||||
|
for i := 0; i < v.Len(); i++ {
|
||||||
|
// Format the row as a slice.
|
||||||
|
rowMap, err := valueToTableMap(v.Index(i))
|
||||||
|
if err != nil {
|
||||||
|
return "", xerrors.Errorf("get table row map %v: %w", i, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rowSlice := make([]any, len(headers))
|
||||||
|
for i, h := range headersRaw {
|
||||||
|
v, ok := rowMap[h]
|
||||||
|
if !ok {
|
||||||
|
v = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special type formatting.
|
||||||
|
switch val := v.(type) {
|
||||||
|
case time.Time:
|
||||||
|
v = val.Format(time.Stamp)
|
||||||
|
case *time.Time:
|
||||||
|
if val != nil {
|
||||||
|
v = val.Format(time.Stamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rowSlice[i] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
tw.AppendRow(table.Row(rowSlice))
|
||||||
|
}
|
||||||
|
|
||||||
|
return tw.Render(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTableStructTag returns the name of the field according to the `table`
|
||||||
|
// struct tag. If the table tag does not exist or is "-", an empty string is
|
||||||
|
// returned. If the table tag is malformed, an error is returned.
|
||||||
|
//
|
||||||
|
// The returned name is transformed from "snake_case" to "normal text".
|
||||||
|
func parseTableStructTag(field reflect.StructField) (name string, recurse bool, err error) {
|
||||||
|
tags, err := structtag.Parse(string(field.Tag))
|
||||||
|
if err != nil {
|
||||||
|
return "", false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, err := tags.Get("table")
|
||||||
|
if err != nil || tag.Name == "-" {
|
||||||
|
// tags.Get only returns an error if the tag is not found.
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
recursive := false
|
||||||
|
for _, opt := range tag.Options {
|
||||||
|
if opt == "recursive" {
|
||||||
|
recursive = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false, xerrors.Errorf("unknown option %q in struct field tag", opt)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.ReplaceAll(tag.Name, "_", " "), recursive, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isStructOrStructPointer(t reflect.Type) bool {
|
||||||
|
return t.Kind() == reflect.Struct || (t.Kind() == reflect.Pointer && t.Elem().Kind() == reflect.Struct)
|
||||||
|
}
|
||||||
|
|
||||||
|
// typeToTableHeaders converts a type to a slice of column names. If the given
|
||||||
|
// type is invalid (not a struct or a pointer to a struct, has invalid table
|
||||||
|
// tags, etc.), an error is returned.
|
||||||
|
func typeToTableHeaders(t reflect.Type) ([]string, error) {
|
||||||
|
if !isStructOrStructPointer(t) {
|
||||||
|
return nil, xerrors.Errorf("typeToTableHeaders called with a non-struct or a non-pointer-to-a-struct type")
|
||||||
|
}
|
||||||
|
if t.Kind() == reflect.Pointer {
|
||||||
|
t = t.Elem()
|
||||||
|
}
|
||||||
|
|
||||||
|
headers := []string{}
|
||||||
|
for i := 0; i < t.NumField(); i++ {
|
||||||
|
field := t.Field(i)
|
||||||
|
name, recursive, err := parseTableStructTag(field)
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerrors.Errorf("parse struct tags for field %q in type %q: %w", field.Name, t.String(), err)
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldType := field.Type
|
||||||
|
if recursive {
|
||||||
|
if !isStructOrStructPointer(fieldType) {
|
||||||
|
return nil, xerrors.Errorf("field %q in type %q is marked as recursive but does not contain a struct or a pointer to a struct", field.Name, t.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
childNames, err := typeToTableHeaders(fieldType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerrors.Errorf("get child field header names for field %q in type %q: %w", field.Name, fieldType.String(), err)
|
||||||
|
}
|
||||||
|
for _, childName := range childNames {
|
||||||
|
headers = append(headers, fmt.Sprintf("%s %s", name, childName))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = append(headers, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// valueToTableMap converts a struct to a map of column name to value. If the
|
||||||
|
// given type is invalid (not a struct or a pointer to a struct, has invalid
|
||||||
|
// table tags, etc.), an error is returned.
|
||||||
|
func valueToTableMap(val reflect.Value) (map[string]any, error) {
|
||||||
|
if !isStructOrStructPointer(val.Type()) {
|
||||||
|
return nil, xerrors.Errorf("valueToTableMap called with a non-struct or a non-pointer-to-a-struct type")
|
||||||
|
}
|
||||||
|
if val.Kind() == reflect.Pointer {
|
||||||
|
if val.IsNil() {
|
||||||
|
// No data for this struct, so return an empty map. All values will
|
||||||
|
// be rendered as nil in the resulting table.
|
||||||
|
return map[string]any{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
val = val.Elem()
|
||||||
|
}
|
||||||
|
|
||||||
|
row := map[string]any{}
|
||||||
|
for i := 0; i < val.NumField(); i++ {
|
||||||
|
field := val.Type().Field(i)
|
||||||
|
fieldVal := val.Field(i)
|
||||||
|
name, recursive, err := parseTableStructTag(field)
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerrors.Errorf("parse struct tags for field %q in type %T: %w", field.Name, val, err)
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurse if it's a struct.
|
||||||
|
fieldType := field.Type
|
||||||
|
if recursive {
|
||||||
|
if !isStructOrStructPointer(fieldType) {
|
||||||
|
return nil, xerrors.Errorf("field %q in type %q is marked as recursive but does not contain a struct or a pointer to a struct", field.Name, fieldType.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// valueToTableMap does nothing on pointers so we don't need to
|
||||||
|
// filter here.
|
||||||
|
childMap, err := valueToTableMap(fieldVal)
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerrors.Errorf("get child field values for field %q in type %q: %w", field.Name, fieldType.String(), err)
|
||||||
|
}
|
||||||
|
for childName, childValue := range childMap {
|
||||||
|
row[fmt.Sprintf("%s %s", name, childName)] = childValue
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, we just use the field value.
|
||||||
|
row[name] = val.Field(i).Interface()
|
||||||
|
}
|
||||||
|
|
||||||
|
return row, nil
|
||||||
|
}
|
||||||
|
341
cli/cliui/table_test.go
Normal file
341
cli/cliui/table_test.go
Normal file
@ -0,0 +1,341 @@
|
|||||||
|
package cliui_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/coder/coder/cli/cliui"
|
||||||
|
)
|
||||||
|
|
||||||
|
type tableTest1 struct {
|
||||||
|
Name string `table:"name"`
|
||||||
|
NotIncluded string // no table tag
|
||||||
|
Age int `table:"age"`
|
||||||
|
Roles []string `table:"roles"`
|
||||||
|
Sub1 tableTest2 `table:"sub_1,recursive"`
|
||||||
|
Sub2 *tableTest2 `table:"sub_2,recursive"`
|
||||||
|
Sub3 tableTest3 `table:"sub 3,recursive"`
|
||||||
|
Sub4 tableTest2 `table:"sub 4"` // not recursive
|
||||||
|
|
||||||
|
// Types with special formatting.
|
||||||
|
Time time.Time `table:"time"`
|
||||||
|
TimePtr *time.Time `table:"time_ptr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tableTest2 struct {
|
||||||
|
Name string `table:"name"`
|
||||||
|
Age int `table:"age"`
|
||||||
|
NotIncluded string `table:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tableTest3 struct {
|
||||||
|
NotIncluded string // no table tag
|
||||||
|
Sub tableTest2 `table:"inner,recursive"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_DisplayTable(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
someTime := time.Date(2022, 8, 2, 15, 49, 10, 0, time.Local)
|
||||||
|
in := []tableTest1{
|
||||||
|
{
|
||||||
|
Name: "foo",
|
||||||
|
Age: 10,
|
||||||
|
Roles: []string{"a", "b", "c"},
|
||||||
|
Sub1: tableTest2{
|
||||||
|
Name: "foo1",
|
||||||
|
Age: 11,
|
||||||
|
},
|
||||||
|
Sub2: &tableTest2{
|
||||||
|
Name: "foo2",
|
||||||
|
Age: 12,
|
||||||
|
},
|
||||||
|
Sub3: tableTest3{
|
||||||
|
Sub: tableTest2{
|
||||||
|
Name: "foo3",
|
||||||
|
Age: 13,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Sub4: tableTest2{
|
||||||
|
Name: "foo4",
|
||||||
|
Age: 14,
|
||||||
|
},
|
||||||
|
Time: someTime,
|
||||||
|
TimePtr: &someTime,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "bar",
|
||||||
|
Age: 20,
|
||||||
|
Roles: []string{"a"},
|
||||||
|
Sub1: tableTest2{
|
||||||
|
Name: "bar1",
|
||||||
|
Age: 21,
|
||||||
|
},
|
||||||
|
Sub2: nil,
|
||||||
|
Sub3: tableTest3{
|
||||||
|
Sub: tableTest2{
|
||||||
|
Name: "bar3",
|
||||||
|
Age: 23,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Sub4: tableTest2{
|
||||||
|
Name: "bar4",
|
||||||
|
Age: 24,
|
||||||
|
},
|
||||||
|
Time: someTime,
|
||||||
|
TimePtr: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "baz",
|
||||||
|
Age: 30,
|
||||||
|
Roles: nil,
|
||||||
|
Sub1: tableTest2{
|
||||||
|
Name: "baz1",
|
||||||
|
Age: 31,
|
||||||
|
},
|
||||||
|
Sub2: nil,
|
||||||
|
Sub3: tableTest3{
|
||||||
|
Sub: tableTest2{
|
||||||
|
Name: "baz3",
|
||||||
|
Age: 33,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Sub4: tableTest2{
|
||||||
|
Name: "baz4",
|
||||||
|
Age: 34,
|
||||||
|
},
|
||||||
|
Time: someTime,
|
||||||
|
TimePtr: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// This test tests skipping fields without table tags, recursion, pointer
|
||||||
|
// dereferencing, and nil pointer skipping.
|
||||||
|
t.Run("OK", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
expected := `
|
||||||
|
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
|
||||||
|
foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } Aug 2 15:49:10 Aug 2 15:49:10
|
||||||
|
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } Aug 2 15:49:10 <nil>
|
||||||
|
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } Aug 2 15:49:10 <nil>
|
||||||
|
`
|
||||||
|
|
||||||
|
// Test with non-pointer values.
|
||||||
|
out, err := cliui.DisplayTable(in, "", nil)
|
||||||
|
log.Println("rendered table:\n" + out)
|
||||||
|
require.NoError(t, err)
|
||||||
|
compareTables(t, expected, out)
|
||||||
|
|
||||||
|
// Test with pointer values.
|
||||||
|
inPtr := make([]*tableTest1, len(in))
|
||||||
|
for i, v := range in {
|
||||||
|
v := v
|
||||||
|
inPtr[i] = &v
|
||||||
|
}
|
||||||
|
out, err = cliui.DisplayTable(inPtr, "", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
compareTables(t, expected, out)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Sort", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
expected := `
|
||||||
|
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
|
||||||
|
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } Aug 2 15:49:10 <nil>
|
||||||
|
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } Aug 2 15:49:10 <nil>
|
||||||
|
foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } Aug 2 15:49:10 Aug 2 15:49:10
|
||||||
|
`
|
||||||
|
|
||||||
|
out, err := cliui.DisplayTable(in, "name", nil)
|
||||||
|
log.Println("rendered table:\n" + out)
|
||||||
|
require.NoError(t, err)
|
||||||
|
compareTables(t, expected, out)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Filter", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
expected := `
|
||||||
|
NAME SUB 1 NAME SUB 3 INNER NAME TIME
|
||||||
|
foo foo1 foo3 Aug 2 15:49:10
|
||||||
|
bar bar1 bar3 Aug 2 15:49:10
|
||||||
|
baz baz1 baz3 Aug 2 15:49:10
|
||||||
|
`
|
||||||
|
|
||||||
|
out, err := cliui.DisplayTable(in, "", []string{"name", "sub_1_name", "sub_3 inner name", "time"})
|
||||||
|
log.Println("rendered table:\n" + out)
|
||||||
|
require.NoError(t, err)
|
||||||
|
compareTables(t, expected, out)
|
||||||
|
})
|
||||||
|
|
||||||
|
// This test ensures that safeties against invalid use of `table` tags
|
||||||
|
// causes errors (even without data).
|
||||||
|
t.Run("Errors", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("NotSlice", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var in string
|
||||||
|
_, err := cliui.DisplayTable(in, "", nil)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("BadSortColumn", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := cliui.DisplayTable(in, "bad_column_does_not_exist", nil)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("BadFilterColumns", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := cliui.DisplayTable(in, "", []string{"name", "bad_column_does_not_exist"})
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Interfaces", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("WithoutData", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var in []any
|
||||||
|
_, err := cliui.DisplayTable(in, "", nil)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("WithData", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
in := []any{tableTest1{}}
|
||||||
|
_, err := cliui.DisplayTable(in, "", nil)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("NotStruct", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("WithoutData", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var in []string
|
||||||
|
_, err := cliui.DisplayTable(in, "", nil)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("WithData", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
in := []string{"foo", "bar", "baz"}
|
||||||
|
_, err := cliui.DisplayTable(in, "", nil)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("NoTableTags", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type noTableTagsTest struct {
|
||||||
|
Field string `json:"field"`
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("WithoutData", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var in []noTableTagsTest
|
||||||
|
_, err := cliui.DisplayTable(in, "", nil)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("WithData", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
in := []noTableTagsTest{{Field: "hi"}}
|
||||||
|
_, err := cliui.DisplayTable(in, "", nil)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("InvalidTag/NoName", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type noNameTest struct {
|
||||||
|
Field string `table:""`
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("WithoutData", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var in []noNameTest
|
||||||
|
_, err := cliui.DisplayTable(in, "", nil)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("WithData", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
in := []noNameTest{{Field: "test"}}
|
||||||
|
_, err := cliui.DisplayTable(in, "", nil)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("InvalidTag/BadSyntax", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type invalidSyntaxTest struct {
|
||||||
|
Field string `table:"asda,asdjada"`
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("WithoutData", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var in []invalidSyntaxTest
|
||||||
|
_, err := cliui.DisplayTable(in, "", nil)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("WithData", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
in := []invalidSyntaxTest{{Field: "test"}}
|
||||||
|
_, err := cliui.DisplayTable(in, "", nil)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// compareTables normalizes the incoming table lines
|
||||||
|
func compareTables(t *testing.T, expected, out string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
expectedLines := strings.Split(strings.TrimSpace(expected), "\n")
|
||||||
|
gotLines := strings.Split(strings.TrimSpace(out), "\n")
|
||||||
|
assert.Equal(t, len(expectedLines), len(gotLines), "expected line count does not match generated line count")
|
||||||
|
|
||||||
|
// Map the expected and got lines to normalize them.
|
||||||
|
expectedNormalized := make([]string, len(expectedLines))
|
||||||
|
gotNormalized := make([]string, len(gotLines))
|
||||||
|
normalizeLine := func(s string) string {
|
||||||
|
return strings.Join(strings.Fields(strings.TrimSpace(s)), " ")
|
||||||
|
}
|
||||||
|
for i, s := range expectedLines {
|
||||||
|
expectedNormalized[i] = normalizeLine(s)
|
||||||
|
}
|
||||||
|
for i, s := range gotLines {
|
||||||
|
gotNormalized[i] = normalizeLine(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Equal(t, expectedNormalized, gotNormalized, "expected lines to match generated lines")
|
||||||
|
}
|
@ -38,7 +38,10 @@ func userList() *cobra.Command {
|
|||||||
out := ""
|
out := ""
|
||||||
switch outputFormat {
|
switch outputFormat {
|
||||||
case "table", "":
|
case "table", "":
|
||||||
out = displayUsers(columns, users...)
|
out, err = cliui.DisplayTable(users, "Username", columns)
|
||||||
|
if err != nil {
|
||||||
|
return xerrors.Errorf("render table: %w", err)
|
||||||
|
}
|
||||||
case "json":
|
case "json":
|
||||||
outBytes, err := json.Marshal(users)
|
outBytes, err := json.Marshal(users)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
27
cli/users.go
27
cli/users.go
@ -1,12 +1,8 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jedib0t/go-pretty/v6/table"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/coder/coder/cli/cliui"
|
|
||||||
"github.com/coder/coder/codersdk"
|
"github.com/coder/coder/codersdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -25,26 +21,3 @@ func users() *cobra.Command {
|
|||||||
)
|
)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
// displayUsers will return a table displaying all users passed in.
|
|
||||||
// filterColumns must be a subset of the user fields and will determine which
|
|
||||||
// columns to display
|
|
||||||
func displayUsers(filterColumns []string, users ...codersdk.User) string {
|
|
||||||
tableWriter := cliui.Table()
|
|
||||||
header := table.Row{"id", "username", "email", "created at", "status"}
|
|
||||||
tableWriter.AppendHeader(header)
|
|
||||||
tableWriter.SetColumnConfigs(cliui.FilterTableColumns(header, filterColumns))
|
|
||||||
tableWriter.SortBy([]table.SortBy{{
|
|
||||||
Name: "username",
|
|
||||||
}})
|
|
||||||
for _, user := range users {
|
|
||||||
tableWriter.AppendRow(table.Row{
|
|
||||||
user.ID.String(),
|
|
||||||
user.Username,
|
|
||||||
user.Email,
|
|
||||||
user.CreatedAt.Format(time.Stamp),
|
|
||||||
user.Status,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return tableWriter.Render()
|
|
||||||
}
|
|
||||||
|
@ -59,7 +59,11 @@ func createUserStatusCommand(sdkStatus codersdk.UserStatus) *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Display the user
|
// Display the user
|
||||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), displayUsers(columns, user))
|
table, err := cliui.DisplayTable([]codersdk.User{user}, "", columns)
|
||||||
|
if err != nil {
|
||||||
|
return xerrors.Errorf("render user table: %w", err)
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintln(cmd.OutOrStdout(), table)
|
||||||
|
|
||||||
// User status is already set to this
|
// User status is already set to this
|
||||||
if user.Status == sdkStatus {
|
if user.Status == sdkStatus {
|
||||||
|
@ -42,11 +42,11 @@ type UsersRequest struct {
|
|||||||
|
|
||||||
// User represents a user in Coder.
|
// User represents a user in Coder.
|
||||||
type User struct {
|
type User struct {
|
||||||
ID uuid.UUID `json:"id" validate:"required"`
|
ID uuid.UUID `json:"id" validate:"required" table:"id"`
|
||||||
Email string `json:"email" validate:"required"`
|
Username string `json:"username" validate:"required" table:"username"`
|
||||||
CreatedAt time.Time `json:"created_at" validate:"required"`
|
Email string `json:"email" validate:"required" table:"email"`
|
||||||
Username string `json:"username" validate:"required"`
|
CreatedAt time.Time `json:"created_at" validate:"required" table:"created at"`
|
||||||
Status UserStatus `json:"status"`
|
Status UserStatus `json:"status" table:"status"`
|
||||||
OrganizationIDs []uuid.UUID `json:"organization_ids"`
|
OrganizationIDs []uuid.UUID `json:"organization_ids"`
|
||||||
Roles []Role `json:"roles"`
|
Roles []Role `json:"roles"`
|
||||||
}
|
}
|
||||||
|
3
go.mod
3
go.mod
@ -64,6 +64,7 @@ require (
|
|||||||
github.com/elastic/go-sysinfo v1.8.1
|
github.com/elastic/go-sysinfo v1.8.1
|
||||||
github.com/fatih/color v1.13.0
|
github.com/fatih/color v1.13.0
|
||||||
github.com/fatih/structs v1.1.0
|
github.com/fatih/structs v1.1.0
|
||||||
|
github.com/fatih/structtag v1.2.0
|
||||||
github.com/fergusstrange/embedded-postgres v1.16.0
|
github.com/fergusstrange/embedded-postgres v1.16.0
|
||||||
github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa
|
github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa
|
||||||
github.com/gen2brain/beeep v0.0.0-20220402123239-6a3042f4b71a
|
github.com/gen2brain/beeep v0.0.0-20220402123239-6a3042f4b71a
|
||||||
@ -162,7 +163,7 @@ require (
|
|||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bep/godartsass v0.14.0 // indirect
|
github.com/bep/godartsass v0.14.0 // indirect
|
||||||
github.com/bep/golibsass v1.1.0 // indirect
|
github.com/bep/golibsass v1.1.0 // indirect
|
||||||
github.com/cenkalti/backoff/v4 v4.1.3 // indirect
|
github.com/cenkalti/backoff/v4 v4.1.3
|
||||||
github.com/cespare/xxhash/v2 v2.1.2 // indirect
|
github.com/cespare/xxhash/v2 v2.1.2 // indirect
|
||||||
github.com/charmbracelet/bubbles v0.10.3 // indirect
|
github.com/charmbracelet/bubbles v0.10.3 // indirect
|
||||||
github.com/charmbracelet/bubbletea v0.20.0 // indirect
|
github.com/charmbracelet/bubbletea v0.20.0 // indirect
|
||||||
|
1
go.sum
1
go.sum
@ -589,6 +589,7 @@ github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
|
|||||||
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
|
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
|
||||||
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
|
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
|
||||||
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||||
|
github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4=
|
||||||
github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94=
|
github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94=
|
||||||
github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/felixge/httpsnoop v1.0.2/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.2/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
|
@ -366,9 +366,9 @@ export interface UploadResponse {
|
|||||||
// From codersdk/users.go
|
// From codersdk/users.go
|
||||||
export interface User {
|
export interface User {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
|
readonly username: string
|
||||||
readonly email: string
|
readonly email: string
|
||||||
readonly created_at: string
|
readonly created_at: string
|
||||||
readonly username: string
|
|
||||||
readonly status: UserStatus
|
readonly status: UserStatus
|
||||||
readonly organization_ids: string[]
|
readonly organization_ids: string[]
|
||||||
readonly roles: Role[]
|
readonly roles: Role[]
|
||||||
|
Reference in New Issue
Block a user