Commit 6f3125e0 authored by Rémy Coutable's avatar Rémy Coutable

Merge branch '259014-update-gitlab-styles' into 'master'

Update rubocop/gitlab-styles

See merge request gitlab-org/gitlab!46477
parents 07257f8b 0f91b360
<script>
import { GlTable, GlIcon, GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui';
import {
GlButtonGroup,
GlButton,
GlIcon,
GlLoadingIcon,
GlTable,
GlTooltipDirective,
} from '@gitlab/ui';
import { s__, __ } from '~/locale';
import Tracking from '~/tracking';
import { trackAlertIntegrationsViewsOptions } from '../constants';
......@@ -25,9 +32,11 @@ const bodyTrClass =
export default {
i18n,
components: {
GlTable,
GlButtonGroup,
GlButton,
GlIcon,
GlLoadingIcon,
GlTable,
},
directives: {
GlTooltip: GlTooltipDirective,
......@@ -57,6 +66,10 @@ export default {
key: 'type',
label: __('Type'),
},
{
key: 'actions',
label: __('Actions'),
},
],
computed: {
tbodyTrClass() {
......@@ -111,6 +124,13 @@ export default {
</span>
</template>
<template #cell(actions)="{ item }">
<gl-button-group>
<gl-button icon="pencil" @click="$emit('edit-integration', { id: item.id })" />
<gl-button icon="remove" @click="$emit('delete-integration', { id: item.id })" />
</gl-button-group>
</template>
<template #table-busy>
<gl-loading-icon size="lg" color="dark" class="mt-3" />
</template>
......
......@@ -21,12 +21,14 @@ import {
JSON_VALIDATE_DELAY,
targetPrometheusUrlPlaceholder,
typeSet,
defaultFormState,
} from '../constants';
export default {
targetPrometheusUrlPlaceholder,
JSON_VALIDATE_DELAY,
typeSet,
defaultFormState,
i18n: {
integrationFormSteps: {
step1: {
......@@ -62,6 +64,11 @@ export default {
label: s__('AlertSettings|Prometheus API base URL'),
help: s__('AlertSettings|URL cannot be blank and must start with http or https'),
},
restKeyInfo: {
label: s__(
'AlertSettings|Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in.',
),
},
},
},
components: {
......@@ -95,23 +102,18 @@ export default {
type: Boolean,
required: true,
},
currentIntegration: {
type: Object,
required: false,
default: null,
},
},
data() {
return {
selectedIntegration: integrationTypesNew[0].value,
active: false,
options: integrationTypesNew,
formVisible: false,
integrationForm: {
name: '',
integrationTestPayload: {
json: null,
error: null,
},
active: false,
authKey: '',
url: '',
apiUrl: '',
},
};
},
computed: {
......@@ -125,9 +127,29 @@ export default {
case this.$options.typeSet.prometheus:
return this.prometheus;
default:
return {};
return this.defaultFormState;
}
},
integrationForm() {
return {
name: this.currentIntegration?.name || '',
integrationTestPayload: {
json: null,
error: null,
},
active: this.currentIntegration?.active || false,
token: this.currentIntegration?.token || '',
url: this.currentIntegration?.url || '',
apiUrl: this.currentIntegration?.apiUrl || '',
};
},
},
watch: {
currentIntegration(val) {
this.selectedIntegration = val.type;
this.active = val.active;
this.onIntegrationTypeSelect();
},
},
methods: {
onIntegrationTypeSelect() {
......@@ -142,18 +164,29 @@ export default {
this.onSubmit();
},
onSubmit() {
const { name, apiUrl, active } = this.integrationForm;
const { name, apiUrl } = this.integrationForm;
const variables =
this.selectedIntegration === this.$options.typeSet.http
? { name, active }
: { apiUrl, active };
this.$emit('on-create-new-integration', { type: this.selectedIntegration, variables });
? { name, active: this.active }
: { apiUrl, active: this.active };
const integrationPayload = { type: this.selectedIntegration, variables };
if (this.currentIntegration) {
return this.$emit('update-integration', integrationPayload);
}
return this.$emit('create-new-integration', integrationPayload);
},
onReset() {
// TODO: Reset form values
this.integrationForm = this.defaultFormState;
this.selectedIntegration = integrationTypesNew[0].value;
this.onIntegrationTypeSelect();
},
onResetAuthKey() {
// TODO: Handle reset auth key via GraphQL
this.$emit('reset-token', {
type: this.selectedIntegration,
variables: { id: this.currentIntegration.id },
});
},
validateJson() {
this.integrationForm.integrationTestPayload.error = null;
......@@ -214,7 +247,7 @@ export default {
/>
<gl-toggle
v-model="integrationForm.active"
v-model="active"
:is-loading="loading"
:label="__('Active')"
class="gl-my-4 gl-font-weight-normal"
......@@ -242,13 +275,9 @@ export default {
{{ s__('AlertSettings|Webhook URL') }}
</span>
<gl-form-input-group id="url" readonly :value="selectedIntegrationType.url">
<gl-form-input-group id="url" readonly :value="integrationForm.url">
<template #append>
<clipboard-button
:text="selectedIntegrationType.url || ''"
:title="__('Copy')"
class="gl-m-0!"
/>
<clipboard-button :text="integrationForm.url" :title="__('Copy')" class="gl-m-0!" />
</template>
</gl-form-input-group>
</div>
......@@ -262,14 +291,10 @@ export default {
id="authorization-key"
class="gl-mb-2"
readonly
:value="selectedIntegrationType.authKey"
:value="integrationForm.token"
>
<template #append>
<clipboard-button
:text="selectedIntegrationType.authKey || ''"
:title="__('Copy')"
class="gl-m-0!"
/>
<clipboard-button :text="integrationForm.token" :title="__('Copy')" class="gl-m-0!" />
</template>
</gl-form-input-group>
......@@ -281,9 +306,9 @@ export default {
:title="$options.i18n.integrationFormSteps.step3.reset"
:ok-title="$options.i18n.integrationFormSteps.step3.reset"
ok-variant="danger"
@ok="() => {}"
@ok="onResetAuthKey"
>
{{ $options.i18n.integrationFormSteps.step3.reset }}
{{ $options.i18n.integrationFormSteps.restKeyInfo.label }}
</gl-modal>
</div>
</gl-form-group>
......
......@@ -59,7 +59,7 @@ export default {
selectedIntegration: integrationTypes[0].value,
options: integrationTypes,
active: false,
authKey: '',
token: '',
targetUrl: '',
feedback: {
variant: 'danger',
......@@ -98,7 +98,7 @@ export default {
case 'HTTP': {
return {
url: this.generic.url,
authKey: this.generic.authKey,
token: this.generic.token,
active: this.generic.active,
resetKey: this.resetKey.bind(this),
};
......@@ -106,7 +106,7 @@ export default {
case 'PROMETHEUS': {
return {
url: this.prometheus.url,
authKey: this.prometheus.authKey,
token: this.prometheus.token,
active: this.prometheus.active,
resetKey: this.resetKey.bind(this, 'PROMETHEUS'),
targetUrl: this.prometheus.prometheusApiUrl,
......@@ -167,7 +167,7 @@ export default {
this.setOpsgenieAsDefault();
}
this.active = this.selectedIntegrationType.active;
this.authKey = this.selectedIntegrationType.authKey ?? '';
this.token = this.selectedIntegrationType.token ?? '';
},
methods: {
createUserErrorMessage(errors = {}) {
......@@ -212,8 +212,8 @@ export default {
return fn
.then(({ data: { token } }) => {
this.authKey = token;
this.setFeedback({ feedbackMessage: this.$options.i18n.authKeyRest, variant: 'success' });
this.token = token;
this.setFeedback({ feedbackMessage: this.$options.i18n.tokenRest, variant: 'success' });
})
.catch(() => {
this.setFeedback({ feedbackMessage: this.$options.i18n.errorKeyMsg, variant: 'danger' });
......@@ -313,7 +313,7 @@ export default {
.updateTestAlert({
endpoint: this.selectedIntegrationType.url,
data: this.testAlert.json,
authKey: this.selectedIntegrationType.authKey,
token: this.selectedIntegrationType.token,
})
.then(() => {
this.setFeedback({
......@@ -439,21 +439,21 @@ export default {
{{ prometheusInfo }}
</span>
</gl-form-group>
<gl-form-group :label="$options.i18n.authKeyLabel" label-for="authorization-key">
<gl-form-input-group id="authorization-key" class="gl-mb-2" readonly :value="authKey">
<gl-form-group :label="$options.i18n.tokenLabel" label-for="authorization-key">
<gl-form-input-group id="authorization-key" class="gl-mb-2" readonly :value="token">
<template #append>
<clipboard-button
:text="authKey"
:text="token"
:title="$options.i18n.copyToClipboard"
class="gl-m-0!"
/>
</template>
</gl-form-input-group>
<gl-button v-gl-modal.authKeyModal :disabled="!active" class="gl-mt-3">{{
<gl-button v-gl-modal.tokenModal :disabled="!active" class="gl-mt-3">{{
$options.i18n.resetKey
}}</gl-button>
<gl-modal
modal-id="authKeyModal"
modal-id="tokenModal"
:title="$options.i18n.resetKey"
:ok-title="$options.i18n.resetKey"
ok-variant="danger"
......
......@@ -7,6 +7,10 @@ import createFlash, { FLASH_TYPES } from '~/flash';
import getIntegrationsQuery from '../graphql/queries/get_integrations.query.graphql';
import createHttpIntegrationMutation from '../graphql/mutations/create_http_integration.mutation.graphql';
import createPrometheusIntegrationMutation from '../graphql/mutations/create_prometheus_integration.mutation.graphql';
import updateHttpIntegrationMutation from '../graphql/mutations/update_http_integration.mutation.graphql';
import updatePrometheusIntegrationMutation from '../graphql/mutations/update_prometheus_integration.mutation.graphql';
import resetHttpTokenMutation from '../graphql/mutations/reset_http_token.mutation.graphql';
import resetPrometheusTokenMutation from '../graphql/mutations/reset_prometheus_token.mutation.graphql';
import IntegrationsList from './alerts_integrations_list.vue';
import SettingsFormOld from './alerts_settings_form_old.vue';
import SettingsFormNew from './alerts_settings_form_new.vue';
......@@ -52,16 +56,16 @@ export default {
list,
};
},
error() {
this.errored = true;
error(err) {
createFlash({ message: err });
},
},
},
data() {
return {
errored: false,
isUpdating: false,
integrations: {},
currentIntegration: null,
};
},
computed: {
......@@ -84,7 +88,7 @@ export default {
},
},
methods: {
onCreateNewIntegration({ type, variables }) {
createNewIntegration({ type, variables }) {
this.isUpdating = true;
this.$apollo
.mutate({
......@@ -109,7 +113,6 @@ export default {
});
})
.catch(err => {
this.errored = true;
createFlash({ message: err });
})
.finally(() => {
......@@ -151,6 +154,72 @@ export default {
data,
});
},
updateIntegration({ type, variables }) {
this.isUpdating = true;
this.$apollo
.mutate({
mutation:
type === this.$options.typeSet.http
? updateHttpIntegrationMutation
: updatePrometheusIntegrationMutation,
variables: {
...variables,
id: this.currentIntegration.id,
},
})
.then(({ data: { httpIntegrationUpdate, prometheusIntegrationUpdate } = {} } = {}) => {
const error = httpIntegrationUpdate?.errors[0] || prometheusIntegrationUpdate?.errors[0];
if (error) {
return createFlash({ message: error });
}
return createFlash({
message: this.$options.i18n.changesSaved,
type: FLASH_TYPES.SUCCESS,
});
})
.catch(err => {
createFlash({ message: err });
})
.finally(() => {
this.isUpdating = false;
});
},
resetToken({ type, variables }) {
this.isUpdating = true;
this.$apollo
.mutate({
mutation:
type === this.$options.typeSet.http
? resetHttpTokenMutation
: resetPrometheusTokenMutation,
variables,
})
.then(
({ data: { httpIntegrationResetToken, prometheusIntegrationResetToken } = {} } = {}) => {
const error =
httpIntegrationResetToken?.errors[0] || prometheusIntegrationResetToken?.errors[0];
if (error) {
return createFlash({ message: error });
}
return createFlash({
message: this.$options.i18n.changesSaved,
type: FLASH_TYPES.SUCCESS,
});
},
)
.catch(err => {
createFlash({ message: err });
})
.finally(() => {
this.isUpdating = false;
});
},
editIntegration({ id }) {
this.currentIntegration = this.integrations.list.find(integration => integration.id === id);
},
deleteIntegration() {
// TODO, handle delete via GraphQL
},
},
};
</script>
......@@ -160,11 +229,16 @@ export default {
<integrations-list
:integrations="glFeatures.httpIntegrationsList ? integrations.list : intergrationsOptionsOld"
:loading="loading"
@edit-integration="editIntegration"
@delete-integration="deleteIntegration"
/>
<settings-form-new
v-if="glFeatures.httpIntegrationsList"
:loading="loading"
@on-create-new-integration="onCreateNewIntegration"
:loading="isUpdating"
:current-integration="currentIntegration"
@create-new-integration="createNewIntegration"
@update-integration="updateIntegration"
@reset-token="resetToken"
/>
<settings-form-old v-else />
</div>
......
......@@ -57,6 +57,15 @@ export const typeSet = {
prometheus: 'PROMETHEUS',
};
export const defaultFormState = {
name: '',
active: false,
token: '',
url: '',
apiUrl: '',
integrationTestPayload: { json: null, error: null },
};
export const JSON_VALIDATE_DELAY = 250;
export const targetPrometheusUrlPlaceholder = 'http://prometheus.example.com/';
......
#import "../fragments/integration_item.fragment.graphql"
mutation resetHttpIntegrationToken($id: ID!) {
httpIntegrationResetToken(input: { id: $id }) {
errors
integration {
...IntegrationItem
}
}
}
#import "../fragments/integration_item.fragment.graphql"
mutation resetPrometheusIntegrationToken($id: ID!) {
prometheusIntegrationResetToken(input: { id: $id }) {
errors
integration {
...IntegrationItem
}
}
}
#import "../fragments/integration_item.fragment.graphql"
mutation updateHttpIntegration($id: ID!, $name: String!, $active: Boolean!) {
httpIntegrationUpdate(input: { id: $id, name: $name, active: $active }) {
errors
integration {
...IntegrationItem
}
}
}
#import "../fragments/integration_item.fragment.graphql"
mutation updatePrometheusIntegration($id: ID!, $apiUrl: String!, $active: Boolean!) {
prometheusIntegrationUpdate(input: { id: $id, apiUrl: $apiUrl, active: $active }) {
errors
integration {
...IntegrationItem
}
}
}
......@@ -50,7 +50,7 @@ export default el => {
prometheus: {
active: parseBoolean(prometheusActivated),
url: prometheusUrl,
authKey: prometheusAuthorizationKey,
token: prometheusAuthorizationKey,
prometheusFormPath,
prometheusResetKeyPath,
prometheusApiUrl,
......@@ -60,7 +60,7 @@ export default el => {
alertsUsageUrl,
active: parseBoolean(activatedStr),
formPath,
authKey: authorizationKey,
token: authorizationKey,
url,
},
opsgenie: {
......
<script>
import { GlButton, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import { VALID_DESIGN_FILE_MIMETYPE } from '../../constants';
export default {
components: {
GlButton,
GlLoadingIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
......@@ -38,12 +37,12 @@ export default {
)
"
:disabled="isSaving"
:loading="isSaving"
variant="default"
size="small"
@click="openFileUpload"
>
{{ s__('DesignManagement|Upload designs') }}
<gl-loading-icon v-if="isSaving" inline class="ml-1" />
</gl-button>
<input
......
......@@ -230,7 +230,13 @@ export default {
:href="titleLink"
@click="handleFileNameClick"
>
<file-icon :file-name="filePath" :size="18" aria-hidden="true" css-classes="gl-mr-2" />
<file-icon
:file-name="filePath"
:size="18"
aria-hidden="true"
css-classes="gl-mr-2"
:submodule="diffFile.submodule"
/>
<span v-if="isFileRenamed">
<strong
v-gl-tooltip
......
......@@ -664,6 +664,7 @@ export const generateTreeList = files => {
addedLines: file.added_lines,
removedLines: file.removed_lines,
parentPath: parent ? `${parent.path}/` : '/',
submodule: file.submodule,
});
} else {
Object.assign(entry, {
......
......@@ -52,7 +52,7 @@ export default {
<p class="gl-mb-0">
{{
s__(
'Integrations|Projects using custom settings will not be impacted unless the project owner chooses to use instance-level defaults.',
'Integrations|Projects using custom settings will not be impacted unless the project owner chooses to use parent level defaults.',
)
}}
</p>
......
......@@ -38,8 +38,11 @@ export default {
isJira() {
return this.propsSource.type === 'jira';
},
isInstanceLevel() {
return this.propsSource.integrationLevel === integrationLevels.INSTANCE;
isInstanceOrGroupLevel() {
return (
this.propsSource.integrationLevel === integrationLevels.INSTANCE ||
this.propsSource.integrationLevel === integrationLevels.GROUP
);
},
showJiraIssuesFields() {
return this.isJira && this.glFeatures.jiraIssuesIntegration;
......@@ -91,7 +94,7 @@ export default {
v-bind="propsSource.jiraIssuesProps"
/>
<div v-if="isEditable" class="footer-block row-content-block">
<template v-if="isInstanceLevel">
<template v-if="isInstanceOrGroupLevel">
<gl-button
v-gl-modal.confirmSaveIntegration
category="primary"
......
......@@ -108,12 +108,7 @@ export default {
:label="s__('Integrations|Comment detail:')"
data-testid="comment-detail"
>
<input
v-if="isInheriting"
name="service[comment_detail]"
type="hidden"
:value="commentDetail"
/>
<input name="service[comment_detail]" type="hidden" :value="commentDetail" />
<gl-form-radio
v-for="commentDetailOption in commentDetailOptions"
:key="commentDetailOption.value"
......
......@@ -6,13 +6,13 @@ import {
GlDatepicker,
GlLink,
GlSprintf,
GlSearchBoxByType,
GlButton,
GlFormInput,
} from '@gitlab/ui';
import eventHub from '../event_hub';
import { s__, sprintf } from '~/locale';
import Api from '~/api';
import MembersTokenSelect from '~/invite_members/components/members_token_select.vue';
export default {
name: 'InviteMembersModal',
......@@ -23,9 +23,9 @@ export default {
GlDropdown,
GlDropdownItem,
GlSprintf,
GlSearchBoxByType,
GlButton,
GlFormInput,
MembersTokenSelect,
},
props: {
groupId: {
......@@ -129,44 +129,45 @@ export default {
},
labels: {
modalTitle: s__('InviteMembersModal|Invite team members'),
userToInvite: s__('InviteMembersModal|GitLab member or Email address'),
newUsersToInvite: s__('InviteMembersModal|GitLab member or Email address'),
userPlaceholder: s__('InviteMembersModal|Search for members to invite'),
accessLevel: s__('InviteMembersModal|Choose a role permission'),
accessExpireDate: s__('InviteMembersModal|Access expiration date (optional)'),
toastMessageSuccessful: s__('InviteMembersModal|Users were succesfully added'),
toastMessageUnsuccessful: s__('InviteMembersModal|User not invited. Feature coming soon!'),
toastMessageSuccessful: s__('InviteMembersModal|Members were successfully added'),
toastMessageUnsuccessful: s__('InviteMembersModal|Some of the members could not be added'),
readMoreText: s__(`InviteMembersModal|%{linkStart}Read more%{linkEnd} about role permissions`),
inviteButtonText: s__('InviteMembersModal|Invite'),
cancelButtonText: s__('InviteMembersModal|Cancel'),
headerCloseLabel: s__('InviteMembersModal|Close invite team members'),
},
membersTokenSelectLabelId: 'invite-members-input',
};
</script>
<template>
<gl-modal :modal-id="modalId" size="sm" :title="$options.labels.modalTitle">
<gl-modal
:modal-id="modalId"
size="sm"
:title="$options.labels.modalTitle"
:header-close-label="$options.labels.headerCloseLabel"
>
<div class="gl-ml-5 gl-mr-5">
<div>{{ introText }}</div>
<label class="gl-font-weight-bold gl-mt-5">{{ $options.labels.userToInvite }}</label>
<label :id="$options.membersTokenSelectLabelId" class="gl-font-weight-bold gl-mt-5">{{
$options.labels.newUsersToInvite
}}</label>
<div class="gl-mt-2">
<gl-search-box-by-type
<members-token-select
v-model="newUsersToInvite"
:label="$options.labels.newUsersToInvite"
:aria-labelledby="$options.membersTokenSelectLabelId"
:placeholder="$options.labels.userPlaceholder"
type="text"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
/>
</div>
<label class="gl-font-weight-bold gl-mt-5">{{ $options.labels.accessLevel }}</label>
<div class="gl-mt-2 gl-w-half gl-xs-w-full">
<gl-dropdown
menu-class="dropdown-menu-selectable"
class="gl-shadow-none gl-w-full"
v-bind="$attrs"
:text="selectedRoleName"
>
<gl-dropdown class="gl-shadow-none gl-w-full" v-bind="$attrs" :text="selectedRoleName">
<template v-for="(key, item) in accessLevels">
<gl-dropdown-item
:key="key"
......@@ -215,9 +216,13 @@ export default {
{{ $options.labels.cancelButtonText }}
</gl-button>
<div class="gl-mr-3"></div>
<gl-button ref="inviteButton" variant="success" @click="sendInvite">{{
$options.labels.inviteButtonText
}}</gl-button>
<gl-button
ref="inviteButton"
:disabled="!newUsersToInvite"
variant="success"
@click="sendInvite"
>{{ $options.labels.inviteButtonText }}</gl-button
>
</div>
</template>
</gl-modal>
......
<script>
import { debounce } from 'lodash';
import { GlTokenSelector, GlAvatar, GlAvatarLabeled } from '@gitlab/ui';
import { USER_SEARCH_DELAY } from '../constants';
import Api from '~/api';
export default {
components: {
GlTokenSelector,
GlAvatar,
GlAvatarLabeled,
},
props: {
placeholder: {
type: String,
required: false,
default: '',
},
ariaLabelledby: {
type: String,
required: true,
},
},
data() {
return {
loading: false,
query: '',
users: [],
selectedTokens: [],
hasBeenFocused: false,
hideDropdownWithNoItems: true,
};
},
computed: {
newUsersToInvite() {
return this.selectedTokens
.map(obj => {
return obj.id;
})
.join(',');
},
placeholderText() {
if (this.selectedTokens.length === 0) {
return this.placeholder;
}
return '';
},
},
methods: {
handleTextInput(query) {
this.hideDropdownWithNoItems = false;
this.query = query;
this.loading = true;
this.retrieveUsers(query);
},
retrieveUsers: debounce(function debouncedRetrieveUsers() {
return Api.users(this.query, this.$options.queryOptions)
.then(response => {
this.users = response.data.map(token => ({
id: token.id,
name: token.name,
username: token.username,
avatar_url: token.avatar_url,
}));
this.loading = false;
})
.catch(() => {
this.loading = false;
});
}, USER_SEARCH_DELAY),
handleInput() {
this.$emit('input', this.newUsersToInvite);
},
handleBlur() {
this.hideDropdownWithNoItems = false;
},
handleFocus() {
// The modal auto-focuses on the input when opened.
// This prevents the dropdown from opening when the modal opens.
if (this.hasBeenFocused) {
this.loading = true;
this.retrieveUsers();
}
this.hasBeenFocused = true;
},
},
queryOptions: { exclude_internal: true, active: true },
};
</script>
<template>
<gl-token-selector
v-model="selectedTokens"
:dropdown-items="users"
:loading="loading"
:allow-user-defined-tokens="false"
:hide-dropdown-with-no-items="hideDropdownWithNoItems"
:placeholder="placeholderText"
:aria-labelledby="ariaLabelledby"
@blur="handleBlur"
@text-input="handleTextInput"
@input="handleInput"
@focus="handleFocus"
>
<template #token-content="{ token }">
<gl-avatar v-if="token.avatar_url" :src="token.avatar_url" :size="16" />
{{ token.name }}
</template>
<template #dropdown-item-content="{ dropdownItem }">
<gl-avatar-labeled
:src="dropdownItem.avatar_url"
:size="32"
:label="dropdownItem.name"
:sub-label="dropdownItem.username"
/>
</template>
</gl-token-selector>
</template>
export const USER_SEARCH_DELAY = 200;
globals:
AP: readonly
rules:
'@gitlab/require-i18n-strings': off
'@gitlab/vue-require-i18n-strings': off
<script>
export default {};
</script>
<template>
<div></div>
</template>
import Vue from 'vue';
import App from './components/app.vue';
function initJiraConnect() {
const el = document.querySelector('.js-jira-connect-app');
return new Vue({
el,
render(createElement) {
return createElement(App, {});
},
});
}
document.addEventListener('DOMContentLoaded', initJiraConnect);
......@@ -153,6 +153,7 @@ export default {
:folder="isTree"
:opened="file.opened"
:size="16"
:submodule="file.submodule"
/>
<gl-truncate v-if="truncateMiddle" :text="file.name" position="middle" class="gl-pr-7" />
<template v-else>{{ file.name }}</template>
......
......@@ -115,14 +115,10 @@ code {
background-color: $gray-50;
border-radius: $border-radius-default;
.code > & {
background-color: inherit;
padding: unset;
}
.code > &,
.build-trace & {
background-color: inherit;
padding: inherit;
padding: unset;
}
}
......
@import 'framework/variables';
@import 'mixins_and_variables_and_functions';
// We should only import styles that we actually use.
// @import '@gitlab/ui/src/scss/gitlab_ui';
$atlaskit-border-color: #dfe1e6;
......
......@@ -150,6 +150,14 @@ module PageLayoutHelper
css_class.join(' ')
end
def page_itemtype(itemtype = nil)
if itemtype
@page_itemtype = { itemscope: true, itemtype: itemtype }
else
@page_itemtype || {}
end
end
private
def generic_canonical_url
......
......@@ -91,18 +91,18 @@ module UsersHelper
end
end
def work_information(user)
def work_information(user, with_schema_markup: false)
return unless user
organization = user.organization
job_title = user.job_title
if organization.present? && job_title.present?
s_('Profile|%{job_title} at %{organization}') % { job_title: job_title, organization: organization }
render_job_title_and_organization(job_title, organization, with_schema_markup: with_schema_markup)
elsif job_title.present?
job_title
render_job_title(job_title, with_schema_markup: with_schema_markup)
elsif organization.present?
organization
render_organization(organization, with_schema_markup: with_schema_markup)
end
end
......@@ -151,6 +151,35 @@ module UsersHelper
items
end
def render_job_title(job_title, with_schema_markup: false)
if with_schema_markup
content_tag :span, itemprop: 'jobTitle' do
job_title
end
else
job_title
end
end
def render_organization(organization, with_schema_markup: false)
if with_schema_markup
content_tag :span, itemprop: 'worksFor' do
organization
end
else
organization
end
end
def render_job_title_and_organization(job_title, organization, with_schema_markup: false)
if with_schema_markup
job_title = '<span itemprop="jobTitle">'.html_safe + job_title + "</span>".html_safe
organization = '<span itemprop="worksFor">'.html_safe + organization + "</span>".html_safe
end
html_escape(s_('Profile|%{job_title} at %{organization}')) % { job_title: job_title, organization: organization }
end
end
UsersHelper.prepend_if_ee('EE::UsersHelper')
......@@ -1823,6 +1823,10 @@ class Project < ApplicationRecord
ensure_pages_metadatum.update!(deployed: false, artifacts_archive: nil, pages_deployment: nil)
end
def update_pages_deployment!(deployment)
ensure_pages_metadatum.update!(pages_deployment: deployment)
end
def write_repository_config(gl_full_path: full_path)
# We'd need to keep track of project full path otherwise directory tree
# created with hashed storage enabled cannot be usefully imported using
......
......@@ -138,7 +138,7 @@ module Projects
deployment = project.pages_deployments.create!(file: file,
file_count: entries_count,
file_sha256: sha256)
project.pages_metadatum.update!(pages_deployment: deployment)
project.update_pages_deployment!(deployment)
end
DestroyPagesDeploymentsWorker.perform_in(
......
......@@ -21,6 +21,8 @@
.gl-mt-5
%p Note: this integration only works with accounts on GitLab.com (SaaS).
- else
.js-jira-connect-app
%form#add-subscription-form.subscription-form{ action: jira_connect_subscriptions_path }
.ak-field-group
%label
......@@ -57,5 +59,8 @@
or enable cross-site cookies in your browser when adding a namespace.
%a{ href: 'https://gitlab.com/gitlab-org/gitlab/-/issues/263509', target: '_blank', rel: 'noopener noreferrer' } Learn more
= webpack_bundle_tag 'performance_bar' if performance_bar_enabled?
= webpack_bundle_tag 'jira_connect_app'
= page_specific_javascript_tag('jira_connect.js')
- add_page_specific_style 'page_bundles/jira_connect'
......@@ -20,6 +20,6 @@
- unless @hide_breadcrumbs
= render "layouts/nav/breadcrumbs"
%div{ class: "#{(container_class unless @no_container)} #{@content_class}" }
.content{ id: "content-body" }
.content{ id: "content-body", **page_itemtype }
= render "layouts/flash", extra_flash_class: 'limit-container-width'
= yield
......@@ -4,6 +4,7 @@
- page_title @user.blocked? ? s_('UserProfile|Blocked user') : @user.name
- page_description @user.bio_html
- header_title @user.name, user_path(@user)
- page_itemtype 'http://schema.org/Person'
- link_classes = "flex-grow-1 mx-1 "
= content_for :meta_tags do
......@@ -35,7 +36,7 @@
.profile-header{ class: [('with-no-profile-tabs' if profile_tabs.empty?)] }
.avatar-holder
= link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do
= image_tag avatar_icon_for_user(@user, 90), class: "avatar s90", alt: ''
= image_tag avatar_icon_for_user(@user, 90), class: "avatar s90", alt: '', itemprop: 'image'
- if @user.blocked?
.user-info
......@@ -44,7 +45,7 @@
= render "users/profile_basic_info"
- else
.user-info
.cover-title
.cover-title{ itemprop: 'name' }
= @user.name
- if @user.status
......@@ -54,15 +55,15 @@
= render "users/profile_basic_info"
.cover-desc.cgray.mb-1.mb-sm-2
- unless @user.location.blank?
.profile-link-holder.middle-dot-divider-sm.d-block.d-sm-inline.mb-1.mb-sm-0
.profile-link-holder.middle-dot-divider-sm.d-block.d-sm-inline.mb-1.mb-sm-0{ itemprop: 'address', itemscope: true, itemtype: 'https://schema.org/PostalAddress' }
= sprite_icon('location', css_class: 'vertical-align-sub fgray')
%span.vertical-align-middle
%span.vertical-align-middle{ itemprop: 'addressLocality' }
= @user.location
- unless work_information(@user).blank?
.profile-link-holder.middle-dot-divider-sm.d-block.d-sm-inline
= sprite_icon('work', css_class: 'vertical-align-middle fgray')
%span.vertical-align-middle
= work_information(@user)
= work_information(@user, with_schema_markup: true)
.cover-desc.cgray.mb-1.mb-sm-2
- unless @user.skype.blank?
.profile-link-holder.middle-dot-divider
......@@ -80,10 +81,10 @@
.profile-link-holder.middle-dot-divider-sm.d-block.d-sm-inline.mt-1.mt-sm-0
- if Feature.enabled?(:security_auto_fix) && @user.bot?
= sprite_icon('question', css_class: 'gl-text-blue-600')
= link_to @user.short_website_url, @user.full_website_url, class: 'text-link', target: '_blank', rel: 'me noopener noreferrer nofollow'
= link_to @user.short_website_url, @user.full_website_url, class: 'text-link', target: '_blank', rel: 'me noopener noreferrer nofollow', itemprop: 'url'
- unless @user.public_email.blank?
.profile-link-holder.middle-dot-divider-sm.d-block.d-sm-inline.mt-1.mt-sm-0
= link_to @user.public_email, "mailto:#{@user.public_email}", class: 'text-link'
= link_to @user.public_email, "mailto:#{@user.public_email}", class: 'text-link', itemprop: 'email'
- if @user.bio.present?
.cover-desc.cgray
.profile-user-bio
......
---
title: Display submodules in MR tree and file header
merge_request: 46840
author:
type: fixed
---
title: Fix setting Comment detail for Jira and modal for groups
merge_request: 46945
author:
type: fixed
---
title: Add structured markup for users
merge_request: 46553
author:
type: added
---
title: Use standard loading state for Design Upload button
merge_request: 46292
author:
type: changed
---
title: Fix code lines being cut-off on failed job tab
merge_request: 46885
author:
type: fixed
---
title: Add GraphQL burnup endpoint under milestone and iteration reports
merge_request: 45121
author:
type: added
---
name: security_on_demand_scans_http_header_validation
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/42812
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/276403
milestone: '13.6'
type: development
group: group::dynamic analysis
default_enabled: false
......@@ -82,6 +82,7 @@ function generateEntries() {
// sentry: './sentry/index.js', Temporarily commented out to investigate performance: https://gitlab.com/gitlab-org/gitlab/-/issues/251179
performance_bar: './performance_bar/index.js',
chrome_84_icon_fix: './lib/chrome_84_icon_fix.js',
jira_connect_app: './jira_connect/index.js',
};
return Object.assign(manualEntries, autoEntries);
......
This diff is collapsed.
---
stage: none
group: unassigned
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
info: "To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers"
description: 'Learn how to install, configure, update, and maintain your GitLab instance.'
---
......
......@@ -10818,7 +10818,7 @@ enum IssueType {
"""
Represents an iteration object
"""
type Iteration implements TimeboxBurnupTimeSeriesInterface {
type Iteration implements TimeboxReportInterface {
"""
Daily scope and completed totals for burnup charts
"""
......@@ -10854,6 +10854,11 @@ type Iteration implements TimeboxBurnupTimeSeriesInterface {
"""
iid: ID!
"""
Historically accurate report about the timebox
"""
report: TimeboxReport
"""
Web path of the iteration, scoped to the query parent. Only valid for Project parents. Returns null in other contexts
"""
......@@ -12831,7 +12836,7 @@ type MetricsDashboardAnnotationEdge {
"""
Represents a milestone
"""
type Milestone implements TimeboxBurnupTimeSeriesInterface {
type Milestone implements TimeboxReportInterface {
"""
Daily scope and completed totals for burnup charts
"""
......@@ -12867,6 +12872,11 @@ type Milestone implements TimeboxBurnupTimeSeriesInterface {
"""
projectMilestone: Boolean!
"""
Historically accurate report about the timebox
"""
report: TimeboxReport
"""
Timestamp of the milestone start date
"""
......@@ -20155,13 +20165,28 @@ Time represented in ISO 8601
"""
scalar Time
interface TimeboxBurnupTimeSeriesInterface {
"""
Represents a historically accurate report about the timebox
"""
type TimeboxReport {
"""
Daily scope and completed totals for burnup charts
"""
burnupTimeSeries: [BurnupChartDailyTotals!]
}
interface TimeboxReportInterface {
"""
Daily scope and completed totals for burnup charts
"""
burnupTimeSeries: [BurnupChartDailyTotals!]
"""
Historically accurate report about the timebox
"""
report: TimeboxReport
}
"""
A time-frame defined as a closed inclusive range of two dates
"""
......
......@@ -29533,6 +29533,20 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "report",
"description": "Historically accurate report about the timebox",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "TimeboxReport",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "scopedPath",
"description": "Web path of the iteration, scoped to the query parent. Only valid for Project parents. Returns null in other contexts",
......@@ -29670,7 +29684,7 @@
"interfaces": [
{
"kind": "INTERFACE",
"name": "TimeboxBurnupTimeSeriesInterface",
"name": "TimeboxReportInterface",
"ofType": null
}
],
......@@ -35318,6 +35332,20 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "report",
"description": "Historically accurate report about the timebox",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "TimeboxReport",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "startDate",
"description": "Timestamp of the milestone start date",
......@@ -35441,7 +35469,7 @@
"interfaces": [
{
"kind": "INTERFACE",
"name": "TimeboxBurnupTimeSeriesInterface",
"name": "TimeboxReportInterface",
"ofType": null
}
],
......@@ -58503,9 +58531,44 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "TimeboxReport",
"description": "Represents a historically accurate report about the timebox",
"fields": [
{
"name": "burnupTimeSeries",
"description": "Daily scope and completed totals for burnup charts",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "BurnupChartDailyTotals",
"ofType": null
}
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INTERFACE",
"name": "TimeboxBurnupTimeSeriesInterface",
"name": "TimeboxReportInterface",
"description": null,
"fields": [
{
......@@ -58529,6 +58592,20 @@
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "report",
"description": "Historically accurate report about the timebox",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "TimeboxReport",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
......@@ -1652,6 +1652,7 @@ Represents an iteration object.
| `dueDate` | Time | Timestamp of the iteration due date |
| `id` | ID! | ID of the iteration |
| `iid` | ID! | Internal ID of the iteration |
| `report` | TimeboxReport | Historically accurate report about the timebox |
| `scopedPath` | String | Web path of the iteration, scoped to the query parent. Only valid for Project parents. Returns null in other contexts |
| `scopedUrl` | String | Web URL of the iteration, scoped to the query parent. Only valid for Project parents. Returns null in other contexts |
| `startDate` | Time | Timestamp of the iteration start date |
......@@ -1964,6 +1965,7 @@ Represents a milestone.
| `groupMilestone` | Boolean! | Indicates if milestone is at group level |
| `id` | ID! | ID of the milestone |
| `projectMilestone` | Boolean! | Indicates if milestone is at project level |
| `report` | TimeboxReport | Historically accurate report about the timebox |
| `startDate` | Time | Timestamp of the milestone start date |
| `state` | MilestoneStateEnum! | State of the milestone |
| `stats` | MilestoneStats | Milestone statistics |
......@@ -2958,6 +2960,14 @@ Represents a requirement test report.
| `id` | ID! | ID of the test report |
| `state` | TestReportState! | State of the test report |
### TimeboxReport
Represents a historically accurate report about the timebox.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `burnupTimeSeries` | BurnupChartDailyTotals! => Array | Daily scope and completed totals for burnup charts |
### Timelog
| Field | Type | Description |
......
......@@ -128,12 +128,6 @@ This helps you avoid having to add the `only:` rule to all of your jobs to make
them always run. You can use this format to set up a Review App, helping to
save resources.
### Using SAST, DAST, and other Secure Templates with Pipelines for Merge Requests
To use [Secure templates](https://gitlab.com/gitlab-org/gitlab/-/tree/master/lib/gitlab/ci/templates/Security)
with pipelines for merge requests, you may need to apply a `rules: if: merge_request_event` for the
Secure scans to run in the same pipeline as the commit.
#### Excluding certain branches
Pipelines for merge requests require special treatment when
......
......@@ -138,13 +138,14 @@ Commit messages should follow the guidelines below, for reasons explained by Chr
- The merge request should not contain more than 10 commit messages.
- The commit subject should contain at least 3 words.
CAUTION: **Caution:**
If the guidelines are not met, the MR may not pass the
[Danger checks](https://gitlab.com/gitlab-org/gitlab/blob/master/danger/commit_messages/Dangerfile).
TIP: **Tip:**
Consider enabling [Squash and merge](../../user/project/merge_requests/squash_and_merge.md#squash-and-merge) if your merge
request includes "Applied suggestion to X files" commits, so that Danger can ignore those.
**Important notes:**
- If the guidelines are not met, the MR may not pass the [Danger checks](https://gitlab.com/gitlab-org/gitlab/blob/master/danger/commit_messages/Dangerfile).
- Consider enabling [Squash and merge](../../user/project/merge_requests/squash_and_merge.md#squash-and-merge)
if your merge request includes "Applied suggestion to X files" commits, so that Danger can ignore those.
- The prefixes in the form of `[prefix]` and `prefix:` are allowed (they can be all lowercase, as long
as the message itself is capitalized). For instance, `danger: Improve Danger behavior` and
`[API] Improve the labels endpoint` are valid commit messages.
#### Why these standards matter
......
......@@ -83,3 +83,25 @@ inject scripts into the web app.
Inline styles should be avoided in almost all cases, they should only be used
when no alternatives can be found. This allows reusability of styles as well as
readability.
### Sanitize HTML output
If you need to output raw HTML, you should sanitize it.
If you are using Vue, you can use the[`v-safe-html` directive](https://gitlab-org.gitlab.io/gitlab-ui/?path=/story/directives-safe-html-directive--default) from GitLab UI.
For other use cases, wrap a preconfigured version of [`dompurify`](https://www.npmjs.com/package/dompurify)
that also allows the icons to be rendered:
```javascript
import { sanitize } from '~/lib/dompurify';
const unsafeHtml = '<some unsafe content ... >';
// ...
element.appendChild(sanitize(unsafeHtml));
```
This `sanitize` function takes the same configuration as the
original.
---
description: "Internal users documentation."
type: concepts, reference, dev
stage: none
group: Development
info: "See the Technical Writers assigned to Development Guidelines: https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments-to-development-guidelines"
---
# Internal users
GitLab uses internal users (sometimes referred to as "bots") to perform
actions or functions that cannot be attributed to a regular user.
These users are created programatically throughout the codebase itself when
necessary, and do not count towards a license limit.
They are used when a traditional user account would not be applicable, for
example when generating alerts or automatic review feedback.
Technically, an internal user is a type of user, but they have reduced access
and a very specific purpose. They cannot be used for regular user actions,
such as authentication or API requests.
They have email addresses and names which can be attributed to any actions
they perform.
For example, when we [migrated](https://gitlab.com/gitlab-org/gitlab/-/issues/216120)
GitLab Snippets to [Versioned Snippets](../user/snippets.md#versioned-snippets)
in GitLab 13.0, we used an internal user to attribute the authorship of
snippets to itself when a snippet's author wasn't available for creating
repository commits, such as when the user has been disabled, so the Migration
Bot was used instead.
For this bot:
- The name was set to `GitLab Migration Bot`.
- The email was set to `noreply+gitlab-migration-bot@{instance host}`.
Other examples of internal users:
- [Alert Bot](../operations/metrics/alerts.md#trigger-actions-from-alerts)
- [Ghost User](../user/profile/account/delete_account.md#associated-records)
- [Support Bot](../user/project/service_desk.md#support-bot-user)
- Visual Review Bot
......@@ -329,6 +329,7 @@ References:
- When updating the content of an HTML element using JavaScript, mark user-controlled values as `textContent` or `nodeValue` instead of `innerHTML`.
- Avoid using `v-html` with user-controlled data, use [`v-safe-html`](https://gitlab-org.gitlab.io/gitlab-ui/?path=/story/directives-safe-html-directive--default) instead.
- Render unsafe or unsanitized content using [`dompurify`](fe_guide/security.md#sanitize-html-output).
- Consider using [`gl-sprintf`](../../ee/development/i18n/externalization.md#interpolation) to interpolate translated strings securely.
- Avoid `__()` with translations that contain user-controlled values.
- When working with `postMessage`, ensure the `origin` of the message is allowlisted.
......
......@@ -16,7 +16,7 @@ This is a partial list of the [RSpec metadata](https://relishapp.com/rspec/rspec
| `:elasticsearch` | The test requires an Elasticsearch service. It is used by the [instance-level scenario](https://gitlab.com/gitlab-org/gitlab-qa#definitions) [`Test::Integration::Elasticsearch`](https://gitlab.com/gitlab-org/gitlab/-/blob/72b62b51bdf513e2936301cb6c7c91ec27c35b4d/qa/qa/ee/scenario/test/integration/elasticsearch.rb) to include only tests that require Elasticsearch. |
| `:gitaly_cluster` | The test will run against a GitLab instance where repositories are stored on redundant Gitaly nodes behind a Praefect node. All nodes are [separate containers](../../../administration/gitaly/praefect.md#requirements-for-configuring-a-gitaly-cluster). Tests that use this tag have a longer setup time since there are three additional containers that need to be started. |
| `:jira` | The test requires a Jira Server. [GitLab-QA](https://gitlab.com/gitlab-org/gitlab-qa) will provision the Jira Server in a Docker container when the `Test::Integration::Jira` test scenario is run.
| `:kubernetes` | The test includes a GitLab instance that is configured to be run behind an SSH tunnel, allowing a TLS-accessible GitLab. This test will also include provisioning of at least one Kubernetes cluster to test against. *This tag is often be paired with `:orchestrated`.* |
| `:kubernetes` | The test includes a GitLab instance that is configured to be run behind an SSH tunnel, allowing a TLS-accessible GitLab. This test will also include provisioning of at least one Kubernetes cluster to test against. _This tag is often be paired with `:orchestrated`._ |
| `:only` | The test is only to be run against specific environments or pipelines. See [Environment selection](environment_selection.md) for more information. |
| `:orchestrated` | The GitLab instance under test may be [configured by `gitlab-qa`](https://gitlab.com/gitlab-org/gitlab-qa/-/blob/master/docs/what_tests_can_be_run.md#orchestrated-tests) to be different to the default GitLab configuration, or `gitlab-qa` may launch additional services in separate Docker containers, or both. Tests tagged with `:orchestrated` are excluded when testing environments where we can't dynamically modify GitLab's configuration (for example, Staging). |
| `:quarantine` | The test has been [quarantined](https://about.gitlab.com/handbook/engineering/quality/guidelines/debugging-qa-test-failures/#quarantining-tests), will run in a separate job that only includes quarantined tests, and is allowed to fail. The test will be skipped in its regular job so that if it fails it will not hold up the pipeline. Note that you can also [quarantine a test only when it runs against specific environment](environment_selection.md#quarantining-a-test-for-a-specific-environment). |
......@@ -25,3 +25,20 @@ This is a partial list of the [RSpec metadata](https://relishapp.com/rspec/rspec
| `:runner` | The test depends on and will set up a GitLab Runner instance, typically to run a pipeline. |
| `:skip_live_env` | The test will be excluded when run against live deployed environments such as Staging, Canary, and Production. |
| `:testcase` | The link to the test case issue in the [Quality Testcases project](https://gitlab.com/gitlab-org/quality/testcases/). |
| `:mattermost` | The test requires a GitLab Mattermost service on the GitLab instance. |
| `:ldap_no_server` | The test requires a GitLab instance to be configured to use LDAP. To be used with the `:orchestrated` tag. It does not spin up an LDAP server at orchestration time. Instead, it creates the LDAP server at runtime. |
| `:ldap_no_tls` | The test requires a GitLab instance to be configured to use an external LDAP server with TLS not enabled. |
| `:ldap_tls` | The test requires a GitLab instance to be configured to use an external LDAP server with TLS enabled. |
| `:object_storage` | The test requires a GitLab instance to be configured to use multiple [object storage types](../../../administration/object_storage.md). Uses MinIO as the object storage server. |
| `:smtp` | The test requires a GitLab instance to be configured to use an SMTP server. Tests SMTP notification email delivery from GitLab by using MailHog. |
| `:group_saml` | The test requires a GitLab instance that has SAML SSO enabled at the group level. Interacts with an external SAML identity provider. Paired with the `:orchestrated` tag. |
| `:instance_saml` | The test requires a GitLab instance that has SAML SSO enabled at the instance level. Interacts with an external SAML identity provider. Paired with the `:orchestrated` tag. |
| `:skip_signup_disabled` | The test uses UI to sign up a new user and will be skipped in any environment that does not allow new user registration via the UI. |
| `:smoke` | The test belongs to the test suite which verifies basic functionality of a GitLab instance.|
| `:github` | The test requires a GitHub personal access token. |
| `:repository_storage` | The test requires a GitLab instance to be configured to use multiple [repository storage paths](../../../administration/repository_storage_paths.md). Paired with the `:orchestrated` tag. |
| `:geo` | The test requires two GitLab Geo instances - a primary and a secondary - to be spun up. |
| `:relative_url` | The test requires a GitLab instance to be installed under a [relative URL](../../../install/relative_url.md). |
| `:requires_git_protocol_v2` | The test requires that Git protocol version 2 is enabled on the server. It's assumed to be enabled by default but if not the test can be skipped by setting `QA_CAN_TEST_GIT_PROTOCOL_V2` to `false`. |
| `:requires_praefect` | The test requires that the GitLab instance uses [Gitaly Cluster](../../../administration/gitaly/praefect.md) (a.k.a. Praefect) as the repository storage . It's assumed to be used by default but if not the test can be skipped by setting `QA_CAN_TEST_PRAEFECT` to `false`. |
| `:packages` | The test requires a GitLab instance that has the [Package Registry](../../../administration/packages/#gitlab-package-registry-administration) enabled. |
---
stage: none
group: unassigned
stage: Enablement
group: Distribution
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
type: concepts
---
......@@ -57,21 +57,11 @@ one major version. For example, it is safe to:
- `12.7.5` -> `12.10.5`
- `11.3.4` -> `11.11.1`
- `10.6.6` -> `10.8.3`
- `11.3.4` -> `11.11.8`
- `10.6.6` -> `10.8.7`
- `9.2.3` -> `9.5.5`
- `8.9.4` -> `8.12.3`
- Upgrade the *patch* version. For example:
- `12.0.4` -> `12.0.12`
- `11.11.1` -> `11.11.8`
- `10.6.3` -> `10.6.6`
- `11.11.1` -> `11.11.8`
- `10.6.3` -> `10.6.6`
- `9.5.5` -> `9.5.9`
- `8.9.2` -> `8.9.6`
NOTE: **Note:**
Version specific changes in Omnibus GitLab Linux packages can be found in [the Omnibus GitLab documentation](https://docs.gitlab.com/omnibus/update/README.html#version-specific-changes).
......@@ -82,87 +72,9 @@ Instructions are available for downloading an Omnibus GitLab Linux package local
NOTE: **Note:**
A step-by-step guide to [upgrading the Omnibus-bundled PostgreSQL is documented separately](https://docs.gitlab.com/omnibus/settings/database.html#upgrade-packaged-postgresql-server).
### Upgrading major versions
Upgrading the *major* version requires more attention.
Backward-incompatible changes and migrations are reserved for major versions.
We cannot guarantee that upgrading between major versions will be seamless.
We suggest upgrading to the latest available *minor* version within
your major version before proceeding to the next major version.
Doing this will address any backward-incompatible changes or deprecations
to help ensure a successful upgrade to the next major release.
It's also important to ensure that any background migrations have been fully completed
before upgrading to a new major version. To see the current size of the `background_migration` queue,
[Check for background migrations before upgrading](../update/README.md#checking-for-background-migrations-before-upgrading).
If your GitLab instance has any runners associated with it, it is very
important to upgrade GitLab Runner to match the GitLab minor version that was
upgraded to. This is to ensure [compatibility with GitLab versions](https://docs.gitlab.com/runner/#compatibility-with-gitlab-versions).
### Version 12 onward: Extra step for major upgrades
From version 12 onward, an additional step is required. More significant migrations
may occur during major release upgrades.
NOTE: **Note:**
If you are planning to upgrade from `12.0.x` to `12.10.x`, it is necessary to perform an intermediary upgrade to `12.1.x`
before upgrading to `12.10.x` to avoid [#215141](https://gitlab.com/gitlab-org/gitlab/-/issues/215141).
To ensure these are successful:
1. Increment to the first minor version (`x.0.x`) during the major version jump.
1. Proceed with upgrading to a newer release.
**For example: `11.5.x` -> `11.11.x` -> `12.0.x` -> `12.1.x` -> `12.10.x` -> `13.0.x`**
### Example upgrade paths
Please see the table below for some examples:
## Upgrading major versions
| Target version | Your version | Recommended upgrade path | Note |
| --------------------- | ------------ | ------------------------ | ---- |
| `13.4.3` | `12.9.2` | `12.9.2` -> `12.10.14` -> `13.0.14` -> `13.4.3` | Two intermediate versions are required: the final `12.10` release, plus `13.0`. |
| `13.2.10` | `11.5.0` | `11.5.0` -> `11.11.8` -> `12.0.12` -> `12.1.17` -> `12.10.14` -> `13.0.14` -> `13.2.10` | Five intermediate versions are required: the final `11.11`, `12.0`, `12.1` and `12.10` releases, plus `13.0`. |
| `12.10.14` | `11.3.4` | `11.3.4` -> `11.11.8` -> `12.0.12` -> `12.1.17` -> `12.10.14` | Three intermediate versions are required: the final `11.11` and `12.0` releases, plus `12.1` |
| `12.9.5` | `10.4.5` | `10.4.5` -> `10.8.7` -> `11.11.8` -> `12.0.12` -> `12.1.17` -> `12.9.5` | Four intermediate versions are required: `10.8`, `11.11`, `12.0` and `12.1`, then `12.9.5` |
| `12.2.5` | `9.2.6` | `9.2.6` -> `9.5.10` -> `10.8.7` -> `11.11.8` -> `12.0.12` -> `12.1.17` -> `12.2.5` | Five intermediate versions are required: `9.5`, `10.8`, `11.11`, `12.0`, `12.1`, then `12.2`. |
| `11.3.4` | `8.13.4` | `8.13.4` -> `8.17.7` -> `9.5.10` -> `10.8.7` -> `11.3.4` | `8.17.7` is the last version in version 8, `9.5.10` is the last version in version 9, `10.8.7` is the last version in version 10. |
### Upgrades from versions earlier than 8.12
- `8.11.x` and earlier: you might have to upgrade to `8.12.0` specifically before you can upgrade to `8.17.7`. This was [reported in an issue](https://gitlab.com/gitlab-org/gitlab/-/issues/207259).
- [CI changes prior to version 8.0](https://docs.gitlab.com/omnibus/update/README.html#updating-gitlab-ci-from-prior-540-to-version-714-via-omnibus-gitlab)
when it was merged into GitLab.
### Multi-step upgrade paths with GitLab all-in-one Linux package repository
Linux package managers default to installing the latest available version of a package for installation and upgrades.
Upgrading directly to the latest major version can be problematic for older GitLab versions that require a multi-stage upgrade path.
When following an upgrade path spanning multiple versions, for each upgrade, specify the intended GitLab version number in your package manager's install or upgrade command.
Examples:
```shell
# apt-get (Ubuntu/Debian)
sudo apt-get upgrade gitlab-ee=12.0.12-ee.0
# yum (RHEL/CentOS 6 and 7)
yum install gitlab-ee-12.0.12-ee.0.el7
# dnf (RHEL/CentOS 8)
dnf install gitlab-ee-12.0.12-ee.0.el8
# zypper (SUSE)
zypper install gitlab-ee=12.0.12-ee.0
```
To identify the GitLab version number in your package manager, run the following commands:
```shell
# apt-cache (Ubuntu/Debian)
sudo apt-cache madison gitlab-ee
# yum (RHEL/CentOS 6 and 7)
yum --showduplicates list gitlab-ee
```
Backward-incompatible changes and migrations are reserved for major versions. See the [upgrade guide](../update/README.md#upgrading-to-a-new-major-version).
## Patch releases
......@@ -237,19 +149,6 @@ This decision is made on a case-by-case basis.
## More information
Check [our release posts](https://about.gitlab.com/releases/categories/releases/).
Each month, we publish either a major or minor release of GitLab. At the end
of those release posts, there are three sections to look for: Deprecations, Removals, and Important notes on upgrading. These will include:
- Steps you need to perform as part of an upgrade.
For example [8.12](https://about.gitlab.com/releases/2016/09/22/gitlab-8-12-released/#upgrade-barometer)
required the Elasticsearch index to be recreated. Any older version of GitLab upgrading to 8.12 or higher would require this.
- Changes to the versions of software we support such as
[ceasing support for IE11 in GitLab 13](https://about.gitlab.com/releases/2020/03/22/gitlab-12-9-released/#ending-support-for-internet-explorer-11).
You should check all the major and minor versions you're passing over.
More information about the release procedures can be found in our
[release documentation](https://gitlab.com/gitlab-org/release/docs). You may also want to read our
[Responsible Disclosure Policy](https://about.gitlab.com/security/disclosure/).
This diff is collapsed.
......@@ -18,6 +18,12 @@ If you choose a size larger than what is currently configured for the web server
you will likely get errors. See the [troubleshooting section](#troubleshooting) for more
details.
## Max push size
You can change the maximum push size for your repository.
Navigate to **Admin Area (wrench icon) > Settings > General**, then expand **Account and Limit**.
From here, you can increase or decrease by changing the value in `Maximum push size (MB)`.
## Max import size
You can change the maximum file size for imports in GitLab.
......
......@@ -21,7 +21,7 @@ For an overview of application security with GitLab, see
## Quick start
Get started quickly with Dependency Scanning, License Scanning, Static Application Security
Testing (SAST), and Secret Detection by adding the following to your `.gitlab-ci.yml`:
Testing (SAST), and Secret Detection by adding the following to your [`.gitlab-ci.yml`](../../ci/yaml/README.md):
```yaml
include:
......@@ -76,6 +76,20 @@ GitLab uses the following tools to scan and report known vulnerabilities found i
| [Static Application Security Testing (SAST)](sast/index.md) | Analyze source code for known vulnerabilities. |
| [Coverage fuzzing](coverage_fuzzing/index.md) **(ULTIMATE)** | Find unknown bugs and vulnerabilities with coverage-guided fuzzing. |
### Use security scanning tools with Pipelines for Merge Requests
The security scanning tools can all be added to pipelines with [templates](https://gitlab.com/gitlab-org/gitlab/-/tree/master/lib/gitlab/ci/templates/Security).
See each tool for details on how to use include each template in your CI/CD configuration.
By default, the application security jobs are configured to run for branch pipelines only.
To use them with [pipelines for merge requests](../../ci/merge_request_pipelines/index.md),
you may need to override the default `rules:` configuration to add:
```yaml
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
```
## Security Scanning with Auto DevOps
When [Auto DevOps](../../topics/autodevops/) is enabled, all GitLab Security scanning tools will be configured using default settings.
......@@ -144,8 +158,8 @@ To view details of DAST vulnerabilities:
1. Click on the vulnerability's description. The following details are provided:
| Field | Description |
|:-----------------|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Field | Description |
|:-----------------|:------------------------------------------------------------------ |
| Description | Description of the vulnerability. |
| Project | Namespace and project in which the vulnerability was detected. |
| Method | HTTP method used to detect the vulnerability. |
......@@ -238,14 +252,11 @@ Selecting the button creates a merge request with the solution.
#### Manually applying the suggested patch
1. To manually apply the patch that was generated by GitLab for a vulnerability, select the dropdown arrow on the **Resolve
with merge request** button, then select **Download patch to resolve**:
![Resolve with Merge Request button dropdown](img/vulnerability_page_merge_request_button_dropdown_v13_1.png)
To manually apply the patch that GitLab generated for a vulnerability:
1. The button's text changes to **Download patch to resolve**. Click on it to download the patch:
1. Select the **Resolve with merge request** dropdown, then select **Download patch to resolve**:
![Download patch button](img/vulnerability_page_download_patch_button_v13_1.png)
![Resolve with Merge Request button dropdown](img/vulnerability_page_merge_request_button_dropdown_v13_1.png)
1. Ensure your local project has the same commit checked out that was used to generate the patch.
1. Run `git apply remediation.patch`.
......
......@@ -72,6 +72,17 @@ With GitLab Enterprise Edition, you can also:
You can also [integrate](project/integrations/overview.md) GitLab with numerous third-party applications, such as Mattermost, Microsoft Teams, HipChat, Trello, Slack, Bamboo CI, Jira, and a lot more.
## User types
There are several types of users in GitLab:
- Regular users and GitLab.com users. <!-- Note: further description TBA -->
- [Groups](group/index.md) of users.
- GitLab [admin area](admin_area/index.md) user.
- [GitLab Administrator](../administration/index.md) with full access to
self-managed instances' features and settings.
- [Internal users](../development/internal_users.md).
## Projects
In GitLab, you can create [projects](project/index.md) to host
......
......@@ -117,6 +117,28 @@ the dropdown) **Approved-By** and select the user.
![Filter MRs by approved by](img/filter_approved_by_merge_requests_v13_0.png)
### Filtering merge requests by environment or deployment date **(CORE)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/44041) in GitLab 13.6.
To filter merge requests by deployment data, such as the environment or a date,
you can type (or select from the dropdown) the following:
- Environment
- Deployed-before
- Deployed-after
When filtering by an environment, a dropdown presents all environments that
you can choose from:
![Filter MRs by their environment](img/filtering_merge_requests_by_environment_v13_6.png)
When filtering by a deploy date, you must enter the date manually. Deploy dates
use the format `YYYY-MM-DD`, and must be quoted if you wish to specify
both a date and time (`"YYYY-MM-DD HH:MM"`):
![Filter MRs by a deploy date](img/filtering_merge_requests_by_date_v13_6.png)
## Filters autocomplete
GitLab provides many filters across many pages (issues, merge requests, epics,
......
......@@ -11,11 +11,16 @@ import {
GlInputGroupText,
GlLoadingIcon,
} from '@gitlab/ui';
import { omit } from 'lodash';
import * as Sentry from '~/sentry/wrapper';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import download from '~/lib/utils/downloader';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { cleanLeadingSeparator, joinPaths, stripPathTail } from '~/lib/utils/url_utility';
import { fetchPolicies } from '~/lib/graphql';
import {
DAST_SITE_VALIDATION_HTTP_HEADER_KEY,
DAST_SITE_VALIDATION_METHOD_HTTP_HEADER,
DAST_SITE_VALIDATION_METHOD_TEXT_FILE,
DAST_SITE_VALIDATION_METHODS,
DAST_SITE_VALIDATION_STATUS,
......@@ -27,6 +32,7 @@ import dastSiteValidationQuery from '../graphql/dast_site_validation.query.graph
export default {
name: 'DastSiteValidation',
components: {
ClipboardButton,
GlAlert,
GlButton,
GlCard,
......@@ -38,6 +44,7 @@ export default {
GlInputGroupText,
GlLoadingIcon,
},
mixins: [glFeatureFlagsMixin()],
apollo: {
dastSiteValidation: {
query: dastSiteValidationQuery,
......@@ -103,6 +110,16 @@ export default {
};
},
computed: {
validationMethodOptions() {
const isHttpHeaderValidationEnabled = this.glFeatures
.securityOnDemandScansHttpHeaderValidation;
const enabledValidationMethods = omit(DAST_SITE_VALIDATION_METHODS, [
!isHttpHeaderValidationEnabled ? DAST_SITE_VALIDATION_METHOD_HTTP_HEADER : '',
]);
return Object.values(enabledValidationMethods);
},
urlObject() {
try {
return new URL(this.targetUrl);
......@@ -119,12 +136,18 @@ export default {
isTextFileValidation() {
return this.validationMethod === DAST_SITE_VALIDATION_METHOD_TEXT_FILE;
},
isHttpHeaderValidation() {
return this.validationMethod === DAST_SITE_VALIDATION_METHOD_HTTP_HEADER;
},
textFileName() {
return `GitLab-DAST-Site-Validation-${this.token}.txt`;
},
locationStepLabel() {
return DAST_SITE_VALIDATION_METHODS[this.validationMethod].i18n.locationStepLabel;
},
httpHeader() {
return `${DAST_SITE_VALIDATION_HTTP_HEADER_KEY}: uuid-code-${this.token}`;
},
},
watch: {
targetUrl() {
......@@ -132,13 +155,22 @@ export default {
},
},
created() {
this.unsubscribe = this.$watch(() => this.token, this.updateValidationPath, {
this.unsubscribe = this.$watch(
() => [this.token, this.validationMethod],
this.updateValidationPath,
{
immediate: true,
});
},
);
},
methods: {
updateValidationPath() {
this.validationPath = joinPaths(stripPathTail(this.path), this.textFileName);
this.validationPath = this.isTextFileValidation
? this.getTextFileValidationPath()
: this.path;
},
getTextFileValidationPath() {
return joinPaths(stripPathTail(this.path), this.textFileName);
},
onValidationPathInput() {
this.unsubscribe();
......@@ -189,7 +221,6 @@ export default {
this.hasValidationError = true;
},
},
validationMethodOptions: Object.values(DAST_SITE_VALIDATION_METHODS),
};
</script>
......@@ -199,7 +230,7 @@ export default {
{{ s__('DastProfiles|Site is not validated yet, please follow the steps.') }}
</gl-alert>
<gl-form-group :label="s__('DastProfiles|Step 1 - Choose site validation method')">
<gl-form-radio-group v-model="validationMethod" :options="$options.validationMethodOptions" />
<gl-form-radio-group v-model="validationMethod" :options="validationMethodOptions" />
</gl-form-group>
<gl-form-group
v-if="isTextFileValidation"
......@@ -217,6 +248,16 @@ export default {
{{ textFileName }}
</gl-button>
</gl-form-group>
<gl-form-group
v-else-if="isHttpHeaderValidation"
:label="s__('DastProfiles|Step 2 - Add following HTTP header to your site')"
>
<code class="gl-p-3 gl-bg-black gl-text-white">{{ httpHeader }}</code>
<clipboard-button
:text="httpHeader"
:title="s__('DastProfiles|Copy HTTP header to clipboard')"
/>
</gl-form-group>
<gl-form-group :label="locationStepLabel" class="mw-460">
<gl-form-input-group>
<template #prepend>
......@@ -255,7 +296,7 @@ export default {
<gl-icon name="status_failed" />
{{
s__(
'DastProfiles|Validation failed, please make sure that you follow the steps above with the choosen method.',
'DastProfiles|Validation failed, please make sure that you follow the steps above with the chosen method.',
)
}}
</template>
......
import { s__ } from '~/locale';
export const DAST_SITE_VALIDATION_METHOD_TEXT_FILE = 'TEXT_FILE';
export const DAST_SITE_VALIDATION_METHOD_HTTP_HEADER = 'HTTP_HEADER';
export const DAST_SITE_VALIDATION_METHODS = {
[DAST_SITE_VALIDATION_METHOD_TEXT_FILE]: {
value: DAST_SITE_VALIDATION_METHOD_TEXT_FILE,
......@@ -9,6 +11,13 @@ export const DAST_SITE_VALIDATION_METHODS = {
locationStepLabel: s__('DastProfiles|Step 3 - Confirm text file location and validate'),
},
},
[DAST_SITE_VALIDATION_METHOD_HTTP_HEADER]: {
value: DAST_SITE_VALIDATION_METHOD_HTTP_HEADER,
text: s__('DastProfiles|Header validation'),
i18n: {
locationStepLabel: s__('DastProfiles|Step 3 - Confirm header location and validate'),
},
},
};
export const DAST_SITE_VALIDATION_STATUS = {
......@@ -19,3 +28,4 @@ export const DAST_SITE_VALIDATION_STATUS = {
};
export const DAST_SITE_VALIDATION_POLL_INTERVAL = 1000;
export const DAST_SITE_VALIDATION_HTTP_HEADER_KEY = 'Gitlab-On-Demand-DAST';
......@@ -6,6 +6,7 @@ module Projects
before_action do
authorize_read_on_demand_scans!
push_frontend_feature_flag(:security_on_demand_scans_site_validation, @project)
push_frontend_feature_flag(:security_on_demand_scans_http_header_validation, @project)
end
feature_category :dynamic_application_security_testing
......
......@@ -6,7 +6,7 @@ module EE
extend ActiveSupport::Concern
prepended do
implements ::Types::TimeboxBurnupTimeSeriesInterface
implements ::Types::TimeboxReportInterface
end
end
end
......
......@@ -9,11 +9,11 @@ module Resolvers
def resolve(*args)
return [] unless timebox.burnup_charts_available?
response = TimeboxBurnupChartService.new(timebox).execute
response = TimeboxReportService.new(timebox).execute
raise GraphQL::ExecutionError, response.message if response.error?
response.payload
response.payload[:burnup_time_series]
end
end
end
# frozen_string_literal: true
module Resolvers
class TimeboxReportResolver < BaseResolver
type Types::TimeboxReportType, null: true
alias_method :timebox, :synchronized_object
def resolve(*args)
return {} unless timebox.burnup_charts_available?
response = TimeboxReportService.new(timebox).execute
raise GraphQL::ExecutionError, response.message if response.error?
response.payload
end
end
end
......@@ -9,7 +9,7 @@ module Types
authorize :read_iteration
implements ::Types::TimeboxBurnupTimeSeriesInterface
implements ::Types::TimeboxReportInterface
field :id, GraphQL::ID_TYPE, null: false,
description: 'ID of the iteration'
......
# frozen_string_literal: true
module Types
module TimeboxBurnupTimeSeriesInterface
module TimeboxReportInterface
include BaseInterface
field :report, Types::TimeboxReportType, null: true,
resolver: ::Resolvers::TimeboxReportResolver,
description: 'Historically accurate report about the timebox',
complexity: 175
field :burnup_time_series, [::Types::BurnupChartDailyTotalsType], null: true,
resolver: ::Resolvers::TimeboxBurnupTimeSeriesResolver,
description: 'Daily scope and completed totals for burnup charts',
......
# frozen_string_literal: true
# rubocop: disable Graphql/AuthorizeTypes
module Types
class TimeboxReportType < BaseObject
graphql_name 'TimeboxReport'
description 'Represents a historically accurate report about the timebox'
field :burnup_time_series, [::Types::BurnupChartDailyTotalsType], null: true,
description: 'Daily scope and completed totals for burnup charts'
end
end
......@@ -7,7 +7,7 @@
# This is implemented by iterating over all relevant resource events ordered by time. We need to do this
# so that we can keep track of the issue's state during that point in time and handle the events based on that.
class TimeboxBurnupChartService
class TimeboxReportService
include Gitlab::Utils::StrongMemoize
EVENT_COUNT_LIMIT = 50_000
......@@ -35,7 +35,9 @@ class TimeboxBurnupChartService
end
end
ServiceResponse.success(payload: chart_data)
ServiceResponse.success(payload: {
burnup_time_series: chart_data
})
end
private
......
......@@ -14,7 +14,7 @@ export const dastSiteValidation = (status = DAST_SITE_VALIDATION_STATUS.PENDING)
export const dastSiteValidationCreate = (errors = []) => ({
data: {
dastSiteValidationCreate: { status: DAST_SITE_VALIDATION_STATUS.PASSED, id: '1', errors },
dastSiteValidationCreate: { status: DAST_SITE_VALIDATION_STATUS.PENDING, id: '1', errors },
},
});
......
......@@ -85,31 +85,29 @@ describe('IterationSelect', () => {
});
describe('when a user can edit', () => {
it('opens the dropdown on click of the edit button', () => {
it('opens the dropdown on click of the edit button', async () => {
createComponent({ props: { canEdit: true } });
expect(wrapper.find(GlDropdown).isVisible()).toBe(false);
toggleDropdown();
return wrapper.vm.$nextTick().then(() => {
await wrapper.vm.$nextTick();
expect(wrapper.find(GlDropdown).isVisible()).toBe(true);
});
});
it('focuses on the input', () => {
it('focuses on the input', async () => {
createComponent({ props: { canEdit: true } });
const spy = jest.spyOn(wrapper.vm.$refs.search, 'focusInput');
toggleDropdown();
return wrapper.vm.$nextTick().then(() => {
await wrapper.vm.$nextTick();
expect(spy).toHaveBeenCalled();
});
});
it('stops propagation of the click event to avoid opening milestone dropdown', () => {
it('stops propagation of the click event to avoid opening milestone dropdown', async () => {
const spy = jest.fn();
createComponent({ props: { canEdit: true } });
......@@ -117,10 +115,9 @@ describe('IterationSelect', () => {
toggleDropdown(spy);
return wrapper.vm.$nextTick().then(() => {
await wrapper.vm.$nextTick();
expect(spy).toHaveBeenCalledTimes(1);
});
});
describe('when user is editing', () => {
describe('when rendering the dropdown', () => {
......@@ -214,12 +211,11 @@ describe('IterationSelect', () => {
});
});
it('sets the value returned from the mutation to currentIteration', () => {
return wrapper.vm.$nextTick().then(() => {
it('sets the value returned from the mutation to currentIteration', async () => {
await wrapper.vm.$nextTick();
expect(wrapper.vm.currentIteration).toBe('123');
});
});
});
describe('when error', () => {
const bootstrapComponent = mutationResp => {
......@@ -247,8 +243,8 @@ describe('IterationSelect', () => {
.vm.$emit('click');
});
it('calls createFlash with $expectedMsg', () => {
return wrapper.vm.$nextTick().then(() => {
it('calls createFlash with $expectedMsg', async () => {
await wrapper.vm.$nextTick();
expect(createFlash).toHaveBeenCalledWith(expectedMsg);
});
});
......@@ -256,44 +252,41 @@ describe('IterationSelect', () => {
});
});
});
});
describe('when a user is searching', () => {
beforeEach(() => {
createComponent({});
});
it('sets the search term', () => {
it('sets the search term', async () => {
wrapper.find(GlSearchBoxByType).vm.$emit('input', 'testing');
return wrapper.vm.$nextTick().then(() => {
await wrapper.vm.$nextTick();
expect(wrapper.vm.searchTerm).toBe('testing');
});
});
});
describe('when the user off clicks', () => {
describe('when the dropdown is open', () => {
beforeEach(() => {
beforeEach(async () => {
createComponent({});
toggleDropdown();
return wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
});
it('closes the dropdown', () => {
it('closes the dropdown', async () => {
expect(wrapper.find(GlDropdown).isVisible()).toBe(true);
toggleDropdown();
return wrapper.vm.$nextTick().then(() => {
await wrapper.vm.$nextTick();
expect(wrapper.find(GlDropdown).isVisible()).toBe(false);
});
});
});
});
});
describe('apollo schema', () => {
describe('iterations', () => {
......
......@@ -29,64 +29,61 @@ describe('SidebarItemEpicsSelect', () => {
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('methods', () => {
describe('getInitialEpicLoading', () => {
it('should return `false` when `initialEpic` prop is provided', () => {
it('should return `false` when `initialEpic` prop is provided', async () => {
wrapper.setProps({
initialEpic: mockEpic1,
});
return wrapper.vm.$nextTick(() => {
await wrapper.vm.$nextTick();
expect(wrapper.vm.getInitialEpicLoading()).toBe(false);
});
});
it('should return value of `sidebarStore.isFetching.epic` when `initialEpic` prop is null and `isFetching` is available', () => {
it('should return value of `sidebarStore.isFetching.epic` when `initialEpic` prop is null and `isFetching` is available', async () => {
wrapper.setProps({
sidebarStore: { isFetching: { epic: true } },
});
return wrapper.vm.$nextTick(() => {
await wrapper.vm.$nextTick();
expect(wrapper.vm.getInitialEpicLoading()).toBe(true);
});
});
it('should return `false` when both `initialEpic` and `sidebarStore.isFetching` are unavailable', () => {
it('should return `false` when both `initialEpic` and `sidebarStore.isFetching` are unavailable', async () => {
wrapper.setProps({
initialEpic: null,
sidebarStore: { isFetching: null },
});
return wrapper.vm.$nextTick(() => {
await wrapper.vm.$nextTick();
expect(wrapper.vm.getInitialEpicLoading()).toBe(false);
});
});
});
describe('getEpic', () => {
it('should return value of `initialEpic` as it is when it is available', () => {
it('should return value of `initialEpic` as it is when it is available', async () => {
wrapper.setProps({
initialEpic: mockEpic1,
});
return wrapper.vm.$nextTick(() => {
await wrapper.vm.$nextTick();
expect(wrapper.vm.getEpic()).toBe(mockEpic1);
});
});
it('should return value of `sidebarStore.epic` as it is when it is available', () => {
expect(wrapper.vm.getEpic()).toBe(mockEpic1);
});
it('should return No Epic object as it is when both `initialEpic` & `sidebarStore.epic` are unavailable', () => {
it('should return No Epic object as it is when both `initialEpic` & `sidebarStore.epic` are unavailable', async () => {
wrapper.setProps({
initialEpic: null,
sidebarStore: { epic: null },
});
return wrapper.vm.$nextTick(() => {
await wrapper.vm.$nextTick();
expect(wrapper.vm.getEpic()).toEqual(
expect.objectContaining({
id: 0,
......@@ -96,7 +93,6 @@ describe('SidebarItemEpicsSelect', () => {
});
});
});
});
describe('template', () => {
it('should render epics-select component', () => {
......
import { GlDropdown, GlDropdownItem, GlLoadingIcon } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import Vue from 'vue';
import Status from 'ee/sidebar/components/status/status.vue';
import { healthStatus, healthStatusTextMap } from 'ee/sidebar/constants';
......@@ -46,6 +45,7 @@ describe('Status', () => {
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('shows the text "Status"', () => {
......@@ -106,7 +106,7 @@ describe('Status', () => {
});
describe('remove status dropdown item', () => {
it('is displayed when there is a status', () => {
it('is displayed when there is a status', async () => {
const props = {
isEditable: true,
status: healthStatus.AT_RISK,
......@@ -116,10 +116,9 @@ describe('Status', () => {
wrapper.vm.isDropdownShowing = true;
wrapper.vm.$nextTick(() => {
await wrapper.vm.$nextTick();
expect(getRemoveStatusItem(wrapper).exists()).toBe(true);
});
});
it('emits an onDropdownClick event with argument null when clicked', () => {
const props = {
......@@ -201,14 +200,13 @@ describe('Status', () => {
mountStatus(props);
});
it('shows the dropdown when the Edit button is clicked', () => {
it('shows the dropdown when the Edit button is clicked', async () => {
getEditButton(wrapper).trigger('click');
return Vue.nextTick().then(() => {
await wrapper.vm.$nextTick();
expect(getDropdownClasses(wrapper)).toContain('show');
});
});
});
describe('when visible', () => {
beforeEach(() => {
......@@ -231,24 +229,22 @@ describe('Status', () => {
).toContain(message);
});
it('hides form when the `edit` button is clicked', () => {
it('hides form when the `edit` button is clicked', async () => {
getEditButton(wrapper).trigger('click');
return Vue.nextTick().then(() => {
await wrapper.vm.$nextTick();
expect(getDropdownClasses(wrapper)).toContain('gl-display-none');
});
});
it('hides form when a dropdown item is clicked', () => {
it('hides form when a dropdown item is clicked', async () => {
const dropdownItem = wrapper.findAll(GlDropdownItem).at(1);
dropdownItem.vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
await wrapper.vm.$nextTick();
expect(getDropdownClasses(wrapper)).toContain('gl-display-none');
});
});
});
describe('dropdown', () => {
const getIterableArray = arr => {
......@@ -285,15 +281,14 @@ describe('Status', () => {
// Test that "onTrack", "needsAttention", and "atRisk" values are emitted when form is submitted
it.each(getIterableArray(Object.values(healthStatus)))(
'emits onFormSubmit event with argument "%s" when user selects the option and submits form',
(status, index) => {
async (status, index) => {
wrapper
.findAll(GlDropdownItem)
.at(index + 1)
.vm.$emit('click', { preventDefault: () => null });
return Vue.nextTick().then(() => {
await wrapper.vm.$nextTick();
expect(wrapper.emitted().onDropdownClick[0]).toEqual([status]);
});
},
);
});
......
import Vue from 'vue';
import { shallowMount } from '@vue/test-utils';
import { escape } from 'lodash';
import ancestorsTree from 'ee/sidebar/components/ancestors_tree/ancestors_tree.vue';
import mountComponent from 'helpers/vue_mount_component_helper';
import { GlLoadingIcon } from '@gitlab/ui';
import AncestorsTree from 'ee/sidebar/components/ancestors_tree/ancestors_tree.vue';
describe('AncestorsTreeContainer', () => {
let vm;
let wrapper;
const ancestors = [
{ id: 1, url: '', title: 'A', state: 'open' },
{ id: 2, url: '', title: 'B', state: 'open' },
];
beforeEach(() => {
const AncestorsTreeContainer = Vue.extend(ancestorsTree);
vm = mountComponent(AncestorsTreeContainer, { ancestors, isFetching: false });
const defaultProps = {
ancestors,
isFetching: false,
};
const createComponent = (props = {}) => {
wrapper = shallowMount(AncestorsTree, {
propsData: { ...defaultProps, ...props },
});
};
afterEach(() => {
vm.$destroy();
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
const findTooltip = () => wrapper.find('.collapse-truncated-title');
const containsTimeline = () => wrapper.contains('.vertical-timeline');
const containsValue = () => wrapper.contains('.value');
it('renders all ancestors rows', () => {
expect(vm.$el.querySelectorAll('.vertical-timeline-row')).toHaveLength(ancestors.length);
createComponent();
expect(wrapper.findAll('.vertical-timeline-row')).toHaveLength(ancestors.length);
});
it('renders tooltip with the immediate parent', () => {
expect(vm.$el.querySelector('.collapse-truncated-title').innerText.trim()).toBe(
ancestors.slice(-1)[0].title,
);
createComponent();
expect(findTooltip().text()).toBe(ancestors.slice(-1)[0].title);
});
it('does not render timeline when fetching', () => {
vm.$props.isFetching = true;
return vm.$nextTick().then(() => {
expect(vm.$el.querySelector('.vertical-timeline')).toBeNull();
expect(vm.$el.querySelector('.value')).toBeNull();
createComponent({
isFetching: true,
});
expect(containsTimeline()).toBe(false);
expect(containsValue()).toBe(false);
});
it('render `None` when ancestors is an empty array', () => {
vm.$props.ancestors = [];
return vm.$nextTick().then(() => {
expect(vm.$el.querySelector('.vertical-timeline')).toBeNull();
expect(vm.$el.querySelector('.value')).not.toBeNull();
createComponent({
ancestors: [],
});
expect(containsTimeline()).toBe(false);
expect(containsValue()).not.toBe(false);
});
it('render loading icon when isFetching is true', () => {
vm.$props.isFetching = true;
return vm.$nextTick().then(() => {
expect(vm.$el.querySelector('.fa-spinner')).toBeDefined();
createComponent({
isFetching: true,
});
expect(wrapper.contains(GlLoadingIcon)).toBe(true);
});
it('escapes html in the tooltip', () => {
const title = '<script>alert(1);</script>';
const escapedTitle = escape(title);
vm.$props.ancestors = [{ id: 1, url: '', title, state: 'open' }];
return vm.$nextTick().then(() => {
const tooltip = vm.$el.querySelector('.collapse-truncated-title');
expect(tooltip.innerText).toBe(escapedTitle);
createComponent({
ancestors: [{ id: 1, url: '', title, state: 'open' }],
});
expect(findTooltip().text()).toBe(escapedTitle);
});
});
import Vue from 'vue';
import sidebarWeight from 'ee/sidebar/components/weight/sidebar_weight.vue';
import { shallowMount } from '@vue/test-utils';
import SidebarWeight from 'ee/sidebar/components/weight/sidebar_weight.vue';
import SidebarMediator from 'ee/sidebar/sidebar_mediator';
import SidebarStore from 'ee/sidebar/stores/sidebar_store';
import mountComponent from 'helpers/vue_mount_component_helper';
import SidebarService from '~/sidebar/services/sidebar_service';
import eventHub from '~/sidebar/event_hub';
import Mock from './ee_mock_data';
describe('Sidebar Weight', () => {
let vm;
let sidebarMediator;
let SidebarWeight;
let wrapper;
const createComponent = (props = {}) => {
wrapper = shallowMount(SidebarWeight, {
propsData: { ...props },
});
};
beforeEach(() => {
SidebarWeight = Vue.extend(sidebarWeight);
// Set up the stores, services, etc
sidebarMediator = new SidebarMediator(Mock.mediator);
});
afterEach(() => {
vm.$destroy();
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
SidebarService.singleton = null;
SidebarStore.singleton = null;
SidebarMediator.singleton = null;
......@@ -27,7 +33,8 @@ describe('Sidebar Weight', () => {
it('calls the mediator updateWeight on event', () => {
jest.spyOn(SidebarMediator.prototype, 'updateWeight').mockReturnValue(Promise.resolve());
vm = mountComponent(SidebarWeight, {
createComponent({
mediator: sidebarMediator,
});
......
import Vue from 'vue';
import weight from 'ee/sidebar/components/weight/weight.vue';
import mountComponent from 'helpers/vue_mount_component_helper';
import { shallowMount } from '@vue/test-utils';
import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper';
import Weight from 'ee/sidebar/components/weight/weight.vue';
import eventHub from '~/sidebar/event_hub';
import { ENTER_KEY_CODE } from '~/lib/utils/keycodes';
const DEFAULT_PROPS = {
weightNoneValue: 'None',
};
describe('Weight', () => {
let vm;
let Weight;
let wrapper;
beforeEach(() => {
Weight = Vue.extend(weight);
const defaultProps = {
weightNoneValue: 'None',
};
const createComponent = (props = {}) => {
wrapper = shallowMount(Weight, {
propsData: { ...defaultProps, ...props },
});
};
afterEach(() => {
vm.$destroy();
});
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
const containsCollapsedLoadingIcon = () => wrapper.contains('.js-weight-collapsed-loading-icon');
const containsLoadingIcon = () => wrapper.contains('.js-weight-loading-icon');
const findCollapsedLabel = () => wrapper.find('.js-weight-collapsed-weight-label');
const findLabelValue = () => wrapper.find('.js-weight-weight-label-value');
const findLabelNoValue = () => wrapper.find('.js-weight-weight-label .no-value');
const findCollapsedBlock = () => wrapper.find('.js-weight-collapsed-block');
const findEditLink = () => wrapper.find('.js-weight-edit-link');
const findRemoveLink = () => wrapper.find('.js-weight-remove-link');
const containsEditableField = () => wrapper.contains({ ref: 'editableField' });
const containsInputError = () => wrapper.contains('.gl-field-error');
it('shows loading spinner when fetching', () => {
vm = mountComponent(Weight, {
...DEFAULT_PROPS,
createComponent({
fetching: true,
});
expect(vm.$el.querySelector('.js-weight-collapsed-loading-icon')).not.toBeNull();
expect(vm.$el.querySelector('.js-weight-loading-icon')).not.toBeNull();
expect(containsCollapsedLoadingIcon()).toBe(true);
expect(containsLoadingIcon()).toBe(true);
});
it('shows loading spinner when loading', () => {
vm = mountComponent(Weight, {
...DEFAULT_PROPS,
createComponent({
fetching: false,
loading: true,
});
// We show the value in the collapsed view instead of the loading icon
expect(vm.$el.querySelector('.js-weight-collapsed-loading-icon')).toBeNull();
expect(vm.$el.querySelector('.js-weight-loading-icon')).not.toBeNull();
expect(containsCollapsedLoadingIcon()).toBe(false);
expect(containsLoadingIcon()).toBe(true);
});
it('shows weight value', () => {
const WEIGHT = 3;
vm = mountComponent(Weight, {
...DEFAULT_PROPS,
const expectedWeight = 3;
createComponent({
fetching: false,
weight: WEIGHT,
weight: expectedWeight,
});
expect(vm.$el.querySelector('.js-weight-collapsed-weight-label').textContent.trim()).toBe(
`${WEIGHT}`,
);
expect(vm.$el.querySelector('.js-weight-weight-label-value').textContent.trim()).toBe(
`${WEIGHT}`,
);
expect(findCollapsedLabel().text()).toBe(`${expectedWeight}`);
expect(findLabelValue().text()).toBe(`${expectedWeight}`);
});
it('shows weight no-value', () => {
const WEIGHT = null;
vm = mountComponent(Weight, {
...DEFAULT_PROPS,
const expectedWeight = null;
createComponent({
fetching: false,
weight: WEIGHT,
weight: expectedWeight,
});
expect(vm.$el.querySelector('.js-weight-collapsed-weight-label').textContent.trim()).toBe(
'None',
);
expect(vm.$el.querySelector('.js-weight-weight-label .no-value').textContent.trim()).toBe(
'None',
);
expect(findCollapsedLabel().text()).toBe(defaultProps.weightNoneValue);
expect(findLabelNoValue().text()).toBe(defaultProps.weightNoneValue);
});
it('adds `collapse-after-update` class when clicking the collapsed block', () => {
vm = mountComponent(Weight, {
...DEFAULT_PROPS,
});
it('adds `collapse-after-update` class when clicking the collapsed block', async () => {
createComponent();
vm.$el.querySelector('.js-weight-collapsed-block').click();
findCollapsedBlock().trigger('click');
return vm.$nextTick().then(() => {
expect(vm.$el.classList.contains('collapse-after-update')).toBe(true);
});
await wrapper.vm.$nextTick;
expect(wrapper.classes()).toContain('collapse-after-update');
});
it('shows dropdown on "Edit" link click', () => {
vm = mountComponent(Weight, {
...DEFAULT_PROPS,
it('shows dropdown on "Edit" link click', async () => {
createComponent({
editable: true,
});
expect(vm.shouldShowEditField).toBe(false);
expect(containsEditableField()).toBe(false);
vm.$el.querySelector('.js-weight-edit-link').click();
findEditLink().trigger('click');
return vm.$nextTick().then(() => {
expect(vm.shouldShowEditField).toBe(true);
});
await wrapper.vm.$nextTick;
expect(containsEditableField()).toBe(true);
});
it('emits event on input submission', () => {
const ID = 123;
it('emits event on input submission', async () => {
const mockId = 123;
const expectedWeightValue = '3';
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
vm = mountComponent(Weight, {
...DEFAULT_PROPS,
createComponent({
editable: true,
id: ID,
id: mockId,
});
vm.$el.querySelector('.js-weight-edit-link').click();
findEditLink().trigger('click');
await wrapper.vm.$nextTick;
return vm.$nextTick(() => {
const event = new CustomEvent('keydown');
event.keyCode = ENTER_KEY_CODE;
vm.$refs.editableField.click();
vm.$refs.editableField.value = expectedWeightValue;
vm.$refs.editableField.dispatchEvent(event);
const { editableField } = wrapper.vm.$refs;
editableField.click();
editableField.value = expectedWeightValue;
editableField.dispatchEvent(event);
expect(vm.hasValidInput).toBe(true);
expect(eventHub.$emit).toHaveBeenCalledWith('updateWeight', expectedWeightValue, ID);
});
await wrapper.vm.$nextTick;
expect(containsInputError()).toBe(false);
expect(eventHub.$emit).toHaveBeenCalledWith('updateWeight', expectedWeightValue, mockId);
});
it('emits event on remove weight link click', () => {
const ID = 123;
it('emits event on remove weight link click', async () => {
const mockId = 234;
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
vm = mountComponent(Weight, {
...DEFAULT_PROPS,
createComponent({
editable: true,
weight: 3,
id: ID,
id: mockId,
});
vm.$el.querySelector('.js-weight-remove-link').click();
findRemoveLink().trigger('click');
return vm.$nextTick(() => {
expect(vm.hasValidInput).toBe(true);
expect(eventHub.$emit).toHaveBeenCalledWith('updateWeight', '', ID);
});
await wrapper.vm.$nextTick;
expect(containsInputError()).toBe(false);
expect(eventHub.$emit).toHaveBeenCalledWith('updateWeight', '', mockId);
});
it('triggers error on invalid negative integer value', () => {
vm = mountComponent(Weight, {
...DEFAULT_PROPS,
it('triggers error on invalid negative integer value', async () => {
createComponent({
editable: true,
});
vm.$el.querySelector('.js-weight-edit-link').click();
findEditLink().trigger('click');
await wrapper.vm.$nextTick;
return vm.$nextTick(() => {
const event = new CustomEvent('keydown');
event.keyCode = ENTER_KEY_CODE;
vm.$refs.editableField.click();
vm.$refs.editableField.value = -9001;
vm.$refs.editableField.dispatchEvent(event);
const { editableField } = wrapper.vm.$refs;
editableField.click();
editableField.value = -9001;
editableField.dispatchEvent(event);
expect(vm.hasValidInput).toBe(false);
});
await wrapper.vm.$nextTick;
expect(containsInputError()).toBe(true);
});
describe('tracking', () => {
let trackingSpy;
beforeEach(() => {
vm = mountComponent(Weight, {
...DEFAULT_PROPS,
createComponent({
editable: true,
});
trackingSpy = mockTracking('_category_', vm.$el, (obj, what) =>
trackingSpy = mockTracking('_category_', wrapper.element, (obj, what) =>
jest.spyOn(obj, what).mockImplementation(() => {}),
);
});
......@@ -184,12 +190,12 @@ describe('Weight', () => {
unmockTracking();
});
it('calls trackEvent when "Edit" is clicked', () => {
triggerEvent(vm.$el.querySelector('.js-weight-edit-link'));
it('calls trackEvent when "Edit" is clicked', async () => {
triggerEvent(findEditLink().element);
await wrapper.vm.$nextTick;
return vm.$nextTick().then(() => {
expect(trackingSpy).toHaveBeenCalled();
});
});
});
});
......@@ -3,5 +3,11 @@
require 'spec_helper'
RSpec.describe GitlabSchema.types['Milestone'] do
it { expect(described_class).to have_graphql_field(:burnup_time_series) }
it 'has the expected fields' do
expected_fields = %w[
report burnup_time_series
]
expect(described_class).to have_graphql_fields(*expected_fields).at_least
end
end
......@@ -54,7 +54,7 @@ RSpec.describe Resolvers::TimeboxBurnupTimeSeriesResolver do
context 'when the service returns an error' do
before do
stub_const('TimeboxBurnupChartService::EVENT_COUNT_LIMIT', 1)
stub_const('TimeboxReportService::EVENT_COUNT_LIMIT', 1)
end
it 'raises a GraphQL exception' do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::TimeboxReportResolver do
include GraphqlHelpers
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:issues) { create_list(:issue, 2, project: project) }
let_it_be(:start_date) { Date.today }
let_it_be(:due_date) { start_date + 2.weeks }
before do
stub_licensed_features(milestone_charts: true, issue_weights: true, iterations: true)
end
RSpec.shared_examples 'timebox time series' do
subject { resolve(described_class, obj: timebox) }
context 'when the feature flag is disabled' do
before do
stub_feature_flags(burnup_charts: false, iteration_charts: false)
end
it 'returns empty data' do
expect(subject).to be_empty
end
end
context 'when the feature flag is enabled' do
before do
stub_feature_flags(burnup_charts: true, iteration_charts: true)
end
it 'returns burnup chart data' do
expect(subject).to eq(burnup_time_series: [
{
date: start_date + 4.days,
scope_count: 1,
scope_weight: 0,
completed_count: 0,
completed_weight: 0
},
{
date: start_date + 9.days,
scope_count: 2,
scope_weight: 0,
completed_count: 0,
completed_weight: 0
}
])
end
context 'when the service returns an error' do
before do
stub_const('TimeboxReportService::EVENT_COUNT_LIMIT', 1)
end
it 'raises a GraphQL exception' do
expect { subject }.to raise_error(GraphQL::ExecutionError, 'Burnup chart could not be generated due to too many events')
end
end
end
end
context 'when timebox is a milestone' do
let_it_be(:timebox) { create(:milestone, project: project, start_date: start_date, due_date: due_date) }
before_all do
create(:resource_milestone_event, issue: issues[0], milestone: timebox, action: :add, created_at: start_date + 4.days)
create(:resource_milestone_event, issue: issues[1], milestone: timebox, action: :add, created_at: start_date + 9.days)
end
it_behaves_like 'timebox time series'
end
context 'when timebox is an iteration' do
let_it_be(:timebox) { create(:iteration, group: group, start_date: start_date, due_date: due_date) }
before_all do
create(:resource_iteration_event, issue: issues[0], iteration: timebox, action: :add, created_at: start_date + 4.days)
create(:resource_iteration_event, issue: issues[1], iteration: timebox, action: :add, created_at: start_date + 9.days)
end
it_behaves_like 'timebox time series'
end
end
......@@ -10,7 +10,7 @@ RSpec.describe GitlabSchema.types['Iteration'] do
it 'has the expected fields' do
expected_fields = %w[
id id title description state web_path web_url scoped_path scoped_url
due_date start_date created_at updated_at burnup_time_series
due_date start_date created_at updated_at report burnup_time_series
]
expect(described_class).to have_graphql_fields(*expected_fields).at_least
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['TimeboxReport'] do
it { expect(described_class.graphql_name).to eq('TimeboxReport') }
it { expect(described_class).to have_graphql_field(:burnup_time_series) }
end
......@@ -31,7 +31,7 @@ RSpec.shared_examples 'timebox chart' do |timebox_type|
end
it 'returns an error when the number of events exceeds the limit' do
stub_const('TimeboxBurnupChartService::EVENT_COUNT_LIMIT', 1)
stub_const('TimeboxReportService::EVENT_COUNT_LIMIT', 1)
create(:"resource_#{timebox_type}_event", issue: issues[0], "#{timebox_type}" => timebox, action: :add, created_at: timebox_start_date - 21.days)
create(:"resource_#{timebox_type}_event", issue: issues[1], "#{timebox_type}" => timebox, action: :add, created_at: timebox_start_date - 20.days)
......@@ -56,7 +56,7 @@ RSpec.shared_examples 'timebox chart' do |timebox_type|
create(:resource_state_event, issue: issues[3], state: :closed, created_at: timebox_start_date - 6.days)
expect(response.success?).to eq(true)
expect(response.payload).to eq([
expect(response.payload[:burnup_time_series]).to eq([
{
date: timebox_start_date,
scope_count: 4,
......@@ -98,7 +98,7 @@ RSpec.shared_examples 'timebox chart' do |timebox_type|
create(:"resource_#{timebox_type}_event", issue: issues[0], "#{timebox_type}" => timebox, action: :remove, created_at: timebox_start_date + 21.days)
expect(response.success?).to eq(true)
expect(response.payload).to eq([
expect(response.payload[:burnup_time_series]).to eq([
{
date: timebox_start_date + 4.days,
scope_count: 2,
......@@ -159,7 +159,7 @@ RSpec.shared_examples 'timebox chart' do |timebox_type|
create(:resource_state_event, issue: issues[1], state: :closed, created_at: timebox_start_date + 9.days)
expect(response.success?).to eq(true)
expect(response.payload).to eq([
expect(response.payload[:burnup_time_series]).to eq([
{
date: timebox_start_date,
scope_count: 1,
......@@ -230,7 +230,7 @@ RSpec.shared_examples 'timebox chart' do |timebox_type|
create(:resource_weight_event, issue: issues[0], weight: 10, created_at: timebox_start_date + 5.days)
expect(response.success?).to eq(true)
expect(response.payload).to eq([
expect(response.payload[:burnup_time_series]).to eq([
{
date: timebox_start_date,
scope_count: 1,
......@@ -271,7 +271,7 @@ RSpec.shared_examples 'timebox chart' do |timebox_type|
end
end
RSpec.describe TimeboxBurnupChartService do
RSpec.describe TimeboxReportService do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:timebox_start_date) { Date.today }
......
......@@ -8319,6 +8319,9 @@ msgstr ""
msgid "DastProfiles|Authentication URL"
msgstr ""
msgid "DastProfiles|Copy HTTP header to clipboard"
msgstr ""
msgid "DastProfiles|Could not create site validation token. Please refresh the page, or try again later."
msgstr ""
......@@ -8385,6 +8388,9 @@ msgstr ""
msgid "DastProfiles|Error Details"
msgstr ""
msgid "DastProfiles|Header validation"
msgstr ""
msgid "DastProfiles|Hide debug messages"
msgstr ""
......@@ -8469,9 +8475,15 @@ msgstr ""
msgid "DastProfiles|Step 1 - Choose site validation method"
msgstr ""
msgid "DastProfiles|Step 2 - Add following HTTP header to your site"
msgstr ""
msgid "DastProfiles|Step 2 - Add following text to the target site"
msgstr ""
msgid "DastProfiles|Step 3 - Confirm header location and validate"
msgstr ""
msgid "DastProfiles|Step 3 - Confirm text file location and validate"
msgstr ""
......@@ -8508,7 +8520,7 @@ msgstr ""
msgid "DastProfiles|Validating..."
msgstr ""
msgid "DastProfiles|Validation failed, please make sure that you follow the steps above with the choosen method."
msgid "DastProfiles|Validation failed, please make sure that you follow the steps above with the chosen method."
msgstr ""
msgid "DastProfiles|Validation failed. Please try again."
......@@ -14507,7 +14519,7 @@ msgstr ""
msgid "Integrations|Issues created in Jira are shown here once you have created the issues in project setup in Jira."
msgstr ""
msgid "Integrations|Projects using custom settings will not be impacted unless the project owner chooses to use instance-level defaults."
msgid "Integrations|Projects using custom settings will not be impacted unless the project owner chooses to use parent level defaults."
msgstr ""
msgid "Integrations|Return to GitLab for Jira"
......@@ -14732,6 +14744,9 @@ msgstr ""
msgid "InviteMembersModal|Choose a role permission"
msgstr ""
msgid "InviteMembersModal|Close invite team members"
msgstr ""
msgid "InviteMembersModal|GitLab member or Email address"
msgstr ""
......@@ -14741,13 +14756,13 @@ msgstr ""
msgid "InviteMembersModal|Invite team members"
msgstr ""
msgid "InviteMembersModal|Search for members to invite"
msgid "InviteMembersModal|Members were successfully added"
msgstr ""
msgid "InviteMembersModal|User not invited. Feature coming soon!"
msgid "InviteMembersModal|Search for members to invite"
msgstr ""
msgid "InviteMembersModal|Users were succesfully added"
msgid "InviteMembersModal|Some of the members could not be added"
msgstr ""
msgid "InviteMembersModal|You're inviting members to the %{group_name} group"
......
......@@ -2,11 +2,8 @@
module QA
RSpec.describe 'Verify' do
describe 'Run pipeline', :requires_admin, :skip_live_env do
# [TODO]: Developer to remove :requires_admin and :skip_live_env once FF is removed in https://gitlab.com/gitlab-org/gitlab/-/issues/229632
describe 'Run pipeline', only: { subdomain: :staging } do
context 'with web only rule' do
let(:feature_flag) { :new_pipeline_form }
let(:job_name) { 'test_job' }
let(:project) do
Resource::Project.fabricate_via_api! do |project|
......@@ -29,6 +26,7 @@ module QA
script: echo 'OK'
only:
- web
YAML
}
]
......@@ -37,16 +35,11 @@ module QA
end
before do
Runtime::Feature.enable(feature_flag) # [TODO]: Developer to remove when feature flag is removed
Flow::Login.sign_in
project.visit!
Page::Project::Menu.perform(&:click_ci_cd_pipelines)
end
after do
Runtime::Feature.disable(feature_flag) # [TODO]: Developer to remove when feature flag is removed
end
it 'can trigger pipeline', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/946' do
Page::Project::Pipeline::Index.perform do |index|
expect(index).not_to have_pipeline # should not auto trigger pipeline
......
......@@ -5,11 +5,13 @@ require 'spec_helper'
RSpec.describe 'User page' do
include ExternalAuthorizationServiceHelpers
let(:user) { create(:user, bio: '**Lorem** _ipsum_ dolor sit [amet](https://example.com)') }
let_it_be(:user) { create(:user, bio: '**Lorem** _ipsum_ dolor sit [amet](https://example.com)') }
subject { visit(user_path(user)) }
context 'with public profile' do
it 'shows all the tabs' do
visit(user_path(user))
subject
page.within '.nav-links' do
expect(page).to have_link('Overview')
......@@ -22,14 +24,12 @@ RSpec.describe 'User page' do
end
it 'does not show private profile message' do
visit(user_path(user))
subject
expect(page).not_to have_content("This user has a private profile")
end
context 'work information' do
subject { visit(user_path(user)) }
it 'shows job title and organization details' do
user.update(organization: 'GitLab - work info test', job_title: 'Frontend Engineer')
......@@ -57,24 +57,24 @@ RSpec.describe 'User page' do
end
context 'with private profile' do
let(:user) { create(:user, private_profile: true) }
let_it_be(:user) { create(:user, private_profile: true) }
it 'shows no tab' do
visit(user_path(user))
subject
expect(page).to have_css("div.profile-header")
expect(page).not_to have_css("ul.nav-links")
end
it 'shows private profile message' do
visit(user_path(user))
subject
expect(page).to have_content("This user has a private profile")
end
it 'shows own tabs' do
sign_in(user)
visit(user_path(user))
subject
page.within '.nav-links' do
expect(page).to have_link('Overview')
......@@ -88,36 +88,36 @@ RSpec.describe 'User page' do
end
context 'with blocked profile' do
let(:user) { create(:user, state: :blocked) }
let_it_be(:user) { create(:user, state: :blocked) }
it 'shows no tab' do
visit(user_path(user))
subject
expect(page).to have_css("div.profile-header")
expect(page).not_to have_css("ul.nav-links")
end
it 'shows blocked message' do
visit(user_path(user))
subject
expect(page).to have_content("This user is blocked")
end
it 'shows user name as blocked' do
visit(user_path(user))
subject
expect(page).to have_css(".cover-title", text: 'Blocked user')
end
it 'shows no additional fields' do
visit(user_path(user))
subject
expect(page).not_to have_css(".profile-user-bio")
expect(page).not_to have_css(".profile-link-holder")
end
it 'shows username' do
visit(user_path(user))
subject
expect(page).to have_content("@#{user.username}")
end
......@@ -126,7 +126,7 @@ RSpec.describe 'User page' do
it 'shows the status if there was one' do
create(:user_status, user: user, message: "Working hard!")
visit(user_path(user))
subject
expect(page).to have_content("Working hard!")
end
......@@ -135,7 +135,7 @@ RSpec.describe 'User page' do
it 'shows the sign in link' do
stub_application_setting(signup_enabled: false)
visit(user_path(user))
subject
page.within '.navbar-nav' do
expect(page).to have_link('Sign in')
......@@ -147,7 +147,7 @@ RSpec.describe 'User page' do
it 'shows the sign in and register link' do
stub_application_setting(signup_enabled: true)
visit(user_path(user))
subject
page.within '.navbar-nav' do
expect(page).to have_link('Sign in / Register')
......@@ -157,7 +157,7 @@ RSpec.describe 'User page' do
context 'most recent activity' do
it 'shows the most recent activity' do
visit(user_path(user))
subject
expect(page).to have_content('Most Recent Activity')
end
......@@ -168,7 +168,7 @@ RSpec.describe 'User page' do
end
it 'hides the most recent activity' do
visit(user_path(user))
subject
expect(page).not_to have_content('Most Recent Activity')
end
......@@ -177,14 +177,14 @@ RSpec.describe 'User page' do
context 'page description' do
before do
visit(user_path(user))
subject
end
it_behaves_like 'page meta description', 'Lorem ipsum dolor sit amet'
end
context 'with a bot user' do
let(:user) { create(:user, user_type: :security_bot) }
let_it_be(:user) { create(:user, user_type: :security_bot) }
describe 'feature flag enabled' do
before do
......@@ -192,7 +192,7 @@ RSpec.describe 'User page' do
end
it 'only shows Overview and Activity tabs' do
visit(user_path(user))
subject
page.within '.nav-links' do
expect(page).to have_link('Overview')
......@@ -211,7 +211,7 @@ RSpec.describe 'User page' do
end
it 'only shows Overview and Activity tabs' do
visit(user_path(user))
subject
page.within '.nav-links' do
expect(page).to have_link('Overview')
......@@ -224,4 +224,24 @@ RSpec.describe 'User page' do
end
end
end
context 'structured markup' do
let_it_be(:user) { create(:user, website_url: 'https://gitlab.com', organization: 'GitLab', job_title: 'Frontend Engineer', email: 'public@example.com', public_email: 'public@example.com', location: 'Country', created_at: Time.now, updated_at: Time.now) }
it 'shows Person structured markup' do
subject
aggregate_failures do
expect(page).to have_selector('[itemscope][itemtype="http://schema.org/Person"]')
expect(page).to have_selector('img[itemprop="image"]')
expect(page).to have_selector('[itemprop="name"]')
expect(page).to have_selector('[itemprop="address"][itemscope][itemtype="https://schema.org/PostalAddress"]')
expect(page).to have_selector('[itemprop="addressLocality"]')
expect(page).to have_selector('[itemprop="url"]')
expect(page).to have_selector('[itemprop="email"]')
expect(page).to have_selector('span[itemprop="jobTitle"]')
expect(page).to have_selector('span[itemprop="worksFor"]')
end
end
end
end
......@@ -24,10 +24,10 @@ exports[`AlertsSettingsFormOld with default values renders the initial template
</span>
</gl-form-group-stub>
<gl-form-group-stub label=\\"Authorization key\\" label-for=\\"authorization-key\\">
<gl-form-group-stub label-for=\\"authorization-key\\">
<gl-form-input-group-stub value=\\"\\" predefinedoptions=\\"[object Object]\\" id=\\"authorization-key\\" readonly=\\"\\" class=\\"gl-mb-2\\"></gl-form-input-group-stub>
<gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\" class=\\"gl-mt-3\\" role=\\"button\\" tabindex=\\"0\\">Reset key</gl-button-stub>
<gl-modal-stub modalid=\\"authKeyModal\\" titletag=\\"h4\\" modalclass=\\"\\" size=\\"md\\" title=\\"Reset key\\" ok-title=\\"Reset key\\" ok-variant=\\"danger\\">
<gl-modal-stub modalid=\\"tokenModal\\" titletag=\\"h4\\" modalclass=\\"\\" size=\\"md\\" title=\\"Reset key\\" ok-title=\\"Reset key\\" ok-variant=\\"danger\\">
Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in.
</gl-modal-stub>
</gl-form-group-stub>
......
......@@ -70,16 +70,70 @@ describe('AlertsSettingsFormNew', () => {
});
});
describe('when form is invalid', () => {
// TODO, implement specs for when form is invalid
describe('submitting integration form', () => {
it('allows for create-new-integration with the correct form values for HTTP', async () => {
createComponent({});
const options = findSelect().findAll('option');
await options.at(1).setSelected();
await findFormFields()
.at(0)
.setValue('Test integration');
await findFormToggle().trigger('click');
await wrapper.vm.$nextTick();
expect(findSubmitButton().exists()).toBe(true);
expect(findSubmitButton().text()).toBe('Save integration');
findForm().trigger('submit');
await wrapper.vm.$nextTick();
expect(wrapper.emitted('create-new-integration')).toBeTruthy();
expect(wrapper.emitted('create-new-integration')[0]).toEqual([
{ type: typeSet.http, variables: { name: 'Test integration', active: true } },
]);
});
describe('when form is valid', () => {
beforeEach(() => {
it('allows for create-new-integration with the correct form values for PROMETHEUS', async () => {
createComponent({});
const options = findSelect().findAll('option');
await options.at(2).setSelected();
await findFormFields()
.at(0)
.setValue('Test integration');
await findFormFields()
.at(1)
.setValue('https://test.com');
await findFormToggle().trigger('click');
await wrapper.vm.$nextTick();
expect(findSubmitButton().exists()).toBe(true);
expect(findSubmitButton().text()).toBe('Save integration');
findForm().trigger('submit');
await wrapper.vm.$nextTick();
expect(wrapper.emitted('create-new-integration')).toBeTruthy();
expect(wrapper.emitted('create-new-integration')[0]).toEqual([
{ type: typeSet.prometheus, variables: { apiUrl: 'https://test.com', active: true } },
]);
});
it('allows for update-integration with the correct form values for HTTP', async () => {
createComponent({
props: {
currentIntegration: { id: '1' },
loading: false,
},
});
it('allows for on-create-new-integration with the correct form values for HTTP', async () => {
const options = findSelect().findAll('option');
await options.at(1).setSelected();
......@@ -97,13 +151,20 @@ describe('AlertsSettingsFormNew', () => {
await wrapper.vm.$nextTick();
expect(wrapper.emitted('on-create-new-integration')).toBeTruthy();
expect(wrapper.emitted('on-create-new-integration')[0]).toEqual([
expect(wrapper.emitted('update-integration')).toBeTruthy();
expect(wrapper.emitted('update-integration')[0]).toEqual([
{ type: typeSet.http, variables: { name: 'Test integration', active: true } },
]);
});
it('allows for on-create-new-integration with the correct form values for PROMETHEUS', async () => {
it('allows for update-integration with the correct form values for PROMETHEUS', async () => {
createComponent({
props: {
currentIntegration: { id: '1' },
loading: false,
},
});
const options = findSelect().findAll('option');
await options.at(2).setSelected();
......@@ -124,8 +185,8 @@ describe('AlertsSettingsFormNew', () => {
await wrapper.vm.$nextTick();
expect(wrapper.emitted('on-create-new-integration')).toBeTruthy();
expect(wrapper.emitted('on-create-new-integration')[0]).toEqual([
expect(wrapper.emitted('update-integration')).toBeTruthy();
expect(wrapper.emitted('update-integration')[0]).toEqual([
{ type: typeSet.prometheus, variables: { apiUrl: 'https://test.com', active: true } },
]);
});
......
......@@ -69,7 +69,7 @@ describe('AlertsSettingsFormOld', () => {
createComponent(
{},
{
authKey: 'newToken',
token: 'newToken',
},
);
......
......@@ -6,14 +6,24 @@ import AlertsSettingsFormNew from '~/alerts_settings/components/alerts_settings_
import IntegrationsList from '~/alerts_settings/components/alerts_integrations_list.vue';
import createHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql';
import createPrometheusIntegrationMutation from '~/alerts_settings/graphql/mutations/create_prometheus_integration.mutation.graphql';
import updateHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql';
import updatePrometheusIntegrationMutation from '~/alerts_settings/graphql/mutations/update_prometheus_integration.mutation.graphql';
import resetHttpTokenMutation from '~/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql';
import resetPrometheusTokenMutation from '~/alerts_settings/graphql/mutations/reset_prometheus_token.mutation.graphql';
import { typeSet } from '~/alerts_settings/constants';
import createFlash from '~/flash';
import { defaultAlertSettingsConfig } from './util';
import mockIntegrations from './mocks/integrations.json';
import {
createHttpVariables,
updateHttpVariables,
createPrometheusVariables,
updatePrometheusVariables,
ID,
} from './mocks/apollo_mock';
jest.mock('~/flash');
const projectPath = '';
describe('AlertsSettingsWrapper', () => {
let wrapper;
......@@ -80,7 +90,7 @@ describe('AlertsSettingsWrapper', () => {
it('renders the IntegrationsList table using the API data', () => {
createComponent({
data: { integrations: { list: mockIntegrations } },
data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
provide: { glFeatures: { httpIntegrationsList: true } },
loading: false,
});
......@@ -100,7 +110,7 @@ describe('AlertsSettingsWrapper', () => {
it('calls `$apollo.mutate` with `createHttpIntegrationMutation`', () => {
createComponent({
data: { integrations: { list: mockIntegrations } },
data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
provide: { glFeatures: { httpIntegrationsList: true } },
loading: false,
});
......@@ -108,26 +118,66 @@ describe('AlertsSettingsWrapper', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({
data: { createHttpIntegrationMutation: { integration: { id: '1' } } },
});
wrapper.find(AlertsSettingsFormNew).vm.$emit('on-create-new-integration', {
type: 'HTTP',
variables: { name: 'Test 1', active: true },
wrapper.find(AlertsSettingsFormNew).vm.$emit('create-new-integration', {
type: typeSet.http,
variables: createHttpVariables,
});
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1);
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: createHttpIntegrationMutation,
update: expect.anything(),
variables: createHttpVariables,
});
});
it('calls `$apollo.mutate` with `updateHttpIntegrationMutation`', () => {
createComponent({
data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
provide: { glFeatures: { httpIntegrationsList: true } },
loading: false,
});
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({
data: { updateHttpIntegrationMutation: { integration: { id: '1' } } },
});
wrapper.find(AlertsSettingsFormNew).vm.$emit('update-integration', {
type: typeSet.http,
variables: updateHttpVariables,
});
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: updateHttpIntegrationMutation,
variables: updateHttpVariables,
});
});
it('calls `$apollo.mutate` with `resetHttpTokenMutation`', () => {
createComponent({
data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
provide: { glFeatures: { httpIntegrationsList: true } },
loading: false,
});
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({
data: { resetHttpTokenMutation: { integration: { id: '1' } } },
});
wrapper.find(AlertsSettingsFormNew).vm.$emit('reset-token', {
type: typeSet.http,
variables: { id: ID },
});
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: resetHttpTokenMutation,
variables: {
name: 'Test 1',
active: true,
projectPath,
id: ID,
},
});
});
it('calls `$apollo.mutate` with `createPrometheusIntegrationMutation`', () => {
createComponent({
data: { integrations: { list: mockIntegrations } },
data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
provide: { glFeatures: { httpIntegrationsList: true } },
loading: false,
});
......@@ -135,33 +185,107 @@ describe('AlertsSettingsWrapper', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({
data: { createPrometheusIntegrationMutation: { integration: { id: '2' } } },
});
wrapper.find(AlertsSettingsFormNew).vm.$emit('on-create-new-integration', {
type: 'PROMETHEUS',
variables: { apiUrl: 'https://test.com', active: true },
wrapper.find(AlertsSettingsFormNew).vm.$emit('create-new-integration', {
type: typeSet.prometheus,
variables: createPrometheusVariables,
});
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1);
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: createPrometheusIntegrationMutation,
update: expect.anything(),
variables: createPrometheusVariables,
});
});
it('calls `$apollo.mutate` with `updatePrometheusIntegrationMutation`', () => {
createComponent({
data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
provide: { glFeatures: { httpIntegrationsList: true } },
loading: false,
});
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({
data: { updatePrometheusIntegrationMutation: { integration: { id: '2' } } },
});
wrapper.find(AlertsSettingsFormNew).vm.$emit('update-integration', {
type: typeSet.prometheus,
variables: updatePrometheusVariables,
});
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: updatePrometheusIntegrationMutation,
variables: updatePrometheusVariables,
});
});
it('calls `$apollo.mutate` with `resetPrometheusTokenMutation`', () => {
createComponent({
data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
provide: { glFeatures: { httpIntegrationsList: true } },
loading: false,
});
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({
data: { resetPrometheusTokenMutation: { integration: { id: '1' } } },
});
wrapper.find(AlertsSettingsFormNew).vm.$emit('reset-token', {
type: typeSet.prometheus,
variables: { id: ID },
});
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: resetPrometheusTokenMutation,
variables: {
apiUrl: 'https://test.com',
active: true,
projectPath,
id: ID,
},
});
});
it('shows error alert when integration creation fails ', () => {
it('shows error alert when integration creation fails ', async () => {
const errorMsg = 'Something went wrong';
createComponent({
data: { integrations: { list: mockIntegrations } },
data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
provide: { glFeatures: { httpIntegrationsList: true } },
loading: false,
});
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(errorMsg);
wrapper.find(AlertsSettingsFormNew).vm.$emit('create-new-integration', {});
setImmediate(() => {
expect(createFlash).toHaveBeenCalledWith({ message: errorMsg });
});
});
it('shows error alert when integration token reset fails ', () => {
const errorMsg = 'Something went wrong';
createComponent({
data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
provide: { glFeatures: { httpIntegrationsList: true } },
loading: false,
});
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(errorMsg);
wrapper.find(AlertsSettingsFormNew).vm.$emit('on-create-new-integration', {});
wrapper.find(AlertsSettingsFormNew).vm.$emit('reset-token', {});
setImmediate(() => {
expect(createFlash).toHaveBeenCalledWith({ message: errorMsg });
});
});
it('shows error alert when integration update fails ', () => {
const errorMsg = 'Something went wrong';
createComponent({
data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
provide: { glFeatures: { httpIntegrationsList: true } },
loading: false,
});
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(errorMsg);
wrapper.find(AlertsSettingsFormNew).vm.$emit('update-integration', {});
setImmediate(() => {
expect(createFlash).toHaveBeenCalledWith({ message: errorMsg });
......
const projectPath = '';
export const ID = 'gid://gitlab/AlertManagement::HttpIntegration/7';
export const createHttpVariables = {
name: 'Test Pre',
active: true,
projectPath,
};
export const updateHttpVariables = {
name: 'Test Pre',
active: true,
id: ID,
};
export const createPrometheusVariables = {
apiUrl: 'https://test-pre.com',
active: true,
projectPath,
};
export const updatePrometheusVariables = {
apiUrl: 'https://test-pre.com',
active: true,
id: ID,
};
......@@ -15,40 +15,6 @@ exports[`Design management upload button component renders inverted upload desig
Upload designs
<!---->
</gl-button-stub>
<input
accept="image/*"
class="hide"
multiple="multiple"
name="design_file"
type="file"
/>
</div>
`;
exports[`Design management upload button component renders loading icon 1`] = `
<div>
<gl-button-stub
buttontextclasses=""
category="primary"
disabled="true"
icon=""
size="small"
title="Adding a design with the same filename replaces the file in a new version."
variant="default"
>
Upload designs
<gl-loading-icon-stub
class="ml-1"
color="dark"
inline="true"
label="Loading"
size="sm"
/>
</gl-button-stub>
<input
......@@ -74,7 +40,6 @@ exports[`Design management upload button component renders upload design button
Upload designs
<!---->
</gl-button-stub>
<input
......
import { shallowMount } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui';
import UploadButton from '~/design_management/components/upload/button.vue';
describe('Design management upload button component', () => {
let wrapper;
function createComponent(isSaving = false, isInverted = false) {
function createComponent({ isSaving = false, isInverted = false } = {}) {
wrapper = shallowMount(UploadButton, {
propsData: {
isSaving,
......@@ -24,15 +25,19 @@ describe('Design management upload button component', () => {
});
it('renders inverted upload design button', () => {
createComponent(false, true);
createComponent({ isInverted: true });
expect(wrapper.element).toMatchSnapshot();
});
it('renders loading icon', () => {
createComponent(true);
describe('when `isSaving` prop is `true`', () => {
it('Button `loading` prop is `true`', () => {
createComponent({ isSaving: true });
expect(wrapper.element).toMatchSnapshot();
const button = wrapper.find(GlButton);
expect(button.exists()).toBe(true);
expect(button.props('loading')).toBe(true);
});
});
describe('onFileUploadChange', () => {
......
......@@ -6,6 +6,7 @@ import { mockTracking, triggerEvent } from 'helpers/tracking_helper';
import DiffFileHeader from '~/diffs/components/diff_file_header.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import diffDiscussionsMockData from '../mock_data/diff_discussions';
import { truncateSha } from '~/lib/utils/text_utility';
import { diffViewerModes } from '~/ide/constants';
......@@ -207,6 +208,14 @@ describe('DiffFileHeader component', () => {
});
expect(findFileActions().exists()).toBe(false);
});
it('renders submodule icon', () => {
createComponent({
diffFile: submoduleDiffFile,
});
expect(wrapper.find(FileIcon).props('submodule')).toBe(true);
});
});
describe('for any file', () => {
......
......@@ -34,7 +34,7 @@ describe('ConfirmationModal', () => {
'Saving will update the default settings for all projects that are not using custom settings.',
);
expect(findGlModal().text()).toContain(
'Projects using custom settings will not be impacted unless the project owner chooses to use instance-level defaults.',
'Projects using custom settings will not be impacted unless the project owner chooses to use parent level defaults.',
);
});
......
......@@ -9,6 +9,7 @@ import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_field
import JiraIssuesFields from '~/integrations/edit/components/jira_issues_fields.vue';
import TriggerFields from '~/integrations/edit/components/trigger_fields.vue';
import DynamicField from '~/integrations/edit/components/dynamic_field.vue';
import { integrationLevels } from '~/integrations/edit/constants';
describe('IntegrationForm', () => {
let wrapper;
......@@ -69,14 +70,24 @@ describe('IntegrationForm', () => {
describe('integrationLevel is instance', () => {
it('renders ConfirmationModal', () => {
createComponent({
integrationLevel: 'instance',
integrationLevel: integrationLevels.INSTANCE,
});
expect(findConfirmationModal().exists()).toBe(true);
});
});
describe('integrationLevel is not instance', () => {
describe('integrationLevel is group', () => {
it('renders ConfirmationModal', () => {
createComponent({
integrationLevel: integrationLevels.GROUP,
});
expect(findConfirmationModal().exists()).toBe(true);
});
});
describe('integrationLevel is project', () => {
it('does not render ConfirmationModal', () => {
createComponent({
integrationLevel: 'project',
......
......@@ -9,7 +9,7 @@ const accessLevels = { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, O
const defaultAccessLevel = '10';
const helpLink = 'https://example.com';
const createComponent = () => {
const createComponent = (data = {}) => {
return shallowMount(InviteMembersModal, {
propsData: {
groupId,
......@@ -18,9 +18,14 @@ const createComponent = () => {
defaultAccessLevel,
helpLink,
},
data() {
return data;
},
stubs: {
GlSprintf,
'gl-modal': '<div><slot name="modal-footer"></slot><slot></slot></div>',
'gl-dropdown': true,
'gl-dropdown-item': true,
GlSprintf,
},
});
};
......@@ -34,7 +39,7 @@ describe('InviteMembersModal', () => {
});
const findDropdown = () => wrapper.find(GlDropdown);
const findDropdownItems = () => wrapper.findAll(GlDropdownItem);
const findDropdownItems = () => findDropdown().findAll(GlDropdownItem);
const findDatepicker = () => wrapper.find(GlDatepicker);
const findLink = () => wrapper.find(GlLink);
const findCancelButton = () => wrapper.find({ ref: 'cancelButton' });
......@@ -88,25 +93,69 @@ describe('InviteMembersModal', () => {
format: 'json',
};
describe('when the invite was sent successfully', () => {
beforeEach(() => {
wrapper = createComponent();
jest.spyOn(Api, 'inviteGroupMember').mockResolvedValue({ data: postData });
wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'inviteGroupMember').mockResolvedValue({ data: postData });
wrapper.vm.submitForm(postData);
});
it('displays the successful toastMessage', () => {
const toastMessageSuccessful = 'Members were successfully added';
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(
toastMessageSuccessful,
wrapper.vm.toastOptions,
);
});
it('calls Api inviteGroupMember with the correct params', () => {
expect(Api.inviteGroupMember).toHaveBeenCalledWith(groupId, postData);
});
});
describe('when the invite was sent successfully', () => {
const toastMessageSuccessful = 'Users were succesfully added';
describe('when sending the invite for a single member returned an api error', () => {
const apiErrorMessage = 'Members already exists';
it('displays the successful toastMessage', () => {
beforeEach(() => {
wrapper = createComponent({ newUsersToInvite: '123' });
wrapper.vm.$toast = { show: jest.fn() };
jest
.spyOn(Api, 'inviteGroupMember')
.mockRejectedValue({ response: { data: { message: apiErrorMessage } } });
findInviteButton().vm.$emit('click');
});
it('displays the api error message for the toastMessage', () => {
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(
toastMessageSuccessful,
apiErrorMessage,
wrapper.vm.toastOptions,
);
});
});
describe('when sending the invite for multiple members returned any error', () => {
const genericErrorMessage = 'Some of the members could not be added';
beforeEach(() => {
wrapper = createComponent({ newUsersToInvite: '123' });
wrapper.vm.$toast = { show: jest.fn() };
jest
.spyOn(Api, 'inviteGroupMember')
.mockRejectedValue({ response: { data: { success: false } } });
findInviteButton().vm.$emit('click');
});
it('displays the expected toastMessage', () => {
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(
genericErrorMessage,
wrapper.vm.toastOptions,
);
});
......
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { GlTokenSelector } from '@gitlab/ui';
import waitForPromises from 'helpers/wait_for_promises';
import Api from '~/api';
import MembersTokenSelect from '~/invite_members/components/members_token_select.vue';
const label = 'testgroup';
const placeholder = 'Search for a member';
const user1 = { id: 1, name: 'Name One', username: 'one_1', avatar_url: '' };
const user2 = { id: 2, name: 'Name Two', username: 'two_2', avatar_url: '' };
const allUsers = [user1, user2];
const createComponent = () => {
return shallowMount(MembersTokenSelect, {
propsData: {
ariaLabelledby: label,
placeholder,
},
});
};
describe('MembersTokenSelect', () => {
let wrapper;
beforeEach(() => {
jest.spyOn(Api, 'users').mockResolvedValue({ data: allUsers });
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findTokenSelector = () => wrapper.find(GlTokenSelector);
describe('rendering the token-selector component', () => {
it('renders with the correct props', () => {
const expectedProps = {
ariaLabelledby: label,
placeholder,
};
expect(findTokenSelector().props()).toEqual(expect.objectContaining(expectedProps));
});
});
describe('users', () => {
describe('when input is focused for the first time (modal auto-focus)', () => {
it('does not call the API', async () => {
findTokenSelector().vm.$emit('focus');
await waitForPromises();
expect(Api.users).not.toHaveBeenCalled();
});
});
describe('when input is manually focused', () => {
it('calls the API and sets dropdown items as request result', async () => {
const tokenSelector = findTokenSelector();
tokenSelector.vm.$emit('focus');
tokenSelector.vm.$emit('blur');
tokenSelector.vm.$emit('focus');
await waitForPromises();
expect(tokenSelector.props('dropdownItems')).toMatchObject(allUsers);
expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false);
});
});
describe('when text input is typed in', () => {
it('calls the API with search parameter', async () => {
const searchParam = 'One';
const tokenSelector = findTokenSelector();
tokenSelector.vm.$emit('text-input', searchParam);
await waitForPromises();
expect(Api.users).toHaveBeenCalledWith(searchParam, wrapper.vm.$options.queryOptions);
expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false);
});
});
describe('when user is selected', () => {
it('emits `input` event with selected users', () => {
findTokenSelector().vm.$emit('input', [
{ id: 1, name: 'John Smith' },
{ id: 2, name: 'Jane Doe' },
]);
expect(wrapper.emitted().input[0][0]).toBe('1,2');
});
});
});
describe('when text input is blurred', () => {
it('clears text input', async () => {
const tokenSelector = findTokenSelector();
tokenSelector.vm.$emit('blur');
await nextTick();
expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false);
});
});
});
......@@ -3,6 +3,7 @@ import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import FileRow from '~/vue_shared/components/file_row.vue';
import FileHeader from '~/vue_shared/components/file_row_header.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import { escapeFileUrl } from '~/lib/utils/url_utility';
describe('File row component', () => {
......@@ -151,4 +152,18 @@ describe('File row component', () => {
expect(wrapper.find('.file-row-name').classes()).toContain('font-weight-bold');
});
it('renders submodule icon', () => {
const submodule = true;
createComponent({
file: {
...file(),
submodule,
},
level: 0,
});
expect(wrapper.find(FileIcon).props('submodule')).toBe(submodule);
});
});
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment