diff --git a/app/assets/javascripts/sidebar/components/sidebar_color_picker.vue b/app/assets/javascripts/sidebar/components/sidebar_color_picker.vue index 26a769585d76b2603e73d397af715e1188b82a60..ef75c2088c23d5e218fa9ae21767f6c04b24969a 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_color_picker.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_color_picker.vue @@ -15,6 +15,11 @@ export default { required: false, default: '', }, + autofocus: { + type: Boolean, + required: false, + default: false, + }, }, computed: { suggestedColors() { @@ -71,6 +76,7 @@ export default { /> import('ee_component/work_items/components/work_item_color_inline.vue'), + WorkItemColorWithEdit: () => + import('ee_component/work_items/components/work_item_color_with_edit.vue'), }, mixins: [glFeatureFlagMixin()], props: { @@ -306,6 +308,22 @@ export default { @error="$emit('error', $event)" /> + - +import { + GlForm, + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlButton, + GlLoadingIcon, +} from '@gitlab/ui'; +import { validateHexColor } from '~/lib/utils/color_utils'; +import { __ } from '~/locale'; +import { + I18N_WORK_ITEM_ERROR_UPDATING, + sprintfWorkItem, + WIDGET_TYPE_COLOR, + TRACKING_CATEGORY_SHOW, +} from '~/work_items/constants'; +import { DEFAULT_COLOR } from '~/vue_shared/components/color_select_dropdown/constants'; +import SidebarColorView from '~/sidebar/components/sidebar_color_view.vue'; +import SidebarColorPicker from '~/sidebar/components/sidebar_color_picker.vue'; +import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; +import Tracking from '~/tracking'; + +export default { + i18n: { + colorLabel: __('Color'), + }, + inputId: 'color-widget-input', + components: { + GlForm, + SidebarColorPicker, + SidebarColorView, + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlButton, + GlLoadingIcon, + }, + mixins: [Tracking.mixin()], + props: { + canUpdate: { + type: Boolean, + required: false, + default: false, + }, + workItem: { + type: Object, + required: true, + }, + }, + data() { + return { + currentColor: '', + isEditing: false, + isUpdating: false, + }; + }, + computed: { + workItemId() { + return this.workItem?.id; + }, + workItemType() { + return this.workItem?.workItemType?.name; + }, + workItemColorWidget() { + return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_COLOR); + }, + color() { + return this.workItemColorWidget?.color; + }, + textColor() { + return this.workItemColorWidget?.textColor; + }, + tracking() { + return { + category: TRACKING_CATEGORY_SHOW, + label: 'item_color', + property: `type_${this.workItemType}`, + }; + }, + }, + created() { + this.currentColor = this.color; + }, + methods: { + async updateColor() { + if (!this.canUpdate || this.color === this.currentColor) { + this.isEditing = false; + return; + } + + this.isUpdating = true; + this.currentColor = validateHexColor(this.currentColor) + ? this.currentColor + : DEFAULT_COLOR.color; + + try { + const { + data: { + workItemUpdate: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: updateWorkItemMutation, + variables: { + input: { + id: this.workItemId, + colorWidget: { color: this.currentColor }, + }, + }, + optimisticResponse: { + workItemUpdate: { + errors: [], + workItem: { + ...this.workItem, + widgets: [ + ...this.workItem.widgets, + { + color: this.currentColor, + textColor: this.textColor, + type: WIDGET_TYPE_COLOR, + __typename: 'WorkItemWidgetColor', + }, + ], + }, + }, + }, + }); + + if (errors.length) { + throw new Error(errors.join('\n')); + } + this.track('updated_color'); + } catch { + const msg = sprintfWorkItem(I18N_WORK_ITEM_ERROR_UPDATING, this.workItemType); + this.$emit('error', msg); + } finally { + this.isEditing = false; + this.isUpdating = false; + } + }, + resetColor() { + this.currentColor = null; + this.updateColor(); + }, + }, +}; + + + diff --git a/ee/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js b/ee/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js index bd4a1b462c5c9c0a4978e2830e8a7f852a741942..0c597a349800413eeabac1edb103f1f86b3181b1 100644 --- a/ee/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js +++ b/ee/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js @@ -9,6 +9,7 @@ import WorkItemWeight from 'ee/work_items/components/work_item_weight_with_edit. import WorkItemWeightInline from 'ee/work_items/components/work_item_weight_inline.vue'; import WorkItemIterationInline from 'ee/work_items/components/work_item_iteration_inline.vue'; import WorkItemColorInline from 'ee/work_items/components/work_item_color_inline.vue'; +import WorkItemColorWithEdit from 'ee/work_items/components/work_item_color_with_edit.vue'; import waitForPromises from 'helpers/wait_for_promises'; import createMockApollo from 'helpers/mock_apollo_helper'; import { workItemResponseFactory } from 'jest/work_items/mock_data'; @@ -36,6 +37,7 @@ describe('EE WorkItemAttributesWrapper component', () => { const findWorkItemProgressInline = () => wrapper.findComponent(WorkItemProgressInline); const findWorkItemProgressWithEdit = () => wrapper.findComponent(WorkItemProgressWithEdit); const findWorkItemColorInline = () => wrapper.findComponent(WorkItemColorInline); + const findWorkItemColorWithEdit = () => wrapper.findComponent(WorkItemColorWithEdit); const findWorkItemHealthStatus = () => wrapper.findComponent(WorkItemHealthStatus); const findWorkItemHealthStatusInline = () => wrapper.findComponent(WorkItemHealthStatusInline); @@ -240,21 +242,39 @@ describe('EE WorkItemAttributesWrapper component', () => { ${'when widget is returned from API'} | ${true} | ${true} ${'when widget is not returned from API'} | ${false} | ${false} `('$description', ({ colorWidgetPresent, exists }) => { - it(`${colorWidgetPresent ? 'renders' : 'does not render'} progress component`, () => { + it(`${colorWidgetPresent ? 'renders' : 'does not render'} color component`, () => { const response = workItemResponseFactory({ colorWidgetPresent }); createComponent({ workItem: response.data.workItem }); - expect(findWorkItemColorInline().exists()).toBe(exists); + expect(findWorkItemColorWithEdit().exists()).toBe(exists); }); }); + it('renders WorkItemColorWithEdit when workItemsMvc2 enabled', async () => { + createComponent(); + + await waitForPromises(); + + expect(findWorkItemColorWithEdit().exists()).toBe(true); + expect(findWorkItemColorInline().exists()).toBe(false); + }); + + it('renders WorkItemColorInline when workItemsMvc2 disabled', async () => { + createComponent({ workItemsMvc2: false }); + + await waitForPromises(); + + expect(findWorkItemColorWithEdit().exists()).toBe(false); + expect(findWorkItemColorInline().exists()).toBe(true); + }); + it('emits an error event to the wrapper', async () => { const response = workItemResponseFactory({ colorWidgetPresent: true }); createComponent({ workItem: response.data.workItem }); const updateError = 'Failed to update'; - findWorkItemColorInline().vm.$emit('error', updateError); + findWorkItemColorWithEdit().vm.$emit('error', updateError); await nextTick(); expect(wrapper.emitted('error')).toEqual([[updateError]]); diff --git a/ee/spec/frontend/work_items/components/work_item_color_with_edit_spec.js b/ee/spec/frontend/work_items/components/work_item_color_with_edit_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..1504c92983ff44f6856c6b80d94f5047cb52f3ac --- /dev/null +++ b/ee/spec/frontend/work_items/components/work_item_color_with_edit_spec.js @@ -0,0 +1,195 @@ +import { GlDisclosureDropdown } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { + updateWorkItemMutationResponseFactory, + groupWorkItemByIidResponseFactory, + updateWorkItemMutationErrorResponse, + epicType, +} from 'jest/work_items/mock_data'; +import WorkItemColorWithEdit from 'ee/work_items/components/work_item_color_with_edit.vue'; +import SidebarColorView from '~/sidebar/components/sidebar_color_view.vue'; +import SidebarColorPicker from '~/sidebar/components/sidebar_color_picker.vue'; +import { DEFAULT_COLOR } from '~/vue_shared/components/color_select_dropdown/constants'; +import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; +import { workItemColorWidget } from '../mock_data'; + +describe('WorkItemColor component', () => { + Vue.use(VueApollo); + + let wrapper; + const selectedColor = '#ffffff'; + + const mockWorkItem = groupWorkItemByIidResponseFactory({ + workItemType: epicType, + colorWidgetPresent: true, + color: DEFAULT_COLOR.color, + }).data.workspace.workItems.nodes[0]; + const mockSelectedColorWorkItem = groupWorkItemByIidResponseFactory({ + workItemType: epicType, + colorWidgetPresent: true, + color: selectedColor, + }).data.workspace.workItems.nodes[0]; + const successUpdateWorkItemMutationHandler = jest + .fn() + .mockResolvedValue( + updateWorkItemMutationResponseFactory({ colorWidgetPresent: true, color: selectedColor }), + ); + const successUpdateWorkItemMutationDefaultColorHandler = jest.fn().mockResolvedValue( + updateWorkItemMutationResponseFactory({ + colorWidgetPresent: true, + color: DEFAULT_COLOR.color, + }), + ); + + const createComponent = ({ + canUpdate = true, + mutationHandler = successUpdateWorkItemMutationHandler, + workItem = mockWorkItem, + mountFn = shallowMountExtended, + stubs = {}, + } = {}) => { + wrapper = mountFn(WorkItemColorWithEdit, { + apolloProvider: createMockApollo([[updateWorkItemMutation, mutationHandler]]), + propsData: { + canUpdate, + workItem, + }, + stubs, + }); + }; + + const findSidebarColorView = () => wrapper.findComponent(SidebarColorView); + const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); + const findSidebarColorPicker = () => wrapper.findComponent(SidebarColorPicker); + const findColorHeaderTitle = () => wrapper.findByTestId('color-header-title'); + const findEditButton = () => wrapper.findByTestId('edit-color'); + const findApplyButton = () => wrapper.findByTestId('apply-color'); + + const selectColor = async (color) => { + await findEditButton().vm.$emit('click'); + findSidebarColorPicker().vm.$emit('input', color); + findDropdown().vm.$emit('hidden'); + }; + + describe('when work item epic color can not be updated', () => { + beforeEach(() => { + createComponent({ canUpdate: false, workItem: mockSelectedColorWorkItem }); + }); + + it('renders the color view component with provided value', () => { + expect(findSidebarColorView().exists()).toBe(true); + expect(findSidebarColorView().props('color')).toBe(selectedColor); + }); + + it('does not render the color picker component and edit button', () => { + expect(findSidebarColorPicker().exists()).toBe(false); + expect(findEditButton().exists()).toBe(false); + }); + }); + + describe('when work item epic color can be updated', () => { + describe('when not editing', () => { + beforeEach(() => { + createComponent({ workItem: mockSelectedColorWorkItem }); + }); + + it('renders the color view component and the edit button', () => { + expect(findSidebarColorView().exists()).toBe(true); + expect(findSidebarColorView().props('color')).toBe(selectedColor); + expect(findEditButton().exists()).toBe(true); + }); + + it('does not render the color picker component', () => { + expect(findSidebarColorPicker().exists()).toBe(false); + }); + }); + + describe('when editing', () => { + beforeEach(() => { + createComponent({ workItem: mockWorkItem }); + findEditButton().vm.$emit('click'); + }); + + it('renders the color picker component and the apply button', () => { + expect(findSidebarColorPicker().exists()).toBe(true); + expect(findApplyButton().exists()).toBe(true); + }); + + it('does not render the color view component and edit button', () => { + expect(findSidebarColorView().exists()).toBe(false); + expect(findEditButton().exists()).toBe(false); + }); + + it('updates the color if apply button is clicked after selecting input color', async () => { + findSidebarColorPicker().vm.$emit('input', selectedColor); + findApplyButton().vm.$emit('click'); + + await waitForPromises(); + + expect(successUpdateWorkItemMutationHandler).toHaveBeenCalledWith({ + input: { + id: workItemColorWidget.id, + colorWidget: { + color: selectedColor, + }, + }, + }); + }); + + it.each` + color | inputColor | successHandler + ${selectedColor} | ${selectedColor} | ${successUpdateWorkItemMutationHandler} + ${DEFAULT_COLOR.color} | ${null} | ${successUpdateWorkItemMutationDefaultColorHandler} + `( + 'updates the color from $inputColor to $color if dropdown is closed after selecting input color', + async ({ color, inputColor, successHandler }) => { + createComponent({ mutationHandler: successHandler }); + + selectColor(inputColor); + + await waitForPromises(); + + expect(successHandler).toHaveBeenCalledWith({ + input: { + id: workItemColorWidget.id, + colorWidget: { + color, + }, + }, + }); + }, + ); + + it.each` + errorType | expectedErrorMessage | failureHandler + ${'graphql error'} | ${'Something went wrong while updating the epic. Please try again.'} | ${jest.fn().mockResolvedValue(updateWorkItemMutationErrorResponse)} + ${'network error'} | ${'Something went wrong while updating the epic. Please try again.'} | ${jest.fn().mockRejectedValue(new Error())} + `( + 'emits an error when there is a $errorType', + async ({ expectedErrorMessage, failureHandler }) => { + createComponent({ + mutationHandler: failureHandler, + }); + + selectColor(selectedColor); + + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[expectedErrorMessage]]); + }, + ); + }); + }); + + it('renders the title in the dropdown header', async () => { + createComponent({ mountFn: mountExtended, stubs: { SidebarColorPicker: true } }); + + await findEditButton().vm.$emit('click'); + + expect(findColorHeaderTitle().text()).toBe('Select a color'); + }); +}); diff --git a/spec/frontend/sidebar/components/sidebar_color_picker_spec.js b/spec/frontend/sidebar/components/sidebar_color_picker_spec.js index 7ce556fe36899a2a3b6571623545c598d9bc6475..56e79c5a1199082ac5c7db42784695249435e3b2 100644 --- a/spec/frontend/sidebar/components/sidebar_color_picker_spec.js +++ b/spec/frontend/sidebar/components/sidebar_color_picker_spec.js @@ -10,10 +10,11 @@ describe('SibebarColorPicker', () => { const findColorPicker = () => wrapper.findComponent(GlFormInput); const findColorPickerText = () => wrapper.findByTestId('selected-color-text'); - const createComponent = ({ value = '' } = {}) => { + const createComponent = ({ value = '', autofocus = false } = {}) => { wrapper = shallowMountExtended(SibebarColorPicker, { propsData: { value, + autofocus, }, }); }; @@ -32,6 +33,12 @@ describe('SibebarColorPicker', () => { expect(findColorPickerText().attributes('value')).toBe('#343434'); }); + it('sets autofocus attribute if the prop is passed as true', () => { + createComponent({ autofocus: true }); + + expect(findColorPickerText().attributes('autofocus')).toBe('true'); + }); + describe('color picker', () => { beforeEach(() => { createComponent();