Skip to content
代码片段 群组 项目
未验证 提交 db2ac2f4 编辑于 作者: Rajan Mistry's avatar Rajan Mistry 提交者: GitLab
浏览文件

Work item Epic inline color widget

Create work item epic inline color
widget
上级 8c31b1cb
No related branches found
No related tags found
无相关合并请求
显示
470 个添加3 个删除
......@@ -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"
......
......@@ -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))"
/>
......
<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>
......@@ -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"
......
......@@ -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';
......
<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>
......@@ -91,4 +91,7 @@ fragment WorkItemMetadataWidgets on WorkItemWidget {
... on WorkItemWidgetHierarchy {
type
}
... on WorkItemWidgetColor {
type
}
}
......@@ -170,4 +170,10 @@ fragment WorkItemWidgets on WorkItemWidget {
}
}
}
... on WorkItemWidgetColor {
color
textColor
type
}
}
......@@ -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]]);
});
});
});
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]]);
},
);
});
......@@ -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',
};
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');
});
});
......@@ -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' },
],
},
},
......
0% 加载中 .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册