diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue index f3209d1a02f050b0be817bf082e68b97ae441b5e..512709914276a96aad35d6077398a95ee25395d5 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue @@ -148,7 +148,7 @@ export default { data-testid="label-title-input" /> </div> - <sidebar-color-picker v-model.trim="selectedColor" /> + <sidebar-color-picker v-model.trim="selectedColor" class="gl-px-3" /> <div class="dropdown-actions gl-display-flex gl-justify-content-space-between gl-pt-3 gl-px-3"> <gl-button :disabled="disableCreate" diff --git a/app/assets/javascripts/sidebar/components/sidebar_color_picker.vue b/app/assets/javascripts/sidebar/components/sidebar_color_picker.vue index 95b1febb5754556e71d328acf8f08a3694e8ba93..26a769585d76b2603e73d397af715e1188b82a60 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_color_picker.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_color_picker.vue @@ -40,17 +40,22 @@ export default { getColorName(color) { return Object.values(color).pop(); }, + getStyle(color) { + return { + backgroundColor: this.getColorCode(color), + }; + }, }, }; </script> <template> - <div class="dropdown-content gl-px-3"> + <div class="dropdown-content"> <div class="suggest-colors suggest-colors-dropdown gl-mt-0!"> <gl-link v-for="(color, index) in suggestedColors" :key="index" v-gl-tooltip:tooltipcontainer - :style="{ backgroundColor: getColorCode(color) }" + :style="getStyle(color)" :title="getColorName(color)" @click.prevent="handleColorClick(getColorCode(color))" /> diff --git a/app/assets/javascripts/sidebar/components/sidebar_color_view.vue b/app/assets/javascripts/sidebar/components/sidebar_color_view.vue new file mode 100644 index 0000000000000000000000000000000000000000..1c9492a5b2e952f1f0786c6a868970d9b4184870 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/sidebar_color_view.vue @@ -0,0 +1,36 @@ +<script> +import SafeHtml from '~/vue_shared/directives/safe_html'; + +export default { + directives: { + SafeHtml, + }, + props: { + color: { + type: String, + required: true, + }, + }, + computed: { + style() { + return { + backgroundColor: this.color, + }; + }, + }, +}; +</script> +<template> + <span> + <span + :style="style" + data-testid="color-chip" + class="gl-display-inline-block gl-w-5 gl-h-5 gl-rounded-base gl-vertical-align-middle gl-mr-1" + ></span> + <span + v-safe-html="color" + class="gl-display-inline-block gl-vertical-align-middle" + data-testid="color-value" + ></span> + </span> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue b/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue index a0870cbd90ec376a8893fffd79f554560f1fa1ce..9dd284b7880a68d2935ce4514a1439b788286ff5 100644 --- a/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue +++ b/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue @@ -12,6 +12,7 @@ import { WIDGET_TYPE_PROGRESS, WIDGET_TYPE_START_AND_DUE_DATE, WIDGET_TYPE_WEIGHT, + WIDGET_TYPE_COLOR, WORK_ITEM_TYPE_VALUE_KEY_RESULT, WORK_ITEM_TYPE_VALUE_OBJECTIVE, WORK_ITEM_TYPE_VALUE_TASK, @@ -51,6 +52,8 @@ export default { import('ee_component/work_items/components/work_item_health_status_with_edit.vue'), WorkItemHealthStatusInline: () => import('ee_component/work_items/components/work_item_health_status_inline.vue'), + WorkItemColorInline: () => + import('ee_component/work_items/components/work_item_color_inline.vue'), }, mixins: [glFeatureFlagMixin()], props: { @@ -113,6 +116,9 @@ export default { workItemParent() { return this.isWidgetPresent(WIDGET_TYPE_HIERARCHY)?.parent; }, + workItemColor() { + return this.isWidgetPresent(WIDGET_TYPE_COLOR); + }, }, methods: { isWidgetPresent(type) { @@ -296,6 +302,13 @@ export default { @error="$emit('error', $event)" /> </template> + <work-item-color-inline + v-if="workItemColor" + class="gl-mb-5" + :work-item="workItem" + :can-update="canUpdate" + @error="$emit('error', $event)" + /> <participants v-if="workItemParticipants && glFeatures.workItemsMvc" class="gl-mb-5" diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index 64984863f559dfe0d052dc5006713ce6ec23d83c..39d7fe338fcdec9f721cdabdf9bea466a2d1f67e 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -25,6 +25,7 @@ export const WIDGET_TYPE_ITERATION = 'ITERATION'; export const WIDGET_TYPE_NOTES = 'NOTES'; export const WIDGET_TYPE_HEALTH_STATUS = 'HEALTH_STATUS'; export const WIDGET_TYPE_LINKED_ITEMS = 'LINKED_ITEMS'; +export const WIDGET_TYPE_COLOR = 'COLOR'; export const WORK_ITEM_TYPE_ENUM_INCIDENT = 'INCIDENT'; export const WORK_ITEM_TYPE_ENUM_ISSUE = 'ISSUE'; diff --git a/ee/app/assets/javascripts/work_items/components/work_item_color_inline.vue b/ee/app/assets/javascripts/work_items/components/work_item_color_inline.vue new file mode 100644 index 0000000000000000000000000000000000000000..f71110cd87fe24c1454d43d48e32b197468b8e57 --- /dev/null +++ b/ee/app/assets/javascripts/work_items/components/work_item_color_inline.vue @@ -0,0 +1,162 @@ +<script> +import { GlFormGroup, GlDisclosureDropdown, GlDisclosureDropdownItem, GlButton } 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'), + }, + components: { + GlFormGroup, + SidebarColorPicker, + SidebarColorView, + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlButton, + }, + mixins: [Tracking.mixin()], + props: { + canUpdate: { + type: Boolean, + required: false, + default: false, + }, + workItem: { + type: Object, + required: true, + }, + }, + data() { + return { + currentColor: '', + }; + }, + 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) { + return; + } + + this.currentColor = validateHexColor(this.currentColor) + ? this.currentColor + : DEFAULT_COLOR.color; + + try { + const { + data: { + workItemUpdate: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: updateWorkItemMutation, + optimisticResponse: { + workItemUpdate: { + errors: [], + workItem: { + ...this.workItem, + widgets: [ + ...this.workItem.widgets, + { + color: this.currentColor, + textColor: this.textColor, + type: WIDGET_TYPE_COLOR, + __typename: 'WorkItemWidgetColor', + }, + ], + }, + }, + }, + variables: { + input: { + id: this.workItemId, + colorWidget: { color: this.currentColor }, + }, + }, + }); + + 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); + } + }, + }, +}; +</script> + +<template> + <gl-form-group + class="work-item-dropdown" + :label="$options.i18n.colorLabel" + label-class="gl-pb-0! gl-mt-3 gl-overflow-wrap-break gl-display-flex gl-align-items-center work-item-field-label gl-w-full" + label-cols="3" + label-cols-lg="2" + > + <div v-if="!canUpdate" class="gl-ml-4 gl-mt-3 work-item-field-value"> + <sidebar-color-view :color="currentColor" /> + </div> + <gl-disclosure-dropdown v-else category="tertiary" :auto-close="false" @hidden="updateColor"> + <template #header> + <div + class="gl-display-flex gl-align-items-center gl-p-4! gl-min-h-8 gl-border-b-1 gl-border-b-solid gl-border-b-gray-200" + > + <div + data-testid="color-header-title" + class="gl-flex-grow-1 gl-font-weight-bold gl-font-sm gl-pr-2" + > + {{ __('Select a color') }} + </div> + </div> + </template> + <template #toggle> + <gl-button category="tertiary" class="work-item-color-button gl-display-flex"> + <sidebar-color-view :color="currentColor" /> + </gl-button> + </template> + <gl-disclosure-dropdown-item> + <sidebar-color-picker v-model="currentColor" class="gl-px-2" /> + </gl-disclosure-dropdown-item> + </gl-disclosure-dropdown> + </gl-form-group> +</template> diff --git a/ee/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql b/ee/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql index 36a138d0d9a507618b0759c6e9c5ff918883991d..9a99dee8f9491675ed56b5bc91c50003606cf22e 100644 --- a/ee/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql +++ b/ee/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql @@ -91,4 +91,7 @@ fragment WorkItemMetadataWidgets on WorkItemWidget { ... on WorkItemWidgetHierarchy { type } + ... on WorkItemWidgetColor { + type + } } diff --git a/ee/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql b/ee/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql index 5102ca988267ef017c1118838f2d968c1d8e8c71..a353c8a817b48449eaa68be6baa6c8fc794c0b10 100644 --- a/ee/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql +++ b/ee/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql @@ -170,4 +170,10 @@ fragment WorkItemWidgets on WorkItemWidget { } } } + + ... on WorkItemWidgetColor { + color + textColor + type + } } 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 cb05321c62bb953b2bb11d349065f575bb9026a7..40f899d454a3466b3d539cab1af645cf5d2b6dc9 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 @@ -7,6 +7,7 @@ import WorkItemHealthStatusInline from 'ee/work_items/components/work_item_healt import WorkItemWeight from 'ee/work_items/components/work_item_weight_with_edit.vue'; 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 waitForPromises from 'helpers/wait_for_promises'; import createMockApollo from 'helpers/mock_apollo_helper'; import { workItemResponseFactory } from 'jest/work_items/mock_data'; @@ -32,6 +33,7 @@ describe('EE WorkItemAttributesWrapper component', () => { const findWorkItemWeight = () => wrapper.findComponent(WorkItemWeight); const findWorkItemWeightInline = () => wrapper.findComponent(WorkItemWeightInline); const findWorkItemProgress = () => wrapper.findComponent(WorkItemProgress); + const findWorkItemColorInline = () => wrapper.findComponent(WorkItemColorInline); const findWorkItemHealthStatus = () => wrapper.findComponent(WorkItemHealthStatus); const findWorkItemHealthStatusInline = () => wrapper.findComponent(WorkItemHealthStatusInline); @@ -211,4 +213,31 @@ describe('EE WorkItemAttributesWrapper component', () => { expect(wrapper.emitted('error')).toEqual([[updateError]]); }); }); + + describe('color widget', () => { + describe.each` + description | colorWidgetPresent | exists + ${'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`, () => { + const response = workItemResponseFactory({ colorWidgetPresent }); + + createComponent({ workItem: response.data.workItem }); + + expect(findWorkItemColorInline().exists()).toBe(exists); + }); + }); + + 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); + await nextTick(); + + expect(wrapper.emitted('error')).toEqual([[updateError]]); + }); + }); }); diff --git a/ee/spec/frontend/work_items/components/work_item_color_inline_spec.js b/ee/spec/frontend/work_items/components/work_item_color_inline_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..0e5d39325847919f36a385993c26a9aa99e62dab --- /dev/null +++ b/ee/spec/frontend/work_items/components/work_item_color_inline_spec.js @@ -0,0 +1,149 @@ +import { GlDisclosureDropdown, GlFormGroup } 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 WorkItemColorInline from 'ee/work_items/components/work_item_color_inline.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('WorkItemColorInline', () => { + 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(WorkItemColorInline, { + apolloProvider: createMockApollo([[updateWorkItemMutation, mutationHandler]]), + propsData: { + canUpdate, + workItem, + }, + stubs, + }); + }; + + const findGlFormGroup = () => wrapper.findComponent(GlFormGroup); + const findSidebarColorView = () => wrapper.findComponent(SidebarColorView); + const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); + const findSidebarColorPicker = () => wrapper.findComponent(SidebarColorPicker); + const findColorHeaderTitle = () => wrapper.findByTestId('color-header-title'); + + const selectColor = (color) => { + findSidebarColorPicker().vm.$emit('input', color); + findDropdown().vm.$emit('hidden'); + }; + + it('renders the color view component and not the color picker', () => { + createComponent({ workItem: mockSelectedColorWorkItem, canUpdate: false }); + + expect(findSidebarColorView().props('color')).toBe(selectedColor); + expect(findSidebarColorPicker().exists()).toBe(false); + }); + + it('renders the header title in the dropdown', () => { + createComponent({ mountFn: mountExtended, stubs: { SidebarColorPicker: true } }); + + expect(findColorHeaderTitle().text()).toBe('Select a color'); + }); + + it('renders the components with default values', () => { + createComponent(); + + expect(findGlFormGroup().attributes('label')).toBe('Color'); + expect(findDropdown().props()).toMatchObject({ + category: 'tertiary', + autoClose: false, + }); + expect(findSidebarColorPicker().props('value')).toBe(DEFAULT_COLOR.color); + expect(findSidebarColorView().exists()).toBe(false); + }); + + it('renders the SidebarColorPicker component with custom values', () => { + createComponent({ workItem: mockSelectedColorWorkItem }); + + expect(findSidebarColorPicker().props('value')).toBe(selectedColor); + }); + + it.each` + color | inputColor | successHandler + ${selectedColor} | ${selectedColor} | ${successUpdateWorkItemMutationHandler} + ${DEFAULT_COLOR.color} | ${null} | ${successUpdateWorkItemMutationDefaultColorHandler} + `( + 'calls update work item mutation with $color when color is changed to $inputColor', + async ({ color, inputColor, successHandler }) => { + createComponent({ color, 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]]); + }, + ); +}); diff --git a/ee/spec/frontend/work_items/mock_data.js b/ee/spec/frontend/work_items/mock_data.js index 303df2eecbb3b4f9879ddcecfe8890eb40e8e4dd..50f166b1d4ebefa39aeb95b9311debb609d9572e 100644 --- a/ee/spec/frontend/work_items/mock_data.js +++ b/ee/spec/frontend/work_items/mock_data.js @@ -103,3 +103,30 @@ export const workItemObjectiveMetadataWidgetsEE = { __typename: 'WorkItemWidgetStartAndDueDate', }, }; + +export const workItemColorWidget = { + id: 'gid://gitlab/WorkItem/1', + iid: '1', + title: 'Work item epic 5', + namespace: { + id: 'gid://gitlab/Group/1', + fullPath: 'gitlab-org', + name: 'Gitlab Org', + __typename: 'Namespace', + }, + workItemType: { + id: 'gid://gitlab/WorkItems::Type/1', + name: 'Epic', + iconName: 'issue-type-epic', + __typename: 'WorkItemType', + }, + widgets: [ + { + color: '#1068bf', + textColor: '#FFFFFF', + type: 'COLOR', + __typename: 'WorkItemWidgetColor', + }, + ], + __typename: 'WorkItem', +}; diff --git a/spec/frontend/sidebar/components/sidebar_color_view_spec.js b/spec/frontend/sidebar/components/sidebar_color_view_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..523e0098df3c703debb6ef2f9f17137892a7731b --- /dev/null +++ b/spec/frontend/sidebar/components/sidebar_color_view_spec.js @@ -0,0 +1,26 @@ +import SidebarColorView from '~/sidebar/components/sidebar_color_view.vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +describe('SidebarColorView component', () => { + let wrapper; + + const createComponent = ({ color = '' } = {}) => { + wrapper = shallowMountExtended(SidebarColorView, { + propsData: { + color, + }, + }); + }; + + const findColorChip = () => wrapper.findByTestId('color-chip'); + const findColorValue = () => wrapper.findByTestId('color-value'); + + it('renders the color chip and value', () => { + createComponent({ + color: '#ffffff', + }); + + expect(findColorChip().attributes('style')).toBe('background-color: rgb(255, 255, 255);'); + expect(findColorValue().element.innerHTML).toBe('#ffffff'); + }); +}); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index d6346689adb79d17f104b2b5fed76194602aaa38..0962bed9a4cab149cd3c0748ec1038f4756d81c3 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -639,6 +639,7 @@ export const workItemResponseFactory = ({ canInviteMembers = false, labelsWidgetPresent = true, linkedItemsWidgetPresent = true, + colorWidgetPresent = true, labels = mockLabels, allowsScopedLabels = false, lastEditedAt = null, @@ -652,6 +653,7 @@ export const workItemResponseFactory = ({ awardEmoji = mockAwardsWidget, state = 'OPEN', linkedItems = mockEmptyLinkedItems, + color = '#1068bf', } = {}) => ({ data: { workItem: { @@ -882,6 +884,14 @@ export const workItemResponseFactory = ({ } : { type: 'MOCK TYPE' }, linkedItemsWidgetPresent ? linkedItems : { type: 'MOCK TYPE' }, + colorWidgetPresent + ? { + color, + textColor: '#FFFFFF', + type: 'COLOR', + __typename: 'WorkItemWidgetColor', + } + : { type: 'MOCK TYPE' }, ], }, },