diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue index b62a2c7bcd1fce842796df620c097a669b23d621..6c615109bb87e0b64bf8f25836d976dcaa9fbe44 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue @@ -9,6 +9,8 @@ import { GlLoadingIcon, GlIcon, GlTooltipDirective, + GlPopover, + GlButton, } from '@gitlab/ui'; import { kebabCase, snakeCase } from 'lodash'; import createFlash from '~/flash'; @@ -17,6 +19,7 @@ import { IssuableType } from '~/issues/constants'; import { timeFor } from '~/lib/utils/datetime_utility'; import { __ } from '~/locale'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { dropdowni18nText, Tracking, @@ -47,7 +50,10 @@ export default { GlSearchBoxByType, GlIcon, GlLoadingIcon, + GlPopover, + GlButton, }, + mixins: [glFeatureFlagMixin()], inject: { isClassicSidebar: { default: false, @@ -66,6 +72,7 @@ export default { }, }, }, + props: { issuableAttribute: { type: String, @@ -111,6 +118,10 @@ export default { }; }, update(data) { + if (this.glFeatures?.epicWidgetEditConfirmation && this.isEpic) { + this.hasCurrentAttribute = data?.workspace?.issuable.hasEpic; + } + return data?.workspace?.issuable.attribute; }, error(error) { @@ -179,6 +190,8 @@ export default { updating: false, selectedTitle: null, currentAttribute: null, + hasCurrentAttribute: false, + editConfirmation: false, attributesList: [], tracking: { event: Tracking.editEvent, @@ -228,6 +241,15 @@ export default { snake: snakeCase(this.issuableAttribute), }; }, + shouldShowConfirmationPopover() { + if (!this.glFeatures?.epicWidgetEditConfirmation) { + return false; + } + + return this.isEpic && this.currentAttribute === null && this.hasCurrentAttribute + ? !this.editConfirmation + : false; + }, }, methods: { updateAttribute(attributeId) { @@ -299,6 +321,17 @@ export default { setFocus() { this.$refs.search.focusInput(); }, + handlePopoverClose() { + this.$refs.popover.$emit('close'); + }, + handlePopoverConfirm(cb) { + this.editConfirmation = true; + this.handlePopoverClose(); + setTimeout(cb, 0); + }, + handleEditConfirmation() { + this.$refs.popover.$emit('open'); + }, }, }; </script> @@ -308,10 +341,13 @@ export default { ref="editable" :title="attributeTypeTitle" :data-testid="`${formatIssuableAttribute.kebab}-edit`" + :button-id="`${formatIssuableAttribute.kebab}-edit`" :tracking="tracking" + :should-show-confirmation-popover="shouldShowConfirmationPopover" :loading="updating || loading" @open="handleOpen" @close="handleClose" + @edit-confirm="handleEditConfirmation" > <template #collapsed> <slot name="value-collapsed" :current-attribute="currentAttribute"> @@ -332,6 +368,10 @@ export default { :class="isClassicSidebar ? 'hide-collapsed' : 'gl-mt-3'" > <span v-if="updating">{{ selectedTitle }}</span> + <template v-else-if="!currentAttribute && hasCurrentAttribute"> + <gl-icon name="warning" class="gl-text-orange-500" /> + <span class="gl-text-gray-500">{{ i18n.noPermissionToView }}</span> + </template> <span v-else-if="!currentAttribute" class="gl-text-gray-500"> {{ $options.i18n.none }} </span> @@ -354,7 +394,40 @@ export default { </slot> </div> </template> - <template #default> + <template v-if="shouldShowConfirmationPopover" #default="{ toggle }"> + <gl-popover + ref="popover" + :target="`${formatIssuableAttribute.kebab}-edit`" + placement="bottomleft" + boundary="viewport" + triggers="click" + > + <div class="gl-mb-4 gl-font-base"> + {{ i18n.editConfirmation }} + </div> + <div class="gl-display-flex gl-align-items-center"> + <gl-button + size="small" + variant="confirm" + category="primary" + data-testid="confirm-edit-cta" + @click.prevent="() => handlePopoverConfirm(toggle)" + >{{ i18n.editConfirmationCta }}</gl-button + > + <gl-button + class="gl-ml-auto" + size="small" + name="cancel" + variant="default" + category="primary" + data-testid="confirm-edit-cancel" + @click.prevent="handlePopoverClose" + >{{ i18n.editConfirmationCancel }}</gl-button + > + </div> + </gl-popover> + </template> + <template v-else #default> <gl-dropdown ref="newDropdown" lazy diff --git a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue index 7551b181a58a9d62bc2ef2c1293743085799ee7f..cc88812c7b078f65c5ab1878b94f2f85dc6cab98 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue @@ -14,6 +14,11 @@ export default { }, }, props: { + buttonId: { + type: String, + required: false, + default: '', + }, title: { type: String, required: false, @@ -48,6 +53,11 @@ export default { required: false, default: true, }, + shouldShowConfirmationPopover: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -97,6 +107,11 @@ export default { window.removeEventListener('keyup', this.collapseOnEscape); }, toggle({ emitEvent = true } = {}) { + if (this.shouldShowConfirmationPopover) { + this.$emit('edit-confirm'); + return; + } + if (this.edit) { this.collapse({ emitEvent }); } else { @@ -132,6 +147,7 @@ export default { <slot name="collapsed-right"></slot> <gl-button v-if="canUpdate && !initialLoading && canEdit" + :id="buttonId" category="tertiary" size="small" class="gl-text-gray-900! gl-ml-auto hide-collapsed gl-mr-n2 shortcut-sidebar-dropdown-toggle" @@ -151,7 +167,7 @@ export default { <slot name="collapsed">{{ __('None') }}</slot> </div> <div v-show="edit" data-testid="expanded-content" :class="{ 'gl-mt-3': !isClassicSidebar }"> - <slot :edit="edit"></slot> + <slot :edit="edit" :toggle="toggle"></slot> </div> </template> </div> diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js index 989dc574bc3f58bfc788611ef04e6d22f21aeafa..e6328ad1cbb27af39c4bd44a79afdb312f77d352 100644 --- a/app/assets/javascripts/sidebar/constants.js +++ b/app/assets/javascripts/sidebar/constants.js @@ -313,6 +313,22 @@ export function dropdowni18nText(issuableAttribute, issuableType) { ), { issuableAttribute, issuableType }, ), + noPermissionToView: sprintf( + s__("DropdownWidget|You don't have permission to view this %{issuableAttribute}."), + { issuableAttribute }, + ), + editConfirmation: sprintf( + s__( + 'DropdownWidget|You do not have permission to view the currently assigned %{issuableAttribute} and will not be able to choose it again if you reassign it.', + ), + { + issuableAttribute, + }, + ), + editConfirmationCta: sprintf(s__('DropdownWidget|Edit %{issuableAttribute}'), { + issuableAttribute, + }), + editConfirmationCancel: s__('DropdownWidget|Cancel'), }; } diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index d19db2b11abb90c79d09621986c36c0d8ce1b101..32a83f2583ca47720e6f27327d68d51fb86f8697 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -53,6 +53,7 @@ class Projects::IssuesController < Projects::ApplicationController push_frontend_feature_flag(:realtime_labels, project) push_force_frontend_feature_flag(:work_items_mvc_2, project&.work_items_mvc_2_feature_flag_enabled?) push_frontend_feature_flag(:work_items_hierarchy, project) + push_frontend_feature_flag(:epic_widget_edit_confirmation, project) push_force_frontend_feature_flag(:work_items_create_from_markdown, project&.work_items_create_from_markdown_feature_flag_enabled?) end diff --git a/config/feature_flags/development/epic_widget_edit_confirmation.yml b/config/feature_flags/development/epic_widget_edit_confirmation.yml new file mode 100644 index 0000000000000000000000000000000000000000..6c92ef44e2feabf8c0c625f8b9ff252f7d66d6f8 --- /dev/null +++ b/config/feature_flags/development/epic_widget_edit_confirmation.yml @@ -0,0 +1,8 @@ +--- +name: epic_widget_edit_confirmation +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/96872 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/372429 +milestone: '15.4' +type: development +group: group::product planning +default_enabled: false diff --git a/ee/app/assets/javascripts/sidebar/queries/project_issue_epic.query.graphql b/ee/app/assets/javascripts/sidebar/queries/project_issue_epic.query.graphql index 24e92ac34e8b6312dfe2f92af960c21072ed5535..7c9bcdf910791aca6c2472941fb428c0a9d1344e 100644 --- a/ee/app/assets/javascripts/sidebar/queries/project_issue_epic.query.graphql +++ b/ee/app/assets/javascripts/sidebar/queries/project_issue_epic.query.graphql @@ -5,6 +5,7 @@ query projectIssueEpic($fullPath: ID!, $iid: String!) { id issuable: issue(iid: $iid) { id + hasEpic attribute: epic { ...EpicFragment } diff --git a/ee/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js b/ee/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js index 1ea5e303acfc3362d12da0899888f43ef3cf02c6..f7ed32421f7974616cdd49b57fc5ad03ad0e5d62 100644 --- a/ee/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js +++ b/ee/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js @@ -22,6 +22,7 @@ import { mockEpicMutationResponse, mockEpic2, emptyGroupEpicsResponse, + mockNoPermissionEpicResponse, } from '../mock_data'; jest.mock('~/flash'); @@ -32,10 +33,21 @@ describe('SidebarDropdownWidget', () => { const findDropdown = () => wrapper.findComponent(GlDropdown); const findAllDropdownItems = () => wrapper.findAll(GlDropdownItem); + const findPopoverCta = () => wrapper.findByTestId('confirm-edit-cta'); + const findPopoverCancel = () => wrapper.findByTestId('confirm-edit-cancel'); const findDropdownItemWithText = (text) => findAllDropdownItems().wrappers.find((x) => x.text() === text); const findSelectedAttribute = () => wrapper.findByTestId('select-epic'); + const waitForDropdown = async () => { + /** This sequence is important to wait for + * dropdown to render + */ + await waitForPromises(); + jest.runOnlyPendingTimers(); + await waitForPromises(); + }; + const createComponentWithApollo = async ({ requestHandlers = [], groupEpicsSpy = jest.fn().mockResolvedValue(mockGroupEpicsResponse), @@ -50,7 +62,11 @@ describe('SidebarDropdownWidget', () => { wrapper = extendedWrapper( mount(SidebarDropdownWidget, { - provide: { canUpdate: true, issuableAttributesQueries }, + provide: { + canUpdate: true, + issuableAttributesQueries, + glFeatures: { epicWidgetEditConfirmation: true }, + }, apolloProvider: mockApollo, propsData: { workspacePath: mockIssue.projectPath, @@ -223,6 +239,52 @@ describe('SidebarDropdownWidget', () => { }); }); }); + + describe("when attribute type is 'epic'", () => { + describe("when user doesn't have permission", () => { + it('opens popover on edit click', async () => { + await createComponentWithApollo({ + currentEpicSpy: jest.fn().mockResolvedValue(mockNoPermissionEpicResponse), + }); + + const spy = jest.spyOn(wrapper.vm.$children[0].$refs.popover, '$emit'); + + await clickEdit(wrapper); + + expect(spy).toHaveBeenCalledWith('open'); + + spy.mockRestore(); + }); + + it('renders dropdown when popover is confirmed', async () => { + await createComponentWithApollo({ + currentEpicSpy: jest.fn().mockResolvedValue(mockNoPermissionEpicResponse), + }); + + await clickEdit(wrapper); + + const button = findPopoverCta(); + button.trigger('click'); + await waitForDropdown(); + + expect(findDropdown().isVisible()).toBe(true); + }); + + it('does not render dropdown when popover is canceled', async () => { + await createComponentWithApollo({ + currentEpicSpy: jest.fn().mockResolvedValue(mockNoPermissionEpicResponse), + }); + + await clickEdit(wrapper); + + const button = findPopoverCancel(); + button.trigger('click'); + await waitForDropdown(); + + expect(findDropdown().exists()).toBe(false); + }); + }); + }); }); }); }); diff --git a/ee/spec/frontend/sidebar/mock_data.js b/ee/spec/frontend/sidebar/mock_data.js index eab8cf174648ff7250a2eb39e59d4f807265dca2..d38e8254e8dfed516c0e6d836eb749d89f1b2678 100644 --- a/ee/spec/frontend/sidebar/mock_data.js +++ b/ee/spec/frontend/sidebar/mock_data.js @@ -130,7 +130,17 @@ export const noCurrentEpicResponse = { data: { workspace: { id: '1', - issuable: { id: mockIssueId, attribute: null, __typename: 'Issue' }, + issuable: { id: mockIssueId, hasEpic: false, attribute: null, __typename: 'Issue' }, + __typename: 'Project', + }, + }, +}; + +export const mockNoPermissionEpicResponse = { + data: { + workspace: { + id: '1', + issuable: { id: mockIssueId, hasEpic: true, attribute: null, __typename: 'Issue' }, __typename: 'Project', }, }, diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 5b7a47191e9840e449810129f1744bb92ff64d34..26d3839ff81ca3611a8ec6668bc71a94450d6a0e 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -14031,6 +14031,12 @@ msgstr "" msgid "DropdownWidget|Assign %{issuableAttribute}" msgstr "" +msgid "DropdownWidget|Cancel" +msgstr "" + +msgid "DropdownWidget|Edit %{issuableAttribute}" +msgstr "" + msgid "DropdownWidget|Failed to fetch the %{issuableAttribute} for this %{issuableType}. Please try again." msgstr "" @@ -14046,6 +14052,12 @@ msgstr "" msgid "DropdownWidget|No open %{issuableAttribute} found" msgstr "" +msgid "DropdownWidget|You do not have permission to view the currently assigned %{issuableAttribute} and will not be able to choose it again if you reassign it." +msgstr "" + +msgid "DropdownWidget|You don't have permission to view this %{issuableAttribute}." +msgstr "" + msgid "Due Date" msgstr "" diff --git a/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js index 8ebd2dabfc2e6bb63b21a8029854a361c4dfc500..6761731c0936af253fa1c0e7a761c68ec8f01eb5 100644 --- a/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js +++ b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js @@ -238,6 +238,24 @@ describe('SidebarDropdownWidget', () => { expect(findSelectedAttribute().text()).toBe('None'); }); }); + + describe("when user doesn't have permission to view current attribute", () => { + it('renders no permission text', () => { + createComponent({ + data: { + hasCurrentAttribute: true, + currentAttribute: null, + }, + queries: { + currentAttribute: { loading: false }, + }, + }); + + expect(findSelectedAttribute().text()).toBe( + `You don't have permission to view this ${wrapper.props('issuableAttribute')}.`, + ); + }); + }); }); describe('when a user can edit', () => {