Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
		
							parent
							
								
									43b91399ae
								
							
						
					
					
						commit
						93c27b216a
					
				| 
						 | 
				
			
			@ -1 +1 @@
 | 
			
		|||
13.9.1
 | 
			
		||||
13.10.1
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,192 @@
 | 
			
		|||
<script>
 | 
			
		||||
import { GlIcon, GlLink, GlPopover, GlLoadingIcon } from '@gitlab/ui';
 | 
			
		||||
import { blockingIssuablesQueries, issuableTypes } from '~/boards/constants';
 | 
			
		||||
import { IssueType } from '~/graphql_shared/constants';
 | 
			
		||||
import { convertToGraphQLId } from '~/graphql_shared/utils';
 | 
			
		||||
import { truncate } from '~/lib/utils/text_utility';
 | 
			
		||||
import { __, n__, s__, sprintf } from '~/locale';
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  i18n: {
 | 
			
		||||
    issuableType: {
 | 
			
		||||
      [issuableTypes.issue]: __('issue'),
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  graphQLIdType: {
 | 
			
		||||
    [issuableTypes.issue]: IssueType,
 | 
			
		||||
  },
 | 
			
		||||
  referenceFormatter: {
 | 
			
		||||
    [issuableTypes.issue]: (r) => r.split('/')[1],
 | 
			
		||||
  },
 | 
			
		||||
  defaultDisplayLimit: 3,
 | 
			
		||||
  textTruncateWidth: 80,
 | 
			
		||||
  components: {
 | 
			
		||||
    GlIcon,
 | 
			
		||||
    GlPopover,
 | 
			
		||||
    GlLink,
 | 
			
		||||
    GlLoadingIcon,
 | 
			
		||||
  },
 | 
			
		||||
  blockingIssuablesQueries,
 | 
			
		||||
  props: {
 | 
			
		||||
    item: {
 | 
			
		||||
      type: Object,
 | 
			
		||||
      required: true,
 | 
			
		||||
    },
 | 
			
		||||
    uniqueId: {
 | 
			
		||||
      type: String,
 | 
			
		||||
      required: true,
 | 
			
		||||
    },
 | 
			
		||||
    issuableType: {
 | 
			
		||||
      type: String,
 | 
			
		||||
      required: true,
 | 
			
		||||
      validator(value) {
 | 
			
		||||
        return [issuableTypes.issue].includes(value);
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  apollo: {
 | 
			
		||||
    blockingIssuables: {
 | 
			
		||||
      skip() {
 | 
			
		||||
        return this.skip;
 | 
			
		||||
      },
 | 
			
		||||
      query() {
 | 
			
		||||
        return blockingIssuablesQueries[this.issuableType].query;
 | 
			
		||||
      },
 | 
			
		||||
      variables() {
 | 
			
		||||
        return {
 | 
			
		||||
          id: convertToGraphQLId(this.$options.graphQLIdType[this.issuableType], this.item.id),
 | 
			
		||||
        };
 | 
			
		||||
      },
 | 
			
		||||
      update(data) {
 | 
			
		||||
        this.skip = true;
 | 
			
		||||
 | 
			
		||||
        return data?.issuable?.blockingIssuables?.nodes || [];
 | 
			
		||||
      },
 | 
			
		||||
      error(error) {
 | 
			
		||||
        const message = sprintf(s__('Boards|Failed to fetch blocking %{issuableType}s'), {
 | 
			
		||||
          issuableType: this.issuableTypeText,
 | 
			
		||||
        });
 | 
			
		||||
        this.$emit('blocking-issuables-error', { error, message });
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  data() {
 | 
			
		||||
    return {
 | 
			
		||||
      skip: true,
 | 
			
		||||
      blockingIssuables: [],
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  computed: {
 | 
			
		||||
    displayedIssuables() {
 | 
			
		||||
      const { defaultDisplayLimit, referenceFormatter } = this.$options;
 | 
			
		||||
      return this.blockingIssuables.slice(0, defaultDisplayLimit).map((i) => {
 | 
			
		||||
        return {
 | 
			
		||||
          ...i,
 | 
			
		||||
          title: truncate(i.title, this.$options.textTruncateWidth),
 | 
			
		||||
          reference: referenceFormatter[this.issuableType](i.reference),
 | 
			
		||||
        };
 | 
			
		||||
      });
 | 
			
		||||
    },
 | 
			
		||||
    loading() {
 | 
			
		||||
      return this.$apollo.queries.blockingIssuables.loading;
 | 
			
		||||
    },
 | 
			
		||||
    issuableTypeText() {
 | 
			
		||||
      return this.$options.i18n.issuableType[this.issuableType];
 | 
			
		||||
    },
 | 
			
		||||
    blockedLabel() {
 | 
			
		||||
      return sprintf(
 | 
			
		||||
        n__(
 | 
			
		||||
          'Boards|Blocked by %{blockedByCount} %{issuableType}',
 | 
			
		||||
          'Boards|Blocked by %{blockedByCount} %{issuableType}s',
 | 
			
		||||
          this.item.blockedByCount,
 | 
			
		||||
        ),
 | 
			
		||||
        {
 | 
			
		||||
          blockedByCount: this.item.blockedByCount,
 | 
			
		||||
          issuableType: this.issuableTypeText,
 | 
			
		||||
        },
 | 
			
		||||
      );
 | 
			
		||||
    },
 | 
			
		||||
    glIconId() {
 | 
			
		||||
      return `blocked-icon-${this.uniqueId}`;
 | 
			
		||||
    },
 | 
			
		||||
    hasMoreIssuables() {
 | 
			
		||||
      return this.item.blockedByCount > this.$options.defaultDisplayLimit;
 | 
			
		||||
    },
 | 
			
		||||
    displayedIssuablesCount() {
 | 
			
		||||
      return this.hasMoreIssuables
 | 
			
		||||
        ? this.item.blockedByCount - this.$options.defaultDisplayLimit
 | 
			
		||||
        : this.item.blockedByCount;
 | 
			
		||||
    },
 | 
			
		||||
    moreIssuablesText() {
 | 
			
		||||
      return sprintf(
 | 
			
		||||
        n__(
 | 
			
		||||
          'Boards|+ %{displayedIssuablesCount} more %{issuableType}',
 | 
			
		||||
          'Boards|+ %{displayedIssuablesCount} more %{issuableType}s',
 | 
			
		||||
          this.displayedIssuablesCount,
 | 
			
		||||
        ),
 | 
			
		||||
        {
 | 
			
		||||
          displayedIssuablesCount: this.displayedIssuablesCount,
 | 
			
		||||
          issuableType: this.issuableTypeText,
 | 
			
		||||
        },
 | 
			
		||||
      );
 | 
			
		||||
    },
 | 
			
		||||
    viewAllIssuablesText() {
 | 
			
		||||
      return sprintf(s__('Boards|View all blocking %{issuableType}s'), {
 | 
			
		||||
        issuableType: this.issuableTypeText,
 | 
			
		||||
      });
 | 
			
		||||
    },
 | 
			
		||||
    loadingMessage() {
 | 
			
		||||
      return sprintf(s__('Boards|Retrieving blocking %{issuableType}s'), {
 | 
			
		||||
        issuableType: this.issuableTypeText,
 | 
			
		||||
      });
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
    handleMouseEnter() {
 | 
			
		||||
      this.skip = false;
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
</script>
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="gl-display-inline">
 | 
			
		||||
    <gl-icon
 | 
			
		||||
      :id="glIconId"
 | 
			
		||||
      ref="icon"
 | 
			
		||||
      name="issue-block"
 | 
			
		||||
      class="issue-blocked-icon gl-mr-2 gl-cursor-pointer"
 | 
			
		||||
      data-testid="issue-blocked-icon"
 | 
			
		||||
      @mouseenter="handleMouseEnter"
 | 
			
		||||
    />
 | 
			
		||||
    <gl-popover :target="glIconId" placement="top" triggers="hover">
 | 
			
		||||
      <template #title
 | 
			
		||||
        ><span data-testid="popover-title">{{ blockedLabel }}</span></template
 | 
			
		||||
      >
 | 
			
		||||
      <template v-if="loading">
 | 
			
		||||
        <gl-loading-icon />
 | 
			
		||||
        <p class="gl-mt-4 gl-mb-0 gl-font-small">{{ loadingMessage }}</p>
 | 
			
		||||
      </template>
 | 
			
		||||
      <template v-else>
 | 
			
		||||
        <ul class="gl-list-style-none gl-p-0">
 | 
			
		||||
          <li v-for="issuable in displayedIssuables" :key="issuable.id">
 | 
			
		||||
            <gl-link :href="issuable.webUrl" class="gl-text-blue-500! gl-font-sm">{{
 | 
			
		||||
              issuable.reference
 | 
			
		||||
            }}</gl-link>
 | 
			
		||||
            <p class="gl-mb-3 gl-display-block!" data-testid="issuable-title">
 | 
			
		||||
              {{ issuable.title }}
 | 
			
		||||
            </p>
 | 
			
		||||
          </li>
 | 
			
		||||
        </ul>
 | 
			
		||||
        <div v-if="hasMoreIssuables" class="gl-mt-4">
 | 
			
		||||
          <p class="gl-mb-3" data-testid="hidden-blocking-count">{{ moreIssuablesText }}</p>
 | 
			
		||||
          <gl-link
 | 
			
		||||
            data-testid="view-all-issues"
 | 
			
		||||
            :href="`${item.webUrl}#related-issues`"
 | 
			
		||||
            class="gl-text-blue-500! gl-font-sm"
 | 
			
		||||
            >{{ viewAllIssuablesText }}</gl-link
 | 
			
		||||
          >
 | 
			
		||||
        </div>
 | 
			
		||||
      </template>
 | 
			
		||||
    </gl-popover>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
| 
						 | 
				
			
			@ -10,6 +10,7 @@ import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
 | 
			
		|||
import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
 | 
			
		||||
import { ListType } from '../constants';
 | 
			
		||||
import eventHub from '../eventhub';
 | 
			
		||||
import BoardBlockedIcon from './board_blocked_icon.vue';
 | 
			
		||||
import IssueDueDate from './issue_due_date.vue';
 | 
			
		||||
import IssueTimeEstimate from './issue_time_estimate.vue';
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -22,6 +23,7 @@ export default {
 | 
			
		|||
    IssueDueDate,
 | 
			
		||||
    IssueTimeEstimate,
 | 
			
		||||
    IssueCardWeight: () => import('ee_component/boards/components/issue_card_weight.vue'),
 | 
			
		||||
    BoardBlockedIcon,
 | 
			
		||||
  },
 | 
			
		||||
  directives: {
 | 
			
		||||
    GlTooltip: GlTooltipDirective,
 | 
			
		||||
| 
						 | 
				
			
			@ -52,7 +54,7 @@ export default {
 | 
			
		|||
    };
 | 
			
		||||
  },
 | 
			
		||||
  computed: {
 | 
			
		||||
    ...mapState(['isShowingLabels']),
 | 
			
		||||
    ...mapState(['isShowingLabels', 'issuableType']),
 | 
			
		||||
    ...mapGetters(['isEpicBoard']),
 | 
			
		||||
    cappedAssignees() {
 | 
			
		||||
      // e.g. maxRender is 4,
 | 
			
		||||
| 
						 | 
				
			
			@ -114,7 +116,7 @@ export default {
 | 
			
		|||
    },
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
    ...mapActions(['performSearch']),
 | 
			
		||||
    ...mapActions(['performSearch', 'setError']),
 | 
			
		||||
    isIndexLessThanlimit(index) {
 | 
			
		||||
      return index < this.limitBeforeCounter;
 | 
			
		||||
    },
 | 
			
		||||
| 
						 | 
				
			
			@ -164,14 +166,12 @@ export default {
 | 
			
		|||
  <div>
 | 
			
		||||
    <div class="gl-display-flex" dir="auto">
 | 
			
		||||
      <h4 class="board-card-title gl-mb-0 gl-mt-0">
 | 
			
		||||
        <gl-icon
 | 
			
		||||
        <board-blocked-icon
 | 
			
		||||
          v-if="item.blocked"
 | 
			
		||||
          v-gl-tooltip
 | 
			
		||||
          name="issue-block"
 | 
			
		||||
          :title="blockedLabel"
 | 
			
		||||
          class="issue-blocked-icon gl-mr-2"
 | 
			
		||||
          :aria-label="blockedLabel"
 | 
			
		||||
          data-testid="issue-blocked-icon"
 | 
			
		||||
          :item="item"
 | 
			
		||||
          :unique-id="`${item.id}${list.id}`"
 | 
			
		||||
          :issuable-type="issuableType"
 | 
			
		||||
          @blocking-issuables-error="setError"
 | 
			
		||||
        />
 | 
			
		||||
        <gl-icon
 | 
			
		||||
          v-if="item.confidential"
 | 
			
		||||
| 
						 | 
				
			
			@ -181,13 +181,9 @@ export default {
 | 
			
		|||
          class="confidential-icon gl-mr-2"
 | 
			
		||||
          :aria-label="__('Confidential')"
 | 
			
		||||
        />
 | 
			
		||||
        <a
 | 
			
		||||
          :href="item.path || item.webUrl || ''"
 | 
			
		||||
          :title="item.title"
 | 
			
		||||
          class="js-no-trigger"
 | 
			
		||||
          @mousemove.stop
 | 
			
		||||
          >{{ item.title }}</a
 | 
			
		||||
        >
 | 
			
		||||
        <a :href="item.path || item.webUrl || ''" :title="item.title" @mousemove.stop>{{
 | 
			
		||||
          item.title
 | 
			
		||||
        }}</a>
 | 
			
		||||
      </h4>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div v-if="showLabelFooter" class="board-card-labels gl-mt-2 gl-display-flex gl-flex-wrap">
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -17,7 +17,7 @@ export default {
 | 
			
		|||
      gon.features?.graphqlBoardLists || gon.features?.epicBoards
 | 
			
		||||
        ? BoardColumn
 | 
			
		||||
        : BoardColumnDeprecated,
 | 
			
		||||
    BoardContentSidebar: () => import('ee_component/boards/components/board_content_sidebar.vue'),
 | 
			
		||||
    BoardContentSidebar: () => import('~/boards/components/board_content_sidebar.vue'),
 | 
			
		||||
    EpicsSwimlanes: () => import('ee_component/boards/components/epics_swimlanes.vue'),
 | 
			
		||||
    GlAlert,
 | 
			
		||||
  },
 | 
			
		||||
| 
						 | 
				
			
			@ -69,7 +69,7 @@ export default {
 | 
			
		|||
    },
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
    ...mapActions(['moveList']),
 | 
			
		||||
    ...mapActions(['moveList', 'unsetError']),
 | 
			
		||||
    afterFormEnters() {
 | 
			
		||||
      const el = this.canDragColumns ? this.$refs.list.$el : this.$refs.list;
 | 
			
		||||
      el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' });
 | 
			
		||||
| 
						 | 
				
			
			@ -100,7 +100,7 @@ export default {
 | 
			
		|||
 | 
			
		||||
<template>
 | 
			
		||||
  <div>
 | 
			
		||||
    <gl-alert v-if="error" variant="danger" :dismissible="false">
 | 
			
		||||
    <gl-alert v-if="error" variant="danger" :dismissible="true" @dismiss="unsetError">
 | 
			
		||||
      {{ error }}
 | 
			
		||||
    </gl-alert>
 | 
			
		||||
    <component
 | 
			
		||||
| 
						 | 
				
			
			@ -134,6 +134,9 @@ export default {
 | 
			
		|||
      :disabled="disabled"
 | 
			
		||||
    />
 | 
			
		||||
 | 
			
		||||
    <board-content-sidebar v-if="isSwimlanesOn || glFeatures.graphqlBoardLists" />
 | 
			
		||||
    <board-content-sidebar
 | 
			
		||||
      v-if="isSwimlanesOn || glFeatures.graphqlBoardLists"
 | 
			
		||||
      class="issue-boards-sidebar"
 | 
			
		||||
    />
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,101 @@
 | 
			
		|||
<script>
 | 
			
		||||
import { GlDrawer } from '@gitlab/ui';
 | 
			
		||||
import { mapState, mapActions, mapGetters } from 'vuex';
 | 
			
		||||
import BoardSidebarEpicSelect from 'ee_component/boards/components/sidebar/board_sidebar_epic_select.vue';
 | 
			
		||||
import BoardSidebarWeightInput from 'ee_component/boards/components/sidebar/board_sidebar_weight_input.vue';
 | 
			
		||||
import SidebarIterationWidget from 'ee_component/sidebar/components/sidebar_iteration_widget.vue';
 | 
			
		||||
import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue';
 | 
			
		||||
import BoardSidebarIssueTitle from '~/boards/components/sidebar/board_sidebar_issue_title.vue';
 | 
			
		||||
import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
 | 
			
		||||
import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sidebar_milestone_select.vue';
 | 
			
		||||
import BoardSidebarSubscription from '~/boards/components/sidebar/board_sidebar_subscription.vue';
 | 
			
		||||
import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue';
 | 
			
		||||
import { ISSUABLE } from '~/boards/constants';
 | 
			
		||||
import { contentTop } from '~/lib/utils/common_utils';
 | 
			
		||||
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
 | 
			
		||||
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  headerHeight: `${contentTop()}px`,
 | 
			
		||||
  components: {
 | 
			
		||||
    GlDrawer,
 | 
			
		||||
    BoardSidebarIssueTitle,
 | 
			
		||||
    SidebarAssigneesWidget,
 | 
			
		||||
    BoardSidebarTimeTracker,
 | 
			
		||||
    BoardSidebarLabelsSelect,
 | 
			
		||||
    BoardSidebarDueDate,
 | 
			
		||||
    BoardSidebarSubscription,
 | 
			
		||||
    BoardSidebarMilestoneSelect,
 | 
			
		||||
    BoardSidebarEpicSelect,
 | 
			
		||||
    SidebarIterationWidget,
 | 
			
		||||
    BoardSidebarWeightInput,
 | 
			
		||||
  },
 | 
			
		||||
  mixins: [glFeatureFlagsMixin()],
 | 
			
		||||
  computed: {
 | 
			
		||||
    ...mapGetters([
 | 
			
		||||
      'isSidebarOpen',
 | 
			
		||||
      'activeIssue',
 | 
			
		||||
      'groupPathForActiveIssue',
 | 
			
		||||
      'projectPathForActiveIssue',
 | 
			
		||||
    ]),
 | 
			
		||||
    ...mapState(['sidebarType', 'issuableType']),
 | 
			
		||||
    isIssuableSidebar() {
 | 
			
		||||
      return this.sidebarType === ISSUABLE;
 | 
			
		||||
    },
 | 
			
		||||
    showSidebar() {
 | 
			
		||||
      return this.isIssuableSidebar && this.isSidebarOpen;
 | 
			
		||||
    },
 | 
			
		||||
    fullPath() {
 | 
			
		||||
      return this.activeIssue?.referencePath?.split('#')[0] || '';
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
    ...mapActions(['toggleBoardItem', 'setAssignees']),
 | 
			
		||||
    updateAssignees(data) {
 | 
			
		||||
      const assignees = data.issueSetAssignees?.issue?.assignees?.nodes || [];
 | 
			
		||||
      this.setAssignees(assignees);
 | 
			
		||||
    },
 | 
			
		||||
    handleClose() {
 | 
			
		||||
      this.toggleBoardItem({ boardItem: this.activeIssue, sidebarType: this.sidebarType });
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <gl-drawer
 | 
			
		||||
    v-if="showSidebar"
 | 
			
		||||
    data-testid="sidebar-drawer"
 | 
			
		||||
    :open="isSidebarOpen"
 | 
			
		||||
    :header-height="$options.headerHeight"
 | 
			
		||||
    @close="handleClose"
 | 
			
		||||
  >
 | 
			
		||||
    <template #header>{{ __('Issue details') }}</template>
 | 
			
		||||
    <template #default>
 | 
			
		||||
      <board-sidebar-issue-title />
 | 
			
		||||
      <sidebar-assignees-widget
 | 
			
		||||
        :iid="activeIssue.iid"
 | 
			
		||||
        :full-path="fullPath"
 | 
			
		||||
        :initial-assignees="activeIssue.assignees"
 | 
			
		||||
        class="assignee"
 | 
			
		||||
        @assignees-updated="updateAssignees"
 | 
			
		||||
      />
 | 
			
		||||
      <board-sidebar-epic-select class="epic" />
 | 
			
		||||
      <div>
 | 
			
		||||
        <board-sidebar-milestone-select />
 | 
			
		||||
        <sidebar-iteration-widget
 | 
			
		||||
          :iid="activeIssue.iid"
 | 
			
		||||
          :workspace-path="projectPathForActiveIssue"
 | 
			
		||||
          :iterations-workspace-path="groupPathForActiveIssue"
 | 
			
		||||
          :issuable-type="issuableType"
 | 
			
		||||
          class="gl-mt-5"
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
      <board-sidebar-time-tracker class="swimlanes-sidebar-time-tracker" />
 | 
			
		||||
      <board-sidebar-due-date />
 | 
			
		||||
      <board-sidebar-labels-select class="labels" />
 | 
			
		||||
      <board-sidebar-weight-input v-if="glFeatures.issueWeights" class="weight" />
 | 
			
		||||
      <board-sidebar-subscription class="subscriptions" />
 | 
			
		||||
    </template>
 | 
			
		||||
  </gl-drawer>
 | 
			
		||||
</template>
 | 
			
		||||
| 
						 | 
				
			
			@ -98,14 +98,14 @@ export default {
 | 
			
		|||
      <gl-button
 | 
			
		||||
        v-if="canUpdate"
 | 
			
		||||
        variant="link"
 | 
			
		||||
        class="gl-text-gray-900! gl-ml-5 js-sidebar-dropdown-toggle"
 | 
			
		||||
        class="gl-text-gray-900! gl-ml-5 js-sidebar-dropdown-toggle edit-link"
 | 
			
		||||
        data-testid="edit-button"
 | 
			
		||||
        @click="toggle"
 | 
			
		||||
      >
 | 
			
		||||
        {{ __('Edit') }}
 | 
			
		||||
      </gl-button>
 | 
			
		||||
    </header>
 | 
			
		||||
    <div v-show="!edit" class="gl-text-gray-500" data-testid="collapsed-content">
 | 
			
		||||
    <div v-show="!edit" class="gl-text-gray-500 value" data-testid="collapsed-content">
 | 
			
		||||
      <slot name="collapsed">{{ __('None') }}</slot>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div v-show="edit" data-testid="expanded-content">
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,25 @@
 | 
			
		|||
<script>
 | 
			
		||||
import { mapGetters } from 'vuex';
 | 
			
		||||
import IssuableTimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue';
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  components: {
 | 
			
		||||
    IssuableTimeTracker,
 | 
			
		||||
  },
 | 
			
		||||
  inject: ['timeTrackingLimitToHours'],
 | 
			
		||||
  computed: {
 | 
			
		||||
    ...mapGetters(['activeIssue']),
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <issuable-time-tracker
 | 
			
		||||
    :time-estimate="activeIssue.timeEstimate"
 | 
			
		||||
    :time-spent="activeIssue.totalTimeSpent"
 | 
			
		||||
    :human-time-estimate="activeIssue.humanTimeEstimate"
 | 
			
		||||
    :human-time-spent="activeIssue.humanTotalTimeSpent"
 | 
			
		||||
    :limit-to-hours="timeTrackingLimitToHours"
 | 
			
		||||
    :show-collapsed="false"
 | 
			
		||||
  />
 | 
			
		||||
</template>
 | 
			
		||||
| 
						 | 
				
			
			@ -1,4 +1,5 @@
 | 
			
		|||
import { __ } from '~/locale';
 | 
			
		||||
import boardBlockingIssuesQuery from './graphql/board_blocking_issues.query.graphql';
 | 
			
		||||
 | 
			
		||||
export const issuableTypes = {
 | 
			
		||||
  issue: 'issue',
 | 
			
		||||
| 
						 | 
				
			
			@ -45,3 +46,9 @@ export default {
 | 
			
		|||
  BoardType,
 | 
			
		||||
  ListType,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const blockingIssuablesQueries = {
 | 
			
		||||
  [issuableTypes.issue]: {
 | 
			
		||||
    query: boardBlockingIssuesQuery,
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,16 @@
 | 
			
		|||
query BoardBlockingIssues($id: IssueID!) {
 | 
			
		||||
  issuable: issue(id: $id) {
 | 
			
		||||
    __typename
 | 
			
		||||
    id
 | 
			
		||||
    blockingIssuables: blockedByIssues {
 | 
			
		||||
      __typename
 | 
			
		||||
      nodes {
 | 
			
		||||
        id
 | 
			
		||||
        iid
 | 
			
		||||
        title
 | 
			
		||||
        reference(full: true)
 | 
			
		||||
        webUrl
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -107,6 +107,7 @@ export default () => {
 | 
			
		|||
      milestoneListsAvailable: parseBoolean($boardApp.dataset.milestoneListsAvailable),
 | 
			
		||||
      assigneeListsAvailable: parseBoolean($boardApp.dataset.assigneeListsAvailable),
 | 
			
		||||
      iterationListsAvailable: parseBoolean($boardApp.dataset.iterationListsAvailable),
 | 
			
		||||
      issuableType: issuableTypes.issue,
 | 
			
		||||
    },
 | 
			
		||||
    store,
 | 
			
		||||
    apolloProvider,
 | 
			
		||||
| 
						 | 
				
			
			@ -340,7 +341,7 @@ export default () => {
 | 
			
		|||
      },
 | 
			
		||||
      computed: {
 | 
			
		||||
        disabled() {
 | 
			
		||||
          if (!this.store) {
 | 
			
		||||
          if (!this.store || !this.store.lists) {
 | 
			
		||||
            return true;
 | 
			
		||||
          }
 | 
			
		||||
          return !this.store.lists.filter((list) => !list.preset).length;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,3 +1,4 @@
 | 
			
		|||
import * as Sentry from '@sentry/browser';
 | 
			
		||||
import { pick } from 'lodash';
 | 
			
		||||
import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql';
 | 
			
		||||
import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql';
 | 
			
		||||
| 
						 | 
				
			
			@ -608,6 +609,18 @@ export default {
 | 
			
		|||
    }
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  setError: ({ commit }, { message, error, captureError = false }) => {
 | 
			
		||||
    commit(types.SET_ERROR, message);
 | 
			
		||||
 | 
			
		||||
    if (captureError) {
 | 
			
		||||
      Sentry.captureException(error);
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  unsetError: ({ commit }) => {
 | 
			
		||||
    commit(types.SET_ERROR, undefined);
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  fetchBacklog: () => {
 | 
			
		||||
    notImplemented();
 | 
			
		||||
  },
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -49,3 +49,4 @@ 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';
 | 
			
		||||
export const SET_ERROR = 'SET_ERROR';
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -309,4 +309,8 @@ export default {
 | 
			
		|||
  [mutationTypes.RESET_BOARD_ITEM_SELECTION]: (state) => {
 | 
			
		||||
    state.selectedBoardItems = [];
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  [mutationTypes.SET_ERROR]: (state, error) => {
 | 
			
		||||
    state.error = error;
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,2 @@
 | 
			
		|||
/* eslint-disable @gitlab/require-i18n-strings */
 | 
			
		||||
export const IssueType = 'Issue';
 | 
			
		||||
| 
						 | 
				
			
			@ -29,7 +29,7 @@ module DropdownsHelper
 | 
			
		|||
          output << dropdown_filter(options[:placeholder])
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        output << content_tag(:div, class: "dropdown-content #{options[:content_class] if options.key?(:content_class)}") do
 | 
			
		||||
        output << content_tag(:div, data: { qa_selector: "dropdown_list_content" }, class: "dropdown-content #{options[:content_class] if options.key?(:content_class)}") do
 | 
			
		||||
          capture(&block) if block && !options.key?(:footer_content)
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -102,7 +102,7 @@ module DropdownsHelper
 | 
			
		|||
 | 
			
		||||
  def dropdown_filter(placeholder, search_id: nil)
 | 
			
		||||
    content_tag :div, class: "dropdown-input" do
 | 
			
		||||
      filter_output = search_field_tag search_id, nil, class: "dropdown-input-field qa-dropdown-input-field", placeholder: placeholder, autocomplete: 'off'
 | 
			
		||||
      filter_output = search_field_tag search_id, nil, data: { qa_selector: "dropdown_input_field" }, class: "dropdown-input-field", placeholder: placeholder, autocomplete: 'off'
 | 
			
		||||
      filter_output << sprite_icon('search', css_class: 'dropdown-input-search')
 | 
			
		||||
      filter_output << sprite_icon('close', size: 16, css_class: 'dropdown-input-clear js-dropdown-input-clear')
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -857,7 +857,7 @@ class NotificationService
 | 
			
		|||
  end
 | 
			
		||||
 | 
			
		||||
  def warn_skipping_notifications(user, object)
 | 
			
		||||
    Gitlab::AppLogger.warn(message: "Skipping sending notifications", user: user.id, klass: object.class, object_id: object.id)
 | 
			
		||||
    Gitlab::AppLogger.warn(message: "Skipping sending notifications", user: user.id, klass: object.class.to_s, object_id: object.id)
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -45,7 +45,7 @@
 | 
			
		|||
= render_if_exists 'groups/custom_project_templates_setting'
 | 
			
		||||
= render_if_exists 'groups/templates_setting', expanded: expanded
 | 
			
		||||
 | 
			
		||||
%section.settings.gs-advanced.no-animate#js-advanced-settings{ class: ('expanded' if expanded) }
 | 
			
		||||
%section.settings.gs-advanced.no-animate#js-advanced-settings{ class: ('expanded' if expanded), data: { qa_selector: 'advanced_settings_content' } }
 | 
			
		||||
  .settings-header
 | 
			
		||||
    %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only{ role: 'button' }
 | 
			
		||||
      = _('Advanced')
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -28,7 +28,7 @@
 | 
			
		|||
  %h4.warning-title= s_('GroupSettings|Transfer group')
 | 
			
		||||
  = form_for @group, url: transfer_group_path(@group), method: :put, html: { class: 'js-group-transfer-form' } do |f|
 | 
			
		||||
    .form-group
 | 
			
		||||
      = dropdown_tag('Select parent group', options: { toggle_class: 'js-groups-dropdown', title: 'Parent Group', filter: true, dropdown_class: 'dropdown-open-top dropdown-group-transfer', placeholder: 'Search groups', data: { data: parent_group_options(@group) } })
 | 
			
		||||
      = dropdown_tag('Select parent group', options: { toggle_class: 'js-groups-dropdown', title: 'Parent Group', filter: true, dropdown_class: 'dropdown-open-top dropdown-group-transfer', placeholder: 'Search groups', data: { data: parent_group_options(@group), qa_selector: 'select_group_dropdown' } })
 | 
			
		||||
      = hidden_field_tag 'new_parent_group_id'
 | 
			
		||||
 | 
			
		||||
    %ul
 | 
			
		||||
| 
						 | 
				
			
			@ -38,7 +38,7 @@
 | 
			
		|||
      %li= s_('GroupSettings|You can only transfer the group to a group you manage.')
 | 
			
		||||
      %li= s_('GroupSettings|You will need to update your local repositories to point to the new location.')
 | 
			
		||||
      %li= s_("GroupSettings|If the parent group's visibility is lower than the group current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility.")
 | 
			
		||||
    = f.submit s_('GroupSettings|Transfer group'), class: 'btn gl-button btn-warning'
 | 
			
		||||
    = f.submit s_('GroupSettings|Transfer group'), class: 'btn gl-button btn-warning', data: { qa_selector: "transfer_group_button" }
 | 
			
		||||
 | 
			
		||||
= render 'groups/settings/remove', group: @group
 | 
			
		||||
= render_if_exists 'groups/settings/restore', group: @group
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
---
 | 
			
		||||
title: Add blocked issues detail popover for boards cards
 | 
			
		||||
merge_request: 55821
 | 
			
		||||
author:
 | 
			
		||||
type: added
 | 
			
		||||
| 
						 | 
				
			
			@ -131,6 +131,18 @@ Example response:
 | 
			
		|||
        "version": "1.5.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "details": {
 | 
			
		||||
      "custom_field": {
 | 
			
		||||
        "name": "URLs",
 | 
			
		||||
        "type": "list",
 | 
			
		||||
        "items": [
 | 
			
		||||
          {
 | 
			
		||||
            "type": "url",
 | 
			
		||||
            "href": "http://site.com/page/1"
 | 
			
		||||
          }
 | 
			
		||||
        ]
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "solution": "Upgrade to fixed version.\r\n",
 | 
			
		||||
    "blob_path": "/tests/yarn-remediation-test/blob/cc6c4a0778460455ae5d16ca7025ca9ca1ca75ac/yarn.lock"
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -7,94 +7,101 @@ type: reference, howto
 | 
			
		|||
 | 
			
		||||
# Dynamic Application Security Testing (DAST) **(ULTIMATE)**
 | 
			
		||||
 | 
			
		||||
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/4348) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 10.4.
 | 
			
		||||
If you deploy your web application into a new environment, your application may
 | 
			
		||||
become exposed to new types of attacks. For example, misconfigurations of your
 | 
			
		||||
application server or incorrect assumptions about security controls may not be
 | 
			
		||||
visible from the source code.
 | 
			
		||||
 | 
			
		||||
Your application may be exposed to a new category of attacks once deployed into a new environment. For
 | 
			
		||||
example, application server misconfigurations or incorrect assumptions about security controls may
 | 
			
		||||
not be visible from source code alone. Dynamic Application Security Testing (DAST) checks an
 | 
			
		||||
application for these types of vulnerabilities in a deployed environment. GitLab DAST uses the
 | 
			
		||||
popular open source tool [OWASP Zed Attack Proxy](https://www.zaproxy.org/) to analyze your running
 | 
			
		||||
web application.
 | 
			
		||||
Dynamic Application Security Testing (DAST) examines applications for
 | 
			
		||||
vulnerabilities like these in deployed environments. DAST uses the open source
 | 
			
		||||
tool [OWASP Zed Attack Proxy](https://www.zaproxy.org/) for analysis.
 | 
			
		||||
 | 
			
		||||
NOTE:
 | 
			
		||||
The whitepaper ["A Seismic Shift in Application Security"](https://about.gitlab.com/resources/whitepaper-seismic-shift-application-security/)
 | 
			
		||||
explains how 4 of the top 6 attacks were application based. Download it to learn how to protect your
 | 
			
		||||
organization.
 | 
			
		||||
To learn how four of the top six attacks were application-based and how
 | 
			
		||||
to protect your organization, download our
 | 
			
		||||
["A Seismic Shift in Application Security"](https://about.gitlab.com/resources/whitepaper-seismic-shift-application-security/)
 | 
			
		||||
whitepaper.
 | 
			
		||||
 | 
			
		||||
In GitLab, DAST is commonly initiated by a merge request and runs as a job in the CI/CD pipeline.
 | 
			
		||||
You can also run a DAST scan on demand, outside the CI/CD pipeline. Your running web application is
 | 
			
		||||
analyzed for known vulnerabilities. GitLab checks the DAST report, compares the vulnerabilities
 | 
			
		||||
found between the source and target branches, and shows any relevant findings on the merge request.
 | 
			
		||||
You can use DAST to examine your web applications:
 | 
			
		||||
 | 
			
		||||
Note that this comparison logic uses only the latest pipeline executed for the target branch's base
 | 
			
		||||
commit. Running the pipeline on any other commit has no effect on the merge request.
 | 
			
		||||
- When initiated by a merge request, running as CI/CD pipeline job.
 | 
			
		||||
- On demand, outside the CI/CD pipeline.
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
After DAST creates its report, GitLab evaluates it for discovered
 | 
			
		||||
vulnerabilities between the source and target branches. Relevant
 | 
			
		||||
findings are noted in the merge request.
 | 
			
		||||
 | 
			
		||||
The comparison logic uses only the latest pipeline executed for the target
 | 
			
		||||
branch's base commit. Running the pipeline on other commits has no effect on
 | 
			
		||||
the merge request.
 | 
			
		||||
 | 
			
		||||
## Prerequisite
 | 
			
		||||
 | 
			
		||||
To use DAST, ensure you're using GitLab Runner with the
 | 
			
		||||
[`docker` executor](https://docs.gitlab.com/runner/executors/docker.html).
 | 
			
		||||
 | 
			
		||||
## Enable DAST
 | 
			
		||||
 | 
			
		||||
### Prerequisites
 | 
			
		||||
 | 
			
		||||
- GitLab Runner with the [`docker` executor](https://docs.gitlab.com/runner/executors/docker.html).
 | 
			
		||||
 | 
			
		||||
To enable DAST, either:
 | 
			
		||||
 | 
			
		||||
- Enable [Auto DAST](../../../topics/autodevops/stages.md#auto-dast), provided by
 | 
			
		||||
  [Auto DevOps](../../../topics/autodevops/index.md).
 | 
			
		||||
- [Include the DAST template](#dast-cicd-template) in your existing `.gitlab-ci.yml` file.
 | 
			
		||||
- Enable [Auto DAST](../../../topics/autodevops/stages.md#auto-dast) (provided
 | 
			
		||||
  by [Auto DevOps](../../../topics/autodevops/index.md)).
 | 
			
		||||
- Manually [include the DAST template](#include-the-dast-template) in your existing
 | 
			
		||||
  `.gitlab-ci.yml` file.
 | 
			
		||||
 | 
			
		||||
### DAST CI/CD template
 | 
			
		||||
### Include the DAST template
 | 
			
		||||
 | 
			
		||||
The DAST job is defined in a CI/CD template file you reference in your CI/CD configuration file. The
 | 
			
		||||
template is included with GitLab. Updates to the template are provided with GitLab upgrades. You
 | 
			
		||||
benefit from any improvements and additions.
 | 
			
		||||
If you want to manually add DAST to your application, the DAST job is defined
 | 
			
		||||
in a CI/CD template file. Updates to the template are provided with GitLab
 | 
			
		||||
upgrades, allowing you to benefit from any improvements and additions.
 | 
			
		||||
 | 
			
		||||
The following templates are available:
 | 
			
		||||
To include the DAST template:
 | 
			
		||||
 | 
			
		||||
- [`DAST.gitlab-ci.yml`](https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/ci/templates/Security/DAST.gitlab-ci.yml):
 | 
			
		||||
1. Select the CI/CD template you want to use:
 | 
			
		||||
 | 
			
		||||
   - [`DAST.gitlab-ci.yml`](https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/ci/templates/Security/DAST.gitlab-ci.yml):
 | 
			
		||||
     Stable version of the DAST CI/CD template.
 | 
			
		||||
- [`DAST.latest.gitlab-ci.yml`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Security/DAST.latest.gitlab-ci.yml):
 | 
			
		||||
   - [`DAST.latest.gitlab-ci.yml`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Security/DAST.latest.gitlab-ci.yml):
 | 
			
		||||
     Latest version of the DAST template. ([Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/254325)
 | 
			
		||||
  in GitLab 13.8). Please note that the latest version may include breaking changes. Check the
 | 
			
		||||
  [DAST troubleshooting guide](#troubleshooting) if you experience problems.
 | 
			
		||||
     in GitLab 13.8).
 | 
			
		||||
 | 
			
		||||
Use the stable template unless you need a feature provided only in the latest template.
 | 
			
		||||
   WARNING:
 | 
			
		||||
   The latest version of the template may include breaking changes. Use the
 | 
			
		||||
   stable template unless you need a feature provided only in the latest template.
 | 
			
		||||
 | 
			
		||||
See the CI/CD [documentation](../../../development/cicd/templates.md#latest-version)
 | 
			
		||||
on template versioning for more information.
 | 
			
		||||
   For more information about template versioning, see the
 | 
			
		||||
   [CI/CD documentation](../../../development/cicd/templates.md#latest-version).
 | 
			
		||||
 | 
			
		||||
#### Include the DAST template
 | 
			
		||||
1. Add the template to GitLab, based on your version of GitLab:
 | 
			
		||||
 | 
			
		||||
The method of including the DAST template depends on the GitLab version:
 | 
			
		||||
 | 
			
		||||
- In GitLab 11.9 and later, [include](../../../ci/yaml/README.md#includetemplate) the
 | 
			
		||||
  `DAST.gitlab-ci.yml` template.
 | 
			
		||||
 | 
			
		||||
  Add the following to your `.gitlab-ci.yml` file:
 | 
			
		||||
   - In GitLab 11.9 and later, [include](../../../ci/yaml/README.md#includetemplate)
 | 
			
		||||
     the template by adding the following to your `.gitlab-ci.yml` file:
 | 
			
		||||
 | 
			
		||||
     ```yaml
 | 
			
		||||
     include:
 | 
			
		||||
    - template: DAST.gitlab-ci.yml
 | 
			
		||||
       - template: <template_file.yml>
 | 
			
		||||
 | 
			
		||||
     variables:
 | 
			
		||||
       DAST_WEBSITE: https://example.com
 | 
			
		||||
     ```
 | 
			
		||||
 | 
			
		||||
- In GitLab 11.8 and earlier, copy the template's content into your `.gitlab_ci.yml` file.
 | 
			
		||||
   - In GitLab 11.8 and earlier, add the contents of the template to your
 | 
			
		||||
     `.gitlab_ci.yml` file.
 | 
			
		||||
 | 
			
		||||
#### Template options
 | 
			
		||||
1. Define the URL to be scanned by DAST by using one of these methods:
 | 
			
		||||
 | 
			
		||||
Running a DAST scan requires a URL. There are two ways to define the URL to be scanned by DAST:
 | 
			
		||||
   - Set the `DAST_WEBSITE` [CI/CD variable](../../../ci/yaml/README.md#variables).
 | 
			
		||||
     If set, this value takes precedence.
 | 
			
		||||
 | 
			
		||||
1. Set the `DAST_WEBSITE` [CI/CD variable](../../../ci/yaml/README.md#variables).
 | 
			
		||||
   - Add the URL in an `environment_url.txt` file at the root of your project. This is
 | 
			
		||||
     useful for testing in dynamic environments. To run DAST against an application
 | 
			
		||||
     dynamically created during a GitLab CI/CD pipeline, a job that runs prior to
 | 
			
		||||
     the DAST scan must persist the application's domain in an `environment_url.txt`
 | 
			
		||||
     file. DAST automatically parses the `environment_url.txt` file to find its
 | 
			
		||||
     scan target.
 | 
			
		||||
 | 
			
		||||
1. Add it in an `environment_url.txt` file at the root of your project.
 | 
			
		||||
   This is useful for testing in dynamic environments. To run DAST against an application
 | 
			
		||||
   dynamically created during a GitLab CI/CD pipeline, a job that runs prior to the DAST scan must
 | 
			
		||||
   persist the application's domain in an `environment_url.txt` file. DAST automatically parses the
 | 
			
		||||
   `environment_url.txt` file to find its scan target.
 | 
			
		||||
 | 
			
		||||
   For example, in a job that runs prior to DAST, you could include code that looks similar to:
 | 
			
		||||
     For example, in a job that runs prior to DAST, you could include code that
 | 
			
		||||
     looks similar to:
 | 
			
		||||
 | 
			
		||||
     ```yaml
 | 
			
		||||
     script:
 | 
			
		||||
| 
						 | 
				
			
			@ -104,28 +111,31 @@ Running a DAST scan requires a URL. There are two ways to define the URL to be s
 | 
			
		|||
       when: always
 | 
			
		||||
     ```
 | 
			
		||||
 | 
			
		||||
   You can see an example of this in our [Auto DevOps CI YAML](https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml) file.
 | 
			
		||||
 | 
			
		||||
If both values are set, the `DAST_WEBSITE` value takes precedence.
 | 
			
		||||
     You can see an example of this in our
 | 
			
		||||
     [Auto DevOps CI YAML](https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml)
 | 
			
		||||
     file.
 | 
			
		||||
 | 
			
		||||
The included template creates a `dast` job in your CI/CD pipeline and scans
 | 
			
		||||
your project's running application for possible vulnerabilities.
 | 
			
		||||
 | 
			
		||||
The results are saved as a
 | 
			
		||||
[DAST report artifact](../../../ci/pipelines/job_artifacts.md#artifactsreportsdast)
 | 
			
		||||
that you can later download and analyze. Due to implementation limitations we
 | 
			
		||||
that you can later download and analyze. Due to implementation limitations, we
 | 
			
		||||
always take the latest DAST artifact available. Behind the scenes, the
 | 
			
		||||
[GitLab DAST Docker image](https://gitlab.com/gitlab-org/security-products/dast)
 | 
			
		||||
is used to run the tests on the specified URL and scan it for possible vulnerabilities.
 | 
			
		||||
is used to run the tests on the specified URL and scan it for possible
 | 
			
		||||
vulnerabilities.
 | 
			
		||||
 | 
			
		||||
By default, the DAST template uses the latest major version of the DAST Docker
 | 
			
		||||
image. Using the `DAST_VERSION` variable, you can choose how DAST updates:
 | 
			
		||||
 | 
			
		||||
- Automatically update DAST with new features and fixes by pinning to a major version (such as `1`).
 | 
			
		||||
- Automatically update DAST with new features and fixes by pinning to a major
 | 
			
		||||
  version (such as `1`).
 | 
			
		||||
- Only update fixes by pinning to a minor version (such as `1.6`).
 | 
			
		||||
- Prevent all updates by pinning to a specific version (such as `1.6.4`).
 | 
			
		||||
 | 
			
		||||
Find the latest DAST versions on the [Releases](https://gitlab.com/gitlab-org/security-products/dast/-/releases) page.
 | 
			
		||||
Find the latest DAST versions on the [Releases](https://gitlab.com/gitlab-org/security-products/dast/-/releases)
 | 
			
		||||
page.
 | 
			
		||||
 | 
			
		||||
## Deployment options
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -747,7 +757,7 @@ successfully run. For more information, see [Offline environments](../offline_de
 | 
			
		|||
 | 
			
		||||
To use DAST in an offline environment, you need:
 | 
			
		||||
 | 
			
		||||
- GitLab Runner with the [`docker` or `kubernetes` executor](#prerequisites).
 | 
			
		||||
- GitLab Runner with the [`docker` or `kubernetes` executor](#prerequisite).
 | 
			
		||||
- Docker Container Registry with a locally available copy of the DAST
 | 
			
		||||
  [container image](https://gitlab.com/gitlab-org/security-products/dast), found in the
 | 
			
		||||
  [DAST container registry](https://gitlab.com/gitlab-org/security-products/dast/container_registry).
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 42 KiB  | 
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 16 KiB  | 
| 
						 | 
				
			
			@ -280,6 +280,7 @@ group-level objects are available.
 | 
			
		|||
#### GraphQL-based sidebar for group issue boards **(PREMIUM)**
 | 
			
		||||
 | 
			
		||||
<!-- When the feature flag is removed, integrate this section into the above ("Group issue boards"). -->
 | 
			
		||||
<!-- This anchor is linked from #blocked-issues as well. -->
 | 
			
		||||
 | 
			
		||||
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/285074) in GitLab 13.9.
 | 
			
		||||
> - It's [deployed behind a feature flag](../feature_flags.md), disabled by default.
 | 
			
		||||
| 
						 | 
				
			
			@ -407,12 +408,18 @@ To set a WIP limit for a list:
 | 
			
		|||
 | 
			
		||||
## Blocked issues
 | 
			
		||||
 | 
			
		||||
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/34723) in GitLab 12.8.
 | 
			
		||||
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/34723) in GitLab 12.8.
 | 
			
		||||
> - [View blocking issues when hovering over blocked icon](https://gitlab.com/gitlab-org/gitlab/-/issues/210452) in GitLab 13.10.
 | 
			
		||||
 | 
			
		||||
If an issue is blocked by another issue, an icon appears next to its title to indicate its blocked
 | 
			
		||||
status.
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
When you hover over the blocked icon (**{issue-block}**), a detailed information popover is displayed.
 | 
			
		||||
 | 
			
		||||
To enable this in group issue boards, enable the [GraphQL-based sidebar](#graphql-based-sidebar-for-group-issue-boards).
 | 
			
		||||
The feature is enabled by default when you use group issue boards with epic swimlanes.
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
## Actions you can take on an issue board
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4880,6 +4880,11 @@ msgstr ""
 | 
			
		|||
msgid "Boards and Board Lists"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Boards|+ %{displayedIssuablesCount} more %{issuableType}"
 | 
			
		||||
msgid_plural "Boards|+ %{displayedIssuablesCount} more %{issuableType}s"
 | 
			
		||||
msgstr[0] ""
 | 
			
		||||
msgstr[1] ""
 | 
			
		||||
 | 
			
		||||
msgid "Boards|An error occurred while creating the issue. Please try again."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -4922,6 +4927,11 @@ msgstr ""
 | 
			
		|||
msgid "Boards|An error occurred while updating the list. Please try again."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Boards|Blocked by %{blockedByCount} %{issuableType}"
 | 
			
		||||
msgid_plural "Boards|Blocked by %{blockedByCount} %{issuableType}s"
 | 
			
		||||
msgstr[0] ""
 | 
			
		||||
msgstr[1] ""
 | 
			
		||||
 | 
			
		||||
msgid "Boards|Board"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -4934,6 +4944,15 @@ msgstr ""
 | 
			
		|||
msgid "Boards|Expand"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Boards|Failed to fetch blocking %{issuableType}s"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Boards|Retrieving blocking %{issuableType}s"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Boards|View all blocking %{issuableType}s"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Boards|View scope"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -9,6 +9,7 @@ module QA
 | 
			
		|||
 | 
			
		||||
          view 'app/views/groups/edit.html.haml' do
 | 
			
		||||
            element :permission_lfs_2fa_content
 | 
			
		||||
            element :advanced_settings_content
 | 
			
		||||
          end
 | 
			
		||||
 | 
			
		||||
          view 'app/views/groups/settings/_permissions.html.haml' do
 | 
			
		||||
| 
						 | 
				
			
			@ -40,6 +41,16 @@ module QA
 | 
			
		|||
            element :project_creation_level_dropdown
 | 
			
		||||
          end
 | 
			
		||||
 | 
			
		||||
          view 'app/views/groups/settings/_advanced.html.haml' do
 | 
			
		||||
            element :select_group_dropdown
 | 
			
		||||
            element :transfer_group_button
 | 
			
		||||
          end
 | 
			
		||||
 | 
			
		||||
          view 'app/helpers/dropdowns_helper.rb' do
 | 
			
		||||
            element :dropdown_input_field
 | 
			
		||||
            element :dropdown_list_content
 | 
			
		||||
          end
 | 
			
		||||
 | 
			
		||||
          def set_group_name(name)
 | 
			
		||||
            find_element(:group_name_field).send_keys([:command, 'a'], :backspace)
 | 
			
		||||
            find_element(:group_name_field).set name
 | 
			
		||||
| 
						 | 
				
			
			@ -106,6 +117,19 @@ module QA
 | 
			
		|||
 | 
			
		||||
            click_element(:save_permissions_changes_button)
 | 
			
		||||
          end
 | 
			
		||||
 | 
			
		||||
          def transfer_group(target_group)
 | 
			
		||||
            expand_content :advanced_settings_content
 | 
			
		||||
 | 
			
		||||
            click_element :select_group_dropdown
 | 
			
		||||
            fill_element(:dropdown_input_field, target_group)
 | 
			
		||||
 | 
			
		||||
            within_element(:dropdown_list_content) do
 | 
			
		||||
              click_on target_group
 | 
			
		||||
            end
 | 
			
		||||
 | 
			
		||||
            click_element :transfer_group_button
 | 
			
		||||
          end
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,48 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
module QA
 | 
			
		||||
  RSpec.describe 'Manage' do
 | 
			
		||||
    describe 'Subgroup transfer' do
 | 
			
		||||
      let(:source_group) do
 | 
			
		||||
        Resource::Group.fabricate_via_api! do |group|
 | 
			
		||||
          group.path = "source-group-for-transfer_#{SecureRandom.hex(8)}"
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      let!(:target_group) do
 | 
			
		||||
        Resource::Group.fabricate_via_api! do |group|
 | 
			
		||||
          group.path = "target-group-for-transfer_#{SecureRandom.hex(8)}"
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      let(:sub_group_for_transfer) do
 | 
			
		||||
        Resource::Group.fabricate_via_api! do |group|
 | 
			
		||||
          group.path = "subgroup-for-transfer_#{SecureRandom.hex(8)}"
 | 
			
		||||
          group.sandbox = source_group
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      before do
 | 
			
		||||
        Flow::Login.sign_in
 | 
			
		||||
        sub_group_for_transfer.visit!
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      it 'transfers a subgroup to another group',
 | 
			
		||||
        testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1724' do
 | 
			
		||||
        Page::Group::Menu.perform(&:click_group_general_settings_item)
 | 
			
		||||
        Page::Group::Settings::General.perform do |general|
 | 
			
		||||
          general.transfer_group(target_group.path)
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        expect(page).to have_text("Group '#{sub_group_for_transfer.path}' was successfully transferred.")
 | 
			
		||||
        expect(page.driver.current_url).to include("#{target_group.path}/#{sub_group_for_transfer.path}")
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      after do
 | 
			
		||||
        source_group&.remove_via_api!
 | 
			
		||||
        target_group&.remove_via_api!
 | 
			
		||||
        sub_group_for_transfer&.remove_via_api!
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -1,11 +1,14 @@
 | 
			
		|||
import { GlLabel } from '@gitlab/ui';
 | 
			
		||||
import { mount } from '@vue/test-utils';
 | 
			
		||||
import { range } from 'lodash';
 | 
			
		||||
import Vuex from 'vuex';
 | 
			
		||||
import BoardBlockedIcon from '~/boards/components/board_blocked_icon.vue';
 | 
			
		||||
import BoardCardInner from '~/boards/components/board_card_inner.vue';
 | 
			
		||||
import { issuableTypes } from '~/boards/constants';
 | 
			
		||||
import eventHub from '~/boards/eventhub';
 | 
			
		||||
import defaultStore from '~/boards/stores';
 | 
			
		||||
import { updateHistory } from '~/lib/utils/url_utility';
 | 
			
		||||
import { mockLabelList } from './mock_data';
 | 
			
		||||
import { mockLabelList, mockIssue } from './mock_data';
 | 
			
		||||
 | 
			
		||||
jest.mock('~/lib/utils/url_utility');
 | 
			
		||||
jest.mock('~/boards/eventhub');
 | 
			
		||||
| 
						 | 
				
			
			@ -29,8 +32,28 @@ describe('Board card component', () => {
 | 
			
		|||
  let wrapper;
 | 
			
		||||
  let issue;
 | 
			
		||||
  let list;
 | 
			
		||||
  let store;
 | 
			
		||||
 | 
			
		||||
  const findBoardBlockedIcon = () => wrapper.find(BoardBlockedIcon);
 | 
			
		||||
 | 
			
		||||
  const createStore = () => {
 | 
			
		||||
    store = new Vuex.Store({
 | 
			
		||||
      ...defaultStore,
 | 
			
		||||
      state: {
 | 
			
		||||
        ...defaultStore.state,
 | 
			
		||||
        issuableType: issuableTypes.issue,
 | 
			
		||||
      },
 | 
			
		||||
      getters: {
 | 
			
		||||
        isGroupBoard: () => true,
 | 
			
		||||
        isEpicBoard: () => false,
 | 
			
		||||
        isProjectBoard: () => false,
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const createWrapper = (props = {}) => {
 | 
			
		||||
    createStore();
 | 
			
		||||
 | 
			
		||||
  const createWrapper = (props = {}, store = defaultStore) => {
 | 
			
		||||
    wrapper = mount(BoardCardInner, {
 | 
			
		||||
      store,
 | 
			
		||||
      propsData: {
 | 
			
		||||
| 
						 | 
				
			
			@ -41,6 +64,13 @@ describe('Board card component', () => {
 | 
			
		|||
      stubs: {
 | 
			
		||||
        GlLabel: true,
 | 
			
		||||
      },
 | 
			
		||||
      mocks: {
 | 
			
		||||
        $apollo: {
 | 
			
		||||
          queries: {
 | 
			
		||||
            blockingIssuables: { loading: false },
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      provide: {
 | 
			
		||||
        rootPath: '/',
 | 
			
		||||
        scopedLabelsAvailable: false,
 | 
			
		||||
| 
						 | 
				
			
			@ -51,14 +81,9 @@ describe('Board card component', () => {
 | 
			
		|||
  beforeEach(() => {
 | 
			
		||||
    list = mockLabelList;
 | 
			
		||||
    issue = {
 | 
			
		||||
      title: 'Testing',
 | 
			
		||||
      id: 1,
 | 
			
		||||
      iid: 1,
 | 
			
		||||
      confidential: false,
 | 
			
		||||
      ...mockIssue,
 | 
			
		||||
      labels: [list.label],
 | 
			
		||||
      assignees: [],
 | 
			
		||||
      referencePath: '#1',
 | 
			
		||||
      webUrl: '/test/1',
 | 
			
		||||
      weight: 1,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -68,6 +93,7 @@ describe('Board card component', () => {
 | 
			
		|||
  afterEach(() => {
 | 
			
		||||
    wrapper.destroy();
 | 
			
		||||
    wrapper = null;
 | 
			
		||||
    store = null;
 | 
			
		||||
    jest.clearAllMocks();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -87,18 +113,38 @@ describe('Board card component', () => {
 | 
			
		|||
    expect(wrapper.find('.confidential-icon').exists()).toBe(false);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('does not render blocked icon', () => {
 | 
			
		||||
    expect(wrapper.find('.issue-blocked-icon').exists()).toBe(false);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('renders issue ID with #', () => {
 | 
			
		||||
    expect(wrapper.find('.board-card-number').text()).toContain(`#${issue.id}`);
 | 
			
		||||
    expect(wrapper.find('.board-card-number').text()).toContain(`#${issue.iid}`);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('does not render assignee', () => {
 | 
			
		||||
    expect(wrapper.find('.board-card-assignee .avatar').exists()).toBe(false);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('blocked', () => {
 | 
			
		||||
    it('renders blocked icon if issue is blocked', async () => {
 | 
			
		||||
      createWrapper({
 | 
			
		||||
        item: {
 | 
			
		||||
          ...issue,
 | 
			
		||||
          blocked: true,
 | 
			
		||||
        },
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      expect(findBoardBlockedIcon().exists()).toBe(true);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('does not show blocked icon if issue is not blocked', () => {
 | 
			
		||||
      createWrapper({
 | 
			
		||||
        item: {
 | 
			
		||||
          ...issue,
 | 
			
		||||
          blocked: false,
 | 
			
		||||
        },
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      expect(findBoardBlockedIcon().exists()).toBe(false);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('confidential issue', () => {
 | 
			
		||||
    beforeEach(() => {
 | 
			
		||||
      wrapper.setProps({
 | 
			
		||||
| 
						 | 
				
			
			@ -303,21 +349,6 @@ describe('Board card component', () => {
 | 
			
		|||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('blocked', () => {
 | 
			
		||||
    beforeEach(() => {
 | 
			
		||||
      wrapper.setProps({
 | 
			
		||||
        item: {
 | 
			
		||||
          ...wrapper.props('item'),
 | 
			
		||||
          blocked: true,
 | 
			
		||||
        },
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('renders blocked icon if issue is blocked', () => {
 | 
			
		||||
      expect(wrapper.find('.issue-blocked-icon').exists()).toBe(true);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('filterByLabel method', () => {
 | 
			
		||||
    beforeEach(() => {
 | 
			
		||||
      delete window.location;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,30 @@
 | 
			
		|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
 | 
			
		||||
 | 
			
		||||
exports[`BoardBlockedIcon on mouseenter on blocked icon with more than three blocking issues matches the snapshot 1`] = `
 | 
			
		||||
"<div class=\\"gl-display-inline\\"><svg data-testid=\\"issue-blocked-icon\\" aria-hidden=\\"true\\" class=\\"issue-blocked-icon gl-mr-2 gl-cursor-pointer gl-icon s16\\" id=\\"blocked-icon-uniqueId\\">
 | 
			
		||||
    <use href=\\"#issue-block\\"></use>
 | 
			
		||||
  </svg>
 | 
			
		||||
  <div class=\\"gl-popover\\">
 | 
			
		||||
    <ul class=\\"gl-list-style-none gl-p-0\\">
 | 
			
		||||
      <li><a href=\\"http://gdk.test:3000/gitlab-org/my-project-1/-/issues/6\\" class=\\"gl-link gl-text-blue-500! gl-font-sm\\">my-project-1#6</a>
 | 
			
		||||
        <p data-testid=\\"issuable-title\\" class=\\"gl-mb-3 gl-display-block!\\">
 | 
			
		||||
          blocking issue title 1
 | 
			
		||||
        </p>
 | 
			
		||||
      </li>
 | 
			
		||||
      <li><a href=\\"http://gdk.test:3000/gitlab-org/my-project-1/-/issues/5\\" class=\\"gl-link gl-text-blue-500! gl-font-sm\\">my-project-1#5</a>
 | 
			
		||||
        <p data-testid=\\"issuable-title\\" class=\\"gl-mb-3 gl-display-block!\\">
 | 
			
		||||
          blocking issue title 2 + blocking issue title 2 + blocking issue title 2 + bloc…
 | 
			
		||||
        </p>
 | 
			
		||||
      </li>
 | 
			
		||||
      <li><a href=\\"http://gdk.test:3000/gitlab-org/my-project-1/-/issues/4\\" class=\\"gl-link gl-text-blue-500! gl-font-sm\\">my-project-1#4</a>
 | 
			
		||||
        <p data-testid=\\"issuable-title\\" class=\\"gl-mb-3 gl-display-block!\\">
 | 
			
		||||
          blocking issue title 3
 | 
			
		||||
        </p>
 | 
			
		||||
      </li>
 | 
			
		||||
    </ul>
 | 
			
		||||
    <div class=\\"gl-mt-4\\">
 | 
			
		||||
      <p data-testid=\\"hidden-blocking-count\\" class=\\"gl-mb-3\\">+ 1 more issue</p> <a data-testid=\\"view-all-issues\\" href=\\"http://gdk.test:3000/gitlab-org/my-project-1/-/issues/0#related-issues\\" class=\\"gl-link gl-text-blue-500! gl-font-sm\\">View all blocking issues</a>
 | 
			
		||||
    </div><span data-testid=\\"popover-title\\">Blocked by 4 issues</span>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>"
 | 
			
		||||
`;
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,226 @@
 | 
			
		|||
import { GlIcon, GlLink, GlPopover, GlLoadingIcon } from '@gitlab/ui';
 | 
			
		||||
import { shallowMount, mount } from '@vue/test-utils';
 | 
			
		||||
import Vue from 'vue';
 | 
			
		||||
import VueApollo from 'vue-apollo';
 | 
			
		||||
import createMockApollo from 'helpers/mock_apollo_helper';
 | 
			
		||||
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
 | 
			
		||||
import waitForPromises from 'helpers/wait_for_promises';
 | 
			
		||||
import BoardBlockedIcon from '~/boards/components/board_blocked_icon.vue';
 | 
			
		||||
import { blockingIssuablesQueries, issuableTypes } from '~/boards/constants';
 | 
			
		||||
import { truncate } from '~/lib/utils/text_utility';
 | 
			
		||||
import {
 | 
			
		||||
  mockIssue,
 | 
			
		||||
  mockBlockingIssue1,
 | 
			
		||||
  mockBlockingIssue2,
 | 
			
		||||
  mockBlockingIssuablesResponse1,
 | 
			
		||||
  mockBlockingIssuablesResponse2,
 | 
			
		||||
  mockBlockingIssuablesResponse3,
 | 
			
		||||
  mockBlockedIssue1,
 | 
			
		||||
  mockBlockedIssue2,
 | 
			
		||||
} from '../mock_data';
 | 
			
		||||
 | 
			
		||||
describe('BoardBlockedIcon', () => {
 | 
			
		||||
  let wrapper;
 | 
			
		||||
  let mockApollo;
 | 
			
		||||
 | 
			
		||||
  const findGlIcon = () => wrapper.find(GlIcon);
 | 
			
		||||
  const findGlPopover = () => wrapper.find(GlPopover);
 | 
			
		||||
  const findGlLink = () => wrapper.find(GlLink);
 | 
			
		||||
  const findPopoverTitle = () => wrapper.findByTestId('popover-title');
 | 
			
		||||
  const findIssuableTitle = () => wrapper.findByTestId('issuable-title');
 | 
			
		||||
  const findHiddenBlockingCount = () => wrapper.findByTestId('hidden-blocking-count');
 | 
			
		||||
  const findViewAllIssuableLink = () => wrapper.findByTestId('view-all-issues');
 | 
			
		||||
 | 
			
		||||
  const waitForApollo = async () => {
 | 
			
		||||
    jest.runOnlyPendingTimers();
 | 
			
		||||
    await waitForPromises();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const mouseenter = async () => {
 | 
			
		||||
    findGlIcon().vm.$emit('mouseenter');
 | 
			
		||||
 | 
			
		||||
    await wrapper.vm.$nextTick();
 | 
			
		||||
    await waitForApollo();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  afterEach(() => {
 | 
			
		||||
    wrapper.destroy();
 | 
			
		||||
    wrapper = null;
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const createWrapperWithApollo = ({
 | 
			
		||||
    item = mockBlockedIssue1,
 | 
			
		||||
    blockingIssuablesSpy = jest.fn().mockResolvedValue(mockBlockingIssuablesResponse1),
 | 
			
		||||
  } = {}) => {
 | 
			
		||||
    mockApollo = createMockApollo([
 | 
			
		||||
      [blockingIssuablesQueries[issuableTypes.issue].query, blockingIssuablesSpy],
 | 
			
		||||
    ]);
 | 
			
		||||
 | 
			
		||||
    Vue.use(VueApollo);
 | 
			
		||||
    wrapper = extendedWrapper(
 | 
			
		||||
      mount(BoardBlockedIcon, {
 | 
			
		||||
        apolloProvider: mockApollo,
 | 
			
		||||
        propsData: {
 | 
			
		||||
          item: {
 | 
			
		||||
            ...mockIssue,
 | 
			
		||||
            ...item,
 | 
			
		||||
          },
 | 
			
		||||
          uniqueId: 'uniqueId',
 | 
			
		||||
          issuableType: issuableTypes.issue,
 | 
			
		||||
        },
 | 
			
		||||
        attachTo: document.body,
 | 
			
		||||
      }),
 | 
			
		||||
    );
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const createWrapper = ({ item = {}, queries = {}, data = {}, loading = false } = {}) => {
 | 
			
		||||
    wrapper = extendedWrapper(
 | 
			
		||||
      shallowMount(BoardBlockedIcon, {
 | 
			
		||||
        propsData: {
 | 
			
		||||
          item: {
 | 
			
		||||
            ...mockIssue,
 | 
			
		||||
            ...item,
 | 
			
		||||
          },
 | 
			
		||||
          uniqueId: 'uniqueid',
 | 
			
		||||
          issuableType: issuableTypes.issue,
 | 
			
		||||
        },
 | 
			
		||||
        data() {
 | 
			
		||||
          return {
 | 
			
		||||
            ...data,
 | 
			
		||||
          };
 | 
			
		||||
        },
 | 
			
		||||
        mocks: {
 | 
			
		||||
          $apollo: {
 | 
			
		||||
            queries: {
 | 
			
		||||
              blockingIssuables: { loading },
 | 
			
		||||
              ...queries,
 | 
			
		||||
            },
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        stubs: {
 | 
			
		||||
          GlPopover,
 | 
			
		||||
        },
 | 
			
		||||
        attachTo: document.body,
 | 
			
		||||
      }),
 | 
			
		||||
    );
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  it('should render blocked icon', () => {
 | 
			
		||||
    createWrapper();
 | 
			
		||||
 | 
			
		||||
    expect(findGlIcon().exists()).toBe(true);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('should display a loading spinner while loading', () => {
 | 
			
		||||
    createWrapper({ loading: true });
 | 
			
		||||
 | 
			
		||||
    expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('should not query for blocking issuables by default', async () => {
 | 
			
		||||
    createWrapperWithApollo();
 | 
			
		||||
 | 
			
		||||
    expect(findGlPopover().text()).not.toContain(mockBlockingIssue1.title);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('on mouseenter on blocked icon', () => {
 | 
			
		||||
    it('should query for blocking issuables and render the result', async () => {
 | 
			
		||||
      createWrapperWithApollo();
 | 
			
		||||
 | 
			
		||||
      expect(findGlPopover().text()).not.toContain(mockBlockingIssue1.title);
 | 
			
		||||
 | 
			
		||||
      await mouseenter();
 | 
			
		||||
 | 
			
		||||
      expect(findGlPopover().exists()).toBe(true);
 | 
			
		||||
      expect(findIssuableTitle().text()).toContain(mockBlockingIssue1.title);
 | 
			
		||||
      expect(wrapper.vm.skip).toBe(true);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should emit "blocking-issuables-error" event on query error', async () => {
 | 
			
		||||
      const mockError = new Error('mayday');
 | 
			
		||||
      createWrapperWithApollo({ blockingIssuablesSpy: jest.fn().mockRejectedValue(mockError) });
 | 
			
		||||
 | 
			
		||||
      await mouseenter();
 | 
			
		||||
 | 
			
		||||
      const [
 | 
			
		||||
        [
 | 
			
		||||
          {
 | 
			
		||||
            message,
 | 
			
		||||
            error: { networkError },
 | 
			
		||||
          },
 | 
			
		||||
        ],
 | 
			
		||||
      ] = wrapper.emitted('blocking-issuables-error');
 | 
			
		||||
      expect(message).toBe('Failed to fetch blocking issues');
 | 
			
		||||
      expect(networkError).toBe(mockError);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    describe('with a single blocking issue', () => {
 | 
			
		||||
      beforeEach(async () => {
 | 
			
		||||
        createWrapperWithApollo();
 | 
			
		||||
 | 
			
		||||
        await mouseenter();
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      it('should render a title of the issuable', async () => {
 | 
			
		||||
        expect(findIssuableTitle().text()).toBe(mockBlockingIssue1.title);
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      it('should render issuable reference and link to the issuable', async () => {
 | 
			
		||||
        const formattedRef = mockBlockingIssue1.reference.split('/')[1];
 | 
			
		||||
 | 
			
		||||
        expect(findGlLink().text()).toBe(formattedRef);
 | 
			
		||||
        expect(findGlLink().attributes('href')).toBe(mockBlockingIssue1.webUrl);
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      it('should render popover title with correct blocking issuable count', async () => {
 | 
			
		||||
        expect(findPopoverTitle().text()).toBe('Blocked by 1 issue');
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    describe('when issue has a long title', () => {
 | 
			
		||||
      it('should render a truncated title', async () => {
 | 
			
		||||
        createWrapperWithApollo({
 | 
			
		||||
          blockingIssuablesSpy: jest.fn().mockResolvedValue(mockBlockingIssuablesResponse2),
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        await mouseenter();
 | 
			
		||||
 | 
			
		||||
        const truncatedTitle = truncate(
 | 
			
		||||
          mockBlockingIssue2.title,
 | 
			
		||||
          wrapper.vm.$options.textTruncateWidth,
 | 
			
		||||
        );
 | 
			
		||||
        expect(findIssuableTitle().text()).toBe(truncatedTitle);
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    describe('with more than three blocking issues', () => {
 | 
			
		||||
      beforeEach(async () => {
 | 
			
		||||
        createWrapperWithApollo({
 | 
			
		||||
          item: mockBlockedIssue2,
 | 
			
		||||
          blockingIssuablesSpy: jest.fn().mockResolvedValue(mockBlockingIssuablesResponse3),
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        await mouseenter();
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      it('matches the snapshot', () => {
 | 
			
		||||
        expect(wrapper.html()).toMatchSnapshot();
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      it('should render popover title with correct blocking issuable count', async () => {
 | 
			
		||||
        expect(findPopoverTitle().text()).toBe('Blocked by 4 issues');
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      it('should render the number of hidden blocking issuables', () => {
 | 
			
		||||
        expect(findHiddenBlockingCount().text()).toBe('+ 1 more issue');
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      it('should link to the blocked issue page at the related issue anchor', async () => {
 | 
			
		||||
        expect(findViewAllIssuableLink().text()).toBe('View all blocking issues');
 | 
			
		||||
        expect(findViewAllIssuableLink().attributes('href')).toBe(
 | 
			
		||||
          `${mockBlockedIssue2.webUrl}#related-issues`,
 | 
			
		||||
        );
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,125 @@
 | 
			
		|||
import { GlDrawer } from '@gitlab/ui';
 | 
			
		||||
import { shallowMount } from '@vue/test-utils';
 | 
			
		||||
import Vuex from 'vuex';
 | 
			
		||||
import { stubComponent } from 'helpers/stub_component';
 | 
			
		||||
import BoardContentSidebar from '~/boards/components/board_content_sidebar.vue';
 | 
			
		||||
import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue';
 | 
			
		||||
import BoardSidebarIssueTitle from '~/boards/components/sidebar/board_sidebar_issue_title.vue';
 | 
			
		||||
import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
 | 
			
		||||
import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sidebar_milestone_select.vue';
 | 
			
		||||
import BoardSidebarSubscription from '~/boards/components/sidebar/board_sidebar_subscription.vue';
 | 
			
		||||
import { ISSUABLE } from '~/boards/constants';
 | 
			
		||||
import { mockIssue, mockIssueGroupPath, mockIssueProjectPath } from '../mock_data';
 | 
			
		||||
 | 
			
		||||
describe('BoardContentSidebar', () => {
 | 
			
		||||
  let wrapper;
 | 
			
		||||
  let store;
 | 
			
		||||
 | 
			
		||||
  const createStore = ({ mockGetters = {}, mockActions = {} } = {}) => {
 | 
			
		||||
    store = new Vuex.Store({
 | 
			
		||||
      state: {
 | 
			
		||||
        sidebarType: ISSUABLE,
 | 
			
		||||
        issues: { [mockIssue.id]: mockIssue },
 | 
			
		||||
        activeId: mockIssue.id,
 | 
			
		||||
        issuableType: 'issue',
 | 
			
		||||
      },
 | 
			
		||||
      getters: {
 | 
			
		||||
        activeIssue: () => mockIssue,
 | 
			
		||||
        groupPathForActiveIssue: () => mockIssueGroupPath,
 | 
			
		||||
        projectPathForActiveIssue: () => mockIssueProjectPath,
 | 
			
		||||
        isSidebarOpen: () => true,
 | 
			
		||||
        ...mockGetters,
 | 
			
		||||
      },
 | 
			
		||||
      actions: mockActions,
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const createComponent = () => {
 | 
			
		||||
    wrapper = shallowMount(BoardContentSidebar, {
 | 
			
		||||
      provide: {
 | 
			
		||||
        canUpdate: true,
 | 
			
		||||
        rootPath: '/',
 | 
			
		||||
        groupId: '#',
 | 
			
		||||
      },
 | 
			
		||||
      store,
 | 
			
		||||
      stubs: {
 | 
			
		||||
        GlDrawer: stubComponent(GlDrawer, {
 | 
			
		||||
          template: '<div><slot name="header"></slot><slot></slot></div>',
 | 
			
		||||
        }),
 | 
			
		||||
      },
 | 
			
		||||
      mocks: {
 | 
			
		||||
        $apollo: {
 | 
			
		||||
          queries: {
 | 
			
		||||
            participants: {
 | 
			
		||||
              loading: false,
 | 
			
		||||
            },
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  beforeEach(() => {
 | 
			
		||||
    createStore();
 | 
			
		||||
    createComponent();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  afterEach(() => {
 | 
			
		||||
    wrapper.destroy();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('confirms we render GlDrawer', () => {
 | 
			
		||||
    expect(wrapper.find(GlDrawer).exists()).toBe(true);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('does not render GlDrawer when isSidebarOpen is false', () => {
 | 
			
		||||
    createStore({ mockGetters: { isSidebarOpen: () => false } });
 | 
			
		||||
    createComponent();
 | 
			
		||||
 | 
			
		||||
    expect(wrapper.find(GlDrawer).exists()).toBe(false);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('applies an open attribute', () => {
 | 
			
		||||
    expect(wrapper.find(GlDrawer).props('open')).toBe(true);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('renders BoardSidebarLabelsSelect', () => {
 | 
			
		||||
    expect(wrapper.find(BoardSidebarLabelsSelect).exists()).toBe(true);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('renders BoardSidebarIssueTitle', () => {
 | 
			
		||||
    expect(wrapper.find(BoardSidebarIssueTitle).exists()).toBe(true);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('renders BoardSidebarDueDate', () => {
 | 
			
		||||
    expect(wrapper.find(BoardSidebarDueDate).exists()).toBe(true);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('renders BoardSidebarSubscription', () => {
 | 
			
		||||
    expect(wrapper.find(BoardSidebarSubscription).exists()).toBe(true);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('renders BoardSidebarMilestoneSelect', () => {
 | 
			
		||||
    expect(wrapper.find(BoardSidebarMilestoneSelect).exists()).toBe(true);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('when we emit close', () => {
 | 
			
		||||
    let toggleBoardItem;
 | 
			
		||||
 | 
			
		||||
    beforeEach(() => {
 | 
			
		||||
      toggleBoardItem = jest.fn();
 | 
			
		||||
      createStore({ mockActions: { toggleBoardItem } });
 | 
			
		||||
      createComponent();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('calls toggleBoardItem with correct parameters', async () => {
 | 
			
		||||
      wrapper.find(GlDrawer).vm.$emit('close');
 | 
			
		||||
 | 
			
		||||
      expect(toggleBoardItem).toHaveBeenCalledTimes(1);
 | 
			
		||||
      expect(toggleBoardItem).toHaveBeenCalledWith(expect.any(Object), {
 | 
			
		||||
        boardItem: mockIssue,
 | 
			
		||||
        sidebarType: ISSUABLE,
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,58 @@
 | 
			
		|||
/*
 | 
			
		||||
    To avoid duplicating tests in time_tracker.spec,
 | 
			
		||||
    this spec only contains a simple test to check rendering.
 | 
			
		||||
 | 
			
		||||
    A detailed feature spec is used to test time tracking feature
 | 
			
		||||
    in swimlanes sidebar.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
import { shallowMount } from '@vue/test-utils';
 | 
			
		||||
import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue';
 | 
			
		||||
import { createStore } from '~/boards/stores';
 | 
			
		||||
import IssuableTimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue';
 | 
			
		||||
 | 
			
		||||
describe('BoardSidebarTimeTracker', () => {
 | 
			
		||||
  let wrapper;
 | 
			
		||||
  let store;
 | 
			
		||||
 | 
			
		||||
  const createComponent = (options) => {
 | 
			
		||||
    wrapper = shallowMount(BoardSidebarTimeTracker, {
 | 
			
		||||
      store,
 | 
			
		||||
      ...options,
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  beforeEach(() => {
 | 
			
		||||
    store = createStore();
 | 
			
		||||
    store.state.boardItems = {
 | 
			
		||||
      1: {
 | 
			
		||||
        timeEstimate: 3600,
 | 
			
		||||
        totalTimeSpent: 1800,
 | 
			
		||||
        humanTimeEstimate: '1h',
 | 
			
		||||
        humanTotalTimeSpent: '30min',
 | 
			
		||||
      },
 | 
			
		||||
    };
 | 
			
		||||
    store.state.activeId = '1';
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  afterEach(() => {
 | 
			
		||||
    wrapper.destroy();
 | 
			
		||||
    wrapper = null;
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it.each([[true], [false]])(
 | 
			
		||||
    'renders IssuableTimeTracker with correct spent and estimated time (timeTrackingLimitToHours=%s)',
 | 
			
		||||
    (timeTrackingLimitToHours) => {
 | 
			
		||||
      createComponent({ provide: { timeTrackingLimitToHours } });
 | 
			
		||||
 | 
			
		||||
      expect(wrapper.find(IssuableTimeTracker).props()).toEqual({
 | 
			
		||||
        timeEstimate: 3600,
 | 
			
		||||
        timeSpent: 1800,
 | 
			
		||||
        humanTimeEstimate: '1h',
 | 
			
		||||
        humanTimeSpent: '30min',
 | 
			
		||||
        limitToHours: timeTrackingLimitToHours,
 | 
			
		||||
        showCollapsed: false,
 | 
			
		||||
      });
 | 
			
		||||
    },
 | 
			
		||||
  );
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -125,7 +125,7 @@ export const labels = [
 | 
			
		|||
export const rawIssue = {
 | 
			
		||||
  title: 'Issue 1',
 | 
			
		||||
  id: 'gid://gitlab/Issue/436',
 | 
			
		||||
  iid: 27,
 | 
			
		||||
  iid: '27',
 | 
			
		||||
  dueDate: null,
 | 
			
		||||
  timeEstimate: 0,
 | 
			
		||||
  weight: null,
 | 
			
		||||
| 
						 | 
				
			
			@ -152,7 +152,7 @@ export const rawIssue = {
 | 
			
		|||
 | 
			
		||||
export const mockIssue = {
 | 
			
		||||
  id: 'gid://gitlab/Issue/436',
 | 
			
		||||
  iid: 27,
 | 
			
		||||
  iid: '27',
 | 
			
		||||
  title: 'Issue 1',
 | 
			
		||||
  dueDate: null,
 | 
			
		||||
  timeEstimate: 0,
 | 
			
		||||
| 
						 | 
				
			
			@ -398,3 +398,93 @@ export const mockActiveGroupProjects = [
 | 
			
		|||
  { ...mockGroupProject1, archived: false },
 | 
			
		||||
  { ...mockGroupProject2, archived: false },
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
export const mockIssueGroupPath = 'gitlab-org';
 | 
			
		||||
export const mockIssueProjectPath = `${mockIssueGroupPath}/gitlab-test`;
 | 
			
		||||
 | 
			
		||||
export const mockBlockingIssue1 = {
 | 
			
		||||
  id: 'gid://gitlab/Issue/525',
 | 
			
		||||
  iid: '6',
 | 
			
		||||
  title: 'blocking issue title 1',
 | 
			
		||||
  reference: 'gitlab-org/my-project-1#6',
 | 
			
		||||
  webUrl: 'http://gdk.test:3000/gitlab-org/my-project-1/-/issues/6',
 | 
			
		||||
  __typename: 'Issue',
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const mockBlockingIssue2 = {
 | 
			
		||||
  id: 'gid://gitlab/Issue/524',
 | 
			
		||||
  iid: '5',
 | 
			
		||||
  title:
 | 
			
		||||
    'blocking issue title 2 + blocking issue title 2 + blocking issue title 2 + blocking issue title 2',
 | 
			
		||||
  reference: 'gitlab-org/my-project-1#5',
 | 
			
		||||
  webUrl: 'http://gdk.test:3000/gitlab-org/my-project-1/-/issues/5',
 | 
			
		||||
  __typename: 'Issue',
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const mockBlockingIssue3 = {
 | 
			
		||||
  id: 'gid://gitlab/Issue/523',
 | 
			
		||||
  iid: '4',
 | 
			
		||||
  title: 'blocking issue title 3',
 | 
			
		||||
  reference: 'gitlab-org/my-project-1#4',
 | 
			
		||||
  webUrl: 'http://gdk.test:3000/gitlab-org/my-project-1/-/issues/4',
 | 
			
		||||
  __typename: 'Issue',
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const mockBlockingIssue4 = {
 | 
			
		||||
  id: 'gid://gitlab/Issue/522',
 | 
			
		||||
  iid: '3',
 | 
			
		||||
  title: 'blocking issue title 4',
 | 
			
		||||
  reference: 'gitlab-org/my-project-1#3',
 | 
			
		||||
  webUrl: 'http://gdk.test:3000/gitlab-org/my-project-1/-/issues/3',
 | 
			
		||||
  __typename: 'Issue',
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const mockBlockingIssuablesResponse1 = {
 | 
			
		||||
  data: {
 | 
			
		||||
    issuable: {
 | 
			
		||||
      __typename: 'Issue',
 | 
			
		||||
      id: 'gid://gitlab/Issue/527',
 | 
			
		||||
      blockingIssuables: {
 | 
			
		||||
        __typename: 'IssueConnection',
 | 
			
		||||
        nodes: [mockBlockingIssue1],
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const mockBlockingIssuablesResponse2 = {
 | 
			
		||||
  data: {
 | 
			
		||||
    issuable: {
 | 
			
		||||
      __typename: 'Issue',
 | 
			
		||||
      id: 'gid://gitlab/Issue/527',
 | 
			
		||||
      blockingIssuables: {
 | 
			
		||||
        __typename: 'IssueConnection',
 | 
			
		||||
        nodes: [mockBlockingIssue2],
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const mockBlockingIssuablesResponse3 = {
 | 
			
		||||
  data: {
 | 
			
		||||
    issuable: {
 | 
			
		||||
      __typename: 'Issue',
 | 
			
		||||
      id: 'gid://gitlab/Issue/527',
 | 
			
		||||
      blockingIssuables: {
 | 
			
		||||
        __typename: 'IssueConnection',
 | 
			
		||||
        nodes: [mockBlockingIssue1, mockBlockingIssue2, mockBlockingIssue3, mockBlockingIssue4],
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const mockBlockedIssue1 = {
 | 
			
		||||
  id: '527',
 | 
			
		||||
  blockedByCount: 1,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const mockBlockedIssue2 = {
 | 
			
		||||
  id: '527',
 | 
			
		||||
  blockedByCount: 4,
 | 
			
		||||
  webUrl: 'http://gdk.test:3000/gitlab-org/my-project-1/-/issues/0',
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,3 +1,4 @@
 | 
			
		|||
import * as Sentry from '@sentry/browser';
 | 
			
		||||
import testAction from 'helpers/vuex_action_helper';
 | 
			
		||||
import {
 | 
			
		||||
  fullBoardId,
 | 
			
		||||
| 
						 | 
				
			
			@ -1378,6 +1379,51 @@ describe('toggleBoardItem', () => {
 | 
			
		|||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
describe('setError', () => {
 | 
			
		||||
  it('should commit mutation SET_ERROR', () => {
 | 
			
		||||
    testAction({
 | 
			
		||||
      action: actions.setError,
 | 
			
		||||
      payload: { message: 'mayday' },
 | 
			
		||||
      expectedMutations: [
 | 
			
		||||
        {
 | 
			
		||||
          payload: 'mayday',
 | 
			
		||||
          type: types.SET_ERROR,
 | 
			
		||||
        },
 | 
			
		||||
      ],
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('should capture error using Sentry when captureError is true', () => {
 | 
			
		||||
    jest.spyOn(Sentry, 'captureException');
 | 
			
		||||
 | 
			
		||||
    const mockError = new Error();
 | 
			
		||||
    actions.setError(
 | 
			
		||||
      { commit: () => {} },
 | 
			
		||||
      {
 | 
			
		||||
        message: 'mayday',
 | 
			
		||||
        error: mockError,
 | 
			
		||||
        captureError: true,
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    expect(Sentry.captureException).toHaveBeenNthCalledWith(1, mockError);
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
describe('unsetError', () => {
 | 
			
		||||
  it('should commit mutation SET_ERROR with undefined as payload', () => {
 | 
			
		||||
    testAction({
 | 
			
		||||
      action: actions.unsetError,
 | 
			
		||||
      expectedMutations: [
 | 
			
		||||
        {
 | 
			
		||||
          payload: undefined,
 | 
			
		||||
          type: types.SET_ERROR,
 | 
			
		||||
        },
 | 
			
		||||
      ],
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
describe('fetchBacklog', () => {
 | 
			
		||||
  expectNotImplemented(actions.fetchBacklog);
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -666,4 +666,14 @@ describe('Board Store Mutations', () => {
 | 
			
		|||
      expect(state.selectedBoardItems).toEqual([]);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('SET_ERROR', () => {
 | 
			
		||||
    it('Should set error state', () => {
 | 
			
		||||
      state.error = undefined;
 | 
			
		||||
 | 
			
		||||
      mutations[types.SET_ERROR](state, 'mayday');
 | 
			
		||||
 | 
			
		||||
      expect(state.error).toBe('mayday');
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -105,7 +105,7 @@ RSpec.describe NotificationService, :mailer do
 | 
			
		|||
      recipient_1 = NotificationRecipient.new(user_1, :custom, custom_action: :new_release)
 | 
			
		||||
      allow(NotificationRecipients::BuildService).to receive(:build_new_release_recipients).and_return([recipient_1])
 | 
			
		||||
 | 
			
		||||
      expect(Gitlab::AppLogger).to receive(:warn).with(message: 'Skipping sending notifications', user: current_user.id, klass: object.class, object_id: object.id)
 | 
			
		||||
      expect(Gitlab::AppLogger).to receive(:warn).with(message: 'Skipping sending notifications', user: current_user.id, klass: object.class.to_s, object_id: object.id)
 | 
			
		||||
 | 
			
		||||
      action
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -49,7 +49,7 @@ RSpec.describe NewIssueWorker do
 | 
			
		|||
          expect(Notify).not_to receive(:new_issue_email)
 | 
			
		||||
            .with(mentioned.id, issue.id, NotificationReason::MENTIONED)
 | 
			
		||||
 | 
			
		||||
          expect(Gitlab::AppLogger).to receive(:warn).with(message: 'Skipping sending notifications', user: user.id, klass: issue.class, object_id: issue.id)
 | 
			
		||||
          expect(Gitlab::AppLogger).to receive(:warn).with(message: 'Skipping sending notifications', user: user.id, klass: issue.class.to_s, object_id: issue.id)
 | 
			
		||||
 | 
			
		||||
          worker.perform(issue.id, user.id)
 | 
			
		||||
        end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -53,7 +53,7 @@ RSpec.describe NewMergeRequestWorker do
 | 
			
		|||
          expect(Notify).not_to receive(:new_merge_request_email)
 | 
			
		||||
            .with(mentioned.id, merge_request.id, NotificationReason::MENTIONED)
 | 
			
		||||
 | 
			
		||||
          expect(Gitlab::AppLogger).to receive(:warn).with(message: 'Skipping sending notifications', user: user.id, klass: merge_request.class, object_id: merge_request.id)
 | 
			
		||||
          expect(Gitlab::AppLogger).to receive(:warn).with(message: 'Skipping sending notifications', user: user.id, klass: merge_request.class.to_s, object_id: merge_request.id)
 | 
			
		||||
 | 
			
		||||
          worker.perform(merge_request.id, user.id)
 | 
			
		||||
        end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue