diff options
4 files changed, 242 insertions, 4 deletions
diff --git a/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue b/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue new file mode 100644 index 00000000000..7d49c87271d --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue @@ -0,0 +1,89 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import Icon from './icon.vue'; + +export default { + components: { + Icon, + GlButton, + }, + props: { + size: { + type: String, + required: false, + default: '', + }, + primaryButtonClass: { + type: String, + required: false, + default: '', + }, + dropdownClass: { + type: String, + required: false, + default: '', + }, + actions: { + type: Array, + required: true, + }, + defaultAction: { + type: Number, + required: true, + }, + }, + data() { + return { + selectedAction: this.defaultAction, + }; + }, + computed: { + selectedActionTitle() { + return this.actions[this.selectedAction].title; + }, + buttonSizeClass() { + return `btn-${this.size}`; + }, + }, + methods: { + handlePrimaryActionClick() { + this.$emit('onActionClick', this.actions[this.selectedAction]); + }, + handleActionClick(selectedAction) { + this.selectedAction = selectedAction; + this.$emit('onActionSelect', selectedAction); + }, + }, +}; +</script> + +<template> + <div class="btn-group droplab-dropdown comment-type-dropdown"> + <gl-button :class="primaryButtonClass" :size="size" @click.prevent="handlePrimaryActionClick"> + {{ selectedActionTitle }} + </gl-button> + <button + :class="buttonSizeClass" + type="button" + class="btn dropdown-toggle pl-2 pr-2" + data-display="static" + data-toggle="dropdown" + > + <icon name="arrow-down" aria-label="toggle dropdown" /> + </button> + <ul :class="dropdownClass" class="dropdown-menu dropdown-open-top"> + <template v-for="(action, index) in actions"> + <li :key="index" :class="{ 'droplab-item-selected': selectedAction === index }"> + <gl-button class="btn-transparent" @click.prevent="handleActionClick(index)"> + <i aria-hidden="true" class="fa fa-check icon"> </i> + <div class="description"> + <strong>{{ action.title }}</strong> + <p>{{ action.description }}</p> + </div> + </gl-button> + </li> + <li v-if="index === 0" :key="`${index}-separator`" class="divider droplab-item-ignore"></li> + </template> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue index 7e79e63aa1e..715cf97f0ac 100644 --- a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue +++ b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue @@ -62,6 +62,15 @@ export default { assigneeName: assignee.name, }); }, + // This method is for backward compat + // since Graph query would return camelCase + // props while Rails would return snake_case + webUrl(assignee) { + return assignee.web_url || assignee.webUrl; + }, + avatarUrl(assignee) { + return assignee.avatar_url || assignee.avatarUrl; + }, }, }; </script> @@ -70,9 +79,9 @@ export default { <user-avatar-link v-for="assignee in assigneesToShow" :key="assignee.id" - :link-href="assignee.web_url" + :link-href="webUrl(assignee)" :img-alt="avatarUrlTitle(assignee)" - :img-src="assignee.avatar_url" + :img-src="avatarUrl(assignee)" :img-size="24" class="js-no-trigger" tooltip-placement="bottom" diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue b/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue index 53e6efa6ea3..9b2ee5062b1 100644 --- a/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue +++ b/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue @@ -19,10 +19,14 @@ export default { }, computed: { milestoneDue() { - return this.milestone.due_date ? parsePikadayDate(this.milestone.due_date) : null; + const dueDate = this.milestone.due_date || this.milestone.dueDate; + + return dueDate ? parsePikadayDate(dueDate) : null; }, milestoneStart() { - return this.milestone.start_date ? parsePikadayDate(this.milestone.start_date) : null; + const startDate = this.milestone.start_date || this.milestone.startDate; + + return startDate ? parsePikadayDate(startDate) : null; }, isMilestoneStarted() { if (!this.milestoneStart) { diff --git a/spec/frontend/vue_shared/droplab_dropdown_button_spec.js b/spec/frontend/vue_shared/droplab_dropdown_button_spec.js new file mode 100644 index 00000000000..22295721328 --- /dev/null +++ b/spec/frontend/vue_shared/droplab_dropdown_button_spec.js @@ -0,0 +1,136 @@ +import { mount, createLocalVue } from '@vue/test-utils'; + +import DroplabDropdownButton from '~/vue_shared/components/droplab_dropdown_button.vue'; + +const mockActions = [ + { + title: 'Foo', + description: 'Some foo action', + }, + { + title: 'Bar', + description: 'Some bar action', + }, +]; + +const createComponent = ({ + size = '', + dropdownClass = '', + actions = mockActions, + defaultAction = 0, +}) => { + const localVue = createLocalVue(); + + return mount(DroplabDropdownButton, { + localVue, + propsData: { + size, + dropdownClass, + actions, + defaultAction, + }, + }); +}; + +describe('DroplabDropdownButton', () => { + let wrapper; + + beforeEach(() => { + wrapper = createComponent({}); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('data', () => { + it('contains `selectedAction` representing value of `defaultAction` prop', () => { + expect(wrapper.vm.selectedAction).toBe(0); + }); + }); + + describe('computed', () => { + describe('selectedActionTitle', () => { + it('returns string containing title of selected action', () => { + wrapper.setData({ selectedAction: 0 }); + + expect(wrapper.vm.selectedActionTitle).toBe(mockActions[0].title); + + wrapper.setData({ selectedAction: 1 }); + + expect(wrapper.vm.selectedActionTitle).toBe(mockActions[1].title); + }); + }); + + describe('buttonSizeClass', () => { + it('returns string containing button sizing class based on `size` prop', done => { + const wrapperWithSize = createComponent({ + size: 'sm', + }); + + wrapperWithSize.vm.$nextTick(() => { + expect(wrapperWithSize.vm.buttonSizeClass).toBe('btn-sm'); + + done(); + wrapperWithSize.destroy(); + }); + }); + }); + }); + + describe('methods', () => { + describe('handlePrimaryActionClick', () => { + it('emits `onActionClick` event on component with selectedAction object as param', () => { + jest.spyOn(wrapper.vm, '$emit'); + + wrapper.setData({ selectedAction: 0 }); + wrapper.vm.handlePrimaryActionClick(); + + expect(wrapper.vm.$emit).toHaveBeenCalledWith('onActionClick', mockActions[0]); + }); + }); + + describe('handleActionClick', () => { + it('emits `onActionSelect` event on component with selectedAction index as param', () => { + jest.spyOn(wrapper.vm, '$emit'); + + wrapper.vm.handleActionClick(1); + + expect(wrapper.vm.$emit).toHaveBeenCalledWith('onActionSelect', 1); + }); + }); + }); + + describe('template', () => { + it('renders default action button', () => { + const defaultButton = wrapper.findAll('.btn').at(0); + + expect(defaultButton.text()).toBe(mockActions[0].title); + }); + + it('renders dropdown button', () => { + const dropdownButton = wrapper.findAll('.dropdown-toggle').at(0); + + expect(dropdownButton.isVisible()).toBe(true); + }); + + it('renders dropdown actions', () => { + const dropdownActions = wrapper.findAll('.dropdown-menu li button'); + + Array(dropdownActions.length) + .fill() + .forEach((_, index) => { + const actionContent = dropdownActions.at(index).find('.description'); + + expect(actionContent.find('strong').text()).toBe(mockActions[index].title); + expect(actionContent.find('p').text()).toBe(mockActions[index].description); + }); + }); + + it('renders divider between dropdown actions', () => { + const dropdownDivider = wrapper.find('.dropdown-menu .divider'); + + expect(dropdownDivider.isVisible()).toBe(true); + }); + }); +}); |