Commit e4b83504 authored by Eulyeon Ko's avatar Eulyeon Ko Committed by Natalia Tepluhina

Fix iteration select UI

- Make Iterations dropdown component to be consistent -
  with gitlab/ui.
- Do not fetch iterations list until user clicks edit.
- Replace out deprecatedFlash with createFlash
- Improve spec by using mock apollo client
parent 5caf8fc8
...@@ -156,14 +156,6 @@ ...@@ -156,14 +156,6 @@
color: inherit; color: inherit;
} }
// TODO remove this class once we can generate a correct hover utility from `gitlab/ui`,
// see here: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/39286#note_396767000
.btn-link-hover:hover {
* {
@include gl-text-blue-800;
}
}
.issuable-header-text { .issuable-header-text {
margin-top: 7px; margin-top: 7px;
} }
......
...@@ -4,20 +4,34 @@ import { ...@@ -4,20 +4,34 @@ import {
GlLink, GlLink,
GlDropdown, GlDropdown,
GlDropdownItem, GlDropdownItem,
GlDropdownText,
GlSearchBoxByType, GlSearchBoxByType,
GlDropdownSectionHeader, GlDropdownDivider,
GlLoadingIcon,
GlIcon, GlIcon,
GlTooltipDirective, GlTooltipDirective,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { deprecatedCreateFlash as createFlash } from '~/flash'; import createFlash from '~/flash';
import * as Sentry from '~/sentry/wrapper';
import { __ } from '~/locale';
import groupIterationsQuery from '../queries/group_iterations.query.graphql'; import groupIterationsQuery from '../queries/group_iterations.query.graphql';
import currentIterationQuery from '../queries/issue_iteration.query.graphql'; import currentIterationQuery from '../queries/issue_iteration.query.graphql';
import setIssueIterationMutation from '../queries/set_iteration_on_issue.mutation.graphql'; import setIssueIterationMutation from '../queries/set_iteration_on_issue.mutation.graphql';
import { iterationSelectTextMap, iterationDisplayState } from '../constants'; import { iterationSelectTextMap, iterationDisplayState, noIteration } from '../constants';
export default { export default {
noIteration: iterationSelectTextMap.noIteration, noIteration,
iterationText: iterationSelectTextMap.iteration, i18n: {
iteration: iterationSelectTextMap.iteration,
noIteration: iterationSelectTextMap.noIteration,
assignIteration: iterationSelectTextMap.assignIteration,
iterationSelectFail: iterationSelectTextMap.iterationSelectFail,
noIterationsFound: iterationSelectTextMap.noIterationsFound,
currentIterationFetchError: iterationSelectTextMap.currentIterationFetchError,
iterationsFetchError: iterationSelectTextMap.iterationsFetchError,
edit: __('Edit'),
none: __('None'),
},
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
}, },
...@@ -26,9 +40,11 @@ export default { ...@@ -26,9 +40,11 @@ export default {
GlLink, GlLink,
GlDropdown, GlDropdown,
GlDropdownItem, GlDropdownItem,
GlDropdownText,
GlDropdownDivider,
GlSearchBoxByType, GlSearchBoxByType,
GlDropdownSectionHeader,
GlIcon, GlIcon,
GlLoadingIcon,
}, },
props: { props: {
canEdit: { canEdit: {
...@@ -58,14 +74,20 @@ export default { ...@@ -58,14 +74,20 @@ export default {
}; };
}, },
update(data) { update(data) {
return data?.project?.issue?.iteration; return data?.project?.issue.iteration;
},
error(error) {
createFlash({ message: this.$options.i18n.currentIterationFetchError });
Sentry.captureException(error);
}, },
}, },
iterations: { iterations: {
query: groupIterationsQuery, query: groupIterationsQuery,
skip() {
return !this.editing;
},
debounce: 250, debounce: 250,
variables() { variables() {
// TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/220381
const search = this.searchTerm === '' ? '' : `"${this.searchTerm}"`; const search = this.searchTerm === '' ? '' : `"${this.searchTerm}"`;
return { return {
...@@ -75,10 +97,11 @@ export default { ...@@ -75,10 +97,11 @@ export default {
}; };
}, },
update(data) { update(data) {
// TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/220379 return data?.group?.iterations.nodes || [];
const nodes = data.group?.iterations?.nodes || []; },
error(error) {
return iterationSelectTextMap.noIterationItem.concat(nodes); createFlash({ message: this.$options.i18n.iterationsFetchError });
Sentry.captureException(error);
}, },
}, },
}, },
...@@ -86,8 +109,10 @@ export default { ...@@ -86,8 +109,10 @@ export default {
return { return {
searchTerm: '', searchTerm: '',
editing: false, editing: false,
currentIteration: undefined, updating: false,
iterations: iterationSelectTextMap.noIterationItem, selectedTitle: null,
currentIteration: null,
iterations: [],
}; };
}, },
computed: { computed: {
...@@ -100,8 +125,17 @@ export default { ...@@ -100,8 +125,17 @@ export default {
iterationUrl() { iterationUrl() {
return this.currentIteration?.webUrl; return this.currentIteration?.webUrl;
}, },
dropdownText() {
return this.currentIteration ? this.currentIteration?.title : this.$options.i18n.iteration;
},
showNoIterationContent() { showNoIterationContent() {
return !this.editing && !this.currentIteration?.id; return !this.updating && !this.currentIteration;
},
loading() {
return this.updating || this.$apollo.queries.currentIteration.loading;
},
noIterations() {
return this.iterations.length === 0;
}, },
}, },
mounted() { mounted() {
...@@ -114,16 +148,18 @@ export default { ...@@ -114,16 +148,18 @@ export default {
toggleDropdown() { toggleDropdown() {
this.editing = !this.editing; this.editing = !this.editing;
this.$nextTick(() => { if (this.editing) {
if (this.editing) { this.showDropdown();
this.$refs.search.focusInput(); }
}
});
}, },
setIteration(iterationId) { setIteration(iterationId) {
this.editing = false;
if (iterationId === this.currentIteration?.id) return; if (iterationId === this.currentIteration?.id) return;
this.editing = false; this.updating = true;
const selectedIteration = this.iterations.find((i) => i.id === iterationId);
this.selectedTitle = selectedIteration ? selectedIteration.title : this.$options.i18n.none;
this.$apollo this.$apollo
.mutate({ .mutate({
...@@ -143,6 +179,11 @@ export default { ...@@ -143,6 +179,11 @@ export default {
const { iterationSelectFail } = iterationSelectTextMap; const { iterationSelectFail } = iterationSelectTextMap;
createFlash(iterationSelectFail); createFlash(iterationSelectFail);
})
.finally(() => {
this.updating = false;
this.searchTerm = '';
this.selectedTitle = null;
}); });
}, },
handleOffClick(event) { handleOffClick(event) {
...@@ -157,6 +198,12 @@ export default { ...@@ -157,6 +198,12 @@ export default {
iterationId === this.currentIteration?.id || (!this.currentIteration?.id && !iterationId) iterationId === this.currentIteration?.id || (!this.currentIteration?.id && !iterationId)
); );
}, },
showDropdown() {
this.$refs.newDropdown.show();
},
setFocus() {
this.$refs.search.focusInput();
},
}, },
}; };
</script> </script>
...@@ -164,49 +211,79 @@ export default { ...@@ -164,49 +211,79 @@ export default {
<template> <template>
<div data-qa-selector="iteration_container"> <div data-qa-selector="iteration_container">
<div v-gl-tooltip class="sidebar-collapsed-icon"> <div v-gl-tooltip class="sidebar-collapsed-icon">
<gl-icon :size="16" :aria-label="$options.iterationText" name="iteration" /> <gl-icon :size="16" :aria-label="$options.i18n.iteration" name="iteration" />
<span class="collapse-truncated-title">{{ iterationTitle }}</span> <span class="collapse-truncated-title">{{ iterationTitle }}</span>
</div> </div>
<div class="title hide-collapsed mt-3"> <div class="hide-collapsed gl-mt-5">
{{ $options.iterationText }} {{ $options.i18n.iteration }}
<gl-loading-icon
v-if="loading"
class="gl-ml-2"
:inline="true"
data-testid="loading-icon-title"
/>
<gl-button <gl-button
v-if="canEdit" v-if="canEdit"
variant="link" variant="link"
class="js-sidebar-dropdown-toggle edit-link gl-shadow-none float-right gl-reset-color! btn-link-hover" class="js-sidebar-dropdown-toggle edit-link gl-shadow-none float-right gl-reset-color! gl-hover-text-blue-800! gl-mt-1"
data-testid="iteration-edit-link" data-testid="iteration-edit-link"
data-track-label="right_sidebar" data-track-label="right_sidebar"
data-track-property="iteration" data-track-property="iteration"
data-track-event="click_edit_button" data-track-event="click_edit_button"
data-qa-selector="edit_iteration_link" data-qa-selector="edit_iteration_link"
@click.stop="toggleDropdown" @click.stop="toggleDropdown"
>{{ __('Edit') }}</gl-button >{{ $options.i18n.edit }}</gl-button
> >
</div> </div>
<div data-testid="select-iteration" class="hide-collapsed"> <div v-if="!editing" data-testid="select-iteration" class="hide-collapsed">
<span v-if="showNoIterationContent" class="no-value">{{ $options.noIteration }}</span> <strong v-if="updating">{{ selectedTitle }}</strong>
<gl-link v-else-if="!editing" data-qa-selector="iteration_link" :href="iterationUrl" <span v-else-if="showNoIterationContent" class="gl-text-gray-500">{{
$options.i18n.none
}}</span>
<gl-link v-else data-qa-selector="iteration_link" :href="iterationUrl"
><strong>{{ iterationTitle }}</strong></gl-link ><strong>{{ iterationTitle }}</strong></gl-link
> >
</div> </div>
<gl-dropdown <gl-dropdown
v-show="editing" v-show="editing"
ref="newDropdown" ref="newDropdown"
:text="$options.iterationText" lazy
class="dropdown gl-w-full" :header-text="$options.i18n.assignIteration"
:class="{ show: editing }" :text="dropdownText"
:loading="loading"
class="gl-w-full"
@shown="setFocus"
@hidden="toggleDropdown"
> >
<gl-dropdown-section-header class="d-flex justify-content-center">{{
__('Assign Iteration')
}}</gl-dropdown-section-header>
<gl-search-box-by-type ref="search" v-model="searchTerm" /> <gl-search-box-by-type ref="search" v-model="searchTerm" />
<gl-dropdown-item <gl-dropdown-item
v-for="iterationItem in iterations" data-testid="no-iteration-item"
:key="iterationItem.id"
:is-check-item="true" :is-check-item="true"
:is-checked="isIterationChecked(iterationItem.id)" :is-checked="isIterationChecked($options.noIteration)"
@click="setIteration(iterationItem.id)" @click="setIteration($options.noIteration)"
>{{ iterationItem.title }}</gl-dropdown-item
> >
{{ $options.i18n.noIteration }}
</gl-dropdown-item>
<gl-dropdown-divider />
<gl-loading-icon
v-if="$apollo.queries.iterations.loading"
class="gl-py-4"
data-testid="loading-icon-dropdown"
/>
<template v-else>
<gl-dropdown-text v-if="noIterations">
{{ $options.i18n.noIterationsFound }}
</gl-dropdown-text>
<gl-dropdown-item
v-for="iterationItem in iterations"
:key="iterationItem.id"
:is-check-item="true"
:is-checked="isIterationChecked(iterationItem.id)"
data-testid="iteration-items"
@click="setIteration(iterationItem.id)"
>{{ iterationItem.title }}</gl-dropdown-item
>
</template>
</gl-dropdown> </gl-dropdown>
</div> </div>
</template> </template>
...@@ -16,9 +16,15 @@ export const iterationSelectTextMap = { ...@@ -16,9 +16,15 @@ export const iterationSelectTextMap = {
iteration: __('Iteration'), iteration: __('Iteration'),
noIteration: __('No iteration'), noIteration: __('No iteration'),
noIterationItem: [{ title: __('No iteration'), id: null }], noIterationItem: [{ title: __('No iteration'), id: null }],
assignIteration: __('Assign Iteration'),
iterationSelectFail: __('Failed to set iteration on this issue. Please try again.'), iterationSelectFail: __('Failed to set iteration on this issue. Please try again.'),
currentIterationFetchError: __('Failed to fetch the iteration for this issue. Please try again.'),
iterationsFetchError: __('Failed to fetch the iterations for the group. Please try again.'),
noIterationsFound: __('No iterations found'),
}; };
export const noIteration = null;
export const iterationDisplayState = 'opened'; export const iterationDisplayState = 'opened';
export const healthStatusForRestApi = { export const healthStatusForRestApi = {
......
mutation updateIssueConfidential($projectPath: ID!, $iid: String!, $iterationId: ID) { mutation setIssueIterationMutation($projectPath: ID!, $iid: String!, $iterationId: ID) {
issueSetIteration(input: { projectPath: $projectPath, iid: $iid, iterationId: $iterationId }) { issueSetIteration(input: { projectPath: $projectPath, iid: $iid, iterationId: $iterationId }) {
errors errors
issue { issue {
......
---
title: Fixed iteration dropdown UI
merge_request: 52987
author:
type: fixed
...@@ -145,7 +145,7 @@ RSpec.describe 'Issue Sidebar' do ...@@ -145,7 +145,7 @@ RSpec.describe 'Issue Sidebar' do
select_iteration('No iteration') select_iteration('No iteration')
expect(page.find('[data-testid="select-iteration"]')).to have_content('No iteration') expect(page.find('[data-testid="select-iteration"]')).to have_content('None')
end end
it 'does not show closed iterations' do it 'does not show closed iterations' do
......
export const mockIssue = {
projectPath: 'gitlab-org/some-project',
iid: '1',
groupPath: 'gitlab-org',
};
export const mockIssueId = 'gid://gitlab/Issue/1';
export const mockIteration1 = {
__typename: 'Iteration',
id: 'gid://gitlab/Iteration/1',
title: 'Foobar Iteration',
webUrl: 'http://gdk.test:3000/groups/gitlab-org/-/iterations/1',
state: 'opened',
};
export const mockIteration2 = {
__typename: 'Iteration',
id: 'gid://gitlab/Iteration/2',
title: 'Awesome Iteration',
webUrl: 'http://gdk.test:3000/groups/gitlab-org/-/iterations/2',
state: 'opened',
};
export const mockIterationsResponse = {
data: {
group: {
iterations: {
nodes: [mockIteration1, mockIteration2],
},
__typename: 'IterationConnection',
},
__typename: 'Group',
},
};
export const emptyIterationsResponse = {
data: {
group: {
iterations: {
nodes: [],
},
__typename: 'IterationConnection',
},
__typename: 'Group',
},
};
export const noCurrentIterationResponse = {
data: {
project: {
issue: { id: mockIssueId, iteration: null, __typename: 'Issue' },
__typename: 'Project',
},
},
};
export const mockMutationResponse = {
data: {
issueSetIteration: {
errors: [],
issue: {
id: mockIssueId,
iteration: {
id: 'gid://gitlab/Iteration/2',
title: 'Awesome Iteration',
state: 'opened',
__typename: 'Iteration',
},
__typename: 'Issue',
},
__typename: 'IssueSetIterationPayload',
},
},
};
...@@ -12080,6 +12080,12 @@ msgstr "" ...@@ -12080,6 +12080,12 @@ msgstr ""
msgid "Failed to enqueue the rebase operation, possibly due to a long-lived transaction. Try again later." msgid "Failed to enqueue the rebase operation, possibly due to a long-lived transaction. Try again later."
msgstr "" msgstr ""
msgid "Failed to fetch the iteration for this issue. Please try again."
msgstr ""
msgid "Failed to fetch the iterations for the group. Please try again."
msgstr ""
msgid "Failed to find import label for Jira import." msgid "Failed to find import label for Jira import."
msgstr "" msgstr ""
...@@ -19842,6 +19848,9 @@ msgstr "" ...@@ -19842,6 +19848,9 @@ msgstr ""
msgid "No iteration" msgid "No iteration"
msgstr "" msgstr ""
msgid "No iterations found"
msgstr ""
msgid "No iterations to show" msgid "No iterations to show"
msgstr "" msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment