diff options
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 |