diff options
Diffstat (limited to 'app/assets/javascripts/boards')
38 files changed, 464 insertions, 553 deletions
diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js index f53d41dd0f4..e14a770411e 100644 --- a/app/assets/javascripts/boards/boards_util.js +++ b/app/assets/javascripts/boards/boards_util.js @@ -1,6 +1,6 @@ import { sortBy, cloneDeep } from 'lodash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { ListType, NOT_FILTER, AssigneeIdParamValues } from './constants'; +import { ListType } from './constants'; export function getMilestone() { return null; @@ -40,7 +40,7 @@ export function formatListIssues(listIssues) { let listItemsCount; const listData = listIssues.nodes.reduce((map, list) => { - listItemsCount = list.issues.count; + listItemsCount = list.issuesCount; let sortedIssues = list.issues.edges.map((issueNode) => ({ ...issueNode.node, })); @@ -175,45 +175,106 @@ export function isListDraggable(list) { return list.listType !== ListType.backlog && list.listType !== ListType.closed; } -export function transformNotFilters(filters) { - return Object.keys(filters) - .filter((key) => key.startsWith(NOT_FILTER)) - .reduce((obj, key) => { - return { - ...obj, - [key.substring(4, key.length - 1)]: filters[key], - }; - }, {}); -} - -export function getSupportedParams(filters, supportedFilters) { - return supportedFilters.reduce((acc, f) => { - /** - * TODO the API endpoint for the classic boards - * accepts assignee wildcard value as 'assigneeId' param - - * while the GraphQL query accepts the value in 'assigneWildcardId' field. - * Once we deprecate the classics boards, - * we should change the filtered search bar to use 'asssigneeWildcardId' as a token name. - */ - if (f === 'assigneeId' && filters[f]) { - return AssigneeIdParamValues.includes(filters[f]) - ? { - ...acc, - assigneeWildcardId: filters[f].toUpperCase(), - } - : acc; - } - - if (filters[f]) { - return { - ...acc, - [f]: filters[f], - }; - } - - return acc; - }, {}); -} +export const FiltersInfo = { + assigneeUsername: { + negatedSupport: true, + }, + assigneeId: { + // assigneeId should be renamed to assigneeWildcardId. + // Classic boards used 'assigneeId' + remap: () => 'assigneeWildcardId', + }, + assigneeWildcardId: { + negatedSupport: false, + transform: (val) => val.toUpperCase(), + }, + authorUsername: { + negatedSupport: true, + }, + labelName: { + negatedSupport: true, + }, + milestoneTitle: { + negatedSupport: true, + }, + myReactionEmoji: { + negatedSupport: true, + }, + releaseTag: { + negatedSupport: true, + }, + search: { + negatedSupport: false, + }, +}; + +/** + * @param {Object} filters - ex. { search: "foobar", "not[authorUsername]": "root", } + * @returns {Object} - ex. [ ["search", "foobar", false], ["authorUsername", "root", true], ] + */ +const parseFilters = (filters) => { + /* eslint-disable-next-line @gitlab/require-i18n-strings */ + const isNegated = (x) => x.startsWith('not[') && x.endsWith(']'); + + return Object.entries(filters).map(([k, v]) => { + const isNot = isNegated(k); + const filterKey = isNot ? k.slice(4, -1) : k; + + return [filterKey, v, isNot]; + }); +}; + +/** + * Returns an object of filter key/value pairs used as variables in GraphQL requests. + * (warning: filter values are not validated) + * + * @param {Object} objParam.filters - filters extracted from url params. ex. { search: "foobar", "not[authorUsername]": "root", } + * @param {string} objParam.issuableType - issuable type e.g., issue. + * @param {Object} objParam.filterInfo - data on filters such as how to transform filter value, if filter can be negated, etc. + * @param {Object} objParam.filterFields - data on what filters are available for given issuableType (based on GraphQL schema) + */ +export const filterVariables = ({ filters, issuableType, filterInfo, filterFields }) => + parseFilters(filters) + .map(([k, v, negated]) => { + // for legacy reasons, some filters need to be renamed to correct GraphQL fields. + const remapAvailable = filterInfo[k]?.remap; + const remappedKey = remapAvailable ? filterInfo[k].remap(k, v) : k; + + return [remappedKey, v, negated]; + }) + .filter(([k, , negated]) => { + // remove unsupported filters (+ check if the filters support negation) + const supported = filterFields[issuableType].includes(k); + if (supported) { + return negated ? filterInfo[k].negatedSupport : true; + } + + return false; + }) + .map(([k, v, negated]) => { + // if the filter value needs a special transformation, apply it (e.g., capitalization) + const transform = filterInfo[k]?.transform; + const newVal = transform ? transform(v) : v; + + return [k, newVal, negated]; + }) + .reduce( + (acc, [k, v, negated]) => { + return negated + ? { + ...acc, + not: { + ...acc.not, + [k]: v, + }, + } + : { + ...acc, + [k]: v, + }; + }, + { not: {} }, + ); // EE-specific feature. Find the implementation in the `ee/`-folder export function transformBoardConfig() { @@ -228,5 +289,4 @@ export default { fullLabelId, fullIterationId, isListDraggable, - transformNotFilters, }; diff --git a/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue b/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue index 85f001d9d61..2aee84b805f 100644 --- a/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue +++ b/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue @@ -1,21 +1,25 @@ <script> import { GlButton } from '@gitlab/ui'; import { mapActions } from 'vuex'; +import Tracking from '~/tracking'; export default { components: { GlButton, }, + mixins: [Tracking.mixin()], methods: { ...mapActions(['setAddColumnFormVisibility']), + handleClick() { + this.setAddColumnFormVisibility(true); + this.track('click_button', { label: 'create_list' }); + }, }, }; </script> <template> <div class="gl-ml-3 gl-display-flex gl-align-items-center" data-testid="boards-create-list"> - <gl-button variant="confirm" @click="setAddColumnFormVisibility(true)" - >{{ __('Create list') }} - </gl-button> + <gl-button variant="confirm" @click="handleClick">{{ __('Create list') }} </gl-button> </div> </template> diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue index 2821b799cef..1e780f9ef84 100644 --- a/app/assets/javascripts/boards/components/board_card.vue +++ b/app/assets/javascripts/boards/components/board_card.vue @@ -1,5 +1,6 @@ <script> import { mapActions, mapState } from 'vuex'; +import Tracking from '~/tracking'; import BoardCardInner from './board_card_inner.vue'; export default { @@ -7,6 +8,7 @@ export default { components: { BoardCardInner, }, + mixins: [Tracking.mixin()], props: { list: { type: Object, @@ -40,6 +42,12 @@ export default { this.selectedBoardItems.findIndex((boardItem) => boardItem.id === this.item.id) > -1 ); }, + isDisabled() { + return this.disabled || !this.item.id || this.item.isLoading; + }, + isDraggable() { + return !this.disabled && this.item.id && !this.item.isLoading; + }, }, methods: { ...mapActions(['toggleBoardItemMultiSelection', 'toggleBoardItem']), @@ -48,10 +56,11 @@ export default { if (e.target.closest('.js-no-trigger')) return; const isMultiSelect = e.ctrlKey || e.metaKey; - if (isMultiSelect) { + if (isMultiSelect && gon?.features?.boardMultiSelect) { this.toggleBoardItemMultiSelection(this.item); } else { this.toggleBoardItem({ boardItem: this.item }); + this.track('click_card', { label: 'right_sidebar' }); } }, }, @@ -63,9 +72,10 @@ export default { data-qa-selector="board_card" :class="{ 'multi-select': multiSelectVisible, - 'user-can-drag': !disabled && item.id, - 'is-disabled': disabled || !item.id, + 'user-can-drag': isDraggable, + 'is-disabled': isDisabled, 'is-active': isActive, + 'gl-cursor-not-allowed gl-bg-gray-10': item.isLoading, }" :index="index" :data-item-id="item.id" diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue index 0cb2e64042e..2f4e9044b9e 100644 --- a/app/assets/javascripts/boards/components/board_card_inner.vue +++ b/app/assets/javascripts/boards/components/board_card_inner.vue @@ -1,5 +1,5 @@ <script> -import { GlLabel, GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { GlLabel, GlTooltipDirective, GlIcon, GlLoadingIcon } from '@gitlab/ui'; import { sortBy } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; import boardCardInner from 'ee_else_ce/boards/mixins/board_card_inner'; @@ -17,6 +17,7 @@ import IssueTimeEstimate from './issue_time_estimate.vue'; export default { components: { GlLabel, + GlLoadingIcon, GlIcon, UserAvatarLink, TooltipOnTruncate, @@ -181,9 +182,13 @@ export default { class="confidential-icon gl-mr-2" :aria-label="__('Confidential')" /> - <a :href="item.path || item.webUrl || ''" :title="item.title" @mousemove.stop>{{ - item.title - }}</a> + <a + :href="item.path || item.webUrl || ''" + :title="item.title" + :class="{ 'gl-text-gray-400!': item.isLoading }" + @mousemove.stop + >{{ item.title }}</a + > </h4> </div> <div v-if="showLabelFooter" class="board-card-labels gl-mt-2 gl-display-flex gl-flex-wrap"> @@ -206,6 +211,7 @@ export default { <div class="gl-display-flex align-items-start flex-wrap-reverse board-card-number-container gl-overflow-hidden js-board-card-number-container" > + <gl-loading-icon v-if="item.isLoading" size="md" class="mt-3" /> <span v-if="item.referencePath" class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3" diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index c9e667d526c..cc7262f3a39 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -24,11 +24,6 @@ export default { type: Boolean, required: true, }, - canAdminList: { - type: Boolean, - required: false, - default: false, - }, }, computed: { ...mapState(['filterParams', 'highlightedLists']), @@ -92,14 +87,8 @@ export default { class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base" :class="{ 'board-column-highlighted': highlighted }" > - <board-list-header :can-admin-list="canAdminList" :list="list" :disabled="disabled" /> - <board-list - ref="board-list" - :disabled="disabled" - :board-items="listItems" - :list="list" - :can-admin-list="canAdminList" - /> + <board-list-header :list="list" :disabled="disabled" /> + <board-list ref="board-list" :disabled="disabled" :board-items="listItems" :list="list" /> </div> </div> </template> diff --git a/app/assets/javascripts/boards/components/board_column_deprecated.vue b/app/assets/javascripts/boards/components/board_column_deprecated.vue index 3dc77654e28..7c090dfaa53 100644 --- a/app/assets/javascripts/boards/components/board_column_deprecated.vue +++ b/app/assets/javascripts/boards/components/board_column_deprecated.vue @@ -26,11 +26,6 @@ export default { type: Boolean, required: true, }, - canAdminList: { - type: Boolean, - required: false, - default: false, - }, }, data() { return { @@ -110,7 +105,7 @@ export default { class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base" :class="{ 'board-column-highlighted': list.highlighted }" > - <board-list-header :can-admin-list="canAdminList" :list="list" :disabled="disabled" /> + <board-list-header :list="list" :disabled="disabled" /> <board-list ref="board-list" :disabled="disabled" :issues="listIssues" :list="list" /> </div> </div> diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index b8a38d833ad..b770ac06e89 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -106,7 +106,6 @@ export default { v-for="(list, index) in boardListsToUse" :key="index" ref="board" - :can-admin-list="canAdminList" :list="list" :disabled="disabled" /> diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue index e1f8457c0e2..16a8a9d253f 100644 --- a/app/assets/javascripts/boards/components/board_content_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue @@ -1,16 +1,17 @@ <script> import { GlDrawer } from '@gitlab/ui'; import { mapState, mapActions, mapGetters } from 'vuex'; -import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue'; +import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdown_widget.vue'; import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue'; -import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sidebar_milestone_select.vue'; import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue'; import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue'; import { ISSUABLE } from '~/boards/constants'; import { contentTop } from '~/lib/utils/common_utils'; import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue'; import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue'; +import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue'; import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { headerHeight: `${contentTop()}px`, @@ -18,19 +19,18 @@ export default { GlDrawer, BoardSidebarTitle, SidebarAssigneesWidget, + SidebarDateWidget, SidebarConfidentialityWidget, BoardSidebarTimeTracker, BoardSidebarLabelsSelect, - BoardSidebarDueDate, SidebarSubscriptionsWidget, - BoardSidebarMilestoneSelect, - BoardSidebarEpicSelect: () => - import('ee_component/boards/components/sidebar/board_sidebar_epic_select.vue'), + SidebarDropdownWidget, BoardSidebarWeightInput: () => import('ee_component/boards/components/sidebar/board_sidebar_weight_input.vue'), - SidebarIterationWidget: () => - import('ee_component/sidebar/components/sidebar_iteration_widget.vue'), + IterationSidebarDropdownWidget: () => + import('ee_component/sidebar/components/iteration_sidebar_dropdown_widget.vue'), }, + mixins: [glFeatureFlagMixin()], inject: { multipleAssigneesFeatureAvailable: { default: false, @@ -89,20 +89,57 @@ export default { :allow-multiple-assignees="multipleAssigneesFeatureAvailable" @assignees-updated="setAssignees" /> - <board-sidebar-epic-select v-if="epicFeatureAvailable" class="epic" /> + <sidebar-dropdown-widget + v-if="epicFeatureAvailable" + :iid="activeBoardItem.iid" + issuable-attribute="epic" + :workspace-path="projectPathForActiveIssue" + :attr-workspace-path="groupPathForActiveIssue" + :issuable-type="issuableType" + data-testid="sidebar-epic" + /> <div> - <board-sidebar-milestone-select /> - <sidebar-iteration-widget - v-if="iterationFeatureAvailable" + <sidebar-dropdown-widget :iid="activeBoardItem.iid" + issuable-attribute="milestone" :workspace-path="projectPathForActiveIssue" - :iterations-workspace-path="groupPathForActiveIssue" + :attr-workspace-path="projectPathForActiveIssue" :issuable-type="issuableType" - class="gl-mt-5" + data-testid="sidebar-milestones" /> + <template v-if="!glFeatures.iterationCadences"> + <sidebar-dropdown-widget + v-if="iterationFeatureAvailable" + :iid="activeBoardItem.iid" + issuable-attribute="iteration" + :workspace-path="projectPathForActiveIssue" + :attr-workspace-path="groupPathForActiveIssue" + :issuable-type="issuableType" + class="gl-mt-5" + data-testid="iteration-edit" + data-qa-selector="iteration_container" + /> + </template> + <template v-else> + <iteration-sidebar-dropdown-widget + v-if="iterationFeatureAvailable" + :iid="activeBoardItem.iid" + :workspace-path="projectPathForActiveIssue" + :attr-workspace-path="groupPathForActiveIssue" + :issuable-type="issuableType" + class="gl-mt-5" + data-testid="iteration-edit" + data-qa-selector="iteration_container" + /> + </template> </div> <board-sidebar-time-tracker class="swimlanes-sidebar-time-tracker" /> - <board-sidebar-due-date /> + <sidebar-date-widget + :iid="activeBoardItem.iid" + :full-path="fullPath" + :issuable-type="issuableType" + data-testid="sidebar-due-date" + /> <board-sidebar-labels-select class="labels" /> <board-sidebar-weight-input v-if="weightFeatureAvailable" class="weight" /> <sidebar-confidentiality-widget diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue index e564af0c353..13388f02f1f 100644 --- a/app/assets/javascripts/boards/components/board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/board_filtered_search.vue @@ -3,6 +3,7 @@ import { pickBy } from 'lodash'; import { mapActions } from 'vuex'; import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; export default { @@ -104,7 +105,9 @@ export default { }, getFilterParams(filters = []) { const notFilters = filters.filter((item) => item.value.operator === '!='); - const equalsFilters = filters.filter((item) => item.value.operator === '='); + const equalsFilters = filters.filter( + (item) => item?.value?.operator === '=' || item.type === FILTERED_SEARCH_TERM, + ); return { ...this.generateParams(equalsFilters), not: { ...this.generateParams(notFilters) } }; }, diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index 78da4137d69..aa75a0d68f5 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -1,7 +1,7 @@ <script> -import { GlModal } from '@gitlab/ui'; -import { mapGetters } from 'vuex'; -import { deprecatedCreateFlash as Flash } from '~/flash'; +import { GlModal, GlAlert } from '@gitlab/ui'; +import { mapGetters, mapActions, mapState } from 'vuex'; +import ListLabel from '~/boards/models/label'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { getParameterByName } from '~/lib/utils/common_utils'; import { visitUrl } from '~/lib/utils/url_utility'; @@ -44,6 +44,7 @@ export default { BoardScope: () => import('ee_component/boards/components/board_scope.vue'), GlModal, BoardConfigurationOptions, + GlAlert, }, inject: { fullPath: { @@ -107,6 +108,7 @@ export default { }; }, computed: { + ...mapState(['error']), ...mapGetters(['isIssueBoard', 'isGroupBoard', 'isProjectBoard']), isNewForm() { return this.currentPage === formType.new; @@ -222,9 +224,7 @@ export default { } }, methods: { - setIteration(iterationId) { - this.board.iteration_id = iterationId; - }, + ...mapActions(['setError', 'unsetError']), boardCreateResponse(data) { return data.createBoard.board.webPath; }, @@ -235,6 +235,9 @@ export default { : ''; return `${path}${param}`; }, + cancel() { + this.$emit('cancel'); + }, async createOrUpdateBoard() { const response = await this.$apollo.mutate({ mutation: this.currentMutation, @@ -263,7 +266,7 @@ export default { await this.deleteBoard(); visitUrl(this.rootPath); } catch { - Flash(this.$options.i18n.deleteErrorMessage); + this.setError({ message: this.$options.i18n.deleteErrorMessage }); } finally { this.isLoading = false; } @@ -272,15 +275,12 @@ export default { const url = await this.createOrUpdateBoard(); visitUrl(url); } catch { - Flash(this.$options.i18n.saveErrorMessage); + this.setError({ message: this.$options.i18n.saveErrorMessage }); } finally { this.isLoading = false; } } }, - cancel() { - this.$emit('cancel'); - }, resetFormState() { if (this.isNewForm) { // Clear the form when we open the "New board" modal @@ -289,6 +289,25 @@ export default { this.board = { ...boardDefaults, ...this.currentBoard }; } }, + setIteration(iterationId) { + this.board.iteration_id = iterationId; + }, + setBoardLabels(labels) { + labels.forEach((label) => { + if (label.set && !this.board.labels.find((l) => l.id === label.id)) { + this.board.labels.push( + new ListLabel({ + id: label.id, + title: label.title, + color: label.color, + textColor: label.text_color, + }), + ); + } else if (!label.set) { + this.board.labels = this.board.labels.filter((selected) => selected.id !== label.id); + } + }); + }, }, }; </script> @@ -308,6 +327,15 @@ export default { @close="cancel" @hide.prevent > + <gl-alert + v-if="error" + class="gl-mb-3" + variant="danger" + :dismissible="true" + @dismiss="unsetError" + > + {{ error }} + </gl-alert> <p v-if="isDeleteForm" data-testid="delete-confirmation-message"> {{ $options.i18n.deleteConfirmationMessage }} </p> @@ -346,6 +374,7 @@ export default { :group-id="groupId" :weights="weights" @set-iteration="setIteration" + @set-board-labels="setBoardLabels" /> </form> </gl-modal> diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 94e29f3ad86..81740b5cd17 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -1,10 +1,11 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui'; import Draggable from 'vuedraggable'; import { mapActions, mapGetters, mapState } from 'vuex'; import { sortableStart, sortableEnd } from '~/boards/mixins/sortable_default_options'; import { sprintf, __ } from '~/locale'; import defaultSortableConfig from '~/sortable/sortable_config'; +import Tracking from '~/tracking'; import eventHub from '../eventhub'; import BoardCard from './board_card.vue'; import BoardNewIssue from './board_new_issue.vue'; @@ -21,6 +22,13 @@ export default { BoardCard, BoardNewIssue, GlLoadingIcon, + GlIntersectionObserver, + }, + mixins: [Tracking.mixin()], + inject: { + canAdminList: { + default: false, + }, }, props: { disabled: { @@ -35,11 +43,6 @@ export default { type: Array, required: true, }, - canAdminList: { - type: Boolean, - required: false, - default: false, - }, }, data() { return { @@ -65,7 +68,7 @@ export default { return this.list.maxIssueCount > 0 && this.listItemsCount > this.list.maxIssueCount; }, hasNextPage() { - return this.pageInfoByListId[this.list.id].hasNextPage; + return this.pageInfoByListId[this.list.id]?.hasNextPage; }, loading() { return this.listsFlags[this.list.id]?.isLoading; @@ -86,7 +89,9 @@ export default { : this.$options.i18n.showingAllIssues; }, treeRootWrapper() { - return this.canAdminList ? Draggable : 'ul'; + return this.canAdminList && !this.listsFlags[this.list.id]?.addItemToListInProgress + ? Draggable + : 'ul'; }, treeRootOptions() { const options = { @@ -108,19 +113,21 @@ export default { this.showCount = this.scrollHeight() > Math.ceil(this.listHeight()); }); }, - }, - created() { - eventHub.$on(`toggle-issue-form-${this.list.id}`, this.toggleForm); - eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop); - }, - mounted() { - // Scroll event on list to load more - this.listRef.addEventListener('scroll', this.onScroll); + 'list.id': { + handler(id, oldVal) { + if (id) { + eventHub.$on(`toggle-issue-form-${this.list.id}`, this.toggleForm); + eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop); + eventHub.$off(`toggle-issue-form-${oldVal}`, this.toggleForm); + eventHub.$off(`scroll-board-list-${oldVal}`, this.scrollToTop); + } + }, + immediate: true, + }, }, beforeDestroy() { eventHub.$off(`toggle-issue-form-${this.list.id}`, this.toggleForm); eventHub.$off(`scroll-board-list-${this.list.id}`, this.scrollToTop); - this.listRef.removeEventListener('scroll', this.onScroll); }, methods: { ...mapActions(['fetchItemsForList', 'moveItem']), @@ -142,28 +149,31 @@ export default { toggleForm() { this.showIssueForm = !this.showIssueForm; }, - onScroll() { - window.requestAnimationFrame(() => { - if ( - !this.loadingMore && - this.scrollTop() > this.scrollHeight() - this.scrollOffset && - this.hasNextPage - ) { - this.loadNextPage(); - } - }); + onReachingListBottom() { + if (!this.loadingMore && this.hasNextPage) { + this.showCount = true; + this.loadNextPage(); + } }, handleDragOnStart() { sortableStart(); + this.track('drag_card', { label: 'board' }); }, handleDragOnEnd(params) { sortableEnd(); - const { newIndex, oldIndex, from, to, item } = params; + const { oldIndex, from, to, item } = params; + let { newIndex } = params; const { itemId, itemIid, itemPath } = item.dataset; - const { children } = to; + let { children } = to; let moveBeforeId; let moveAfterId; + children = Array.from(children).filter((card) => card.classList.contains('board-card')); + + if (newIndex > children.length) { + newIndex = children.length; + } + const getItemId = (el) => Number(el.dataset.itemId); // If item is being moved within the same list @@ -226,6 +236,7 @@ export default { :data-board="list.id" :data-board-type="list.listType" :class="{ 'bg-danger-100': boardItemsSizeExceedsMax }" + draggable=".board-card" class="board-list gl-w-full gl-h-full gl-list-style-none gl-mb-0 gl-p-2 js-board-list" data-testid="tree-root-wrapper" @start="handleDragOnStart" @@ -240,15 +251,17 @@ export default { :item="item" :disabled="disabled" /> - <li v-if="showCount" class="board-list-count gl-text-center" data-issue-id="-1"> - <gl-loading-icon - v-if="loadingMore" - :label="$options.i18n.loadingMoreboardItems" - data-testid="count-loading-icon" - /> - <span v-if="showingAllItems">{{ showingAllItemsText }}</span> - <span v-else>{{ paginatedIssueText }}</span> - </li> + <gl-intersection-observer @appear="onReachingListBottom"> + <li v-if="showCount" class="board-list-count gl-text-center" data-issue-id="-1"> + <gl-loading-icon + v-if="loadingMore" + :label="$options.i18n.loadingMoreboardItems" + data-testid="count-loading-icon" + /> + <span v-if="showingAllItems">{{ showingAllItemsText }}</span> + <span v-else>{{ paginatedIssueText }}</span> + </li> + </gl-intersection-observer> </component> </div> </template> diff --git a/app/assets/javascripts/boards/components/board_list_deprecated.vue b/app/assets/javascripts/boards/components/board_list_deprecated.vue index 0534e027c86..9b3e7e1547d 100644 --- a/app/assets/javascripts/boards/components/board_list_deprecated.vue +++ b/app/assets/javascripts/boards/components/board_list_deprecated.vue @@ -1,7 +1,7 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; import { Sortable, MultiDrag } from 'sortablejs'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { sprintf, __ } from '~/locale'; import eventHub from '../eventhub'; @@ -91,6 +91,13 @@ export default { } }); }, + 'list.id': { + handler(id) { + if (id) { + eventHub.$on(`toggle-issue-form-${this.list.id}`, this.toggleForm); + } + }, + }, }, created() { eventHub.$on(`toggle-issue-form-${this.list.id}`, this.toggleForm); @@ -295,7 +302,9 @@ export default { } if (!toList) { - createFlash(__('Something went wrong while performing the action.')); + createFlash({ + message: __('Something went wrong while performing the action.'), + }); } if (!isSameList) { diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index f94697172ac..bf8396f52a6 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -14,6 +14,7 @@ import { isScopedLabel, parseBoolean } from '~/lib/utils/common_utils'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { n__, s__, __ } from '~/locale'; import sidebarEventHub from '~/sidebar/event_hub'; +import Tracking from '~/tracking'; import AccessorUtilities from '../../lib/utils/accessor'; import { inactiveId, LIST, ListType } from '../constants'; import eventHub from '../eventhub'; @@ -38,6 +39,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + mixins: [Tracking.mixin()], inject: { boardId: { default: '', @@ -98,6 +100,12 @@ export default { showListDetails() { return !this.list.collapsed || !this.isSwimlanesHeader; }, + showListHeaderActions() { + if (this.isLoggedIn) { + return this.isNewIssueShown || this.isSettingsShown; + } + return false; + }, itemsCount() { return this.list.issuesCount; }, @@ -149,6 +157,8 @@ export default { } this.setActiveId({ id: this.list.id, sidebarType: LIST }); + + this.track('click_button', { label: 'list_settings' }); }, showScopedLabels(label) { return this.scopedLabelsAvailable && isScopedLabel(label); @@ -170,6 +180,11 @@ export default { // When expanding/collapsing, the tooltip on the caret button sometimes stays open. // Close all tooltips manually to prevent dangling tooltips. this.$root.$emit(BV_HIDE_TOOLTIP); + + this.track('click_toggle_button', { + label: 'toggle_list', + property: collapsed ? 'closed' : 'open', + }); }, addToLocalStorage() { if (AccessorUtilities.isLocalStorageAccessSafe()) { @@ -351,10 +366,7 @@ export default { <!-- EE end --> </span> </div> - <gl-button-group - v-if="isNewIssueShown || isSettingsShown" - class="board-list-button-group pl-2" - > + <gl-button-group v-if="showListHeaderActions" class="board-list-button-group gl-pl-2"> <gl-button v-if="isNewIssueShown" v-show="!list.collapsed" diff --git a/app/assets/javascripts/boards/components/board_list_header_deprecated.vue b/app/assets/javascripts/boards/components/board_list_header_deprecated.vue index 429ffd4cd06..bc29728fc55 100644 --- a/app/assets/javascripts/boards/components/board_list_header_deprecated.vue +++ b/app/assets/javascripts/boards/components/board_list_header_deprecated.vue @@ -35,6 +35,9 @@ export default { GlTooltip: GlTooltipDirective, }, inject: { + currentUserId: { + default: null, + }, boardId: { default: '', }, @@ -63,7 +66,7 @@ export default { computed: { ...mapState(['activeId']), isLoggedIn() { - return Boolean(gon.current_user_id); + return Boolean(this.currentUserId); }, listType() { return this.list.type; @@ -89,6 +92,12 @@ export default { showListDetails() { return this.list.isExpanded || !this.isSwimlanesHeader; }, + showListHeaderActions() { + if (this.isLoggedIn) { + return this.isNewIssueShown || this.isSettingsShown; + } + return false; + }, issuesCount() { return this.list.issuesSize; }, @@ -320,10 +329,7 @@ export default { </template> </span> </div> - <gl-button-group - v-if="isNewIssueShown || isSettingsShown" - class="board-list-button-group pl-2" - > + <gl-button-group v-if="showListHeaderActions" class="board-list-button-group pl-2"> <gl-button v-if="isNewIssueShown" ref="newIssueBtn" diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index 144cae15ab3..a63b49f9508 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -102,7 +102,7 @@ export default { ref="submitButton" :disabled="disabled" class="float-left js-no-auto-disable" - variant="success" + variant="confirm" category="primary" type="submit" > diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue index 3d7f1f38a34..75975c77df5 100644 --- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue @@ -6,6 +6,7 @@ import boardsStore from '~/boards/stores/boards_store'; import { isScopedLabel } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import eventHub from '~/sidebar/event_hub'; +import Tracking from '~/tracking'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; // NOTE: need to revisit how we handle headerHeight, because we have so many different header and footer options. @@ -21,7 +22,7 @@ export default { BoardSettingsListTypes: () => import('ee_component/boards/components/board_settings_list_types.vue'), }, - mixins: [glFeatureFlagMixin()], + mixins: [glFeatureFlagMixin(), Tracking.mixin()], inject: ['canAdminList'], data() { return { @@ -72,6 +73,7 @@ export default { // eslint-disable-next-line no-alert if (window.confirm(__('Are you sure you want to remove this list?'))) { if (this.shouldUseGraphQL || this.isEpicBoard) { + this.track('click_button', { label: 'remove_list' }); this.removeList(this.activeId); } else { this.activeList.destroy(); diff --git a/app/assets/javascripts/boards/components/config_toggle.vue b/app/assets/javascripts/boards/components/config_toggle.vue index fdb60d0ae6a..30e304b8a65 100644 --- a/app/assets/javascripts/boards/components/config_toggle.vue +++ b/app/assets/javascripts/boards/components/config_toggle.vue @@ -3,6 +3,7 @@ import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; import { formType } from '~/boards/constants'; import eventHub from '~/boards/eventhub'; import { s__, __ } from '~/locale'; +import Tracking from '~/tracking'; export default { components: { @@ -12,6 +13,7 @@ export default { GlTooltip: GlTooltipDirective, GlModalDirective, }, + mixins: [Tracking.mixin()], props: { boardsStore: { type: Object, @@ -37,6 +39,7 @@ export default { }, methods: { showPage() { + this.track('click_button', { label: 'edit_board' }); eventHub.$emit('showBoardModal', formType.edit); if (this.boardsStore) { this.boardsStore.showPage(formType.edit); diff --git a/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue b/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue index 2652fac1818..6e90731cc2f 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue @@ -95,6 +95,9 @@ export default { } return __('Blocked issue'); }, + assignees() { + return this.issue.assignees.filter((_, index) => this.shouldRenderAssignee(index)); + }, }, methods: { isIndexLessThanlimit(index) { @@ -215,8 +218,7 @@ export default { </div> <div class="board-card-assignee gl-display-flex"> <user-avatar-link - v-for="(assignee, index) in issue.assignees" - v-if="shouldRenderAssignee(index)" + v-for="assignee in assignees" :key="assignee.id" :link-href="assigneeUrl(assignee)" :img-alt="avatarUrlTitle(assignee)" diff --git a/app/assets/javascripts/boards/components/issue_time_estimate_deprecated.vue b/app/assets/javascripts/boards/components/issue_time_estimate_deprecated.vue index fe56833016e..8ddf50cb357 100644 --- a/app/assets/javascripts/boards/components/issue_time_estimate_deprecated.vue +++ b/app/assets/javascripts/boards/components/issue_time_estimate_deprecated.vue @@ -10,7 +10,7 @@ export default { }, props: { estimate: { - type: Number, + type: [Number, String], required: true, }, }, diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue deleted file mode 100644 index 13e1e232676..00000000000 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue +++ /dev/null @@ -1,110 +0,0 @@ -<script> -import { GlButton, GlDatepicker } from '@gitlab/ui'; -import { mapGetters, mapActions } from 'vuex'; -import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; -import createFlash from '~/flash'; -import { dateInWords, formatDate, parsePikadayDate } from '~/lib/utils/datetime_utility'; -import { __ } from '~/locale'; - -export default { - components: { - BoardEditableItem, - GlButton, - GlDatepicker, - }, - data() { - return { - loading: false, - }; - }, - computed: { - ...mapGetters(['activeBoardItem', 'projectPathForActiveIssue']), - hasDueDate() { - return this.activeBoardItem.dueDate != null; - }, - parsedDueDate() { - if (!this.hasDueDate) { - return null; - } - - return parsePikadayDate(this.activeBoardItem.dueDate); - }, - formattedDueDate() { - if (!this.hasDueDate) { - return ''; - } - - return dateInWords(this.parsedDueDate, true); - }, - }, - methods: { - ...mapActions(['setActiveIssueDueDate']), - async openDatePicker() { - await this.$nextTick(); - this.$refs.datePicker.calendar.show(); - }, - async setDueDate(date) { - this.loading = true; - this.$refs.sidebarItem.collapse(); - - try { - const dueDate = date ? formatDate(date, 'yyyy-mm-dd') : null; - await this.setActiveIssueDueDate({ dueDate, projectPath: this.projectPathForActiveIssue }); - } catch (e) { - createFlash({ message: this.$options.i18n.updateDueDateError }); - } finally { - this.loading = false; - } - }, - }, - i18n: { - dueDate: __('Due date'), - removeDueDate: __('remove due date'), - updateDueDateError: __('An error occurred when updating the issue due date'), - }, -}; -</script> - -<template> - <board-editable-item - ref="sidebarItem" - class="board-sidebar-due-date" - data-testid="sidebar-due-date" - :title="$options.i18n.dueDate" - :loading="loading" - @open="openDatePicker" - > - <template v-if="hasDueDate" #collapsed> - <div class="gl-display-flex gl-align-items-center"> - <strong class="gl-text-gray-900">{{ formattedDueDate }}</strong> - <span class="gl-mx-2">-</span> - <gl-button - variant="link" - class="gl-text-gray-500!" - data-testid="reset-button" - :disabled="loading" - @click="setDueDate(null)" - > - {{ $options.i18n.removeDueDate }} - </gl-button> - </div> - </template> - <gl-datepicker - ref="datePicker" - :value="parsedDueDate" - show-clear-button - @input="setDueDate" - @clear="setDueDate(null)" - /> - </board-editable-item> -</template> -<style> -/* - * This can be removed after closing: - * https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1048 - */ -.board-sidebar-due-date .gl-datepicker, -.board-sidebar-due-date .gl-datepicker-input { - width: 100%; -} -</style> diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue index 919ef0d3783..29febd0fa51 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue @@ -3,7 +3,6 @@ import { GlLabel } from '@gitlab/ui'; import { mapGetters, mapActions } from 'vuex'; import Api from '~/api'; import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; -import createFlash from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { isScopedLabel } from '~/lib/utils/common_utils'; import { mergeUrlParams } from '~/lib/utils/url_utility'; @@ -50,10 +49,10 @@ export default { /* Labels fetched in epic boards are always group-level labels and the correct path are passed from the backend (injected through labelsFetchPath) - + For issue boards, we should always include project-level labels and use a different endpoint. (it requires knowing the project path of a selected issue.) - + Note 1. that we will be using GraphQL to fetch labels when we create a labels select widget. And this component will be removed _wholesale_ https://gitlab.com/gitlab-org/gitlab/-/issues/300653. @@ -74,7 +73,7 @@ export default { }, }, methods: { - ...mapActions(['setActiveBoardItemLabels']), + ...mapActions(['setActiveBoardItemLabels', 'setError']), async setLabels(payload) { this.loading = true; this.$refs.sidebarItem.collapse(); @@ -88,7 +87,7 @@ export default { const input = { addLabelIds, removeLabelIds, projectPath: this.projectPathForActiveIssue }; await this.setActiveBoardItemLabels(input); } catch (e) { - createFlash({ message: __('An error occurred while updating labels.') }); + this.setError({ error: e, message: __('An error occurred while updating labels.') }); } finally { this.loading = false; } @@ -101,7 +100,7 @@ export default { const input = { removeLabelIds, projectPath: this.projectPathForActiveIssue }; await this.setActiveBoardItemLabels(input); } catch (e) { - createFlash({ message: __('An error occurred when removing the label.') }); + this.setError({ error: e, message: __('An error occurred when removing the label.') }); } finally { this.loading = false; } diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue deleted file mode 100644 index ad225c7bf5c..00000000000 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue +++ /dev/null @@ -1,158 +0,0 @@ -<script> -import { - GlDropdown, - GlDropdownItem, - GlDropdownText, - GlSearchBoxByType, - GlDropdownDivider, - GlLoadingIcon, -} from '@gitlab/ui'; -import { mapGetters, mapActions } from 'vuex'; -import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; -import createFlash from '~/flash'; -import { __, s__ } from '~/locale'; -import projectMilestones from '../../graphql/project_milestones.query.graphql'; - -export default { - components: { - BoardEditableItem, - GlDropdown, - GlLoadingIcon, - GlDropdownItem, - GlDropdownText, - GlSearchBoxByType, - GlDropdownDivider, - }, - data() { - return { - milestones: [], - searchTitle: '', - loading: false, - edit: false, - }; - }, - apollo: { - milestones: { - query: projectMilestones, - debounce: 250, - skip() { - return !this.edit; - }, - variables() { - return { - fullPath: this.projectPath, - searchTitle: this.searchTitle, - state: 'active', - includeAncestors: true, - }; - }, - update(data) { - const edges = data?.project?.milestones?.edges ?? []; - return edges.map((item) => item.node); - }, - error() { - createFlash({ message: this.$options.i18n.fetchMilestonesError }); - }, - }, - }, - computed: { - ...mapGetters(['activeBoardItem']), - hasMilestone() { - return this.activeBoardItem.milestone !== null; - }, - groupFullPath() { - const { referencePath = '' } = this.activeBoardItem; - return referencePath.slice(0, referencePath.indexOf('/')); - }, - projectPath() { - const { referencePath = '' } = this.activeBoardItem; - return referencePath.slice(0, referencePath.indexOf('#')); - }, - dropdownText() { - return this.activeBoardItem.milestone?.title ?? this.$options.i18n.noMilestone; - }, - }, - methods: { - ...mapActions(['setActiveIssueMilestone']), - handleOpen() { - this.edit = true; - this.$refs.dropdown.show(); - }, - handleClose() { - this.edit = false; - this.$refs.sidebarItem.collapse(); - }, - async setMilestone(milestoneId) { - this.loading = true; - this.searchTitle = ''; - this.handleClose(); - - try { - const input = { milestoneId, projectPath: this.projectPath }; - await this.setActiveIssueMilestone(input); - } catch (e) { - createFlash({ message: this.$options.i18n.updateMilestoneError }); - } finally { - this.loading = false; - } - }, - }, - i18n: { - milestone: __('Milestone'), - noMilestone: __('No milestone'), - assignMilestone: __('Assign milestone'), - noMilestonesFound: s__('Milestones|No milestones found'), - fetchMilestonesError: __('There was a problem fetching milestones.'), - updateMilestoneError: __('An error occurred while updating the milestone.'), - }, -}; -</script> - -<template> - <board-editable-item - ref="sidebarItem" - :title="$options.i18n.milestone" - :loading="loading" - data-testid="sidebar-milestones" - @open="handleOpen" - @close="handleClose" - > - <template v-if="hasMilestone" #collapsed> - <strong class="gl-text-gray-900">{{ activeBoardItem.milestone.title }}</strong> - </template> - <gl-dropdown - ref="dropdown" - :text="dropdownText" - :header-text="$options.i18n.assignMilestone" - block - @hide="handleClose" - > - <gl-search-box-by-type ref="search" v-model.trim="searchTitle" class="gl-m-3" /> - <gl-dropdown-item - data-testid="no-milestone-item" - :is-check-item="true" - :is-checked="!activeBoardItem.milestone" - @click="setMilestone(null)" - > - {{ $options.i18n.noMilestone }} - </gl-dropdown-item> - <gl-dropdown-divider /> - <gl-loading-icon v-if="$apollo.loading" class="gl-py-4" /> - <template v-else-if="milestones.length > 0"> - <gl-dropdown-item - v-for="milestone in milestones" - :key="milestone.id" - :is-check-item="true" - :is-checked="activeBoardItem.milestone && milestone.id === activeBoardItem.milestone.id" - data-testid="milestone-item" - @click="setMilestone(milestone.id)" - > - {{ milestone.title }} - </gl-dropdown-item> - </template> - <gl-dropdown-text v-else data-testid="no-milestones-found"> - {{ $options.i18n.noMilestonesFound }} - </gl-dropdown-text> - </gl-dropdown> - </board-editable-item> -</template> diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue index 376985f7cb6..4f5c55d0c5d 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue @@ -1,7 +1,6 @@ <script> import { GlToggle } from '@gitlab/ui'; import { mapGetters, mapActions } from 'vuex'; -import createFlash from '~/flash'; import { __, s__ } from '~/locale'; export default { @@ -39,17 +38,16 @@ export default { }, }, methods: { - ...mapActions(['setActiveItemSubscribed']), + ...mapActions(['setActiveItemSubscribed', 'setError']), async handleToggleSubscription() { this.loading = true; - try { await this.setActiveItemSubscribed({ subscribed: !this.activeBoardItem.subscribed, projectPath: this.projectPathForActiveIssue, }); } catch (error) { - createFlash({ message: this.$options.i18n.updateSubscribedErrorMessage }); + this.setError({ error, message: this.$options.i18n.updateSubscribedErrorMessage }); } finally { this.loading = false; } diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue index 96d444980a8..5d61f7b2887 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue @@ -9,17 +9,29 @@ export default { inject: ['timeTrackingLimitToHours'], computed: { ...mapGetters(['activeBoardItem']), + initialTimeTracking() { + const { + timeEstimate, + totalTimeSpent, + humanTimeEstimate, + humanTotalTimeSpent, + } = this.activeBoardItem; + return { + timeEstimate, + totalTimeSpent, + humanTimeEstimate, + humanTotalTimeSpent, + }; + }, }, }; </script> <template> <issuable-time-tracker - :time-estimate="activeBoardItem.timeEstimate" - :time-spent="activeBoardItem.totalTimeSpent" - :human-time-estimate="activeBoardItem.humanTimeEstimate" - :human-time-spent="activeBoardItem.humanTotalTimeSpent" + :issuable-iid="activeBoardItem.iid.toString()" :limit-to-hours="timeTrackingLimitToHours" + :initial-time-tracking="initialTimeTracking" :show-collapsed="false" /> </template> diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue index b8d3107c377..e77aadfa50e 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue @@ -2,7 +2,6 @@ import { GlAlert, GlButton, GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui'; import { mapGetters, mapActions } from 'vuex'; import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; -import createFlash from '~/flash'; import { joinPaths } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; @@ -53,7 +52,7 @@ export default { }, }, methods: { - ...mapActions(['setActiveItemTitle']), + ...mapActions(['setActiveItemTitle', 'setError']), getPendingChangesKey(item) { if (!item) { return ''; @@ -97,7 +96,7 @@ export default { this.showChangesAlert = false; } catch (e) { this.title = this.item.title; - createFlash({ message: this.$options.i18n.updateTitleError }); + this.setError({ error: e, message: this.$options.i18n.updateTitleError }); } finally { this.loading = false; } diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js index d88774d11c1..80a8fc99895 100644 --- a/app/assets/javascripts/boards/constants.js +++ b/app/assets/javascripts/boards/constants.js @@ -9,17 +9,6 @@ import updateBoardListMutation from './graphql/board_list_update.mutation.graphq import issueSetSubscriptionMutation from './graphql/issue_set_subscription.mutation.graphql'; import issueSetTitleMutation from './graphql/issue_set_title.mutation.graphql'; -export const SupportedFilters = [ - 'assigneeUsername', - 'authorUsername', - 'labelName', - 'milestoneTitle', - 'releaseTag', - 'search', - 'myReactionEmoji', - 'assigneeId', -]; - /* eslint-disable-next-line @gitlab/require-i18n-strings */ export const AssigneeIdParamValues = ['Any', 'None']; @@ -47,6 +36,7 @@ export const ListTypeTitles = { milestone: __('Milestone'), iteration: __('Iteration'), label: __('Label'), + backlog: __('Open'), }; export const formType = { @@ -60,8 +50,6 @@ export const inactiveId = 0; export const ISSUABLE = 'issuable'; export const LIST = 'list'; -export const NOT_FILTER = 'not['; - export const flashAnimationDuration = 2000; export const listsQuery = { @@ -106,6 +94,19 @@ export const subscriptionQueries = { }, }; +export const FilterFields = { + [issuableTypes.issue]: [ + 'assigneeUsername', + 'assigneeWildcardId', + 'authorUsername', + 'labelName', + 'milestoneTitle', + 'myReactionEmoji', + 'releaseTag', + 'search', + ], +}; + export default { BoardType, ListType, diff --git a/app/assets/javascripts/boards/graphql/group_board_members.query.graphql b/app/assets/javascripts/boards/graphql/group_board_members.query.graphql new file mode 100644 index 00000000000..3b8c5389725 --- /dev/null +++ b/app/assets/javascripts/boards/graphql/group_board_members.query.graphql @@ -0,0 +1,16 @@ +#import "~/graphql_shared/fragments/user.fragment.graphql" + +query GroupBoardMembers($fullPath: ID!, $search: String) { + workspace: group(fullPath: $fullPath) { + __typename + assignees: groupMembers(search: $search) { + __typename + nodes { + id + user { + ...User + } + } + } + } +} diff --git a/app/assets/javascripts/boards/graphql/issue.fragment.graphql b/app/assets/javascripts/boards/graphql/issue.fragment.graphql index 47ecb55c72b..0ff70703e1a 100644 --- a/app/assets/javascripts/boards/graphql/issue.fragment.graphql +++ b/app/assets/javascripts/boards/graphql/issue.fragment.graphql @@ -14,10 +14,6 @@ fragment IssueNode on Issue { confidential webUrl relativePosition - milestone { - id - title - } assignees { nodes { ...User diff --git a/app/assets/javascripts/boards/graphql/issue_set_due_date.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_set_due_date.mutation.graphql deleted file mode 100644 index bbea248cf85..00000000000 --- a/app/assets/javascripts/boards/graphql/issue_set_due_date.mutation.graphql +++ /dev/null @@ -1,8 +0,0 @@ -mutation issueSetDueDate($input: UpdateIssueInput!) { - updateIssue(input: $input) { - issue { - dueDate - } - errors - } -} diff --git a/app/assets/javascripts/boards/graphql/issue_set_milestone.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_set_milestone.mutation.graphql deleted file mode 100644 index 5dc78a03a06..00000000000 --- a/app/assets/javascripts/boards/graphql/issue_set_milestone.mutation.graphql +++ /dev/null @@ -1,12 +0,0 @@ -mutation issueSetMilestone($input: UpdateIssueInput!) { - updateIssue(input: $input) { - issue { - milestone { - id - title - description - } - } - errors - } -} diff --git a/app/assets/javascripts/boards/graphql/lists_issues.query.graphql b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql index 43af7d2b2f1..d1cb1ecf834 100644 --- a/app/assets/javascripts/boards/graphql/lists_issues.query.graphql +++ b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql @@ -12,11 +12,11 @@ query ListIssues( ) { group(fullPath: $fullPath) @include(if: $isGroup) { board(id: $boardId) { - lists(id: $id) { + lists(id: $id, issueFilters: $filters) { nodes { id + issuesCount issues(first: $first, filters: $filters, after: $after) { - count edges { node { ...IssueNode @@ -33,11 +33,11 @@ query ListIssues( } project(fullPath: $fullPath) @include(if: $isProject) { board(id: $boardId) { - lists(id: $id) { + lists(id: $id, issueFilters: $filters) { nodes { id + issuesCount issues(first: $first, filters: $filters, after: $after) { - count edges { node { ...IssueNode diff --git a/app/assets/javascripts/boards/graphql/project_board_members.query.graphql b/app/assets/javascripts/boards/graphql/project_board_members.query.graphql new file mode 100644 index 00000000000..fc6cc6b832c --- /dev/null +++ b/app/assets/javascripts/boards/graphql/project_board_members.query.graphql @@ -0,0 +1,16 @@ +#import "~/graphql_shared/fragments/user.fragment.graphql" + +query ProjectBoardMembers($fullPath: ID!, $search: String) { + workspace: project(fullPath: $fullPath) { + __typename + assignees: projectMembers(search: $search) { + __typename + nodes { + id + user { + ...User + } + } + } + } +} diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 1888645ef78..fb347ce852d 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -27,7 +27,6 @@ import FilteredSearchBoards from '~/boards/filtered_search_boards'; import store from '~/boards/stores'; import boardsStore from '~/boards/stores/boards_store'; import toggleFocusMode from '~/boards/toggle_focus'; -import { deprecatedCreateFlash as Flash } from '~/flash'; import createDefaultClient from '~/lib/graphql'; import { NavigationType, @@ -196,7 +195,7 @@ export default () => { } }, methods: { - ...mapActions(['setInitialBoardData', 'performSearch']), + ...mapActions(['setInitialBoardData', 'performSearch', 'setError']), initialBoardLoad() { boardsStore .all() @@ -205,8 +204,11 @@ export default () => { lists.forEach((list) => boardsStore.addList(list)); this.loading = false; }) - .catch(() => { - Flash(__('An error occurred while fetching the board lists. Please try again.')); + .catch((error) => { + this.setError({ + error, + message: __('An error occurred while fetching the board lists. Please try again.'), + }); }); }, updateTokens() { @@ -250,7 +252,7 @@ export default () => { .catch(() => { newIssue.setFetchingState('subscriptions', false); setWeightFetchingState(newIssue, false); - Flash(__('An error occurred while fetching sidebar data')); + this.setError({ message: __('An error occurred while fetching sidebar data') }); }); } @@ -287,7 +289,9 @@ export default () => { }) .catch(() => { issue.setFetchingState('subscriptions', false); - Flash(__('An error occurred when toggling the notification subscription')); + this.setError({ + message: __('An error occurred when toggling the notification subscription'), + }); }); } }, diff --git a/app/assets/javascripts/boards/models/project.js b/app/assets/javascripts/boards/models/project.js index a3d5c7af7ac..9468a02856e 100644 --- a/app/assets/javascripts/boards/models/project.js +++ b/app/assets/javascripts/boards/models/project.js @@ -2,5 +2,6 @@ export default class IssueProject { constructor(obj) { this.id = obj.id; this.path = obj.path; + this.fullPath = obj.path_with_namespace; } } diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index 5158e82c320..d4893f9eca7 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -7,11 +7,12 @@ import { ISSUABLE, titleQueries, subscriptionQueries, - SupportedFilters, deleteListQueries, listsQuery, updateListQueries, issuableTypes, + FilterFields, + ListTypeTitles, } from 'ee_else_ce/boards/constants'; import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql'; import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql'; @@ -26,17 +27,15 @@ import { formatIssue, formatIssueInput, updateListPosition, - transformNotFilters, moveItemListHelper, getMoveData, - getSupportedParams, + FiltersInfo, + filterVariables, } from '../boards_util'; import boardLabelsQuery from '../graphql/board_labels.query.graphql'; import groupProjectsQuery from '../graphql/group_projects.query.graphql'; import issueCreateMutation from '../graphql/issue_create.mutation.graphql'; -import issueSetDueDateMutation from '../graphql/issue_set_due_date.mutation.graphql'; import issueSetLabelsMutation from '../graphql/issue_set_labels.mutation.graphql'; -import issueSetMilestoneMutation from '../graphql/issue_set_milestone.mutation.graphql'; import listsIssuesQuery from '../graphql/lists_issues.query.graphql'; import * as types from './mutation_types'; @@ -60,13 +59,16 @@ export default { dispatch('setActiveId', { id: inactiveId, sidebarType: '' }); }, - setFilters: ({ commit }, filters) => { - const filterParams = { - ...getSupportedParams(filters, SupportedFilters), - not: transformNotFilters(filters), - }; - - commit(types.SET_FILTERS, filterParams); + setFilters: ({ commit, state: { issuableType } }, filters) => { + commit( + types.SET_FILTERS, + filterVariables({ + filters, + issuableType, + filterInfo: FiltersInfo, + filterFields: FilterFields, + }), + ); }, performSearch({ dispatch }) { @@ -166,8 +168,11 @@ export default { }); }, - addList: ({ commit }, list) => { + addList: ({ commit, dispatch, getters }, list) => { commit(types.RECEIVE_ADD_LIST_SUCCESS, updateListPosition(list)); + dispatch('fetchItemsForList', { + listId: getters.getListByTitle(ListTypeTitles.backlog).id, + }); }, fetchLabels: ({ state, commit, getters }, searchTerm) => { @@ -258,7 +263,7 @@ export default { commit(types.TOGGLE_LIST_COLLAPSED, { listId, collapsed }); }, - removeList: ({ state: { issuableType, boardLists }, commit }, listId) => { + removeList: ({ state: { issuableType, boardLists }, commit, dispatch, getters }, listId) => { const listsBackup = { ...boardLists }; commit(types.REMOVE_LIST, listId); @@ -278,6 +283,10 @@ export default { }) => { if (errors.length > 0) { commit(types.REMOVE_LIST_FAILURE, listsBackup); + } else { + dispatch('fetchItemsForList', { + listId: getters.getListByTitle(ListTypeTitles.backlog).id, + }); } }, ) @@ -287,6 +296,9 @@ export default { }, fetchItemsForList: ({ state, commit }, { listId, fetchNext = false }) => { + if (!fetchNext) { + commit(types.RESET_ITEMS_FOR_LIST, listId); + } commit(types.REQUEST_ITEMS_FOR_LIST, { listId, fetchNext }); const { fullPath, fullBoardId, boardType, filterParams } = state; @@ -298,7 +310,7 @@ export default { filters: filterParams, isGroup: boardType === BoardType.group, isProject: boardType === BoardType.project, - first: 20, + first: 10, after: fetchNext ? state.pageInfoByListId[listId].endCursor : undefined, }; @@ -465,32 +477,13 @@ export default { }); }, - setActiveIssueMilestone: async ({ commit, getters }, input) => { - const { activeBoardItem } = getters; - const { data } = await gqlClient.mutate({ - mutation: issueSetMilestoneMutation, - variables: { - input: { - iid: String(activeBoardItem.iid), - milestoneId: getIdFromGraphQLId(input.milestoneId), - projectPath: input.projectPath, - }, - }, - }); - - if (data.updateIssue.errors?.length > 0) { - throw new Error(data.updateIssue.errors); - } - - commit(types.UPDATE_BOARD_ITEM_BY_ID, { - itemId: activeBoardItem.id, - prop: 'milestone', - value: data.updateIssue.issue.milestone, + addListItem: ({ commit }, { list, item, position, inProgress = false }) => { + commit(types.ADD_BOARD_ITEM_TO_LIST, { + listId: list.id, + itemId: item.id, + atIndex: position, + inProgress, }); - }, - - addListItem: ({ commit }, { list, item, position }) => { - commit(types.ADD_BOARD_ITEM_TO_LIST, { listId: list.id, itemId: item.id, atIndex: position }); commit(types.UPDATE_BOARD_ITEM, item); }, @@ -509,8 +502,8 @@ export default { input.projectPath = fullPath; } - const placeholderIssue = formatIssue({ ...issueInput, id: placeholderId }); - dispatch('addListItem', { list, item: placeholderIssue, position: 0 }); + const placeholderIssue = formatIssue({ ...issueInput, id: placeholderId, isLoading: true }); + dispatch('addListItem', { list, item: placeholderIssue, position: 0, inProgress: true }); gqlClient .mutate({ @@ -565,30 +558,6 @@ export default { }); }, - setActiveIssueDueDate: async ({ commit, getters }, input) => { - const { activeBoardItem } = getters; - const { data } = await gqlClient.mutate({ - mutation: issueSetDueDateMutation, - variables: { - input: { - iid: String(activeBoardItem.iid), - projectPath: input.projectPath, - dueDate: input.dueDate, - }, - }, - }); - - if (data.updateIssue?.errors?.length > 0) { - throw new Error(data.updateIssue.errors); - } - - commit(types.UPDATE_BOARD_ITEM_BY_ID, { - itemId: activeBoardItem.id, - prop: 'dueDate', - value: data.updateIssue.issue.dueDate, - }); - }, - setActiveItemSubscribed: async ({ commit, getters, state }, input) => { const { activeBoardItem, isEpicBoard } = getters; const { fullPath, issuableType } = state; @@ -721,7 +690,7 @@ export default { } }, - setError: ({ commit }, { message, error, captureError = false }) => { + setError: ({ commit }, { message, error, captureError = true }) => { commit(types.SET_ERROR, message); if (captureError) { diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js index ccea2917c2c..38c54bc8c5d 100644 --- a/app/assets/javascripts/boards/stores/mutation_types.js +++ b/app/assets/javascripts/boards/stores/mutation_types.js @@ -15,6 +15,7 @@ export const UPDATE_LIST_FAILURE = 'UPDATE_LIST_FAILURE'; export const TOGGLE_LIST_COLLAPSED = 'TOGGLE_LIST_COLLAPSED'; export const REMOVE_LIST = 'REMOVE_LIST'; export const REMOVE_LIST_FAILURE = 'REMOVE_LIST_FAILURE'; +export const RESET_ITEMS_FOR_LIST = 'RESET_ITEMS_FOR_LIST'; export const REQUEST_ITEMS_FOR_LIST = 'REQUEST_ITEMS_FOR_LIST'; export const RECEIVE_ITEMS_FOR_LIST_FAILURE = 'RECEIVE_ITEMS_FOR_LIST_FAILURE'; export const RECEIVE_ITEMS_FOR_LIST_SUCCESS = 'RECEIVE_ITEMS_FOR_LIST_SUCCESS'; diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index 667628b2998..6cd0a62657e 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -117,6 +117,11 @@ export default { state.boardLists = listsBackup; }, + [mutationTypes.RESET_ITEMS_FOR_LIST]: (state, listId) => { + Vue.set(state, 'backupItemsList', state.boardItemsByListId[listId]); + Vue.set(state.boardItemsByListId, listId, []); + }, + [mutationTypes.REQUEST_ITEMS_FOR_LIST]: (state, { listId, fetchNext }) => { Vue.set(state.listsFlags, listId, { [fetchNext ? 'isLoadingMore' : 'isLoading']: true }); }, @@ -138,6 +143,7 @@ export default { 'Boards|An error occurred while fetching the board issues. Please reload the page.', ); Vue.set(state.listsFlags, listId, { isLoading: false, isLoadingMore: false }); + Vue.set(state.boardItemsByListId, listId, state.backupItemsList); }, [mutationTypes.RESET_ISSUES]: (state) => { @@ -166,8 +172,9 @@ export default { [mutationTypes.ADD_BOARD_ITEM_TO_LIST]: ( state, - { itemId, listId, moveBeforeId, moveAfterId, atIndex }, + { itemId, listId, moveBeforeId, moveAfterId, atIndex, inProgress = false }, ) => { + Vue.set(state.listsFlags, listId, { ...state.listsFlags, addItemToListInProgress: inProgress }); addItemToList({ state, listId, itemId, moveBeforeId, moveAfterId, atIndex }); }, diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js index 19ba2a5df83..7be5ae8b583 100644 --- a/app/assets/javascripts/boards/stores/state.js +++ b/app/assets/javascripts/boards/stores/state.js @@ -11,6 +11,7 @@ export default () => ({ boardLists: {}, listsFlags: {}, boardItemsByListId: {}, + backupItemsList: [], isSettingAssignees: false, pageInfoByListId: {}, boardItems: {}, |