Skip to content
This repository has been archived by the owner on Jan 19, 2023. It is now read-only.

Add timeline component #2130

Merged
merged 3 commits into from Mar 11, 2021
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions changelogs/unreleased/2130-GuessWhoSamFoo
@@ -0,0 +1 @@
Added timeline component
2 changes: 2 additions & 0 deletions pkg/view/component/base.go
Expand Up @@ -76,6 +76,8 @@ const (
TypeTerminal = "terminal"
// TypeText is a text component.
TypeText = "text"
// TypeTimeline is a timeline component.
TypeTimeline = "timeline"
// TypeTimestamp is a timestamp component.
TypeTimestamp = "timestamp"
// TypeYAML is a YAML component.
Expand Down
11 changes: 11 additions & 0 deletions pkg/view/component/testdata/config_timeline.json
@@ -0,0 +1,11 @@
{
"steps": [
{
"state": "current",
"header": "Header",
"title": "Title",
"description": "Description"
}
],
"vertical": true
}
40 changes: 40 additions & 0 deletions pkg/view/component/testdata/timeline.json
@@ -0,0 +1,40 @@
{
"metadata": {
"type": "timeline"
},
"config": {
"steps": [
{
"state": "success",
"header": "success header",
"title": "Step 1",
"description": "this is a success"
},
{
"state": "error",
"header": "error header",
"title": "Step 2",
"description": "this is an error"
},
{
"state": "current",
"header": "current header",
"title": "Step 3",
"description": "this is a current step"
},
{
"state": "processing",
"header": "processing header",
"title": "Step 4",
"description": "this is processing"
},
{
"state": "not-started",
"header": "not started header",
"title": "Step 5",
"description": "this has not started"
}
],
"vertical": false
}
}
109 changes: 109 additions & 0 deletions pkg/view/component/timeline.go
@@ -0,0 +1,109 @@
/*
Copyright (c) 2021 the Octant contributors. All Rights Reserved.
SPDX-License-Identifier: Apache-2.0
*/

package component

import (
"sync"

"github.com/pkg/errors"
)

// Timeline is a component for timeline
// +octant:component
type Timeline struct {
Base
Config TimelineConfig `json:"config"`

mu sync.Mutex
}

// TimelineConfig is the contents of Timeline
type TimelineConfig struct {
Steps []TimelineStep `json:"steps"`
Vertical bool `json:"vertical"`
}

// TimelineStep is the data for each timeline step
type TimelineStep struct {
State TimelineState `json:"state"`
Header string `json:"header"`
Title string `json:"title"`
Description string `json:"description"`
ButtonGroup *ButtonGroup `json:"buttonGroup,omitempty"`
}

func (t *TimelineStep) UnmarshalJSON(data []byte) error {
x := struct {
State TimelineState `json:"state"`
Header string `json:"header"`
Title string `json:"title"`
Description string `json:"description"`
ButtonGroup *TypedObject `json:"buttonGroup,omitempty"`
}{}
if err := json.Unmarshal(data, &x); err != nil {
return err
}
if x.ButtonGroup != nil {
component, err := x.ButtonGroup.ToComponent()
if err != nil {
return err
}
buttonGroup, ok := component.(*ButtonGroup)
if !ok {
return errors.New("item was not a buttonGroup")
}
t.ButtonGroup = buttonGroup
}
t.State = x.State
t.Title = x.Title
t.Header = x.Header
t.Description = x.Description

return nil
}

// TimelineState is the state of a timeline step
type TimelineState string

const (
TimelineStepNotStarted TimelineState = "not-started"
TimelineStepCurrent TimelineState = "current"
TimelineStepProcessing TimelineState = "processing"
TimelineStepSuccess TimelineState = "success"
TimelineStepError TimelineState = "error"
)

// NewTimeline creates a timeline component
func NewTimeline(steps []TimelineStep, vertical bool) *Timeline {
return &Timeline{
Base: newBase(TypeTimeline, nil),
Config: TimelineConfig{
Steps: steps,
Vertical: vertical,
},
}
}

// Add adds an additional step to the timeline
func (t *Timeline) Add(steps ...TimelineStep) {
t.mu.Lock()
defer t.mu.Unlock()
t.Config.Steps = append(t.Config.Steps, steps...)
}

type timelineMarshal Timeline

func (t *Timeline) MarshalJSON() ([]byte, error) {
t.mu.Lock()
defer t.mu.Unlock()

m := timelineMarshal{
Base: t.Base,
Config: t.Config,
}
m.Metadata.Type = TypeTimeline
return json.Marshal(&m)
}
95 changes: 95 additions & 0 deletions pkg/view/component/timeline_test.go
@@ -0,0 +1,95 @@
/*
Copyright (c) 2021 the Octant contributors. All Rights Reserved.
SPDX-License-Identifier: Apache-2.0
*/

package component

import (
"io/ioutil"
"path"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func Test_Timeline_Marshal(t *testing.T) {
tests := []struct {
name string
input Component
expectedPath string
isErr bool
}{
{
name: "in general",
input: &Timeline{
Base: newBase(TypeTimeline, nil),
Config: TimelineConfig{
Steps: []TimelineStep{
{
State: TimelineStepSuccess,
Title: "Step 1",
Header: "success header",
Description: "this is a success",
},
{
State: TimelineStepError,
Title: "Step 2",
Header: "error header",
Description: "this is an error",
},
{
State: TimelineStepCurrent,
Title: "Step 3",
Header: "current header",
Description: "this is a current step",
},
{
State: TimelineStepProcessing,
Title: "Step 4",
Header: "processing header",
Description: "this is processing",
},
{
State: TimelineStepNotStarted,
Title: "Step 5",
Header: "not started header",
Description: "this has not started",
},
},
Vertical: false,
},
},
expectedPath: "timeline.json",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
actual, err := json.Marshal(tc.input)
isErr := err != nil
if isErr != tc.isErr {
t.Fatalf("Unexpected error: %v", err)
}
expected, err := ioutil.ReadFile(path.Join("testdata", tc.expectedPath))
require.NoError(t, err)
assert.JSONEq(t, string(expected), string(actual))
})
}
}

func Test_Timeline_Add(t *testing.T) {
step := TimelineStep{
State: TimelineStepCurrent,
Title: "Title",
Header: "Header",
Description: "Description",
}
timeline := NewTimeline([]TimelineStep{}, true)
timeline.Add(step)

expected := []TimelineStep{
step,
}
assert.Equal(t, expected, timeline.Config.Steps)
}
5 changes: 5 additions & 0 deletions pkg/view/component/unmarshal.go
Expand Up @@ -164,6 +164,11 @@ func unmarshal(to TypedObject) (Component, error) {
err = errors.Wrapf(json.Unmarshal(to.Config, &t.Config),
"unmarshal text config")
o = t
case TypeTimeline:
t := &Timeline{Base: Base{Metadata: to.Metadata}}
err = errors.Wrapf(json.Unmarshal(to.Config, &t.Config),
"unmarshal timeline config")
o = t
case TypeTimestamp:
t := &Timestamp{Base: Base{Metadata: to.Metadata}}
err = errors.Wrapf(json.Unmarshal(to.Config, &t.Config),
Expand Down
19 changes: 19 additions & 0 deletions pkg/view/component/unmarshal_test.go
Expand Up @@ -597,6 +597,25 @@ func Test_unmarshal(t *testing.T) {
Base: newBase(TypeTimestamp, nil),
},
},
{
name: "timeline",
configFile: "config_timeline.json",
objectType: "timeline",
expected: &Timeline{
Config: TimelineConfig{
Steps: []TimelineStep{
{
State: TimelineStepCurrent,
Header: "Header",
Title: "Title",
Description: "Description",
},
},
Vertical: true,
},
Base: newBase(TypeTimeline, nil),
},
},
}

for _, tc := range cases {
Expand Down
@@ -0,0 +1,15 @@
<clr-timeline [clrLayout]="vertical ? 'vertical':'horizontal'">
<ng-container *ngFor="let step of steps; trackBy: trackByFn">
<clr-timeline-step [clrState]="step.state">
<clr-timeline-step-header>{{ step.header }}</clr-timeline-step-header>
<clr-timeline-step-title>{{ step.title }}</clr-timeline-step-title>
<clr-timeline-step-description>
{{ step.description }}
<ng-container *ngIf="step.buttonGroup">
<br>
<app-button-group [view]="step.buttonGroup"></app-button-group>
</ng-container>
</clr-timeline-step-description>
</clr-timeline-step>
</ng-container>
</clr-timeline>
@@ -0,0 +1,3 @@
::ng-deep .btn-group .btn {
margin-top: 0.3rem;
}
@@ -0,0 +1,57 @@
// Copyright (c) 2021 the Octant contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
//
import { Component } from '@angular/core';
import { TimelineView } from '../../../models/content';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { TimelineComponent } from './timeline.component';
@Component({
template: '<app-view-timeline [view]="view"></app-view-timeline>',
})
class TestWrapperComponent {
view: TimelineView;
}

describe('TimelineComponent', () => {
describe('handle changes', () => {
let component: TestWrapperComponent;
let fixture: ComponentFixture<TestWrapperComponent>;

beforeEach(
waitForAsync(() => {
TestBed.configureTestingModule({
providers: [],
declarations: [TestWrapperComponent, TimelineComponent],
}).compileComponents();
})
);

beforeEach(() => {
fixture = TestBed.createComponent(TestWrapperComponent);
component = fixture.componentInstance;
});

it('should show step', () => {
const element: HTMLDivElement = fixture.nativeElement;
component.view = {
config: {
steps: [
{
state: 'current',
header: 'header',
title: 'title',
description: 'description',
},
],
vertical: false,
},
metadata: { type: 'timeline', title: [], accessor: 'accessor' },
};
fixture.detectChanges();

expect(element.querySelector('app-view-timeline').innerHTML).toContain(
'description'
);
});
});
});