Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2023-09-27 03:10:19 +00:00
parent 272c39ac05
commit 0a0dcc392c
72 changed files with 951 additions and 822 deletions

View File

@ -3,4 +3,3 @@ Cop/ExperimentsTestCoverage:
Exclude:
- 'app/controllers/groups/boards_controller.rb'
- 'app/controllers/projects/boards_controller.rb'
- 'app/experiments/build_ios_app_guide_email_experiment.rb'

View File

@ -266,7 +266,6 @@ Layout/LineEndStringConcatenationIndentation:
- 'spec/lib/gitlab/ci/templates/themekit_gitlab_ci_yaml_spec.rb'
- 'spec/lib/gitlab/database/background_migration/batched_migration_runner_spec.rb'
- 'spec/lib/gitlab/database/migrations/batched_background_migration_helpers_spec.rb'
- 'spec/lib/gitlab/email/message/build_ios_app_guide_spec.rb'
- 'spec/lib/gitlab/email/message/in_product_marketing/helper_spec.rb'
- 'spec/lib/gitlab/github_import/markdown_text_spec.rb'
- 'spec/lib/gitlab/github_import/representation/issue_event_spec.rb'

View File

@ -3498,7 +3498,6 @@ RSpec/MissingFeatureCategory:
- 'spec/lib/gitlab/email/hook/delivery_metrics_observer_spec.rb'
- 'spec/lib/gitlab/email/hook/disable_email_interceptor_spec.rb'
- 'spec/lib/gitlab/email/hook/smime_signature_interceptor_spec.rb'
- 'spec/lib/gitlab/email/message/build_ios_app_guide_spec.rb'
- 'spec/lib/gitlab/email/message/in_product_marketing/admin_verify_spec.rb'
- 'spec/lib/gitlab/email/message/in_product_marketing/base_spec.rb'
- 'spec/lib/gitlab/email/message/in_product_marketing/create_spec.rb'

View File

@ -137,7 +137,6 @@ RSpec/ReturnFromStub:
- 'spec/lib/gitlab/daemon_spec.rb'
- 'spec/lib/gitlab/database_importers/instance_administrators/create_group_spec.rb'
- 'spec/lib/gitlab/diff/file_spec.rb'
- 'spec/lib/gitlab/email/message/build_ios_app_guide_spec.rb'
- 'spec/lib/gitlab/email/message/in_product_marketing/helper_spec.rb'
- 'spec/lib/gitlab/exclusive_lease_helpers/sleeping_lock_spec.rb'
- 'spec/lib/gitlab/exclusive_lease_helpers_spec.rb'

View File

@ -87,6 +87,7 @@ export const REPORTED_CONTENT_I18N = {
comment: s__('AbuseReport|Reported comment'),
issue: s__('AbuseReport|Reported issue'),
merge_request: s__('AbuseReport|Reported merge request'),
epic: s__('AbuseReport|Reported epic'),
unknown: s__('AbuseReport|Reported content'),
},
viewScreenshot: s__('AbuseReport|View screenshot'),
@ -96,6 +97,7 @@ export const REPORTED_CONTENT_I18N = {
comment: s__('AbuseReport|Go to comment'),
issue: s__('AbuseReport|Go to issue'),
merge_request: s__('AbuseReport|Go to merge request'),
epic: s__('AbuseReport|Go to epic'),
unknown: s__('AbuseReport|Go to content'),
},
reportedBy: s__('AbuseReport|Reported by'),

View File

@ -26,9 +26,6 @@ export const DEFAULT_FIELDS_ADMIN = [
{ key: 'project', label: __('Project'), columnClass: 'gl-w-20p' },
{ key: 'runner', label: __('Runner'), columnClass: 'gl-w-15p' },
{ key: 'pipeline', label: __('Pipeline'), columnClass: 'gl-w-10p' },
{ key: 'stage', label: __('Stage'), columnClass: 'gl-w-10p' },
{ key: 'name', label: __('Name'), columnClass: 'gl-w-15p' },
{ key: 'duration', label: __('Duration'), columnClass: 'gl-w-15p' },
{ key: 'actions', label: '', columnClass: 'gl-w-10p' },
];

View File

@ -120,7 +120,7 @@ export default {
:class="cssClass"
:disabled="isDisabled"
class="js-ci-action gl-ci-action-icon-container ci-action-icon-container ci-action-icon-wrapper gl-display-flex gl-align-items-center gl-justify-content-center"
data-testid="ci-action-component"
data-testid="ci-action-button"
@click.stop="onClickAction"
>
<div

View File

@ -35,9 +35,6 @@ export default {
jobRef() {
return this.job?.refName;
},
jobRefPath() {
return this.job?.refPath;
},
jobTags() {
return this.job.tags;
},
@ -72,61 +69,60 @@ export default {
<template>
<div>
<div class="gl-text-truncate gl-p-3 gl-mt-n3 gl-mx-n3 gl-mb-n2">
<gl-icon
v-if="jobStuck"
v-gl-tooltip="$options.i18n.stuckText"
name="warning"
:size="$options.iconSize"
class="gl-mr-2"
data-testid="stuck-icon"
/>
<gl-link
v-if="canReadJob"
class="gl-text-blue-600!"
:href="jobPath"
data-testid="job-id-link"
>
{{ jobId }}
<span class="gl-text-truncate">
<span data-testid="job-name">{{ jobId }}: {{ job.name }}</span>
</span>
</gl-link>
<span v-else data-testid="job-id-limited-access">{{ jobId }}</span>
<span v-else data-testid="job-id-limited-access">{{ jobId }}: {{ job.name }}</span>
</div>
<gl-icon
v-if="jobStuck"
v-gl-tooltip="$options.i18n.stuckText"
name="warning"
:size="$options.iconSize"
data-testid="stuck-icon"
/>
<div
class="gl-display-flex gl-text-gray-700 gl-align-items-center gl-lg-justify-content-start gl-justify-content-end gl-mt-2"
>
<div
v-if="jobRef"
class="gl-p-2 gl-rounded-base gl-bg-gray-50 gl-max-w-15 gl-text-truncate"
<div
class="gl-display-flex gl-text-gray-700 gl-align-items-center gl-lg-justify-content-start gl-justify-content-end gl-mt-1"
>
<div v-if="jobRef" class="gl-p-2 gl-rounded-base gl-bg-gray-50 gl-max-w-26 gl-text-truncate">
<gl-icon
v-if="createdByTag"
name="label"
:size="$options.iconSize"
data-testid="label-icon"
/>
<gl-icon v-else name="fork" :size="$options.iconSize" data-testid="fork-icon" />
<gl-link
class="gl-font-sm gl-font-monospace gl-text-gray-700 gl-hover-text-gray-900"
:href="job.refPath"
data-testid="job-ref"
>{{ job.refName }}</gl-link
>
</div>
<span v-else>{{ __('none') }}</span>
<div class="gl-ml-2 gl-p-2 gl-rounded-base gl-bg-gray-50">
<gl-icon class="gl-mx-2" name="commit" :size="$options.iconSize" />
<gl-link
class="gl-font-sm gl-font-monospace gl-text-gray-700 gl-hover-text-gray-900"
:href="job.commitPath"
data-testid="job-sha"
>{{ job.shortSha }}</gl-link
>
<gl-icon
v-if="createdByTag"
name="label"
:size="$options.iconSize"
data-testid="label-icon"
/>
<gl-icon v-else name="fork" :size="$options.iconSize" data-testid="fork-icon" />
<gl-link
class="gl-font-sm gl-font-monospace gl-text-gray-700 gl-hover-text-gray-900"
:href="job.refPath"
data-testid="job-ref"
>{{ job.refName }}</gl-link
>
</div>
<span v-else>{{ __('none') }}</span>
<div class="gl-ml-2 gl-p-2 gl-rounded-base gl-bg-gray-50">
<gl-icon class="gl-mx-2" name="commit" :size="$options.iconSize" />
<gl-link
class="gl-font-sm gl-font-monospace gl-text-gray-700 gl-hover-text-gray-900"
:href="job.commitPath"
data-testid="job-sha"
>{{ job.shortSha }}</gl-link
>
</div>
</div>
</div>
<div>
<div class="gl-mt-2">
<gl-badge
v-for="tag in jobTags"
:key="tag"
@ -136,7 +132,6 @@ export default {
>
{{ tag }}
</gl-badge>
<gl-badge
v-if="triggered"
variant="info"

View File

@ -1,8 +1,12 @@
<script>
import { GlAvatar, GlLink } from '@gitlab/ui';
import { s__ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
export default {
i18n: {
stageLabel: s__('Jobs|Stage'),
},
components: {
GlAvatar,
GlLink,
@ -36,21 +40,22 @@ export default {
<template>
<div>
<div class="gl-p-3 gl-mt-n3">
<gl-link
class="gl-text-truncate gl-ml-n3 gl-text-gray-500!"
:href="pipelinePath"
data-testid="pipeline-id"
>
<div class="gl-p-3 gl-mt-n3 gl-mx-n3">
<gl-link class="gl-text-truncate" :href="pipelinePath" data-testid="pipeline-id">
{{ pipelineId }}
</gl-link>
<span class="gl-text-secondary">
<span>{{ __('created by') }}</span>
<gl-link v-if="showAvatar" :href="userPath" data-testid="pipeline-user-link">
<gl-avatar :src="pipelineUserAvatar" :size="16" />
</gl-link>
<span v-else>{{ __('API') }}</span>
</span>
</div>
<div class="gl-font-sm gl-text-secondary gl-mt-n2">
<span>{{ __('created by') }}</span>
<gl-link v-if="showAvatar" :href="userPath" data-testid="pipeline-user-link">
<gl-avatar :src="pipelineUserAvatar" :size="16" />
</gl-link>
<span v-else>{{ __('API') }}</span>
<div v-if="job.stage" class="gl-text-truncate gl-font-sm gl-text-secondary gl-mt-1">
<span data-testid="job-stage-name">{{ $options.i18n.stageLabel }}: {{ job.stage.name }}</span>
</div>
</div>
</template>

View File

@ -1,12 +1,14 @@
<script>
import { GlIcon } from '@gitlab/ui';
import { formatTime } from '~/lib/utils/datetime_utility';
import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
export default {
iconSize: 12,
components: {
CiBadgeLink,
GlIcon,
TimeAgoTooltip,
},
@ -36,17 +38,16 @@ export default {
<template>
<div>
<div v-if="duration" data-testid="job-duration">
<gl-icon name="timer" :size="$options.iconSize" data-testid="duration-icon" />
{{ durationFormatted }}
</div>
<div
v-if="finishedTime"
:class="{ 'gl-mt-2': hasDurationAndFinishedTime }"
data-testid="job-finished-time"
>
<gl-icon name="calendar" :size="$options.iconSize" data-testid="finished-time-icon" />
<time-ago-tooltip :time="finishedTime" />
<ci-badge-link :status="job.detailedStatus" />
<div class="gl-font-sm gl-text-secondary gl-mt-2 gl-ml-3">
<div v-if="duration" data-testid="job-duration">
<gl-icon name="timer" :size="$options.iconSize" data-testid="duration-icon" />
{{ durationFormatted }}
</div>
<div v-if="finishedTime" data-testid="job-finished-time">
<gl-icon name="calendar" :size="$options.iconSize" data-testid="finished-time-icon" />
<time-ago-tooltip :time="finishedTime" />
</div>
</div>
</div>
</template>

View File

@ -1,12 +1,11 @@
<script>
import { GlTable } from '@gitlab/ui';
import { s__ } from '~/locale';
import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue';
import ProjectCell from '~/ci/admin/jobs_table/components/cells/project_cell.vue';
import RunnerCell from '~/ci/admin/jobs_table/components/cells/runner_cell.vue';
import { DEFAULT_FIELDS } from '../constants';
import { JOBS_DEFAULT_FIELDS } from '../constants';
import ActionsCell from './job_cells/actions_cell.vue';
import DurationCell from './job_cells/duration_cell.vue';
import StatusCell from './job_cells/status_cell.vue';
import JobCell from './job_cells/job_cell.vue';
import PipelineCell from './job_cells/pipeline_cell.vue';
@ -16,13 +15,12 @@ export default {
},
components: {
ActionsCell,
CiBadgeLink,
DurationCell,
GlTable,
StatusCell,
JobCell,
PipelineCell,
ProjectCell,
RunnerCell,
GlTable,
},
props: {
jobs: {
@ -32,7 +30,7 @@ export default {
tableFields: {
type: Array,
required: false,
default: () => DEFAULT_FIELDS,
default: () => JOBS_DEFAULT_FIELDS,
},
admin: {
type: Boolean,
@ -64,7 +62,7 @@ export default {
</template>
<template #cell(status)="{ item }">
<ci-badge-link :status="item.detailedStatus" />
<status-cell :job="item" />
</template>
<template #cell(job)="{ item }">
@ -75,6 +73,14 @@ export default {
<pipeline-cell :job="item" />
</template>
<template #cell(stage)="{ item }">
<div class="gl-text-truncate">
<span v-if="item.stage" data-testid="job-stage-name" class="gl-text-secondary">{{
item.stage.name
}}</span>
</div>
</template>
<template v-if="admin" #cell(project)="{ item }">
<project-cell :job="item" />
</template>
@ -83,22 +89,6 @@ export default {
<runner-cell :job="item" />
</template>
<template #cell(stage)="{ item }">
<div class="gl-text-truncate">
<span v-if="item.stage" data-testid="job-stage-name">{{ item.stage.name }}</span>
</div>
</template>
<template #cell(name)="{ item }">
<div class="gl-text-truncate">
<span data-testid="job-name">{{ item.name }}</span>
</div>
</template>
<template #cell(duration)="{ item }">
<duration-cell :job="item" />
</template>
<template #cell(coverage)="{ item }">
<span v-if="item.coverage" data-testid="job-coverage">{{
formatCoverage(item.coverage)

View File

@ -29,6 +29,7 @@ export const PLAY_JOB_CONFIRMATION_MESSAGE = s__(
export const RUN_JOB_NOW_HEADER_TITLE = s__('DelayedJobs|Run the delayed job now?');
/* Table constants */
/* There is another field list based on this one in app/assets/javascripts/ci/admin/jobs_table/constants.js */
export const DEFAULT_FIELDS = [
{
key: 'status',
@ -38,7 +39,7 @@ export const DEFAULT_FIELDS = [
{
key: 'job',
label: __('Job'),
columnClass: 'gl-w-20p',
columnClass: 'gl-w-quarter',
},
{
key: 'pipeline',
@ -50,16 +51,6 @@ export const DEFAULT_FIELDS = [
label: __('Stage'),
columnClass: 'gl-w-10p',
},
{
key: 'name',
label: __('Name'),
columnClass: 'gl-w-15p',
},
{
key: 'duration',
label: __('Duration'),
columnClass: 'gl-w-15p',
},
{
key: 'coverage',
label: __('Coverage'),
@ -69,8 +60,10 @@ export const DEFAULT_FIELDS = [
{
key: 'actions',
label: '',
tdClass: 'gl-text-right',
columnClass: 'gl-w-10p',
},
];
export const JOBS_DEFAULT_FIELDS = DEFAULT_FIELDS.filter((field) => field.key !== 'stage');
export const JOBS_TAB_FIELDS = DEFAULT_FIELDS.filter((field) => field.key !== 'pipeline');

View File

@ -65,7 +65,7 @@ export default {
<div
:id="computedJobId"
class="ci-job-dropdown-container dropdown dropright"
data-qa-selector="job_dropdown_container"
data-testid="job-dropdown-container"
>
<button
type="button"
@ -90,7 +90,7 @@ export default {
<ul
class="dropdown-menu big-pipeline-graph-dropdown-menu js-grouped-pipeline-dropdown"
data-qa-selector="jobs_dropdown_menu"
data-testid="jobs-dropdown-menu"
>
<li class="scrollable-menu">
<ul>

View File

@ -312,7 +312,6 @@ export default {
<div
:id="computedJobId"
class="ci-job-component gl-display-flex gl-justify-content-space-between gl-pipeline-job-width"
data-qa-selector="job_item_container"
>
<component
:is="nameComponent"
@ -326,7 +325,6 @@ export default {
:href="detailsPath"
class="js-pipeline-graph-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none gl-w-full"
:data-testid="testId"
data-qa-selector="job_link"
@click="jobItemClick"
@mouseout="hideTooltips"
>
@ -356,7 +354,6 @@ export default {
class="gl-mr-1"
:should-trigger-click="shouldTriggerActionClick"
:with-confirmation-modal="withConfirmationModal"
data-qa-selector="job_action_button"
@actionButtonClicked="handleConfirmationModalPreferences"
@pipelineActionRequestComplete="pipelineActionRequestComplete"
@showActionConfirmationModal="showActionConfirmationModal"

View File

@ -233,7 +233,7 @@ export default {
ref="linkedPipeline"
class="gl-h-full gl-display-flex! gl-px-2"
:class="flexDirection"
data-qa-selector="linked_pipeline_container"
data-testid="linked-pipeline-container"
@mouseover="onDownstreamHovered"
@mouseleave="onDownstreamHoverLeave"
>
@ -247,11 +247,7 @@ export default {
<div
class="gl-display-flex gl-downstream-pipeline-job-width gl-flex-direction-column gl-line-height-normal"
>
<span
class="gl-text-truncate"
data-testid="downstream-title"
data-qa-selector="downstream_title_content"
>
<span class="gl-text-truncate" data-testid="downstream-title-content">
{{ downstreamTitle }}
</span>
<div class="gl-text-truncate">
@ -294,7 +290,6 @@ export default {
:icon="expandedIcon"
:aria-label="expandBtnText"
data-testid="expand-pipeline-button"
data-qa-selector="expand_linked_pipeline_button"
@mouseover="setExpandBtnActiveState(true)"
@mouseout="setExpandBtnActiveState(false)"
@focus="setExpandBtnActiveState(true)"

View File

@ -179,6 +179,7 @@ export default {
{ 'gl-opacity-3': isFadedOut(group.name) },
'gl-transition-duration-slow gl-transition-timing-function-ease',
]"
data-testid="job-item-container"
@pipelineActionRequestComplete="$emit('refreshPipelineGraph')"
@setSkipRetryModal="$emit('setSkipRetryModal')"
/>

View File

@ -396,11 +396,7 @@ export default {
</div>
</gl-alert>
<gl-loading-icon v-if="loading" class="gl-text-left" size="lg" />
<div
v-else
class="gl-display-flex gl-justify-content-space-between gl-flex-wrap"
data-qa-selector="pipeline_details_header"
>
<div v-else class="gl-display-flex gl-justify-content-space-between gl-flex-wrap">
<div>
<h3 v-if="name" class="gl-mt-0 gl-mb-3" data-testid="pipeline-name">{{ name }}</h3>
<h3 v-else class="gl-mt-0 gl-mb-3" data-testid="pipeline-commit-title">

View File

@ -7,7 +7,6 @@ import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import projectWorkItemsQuery from '../../graphql/project_work_items.query.graphql';
import {
WORK_ITEMS_TYPE_MAP,
WORK_ITEM_TYPE_ENUM_TASK,
I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER,
sprintfWorkItem,
} from '../../constants';
@ -29,7 +28,7 @@ export default {
childrenType: {
type: String,
required: false,
default: WORK_ITEM_TYPE_ENUM_TASK,
default: '',
},
childrenIds: {
type: Array,
@ -53,7 +52,7 @@ export default {
return {
fullPath: this.fullPath,
searchTerm: this.search?.title || this.search,
types: [this.childrenType],
types: this.childrenType ? [this.childrenType] : [],
in: this.search ? 'TITLE' : undefined,
};
},
@ -106,6 +105,7 @@ export default {
},
handleFocus() {
this.searchStarted = true;
this.$emit('searching', true);
},
handleMouseOver() {
this.timeout = setTimeout(() => {
@ -115,11 +115,22 @@ export default {
handleMouseOut() {
clearTimeout(this.timeout);
},
handleBlur() {
this.$emit('searching', false);
},
focusInputText() {
this.$nextTick(() => {
if (this.areWorkItemsToAddValid) {
this.$refs.tokenSelector.$el.querySelector('input[type="text"]').focus();
}
});
},
},
};
</script>
<template>
<gl-token-selector
ref="tokenSelector"
v-model="workItemsToAdd"
:dropdown-items="availableWorkItems"
:loading="isLoading"
@ -131,13 +142,14 @@ export default {
@focus="handleFocus"
@mouseover.native="handleMouseOver"
@mouseout.native="handleMouseOut"
@token-add="focusInputText"
@token-remove="focusInputText"
@blur="handleBlur"
>
<template #token-content="{ token }">
{{ token.title }}
</template>
<template #token-content="{ token }"> {{ token.iid }} {{ token.title }} </template>
<template #dropdown-item-content="{ dropdownItem }">
<div class="gl-display-flex">
<div class="gl-text-secondary gl-mr-4">{{ getIdFromGraphQLId(dropdownItem.id) }}</div>
<div class="gl-text-secondary gl-font-sm gl-mr-4">{{ dropdownItem.iid }}</div>
<div class="gl-text-truncate">{{ dropdownItem.title }}</div>
</div>
</template>

View File

@ -605,8 +605,10 @@ export default {
/>
<work-item-relationships
v-if="showWorkItemLinkedItems"
:work-item-id="workItem.id"
:work-item-iid="workItemIid"
:work-item-full-path="workItem.project.fullPath"
:work-item-type="workItem.workItemType.name"
@showModal="openInModal"
/>
<work-item-notes

View File

@ -225,7 +225,6 @@ export default {
this.error = null;
},
addChild() {
this.searchStarted = false;
this.$apollo
.mutate({
mutation: updateWorkItemMutation,

View File

@ -0,0 +1,245 @@
<script>
import { produce } from 'immer';
import { GlFormGroup, GlForm, GlFormRadioGroup, GlButton, GlAlert } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import WorkItemTokenInput from '../shared/work_item_token_input.vue';
import addLinkedItemsMutation from '../../graphql/add_linked_items.mutation.graphql';
import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql';
import {
LINK_ITEM_FORM_HEADER_LABEL,
WIDGET_TYPE_LINKED_ITEMS,
LINKED_ITEM_TYPE_VALUE,
} from '../../constants';
export default {
components: {
GlForm,
GlButton,
GlFormGroup,
GlFormRadioGroup,
GlAlert,
WorkItemTokenInput,
},
props: {
workItemId: {
type: String,
required: false,
default: null,
},
workItemIid: {
type: String,
required: false,
default: null,
},
workItemFullPath: {
type: String,
required: false,
default: null,
},
workItemType: {
type: String,
required: false,
default: null,
},
childrenIds: {
type: Array,
required: false,
default: () => [],
},
},
data() {
return {
linkedItemType: LINKED_ITEM_TYPE_VALUE.RELATED,
linkedItemTypes: [
{
text: this.$options.i18n.relatedToLabel,
value: LINKED_ITEM_TYPE_VALUE.RELATED,
},
{
text: this.$options.i18n.blockingLabel,
value: LINKED_ITEM_TYPE_VALUE.BLOCKS,
},
{
text: this.$options.i18n.blockedByLabel,
value: LINKED_ITEM_TYPE_VALUE.BLOCKED_BY,
},
],
workItemsToAdd: [],
error: null,
showWorkItemsToAddInvalidMessage: false,
isSubmitting: false,
searchInProgress: false,
};
},
computed: {
linkItemFormHeaderLabel() {
return LINK_ITEM_FORM_HEADER_LABEL[this.workItemType];
},
workItemsToAddInvalidMessage() {
return this.$options.i18n.addChildErrorMessage;
},
isSubmitButtonDisabled() {
return this.workItemsToAdd.length <= 0 || !this.areWorkItemsToAddValid;
},
areWorkItemsToAddValid() {
return this.workItemsToAdd.length < 4;
},
errorMessage() {
return !this.areWorkItemsToAddValid ? this.$options.i18n.max3ItemsErrorMessage : '';
},
},
methods: {
async linkWorkItem() {
try {
if (this.searchInProgress) {
return;
}
this.isSubmitting = true;
const {
data: {
workItemAddLinkedItems: { errors },
},
} = await this.$apollo.mutate({
mutation: addLinkedItemsMutation,
variables: {
input: {
id: this.workItemId,
linkType: this.linkedItemType,
workItemsIds: this.workItemsToAdd.map((wi) => wi.id),
},
},
update: (
cache,
{
data: {
workItemAddLinkedItems: { workItem },
},
},
) => {
const queryArgs = {
query: workItemByIidQuery,
variables: { fullPath: this.workItemFullPath, iid: this.workItemIid },
};
const sourceData = cache.readQuery(queryArgs);
if (!sourceData) {
return;
}
cache.writeQuery({
...queryArgs,
data: produce(sourceData, (draftState) => {
const linkedItemsWidget = draftState.workspace.workItems.nodes[0].widgets?.find(
(widget) => widget.type === WIDGET_TYPE_LINKED_ITEMS,
);
linkedItemsWidget.linkedItems = workItem.widgets?.find(
(widget) => widget.type === WIDGET_TYPE_LINKED_ITEMS,
).linkedItems;
}),
});
},
});
if (errors.length > 0) {
[this.error] = errors;
return;
}
this.workItemsToAdd = [];
this.unsetError();
this.showWorkItemsToAddInvalidMessage = false;
this.linkedItemType = LINKED_ITEM_TYPE_VALUE.RELATED;
this.$emit('submitted');
} catch (e) {
this.error = this.$options.i18n.addLinkedItemErrorMessage;
} finally {
this.isSubmitting = false;
}
},
unsetError() {
this.error = null;
},
},
i18n: {
addButtonLabel: __('Add'),
relatedToLabel: s__('WorkItem|relates to'),
blockingLabel: s__('WorkItem|blocks'),
blockedByLabel: s__('WorkItem|is blocked by'),
max3ItemsNoteLabel: s__('WorkItem|Add a maximum of 3 items at a time.'),
linkItemInputLabel: s__('WorkItem|the following item(s)'),
addLinkedItemErrorMessage: s__(
'WorkItem|Something went wrong when trying to link a item. Please try again.',
),
max3ItemsErrorMessage: s__('WorkItem|Only 3 items can be added at a time.'),
},
};
</script>
<template>
<gl-form
class="gl-new-card-add-form"
data-testid="link-work-item-form"
@submit.stop.prevent="linkWorkItem"
>
<gl-alert v-if="error" variant="danger" class="gl-mb-3" @dismiss="unsetError">
{{ error }}
</gl-alert>
<gl-form-group
:label="linkItemFormHeaderLabel"
label-for="linked-item-type-radio"
label-class="label-bold"
class="gl-mb-3"
>
<gl-form-radio-group
id="linked-item-type-radio"
v-model="linkedItemType"
:options="linkedItemTypes"
:checked="linkedItemType"
/>
</gl-form-group>
<p class="gl-font-weight-bold gl-mb-2">
{{ $options.i18n.linkItemInputLabel }}
</p>
<div class="gl-mb-5">
<work-item-token-input
v-model="workItemsToAdd"
class="gl-mb-2"
:parent-work-item-id="workItemId"
:children-ids="childrenIds"
:are-work-items-to-add-valid="areWorkItemsToAddValid"
:full-path="workItemFullPath"
:max-selection-limit="3"
@searching="searchInProgress = $event"
/>
<div v-if="errorMessage" class="gl-mb-2 gl-text-red-500">
{{ $options.i18n.max3ItemsErrorMessage }}
</div>
<div v-if="!errorMessage" data-testid="max-work-item-note" class="gl-text-gray-500">
{{ $options.i18n.max3ItemsNoteLabel }}
</div>
<div
v-if="showWorkItemsToAddInvalidMessage"
class="gl-text-red-500"
data-testid="work-items-invalid"
>
{{ workItemsToAddInvalidMessage }}
</div>
</div>
<gl-button
data-testid="link-work-item-button"
category="primary"
variant="confirm"
size="small"
type="submit"
:disabled="isSubmitButtonDisabled"
:loading="isSubmitting"
class="gl-mr-2"
>
{{ $options.i18n.addButtonLabel }}
</gl-button>
<gl-button category="secondary" size="small" @click="$emit('cancel')">
{{ s__('WorkItem|Cancel') }}
</gl-button>
</gl-form>
</template>

View File

@ -33,7 +33,7 @@ export default {
};
</script>
<template>
<div>
<div data-testid="work-item-linked-items-list">
<h4
v-if="heading"
data-testid="work-items-list-heading"

View File

@ -8,6 +8,7 @@ import { WIDGET_TYPE_LINKED_ITEMS, LINKED_CATEGORIES_MAP } from '../../constants
import WidgetWrapper from '../widget_wrapper.vue';
import WorkItemRelationshipList from './work_item_relationship_list.vue';
import WorkItemAddRelationshipForm from './work_item_add_relationship_form.vue';
export default {
components: {
@ -16,8 +17,14 @@ export default {
GlButton,
WidgetWrapper,
WorkItemRelationshipList,
WorkItemAddRelationshipForm,
},
props: {
workItemId: {
type: String,
required: false,
default: null,
},
workItemIid: {
type: String,
required: true,
@ -26,6 +33,11 @@ export default {
type: String,
required: true,
},
workItemType: {
type: String,
required: false,
default: null,
},
},
apollo: {
workItem: {
@ -74,13 +86,13 @@ export default {
linksRelatesTo: [],
linksIsBlockedBy: [],
linksBlocks: [],
isShownLinkItemForm: false,
widgetName: 'linkeditems',
};
},
computed: {
canUpdate() {
// This will be false untill we implement remove item mutation
return false;
canAdminWorkItemLink() {
return this.workItem?.userPermissions?.adminWorkItemLink;
},
isLoading() {
return this.$apollo.queries.workItem.loading;
@ -91,11 +103,22 @@ export default {
linkedWorkItems() {
return this.linkedWorkItemsWidget?.linkedItems?.nodes || [];
},
childrenIds() {
return this.linkedWorkItems.map((item) => item.workItem.id);
},
linkedWorkItemsCount() {
return this.linkedWorkItems.length;
},
isEmptyRelatedWorkItems() {
return !this.error && this.linkedWorkItems.length === 0;
return !this.isShownLinkItemForm && !this.error && this.linkedWorkItems.length === 0;
},
},
methods: {
showLinkItemForm() {
this.isShownLinkItemForm = true;
},
hideLinkItemForm() {
this.isShownLinkItemForm = false;
},
},
i18n: {
@ -131,12 +154,28 @@ export default {
</div>
</template>
<template #header-right>
<gl-button size="small" class="gl-ml-3">
<gl-button
v-if="canAdminWorkItemLink"
data-testid="link-item-add-button"
size="small"
class="gl-ml-3"
@click="showLinkItemForm"
>
<slot name="add-button-text">{{ $options.i18n.addLinkedWorkItemButtonLabel }}</slot>
</gl-button>
</template>
<template #body>
<div class="gl-new-card-content">
<work-item-add-relationship-form
v-if="isShownLinkItemForm"
:work-item-id="workItemId"
:work-item-iid="workItemIid"
:work-item-full-path="workItemFullPath"
:children-ids="childrenIds"
:work-item-type="workItemType"
@submitted="hideLinkItemForm"
@cancel="hideLinkItemForm"
/>
<gl-loading-icon v-if="isLoading" color="dark" class="gl-my-2" />
<template v-else>
<div v-if="isEmptyRelatedWorkItems" data-testid="links-empty">
@ -154,7 +193,7 @@ export default {
:linked-items="linksBlocks"
:heading="$options.i18n.blockingTitle"
:work-item-full-path="workItemFullPath"
:can-update="canUpdate"
:can-update="false"
@showModal="$emit('showModal', { event: $event.event, modalWorkItem: $event.child })"
/>
<work-item-relationship-list
@ -166,7 +205,7 @@ export default {
:linked-items="linksIsBlockedBy"
:heading="$options.i18n.blockedByTitle"
:work-item-full-path="workItemFullPath"
:can-update="canUpdate"
:can-update="false"
@showModal="$emit('showModal', { event: $event.event, modalWorkItem: $event.child })"
/>
<work-item-relationship-list
@ -174,7 +213,7 @@ export default {
:linked-items="linksRelatesTo"
:heading="$options.i18n.relatedToTitle"
:work-item-full-path="workItemFullPath"
:can-update="canUpdate"
:can-update="false"
@showModal="$emit('showModal', { event: $event.event, modalWorkItem: $event.child })"
/>
</template>

View File

@ -113,7 +113,7 @@ export const I18N_WORK_ITEM_COPY_CREATE_NOTE_EMAIL = s__(
);
export const sprintfWorkItem = (msg, workItemTypeArg, parentWorkItemType = '') => {
const workItemType = workItemTypeArg || s__('WorkItem|Work item');
const workItemType = workItemTypeArg || s__('WorkItem|item');
return capitalizeFirstCharacter(
sprintf(msg, {
workItemType: workItemType.toLocaleLowerCase(),
@ -186,8 +186,11 @@ export const WORK_ITEM_NAME_TO_ICON_MAP = {
Issue: 'issue-type-issue',
Task: 'issue-type-task',
Objective: 'issue-type-objective',
Incident: 'issue-type-incident',
// eslint-disable-next-line @gitlab/require-i18n-strings
'Key Result': 'issue-type-keyresult',
// eslint-disable-next-line @gitlab/require-i18n-strings
'Test Case': 'issue-type-test-case',
};
export const FORM_TYPES = {
@ -262,3 +265,15 @@ export const LINKED_CATEGORIES_MAP = {
IS_BLOCKED_BY: 'is_blocked_by',
BLOCKS: 'blocks',
};
export const LINKED_ITEM_TYPE_VALUE = {
RELATED: 'RELATED',
BLOCKED_BY: 'BLOCKED_BY',
BLOCKS: 'BLOCKS',
};
export const LINK_ITEM_FORM_HEADER_LABEL = {
[WORK_ITEM_TYPE_VALUE_OBJECTIVE]: s__('WorkItem|The current objective'),
[WORK_ITEM_TYPE_VALUE_KEY_RESULT]: s__('WorkItem|The current key result'),
[WORK_ITEM_TYPE_VALUE_TASK]: s__('WorkItem|The current task'),
};

View File

@ -0,0 +1,10 @@
#import "./work_item.fragment.graphql"
mutation addLinkedItems($input: WorkItemAddLinkedItemsInput!) {
workItemAddLinkedItems(input: $input) {
workItem {
...WorkItem
}
errors
}
}

View File

@ -9,6 +9,7 @@ query projectWorkItems(
workItems(search: $searchTerm, types: $types, in: $in) {
nodes {
id
iid
title
state
confidential

View File

@ -33,6 +33,7 @@ fragment WorkItem on WorkItem {
adminParentLink
setWorkItemMetadata
createNote
adminWorkItemLink
}
widgets {
...WorkItemWidgets

View File

@ -1,6 +0,0 @@
# frozen_string_literal: true
class BuildIosAppGuideEmailExperiment < ApplicationExperiment
control { false }
candidate { true }
end

View File

@ -12,12 +12,6 @@ module Emails
'X-Mailgun-Tag' => 'marketing'
}.freeze
def build_ios_app_guide_email(recipient_email)
@message = ::Gitlab::Email::Message::BuildIosAppGuide.new
mail_to(to: recipient_email, subject: @message.subject_line)
end
private
def mail_to(to:, subject:)

View File

@ -158,6 +158,10 @@ class AbuseReport < ApplicationRecord
Project.find_by_full_path(route_hash.values_at(:namespace_id, :project_id).join('/'))
end
def group
Group.find_by_full_path(route_hash[:group_id])
end
def route_hash
match = Rails.application.routes.recognize_path(reported_from_url)
return {} if match[:unmatched_route].present?

View File

@ -3,30 +3,21 @@
module Users
class InProductMarketingEmail < ApplicationRecord
include BulkInsertSafe
include IgnorableColumns
BUILD_IOS_APP_GUIDE = 'build_ios_app_guide'
CAMPAIGNS = [BUILD_IOS_APP_GUIDE].freeze
ignore_column :campaign, remove_with: '16.7', remove_after: '2023-11-15'
belongs_to :user
validates :user, presence: true
validates :track, :series, presence: true, if: -> { campaign.blank? }
validates :campaign, presence: true, if: -> { track.blank? && series.blank? }
validates :campaign, inclusion: { in: CAMPAIGNS }, allow_nil: true
validates :track, presence: true
validates :series, presence: true
validates :user_id, uniqueness: {
scope: [:track, :series],
message: 'track series email has already been sent'
}, if: -> { track.present? }
validates :user_id, uniqueness: {
scope: :campaign,
message: 'campaign email has already been sent'
}, if: -> { campaign.present? }
validate :campaign_or_track_series
enum track: {
create: 0,
verify: 1,
@ -53,11 +44,6 @@ module Users
users_without_records(join_condition)
end
scope :without_campaign, ->(campaign) do
join_condition = for_user.and(for_campaign(campaign))
users_without_records(join_condition)
end
def self.users_table
User.arel_table
end
@ -78,10 +64,6 @@ module Users
arel_table[:user_id].eq(users_table[:id])
end
def self.for_campaign(campaign)
arel_table[:campaign].eq(campaign)
end
def self.for_track_and_series(track, series)
arel_table[:track].eq(ACTIVE_TRACKS[track])
.and(arel_table[:series]).eq(series)
@ -92,13 +74,5 @@ module Users
email.update(cta_clicked_at: Time.zone.now) if email && email.cta_clicked_at.blank?
end
private
def campaign_or_track_series
if campaign.present? && (track.present? || series.present?)
errors.add(:campaign, 'should be a campaign or a track and series but not both')
end
end
end
end

View File

@ -1,53 +0,0 @@
# frozen_string_literal: true
module Projects
class InProductMarketingCampaignEmailsService
include Gitlab::Experiment::Dsl
def initialize(project, campaign)
@project = project
@campaign = campaign
@sent_email_records = ::Users::InProductMarketingEmailRecords.new
end
def execute
send_emails
end
private
attr_reader :project, :campaign, :sent_email_records
def send_emails
project_users.each do |user|
send_email(user)
end
sent_email_records.save!
end
def project_users
@project_users ||= project.users.merge(Users::InProductMarketingEmail.without_campaign(campaign))
end
def project_users_max_access_levels
ids = project_users.map(&:id)
@project_users_max_access_levels ||= project.team.max_member_access_for_user_ids(ids)
end
def send_email(user)
return unless user.can?(:receive_notifications)
return unless target_user?(user)
Notify.build_ios_app_guide_email(user.notification_email_or_default).deliver_later
sent_email_records.add(user, campaign: campaign)
experiment(:build_ios_app_guide_email, project: project).track(:email_sent)
end
def target_user?(user)
max_access_level = project_users_max_access_levels[user.id]
max_access_level >= Gitlab::Access::DEVELOPER
end
end
end

View File

@ -28,26 +28,11 @@ module Projects
project_setting.target_platforms = target_platforms
project_setting.save
send_build_ios_app_guide_email
project_setting.target_platforms
end
def project_setting
@project_setting ||= ::ProjectSetting.find_or_initialize_by(project: project) # rubocop:disable CodeReuse/ActiveRecord
end
def experiment_candidate?
experiment(:build_ios_app_guide_email, project: project).run
end
def send_build_ios_app_guide_email
return unless target_platforms.include? :ios
return unless experiment_candidate?
campaign = Users::InProductMarketingEmail::BUILD_IOS_APP_GUIDE
Projects::InProductMarketingCampaignEmailsService.new(project, campaign).execute
end
end
end

View File

@ -13,10 +13,9 @@ module Users
@records = []
end
def add(user, campaign: nil, track: nil, series: nil)
def add(user, track: nil, series: nil)
@records << Users::InProductMarketingEmail.new(
user: user,
campaign: campaign,
track: track,
series: series,
created_at: Time.zone.now,

View File

@ -51,7 +51,7 @@ module WorkItems
override :issuables_already_assigned_message
def issuables_already_assigned_message
_('Work items are already linked')
_('Items are already linked')
end
override :issuables_not_found_message

View File

@ -2,7 +2,7 @@
- unless @skip_current_level_breadcrumb
- push_to_schema_breadcrumb(@breadcrumb_title, breadcrumb_title_link)
%nav.breadcrumbs{ 'aria-label': _('Breadcrumbs'), data: { testid: 'breadcrumb-links', qa_selector: 'breadcrumb_links_content' } }
%nav.breadcrumbs{ 'aria-label': _('Breadcrumbs'), data: { testid: 'breadcrumb-links' } }
%ul.list-unstyled.breadcrumbs-list.js-breadcrumbs-list
- unless hide_top_links
= header_title
@ -11,7 +11,7 @@
= breadcrumb_list_item link_to(extra[:text], extra[:link])
= render "layouts/nav/breadcrumbs/collapsed_inline_list", location: :after
- unless @skip_current_level_breadcrumb
%li{ data: { testid: 'breadcrumb-current-link', qa_selector: 'breadcrumb_current_link' } }
%li{ data: { testid: 'breadcrumb-current-link' } }
= link_to @breadcrumb_title, breadcrumb_title_link
-# haml-lint:disable InlineJavaScript
%script{ type: 'application/ld+json' }

View File

@ -1,13 +0,0 @@
%tr
%td{ bgcolor: "#ffffff", height: "auto", style: "max-width: 600px; width: 100%; text-align: center; height: 200px; padding: 25px 15px; mso-line-height-rule: exactly; min-height: 40px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;", valign: "middle", width: "100%" }
= inline_image_link(@message.logo_path, { width: '150', style: 'width: 150px;' })
%h1{ style: "font-size: 40px; line-height: 46x; color: #000000; padding: 20px 0 0 0; font-weight: normal;" }
= @message.title
%tr
%td{ style: "padding: 10px 20px 30px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; color:#000000; font-size: 18px; line-height: 24px;" }
%p{ style: "margin: 0 0 20px 0;" }
= @message.body_line1.html_safe
%tr
%td{ align: "center", style: "padding: 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" }
.cta_link.cta_link_primary= @message.cta_link
.cta_link.cta_link_secondary= @message.cta2_link

View File

@ -1,13 +0,0 @@
<%= @message.title %>
<%= @message.body_line1 %>
<%= @message.cta_link %>
<%= @message.cta2_link %>
<%= @message.footer_links %>
<%= @message.address %>
<%= @message.unsubscribe %>

View File

@ -1,8 +0,0 @@
---
name: build_ios_app_guide_email
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/83817
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/357803
milestone: '15.0'
type: experiment
group: group::activation
default_enabled: false

View File

@ -63,24 +63,24 @@ To run a DAST authenticated scan:
### Available CI/CD variables
| CI/CD variable | Type | Description |
|:-----------------------------------------------|:------------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `DAST_AUTH_COOKIES` | string | Set to a comma-separated list of cookie names to specify which cookies are used for authentication. |
| `DAST_AUTH_REPORT` | boolean | Set to `true` to generate a report detailing steps taken during the authentication process. You must also define `gl-dast-debug-auth-report.html` as a CI job artifact to be able to access the generated report. The report's content aids when debugging authentication failures. |
| `DAST_AUTH_TYPE` <sup>2</sup> | string | The authentication type to use. Example: `basic-digest`. |
| `DAST_AUTH_URL` <sup>1</sup> | URL | The URL of the page containing the sign-in HTML form on the target website. `DAST_USERNAME` and `DAST_PASSWORD` are submitted with the login form to create an authenticated scan. Example: `https://login.example.com`. |
| `DAST_AUTH_VERIFICATION_LOGIN_FORM` | boolean | Verifies successful authentication by checking for the absence of a login form after the login form has been submitted. |
| `DAST_AUTH_VERIFICATION_SELECTOR` | [selector](#finding-an-elements-selector) | Verifies successful authentication by checking for presence of a selector after the login form has been submitted. Example: `css:.user-photo`. |
| `DAST_AUTH_VERIFICATION_URL` <sup>1</sup> | URL | Verifies successful authentication by checking the URL in the browser after the login form has been submitted. Example: `"https://example.com/loggedin_page"`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/207335) in GitLab 13.8. |
| `DAST_BROWSER_PATH_TO_LOGIN_FORM` <sup>1</sup> | [selector](#finding-an-elements-selector) | Comma-separated list of selectors that are selected prior to attempting to enter `DAST_USERNAME` and `DAST_PASSWORD` into the login form. Example: `"css:.navigation-menu,css:.login-menu-item"`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/326633) in GitLab 14.1. |
| `DAST_EXCLUDE_URLS` <sup>1</sup> | URLs | The URLs to skip during the authenticated scan; comma-separated. Regular expression syntax can be used to match multiple URLs. For example, `.*` matches an arbitrary character sequence. |
| `DAST_FIRST_SUBMIT_FIELD` | string | The `id` or `name` of the element that when selected submits the username form of a multi-page login process. For example, `css:button[type='user-submit']`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/9894) in GitLab 12.4. |
| `DAST_PASSWORD` <sup>1</sup> | string | The password to authenticate to in the website. Example: `P@55w0rd!` |
| `DAST_PASSWORD_FIELD` | string | The selector of password field at the sign-in HTML form. Example: `id:password` |
| `DAST_SUBMIT_FIELD` | string | The `id` or `name` of the element that when selected submits the login form or the password form of a multi-page login process. For example, `css:button[type='submit']`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/9894) in GitLab 12.4. |
| `DAST_USERNAME` <sup>1</sup> | string | The username to authenticate to in the website. Example: `admin` |
| `DAST_USERNAME_FIELD` <sup>1</sup> | string | The selector of username field at the sign-in HTML form. Example: `name:username` |
| `DAST_AUTH_DISABLE_CLEAR_FIELDS` | boolean | Disables clearing of username and password fields before attempting manual login. Set to `false` by default. |
| CI/CD variable | Type | Description |
|:-----------------------------------------------|:------------------------------------------|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `DAST_AUTH_COOKIES` | string | Set to a comma-separated list of cookie names to specify which cookies are used for authentication. |
| `DAST_AUTH_REPORT` | boolean | Set to `true` to generate a report detailing steps taken during the authentication process. You must also define `gl-dast-debug-auth-report.html` as a CI job artifact to be able to access the generated report. The report's content aids when debugging authentication failures. |
| `DAST_AUTH_TYPE` <sup>2</sup> | string | The authentication type to use. Example: `basic-digest`. |
| `DAST_AUTH_URL` <sup>1</sup> | URL | The URL of the page containing the login form on the target website. `DAST_USERNAME` and `DAST_PASSWORD` are submitted with the login form to create an authenticated scan. Example: `https://login.example.com`. |
| `DAST_AUTH_VERIFICATION_LOGIN_FORM` | boolean | Verifies successful authentication by checking for the absence of a login form after the login form has been submitted. |
| `DAST_AUTH_VERIFICATION_SELECTOR` | [selector](#finding-an-elements-selector) | A selector describing an element whose presence is used to determine if authentication has succeeded after the login form is submitted. Example: `css:.user-photo`. |
| `DAST_AUTH_VERIFICATION_URL` <sup>1</sup> | URL | A URL that is compared to the URL in the browser to determine if authentication has succeeded after the login form is submitted. Example: `"https://example.com/loggedin_page"`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/207335) in GitLab 13.8. |
| `DAST_BROWSER_PATH_TO_LOGIN_FORM` <sup>1</sup> | [selector](#finding-an-elements-selector) | A comma-separated list of selectors representing elements to click on prior to entering the `DAST_USERNAME` and `DAST_PASSWORD` into the login form. Example: `"css:.navigation-menu,css:.login-menu-item"`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/326633) in GitLab 14.1. |
| `DAST_EXCLUDE_URLS` <sup>1</sup> | URLs | The URLs to skip during the authenticated scan; comma-separated. Regular expression syntax can be used to match multiple URLs. For example, `.*` matches an arbitrary character sequence. |
| `DAST_FIRST_SUBMIT_FIELD` | [selector](#finding-an-elements-selector) | A selector describing the element that is clicked on to submit the username form of a multi-page login process. For example, `css:button[type='user-submit']`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/9894) in GitLab 12.4. |
| `DAST_PASSWORD` <sup>1</sup> | string | The password to authenticate to in the website. Example: `P@55w0rd!` |
| `DAST_PASSWORD_FIELD` | [selector](#finding-an-elements-selector) | A selector describing the element used to enter the password on the login form. Example: `id:password` |
| `DAST_SUBMIT_FIELD` | [selector](#finding-an-elements-selector) | A selector describing the element clicked on to submit the login form for a single-page login form, or the password form for a multi-page login form. For example, `css:button[type='submit']`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/9894) in GitLab 12.4. |
| `DAST_USERNAME` <sup>1</sup> | string | The username to authenticate to in the website. Example: `admin` |
| `DAST_USERNAME_FIELD` <sup>1</sup> | [selector](#finding-an-elements-selector) | A selector describing the element used to enter the username on the login form. Example: `name:username` |
| `DAST_AUTH_DISABLE_CLEAR_FIELDS` | boolean | Disables clearing of username and password fields before attempting manual login. Set to `false` by default. |
1. Available to an on-demand proxy-based DAST scan.
1. Not available to proxy-based scans.

View File

@ -1,57 +0,0 @@
# frozen_string_literal: true
module Gitlab
module Email
module Message
class BuildIosAppGuide
include Gitlab::Email::Message::InProductMarketing::Helper
include Gitlab::Routing
attr_accessor :format
def initialize(format: :html)
@format = format
end
def subject_line
s_('InProductMarketing|Get set up to build for iOS')
end
def title
s_("InProductMarketing|Building for iOS? We've got you covered.")
end
def body_line1
s_(
'InProductMarketing|Want to get your iOS app up and running, including publishing all the way to ' \
'TestFlight? Follow our guide to set up GitLab and fastlane to publish iOS apps to the App Store.'
)
end
def cta_text
s_('InProductMarketing|Learn how to build for iOS')
end
def cta_link
action_link(cta_text, 'https://about.gitlab.com/blog/2019/03/06/ios-publishing-with-gitlab-and-fastlane/')
end
def cta2_text
s_('InProductMarketing|Watch iOS building in action.')
end
def cta2_link
action_link(cta2_text, 'https://www.youtube.com/watch?v=325FyJt7ZG8')
end
def logo_path
'mailers/in_product_marketing/create-0.png'
end
def unsubscribe
unsubscribe_message
end
end
end
end
end

View File

@ -2333,6 +2333,9 @@ msgstr ""
msgid "AbuseReport|Go to content"
msgstr ""
msgid "AbuseReport|Go to epic"
msgstr ""
msgid "AbuseReport|Go to issue"
msgstr ""
@ -2399,6 +2402,9 @@ msgstr ""
msgid "AbuseReport|Reported content"
msgstr ""
msgid "AbuseReport|Reported epic"
msgstr ""
msgid "AbuseReport|Reported issue"
msgstr ""
@ -24541,9 +24547,6 @@ msgstr ""
msgid "InProductMarketing|Blog"
msgstr ""
msgid "InProductMarketing|Building for iOS? We've got you covered."
msgstr ""
msgid "InProductMarketing|Built-in security"
msgstr ""
@ -24556,9 +24559,6 @@ msgstr ""
msgid "InProductMarketing|Free guest users"
msgstr ""
msgid "InProductMarketing|Get set up to build for iOS"
msgstr ""
msgid "InProductMarketing|If you don't want to receive marketing emails directly from GitLab, %{marketing_preference_link}."
msgstr ""
@ -24568,9 +24568,6 @@ msgstr ""
msgid "InProductMarketing|Invite unlimited colleagues"
msgstr ""
msgid "InProductMarketing|Learn how to build for iOS"
msgstr ""
msgid "InProductMarketing|No credit card required"
msgstr ""
@ -24589,15 +24586,9 @@ msgstr ""
msgid "InProductMarketing|Twitter"
msgstr ""
msgid "InProductMarketing|Want to get your iOS app up and running, including publishing all the way to TestFlight? Follow our guide to set up GitLab and fastlane to publish iOS apps to the App Store."
msgstr ""
msgid "InProductMarketing|Want to host GitLab on your servers?"
msgstr ""
msgid "InProductMarketing|Watch iOS building in action."
msgstr ""
msgid "InProductMarketing|YouTube"
msgstr ""
@ -26186,6 +26177,9 @@ msgstr ""
msgid "Item with ID: %{id} cannot be added. You don't have permission to perform this action."
msgstr ""
msgid "Items are already linked"
msgstr ""
msgid "Iteration"
msgstr ""
@ -26969,6 +26963,9 @@ msgstr ""
msgid "Jobs|Root cause analysis"
msgstr ""
msgid "Jobs|Stage"
msgstr ""
msgid "Jobs|There was a problem fetching the failed jobs."
msgstr ""
@ -53543,9 +53540,6 @@ msgstr ""
msgid "Work item promoted successfully."
msgstr ""
msgid "Work items are already linked"
msgstr ""
msgid "WorkItem|%{count} more assignees"
msgstr ""
@ -53570,6 +53564,9 @@ msgstr ""
msgid "WorkItem|Add %{workItemType}s"
msgstr ""
msgid "WorkItem|Add a maximum of 3 items at a time."
msgstr ""
msgid "WorkItem|Add a title"
msgstr ""
@ -53728,6 +53725,9 @@ msgstr ""
msgid "WorkItem|Objective"
msgstr ""
msgid "WorkItem|Only 3 items can be added at a time."
msgstr ""
msgid "WorkItem|Only project members with at least the Reporter role, the author, and assignees can view or be notified about this %{workItemType}."
msgstr ""
@ -53788,6 +53788,9 @@ msgstr ""
msgid "WorkItem|Something went wrong when trying to create a child. Please try again."
msgstr ""
msgid "WorkItem|Something went wrong when trying to link a item. Please try again."
msgstr ""
msgid "WorkItem|Something went wrong while copying the %{workItemType} email address. Please try again."
msgstr ""
@ -53836,6 +53839,15 @@ msgstr ""
msgid "WorkItem|Test case"
msgstr ""
msgid "WorkItem|The current key result"
msgstr ""
msgid "WorkItem|The current objective"
msgstr ""
msgid "WorkItem|The current task"
msgstr ""
msgid "WorkItem|This %{workItemType} is confidential and should only be visible to team members with at least Reporter access"
msgstr ""
@ -53866,6 +53878,21 @@ msgstr ""
msgid "WorkItem|You're only seeing %{boldStart}other activity%{boldEnd} in the feed. To add a comment, switch to one of the following options."
msgstr ""
msgid "WorkItem|blocks"
msgstr ""
msgid "WorkItem|is blocked by"
msgstr ""
msgid "WorkItem|item"
msgstr ""
msgid "WorkItem|relates to"
msgstr ""
msgid "WorkItem|the following item(s)"
msgstr ""
msgid "Workspaces"
msgstr ""

View File

@ -63,7 +63,7 @@
"@gitlab/svgs": "3.63.0",
"@gitlab/ui": "66.4.0",
"@gitlab/visual-review-tools": "1.7.3",
"@gitlab/web-ide": "0.0.1-dev-20230915130935",
"@gitlab/web-ide": "0.0.1-dev-20230921201626",
"@mattiasbuelens/web-streams-adapter": "^0.1.0",
"@popperjs/core": "^2.11.2",
"@rails/actioncable": "7.0.8",

View File

@ -10,12 +10,12 @@ module QA
super
base.view 'app/views/layouts/nav/breadcrumbs/_breadcrumbs.html.haml' do
element :breadcrumb_links_content
element 'breadcrumb-links'
end
end
def has_breadcrumb?(text)
has_element?(:breadcrumb_links_content, text: text)
has_element?('breadcrumb-links', text: text)
end
end
end

View File

@ -89,8 +89,8 @@ module QA
end
base.view 'app/views/layouts/nav/breadcrumbs/_breadcrumbs.html.haml' do
element :breadcrumb_links_content
element :breadcrumb_current_link
element 'breadcrumb-links'
element 'breadcrumb-current-link'
end
end
@ -273,8 +273,8 @@ module QA
end
def snippet_id
within_element(:breadcrumb_links_content) do
find_element(:breadcrumb_current_link).text.delete_prefix('$')
within_element('breadcrumb-links') do
find_element('breadcrumb-current-link').text.delete_prefix('$')
end
end
end

View File

@ -8,81 +8,64 @@ module QA
include Component::CiBadgeLink
view 'app/assets/javascripts/ci/pipeline_details/header/pipeline_details_header.vue' do
element :pipeline_details_header, required: true
end
view 'app/assets/javascripts/ci/pipeline_details/graph/components/graph_component.vue' do
element :pipeline_graph, /class.*pipeline-graph.*/ # rubocop:disable QA/ElementWithPattern
element 'pipeline-details-header', required: true
end
view 'app/assets/javascripts/ci/pipeline_details/graph/components/job_item.vue' do
element :job_item_container, required: true
element :job_link, required: true
element :job_action_button
element 'job-with-link', required: true
end
view 'app/assets/javascripts/ci/common/private/job_action_component.vue' do
element 'ci-action-button'
end
view 'app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipeline.vue' do
element :expand_linked_pipeline_button
element :linked_pipeline_container
element :downstream_title_content
end
view 'app/assets/javascripts/ci/reports/components/report_section.vue' do
element :expand_report_button
end
view 'app/assets/javascripts/vue_shared/components/ci_icon.vue' do
element :status_icon, 'ci-status-icon-${status}' # rubocop:disable QA/ElementWithPattern
element 'expand-pipeline-button'
element 'linked-pipeline-container'
element 'downstream-title-content'
end
view 'app/assets/javascripts/ci/pipeline_details/graph/components/job_group_dropdown.vue' do
element :job_dropdown_container
element :jobs_dropdown_menu
element 'job-dropdown-container'
element 'jobs-dropdown-menu'
end
view 'app/views/layouts/nav/breadcrumbs/_breadcrumbs.html.haml' do
element :breadcrumb_links_content
element :breadcrumb_current_link
end
def pipeline_id
within_element(:breadcrumb_links_content) do
find_element(:breadcrumb_current_link).text.delete_prefix('#')
end
view 'app/assets/javascripts/ci/pipeline_details/graph/components/stage_column_component.vue' do
element 'job-item-container', required: true
end
def running?(wait: 0)
within_element(:pipeline_details_header) do
within_element('pipeline-details-header') do
page.has_content?('running', wait: wait)
end
end
def has_build?(name, status: :success, wait: nil)
if status
within_element(:job_item_container, text: name) do
within_element('job-item-container', text: name) do
has_selector?(".ci-status-icon-#{status}", **{ wait: wait }.compact)
end
else
has_element?(:job_item_container, text: name)
has_element?('job-item-container', text: name)
end
end
def has_job?(job_name)
has_element?(:job_link, text: job_name)
has_element?('job-with-link', text: job_name)
end
def has_no_job?(job_name)
has_no_element?(:job_link, text: job_name)
has_no_element?('job-with-link', text: job_name)
end
def linked_pipelines
all_elements(:linked_pipeline_container, minimum: 1)
all_elements('linked-pipeline-container', minimum: 1)
end
def find_linked_pipeline_by_title(title)
linked_pipelines.find do |pipeline|
within(pipeline) do
find_element(:downstream_title_content).text.include?(title)
find_element('downstream-title-content').text.include?(title)
end
end
end
@ -91,14 +74,14 @@ module QA
# If the pipeline page has loaded linked pipelines should appear, but it can take a little while,
# especially on busier environments.
retry_until(reload: true, message: 'Waiting for linked pipeline to appear') do
title ? find_linked_pipeline_by_title(title) : has_element?(:linked_pipeline_container)
title ? find_linked_pipeline_by_title(title) : has_element?('linked-pipeline-container')
end
end
alias_method :has_child_pipeline?, :has_linked_pipeline?
def has_no_linked_pipeline?
has_no_element?(:linked_pipeline_container)
has_no_element?('linked-pipeline-container')
end
alias_method :has_no_child_pipeline?, :has_no_linked_pipeline?
@ -106,19 +89,13 @@ module QA
def expand_linked_pipeline(title: nil)
linked_pipeline = title ? find_linked_pipeline_by_title(title) : linked_pipelines.first
within_element_by_index(:linked_pipeline_container, linked_pipelines.index(linked_pipeline)) do
click_element(:expand_linked_pipeline_button)
within_element_by_index('linked-pipeline-container', linked_pipelines.index(linked_pipeline)) do
click_element('expand-pipeline-button')
end
end
alias_method :expand_child_pipeline, :expand_linked_pipeline
def expand_license_report
within_element(:license_report_widget) do
click_element(:expand_report_button)
end
end
def click_on_first_job
first('.js-pipeline-graph-job-link', wait: QA::Support::Repeater::DEFAULT_MAX_WAIT_TIME).click
end
@ -126,33 +103,33 @@ module QA
def click_job(job_name)
# Retry due to transient bug https://gitlab.com/gitlab-org/gitlab/-/issues/347126
QA::Support::Retrier.retry_on_exception do
click_element(:job_link, Project::Job::Show, text: job_name)
click_element('job-with-link', Project::Job::Show, text: job_name)
end
end
def click_job_action(job_name)
wait_for_requests
within_element(:job_item_container, text: job_name) do
click_element(:job_action_button)
within_element('job-item-container', text: job_name) do
click_element('ci-action-button')
end
end
def click_job_dropdown(job_dropdown_name)
click_element(:job_dropdown_container, text: job_dropdown_name)
click_element('job-dropdown-container', text: job_dropdown_name)
end
def has_skipped_job_in_group?
within_element(:jobs_dropdown_menu) do
all_elements(:job_item_container, minimum: 1).all? do
within_element('jobs-dropdown-menu') do
all_elements('job-with-link', minimum: 1).all? do
has_selector?('.ci-status-icon-skipped')
end
end
end
def has_no_skipped_job_in_group?
within_element(:jobs_dropdown_menu) do
all_elements(:job_item_container, minimum: 1).all? do
within_element('jobs-dropdown-menu') do
all_elements('job-with-link', minimum: 1).all? do
has_no_selector?('.ci-status-icon-skipped')
end
end

View File

@ -18,8 +18,8 @@ module QA
Flow::Login.sign_in
end
shared_examples 'milestone assigned to existing issue' do |testcase|
it 'is assigned to an existing issue', testcase: testcase do
shared_examples 'when assigned to existing issue' do |testcase|
it 'is assigned', testcase: testcase do
issue.visit!
Page::Project::Issue::Show.perform do |existing_issue|
@ -30,8 +30,8 @@ module QA
end
end
shared_examples 'milestone assigned to new issue' do |testcase|
it 'is assigned to a new issue', testcase: testcase do
shared_examples 'when assigned to new issue' do |testcase|
it 'is assigned', testcase: testcase do
Resource::Issue.fabricate_via_browser_ui! do |new_issue|
new_issue.project = project
new_issue.milestone = milestone
@ -46,15 +46,15 @@ module QA
context 'Group milestone' do
let(:milestone) { create(:group_milestone, group: group, start_date: start_date, due_date: due_date) }
it_behaves_like 'milestone assigned to existing issue', 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347964'
it_behaves_like 'milestone assigned to new issue', 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347965'
it_behaves_like 'when assigned to existing issue', 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347964'
it_behaves_like 'when assigned to new issue', 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347965'
end
context 'Project milestone' do
let(:milestone) { create(:project_milestone, project: project, start_date: start_date, due_date: due_date) }
it_behaves_like 'milestone assigned to existing issue', 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347962'
it_behaves_like 'milestone assigned to new issue', 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347963'
it_behaves_like 'when assigned to existing issue', 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347962'
it_behaves_like 'when assigned to new issue', 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347963'
end
end
end

View File

@ -6,11 +6,5 @@ FactoryBot.define do
track { 'create' }
series { 0 }
trait :campaign do
track { nil }
series { nil }
campaign { Users::InProductMarketingEmail::BUILD_IOS_APP_GUIDE }
end
end
end

View File

@ -0,0 +1,89 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Work item linked items', :js, feature_category: :team_planning do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, :public, namespace: group) }
let_it_be(:user) { create(:user) }
let_it_be(:work_item) { create(:work_item, project: project) }
let(:work_items_path) { project_work_items_path(project, work_items_path: work_item.iid) }
let_it_be(:task) { create(:work_item, :task, project: project, title: 'Task 1') }
context 'for signed in user' do
include ContentEditorHelpers
before_all do
project.add_developer(user)
end
before do
sign_in(user)
stub_feature_flags(work_items: true)
stub_feature_flags(linked_work_items: true)
visit work_items_path
wait_for_requests
close_rich_text_promo_popover_if_present
end
it 'are not displayed when issue does not have work item links', :aggregate_failures do
page.within('.work-item-relationships') do
expect(page).to have_selector('[data-testid="link-item-add-button"]')
expect(page).not_to have_selector('[data-testid="link-work-item-form"]')
expect(page).not_to have_selector('[data-testid="work-item-linked-items-list"]')
end
end
it 'toggles widget body', :aggregate_failures do
page.within('.work-item-relationships') do
expect(page).to have_selector('[data-testid="widget-body"]')
click_button 'Collapse'
expect(page).not_to have_selector('[data-testid="widget-body"]')
click_button 'Expand'
expect(page).to have_selector('[data-testid="widget-body"]')
end
end
it 'toggles form', :aggregate_failures do
page.within('.work-item-relationships') do
expect(page).not_to have_selector('[data-testid="link-work-item-form"]')
click_button 'Add'
expect(page).to have_selector('[data-testid="link-work-item-form"]')
click_button 'Cancel'
expect(page).not_to have_selector('[data-testid="link-work-item-form"]')
end
end
it 'links a new item', :aggregate_failures do
page.within('.work-item-relationships') do
click_button 'Add'
within_testid('link-work-item-form') do
expect(page).to have_button('Add', disabled: true)
find_by_testid('work-item-token-select-input').set(task.title)
wait_for_all_requests
click_button task.title
expect(page).to have_button('Add', disabled: false)
click_button 'Add'
wait_for_all_requests
end
expect(find('.work-items-list')).to have_content('Task 1')
end
end
end
end

View File

@ -124,7 +124,9 @@ RSpec.describe 'Work item children', :js, feature_category: :team_planning do
expect(page).to have_button('Add task', disabled: false)
click_button 'Add task'
send_keys :escape
click_button('Add task')
wait_for_all_requests
@ -162,7 +164,9 @@ RSpec.describe 'Work item children', :js, feature_category: :team_planning do
wait_for_all_requests
click_button task.title
click_button 'Add task'
send_keys :escape
click_button('Add task')
wait_for_all_requests
end

View File

@ -40,20 +40,20 @@ describe('Job Cell', () => {
};
describe('Job Id', () => {
it('displays the job id and links to the job', () => {
it('displays the job id, job name and links to the job', () => {
createComponent();
const expectedJobId = `#${getIdFromGraphQLId(mockJob.id)}`;
const expectedJobId = `#${getIdFromGraphQLId(mockJob.id)}: ${mockJob.name}`;
expect(findJobIdLink().text()).toBe(expectedJobId);
expect(findJobIdLink().attributes('href')).toBe(mockJob.detailedStatus.detailsPath);
expect(findJobIdNoLink().exists()).toBe(false);
});
it('display the job id with no link', () => {
it('display the job id and job name with no link', () => {
createComponent(jobAsGuest);
const expectedJobId = `#${getIdFromGraphQLId(jobAsGuest.id)}`;
const expectedJobId = `#${getIdFromGraphQLId(jobAsGuest.id)}: ${jobAsGuest.name}`;
expect(findJobIdNoLink().text()).toBe(expectedJobId);
expect(findJobIdNoLink().exists()).toBe(true);

View File

@ -1,6 +1,6 @@
import { shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import DurationCell from '~/ci/jobs_page/components/job_cells/duration_cell.vue';
import StatusCell from '~/ci/jobs_page/components/job_cells/status_cell.vue';
describe('Duration Cell', () => {
let wrapper;
@ -12,7 +12,7 @@ describe('Duration Cell', () => {
const createComponent = (props) => {
wrapper = extendedWrapper(
shallowMount(DurationCell, {
shallowMount(StatusCell, {
propsData: {
job: {
...props,

View File

@ -2,6 +2,7 @@ import { GlTable } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import JobsTable from '~/ci/jobs_page/components/jobs_table.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue';
import { DEFAULT_FIELDS_ADMIN } from '~/ci/admin/jobs_table/constants';
import ProjectCell from '~/ci/admin/jobs_table/components/cells/project_cell.vue';
@ -47,11 +48,11 @@ describe('Jobs Table', () => {
expect(findCiBadgeLink().exists()).toBe(true);
});
it('displays the job stage and name', () => {
it('displays the job stage, id and name', () => {
const [firstJob] = mockJobsNodes;
expect(findJobStage().text()).toBe(firstJob.stage.name);
expect(findJobName().text()).toBe(firstJob.name);
expect(findJobStage().text()).toBe(`Stage: ${firstJob.stage.name}`);
expect(findJobName().text()).toBe(`#${getIdFromGraphQLId(firstJob.id)}: ${firstJob.name}`);
});
it('displays the coverage for only jobs that have coverage', () => {

View File

@ -27,7 +27,7 @@ describe('pipeline graph job item', () => {
const findJobWithoutLink = () => wrapper.findByTestId('job-without-link');
const findJobWithLink = () => wrapper.findByTestId('job-with-link');
const findActionVueComponent = () => wrapper.findComponent(ActionComponent);
const findActionComponent = () => wrapper.findByTestId('ci-action-component');
const findActionComponent = () => wrapper.findByTestId('ci-action-button');
const findBadge = () => wrapper.findComponent(GlBadge);
const findJobLink = () => wrapper.findByTestId('job-with-link');
const findModal = () => wrapper.findComponent(GlModal);

View File

@ -37,7 +37,7 @@ describe('Linked pipeline', () => {
const findButton = () => wrapper.findComponent(GlButton);
const findCancelButton = () => wrapper.findByLabelText('Cancel downstream pipeline');
const findCardTooltip = () => wrapper.findComponent(GlTooltip);
const findDownstreamPipelineTitle = () => wrapper.findByTestId('downstream-title');
const findDownstreamPipelineTitle = () => wrapper.findByTestId('downstream-title-content');
const findExpandButton = () => wrapper.findByTestId('expand-pipeline-button');
const findLinkedPipeline = () => wrapper.findComponent({ ref: 'linkedPipeline' });
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);

View File

@ -123,7 +123,7 @@ describe('HeaderActions component', () => {
const findMobileDropdownItems = () => findMobileDropdown().findAllComponents(GlDropdownItem);
const findDesktopDropdownItems = () => findDesktopDropdown().findAllComponents(GlDropdownItem);
const findAbuseCategorySelector = () => wrapper.findComponent(AbuseCategorySelector);
const findReportAbuseSelectorItem = () => wrapper.find(`[data-testid="report-abuse-item"]`);
const findReportAbuseButton = () => wrapper.find(`[data-testid="report-abuse-item"]`);
const findNotificationWidget = () => wrapper.find(`[data-testid="notification-toggle"]`);
const findLockIssueWidget = () => wrapper.find(`[data-testid="lock-issue-toggle"]`);
const findCopyRefenceDropdownItem = () => wrapper.find(`[data-testid="copy-reference"]`);
@ -490,29 +490,41 @@ describe('HeaderActions component', () => {
});
});
describe('abuse category selector', () => {
describe('report abuse to admin button', () => {
beforeEach(() => {
wrapper = mountComponent({ props: { isIssueAuthor: false } });
});
it("doesn't render", () => {
it('renders the button but not the abuse category drawer', () => {
expect(findReportAbuseButton().exists()).toBe(true);
expect(findAbuseCategorySelector().exists()).toEqual(false);
});
it('opens the drawer', async () => {
findReportAbuseSelectorItem().vm.$emit('click');
it('opens the abuse category drawer', async () => {
findReportAbuseButton().vm.$emit('click');
await nextTick();
expect(findAbuseCategorySelector().props('showDrawer')).toEqual(true);
});
it('closes the drawer', async () => {
await findReportAbuseSelectorItem().vm.$emit('click');
await findAbuseCategorySelector().vm.$emit('close-drawer');
it('closes the abuse category drawer', async () => {
await findReportAbuseButton().vm.$emit('click');
expect(findAbuseCategorySelector().exists()).toEqual(true);
await findAbuseCategorySelector().vm.$emit('close-drawer');
expect(findAbuseCategorySelector().exists()).toEqual(false);
});
describe('when the logged in user is the issue author', () => {
beforeEach(() => {
wrapper = mountComponent({ props: { isIssueAuthor: true } });
});
it('does not render the button', () => {
expect(findReportAbuseButton().exists()).toBe(false);
});
});
});
describe('notification toggle', () => {
@ -694,7 +706,7 @@ describe('HeaderActions component', () => {
expect(findDesktopDropdown().exists()).toBe(headerActionsVisible);
expect(findCopyRefenceDropdownItem().exists()).toBe(headerActionsVisible);
expect(findNotificationWidget().exists()).toBe(false);
expect(findReportAbuseSelectorItem().exists()).toBe(false);
expect(findReportAbuseButton().exists()).toBe(false);
expect(findLockIssueWidget().exists()).toBe(false);
});
},

View File

@ -1,7 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`WorkItemRelationshipList renders linked item list 1`] = `
<div>
<div
data-testid="work-item-linked-items-list"
>
<h4
class="gl-font-sm gl-font-weight-semibold gl-mb-2 gl-mt-3 gl-mx-2 gl-text-gray-700"
data-testid="work-items-list-heading"

View File

@ -0,0 +1,170 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { GlForm, GlFormRadioGroup, GlAlert } from '@gitlab/ui';
import { s__ } from '~/locale';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import WorkItemAddRelationshipForm from '~/work_items/components/work_item_relationships/work_item_add_relationship_form.vue';
import WorkItemTokenInput from '~/work_items/components/shared/work_item_token_input.vue';
import addLinkedItemsMutation from '~/work_items/graphql/add_linked_items.mutation.graphql';
import { LINKED_ITEM_TYPE_VALUE } from '~/work_items/constants';
import { linkedWorkItemResponse } from '../../mock_data';
describe('WorkItemAddRelationshipForm', () => {
Vue.use(VueApollo);
let wrapper;
const linkedWorkItemsSuccessMutationHandler = jest
.fn()
.mockResolvedValue(linkedWorkItemResponse());
const createComponent = async ({
workItemId = 'gid://gitlab/WorkItem/1',
workItemIid = '1',
workItemType = 'Objective',
childrenIds = [],
linkedWorkItemsMutationHandler = linkedWorkItemsSuccessMutationHandler,
} = {}) => {
const mockApolloProvider = createMockApollo([
[addLinkedItemsMutation, linkedWorkItemsMutationHandler],
]);
wrapper = shallowMountExtended(WorkItemAddRelationshipForm, {
apolloProvider: mockApolloProvider,
propsData: {
workItemId,
workItemIid,
workItemFullPath: 'test-project-path',
workItemType,
childrenIds,
},
});
await waitForPromises();
};
const findLinkWorkItemForm = () => wrapper.findComponent(GlForm);
const findLinkWorkItemButton = () => wrapper.findByTestId('link-work-item-button');
const findMaxWorkItemNote = () => wrapper.findByTestId('max-work-item-note');
const findRadioGroup = () => wrapper.findComponent(GlFormRadioGroup);
const findWorkItemTokenInput = () => wrapper.findComponent(WorkItemTokenInput);
const findGlAlert = () => wrapper.findComponent(GlAlert);
beforeEach(async () => {
await createComponent();
});
it('renders link work item form with default values', () => {
expect(findLinkWorkItemForm().exists()).toBe(true);
expect(findRadioGroup().props('options')).toEqual([
{ text: s__('WorkItem|relates to'), value: LINKED_ITEM_TYPE_VALUE.RELATED },
{ text: s__('WorkItem|blocks'), value: LINKED_ITEM_TYPE_VALUE.BLOCKS },
{ text: s__('WorkItem|is blocked by'), value: LINKED_ITEM_TYPE_VALUE.BLOCKED_BY },
]);
expect(findLinkWorkItemButton().attributes('disabled')).toBe('true');
expect(findMaxWorkItemNote().text()).toBe(s__('WorkItem|Add a maximum of 3 items at a time.'));
});
it('renders work item token input with default props', () => {
expect(findWorkItemTokenInput().props()).toMatchObject({
value: [],
fullPath: 'test-project-path',
childrenIds: [],
parentWorkItemId: 'gid://gitlab/WorkItem/1',
areWorkItemsToAddValid: true,
});
});
describe('linking a work item', () => {
const selectWorkItemTokens = (workItems) => {
findWorkItemTokenInput().vm.$emit('input', workItems);
};
it('enables add button when work item is selected', async () => {
await selectWorkItemTokens([
{
id: 'gid://gitlab/WorkItem/644',
},
]);
expect(findLinkWorkItemButton().attributes('disabled')).toBeUndefined();
});
it('disables button when more than 3 work items are selected', async () => {
await selectWorkItemTokens([
{
id: 'gid://gitlab/WorkItem/641',
},
{
id: 'gid://gitlab/WorkItem/642',
},
{
id: 'gid://gitlab/WorkItem/643',
},
{
id: 'gid://gitlab/WorkItem/644',
},
]);
expect(findWorkItemTokenInput().props('areWorkItemsToAddValid')).toBe(false);
expect(findLinkWorkItemButton().attributes('disabled')).toBe('true');
});
it.each`
assertionName | linkTypeInput
${'related'} | ${LINKED_ITEM_TYPE_VALUE.RELATED}
${'blocking'} | ${LINKED_ITEM_TYPE_VALUE.BLOCKED_BY}
`('selects and links $assertionName work item', async ({ linkTypeInput }) => {
findRadioGroup().vm.$emit('input', linkTypeInput);
await selectWorkItemTokens([
{
id: 'gid://gitlab/WorkItem/641',
},
{
id: 'gid://gitlab/WorkItem/642',
},
]);
expect(findWorkItemTokenInput().props('areWorkItemsToAddValid')).toBe(true);
findLinkWorkItemForm().vm.$emit('submit', {
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
});
await waitForPromises();
expect(linkedWorkItemsSuccessMutationHandler).toHaveBeenCalledWith({
input: {
id: 'gid://gitlab/WorkItem/1',
linkType: linkTypeInput,
workItemsIds: ['gid://gitlab/WorkItem/641', 'gid://gitlab/WorkItem/642'],
},
});
});
it.each`
errorType | mutationMock | errorMessage
${'an error in the mutation response'} | ${jest.fn().mockResolvedValue(linkedWorkItemResponse({}, ['Linked Item failed']))} | ${'Linked Item failed'}
${'a network error'} | ${jest.fn().mockRejectedValue(new Error('Network Error'))} | ${'Something went wrong when trying to link a item. Please try again.'}
`('shows an error message when there is $errorType', async ({ mutationMock, errorMessage }) => {
createComponent({ linkedWorkItemsMutationHandler: mutationMock });
await selectWorkItemTokens([
{
id: 'gid://gitlab/WorkItem/641',
},
]);
findLinkWorkItemForm().vm.$emit('submit', {
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
});
await waitForPromises();
expect(findGlAlert().exists()).toBe(true);
expect(findGlAlert().text()).toBe(errorMessage);
});
});
});

View File

@ -9,6 +9,7 @@ import waitForPromises from 'helpers/wait_for_promises';
import WidgetWrapper from '~/work_items/components/widget_wrapper.vue';
import WorkItemRelationships from '~/work_items/components/work_item_relationships/work_item_relationships.vue';
import WorkItemRelationshipList from '~/work_items/components/work_item_relationships/work_item_relationship_list.vue';
import WorkItemAddRelationshipForm from '~/work_items/components/work_item_relationships/work_item_add_relationship_form.vue';
import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
import {
@ -24,23 +25,20 @@ describe('WorkItemRelationships', () => {
const emptyLinkedWorkItemsQueryHandler = jest
.fn()
.mockResolvedValue(workItemByIidResponseFactory());
const linkedWorkItemsQueryHandler = jest
.fn()
.mockResolvedValue(workItemByIidResponseFactory({ linkedItems: mockLinkedItems }));
const blockingLinkedWorkItemQueryHandler = jest
.fn()
.mockResolvedValue(workItemByIidResponseFactory({ linkedItems: mockBlockingLinkedItem }));
const createComponent = async ({
workItemQueryHandler = emptyLinkedWorkItemsQueryHandler,
workItemType = 'Task',
} = {}) => {
const mockApollo = createMockApollo([[workItemByIidQuery, workItemQueryHandler]]);
wrapper = shallowMountExtended(WorkItemRelationships, {
apolloProvider: mockApollo,
propsData: {
workItemId: 'gid://gitlab/WorkItem/1',
workItemIid: '1',
workItemFullPath: 'test-project-path',
workItemType,
},
});
@ -53,6 +51,8 @@ describe('WorkItemRelationships', () => {
const findLinkedItemsCountContainer = () => wrapper.findByTestId('linked-items-count');
const findAllWorkItemRelationshipListComponents = () =>
wrapper.findAllComponents(WorkItemRelationshipList);
const findAddButton = () => wrapper.findByTestId('link-item-add-button');
const findWorkItemRelationshipForm = () => wrapper.findComponent(WorkItemAddRelationshipForm);
it('shows loading icon when query is not processed', () => {
createComponent();
@ -60,22 +60,32 @@ describe('WorkItemRelationships', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
it('renders the component with empty message when there are no items', async () => {
it('renders the component with with defaults', async () => {
await createComponent();
expect(wrapper.find('.work-item-relationships').exists()).toBe(true);
expect(findEmptyRelatedMessageContainer().exists()).toBe(true);
expect(findAddButton().exists()).toBe(true);
expect(findWorkItemRelationshipForm().exists()).toBe(false);
});
it('renders blocking linked item lists', async () => {
await createComponent({ workItemQueryHandler: blockingLinkedWorkItemQueryHandler });
await createComponent({
workItemQueryHandler: jest
.fn()
.mockResolvedValue(workItemByIidResponseFactory({ linkedItems: mockBlockingLinkedItem })),
});
expect(findAllWorkItemRelationshipListComponents().length).toBe(1);
expect(findLinkedItemsCountContainer().text()).toBe('1');
});
it('renders blocking, blocked by and related to linked item lists with proper count', async () => {
await createComponent({ workItemQueryHandler: linkedWorkItemsQueryHandler });
await createComponent({
workItemQueryHandler: jest
.fn()
.mockResolvedValue(workItemByIidResponseFactory({ linkedItems: mockLinkedItems })),
});
// renders all 3 lists: blocking, blocked by and related to
expect(findAllWorkItemRelationshipListComponents().length).toBe(3);
@ -90,4 +100,24 @@ describe('WorkItemRelationships', () => {
expect(findWidgetWrapper().props('error')).toBe(errorMessage);
});
it('does not render add button when there is no permission', async () => {
await createComponent({
workItemQueryHandler: jest
.fn()
.mockResolvedValue(workItemByIidResponseFactory({ canAdminWorkItemLink: false })),
});
expect(findAddButton().exists()).toBe(false);
});
it('shows form on add button and hides when cancel button is clicked', async () => {
await createComponent();
await findAddButton().vm.$emit('click');
expect(findWorkItemRelationshipForm().exists()).toBe(true);
await findWorkItemRelationshipForm().vm.$emit('cancel');
expect(findWorkItemRelationshipForm().exists()).toBe(false);
});
});

View File

@ -146,6 +146,7 @@ export const workItemQueryResponse = {
setWorkItemMetadata: false,
adminParentLink: false,
createNote: false,
adminWorkItemLink: true,
__typename: 'WorkItemPermissions',
},
widgets: [
@ -251,6 +252,7 @@ export const updateWorkItemMutationResponse = {
setWorkItemMetadata: false,
adminParentLink: false,
createNote: false,
adminWorkItemLink: true,
__typename: 'WorkItemPermissions',
},
reference: 'test-project-path#1',
@ -360,6 +362,7 @@ export const convertWorkItemMutationResponse = {
setWorkItemMetadata: false,
adminParentLink: false,
createNote: false,
adminWorkItemLink: true,
__typename: 'WorkItemPermissions',
},
reference: 'gitlab-org/gitlab-test#1',
@ -579,6 +582,7 @@ export const workItemResponseFactory = ({
canDelete = false,
canCreateNote = false,
adminParentLink = false,
canAdminWorkItemLink = true,
notificationsWidgetPresent = true,
currentUserTodosWidgetPresent = true,
awardEmojiWidgetPresent = true,
@ -636,6 +640,7 @@ export const workItemResponseFactory = ({
updateWorkItem: canUpdate,
setWorkItemMetadata: canUpdate,
adminParentLink,
adminWorkItemLink: canAdminWorkItemLink,
createNote: canCreateNote,
__typename: 'WorkItemPermissions',
},
@ -914,6 +919,7 @@ export const createWorkItemMutationResponse = {
setWorkItemMetadata: false,
adminParentLink: false,
createNote: false,
adminWorkItemLink: true,
__typename: 'WorkItemPermissions',
},
reference: 'test-project-path#1',
@ -996,6 +1002,7 @@ export const workItemHierarchyEmptyResponse = {
setWorkItemMetadata: false,
adminParentLink: false,
createNote: false,
adminWorkItemLink: true,
__typename: 'WorkItemPermissions',
},
confidential: false,
@ -1046,6 +1053,7 @@ export const workItemHierarchyNoUpdatePermissionResponse = {
setWorkItemMetadata: false,
adminParentLink: false,
createNote: false,
adminWorkItemLink: true,
__typename: 'WorkItemPermissions',
},
project: {
@ -1196,6 +1204,7 @@ export const workItemHierarchyResponse = {
setWorkItemMetadata: true,
adminParentLink: true,
createNote: true,
adminWorkItemLink: true,
__typename: 'WorkItemPermissions',
},
author: {
@ -1297,6 +1306,7 @@ export const workItemObjectiveWithChild = {
setWorkItemMetadata: true,
adminParentLink: true,
createNote: true,
adminWorkItemLink: true,
__typename: 'WorkItemPermissions',
},
author: {
@ -1368,6 +1378,7 @@ export const workItemHierarchyTreeResponse = {
setWorkItemMetadata: true,
adminParentLink: true,
createNote: true,
adminWorkItemLink: true,
__typename: 'WorkItemPermissions',
},
confidential: false,
@ -1449,6 +1460,7 @@ export const changeIndirectWorkItemParentMutationResponse = {
setWorkItemMetadata: true,
adminParentLink: true,
createNote: true,
adminWorkItemLink: true,
__typename: 'WorkItemPermissions',
},
description: null,
@ -1517,6 +1529,7 @@ export const changeWorkItemParentMutationResponse = {
setWorkItemMetadata: true,
adminParentLink: true,
createNote: true,
adminWorkItemLink: true,
__typename: 'WorkItemPermissions',
},
description: null,
@ -1568,6 +1581,7 @@ export const availableWorkItemsResponse = {
nodes: [
{
id: 'gid://gitlab/WorkItem/458',
iid: '2',
title: 'Task 1',
state: 'OPEN',
createdAt: '2022-08-03T12:41:54Z',
@ -1576,6 +1590,7 @@ export const availableWorkItemsResponse = {
},
{
id: 'gid://gitlab/WorkItem/459',
iid: '3',
title: 'Task 2',
state: 'OPEN',
createdAt: '2022-08-03T12:41:54Z',
@ -1584,6 +1599,7 @@ export const availableWorkItemsResponse = {
},
{
id: 'gid://gitlab/WorkItem/460',
iid: '4',
title: 'Task 3',
state: 'OPEN',
createdAt: '2022-08-03T12:41:54Z',
@ -1605,6 +1621,7 @@ export const searchedWorkItemsResponse = {
nodes: [
{
id: 'gid://gitlab/WorkItem/459',
iid: '3',
title: 'Task 2',
state: 'OPEN',
createdAt: '2022-08-03T12:41:54Z',
@ -3439,6 +3456,19 @@ export const getTodosMutationResponse = (state) => {
};
};
export const linkedWorkItemResponse = (options, errors = []) => {
const response = workItemResponseFactory(options);
return {
data: {
workItemAddLinkedItems: {
workItem: response.data.workItem,
errors,
__typename: 'WorkItemAddLinkedItemsPayload',
},
},
};
};
export const groupWorkItemsQueryResponse = {
data: {
group: {

View File

@ -199,8 +199,6 @@ describe('Create work item component', () => {
wrapper.find('form').trigger('submit');
await waitForPromises();
expect(findAlert().text()).toBe(
'Something went wrong when creating work item. Please try again.',
);
expect(findAlert().text()).toBe('Something went wrong when creating item. Please try again.');
});
});

View File

@ -1,19 +0,0 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Email::Message::BuildIosAppGuide, :saas do
subject(:message) { described_class.new }
it 'contains the correct message', :aggregate_failures do
expect(message.subject_line).to eq 'Get set up to build for iOS'
expect(message.title).to eq "Building for iOS? We've got you covered."
expect(message.body_line1).to eq "Want to get your iOS app up and running, including " \
"publishing all the way to TestFlight? Follow our guide to set up GitLab and fastlane to publish iOS apps to " \
"the App Store."
expect(message.cta_text).to eq 'Learn how to build for iOS'
expect(message.cta2_text).to eq 'Watch iOS building in action.'
expect(message.logo_path).to eq 'mailers/in_product_marketing/create-0.png'
expect(message.unsubscribe).to include('%tag_unsubscribe_url%')
end
end

View File

@ -1,51 +1,17 @@
# frozen_string_literal: true
require 'spec_helper'
require 'email_spec'
RSpec.describe Emails::InProductMarketing do
include EmailSpec::Matchers
include Gitlab::Routing.url_helpers
let_it_be(:user) { create(:user) }
shared_examples 'has custom headers when on gitlab.com' do
context 'when on gitlab.com', :saas do
it 'has custom headers' do
aggregate_failures do
expect(subject).to deliver_from(described_class::FROM_ADDRESS)
expect(subject).to reply_to(described_class::FROM_ADDRESS)
expect(subject).to have_header('X-Mailgun-Track', 'yes')
expect(subject).to have_header('X-Mailgun-Track-Clicks', 'yes')
expect(subject).to have_header('X-Mailgun-Track-Opens', 'yes')
expect(subject).to have_header('X-Mailgun-Tag', 'marketing')
expect(subject).to have_body_text('%tag_unsubscribe_url%')
end
end
end
end
describe '#build_ios_app_guide_email' do
subject { Notify.build_ios_app_guide_email(user.notification_email_or_default) }
it 'sends to the right user' do
expect(subject).to deliver_to(user.notification_email_or_default)
end
it 'has the correct subject and content' do
message = Gitlab::Email::Message::BuildIosAppGuide.new
cta_url = 'https://about.gitlab.com/blog/2019/03/06/ios-publishing-with-gitlab-and-fastlane/'
cta2_url = 'https://www.youtube.com/watch?v=325FyJt7ZG8'
aggregate_failures do
is_expected.to have_subject(message.subject_line)
is_expected.to have_body_text(message.title)
is_expected.to have_body_text(message.body_line1)
is_expected.to have_body_text(CGI.unescapeHTML(message.cta_link))
is_expected.to have_body_text(CGI.unescapeHTML(message.cta2_link))
is_expected.to have_body_text(cta_url)
is_expected.to have_body_text(cta2_url)
end
end
it 'has correct custom headers' do
expect(described_class::FROM_ADDRESS).to be('GitLab <team@gitlab.com>')
expect(described_class::CUSTOM_HEADERS).to eq({
from: 'GitLab <team@gitlab.com>',
reply_to: 'GitLab <team@gitlab.com>',
'X-Mailgun-Track' => 'yes',
'X-Mailgun-Track-Clicks' => 'yes',
'X-Mailgun-Track-Opens' => 'yes',
'X-Mailgun-Tag' => 'marketing'
})
end
end

View File

@ -263,7 +263,7 @@ RSpec.describe AbuseReport, feature_category: :insider_threat do
let_it_be(:merge_request) { create(:merge_request) }
let_it_be(:user) { create(:user) }
subject { report.report_type }
subject(:report_type) { report.report_type }
context 'when reported from an issue' do
let(:url) { project_issue_url(issue.project, issue) }
@ -322,7 +322,7 @@ RSpec.describe AbuseReport, feature_category: :insider_threat do
let_it_be(:merge_request) { create(:merge_request, description: 'mr description') }
let_it_be(:user) { create(:user) }
subject { report.reported_content }
subject(:reported_content) { report.reported_content }
context 'when reported from an issue' do
let(:url) { project_issue_url(issue.project, issue) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe Users::InProductMarketingEmail, type: :model do
RSpec.describe Users::InProductMarketingEmail, type: :model, feature_category: :onboarding do
let(:track) { :create }
let(:series) { 0 }
@ -24,28 +24,6 @@ RSpec.describe Users::InProductMarketingEmail, type: :model do
.scoped_to([:track, :series]).with_message('track series email has already been sent')
}
end
context 'when campaign email' do
subject { build(:in_product_marketing_email, :campaign) }
it { is_expected.to validate_presence_of(:campaign) }
it { is_expected.not_to validate_presence_of(:track) }
it { is_expected.not_to validate_presence_of(:series) }
it {
is_expected.to validate_uniqueness_of(:user_id)
.scoped_to(:campaign).with_message('campaign email has already been sent')
}
it { is_expected.to validate_inclusion_of(:campaign).in_array(described_class::CAMPAIGNS) }
end
context 'when mixing campaign and track+series' do
it 'is not valid' do
expect(build(:in_product_marketing_email, :campaign, track: :create)).not_to be_valid
expect(build(:in_product_marketing_email, :campaign, series: 0)).not_to be_valid
end
end
end
describe '.without_track_and_series' do
@ -84,27 +62,6 @@ RSpec.describe Users::InProductMarketingEmail, type: :model do
end
end
describe '.without_campaign' do
let_it_be(:user) { create(:user) }
let_it_be(:other_user) { create(:user) }
let(:campaign) { Users::InProductMarketingEmail::BUILD_IOS_APP_GUIDE }
subject(:without_campaign) { User.merge(described_class.without_campaign(campaign)) }
context 'when record for campaign already exists' do
before do
create(:in_product_marketing_email, :campaign, campaign: campaign, user: user)
end
it { is_expected.to match_array [other_user] }
end
context 'when record for campaign does not exist' do
it { is_expected.to match_array [user, other_user] }
end
end
describe '.for_user_with_track_and_series' do
let_it_be(:user) { create(:user) }
let_it_be(:in_product_marketing_email) { create(:in_product_marketing_email, series: 0, track: 0, user: user) }

View File

@ -1,136 +0,0 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Projects::InProductMarketingCampaignEmailsService, feature_category: :activation do
describe '#execute' do
let(:user) { create(:user) }
let(:project) { create(:project) }
let(:campaign) { Users::InProductMarketingEmail::BUILD_IOS_APP_GUIDE }
before do
allow(Notify)
.to receive(:build_ios_app_guide_email)
.and_return(instance_double(ActionMailer::MessageDelivery, deliver_later: true))
end
subject(:execute) do
described_class.new(project, campaign).execute
end
context 'users can receive marketing emails' do
let(:maintainer) { create(:user) }
let(:developer) { create(:user) }
before do
project.add_developer(developer)
project.add_maintainer(maintainer)
end
it 'sends the email to all project members with access_level >= Developer', :aggregate_failures do
[project.owner, maintainer, developer].each do |user|
email = user.notification_email_or_default
expect(Notify).to receive(:build_ios_app_guide_email).with(email)
end
execute
end
it 'records sent emails', :aggregate_failures do
expect { execute }.to change { Users::InProductMarketingEmail.count }.from(0).to(3)
[project.owner, maintainer, developer].each do |user|
expect(
Users::InProductMarketingEmail.where(
user: user,
campaign: campaign
)
).to exist
end
end
it 'tracks experiment :email_sent event', :experiment do
expect(experiment(:build_ios_app_guide_email)).to track(:email_sent)
.on_next_instance
.with_context(project: project)
execute
end
end
shared_examples 'does not send the email' do
it do
email = user.notification_email_or_default
expect(Notify).not_to receive(:build_ios_app_guide_email).with(email)
execute
end
end
shared_examples 'does not create a record of the sent email' do
it do
expect(
Users::InProductMarketingEmail.where(
user: user,
campaign: campaign
)
).not_to exist
execute
end
end
context "when user can't receive marketing emails" do
before do
project.add_developer(user)
end
context 'when user.can?(:receive_notifications) is false' do
it 'does not send the email' do
allow_next_found_instance_of(User) do |user|
allow(user).to receive(:can?).with(:receive_notifications) { false }
email = user.notification_email_or_default
expect(Notify).not_to receive(:build_ios_app_guide_email).with(email)
expect(
Users::InProductMarketingEmail.where(
user: user,
campaign: campaign
)
).not_to exist
end
execute
end
end
end
context 'when campaign email has already been sent to the user' do
before do
project.add_developer(user)
create(:in_product_marketing_email, :campaign, user: user, campaign: campaign)
end
it_behaves_like 'does not send the email'
end
context "when user is a reporter" do
before do
project.add_reporter(user)
end
it_behaves_like 'does not send the email'
it_behaves_like 'does not create a record of the sent email'
end
context "when user is a guest" do
before do
project.add_guest(user)
end
it_behaves_like 'does not send the email'
it_behaves_like 'does not create a record of the sent email'
end
end
end

View File

@ -51,52 +51,6 @@ RSpec.describe Projects::RecordTargetPlatformsService, '#execute', feature_categ
end
end
end
describe 'Build iOS guide email experiment' do
shared_examples 'tracks experiment assignment event' do
it 'tracks the assignment event', :experiment do
expect(experiment(:build_ios_app_guide_email))
.to track(:assignment)
.with_context(project: project)
.on_next_instance
execute
end
end
context 'experiment candidate' do
before do
stub_experiments(build_ios_app_guide_email: :candidate)
end
it 'executes a Projects::InProductMarketingCampaignEmailsService' do
service_double = instance_double(Projects::InProductMarketingCampaignEmailsService, execute: true)
expect(Projects::InProductMarketingCampaignEmailsService)
.to receive(:new).with(project, Users::InProductMarketingEmail::BUILD_IOS_APP_GUIDE)
.and_return service_double
expect(service_double).to receive(:execute)
execute
end
it_behaves_like 'tracks experiment assignment event'
end
context 'experiment control' do
before do
stub_experiments(build_ios_app_guide_email: :control)
end
it 'does not execute a Projects::InProductMarketingCampaignEmailsService' do
expect(Projects::InProductMarketingCampaignEmailsService).not_to receive(:new)
execute
end
it_behaves_like 'tracks experiment assignment event'
end
end
end
context 'when project is not an XCode project' do

View File

@ -17,7 +17,6 @@ RSpec.describe Users::InProductMarketingEmailRecords, feature_category: :onboard
records.add(user, track: :team_short, series: 0)
records.add(user, track: :create, series: 1)
records.add(user, campaign: Users::InProductMarketingEmail::BUILD_IOS_APP_GUIDE)
end
it 'bulk inserts added records' do
@ -36,30 +35,20 @@ RSpec.describe Users::InProductMarketingEmailRecords, feature_category: :onboard
freeze_time do
records.add(user, track: :team_short, series: 0)
records.add(user, track: :create, series: 1)
records.add(user, campaign: Users::InProductMarketingEmail::BUILD_IOS_APP_GUIDE)
first, second, third = records.records
first, second = records.records
expect(first).to be_a Users::InProductMarketingEmail
expect(first.campaign).to be_nil
expect(first.track.to_sym).to eq :team_short
expect(first.series).to eq 0
expect(first.created_at).to eq Time.zone.now
expect(first.updated_at).to eq Time.zone.now
expect(second).to be_a Users::InProductMarketingEmail
expect(second.campaign).to be_nil
expect(second.track.to_sym).to eq :create
expect(second.series).to eq 1
expect(second.created_at).to eq Time.zone.now
expect(second.updated_at).to eq Time.zone.now
expect(third).to be_a Users::InProductMarketingEmail
expect(third.campaign).to eq Users::InProductMarketingEmail::BUILD_IOS_APP_GUIDE
expect(third.track).to be_nil
expect(third.series).to be_nil
expect(third.created_at).to eq Time.zone.now
expect(third.updated_at).to eq Time.zone.now
end
end
end

View File

@ -28,7 +28,7 @@ RSpec.describe WorkItems::RelatedWorkItemLinks::CreateService, feature_category:
it_behaves_like 'issuable link creation', use_references: false do
let(:response_keys) { [:status, :created_references, :message] }
let(:already_assigned_error_msg) { "Work items are already linked" }
let(:already_assigned_error_msg) { "Items are already linked" }
let(:no_found_error_msg) do
'No matching work item found. Make sure you are adding a valid ID and you have access to the item.'
end

View File

@ -6244,7 +6244,6 @@
- './spec/lib/gitlab/email/hook/delivery_metrics_observer_spec.rb'
- './spec/lib/gitlab/email/hook/disable_email_interceptor_spec.rb'
- './spec/lib/gitlab/email/hook/smime_signature_interceptor_spec.rb'
- './spec/lib/gitlab/email/message/build_ios_app_guide_spec.rb'
- './spec/lib/gitlab/email/message/in_product_marketing/admin_verify_spec.rb'
- './spec/lib/gitlab/email/message/in_product_marketing/base_spec.rb'
- './spec/lib/gitlab/email/message/in_product_marketing/create_spec.rb'
@ -9433,7 +9432,6 @@
- './spec/services/projects/import_export/export_service_spec.rb'
- './spec/services/projects/import_export/relation_export_service_spec.rb'
- './spec/services/projects/import_service_spec.rb'
- './spec/services/projects/in_product_marketing_campaign_emails_service_spec.rb'
- './spec/services/projects/lfs_pointers/lfs_download_link_list_service_spec.rb'
- './spec/services/projects/lfs_pointers/lfs_download_service_spec.rb'
- './spec/services/projects/lfs_pointers/lfs_import_service_spec.rb'

View File

@ -1,12 +0,0 @@
#!/usr/bin/env sh -e
for RUBY in 1.9.3 2.0.0 2.1 2.2
do
for RAILS in 2.3.8 3.0.0 3.1.0 3.2.0 4.0.0 4.1.0 4.2.0
do
if [[ $RUBY -gt 1.9.3 && $RAILS -lt 4.0.0 ]]; then
continue
fi
RBENV_VERSION=$RUBY ACTIVERECORD=$RAILS bundle && bundle exec rake
done
done

View File

@ -1292,10 +1292,10 @@
resolved "https://registry.yarnpkg.com/@gitlab/visual-review-tools/-/visual-review-tools-1.7.3.tgz#9ea641146436da388ffbad25d7f2abe0df52c235"
integrity sha512-NMV++7Ew1FSBDN1xiZaauU9tfeSfgDHcOLpn+8bGpP+O5orUPm2Eu66R5eC5gkjBPaXosNAxNWtriee+aFk4+g==
"@gitlab/web-ide@0.0.1-dev-20230915130935":
version "0.0.1-dev-20230915130935"
resolved "https://registry.yarnpkg.com/@gitlab/web-ide/-/web-ide-0.0.1-dev-20230915130935.tgz#a7c8585d3413b235a23d54c5fb3d5f22be95f443"
integrity sha512-80zCGoVU1GhovWOH/ieYKzYx4IWmXovbjpM2elwvggAZBVqwHN1pvhgm/W4XjE18kSRAxRCjHIkhEflb0Mhntg==
"@gitlab/web-ide@0.0.1-dev-20230921201626":
version "0.0.1-dev-20230921201626"
resolved "https://registry.yarnpkg.com/@gitlab/web-ide/-/web-ide-0.0.1-dev-20230921201626.tgz#9c31a29e6552c48395dcb7b7b82db5e18d367e4b"
integrity sha512-EQENU4fv9u7SZEjkEOwjvMr9vmGC8QPeIiaDIVmpHUl51LPp42fN7HsW1zGtg4JF0Aq8R0jrvn1nMhLst5XnRA==
"@graphql-eslint/eslint-plugin@3.20.1":
version "3.20.1"