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 @@
+
+
+
+
+
+
+
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)"
/>
+
+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);
+ }
+ },
+ },
+};
+
+
+
+
+
+
+
+
+
+
+
+ {{ __('Select a color') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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' },
],
},
},