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:
-rw-r--r--app/assets/javascripts/boards/components/board_card.vue81
-rw-r--r--app/assets/javascripts/boards/components/board_card_deprecated.vue61
-rw-r--r--app/assets/javascripts/boards/components/board_card_layout.vue98
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue6
-rw-r--r--app/assets/javascripts/boards/components/board_list_deprecated.vue2
-rw-r--r--app/assets/javascripts/boards/stores/actions.js31
-rw-r--r--app/assets/javascripts/boards/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/boards/stores/mutations.js5
-rw-r--r--app/graphql/mutations/custom_emoji/create.rb1
-rw-r--r--app/models/custom_emoji.rb2
-rw-r--r--app/models/user.rb1
-rw-r--r--changelogs/unreleased/tc-add-custom-emoji-creator.yml5
-rw-r--r--db/migrate/20210205134213_add_creator_id_to_custom_emoji.rb23
-rw-r--r--db/migrate/20210219100137_add_creator_foreign_key_to_custom_emoji.rb23
-rw-r--r--db/schema_migrations/202102051342131
-rw-r--r--db/schema_migrations/202102191001371
-rw-r--r--db/structure.sql6
-rw-r--r--doc/ci/pipelines/settings.md4
-rw-r--r--doc/topics/autodevops/index.md4
-rw-r--r--doc/user/group/repositories_analytics/index.md3
-rw-r--r--spec/factories/custom_emoji.rb1
-rw-r--r--spec/frontend/boards/board_list_spec.js16
-rw-r--r--spec/frontend/boards/components/board_card_deprecated_spec.js219
-rw-r--r--spec/frontend/boards/components/board_card_layout_spec.js116
-rw-r--r--spec/frontend/boards/components/board_card_spec.js264
-rw-r--r--spec/frontend/boards/stores/actions_spec.js63
-rw-r--r--spec/frontend/boards/stores/mutations_spec.js15
-rw-r--r--spec/graphql/mutations/custom_emoji/create_spec.rb27
-rw-r--r--spec/models/custom_emoji_spec.rb6
-rw-r--r--spec/models/user_spec.rb1
30 files changed, 656 insertions, 431 deletions
diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue
index e6009343626..a0fee1b064b 100644
--- a/app/assets/javascripts/boards/components/board_card.vue
+++ b/app/assets/javascripts/boards/components/board_card.vue
@@ -1,14 +1,11 @@
<script>
-import sidebarEventHub from '~/sidebar/event_hub';
-import eventHub from '../eventhub';
-import boardsStore from '../stores/boards_store';
-import BoardCardLayout from './board_card_layout.vue';
-import BoardCardLayoutDeprecated from './board_card_layout_deprecated.vue';
+import { mapActions, mapGetters, mapState } from 'vuex';
+import IssueCardInner from './issue_card_inner.vue';
export default {
- name: 'BoardsIssueCard',
+ name: 'BoardCard',
components: {
- BoardCardLayout: gon.features?.graphqlBoardLists ? BoardCardLayout : BoardCardLayoutDeprecated,
+ IssueCardInner,
},
props: {
list: {
@@ -21,29 +18,41 @@ export default {
default: () => ({}),
required: false,
},
+ disabled: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ index: {
+ type: Number,
+ default: 0,
+ required: false,
+ },
},
- methods: {
- // These are methods instead of computed's, because boardsStore is not reactive.
+ computed: {
+ ...mapState(['selectedBoardItems', 'activeId']),
+ ...mapGetters(['isSwimlanesOn']),
isActive() {
- return this.getActiveId() === this.issue.id;
+ return this.issue.id === this.activeId;
},
- getActiveId() {
- return boardsStore.detail?.issue?.id;
+ multiSelectVisible() {
+ return (
+ !this.activeId &&
+ this.selectedBoardItems.findIndex((boardItem) => boardItem.id === this.issue.id) > -1
+ );
},
- showIssue({ isMultiSelect }) {
- // If no issues are opened, close all sidebars first
- if (!this.getActiveId()) {
- sidebarEventHub.$emit('sidebar.closeAll');
- }
- if (this.isActive()) {
- eventHub.$emit('clearDetailIssue', isMultiSelect);
+ },
+ methods: {
+ ...mapActions(['toggleBoardItemMultiSelection', 'toggleBoardItem']),
+ toggleIssue(e) {
+ // Don't do anything if this happened on a no trigger element
+ if (e.target.classList.contains('js-no-trigger')) return;
- if (isMultiSelect) {
- eventHub.$emit('newDetailIssue', this.issue, isMultiSelect);
- }
+ const isMultiSelect = e.ctrlKey || e.metaKey;
+ if (isMultiSelect) {
+ this.toggleBoardItemMultiSelection(this.issue);
} else {
- eventHub.$emit('newDetailIssue', this.issue, isMultiSelect);
- boardsStore.setListDetail(this.list);
+ this.toggleBoardItem({ boardItem: this.issue });
}
},
},
@@ -51,12 +60,22 @@ export default {
</script>
<template>
- <board-card-layout
+ <li
data-qa-selector="board_card"
- :issue="issue"
- :list="list"
- :is-active="isActive()"
- v-bind="$attrs"
- @show="showIssue"
- />
+ :class="{
+ 'multi-select': multiSelectVisible,
+ 'user-can-drag': !disabled && issue.id,
+ 'is-disabled': disabled || !issue.id,
+ 'is-active': isActive,
+ }"
+ :index="index"
+ :data-issue-id="issue.id"
+ :data-issue-iid="issue.iid"
+ :data-issue-path="issue.referencePath"
+ data-testid="board_card"
+ class="board-card gl-p-5 gl-rounded-base"
+ @mouseup="toggleIssue($event)"
+ >
+ <issue-card-inner :list="list" :issue="issue" :update-filters="true" />
+ </li>
</template>
diff --git a/app/assets/javascripts/boards/components/board_card_deprecated.vue b/app/assets/javascripts/boards/components/board_card_deprecated.vue
new file mode 100644
index 00000000000..e12a2836f67
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_card_deprecated.vue
@@ -0,0 +1,61 @@
+<script>
+// This component is being replaced in favor of './board_card.vue' for GraphQL boards
+import sidebarEventHub from '~/sidebar/event_hub';
+import eventHub from '../eventhub';
+import boardsStore from '../stores/boards_store';
+import BoardCardLayoutDeprecated from './board_card_layout_deprecated.vue';
+
+export default {
+ components: {
+ BoardCardLayout: BoardCardLayoutDeprecated,
+ },
+ props: {
+ list: {
+ type: Object,
+ default: () => ({}),
+ required: false,
+ },
+ issue: {
+ type: Object,
+ default: () => ({}),
+ required: false,
+ },
+ },
+ methods: {
+ // These are methods instead of computed's, because boardsStore is not reactive.
+ isActive() {
+ return this.getActiveId() === this.issue.id;
+ },
+ getActiveId() {
+ return boardsStore.detail?.issue?.id;
+ },
+ showIssue({ isMultiSelect }) {
+ // If no issues are opened, close all sidebars first
+ if (!this.getActiveId()) {
+ sidebarEventHub.$emit('sidebar.closeAll');
+ }
+ if (this.isActive()) {
+ eventHub.$emit('clearDetailIssue', isMultiSelect);
+
+ if (isMultiSelect) {
+ eventHub.$emit('newDetailIssue', this.issue, isMultiSelect);
+ }
+ } else {
+ eventHub.$emit('newDetailIssue', this.issue, isMultiSelect);
+ boardsStore.setListDetail(this.list);
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <board-card-layout
+ data-qa-selector="board_card"
+ :issue="issue"
+ :list="list"
+ :is-active="isActive()"
+ v-bind="$attrs"
+ @show="showIssue"
+ />
+</template>
diff --git a/app/assets/javascripts/boards/components/board_card_layout.vue b/app/assets/javascripts/boards/components/board_card_layout.vue
deleted file mode 100644
index 5e3c3702519..00000000000
--- a/app/assets/javascripts/boards/components/board_card_layout.vue
+++ /dev/null
@@ -1,98 +0,0 @@
-<script>
-import { mapActions, mapGetters, mapState } from 'vuex';
-import { ISSUABLE } from '~/boards/constants';
-import IssueCardInner from './issue_card_inner.vue';
-
-export default {
- name: 'BoardCardLayout',
- components: {
- IssueCardInner,
- },
- props: {
- list: {
- type: Object,
- default: () => ({}),
- required: false,
- },
- issue: {
- type: Object,
- default: () => ({}),
- required: false,
- },
- disabled: {
- type: Boolean,
- default: false,
- required: false,
- },
- index: {
- type: Number,
- default: 0,
- required: false,
- },
- isActive: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- data() {
- return {
- showDetail: false,
- };
- },
- computed: {
- ...mapState(['selectedBoardItems']),
- ...mapGetters(['isSwimlanesOn']),
- multiSelectVisible() {
- return this.selectedBoardItems.findIndex((boardItem) => boardItem.id === this.issue.id) > -1;
- },
- },
- methods: {
- ...mapActions(['setActiveId', 'toggleBoardItemMultiSelection']),
- mouseDown() {
- this.showDetail = true;
- },
- mouseMove() {
- this.showDetail = false;
- },
- showIssue(e) {
- // Don't do anything if this happened on a no trigger element
- if (e.target.classList.contains('js-no-trigger')) return;
-
- const isMultiSelect = e.ctrlKey || e.metaKey;
-
- if (!isMultiSelect) {
- this.setActiveId({ id: this.issue.id, sidebarType: ISSUABLE });
- } else {
- this.toggleBoardItemMultiSelection(this.issue);
- }
-
- if (this.showDetail || isMultiSelect) {
- this.showDetail = false;
- }
- },
- },
-};
-</script>
-
-<template>
- <li
- :class="{
- 'multi-select': multiSelectVisible,
- 'user-can-drag': !disabled && issue.id,
- 'is-disabled': disabled || !issue.id,
- 'is-active': isActive,
- }"
- :index="index"
- :data-issue-id="issue.id"
- :data-issue-iid="issue.iid"
- :data-issue-path="issue.referencePath"
- data-testid="board_card"
- class="board-card gl-p-5 gl-rounded-base"
- @mousedown="mouseDown"
- @mousemove="mouseMove"
- @mouseup="showIssue($event)"
- >
- <issue-card-inner :list="list" :issue="issue" :update-filters="true" />
- </li>
-</template>
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index fbdba02b356..0142b0f4e31 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -230,7 +230,11 @@ export default {
: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" />
+ <gl-loading-icon
+ v-if="loadingMore"
+ :label="$options.i18n.loadingMoreissues"
+ data-testid="count-loading-icon"
+ />
<span v-if="showingAllIssues">{{ $options.i18n.showingAllIssues }}</span>
<span v-else>{{ paginatedIssueText }}</span>
</li>
diff --git a/app/assets/javascripts/boards/components/board_list_deprecated.vue b/app/assets/javascripts/boards/components/board_list_deprecated.vue
index 9b4961d362d..d59fbcc1b31 100644
--- a/app/assets/javascripts/boards/components/board_list_deprecated.vue
+++ b/app/assets/javascripts/boards/components/board_list_deprecated.vue
@@ -11,7 +11,7 @@ import {
sortableEnd,
} from '../mixins/sortable_default_options';
import boardsStore from '../stores/boards_store';
-import boardCard from './board_card.vue';
+import boardCard from './board_card_deprecated.vue';
import boardNewIssue from './board_new_issue_deprecated.vue';
// This component is being replaced in favor of './board_list.vue' for GraphQL boards
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
index 0f26733b1f1..9cb8d664e70 100644
--- a/app/assets/javascripts/boards/stores/actions.js
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -1,6 +1,12 @@
import { pick } from 'lodash';
import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql';
-import { BoardType, ListType, inactiveId, flashAnimationDuration } from '~/boards/constants';
+import {
+ BoardType,
+ ListType,
+ inactiveId,
+ flashAnimationDuration,
+ ISSUABLE,
+} from '~/boards/constants';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import createGqClient, { fetchPolicies } from '~/lib/graphql';
import { convertObjectPropsToCamelCase, urlParamsToObject } from '~/lib/utils/common_utils';
@@ -536,10 +542,17 @@ export default {
commit(types.SET_SELECTED_PROJECT, project);
},
- toggleBoardItemMultiSelection: ({ commit, state }, boardItem) => {
+ toggleBoardItemMultiSelection: ({ commit, state, dispatch, getters }, boardItem) => {
const { selectedBoardItems } = state;
const index = selectedBoardItems.indexOf(boardItem);
+ // If user already selected an item (activeIssue) without using mult-select,
+ // include that item in the selection and unset state.ActiveId to hide the sidebar.
+ if (getters.activeIssue) {
+ commit(types.ADD_BOARD_ITEM_TO_SELECTION, getters.activeIssue);
+ dispatch('unsetActiveId');
+ }
+
if (index === -1) {
commit(types.ADD_BOARD_ITEM_TO_SELECTION, boardItem);
} else {
@@ -551,6 +564,20 @@ export default {
commit(types.SET_ADD_COLUMN_FORM_VISIBLE, visible);
},
+ resetBoardItemMultiSelection: ({ commit }) => {
+ commit(types.RESET_BOARD_ITEM_SELECTION);
+ },
+
+ toggleBoardItem: ({ state, dispatch }, { boardItem, sidebarType = ISSUABLE }) => {
+ dispatch('resetBoardItemMultiSelection');
+
+ if (boardItem.id === state.activeId) {
+ dispatch('unsetActiveId');
+ } else {
+ dispatch('setActiveId', { id: boardItem.id, sidebarType });
+ }
+ },
+
fetchBacklog: () => {
notImplemented();
},
diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js
index c87129dfd57..4b43cca9675 100644
--- a/app/assets/javascripts/boards/stores/mutation_types.js
+++ b/app/assets/javascripts/boards/stores/mutation_types.js
@@ -45,3 +45,4 @@ export const REMOVE_BOARD_ITEM_FROM_SELECTION = 'REMOVE_BOARD_ITEM_FROM_SELECTIO
export const SET_ADD_COLUMN_FORM_VISIBLE = 'SET_ADD_COLUMN_FORM_VISIBLE';
export const ADD_LIST_TO_HIGHLIGHTED_LISTS = 'ADD_LIST_TO_HIGHLIGHTED_LISTS';
export const REMOVE_LIST_FROM_HIGHLIGHTED_LISTS = 'REMOVE_LIST_FROM_HIGHLIGHTED_LISTS';
+export const RESET_BOARD_ITEM_SELECTION = 'RESET_BOARD_ITEM_SELECTION';
diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js
index b4aeb00d103..12e96e7c9eb 100644
--- a/app/assets/javascripts/boards/stores/mutations.js
+++ b/app/assets/javascripts/boards/stores/mutations.js
@@ -111,6 +111,7 @@ export default {
[mutationTypes.RECEIVE_ITEMS_FOR_LIST_SUCCESS]: (state, { listIssues, listPageInfo, listId }) => {
const { listData, issues } = listIssues;
Vue.set(state, 'issues', { ...state.issues, ...issues });
+
Vue.set(
state.issuesByListId,
listId,
@@ -280,4 +281,8 @@ export default {
[mutationTypes.REMOVE_LIST_FROM_HIGHLIGHTED_LISTS]: (state, listId) => {
state.highlightedLists = state.highlightedLists.filter((id) => id !== listId);
},
+
+ [mutationTypes.RESET_BOARD_ITEM_SELECTION]: (state) => {
+ state.selectedBoardItems = [];
+ },
};
diff --git a/app/graphql/mutations/custom_emoji/create.rb b/app/graphql/mutations/custom_emoji/create.rb
index 9ec96be0f26..5cf54f8f877 100644
--- a/app/graphql/mutations/custom_emoji/create.rb
+++ b/app/graphql/mutations/custom_emoji/create.rb
@@ -31,6 +31,7 @@ module Mutations
group = authorized_find!(group_path: group_path)
# See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/37911#note_444682238
args[:external] = true
+ args[:creator] = current_user
custom_emoji = group.custom_emoji.create(args)
diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb
index f4c914c6a3a..aea48a5ec20 100644
--- a/app/models/custom_emoji.rb
+++ b/app/models/custom_emoji.rb
@@ -6,6 +6,7 @@ class CustomEmoji < ApplicationRecord
belongs_to :namespace, inverse_of: :custom_emoji
belongs_to :group, -> { where(type: 'Group') }, foreign_key: 'namespace_id'
+ belongs_to :creator, class_name: "User", inverse_of: :created_custom_emoji
# For now only external emoji are supported. See https://gitlab.com/gitlab-org/gitlab/-/issues/230467
validates :external, inclusion: { in: [true] }
@@ -15,6 +16,7 @@ class CustomEmoji < ApplicationRecord
validate :valid_emoji_name
validates :group, presence: true
+ validates :creator, presence: true
validates :name,
uniqueness: { scope: [:namespace_id, :name] },
presence: true,
diff --git a/app/models/user.rb b/app/models/user.rb
index 1f8b680c7e5..d91fc3ebce4 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -179,6 +179,7 @@ class User < ApplicationRecord
has_many :merge_request_reviewers, inverse_of: :reviewer
has_many :assigned_issues, class_name: "Issue", through: :issue_assignees, source: :issue
has_many :assigned_merge_requests, class_name: "MergeRequest", through: :merge_request_assignees, source: :merge_request
+ has_many :created_custom_emoji, class_name: 'CustomEmoji', inverse_of: :creator
has_many :bulk_imports
diff --git a/changelogs/unreleased/tc-add-custom-emoji-creator.yml b/changelogs/unreleased/tc-add-custom-emoji-creator.yml
new file mode 100644
index 00000000000..da4d33f674b
--- /dev/null
+++ b/changelogs/unreleased/tc-add-custom-emoji-creator.yml
@@ -0,0 +1,5 @@
+---
+title: Add creator to custom emoji
+merge_request: 53879
+author:
+type: changed
diff --git a/db/migrate/20210205134213_add_creator_id_to_custom_emoji.rb b/db/migrate/20210205134213_add_creator_id_to_custom_emoji.rb
new file mode 100644
index 00000000000..c01335767a8
--- /dev/null
+++ b/db/migrate/20210205134213_add_creator_id_to_custom_emoji.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class AddCreatorIdToCustomEmoji < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+
+ def up
+ # Custom Emoji is at the moment behind a default-disabled feature flag. It
+ # will be unlikely there are any records in this table, but to able to
+ # ensure a not-null constraint delete any existing rows.
+ # Roll-out issue: https://gitlab.com/gitlab-org/gitlab/-/issues/231317
+ execute 'DELETE FROM custom_emoji'
+
+ add_reference :custom_emoji, # rubocop:disable Migration/AddReference
+ :creator,
+ index: true,
+ null: false, # rubocop:disable Rails/NotNullColumn
+ foreign_key: false # FK is added in 20210219100137
+ end
+
+ def down
+ remove_reference :custom_emoji, :creator
+ end
+end
diff --git a/db/migrate/20210219100137_add_creator_foreign_key_to_custom_emoji.rb b/db/migrate/20210219100137_add_creator_foreign_key_to_custom_emoji.rb
new file mode 100644
index 00000000000..a954ba5ba3b
--- /dev/null
+++ b/db/migrate/20210219100137_add_creator_foreign_key_to_custom_emoji.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class AddCreatorForeignKeyToCustomEmoji < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ FK_NAME = 'fk_custom_emoji_creator_id'
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_foreign_key :custom_emoji, :users,
+ on_delete: :cascade,
+ column: :creator_id,
+ name: FK_NAME
+ end
+
+ def down
+ with_lock_retries do
+ remove_foreign_key :custom_emoji, name: FK_NAME
+ end
+ end
+end
diff --git a/db/schema_migrations/20210205134213 b/db/schema_migrations/20210205134213
new file mode 100644
index 00000000000..bc2525e85cd
--- /dev/null
+++ b/db/schema_migrations/20210205134213
@@ -0,0 +1 @@
+7c368cad497ccfd86c6a92e2edfec1d2a16879eb749184b1d20c5ab4c702b974 \ No newline at end of file
diff --git a/db/schema_migrations/20210219100137 b/db/schema_migrations/20210219100137
new file mode 100644
index 00000000000..c573c4fde5f
--- /dev/null
+++ b/db/schema_migrations/20210219100137
@@ -0,0 +1 @@
+be2ddc15e16d7d59bd050a60faaa0b6941d94ba7c47a88be473bcf3a17bb2763 \ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index 3611e0ae9cd..5e1fc008256 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -11609,6 +11609,7 @@ CREATE TABLE custom_emoji (
name text NOT NULL,
file text NOT NULL,
external boolean DEFAULT true NOT NULL,
+ creator_id bigint NOT NULL,
CONSTRAINT check_8c586dd507 CHECK ((char_length(name) <= 36)),
CONSTRAINT check_dd5d60f1fb CHECK ((char_length(file) <= 255))
);
@@ -21970,6 +21971,8 @@ CREATE INDEX index_csv_issue_imports_on_project_id ON csv_issue_imports USING bt
CREATE INDEX index_csv_issue_imports_on_user_id ON csv_issue_imports USING btree (user_id);
+CREATE INDEX index_custom_emoji_on_creator_id ON custom_emoji USING btree (creator_id);
+
CREATE UNIQUE INDEX index_custom_emoji_on_namespace_id_and_name ON custom_emoji USING btree (namespace_id, name);
CREATE UNIQUE INDEX index_daily_build_group_report_results_unique_columns ON ci_daily_build_group_report_results USING btree (project_id, ref_path, date, group_name);
@@ -24689,6 +24692,9 @@ ALTER TABLE ONLY todos
ALTER TABLE ONLY geo_event_log
ADD CONSTRAINT fk_cff7185ad2 FOREIGN KEY (reset_checksum_event_id) REFERENCES geo_reset_checksum_events(id) ON DELETE CASCADE;
+ALTER TABLE ONLY custom_emoji
+ ADD CONSTRAINT fk_custom_emoji_creator_id FOREIGN KEY (creator_id) REFERENCES users(id) ON DELETE CASCADE;
+
ALTER TABLE ONLY bulk_import_entities
ADD CONSTRAINT fk_d06d023c30 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
diff --git a/doc/ci/pipelines/settings.md b/doc/ci/pipelines/settings.md
index 466c19154bf..7c84cba7fe3 100644
--- a/doc/ci/pipelines/settings.md
+++ b/doc/ci/pipelines/settings.md
@@ -159,11 +159,13 @@ averaged.
To see the evolution of your project code coverage over time,
you can view a graph or download a CSV file with this data. From your project:
-1. Go to **{chart}** **Project Analytics > Repository** to see the historic data for each job listed in the dropdown above the graph.
+1. Go to **Project Analytics > Repository** to see the historic data for each job listed in the dropdown above the graph.
1. If you want a CSV file of that data, click **Download raw data (`.csv`)**
![Code coverage graph of a project over time](img/code_coverage_graph_v13_1.png)
+Code coverage data is also [available at the group level](../../user/group/repositories_analytics/index.md).
+
### Removing color codes
Some test coverage tools output with ANSI color codes that aren't
diff --git a/doc/topics/autodevops/index.md b/doc/topics/autodevops/index.md
index 20a2c4b68ee..44072147fde 100644
--- a/doc/topics/autodevops/index.md
+++ b/doc/topics/autodevops/index.md
@@ -148,7 +148,7 @@ any of the following places:
[groups](../../user/group/clusters/index.md#base-domain)
- or at the project level as a variable: `KUBE_INGRESS_BASE_DOMAIN`
- or at the group level as a variable: `KUBE_INGRESS_BASE_DOMAIN`
-- or as an instance-wide fallback in **Admin Area > Settings** under the
+- or as an instance-wide fallback in **Admin Area > Settings > CI/CD** under the
**Continuous Integration and Delivery** section
The base domain variable `KUBE_INGRESS_BASE_DOMAIN` follows the same order of precedence
@@ -224,7 +224,7 @@ To enable or disable Auto DevOps at the group level:
Even when disabled at the instance level, group owners and project maintainers can still enable
Auto DevOps at the group and project level, respectively.
-1. Go to **Admin Area > Settings > Continuous Integration and Deployment**.
+1. Go to **Admin Area > Settings > CI/CD > Continuous Integration and Deployment**.
1. Select **Default to Auto DevOps pipeline for all projects** to enable it.
1. (Optional) You can set up the Auto DevOps [base domain](#auto-devops-base-domain),
for Auto Deploy and Auto Review Apps to use.
diff --git a/doc/user/group/repositories_analytics/index.md b/doc/user/group/repositories_analytics/index.md
index 1cb7c05bb5f..42522723047 100644
--- a/doc/user/group/repositories_analytics/index.md
+++ b/doc/user/group/repositories_analytics/index.md
@@ -40,6 +40,9 @@ To see the latest code coverage for each project in your group:
1. Go to **Analytics > Repositories** in the group (not from a project).
1. In the **Latest test coverage results** section, use the **Select projects** dropdown to choose the projects you want to check.
+You can download code coverage data for specific projects using
+[code coverage history](../../../ci/pipelines/settings.md#code-coverage-history).
+
## Download historic test coverage data
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/215104) in [GitLab Premium](https://about.gitlab.com/pricing/) 13.4.
diff --git a/spec/factories/custom_emoji.rb b/spec/factories/custom_emoji.rb
index ba1ae11c18d..88e50eafa7c 100644
--- a/spec/factories/custom_emoji.rb
+++ b/spec/factories/custom_emoji.rb
@@ -6,5 +6,6 @@ FactoryBot.define do
namespace
group
file { 'https://gitlab.com/images/partyparrot.png' }
+ creator { namespace.owner }
end
end
diff --git a/spec/frontend/boards/board_list_spec.js b/spec/frontend/boards/board_list_spec.js
index 290c668cd75..ef5cc4c3cd1 100644
--- a/spec/frontend/boards/board_list_spec.js
+++ b/spec/frontend/boards/board_list_spec.js
@@ -1,8 +1,9 @@
-import { createLocalVue, mount } from '@vue/test-utils';
+import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame';
import BoardCard from '~/boards/components/board_card.vue';
import BoardList from '~/boards/components/board_list.vue';
+import BoardNewIssue from '~/boards/components/board_new_issue.vue';
import eventHub from '~/boards/eventhub';
import defaultState from '~/boards/stores/state';
import { mockList, mockIssuesByListId, issues, mockIssues } from './mock_data';
@@ -38,6 +39,7 @@ const createComponent = ({
'gid://gitlab/List/1': {},
'gid://gitlab/List/2': {},
},
+ selectedBoardItems: [],
...state,
});
@@ -58,7 +60,7 @@ const createComponent = ({
list.issuesCount = 1;
}
- const component = mount(BoardList, {
+ const component = shallowMount(BoardList, {
localVue,
propsData: {
disabled: false,
@@ -74,6 +76,10 @@ const createComponent = ({
weightFeatureAvailable: false,
boardWeight: null,
},
+ stubs: {
+ BoardCard,
+ BoardNewIssue,
+ },
});
return component;
@@ -81,7 +87,10 @@ const createComponent = ({
describe('Board list component', () => {
let wrapper;
+
const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`);
+ const findIssueCountLoadingIcon = () => wrapper.find('[data-testid="count-loading-icon"]');
+
useFakeRequestAnimationFrame();
afterEach(() => {
@@ -189,7 +198,8 @@ describe('Board list component', () => {
wrapper.vm.showCount = true;
await wrapper.vm.$nextTick();
- expect(wrapper.find('.board-list-count .gl-spinner').exists()).toBe(true);
+
+ expect(findIssueCountLoadingIcon().exists()).toBe(true);
});
});
diff --git a/spec/frontend/boards/components/board_card_deprecated_spec.js b/spec/frontend/boards/components/board_card_deprecated_spec.js
new file mode 100644
index 00000000000..6be84f6f111
--- /dev/null
+++ b/spec/frontend/boards/components/board_card_deprecated_spec.js
@@ -0,0 +1,219 @@
+/* global List */
+/* global ListAssignee */
+/* global ListLabel */
+
+import { mount } from '@vue/test-utils';
+
+import MockAdapter from 'axios-mock-adapter';
+import waitForPromises from 'helpers/wait_for_promises';
+import BoardCardDeprecated from '~/boards/components/board_card_deprecated.vue';
+import issueCardInner from '~/boards/components/issue_card_inner.vue';
+import eventHub from '~/boards/eventhub';
+import store from '~/boards/stores';
+import boardsStore from '~/boards/stores/boards_store';
+import axios from '~/lib/utils/axios_utils';
+
+import sidebarEventHub from '~/sidebar/event_hub';
+import '~/boards/models/label';
+import '~/boards/models/assignee';
+import '~/boards/models/list';
+import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+import { listObj, boardsMockInterceptor, setMockEndpoints } from '../mock_data';
+
+describe('BoardCard', () => {
+ let wrapper;
+ let mock;
+ let list;
+
+ const findIssueCardInner = () => wrapper.find(issueCardInner);
+ const findUserAvatarLink = () => wrapper.find(userAvatarLink);
+
+ // this particular mount component needs to be used after the root beforeEach because it depends on list being initialized
+ const mountComponent = (propsData) => {
+ wrapper = mount(BoardCardDeprecated, {
+ stubs: {
+ issueCardInner,
+ },
+ store,
+ propsData: {
+ list,
+ issue: list.issues[0],
+ disabled: false,
+ index: 0,
+ ...propsData,
+ },
+ provide: {
+ groupId: null,
+ rootPath: '/',
+ scopedLabelsAvailable: false,
+ },
+ });
+ };
+
+ const setupData = async () => {
+ list = new List(listObj);
+ boardsStore.create();
+ boardsStore.detail.issue = {};
+ const label1 = new ListLabel({
+ id: 3,
+ title: 'testing 123',
+ color: '#000cff',
+ text_color: 'white',
+ description: 'test',
+ });
+ await waitForPromises();
+
+ list.issues[0].labels.push(label1);
+ };
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ mock.onAny().reply(boardsMockInterceptor);
+ setMockEndpoints();
+ return setupData();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ list = null;
+ mock.restore();
+ });
+
+ it('when details issue is empty does not show the element', () => {
+ mountComponent();
+ expect(wrapper.find('[data-testid="board_card"').classes()).not.toContain('is-active');
+ });
+
+ it('when detailIssue is equal to card issue shows the element', () => {
+ [boardsStore.detail.issue] = list.issues;
+ mountComponent();
+
+ expect(wrapper.classes()).toContain('is-active');
+ });
+
+ it('when multiSelect does not contain issue removes multi select class', () => {
+ mountComponent();
+ expect(wrapper.classes()).not.toContain('multi-select');
+ });
+
+ it('when multiSelect contain issue add multi select class', () => {
+ boardsStore.multiSelect.list = [list.issues[0]];
+ mountComponent();
+
+ expect(wrapper.classes()).toContain('multi-select');
+ });
+
+ it('adds user-can-drag class if not disabled', () => {
+ mountComponent();
+ expect(wrapper.classes()).toContain('user-can-drag');
+ });
+
+ it('does not add user-can-drag class disabled', () => {
+ mountComponent({ disabled: true });
+
+ expect(wrapper.classes()).not.toContain('user-can-drag');
+ });
+
+ it('does not add disabled class', () => {
+ mountComponent();
+ expect(wrapper.classes()).not.toContain('is-disabled');
+ });
+
+ it('adds disabled class is disabled is true', () => {
+ mountComponent({ disabled: true });
+
+ expect(wrapper.classes()).toContain('is-disabled');
+ });
+
+ describe('mouse events', () => {
+ it('does not set detail issue if showDetail is false', () => {
+ mountComponent();
+ expect(boardsStore.detail.issue).toEqual({});
+ });
+
+ it('does not set detail issue if link is clicked', () => {
+ mountComponent();
+ findIssueCardInner().find('a').trigger('mouseup');
+
+ expect(boardsStore.detail.issue).toEqual({});
+ });
+
+ it('does not set detail issue if img is clicked', () => {
+ mountComponent({
+ issue: {
+ ...list.issues[0],
+ assignees: [
+ new ListAssignee({
+ id: 1,
+ name: 'testing 123',
+ username: 'test',
+ avatar: 'test_image',
+ }),
+ ],
+ },
+ });
+
+ findUserAvatarLink().trigger('mouseup');
+
+ expect(boardsStore.detail.issue).toEqual({});
+ });
+
+ it('does not set detail issue if showDetail is false after mouseup', () => {
+ mountComponent();
+ wrapper.trigger('mouseup');
+
+ expect(boardsStore.detail.issue).toEqual({});
+ });
+
+ it('sets detail issue to card issue on mouse up', () => {
+ jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
+
+ mountComponent();
+
+ wrapper.trigger('mousedown');
+ wrapper.trigger('mouseup');
+
+ expect(eventHub.$emit).toHaveBeenCalledWith('newDetailIssue', wrapper.vm.issue, false);
+ expect(boardsStore.detail.list).toEqual(wrapper.vm.list);
+ });
+
+ it('resets detail issue to empty if already set', () => {
+ jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
+ const [issue] = list.issues;
+ boardsStore.detail.issue = issue;
+ mountComponent();
+
+ wrapper.trigger('mousedown');
+ wrapper.trigger('mouseup');
+
+ expect(eventHub.$emit).toHaveBeenCalledWith('clearDetailIssue', false);
+ });
+ });
+
+ describe('sidebarHub events', () => {
+ it('closes all sidebars before showing an issue if no issues are opened', () => {
+ jest.spyOn(sidebarEventHub, '$emit').mockImplementation(() => {});
+ boardsStore.detail.issue = {};
+ mountComponent();
+
+ // sets conditional so that event is emitted.
+ wrapper.trigger('mousedown');
+
+ wrapper.trigger('mouseup');
+
+ expect(sidebarEventHub.$emit).toHaveBeenCalledWith('sidebar.closeAll');
+ });
+
+ it('it does not closes all sidebars before showing an issue if an issue is opened', () => {
+ jest.spyOn(sidebarEventHub, '$emit').mockImplementation(() => {});
+ const [issue] = list.issues;
+ boardsStore.detail.issue = issue;
+ mountComponent();
+
+ wrapper.trigger('mousedown');
+
+ expect(sidebarEventHub.$emit).not.toHaveBeenCalledWith('sidebar.closeAll');
+ });
+ });
+});
diff --git a/spec/frontend/boards/components/board_card_layout_spec.js b/spec/frontend/boards/components/board_card_layout_spec.js
deleted file mode 100644
index 3fa8714807c..00000000000
--- a/spec/frontend/boards/components/board_card_layout_spec.js
+++ /dev/null
@@ -1,116 +0,0 @@
-import { createLocalVue, shallowMount } from '@vue/test-utils';
-import Vuex from 'vuex';
-
-import BoardCardLayout from '~/boards/components/board_card_layout.vue';
-import IssueCardInner from '~/boards/components/issue_card_inner.vue';
-import { ISSUABLE } from '~/boards/constants';
-import defaultState from '~/boards/stores/state';
-import { mockLabelList, mockIssue } from '../mock_data';
-
-describe('Board card layout', () => {
- let wrapper;
- let store;
-
- const localVue = createLocalVue();
- localVue.use(Vuex);
-
- const createStore = ({ getters = {}, actions = {} } = {}) => {
- store = new Vuex.Store({
- state: defaultState,
- actions,
- getters,
- });
- };
-
- // this particular mount component needs to be used after the root beforeEach because it depends on list being initialized
- const mountComponent = ({ propsData = {}, provide = {} } = {}) => {
- wrapper = shallowMount(BoardCardLayout, {
- localVue,
- stubs: {
- IssueCardInner,
- },
- store,
- propsData: {
- list: mockLabelList,
- issue: mockIssue,
- disabled: false,
- index: 0,
- ...propsData,
- },
- provide: {
- groupId: null,
- rootPath: '/',
- scopedLabelsAvailable: false,
- ...provide,
- },
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
- describe('mouse events', () => {
- it('sets showDetail to true on mousedown', async () => {
- createStore();
- mountComponent();
-
- wrapper.trigger('mousedown');
- await wrapper.vm.$nextTick();
-
- expect(wrapper.vm.showDetail).toBe(true);
- });
-
- it('sets showDetail to false on mousemove', async () => {
- createStore();
- mountComponent();
- wrapper.trigger('mousedown');
- await wrapper.vm.$nextTick();
- expect(wrapper.vm.showDetail).toBe(true);
- wrapper.trigger('mousemove');
- await wrapper.vm.$nextTick();
- expect(wrapper.vm.showDetail).toBe(false);
- });
-
- it("calls 'setActiveId'", async () => {
- const setActiveId = jest.fn();
- createStore({
- actions: {
- setActiveId,
- },
- });
- mountComponent();
-
- wrapper.trigger('mouseup');
- await wrapper.vm.$nextTick();
-
- expect(setActiveId).toHaveBeenCalledTimes(1);
- expect(setActiveId).toHaveBeenCalledWith(expect.any(Object), {
- id: mockIssue.id,
- sidebarType: ISSUABLE,
- });
- });
-
- it("calls 'setActiveId' when epic swimlanes is active", async () => {
- const setActiveId = jest.fn();
- const isSwimlanesOn = () => true;
- createStore({
- getters: { isSwimlanesOn },
- actions: {
- setActiveId,
- },
- });
- mountComponent();
-
- wrapper.trigger('mouseup');
- await wrapper.vm.$nextTick();
-
- expect(setActiveId).toHaveBeenCalledTimes(1);
- expect(setActiveId).toHaveBeenCalledWith(expect.any(Object), {
- id: mockIssue.id,
- sidebarType: ISSUABLE,
- });
- });
- });
-});
diff --git a/spec/frontend/boards/components/board_card_spec.js b/spec/frontend/boards/components/board_card_spec.js
index 5f26ae1bb3b..d68e0f9fc23 100644
--- a/spec/frontend/boards/components/board_card_spec.js
+++ b/spec/frontend/boards/components/board_card_spec.js
@@ -1,43 +1,49 @@
-/* global List */
-/* global ListAssignee */
-/* global ListLabel */
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import Vuex from 'vuex';
-import { mount } from '@vue/test-utils';
-
-import MockAdapter from 'axios-mock-adapter';
-import waitForPromises from 'helpers/wait_for_promises';
import BoardCard from '~/boards/components/board_card.vue';
-import issueCardInner from '~/boards/components/issue_card_inner.vue';
-import eventHub from '~/boards/eventhub';
-import store from '~/boards/stores';
-import boardsStore from '~/boards/stores/boards_store';
-import axios from '~/lib/utils/axios_utils';
-
-import sidebarEventHub from '~/sidebar/event_hub';
-import '~/boards/models/label';
-import '~/boards/models/assignee';
-import '~/boards/models/list';
-import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
-import { listObj, boardsMockInterceptor, setMockEndpoints } from '../mock_data';
-
-describe('BoardCard', () => {
- let wrapper;
- let mock;
- let list;
+import IssueCardInner from '~/boards/components/issue_card_inner.vue';
+import { inactiveId } from '~/boards/constants';
+import { mockLabelList, mockIssue } from '../mock_data';
- const findIssueCardInner = () => wrapper.find(issueCardInner);
- const findUserAvatarLink = () => wrapper.find(userAvatarLink);
+describe('Board card layout', () => {
+ let wrapper;
+ let store;
+ let mockActions;
+
+ const localVue = createLocalVue();
+ localVue.use(Vuex);
+
+ const createStore = ({ initialState = {}, isSwimlanesOn = false } = {}) => {
+ mockActions = {
+ toggleBoardItem: jest.fn(),
+ toggleBoardItemMultiSelection: jest.fn(),
+ };
+
+ store = new Vuex.Store({
+ state: {
+ activeId: inactiveId,
+ selectedBoardItems: [],
+ ...initialState,
+ },
+ actions: mockActions,
+ getters: {
+ isSwimlanesOn: () => isSwimlanesOn,
+ },
+ });
+ };
// this particular mount component needs to be used after the root beforeEach because it depends on list being initialized
- const mountComponent = (propsData) => {
- wrapper = mount(BoardCard, {
+ const mountComponent = ({ propsData = {}, provide = {} } = {}) => {
+ wrapper = shallowMount(BoardCard, {
+ localVue,
stubs: {
- issueCardInner,
+ IssueCardInner,
},
store,
propsData: {
- list,
- issue: list.issues[0],
+ list: mockLabelList,
+ issue: mockIssue,
disabled: false,
index: 0,
...propsData,
@@ -46,174 +52,94 @@ describe('BoardCard', () => {
groupId: null,
rootPath: '/',
scopedLabelsAvailable: false,
+ ...provide,
},
});
};
- const setupData = async () => {
- list = new List(listObj);
- boardsStore.create();
- boardsStore.detail.issue = {};
- const label1 = new ListLabel({
- id: 3,
- title: 'testing 123',
- color: '#000cff',
- text_color: 'white',
- description: 'test',
- });
- await waitForPromises();
-
- list.issues[0].labels.push(label1);
+ const selectCard = async () => {
+ wrapper.trigger('mouseup');
+ await wrapper.vm.$nextTick();
};
- beforeEach(() => {
- mock = new MockAdapter(axios);
- mock.onAny().reply(boardsMockInterceptor);
- setMockEndpoints();
- return setupData();
- });
+ const multiSelectCard = async () => {
+ wrapper.trigger('mouseup', { ctrlKey: true });
+ await wrapper.vm.$nextTick();
+ };
afterEach(() => {
wrapper.destroy();
wrapper = null;
- list = null;
- mock.restore();
- });
-
- it('when details issue is empty does not show the element', () => {
- mountComponent();
- expect(wrapper.find('[data-testid="board_card"').classes()).not.toContain('is-active');
- });
-
- it('when detailIssue is equal to card issue shows the element', () => {
- [boardsStore.detail.issue] = list.issues;
- mountComponent();
-
- expect(wrapper.classes()).toContain('is-active');
- });
-
- it('when multiSelect does not contain issue removes multi select class', () => {
- mountComponent();
- expect(wrapper.classes()).not.toContain('multi-select');
- });
-
- it('when multiSelect contain issue add multi select class', () => {
- boardsStore.multiSelect.list = [list.issues[0]];
- mountComponent();
-
- expect(wrapper.classes()).toContain('multi-select');
- });
-
- it('adds user-can-drag class if not disabled', () => {
- mountComponent();
- expect(wrapper.classes()).toContain('user-can-drag');
- });
-
- it('does not add user-can-drag class disabled', () => {
- mountComponent({ disabled: true });
-
- expect(wrapper.classes()).not.toContain('user-can-drag');
- });
-
- it('does not add disabled class', () => {
- mountComponent();
- expect(wrapper.classes()).not.toContain('is-disabled');
+ store = null;
});
- it('adds disabled class is disabled is true', () => {
- mountComponent({ disabled: true });
-
- expect(wrapper.classes()).toContain('is-disabled');
- });
-
- describe('mouse events', () => {
- it('does not set detail issue if showDetail is false', () => {
+ describe.each`
+ isSwimlanesOn
+ ${true} | ${false}
+ `('when isSwimlanesOn is $isSwimlanesOn', ({ isSwimlanesOn }) => {
+ it('should not highlight the card by default', async () => {
+ createStore({ isSwimlanesOn });
mountComponent();
- expect(boardsStore.detail.issue).toEqual({});
- });
- it('does not set detail issue if link is clicked', () => {
- mountComponent();
- findIssueCardInner().find('a').trigger('mouseup');
-
- expect(boardsStore.detail.issue).toEqual({});
+ expect(wrapper.classes()).not.toContain('is-active');
+ expect(wrapper.classes()).not.toContain('multi-select');
});
- it('does not set detail issue if img is clicked', () => {
- mountComponent({
- issue: {
- ...list.issues[0],
- assignees: [
- new ListAssignee({
- id: 1,
- name: 'testing 123',
- username: 'test',
- avatar: 'test_image',
- }),
- ],
+ it('should highlight the card with a correct style when selected', async () => {
+ createStore({
+ initialState: {
+ activeId: mockIssue.id,
},
+ isSwimlanesOn,
});
-
- findUserAvatarLink().trigger('mouseup');
-
- expect(boardsStore.detail.issue).toEqual({});
- });
-
- it('does not set detail issue if showDetail is false after mouseup', () => {
- mountComponent();
- wrapper.trigger('mouseup');
-
- expect(boardsStore.detail.issue).toEqual({});
- });
-
- it('sets detail issue to card issue on mouse up', () => {
- jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
-
mountComponent();
- wrapper.trigger('mousedown');
- wrapper.trigger('mouseup');
-
- expect(eventHub.$emit).toHaveBeenCalledWith('newDetailIssue', wrapper.vm.issue, false);
- expect(boardsStore.detail.list).toEqual(wrapper.vm.list);
+ expect(wrapper.classes()).toContain('is-active');
+ expect(wrapper.classes()).not.toContain('multi-select');
});
- it('resets detail issue to empty if already set', () => {
- jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
- const [issue] = list.issues;
- boardsStore.detail.issue = issue;
+ it('should highlight the card with a correct style when multi-selected', async () => {
+ createStore({
+ initialState: {
+ activeId: inactiveId,
+ selectedBoardItems: [mockIssue],
+ },
+ isSwimlanesOn,
+ });
mountComponent();
- wrapper.trigger('mousedown');
- wrapper.trigger('mouseup');
-
- expect(eventHub.$emit).toHaveBeenCalledWith('clearDetailIssue', false);
+ expect(wrapper.classes()).toContain('multi-select');
+ expect(wrapper.classes()).not.toContain('is-active');
});
- });
-
- describe('sidebarHub events', () => {
- it('closes all sidebars before showing an issue if no issues are opened', () => {
- jest.spyOn(sidebarEventHub, '$emit').mockImplementation(() => {});
- boardsStore.detail.issue = {};
- mountComponent();
-
- // sets conditional so that event is emitted.
- wrapper.trigger('mousedown');
- wrapper.trigger('mouseup');
+ describe('when mouseup event is called on the issue card', () => {
+ beforeEach(() => {
+ createStore({ isSwimlanesOn });
+ mountComponent();
+ });
- expect(sidebarEventHub.$emit).toHaveBeenCalledWith('sidebar.closeAll');
- });
+ describe('when not using multi-select', () => {
+ it('should call vuex action "toggleBoardItem" with correct parameters', async () => {
+ await selectCard();
- it('it does not closes all sidebars before showing an issue if an issue is opened', () => {
- jest.spyOn(sidebarEventHub, '$emit').mockImplementation(() => {});
- const [issue] = list.issues;
- boardsStore.detail.issue = issue;
- mountComponent();
+ expect(mockActions.toggleBoardItem).toHaveBeenCalledTimes(1);
+ expect(mockActions.toggleBoardItem).toHaveBeenCalledWith(expect.any(Object), {
+ boardItem: mockIssue,
+ });
+ });
+ });
- wrapper.trigger('mousedown');
+ describe('when using multi-select', () => {
+ it('should call vuex action "multiSelectBoardItem" with correct parameters', async () => {
+ await multiSelectCard();
- expect(sidebarEventHub.$emit).not.toHaveBeenCalledWith('sidebar.closeAll');
+ expect(mockActions.toggleBoardItemMultiSelection).toHaveBeenCalledTimes(1);
+ expect(mockActions.toggleBoardItemMultiSelection).toHaveBeenCalledWith(
+ expect.any(Object),
+ mockIssue,
+ );
+ });
+ });
});
});
});
diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js
index 7f8acd68ff6..c59e5876346 100644
--- a/spec/frontend/boards/stores/actions_spec.js
+++ b/spec/frontend/boards/stores/actions_spec.js
@@ -5,7 +5,7 @@ import {
formatBoardLists,
formatIssueInput,
} from '~/boards/boards_util';
-import { inactiveId } from '~/boards/constants';
+import { inactiveId, ISSUABLE } from '~/boards/constants';
import destroyBoardListMutation from '~/boards/graphql/board_list_destroy.mutation.graphql';
import issueCreateMutation from '~/boards/graphql/issue_create.mutation.graphql';
import issueMoveListMutation from '~/boards/graphql/issue_move_list.mutation.graphql';
@@ -1246,6 +1246,7 @@ describe('setSelectedProject', () => {
describe('toggleBoardItemMultiSelection', () => {
const boardItem = mockIssue;
+ const boardItem2 = mockIssue2;
it('should commit mutation ADD_BOARD_ITEM_TO_SELECTION if item is not on selection state', () => {
testAction(
@@ -1276,6 +1277,66 @@ describe('toggleBoardItemMultiSelection', () => {
[],
);
});
+
+ it('should additionally commit mutation ADD_BOARD_ITEM_TO_SELECTION for active issue and dispatch unsetActiveId', () => {
+ testAction(
+ actions.toggleBoardItemMultiSelection,
+ boardItem2,
+ { activeId: mockActiveIssue.id, activeIssue: mockActiveIssue, selectedBoardItems: [] },
+ [
+ {
+ type: types.ADD_BOARD_ITEM_TO_SELECTION,
+ payload: mockActiveIssue,
+ },
+ {
+ type: types.ADD_BOARD_ITEM_TO_SELECTION,
+ payload: boardItem2,
+ },
+ ],
+ [{ type: 'unsetActiveId' }],
+ );
+ });
+});
+
+describe('resetBoardItemMultiSelection', () => {
+ it('should commit mutation RESET_BOARD_ITEM_SELECTION', () => {
+ testAction({
+ action: actions.resetBoardItemMultiSelection,
+ state: { selectedBoardItems: [mockIssue] },
+ expectedMutations: [
+ {
+ type: types.RESET_BOARD_ITEM_SELECTION,
+ },
+ ],
+ });
+ });
+});
+
+describe('toggleBoardItem', () => {
+ it('should dispatch resetBoardItemMultiSelection and unsetActiveId when boardItem is the active item', () => {
+ testAction({
+ action: actions.toggleBoardItem,
+ payload: { boardItem: mockIssue },
+ state: {
+ activeId: mockIssue.id,
+ },
+ expectedActions: [{ type: 'resetBoardItemMultiSelection' }, { type: 'unsetActiveId' }],
+ });
+ });
+
+ it('should dispatch resetBoardItemMultiSelection and setActiveId when boardItem is not the active item', () => {
+ testAction({
+ action: actions.toggleBoardItem,
+ payload: { boardItem: mockIssue },
+ state: {
+ activeId: inactiveId,
+ },
+ expectedActions: [
+ { type: 'resetBoardItemMultiSelection' },
+ { type: 'setActiveId', payload: { id: mockIssue.id, sidebarType: ISSUABLE } },
+ ],
+ });
+ });
});
describe('fetchBacklog', () => {
diff --git a/spec/frontend/boards/stores/mutations_spec.js b/spec/frontend/boards/stores/mutations_spec.js
index 01211fa02e9..e4dd368889a 100644
--- a/spec/frontend/boards/stores/mutations_spec.js
+++ b/spec/frontend/boards/stores/mutations_spec.js
@@ -610,14 +610,21 @@ describe('Board Store Mutations', () => {
describe('REMOVE_BOARD_ITEM_FROM_SELECTION', () => {
it('Should remove boardItem to selectedBoardItems state', () => {
- state = {
- ...state,
- selectedBoardItems: [mockIssue],
- };
+ state.selectedBoardItems = [mockIssue];
mutations[types.REMOVE_BOARD_ITEM_FROM_SELECTION](state, mockIssue);
expect(state.selectedBoardItems).toEqual([]);
});
});
+
+ describe('RESET_BOARD_ITEM_SELECTION', () => {
+ it('Should reset selectedBoardItems state', () => {
+ state.selectedBoardItems = [mockIssue];
+
+ mutations[types.RESET_BOARD_ITEM_SELECTION](state, mockIssue);
+
+ expect(state.selectedBoardItems).toEqual([]);
+ });
+ });
});
diff --git a/spec/graphql/mutations/custom_emoji/create_spec.rb b/spec/graphql/mutations/custom_emoji/create_spec.rb
new file mode 100644
index 00000000000..118c5d67188
--- /dev/null
+++ b/spec/graphql/mutations/custom_emoji/create_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::CustomEmoji::Create do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:user) { create(:user) }
+ let(:args) { { group_path: group.full_path, name: 'tanuki', url: 'https://about.gitlab.com/images/press/logo/png/gitlab-icon-rgb.png' } }
+
+ before do
+ group.add_developer(user)
+ end
+
+ describe '#resolve' do
+ subject(:resolve) { described_class.new(object: nil, context: { current_user: user }, field: nil).resolve(**args) }
+
+ it 'creates the custom emoji' do
+ expect { resolve }.to change(CustomEmoji, :count).by(1)
+ end
+
+ it 'sets the creator to be the user who added the emoji' do
+ resolve
+
+ expect(CustomEmoji.last.creator).to eq(user)
+ end
+ end
+end
diff --git a/spec/models/custom_emoji_spec.rb b/spec/models/custom_emoji_spec.rb
index 41ce480b02f..e34934d393a 100644
--- a/spec/models/custom_emoji_spec.rb
+++ b/spec/models/custom_emoji_spec.rb
@@ -4,8 +4,10 @@ require 'spec_helper'
RSpec.describe CustomEmoji do
describe 'Associations' do
- it { is_expected.to belong_to(:namespace) }
+ it { is_expected.to belong_to(:namespace).inverse_of(:custom_emoji) }
+ it { is_expected.to belong_to(:creator).inverse_of(:created_custom_emoji) }
it { is_expected.to have_db_column(:file) }
+ it { is_expected.to validate_presence_of(:creator) }
it { is_expected.to validate_length_of(:name).is_at_most(36) }
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to have_db_column(:external) }
@@ -36,7 +38,7 @@ RSpec.describe CustomEmoji do
new_emoji = build(:custom_emoji, name: old_emoji.name, namespace: old_emoji.namespace, group: group)
expect(new_emoji).not_to be_valid
- expect(new_emoji.errors.messages).to eq(name: ["has already been taken"])
+ expect(new_emoji.errors.messages).to include(name: ["has already been taken"])
end
it 'disallows non http and https file value' do
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 860c015e166..084cfb55de0 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -101,6 +101,7 @@ RSpec.describe User do
it { is_expected.to have_many(:reviews).inverse_of(:author) }
it { is_expected.to have_many(:merge_request_assignees).inverse_of(:assignee) }
it { is_expected.to have_many(:merge_request_reviewers).inverse_of(:reviewer) }
+ it { is_expected.to have_many(:created_custom_emoji).inverse_of(:creator) }
describe "#user_detail" do
it 'does not persist `user_detail` by default' do