From 651c5dff8620df58b15265fcf3043d49af77fc05 Mon Sep 17 00:00:00 2001 From: Ezekiel Kigbo Date: Mon, 5 Aug 2019 15:27:24 +1000 Subject: [PATCH 1/6] Move cycle analytics stages templates to vue The existing stage list items are rendered in haml, migrating them to vuejs for future work. Fix alignment of median value Test for stage_nav_item.vue --- .../components/stage_card_list_item.vue | 17 +++ .../components/stage_nav_item.vue | 50 +++++++ .../cycle_analytics/cycle_analytics_bundle.js | 2 + .../stylesheets/pages/cycle_analytics.scss | 64 +++++---- .../projects/cycle_analytics/show.html.haml | 30 +++-- .../cycle_analytics/stage_nav_item_spec.js | 124 ++++++++++++++++++ 6 files changed, 250 insertions(+), 37 deletions(-) create mode 100644 app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue create mode 100644 app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue create mode 100644 spec/frontend/cycle_analytics/stage_nav_item_spec.js diff --git a/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue b/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue new file mode 100644 index 00000000000000..dbb39609cec522 --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue @@ -0,0 +1,17 @@ + + + diff --git a/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue b/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue new file mode 100644 index 00000000000000..5d45de2cde8a4c --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue @@ -0,0 +1,50 @@ + + + diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js index 671405602cc78a..b3ae47af750608 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js @@ -12,6 +12,7 @@ import stageComponent from './components/stage_component.vue'; import stageReviewComponent from './components/stage_review_component.vue'; import stageStagingComponent from './components/stage_staging_component.vue'; import stageTestComponent from './components/stage_test_component.vue'; +import stageNavItem from './components/stage_nav_item.vue'; import CycleAnalyticsService from './cycle_analytics_service'; import CycleAnalyticsStore from './cycle_analytics_store'; @@ -41,6 +42,7 @@ export default () => { import('ee_component/analytics/shared/components/projects_dropdown_filter.vue'), DateRangeDropdown: () => import('ee_component/analytics/shared/components/date_range_dropdown.vue'), + 'stage-nav-item': stageNavItem, }, mixins: [filterMixins], data() { diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss index 2b932d164a5c2a..0bd657bf2340c9 100644 --- a/app/assets/stylesheets/pages/cycle_analytics.scss +++ b/app/assets/stylesheets/pages/cycle_analytics.scss @@ -51,12 +51,12 @@ } .stage-header { - width: 26%; - padding-left: $gl-padding; + width: 24%; + padding-left: 2 * $gl-padding; } .median-header { - width: 14%; + width: 16%; } .event-header { @@ -67,7 +67,7 @@ .total-time-header { width: 15%; text-align: right; - padding-right: $gl-padding; + padding-right: 2 * $gl-padding; } .stage-name { @@ -153,50 +153,66 @@ } .stage-nav-item { + $card-list-border-radius: 4px; + // $card-list-border-radius: 2px; + $card-list-active-bg: $blue-50; + $card-list-active-border-color: $blue-300; + $card-list-active-shadow: $blue-500; + display: flex; line-height: 65px; - border-top: 1px solid transparent; - border-bottom: 1px solid transparent; - border-right: 1px solid $border-color; - background-color: $gray-light; + border: $border-color solid 1px; + border-radius: $card-list-border-radius; + // margin: $card-list-border-radius; + margin: 0; + margin-bottom: $card-list-border-radius; + margin-left: 2 * $card-list-border-radius; + padding-left: 24px; // TODO: utility? + + .stage-name { padding: 0; } &.active { - background-color: transparent; - border-right-color: transparent; - border-top-color: $border-color; - border-bottom-color: $border-color; - box-shadow: inset 2px 0 0 0 $blue-500; + /* TODO: replace with utilities */ + background: $card-list-active-bg; + border-color: $card-list-active-border-color; + box-shadow: inset 4px 0 0 0 $card-list-active-shadow; .stage-name { font-weight: $gl-font-weight-bold; + padding: 0; } } + // > :first-child, + // > :last-child { + // border-radius: $border-radius-width; + // } + &:hover:not(.active) { background-color: $gray-lightest; box-shadow: inset 2px 0 0 0 $border-color; cursor: pointer; } - &:first-child { - border-top: 0; - } + // &:first-child { + // border-top: 0; + // } - &:last-child { - border-bottom: 0; - } + // &:last-child { + // border-bottom: 0; + // } .stage-nav-item-cell { &.stage-median { margin-left: auto; - margin-right: $gl-padding; - min-width: calc(35% - #{$gl-padding}); + margin-right: $gl-padding * 1.5; + min-width: 37%; } } - .stage-name { - padding-left: 16px; - } + // .stage-name { + // padding-left: 16px; + // } .stage-empty, .not-available { diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index 59f0afd59e62fc..535e3ebfe0e4ca 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -1,3 +1,4 @@ +- use_customisable_ca = Feature.enabled?(:customisable_cycle_analytics) - @no_container = true - page_title "Cycle Analytics" @@ -34,7 +35,7 @@ {{ n__('Last %d day', 'Last %d days', 90) }} .stage-panel-container .card.stage-panel - .card-header + .card-header.border-bottom-0 %nav.col-headers %ul %li.stage-header @@ -56,18 +57,21 @@ .stage-panel-body %nav.stage-nav %ul - %li.stage-nav-item{ ':class' => '{ active: stage.active }', '@click' => 'selectStage(stage)', "v-for" => "stage in state.stages" } - .stage-nav-item-cell.stage-name - {{ stage.title }} - .stage-nav-item-cell.stage-median - %template{ "v-if" => "stage.isUserAllowed" } - %span{ "v-if" => "stage.value" } - {{ stage.value }} - %span.stage-empty{ "v-else" => true } - {{ __('Not enough data') }} - %template{ "v-else" => true } - %span.not-available - {{ __('Not available') }} + - if use_customisable_ca + %stage-nav-item{ "v-for" => "stage in state.stages", ":key" => '`ca-stage-title-${stage.title}`', '@select' => 'selectStage(stage)', ":title" => "stage.title", ":is-user-allowed" => "stage.isUserAllowed", ":value" => "stage.value", ":is-active" => "stage.active" } + - else + %li.stage-nav-item{ ':class' => '{ active: stage.active }', '@click' => 'selectStage(stage)', "v-for" => "stage in state.stages" } + .stage-nav-item-cell.stage-name + {{ stage.title }} + .stage-nav-item-cell.stage-median + %template{ "v-if" => "stage.isUserAllowed" } + %span{ "v-if" => "stage.value" } + {{ stage.value }} + %span.stage-empty{ "v-else" => true } + {{ __('Not enough data') }} + %template{ "v-else" => true } + %span.not-available + {{ __('Not available') }} .section.stage-events %template{ "v-if" => "isLoadingStage" } = icon("spinner spin") diff --git a/spec/frontend/cycle_analytics/stage_nav_item_spec.js b/spec/frontend/cycle_analytics/stage_nav_item_spec.js new file mode 100644 index 00000000000000..a85c75b5446aa5 --- /dev/null +++ b/spec/frontend/cycle_analytics/stage_nav_item_spec.js @@ -0,0 +1,124 @@ +import { mount, shallowMount } from '@vue/test-utils'; +import StageNavItem from '~/cycle_analytics/components/stage_nav_item.vue'; + +describe('StageNavItem', () => { + let wrapper = null; + const title = 'Cool stage'; + const value = '1 day'; + + function createComponent(props, shallow = true) { + const func = shallow ? shallowMount : mount; + return func(StageNavItem, { + propsData: { + isActive: false, + isUserAllowed: false, + title, + value, + ...props, + }, + }); + } + + function hasStageName() { + const stageName = wrapper.find('.stage-name'); + expect(stageName.exists()).toBe(true); + expect(stageName.text()).toEqual(title); + } + + describe('User has access', () => { + beforeEach(() => { + wrapper = createComponent({ isUserAllowed: true }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders stage name', () => { + hasStageName(); + }); + + describe('with a value', () => { + beforeEach(() => { + wrapper = createComponent({ isUserAllowed: true }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + it('renders the value', () => { + expect(wrapper.find('.stage-empty').exists()).toBe(false); + expect(wrapper.find('.not-available').exists()).toBe(false); + expect(wrapper.find('span').text()).toEqual(value); + }); + }); + + describe('without a value', () => { + beforeEach(() => { + wrapper = createComponent({ isUserAllowed: true, value: null }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('has the stage-empty class', () => { + expect(wrapper.find('.stage-empty').exists()).toBe(true); + }); + + it('renders Not enough data', () => { + expect(wrapper.find('span').text()).toEqual('Not enough data'); + }); + }); + }); + + describe('is active', () => { + beforeEach(() => { + wrapper = createComponent({ isActive: true }, false); + }); + + afterEach(() => { + wrapper.destroy(); + }); + it('has the active class', () => { + expect(wrapper.find('.stage-nav-item').classes('active')).toBe(true); + }); + }); + + describe('is not active', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + it('emits the `select` event when clicked', () => { + expect(wrapper.emitted().select).toBeUndefined(); + wrapper.trigger('click'); + expect(wrapper.emitted().select.length).toBe(1); + }); + }); + + describe('User does not have access', () => { + beforeEach(() => { + wrapper = createComponent({ isUserAllowed: false }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + it('renders stage name', () => { + hasStageName(); + }); + + it('has class not-available', () => { + expect(wrapper.find('.stage-empty').exists()).toBe(false); + expect(wrapper.find('.not-available').exists()).toBe(true); + }); + + it('renders Not available', () => { + expect(wrapper.find('.not-available').text()).toBe('Not available'); + }); + }); +}); -- GitLab From 0788c19d3691f983025a4d2538db257f75c6f015 Mon Sep 17 00:00:00 2001 From: Ezekiel Kigbo Date: Fri, 2 Aug 2019 23:15:23 +1000 Subject: [PATCH 2/6] Added menu item for additional options Updated tests for stage-nav-item Added tests to check the different states required for stage-nav-items. --- .../components/stage_card_list_item.vue | 22 +++++ .../components/stage_nav_item.vue | 43 +++++++- .../stylesheets/pages/cycle_analytics.scss | 5 +- .../projects/cycle_analytics/show.html.haml | 17 +--- .../cycle_analytics/stage_nav_item_spec.js | 98 +++++++++++++++---- 5 files changed, 147 insertions(+), 38 deletions(-) diff --git a/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue b/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue index dbb39609cec522..55c92920fc0e80 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue +++ b/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue @@ -1,11 +1,20 @@ @@ -13,5 +22,18 @@ export default { diff --git a/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue b/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue index 5d45de2cde8a4c..fcbd1549cd2674 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue +++ b/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue @@ -7,10 +7,15 @@ export default { StageCardListItem, }, props: { + isDefaultStage: { + type: Boolean, + default: false, + }, isActive: { type: Boolean, default: false, }, + // TODO: might not need this, rely on canEdit?? isUserAllowed: { type: Boolean, required: true, @@ -23,18 +28,33 @@ export default { type: String, default: '', }, + canEdit: { + type: Boolean, + default: false, + }, + }, + data() { + return { + displayMenu: true, + }; }, computed: { hasValue() { return this.value && this.value.length > 0; }, + editable() { + return this.isUserAllowed && this.canEdit; + }, + }, + mounted() { + // console.log('StageNavItem::props', this.propsData); }, }; diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss index 0bd657bf2340c9..9f9686c57282d9 100644 --- a/app/assets/stylesheets/pages/cycle_analytics.scss +++ b/app/assets/stylesheets/pages/cycle_analytics.scss @@ -51,12 +51,12 @@ } .stage-header { - width: 24%; + width: 22.5%; padding-left: 2 * $gl-padding; } .median-header { - width: 16%; + width: 17.5%; } .event-header { @@ -168,6 +168,7 @@ margin-bottom: $card-list-border-radius; margin-left: 2 * $card-list-border-radius; padding-left: 24px; // TODO: utility? + padding-right: 24px; // TODO: utility? .stage-name { padding: 0; } diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index 535e3ebfe0e4ca..4f58e96031107d 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -1,4 +1,3 @@ -- use_customisable_ca = Feature.enabled?(:customisable_cycle_analytics) - @no_container = true - page_title "Cycle Analytics" @@ -57,21 +56,7 @@ .stage-panel-body %nav.stage-nav %ul - - if use_customisable_ca - %stage-nav-item{ "v-for" => "stage in state.stages", ":key" => '`ca-stage-title-${stage.title}`', '@select' => 'selectStage(stage)', ":title" => "stage.title", ":is-user-allowed" => "stage.isUserAllowed", ":value" => "stage.value", ":is-active" => "stage.active" } - - else - %li.stage-nav-item{ ':class' => '{ active: stage.active }', '@click' => 'selectStage(stage)', "v-for" => "stage in state.stages" } - .stage-nav-item-cell.stage-name - {{ stage.title }} - .stage-nav-item-cell.stage-median - %template{ "v-if" => "stage.isUserAllowed" } - %span{ "v-if" => "stage.value" } - {{ stage.value }} - %span.stage-empty{ "v-else" => true } - {{ __('Not enough data') }} - %template{ "v-else" => true } - %span.not-available - {{ __('Not available') }} + %stage-nav-item{ "v-for" => "stage in state.stages", ":key" => '`ca-stage-title-${stage.title}`', '@select' => 'selectStage(stage)', ":title" => "stage.title", ":is-user-allowed" => "stage.isUserAllowed", ":value" => "stage.value", ":is-active" => "stage.active", "canEdit" => false } .section.stage-events %template{ "v-if" => "isLoadingStage" } = icon("spinner spin") diff --git a/spec/frontend/cycle_analytics/stage_nav_item_spec.js b/spec/frontend/cycle_analytics/stage_nav_item_spec.js index a85c75b5446aa5..e50018333b963b 100644 --- a/spec/frontend/cycle_analytics/stage_nav_item_spec.js +++ b/spec/frontend/cycle_analytics/stage_nav_item_spec.js @@ -1,3 +1,4 @@ +// import Vue from 'vue'; import { mount, shallowMount } from '@vue/test-utils'; import StageNavItem from '~/cycle_analytics/components/stage_nav_item.vue'; @@ -10,12 +11,15 @@ describe('StageNavItem', () => { const func = shallow ? shallowMount : mount; return func(StageNavItem, { propsData: { + canEdit: false, isActive: false, isUserAllowed: false, + isDefaultStage: true, title, value, ...props, }, + // sync: true, }); } @@ -25,19 +29,18 @@ describe('StageNavItem', () => { expect(stageName.text()).toEqual(title); } - describe('User has access', () => { - beforeEach(() => { - wrapper = createComponent({ isUserAllowed: true }); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders stage name', () => { - hasStageName(); - }); + function hasMedianValue() { + const median = wrapper.find('.stage-median'); + expect(median.exists()).toBe(true); + expect(median.text()).toEqual(value); + } + it('renders stage name', () => { + wrapper = createComponent({ isUserAllowed: true }); + hasStageName(); + wrapper.destroy(); + }); + describe('User has access', () => { describe('with a value', () => { beforeEach(() => { wrapper = createComponent({ isUserAllowed: true }); @@ -46,10 +49,10 @@ describe('StageNavItem', () => { afterEach(() => { wrapper.destroy(); }); - it('renders the value', () => { + it('renders the value for median value', () => { expect(wrapper.find('.stage-empty').exists()).toBe(false); expect(wrapper.find('.not-available').exists()).toBe(false); - expect(wrapper.find('span').text()).toEqual(value); + expect(wrapper.find('.stage-median').text()).toEqual(value); }); }); @@ -66,8 +69,8 @@ describe('StageNavItem', () => { expect(wrapper.find('.stage-empty').exists()).toBe(true); }); - it('renders Not enough data', () => { - expect(wrapper.find('span').text()).toEqual('Not enough data'); + it('renders Not enough data for the median value', () => { + expect(wrapper.find('.stage-median').text()).toEqual('Not enough data'); }); }); }); @@ -102,7 +105,7 @@ describe('StageNavItem', () => { describe('User does not have access', () => { beforeEach(() => { - wrapper = createComponent({ isUserAllowed: false }); + wrapper = createComponent({ isUserAllowed: false }, false); }); afterEach(() => { @@ -117,8 +120,65 @@ describe('StageNavItem', () => { expect(wrapper.find('.not-available').exists()).toBe(true); }); - it('renders Not available', () => { - expect(wrapper.find('.not-available').text()).toBe('Not available'); + it('renders Not available for the median value', () => { + expect(wrapper.find('.stage-median').text()).toBe('Not available'); + }); + it('does not render options menu', () => { + expect(wrapper.find('.more-actions-toggle').exists()).toBe(false); + }); + }); + + describe('User can edit stages', () => { + beforeEach(() => { + wrapper = createComponent({ canEdit: true, isUserAllowed: true }, false); + }); + + afterEach(() => { + wrapper.destroy(); + }); + it('renders stage name', () => { + hasStageName(); + }); + + it('renders options menu', () => { + expect(wrapper.find('.more-actions-toggle').exists()).toBe(true); + }); + + describe('Default stages', () => { + beforeEach(() => { + wrapper = createComponent( + { canEdit: true, isUserAllowed: true, isDefaultStage: true }, + false, + ); + }); + it('can hide the stage', () => { + expect(wrapper.html().indexOf('Hide stage') > 0).toBe(true); + }); + it('can not edit the stage', () => { + expect(wrapper.html().indexOf('Edit stage') > 0).toBe(false); + }); + it('can not remove the stage', () => { + expect(wrapper.html().indexOf('Remove stage') > 0).toBe(false); + }); + }); + + describe('Custom stages', () => { + beforeEach(() => { + wrapper = createComponent( + { canEdit: true, isUserAllowed: true, isDefaultStage: false }, + false, + ); + }); + it('can edit the stage', () => { + expect(wrapper.html().indexOf('Edit stage') > 0).toBe(true); + }); + it('can remove the stage', () => { + expect(wrapper.html().indexOf('Remove stage') > 0).toBe(true); + }); + + it('can not hide the stage', () => { + expect(wrapper.html().indexOf('Hide stage') > 0).toBe(false); + }); }); }); }); -- GitLab From de3b4e543ac42dae404c96c3be64fe5384b28e9a Mon Sep 17 00:00:00 2001 From: Ezekiel Kigbo Date: Wed, 7 Aug 2019 23:04:33 +1000 Subject: [PATCH 3/6] Replace css with utility classes Replace css styling with bootstrap utility classes where possible. --- .../components/stage_card_list_item.vue | 4 +- .../components/stage_nav_item.vue | 6 +- .../stylesheets/pages/cycle_analytics.scss | 64 ++++--------------- .../projects/cycle_analytics/show.html.haml | 10 +-- .../cycle_analytics/stage_nav_item_spec.js | 6 -- 5 files changed, 22 insertions(+), 68 deletions(-) diff --git a/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue b/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue index 55c92920fc0e80..125c9582e8e28f 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue +++ b/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue @@ -20,13 +20,13 @@ export default {