From ad40f757f67651c7f3272551db18d97152eee93e Mon Sep 17 00:00:00 2001 From: Jakub Sobon Date: Tue, 12 Mar 2019 00:30:44 -0400 Subject: [PATCH] Drafting validation for table. --- widgets/table/content.go | 71 +++++++++++++++++--- widgets/table/content_cell.go | 33 ++++++++-- widgets/table/content_data.go | 12 ++++ widgets/table/content_row.go | 61 ++++++++++++++---- widgets/table/content_test.go | 103 ++++++++++++++++++++++++++++++ widgets/table/content_validate.go | 33 ++++++++++ widgets/table/options.go | 2 +- 7 files changed, 286 insertions(+), 29 deletions(-) create mode 100644 widgets/table/content_validate.go diff --git a/widgets/table/content.go b/widgets/table/content.go index f3b8a20..cf4c5f5 100644 --- a/widgets/table/content.go +++ b/widgets/table/content.go @@ -18,7 +18,7 @@ package table // content. import ( - "errors" + "fmt" "github.com/mum4k/termdash/align" "github.com/mum4k/termdash/cell" @@ -34,16 +34,29 @@ type ContentOption interface { // contentOptions stores options that apply to the content level. type contentOptions struct { - border linestyle.LineStyle - borderCellOpts []cell.Option - columnWidthsPercent []int - + border linestyle.LineStyle + borderCellOpts []cell.Option + columnWidthsPercent []int horizontalCellSpacing int verticalCellSpacing int + // hierarchical are the specified hierarchical options at the content + // level. hierarchical *hierarchicalOptions } +// newContentOptions returns a new contentOptions instance with the options +// applied. +func newContentOptions(opts ...ContentOption) *contentOptions { + co := &contentOptions{ + hierarchical: &hierarchicalOptions{}, + } + for _, opt := range opts { + opt.set(co) + } + return co +} + // hierarchicalOptions stores options that can be applied at multiple levels or // hierarchy, i.e. the Content (top level), the Row or the Cell. type hierarchicalOptions struct { @@ -203,10 +216,14 @@ type Columns int // // This object is thread-safe. type Content struct { - colNum Columns + // cols is the number of columns in the content. + cols Columns + // header is the header row, or nil if one wasn't provided. header *Row - rows []*Row + // rows are the rows in the table. + rows []*Row + // opts are the options provided to NewContent. opts *contentOptions } @@ -216,16 +233,50 @@ type Content struct { // All rows must contain the same number of columns (the same number of cells) // allowing for the CellColSpan option. func NewContent(cols Columns, rows []*Row, opts ...ContentOption) (*Content, error) { - return nil, errors.New("unimplemented") + c := &Content{ + cols: cols, + opts: newContentOptions(opts...), + } + for _, r := range rows { + if err := c.addRow(r); err != nil { + return nil, err + } + } + + if err := c.validate(); err != nil { + return nil, err + } + return c, nil +} + +// validate validates the content. +func (c *Content) validate() error { + return validateContent(c) } // AddRow adds a row to the content. // If you need to apply options at the Row level, use AddRowWithOpts. func (c *Content) AddRow(cells ...*Cell) error { - return errors.New("unimplemented") + return c.AddRowWithOpts(cells) +} + +// addRow adds the row to the content. +func (c *Content) addRow(r *Row) error { + if r.isHeader { + if c.header != nil { + return fmt.Errorf("the content can only have one header row, already have: %v", c.header) + } + c.header = r + } else { + c.rows = append(c.rows, r) + } + return nil } // AddRowWithOpts adds a row to the content and applies the options. func (c *Content) AddRowWithOpts(cells []*Cell, opts ...RowOption) error { - return errors.New("unimplemented") + if err := c.addRow(NewRowWithOpts(cells, opts...)); err != nil { + return err + } + return c.validate() } diff --git a/widgets/table/content_cell.go b/widgets/table/content_cell.go index 17ec44e..6039cdd 100644 --- a/widgets/table/content_cell.go +++ b/widgets/table/content_cell.go @@ -17,6 +17,9 @@ package table // content_cell.go defines a type that represents a single cell in the table. import ( + "bytes" + "fmt" + "github.com/mum4k/termdash/align" "github.com/mum4k/termdash/cell" "github.com/mum4k/termdash/internal/wrap" @@ -133,13 +136,26 @@ func CellWrapContent() CellOption { // Cell is one cell in a Row. type Cell struct { + // data are the text data in the cell. data []*Data - colSpan int - rowSpan int + // colSpan specified how many columns does this cell span. + colSpan int + // rowSpan specified how many rows does this cell span. + rowSpan int + // hierarchical are the specified hierarchical options at the cell level. hierarchical *hierarchicalOptions } +// String implements fmt.Stringer. +func (c *Cell) String() string { + var b bytes.Buffer + for _, d := range c.data { + b.WriteString(d.String()) + } + return fmt.Sprintf("| %v ", b.String()) +} + // NewCell returns a new Cell with the provided text. // If you need to apply options at the Cell or Data level use NewCellWithOpts. // The text contain cannot control characters (unicode.IsControl) or space @@ -148,10 +164,19 @@ type Cell struct { // Any newline ('\n') characters are interpreted as newlines when displaying // the text. func NewCell(text string) *Cell { - return nil + return NewCellWithOpts([]*Data{NewData(text)}) } // NewCellWithOpts returns a new Cell with the provided data and options. func NewCellWithOpts(data []*Data, opts ...CellOption) *Cell { - return nil + c := &Cell{ + data: data, + colSpan: 1, + rowSpan: 1, + hierarchical: &hierarchicalOptions{}, + } + for _, opt := range opts { + opt.set(c) + } + return c } diff --git a/widgets/table/content_data.go b/widgets/table/content_data.go index 1a8e21a..8371300 100644 --- a/widgets/table/content_data.go +++ b/widgets/table/content_data.go @@ -17,6 +17,8 @@ package table // content_data.go defines a type that represents data within a table cell. import ( + "bytes" + "github.com/mum4k/termdash/cell" "github.com/mum4k/termdash/internal/canvas/buffer" ) @@ -49,9 +51,19 @@ func DataCellOpts(cellOpts ...cell.Option) DataOption { // Data is part of (or the full) the data that is displayed inside one Cell. type Data struct { + // cells contain the text and its cell options. cells []*buffer.Cell } +// String implements fmt.Stringer. +func (d *Data) String() string { + var b bytes.Buffer + for _, c := range d.cells { + b.WriteRune(c.Rune) + } + return b.String() +} + // NewData creates new Data with the provided text and applies the options. // The text contain cannot control characters (unicode.IsControl) or space // character (unicode.IsSpace) other than: diff --git a/widgets/table/content_row.go b/widgets/table/content_row.go index 3dd90e9..1f8b267 100644 --- a/widgets/table/content_row.go +++ b/widgets/table/content_row.go @@ -17,6 +17,9 @@ package table // content_row.go defines a type that represents a single row in the table. import ( + "bytes" + "fmt" + "github.com/mum4k/termdash/align" "github.com/mum4k/termdash/cell" "github.com/mum4k/termdash/terminal/terminalapi" @@ -56,14 +59,6 @@ func (ro rowOption) set(r *Row) { ro(r) } -// RowHighlighted sets the row as highlighted, the user can then change which -// row is highlighted using keyboard or mouse input. -func RowHighlighted() RowOption { - return rowOption(func(r *Row) { - r.highlighted = true - }) -} - // RowCallback allows this row to be activated and provides a function that // should be called upon each row activation by the user. func RowCallback(fn RowCallbackFn) RowOption { @@ -139,20 +134,39 @@ func RowAlignVertical(v align.Vertical) RowOption { // Row is one row in the table. type Row struct { + // cells are the cells in this row. cells []*Cell - rowCallback RowCallbackFn - highlighted bool + // isHeader asserts if this row is the header of the table. + isHeader bool + + // rowCallback is the function to call when this row is activated. + // Can be nil if the row cannot be activated and is always nil on the + // header row. + rowCallback RowCallbackFn + // hierarchical are the specified hierarchical options at the row level. hierarchical *hierarchicalOptions } +// String implements fmt.Stringer. +func (r *Row) String() string { + if len(r.cells) == 0 { + return "Row{}" + } + var b bytes.Buffer + for _, c := range r.cells { + b.WriteString(c.String()) + } + return fmt.Sprintf("Row{%v|}", b.String()) +} + // NewHeader returns a new Row that will be the header of the table. // The header remains visible while scrolling and allows for sorting of content // based on its values. Header row cannot be highlighted. // Content can only have one header Row. // If you need to apply options at the Row level, use NewHeaderWithOpts. func NewHeader(cells ...*Cell) *Row { - return nil + return NewHeaderWithOpts(cells) } // NewHeaderWithOpts returns a new Row that will be the header of the table and @@ -161,18 +175,37 @@ func NewHeader(cells ...*Cell) *Row { // based on its values. Header row cannot be highlighted. // Content can only have one header Row. func NewHeaderWithOpts(cells []*Cell, opts ...RowOption) *Row { - return nil + r := NewRowWithOpts(cells, opts...) + r.isHeader = true + return r } // NewRow returns a new Row instance with the provided cells. // If you need to apply options at the Row level, use NewRowWithOpts. // If you need to add a table header Row, use NewHeader. func NewRow(cells ...*Cell) *Row { - return nil + return NewRowWithOpts(cells) } // NewRowWithOpts returns a new Row instance with the provided cells and applies // the row options. func NewRowWithOpts(cells []*Cell, opts ...RowOption) *Row { - return nil + r := &Row{ + cells: cells, + hierarchical: &hierarchicalOptions{}, + } + for _, opt := range opts { + opt.set(r) + } + return r +} + +// effectiveColumns returns the number of columns this row effectively occupies. +// This accounts for cells that specify colSpan > 1. +func (r *Row) effectiveColumns() int { + var cols int + for _, c := range r.cells { + cols += c.colSpan + } + return cols } diff --git a/widgets/table/content_test.go b/widgets/table/content_test.go index f58cc40..0c764c2 100644 --- a/widgets/table/content_test.go +++ b/widgets/table/content_test.go @@ -14,6 +14,11 @@ package table +import ( + "strings" + "testing" +) + func ExampleContent() { rows := []*Row{ NewHeader( @@ -31,3 +36,101 @@ func ExampleContent() { panic(err) } } + +func TestContent(t *testing.T) { + tests := []struct { + desc string + columns Columns + rows []*Row + opts []ContentOption + wantSubstr string + }{ + { + desc: "fails when number of columns is negative", + columns: Columns(-1), + rows: []*Row{ + NewRow( + NewCell("0"), + ), + }, + wantSubstr: "invalid number of columns", + }, + { + desc: "fails when rows doesn't have the specified number of columns", + columns: Columns(2), + rows: []*Row{ + NewRow( + NewCell("0"), + ), + }, + wantSubstr: "all rows must occupy", + }, + { + desc: "succeeds when row columns match content", + columns: Columns(2), + rows: []*Row{ + NewRow( + NewCell("0"), + NewCell("1"), + ), + }, + }, + // fails on colspan <1 + // fails on rowspan <1 + { + desc: "succeeds when row has a column with a colSpan", + columns: Columns(2), + rows: []*Row{ + NewRow( + NewCellWithOpts( + []*Data{NewData("0")}, + CellColSpan(2), + ), + ), + }, + }, + + // Content: + // rows with zero columns + // rows with varying number of columns + // rows with columns that have valid colspan + // rows with columns that have valid rowspan + // rows with columns that have valid colspan and rowspan + // too many column widths + // not enough column widths + // zero column width + // negative column width + // zero row height + // negative row height + // zero and negative padding + // zero and negative spacing + + // Row: + // too many header rows + // nil row callback + // zero and negative row height + // zero and negative padding + + // cell: + // zero and negative colspan + // zero and negative rowspan + // zero and negative cell height + // zero and negative padding + + // data: + // invalid space characters in data + + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + _, err := NewContent(tc.columns, tc.rows, tc.opts...) + if (err != nil) != (tc.wantSubstr != "") { + t.Errorf("NewContent => unexpected error: %v, wantSubstr: %q", err, tc.wantSubstr) + } + if err != nil && !strings.Contains(err.Error(), tc.wantSubstr) { + t.Errorf("NewContent => unexpected error: %v, wantSubstr: %q", err, tc.wantSubstr) + } + }) + } +} diff --git a/widgets/table/content_validate.go b/widgets/table/content_validate.go new file mode 100644 index 0000000..d95fe8f --- /dev/null +++ b/widgets/table/content_validate.go @@ -0,0 +1,33 @@ +// Copyright 2019 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package table + +// contant_validate.go contains code that validates the user provided Content. + +import "fmt" + +// validateContent validates the content instance. +func validateContent(c *Content) error { + if min := 1; int(c.cols) < min { + return fmt.Errorf("invalid number of columns %d, must be a value in range %d <= v", c.cols, min) + } + + for _, r := range c.rows { + if got, want := r.effectiveColumns(), int(c.cols); got != want { + return fmt.Errorf("content has %d columns, but row %v has %d, all rows must occupy the same amount of columns", want, r, got) + } + } + return nil +} diff --git a/widgets/table/options.go b/widgets/table/options.go index d2269ff..ddcbce4 100644 --- a/widgets/table/options.go +++ b/widgets/table/options.go @@ -119,7 +119,7 @@ func HighlightDelay(d time.Duration) Option { // HighlightCellOpts sets the cell options on cells that are part of a // highlighted row. // Defaults to DefaultHighlightColor. -func HighlightColor(cellOpts ...cell.Option) Option { +func HighlightCellOpts(cellOpts ...cell.Option) Option { return option(func(opts *options) { opts.highlightCellOpts = cellOpts })