diff options
50 files changed, 745 insertions, 198 deletions
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 6e89702d316..02fe0a51d0b 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -b9703797a48c5cff1adf84a152c4024cff9b35ed +47f676eea28871563414671e1016fb28b1b3e167 diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js index 652f8a8a107..5c8df94ca90 100644 --- a/app/assets/javascripts/boards/boards_util.js +++ b/app/assets/javascripts/boards/boards_util.js @@ -1,17 +1,28 @@ +import { sortBy } from 'lodash'; import ListIssue from 'ee_else_ce/boards/models/issue'; +import { ListType } from './constants'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; export function getMilestone() { return null; } +export function formatIssue(issue) { + return new ListIssue({ + ...issue, + labels: issue.labels?.nodes || [], + assignees: issue.assignees?.nodes || [], + }); +} + export function formatListIssues(listIssues) { const issues = {}; const listData = listIssues.nodes.reduce((map, list) => { + const sortedIssues = sortBy(list.issues.nodes, 'relativePosition'); return { ...map, - [list.id]: list.issues.nodes.map(i => { + [list.id]: sortedIssues.map(i => { const id = getIdFromGraphQLId(i.id); const listIssue = new ListIssue({ @@ -35,7 +46,27 @@ export function fullBoardId(boardId) { return `gid://gitlab/Board/${boardId}`; } +export function moveIssueListHelper(issue, fromList, toList) { + if (toList.type === ListType.label) { + issue.addLabel(toList.label); + } + if (fromList && fromList.type === ListType.label) { + issue.removeLabel(fromList.label); + } + + if (toList.type === ListType.assignee) { + issue.addAssignee(toList.assignee); + } + if (fromList && fromList.type === ListType.assignee) { + issue.removeAssignee(fromList.assignee); + } + + return issue; +} + export default { getMilestone, + formatIssue, formatListIssues, + fullBoardId, }; diff --git a/app/assets/javascripts/boards/components/board_card_layout.vue b/app/assets/javascripts/boards/components/board_card_layout.vue index f3ce25b4696..e3b57d5a7f7 100644 --- a/app/assets/javascripts/boards/components/board_card_layout.vue +++ b/app/assets/javascripts/boards/components/board_card_layout.vue @@ -95,6 +95,8 @@ export default { }" :index="index" :data-issue-id="issue.id" + :data-issue-iid="issue.iid" + :data-issue-path="issue.referencePath" data-testid="board_card" class="board-card p-3 rounded" @mousedown="mouseDown" diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js index 98eac35b2ed..822e6d62ab3 100644 --- a/app/assets/javascripts/boards/models/issue.js +++ b/app/assets/javascripts/boards/models/issue.js @@ -15,7 +15,7 @@ class ListIssue { this.labels = []; this.assignees = []; this.selected = false; - this.position = obj.position || obj.relative_position || Infinity; + this.position = obj.position || obj.relative_position || obj.relativePosition || Infinity; this.isFetching = { subscriptions: true, }; diff --git a/app/assets/javascripts/boards/queries/issue.fragment.graphql b/app/assets/javascripts/boards/queries/issue.fragment.graphql index 89d56b895a4..21b52766190 100644 --- a/app/assets/javascripts/boards/queries/issue.fragment.graphql +++ b/app/assets/javascripts/boards/queries/issue.fragment.graphql @@ -12,6 +12,7 @@ fragment IssueNode on Issue { webUrl subscribed blocked + relativePosition epic { id } diff --git a/app/assets/javascripts/boards/queries/issue_move_list.mutation.graphql b/app/assets/javascripts/boards/queries/issue_move_list.mutation.graphql new file mode 100644 index 00000000000..1b5f9564e59 --- /dev/null +++ b/app/assets/javascripts/boards/queries/issue_move_list.mutation.graphql @@ -0,0 +1,28 @@ +#import "./issue.fragment.graphql" + +mutation IssueMoveList( + $projectPath: ID! + $iid: String! + $boardId: ID! + $fromListId: ID + $toListId: ID + $moveBeforeId: ID + $moveAfterId: ID +) { + issueMoveList( + input: { + projectPath: $projectPath + iid: $iid + boardId: $boardId + fromListId: $fromListId + toListId: $toListId + moveBeforeId: $moveBeforeId + moveAfterId: $moveAfterId + } + ) { + issue { + ...IssueNode + } + errors + } +} diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index 8d9b58f71cb..f8767211abd 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -15,6 +15,7 @@ import projectBoardQuery from '../queries/project_board.query.graphql'; import groupBoardQuery from '../queries/group_board.query.graphql'; import createBoardListMutation from '../queries/board_list_create.mutation.graphql'; import updateBoardListMutation from '../queries/board_list_update.mutation.graphql'; +import issueMoveListMutation from '../queries/issue_move_list.mutation.graphql'; const notImplemented = () => { /* eslint-disable-next-line @gitlab/require-i18n-strings */ @@ -227,8 +228,42 @@ export default { .catch(() => commit(types.RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE)); }, - moveIssue: () => { - notImplemented(); + moveIssue: ( + { state, commit }, + { issueId, issueIid, issuePath, fromListId, toListId, moveBeforeId, moveAfterId }, + ) => { + const originalIssue = state.issues[issueId]; + const fromList = state.issuesByListId[fromListId]; + const originalIndex = fromList.indexOf(Number(issueId)); + commit(types.MOVE_ISSUE, { originalIssue, fromListId, toListId, moveBeforeId, moveAfterId }); + + const { boardId } = state.endpoints; + const [groupPath, project] = issuePath.split(/[/#]/); + + gqlClient + .mutate({ + mutation: issueMoveListMutation, + variables: { + projectPath: `${groupPath}/${project}`, + boardId: fullBoardId(boardId), + iid: issueIid, + fromListId: getIdFromGraphQLId(fromListId), + toListId: getIdFromGraphQLId(toListId), + moveBeforeId, + moveAfterId, + }, + }) + .then(({ data }) => { + if (data?.issueMoveList?.errors.length) { + commit(types.MOVE_ISSUE_FAILURE, { originalIssue, fromListId, toListId, originalIndex }); + } else { + const issue = data.issueMoveList?.issue; + commit(types.MOVE_ISSUE_SUCCESS, { issue }); + } + }) + .catch(() => + commit(types.MOVE_ISSUE_FAILURE, { originalIssue, fromListId, toListId, originalIndex }), + ); }, createNewIssue: () => { diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js index a3b84108cb3..5a3d62dc703 100644 --- a/app/assets/javascripts/boards/stores/mutation_types.js +++ b/app/assets/javascripts/boards/stores/mutation_types.js @@ -18,9 +18,9 @@ export const RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE = 'RECEIVE_ISSUES_FOR_ALL_LIST export const REQUEST_ADD_ISSUE = 'REQUEST_ADD_ISSUE'; export const RECEIVE_ADD_ISSUE_SUCCESS = 'RECEIVE_ADD_ISSUE_SUCCESS'; export const RECEIVE_ADD_ISSUE_ERROR = 'RECEIVE_ADD_ISSUE_ERROR'; -export const REQUEST_MOVE_ISSUE = 'REQUEST_MOVE_ISSUE'; -export const RECEIVE_MOVE_ISSUE_SUCCESS = 'RECEIVE_MOVE_ISSUE_SUCCESS'; -export const RECEIVE_MOVE_ISSUE_ERROR = 'RECEIVE_MOVE_ISSUE_ERROR'; +export const MOVE_ISSUE = 'MOVE_ISSUE'; +export const MOVE_ISSUE_SUCCESS = 'MOVE_ISSUE_SUCCESS'; +export const MOVE_ISSUE_FAILURE = 'MOVE_ISSUE_FAILURE'; export const REQUEST_UPDATE_ISSUE = 'REQUEST_UPDATE_ISSUE'; export const RECEIVE_UPDATE_ISSUE_SUCCESS = 'RECEIVE_UPDATE_ISSUE_SUCCESS'; export const RECEIVE_UPDATE_ISSUE_ERROR = 'RECEIVE_UPDATE_ISSUE_ERROR'; diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index f25c339836f..a03c541ada5 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -1,7 +1,9 @@ import Vue from 'vue'; import { sortBy, pull } from 'lodash'; +import { formatIssue, moveIssueListHelper } from '../boards_util'; import * as mutationTypes from './mutation_types'; import { __ } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; const notImplemented = () => { /* eslint-disable-next-line @gitlab/require-i18n-strings */ @@ -12,6 +14,18 @@ const removeIssueFromList = (state, listId, issueId) => { Vue.set(state.issuesByListId, listId, pull(state.issuesByListId[listId], issueId)); }; +const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfterId, atIndex }) => { + const listIssues = state.issuesByListId[listId]; + let newIndex = atIndex || 0; + if (moveBeforeId) { + newIndex = listIssues.indexOf(moveBeforeId) + 1; + } else if (moveAfterId) { + newIndex = listIssues.indexOf(moveAfterId); + } + listIssues.splice(newIndex, 0, issueId); + Vue.set(state.issuesByListId, listId, listIssues); +}; + export default { [mutationTypes.SET_INITIAL_BOARD_DATA](state, data) { const { boardType, disabled, showPromotion, ...endpoints } = data; @@ -111,16 +125,38 @@ export default { notImplemented(); }, - [mutationTypes.REQUEST_MOVE_ISSUE]: () => { - notImplemented(); + [mutationTypes.MOVE_ISSUE]: ( + state, + { originalIssue, fromListId, toListId, moveBeforeId, moveAfterId }, + ) => { + const fromList = state.boardLists.find(l => l.id === fromListId); + const toList = state.boardLists.find(l => l.id === toListId); + + const issue = moveIssueListHelper(originalIssue, fromList, toList); + Vue.set(state.issues, issue.id, issue); + + removeIssueFromList(state, fromListId, issue.id); + addIssueToList({ state, listId: toListId, issueId: issue.id, moveBeforeId, moveAfterId }); }, - [mutationTypes.RECEIVE_MOVE_ISSUE_SUCCESS]: () => { - notImplemented(); + [mutationTypes.MOVE_ISSUE_SUCCESS]: (state, { issue }) => { + const issueId = getIdFromGraphQLId(issue.id); + Vue.set(state.issues, issueId, formatIssue({ ...issue, id: issueId })); }, - [mutationTypes.RECEIVE_MOVE_ISSUE_ERROR]: () => { - notImplemented(); + [mutationTypes.MOVE_ISSUE_FAILURE]: ( + state, + { originalIssue, fromListId, toListId, originalIndex }, + ) => { + state.error = __('An error occurred while moving the issue. Please try again.'); + Vue.set(state.issues, originalIssue.id, originalIssue); + removeIssueFromList(state, toListId, originalIssue.id); + addIssueToList({ + state, + listId: fromListId, + issueId: originalIssue.id, + atIndex: originalIndex, + }); }, [mutationTypes.REQUEST_UPDATE_ISSUE]: () => { diff --git a/app/assets/javascripts/design_management/graphql/fragments/design_list.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/design_list.fragment.graphql index bc3132f9b42..9bd70e7e886 100644 --- a/app/assets/javascripts/design_management/graphql/fragments/design_list.fragment.graphql +++ b/app/assets/javascripts/design_management/graphql/fragments/design_list.fragment.graphql @@ -5,4 +5,9 @@ fragment DesignListItem on Design { notesCount image imageV432x230 + currentUserTodos(state: pending) { + nodes { + id + } + } } diff --git a/app/assets/javascripts/design_management/utils/cache_update.js b/app/assets/javascripts/design_management/utils/cache_update.js index 2ffbae8afe0..ff41136fd54 100644 --- a/app/assets/javascripts/design_management/utils/cache_update.js +++ b/app/assets/javascripts/design_management/utils/cache_update.js @@ -190,25 +190,44 @@ const moveDesignInStore = (store, designManagementMove, query) => { }; export const addPendingTodoToStore = (store, pendingTodo, query, queryVariables) => { - const data = store.readQuery({ + const sourceData = store.readQuery({ query, variables: queryVariables, }); - // TODO produce new version of data that includes the new pendingTodo. - // This is only possible after BE MR: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40555 + const data = produce(sourceData, draftData => { + const design = extractDesign(draftData); + const existingTodos = design.currentUserTodos?.nodes || []; + const newTodoNodes = [...existingTodos, { ...pendingTodo, __typename: 'Todo' }]; + + if (!design.currentUserTodos) { + design.currentUserTodos = { + __typename: 'TodoConnection', + nodes: newTodoNodes, + }; + } else { + design.currentUserTodos.nodes = newTodoNodes; + } + }); store.writeQuery({ query, variables: queryVariables, data }); }; -export const deletePendingTodoFromStore = (store, pendingTodo, query, queryVariables) => { - const data = store.readQuery({ +export const deletePendingTodoFromStore = (store, todoMarkDone, query, queryVariables) => { + const sourceData = store.readQuery({ query, variables: queryVariables, }); - // TODO produce new version of data without the pendingTodo. - // This is only possible after BE MR: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40555 + const { + todo: { id: todoId }, + } = todoMarkDone; + const data = produce(sourceData, draftData => { + const design = extractDesign(draftData); + const existingTodos = design.currentUserTodos?.nodes || []; + + design.currentUserTodos.nodes = existingTodos.filter(({ id }) => id !== todoId); + }); store.writeQuery({ query, variables: queryVariables, data }); }; diff --git a/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue b/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue index c14ea7bd1d2..b92fc8d125d 100644 --- a/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue +++ b/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue @@ -1,12 +1,11 @@ <script> -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlSafeHtmlDirective as SafeHtml, GlModal } from '@gitlab/ui'; import { escape } from 'lodash'; -import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; -import { s__, sprintf } from '~/locale'; +import { __, s__, sprintf } from '~/locale'; export default { components: { - DeprecatedModal, + GlModal, }, directives: { SafeHtml, @@ -66,51 +65,57 @@ export default { false, ); }, - primaryButtonLabel() { - return s__('AdminProjects|Delete project'); - }, canSubmit() { return this.enteredProjectName === this.projectName; }, + primaryProps() { + return { + text: s__('Delete project'), + attributes: [{ variant: 'danger' }, { category: 'primary' }, { disabled: !this.canSubmit }], + }; + }, }, methods: { onCancel() { this.enteredProjectName = ''; }, onSubmit() { + if (!this.canSubmit) { + return; + } this.$refs.form.submit(); this.enteredProjectName = ''; }, }, + cancelProps: { + text: __('Cancel'), + }, }; </script> <template> - <deprecated-modal - id="delete-project-modal" + <gl-modal + modal-id="delete-project-modal" :title="title" - :text="text" - :primary-button-label="primaryButtonLabel" - :submit-disabled="!canSubmit" - kind="danger" - @submit="onSubmit" + :action-primary="primaryProps" + :action-cancel="$options.cancelProps" + :ok-disabled="!canSubmit" + @primary="onSubmit" @cancel="onCancel" > - <template #body="props"> - <p v-safe-html="props.text"></p> - <p v-safe-html="confirmationTextLabel"></p> - <form ref="form" :action="deleteProjectUrl" method="post"> - <input ref="method" type="hidden" name="_method" value="delete" /> - <input :value="csrfToken" type="hidden" name="authenticity_token" /> - <input - v-model="enteredProjectName" - name="projectName" - class="form-control" - type="text" - aria-labelledby="input-label" - autocomplete="off" - /> - </form> - </template> - </deprecated-modal> + <p v-safe-html="text"></p> + <p v-safe-html="confirmationTextLabel"></p> + <form ref="form" :action="deleteProjectUrl" method="post"> + <input ref="method" type="hidden" name="_method" value="delete" /> + <input :value="csrfToken" type="hidden" name="authenticity_token" /> + <input + v-model="enteredProjectName" + name="projectName" + class="form-control" + type="text" + aria-labelledby="input-label" + autocomplete="off" + /> + </form> + </gl-modal> </template> diff --git a/app/assets/javascripts/pages/admin/projects/index/index.js b/app/assets/javascripts/pages/admin/projects/index/index.js index 6fa8760545d..ebb1a74e970 100644 --- a/app/assets/javascripts/pages/admin/projects/index/index.js +++ b/app/assets/javascripts/pages/admin/projects/index/index.js @@ -1,4 +1,3 @@ -import $ from 'jquery'; import Vue from 'vue'; import Translate from '~/vue_shared/translate'; @@ -17,6 +16,18 @@ document.addEventListener('DOMContentLoaded', () => { deleteProjectUrl: '', projectName: '', }, + mounted() { + const deleteProjectButtons = document.querySelectorAll('.delete-project-button'); + deleteProjectButtons.forEach(button => { + button.addEventListener('click', () => { + const buttonProps = button.dataset; + deleteModal.deleteProjectUrl = buttonProps.deleteProjectUrl; + deleteModal.projectName = buttonProps.projectName; + + this.$root.$emit('bv::show::modal', 'delete-project-modal'); + }); + }); + }, render(createElement) { return createElement(deleteProjectModal, { props: { @@ -27,12 +38,4 @@ document.addEventListener('DOMContentLoaded', () => { }); }, }); - - $(document).on('shown.bs.modal', event => { - if (event.relatedTarget.classList.contains('delete-project-button')) { - const buttonProps = event.relatedTarget.dataset; - deleteModal.deleteProjectUrl = buttonProps.deleteProjectUrl; - deleteModal.projectName = buttonProps.projectName; - } - }); }); diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue index 32b0121f6da..434aabc3df9 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue @@ -28,21 +28,24 @@ export default { <template> <div class="dropdown-page-two dropdown-new-label"> - <div class="dropdown-title"> + <div + class="dropdown-title gl-display-flex gl-justify-content-space-between gl-align-items-center" + > <gl-button :aria-label="__('Go back')" category="tertiary" - class="dropdown-title-button dropdown-menu-back" + class="dropdown-menu-back" icon="arrow-left" + size="small" /> {{ headerTitle }} - <button + <gl-button :aria-label="__('Close')" - type="button" - class="dropdown-title-button dropdown-menu-close" - > - <i aria-hidden="true" class="fa fa-times dropdown-menu-close-icon" data-hidden="true"> </i> - </button> + category="tertiary" + class="dropdown-menu-close" + icon="close" + size="small" + /> </div> <div class="dropdown-content"> <div class="dropdown-labels-error js-label-error"></div> @@ -77,12 +80,12 @@ export default { /> </div> <div class="clearfix"> - <button type="button" class="btn btn-primary float-left js-new-label-btn disabled"> + <gl-button category="secondary" class="float-left js-new-label-btn disabled"> {{ __('Create') }} - </button> - <button type="button" class="btn btn-default float-right js-cancel-label-btn"> + </gl-button> + <gl-button category="secondary" class="float-right js-cancel-label-btn"> {{ __('Cancel') }} - </button> + </gl-button> </div> </div> </div> diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss index b97709e140f..68d9694905a 100644 --- a/app/assets/stylesheets/pages/cycle_analytics.scss +++ b/app/assets/stylesheets/pages/cycle_analytics.scss @@ -360,23 +360,3 @@ } } } - -.cycle-analytics-overview { - padding-top: 100px; - - .overview-details { - display: flex; - align-items: center; - } - - .overview-image { - text-align: right; - } - - .overview-icon { - svg { - width: 365px; - height: 227px; - } - } -} diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 9dbe1ab9214..bfb464f1ff2 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -119,11 +119,11 @@ .assignee { .merge-icon { - color: $orange-500; + color: $orange-400; position: absolute; bottom: 0; right: 0; - text-shadow: -1px -1px 0 $white, 1px -1px 0 $white, -1px 1px 0 $white, 1px 1px 0 $white; + text-shadow: -1px -1px 2px $white, 1px -1px 2px $white, -1px 1px 2px $white, 1px 1px 2px $white; } } diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index c4dbce00593..a1a2740cde2 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -9,7 +9,7 @@ module IssuableActions before_action :check_destroy_confirmation!, only: :destroy before_action :authorize_admin_issuable!, only: :bulk_update before_action only: :show do - push_frontend_feature_flag(:scoped_labels, default_enabled: true) + push_frontend_feature_flag(:scoped_labels, type: :licensed, default_enabled: true) end before_action do push_frontend_feature_flag(:not_issuable_queries, @project, default_enabled: true) diff --git a/app/models/ci/build_pending_state.rb b/app/models/ci/build_pending_state.rb new file mode 100644 index 00000000000..1ca81537694 --- /dev/null +++ b/app/models/ci/build_pending_state.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Ci::BuildPendingState < ApplicationRecord + extend Gitlab::Ci::Model + + belongs_to :build, class_name: 'Ci::Build', foreign_key: :build_id + + validates :build, presence: true +end diff --git a/app/models/clusters/instance.rb b/app/models/clusters/instance.rb index 8c9d9ab9ab1..94fadace01c 100644 --- a/app/models/clusters/instance.rb +++ b/app/models/clusters/instance.rb @@ -7,7 +7,7 @@ module Clusters end def feature_available?(feature) - ::Feature.enabled?(feature, default_enabled: true) + ::Feature.enabled?(feature, type: :licensed, default_enabled: true) end def flipper_id diff --git a/app/views/admin/projects/_projects.html.haml b/app/views/admin/projects/_projects.html.haml index f842ab2d009..44317eb7f6e 100644 --- a/app/views/admin/projects/_projects.html.haml +++ b/app/views/admin/projects/_projects.html.haml @@ -5,10 +5,7 @@ %li.project-row{ class: ('no-description' if project.description.blank?) } .controls = link_to 'Edit', edit_project_path(project), id: "edit_#{dom_id(project)}", class: "btn" - %button.delete-project-button.btn.btn-danger{ data: { toggle: 'modal', - target: '#delete-project-modal', - delete_project_url: admin_project_path(project), - project_name: project.name }, type: 'button' } + %button.delete-project-button.btn.btn-danger{ data: { delete_project_url: admin_project_path(project), project_name: project.name } } = s_('AdminProjects|Delete') .stats diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml index 22c2be3b7da..dcc6cba8444 100644 --- a/app/views/layouts/header/_current_user_dropdown.html.haml +++ b/app/views/layouts/header/_current_user_dropdown.html.haml @@ -39,6 +39,8 @@ = link_to _("Help"), help_path %li.d-md-none = link_to _("Support"), support_url + %li.d-md-none + = render 'shared/help_dropdown_forum_link' %li.d-md-none = link_to _("Submit feedback"), "https://about.gitlab.com/submit-feedback" - if current_user_menu?(:help) || current_user_menu?(:settings) || current_user_menu?(:profile) diff --git a/app/views/layouts/header/_help_dropdown.html.haml b/app/views/layouts/header/_help_dropdown.html.haml index ad4e0f1f4b2..2b6cbc1c0ef 100644 --- a/app/views/layouts/header/_help_dropdown.html.haml +++ b/app/views/layouts/header/_help_dropdown.html.haml @@ -6,6 +6,8 @@ %li = link_to _("Support"), support_url %li + = render 'shared/help_dropdown_forum_link' + %li %button.js-shortcuts-modal-trigger{ type: "button" } = _("Keyboard shortcuts") %span.text-secondary.float-right{ "aria-hidden": true }= '?'.html_safe diff --git a/app/views/projects/cycle_analytics/_overview.html.haml b/app/views/projects/cycle_analytics/_overview.html.haml deleted file mode 100644 index 2ca72b141be..00000000000 --- a/app/views/projects/cycle_analytics/_overview.html.haml +++ /dev/null @@ -1,15 +0,0 @@ -.cycle-analytics-overview - .container - .row - .col-md-10.offset-md-1 - .row.overview-details - .col-md-6.overview-text - %h4 Introducing Value Stream Analytics - %p - Value Stream Analytics (VSA) gives an overview of how much time it takes to go from idea to production in your project. - To set up VSA, you must first define a production environment by setting up your CI and then deploy to production. - %p - %a.btn{ href: help_page_path('user/analytics/value_stream_analytics.md'), target: '_blank' } Read more - .col-md-6.overview-image - %span.overview-icon - = custom_icon ('icon_cycle_analytics_overview') diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml index 2f55cce70dc..5e72db22667 100644 --- a/app/views/projects/mirrors/_mirror_repos.html.haml +++ b/app/views/projects/mirrors/_mirror_repos.html.haml @@ -74,4 +74,4 @@ - if mirror.ssh_key_auth? = clipboard_button(text: mirror.ssh_public_key, class: 'btn btn-default', title: _('Copy SSH public key'), qa_selector: 'copy_public_key_button') = render 'shared/remote_mirror_update_button', remote_mirror: mirror - %button.js-delete-mirror.qa-delete-mirror.rspec-delete-mirror.btn.btn-danger{ type: 'button', data: { mirror_id: mirror.id, toggle: 'tooltip', container: 'body' }, title: _('Remove') }= icon('trash-o') + %button.js-delete-mirror.qa-delete-mirror.rspec-delete-mirror.btn.btn-danger{ type: 'button', data: { mirror_id: mirror.id, toggle: 'tooltip', container: 'body' }, title: _('Remove') }= sprite_icon('remove') diff --git a/app/views/shared/_help_dropdown_forum_link.html.haml b/app/views/shared/_help_dropdown_forum_link.html.haml new file mode 100644 index 00000000000..351c875475a --- /dev/null +++ b/app/views/shared/_help_dropdown_forum_link.html.haml @@ -0,0 +1,2 @@ += link_to _("Community forum"), "https://forum.gitlab.com/", target: '_blank', class: 'text-nowrap', + rel: 'noopener noreferrer', data: { 'track_event': 'click_forum', 'track_property': 'question_menu' } diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml index da665f17975..97ed2852871 100644 --- a/app/views/shared/notes/_note.html.haml +++ b/app/views/shared/notes/_note.html.haml @@ -76,4 +76,4 @@ = note.attachment_identifier = link_to delete_attachment_project_note_path(note.project, note), title: _('Delete this attachment'), method: :delete, remote: true, data: { confirm: _('Are you sure you want to remove the attachment?') }, class: 'danger js-note-attachment-delete' do - = icon('trash-o', class: 'cred') + = sprite_icon('remove', css_class: 'cred') diff --git a/bin/feature-flag b/bin/feature-flag index ba86df9c06c..c01fb67131b 100755 --- a/bin/feature-flag +++ b/bin/feature-flag @@ -181,6 +181,10 @@ class FeatureFlagOptionParser $stderr.puts "URL needs to start with https://" end end + + def read_default_enabled(options) + TYPES.dig(options.type, :default_enabled) + end end end @@ -226,7 +230,7 @@ class FeatureFlagCreator 'rollout_issue_url' => options.rollout_issue_url, 'group' => options.group.to_s, 'type' => options.type.to_s, - 'default_enabled' => false + 'default_enabled' => FeatureFlagOptionParser.read_default_enabled(options) ).strip end diff --git a/changelogs/unreleased/225950-replace-fa-trash-o-with-gitlab-svg-remove.yml b/changelogs/unreleased/225950-replace-fa-trash-o-with-gitlab-svg-remove.yml new file mode 100644 index 00000000000..90086279c28 --- /dev/null +++ b/changelogs/unreleased/225950-replace-fa-trash-o-with-gitlab-svg-remove.yml @@ -0,0 +1,5 @@ +--- +title: Replace fa-trash-o icons with GitLab SVG remove icon +merge_request: 41748 +author: +type: changed diff --git a/changelogs/unreleased/feature-gb-add-ci-build-state-table.yml b/changelogs/unreleased/feature-gb-add-ci-build-state-table.yml new file mode 100644 index 00000000000..9db08697bcf --- /dev/null +++ b/changelogs/unreleased/feature-gb-add-ci-build-state-table.yml @@ -0,0 +1,5 @@ +--- +title: Introduce build states table / model / migration +merge_request: 41585 +author: +type: added diff --git a/changelogs/unreleased/retention-add-forum-link-to-help-menu.yml b/changelogs/unreleased/retention-add-forum-link-to-help-menu.yml new file mode 100644 index 00000000000..7c0c38ca857 --- /dev/null +++ b/changelogs/unreleased/retention-add-forum-link-to-help-menu.yml @@ -0,0 +1,5 @@ +--- +title: Add forum link to help menu +merge_request: 41858 +author: +type: added diff --git a/db/migrate/20200904131544_create_ci_build_pending_states.rb b/db/migrate/20200904131544_create_ci_build_pending_states.rb new file mode 100644 index 00000000000..2c21ce3ce32 --- /dev/null +++ b/db/migrate/20200904131544_create_ci_build_pending_states.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class CreateCiBuildPendingStates < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + with_lock_retries do + create_table :ci_build_pending_states do |t| + t.timestamps_with_timezone + t.references :build, index: { unique: true }, null: false, foreign_key: { to_table: :ci_builds, on_delete: :cascade }, type: :bigint + t.integer :state + t.integer :failure_reason + t.binary :trace_checksum + end + end + end + + def down + with_lock_retries do + drop_table :ci_build_pending_states + end + end +end diff --git a/db/schema_migrations/20200904131544 b/db/schema_migrations/20200904131544 new file mode 100644 index 00000000000..62ff8ff56e7 --- /dev/null +++ b/db/schema_migrations/20200904131544 @@ -0,0 +1 @@ +08c9c6e5cd19aac17de7fc639eaca5ddba3e8280452821b8a72f05dcde790feb
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 3aa653cb5a2..686775a5b11 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -9826,6 +9826,25 @@ CREATE SEQUENCE public.ci_build_needs_id_seq ALTER SEQUENCE public.ci_build_needs_id_seq OWNED BY public.ci_build_needs.id; +CREATE TABLE public.ci_build_pending_states ( + id bigint NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + build_id bigint NOT NULL, + state integer, + failure_reason integer, + trace_checksum bytea +); + +CREATE SEQUENCE public.ci_build_pending_states_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.ci_build_pending_states_id_seq OWNED BY public.ci_build_pending_states.id; + CREATE TABLE public.ci_build_report_results ( build_id bigint NOT NULL, project_id bigint NOT NULL, @@ -16963,6 +16982,8 @@ ALTER TABLE ONLY public.chat_teams ALTER COLUMN id SET DEFAULT nextval('public.c ALTER TABLE ONLY public.ci_build_needs ALTER COLUMN id SET DEFAULT nextval('public.ci_build_needs_id_seq'::regclass); +ALTER TABLE ONLY public.ci_build_pending_states ALTER COLUMN id SET DEFAULT nextval('public.ci_build_pending_states_id_seq'::regclass); + ALTER TABLE ONLY public.ci_build_report_results ALTER COLUMN build_id SET DEFAULT nextval('public.ci_build_report_results_build_id_seq'::regclass); ALTER TABLE ONLY public.ci_build_trace_chunks ALTER COLUMN id SET DEFAULT nextval('public.ci_build_trace_chunks_id_seq'::regclass); @@ -17924,6 +17945,9 @@ ALTER TABLE public.merge_request_diffs ALTER TABLE ONLY public.ci_build_needs ADD CONSTRAINT ci_build_needs_pkey PRIMARY KEY (id); +ALTER TABLE ONLY public.ci_build_pending_states + ADD CONSTRAINT ci_build_pending_states_pkey PRIMARY KEY (id); + ALTER TABLE ONLY public.ci_build_report_results ADD CONSTRAINT ci_build_report_results_pkey PRIMARY KEY (build_id); @@ -19370,6 +19394,8 @@ CREATE UNIQUE INDEX index_chat_teams_on_namespace_id ON public.chat_teams USING CREATE UNIQUE INDEX index_ci_build_needs_on_build_id_and_name ON public.ci_build_needs USING btree (build_id, name); +CREATE UNIQUE INDEX index_ci_build_pending_states_on_build_id ON public.ci_build_pending_states USING btree (build_id); + CREATE INDEX index_ci_build_report_results_on_project_id ON public.ci_build_report_results USING btree (project_id); CREATE UNIQUE INDEX index_ci_build_trace_chunks_on_build_id_and_chunk_index ON public.ci_build_trace_chunks USING btree (build_id, chunk_index); @@ -22285,6 +22311,9 @@ ALTER TABLE ONLY public.project_deploy_tokens ALTER TABLE ONLY public.packages_conan_file_metadata ADD CONSTRAINT fk_rails_0afabd9328 FOREIGN KEY (package_file_id) REFERENCES public.packages_package_files(id) ON DELETE CASCADE; +ALTER TABLE ONLY public.ci_build_pending_states + ADD CONSTRAINT fk_rails_0bbbfeaf9d FOREIGN KEY (build_id) REFERENCES public.ci_builds(id) ON DELETE CASCADE; + ALTER TABLE ONLY public.operations_user_lists ADD CONSTRAINT fk_rails_0c716e079b FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE; diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index 6f3897d1574..18d35c3e114 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -3048,7 +3048,7 @@ type DastScannerProfile { profileName: String """ - The maximum number of seconds allowed for the spider to traverse the site + The maximum number of minutes allowed for the spider to traverse the site """ spiderTimeout: Int @@ -3098,7 +3098,7 @@ input DastScannerProfileCreateInput { profileName: String! """ - The maximum number of seconds allowed for the spider to traverse the site. + The maximum number of minutes allowed for the spider to traverse the site. """ spiderTimeout: Int @@ -3213,7 +3213,7 @@ input DastScannerProfileUpdateInput { profileName: String! """ - The maximum number of seconds allowed for the spider to traverse the site. + The maximum number of minutes allowed for the spider to traverse the site. """ spiderTimeout: Int! diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index 44405c21f6a..e28c0201e7e 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -8289,7 +8289,7 @@ }, { "name": "spiderTimeout", - "description": "The maximum number of seconds allowed for the spider to traverse the site", + "description": "The maximum number of minutes allowed for the spider to traverse the site", "args": [ ], @@ -8426,7 +8426,7 @@ }, { "name": "spiderTimeout", - "description": "The maximum number of seconds allowed for the spider to traverse the site.", + "description": "The maximum number of minutes allowed for the spider to traverse the site.", "type": { "kind": "SCALAR", "name": "Int", @@ -8747,7 +8747,7 @@ }, { "name": "spiderTimeout", - "description": "The maximum number of seconds allowed for the spider to traverse the site.", + "description": "The maximum number of minutes allowed for the spider to traverse the site.", "type": { "kind": "NON_NULL", "name": null, diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 1089bfb6aad..086a2dac6d0 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -519,7 +519,7 @@ Represents a DAST scanner profile. | `globalId` | DastScannerProfileID! | ID of the DAST scanner profile | | `id` **{warning-solid}** | ID! | **Deprecated:** Use `global_id`. Deprecated in 13.4 | | `profileName` | String | Name of the DAST scanner profile | -| `spiderTimeout` | Int | The maximum number of seconds allowed for the spider to traverse the site | +| `spiderTimeout` | Int | The maximum number of minutes allowed for the spider to traverse the site | | `targetTimeout` | Int | The maximum number of seconds allowed for the site under test to respond to a request | ## DastScannerProfileCreatePayload diff --git a/doc/development/feature_flags/development.md b/doc/development/feature_flags/development.md index bf7fee90600..b7923cfc268 100644 --- a/doc/development/feature_flags/development.md +++ b/doc/development/feature_flags/development.md @@ -61,6 +61,30 @@ Feature.disabled?(:my_ops_flag, project, type: ops) push_frontend_feature_flag(:my_ops_flag, project, type: :ops) ``` +### `licensed` type + +`licensed` feature flags are used to temporarily disable licensed features. There +should be a one-to-one mapping of `licensed` feature flags to licensed features. + +`licensed` feature flags must be `default_enabled: true`, because that's the only +supported option in the current implementation. This is under development as per +the [related issue](https://gitlab.com/gitlab-org/gitlab/-/issues/218667. + +The `licensed` type has a dedicated set of functions to check if a licensed +feature is available for a project or namespace. This check validates +if the license is assigned to the namespace and feature flag itself. +The `licensed` feature flag has the same name as a licensed feature name: + +```ruby +# Good: checks if feature flag is enabled +project.feature_available?(:my_licensed_feature) +namespace.feature_available?(:my_licensed_feature) + +# Bad: licensed flag must be accessed via `feature_available?` +Feature.enabled?(:my_licensed_feature, type: :licensed) +push_frontend_feature_flag(:my_licensed_feature, type: :licensed) +``` + ## Feature flag definition and validation > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/229161) in GitLab 13.3. @@ -290,13 +314,17 @@ The [`Project#feature_available?`](https://gitlab.com/gitlab-org/gitlab/blob/4cc [`License.feature_available?`](https://gitlab.com/gitlab-org/gitlab/blob/4cc1c62918aa4c31750cb21dfb1a6c3492d71080/ee/app/models/license.rb#L293-300) (EE) methods all implicitly check for a by default enabled feature flag with the same name as the provided argument. -You'd still want to use an explicit `Feature.enabled?` check if your new feature -isn't gated by a License or Plan. - **An important side-effect of the implicit feature flags mentioned above is that unless the feature is explicitly disabled or limited to a percentage of users, the feature flag check will default to `true`.** +NOTE: **Note:** +Due to limitations with `feature_available?`, the YAML definition for `licensed` feature +flags accepts only `default_enabled: true`. This is under development as per the +[related issue](https://gitlab.com/gitlab-org/gitlab/-/issues/218667). + +#### Alpha/beta licensed feature flags + This is relevant when developing the feature using [several smaller merge requests](https://about.gitlab.com/handbook/values/#make-small-merge-requests), or when the feature is considered to be an [alpha or beta](https://about.gitlab.com/handbook/product/gitlab-the-product/#alpha-beta-ga), and @@ -311,6 +339,24 @@ GitLab.com and self-managed instances, you should use the method, according to our [definitions](https://about.gitlab.com/handbook/product/gitlab-the-product/#alpha-beta-ga). This ensures the feature is disabled unless the feature flag is _explicitly_ enabled. +CAUTION: **Caution:** +If `alpha_feature_available?` or `beta_feature_available?` is used, the YAML definition +for the feature flag must use `default_enabled: [false, true]`, because the usage +of the feature flag is undefined. These methods may change, as per the +[related issue](https://gitlab.com/gitlab-org/gitlab/-/issues/218667). + +The resulting YAML should be similar to: + +```yaml +name: scoped_labels +group: group::memory +type: licensed +# The `default_enabled:` is undefined +# as `feature_available?` uses `default_enabled: true` +# as `beta_feature_available?` uses `default_enabled: false` +default_enabled: [false, true] +``` + ### Feature groups Feature groups must be defined statically in `lib/feature.rb` (in the diff --git a/lib/feature/shared.rb b/lib/feature/shared.rb index 53f027e3893..c06f699ef27 100644 --- a/lib/feature/shared.rb +++ b/lib/feature/shared.rb @@ -8,12 +8,14 @@ class Feature module Shared # optional: defines if a on-disk definition is required for this feature flag type # rollout_issue: defines if `bin/feature-flag` asks for rollout issue + # default_enabled: defines a default state of a feature flag when created by `bin/feature-flag` # example: usage being shown when exception is raised TYPES = { development: { description: 'Short lived, used to enable unfinished code to be deployed', optional: true, rollout_issue: true, + default_enabled: false, example: <<-EOS Feature.enabled?(:my_feature_flag, project) Feature.enabled?(:my_feature_flag, project, type: :development) @@ -24,10 +26,21 @@ class Feature description: "Long-lived feature flags that control operational aspects of GitLab's behavior", optional: true, rollout_issue: false, + default_enabled: false, example: <<-EOS Feature.enabled?(:my_ops_flag, type: ops) push_frontend_feature_flag?(:my_ops_flag, project, type: :ops) EOS + }, + licensed: { + description: 'Permanent feature flags used to temporarily disable licensed features.', + optional: true, + rollout_issue: false, + default_enabled: true, + example: <<-EOS + project.feature_available?(:my_licensed_feature) + namespace.feature_available?(:my_licensed_feature) + EOS } }.freeze diff --git a/lib/gitlab/background_migration/populate_vulnerability_historical_statistics.rb b/lib/gitlab/background_migration/populate_vulnerability_historical_statistics.rb new file mode 100644 index 00000000000..a0c89cc4664 --- /dev/null +++ b/lib/gitlab/background_migration/populate_vulnerability_historical_statistics.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + # This class creates/updates those project historical vulnerability statistics + # that haven't been created nor initialized. It should only be executed in EE. + class PopulateVulnerabilityHistoricalStatistics + def perform(project_ids) + end + end + end +end + +Gitlab::BackgroundMigration::PopulateVulnerabilityHistoricalStatistics.prepend_if_ee('EE::Gitlab::BackgroundMigration::PopulateVulnerabilityHistoricalStatistics') diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 0c756ec6644..cc0d4638536 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -1833,9 +1833,6 @@ msgstr "" msgid "AdminProjects|Delete Project %{projectName}?" msgstr "" -msgid "AdminProjects|Delete project" -msgstr "" - msgid "AdminSettings|Apply integration settings to all Projects" msgstr "" @@ -2831,6 +2828,9 @@ msgstr "" msgid "An error occurred while moving the issue." msgstr "" +msgid "An error occurred while moving the issue. Please try again." +msgstr "" + msgid "An error occurred while parsing recent searches" msgstr "" @@ -6375,6 +6375,9 @@ msgstr "" msgid "Commit…" msgstr "" +msgid "Community forum" +msgstr "" + msgid "Company" msgstr "" @@ -7915,10 +7918,10 @@ msgstr "" msgid "DastProfiles|Text file validation" msgstr "" -msgid "DastProfiles|The maximum number of seconds allowed for the site under test to respond to a request." +msgid "DastProfiles|The maximum number of minutes allowed for the spider to traverse the site." msgstr "" -msgid "DastProfiles|The maximum number of seconds allowed for the spider to traverse the site." +msgid "DastProfiles|The maximum number of seconds allowed for the site under test to respond to a request." msgstr "" msgid "DastProfiles|Validate" @@ -16373,6 +16376,9 @@ msgstr "" msgid "NetworkPolicies|Allow all outbound traffic from %{selector} to %{ruleSelector} on %{ports}" msgstr "" +msgid "NetworkPolicies|Are you sure you want to delete this policy? This action cannot be undone." +msgstr "" + msgid "NetworkPolicies|Choose whether to enforce this policy." msgstr "" @@ -16382,6 +16388,12 @@ msgstr "" msgid "NetworkPolicies|Define this policy's location, conditions and actions." msgstr "" +msgid "NetworkPolicies|Delete policy" +msgstr "" + +msgid "NetworkPolicies|Delete policy: %{policy}" +msgstr "" + msgid "NetworkPolicies|Deny all traffic" msgstr "" diff --git a/qa/qa/specs/features/browser_ui/3_create/snippet/share_snippet_spec.rb b/qa/qa/specs/features/browser_ui/3_create/snippet/share_snippet_spec.rb index cb02d84081b..6b21d84cb13 100644 --- a/qa/qa/specs/features/browser_ui/3_create/snippet/share_snippet_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/snippet/share_snippet_spec.rb @@ -17,7 +17,7 @@ module QA end context 'when the snippet is public' do - it 'can be shared with not signed-in users' do + it 'can be shared with not signed-in users', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1016' do snippet sharing_link = Page::Dashboard::Snippet::Show.perform do |snippet| @@ -39,7 +39,7 @@ module QA end context 'when the snippet is changed to private' do - it 'does not display Embed/Share dropdown' do + it 'does not display Embed/Share dropdown', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1015' do snippet Page::Dashboard::Snippet::Show.perform do |snippet| diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js index 8d20db9f24a..717ab5f3add 100644 --- a/spec/frontend/boards/mock_data.js +++ b/spec/frontend/boards/mock_data.js @@ -1,5 +1,9 @@ +/* global ListIssue */ +/* global List */ + import Vue from 'vue'; -import List from '~/boards/models/list'; +import '~/boards/models/list'; +import '~/boards/models/issue'; import boardsStore from '~/boards/stores/boards_store'; export const boardObj = { @@ -94,11 +98,40 @@ export const mockMilestone = { due_date: '2019-12-31', }; +export const rawIssue = { + title: 'Testing', + id: 'gid://gitlab/Issue/1', + iid: 1, + confidential: false, + referencePath: 'gitlab-org/gitlab-test#1', + labels: { + nodes: [ + { + id: 1, + title: 'test', + color: 'red', + description: 'testing', + }, + ], + }, + assignees: { + nodes: [ + { + id: 1, + name: 'name', + username: 'username', + avatar_url: 'http://avatar_url', + }, + ], + }, +}; + export const mockIssue = { title: 'Testing', id: 1, iid: 1, confidential: false, + referencePath: 'gitlab-org/gitlab-test#1', labels: [ { id: 1, @@ -117,11 +150,14 @@ export const mockIssue = { ], }; +export const mockIssueWithModel = new ListIssue(mockIssue); + export const mockIssue2 = { title: 'Planning', id: 2, iid: 2, confidential: false, + referencePath: 'gitlab-org/gitlab-test#2', labels: [ { id: 1, @@ -140,6 +176,8 @@ export const mockIssue2 = { ], }; +export const mockIssue2WithModel = new ListIssue(mockIssue2); + export const BoardsMockData = { GET: { '/test/-/boards/1/lists/300/issues?id=300&page=1': { diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index 17792a8f597..4eb1a370a9f 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -1,5 +1,13 @@ import testAction from 'helpers/vuex_action_helper'; -import { mockListsWithModel, mockLists, mockIssue } from '../mock_data'; +import { + mockListsWithModel, + mockLists, + mockIssue, + mockIssue2, + mockIssueWithModel, + mockIssue2WithModel, + rawIssue, +} from '../mock_data'; import actions, { gqlClient } from '~/boards/stores/actions'; import * as types from '~/boards/stores/mutation_types'; import { inactiveId, ListType } from '~/boards/constants'; @@ -229,7 +237,107 @@ describe('fetchIssuesForList', () => { }); describe('moveIssue', () => { - expectNotImplemented(actions.moveIssue); + const listIssues = { + 'gid://gitlab/List/1': [mockIssue.id, mockIssue2.id], + 'gid://gitlab/List/2': [], + }; + + const issues = { + '1': mockIssueWithModel, + '2': mockIssue2WithModel, + }; + + const state = { + endpoints: { fullPath: 'gitlab-org', boardId: '1' }, + boardType: 'group', + disabled: false, + boardLists: mockListsWithModel, + issuesByListId: listIssues, + issues, + }; + + it('should commit MOVE_ISSUE mutation and MOVE_ISSUE_SUCCESS mutation when successful', done => { + jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ + data: { + issueMoveList: { + issue: rawIssue, + errors: [], + }, + }, + }); + + testAction( + actions.moveIssue, + { + issueId: mockIssue.id, + issueIid: mockIssue.iid, + issuePath: mockIssue.referencePath, + fromListId: 'gid://gitlab/List/1', + toListId: 'gid://gitlab/List/2', + }, + state, + [ + { + type: types.MOVE_ISSUE, + payload: { + originalIssue: mockIssueWithModel, + fromListId: 'gid://gitlab/List/1', + toListId: 'gid://gitlab/List/2', + }, + }, + { + type: types.MOVE_ISSUE_SUCCESS, + payload: { issue: rawIssue }, + }, + ], + [], + done, + ); + }); + + it('should commit MOVE_ISSUE mutation and MOVE_ISSUE_FAILURE mutation when unsuccessful', done => { + jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ + data: { + issueMoveList: { + issue: {}, + errors: [{ foo: 'bar' }], + }, + }, + }); + + testAction( + actions.moveIssue, + { + issueId: mockIssue.id, + issueIid: mockIssue.iid, + issuePath: mockIssue.referencePath, + fromListId: 'gid://gitlab/List/1', + toListId: 'gid://gitlab/List/2', + }, + state, + [ + { + type: types.MOVE_ISSUE, + payload: { + originalIssue: mockIssueWithModel, + fromListId: 'gid://gitlab/List/1', + toListId: 'gid://gitlab/List/2', + }, + }, + { + type: types.MOVE_ISSUE_FAILURE, + payload: { + originalIssue: mockIssueWithModel, + fromListId: 'gid://gitlab/List/1', + toListId: 'gid://gitlab/List/2', + originalIndex: 0, + }, + }, + ], + [], + done, + ); + }); }); describe('createNewIssue', () => { diff --git a/spec/frontend/boards/stores/mutations_spec.js b/spec/frontend/boards/stores/mutations_spec.js index 2927337f455..350eccfa82e 100644 --- a/spec/frontend/boards/stores/mutations_spec.js +++ b/spec/frontend/boards/stores/mutations_spec.js @@ -4,10 +4,13 @@ import defaultState from '~/boards/stores/state'; import { listObj, listObjDuplicate, - mockIssue, - mockIssue2, mockListsWithModel, mockLists, + rawIssue, + mockIssue, + mockIssue2, + mockIssueWithModel, + mockIssue2WithModel, } from '../mock_data'; const expectNotImplemented = action => { @@ -247,16 +250,86 @@ describe('Board Store Mutations', () => { expectNotImplemented(mutations.RECEIVE_ADD_ISSUE_ERROR); }); - describe('REQUEST_MOVE_ISSUE', () => { - expectNotImplemented(mutations.REQUEST_MOVE_ISSUE); + describe('MOVE_ISSUE', () => { + it('updates issuesByListId, moving issue between lists', () => { + const listIssues = { + 'gid://gitlab/List/1': [mockIssue.id, mockIssue2.id], + 'gid://gitlab/List/2': [], + }; + + const issues = { + '1': mockIssueWithModel, + '2': mockIssue2WithModel, + }; + + state = { + ...state, + issuesByListId: listIssues, + boardLists: mockListsWithModel, + issues, + }; + + mutations.MOVE_ISSUE(state, { + originalIssue: mockIssue2WithModel, + fromListId: 'gid://gitlab/List/1', + toListId: 'gid://gitlab/List/2', + }); + + const updatedListIssues = { + 'gid://gitlab/List/1': [mockIssue.id], + 'gid://gitlab/List/2': [mockIssue2.id], + }; + + expect(state.issuesByListId).toEqual(updatedListIssues); + }); }); - describe('RECEIVE_MOVE_ISSUE_SUCCESS', () => { - expectNotImplemented(mutations.RECEIVE_MOVE_ISSUE_SUCCESS); + describe('MOVE_ISSUE_SUCCESS', () => { + it('updates issue in issues state', () => { + const issues = { + '1': { id: rawIssue.id }, + }; + + state = { + ...state, + issues, + }; + + mutations.MOVE_ISSUE_SUCCESS(state, { + issue: rawIssue, + }); + + expect(state.issues).toEqual({ '1': { ...mockIssueWithModel, id: 1 } }); + }); }); - describe('RECEIVE_MOVE_ISSUE_ERROR', () => { - expectNotImplemented(mutations.RECEIVE_MOVE_ISSUE_ERROR); + describe('MOVE_ISSUE_FAILURE', () => { + it('updates issuesByListId, reverting moving issue between lists, and sets error message', () => { + const listIssues = { + 'gid://gitlab/List/1': [mockIssue.id], + 'gid://gitlab/List/2': [mockIssue2.id], + }; + + state = { + ...state, + issuesByListId: listIssues, + }; + + mutations.MOVE_ISSUE_FAILURE(state, { + originalIssue: mockIssue2, + fromListId: 'gid://gitlab/List/1', + toListId: 'gid://gitlab/List/2', + originalIndex: 1, + }); + + const updatedListIssues = { + 'gid://gitlab/List/1': [mockIssue.id, mockIssue2.id], + 'gid://gitlab/List/2': [], + }; + + expect(state.issuesByListId).toEqual(updatedListIssues); + expect(state.error).toEqual('An error occurred while moving the issue. Please try again.'); + }); }); describe('REQUEST_UPDATE_ISSUE', () => { diff --git a/spec/frontend/design_management/mock_data/apollo_mock.js b/spec/frontend/design_management/mock_data/apollo_mock.js index 5e2df3877a5..1c7806c292f 100644 --- a/spec/frontend/design_management/mock_data/apollo_mock.js +++ b/spec/frontend/design_management/mock_data/apollo_mock.js @@ -13,6 +13,9 @@ export const designListQueryResponse = { notesCount: 3, image: 'image-1', imageV432x230: 'image-1', + currentUserTodos: { + nodes: [], + }, }, { id: '2', @@ -21,6 +24,9 @@ export const designListQueryResponse = { notesCount: 2, image: 'image-2', imageV432x230: 'image-2', + currentUserTodos: { + nodes: [], + }, }, { id: '3', @@ -29,6 +35,9 @@ export const designListQueryResponse = { notesCount: 1, image: 'image-3', imageV432x230: 'image-3', + currentUserTodos: { + nodes: [], + }, }, ], }, @@ -60,6 +69,9 @@ export const reorderedDesigns = [ notesCount: 2, image: 'image-2', imageV432x230: 'image-2', + currentUserTodos: { + nodes: [], + }, }, { id: '1', @@ -68,6 +80,9 @@ export const reorderedDesigns = [ notesCount: 3, image: 'image-1', imageV432x230: 'image-1', + currentUserTodos: { + nodes: [], + }, }, { id: '3', @@ -76,6 +91,9 @@ export const reorderedDesigns = [ notesCount: 1, image: 'image-3', imageV432x230: 'image-3', + currentUserTodos: { + nodes: [], + }, }, ]; diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js index 9b7eb4324b3..c2091a681f2 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js @@ -38,9 +38,7 @@ describe('DropdownCreateLabelComponent', () => { }); it('renders `Go back` button on component header', () => { - const backButtonEl = vm.$el.querySelector( - '.dropdown-title button.dropdown-title-button.dropdown-menu-back', - ); + const backButtonEl = vm.$el.querySelector('.dropdown-title .dropdown-menu-back'); expect(backButtonEl).not.toBe(null); expect(backButtonEl.querySelector('[data-testid="arrow-left-icon"]')).not.toBe(null); @@ -62,12 +60,9 @@ describe('DropdownCreateLabelComponent', () => { }); it('renders `Close` button on component header', () => { - const closeButtonEl = vm.$el.querySelector( - '.dropdown-title button.dropdown-title-button.dropdown-menu-close', - ); + const closeButtonEl = vm.$el.querySelector('.dropdown-title .dropdown-menu-close'); expect(closeButtonEl).not.toBe(null); - expect(closeButtonEl.querySelector('.fa-times.dropdown-menu-close-icon')).not.toBe(null); }); it('renders `Name new label` input element', () => { diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 498566c5d60..03e24524f9f 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -286,10 +286,10 @@ RSpec.describe NotificationService, :mailer do describe 'Notes' do context 'issue note' do - let(:project) { create(:project, :private) } - let(:issue) { create(:issue, project: project, assignees: [assignee]) } - let(:mentioned_issue) { create(:issue, assignees: issue.assignees) } - let(:author) { create(:user) } + let_it_be(:project) { create(:project, :private) } + let_it_be(:issue) { create(:issue, project: project, assignees: [assignee]) } + let_it_be(:mentioned_issue) { create(:issue, assignees: issue.assignees) } + let_it_be_with_reload(:author) { create(:user) } let(:note) { create(:note_on_issue, author: author, noteable: issue, project_id: issue.project_id, note: '@mention referenced, @unsubscribed_mentioned and @outsider also') } subject { notification.new_note(note) } @@ -368,7 +368,7 @@ RSpec.describe NotificationService, :mailer do end describe '#new_note' do - before do + before_all do build_team(project) project.add_maintainer(issue.author) project.add_maintainer(assignee) @@ -390,9 +390,12 @@ RSpec.describe NotificationService, :mailer do end context 'with users' do - before do + before_all do add_users(project) add_user_subscriptions(issue) + end + + before do reset_delivered_emails! end @@ -569,17 +572,20 @@ RSpec.describe NotificationService, :mailer do end context 'issue note mention', :deliver_mails_inline do - let(:issue) { create(:issue, project: project, assignees: [assignee]) } - let(:mentioned_issue) { create(:issue, assignees: issue.assignees) } - let(:author) { create(:user) } + let_it_be(:issue) { create(:issue, project: project, assignees: [assignee]) } + let_it_be(:mentioned_issue) { create(:issue, assignees: issue.assignees) } + let_it_be(:author) { create(:user) } let(:note) { create(:note_on_issue, author: author, noteable: issue, project_id: issue.project_id, note: '@all mentioned') } - before do + before_all do build_team(project) build_group(project) add_users(project) add_user_subscriptions(issue) project.add_maintainer(author) + end + + before do reset_delivered_emails! end @@ -726,18 +732,21 @@ RSpec.describe NotificationService, :mailer do end context 'commit note', :deliver_mails_inline do - let(:project) { create(:project, :public, :repository) } - let(:note) { create(:note_on_commit, project: project) } + let_it_be(:project) { create(:project, :public, :repository) } + let_it_be(:note) { create(:note_on_commit, project: project) } - before do + before_all do build_team(project) build_group(project) - reset_delivered_emails! - allow(note.noteable).to receive(:author).and_return(@u_committer) update_custom_notification(:new_note, @u_guest_custom, resource: project) update_custom_notification(:new_note, @u_custom_global) end + before do + reset_delivered_emails! + allow(note.noteable).to receive(:author).and_return(@u_committer) + end + describe '#new_note, #perform_enqueued_jobs' do it do notification.new_note(note) @@ -785,12 +794,12 @@ RSpec.describe NotificationService, :mailer do end context "merge request diff note", :deliver_mails_inline do - let(:project) { create(:project, :repository) } - let(:user) { create(:user) } - let(:merge_request) { create(:merge_request, source_project: project, assignees: [user], author: create(:user)) } - let(:note) { create(:diff_note_on_merge_request, project: project, noteable: merge_request) } + let_it_be(:project) { create(:project, :repository) } + let_it_be(:user) { create(:user) } + let_it_be(:merge_request) { create(:merge_request, source_project: project, assignees: [user], author: create(:user)) } + let_it_be(:note) { create(:diff_note_on_merge_request, project: project, noteable: merge_request) } - before do + before_all do build_team(note.project) project.add_maintainer(merge_request.author) merge_request.assignees.each { |assignee| project.add_maintainer(assignee) } @@ -2521,15 +2530,15 @@ RSpec.describe NotificationService, :mailer do describe 'Pipelines', :deliver_mails_inline do describe '#pipeline_finished' do - let(:project) { create(:project, :public, :repository) } - let(:u_member) { create(:user) } - let(:u_watcher) { create_user_with_notification(:watch, 'watcher') } + let_it_be(:project) { create(:project, :public, :repository) } + let_it_be(:u_member) { create(:user) } + let_it_be(:u_watcher) { create_user_with_notification(:watch, 'watcher') } - let(:u_custom_notification_unset) do + let_it_be(:u_custom_notification_unset) do create_user_with_notification(:custom, 'custom_unset') end - let(:u_custom_notification_enabled) do + let_it_be(:u_custom_notification_enabled) do user = create_user_with_notification(:custom, 'custom_enabled') update_custom_notification(:success_pipeline, user, resource: project) update_custom_notification(:failed_pipeline, user, resource: project) @@ -2537,7 +2546,7 @@ RSpec.describe NotificationService, :mailer do user end - let(:u_custom_notification_disabled) do + let_it_be(:u_custom_notification_disabled) do user = create_user_with_notification(:custom, 'custom_disabled') update_custom_notification(:success_pipeline, user, resource: project, value: false) update_custom_notification(:failed_pipeline, user, resource: project, value: false) @@ -2556,13 +2565,15 @@ RSpec.describe NotificationService, :mailer do before_sha: '00000000') end - before do + before_all do project.add_maintainer(u_member) project.add_maintainer(u_watcher) project.add_maintainer(u_custom_notification_unset) project.add_maintainer(u_custom_notification_enabled) project.add_maintainer(u_custom_notification_disabled) + end + before do reset_delivered_emails! end diff --git a/spec/workers/create_pipeline_worker_spec.rb b/spec/workers/create_pipeline_worker_spec.rb index 6a3729fa28a..da85d700429 100644 --- a/spec/workers/create_pipeline_worker_spec.rb +++ b/spec/workers/create_pipeline_worker_spec.rb @@ -9,7 +9,7 @@ RSpec.describe CreatePipelineWorker do context 'when a project not found' do it 'does not call the Service' do expect(Ci::CreatePipelineService).not_to receive(:new) - expect { worker.perform(99, create(:user).id, 'master', :web) }.to raise_error(ActiveRecord::RecordNotFound) + expect { worker.perform(non_existing_record_id, create(:user).id, 'master', :web) }.to raise_error(ActiveRecord::RecordNotFound) end end @@ -18,7 +18,7 @@ RSpec.describe CreatePipelineWorker do it 'does not call the Service' do expect(Ci::CreatePipelineService).not_to receive(:new) - expect { worker.perform(project.id, 99, project.default_branch, :web) }.to raise_error(ActiveRecord::RecordNotFound) + expect { worker.perform(project.id, non_existing_record_id, project.default_branch, :web) }.to raise_error(ActiveRecord::RecordNotFound) end end diff --git a/spec/workers/new_issue_worker_spec.rb b/spec/workers/new_issue_worker_spec.rb index 570b5d9c06e..7cba3487603 100644 --- a/spec/workers/new_issue_worker_spec.rb +++ b/spec/workers/new_issue_worker_spec.rb @@ -11,13 +11,13 @@ RSpec.describe NewIssueWorker do expect(EventCreateService).not_to receive(:new) expect(NotificationService).not_to receive(:new) - worker.perform(99, create(:user).id) + worker.perform(non_existing_record_id, create(:user).id) end it 'logs an error' do - expect(Gitlab::AppLogger).to receive(:error).with('NewIssueWorker: couldn\'t find Issue with ID=99, skipping job') + expect(Gitlab::AppLogger).to receive(:error).with("NewIssueWorker: couldn't find Issue with ID=#{non_existing_record_id}, skipping job") - worker.perform(99, create(:user).id) + worker.perform(non_existing_record_id, create(:user).id) end end @@ -26,15 +26,15 @@ RSpec.describe NewIssueWorker do expect(EventCreateService).not_to receive(:new) expect(NotificationService).not_to receive(:new) - worker.perform(create(:issue).id, 99) + worker.perform(create(:issue).id, non_existing_record_id) end it 'logs an error' do issue = create(:issue) - expect(Gitlab::AppLogger).to receive(:error).with('NewIssueWorker: couldn\'t find User with ID=99, skipping job') + expect(Gitlab::AppLogger).to receive(:error).with("NewIssueWorker: couldn't find User with ID=#{non_existing_record_id}, skipping job") - worker.perform(issue.id, 99) + worker.perform(issue.id, non_existing_record_id) end end @@ -50,7 +50,7 @@ RSpec.describe NewIssueWorker do it 'creates a notification for the mentioned user' do expect(Notify).to receive(:new_issue_email).with(mentioned.id, issue.id, NotificationReason::MENTIONED) - .and_return(double(deliver_later: true)) + .and_return(double(deliver_later: true)) worker.perform(issue.id, user.id) end diff --git a/spec/workers/new_merge_request_worker_spec.rb b/spec/workers/new_merge_request_worker_spec.rb index 3d78b9703fa..310fde4c7e1 100644 --- a/spec/workers/new_merge_request_worker_spec.rb +++ b/spec/workers/new_merge_request_worker_spec.rb @@ -11,15 +11,15 @@ RSpec.describe NewMergeRequestWorker do expect(EventCreateService).not_to receive(:new) expect(NotificationService).not_to receive(:new) - worker.perform(99, create(:user).id) + worker.perform(non_existing_record_id, create(:user).id) end it 'logs an error' do user = create(:user) - expect(Gitlab::AppLogger).to receive(:error).with('NewMergeRequestWorker: couldn\'t find MergeRequest with ID=99, skipping job') + expect(Gitlab::AppLogger).to receive(:error).with("NewMergeRequestWorker: couldn't find MergeRequest with ID=#{non_existing_record_id}, skipping job") - worker.perform(99, user.id) + worker.perform(non_existing_record_id, user.id) end end @@ -28,15 +28,15 @@ RSpec.describe NewMergeRequestWorker do expect(EventCreateService).not_to receive(:new) expect(NotificationService).not_to receive(:new) - worker.perform(create(:merge_request).id, 99) + worker.perform(create(:merge_request).id, non_existing_record_id) end it 'logs an error' do merge_request = create(:merge_request) - expect(Gitlab::AppLogger).to receive(:error).with('NewMergeRequestWorker: couldn\'t find User with ID=99, skipping job') + expect(Gitlab::AppLogger).to receive(:error).with("NewMergeRequestWorker: couldn't find User with ID=#{non_existing_record_id}, skipping job") - worker.perform(merge_request.id, 99) + worker.perform(merge_request.id, non_existing_record_id) end end @@ -54,8 +54,8 @@ RSpec.describe NewMergeRequestWorker do it 'creates a notification for the mentioned user' do expect(Notify).to receive(:new_merge_request_email) - .with(mentioned.id, merge_request.id, NotificationReason::MENTIONED) - .and_return(double(deliver_later: true)) + .with(mentioned.id, merge_request.id, NotificationReason::MENTIONED) + .and_return(double(deliver_later: true)) worker.perform(merge_request.id, user.id) end |