summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/boards
diff options
context:
space:
mode:
authorRobert Speicher <rspeicher@gmail.com>2021-01-20 13:34:23 -0600
committerRobert Speicher <rspeicher@gmail.com>2021-01-20 13:34:23 -0600
commit6438df3a1e0fb944485cebf07976160184697d72 (patch)
tree00b09bfd170e77ae9391b1a2f5a93ef6839f2597 /app/assets/javascripts/boards
parent42bcd54d971da7ef2854b896a7b34f4ef8601067 (diff)
downloadgitlab-ce-13.8.0-rc42.tar.gz
Add latest changes from gitlab-org/gitlab@13-8-stable-eev13.8.0-rc42
Diffstat (limited to 'app/assets/javascripts/boards')
-rw-r--r--app/assets/javascripts/boards/boards_util.js50
-rw-r--r--app/assets/javascripts/boards/components/board_assignee_dropdown.vue2
-rw-r--r--app/assets/javascripts/boards/components/board_card_layout.vue20
-rw-r--r--app/assets/javascripts/boards/components/board_column.vue81
-rw-r--r--app/assets/javascripts/boards/components/board_column_deprecated.vue105
-rw-r--r--app/assets/javascripts/boards/components/board_column_new.vue82
-rw-r--r--app/assets/javascripts/boards/components/board_configuration_options.vue35
-rw-r--r--app/assets/javascripts/boards/components/board_content.vue27
-rw-r--r--app/assets/javascripts/boards/components/board_form.vue149
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue480
-rw-r--r--app/assets/javascripts/boards/components/board_list_deprecated.vue443
-rw-r--r--app/assets/javascripts/boards/components/board_list_header.vue159
-rw-r--r--app/assets/javascripts/boards/components/board_list_header_deprecated.vue (renamed from app/assets/javascripts/boards/components/board_list_header_new.vue)159
-rw-r--r--app/assets/javascripts/boards/components/board_list_new.vue239
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue.vue84
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue_deprecated.vue (renamed from app/assets/javascripts/boards/components/board_new_issue_new.vue)81
-rw-r--r--app/assets/javascripts/boards/components/board_settings_sidebar.vue2
-rw-r--r--app/assets/javascripts/boards/components/board_sidebar.js6
-rw-r--r--app/assets/javascripts/boards/components/boards_selector.vue6
-rw-r--r--app/assets/javascripts/boards/components/issue_card_inner.vue50
-rw-r--r--app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue245
-rw-r--r--app/assets/javascripts/boards/components/issue_time_estimate.vue28
-rw-r--r--app/assets/javascripts/boards/components/issue_time_estimate_deprecated.vue48
-rw-r--r--app/assets/javascripts/boards/components/modal/footer.vue6
-rw-r--r--app/assets/javascripts/boards/components/modal/index.vue14
-rw-r--r--app/assets/javascripts/boards/components/new_list_dropdown.js2
-rw-r--r--app/assets/javascripts/boards/components/project_select.vue193
-rw-r--r--app/assets/javascripts/boards/components/project_select_deprecated.vue145
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_editable_item.vue39
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue6
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_issue_title.vue171
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue12
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue18
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue2
-rw-r--r--app/assets/javascripts/boards/components/sidebar/remove_issue.vue10
-rw-r--r--app/assets/javascripts/boards/filtered_search_boards.js8
-rw-r--r--app/assets/javascripts/boards/filters/due_date_filters.js2
-rw-r--r--app/assets/javascripts/boards/graphql/board.mutation.graphql11
-rw-r--r--app/assets/javascripts/boards/graphql/board_create.mutation.graphql9
-rw-r--r--app/assets/javascripts/boards/graphql/board_destroy.mutation.graphql7
-rw-r--r--app/assets/javascripts/boards/graphql/board_update.mutation.graphql9
-rw-r--r--app/assets/javascripts/boards/graphql/group_projects.query.graphql17
-rw-r--r--app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql8
-rw-r--r--app/assets/javascripts/boards/index.js46
-rw-r--r--app/assets/javascripts/boards/mixins/sortable_default_options.js2
-rw-r--r--app/assets/javascripts/boards/models/issue.js2
-rw-r--r--app/assets/javascripts/boards/models/list.js4
-rw-r--r--app/assets/javascripts/boards/mount_multiple_boards_switcher.js9
-rw-r--r--app/assets/javascripts/boards/stores/actions.js103
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js145
-rw-r--r--app/assets/javascripts/boards/stores/getters.js18
-rw-r--r--app/assets/javascripts/boards/stores/modal_store.js10
-rw-r--r--app/assets/javascripts/boards/stores/mutation_types.js4
-rw-r--r--app/assets/javascripts/boards/stores/mutations.js40
-rw-r--r--app/assets/javascripts/boards/stores/state.js8
55 files changed, 2246 insertions, 1415 deletions
diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js
index e5ff41dab74..965d3571f42 100644
--- a/app/assets/javascripts/boards/boards_util.js
+++ b/app/assets/javascripts/boards/boards_util.js
@@ -1,5 +1,4 @@
import { sortBy } from 'lodash';
-import axios from '~/lib/utils/axios_utils';
import { ListType } from './constants';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
@@ -42,14 +41,14 @@ export function formatListIssues(listIssues) {
const listData = listIssues.nodes.reduce((map, list) => {
listIssuesCount = list.issues.count;
- let sortedIssues = list.issues.edges.map(issueNode => ({
+ let sortedIssues = list.issues.edges.map((issueNode) => ({
...issueNode.node,
}));
sortedIssues = sortBy(sortedIssues, 'relativePosition');
return {
...map,
- [list.id]: sortedIssues.map(i => {
+ [list.id]: sortedIssues.map((i) => {
const id = getIdFromGraphQLId(i.id);
const listIssue = {
@@ -83,49 +82,64 @@ export function fullBoardId(boardId) {
return `gid://gitlab/Board/${boardId}`;
}
+export function fullIterationId(id) {
+ return `gid://gitlab/Iteration/${id}`;
+}
+
+export function fullUserId(id) {
+ return `gid://gitlab/User/${id}`;
+}
+
+export function fullMilestoneId(id) {
+ return `gid://gitlab/Milestone/${id}`;
+}
+
export function fullLabelId(label) {
- if (label.project_id !== null) {
+ if (label.project_id && label.project_id !== null) {
return `gid://gitlab/ProjectLabel/${label.id}`;
}
return `gid://gitlab/GroupLabel/${label.id}`;
}
+export function formatIssueInput(issueInput, boardConfig) {
+ const { labelIds = [], assigneeIds = [] } = issueInput;
+ const { labels, assigneeId, milestoneId } = boardConfig;
+
+ return {
+ milestoneId: milestoneId ? fullMilestoneId(milestoneId) : null,
+ ...issueInput,
+ labelIds: [...labelIds, ...(labels?.map((l) => fullLabelId(l)) || [])],
+ assigneeIds: [...assigneeIds, ...(assigneeId ? [fullUserId(assigneeId)] : [])],
+ };
+}
+
export function moveIssueListHelper(issue, fromList, toList) {
const updatedIssue = issue;
if (
toList.listType === ListType.label &&
- !updatedIssue.labels.find(label => label.id === toList.label.id)
+ !updatedIssue.labels.find((label) => label.id === toList.label.id)
) {
updatedIssue.labels.push(toList.label);
}
if (fromList?.label && fromList.listType === ListType.label) {
- updatedIssue.labels = updatedIssue.labels.filter(label => fromList.label.id !== label.id);
+ updatedIssue.labels = updatedIssue.labels.filter((label) => fromList.label.id !== label.id);
}
if (
toList.listType === ListType.assignee &&
- !updatedIssue.assignees.find(assignee => assignee.id === toList.assignee.id)
+ !updatedIssue.assignees.find((assignee) => assignee.id === toList.assignee.id)
) {
updatedIssue.assignees.push(toList.assignee);
}
if (fromList?.assignee && fromList.listType === ListType.assignee) {
updatedIssue.assignees = updatedIssue.assignees.filter(
- assignee => assignee.id !== fromList.assignee.id,
+ (assignee) => assignee.id !== fromList.assignee.id,
);
}
return updatedIssue;
}
-export function getBoardsPath(endpoint, board) {
- const path = `${endpoint}${board.id ? `/${board.id}` : ''}.json`;
-
- if (board.id) {
- return axios.put(path, { board });
- }
- return axios.post(path, { board });
-}
-
export function isListDraggable(list) {
return list.listType !== ListType.backlog && list.listType !== ListType.closed;
}
@@ -141,6 +155,6 @@ export default {
formatListIssues,
fullBoardId,
fullLabelId,
- getBoardsPath,
+ fullIterationId,
isListDraggable,
};
diff --git a/app/assets/javascripts/boards/components/board_assignee_dropdown.vue b/app/assets/javascripts/boards/components/board_assignee_dropdown.vue
index 1469efae5a6..5d381f9a570 100644
--- a/app/assets/javascripts/boards/components/board_assignee_dropdown.vue
+++ b/app/assets/javascripts/boards/components/board_assignee_dropdown.vue
@@ -119,7 +119,7 @@ export default {
this.selected = this.selected.concat(name);
},
unselect(name) {
- this.selected = this.selected.filter(user => user.username !== name);
+ this.selected = this.selected.filter((user) => user.username !== name);
},
saveAssignees() {
this.setAssignees(this.selectedUserNames);
diff --git a/app/assets/javascripts/boards/components/board_card_layout.vue b/app/assets/javascripts/boards/components/board_card_layout.vue
index f796acd2303..0a2301394c1 100644
--- a/app/assets/javascripts/boards/components/board_card_layout.vue
+++ b/app/assets/javascripts/boards/components/board_card_layout.vue
@@ -1,12 +1,17 @@
<script>
+import { mapActions, mapGetters } from 'vuex';
import IssueCardInner from './issue_card_inner.vue';
+import IssueCardInnerDeprecated from './issue_card_inner_deprecated.vue';
import boardsStore from '../stores/boards_store';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { ISSUABLE } from '~/boards/constants';
export default {
- name: 'BoardsIssueCard',
+ name: 'BoardCardLayout',
components: {
- IssueCardInner,
+ IssueCardInner: gon.features?.graphqlBoardLists ? IssueCardInner : IssueCardInnerDeprecated,
},
+ mixins: [glFeatureFlagMixin()],
props: {
list: {
type: Object,
@@ -41,11 +46,13 @@ export default {
};
},
computed: {
+ ...mapGetters(['isSwimlanesOn']),
multiSelectVisible() {
- return this.multiSelect.list.findIndex(issue => issue.id === this.issue.id) > -1;
+ return this.multiSelect.list.findIndex((issue) => issue.id === this.issue.id) > -1;
},
},
methods: {
+ ...mapActions(['setActiveId']),
mouseDown() {
this.showDetail = true;
},
@@ -56,6 +63,11 @@ export default {
// Don't do anything if this happened on a no trigger element
if (e.target.classList.contains('js-no-trigger')) return;
+ if (this.glFeatures.graphqlBoardLists || this.isSwimlanesOn) {
+ this.setActiveId({ id: this.issue.id, sidebarType: ISSUABLE });
+ return;
+ }
+
const isMultiSelect = e.ctrlKey || e.metaKey;
if (this.showDetail || isMultiSelect) {
@@ -80,7 +92,7 @@ export default {
:data-issue-iid="issue.iid"
:data-issue-path="issue.referencePath"
data-testid="board_card"
- class="board-card p-3 rounded"
+ class="board-card gl-p-5 gl-rounded-base"
@mousedown="mouseDown"
@mousemove="mouseMove"
@mouseup="showIssue($event)"
diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue
index 753e6941c43..9f0eef844f6 100644
--- a/app/assets/javascripts/boards/components/board_column.vue
+++ b/app/assets/javascripts/boards/components/board_column.vue
@@ -1,16 +1,19 @@
<script>
-// This component is being replaced in favor of './board_column_new.vue' for GraphQL boards
-import Sortable from 'sortablejs';
+import { mapGetters, mapActions, mapState } from 'vuex';
import BoardListHeader from 'ee_else_ce/boards/components/board_list_header.vue';
import BoardList from './board_list.vue';
-import boardsStore from '../stores/boards_store';
-import { getBoardSortableDefaultOptions, sortableEnd } from '../mixins/sortable_default_options';
+import { isListDraggable } from '../boards_util';
export default {
components: {
BoardListHeader,
BoardList,
},
+ inject: {
+ boardId: {
+ default: '',
+ },
+ },
props: {
list: {
type: Object,
@@ -27,58 +30,27 @@ export default {
default: false,
},
},
- inject: {
- boardId: {
- default: '',
- },
- },
- data() {
- return {
- detailIssue: boardsStore.detail,
- filter: boardsStore.filter,
- };
- },
computed: {
+ ...mapState(['filterParams']),
+ ...mapGetters(['getIssuesByList']),
listIssues() {
- return this.list.issues;
+ return this.getIssuesByList(this.list.id);
+ },
+ isListDraggable() {
+ return isListDraggable(this.list);
},
},
watch: {
- filter: {
+ filterParams: {
handler() {
- this.list.page = 1;
- this.list.getIssues(true).catch(() => {
- // TODO: handle request error
- });
+ this.fetchIssuesForList({ listId: this.list.id });
},
deep: true,
+ immediate: true,
},
},
- mounted() {
- const instance = this;
-
- const sortableOptions = getBoardSortableDefaultOptions({
- disabled: this.disabled,
- group: 'boards',
- draggable: '.is-draggable',
- handle: '.js-board-handle',
- onEnd(e) {
- sortableEnd();
-
- const sortable = this;
-
- if (e.newIndex !== undefined && e.oldIndex !== e.newIndex) {
- const order = sortable.toArray();
- const list = boardsStore.findList('id', parseInt(e.item.dataset.id, 10));
-
- instance.$nextTick(() => {
- boardsStore.moveList(list, order);
- });
- }
- },
- });
-
- Sortable.create(this.$el.parentNode, sortableOptions);
+ methods: {
+ ...mapActions(['fetchIssuesForList']),
},
};
</script>
@@ -86,20 +58,25 @@ export default {
<template>
<div
:class="{
- 'is-draggable': !list.preset,
- 'is-expandable': list.isExpandable,
- 'is-collapsed': !list.isExpanded,
- 'board-type-assignee': list.type === 'assignee',
+ 'is-draggable': isListDraggable,
+ 'is-collapsed': list.collapsed,
+ 'board-type-assignee': list.listType === 'assignee',
}"
:data-id="list.id"
- class="board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal"
+ class="board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal is-expandable"
data-qa-selector="board_list"
>
<div
class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base"
>
<board-list-header :can-admin-list="canAdminList" :list="list" :disabled="disabled" />
- <board-list ref="board-list" :disabled="disabled" :issues="listIssues" :list="list" />
+ <board-list
+ ref="board-list"
+ :disabled="disabled"
+ :issues="listIssues"
+ :list="list"
+ :can-admin-list="canAdminList"
+ />
</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
new file mode 100644
index 00000000000..35688efceb4
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_column_deprecated.vue
@@ -0,0 +1,105 @@
+<script>
+// This component is being replaced in favor of './board_column.vue' for GraphQL boards
+import Sortable from 'sortablejs';
+import BoardListHeader from 'ee_else_ce/boards/components/board_list_header_deprecated.vue';
+import BoardList from './board_list_deprecated.vue';
+import boardsStore from '../stores/boards_store';
+import { getBoardSortableDefaultOptions, sortableEnd } from '../mixins/sortable_default_options';
+
+export default {
+ components: {
+ BoardListHeader,
+ BoardList,
+ },
+ inject: {
+ boardId: {
+ default: '',
+ },
+ },
+ props: {
+ list: {
+ type: Object,
+ default: () => ({}),
+ required: false,
+ },
+ disabled: {
+ type: Boolean,
+ required: true,
+ },
+ canAdminList: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ detailIssue: boardsStore.detail,
+ filter: boardsStore.filter,
+ };
+ },
+ computed: {
+ listIssues() {
+ return this.list.issues;
+ },
+ },
+ watch: {
+ filter: {
+ handler() {
+ this.list.page = 1;
+ this.list.getIssues(true).catch(() => {
+ // TODO: handle request error
+ });
+ },
+ deep: true,
+ },
+ },
+ mounted() {
+ const instance = this;
+
+ const sortableOptions = getBoardSortableDefaultOptions({
+ disabled: this.disabled,
+ group: 'boards',
+ draggable: '.is-draggable',
+ handle: '.js-board-handle',
+ onEnd(e) {
+ sortableEnd();
+
+ const sortable = this;
+
+ if (e.newIndex !== undefined && e.oldIndex !== e.newIndex) {
+ const order = sortable.toArray();
+ const list = boardsStore.findList('id', parseInt(e.item.dataset.id, 10));
+
+ instance.$nextTick(() => {
+ boardsStore.moveList(list, order);
+ });
+ }
+ },
+ });
+
+ Sortable.create(this.$el.parentNode, sortableOptions);
+ },
+};
+</script>
+
+<template>
+ <div
+ :class="{
+ 'is-draggable': !list.preset,
+ 'is-expandable': list.isExpandable,
+ 'is-collapsed': !list.isExpanded,
+ 'board-type-assignee': list.type === 'assignee',
+ }"
+ :data-id="list.id"
+ class="board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal"
+ data-qa-selector="board_list"
+ >
+ <div
+ class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base"
+ >
+ <board-list-header :can-admin-list="canAdminList" :list="list" :disabled="disabled" />
+ <board-list ref="board-list" :disabled="disabled" :issues="listIssues" :list="list" />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/boards/components/board_column_new.vue b/app/assets/javascripts/boards/components/board_column_new.vue
deleted file mode 100644
index 7839f45c48b..00000000000
--- a/app/assets/javascripts/boards/components/board_column_new.vue
+++ /dev/null
@@ -1,82 +0,0 @@
-<script>
-import { mapGetters, mapActions, mapState } from 'vuex';
-import BoardListHeader from 'ee_else_ce/boards/components/board_list_header_new.vue';
-import BoardList from './board_list_new.vue';
-import { isListDraggable } from '../boards_util';
-
-export default {
- components: {
- BoardListHeader,
- BoardList,
- },
- props: {
- list: {
- type: Object,
- default: () => ({}),
- required: false,
- },
- disabled: {
- type: Boolean,
- required: true,
- },
- canAdminList: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- inject: {
- boardId: {
- default: '',
- },
- },
- computed: {
- ...mapState(['filterParams']),
- ...mapGetters(['getIssuesByList']),
- listIssues() {
- return this.getIssuesByList(this.list.id);
- },
- isListDraggable() {
- return isListDraggable(this.list);
- },
- },
- watch: {
- filterParams: {
- handler() {
- this.fetchIssuesForList({ listId: this.list.id });
- },
- deep: true,
- immediate: true,
- },
- },
- methods: {
- ...mapActions(['fetchIssuesForList']),
- },
-};
-</script>
-
-<template>
- <div
- :class="{
- 'is-draggable': isListDraggable,
- 'is-collapsed': list.collapsed,
- 'board-type-assignee': list.listType === 'assignee',
- }"
- :data-id="list.id"
- class="board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal is-expandable"
- data-qa-selector="board_list"
- >
- <div
- class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base"
- >
- <board-list-header :can-admin-list="canAdminList" :list="list" :disabled="disabled" />
- <board-list
- ref="board-list"
- :disabled="disabled"
- :issues="listIssues"
- :list="list"
- :can-admin-list="canAdminList"
- />
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/boards/components/board_configuration_options.vue b/app/assets/javascripts/boards/components/board_configuration_options.vue
index 99d1e4a2611..b8ee930a8c9 100644
--- a/app/assets/javascripts/boards/components/board_configuration_options.vue
+++ b/app/assets/javascripts/boards/components/board_configuration_options.vue
@@ -6,36 +6,13 @@ export default {
GlFormCheckbox,
},
props: {
- currentBoard: {
- type: Object,
- required: true,
- },
- board: {
- type: Object,
+ hideBacklogList: {
+ type: Boolean,
required: true,
},
- isNewForm: {
+ hideClosedList: {
type: Boolean,
- required: false,
- default: false,
- },
- },
- data() {
- const { hide_backlog_list: hideBacklogList, hide_closed_list: hideClosedList } = this.isNewForm
- ? this.board
- : this.currentBoard;
-
- return {
- hideClosedList,
- hideBacklogList,
- };
- },
- methods: {
- changeClosedList(checked) {
- this.board.hideClosedList = !checked;
- },
- changeBacklogList(checked) {
- this.board.hideBacklogList = !checked;
+ required: true,
},
},
};
@@ -52,13 +29,13 @@ export default {
<gl-form-checkbox
:checked="!hideBacklogList"
data-testid="backlog-list-checkbox"
- @change="changeBacklogList"
+ @change="$emit('update:hideBacklogList', !hideBacklogList)"
>{{ __('Show the Open list') }}
</gl-form-checkbox>
<gl-form-checkbox
:checked="!hideClosedList"
data-testid="closed-list-checkbox"
- @change="changeClosedList"
+ @change="$emit('update:hideClosedList', !hideClosedList)"
>{{ __('Show the Closed list') }}
</gl-form-checkbox>
</div>
diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue
index b366aa6fdb3..19254343208 100644
--- a/app/assets/javascripts/boards/components/board_content.vue
+++ b/app/assets/javascripts/boards/components/board_content.vue
@@ -3,15 +3,15 @@ import Draggable from 'vuedraggable';
import { mapState, mapGetters, mapActions } from 'vuex';
import { sortBy } from 'lodash';
import { GlAlert } from '@gitlab/ui';
+import BoardColumnDeprecated from './board_column_deprecated.vue';
import BoardColumn from './board_column.vue';
-import BoardColumnNew from './board_column_new.vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import defaultSortableConfig from '~/sortable/sortable_config';
import { sortableEnd, sortableStart } from '~/boards/mixins/sortable_default_options';
export default {
components: {
- BoardColumn: gon.features?.graphqlBoardLists ? BoardColumnNew : BoardColumn,
+ BoardColumn: gon.features?.graphqlBoardLists ? BoardColumn : BoardColumnDeprecated,
BoardContentSidebar: () => import('ee_component/boards/components/board_content_sidebar.vue'),
EpicsSwimlanes: () => import('ee_component/boards/components/epics_swimlanes.vue'),
GlAlert,
@@ -20,7 +20,8 @@ export default {
props: {
lists: {
type: Array,
- required: true,
+ required: false,
+ default: () => [],
},
canAdminList: {
type: Boolean,
@@ -53,7 +54,7 @@ export default {
fallbackOnBody: false,
group: 'boards-list',
tag: 'div',
- value: this.lists,
+ value: this.boardListsToUse,
};
return this.canDragColumns ? options : {};
@@ -108,14 +109,14 @@ export default {
/>
</component>
- <template v-else>
- <epics-swimlanes
- ref="swimlanes"
- :lists="boardListsToUse"
- :can-admin-list="canAdminList"
- :disabled="disabled"
- />
- <board-content-sidebar />
- </template>
+ <epics-swimlanes
+ v-else
+ ref="swimlanes"
+ :lists="boardListsToUse"
+ :can-admin-list="canAdminList"
+ :disabled="disabled"
+ />
+
+ <board-content-sidebar v-if="isSwimlanesOn || glFeatures.graphqlBoardLists" />
</div>
</template>
diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue
index dab934352ca..c701ecd3040 100644
--- a/app/assets/javascripts/boards/components/board_form.vue
+++ b/app/assets/javascripts/boards/components/board_form.vue
@@ -1,20 +1,24 @@
<script>
import { GlModal } from '@gitlab/ui';
-import { pick } from 'lodash';
import { __, s__ } from '~/locale';
import { deprecatedCreateFlash as Flash } from '~/flash';
import { visitUrl } from '~/lib/utils/url_utility';
+import { getParameterByName } from '~/lib/utils/common_utils';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
import boardsStore from '~/boards/stores/boards_store';
-import { fullBoardId, getBoardsPath } from '../boards_util';
+import { fullLabelId, fullBoardId } from '../boards_util';
import BoardConfigurationOptions from './board_configuration_options.vue';
-import createBoardMutation from '../graphql/board.mutation.graphql';
+import updateBoardMutation from '../graphql/board_update.mutation.graphql';
+import createBoardMutation from '../graphql/board_create.mutation.graphql';
+import destroyBoardMutation from '../graphql/board_destroy.mutation.graphql';
const boardDefaults = {
id: false,
name: '',
labels: [],
milestone_id: undefined,
+ iteration_id: undefined,
assignee: {},
assignee_id: undefined,
weight: null,
@@ -46,6 +50,14 @@ export default {
GlModal,
BoardConfigurationOptions,
},
+ inject: {
+ fullPath: {
+ default: '',
+ },
+ rootPath: {
+ default: '',
+ },
+ },
props: {
canAdminBoard: {
type: Boolean,
@@ -89,11 +101,6 @@ export default {
required: true,
},
},
- inject: {
- endpoints: {
- default: {},
- },
- },
data() {
return {
board: { ...boardDefaults, ...this.currentBoard },
@@ -154,14 +161,44 @@ export default {
text: this.$options.i18n.cancelButtonText,
};
},
- boardPayload() {
- const { assignee, milestone, labels } = this.board;
- return {
- ...this.board,
- assignee_id: assignee?.id,
- milestone_id: milestone?.id,
- label_ids: labels.length ? labels.map(b => b.id) : [''],
+ currentMutation() {
+ return this.board.id ? updateBoardMutation : createBoardMutation;
+ },
+ mutationVariables() {
+ const { board } = this;
+ /* eslint-disable @gitlab/require-i18n-strings */
+ let baseMutationVariables = {
+ name: board.name,
+ hideBacklogList: board.hide_backlog_list,
+ hideClosedList: board.hide_closed_list,
};
+
+ if (this.scopedIssueBoardFeatureEnabled) {
+ baseMutationVariables = {
+ ...baseMutationVariables,
+ weight: board.weight,
+ assigneeId: board.assignee?.id ? convertToGraphQLId('User', board.assignee.id) : null,
+ milestoneId:
+ board.milestone?.id || board.milestone?.id === 0
+ ? convertToGraphQLId('Milestone', board.milestone.id)
+ : null,
+ labelIds: board.labels.map(fullLabelId),
+ iterationId: board.iteration_id
+ ? convertToGraphQLId('Iteration', board.iteration_id)
+ : null,
+ };
+ }
+ /* eslint-enable @gitlab/require-i18n-strings */
+ return board.id
+ ? {
+ ...baseMutationVariables,
+ id: fullBoardId(board.id),
+ }
+ : {
+ ...baseMutationVariables,
+ projectPath: this.projectId ? this.fullPath : null,
+ groupPath: this.groupId ? this.fullPath : null,
+ };
},
},
mounted() {
@@ -171,55 +208,51 @@ export default {
}
},
methods: {
- callBoardMutation(id) {
- return this.$apollo.mutate({
- mutation: createBoardMutation,
- variables: {
- ...pick(this.boardPayload, ['hideClosedList', 'hideBacklogList']),
- id,
- },
- });
+ setIteration(iterationId) {
+ this.board.iteration_id = iterationId;
},
- async updateBoard() {
- const responses = await Promise.all([
- // Remove unnecessary REST API call when https://gitlab.com/gitlab-org/gitlab/-/issues/282299#note_462996301 is resolved
- getBoardsPath(this.endpoints.boardsEndpoint, this.boardPayload),
- this.callBoardMutation(fullBoardId(this.boardPayload.id)),
- ]);
+ async createOrUpdateBoard() {
+ const response = await this.$apollo.mutate({
+ mutation: this.currentMutation,
+ variables: { input: this.mutationVariables },
+ });
- return responses[0].data;
- },
- async createBoard() {
- // TODO: change this to use `createBoard` mutation https://gitlab.com/gitlab-org/gitlab/-/issues/292466 is resolved
- const boardData = await getBoardsPath(this.endpoints.boardsEndpoint, this.boardPayload);
- this.callBoardMutation(fullBoardId(boardData.data.id));
+ if (!this.board.id) {
+ return response.data.createBoard.board.webPath;
+ }
- return boardData.data || boardData;
+ const path = response.data.updateBoard.board.webPath;
+ const param = getParameterByName('group_by')
+ ? `?group_by=${getParameterByName('group_by')}`
+ : '';
+ return `${path}${param}`;
},
- submit() {
+ async submit() {
if (this.board.name.length === 0) return;
this.isLoading = true;
if (this.isDeleteForm) {
- boardsStore
- .deleteBoard(this.currentBoard)
- .then(() => {
- this.isLoading = false;
- visitUrl(boardsStore.rootPath);
- })
- .catch(() => {
- Flash(this.$options.i18n.deleteErrorMessage);
- this.isLoading = false;
+ try {
+ await this.$apollo.mutate({
+ mutation: destroyBoardMutation,
+ variables: {
+ id: fullBoardId(this.board.id),
+ },
});
+ visitUrl(this.rootPath);
+ } catch {
+ Flash(this.$options.i18n.deleteErrorMessage);
+ } finally {
+ this.isLoading = false;
+ }
} else {
- const boardAction = this.boardPayload.id ? this.updateBoard : this.createBoard;
- boardAction()
- .then(data => {
- visitUrl(data.board_path);
- })
- .catch(() => {
- Flash(this.$options.i18n.saveErrorMessage);
- this.isLoading = false;
- });
+ try {
+ const url = await this.createOrUpdateBoard();
+ visitUrl(url);
+ } catch {
+ Flash(this.$options.i18n.saveErrorMessage);
+ } finally {
+ this.isLoading = false;
+ }
}
},
cancel() {
@@ -273,9 +306,8 @@ export default {
</div>
<board-configuration-options
- :is-new-form="isNewForm"
- :board="board"
- :current-board="currentBoard"
+ :hide-backlog-list.sync="board.hide_backlog_list"
+ :hide-closed-list.sync="board.hide_closed_list"
/>
<board-scope
@@ -289,6 +321,7 @@ export default {
:project-id="projectId"
:group-id="groupId"
:weights="weights"
+ @set-iteration="setIteration"
/>
</form>
</gl-modal>
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index 1f87b563e73..b6e4d0980fa 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -1,27 +1,24 @@
<script>
-import { Sortable, MultiDrag } from 'sortablejs';
+import Draggable from 'vuedraggable';
+import { mapActions, mapState } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
-import boardNewIssue from './board_new_issue.vue';
-import boardCard from './board_card.vue';
+import defaultSortableConfig from '~/sortable/sortable_config';
+import { sortableStart, sortableEnd } from '~/boards/mixins/sortable_default_options';
+import BoardNewIssue from './board_new_issue.vue';
+import BoardCard from './board_card.vue';
import eventHub from '../eventhub';
-import boardsStore from '../stores/boards_store';
import { sprintf, __ } from '~/locale';
-import { deprecatedCreateFlash as createFlash } from '~/flash';
-import {
- getBoardSortableDefaultOptions,
- sortableStart,
- sortableEnd,
-} from '../mixins/sortable_default_options';
-
-// This component is being replaced in favor of './board_list_new.vue' for GraphQL boards
-
-Sortable.mount(new MultiDrag());
export default {
name: 'BoardList',
+ i18n: {
+ loadingIssues: __('Loading issues'),
+ loadingMoreissues: __('Loading more issues'),
+ showingAllIssues: __('Showing all issues'),
+ },
components: {
- boardCard,
- boardNewIssue,
+ BoardCard,
+ BoardNewIssue,
GlLoadingIcon,
},
props: {
@@ -37,55 +34,67 @@ export default {
type: Array,
required: true,
},
+ canAdminList: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
scrollOffset: 250,
- filters: boardsStore.state.filters,
showCount: false,
showIssueForm: false,
};
},
computed: {
+ ...mapState(['pageInfoByListId', 'listsFlags']),
paginatedIssueText() {
return sprintf(__('Showing %{pageSize} of %{total} issues'), {
- pageSize: this.list.issues.length,
- total: this.list.issuesSize,
+ pageSize: this.issues.length,
+ total: this.list.issuesCount,
});
},
issuesSizeExceedsMax() {
- return this.list.maxIssueCount > 0 && this.list.issuesSize > this.list.maxIssueCount;
+ return this.list.maxIssueCount > 0 && this.list.issuesCount > this.list.maxIssueCount;
+ },
+ hasNextPage() {
+ return this.pageInfoByListId[this.list.id].hasNextPage;
},
loading() {
- return this.list.loading;
+ return this.listsFlags[this.list.id]?.isLoading;
+ },
+ loadingMore() {
+ return this.listsFlags[this.list.id]?.isLoadingMore;
+ },
+ listRef() {
+ // When list is draggable, the reference to the list needs to be accessed differently
+ return this.canAdminList ? this.$refs.list.$el : this.$refs.list;
+ },
+ showingAllIssues() {
+ return this.issues.length === this.list.issuesCount;
+ },
+ treeRootWrapper() {
+ return this.canAdminList ? Draggable : 'ul';
+ },
+ treeRootOptions() {
+ const options = {
+ ...defaultSortableConfig,
+ fallbackOnBody: false,
+ group: 'board-list',
+ tag: 'ul',
+ 'ghost-class': 'board-card-drag-active',
+ 'data-list-id': this.list.id,
+ value: this.issues,
+ };
+
+ return this.canAdminList ? options : {};
},
},
watch: {
- filters: {
- handler() {
- this.list.loadingMore = false;
- this.$refs.list.scrollTop = 0;
- },
- deep: true,
- },
issues() {
this.$nextTick(() => {
- if (
- this.scrollHeight() <= this.listHeight() &&
- this.list.issuesSize > this.list.issues.length &&
- this.list.isExpanded
- ) {
- this.list.page += 1;
- this.list.getIssues(false).catch(() => {
- // TODO: handle request error
- });
- }
-
- if (this.scrollHeight() > Math.ceil(this.listHeight())) {
- this.showCount = true;
- } else {
- this.showCount = false;
- }
+ this.showCount = this.scrollHeight() > Math.ceil(this.listHeight());
});
},
},
@@ -94,315 +103,90 @@ export default {
eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop);
},
mounted() {
- // TODO: Use Draggable in ./board_list_new.vue to drag & drop issue
- // https://gitlab.com/gitlab-org/gitlab/-/issues/218164
- const multiSelectOpts = {
- multiDrag: true,
- selectedClass: 'js-multi-select',
- animation: 500,
- };
-
- const options = getBoardSortableDefaultOptions({
- scroll: true,
- disabled: this.disabled,
- filter: '.board-list-count, .is-disabled',
- dataIdAttr: 'data-issue-id',
- removeCloneOnHide: false,
- ...multiSelectOpts,
- group: {
- name: 'issues',
- /**
- * Dynamically determine between which containers
- * items can be moved or copied as
- * Assignee lists (EE feature) require this behavior
- */
- pull: (to, from, dragEl, e) => {
- // As per Sortable's docs, `to` should provide
- // reference to exact sortable container on which
- // we're trying to drag element, but either it is
- // a library's bug or our markup structure is too complex
- // that `to` never points to correct container
- // See https://github.com/RubaXa/Sortable/issues/1037
- //
- // So we use `e.target` which is always accurate about
- // which element we're currently dragging our card upon
- // So from there, we can get reference to actual container
- // and thus the container type to enable Copy or Move
- if (e.target) {
- const containerEl =
- e.target.closest('.js-board-list') || e.target.querySelector('.js-board-list');
- const toBoardType = containerEl.dataset.boardType;
- const cloneActions = {
- label: ['milestone', 'assignee'],
- assignee: ['milestone', 'label'],
- milestone: ['label', 'assignee'],
- };
-
- if (toBoardType) {
- const fromBoardType = this.list.type;
- // For each list we check if the destination list is
- // a the list were we should clone the issue
- const shouldClone = Object.entries(cloneActions).some(
- entry => fromBoardType === entry[0] && entry[1].includes(toBoardType),
- );
-
- if (shouldClone) {
- return 'clone';
- }
- }
- }
-
- return true;
- },
- revertClone: true,
- },
- onStart: e => {
- const card = this.$refs.issue[e.oldIndex];
-
- card.showDetail = false;
-
- const { list } = card;
-
- const issue = list.findIssue(Number(e.item.dataset.issueId));
-
- boardsStore.startMoving(list, issue);
-
- this.$root.$emit('bv::hide::tooltip');
-
- sortableStart();
- },
- onAdd: e => {
- const { items = [], newIndicies = [] } = e;
- if (items.length) {
- // Not using e.newIndex here instead taking a min of all
- // the newIndicies. Basically we have to find that during
- // a drop what is the index we're going to start putting
- // all the dropped elements from.
- const newIndex = Math.min(...newIndicies.map(obj => obj.index).filter(i => i !== -1));
- const issues = items.map(item =>
- boardsStore.moving.list.findIssue(Number(item.dataset.issueId)),
- );
-
- boardsStore.moveMultipleIssuesToList({
- listFrom: boardsStore.moving.list,
- listTo: this.list,
- issues,
- newIndex,
- });
- } else {
- boardsStore.moveIssueToList(
- boardsStore.moving.list,
- this.list,
- boardsStore.moving.issue,
- e.newIndex,
- );
- this.$nextTick(() => {
- e.item.remove();
- });
- }
- },
- onUpdate: e => {
- const sortedArray = this.sortable.toArray().filter(id => id !== '-1');
-
- const { items = [], newIndicies = [], oldIndicies = [] } = e;
- if (items.length) {
- const newIndex = Math.min(...newIndicies.map(obj => obj.index));
- const issues = items.map(item =>
- boardsStore.moving.list.findIssue(Number(item.dataset.issueId)),
- );
- boardsStore.moveMultipleIssuesInList({
- list: this.list,
- issues,
- oldIndicies: oldIndicies.map(obj => obj.index),
- newIndex,
- idArray: sortedArray,
- });
- e.items.forEach(el => {
- Sortable.utils.deselect(el);
- });
- boardsStore.clearMultiSelect();
- return;
- }
-
- boardsStore.moveIssueInList(
- this.list,
- boardsStore.moving.issue,
- e.oldIndex,
- e.newIndex,
- sortedArray,
- );
- },
- onEnd: e => {
- const { items = [], clones = [], to } = e;
-
- // This is not a multi select operation
- if (!items.length && !clones.length) {
- sortableEnd();
- return;
- }
-
- let toList;
- if (to) {
- const containerEl = to.closest('.js-board-list');
- toList = boardsStore.findList('id', Number(containerEl.dataset.board), '');
- }
-
- /**
- * onEnd is called irrespective if the cards were moved in the
- * same list or the other list. Don't remove items if it's same list.
- */
- const isSameList = toList && toList.id === this.list.id;
- if (toList && !isSameList && boardsStore.shouldRemoveIssue(this.list, toList)) {
- const issues = items.map(item => this.list.findIssue(Number(item.dataset.issueId)));
- if (
- issues.filter(Boolean).length &&
- !boardsStore.issuesAreContiguous(this.list, issues)
- ) {
- const indexes = [];
- const ids = this.list.issues.map(i => i.id);
- issues.forEach(issue => {
- const index = ids.indexOf(issue.id);
- if (index > -1) {
- indexes.push(index);
- }
- });
-
- // Descending sort because splice would cause index discrepancy otherwise
- const sortedIndexes = indexes.sort((a, b) => (a < b ? 1 : -1));
-
- sortedIndexes.forEach(i => {
- /**
- * **setTimeout and splice each element one-by-one in a loop
- * is intended.**
- *
- * The problem here is all the indexes are in the list but are
- * non-contiguous. Due to that, when we splice all the indexes,
- * at once, Vue -- during a re-render -- is unable to find reference
- * nodes and the entire app crashes.
- *
- * If the indexes are contiguous, this piece of code is not
- * executed. If it is, this is a possible regression. Only when
- * issue indexes are far apart, this logic should ever kick in.
- */
- setTimeout(() => {
- this.list.issues.splice(i, 1);
- }, 0);
- });
- }
- }
-
- if (!toList) {
- createFlash(__('Something went wrong while performing the action.'));
- }
-
- if (!isSameList) {
- boardsStore.clearMultiSelect();
-
- // Since Vue's list does not re-render the same keyed item, we'll
- // remove `multi-select` class to express it's unselected
- if (clones && clones.length) {
- clones.forEach(el => el.classList.remove('multi-select'));
- }
-
- // Due to some bug which I am unable to figure out
- // Sortable does not deselect some pending items from the
- // source list.
- // We'll just do it forcefully here.
- Array.from(document.querySelectorAll('.js-multi-select') || []).forEach(item => {
- Sortable.utils.deselect(item);
- });
-
- /**
- * SortableJS leaves all the moving items "as is" on the DOM.
- * Vue picks up and rehydrates the DOM, but we need to explicity
- * remove the "trash" items from the DOM.
- *
- * This is in parity to the logic on single item move from a list/in
- * a list. For reference, look at the implementation of onAdd method.
- */
- this.$nextTick(() => {
- if (items && items.length) {
- items.forEach(item => {
- item.remove();
- });
- }
- });
- }
- sortableEnd();
- },
- onMove(e) {
- return !e.related.classList.contains('board-list-count');
- },
- onSelect(e) {
- const {
- item: { classList },
- } = e;
-
- if (
- classList &&
- classList.contains('js-multi-select') &&
- !classList.contains('multi-select')
- ) {
- Sortable.utils.deselect(e.item);
- }
- },
- onDeselect: e => {
- const {
- item: { dataset, classList },
- } = e;
-
- if (
- classList &&
- classList.contains('multi-select') &&
- !classList.contains('js-multi-select')
- ) {
- const issue = this.list.findIssue(Number(dataset.issueId));
- boardsStore.toggleMultiSelect(issue);
- }
- },
- });
-
- this.sortable = Sortable.create(this.$refs.list, options);
-
// Scroll event on list to load more
- this.$refs.list.addEventListener('scroll', this.onScroll);
+ this.listRef.addEventListener('scroll', this.onScroll);
},
beforeDestroy() {
eventHub.$off(`toggle-issue-form-${this.list.id}`, this.toggleForm);
eventHub.$off(`scroll-board-list-${this.list.id}`, this.scrollToTop);
- this.$refs.list.removeEventListener('scroll', this.onScroll);
+ this.listRef.removeEventListener('scroll', this.onScroll);
},
methods: {
+ ...mapActions(['fetchIssuesForList', 'moveIssue']),
listHeight() {
- return this.$refs.list.getBoundingClientRect().height;
+ return this.listRef.getBoundingClientRect().height;
},
scrollHeight() {
- return this.$refs.list.scrollHeight;
+ return this.listRef.scrollHeight;
},
scrollTop() {
- return this.$refs.list.scrollTop + this.listHeight();
+ return this.listRef.scrollTop + this.listHeight();
},
scrollToTop() {
- this.$refs.list.scrollTop = 0;
+ this.listRef.scrollTop = 0;
},
loadNextPage() {
- const getIssues = this.list.nextPage();
- const loadingDone = () => {
- this.list.loadingMore = false;
- };
-
- if (getIssues) {
- this.list.loadingMore = true;
- getIssues.then(loadingDone).catch(loadingDone);
- }
+ this.fetchIssuesForList({ listId: this.list.id, fetchNext: true });
},
toggleForm() {
this.showIssueForm = !this.showIssueForm;
},
onScroll() {
- if (!this.list.loadingMore && this.scrollTop() > this.scrollHeight() - this.scrollOffset) {
- this.loadNextPage();
+ window.requestAnimationFrame(() => {
+ if (
+ !this.loadingMore &&
+ this.scrollTop() > this.scrollHeight() - this.scrollOffset &&
+ this.hasNextPage
+ ) {
+ this.loadNextPage();
+ }
+ });
+ },
+ handleDragOnStart() {
+ sortableStart();
+ },
+ handleDragOnEnd(params) {
+ sortableEnd();
+ const { newIndex, oldIndex, from, to, item } = params;
+ const { issueId, issueIid, issuePath } = item.dataset;
+ const { children } = to;
+ let moveBeforeId;
+ let moveAfterId;
+
+ const getIssueId = (el) => Number(el.dataset.issueId);
+
+ // If issue is being moved within the same list
+ if (from === to) {
+ if (newIndex > oldIndex && children.length > 1) {
+ // If issue is being moved down we look for the issue that ends up before
+ moveBeforeId = getIssueId(children[newIndex]);
+ } else if (newIndex < oldIndex && children.length > 1) {
+ // If issue is being moved up we look for the issue that ends up after
+ moveAfterId = getIssueId(children[newIndex]);
+ } else {
+ // If issue remains in the same list at the same position we do nothing
+ return;
+ }
+ } else {
+ // We look for the issue that ends up before the moved issue if it exists
+ if (children[newIndex - 1]) {
+ moveBeforeId = getIssueId(children[newIndex - 1]);
+ }
+ // We look for the issue that ends up after the moved issue if it exists
+ if (children[newIndex]) {
+ moveAfterId = getIssueId(children[newIndex]);
+ }
}
+
+ this.moveIssue({
+ issueId,
+ issueIid,
+ issuePath,
+ fromListId: from.dataset.listId,
+ toListId: to.dataset.listId,
+ moveBeforeId,
+ moveAfterId,
+ });
},
},
};
@@ -410,21 +194,31 @@ export default {
<template>
<div
- :class="{ 'd-none': !list.isExpanded, 'd-flex flex-column': list.isExpanded }"
- class="board-list-component position-relative h-100"
+ v-show="!list.collapsed"
+ class="board-list-component gl-relative gl-h-full gl-display-flex gl-flex-direction-column"
data-qa-selector="board_list_cards_area"
>
- <div v-if="loading" class="board-list-loading text-center" :aria-label="__('Loading issues')">
+ <div
+ v-if="loading"
+ class="gl-mt-4 gl-text-center"
+ :aria-label="$options.i18n.loadingIssues"
+ data-testid="board_list_loading"
+ >
<gl-loading-icon />
</div>
- <board-new-issue v-if="list.type !== 'closed' && showIssueForm" :list="list" />
- <ul
+ <board-new-issue v-if="list.listType !== 'closed' && showIssueForm" :list="list" />
+ <component
+ :is="treeRootWrapper"
v-show="!loading"
ref="list"
+ v-bind="treeRootOptions"
:data-board="list.id"
- :data-board-type="list.type"
- :class="{ 'is-smaller': showIssueForm, 'bg-danger-100': issuesSizeExceedsMax }"
- class="board-list w-100 h-100 list-unstyled mb-0 p-1 js-board-list"
+ :data-board-type="list.listType"
+ :class="{ 'bg-danger-100': issuesSizeExceedsMax }"
+ 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"
+ @end="handleDragOnEnd"
>
<board-card
v-for="(issue, index) in issues"
@@ -435,11 +229,11 @@ export default {
:issue="issue"
:disabled="disabled"
/>
- <li v-if="showCount" class="board-list-count text-center" data-issue-id="-1">
- <gl-loading-icon v-show="list.loadingMore" label="Loading more issues" />
- <span v-if="list.issues.length === list.issuesSize">{{ __('Showing all issues') }}</span>
+ <li v-if="showCount" class="board-list-count gl-text-center" data-issue-id="-1">
+ <gl-loading-icon v-if="loadingMore" :label="$options.i18n.loadingMoreissues" />
+ <span v-if="showingAllIssues">{{ $options.i18n.showingAllIssues }}</span>
<span v-else>{{ paginatedIssueText }}</span>
</li>
- </ul>
+ </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
new file mode 100644
index 00000000000..24900346bda
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_list_deprecated.vue
@@ -0,0 +1,443 @@
+<script>
+import { Sortable, MultiDrag } from 'sortablejs';
+import { GlLoadingIcon } from '@gitlab/ui';
+import boardNewIssue from './board_new_issue_deprecated.vue';
+import boardCard from './board_card.vue';
+import eventHub from '../eventhub';
+import boardsStore from '../stores/boards_store';
+import { sprintf, __ } from '~/locale';
+import { deprecatedCreateFlash as createFlash } from '~/flash';
+import {
+ getBoardSortableDefaultOptions,
+ sortableStart,
+ sortableEnd,
+} from '../mixins/sortable_default_options';
+
+// This component is being replaced in favor of './board_list.vue' for GraphQL boards
+
+Sortable.mount(new MultiDrag());
+
+export default {
+ name: 'BoardList',
+ components: {
+ boardCard,
+ boardNewIssue,
+ GlLoadingIcon,
+ },
+ props: {
+ disabled: {
+ type: Boolean,
+ required: true,
+ },
+ list: {
+ type: Object,
+ required: true,
+ },
+ issues: {
+ type: Array,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ scrollOffset: 250,
+ filters: boardsStore.state.filters,
+ showCount: false,
+ showIssueForm: false,
+ };
+ },
+ computed: {
+ paginatedIssueText() {
+ return sprintf(__('Showing %{pageSize} of %{total} issues'), {
+ pageSize: this.list.issues.length,
+ total: this.list.issuesSize,
+ });
+ },
+ issuesSizeExceedsMax() {
+ return this.list.maxIssueCount > 0 && this.list.issuesSize > this.list.maxIssueCount;
+ },
+ loading() {
+ return this.list.loading;
+ },
+ },
+ watch: {
+ filters: {
+ handler() {
+ this.list.loadingMore = false;
+ this.$refs.list.scrollTop = 0;
+ },
+ deep: true,
+ },
+ issues() {
+ this.$nextTick(() => {
+ if (
+ this.scrollHeight() <= this.listHeight() &&
+ this.list.issuesSize > this.list.issues.length &&
+ this.list.isExpanded
+ ) {
+ this.list.page += 1;
+ this.list.getIssues(false).catch(() => {
+ // TODO: handle request error
+ });
+ }
+
+ if (this.scrollHeight() > Math.ceil(this.listHeight())) {
+ this.showCount = true;
+ } else {
+ this.showCount = false;
+ }
+ });
+ },
+ },
+ created() {
+ eventHub.$on(`toggle-issue-form-${this.list.id}`, this.toggleForm);
+ eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop);
+ },
+ mounted() {
+ const multiSelectOpts = {
+ multiDrag: true,
+ selectedClass: 'js-multi-select',
+ animation: 500,
+ };
+
+ const options = getBoardSortableDefaultOptions({
+ scroll: true,
+ disabled: this.disabled,
+ filter: '.board-list-count, .is-disabled',
+ dataIdAttr: 'data-issue-id',
+ removeCloneOnHide: false,
+ ...multiSelectOpts,
+ group: {
+ name: 'issues',
+ /**
+ * Dynamically determine between which containers
+ * items can be moved or copied as
+ * Assignee lists (EE feature) require this behavior
+ */
+ pull: (to, from, dragEl, e) => {
+ // As per Sortable's docs, `to` should provide
+ // reference to exact sortable container on which
+ // we're trying to drag element, but either it is
+ // a library's bug or our markup structure is too complex
+ // that `to` never points to correct container
+ // See https://github.com/RubaXa/Sortable/issues/1037
+ //
+ // So we use `e.target` which is always accurate about
+ // which element we're currently dragging our card upon
+ // So from there, we can get reference to actual container
+ // and thus the container type to enable Copy or Move
+ if (e.target) {
+ const containerEl =
+ e.target.closest('.js-board-list') || e.target.querySelector('.js-board-list');
+ const toBoardType = containerEl.dataset.boardType;
+ const cloneActions = {
+ label: ['milestone', 'assignee'],
+ assignee: ['milestone', 'label'],
+ milestone: ['label', 'assignee'],
+ };
+
+ if (toBoardType) {
+ const fromBoardType = this.list.type;
+ // For each list we check if the destination list is
+ // a the list were we should clone the issue
+ const shouldClone = Object.entries(cloneActions).some(
+ (entry) => fromBoardType === entry[0] && entry[1].includes(toBoardType),
+ );
+
+ if (shouldClone) {
+ return 'clone';
+ }
+ }
+ }
+
+ return true;
+ },
+ revertClone: true,
+ },
+ onStart: (e) => {
+ const card = this.$refs.issue[e.oldIndex];
+
+ card.showDetail = false;
+
+ const { list } = card;
+
+ const issue = list.findIssue(Number(e.item.dataset.issueId));
+
+ boardsStore.startMoving(list, issue);
+
+ this.$root.$emit('bv::hide::tooltip');
+
+ sortableStart();
+ },
+ onAdd: (e) => {
+ const { items = [], newIndicies = [] } = e;
+ if (items.length) {
+ // Not using e.newIndex here instead taking a min of all
+ // the newIndicies. Basically we have to find that during
+ // a drop what is the index we're going to start putting
+ // all the dropped elements from.
+ const newIndex = Math.min(...newIndicies.map((obj) => obj.index).filter((i) => i !== -1));
+ const issues = items.map((item) =>
+ boardsStore.moving.list.findIssue(Number(item.dataset.issueId)),
+ );
+
+ boardsStore.moveMultipleIssuesToList({
+ listFrom: boardsStore.moving.list,
+ listTo: this.list,
+ issues,
+ newIndex,
+ });
+ } else {
+ boardsStore.moveIssueToList(
+ boardsStore.moving.list,
+ this.list,
+ boardsStore.moving.issue,
+ e.newIndex,
+ );
+ this.$nextTick(() => {
+ e.item.remove();
+ });
+ }
+ },
+ onUpdate: (e) => {
+ const sortedArray = this.sortable.toArray().filter((id) => id !== '-1');
+
+ const { items = [], newIndicies = [], oldIndicies = [] } = e;
+ if (items.length) {
+ const newIndex = Math.min(...newIndicies.map((obj) => obj.index));
+ const issues = items.map((item) =>
+ boardsStore.moving.list.findIssue(Number(item.dataset.issueId)),
+ );
+ boardsStore.moveMultipleIssuesInList({
+ list: this.list,
+ issues,
+ oldIndicies: oldIndicies.map((obj) => obj.index),
+ newIndex,
+ idArray: sortedArray,
+ });
+ e.items.forEach((el) => {
+ Sortable.utils.deselect(el);
+ });
+ boardsStore.clearMultiSelect();
+ return;
+ }
+
+ boardsStore.moveIssueInList(
+ this.list,
+ boardsStore.moving.issue,
+ e.oldIndex,
+ e.newIndex,
+ sortedArray,
+ );
+ },
+ onEnd: (e) => {
+ const { items = [], clones = [], to } = e;
+
+ // This is not a multi select operation
+ if (!items.length && !clones.length) {
+ sortableEnd();
+ return;
+ }
+
+ let toList;
+ if (to) {
+ const containerEl = to.closest('.js-board-list');
+ toList = boardsStore.findList('id', Number(containerEl.dataset.board));
+ }
+
+ /**
+ * onEnd is called irrespective if the cards were moved in the
+ * same list or the other list. Don't remove items if it's same list.
+ */
+ const isSameList = toList && toList.id === this.list.id;
+ if (toList && !isSameList && boardsStore.shouldRemoveIssue(this.list, toList)) {
+ const issues = items.map((item) => this.list.findIssue(Number(item.dataset.issueId)));
+ if (
+ issues.filter(Boolean).length &&
+ !boardsStore.issuesAreContiguous(this.list, issues)
+ ) {
+ const indexes = [];
+ const ids = this.list.issues.map((i) => i.id);
+ issues.forEach((issue) => {
+ const index = ids.indexOf(issue.id);
+ if (index > -1) {
+ indexes.push(index);
+ }
+ });
+
+ // Descending sort because splice would cause index discrepancy otherwise
+ const sortedIndexes = indexes.sort((a, b) => (a < b ? 1 : -1));
+
+ sortedIndexes.forEach((i) => {
+ /**
+ * **setTimeout and splice each element one-by-one in a loop
+ * is intended.**
+ *
+ * The problem here is all the indexes are in the list but are
+ * non-contiguous. Due to that, when we splice all the indexes,
+ * at once, Vue -- during a re-render -- is unable to find reference
+ * nodes and the entire app crashes.
+ *
+ * If the indexes are contiguous, this piece of code is not
+ * executed. If it is, this is a possible regression. Only when
+ * issue indexes are far apart, this logic should ever kick in.
+ */
+ setTimeout(() => {
+ this.list.issues.splice(i, 1);
+ }, 0);
+ });
+ }
+ }
+
+ if (!toList) {
+ createFlash(__('Something went wrong while performing the action.'));
+ }
+
+ if (!isSameList) {
+ boardsStore.clearMultiSelect();
+
+ // Since Vue's list does not re-render the same keyed item, we'll
+ // remove `multi-select` class to express it's unselected
+ if (clones && clones.length) {
+ clones.forEach((el) => el.classList.remove('multi-select'));
+ }
+
+ // Due to some bug which I am unable to figure out
+ // Sortable does not deselect some pending items from the
+ // source list.
+ // We'll just do it forcefully here.
+ Array.from(document.querySelectorAll('.js-multi-select') || []).forEach((item) => {
+ Sortable.utils.deselect(item);
+ });
+
+ /**
+ * SortableJS leaves all the moving items "as is" on the DOM.
+ * Vue picks up and rehydrates the DOM, but we need to explicity
+ * remove the "trash" items from the DOM.
+ *
+ * This is in parity to the logic on single item move from a list/in
+ * a list. For reference, look at the implementation of onAdd method.
+ */
+ this.$nextTick(() => {
+ if (items && items.length) {
+ items.forEach((item) => {
+ item.remove();
+ });
+ }
+ });
+ }
+ sortableEnd();
+ },
+ onMove(e) {
+ return !e.related.classList.contains('board-list-count');
+ },
+ onSelect(e) {
+ const {
+ item: { classList },
+ } = e;
+
+ if (
+ classList &&
+ classList.contains('js-multi-select') &&
+ !classList.contains('multi-select')
+ ) {
+ Sortable.utils.deselect(e.item);
+ }
+ },
+ onDeselect: (e) => {
+ const {
+ item: { dataset, classList },
+ } = e;
+
+ if (
+ classList &&
+ classList.contains('multi-select') &&
+ !classList.contains('js-multi-select')
+ ) {
+ const issue = this.list.findIssue(Number(dataset.issueId));
+ boardsStore.toggleMultiSelect(issue);
+ }
+ },
+ });
+
+ this.sortable = Sortable.create(this.$refs.list, options);
+
+ // Scroll event on list to load more
+ this.$refs.list.addEventListener('scroll', this.onScroll);
+ },
+ beforeDestroy() {
+ eventHub.$off(`toggle-issue-form-${this.list.id}`, this.toggleForm);
+ eventHub.$off(`scroll-board-list-${this.list.id}`, this.scrollToTop);
+ this.$refs.list.removeEventListener('scroll', this.onScroll);
+ },
+ methods: {
+ listHeight() {
+ return this.$refs.list.getBoundingClientRect().height;
+ },
+ scrollHeight() {
+ return this.$refs.list.scrollHeight;
+ },
+ scrollTop() {
+ return this.$refs.list.scrollTop + this.listHeight();
+ },
+ scrollToTop() {
+ this.$refs.list.scrollTop = 0;
+ },
+ loadNextPage() {
+ const getIssues = this.list.nextPage();
+ const loadingDone = () => {
+ this.list.loadingMore = false;
+ };
+
+ if (getIssues) {
+ this.list.loadingMore = true;
+ getIssues.then(loadingDone).catch(loadingDone);
+ }
+ },
+ toggleForm() {
+ this.showIssueForm = !this.showIssueForm;
+ },
+ onScroll() {
+ if (!this.list.loadingMore && this.scrollTop() > this.scrollHeight() - this.scrollOffset) {
+ this.loadNextPage();
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ :class="{ 'd-none': !list.isExpanded, 'd-flex flex-column': list.isExpanded }"
+ class="board-list-component position-relative h-100"
+ data-qa-selector="board_list_cards_area"
+ >
+ <div v-if="loading" class="board-list-loading text-center" :aria-label="__('Loading issues')">
+ <gl-loading-icon />
+ </div>
+ <board-new-issue v-if="list.type !== 'closed' && showIssueForm" :list="list" />
+ <ul
+ v-show="!loading"
+ ref="list"
+ :data-board="list.id"
+ :data-board-type="list.type"
+ :class="{ 'is-smaller': showIssueForm, 'bg-danger-100': issuesSizeExceedsMax }"
+ class="board-list w-100 h-100 list-unstyled mb-0 p-1 js-board-list"
+ >
+ <board-card
+ v-for="(issue, index) in issues"
+ ref="issue"
+ :key="issue.id"
+ :index="index"
+ :list="list"
+ :issue="issue"
+ :disabled="disabled"
+ />
+ <li v-if="showCount" class="board-list-count text-center" data-issue-id="-1">
+ <gl-loading-icon v-show="list.loadingMore" label="Loading more issues" />
+ <span v-if="list.issues.length === list.issuesSize">{{ __('Showing all issues') }}</span>
+ <span v-else>{{ paginatedIssueText }}</span>
+ </li>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue
index 3db5c2e0830..06f39eceb08 100644
--- a/app/assets/javascripts/boards/components/board_list_header.vue
+++ b/app/assets/javascripts/boards/components/board_list_header.vue
@@ -9,16 +9,22 @@ import {
GlSprintf,
GlTooltipDirective,
} from '@gitlab/ui';
-import { n__, s__ } from '~/locale';
+import { n__, s__, __ } from '~/locale';
import AccessorUtilities from '../../lib/utils/accessor';
import IssueCount from './issue_count.vue';
-import boardsStore from '../stores/boards_store';
import eventHub from '../eventhub';
import sidebarEventHub from '~/sidebar/event_hub';
import { inactiveId, LIST, ListType } from '../constants';
import { isScopedLabel } from '~/lib/utils/common_utils';
+import { isListDraggable } from '~/boards/boards_util';
export default {
+ i18n: {
+ newIssue: __('New issue'),
+ listSettings: __('List settings'),
+ expand: s__('Boards|Expand'),
+ collapse: s__('Boards|Collapse'),
+ },
components: {
GlButtonGroup,
GlButton,
@@ -31,6 +37,20 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ inject: {
+ boardId: {
+ default: '',
+ },
+ weightFeatureAvailable: {
+ default: false,
+ },
+ scopedLabelsAvailable: {
+ default: false,
+ },
+ currentUserId: {
+ default: null,
+ },
+ },
props: {
list: {
type: Object,
@@ -47,61 +67,53 @@ export default {
default: false,
},
},
- inject: {
- boardId: {
- default: '',
- },
- },
- data() {
- return {
- weightFeatureAvailable: false,
- };
- },
computed: {
...mapState(['activeId']),
isLoggedIn() {
- return Boolean(gon.current_user_id);
+ return Boolean(this.currentUserId);
},
listType() {
- return this.list.type;
+ return this.list.listType;
},
listAssignee() {
return this.list?.assignee?.username || '';
},
listTitle() {
- return this.list?.label?.description || this.list.title || '';
+ return this.list?.label?.description || this.list?.assignee?.name || this.list.title || '';
},
showListHeaderButton() {
return !this.disabled && this.listType !== ListType.closed;
},
showMilestoneListDetails() {
return (
- this.list.type === 'milestone' &&
+ this.listType === ListType.milestone &&
this.list.milestone &&
- (this.list.isExpanded || !this.isSwimlanesHeader)
+ (!this.list.collapsed || !this.isSwimlanesHeader)
);
},
showAssigneeListDetails() {
- return this.list.type === 'assignee' && (this.list.isExpanded || !this.isSwimlanesHeader);
+ return (
+ this.listType === ListType.assignee && (!this.list.collapsed || !this.isSwimlanesHeader)
+ );
},
issuesCount() {
- return this.list.issuesSize;
+ return this.list.issuesCount;
},
issuesTooltipLabel() {
return n__(`%d issue`, `%d issues`, this.issuesCount);
},
chevronTooltip() {
- return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand');
+ return this.list.collapsed ? this.$options.i18n.expand : this.$options.i18n.collapse;
},
chevronIcon() {
- return this.list.isExpanded ? 'chevron-right' : 'chevron-down';
+ return this.list.collapsed ? 'chevron-down' : 'chevron-right';
},
isNewIssueShown() {
return this.listType === ListType.backlog || this.showListHeaderButton;
},
isSettingsShown() {
return (
- this.listType !== ListType.backlog && this.showListHeaderButton && this.list.isExpanded
+ this.listType !== ListType.backlog && this.showListHeaderButton && !this.list.collapsed
);
},
uniqueKey() {
@@ -111,9 +123,15 @@ export default {
collapsedTooltipTitle() {
return this.listTitle || this.listAssignee;
},
+ headerStyle() {
+ return { borderTopColor: this.list?.label?.color };
+ },
+ userCanDrag() {
+ return !this.disabled && isListDraggable(this.list);
+ },
},
methods: {
- ...mapActions(['setActiveId']),
+ ...mapActions(['updateList', 'setActiveId']),
openSidebarSettings() {
if (this.activeId === inactiveId) {
sidebarEventHub.$emit('sidebar.closeAll');
@@ -122,14 +140,14 @@ export default {
this.setActiveId({ id: this.list.id, sidebarType: LIST });
},
showScopedLabels(label) {
- return boardsStore.scopedLabels.enabled && isScopedLabel(label);
+ return this.scopedLabelsAvailable && isScopedLabel(label);
},
showNewIssueForm() {
eventHub.$emit(`toggle-issue-form-${this.list.id}`);
},
toggleExpanded() {
- this.list.isExpanded = !this.list.isExpanded;
+ this.list.collapsed = !this.list.collapsed;
if (!this.isLoggedIn) {
this.addToLocalStorage();
@@ -143,11 +161,11 @@ export default {
},
addToLocalStorage() {
if (AccessorUtilities.isLocalStorageAccessSafe()) {
- localStorage.setItem(`${this.uniqueKey}.expanded`, this.list.isExpanded);
+ localStorage.setItem(`${this.uniqueKey}.expanded`, !this.list.collapsed);
}
},
updateListFunction() {
- this.list.update();
+ this.updateList({ listId: this.list.id, collapsed: this.list.collapsed });
},
},
};
@@ -157,26 +175,25 @@ export default {
<header
:class="{
'has-border': list.label && list.label.color,
- 'gl-h-full': !list.isExpanded,
+ 'gl-h-full': list.collapsed,
'board-inner gl-rounded-top-left-base gl-rounded-top-right-base': isSwimlanesHeader,
}"
- :style="{ borderTopColor: list.label && list.label.color ? list.label.color : null }"
+ :style="headerStyle"
class="board-header gl-relative"
data-qa-selector="board_list_header"
data-testid="board-list-header"
>
<h3
:class="{
- 'user-can-drag': !disabled && !list.preset,
- 'gl-py-3 gl-h-full': !list.isExpanded && !isSwimlanesHeader,
- 'gl-border-b-0': !list.isExpanded || isSwimlanesHeader,
- 'gl-py-2': !list.isExpanded && isSwimlanesHeader,
- 'gl-flex-direction-column': !list.isExpanded,
+ 'user-can-drag': userCanDrag,
+ 'gl-py-3 gl-h-full': list.collapsed && !isSwimlanesHeader,
+ 'gl-border-b-0': list.collapsed || isSwimlanesHeader,
+ 'gl-py-2': list.collapsed && isSwimlanesHeader,
+ 'gl-flex-direction-column': list.collapsed,
}"
class="board-title gl-m-0 gl-display-flex gl-align-items-center gl-font-base gl-px-3 js-board-handle"
>
<gl-button
- v-if="list.isExpandable"
v-gl-tooltip.hover
:aria-label="chevronTooltip"
:title="chevronTooltip"
@@ -186,14 +203,14 @@ export default {
size="small"
@click="toggleExpanded"
/>
- <!-- The following is only true in EE and if it is a milestone -->
+ <!-- EE start -->
<span
v-if="showMilestoneListDetails"
aria-hidden="true"
class="milestone-icon"
:class="{
- 'gl-mt-3 gl-rotate-90': !list.isExpanded,
- 'gl-mr-2': list.isExpanded,
+ 'gl-mt-3 gl-rotate-90': list.collapsed,
+ 'gl-mr-2': !list.collapsed,
}"
>
<gl-icon name="timer" />
@@ -201,90 +218,95 @@ export default {
<a
v-if="showAssigneeListDetails"
- :href="list.assignee.path"
+ :href="list.assignee.webUrl"
class="user-avatar-link js-no-trigger"
:class="{
- 'gl-mt-3 gl-rotate-90': !list.isExpanded,
+ 'gl-mt-3 gl-rotate-90': list.collapsed,
}"
>
<img
v-gl-tooltip.hover.bottom
:title="listAssignee"
:alt="list.assignee.name"
- :src="list.assignee.avatar"
+ :src="list.assignee.avatarUrl"
class="avatar s20"
height="20"
width="20"
/>
</a>
+ <!-- EE end -->
<div
class="board-title-text"
:class="{
- 'gl-display-none': !list.isExpanded && isSwimlanesHeader,
- 'gl-flex-grow-0 gl-my-3 gl-mx-0': !list.isExpanded,
- 'gl-flex-grow-1': list.isExpanded,
+ 'gl-display-none': list.collapsed && isSwimlanesHeader,
+ 'gl-flex-grow-0 gl-my-3 gl-mx-0': list.collapsed,
+ 'gl-flex-grow-1': !list.collapsed,
}"
>
+ <!-- EE start -->
<span
- v-if="list.type !== 'label'"
+ v-if="listType !== 'label'"
v-gl-tooltip.hover
:class="{
- 'gl-display-block': !list.isExpanded || list.type === 'milestone',
+ 'gl-display-block': list.collapsed || listType === 'milestone',
}"
:title="listTitle"
class="board-title-main-text gl-text-truncate"
>
- {{ list.title }}
+ {{ listTitle }}
</span>
<span
- v-if="list.type === 'assignee'"
+ v-if="listType === 'assignee'"
+ v-show="!list.collapsed"
class="gl-ml-2 gl-font-weight-normal gl-text-gray-500"
- :class="{ 'gl-display-none': !list.isExpanded }"
>
@{{ listAssignee }}
</span>
+ <!-- EE end -->
<gl-label
- v-if="list.type === 'label'"
+ v-if="listType === 'label'"
v-gl-tooltip.hover.bottom
:background-color="list.label.color"
:description="list.label.description"
:scoped="showScopedLabels(list.label)"
- :size="!list.isExpanded ? 'sm' : ''"
+ :size="list.collapsed ? 'sm' : ''"
:title="list.label.title"
/>
</div>
+ <!-- EE start -->
<span
- v-if="isSwimlanesHeader && !list.isExpanded"
+ v-if="isSwimlanesHeader && list.collapsed"
ref="collapsedInfo"
aria-hidden="true"
- class="board-header-collapsed-info-icon gl-mt-2 gl-cursor-pointer gl-text-gray-500"
+ class="board-header-collapsed-info-icon gl-cursor-pointer gl-text-gray-500"
>
<gl-icon name="information" />
</span>
- <gl-tooltip v-if="isSwimlanesHeader && !list.isExpanded" :target="() => $refs.collapsedInfo">
+ <gl-tooltip v-if="isSwimlanesHeader && list.collapsed" :target="() => $refs.collapsedInfo">
<div class="gl-font-weight-bold gl-pb-2">{{ collapsedTooltipTitle }}</div>
<div v-if="list.maxIssueCount !== 0">
- &#8226;
+ •
<gl-sprintf :message="__('%{issuesSize} with a limit of %{maxIssueCount}')">
<template #issuesSize>{{ issuesTooltipLabel }}</template>
<template #maxIssueCount>{{ list.maxIssueCount }}</template>
</gl-sprintf>
</div>
- <div v-else>&#8226; {{ issuesTooltipLabel }}</div>
+ <div v-else>• {{ issuesTooltipLabel }}</div>
<div v-if="weightFeatureAvailable">
- &#8226;
+ •
<gl-sprintf :message="__('%{totalWeight} total weight')">
<template #totalWeight>{{ list.totalWeight }}</template>
</gl-sprintf>
</div>
</gl-tooltip>
+ <!-- EE end -->
<div
- class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag text-secondary"
+ class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag gl-text-gray-500"
:class="{
- 'gl-display-none!': !list.isExpanded && isSwimlanesHeader,
- 'gl-p-0': !list.isExpanded,
+ 'gl-display-none!': list.collapsed && isSwimlanesHeader,
+ 'gl-p-0': list.collapsed,
}"
>
<span class="gl-display-inline-flex">
@@ -293,7 +315,7 @@ export default {
<gl-icon class="gl-mr-2" name="issues" />
<issue-count :issues-size="issuesCount" :max-issue-count="list.maxIssueCount" />
</span>
- <!-- The following is only true in EE. -->
+ <!-- EE start -->
<template v-if="weightFeatureAvailable">
<gl-tooltip :target="() => $refs.weightTooltip" :title="weightCountToolTip" />
<span ref="weightTooltip" class="gl-display-inline-flex gl-ml-3">
@@ -301,6 +323,7 @@ export default {
{{ list.totalWeight }}
</span>
</template>
+ <!-- EE end -->
</span>
</div>
<gl-button-group
@@ -309,13 +332,11 @@ export default {
>
<gl-button
v-if="isNewIssueShown"
+ v-show="!list.collapsed"
ref="newIssueBtn"
v-gl-tooltip.hover
- :class="{
- 'gl-display-none': !list.isExpanded,
- }"
- :aria-label="__('New issue')"
- :title="__('New issue')"
+ :aria-label="$options.i18n.newIssue"
+ :title="$options.i18n.newIssue"
class="issue-count-badge-add-button no-drag"
icon="plus"
@click="showNewIssueForm"
@@ -325,13 +346,13 @@ export default {
v-if="isSettingsShown"
ref="settingsBtn"
v-gl-tooltip.hover
- :aria-label="__('List settings')"
+ :aria-label="$options.i18n.listSettings"
class="no-drag js-board-settings-button"
- :title="__('List settings')"
+ :title="$options.i18n.listSettings"
icon="settings"
@click="openSidebarSettings"
/>
- <gl-tooltip :target="() => $refs.settingsBtn">{{ __('List settings') }}</gl-tooltip>
+ <gl-tooltip :target="() => $refs.settingsBtn">{{ $options.i18n.listSettings }}</gl-tooltip>
</gl-button-group>
</h3>
</header>
diff --git a/app/assets/javascripts/boards/components/board_list_header_new.vue b/app/assets/javascripts/boards/components/board_list_header_deprecated.vue
index 44eb2aa34c2..21147f1616c 100644
--- a/app/assets/javascripts/boards/components/board_list_header_new.vue
+++ b/app/assets/javascripts/boards/components/board_list_header_deprecated.vue
@@ -9,22 +9,18 @@ import {
GlSprintf,
GlTooltipDirective,
} from '@gitlab/ui';
-import { n__, s__, __ } from '~/locale';
+import { n__, s__ } from '~/locale';
import AccessorUtilities from '../../lib/utils/accessor';
import IssueCount from './issue_count.vue';
+import boardsStore from '../stores/boards_store';
import eventHub from '../eventhub';
import sidebarEventHub from '~/sidebar/event_hub';
import { inactiveId, LIST, ListType } from '../constants';
import { isScopedLabel } from '~/lib/utils/common_utils';
-import { isListDraggable } from '~/boards/boards_util';
+
+// This component is being replaced in favor of './board_list_header.vue' for GraphQL boards
export default {
- i18n: {
- newIssue: __('New issue'),
- listSettings: __('List settings'),
- expand: s__('Boards|Expand'),
- collapse: s__('Boards|Collapse'),
- },
components: {
GlButtonGroup,
GlButton,
@@ -37,6 +33,11 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ inject: {
+ boardId: {
+ default: '',
+ },
+ },
props: {
list: {
type: Object,
@@ -53,67 +54,56 @@ export default {
default: false,
},
},
- inject: {
- boardId: {
- default: '',
- },
- weightFeatureAvailable: {
- default: false,
- },
- scopedLabelsAvailable: {
- default: false,
- },
- currentUserId: {
- default: null,
- },
+ data() {
+ return {
+ weightFeatureAvailable: false,
+ };
},
computed: {
...mapState(['activeId']),
isLoggedIn() {
- return Boolean(this.currentUserId);
+ return Boolean(gon.current_user_id);
},
listType() {
- return this.list.listType;
+ return this.list.type;
},
listAssignee() {
return this.list?.assignee?.username || '';
},
listTitle() {
- return this.list?.label?.description || this.list?.assignee?.name || this.list.title || '';
+ return this.list?.label?.description || this.list.title || '';
},
showListHeaderButton() {
return !this.disabled && this.listType !== ListType.closed;
},
showMilestoneListDetails() {
return (
- this.listType === ListType.milestone &&
+ this.list.type === 'milestone' &&
this.list.milestone &&
- (!this.list.collapsed || !this.isSwimlanesHeader)
+ (this.list.isExpanded || !this.isSwimlanesHeader)
);
},
showAssigneeListDetails() {
- return (
- this.listType === ListType.assignee && (!this.list.collapsed || !this.isSwimlanesHeader)
- );
+ return this.list.type === 'assignee' && (this.list.isExpanded || !this.isSwimlanesHeader);
},
issuesCount() {
- return this.list.issuesCount;
+ return this.list.issuesSize;
},
issuesTooltipLabel() {
return n__(`%d issue`, `%d issues`, this.issuesCount);
},
chevronTooltip() {
- return this.list.collapsed ? this.$options.i18n.expand : this.$options.i18n.collapse;
+ return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand');
},
chevronIcon() {
- return this.list.collapsed ? 'chevron-down' : 'chevron-right';
+ return this.list.isExpanded ? 'chevron-right' : 'chevron-down';
},
isNewIssueShown() {
return this.listType === ListType.backlog || this.showListHeaderButton;
},
isSettingsShown() {
return (
- this.listType !== ListType.backlog && this.showListHeaderButton && !this.list.collapsed
+ this.listType !== ListType.backlog && this.showListHeaderButton && this.list.isExpanded
);
},
uniqueKey() {
@@ -123,15 +113,9 @@ export default {
collapsedTooltipTitle() {
return this.listTitle || this.listAssignee;
},
- headerStyle() {
- return { borderTopColor: this.list?.label?.color };
- },
- userCanDrag() {
- return !this.disabled && isListDraggable(this.list);
- },
},
methods: {
- ...mapActions(['updateList', 'setActiveId']),
+ ...mapActions(['setActiveId']),
openSidebarSettings() {
if (this.activeId === inactiveId) {
sidebarEventHub.$emit('sidebar.closeAll');
@@ -140,14 +124,14 @@ export default {
this.setActiveId({ id: this.list.id, sidebarType: LIST });
},
showScopedLabels(label) {
- return this.scopedLabelsAvailable && isScopedLabel(label);
+ return boardsStore.scopedLabels.enabled && isScopedLabel(label);
},
showNewIssueForm() {
eventHub.$emit(`toggle-issue-form-${this.list.id}`);
},
toggleExpanded() {
- this.list.collapsed = !this.list.collapsed;
+ this.list.isExpanded = !this.list.isExpanded;
if (!this.isLoggedIn) {
this.addToLocalStorage();
@@ -161,11 +145,11 @@ export default {
},
addToLocalStorage() {
if (AccessorUtilities.isLocalStorageAccessSafe()) {
- localStorage.setItem(`${this.uniqueKey}.expanded`, !this.list.collapsed);
+ localStorage.setItem(`${this.uniqueKey}.expanded`, this.list.isExpanded);
}
},
updateListFunction() {
- this.updateList({ listId: this.list.id, collapsed: this.list.collapsed });
+ this.list.update();
},
},
};
@@ -175,25 +159,26 @@ export default {
<header
:class="{
'has-border': list.label && list.label.color,
- 'gl-h-full': list.collapsed,
+ 'gl-h-full': !list.isExpanded,
'board-inner gl-rounded-top-left-base gl-rounded-top-right-base': isSwimlanesHeader,
}"
- :style="headerStyle"
+ :style="{ borderTopColor: list.label && list.label.color ? list.label.color : null }"
class="board-header gl-relative"
data-qa-selector="board_list_header"
data-testid="board-list-header"
>
<h3
:class="{
- 'user-can-drag': userCanDrag,
- 'gl-py-3 gl-h-full': list.collapsed && !isSwimlanesHeader,
- 'gl-border-b-0': list.collapsed || isSwimlanesHeader,
- 'gl-py-2': list.collapsed && isSwimlanesHeader,
- 'gl-flex-direction-column': list.collapsed,
+ 'user-can-drag': !disabled && !list.preset,
+ 'gl-py-3 gl-h-full': !list.isExpanded && !isSwimlanesHeader,
+ 'gl-border-b-0': !list.isExpanded || isSwimlanesHeader,
+ 'gl-py-2': !list.isExpanded && isSwimlanesHeader,
+ 'gl-flex-direction-column': !list.isExpanded,
}"
class="board-title gl-m-0 gl-display-flex gl-align-items-center gl-font-base gl-px-3 js-board-handle"
>
<gl-button
+ v-if="list.isExpandable"
v-gl-tooltip.hover
:aria-label="chevronTooltip"
:title="chevronTooltip"
@@ -203,14 +188,14 @@ export default {
size="small"
@click="toggleExpanded"
/>
- <!-- EE start -->
+ <!-- The following is only true in EE and if it is a milestone -->
<span
v-if="showMilestoneListDetails"
aria-hidden="true"
class="milestone-icon"
:class="{
- 'gl-mt-3 gl-rotate-90': list.collapsed,
- 'gl-mr-2': !list.collapsed,
+ 'gl-mt-3 gl-rotate-90': !list.isExpanded,
+ 'gl-mr-2': list.isExpanded,
}"
>
<gl-icon name="timer" />
@@ -218,95 +203,90 @@ export default {
<a
v-if="showAssigneeListDetails"
- :href="list.assignee.webUrl"
+ :href="list.assignee.path"
class="user-avatar-link js-no-trigger"
:class="{
- 'gl-mt-3 gl-rotate-90': list.collapsed,
+ 'gl-mt-3 gl-rotate-90': !list.isExpanded,
}"
>
<img
v-gl-tooltip.hover.bottom
:title="listAssignee"
:alt="list.assignee.name"
- :src="list.assignee.avatarUrl"
+ :src="list.assignee.avatar"
class="avatar s20"
height="20"
width="20"
/>
</a>
- <!-- EE end -->
<div
class="board-title-text"
:class="{
- 'gl-display-none': list.collapsed && isSwimlanesHeader,
- 'gl-flex-grow-0 gl-my-3 gl-mx-0': list.collapsed,
- 'gl-flex-grow-1': !list.collapsed,
+ 'gl-display-none': !list.isExpanded && isSwimlanesHeader,
+ 'gl-flex-grow-0 gl-my-3 gl-mx-0': !list.isExpanded,
+ 'gl-flex-grow-1': list.isExpanded,
}"
>
- <!-- EE start -->
<span
- v-if="listType !== 'label'"
+ v-if="list.type !== 'label'"
v-gl-tooltip.hover
:class="{
- 'gl-display-block': list.collapsed || listType === 'milestone',
+ 'gl-display-block': !list.isExpanded || list.type === 'milestone',
}"
:title="listTitle"
class="board-title-main-text gl-text-truncate"
>
- {{ listTitle }}
+ {{ list.title }}
</span>
<span
- v-if="listType === 'assignee'"
- v-show="!list.collapsed"
+ v-if="list.type === 'assignee'"
class="gl-ml-2 gl-font-weight-normal gl-text-gray-500"
+ :class="{ 'gl-display-none': !list.isExpanded }"
>
@{{ listAssignee }}
</span>
- <!-- EE end -->
<gl-label
- v-if="listType === 'label'"
+ v-if="list.type === 'label'"
v-gl-tooltip.hover.bottom
:background-color="list.label.color"
:description="list.label.description"
:scoped="showScopedLabels(list.label)"
- :size="list.collapsed ? 'sm' : ''"
+ :size="!list.isExpanded ? 'sm' : ''"
:title="list.label.title"
/>
</div>
- <!-- EE start -->
<span
- v-if="isSwimlanesHeader && list.collapsed"
+ v-if="isSwimlanesHeader && !list.isExpanded"
ref="collapsedInfo"
aria-hidden="true"
- class="board-header-collapsed-info-icon gl-cursor-pointer gl-text-gray-500"
+ class="board-header-collapsed-info-icon gl-mt-2 gl-cursor-pointer gl-text-gray-500"
>
<gl-icon name="information" />
</span>
- <gl-tooltip v-if="isSwimlanesHeader && list.collapsed" :target="() => $refs.collapsedInfo">
+ <gl-tooltip v-if="isSwimlanesHeader && !list.isExpanded" :target="() => $refs.collapsedInfo">
<div class="gl-font-weight-bold gl-pb-2">{{ collapsedTooltipTitle }}</div>
<div v-if="list.maxIssueCount !== 0">
- •
+ &#8226;
<gl-sprintf :message="__('%{issuesSize} with a limit of %{maxIssueCount}')">
<template #issuesSize>{{ issuesTooltipLabel }}</template>
<template #maxIssueCount>{{ list.maxIssueCount }}</template>
</gl-sprintf>
</div>
- <div v-else>• {{ issuesTooltipLabel }}</div>
+ <div v-else>&#8226; {{ issuesTooltipLabel }}</div>
<div v-if="weightFeatureAvailable">
- •
+ &#8226;
<gl-sprintf :message="__('%{totalWeight} total weight')">
<template #totalWeight>{{ list.totalWeight }}</template>
</gl-sprintf>
</div>
</gl-tooltip>
- <!-- EE end -->
<div
- class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag gl-text-gray-500"
+ class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag text-secondary"
:class="{
- 'gl-display-none!': list.collapsed && isSwimlanesHeader,
- 'gl-p-0': list.collapsed,
+ 'gl-display-none!': !list.isExpanded && isSwimlanesHeader,
+ 'gl-p-0': !list.isExpanded,
}"
>
<span class="gl-display-inline-flex">
@@ -315,7 +295,7 @@ export default {
<gl-icon class="gl-mr-2" name="issues" />
<issue-count :issues-size="issuesCount" :max-issue-count="list.maxIssueCount" />
</span>
- <!-- EE start -->
+ <!-- The following is only true in EE. -->
<template v-if="weightFeatureAvailable">
<gl-tooltip :target="() => $refs.weightTooltip" :title="weightCountToolTip" />
<span ref="weightTooltip" class="gl-display-inline-flex gl-ml-3">
@@ -323,7 +303,6 @@ export default {
{{ list.totalWeight }}
</span>
</template>
- <!-- EE end -->
</span>
</div>
<gl-button-group
@@ -332,11 +311,13 @@ export default {
>
<gl-button
v-if="isNewIssueShown"
- v-show="!list.collapsed"
ref="newIssueBtn"
v-gl-tooltip.hover
- :aria-label="$options.i18n.newIssue"
- :title="$options.i18n.newIssue"
+ :class="{
+ 'gl-display-none': !list.isExpanded,
+ }"
+ :aria-label="__('New issue')"
+ :title="__('New issue')"
class="issue-count-badge-add-button no-drag"
icon="plus"
@click="showNewIssueForm"
@@ -346,13 +327,13 @@ export default {
v-if="isSettingsShown"
ref="settingsBtn"
v-gl-tooltip.hover
- :aria-label="$options.i18n.listSettings"
+ :aria-label="__('List settings')"
class="no-drag js-board-settings-button"
- :title="$options.i18n.listSettings"
+ :title="__('List settings')"
icon="settings"
@click="openSidebarSettings"
/>
- <gl-tooltip :target="() => $refs.settingsBtn">{{ $options.i18n.listSettings }}</gl-tooltip>
+ <gl-tooltip :target="() => $refs.settingsBtn">{{ __('List settings') }}</gl-tooltip>
</gl-button-group>
</h3>
</header>
diff --git a/app/assets/javascripts/boards/components/board_list_new.vue b/app/assets/javascripts/boards/components/board_list_new.vue
deleted file mode 100644
index 92a381a8f57..00000000000
--- a/app/assets/javascripts/boards/components/board_list_new.vue
+++ /dev/null
@@ -1,239 +0,0 @@
-<script>
-import Draggable from 'vuedraggable';
-import { mapActions, mapState } from 'vuex';
-import { GlLoadingIcon } from '@gitlab/ui';
-import defaultSortableConfig from '~/sortable/sortable_config';
-import { sortableStart, sortableEnd } from '~/boards/mixins/sortable_default_options';
-import BoardNewIssue from './board_new_issue_new.vue';
-import BoardCard from './board_card.vue';
-import eventHub from '../eventhub';
-import { sprintf, __ } from '~/locale';
-
-export default {
- name: 'BoardList',
- i18n: {
- loadingIssues: __('Loading issues'),
- loadingMoreissues: __('Loading more issues'),
- showingAllIssues: __('Showing all issues'),
- },
- components: {
- BoardCard,
- BoardNewIssue,
- GlLoadingIcon,
- },
- props: {
- disabled: {
- type: Boolean,
- required: true,
- },
- list: {
- type: Object,
- required: true,
- },
- issues: {
- type: Array,
- required: true,
- },
- canAdminList: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- data() {
- return {
- scrollOffset: 250,
- showCount: false,
- showIssueForm: false,
- };
- },
- computed: {
- ...mapState(['pageInfoByListId', 'listsFlags']),
- paginatedIssueText() {
- return sprintf(__('Showing %{pageSize} of %{total} issues'), {
- pageSize: this.issues.length,
- total: this.list.issuesCount,
- });
- },
- issuesSizeExceedsMax() {
- return this.list.maxIssueCount > 0 && this.list.issuesCount > this.list.maxIssueCount;
- },
- hasNextPage() {
- return this.pageInfoByListId[this.list.id].hasNextPage;
- },
- loading() {
- return this.listsFlags[this.list.id]?.isLoading;
- },
- loadingMore() {
- return this.listsFlags[this.list.id]?.isLoadingMore;
- },
- listRef() {
- // When list is draggable, the reference to the list needs to be accessed differently
- return this.canAdminList ? this.$refs.list.$el : this.$refs.list;
- },
- showingAllIssues() {
- return this.issues.length === this.list.issuesCount;
- },
- treeRootWrapper() {
- return this.canAdminList ? Draggable : 'ul';
- },
- treeRootOptions() {
- const options = {
- ...defaultSortableConfig,
- fallbackOnBody: false,
- group: 'board-list',
- tag: 'ul',
- 'ghost-class': 'board-card-drag-active',
- 'data-list-id': this.list.id,
- value: this.issues,
- };
-
- return this.canAdminList ? options : {};
- },
- },
- watch: {
- issues() {
- this.$nextTick(() => {
- 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);
- },
- 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(['fetchIssuesForList', 'moveIssue']),
- listHeight() {
- return this.listRef.getBoundingClientRect().height;
- },
- scrollHeight() {
- return this.listRef.scrollHeight;
- },
- scrollTop() {
- return this.listRef.scrollTop + this.listHeight();
- },
- scrollToTop() {
- this.listRef.scrollTop = 0;
- },
- loadNextPage() {
- this.fetchIssuesForList({ listId: this.list.id, fetchNext: true });
- },
- toggleForm() {
- this.showIssueForm = !this.showIssueForm;
- },
- onScroll() {
- window.requestAnimationFrame(() => {
- if (
- !this.loadingMore &&
- this.scrollTop() > this.scrollHeight() - this.scrollOffset &&
- this.hasNextPage
- ) {
- this.loadNextPage();
- }
- });
- },
- handleDragOnStart() {
- sortableStart();
- },
- handleDragOnEnd(params) {
- sortableEnd();
- const { newIndex, oldIndex, from, to, item } = params;
- const { issueId, issueIid, issuePath } = item.dataset;
- const { children } = to;
- let moveBeforeId;
- let moveAfterId;
-
- const getIssueId = el => Number(el.dataset.issueId);
-
- // If issue is being moved within the same list
- if (from === to) {
- if (newIndex > oldIndex && children.length > 1) {
- // If issue is being moved down we look for the issue that ends up before
- moveBeforeId = getIssueId(children[newIndex]);
- } else if (newIndex < oldIndex && children.length > 1) {
- // If issue is being moved up we look for the issue that ends up after
- moveAfterId = getIssueId(children[newIndex]);
- } else {
- // If issue remains in the same list at the same position we do nothing
- return;
- }
- } else {
- // We look for the issue that ends up before the moved issue if it exists
- if (children[newIndex - 1]) {
- moveBeforeId = getIssueId(children[newIndex - 1]);
- }
- // We look for the issue that ends up after the moved issue if it exists
- if (children[newIndex]) {
- moveAfterId = getIssueId(children[newIndex]);
- }
- }
-
- this.moveIssue({
- issueId,
- issueIid,
- issuePath,
- fromListId: from.dataset.listId,
- toListId: to.dataset.listId,
- moveBeforeId,
- moveAfterId,
- });
- },
- },
-};
-</script>
-
-<template>
- <div
- v-show="!list.collapsed"
- class="board-list-component gl-relative gl-h-full gl-display-flex gl-flex-direction-column"
- data-qa-selector="board_list_cards_area"
- >
- <div
- v-if="loading"
- class="gl-mt-4 gl-text-center"
- :aria-label="$options.i18n.loadingIssues"
- data-testid="board_list_loading"
- >
- <gl-loading-icon />
- </div>
- <board-new-issue v-if="list.listType !== 'closed' && showIssueForm" :list="list" />
- <component
- :is="treeRootWrapper"
- v-show="!loading"
- ref="list"
- v-bind="treeRootOptions"
- :data-board="list.id"
- :data-board-type="list.listType"
- :class="{ 'bg-danger-100': issuesSizeExceedsMax }"
- 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"
- @end="handleDragOnEnd"
- >
- <board-card
- v-for="(issue, index) in issues"
- ref="issue"
- :key="issue.id"
- :index="index"
- :list="list"
- :issue="issue"
- :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.loadingMoreissues" />
- <span v-if="showingAllIssues">{{ $options.i18n.showingAllIssues }}</span>
- <span v-else>{{ paginatedIssueText }}</span>
- </li>
- </component>
- </div>
-</template>
diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue
index a9e6d768656..14d28643046 100644
--- a/app/assets/javascripts/boards/components/board_new_issue.vue
+++ b/app/assets/javascripts/boards/components/board_new_issue.vue
@@ -1,94 +1,85 @@
<script>
+import { mapActions, mapState } from 'vuex';
import { GlButton } from '@gitlab/ui';
import { getMilestone } from 'ee_else_ce/boards/boards_util';
-import ListIssue from 'ee_else_ce/boards/models/issue';
import eventHub from '../eventhub';
import ProjectSelect from './project_select.vue';
-import boardsStore from '../stores/boards_store';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-
-// This component is being replaced in favor of './board_new_issue_new.vue' for GraphQL boards
+import { __ } from '~/locale';
export default {
name: 'BoardNewIssue',
+ i18n: {
+ submit: __('Submit issue'),
+ cancel: __('Cancel'),
+ },
components: {
ProjectSelect,
GlButton,
},
mixins: [glFeatureFlagMixin()],
+ inject: ['groupId', 'weightFeatureAvailable', 'boardWeight'],
props: {
list: {
type: Object,
required: true,
},
},
- inject: ['groupId'],
data() {
return {
title: '',
- error: false,
- selectedProject: {},
};
},
computed: {
+ ...mapState(['selectedProject']),
disabled() {
if (this.groupId) {
return this.title === '' || !this.selectedProject.name;
}
return this.title === '';
},
+ inputFieldId() {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ return `${this.list.id}-title`;
+ },
},
mounted() {
this.$refs.input.focus();
eventHub.$on('setSelectedProject', this.setSelectedProject);
},
methods: {
+ ...mapActions(['addListNewIssue']),
submit(e) {
e.preventDefault();
- if (this.title.trim() === '') return Promise.resolve();
-
- this.error = false;
const labels = this.list.label ? [this.list.label] : [];
const assignees = this.list.assignee ? [this.list.assignee] : [];
const milestone = getMilestone(this.list);
- const { weightFeatureAvailable } = boardsStore;
- const { weight } = weightFeatureAvailable ? boardsStore.state.currentBoard : {};
+ const weight = this.weightFeatureAvailable ? this.boardWeight : undefined;
- const issue = new ListIssue({
- title: this.title,
- labels,
- subscribed: true,
- assignees,
- milestone,
- project_id: this.selectedProject.id,
- weight,
- });
+ const { title } = this;
eventHub.$emit(`scroll-board-list-${this.list.id}`);
- this.cancel();
- return this.list
- .newIssue(issue)
- .then(() => {
- boardsStore.setIssueDetail(issue);
- boardsStore.setListDetail(this.list);
- })
- .catch(() => {
- this.list.removeIssue(issue);
-
- // Show error message
- this.error = true;
- });
+ return this.addListNewIssue({
+ issueInput: {
+ title,
+ labelIds: labels?.map((l) => l.id),
+ assigneeIds: assignees?.map((a) => a?.id),
+ milestoneId: milestone?.id,
+ projectPath: this.selectedProject.fullPath,
+ weight: weight >= 0 ? weight : null,
+ },
+ list: this.list,
+ }).then(() => {
+ this.reset();
+ });
},
- cancel() {
+ reset() {
this.title = '';
eventHub.$emit(`toggle-issue-form-${this.list.id}`);
},
- setSelectedProject(selectedProject) {
- this.selectedProject = selectedProject;
- },
},
};
</script>
@@ -96,13 +87,10 @@ export default {
<template>
<div class="board-new-issue-form">
<div class="board-card position-relative p-3 rounded">
- <form @submit="submit($event)">
- <div v-if="error" class="flash-container">
- <div class="flash-alert">{{ __('An error occurred. Please try again.') }}</div>
- </div>
- <label :for="list.id + '-title'" class="label-bold">{{ __('Title') }}</label>
+ <form ref="submitForm" @submit="submit">
+ <label :for="inputFieldId" class="label-bold">{{ __('Title') }}</label>
<input
- :id="list.id + '-title'"
+ :id="inputFieldId"
ref="input"
v-model="title"
class="form-control"
@@ -119,16 +107,18 @@ export default {
variant="success"
category="primary"
type="submit"
- >{{ __('Submit issue') }}</gl-button
>
+ {{ $options.i18n.submit }}
+ </gl-button>
<gl-button
ref="cancelButton"
class="float-right"
type="button"
variant="default"
- @click="cancel"
- >{{ __('Cancel') }}</gl-button
+ @click="reset"
>
+ {{ $options.i18n.cancel }}
+ </gl-button>
</div>
</form>
</div>
diff --git a/app/assets/javascripts/boards/components/board_new_issue_new.vue b/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue
index 969c84ddb59..4fc58742783 100644
--- a/app/assets/javascripts/boards/components/board_new_issue_new.vue
+++ b/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue
@@ -1,33 +1,32 @@
<script>
-import { mapActions } from 'vuex';
import { GlButton } from '@gitlab/ui';
import { getMilestone } from 'ee_else_ce/boards/boards_util';
+import ListIssue from 'ee_else_ce/boards/models/issue';
import eventHub from '../eventhub';
-import ProjectSelect from './project_select.vue';
+import ProjectSelect from './project_select_deprecated.vue';
+import boardsStore from '../stores/boards_store';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { __ } from '~/locale';
+
+// This component is being replaced in favor of './board_new_issue.vue' for GraphQL boards
export default {
name: 'BoardNewIssue',
- i18n: {
- submit: __('Submit issue'),
- cancel: __('Cancel'),
- },
components: {
ProjectSelect,
GlButton,
},
mixins: [glFeatureFlagMixin()],
+ inject: ['groupId'],
props: {
list: {
type: Object,
required: true,
},
},
- inject: ['groupId', 'weightFeatureAvailable', 'boardWeight'],
data() {
return {
title: '',
+ error: false,
selectedProject: {},
};
},
@@ -38,45 +37,52 @@ export default {
}
return this.title === '';
},
- inputFieldId() {
- // eslint-disable-next-line @gitlab/require-i18n-strings
- return `${this.list.id}-title`;
- },
},
mounted() {
this.$refs.input.focus();
eventHub.$on('setSelectedProject', this.setSelectedProject);
},
methods: {
- ...mapActions(['addListNewIssue']),
submit(e) {
e.preventDefault();
+ if (this.title.trim() === '') return Promise.resolve();
+
+ this.error = false;
const labels = this.list.label ? [this.list.label] : [];
const assignees = this.list.assignee ? [this.list.assignee] : [];
const milestone = getMilestone(this.list);
- const weight = this.weightFeatureAvailable ? this.boardWeight : undefined;
+ const { weightFeatureAvailable } = boardsStore;
+ const { weight } = weightFeatureAvailable ? boardsStore.state.currentBoard : {};
- const { title } = this;
+ const issue = new ListIssue({
+ title: this.title,
+ labels,
+ subscribed: true,
+ assignees,
+ milestone,
+ project_id: this.selectedProject.id,
+ weight,
+ });
eventHub.$emit(`scroll-board-list-${this.list.id}`);
+ this.cancel();
- return this.addListNewIssue({
- issueInput: {
- title,
- labelIds: labels?.map(l => l.id),
- assigneeIds: assignees?.map(a => a?.id),
- milestoneId: milestone?.id,
- projectPath: this.selectedProject.path,
- weight: weight >= 0 ? weight : null,
- },
- list: this.list,
- }).then(() => {
- this.reset();
- });
+ return this.list
+ .newIssue(issue)
+ .then(() => {
+ boardsStore.setIssueDetail(issue);
+ boardsStore.setListDetail(this.list);
+ })
+ .catch(() => {
+ this.list.removeIssue(issue);
+
+ // Show error message
+ this.error = true;
+ });
},
- reset() {
+ cancel() {
this.title = '';
eventHub.$emit(`toggle-issue-form-${this.list.id}`);
},
@@ -90,10 +96,13 @@ export default {
<template>
<div class="board-new-issue-form">
<div class="board-card position-relative p-3 rounded">
- <form ref="submitForm" @submit="submit">
- <label :for="inputFieldId" class="label-bold">{{ __('Title') }}</label>
+ <form @submit="submit($event)">
+ <div v-if="error" class="flash-container">
+ <div class="flash-alert">{{ __('An error occurred. Please try again.') }}</div>
+ </div>
+ <label :for="list.id + '-title'" class="label-bold">{{ __('Title') }}</label>
<input
- :id="inputFieldId"
+ :id="list.id + '-title'"
ref="input"
v-model="title"
class="form-control"
@@ -110,18 +119,16 @@ export default {
variant="success"
category="primary"
type="submit"
+ >{{ __('Submit issue') }}</gl-button
>
- {{ $options.i18n.submit }}
- </gl-button>
<gl-button
ref="cancelButton"
class="float-right"
type="button"
variant="default"
- @click="reset"
+ @click="cancel"
+ >{{ __('Cancel') }}</gl-button
>
- {{ $options.i18n.cancel }}
- </gl-button>
</div>
</form>
</div>
diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
index 60db8fefe82..f362fc60bd3 100644
--- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
@@ -116,7 +116,7 @@ export default {
v-if="isWipLimitsOn"
:max-issue-count="activeList.maxIssueCount"
/>
- <div v-if="canAdminList && !activeList.preset && activeList.id" class="gl-m-4">
+ <div v-if="canAdminList && !activeList.preset && activeList.id" class="gl-mt-4">
<gl-button
variant="danger"
category="secondary"
diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js
index d26f15c1723..bf3dc5c608f 100644
--- a/app/assets/javascripts/boards/components/board_sidebar.js
+++ b/app/assets/javascripts/boards/components/board_sidebar.js
@@ -68,7 +68,7 @@ export default Vue.extend({
: __('Label');
},
selectedLabels() {
- return this.hasLabels ? this.issue.labels.map(l => l.title).join(',') : '';
+ return this.hasLabels ? this.issue.labels.map((l) => l.title).join(',') : '';
},
},
watch: {
@@ -82,9 +82,7 @@ export default Vue.extend({
});
$('.js-issue-board-sidebar', this.$el).each((i, el) => {
- $(el)
- .data('deprecatedJQueryDropdown')
- .clearMenu();
+ $(el).data('deprecatedJQueryDropdown').clearMenu();
});
}
diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue
index 4f23c38d0f7..fcd1c3fdceb 100644
--- a/app/assets/javascripts/boards/components/boards_selector.vue
+++ b/app/assets/javascripts/boards/components/boards_selector.vue
@@ -118,7 +118,7 @@ export default {
return this.state.currentPage;
},
filteredBoards() {
- return this.boards.filter(board =>
+ return this.boards.filter((board) =>
board.name.toLowerCase().includes(this.filterTerm.toLowerCase()),
);
},
@@ -181,10 +181,10 @@ export default {
this.loadingRecentBoards = true;
boardsStore
.recentBoards()
- .then(res => {
+ .then((res) => {
this.recentBoards = res.data;
})
- .catch(err => {
+ .catch((err) => {
/**
* If user is unauthorized we'd still want to resolve the
* request to display all boards.
diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue
index ddd20ff281c..457d0d4dcd6 100644
--- a/app/assets/javascripts/boards/components/issue_card_inner.vue
+++ b/app/assets/javascripts/boards/components/issue_card_inner.vue
@@ -1,6 +1,6 @@
<script>
import { sortBy } from 'lodash';
-import { mapState } from 'vuex';
+import { mapActions, mapState } from 'vuex';
import { GlLabel, GlTooltipDirective, GlIcon } from '@gitlab/ui';
import issueCardInner from 'ee_else_ce/boards/mixins/issue_card_inner';
import { sprintf, __, n__ } from '~/locale';
@@ -8,9 +8,10 @@ import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import IssueDueDate from './issue_due_date.vue';
import IssueTimeEstimate from './issue_time_estimate.vue';
-import boardsStore from '../stores/boards_store';
+import eventHub from '../eventhub';
import { isScopedLabel } from '~/lib/utils/common_utils';
import { ListType } from '../constants';
+import { updateHistory } from '~/lib/utils/url_utility';
export default {
components: {
@@ -26,6 +27,7 @@ export default {
GlTooltip: GlTooltipDirective,
},
mixins: [issueCardInner],
+ inject: ['groupId', 'rootPath', 'scopedLabelsAvailable'],
props: {
issue: {
type: Object,
@@ -42,7 +44,6 @@ export default {
default: false,
},
},
- inject: ['groupId', 'rootPath'],
data() {
return {
limitBeforeCounter: 2,
@@ -52,6 +53,16 @@ export default {
},
computed: {
...mapState(['isShowingLabels']),
+ cappedAssignees() {
+ // e.g. maxRender is 4,
+ // Render up to all 4 assignees if there are only 4 assigness
+ // Otherwise render up to the limitBeforeCounter
+ if (this.issue.assignees.length <= this.maxRender) {
+ return this.issue.assignees.slice(0, this.maxRender);
+ }
+
+ return this.issue.assignees.slice(0, this.limitBeforeCounter);
+ },
numberOverLimit() {
return this.issue.assignees.length - this.limitBeforeCounter;
},
@@ -98,19 +109,10 @@ export default {
},
},
methods: {
+ ...mapActions(['performSearch']),
isIndexLessThanlimit(index) {
return index < this.limitBeforeCounter;
},
- shouldRenderAssignee(index) {
- // Eg. maxRender is 4,
- // Render up to all 4 assignees if there are only 4 assigness
- // Otherwise render up to the limitBeforeCounter
- if (this.issue.assignees.length <= this.maxRender) {
- return index < this.maxRender;
- }
-
- return index < this.limitBeforeCounter;
- },
assigneeUrl(assignee) {
if (!assignee) return '';
return `${this.rootPath}${assignee.username}`;
@@ -118,6 +120,9 @@ export default {
avatarUrlTitle(assignee) {
return sprintf(__(`Avatar for %{assigneeName}`), { assigneeName: assignee.name });
},
+ avatarUrl(assignee) {
+ return assignee.avatarUrl || assignee.avatar || gon.default_avatar_url;
+ },
showLabel(label) {
if (!label.id) return false;
return true;
@@ -133,13 +138,19 @@ export default {
},
filterByLabel(label) {
if (!this.updateFilters) return;
- const labelTitle = encodeURIComponent(label.title);
- const filter = `label_name[]=${labelTitle}`;
+ const filterPath = window.location.search ? `${window.location.search}&` : '?';
+ const filter = `label_name[]=${encodeURIComponent(label.title)}`;
- boardsStore.toggleFilter(filter);
+ if (!filterPath.includes(filter)) {
+ updateHistory({
+ url: `${filterPath}${filter}`,
+ });
+ this.performSearch();
+ eventHub.$emit('updateTokens');
+ }
},
showScopedLabel(label) {
- return boardsStore.scopedLabels.enabled && isScopedLabel(label);
+ return this.scopedLabelsAvailable && isScopedLabel(label);
},
},
};
@@ -222,12 +233,11 @@ 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 cappedAssignees"
:key="assignee.id"
:link-href="assigneeUrl(assignee)"
:img-alt="avatarUrlTitle(assignee)"
- :img-src="assignee.avatarUrl || assignee.avatar || assignee.avatar_url"
+ :img-src="avatarUrl(assignee)"
:img-size="24"
class="js-no-trigger"
tooltip-placement="bottom"
diff --git a/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue b/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue
new file mode 100644
index 00000000000..75cf1f0b9e1
--- /dev/null
+++ b/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue
@@ -0,0 +1,245 @@
+<script>
+import { sortBy } from 'lodash';
+import { mapState } from 'vuex';
+import { GlLabel, GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import issueCardInner from 'ee_else_ce/boards/mixins/issue_card_inner';
+import { sprintf, __, n__ } from '~/locale';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
+import IssueDueDate from './issue_due_date.vue';
+import IssueTimeEstimate from './issue_time_estimate_deprecated.vue';
+import boardsStore from '../stores/boards_store';
+import { isScopedLabel } from '~/lib/utils/common_utils';
+
+export default {
+ components: {
+ GlLabel,
+ GlIcon,
+ UserAvatarLink,
+ TooltipOnTruncate,
+ IssueDueDate,
+ IssueTimeEstimate,
+ IssueCardWeight: () => import('ee_component/boards/components/issue_card_weight.vue'),
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [issueCardInner],
+ inject: ['groupId', 'rootPath'],
+ props: {
+ issue: {
+ type: Object,
+ required: true,
+ },
+ list: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ updateFilters: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ limitBeforeCounter: 2,
+ maxRender: 3,
+ maxCounter: 99,
+ };
+ },
+ computed: {
+ ...mapState(['isShowingLabels']),
+ numberOverLimit() {
+ return this.issue.assignees.length - this.limitBeforeCounter;
+ },
+ assigneeCounterTooltip() {
+ const { numberOverLimit, maxCounter } = this;
+ const count = numberOverLimit > maxCounter ? maxCounter : numberOverLimit;
+ return sprintf(__('%{count} more assignees'), { count });
+ },
+ assigneeCounterLabel() {
+ if (this.numberOverLimit > this.maxCounter) {
+ return `${this.maxCounter}+`;
+ }
+
+ return `+${this.numberOverLimit}`;
+ },
+ shouldRenderCounter() {
+ if (this.issue.assignees.length <= this.maxRender) {
+ return false;
+ }
+
+ return this.issue.assignees.length > this.numberOverLimit;
+ },
+ issueId() {
+ if (this.issue.iid) {
+ return `#${this.issue.iid}`;
+ }
+ return false;
+ },
+ showLabelFooter() {
+ return this.isShowingLabels && this.issue.labels.find(this.showLabel);
+ },
+ issueReferencePath() {
+ const { referencePath, groupId } = this.issue;
+ return !groupId ? referencePath.split('#')[0] : null;
+ },
+ orderedLabels() {
+ return sortBy(this.issue.labels.filter(this.isNonListLabel), 'title');
+ },
+ blockedLabel() {
+ if (this.issue.blockedByCount) {
+ return n__(`Blocked by %d issue`, `Blocked by %d issues`, this.issue.blockedByCount);
+ }
+ return __('Blocked issue');
+ },
+ },
+ methods: {
+ isIndexLessThanlimit(index) {
+ return index < this.limitBeforeCounter;
+ },
+ shouldRenderAssignee(index) {
+ // Eg. maxRender is 4,
+ // Render up to all 4 assignees if there are only 4 assigness
+ // Otherwise render up to the limitBeforeCounter
+ if (this.issue.assignees.length <= this.maxRender) {
+ return index < this.maxRender;
+ }
+
+ return index < this.limitBeforeCounter;
+ },
+ assigneeUrl(assignee) {
+ if (!assignee) return '';
+ return `${this.rootPath}${assignee.username}`;
+ },
+ avatarUrlTitle(assignee) {
+ return sprintf(__(`Avatar for %{assigneeName}`), { assigneeName: assignee.name });
+ },
+ showLabel(label) {
+ if (!label.id) return false;
+ return true;
+ },
+ isNonListLabel(label) {
+ return label.id && !(this.list.type === 'label' && this.list.title === label.title);
+ },
+ filterByLabel(label) {
+ if (!this.updateFilters) return;
+ const labelTitle = encodeURIComponent(label.title);
+ const filter = `label_name[]=${labelTitle}`;
+
+ boardsStore.toggleFilter(filter);
+ },
+ showScopedLabel(label) {
+ return boardsStore.scopedLabels.enabled && isScopedLabel(label);
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <div class="gl-display-flex" dir="auto">
+ <h4 class="board-card-title gl-mb-0 gl-mt-0">
+ <gl-icon
+ v-if="issue.blocked"
+ v-gl-tooltip
+ name="issue-block"
+ :title="blockedLabel"
+ class="issue-blocked-icon gl-mr-2"
+ :aria-label="blockedLabel"
+ data-testid="issue-blocked-icon"
+ />
+ <gl-icon
+ v-if="issue.confidential"
+ v-gl-tooltip
+ name="eye-slash"
+ :title="__('Confidential')"
+ class="confidential-icon gl-mr-2"
+ :aria-label="__('Confidential')"
+ />
+ <a
+ :href="issue.path || issue.webUrl || ''"
+ :title="issue.title"
+ class="js-no-trigger"
+ @mousemove.stop
+ >{{ issue.title }}</a
+ >
+ </h4>
+ </div>
+ <div v-if="showLabelFooter" class="board-card-labels gl-mt-2 gl-display-flex gl-flex-wrap">
+ <template v-for="label in orderedLabels">
+ <gl-label
+ :key="label.id"
+ :background-color="label.color"
+ :title="label.title"
+ :description="label.description"
+ size="sm"
+ :scoped="showScopedLabel(label)"
+ @click="filterByLabel(label)"
+ />
+ </template>
+ </div>
+ <div
+ class="board-card-footer gl-display-flex gl-justify-content-space-between gl-align-items-flex-end"
+ >
+ <div
+ class="gl-display-flex align-items-start flex-wrap-reverse board-card-number-container gl-overflow-hidden js-board-card-number-container"
+ >
+ <span
+ v-if="issue.referencePath"
+ class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3"
+ >
+ <tooltip-on-truncate
+ v-if="issueReferencePath"
+ :title="issueReferencePath"
+ placement="bottom"
+ class="board-issue-path gl-text-truncate gl-font-weight-bold"
+ >{{ issueReferencePath }}</tooltip-on-truncate
+ >
+ #{{ issue.iid }}
+ </span>
+ <span class="board-info-items gl-mt-3 gl-display-inline-block">
+ <issue-due-date
+ v-if="issue.dueDate"
+ :date="issue.dueDate"
+ :closed="issue.closed || Boolean(issue.closedAt)"
+ />
+ <issue-time-estimate v-if="issue.timeEstimate" :estimate="issue.timeEstimate" />
+ <issue-card-weight
+ v-if="validIssueWeight"
+ :weight="issue.weight"
+ @click="filterByWeight(issue.weight)"
+ />
+ </span>
+ </div>
+ <div class="board-card-assignee gl-display-flex">
+ <user-avatar-link
+ v-for="(assignee, index) in issue.assignees"
+ v-if="shouldRenderAssignee(index)"
+ :key="assignee.id"
+ :link-href="assigneeUrl(assignee)"
+ :img-alt="avatarUrlTitle(assignee)"
+ :img-src="assignee.avatarUrl || assignee.avatar || assignee.avatar_url"
+ :img-size="24"
+ class="js-no-trigger"
+ tooltip-placement="bottom"
+ >
+ <span class="js-assignee-tooltip">
+ <span class="gl-font-weight-bold gl-display-block">{{ __('Assignee') }}</span>
+ {{ assignee.name }}
+ <span class="text-white-50">@{{ assignee.username }}</span>
+ </span>
+ </user-avatar-link>
+ <span
+ v-if="shouldRenderCounter"
+ v-gl-tooltip
+ :title="assigneeCounterTooltip"
+ class="avatar-counter"
+ data-placement="bottom"
+ >{{ assigneeCounterLabel }}</span
+ >
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/boards/components/issue_time_estimate.vue b/app/assets/javascripts/boards/components/issue_time_estimate.vue
index fe56833016e..f6b00b695da 100644
--- a/app/assets/javascripts/boards/components/issue_time_estimate.vue
+++ b/app/assets/javascripts/boards/components/issue_time_estimate.vue
@@ -1,30 +1,34 @@
<script>
import { GlTooltip, GlIcon } from '@gitlab/ui';
+import { __ } from '~/locale';
import { parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility';
-import boardsStore from '../stores/boards_store';
export default {
+ i18n: {
+ timeEstimate: __('Time estimate'),
+ },
components: {
GlIcon,
GlTooltip,
},
+ inject: ['timeTrackingLimitToHours'],
props: {
estimate: {
type: Number,
required: true,
},
},
- data() {
- return {
- limitToHours: boardsStore.timeTracking.limitToHours,
- };
- },
computed: {
title() {
- return stringifyTime(parseSeconds(this.estimate, { limitToHours: this.limitToHours }), true);
+ return stringifyTime(
+ parseSeconds(this.estimate, { limitToHours: this.timeTrackingLimitToHours }),
+ true,
+ );
},
timeEstimate() {
- return stringifyTime(parseSeconds(this.estimate, { limitToHours: this.limitToHours }));
+ return stringifyTime(
+ parseSeconds(this.estimate, { limitToHours: this.timeTrackingLimitToHours }),
+ );
},
},
};
@@ -33,16 +37,16 @@ export default {
<template>
<span>
<span ref="issueTimeEstimate" class="board-card-info card-number">
- <gl-icon name="hourglass" class="board-card-info-icon" /><time class="board-card-info-text">{{
- timeEstimate
- }}</time>
+ <gl-icon name="hourglass" class="board-card-info-icon" />
+ <time class="board-card-info-text">{{ timeEstimate }}</time>
</span>
<gl-tooltip
:target="() => $refs.issueTimeEstimate"
placement="bottom"
class="js-issue-time-estimate"
>
- <span class="bold d-block">{{ __('Time estimate') }}</span> {{ title }}
+ <span class="gl-font-weight-bold gl-display-block">{{ $options.i18n.timeEstimate }}</span>
+ {{ title }}
</gl-tooltip>
</span>
</template>
diff --git a/app/assets/javascripts/boards/components/issue_time_estimate_deprecated.vue b/app/assets/javascripts/boards/components/issue_time_estimate_deprecated.vue
new file mode 100644
index 00000000000..fe56833016e
--- /dev/null
+++ b/app/assets/javascripts/boards/components/issue_time_estimate_deprecated.vue
@@ -0,0 +1,48 @@
+<script>
+import { GlTooltip, GlIcon } from '@gitlab/ui';
+import { parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility';
+import boardsStore from '../stores/boards_store';
+
+export default {
+ components: {
+ GlIcon,
+ GlTooltip,
+ },
+ props: {
+ estimate: {
+ type: Number,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ limitToHours: boardsStore.timeTracking.limitToHours,
+ };
+ },
+ computed: {
+ title() {
+ return stringifyTime(parseSeconds(this.estimate, { limitToHours: this.limitToHours }), true);
+ },
+ timeEstimate() {
+ return stringifyTime(parseSeconds(this.estimate, { limitToHours: this.limitToHours }));
+ },
+ },
+};
+</script>
+
+<template>
+ <span>
+ <span ref="issueTimeEstimate" class="board-card-info card-number">
+ <gl-icon name="hourglass" class="board-card-info-icon" /><time class="board-card-info-text">{{
+ timeEstimate
+ }}</time>
+ </span>
+ <gl-tooltip
+ :target="() => $refs.issueTimeEstimate"
+ placement="bottom"
+ class="js-issue-time-estimate"
+ >
+ <span class="bold d-block">{{ __('Time estimate') }}</span> {{ title }}
+ </gl-tooltip>
+ </span>
+</template>
diff --git a/app/assets/javascripts/boards/components/modal/footer.vue b/app/assets/javascripts/boards/components/modal/footer.vue
index d28a03da97f..10c29977cae 100644
--- a/app/assets/javascripts/boards/components/modal/footer.vue
+++ b/app/assets/javascripts/boards/components/modal/footer.vue
@@ -40,21 +40,21 @@ export default {
const firstListIndex = 1;
const list = this.modal.selectedList || this.state.lists[firstListIndex];
const selectedIssues = ModalStore.getSelectedIssues();
- const issueIds = selectedIssues.map(issue => issue.id);
+ const issueIds = selectedIssues.map((issue) => issue.id);
const req = this.buildUpdateRequest(list);
// Post the data to the backend
boardsStore.bulkUpdate(issueIds, req).catch(() => {
Flash(__('Failed to update issues, please try again.'));
- selectedIssues.forEach(issue => {
+ selectedIssues.forEach((issue) => {
list.removeIssue(issue);
list.issuesSize -= 1;
});
});
// Add the issues on the frontend
- selectedIssues.forEach(issue => {
+ selectedIssues.forEach((issue) => {
list.addIssue(issue);
list.issuesSize += 1;
});
diff --git a/app/assets/javascripts/boards/components/modal/index.vue b/app/assets/javascripts/boards/components/modal/index.vue
index 817b3bdddb0..84d687a46b9 100644
--- a/app/assets/javascripts/boards/components/modal/index.vue
+++ b/app/assets/javascripts/boards/components/modal/index.vue
@@ -65,9 +65,7 @@ export default {
this.loading = false;
};
- this.loadIssues()
- .then(loadingDone)
- .catch(loadingDone);
+ this.loadIssues().then(loadingDone).catch(loadingDone);
} else if (!this.showAddIssuesModal) {
this.issues = [];
this.selectedIssues = [];
@@ -83,9 +81,7 @@ export default {
this.filterLoading = false;
};
- this.loadIssues(true)
- .then(loadingDone)
- .catch(loadingDone);
+ this.loadIssues(true).then(loadingDone).catch(loadingDone);
}
},
deep: true,
@@ -104,13 +100,13 @@ export default {
page: this.page,
per: this.perPage,
})
- .then(res => res.data)
- .then(data => {
+ .then((res) => res.data)
+ .then((data) => {
if (clearIssues) {
this.issues = [];
}
- data.issues.forEach(issueObj => {
+ data.issues.forEach((issueObj) => {
const issue = new ListIssue(issueObj);
const foundSelectedIssue = ModalStore.findSelectedIssue(issue);
issue.selected = Boolean(foundSelectedIssue);
diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js
index d1011c24977..2bc54155163 100644
--- a/app/assets/javascripts/boards/components/new_list_dropdown.js
+++ b/app/assets/javascripts/boards/components/new_list_dropdown.js
@@ -40,7 +40,7 @@ $(document)
});
export default function initNewListDropdown() {
- $('.js-new-board-list').each(function() {
+ $('.js-new-board-list').each(function () {
const $dropdownToggle = $(this);
const $dropdown = $dropdownToggle.closest('.dropdown');
new CreateLabelDropdown(
diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue
index 9c90938fc52..04699d0d3a4 100644
--- a/app/assets/javascripts/boards/components/project_select.vue
+++ b/app/assets/javascripts/boards/components/project_select.vue
@@ -1,120 +1,141 @@
<script>
-import $ from 'jquery';
-import { escape } from 'lodash';
-import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
-import { __ } from '~/locale';
-import eventHub from '../eventhub';
-import Api from '../../api';
+import { mapActions, mapState } from 'vuex';
+import {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlSearchBoxByType,
+ GlIntersectionObserver,
+ GlLoadingIcon,
+} from '@gitlab/ui';
+import { s__ } from '~/locale';
import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants';
-import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import { ListType } from '../constants';
export default {
- name: 'BoardProjectSelect',
+ name: 'ProjectSelect',
+ i18n: {
+ headerTitle: s__(`BoardNewIssue|Projects`),
+ dropdownText: s__(`BoardNewIssue|Select a project`),
+ searchPlaceholder: s__(`BoardNewIssue|Search projects`),
+ emptySearchResult: s__(`BoardNewIssue|No matching results`),
+ },
+ defaultFetchOptions: {
+ with_issues_enabled: true,
+ with_shared: false,
+ include_subgroups: true,
+ order_by: 'similarity',
+ },
components: {
- GlIcon,
+ GlIntersectionObserver,
GlLoadingIcon,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlSearchBoxByType,
},
+ inject: ['groupId'],
props: {
list: {
type: Object,
required: true,
},
},
- inject: ['groupId'],
data() {
return {
- loading: true,
+ initialLoading: true,
selectedProject: {},
+ searchTerm: '',
};
},
computed: {
+ ...mapState(['groupProjects', 'groupProjectsFlags']),
selectedProjectName() {
- return this.selectedProject.name || __('Select a project');
+ return this.selectedProject.name || this.$options.i18n.dropdownText;
+ },
+ fetchOptions() {
+ const additionalAttrs = {};
+ if (this.list.type && this.list.type !== ListType.backlog) {
+ additionalAttrs.min_access_level = featureAccessLevel.EVERYONE;
+ }
+
+ return {
+ ...this.$options.defaultFetchOptions,
+ ...additionalAttrs,
+ };
+ },
+ isFetchResultEmpty() {
+ return this.groupProjects.length === 0;
+ },
+ hasNextPage() {
+ return this.groupProjectsFlags.pageInfo?.hasNextPage;
+ },
+ },
+ watch: {
+ searchTerm() {
+ this.fetchGroupProjects({ search: this.searchTerm });
},
},
mounted() {
- initDeprecatedJQueryDropdown($(this.$refs.projectsDropdown), {
- filterable: true,
- filterRemote: true,
- search: {
- fields: ['name_with_namespace'],
- },
- clicked: ({ $el, e }) => {
- e.preventDefault();
- this.selectedProject = {
- id: $el.data('project-id'),
- name: $el.data('project-name'),
- path: $el.data('project-path'),
- };
- eventHub.$emit('setSelectedProject', this.selectedProject);
- },
- selectable: true,
- data: (term, callback) => {
- this.loading = true;
- const additionalAttrs = {};
+ this.fetchGroupProjects({});
- if ((this.list.type || this.list.listType) !== ListType.backlog) {
- additionalAttrs.min_access_level = featureAccessLevel.EVERYONE;
- }
-
- return Api.groupProjects(
- this.groupId,
- term,
- {
- with_issues_enabled: true,
- with_shared: false,
- include_subgroups: true,
- order_by: 'similarity',
- ...additionalAttrs,
- },
- projects => {
- this.loading = false;
- callback(projects);
- },
- );
- },
- renderRow(project) {
- return `
- <li>
- <a href='#' class='dropdown-menu-link'
- data-project-id="${project.id}"
- data-project-name="${project.name}"
- data-project-name-with-namespace="${project.name_with_namespace}"
- data-project-path="${project.path_with_namespace}"
- >
- ${escape(project.name_with_namespace)}
- </a>
- </li>
- `;
- },
- text: project => project.name_with_namespace,
- });
+ this.initialLoading = false;
+ },
+ methods: {
+ ...mapActions(['fetchGroupProjects', 'setSelectedProject']),
+ selectProject(projectId) {
+ this.selectedProject = this.groupProjects.find((project) => project.id === projectId);
+ this.setSelectedProject(this.selectedProject);
+ },
+ loadMoreProjects() {
+ this.fetchGroupProjects({ search: this.searchTerm, fetchNext: true });
+ },
},
};
</script>
<template>
<div>
- <label class="label-bold gl-mt-3">{{ __('Project') }}</label>
- <div ref="projectsDropdown" class="dropdown dropdown-projects">
- <button
- class="dropdown-menu-toggle wide"
- type="button"
- data-toggle="dropdown"
- aria-expanded="false"
+ <label class="gl-font-weight-bold gl-mt-3" data-testid="header-label">{{
+ $options.i18n.headerTitle
+ }}</label>
+ <gl-dropdown
+ data-testid="project-select-dropdown"
+ :text="selectedProjectName"
+ :header-text="$options.i18n.headerTitle"
+ block
+ menu-class="gl-w-full!"
+ :loading="initialLoading"
+ >
+ <gl-search-box-by-type
+ v-model.trim="searchTerm"
+ debounce="250"
+ :placeholder="$options.i18n.searchPlaceholder"
+ />
+ <gl-dropdown-item
+ v-for="project in groupProjects"
+ v-show="!groupProjectsFlags.isLoading"
+ :key="project.id"
+ :name="project.name"
+ @click="selectProject(project.id)"
+ >
+ {{ project.nameWithNamespace }}
+ </gl-dropdown-item>
+ <gl-dropdown-text
+ v-show="groupProjectsFlags.isLoading"
+ data-testid="dropdown-text-loading-icon"
+ >
+ <gl-loading-icon class="gl-mx-auto" />
+ </gl-dropdown-text>
+ <gl-dropdown-text
+ v-if="isFetchResultEmpty && !groupProjectsFlags.isLoading"
+ data-testid="empty-result-message"
>
- {{ selectedProjectName }} <gl-icon name="chevron-down" class="dropdown-menu-toggle-icon" />
- </button>
- <div class="dropdown-menu dropdown-menu-selectable dropdown-menu-full-width">
- <div class="dropdown-title">{{ __('Projects') }}</div>
- <div class="dropdown-input">
- <input class="dropdown-input-field" type="search" :placeholder="__('Search projects')" />
- <gl-icon name="search" class="dropdown-input-search" data-hidden="true" />
- </div>
- <div class="dropdown-content"></div>
- <div class="dropdown-loading"><gl-loading-icon /></div>
- </div>
- </div>
+ <span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span>
+ </gl-dropdown-text>
+ <gl-intersection-observer v-if="hasNextPage" @appear="loadMoreProjects">
+ <gl-loading-icon v-if="groupProjectsFlags.isLoadingMore" size="md" />
+ </gl-intersection-observer>
+ </gl-dropdown>
</div>
</template>
diff --git a/app/assets/javascripts/boards/components/project_select_deprecated.vue b/app/assets/javascripts/boards/components/project_select_deprecated.vue
new file mode 100644
index 00000000000..a043dc575ca
--- /dev/null
+++ b/app/assets/javascripts/boards/components/project_select_deprecated.vue
@@ -0,0 +1,145 @@
+<script>
+import {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlSearchBoxByType,
+ GlLoadingIcon,
+} from '@gitlab/ui';
+import eventHub from '../eventhub';
+import { s__ } from '~/locale';
+import Api from '../../api';
+import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants';
+import { ListType } from '../constants';
+
+export default {
+ name: 'ProjectSelect',
+ i18n: {
+ headerTitle: s__(`BoardNewIssue|Projects`),
+ dropdownText: s__(`BoardNewIssue|Select a project`),
+ searchPlaceholder: s__(`BoardNewIssue|Search projects`),
+ emptySearchResult: s__(`BoardNewIssue|No matching results`),
+ },
+ defaultFetchOptions: {
+ with_issues_enabled: true,
+ with_shared: false,
+ include_subgroups: true,
+ order_by: 'similarity',
+ },
+ components: {
+ GlLoadingIcon,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlSearchBoxByType,
+ },
+ inject: ['groupId'],
+ props: {
+ list: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ initialLoading: true,
+ isFetching: false,
+ projects: [],
+ selectedProject: {},
+ searchTerm: '',
+ };
+ },
+ computed: {
+ selectedProjectName() {
+ return this.selectedProject.name || this.$options.i18n.dropdownText;
+ },
+ fetchOptions() {
+ const additionalAttrs = {};
+ if (this.list.type && this.list.type !== ListType.backlog) {
+ additionalAttrs.min_access_level = featureAccessLevel.EVERYONE;
+ }
+
+ return {
+ ...this.$options.defaultFetchOptions,
+ ...additionalAttrs,
+ };
+ },
+ isFetchResultEmpty() {
+ return this.projects.length === 0;
+ },
+ },
+ watch: {
+ searchTerm() {
+ this.fetchProjects();
+ },
+ },
+ async mounted() {
+ await this.fetchProjects();
+
+ this.initialLoading = false;
+ },
+ methods: {
+ async fetchProjects() {
+ this.isFetching = true;
+ try {
+ const projects = await Api.groupProjects(this.groupId, this.searchTerm, this.fetchOptions);
+
+ this.projects = projects.map((project) => {
+ return {
+ id: project.id,
+ name: project.name,
+ namespacedName: project.name_with_namespace,
+ path: project.path_with_namespace,
+ };
+ });
+ } catch (err) {
+ /* Handled in Api.groupProjects */
+ } finally {
+ this.isFetching = false;
+ }
+ },
+ selectProject(projectId) {
+ this.selectedProject = this.projects.find((project) => project.id === projectId);
+
+ eventHub.$emit('setSelectedProject', this.selectedProject);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <label class="gl-font-weight-bold gl-mt-3" data-testid="header-label">{{
+ $options.i18n.headerTitle
+ }}</label>
+ <gl-dropdown
+ data-testid="project-select-dropdown"
+ :text="selectedProjectName"
+ :header-text="$options.i18n.headerTitle"
+ block
+ menu-class="gl-w-full!"
+ :loading="initialLoading"
+ >
+ <gl-search-box-by-type
+ v-model.trim="searchTerm"
+ debounce="250"
+ :placeholder="$options.i18n.searchPlaceholder"
+ />
+ <gl-dropdown-item
+ v-for="project in projects"
+ v-show="!isFetching"
+ :key="project.id"
+ :name="project.name"
+ @click="selectProject(project.id)"
+ >
+ {{ project.namespacedName }}
+ </gl-dropdown-item>
+ <gl-dropdown-text v-show="isFetching" data-testid="dropdown-text-loading-icon">
+ <gl-loading-icon class="gl-mx-auto" />
+ </gl-dropdown-text>
+ <gl-dropdown-text v-if="isFetchResultEmpty && !isFetching" data-testid="empty-result-message">
+ <span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span>
+ </gl-dropdown-text>
+ </gl-dropdown>
+ </div>
+</template>
diff --git a/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue
index ce267be6d45..61863bbe2a9 100644
--- a/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue
+++ b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue
@@ -3,6 +3,7 @@ import { GlButton, GlLoadingIcon } from '@gitlab/ui';
export default {
components: { GlButton, GlLoadingIcon },
+ inject: ['canUpdate'],
props: {
title: {
type: String,
@@ -14,20 +15,41 @@ export default {
required: false,
default: false,
},
+ toggleHeader: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ handleOffClick: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
- inject: ['canUpdate'],
data() {
return {
edit: false,
};
},
+ computed: {
+ showHeader() {
+ if (!this.toggleHeader) {
+ return true;
+ }
+
+ return !this.edit;
+ },
+ },
destroyed() {
window.removeEventListener('click', this.collapseWhenOffClick);
},
methods: {
collapseWhenOffClick({ target }) {
if (!this.$el.contains(target)) {
- this.collapse();
+ this.$emit('off-click');
+ if (this.handleOffClick) {
+ this.collapse();
+ }
}
},
expand() {
@@ -63,21 +85,26 @@ export default {
<template>
<div>
- <div class="gl-display-flex gl-justify-content-space-between gl-mb-3">
+ <header
+ v-show="showHeader"
+ class="gl-display-flex gl-justify-content-space-between gl-align-items-flex-start gl-mb-3"
+ >
<span class="gl-vertical-align-middle">
- <span data-testid="title">{{ title }}</span>
+ <slot name="title">
+ <span data-testid="title">{{ title }}</span>
+ </slot>
<gl-loading-icon v-if="loading" inline class="gl-ml-2" />
</span>
<gl-button
v-if="canUpdate"
variant="link"
- class="gl-text-gray-900! js-sidebar-dropdown-toggle"
+ class="gl-text-gray-900! gl-ml-5 js-sidebar-dropdown-toggle"
data-testid="edit-button"
@click="toggle"
>
{{ __('Edit') }}
</gl-button>
- </div>
+ </header>
<div v-show="!edit" class="gl-text-gray-500" data-testid="collapsed-content">
<slot name="collapsed">{{ __('None') }}</slot>
</div>
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
index 904ceaed1b3..4a664d5beef 100644
--- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue
+++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue
@@ -18,16 +18,16 @@ export default {
};
},
computed: {
- ...mapGetters({ issue: 'activeIssue', projectPathForActiveIssue: 'projectPathForActiveIssue' }),
+ ...mapGetters(['activeIssue', 'projectPathForActiveIssue']),
hasDueDate() {
- return this.issue.dueDate != null;
+ return this.activeIssue.dueDate != null;
},
parsedDueDate() {
if (!this.hasDueDate) {
return null;
}
- return parsePikadayDate(this.issue.dueDate);
+ return parsePikadayDate(this.activeIssue.dueDate);
},
formattedDueDate() {
if (!this.hasDueDate) {
diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_issue_title.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_issue_title.vue
new file mode 100644
index 00000000000..d0e641daf5c
--- /dev/null
+++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_issue_title.vue
@@ -0,0 +1,171 @@
+<script>
+import { mapGetters, mapActions } from 'vuex';
+import { GlAlert, GlButton, GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui';
+import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
+import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
+import { joinPaths } from '~/lib/utils/url_utility';
+import createFlash from '~/flash';
+import { __ } from '~/locale';
+
+export default {
+ components: {
+ GlForm,
+ GlAlert,
+ GlButton,
+ GlFormGroup,
+ GlFormInput,
+ BoardEditableItem,
+ },
+ directives: {
+ autofocusonshow,
+ },
+ data() {
+ return {
+ title: '',
+ loading: false,
+ showChangesAlert: false,
+ };
+ },
+ computed: {
+ ...mapGetters({ issue: 'activeIssue' }),
+ pendingChangesStorageKey() {
+ return this.getPendingChangesKey(this.issue);
+ },
+ projectPath() {
+ const referencePath = this.issue.referencePath || '';
+ return referencePath.slice(0, referencePath.indexOf('#'));
+ },
+ validationState() {
+ return Boolean(this.title);
+ },
+ },
+ watch: {
+ issue: {
+ handler(updatedIssue, formerIssue) {
+ if (formerIssue?.title !== this.title) {
+ localStorage.setItem(this.getPendingChangesKey(formerIssue), this.title);
+ }
+
+ this.title = updatedIssue.title;
+ this.setPendingState();
+ },
+ immediate: true,
+ },
+ },
+ methods: {
+ ...mapActions(['setActiveIssueTitle']),
+ getPendingChangesKey(issue) {
+ if (!issue) {
+ return '';
+ }
+
+ return joinPaths(
+ window.location.pathname.slice(1),
+ String(issue.id),
+ 'issue-title-pending-changes',
+ );
+ },
+ async setPendingState() {
+ const pendingChanges = localStorage.getItem(this.pendingChangesStorageKey);
+
+ if (pendingChanges) {
+ this.title = pendingChanges;
+ this.showChangesAlert = true;
+ await this.$nextTick();
+ this.$refs.sidebarItem.expand();
+ } else {
+ this.showChangesAlert = false;
+ }
+ },
+ cancel() {
+ this.title = this.issue.title;
+ this.$refs.sidebarItem.collapse();
+ this.showChangesAlert = false;
+ localStorage.removeItem(this.pendingChangesStorageKey);
+ },
+ async setTitle() {
+ this.$refs.sidebarItem.collapse();
+
+ if (!this.title || this.title === this.issue.title) {
+ return;
+ }
+
+ try {
+ this.loading = true;
+ await this.setActiveIssueTitle({ title: this.title, projectPath: this.projectPath });
+ localStorage.removeItem(this.pendingChangesStorageKey);
+ this.showChangesAlert = false;
+ } catch (e) {
+ this.title = this.issue.title;
+ createFlash({ message: this.$options.i18n.updateTitleError });
+ } finally {
+ this.loading = false;
+ }
+ },
+ handleOffClick() {
+ if (this.title !== this.issue.title) {
+ this.showChangesAlert = true;
+ localStorage.setItem(this.pendingChangesStorageKey, this.title);
+ } else {
+ this.$refs.sidebarItem.collapse();
+ }
+ },
+ },
+ i18n: {
+ issueTitlePlaceholder: __('Issue title'),
+ submitButton: __('Save changes'),
+ cancelButton: __('Cancel'),
+ updateTitleError: __('An error occurred when updating the issue title'),
+ invalidFeedback: __('An issue title is required'),
+ reviewYourChanges: __('Changes to the title have not been saved'),
+ },
+};
+</script>
+
+<template>
+ <board-editable-item
+ ref="sidebarItem"
+ toggle-header
+ :loading="loading"
+ :handle-off-click="false"
+ @off-click="handleOffClick"
+ >
+ <template #title>
+ <span class="gl-font-weight-bold" data-testid="issue-title">{{ issue.title }}</span>
+ </template>
+ <template #collapsed>
+ <span class="gl-text-gray-800">{{ issue.referencePath }}</span>
+ </template>
+ <template>
+ <gl-alert v-if="showChangesAlert" variant="warning" class="gl-mb-5" :dismissible="false">
+ {{ $options.i18n.reviewYourChanges }}
+ </gl-alert>
+ <gl-form @submit.prevent="setTitle">
+ <gl-form-group :invalid-feedback="$options.i18n.invalidFeedback" :state="validationState">
+ <gl-form-input
+ v-model="title"
+ v-autofocusonshow
+ :placeholder="$options.i18n.issueTitlePlaceholder"
+ :state="validationState"
+ />
+ </gl-form-group>
+
+ <div class="gl-display-flex gl-w-full gl-justify-content-space-between gl-mt-5">
+ <gl-button
+ variant="success"
+ size="small"
+ data-testid="submit-button"
+ :disabled="!title"
+ @click="setTitle"
+ >
+ {{ $options.i18n.submitButton }}
+ </gl-button>
+
+ <gl-button size="small" data-testid="cancel-button" @click="cancel">
+ {{ $options.i18n.cancelButton }}
+ </gl-button>
+ </div>
+ </gl-form>
+ </template>
+ </board-editable-item>
+</template>
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 6a407bd6ba6..dcf769e6fe5 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
@@ -14,18 +14,18 @@ export default {
LabelsSelect,
GlLabel,
},
+ inject: ['labelsFetchPath', 'labelsManagePath', 'labelsFilterBasePath'],
data() {
return {
loading: false,
};
},
- inject: ['labelsFetchPath', 'labelsManagePath', 'labelsFilterBasePath'],
computed: {
...mapGetters(['activeIssue', 'projectPathForActiveIssue']),
selectedLabels() {
const { labels = [] } = this.activeIssue;
- return labels.map(label => ({
+ return labels.map((label) => ({
...label,
id: getIdFromGraphQLId(label.id),
}));
@@ -33,7 +33,7 @@ export default {
issueLabels() {
const { labels = [] } = this.activeIssue;
- return labels.map(label => ({
+ return labels.map((label) => ({
...label,
scoped: isScopedLabel(label),
}));
@@ -46,10 +46,10 @@ export default {
this.$refs.sidebarItem.collapse();
try {
- const addLabelIds = payload.filter(label => label.set).map(label => label.id);
+ const addLabelIds = payload.filter((label) => label.set).map((label) => label.id);
const removeLabelIds = this.selectedLabels
- .filter(label => !payload.find(selected => selected.id === label.id))
- .map(label => label.id);
+ .filter((label) => !payload.find((selected) => selected.id === label.id))
+ .map((label) => label.id);
const input = { addLabelIds, removeLabelIds, projectPath: this.projectPathForActiveIssue };
await this.setActiveIssueLabels(input);
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
index 78c3f8acc62..144a81f009b 100644
--- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue
+++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue
@@ -50,7 +50,7 @@ export default {
},
update(data) {
const edges = data?.group?.milestones?.edges ?? [];
- return edges.map(item => item.node);
+ return edges.map((item) => item.node);
},
error() {
createFlash({ message: this.$options.i18n.fetchMilestonesError });
@@ -58,20 +58,20 @@ export default {
},
},
computed: {
- ...mapGetters({ issue: 'activeIssue' }),
+ ...mapGetters(['activeIssue']),
hasMilestone() {
- return this.issue.milestone !== null;
+ return this.activeIssue.milestone !== null;
},
groupFullPath() {
- const { referencePath = '' } = this.issue;
+ const { referencePath = '' } = this.activeIssue;
return referencePath.slice(0, referencePath.indexOf('/'));
},
projectPath() {
- const { referencePath = '' } = this.issue;
+ const { referencePath = '' } = this.activeIssue;
return referencePath.slice(0, referencePath.indexOf('#'));
},
dropdownText() {
- return this.issue.milestone?.title ?? this.$options.i18n.noMilestone;
+ return this.activeIssue.milestone?.title ?? this.$options.i18n.noMilestone;
},
},
mounted() {
@@ -120,7 +120,7 @@ export default {
@close="edit = false"
>
<template v-if="hasMilestone" #collapsed>
- <strong class="gl-text-gray-900">{{ issue.milestone.title }}</strong>
+ <strong class="gl-text-gray-900">{{ activeIssue.milestone.title }}</strong>
</template>
<template>
<gl-dropdown
@@ -133,7 +133,7 @@ export default {
<gl-dropdown-item
data-testid="no-milestone-item"
:is-check-item="true"
- :is-checked="!issue.milestone"
+ :is-checked="!activeIssue.milestone"
@click="setMilestone(null)"
>
{{ $options.i18n.noMilestone }}
@@ -145,7 +145,7 @@ export default {
v-for="milestone in milestones"
:key="milestone.id"
:is-check-item="true"
- :is-checked="issue.milestone && milestone.id === issue.milestone.id"
+ :is-checked="activeIssue.milestone && milestone.id === activeIssue.milestone.id"
data-testid="milestone-item"
@click="setMilestone(milestone.id)"
>
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 ed069cea630..4aa8d2f55e4 100644
--- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue
+++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue
@@ -15,7 +15,7 @@ export default {
),
},
updateSubscribedErrorMessage: s__(
- 'IssueBoards|An error occurred while setting notifications status.',
+ 'IssueBoards|An error occurred while setting notifications status. Please try again.',
),
},
components: {
diff --git a/app/assets/javascripts/boards/components/sidebar/remove_issue.vue b/app/assets/javascripts/boards/components/sidebar/remove_issue.vue
index 4e5a6609042..8d65f3240c8 100644
--- a/app/assets/javascripts/boards/components/sidebar/remove_issue.vue
+++ b/app/assets/javascripts/boards/components/sidebar/remove_issue.vue
@@ -42,13 +42,13 @@ export default {
axios.patch(this.updateUrl, data).catch(() => {
Flash(__('Failed to remove issue from board, please try again.'));
- lists.forEach(list => {
+ lists.forEach((list) => {
list.addIssue(issue);
});
});
// Remove from the frontend store
- lists.forEach(list => {
+ lists.forEach((list) => {
list.removeIssue(issue);
});
@@ -58,9 +58,11 @@ export default {
* Build the default patch request.
*/
buildPatchRequest(issue, lists) {
- const listLabelIds = lists.map(list => list.label.id);
+ const listLabelIds = lists.map((list) => list.label.id);
- const labelIds = issue.labels.map(label => label.id).filter(id => !listLabelIds.includes(id));
+ const labelIds = issue.labels
+ .map((label) => label.id)
+ .filter((id) => !listLabelIds.includes(id));
return {
label_ids: labelIds,
diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js
index 1667dcc9f2e..94b35aadaf1 100644
--- a/app/assets/javascripts/boards/filtered_search_boards.js
+++ b/app/assets/javascripts/boards/filtered_search_boards.js
@@ -23,8 +23,8 @@ export default class FilteredSearchBoards extends FilteredSearchManager {
// Issue boards is slightly different, we handle all the requests async
// instead or reloading the page, we just re-fire the list ajax requests
this.isHandledAsync = true;
- this.cantEdit = cantEdit.filter(i => typeof i === 'string');
- this.cantEditWithValue = cantEdit.filter(i => typeof i === 'object');
+ this.cantEdit = cantEdit.filter((i) => typeof i === 'string');
+ this.cantEditWithValue = cantEdit.filter((i) => typeof i === 'object');
if (vuexstore.getters.shouldUseGraphQL && vuexstore.state.boardConfig) {
const boardConfigPath = transformBoardConfig(vuexstore.state.boardConfig);
@@ -55,7 +55,7 @@ export default class FilteredSearchBoards extends FilteredSearchManager {
const tokens = FilteredSearchContainer.container.querySelectorAll('.js-visual-token');
// Remove all the tokens as they will be replaced by the search manager
- [].forEach.call(tokens, el => {
+ [].forEach.call(tokens, (el) => {
el.parentNode.removeChild(el);
});
@@ -75,7 +75,7 @@ export default class FilteredSearchBoards extends FilteredSearchManager {
if (this.cantEdit.includes(tokenName)) return false;
return (
this.cantEditWithValue.findIndex(
- token => token.name === tokenName && token.value === tokenValue,
+ (token) => token.name === tokenName && token.value === tokenValue,
) === -1
);
}
diff --git a/app/assets/javascripts/boards/filters/due_date_filters.js b/app/assets/javascripts/boards/filters/due_date_filters.js
index 9eaa0cd227d..c35dedde71b 100644
--- a/app/assets/javascripts/boards/filters/due_date_filters.js
+++ b/app/assets/javascripts/boards/filters/due_date_filters.js
@@ -1,7 +1,7 @@
import Vue from 'vue';
import dateFormat from 'dateformat';
-Vue.filter('due-date', value => {
+Vue.filter('due-date', (value) => {
const date = new Date(value);
return dateFormat(date, 'mmm d, yyyy', true);
});
diff --git a/app/assets/javascripts/boards/graphql/board.mutation.graphql b/app/assets/javascripts/boards/graphql/board.mutation.graphql
deleted file mode 100644
index ef2b81a7939..00000000000
--- a/app/assets/javascripts/boards/graphql/board.mutation.graphql
+++ /dev/null
@@ -1,11 +0,0 @@
-mutation UpdateBoard($id: ID!, $hideClosedList: Boolean, $hideBacklogList: Boolean) {
- updateBoard(
- input: { id: $id, hideClosedList: $hideClosedList, hideBacklogList: $hideBacklogList }
- ) {
- board {
- id
- hideClosedList
- hideBacklogList
- }
- }
-}
diff --git a/app/assets/javascripts/boards/graphql/board_create.mutation.graphql b/app/assets/javascripts/boards/graphql/board_create.mutation.graphql
new file mode 100644
index 00000000000..b3ea79d6443
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql/board_create.mutation.graphql
@@ -0,0 +1,9 @@
+mutation createBoard($input: CreateBoardInput!) {
+ createBoard(input: $input) {
+ board {
+ id
+ webPath
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/boards/graphql/board_destroy.mutation.graphql b/app/assets/javascripts/boards/graphql/board_destroy.mutation.graphql
new file mode 100644
index 00000000000..d4b928749de
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql/board_destroy.mutation.graphql
@@ -0,0 +1,7 @@
+mutation destroyBoard($id: BoardID!) {
+ destroyBoard(input: { id: $id }) {
+ board {
+ id
+ }
+ }
+}
diff --git a/app/assets/javascripts/boards/graphql/board_update.mutation.graphql b/app/assets/javascripts/boards/graphql/board_update.mutation.graphql
new file mode 100644
index 00000000000..3abe09079c7
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql/board_update.mutation.graphql
@@ -0,0 +1,9 @@
+mutation UpdateBoard($input: UpdateBoardInput!) {
+ updateBoard(input: $input) {
+ board {
+ id
+ webPath
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/boards/graphql/group_projects.query.graphql b/app/assets/javascripts/boards/graphql/group_projects.query.graphql
new file mode 100644
index 00000000000..1afa6e48547
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql/group_projects.query.graphql
@@ -0,0 +1,17 @@
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+
+query getGroupProjects($fullPath: ID!, $search: String, $after: String) {
+ group(fullPath: $fullPath) {
+ projects(search: $search, after: $after, first: 100) {
+ nodes {
+ id
+ name
+ fullPath
+ nameWithNamespace
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql
new file mode 100644
index 00000000000..62e6c1352a6
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql
@@ -0,0 +1,8 @@
+mutation issueSetTitle($input: UpdateIssueInput!) {
+ updateIssue(input: $input) {
+ issue {
+ title
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index 64a4f246735..ef70a094f7c 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -55,7 +55,7 @@ export default () => {
const $boardApp = document.getElementById('board-app');
// check for browser back and trigger a hard reload to circumvent browser caching.
- window.addEventListener('pageshow', event => {
+ window.addEventListener('pageshow', (event) => {
const isNavTypeBackForward =
window.performance && window.performance.navigation.type === NavigationType.TYPE_BACK_FORWARD;
@@ -68,8 +68,10 @@ export default () => {
issueBoardsApp.$destroy(true);
}
- boardsStore.create();
- boardsStore.setTimeTrackingLimitToHours($boardApp.dataset.timeTrackingLimitToHours);
+ if (!gon?.features?.graphqlBoardLists) {
+ boardsStore.create();
+ boardsStore.setTimeTrackingLimitToHours($boardApp.dataset.timeTrackingLimitToHours);
+ }
issueBoardsApp = new Vue({
el: $boardApp,
@@ -117,16 +119,9 @@ export default () => {
},
},
created() {
- const endpoints = {
- boardsEndpoint: this.boardsEndpoint,
- recentBoardsEndpoint: this.recentBoardsEndpoint,
- listsEndpoint: this.listsEndpoint,
- bulkUpdatePath: this.bulkUpdatePath,
+ this.setInitialBoardData({
boardId: $boardApp.dataset.boardId,
fullPath: $boardApp.dataset.fullPath,
- };
- this.setInitialBoardData({
- ...endpoints,
boardType: this.parent,
disabled: this.disabled,
boardConfig: {
@@ -134,14 +129,23 @@ export default () => {
milestoneTitle: $boardApp.dataset.boardMilestoneTitle || '',
iterationId: parseInt($boardApp.dataset.boardIterationId, 10),
iterationTitle: $boardApp.dataset.boardIterationTitle || '',
+ assigneeId: $boardApp.dataset.boardAssigneeId,
assigneeUsername: $boardApp.dataset.boardAssigneeUsername,
- labels: $boardApp.dataset.labels ? JSON.parse($boardApp.dataset.labels || []) : [],
+ labels: $boardApp.dataset.labels ? JSON.parse($boardApp.dataset.labels) : [],
+ labelIds: $boardApp.dataset.labelIds ? JSON.parse($boardApp.dataset.labelIds) : [],
weight: $boardApp.dataset.boardWeight
? parseInt($boardApp.dataset.boardWeight, 10)
: null,
},
});
- boardsStore.setEndpoints(endpoints);
+ boardsStore.setEndpoints({
+ boardsEndpoint: this.boardsEndpoint,
+ recentBoardsEndpoint: this.recentBoardsEndpoint,
+ listsEndpoint: this.listsEndpoint,
+ bulkUpdatePath: this.bulkUpdatePath,
+ boardId: $boardApp.dataset.boardId,
+ fullPath: $boardApp.dataset.fullPath,
+ });
boardsStore.rootPath = this.boardsEndpoint;
eventHub.$on('updateTokens', this.updateTokens);
@@ -174,9 +178,9 @@ export default () => {
initialBoardLoad() {
boardsStore
.all()
- .then(res => res.data)
- .then(lists => {
- lists.forEach(list => boardsStore.addList(list));
+ .then((res) => res.data)
+ .then((lists) => {
+ lists.forEach((list) => boardsStore.addList(list));
this.loading = false;
})
.catch(() => {
@@ -194,8 +198,8 @@ export default () => {
setEpicFetchingState(newIssue, true);
boardsStore
.getIssueInfo(sidebarInfoEndpoint)
- .then(res => res.data)
- .then(data => {
+ .then((res) => res.data)
+ .then((data) => {
const {
subscribed,
totalTimeSpent,
@@ -305,7 +309,7 @@ export default () => {
if (!this.store) {
return true;
}
- return !this.store.lists.filter(list => !list.preset).length;
+ return !this.store.lists.filter((list) => !list.preset).length;
},
},
methods: {
@@ -335,7 +339,7 @@ export default () => {
}
mountMultipleBoardsSwitcher({
- boardsEndpoint: $boardApp.dataset.boardsEndpoint,
- recentBoardsEndpoint: $boardApp.dataset.recentBoardsEndpoint,
+ fullPath: $boardApp.dataset.fullPath,
+ rootPath: $boardApp.dataset.boardsEndpoint,
});
};
diff --git a/app/assets/javascripts/boards/mixins/sortable_default_options.js b/app/assets/javascripts/boards/mixins/sortable_default_options.js
index f02c92e4230..a95d749d71c 100644
--- a/app/assets/javascripts/boards/mixins/sortable_default_options.js
+++ b/app/assets/javascripts/boards/mixins/sortable_default_options.js
@@ -24,7 +24,7 @@ export function getBoardSortableDefaultOptions(obj) {
onEnd: sortableEnd,
};
- Object.keys(obj).forEach(key => {
+ Object.keys(obj).forEach((key) => {
defaultSortOptions[key] = obj[key];
});
return defaultSortOptions;
diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js
index 822e6d62ab3..1e77326ba9c 100644
--- a/app/assets/javascripts/boards/models/issue.js
+++ b/app/assets/javascripts/boards/models/issue.js
@@ -70,7 +70,7 @@ class ListIssue {
}
getLists() {
- return boardsStore.state.lists.filter(list => list.findIssue(this.id));
+ return boardsStore.state.lists.filter((list) => list.findIssue(this.id));
}
updateData(newData) {
diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js
index 09f5d5b4dd8..be02ac7b889 100644
--- a/app/assets/javascripts/boards/models/list.js
+++ b/app/assets/javascripts/boards/models/list.js
@@ -35,7 +35,7 @@ class List {
constructor(obj) {
this.id = obj.id;
this.position = obj.position;
- this.title = (obj.list_type || obj.listType) === 'backlog' ? __('Open') : obj.title;
+ this.title = obj.title;
this.type = obj.list_type || obj.listType;
const typeInfo = this.getTypeInfo(this.type);
@@ -134,7 +134,7 @@ class List {
updateMultipleIssues(issues, listFrom, moveBeforeId, moveAfterId) {
boardsStore
.moveMultipleIssues({
- ids: issues.map(issue => issue.id),
+ ids: issues.map((issue) => issue.id),
fromListId: listFrom.id,
toListId: this.id,
moveBeforeId,
diff --git a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js
index df65ebb7526..738c8fb927e 100644
--- a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js
+++ b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js
@@ -10,7 +10,7 @@ const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
-export default (endpoints = {}) => {
+export default (params = {}) => {
const boardsSwitcherElement = document.getElementById('js-multiple-boards-switcher');
return new Vue({
el: boardsSwitcherElement,
@@ -18,6 +18,10 @@ export default (endpoints = {}) => {
BoardsSelector,
},
apolloProvider,
+ provide: {
+ fullPath: params.fullPath,
+ rootPath: params.rootPath,
+ },
data() {
const { dataset } = boardsSwitcherElement;
@@ -35,9 +39,6 @@ export default (endpoints = {}) => {
return { boardsSelectorProps };
},
- provide: {
- endpoints,
- },
render(createElement) {
return createElement(BoardsSelector, {
props: this.boardsSelectorProps,
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
index 59b97eba9fe..1d34f21798a 100644
--- a/app/assets/javascripts/boards/stores/actions.js
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -12,6 +12,8 @@ import {
fullBoardId,
formatListsPageInfo,
formatIssue,
+ formatIssueInput,
+ updateListPosition,
} from '../boards_util';
import createFlash from '~/flash';
import { __ } from '~/locale';
@@ -27,6 +29,8 @@ import issueSetLabelsMutation from '../graphql/issue_set_labels.mutation.graphql
import issueSetDueDateMutation from '../graphql/issue_set_due_date.mutation.graphql';
import issueSetSubscriptionMutation from '../graphql/issue_set_subscription.mutation.graphql';
import issueSetMilestoneMutation from '../graphql/issue_set_milestone.mutation.graphql';
+import issueSetTitleMutation from '../graphql/issue_set_title.mutation.graphql';
+import groupProjectsQuery from '../graphql/group_projects.query.graphql';
const notImplemented = () => {
/* eslint-disable-next-line @gitlab/require-i18n-strings */
@@ -78,8 +82,7 @@ export default {
},
fetchLists: ({ commit, state, dispatch }) => {
- const { endpoints, boardType, filterParams } = state;
- const { fullPath, boardId } = endpoints;
+ const { boardType, filterParams, fullPath, boardId } = state;
const variables = {
fullPath,
@@ -98,7 +101,7 @@ export default {
const { lists, hideBacklogList } = data[boardType]?.board;
commit(types.RECEIVE_BOARD_LISTS_SUCCESS, formatBoardLists(lists));
// Backlog list needs to be created if it doesn't exist and it's not hidden
- if (!lists.nodes.find(l => l.listType === ListType.backlog) && !hideBacklogList) {
+ if (!lists.nodes.find((l) => l.listType === ListType.backlog) && !hideBacklogList) {
dispatch('createList', { backlog: true });
}
})
@@ -106,7 +109,7 @@ export default {
},
createList: ({ state, commit, dispatch }, { backlog, labelId, milestoneId, assigneeId }) => {
- const { boardId } = state.endpoints;
+ const { boardId } = state;
gqlClient
.mutate({
@@ -131,12 +134,11 @@ export default {
},
addList: ({ commit }, list) => {
- commit(types.RECEIVE_ADD_LIST_SUCCESS, list);
+ commit(types.RECEIVE_ADD_LIST_SUCCESS, updateListPosition(list));
},
fetchLabels: ({ state, commit }, searchTerm) => {
- const { endpoints, boardType } = state;
- const { fullPath } = endpoints;
+ const { fullPath, boardType } = state;
const variables = {
fullPath,
@@ -214,11 +216,17 @@ export default {
listId,
},
})
- .then(({ data: { destroyBoardList: { errors } } }) => {
- if (errors.length > 0) {
- commit(types.REMOVE_LIST_FAILURE, listsBackup);
- }
- })
+ .then(
+ ({
+ data: {
+ destroyBoardList: { errors },
+ },
+ }) => {
+ if (errors.length > 0) {
+ commit(types.REMOVE_LIST_FAILURE, listsBackup);
+ }
+ },
+ )
.catch(() => {
commit(types.REMOVE_LIST_FAILURE, listsBackup);
});
@@ -227,8 +235,7 @@ export default {
fetchIssuesForList: ({ state, commit }, { listId, fetchNext = false }) => {
commit(types.REQUEST_ISSUES_FOR_LIST, { listId, fetchNext });
- const { endpoints, boardType, filterParams } = state;
- const { fullPath, boardId } = endpoints;
+ const { fullPath, boardId, boardType, filterParams } = state;
const variables = {
fullPath,
@@ -271,7 +278,7 @@ export default {
const originalIndex = fromList.indexOf(Number(issueId));
commit(types.MOVE_ISSUE, { originalIssue, fromListId, toListId, moveBeforeId, moveAfterId });
- const { boardId } = state.endpoints;
+ const { boardId } = state;
const [fullProjectPath] = issuePath.split(/[#]/);
gqlClient
@@ -356,10 +363,13 @@ export default {
},
createNewIssue: ({ commit, state }, issueInput) => {
- const input = issueInput;
- const { boardType, endpoints } = state;
+ const { boardConfig } = state;
+
+ const input = formatIssueInput(issueInput, boardConfig);
+
+ const { boardType, fullPath } = state;
if (boardType === BoardType.project) {
- input.projectPath = endpoints.fullPath;
+ input.projectPath = fullPath;
}
return gqlClient
@@ -387,7 +397,7 @@ export default {
commit(types.ADD_ISSUE_TO_LIST, { list, issue, position: 0 });
dispatch('createNewIssue', issueInput)
- .then(res => {
+ .then((res) => {
commit(types.ADD_ISSUE_TO_LIST, {
list,
issue: formatIssue({ ...res, id: getIdFromGraphQLId(res.id) }),
@@ -469,6 +479,61 @@ export default {
});
},
+ setActiveIssueTitle: async ({ commit, getters }, input) => {
+ const { activeIssue } = getters;
+ const { data } = await gqlClient.mutate({
+ mutation: issueSetTitleMutation,
+ variables: {
+ input: {
+ iid: String(activeIssue.iid),
+ projectPath: input.projectPath,
+ title: input.title,
+ },
+ },
+ });
+
+ if (data.updateIssue?.errors?.length > 0) {
+ throw new Error(data.updateIssue.errors);
+ }
+
+ commit(types.UPDATE_ISSUE_BY_ID, {
+ issueId: activeIssue.id,
+ prop: 'title',
+ value: data.updateIssue.issue.title,
+ });
+ },
+
+ fetchGroupProjects: ({ commit, state }, { search = '', fetchNext = false }) => {
+ commit(types.REQUEST_GROUP_PROJECTS, fetchNext);
+
+ const { fullPath } = state;
+
+ const variables = {
+ fullPath,
+ search: search !== '' ? search : undefined,
+ after: fetchNext ? state.groupProjectsFlags.pageInfo.endCursor : undefined,
+ };
+
+ return gqlClient
+ .query({
+ query: groupProjectsQuery,
+ variables,
+ })
+ .then(({ data }) => {
+ const { projects } = data.group;
+ commit(types.RECEIVE_GROUP_PROJECTS_SUCCESS, {
+ projects: projects.nodes,
+ pageInfo: projects.pageInfo,
+ fetchNext,
+ });
+ })
+ .catch(() => commit(types.RECEIVE_GROUP_PROJECTS_FAILURE));
+ },
+
+ setSelectedProject: ({ commit }, project) => {
+ commit(types.SET_SELECTED_PROJECT, project);
+ },
+
fetchBacklog: () => {
notImplemented();
},
diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js
index 36702b6ca5f..f59530ddf8f 100644
--- a/app/assets/javascripts/boards/stores/boards_store.js
+++ b/app/assets/javascripts/boards/stores/boards_store.js
@@ -101,7 +101,7 @@ const boardsStore = {
},
new(listObj) {
const list = this.addList(listObj);
- const backlogList = this.findList('type', 'backlog', 'backlog');
+ const backlogList = this.findList('type', 'backlog');
list
.save()
@@ -124,7 +124,7 @@ const boardsStore = {
},
findIssueLabel(issue, findLabel) {
- return issue.labels.find(label => label.id === findLabel.id);
+ return issue.labels.find((label) => label.id === findLabel.id);
},
goToNextPage(list) {
@@ -182,15 +182,15 @@ const boardsStore = {
}
},
findListIssue(list, id) {
- return list.issues.find(issue => issue.id === id);
+ return list.issues.find((issue) => issue.id === id);
},
- removeList(id, type = 'blank') {
- const list = this.findList('id', id, type);
+ removeList(id) {
+ const list = this.findList('id', id);
if (!list) return;
- this.state.lists = this.state.lists.filter(list => list.id !== id);
+ this.state.lists = this.state.lists.filter((list) => list.id !== id);
},
moveList(listFrom, orderLists) {
orderLists.forEach((id, i) => {
@@ -205,7 +205,7 @@ const boardsStore = {
let moveBeforeId = null;
let moveAfterId = null;
- const listHasIssues = issues.every(issue => list.findIssue(issue.id));
+ const listHasIssues = issues.every((issue) => list.findIssue(issue.id));
if (!listHasIssues) {
if (newIndex !== undefined) {
@@ -223,21 +223,21 @@ const boardsStore = {
}
if (list.label) {
- issues.forEach(issue => issue.addLabel(list.label));
+ issues.forEach((issue) => issue.addLabel(list.label));
}
if (list.assignee) {
if (listFrom && listFrom.type === 'assignee') {
- issues.forEach(issue => issue.removeAssignee(listFrom.assignee));
+ issues.forEach((issue) => issue.removeAssignee(listFrom.assignee));
}
- issues.forEach(issue => issue.addAssignee(list.assignee));
+ issues.forEach((issue) => issue.addAssignee(list.assignee));
}
if (IS_EE && list.milestone) {
if (listFrom && listFrom.type === 'milestone') {
- issues.forEach(issue => issue.removeMilestone(listFrom.milestone));
+ issues.forEach((issue) => issue.removeMilestone(listFrom.milestone));
}
- issues.forEach(issue => issue.addMilestone(list.milestone));
+ issues.forEach((issue) => issue.addMilestone(list.milestone));
}
if (listFrom) {
@@ -249,7 +249,7 @@ const boardsStore = {
},
removeListIssues(list, removeIssue) {
- list.issues = list.issues.filter(issue => {
+ list.issues = list.issues.filter((issue) => {
const matchesRemove = removeIssue.id === issue.id;
if (matchesRemove) {
@@ -261,9 +261,9 @@ const boardsStore = {
});
},
removeListMultipleIssues(list, removeIssues) {
- const ids = removeIssues.map(issue => issue.id);
+ const ids = removeIssues.map((issue) => issue.id);
- list.issues = list.issues.filter(issue => {
+ list.issues = list.issues.filter((issue) => {
const matchesRemove = ids.includes(issue.id);
if (matchesRemove) {
@@ -289,9 +289,9 @@ const boardsStore = {
},
moveMultipleIssuesToList({ listFrom, listTo, issues, newIndex }) {
- const issueTo = issues.map(issue => listTo.findIssue(issue.id));
- const issueLists = issues.map(issue => issue.getLists()).flat();
- const listLabels = issueLists.map(list => list.label);
+ const issueTo = issues.map((issue) => listTo.findIssue(issue.id));
+ const issueLists = issues.map((issue) => issue.getLists()).flat();
+ const listLabels = issueLists.map((list) => list.label);
const hasMoveableIssues = issueTo.filter(Boolean).length > 0;
if (!hasMoveableIssues) {
@@ -299,30 +299,30 @@ const boardsStore = {
if (
listTo.type === ListType.assignee &&
listFrom.type === ListType.assignee &&
- issues.some(issue => issue.findAssignee(listTo.assignee))
+ issues.some((issue) => issue.findAssignee(listTo.assignee))
) {
- const targetIssues = issues.map(issue => listTo.findIssue(issue.id));
- targetIssues.forEach(targetIssue => targetIssue.removeAssignee(listFrom.assignee));
+ const targetIssues = issues.map((issue) => listTo.findIssue(issue.id));
+ targetIssues.forEach((targetIssue) => targetIssue.removeAssignee(listFrom.assignee));
} else if (listTo.type === 'milestone') {
- const currentMilestones = issues.map(issue => issue.milestone);
+ const currentMilestones = issues.map((issue) => issue.milestone);
const currentLists = this.state.lists
- .filter(list => list.type === 'milestone' && list.id !== listTo.id)
- .filter(list =>
- list.issues.some(listIssue => issues.some(issue => listIssue.id === issue.id)),
+ .filter((list) => list.type === 'milestone' && list.id !== listTo.id)
+ .filter((list) =>
+ list.issues.some((listIssue) => issues.some((issue) => listIssue.id === issue.id)),
);
- issues.forEach(issue => {
- currentMilestones.forEach(milestone => {
+ issues.forEach((issue) => {
+ currentMilestones.forEach((milestone) => {
issue.removeMilestone(milestone);
});
});
- issues.forEach(issue => {
+ issues.forEach((issue) => {
issue.addMilestone(listTo.milestone);
});
- currentLists.forEach(currentList => {
- issues.forEach(issue => {
+ currentLists.forEach((currentList) => {
+ issues.forEach((issue) => {
currentList.removeIssue(issue);
});
});
@@ -334,36 +334,36 @@ const boardsStore = {
}
} else {
listTo.updateMultipleIssues(issues, listFrom);
- issues.forEach(issue => {
+ issues.forEach((issue) => {
issue.removeLabel(listFrom.label);
});
}
if (listTo.type === ListType.closed && listFrom.type !== ListType.backlog) {
- issueLists.forEach(list => {
- issues.forEach(issue => {
+ issueLists.forEach((list) => {
+ issues.forEach((issue) => {
list.removeIssue(issue);
});
});
- issues.forEach(issue => {
+ issues.forEach((issue) => {
issue.removeLabels(listLabels);
});
} else if (listTo.type === ListType.backlog && listFrom.type === ListType.assignee) {
- issues.forEach(issue => {
+ issues.forEach((issue) => {
issue.removeAssignee(listFrom.assignee);
});
- issueLists.forEach(list => {
- issues.forEach(issue => {
+ issueLists.forEach((list) => {
+ issues.forEach((issue) => {
list.removeIssue(issue);
});
});
} else if (listTo.type === ListType.backlog && listFrom.type === ListType.milestone) {
- issues.forEach(issue => {
+ issues.forEach((issue) => {
issue.removeMilestone(listFrom.milestone);
});
- issueLists.forEach(list => {
- issues.forEach(issue => {
+ issueLists.forEach((list) => {
+ issues.forEach((issue) => {
list.removeIssue(issue);
});
});
@@ -380,8 +380,8 @@ const boardsStore = {
if (issues.length === 1) return true;
// Create list of ids for issues involved.
- const listIssueIds = list.issues.map(issue => issue.id);
- const movedIssueIds = issues.map(issue => issue.id);
+ const listIssueIds = list.issues.map((issue) => issue.id);
+ const movedIssueIds = issues.map((issue) => issue.id);
// Check if moved issue IDs is sub-array
// of source list issue IDs (i.e. contiguous selection).
@@ -391,7 +391,7 @@ const boardsStore = {
moveIssueToList(listFrom, listTo, issue, newIndex) {
const issueTo = listTo.findIssue(issue.id);
const issueLists = issue.getLists();
- const listLabels = issueLists.map(listIssue => listIssue.label);
+ const listLabels = issueLists.map((listIssue) => listIssue.label);
if (!issueTo) {
// Check if target list assignee is already present in this issue
@@ -405,12 +405,12 @@ const boardsStore = {
} else if (listTo.type === 'milestone') {
const currentMilestone = issue.milestone;
const currentLists = this.state.lists
- .filter(list => list.type === 'milestone' && list.id !== listTo.id)
- .filter(list => list.issues.some(listIssue => issue.id === listIssue.id));
+ .filter((list) => list.type === 'milestone' && list.id !== listTo.id)
+ .filter((list) => list.issues.some((listIssue) => issue.id === listIssue.id));
issue.removeMilestone(currentMilestone);
issue.addMilestone(listTo.milestone);
- currentLists.forEach(currentList => currentList.removeIssue(issue));
+ currentLists.forEach((currentList) => currentList.removeIssue(issue));
listTo.addIssue(issue, listFrom, newIndex);
} else {
// Add to new lists issues if it doesn't already exist
@@ -422,7 +422,7 @@ const boardsStore = {
}
if (listTo.type === 'closed' && listFrom.type !== 'backlog') {
- issueLists.forEach(list => {
+ issueLists.forEach((list) => {
list.removeIssue(issue);
});
issue.removeLabels(listLabels);
@@ -461,18 +461,11 @@ const boardsStore = {
moveAfterId: afterId,
});
},
- findList(key, val, type = 'label') {
- const filteredList = this.state.lists.filter(list => {
- const byType = type
- ? list.type === type || list.type === 'assignee' || list.type === 'milestone'
- : true;
-
- return list[key] === val && byType;
- });
- return filteredList[0];
+ findList(key, val) {
+ return this.state.lists.find((list) => list[key] === val);
},
findListByLabelId(id) {
- return this.state.lists.find(list => list.type === 'label' && list.label.id === id);
+ return this.state.lists.find((list) => list.type === 'label' && list.label.id === id);
},
toggleFilter(filter) {
@@ -589,8 +582,8 @@ const boardsStore = {
}
return this.createList(entity.id, entityType)
- .then(res => res.data)
- .then(data => {
+ .then((res) => res.data)
+ .then((data) => {
list.id = data.id;
list.type = data.list_type;
list.position = data.position;
@@ -607,7 +600,7 @@ const boardsStore = {
};
if (list.label && data.label_name) {
- data.label_name = data.label_name.filter(label => label !== list.label.title);
+ data.label_name = data.label_name.filter((label) => label !== list.label.title);
}
if (emptyIssues) {
@@ -615,8 +608,8 @@ const boardsStore = {
}
return this.getIssuesForList(list.id, data)
- .then(res => res.data)
- .then(data => {
+ .then((res) => res.data)
+ .then((data) => {
list.loading = false;
list.issuesSize = data.size;
@@ -624,7 +617,7 @@ const boardsStore = {
list.issues = [];
}
- data.issues.forEach(issueObj => {
+ data.issues.forEach((issueObj) => {
list.addIssue(new ListIssue(issueObj));
});
@@ -634,7 +627,7 @@ const boardsStore = {
getIssuesForList(id, filter = {}) {
const data = { id };
- Object.keys(filter).forEach(key => {
+ Object.keys(filter).forEach((key) => {
data[key] = filter[key];
});
@@ -670,13 +663,13 @@ const boardsStore = {
},
moveListMultipleIssues({ list, issues, oldIndicies, newIndex, moveBeforeId, moveAfterId }) {
- oldIndicies.reverse().forEach(index => {
+ oldIndicies.reverse().forEach((index) => {
list.issues.splice(index, 1);
});
list.issues.splice(newIndex, 0, ...issues);
return this.moveMultipleIssues({
- ids: issues.map(issue => issue.id),
+ ids: issues.map((issue) => issue.id),
fromListId: null,
toListId: null,
moveBeforeId,
@@ -703,8 +696,8 @@ const boardsStore = {
}
return this.newIssue(list.id, issue)
- .then(res => res.data)
- .then(data => list.onNewIssueResponse(issue, data));
+ .then((res) => res.data)
+ .then((data) => list.onNewIssueResponse(issue, data));
},
getBacklog(data) {
@@ -717,7 +710,7 @@ const boardsStore = {
},
removeIssueLabel(issue, removeLabel) {
if (removeLabel) {
- issue.labels = issue.labels.filter(label => removeLabel.id !== label.id);
+ issue.labels = issue.labels.filter((label) => removeLabel.id !== label.id);
}
},
@@ -753,16 +746,12 @@ const boardsStore = {
return axios.get(this.state.endpoints.recentBoardsEndpoint);
},
- deleteBoard({ id }) {
- return axios.delete(this.generateBoardsPath(id));
- },
-
setCurrentBoard(board) {
this.state.currentBoard = board;
},
toggleMultiSelect(issue) {
- const selectedIssueIds = this.multiSelect.list.map(issue => issue.id);
+ const selectedIssueIds = this.multiSelect.list.map((issue) => issue.id);
const index = selectedIssueIds.indexOf(issue.id);
if (index === -1) {
@@ -777,12 +766,12 @@ const boardsStore = {
},
removeIssueAssignee(issue, removeAssignee) {
if (removeAssignee) {
- issue.assignees = issue.assignees.filter(assignee => assignee.id !== removeAssignee.id);
+ issue.assignees = issue.assignees.filter((assignee) => assignee.id !== removeAssignee.id);
}
},
findIssueAssignee(issue, findAssignee) {
- return issue.assignees.find(assignee => assignee.id === findAssignee.id);
+ return issue.assignees.find((assignee) => assignee.id === findAssignee.id);
},
clearMultiSelect() {
@@ -837,11 +826,11 @@ const boardsStore = {
}
if (obj.labels) {
- issue.labels = obj.labels.map(label => new ListLabel(label));
+ issue.labels = obj.labels.map((label) => new ListLabel(label));
}
if (obj.assignees) {
- issue.assignees = obj.assignees.map(a => new ListAssignee(a));
+ issue.assignees = obj.assignees.map((a) => new ListAssignee(a));
}
},
addIssueLabel(issue, label) {
diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js
index ca6887b6f45..d72b5c6fb8e 100644
--- a/app/assets/javascripts/boards/stores/getters.js
+++ b/app/assets/javascripts/boards/stores/getters.js
@@ -2,18 +2,18 @@ import { find } from 'lodash';
import { inactiveId } from '../constants';
export default {
- isSidebarOpen: state => state.activeId !== inactiveId,
+ isSidebarOpen: (state) => state.activeId !== inactiveId,
isSwimlanesOn: () => false,
- getIssueById: state => id => {
+ getIssueById: (state) => (id) => {
return state.issues[id] || {};
},
- getIssuesByList: (state, getters) => listId => {
+ getIssuesByList: (state, getters) => (listId) => {
const listIssueIds = state.issuesByListId[listId] || [];
- return listIssueIds.map(id => getters.getIssueById(id));
+ return listIssueIds.map((id) => getters.getIssueById(id));
},
- activeIssue: state => {
+ activeIssue: (state) => {
return state.issues[state.activeId] || {};
},
@@ -22,12 +22,12 @@ export default {
return referencePath.slice(0, referencePath.indexOf('#'));
},
- getListByLabelId: state => labelId => {
- return find(state.boardLists, l => l.label?.id === labelId);
+ getListByLabelId: (state) => (labelId) => {
+ return find(state.boardLists, (l) => l.label?.id === labelId);
},
- getListByTitle: state => title => {
- return find(state.boardLists, l => l.title === title);
+ getListByTitle: (state) => (title) => {
+ return find(state.boardLists, (l) => l.title === title);
},
shouldUseGraphQL: () => {
diff --git a/app/assets/javascripts/boards/stores/modal_store.js b/app/assets/javascripts/boards/stores/modal_store.js
index b7228bf7bf5..8a8fa61361c 100644
--- a/app/assets/javascripts/boards/stores/modal_store.js
+++ b/app/assets/javascripts/boards/stores/modal_store.js
@@ -40,7 +40,7 @@ class ModalStore {
toggleAll() {
const select = this.selectedCount() !== this.store.issues.length;
- this.store.issues.forEach(issue => {
+ this.store.issues.forEach((issue) => {
const issueUpdate = issue;
if (issueUpdate.selected !== select) {
@@ -56,7 +56,7 @@ class ModalStore {
}
getSelectedIssues() {
- return this.store.selectedIssues.filter(issue => issue.selected);
+ return this.store.selectedIssues.filter((issue) => issue.selected);
}
addSelectedIssue(issue) {
@@ -70,13 +70,13 @@ class ModalStore {
removeSelectedIssue(issue, forcePurge = false) {
if (this.store.activeTab === 'all' || forcePurge) {
this.store.selectedIssues = this.store.selectedIssues.filter(
- fIssue => fIssue.id !== issue.id,
+ (fIssue) => fIssue.id !== issue.id,
);
}
}
purgeUnselectedIssues() {
- this.store.selectedIssues.forEach(issue => {
+ this.store.selectedIssues.forEach((issue) => {
if (!issue.selected) {
this.removeSelectedIssue(issue, true);
}
@@ -88,7 +88,7 @@ class ModalStore {
}
findSelectedIssue(issue) {
- return this.store.selectedIssues.filter(filteredIssue => filteredIssue.id === issue.id)[0];
+ return this.store.selectedIssues.filter((filteredIssue) => filteredIssue.id === issue.id)[0];
}
}
diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js
index 2b2c2bee51c..4697f39498a 100644
--- a/app/assets/javascripts/boards/stores/mutation_types.js
+++ b/app/assets/javascripts/boards/stores/mutation_types.js
@@ -36,3 +36,7 @@ export const SET_ACTIVE_ID = 'SET_ACTIVE_ID';
export const UPDATE_ISSUE_BY_ID = 'UPDATE_ISSUE_BY_ID';
export const SET_ASSIGNEE_LOADING = 'SET_ASSIGNEE_LOADING';
export const RESET_ISSUES = 'RESET_ISSUES';
+export const REQUEST_GROUP_PROJECTS = 'REQUEST_GROUP_PROJECTS';
+export const RECEIVE_GROUP_PROJECTS_SUCCESS = 'RECEIVE_GROUP_PROJECTS_SUCCESS';
+export const RECEIVE_GROUP_PROJECTS_FAILURE = 'RECEIVE_GROUP_PROJECTS_FAILURE';
+export const SET_SELECTED_PROJECT = 'SET_SELECTED_PROJECT';
diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js
index 8c4e514710f..6c79b22d308 100644
--- a/app/assets/javascripts/boards/stores/mutations.js
+++ b/app/assets/javascripts/boards/stores/mutations.js
@@ -32,8 +32,9 @@ export const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfter
export default {
[mutationTypes.SET_INITIAL_BOARD_DATA](state, data) {
- const { boardType, disabled, boardConfig, ...endpoints } = data;
- state.endpoints = endpoints;
+ const { boardType, disabled, boardId, fullPath, boardConfig } = data;
+ state.boardId = boardId;
+ state.fullPath = fullPath;
state.boardType = boardType;
state.disabled = disabled;
state.boardConfig = boardConfig;
@@ -43,7 +44,7 @@ export default {
state.boardLists = lists;
},
- [mutationTypes.RECEIVE_BOARD_LISTS_FAILURE]: state => {
+ [mutationTypes.RECEIVE_BOARD_LISTS_FAILURE]: (state) => {
state.error = s__(
'Boards|An error occurred while fetching the board lists. Please reload the page.',
);
@@ -58,15 +59,15 @@ export default {
state.filterParams = filterParams;
},
- [mutationTypes.CREATE_LIST_FAILURE]: state => {
+ [mutationTypes.CREATE_LIST_FAILURE]: (state) => {
state.error = s__('Boards|An error occurred while creating the list. Please try again.');
},
- [mutationTypes.RECEIVE_LABELS_FAILURE]: state => {
+ [mutationTypes.RECEIVE_LABELS_FAILURE]: (state) => {
state.error = s__('Boards|An error occurred while fetching labels. Please reload the page.');
},
- [mutationTypes.GENERATE_DEFAULT_LISTS_FAILURE]: state => {
+ [mutationTypes.GENERATE_DEFAULT_LISTS_FAILURE]: (state) => {
state.error = s__('Boards|An error occurred while generating lists. Please reload the page.');
},
@@ -128,8 +129,8 @@ export default {
Vue.set(state.listsFlags, listId, { isLoading: false, isLoadingMore: false });
},
- [mutationTypes.RESET_ISSUES]: state => {
- Object.keys(state.issuesByListId).forEach(listId => {
+ [mutationTypes.RESET_ISSUES]: (state) => {
+ Object.keys(state.issuesByListId).forEach((listId) => {
Vue.set(state.issuesByListId, listId, []);
});
},
@@ -205,7 +206,7 @@ export default {
notImplemented();
},
- [mutationTypes.CREATE_ISSUE_FAILURE]: state => {
+ [mutationTypes.CREATE_ISSUE_FAILURE]: (state) => {
state.error = s__('Boards|An error occurred while creating the issue. Please try again.');
},
@@ -236,4 +237,25 @@ export default {
[mutationTypes.TOGGLE_EMPTY_STATE]: () => {
notImplemented();
},
+
+ [mutationTypes.REQUEST_GROUP_PROJECTS]: (state, fetchNext) => {
+ Vue.set(state, 'groupProjectsFlags', {
+ [fetchNext ? 'isLoadingMore' : 'isLoading']: true,
+ pageInfo: state.groupProjectsFlags.pageInfo,
+ });
+ },
+
+ [mutationTypes.RECEIVE_GROUP_PROJECTS_SUCCESS]: (state, { projects, pageInfo, fetchNext }) => {
+ Vue.set(state, 'groupProjects', fetchNext ? [...state.groupProjects, ...projects] : projects);
+ Vue.set(state, 'groupProjectsFlags', { isLoading: false, isLoadingMore: false, pageInfo });
+ },
+
+ [mutationTypes.RECEIVE_GROUP_PROJECTS_FAILURE]: (state) => {
+ state.error = s__('Boards|An error occurred while fetching group projects. Please try again.');
+ Vue.set(state, 'groupProjectsFlags', { isLoading: false, isLoadingMore: false });
+ },
+
+ [mutationTypes.SET_SELECTED_PROJECT]: (state, project) => {
+ state.selectedProject = project;
+ },
};
diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js
index 573e98e56e0..aba7da373cf 100644
--- a/app/assets/javascripts/boards/stores/state.js
+++ b/app/assets/javascripts/boards/stores/state.js
@@ -1,7 +1,6 @@
import { inactiveId } from '~/boards/constants';
export default () => ({
- endpoints: {},
boardType: null,
disabled: false,
isShowingLabels: true,
@@ -15,6 +14,13 @@ export default () => ({
issues: {},
filterParams: {},
boardConfig: {},
+ groupProjects: [],
+ groupProjectsFlags: {
+ isLoading: false,
+ isLoadingMore: false,
+ pageInfo: {},
+ },
+ selectedProject: {},
error: undefined,
// TODO: remove after ce/ee split of board_content.vue
isShowingEpicsSwimlanes: false,