Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-01-18 12:11:05 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2021-01-18 12:11:05 +0300
commitd7432b66ff241af3f39d82da581832a084983378 (patch)
tree02987f56a6d5119197867716faed5719e58b9880
parent2931472c10363925ae473bc646f53f7f58afad36 (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--app/assets/javascripts/boards/boards_util.js22
-rw-r--r--app/assets/javascripts/boards/components/board_column.vue71
-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_content.vue9
-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.vue149
-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)151
-rw-r--r--app/assets/javascripts/boards/components/board_list_new.vue239
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue.vue86
-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)87
-rw-r--r--app/assets/javascripts/boards/index.js10
-rw-r--r--app/assets/javascripts/boards/stores/actions.js6
-rw-r--r--app/controllers/concerns/spammable_actions.rb25
-rw-r--r--app/policies/group_member_policy.rb6
-rw-r--r--app/policies/group_policy.rb2
-rw-r--r--app/services/labels/create_service.rb2
-rw-r--r--app/views/admin/system_info/show.html.haml28
-rw-r--r--app/views/projects/snippets/verify.html.haml2
-rw-r--r--app/views/shared/_recaptcha_form.html.haml5
-rw-r--r--app/views/snippets/verify.html.haml2
-rw-r--r--changelogs/unreleased/add-icons.yml5
-rw-r--r--changelogs/unreleased/ajk-group-member-policy-fix.yml5
-rw-r--r--config/feature_flags/development/graphql_logging.yml4
-rw-r--r--spec/controllers/concerns/spammable_actions_spec.rb198
-rw-r--r--spec/controllers/projects/issues_controller_spec.rb8
-rw-r--r--spec/frontend/boards/board_list_deprecated_spec.js275
-rw-r--r--spec/frontend/boards/board_list_helper.js2
-rw-r--r--spec/frontend/boards/board_list_new_spec.js268
-rw-r--r--spec/frontend/boards/board_list_spec.js346
-rw-r--r--spec/frontend/boards/board_new_issue_deprecated_spec.js (renamed from spec/frontend/boards/board_new_issue_spec.js)2
-rw-r--r--spec/frontend/boards/components/board_column_deprecated_spec.js (renamed from spec/frontend/boards/components/board_column_new_spec.js)49
-rw-r--r--spec/frontend/boards/components/board_column_spec.js49
-rw-r--r--spec/frontend/boards/components/board_content_spec.js18
-rw-r--r--spec/frontend/boards/components/board_list_header_deprecated_spec.js (renamed from spec/frontend/boards/components/board_list_header_new_spec.js)134
-rw-r--r--spec/frontend/boards/components/board_list_header_spec.js130
-rw-r--r--spec/frontend/boards/components/board_new_issue_spec.js (renamed from spec/frontend/boards/components/board_new_issue_new_spec.js)2
-rw-r--r--spec/frontend/boards/mock_data.js2
-rw-r--r--spec/frontend/boards/stores/actions_spec.js79
-rw-r--r--spec/policies/group_member_policy_spec.rb65
-rw-r--r--spec/requests/api/graphql/group/group_members_spec.rb16
-rw-r--r--spec/requests/api/graphql/project/project_members_spec.rb9
-rw-r--r--spec/support/shared_examples/services/onboarding_progress_shared_examples.rb20
44 files changed, 2052 insertions, 1646 deletions
diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js
index 6143beeae45..827eca7d2b4 100644
--- a/app/assets/javascripts/boards/boards_util.js
+++ b/app/assets/javascripts/boards/boards_util.js
@@ -88,13 +88,33 @@ 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 (
diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue
index ef7bfae02a2..9f0eef844f6 100644
--- a/app/assets/javascripts/boards/components/board_column.vue
+++ b/app/assets/javascripts/boards/components/board_column.vue
@@ -1,10 +1,8 @@
<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: {
@@ -32,53 +30,27 @@ export default {
default: false,
},
},
- 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 ad49936567d..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,
- },
- inject: {
- boardId: {
- default: '',
- },
- },
- props: {
- list: {
- type: Object,
- default: () => ({}),
- required: false,
- },
- disabled: {
- type: Boolean,
- required: true,
- },
- canAdminList: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- 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_content.vue b/app/assets/javascripts/boards/components/board_content.vue
index b366aa6fdb3..2d62dc0fa4b 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 : {};
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index cb0061782a0..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..10b0687b2a3
--- /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 814d261e808..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,
@@ -35,6 +41,15 @@ export default {
boardId: {
default: '',
},
+ weightFeatureAvailable: {
+ default: false,
+ },
+ scopedLabelsAvailable: {
+ default: false,
+ },
+ currentUserId: {
+ default: null,
+ },
},
props: {
list: {
@@ -52,56 +67,53 @@ export default {
default: false,
},
},
- 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 06f39eceb08..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,
@@ -41,15 +37,6 @@ export default {
boardId: {
default: '',
},
- weightFeatureAvailable: {
- default: false,
- },
- scopedLabelsAvailable: {
- default: false,
- },
- currentUserId: {
- default: null,
- },
},
props: {
list: {
@@ -67,53 +54,56 @@ export default {
default: false,
},
},
+ 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 726fa4a9b2b..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 90d8f6ebc58..14d28643046 100644
--- a/app/assets/javascripts/boards/components/board_new_issue.vue
+++ b/app/assets/javascripts/boards/components/board_new_issue.vue
@@ -1,22 +1,24 @@
<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_deprecated.vue';
-import boardsStore from '../stores/boards_store';
+import ProjectSelect from './project_select.vue';
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'],
+ inject: ['groupId', 'weightFeatureAvailable', 'boardWeight'],
props: {
list: {
type: Object,
@@ -26,69 +28,58 @@ export default {
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 5a1b8a7672f..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,24 +1,22 @@
<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 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', 'weightFeatureAvailable', 'boardWeight'],
+ inject: ['groupId'],
props: {
list: {
type: Object,
@@ -28,57 +26,69 @@ export default {
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 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.fullPath,
- 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}`);
},
+ setSelectedProject(selectedProject) {
+ this.selectedProject = selectedProject;
+ },
},
};
</script>
@@ -86,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"
@@ -106,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/index.js b/app/assets/javascripts/boards/index.js
index e978eedee7f..ef70a094f7c 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -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,
@@ -127,8 +129,10 @@ 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,
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
index 0fe6d669f86..1d34f21798a 100644
--- a/app/assets/javascripts/boards/stores/actions.js
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -12,6 +12,7 @@ import {
fullBoardId,
formatListsPageInfo,
formatIssue,
+ formatIssueInput,
updateListPosition,
} from '../boards_util';
import createFlash from '~/flash';
@@ -362,7 +363,10 @@ export default {
},
createNewIssue: ({ commit, state }, issueInput) => {
- const input = issueInput;
+ const { boardConfig } = state;
+
+ const input = formatIssueInput(issueInput, boardConfig);
+
const { boardType, fullPath } = state;
if (boardType === BoardType.project) {
input.projectPath = fullPath;
diff --git a/app/controllers/concerns/spammable_actions.rb b/app/controllers/concerns/spammable_actions.rb
index 50c93441dd4..4ec561014a8 100644
--- a/app/controllers/concerns/spammable_actions.rb
+++ b/app/controllers/concerns/spammable_actions.rb
@@ -32,10 +32,6 @@ module SpammableActions
elsif render_recaptcha?
ensure_spam_config_loaded!
- if params[:recaptcha_verification]
- flash[:alert] = _('There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.')
- end
-
respond_to do |format|
format.html do
render :verify
@@ -56,9 +52,9 @@ module SpammableActions
def spammable_params
default_params = { request: request }
- recaptcha_check = params[:recaptcha_verification] &&
+ recaptcha_check = recaptcha_response &&
ensure_spam_config_loaded! &&
- verify_recaptcha
+ verify_recaptcha(response: recaptcha_response)
return default_params unless recaptcha_check
@@ -66,6 +62,23 @@ module SpammableActions
spam_log_id: params[:spam_log_id] }.merge(default_params)
end
+ def recaptcha_response
+ # NOTE: This field name comes from `Recaptcha::ClientHelper#recaptcha_tags` in the recaptcha
+ # gem, which is called from the HAML `_recaptcha_form.html.haml` form.
+ #
+ # It is used in the `Recaptcha::Verify#verify_recaptcha` if the `response` option is not
+ # passed explicitly.
+ #
+ # Instead of relying on this behavior, we are extracting and passing it explicitly. This will
+ # make it consistent with the newer, modern reCAPTCHA verification process as it will be
+ # implemented via the GraphQL API and in Vue components via the native reCAPTCHA Javascript API,
+ # which requires that the recaptcha response param be obtained and passed explicitly.
+ #
+ # After this newer GraphQL/JS API process is fully supported by the backend, we can remove this
+ # (and other) HAML-specific support.
+ params['g-recaptcha-response']
+ end
+
def spammable
raise NotImplementedError, "#{self.class} does not implement #{__method__}"
end
diff --git a/app/policies/group_member_policy.rb b/app/policies/group_member_policy.rb
index 78a2be7a9f8..09cac96e3a5 100644
--- a/app/policies/group_member_policy.rb
+++ b/app/policies/group_member_policy.rb
@@ -10,7 +10,11 @@ class GroupMemberPolicy < BasePolicy
with_score 0
condition(:is_target_user) { @user && @subject.user_id == @user.id }
- rule { anonymous }.prevent_all
+ rule { anonymous }.policy do
+ prevent :update_group_member
+ prevent :destroy_group_member
+ end
+
rule { last_owner }.policy do
prevent :update_group_member
prevent :destroy_group_member
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index 6e272f20e0a..7dd88fcc1ff 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -66,7 +66,7 @@ class GroupPolicy < BasePolicy
with_scope :subject
condition(:has_project_with_service_desk_enabled) { @subject.has_project_with_service_desk_enabled? }
- rule { design_management_enabled }.policy do
+ rule { can?(:read_group) & design_management_enabled }.policy do
enable :read_design_activity
end
diff --git a/app/services/labels/create_service.rb b/app/services/labels/create_service.rb
index a5b30e29e55..665d1035b2b 100644
--- a/app/services/labels/create_service.rb
+++ b/app/services/labels/create_service.rb
@@ -25,3 +25,5 @@ module Labels
end
end
end
+
+Labels::CreateService.prepend_if_ee('EE::Labels::CreateService')
diff --git a/app/views/admin/system_info/show.html.haml b/app/views/admin/system_info/show.html.haml
index ca6efe9b095..7a34972dfbf 100644
--- a/app/views/admin/system_info/show.html.haml
+++ b/app/views/admin/system_info/show.html.haml
@@ -3,29 +3,41 @@
.gl-mt-3
.row
.col-sm
- .bg-light.light-well
- %h4= _('CPU')
+ .bg-light.info-well.p-3
+ %h4.page-title.d-flex
+ .gl-display-flex.gl-align-items-center.gl-justify-content-center
+ = sprite_icon('pod', size: 18, css_class: 'pod-icon gl-mr-3')
+ = _('CPU')
.data
- if @cpus
%h2= _('%{cores} cores') % { cores: @cpus.length }
- else
= sprite_icon('warning-solid', css_class: 'text-warning')
= _('Unable to collect CPU info')
- .bg-light.light-well.gl-mt-3
- %h4= _('Memory Usage')
+ .bg-light.info-well.p-3.gl-mt-3
+ %h4.page-title.d-flex
+ .gl-display-flex.gl-align-items-center.gl-justify-content-center
+ = sprite_icon('status-health', size: 18, css_class: 'pod-icon gl-mr-3')
+ = _('Memory Usage')
.data
- if @memory
%h2 #{number_to_human_size(@memory.active_bytes)} / #{number_to_human_size(@memory.total_bytes)}
- else
= sprite_icon('warning-solid', css_class: 'text-warning')
= _('Unable to collect memory info')
- .bg-light.light-well.gl-mt-3
- %h4= _('Uptime')
+ .bg-light.info-well.p-3.gl-mt-3
+ %h4.page-title.d-flex
+ .gl-display-flex.gl-align-items-center.gl-justify-content-center
+ = sprite_icon('clock', size: 18, css_class: 'pod-icon gl-mr-3')
+ = _('Uptime')
.data
%h2= distance_of_time_in_words_to_now(Rails.application.config.booted_at)
.col-sm
- .bg-light.light-well
- %h4= _('Disk Usage')
+ .bg-light.info-well.p-3
+ %h4.page-title.d-flex
+ .gl-display-flex.gl-align-items-center.gl-justify-content-center
+ = sprite_icon('disk', size: 18, css_class: 'pod-icon gl-mr-3')
+ = _('Disk Usage')
.data
%ul
- @disks.each do |disk|
diff --git a/app/views/projects/snippets/verify.html.haml b/app/views/projects/snippets/verify.html.haml
deleted file mode 100644
index 3c4f08e1df7..00000000000
--- a/app/views/projects/snippets/verify.html.haml
+++ /dev/null
@@ -1,2 +0,0 @@
-= render 'layouts/recaptcha_verification', spammable: @snippet
-
diff --git a/app/views/shared/_recaptcha_form.html.haml b/app/views/shared/_recaptcha_form.html.haml
index 245a86721eb..aa9e9a34c90 100644
--- a/app/views/shared/_recaptcha_form.html.haml
+++ b/app/views/shared/_recaptcha_form.html.haml
@@ -9,8 +9,11 @@
- params[resource_name].each do |field, value|
= hidden_field(resource_name, field, value: value)
= hidden_field_tag(:spam_log_id, spammable.spam_log.id)
- = hidden_field_tag(:recaptcha_verification, true)
+ -# The reCAPTCHA response value will be returned in the 'g-recaptcha-response' field
= recaptcha_tags script: script, callback: 'recaptchaDialogCallback' unless Rails.env.test?
+ -# Fake the 'g-recaptcha-response' field in the test environment, so that the feature spec
+ -# can get to the (mocked) SpamVerdictService check.
+ = hidden_field_tag('g-recaptcha-response', 'abc123') if Rails.env.test?
-# Yields a block with given extra params.
= yield
diff --git a/app/views/snippets/verify.html.haml b/app/views/snippets/verify.html.haml
deleted file mode 100644
index 3c4f08e1df7..00000000000
--- a/app/views/snippets/verify.html.haml
+++ /dev/null
@@ -1,2 +0,0 @@
-= render 'layouts/recaptcha_verification', spammable: @snippet
-
diff --git a/changelogs/unreleased/add-icons.yml b/changelogs/unreleased/add-icons.yml
new file mode 100644
index 00000000000..35f8befa15f
--- /dev/null
+++ b/changelogs/unreleased/add-icons.yml
@@ -0,0 +1,5 @@
+---
+title: Add Icons to headings in system info on admin panel
+merge_request: 46618
+author: Yogi (@yo)
+type: other
diff --git a/changelogs/unreleased/ajk-group-member-policy-fix.yml b/changelogs/unreleased/ajk-group-member-policy-fix.yml
new file mode 100644
index 00000000000..61b1ead3cb1
--- /dev/null
+++ b/changelogs/unreleased/ajk-group-member-policy-fix.yml
@@ -0,0 +1,5 @@
+---
+title: Allow more actions on group members
+merge_request: 50445
+author:
+type: fixed
diff --git a/config/feature_flags/development/graphql_logging.yml b/config/feature_flags/development/graphql_logging.yml
index 30f03a60a06..c3615215048 100644
--- a/config/feature_flags/development/graphql_logging.yml
+++ b/config/feature_flags/development/graphql_logging.yml
@@ -1,8 +1,8 @@
---
name: graphql_logging
introduced_by_url: https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/27885
-rollout_issue_url:
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/35579
milestone: '12.0'
type: development
-group:
+group: group::project management
default_enabled: true
diff --git a/spec/controllers/concerns/spammable_actions_spec.rb b/spec/controllers/concerns/spammable_actions_spec.rb
new file mode 100644
index 00000000000..3b5b4d11a9b
--- /dev/null
+++ b/spec/controllers/concerns/spammable_actions_spec.rb
@@ -0,0 +1,198 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe SpammableActions do
+ controller(ActionController::Base) do
+ include SpammableActions
+
+ # #create is used to test spammable_params
+ # for testing purposes
+ def create
+ spam_params = spammable_params
+
+ # replace the actual request with a string in the JSON response, all we care is that it got set
+ spam_params[:request] = 'this is the request' if spam_params[:request]
+
+ # just return the params in the response so they can be verified in this fake controller spec.
+ # Normally, they are processed further by the controller action
+ render json: spam_params.to_json, status: :ok
+ end
+
+ # #update is used to test recaptcha_check_with_fallback
+ # for testing purposes
+ def update
+ should_redirect = params[:should_redirect] == 'true'
+
+ recaptcha_check_with_fallback(should_redirect) { render json: :ok }
+ end
+
+ private
+
+ def spammable_path
+ '/fake_spammable_path'
+ end
+ end
+
+ before do
+ # Ordinarily we would not stub a method on the class under test, but :ensure_spam_config_loaded!
+ # returns false in the test environment, and is also strong_memoized, so we need to stub it
+ allow(controller).to receive(:ensure_spam_config_loaded!) { true }
+ end
+
+ describe '#spammable_params' do
+ subject { post :create, format: :json, params: params }
+
+ shared_examples 'expects request param only' do
+ it do
+ subject
+
+ expect(response).to be_successful
+ expect(json_response).to eq({ 'request' => 'this is the request' })
+ end
+ end
+
+ shared_examples 'expects all spammable params' do
+ it do
+ subject
+
+ expect(response).to be_successful
+ expect(json_response['request']).to eq('this is the request')
+ expect(json_response['recaptcha_verified']).to eq(true)
+ expect(json_response['spam_log_id']).to eq('1')
+ end
+ end
+
+ let(:recaptcha_response) { nil }
+ let(:spam_log_id) { nil }
+
+ context 'when recaptcha response is not present' do
+ let(:params) do
+ {
+ spam_log_id: spam_log_id
+ }
+ end
+
+ it_behaves_like 'expects request param only'
+ end
+
+ context 'when recaptcha response is present' do
+ let(:recaptcha_response) { 'abd123' }
+ let(:params) do
+ {
+ 'g-recaptcha-response': recaptcha_response,
+ spam_log_id: spam_log_id
+ }
+ end
+
+ context 'when verify_recaptcha returns falsey' do
+ before do
+ expect(controller).to receive(:verify_recaptcha).with(
+ {
+ response: recaptcha_response
+ }) { false }
+ end
+
+ it_behaves_like 'expects request param only'
+ end
+
+ context 'when verify_recaptcha returns truthy' do
+ let(:spam_log_id) { 1 }
+
+ before do
+ expect(controller).to receive(:verify_recaptcha).with(
+ {
+ response: recaptcha_response
+ }) { true }
+ end
+
+ it_behaves_like 'expects all spammable params'
+ end
+ end
+ end
+
+ describe '#recaptcha_check_with_fallback' do
+ shared_examples 'yields to block' do
+ it do
+ subject
+
+ expect(json_response).to eq({ json: 'ok' })
+ end
+ end
+
+ let(:format) { :html }
+
+ subject { post :update, format: format, params: params }
+
+ let(:spammable) { double(:spammable) }
+ let(:should_redirect) { nil }
+ let(:params) do
+ {
+ should_redirect: should_redirect
+ }
+ end
+
+ before do
+ routes.draw { get 'update' => 'anonymous#update' }
+ allow(controller).to receive(:spammable) { spammable }
+ end
+
+ context 'when should_redirect is true and spammable is valid' do
+ let(:should_redirect) { true }
+
+ before do
+ allow(spammable).to receive(:valid?) { true }
+ end
+
+ it 'redirects to spammable_path' do
+ expect(subject).to redirect_to('/fake_spammable_path')
+ end
+ end
+
+ context 'when should_redirect is false or spammable is not valid' do
+ before do
+ allow(spammable).to receive(:valid?) { false }
+ end
+
+ # NOTE: Not adding coverage of details of render_recaptcha?, the plan is to refactor it out
+ # of this module anyway as part of adding support for the GraphQL reCAPTCHA flow.
+
+ context 'when render_recaptcha? is true' do
+ before do
+ expect(controller).to receive(:render_recaptcha?) { true }
+ end
+
+ context 'when format is :html' do
+ it 'renders :verify' do
+ expect(controller).to receive(:render).with(:verify)
+
+ subject
+ end
+ end
+
+ context 'when format is :json' do
+ let(:format) { :json }
+ let(:recaptcha_html) { '<recaptcha-html/>' }
+
+ it 'renders json with recaptcha_html' do
+ expect(controller).to receive(:render_to_string).with(
+ {
+ partial: 'shared/recaptcha_form',
+ formats: :html,
+ locals: {
+ spammable: spammable,
+ script: false,
+ has_submit: false
+ }
+ }
+ ) { recaptcha_html }
+
+ subject
+
+ expect(json_response).to eq({ 'recaptcha_html' => recaptcha_html })
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb
index 65d8babc837..d3bdf1baaae 100644
--- a/spec/controllers/projects/issues_controller_spec.rb
+++ b/spec/controllers/projects/issues_controller_spec.rb
@@ -1019,7 +1019,7 @@ RSpec.describe Projects::IssuesController do
def update_verified_issue
update_issue(
issue_params: { title: spammy_title },
- additional_params: { spam_log_id: spam_logs.last.id, recaptcha_verification: true })
+ additional_params: { spam_log_id: spam_logs.last.id, 'g-recaptcha-response': true })
end
it 'returns 200 status' do
@@ -1037,7 +1037,7 @@ RSpec.describe Projects::IssuesController do
it 'does not mark spam log as recaptcha_verified when it does not belong to current_user' do
spam_log = create(:spam_log)
- expect { update_issue(issue_params: { spam_log_id: spam_log.id, recaptcha_verification: true }) }
+ expect { update_issue(issue_params: { spam_log_id: spam_log.id, 'g-recaptcha-response': true }) }
.not_to change { SpamLog.last.recaptcha_verified }
end
end
@@ -1314,7 +1314,7 @@ RSpec.describe Projects::IssuesController do
let!(:last_spam_log) { spam_logs.last }
def post_verified_issue
- post_new_issue({}, { spam_log_id: last_spam_log.id, recaptcha_verification: true } )
+ post_new_issue({}, { spam_log_id: last_spam_log.id, 'g-recaptcha-response': true } )
end
before do
@@ -1332,7 +1332,7 @@ RSpec.describe Projects::IssuesController do
it 'does not mark spam log as recaptcha_verified when it does not belong to current_user' do
spam_log = create(:spam_log)
- expect { post_new_issue({}, { spam_log_id: spam_log.id, recaptcha_verification: true } ) }
+ expect { post_new_issue({}, { spam_log_id: spam_log.id, 'g-recaptcha-response': true } ) }
.not_to change { last_spam_log.recaptcha_verified }
end
end
diff --git a/spec/frontend/boards/board_list_deprecated_spec.js b/spec/frontend/boards/board_list_deprecated_spec.js
new file mode 100644
index 00000000000..393d7f954b1
--- /dev/null
+++ b/spec/frontend/boards/board_list_deprecated_spec.js
@@ -0,0 +1,275 @@
+/* global List */
+/* global ListIssue */
+
+import Vue from 'vue';
+import MockAdapter from 'axios-mock-adapter';
+import waitForPromises from 'helpers/wait_for_promises';
+import axios from '~/lib/utils/axios_utils';
+import eventHub from '~/boards/eventhub';
+import BoardList from '~/boards/components/board_list_deprecated.vue';
+import '~/boards/models/issue';
+import '~/boards/models/list';
+import { listObj, boardsMockInterceptor } from './mock_data';
+import store from '~/boards/stores';
+import boardsStore from '~/boards/stores/boards_store';
+
+const createComponent = ({ done, listIssueProps = {}, componentProps = {}, listProps = {} }) => {
+ const el = document.createElement('div');
+
+ document.body.appendChild(el);
+ const mock = new MockAdapter(axios);
+ mock.onAny().reply(boardsMockInterceptor);
+ boardsStore.create();
+
+ const BoardListComp = Vue.extend(BoardList);
+ const list = new List({ ...listObj, ...listProps });
+ const issue = new ListIssue({
+ title: 'Testing',
+ id: 1,
+ iid: 1,
+ confidential: false,
+ labels: [],
+ assignees: [],
+ ...listIssueProps,
+ });
+ if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesSize')) {
+ list.issuesSize = 1;
+ }
+ list.issues.push(issue);
+
+ const component = new BoardListComp({
+ el,
+ store,
+ propsData: {
+ disabled: false,
+ list,
+ issues: list.issues,
+ ...componentProps,
+ },
+ provide: {
+ groupId: null,
+ rootPath: '/',
+ },
+ }).$mount();
+
+ Vue.nextTick(() => {
+ done();
+ });
+
+ return { component, mock };
+};
+
+describe('Board list component', () => {
+ let mock;
+ let component;
+ let getIssues;
+ function generateIssues(compWrapper) {
+ for (let i = 1; i < 20; i += 1) {
+ const issue = { ...compWrapper.list.issues[0] };
+ issue.id += i;
+ compWrapper.list.issues.push(issue);
+ }
+ }
+
+ describe('When Expanded', () => {
+ beforeEach((done) => {
+ getIssues = jest.spyOn(List.prototype, 'getIssues').mockReturnValue(new Promise(() => {}));
+ ({ mock, component } = createComponent({ done }));
+ });
+
+ afterEach(() => {
+ mock.restore();
+ component.$destroy();
+ });
+
+ it('loads first page of issues', () => {
+ return waitForPromises().then(() => {
+ expect(getIssues).toHaveBeenCalled();
+ });
+ });
+
+ it('renders component', () => {
+ expect(component.$el.classList.contains('board-list-component')).toBe(true);
+ });
+
+ it('renders loading icon', () => {
+ component.list.loading = true;
+
+ return Vue.nextTick().then(() => {
+ expect(component.$el.querySelector('.board-list-loading')).not.toBeNull();
+ });
+ });
+
+ it('renders issues', () => {
+ expect(component.$el.querySelectorAll('.board-card').length).toBe(1);
+ });
+
+ it('sets data attribute with issue id', () => {
+ expect(component.$el.querySelector('.board-card').getAttribute('data-issue-id')).toBe('1');
+ });
+
+ it('shows new issue form', () => {
+ component.toggleForm();
+
+ return Vue.nextTick().then(() => {
+ expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull();
+
+ expect(component.$el.querySelector('.is-smaller')).not.toBeNull();
+ });
+ });
+
+ it('shows new issue form after eventhub event', () => {
+ eventHub.$emit(`toggle-issue-form-${component.list.id}`);
+
+ return Vue.nextTick().then(() => {
+ expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull();
+
+ expect(component.$el.querySelector('.is-smaller')).not.toBeNull();
+ });
+ });
+
+ it('does not show new issue form for closed list', () => {
+ component.list.type = 'closed';
+ component.toggleForm();
+
+ return Vue.nextTick().then(() => {
+ expect(component.$el.querySelector('.board-new-issue-form')).toBeNull();
+ });
+ });
+
+ it('shows count list item', () => {
+ component.showCount = true;
+
+ return Vue.nextTick().then(() => {
+ expect(component.$el.querySelector('.board-list-count')).not.toBeNull();
+
+ expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe(
+ 'Showing all issues',
+ );
+ });
+ });
+
+ it('sets data attribute with invalid id', () => {
+ component.showCount = true;
+
+ return Vue.nextTick().then(() => {
+ expect(component.$el.querySelector('.board-list-count').getAttribute('data-issue-id')).toBe(
+ '-1',
+ );
+ });
+ });
+
+ it('shows how many more issues to load', () => {
+ component.showCount = true;
+ component.list.issuesSize = 20;
+
+ return Vue.nextTick().then(() => {
+ expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe(
+ 'Showing 1 of 20 issues',
+ );
+ });
+ });
+
+ it('loads more issues after scrolling', () => {
+ jest.spyOn(component.list, 'nextPage').mockImplementation(() => {});
+ generateIssues(component);
+ component.$refs.list.dispatchEvent(new Event('scroll'));
+
+ return waitForPromises().then(() => {
+ expect(component.list.nextPage).toHaveBeenCalled();
+ });
+ });
+
+ it('does not load issues if already loading', () => {
+ component.list.nextPage = jest
+ .spyOn(component.list, 'nextPage')
+ .mockReturnValue(new Promise(() => {}));
+
+ component.onScroll();
+ component.onScroll();
+
+ return waitForPromises().then(() => {
+ expect(component.list.nextPage).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('shows loading more spinner', () => {
+ component.showCount = true;
+ component.list.loadingMore = true;
+
+ return Vue.nextTick().then(() => {
+ expect(component.$el.querySelector('.board-list-count .gl-spinner')).not.toBeNull();
+ });
+ });
+ });
+
+ describe('When Collapsed', () => {
+ beforeEach((done) => {
+ getIssues = jest.spyOn(List.prototype, 'getIssues').mockReturnValue(new Promise(() => {}));
+ ({ mock, component } = createComponent({
+ done,
+ listProps: { type: 'closed', collapsed: true, issuesSize: 50 },
+ }));
+ generateIssues(component);
+ component.scrollHeight = jest.spyOn(component, 'scrollHeight').mockReturnValue(0);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ component.$destroy();
+ });
+
+ it('does not load all issues', () => {
+ return waitForPromises().then(() => {
+ // Initial getIssues from list constructor
+ expect(getIssues).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+
+ describe('max issue count warning', () => {
+ beforeEach((done) => {
+ ({ mock, component } = createComponent({
+ done,
+ listProps: { type: 'closed', collapsed: true, issuesSize: 50 },
+ }));
+ });
+
+ afterEach(() => {
+ mock.restore();
+ component.$destroy();
+ });
+
+ describe('when issue count exceeds max issue count', () => {
+ it('sets background to bg-danger-100', () => {
+ component.list.issuesSize = 4;
+ component.list.maxIssueCount = 3;
+
+ return Vue.nextTick().then(() => {
+ expect(component.$el.querySelector('.bg-danger-100')).not.toBeNull();
+ });
+ });
+ });
+
+ describe('when list issue count does NOT exceed list max issue count', () => {
+ it('does not sets background to bg-danger-100', () => {
+ component.list.issuesSize = 2;
+ component.list.maxIssueCount = 3;
+
+ return Vue.nextTick().then(() => {
+ expect(component.$el.querySelector('.bg-danger-100')).toBeNull();
+ });
+ });
+ });
+
+ describe('when list max issue count is 0', () => {
+ it('does not sets background to bg-danger-100', () => {
+ component.list.maxIssueCount = 0;
+
+ return Vue.nextTick().then(() => {
+ expect(component.$el.querySelector('.bg-danger-100')).toBeNull();
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/boards/board_list_helper.js b/spec/frontend/boards/board_list_helper.js
index 80d7a72151d..f82b1f7ed5c 100644
--- a/spec/frontend/boards/board_list_helper.js
+++ b/spec/frontend/boards/board_list_helper.js
@@ -5,7 +5,7 @@ import MockAdapter from 'axios-mock-adapter';
import Vue from 'vue';
import Sortable from 'sortablejs';
import axios from '~/lib/utils/axios_utils';
-import BoardList from '~/boards/components/board_list.vue';
+import BoardList from '~/boards/components/board_list_deprecated.vue';
import '~/boards/models/issue';
import '~/boards/models/list';
diff --git a/spec/frontend/boards/board_list_new_spec.js b/spec/frontend/boards/board_list_new_spec.js
deleted file mode 100644
index 56784611322..00000000000
--- a/spec/frontend/boards/board_list_new_spec.js
+++ /dev/null
@@ -1,268 +0,0 @@
-import Vuex from 'vuex';
-import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame';
-import { createLocalVue, mount } from '@vue/test-utils';
-import eventHub from '~/boards/eventhub';
-import BoardList from '~/boards/components/board_list_new.vue';
-import BoardCard from '~/boards/components/board_card.vue';
-import '~/boards/models/list';
-import { mockList, mockIssuesByListId, issues, mockIssues } from './mock_data';
-import defaultState from '~/boards/stores/state';
-
-const localVue = createLocalVue();
-localVue.use(Vuex);
-
-const actions = {
- fetchIssuesForList: jest.fn(),
-};
-
-const createStore = (state = defaultState) => {
- return new Vuex.Store({
- state,
- actions,
- });
-};
-
-const createComponent = ({
- listIssueProps = {},
- componentProps = {},
- listProps = {},
- state = {},
-} = {}) => {
- const store = createStore({
- issuesByListId: mockIssuesByListId,
- issues,
- pageInfoByListId: {
- 'gid://gitlab/List/1': { hasNextPage: true },
- 'gid://gitlab/List/2': {},
- },
- listsFlags: {
- 'gid://gitlab/List/1': {},
- 'gid://gitlab/List/2': {},
- },
- ...state,
- });
-
- const list = {
- ...mockList,
- ...listProps,
- };
- const issue = {
- title: 'Testing',
- id: 1,
- iid: 1,
- confidential: false,
- labels: [],
- assignees: [],
- ...listIssueProps,
- };
- if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesCount')) {
- list.issuesCount = 1;
- }
-
- const component = mount(BoardList, {
- localVue,
- propsData: {
- disabled: false,
- list,
- issues: [issue],
- canAdminList: true,
- ...componentProps,
- },
- store,
- provide: {
- groupId: null,
- rootPath: '/',
- weightFeatureAvailable: false,
- boardWeight: null,
- },
- });
-
- return component;
-};
-
-describe('Board list component', () => {
- let wrapper;
- const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`);
- useFakeRequestAnimationFrame();
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
- describe('When Expanded', () => {
- beforeEach(() => {
- wrapper = createComponent();
- });
-
- it('renders component', () => {
- expect(wrapper.find('.board-list-component').exists()).toBe(true);
- });
-
- it('renders loading icon', () => {
- wrapper = createComponent({
- state: { listsFlags: { 'gid://gitlab/List/1': { isLoading: true } } },
- });
-
- expect(findByTestId('board_list_loading').exists()).toBe(true);
- });
-
- it('renders issues', () => {
- expect(wrapper.findAll(BoardCard).length).toBe(1);
- });
-
- it('sets data attribute with issue id', () => {
- expect(wrapper.find('.board-card').attributes('data-issue-id')).toBe('1');
- });
-
- it('shows new issue form', async () => {
- wrapper.vm.toggleForm();
-
- await wrapper.vm.$nextTick();
- expect(wrapper.find('.board-new-issue-form').exists()).toBe(true);
- });
-
- it('shows new issue form after eventhub event', async () => {
- eventHub.$emit(`toggle-issue-form-${wrapper.vm.list.id}`);
-
- await wrapper.vm.$nextTick();
- expect(wrapper.find('.board-new-issue-form').exists()).toBe(true);
- });
-
- it('does not show new issue form for closed list', () => {
- wrapper.setProps({ list: { type: 'closed' } });
- wrapper.vm.toggleForm();
-
- expect(wrapper.find('.board-new-issue-form').exists()).toBe(false);
- });
-
- it('shows count list item', async () => {
- wrapper.vm.showCount = true;
-
- await wrapper.vm.$nextTick();
- expect(wrapper.find('.board-list-count').exists()).toBe(true);
-
- expect(wrapper.find('.board-list-count').text()).toBe('Showing all issues');
- });
-
- it('sets data attribute with invalid id', async () => {
- wrapper.vm.showCount = true;
-
- await wrapper.vm.$nextTick();
- expect(wrapper.find('.board-list-count').attributes('data-issue-id')).toBe('-1');
- });
-
- it('shows how many more issues to load', async () => {
- wrapper.vm.showCount = true;
- wrapper.setProps({ list: { issuesCount: 20 } });
-
- await wrapper.vm.$nextTick();
- expect(wrapper.find('.board-list-count').text()).toBe('Showing 1 of 20 issues');
- });
- });
-
- describe('load more issues', () => {
- beforeEach(() => {
- wrapper = createComponent({
- listProps: { issuesCount: 25 },
- });
- });
-
- it('loads more issues after scrolling', () => {
- wrapper.vm.listRef.dispatchEvent(new Event('scroll'));
-
- expect(actions.fetchIssuesForList).toHaveBeenCalled();
- });
-
- it('does not load issues if already loading', () => {
- wrapper = createComponent({
- state: { listsFlags: { 'gid://gitlab/List/1': { isLoadingMore: true } } },
- });
- wrapper.vm.listRef.dispatchEvent(new Event('scroll'));
-
- expect(actions.fetchIssuesForList).not.toHaveBeenCalled();
- });
-
- it('shows loading more spinner', async () => {
- wrapper = createComponent({
- state: { listsFlags: { 'gid://gitlab/List/1': { isLoadingMore: true } } },
- });
- wrapper.vm.showCount = true;
-
- await wrapper.vm.$nextTick();
- expect(wrapper.find('.board-list-count .gl-spinner').exists()).toBe(true);
- });
- });
-
- describe('max issue count warning', () => {
- beforeEach(() => {
- wrapper = createComponent({
- listProps: { issuesCount: 50 },
- });
- });
-
- describe('when issue count exceeds max issue count', () => {
- it('sets background to bg-danger-100', async () => {
- wrapper.setProps({ list: { issuesCount: 4, maxIssueCount: 3 } });
-
- await wrapper.vm.$nextTick();
- expect(wrapper.find('.bg-danger-100').exists()).toBe(true);
- });
- });
-
- describe('when list issue count does NOT exceed list max issue count', () => {
- it('does not sets background to bg-danger-100', () => {
- wrapper.setProps({ list: { issuesCount: 2, maxIssueCount: 3 } });
-
- expect(wrapper.find('.bg-danger-100').exists()).toBe(false);
- });
- });
-
- describe('when list max issue count is 0', () => {
- it('does not sets background to bg-danger-100', () => {
- wrapper.setProps({ list: { maxIssueCount: 0 } });
-
- expect(wrapper.find('.bg-danger-100').exists()).toBe(false);
- });
- });
- });
-
- describe('drag & drop issue', () => {
- beforeEach(() => {
- wrapper = createComponent();
- });
-
- describe('handleDragOnStart', () => {
- it('adds a class `is-dragging` to document body', () => {
- expect(document.body.classList.contains('is-dragging')).toBe(false);
-
- findByTestId('tree-root-wrapper').vm.$emit('start');
-
- expect(document.body.classList.contains('is-dragging')).toBe(true);
- });
- });
-
- describe('handleDragOnEnd', () => {
- it('removes class `is-dragging` from document body', () => {
- jest.spyOn(wrapper.vm, 'moveIssue').mockImplementation(() => {});
- document.body.classList.add('is-dragging');
-
- findByTestId('tree-root-wrapper').vm.$emit('end', {
- oldIndex: 1,
- newIndex: 0,
- item: {
- dataset: {
- issueId: mockIssues[0].id,
- issueIid: mockIssues[0].iid,
- issuePath: mockIssues[0].referencePath,
- },
- },
- to: { children: [], dataset: { listId: 'gid://gitlab/List/1' } },
- from: { dataset: { listId: 'gid://gitlab/List/2' } },
- });
-
- expect(document.body.classList.contains('is-dragging')).toBe(false);
- });
- });
- });
-});
diff --git a/spec/frontend/boards/board_list_spec.js b/spec/frontend/boards/board_list_spec.js
index 43755cd8885..1b62f25044e 100644
--- a/spec/frontend/boards/board_list_spec.js
+++ b/spec/frontend/boards/board_list_spec.js
@@ -1,29 +1,51 @@
-/* global List */
-/* global ListIssue */
-
-import Vue from 'vue';
-import MockAdapter from 'axios-mock-adapter';
-import waitForPromises from 'helpers/wait_for_promises';
-import axios from '~/lib/utils/axios_utils';
+import Vuex from 'vuex';
+import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame';
+import { createLocalVue, mount } from '@vue/test-utils';
import eventHub from '~/boards/eventhub';
import BoardList from '~/boards/components/board_list.vue';
-import '~/boards/models/issue';
-import '~/boards/models/list';
-import { listObj, boardsMockInterceptor } from './mock_data';
-import store from '~/boards/stores';
-import boardsStore from '~/boards/stores/boards_store';
-
-const createComponent = ({ done, listIssueProps = {}, componentProps = {}, listProps = {} }) => {
- const el = document.createElement('div');
-
- document.body.appendChild(el);
- const mock = new MockAdapter(axios);
- mock.onAny().reply(boardsMockInterceptor);
- boardsStore.create();
-
- const BoardListComp = Vue.extend(BoardList);
- const list = new List({ ...listObj, ...listProps });
- const issue = new ListIssue({
+import BoardCard from '~/boards/components/board_card.vue';
+import { mockList, mockIssuesByListId, issues, mockIssues } from './mock_data';
+import defaultState from '~/boards/stores/state';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+const actions = {
+ fetchIssuesForList: jest.fn(),
+};
+
+const createStore = (state = defaultState) => {
+ return new Vuex.Store({
+ state,
+ actions,
+ });
+};
+
+const createComponent = ({
+ listIssueProps = {},
+ componentProps = {},
+ listProps = {},
+ state = {},
+} = {}) => {
+ const store = createStore({
+ issuesByListId: mockIssuesByListId,
+ issues,
+ pageInfoByListId: {
+ 'gid://gitlab/List/1': { hasNextPage: true },
+ 'gid://gitlab/List/2': {},
+ },
+ listsFlags: {
+ 'gid://gitlab/List/1': {},
+ 'gid://gitlab/List/2': {},
+ },
+ ...state,
+ });
+
+ const list = {
+ ...mockList,
+ ...listProps,
+ };
+ const issue = {
title: 'Testing',
id: 1,
iid: 1,
@@ -31,244 +53,214 @@ const createComponent = ({ done, listIssueProps = {}, componentProps = {}, listP
labels: [],
assignees: [],
...listIssueProps,
- });
- if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesSize')) {
- list.issuesSize = 1;
+ };
+ if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesCount')) {
+ list.issuesCount = 1;
}
- list.issues.push(issue);
- const component = new BoardListComp({
- el,
- store,
+ const component = mount(BoardList, {
+ localVue,
propsData: {
disabled: false,
list,
- issues: list.issues,
+ issues: [issue],
+ canAdminList: true,
...componentProps,
},
+ store,
provide: {
groupId: null,
rootPath: '/',
+ weightFeatureAvailable: false,
+ boardWeight: null,
},
- }).$mount();
-
- Vue.nextTick(() => {
- done();
});
- return { component, mock };
+ return component;
};
describe('Board list component', () => {
- let mock;
- let component;
- let getIssues;
- function generateIssues(compWrapper) {
- for (let i = 1; i < 20; i += 1) {
- const issue = { ...compWrapper.list.issues[0] };
- issue.id += i;
- compWrapper.list.issues.push(issue);
- }
- }
+ let wrapper;
+ const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`);
+ useFakeRequestAnimationFrame();
- describe('When Expanded', () => {
- beforeEach((done) => {
- getIssues = jest.spyOn(List.prototype, 'getIssues').mockReturnValue(new Promise(() => {}));
- ({ mock, component } = createComponent({ done }));
- });
-
- afterEach(() => {
- mock.restore();
- component.$destroy();
- });
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
- it('loads first page of issues', () => {
- return waitForPromises().then(() => {
- expect(getIssues).toHaveBeenCalled();
- });
+ describe('When Expanded', () => {
+ beforeEach(() => {
+ wrapper = createComponent();
});
it('renders component', () => {
- expect(component.$el.classList.contains('board-list-component')).toBe(true);
+ expect(wrapper.find('.board-list-component').exists()).toBe(true);
});
it('renders loading icon', () => {
- component.list.loading = true;
-
- return Vue.nextTick().then(() => {
- expect(component.$el.querySelector('.board-list-loading')).not.toBeNull();
+ wrapper = createComponent({
+ state: { listsFlags: { 'gid://gitlab/List/1': { isLoading: true } } },
});
+
+ expect(findByTestId('board_list_loading').exists()).toBe(true);
});
it('renders issues', () => {
- expect(component.$el.querySelectorAll('.board-card').length).toBe(1);
+ expect(wrapper.findAll(BoardCard).length).toBe(1);
});
it('sets data attribute with issue id', () => {
- expect(component.$el.querySelector('.board-card').getAttribute('data-issue-id')).toBe('1');
+ expect(wrapper.find('.board-card').attributes('data-issue-id')).toBe('1');
});
- it('shows new issue form', () => {
- component.toggleForm();
-
- return Vue.nextTick().then(() => {
- expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull();
+ it('shows new issue form', async () => {
+ wrapper.vm.toggleForm();
- expect(component.$el.querySelector('.is-smaller')).not.toBeNull();
- });
+ await wrapper.vm.$nextTick();
+ expect(wrapper.find('.board-new-issue-form').exists()).toBe(true);
});
- it('shows new issue form after eventhub event', () => {
- eventHub.$emit(`toggle-issue-form-${component.list.id}`);
-
- return Vue.nextTick().then(() => {
- expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull();
+ it('shows new issue form after eventhub event', async () => {
+ eventHub.$emit(`toggle-issue-form-${wrapper.vm.list.id}`);
- expect(component.$el.querySelector('.is-smaller')).not.toBeNull();
- });
+ await wrapper.vm.$nextTick();
+ expect(wrapper.find('.board-new-issue-form').exists()).toBe(true);
});
it('does not show new issue form for closed list', () => {
- component.list.type = 'closed';
- component.toggleForm();
+ wrapper.setProps({ list: { type: 'closed' } });
+ wrapper.vm.toggleForm();
- return Vue.nextTick().then(() => {
- expect(component.$el.querySelector('.board-new-issue-form')).toBeNull();
- });
+ expect(wrapper.find('.board-new-issue-form').exists()).toBe(false);
});
- it('shows count list item', () => {
- component.showCount = true;
+ it('shows count list item', async () => {
+ wrapper.vm.showCount = true;
- return Vue.nextTick().then(() => {
- expect(component.$el.querySelector('.board-list-count')).not.toBeNull();
+ await wrapper.vm.$nextTick();
+ expect(wrapper.find('.board-list-count').exists()).toBe(true);
- expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe(
- 'Showing all issues',
- );
- });
+ expect(wrapper.find('.board-list-count').text()).toBe('Showing all issues');
});
- it('sets data attribute with invalid id', () => {
- component.showCount = true;
+ it('sets data attribute with invalid id', async () => {
+ wrapper.vm.showCount = true;
- return Vue.nextTick().then(() => {
- expect(component.$el.querySelector('.board-list-count').getAttribute('data-issue-id')).toBe(
- '-1',
- );
- });
+ await wrapper.vm.$nextTick();
+ expect(wrapper.find('.board-list-count').attributes('data-issue-id')).toBe('-1');
});
- it('shows how many more issues to load', () => {
- component.showCount = true;
- component.list.issuesSize = 20;
+ it('shows how many more issues to load', async () => {
+ wrapper.vm.showCount = true;
+ wrapper.setProps({ list: { issuesCount: 20 } });
- return Vue.nextTick().then(() => {
- expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe(
- 'Showing 1 of 20 issues',
- );
- });
+ await wrapper.vm.$nextTick();
+ expect(wrapper.find('.board-list-count').text()).toBe('Showing 1 of 20 issues');
});
+ });
- it('loads more issues after scrolling', () => {
- jest.spyOn(component.list, 'nextPage').mockImplementation(() => {});
- generateIssues(component);
- component.$refs.list.dispatchEvent(new Event('scroll'));
-
- return waitForPromises().then(() => {
- expect(component.list.nextPage).toHaveBeenCalled();
+ describe('load more issues', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ listProps: { issuesCount: 25 },
});
});
- it('does not load issues if already loading', () => {
- component.list.nextPage = jest
- .spyOn(component.list, 'nextPage')
- .mockReturnValue(new Promise(() => {}));
-
- component.onScroll();
- component.onScroll();
+ it('loads more issues after scrolling', () => {
+ wrapper.vm.listRef.dispatchEvent(new Event('scroll'));
- return waitForPromises().then(() => {
- expect(component.list.nextPage).toHaveBeenCalledTimes(1);
- });
+ expect(actions.fetchIssuesForList).toHaveBeenCalled();
});
- it('shows loading more spinner', () => {
- component.showCount = true;
- component.list.loadingMore = true;
-
- return Vue.nextTick().then(() => {
- expect(component.$el.querySelector('.board-list-count .gl-spinner')).not.toBeNull();
+ it('does not load issues if already loading', () => {
+ wrapper = createComponent({
+ state: { listsFlags: { 'gid://gitlab/List/1': { isLoadingMore: true } } },
});
- });
- });
+ wrapper.vm.listRef.dispatchEvent(new Event('scroll'));
- describe('When Collapsed', () => {
- beforeEach((done) => {
- getIssues = jest.spyOn(List.prototype, 'getIssues').mockReturnValue(new Promise(() => {}));
- ({ mock, component } = createComponent({
- done,
- listProps: { type: 'closed', collapsed: true, issuesSize: 50 },
- }));
- generateIssues(component);
- component.scrollHeight = jest.spyOn(component, 'scrollHeight').mockReturnValue(0);
+ expect(actions.fetchIssuesForList).not.toHaveBeenCalled();
});
- afterEach(() => {
- mock.restore();
- component.$destroy();
- });
-
- it('does not load all issues', () => {
- return waitForPromises().then(() => {
- // Initial getIssues from list constructor
- expect(getIssues).toHaveBeenCalledTimes(1);
+ it('shows loading more spinner', async () => {
+ wrapper = createComponent({
+ state: { listsFlags: { 'gid://gitlab/List/1': { isLoadingMore: true } } },
});
+ wrapper.vm.showCount = true;
+
+ await wrapper.vm.$nextTick();
+ expect(wrapper.find('.board-list-count .gl-spinner').exists()).toBe(true);
});
});
describe('max issue count warning', () => {
- beforeEach((done) => {
- ({ mock, component } = createComponent({
- done,
- listProps: { type: 'closed', collapsed: true, issuesSize: 50 },
- }));
- });
-
- afterEach(() => {
- mock.restore();
- component.$destroy();
+ beforeEach(() => {
+ wrapper = createComponent({
+ listProps: { issuesCount: 50 },
+ });
});
describe('when issue count exceeds max issue count', () => {
- it('sets background to bg-danger-100', () => {
- component.list.issuesSize = 4;
- component.list.maxIssueCount = 3;
+ it('sets background to bg-danger-100', async () => {
+ wrapper.setProps({ list: { issuesCount: 4, maxIssueCount: 3 } });
- return Vue.nextTick().then(() => {
- expect(component.$el.querySelector('.bg-danger-100')).not.toBeNull();
- });
+ await wrapper.vm.$nextTick();
+ expect(wrapper.find('.bg-danger-100').exists()).toBe(true);
});
});
describe('when list issue count does NOT exceed list max issue count', () => {
it('does not sets background to bg-danger-100', () => {
- component.list.issuesSize = 2;
- component.list.maxIssueCount = 3;
+ wrapper.setProps({ list: { issuesCount: 2, maxIssueCount: 3 } });
- return Vue.nextTick().then(() => {
- expect(component.$el.querySelector('.bg-danger-100')).toBeNull();
- });
+ expect(wrapper.find('.bg-danger-100').exists()).toBe(false);
});
});
describe('when list max issue count is 0', () => {
it('does not sets background to bg-danger-100', () => {
- component.list.maxIssueCount = 0;
+ wrapper.setProps({ list: { maxIssueCount: 0 } });
+
+ expect(wrapper.find('.bg-danger-100').exists()).toBe(false);
+ });
+ });
+ });
- return Vue.nextTick().then(() => {
- expect(component.$el.querySelector('.bg-danger-100')).toBeNull();
+ describe('drag & drop issue', () => {
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ describe('handleDragOnStart', () => {
+ it('adds a class `is-dragging` to document body', () => {
+ expect(document.body.classList.contains('is-dragging')).toBe(false);
+
+ findByTestId('tree-root-wrapper').vm.$emit('start');
+
+ expect(document.body.classList.contains('is-dragging')).toBe(true);
+ });
+ });
+
+ describe('handleDragOnEnd', () => {
+ it('removes class `is-dragging` from document body', () => {
+ jest.spyOn(wrapper.vm, 'moveIssue').mockImplementation(() => {});
+ document.body.classList.add('is-dragging');
+
+ findByTestId('tree-root-wrapper').vm.$emit('end', {
+ oldIndex: 1,
+ newIndex: 0,
+ item: {
+ dataset: {
+ issueId: mockIssues[0].id,
+ issueIid: mockIssues[0].iid,
+ issuePath: mockIssues[0].referencePath,
+ },
+ },
+ to: { children: [], dataset: { listId: 'gid://gitlab/List/1' } },
+ from: { dataset: { listId: 'gid://gitlab/List/2' } },
});
+
+ expect(document.body.classList.contains('is-dragging')).toBe(false);
});
});
});
diff --git a/spec/frontend/boards/board_new_issue_spec.js b/spec/frontend/boards/board_new_issue_deprecated_spec.js
index 3eebfeca965..8236b468189 100644
--- a/spec/frontend/boards/board_new_issue_spec.js
+++ b/spec/frontend/boards/board_new_issue_deprecated_spec.js
@@ -4,7 +4,7 @@ import Vue from 'vue';
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
-import boardNewIssue from '~/boards/components/board_new_issue.vue';
+import boardNewIssue from '~/boards/components/board_new_issue_deprecated.vue';
import boardsStore from '~/boards/stores/boards_store';
import '~/boards/models/list';
diff --git a/spec/frontend/boards/components/board_column_new_spec.js b/spec/frontend/boards/components/board_column_deprecated_spec.js
index 81c0e60f931..a703caca4eb 100644
--- a/spec/frontend/boards/components/board_column_new_spec.js
+++ b/spec/frontend/boards/components/board_column_deprecated_spec.js
@@ -1,40 +1,65 @@
+import Vue from 'vue';
import { shallowMount } from '@vue/test-utils';
+import AxiosMockAdapter from 'axios-mock-adapter';
+import { TEST_HOST } from 'helpers/test_constants';
import { listObj } from 'jest/boards/mock_data';
-import BoardColumn from '~/boards/components/board_column_new.vue';
+import Board from '~/boards/components/board_column_deprecated.vue';
+import List from '~/boards/models/list';
import { ListType } from '~/boards/constants';
-import { createStore } from '~/boards/stores';
+import axios from '~/lib/utils/axios_utils';
describe('Board Column Component', () => {
let wrapper;
- let store;
+ let axiosMock;
+
+ beforeEach(() => {
+ window.gon = {};
+ axiosMock = new AxiosMockAdapter(axios);
+ axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: [] });
+ });
afterEach(() => {
+ axiosMock.restore();
+
wrapper.destroy();
- wrapper = null;
+
+ localStorage.clear();
});
- const createComponent = ({ listType = ListType.backlog, collapsed = false } = {}) => {
+ const createComponent = ({
+ listType = ListType.backlog,
+ collapsed = false,
+ withLocalStorage = true,
+ } = {}) => {
const boardId = '1';
const listMock = {
...listObj,
- listType,
+ list_type: listType,
collapsed,
};
if (listType === ListType.assignee) {
delete listMock.label;
- listMock.assignee = {};
+ listMock.user = {};
}
- store = createStore();
+ // Making List reactive
+ const list = Vue.observable(new List(listMock));
- wrapper = shallowMount(BoardColumn, {
- store,
+ if (withLocalStorage) {
+ localStorage.setItem(
+ `boards.${boardId}.${list.type}.${list.id}.expanded`,
+ (!collapsed).toString(),
+ );
+ }
+
+ wrapper = shallowMount(Board, {
propsData: {
+ boardId,
disabled: false,
- list: listMock,
+ list,
},
provide: {
boardId,
@@ -57,7 +82,7 @@ describe('Board Column Component', () => {
it('has class is-collapsed when list is collapsed', () => {
createComponent({ collapsed: false });
- expect(isCollapsed()).toBe(false);
+ expect(wrapper.vm.list.isExpanded).toBe(true);
});
it('does not have class is-collapsed when list is expanded', () => {
diff --git a/spec/frontend/boards/components/board_column_spec.js b/spec/frontend/boards/components/board_column_spec.js
index ba11225676b..1dcdad2b492 100644
--- a/spec/frontend/boards/components/board_column_spec.js
+++ b/spec/frontend/boards/components/board_column_spec.js
@@ -1,65 +1,40 @@
-import Vue from 'vue';
import { shallowMount } from '@vue/test-utils';
-import AxiosMockAdapter from 'axios-mock-adapter';
-import { TEST_HOST } from 'helpers/test_constants';
import { listObj } from 'jest/boards/mock_data';
-import Board from '~/boards/components/board_column.vue';
-import List from '~/boards/models/list';
+import BoardColumn from '~/boards/components/board_column.vue';
import { ListType } from '~/boards/constants';
-import axios from '~/lib/utils/axios_utils';
+import { createStore } from '~/boards/stores';
describe('Board Column Component', () => {
let wrapper;
- let axiosMock;
-
- beforeEach(() => {
- window.gon = {};
- axiosMock = new AxiosMockAdapter(axios);
- axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: [] });
- });
+ let store;
afterEach(() => {
- axiosMock.restore();
-
wrapper.destroy();
-
- localStorage.clear();
+ wrapper = null;
});
- const createComponent = ({
- listType = ListType.backlog,
- collapsed = false,
- withLocalStorage = true,
- } = {}) => {
+ const createComponent = ({ listType = ListType.backlog, collapsed = false } = {}) => {
const boardId = '1';
const listMock = {
...listObj,
- list_type: listType,
+ listType,
collapsed,
};
if (listType === ListType.assignee) {
delete listMock.label;
- listMock.user = {};
+ listMock.assignee = {};
}
- // Making List reactive
- const list = Vue.observable(new List(listMock));
+ store = createStore();
- if (withLocalStorage) {
- localStorage.setItem(
- `boards.${boardId}.${list.type}.${list.id}.expanded`,
- (!collapsed).toString(),
- );
- }
-
- wrapper = shallowMount(Board, {
+ wrapper = shallowMount(BoardColumn, {
+ store,
propsData: {
- boardId,
disabled: false,
- list,
+ list: listMock,
},
provide: {
boardId,
@@ -82,7 +57,7 @@ describe('Board Column Component', () => {
it('has class is-collapsed when list is collapsed', () => {
createComponent({ collapsed: false });
- expect(wrapper.vm.list.isExpanded).toBe(true);
+ expect(isCollapsed()).toBe(false);
});
it('does not have class is-collapsed when list is expanded', () => {
diff --git a/spec/frontend/boards/components/board_content_spec.js b/spec/frontend/boards/components/board_content_spec.js
index 291013c561e..98be02d7dbf 100644
--- a/spec/frontend/boards/components/board_content_spec.js
+++ b/spec/frontend/boards/components/board_content_spec.js
@@ -4,7 +4,7 @@ import { GlAlert } from '@gitlab/ui';
import Draggable from 'vuedraggable';
import EpicsSwimlanes from 'ee_component/boards/components/epics_swimlanes.vue';
import getters from 'ee_else_ce/boards/stores/getters';
-import BoardColumn from '~/boards/components/board_column.vue';
+import BoardColumnDeprecated from '~/boards/components/board_column_deprecated.vue';
import { mockLists, mockListsWithModel } from '../mock_data';
import BoardContent from '~/boards/components/board_content.vue';
@@ -17,6 +17,7 @@ const actions = {
describe('BoardContent', () => {
let wrapper;
+ window.gon = {};
const defaultState = {
isShowingEpicsSwimlanes: false,
@@ -56,10 +57,12 @@ describe('BoardContent', () => {
wrapper.destroy();
});
- it('renders a BoardColumn component per list', () => {
+ it('renders a BoardColumnDeprecated component per list', () => {
createComponent();
- expect(wrapper.findAll(BoardColumn)).toHaveLength(mockLists.length);
+ expect(wrapper.findAllComponents(BoardColumnDeprecated)).toHaveLength(
+ mockListsWithModel.length,
+ );
});
it('does not display EpicsSwimlanes component', () => {
@@ -70,6 +73,13 @@ describe('BoardContent', () => {
});
describe('graphqlBoardLists feature flag enabled', () => {
+ beforeEach(() => {
+ createComponent({ graphqlBoardListsEnabled: true });
+ gon.features = {
+ graphqlBoardLists: true,
+ };
+ });
+
describe('can admin list', () => {
beforeEach(() => {
createComponent({ graphqlBoardListsEnabled: true, props: { canAdminList: true } });
@@ -85,7 +95,7 @@ describe('BoardContent', () => {
createComponent({ graphqlBoardListsEnabled: true, props: { canAdminList: false } });
});
- it('renders draggable component', () => {
+ it('does not render draggable component', () => {
expect(wrapper.find(Draggable).exists()).toBe(false);
});
});
diff --git a/spec/frontend/boards/components/board_list_header_new_spec.js b/spec/frontend/boards/components/board_list_header_deprecated_spec.js
index 7fae2108e52..6207724e6a9 100644
--- a/spec/frontend/boards/components/board_list_header_new_spec.js
+++ b/spec/frontend/boards/components/board_list_header_deprecated_spec.js
@@ -1,23 +1,28 @@
-import Vuex from 'vuex';
-import { shallowMount, createLocalVue } from '@vue/test-utils';
-
-import { mockLabelList } from 'jest/boards/mock_data';
-import BoardListHeader from '~/boards/components/board_list_header_new.vue';
+import Vue from 'vue';
+import { shallowMount } from '@vue/test-utils';
+import AxiosMockAdapter from 'axios-mock-adapter';
+
+import { TEST_HOST } from 'helpers/test_constants';
+import { listObj } from 'jest/boards/mock_data';
+import BoardListHeader from '~/boards/components/board_list_header_deprecated.vue';
+import List from '~/boards/models/list';
import { ListType } from '~/boards/constants';
-
-const localVue = createLocalVue();
-
-localVue.use(Vuex);
+import axios from '~/lib/utils/axios_utils';
describe('Board List Header Component', () => {
let wrapper;
- let store;
+ let axiosMock;
- const updateListSpy = jest.fn();
+ beforeEach(() => {
+ window.gon = {};
+ axiosMock = new AxiosMockAdapter(axios);
+ axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: [] });
+ });
afterEach(() => {
+ axiosMock.restore();
+
wrapper.destroy();
- wrapper = null;
localStorage.clear();
});
@@ -26,54 +31,45 @@ describe('Board List Header Component', () => {
listType = ListType.backlog,
collapsed = false,
withLocalStorage = true,
- currentUserId = null,
} = {}) => {
const boardId = '1';
const listMock = {
- ...mockLabelList,
- listType,
+ ...listObj,
+ list_type: listType,
collapsed,
};
if (listType === ListType.assignee) {
delete listMock.label;
- listMock.assignee = {};
+ listMock.user = {};
}
+ // Making List reactive
+ const list = Vue.observable(new List(listMock));
+
if (withLocalStorage) {
localStorage.setItem(
- `boards.${boardId}.${listMock.listType}.${listMock.id}.expanded`,
+ `boards.${boardId}.${list.type}.${list.id}.expanded`,
(!collapsed).toString(),
);
}
- store = new Vuex.Store({
- state: {},
- actions: { updateList: updateListSpy },
- getters: {},
- });
-
wrapper = shallowMount(BoardListHeader, {
- store,
- localVue,
propsData: {
disabled: false,
- list: listMock,
+ list,
},
provide: {
boardId,
- weightFeatureAvailable: false,
- currentUserId,
},
});
};
- const isCollapsed = () => wrapper.vm.list.collapsed;
- const isExpanded = () => !isCollapsed;
+ const isCollapsed = () => !wrapper.props().list.isExpanded;
+ const isExpanded = () => wrapper.vm.list.isExpanded;
const findAddIssueButton = () => wrapper.find({ ref: 'newIssueBtn' });
- const findTitle = () => wrapper.find('.board-title');
const findCaret = () => wrapper.find('.board-title-caret');
describe('Add issue button', () => {
@@ -93,8 +89,6 @@ describe('Board List Header Component', () => {
});
it('has a test for each list type', () => {
- createComponent();
-
Object.values(ListType).forEach((value) => {
expect([...hasAddButton, ...hasNoAddButton]).toContain(value);
});
@@ -108,80 +102,64 @@ describe('Board List Header Component', () => {
});
describe('expanding / collapsing the column', () => {
- it('does not collapse when clicking the header', async () => {
+ it('does not collapse when clicking the header', () => {
createComponent();
expect(isCollapsed()).toBe(false);
-
wrapper.find('[data-testid="board-list-header"]').trigger('click');
- await wrapper.vm.$nextTick();
-
- expect(isCollapsed()).toBe(false);
+ return wrapper.vm.$nextTick().then(() => {
+ expect(isCollapsed()).toBe(false);
+ });
});
- it('collapses expanded Column when clicking the collapse icon', async () => {
+ it('collapses expanded Column when clicking the collapse icon', () => {
createComponent();
- expect(isCollapsed()).toBe(false);
-
+ expect(isExpanded()).toBe(true);
findCaret().vm.$emit('click');
- await wrapper.vm.$nextTick();
-
- expect(isCollapsed()).toBe(true);
+ return wrapper.vm.$nextTick().then(() => {
+ expect(isCollapsed()).toBe(true);
+ });
});
- it('expands collapsed Column when clicking the expand icon', async () => {
+ it('expands collapsed Column when clicking the expand icon', () => {
createComponent({ collapsed: true });
expect(isCollapsed()).toBe(true);
-
findCaret().vm.$emit('click');
- await wrapper.vm.$nextTick();
-
- expect(isCollapsed()).toBe(false);
+ return wrapper.vm.$nextTick().then(() => {
+ expect(isCollapsed()).toBe(false);
+ });
});
- it("when logged in it calls list update and doesn't set localStorage", async () => {
- createComponent({ withLocalStorage: false, currentUserId: 1 });
-
- findCaret().vm.$emit('click');
- await wrapper.vm.$nextTick();
-
- expect(updateListSpy).toHaveBeenCalledTimes(1);
- expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(null);
- });
+ it("when logged in it calls list update and doesn't set localStorage", () => {
+ jest.spyOn(List.prototype, 'update');
+ window.gon.current_user_id = 1;
- it("when logged out it doesn't call list update and sets localStorage", async () => {
- createComponent();
+ createComponent({ withLocalStorage: false });
findCaret().vm.$emit('click');
- await wrapper.vm.$nextTick();
- expect(updateListSpy).not.toHaveBeenCalled();
- expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(String(isExpanded()));
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.list.update).toHaveBeenCalledTimes(1);
+ expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(null);
+ });
});
- });
- describe('user can drag', () => {
- const cannotDragList = [ListType.backlog, ListType.closed];
- const canDragList = [ListType.label, ListType.milestone, ListType.assignee];
+ it("when logged out it doesn't call list update and sets localStorage", () => {
+ jest.spyOn(List.prototype, 'update');
- it.each(cannotDragList)(
- 'does not have user-can-drag-class so user cannot drag list',
- (listType) => {
- createComponent({ listType });
-
- expect(findTitle().classes()).not.toContain('user-can-drag');
- },
- );
+ createComponent();
- it.each(canDragList)('has user-can-drag-class so user can drag list', (listType) => {
- createComponent({ listType });
+ findCaret().vm.$emit('click');
- expect(findTitle().classes()).toContain('user-can-drag');
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.list.update).not.toHaveBeenCalled();
+ expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(String(isExpanded()));
+ });
});
});
});
diff --git a/spec/frontend/boards/components/board_list_header_spec.js b/spec/frontend/boards/components/board_list_header_spec.js
index 58469a1ac9a..357d05ced02 100644
--- a/spec/frontend/boards/components/board_list_header_spec.js
+++ b/spec/frontend/boards/components/board_list_header_spec.js
@@ -1,28 +1,23 @@
-import Vue from 'vue';
-import { shallowMount } from '@vue/test-utils';
-import AxiosMockAdapter from 'axios-mock-adapter';
+import Vuex from 'vuex';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
-import { TEST_HOST } from 'helpers/test_constants';
-import { listObj } from 'jest/boards/mock_data';
+import { mockLabelList } from 'jest/boards/mock_data';
import BoardListHeader from '~/boards/components/board_list_header.vue';
-import List from '~/boards/models/list';
import { ListType } from '~/boards/constants';
-import axios from '~/lib/utils/axios_utils';
+
+const localVue = createLocalVue();
+
+localVue.use(Vuex);
describe('Board List Header Component', () => {
let wrapper;
- let axiosMock;
+ let store;
- beforeEach(() => {
- window.gon = {};
- axiosMock = new AxiosMockAdapter(axios);
- axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: [] });
- });
+ const updateListSpy = jest.fn();
afterEach(() => {
- axiosMock.restore();
-
wrapper.destroy();
+ wrapper = null;
localStorage.clear();
});
@@ -31,45 +26,54 @@ describe('Board List Header Component', () => {
listType = ListType.backlog,
collapsed = false,
withLocalStorage = true,
+ currentUserId = null,
} = {}) => {
const boardId = '1';
const listMock = {
- ...listObj,
- list_type: listType,
+ ...mockLabelList,
+ listType,
collapsed,
};
if (listType === ListType.assignee) {
delete listMock.label;
- listMock.user = {};
+ listMock.assignee = {};
}
- // Making List reactive
- const list = Vue.observable(new List(listMock));
-
if (withLocalStorage) {
localStorage.setItem(
- `boards.${boardId}.${list.type}.${list.id}.expanded`,
+ `boards.${boardId}.${listMock.listType}.${listMock.id}.expanded`,
(!collapsed).toString(),
);
}
+ store = new Vuex.Store({
+ state: {},
+ actions: { updateList: updateListSpy },
+ getters: {},
+ });
+
wrapper = shallowMount(BoardListHeader, {
+ store,
+ localVue,
propsData: {
disabled: false,
- list,
+ list: listMock,
},
provide: {
boardId,
+ weightFeatureAvailable: false,
+ currentUserId,
},
});
};
- const isCollapsed = () => !wrapper.props().list.isExpanded;
- const isExpanded = () => wrapper.vm.list.isExpanded;
+ const isCollapsed = () => wrapper.vm.list.collapsed;
+ const isExpanded = () => !isCollapsed;
const findAddIssueButton = () => wrapper.find({ ref: 'newIssueBtn' });
+ const findTitle = () => wrapper.find('.board-title');
const findCaret = () => wrapper.find('.board-title-caret');
describe('Add issue button', () => {
@@ -89,6 +93,8 @@ describe('Board List Header Component', () => {
});
it('has a test for each list type', () => {
+ createComponent();
+
Object.values(ListType).forEach((value) => {
expect([...hasAddButton, ...hasNoAddButton]).toContain(value);
});
@@ -102,64 +108,80 @@ describe('Board List Header Component', () => {
});
describe('expanding / collapsing the column', () => {
- it('does not collapse when clicking the header', () => {
+ it('does not collapse when clicking the header', async () => {
createComponent();
expect(isCollapsed()).toBe(false);
+
wrapper.find('[data-testid="board-list-header"]').trigger('click');
- return wrapper.vm.$nextTick().then(() => {
- expect(isCollapsed()).toBe(false);
- });
+ await wrapper.vm.$nextTick();
+
+ expect(isCollapsed()).toBe(false);
});
- it('collapses expanded Column when clicking the collapse icon', () => {
+ it('collapses expanded Column when clicking the collapse icon', async () => {
createComponent();
- expect(isExpanded()).toBe(true);
+ expect(isCollapsed()).toBe(false);
+
findCaret().vm.$emit('click');
- return wrapper.vm.$nextTick().then(() => {
- expect(isCollapsed()).toBe(true);
- });
+ await wrapper.vm.$nextTick();
+
+ expect(isCollapsed()).toBe(true);
});
- it('expands collapsed Column when clicking the expand icon', () => {
+ it('expands collapsed Column when clicking the expand icon', async () => {
createComponent({ collapsed: true });
expect(isCollapsed()).toBe(true);
+
findCaret().vm.$emit('click');
- return wrapper.vm.$nextTick().then(() => {
- expect(isCollapsed()).toBe(false);
- });
- });
+ await wrapper.vm.$nextTick();
- it("when logged in it calls list update and doesn't set localStorage", () => {
- jest.spyOn(List.prototype, 'update');
- window.gon.current_user_id = 1;
+ expect(isCollapsed()).toBe(false);
+ });
- createComponent({ withLocalStorage: false });
+ it("when logged in it calls list update and doesn't set localStorage", async () => {
+ createComponent({ withLocalStorage: false, currentUserId: 1 });
findCaret().vm.$emit('click');
+ await wrapper.vm.$nextTick();
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.list.update).toHaveBeenCalledTimes(1);
- expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(null);
- });
+ expect(updateListSpy).toHaveBeenCalledTimes(1);
+ expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(null);
});
- it("when logged out it doesn't call list update and sets localStorage", () => {
- jest.spyOn(List.prototype, 'update');
-
+ it("when logged out it doesn't call list update and sets localStorage", async () => {
createComponent();
findCaret().vm.$emit('click');
+ await wrapper.vm.$nextTick();
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.vm.list.update).not.toHaveBeenCalled();
- expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(String(isExpanded()));
- });
+ expect(updateListSpy).not.toHaveBeenCalled();
+ expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(String(isExpanded()));
+ });
+ });
+
+ describe('user can drag', () => {
+ const cannotDragList = [ListType.backlog, ListType.closed];
+ const canDragList = [ListType.label, ListType.milestone, ListType.assignee];
+
+ it.each(cannotDragList)(
+ 'does not have user-can-drag-class so user cannot drag list',
+ (listType) => {
+ createComponent({ listType });
+
+ expect(findTitle().classes()).not.toContain('user-can-drag');
+ },
+ );
+
+ it.each(canDragList)('has user-can-drag-class so user can drag list', (listType) => {
+ createComponent({ listType });
+
+ expect(findTitle().classes()).toContain('user-can-drag');
});
});
});
diff --git a/spec/frontend/boards/components/board_new_issue_new_spec.js b/spec/frontend/boards/components/board_new_issue_spec.js
index 3265ef40a2e..5a01221a5be 100644
--- a/spec/frontend/boards/components/board_new_issue_new_spec.js
+++ b/spec/frontend/boards/components/board_new_issue_spec.js
@@ -1,6 +1,6 @@
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
-import BoardNewIssue from '~/boards/components/board_new_issue_new.vue';
+import BoardNewIssue from '~/boards/components/board_new_issue.vue';
import '~/boards/models/list';
import { mockList, mockGroupProjects } from '../mock_data';
diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js
index a963a92a366..d5cfb9b7d07 100644
--- a/spec/frontend/boards/mock_data.js
+++ b/spec/frontend/boards/mock_data.js
@@ -285,7 +285,7 @@ export const setMockEndpoints = (opts = {}) => {
export const mockList = {
id: 'gid://gitlab/List/1',
title: 'Backlog',
- position: null,
+ position: -Infinity,
listType: 'backlog',
collapsed: false,
label: null,
diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js
index 72f81d6db29..e4209cd5e55 100644
--- a/spec/frontend/boards/stores/actions_spec.js
+++ b/spec/frontend/boards/stores/actions_spec.js
@@ -16,8 +16,14 @@ import * as types from '~/boards/stores/mutation_types';
import { inactiveId } from '~/boards/constants';
import issueMoveListMutation from '~/boards/graphql/issue_move_list.mutation.graphql';
import destroyBoardListMutation from '~/boards/graphql/board_list_destroy.mutation.graphql';
+import issueCreateMutation from '~/boards/graphql/issue_create.mutation.graphql';
import updateAssignees from '~/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql';
-import { fullBoardId, formatListIssues, formatBoardLists } from '~/boards/boards_util';
+import {
+ fullBoardId,
+ formatListIssues,
+ formatBoardLists,
+ formatIssueInput,
+} from '~/boards/boards_util';
import createFlash from '~/flash';
jest.mock('~/flash');
@@ -728,6 +734,27 @@ describe('createNewIssue', () => {
const state = {
boardType: 'group',
fullPath: 'gitlab-org/gitlab',
+ boardConfig: {
+ labelIds: [],
+ assigneeId: null,
+ milestoneId: -1,
+ },
+ };
+
+ const stateWithBoardConfig = {
+ boardConfig: {
+ labels: [
+ {
+ id: 5,
+ title: 'Test',
+ color: '#ff0000',
+ description: 'testing;',
+ textColor: 'white',
+ },
+ ],
+ assigneeId: 2,
+ milestoneId: 3,
+ },
};
it('should return issue from API on success', async () => {
@@ -744,11 +771,59 @@ describe('createNewIssue', () => {
expect(result).toEqual(mockIssue);
});
+ it('should add board scope to the issue being created', async () => {
+ jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
+ data: {
+ createIssue: {
+ issue: mockIssue,
+ errors: [],
+ },
+ },
+ });
+
+ await actions.createNewIssue({ state: stateWithBoardConfig }, mockIssue);
+ expect(gqlClient.mutate).toHaveBeenCalledWith({
+ mutation: issueCreateMutation,
+ variables: {
+ input: formatIssueInput(mockIssue, stateWithBoardConfig.boardConfig),
+ },
+ });
+ });
+
+ it('should add board scope by merging attributes to the issue being created', async () => {
+ const issue = {
+ ...mockIssue,
+ assigneeIds: ['gid://gitlab/User/1'],
+ labelIds: ['gid://gitlab/GroupLabel/4'],
+ };
+
+ jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
+ data: {
+ createIssue: {
+ issue,
+ errors: [],
+ },
+ },
+ });
+
+ const payload = formatIssueInput(issue, stateWithBoardConfig.boardConfig);
+
+ await actions.createNewIssue({ state: stateWithBoardConfig }, issue);
+ expect(gqlClient.mutate).toHaveBeenCalledWith({
+ mutation: issueCreateMutation,
+ variables: {
+ input: formatIssueInput(issue, stateWithBoardConfig.boardConfig),
+ },
+ });
+ expect(payload.labelIds).toEqual(['gid://gitlab/GroupLabel/4', 'gid://gitlab/GroupLabel/5']);
+ expect(payload.assigneeIds).toEqual(['gid://gitlab/User/1', 'gid://gitlab/User/2']);
+ });
+
it('should commit CREATE_ISSUE_FAILURE mutation when API returns an error', (done) => {
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
createIssue: {
- issue: {},
+ issue: mockIssue,
errors: [{ foo: 'bar' }],
},
},
diff --git a/spec/policies/group_member_policy_spec.rb b/spec/policies/group_member_policy_spec.rb
index 9e58ea81ef3..6099e4549b1 100644
--- a/spec/policies/group_member_policy_spec.rb
+++ b/spec/policies/group_member_policy_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe GroupMemberPolicy do
+ include DesignManagementTestHelpers
+
let(:guest) { create(:user) }
let(:owner) { create(:user) }
let(:group) { create(:group, :private) }
@@ -28,22 +30,64 @@ RSpec.describe GroupMemberPolicy do
permissions.each { |p| is_expected.not_to be_allowed(p) }
end
- context 'with guest user' do
- let(:current_user) { guest }
+ context 'with anonymous user' do
+ let(:group) { create(:group, :public) }
+ let(:current_user) { nil }
+ let(:membership) { guest.members.first }
it do
- expect_disallowed(:member_related_permissions)
+ expect_disallowed(:read_design_activity, *member_related_permissions)
+ expect_allowed(:read_group)
+ end
+
+ context 'design management is enabled' do
+ before do
+ create(:project, :public, group: group) # Necessary to enable design management
+ enable_design_management
+ end
+
+ specify do
+ expect_allowed(:read_design_activity)
+ end
+ end
+
+ context 'for a private group' do
+ let(:group) { create(:group, :private) }
+
+ specify do
+ expect_disallowed(:read_group, :read_design_activity, *member_related_permissions)
+ end
+ end
+
+ context 'for an internal group' do
+ let(:group) { create(:group, :internal) }
+
+ specify do
+ expect_disallowed(:read_group, :read_design_activity, *member_related_permissions)
+ end
end
end
+ context 'with guest user, for own membership' do
+ let(:current_user) { guest }
+
+ specify { expect_disallowed(:update_group_member) }
+ specify { expect_allowed(:read_group, :destroy_group_member) }
+ end
+
+ context 'with guest user, for other membership' do
+ let(:current_user) { guest }
+ let(:membership) { owner.members.first }
+
+ specify { expect_disallowed(:destroy_group_member, :update_group_member) }
+ specify { expect_allowed(:read_group) }
+ end
+
context 'with one owner' do
let(:current_user) { owner }
- it do
- expect_disallowed(:destroy_group_member)
- expect_disallowed(:update_group_member)
- expect_allowed(:read_group)
- end
+ specify { expect_disallowed(*member_related_permissions) }
+ specify { expect_allowed(:read_group) }
end
context 'with more than one owner' do
@@ -53,10 +97,7 @@ RSpec.describe GroupMemberPolicy do
group.add_owner(create(:user))
end
- it do
- expect_allowed(:destroy_group_member)
- expect_allowed(:update_group_member)
- end
+ specify { expect_allowed(*member_related_permissions) }
end
context 'with the group parent' do
diff --git a/spec/requests/api/graphql/group/group_members_spec.rb b/spec/requests/api/graphql/group/group_members_spec.rb
index 3554e22cdf2..452610ab18f 100644
--- a/spec/requests/api/graphql/group/group_members_spec.rb
+++ b/spec/requests/api/graphql/group/group_members_spec.rb
@@ -10,15 +10,13 @@ RSpec.describe 'getting group members information' do
let_it_be(:user_1) { create(:user, username: 'user') }
let_it_be(:user_2) { create(:user, username: 'test') }
- let(:member_data) { graphql_data['group']['groupMembers']['edges'] }
-
before_all do
[user_1, user_2].each { |user| parent_group.add_guest(user) }
end
context 'when the request is correct' do
it_behaves_like 'a working graphql query' do
- before_all do
+ before do
fetch_members
end
end
@@ -80,12 +78,10 @@ RSpec.describe 'getting group members information' do
end
context 'when unauthenticated' do
- it 'returns nothing' do
+ it 'returns visible members' do
fetch_members(current_user: nil)
- expect(graphql_errors).to be_nil
- expect(response).to have_gitlab_http_status(:success)
- expect(member_data).to be_empty
+ expect_array_response(user_1, user_2)
end
end
@@ -112,8 +108,8 @@ RSpec.describe 'getting group members information' do
def expect_array_response(*items)
expect(response).to have_gitlab_http_status(:success)
- expect(member_data).to be_an Array
- expect(member_data.map { |node| node["node"]["user"]["id"] })
- .to match_array(items.map { |u| global_id_of(u) })
+ member_gids = graphql_data_at(:group, :group_members, :edges, :node, :user, :id)
+
+ expect(member_gids).to match_array(items.map { |u| global_id_of(u) })
end
end
diff --git a/spec/requests/api/graphql/project/project_members_spec.rb b/spec/requests/api/graphql/project/project_members_spec.rb
index cb937432ef7..984a0adb8c6 100644
--- a/spec/requests/api/graphql/project/project_members_spec.rb
+++ b/spec/requests/api/graphql/project/project_members_spec.rb
@@ -11,15 +11,13 @@ RSpec.describe 'getting project members information' do
let_it_be(:user_1) { create(:user, username: 'user') }
let_it_be(:user_2) { create(:user, username: 'test') }
- let(:member_data) { graphql_data['project']['projectMembers']['edges'] }
-
before_all do
[user_1, user_2].each { |user| parent_group.add_guest(user) }
end
context 'when the request is correct' do
it_behaves_like 'a working graphql query' do
- before_all do
+ before do
fetch_members(project: parent_project)
end
end
@@ -114,8 +112,7 @@ RSpec.describe 'getting project members information' do
def expect_array_response(*items)
expect(response).to have_gitlab_http_status(:success)
- expect(member_data).to be_an Array
- expect(member_data.map { |node| node['node']['user']['id'] })
- .to match_array(items.map { |u| global_id_of(u) })
+ member_gids = graphql_data_at(:project, :project_members, :edges, :node, :user, :id)
+ expect(member_gids).to match_array(items.map { |u| global_id_of(u) })
end
end
diff --git a/spec/support/shared_examples/services/onboarding_progress_shared_examples.rb b/spec/support/shared_examples/services/onboarding_progress_shared_examples.rb
new file mode 100644
index 00000000000..8c6c2271af3
--- /dev/null
+++ b/spec/support/shared_examples/services/onboarding_progress_shared_examples.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'records an onboarding progress action' do |action|
+ include AfterNextHelpers
+
+ it do
+ expect_next(OnboardingProgressService, namespace)
+ .to receive(:execute).with(action: action).and_call_original
+
+ subject
+ end
+end
+
+RSpec.shared_examples 'does not record an onboarding progress action' do
+ it do
+ expect(OnboardingProgressService).not_to receive(:new)
+
+ subject
+ end
+end