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> <script>
import { GlTable, GlIcon, GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui'; import {
GlButtonGroup,
GlButton,
GlIcon,
GlLoadingIcon,
GlTable,
GlTooltipDirective,
} from '@gitlab/ui';
import { s__, __ } from '~/locale'; import { s__, __ } from '~/locale';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
import { trackAlertIntegrationsViewsOptions } from '../constants'; import { trackAlertIntegrationsViewsOptions } from '../constants';
...@@ -25,9 +32,11 @@ const bodyTrClass = ...@@ -25,9 +32,11 @@ const bodyTrClass =
export default { export default {
i18n, i18n,
components: { components: {
GlTable, GlButtonGroup,
GlButton,
GlIcon, GlIcon,
GlLoadingIcon, GlLoadingIcon,
GlTable,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
...@@ -57,6 +66,10 @@ export default { ...@@ -57,6 +66,10 @@ export default {
key: 'type', key: 'type',
label: __('Type'), label: __('Type'),
}, },
{
key: 'actions',
label: __('Actions'),
},
], ],
computed: { computed: {
tbodyTrClass() { tbodyTrClass() {
...@@ -111,6 +124,13 @@ export default { ...@@ -111,6 +124,13 @@ export default {
</span> </span>
</template> </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> <template #table-busy>
<gl-loading-icon size="lg" color="dark" class="mt-3" /> <gl-loading-icon size="lg" color="dark" class="mt-3" />
</template> </template>
......
...@@ -21,12 +21,14 @@ import { ...@@ -21,12 +21,14 @@ import {
JSON_VALIDATE_DELAY, JSON_VALIDATE_DELAY,
targetPrometheusUrlPlaceholder, targetPrometheusUrlPlaceholder,
typeSet, typeSet,
defaultFormState,
} from '../constants'; } from '../constants';
export default { export default {
targetPrometheusUrlPlaceholder, targetPrometheusUrlPlaceholder,
JSON_VALIDATE_DELAY, JSON_VALIDATE_DELAY,
typeSet, typeSet,
defaultFormState,
i18n: { i18n: {
integrationFormSteps: { integrationFormSteps: {
step1: { step1: {
...@@ -62,6 +64,11 @@ export default { ...@@ -62,6 +64,11 @@ export default {
label: s__('AlertSettings|Prometheus API base URL'), label: s__('AlertSettings|Prometheus API base URL'),
help: s__('AlertSettings|URL cannot be blank and must start with http or https'), 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: { components: {
...@@ -95,23 +102,18 @@ export default { ...@@ -95,23 +102,18 @@ export default {
type: Boolean, type: Boolean,
required: true, required: true,
}, },
currentIntegration: {
type: Object,
required: false,
default: null,
},
}, },
data() { data() {
return { return {
selectedIntegration: integrationTypesNew[0].value, selectedIntegration: integrationTypesNew[0].value,
active: false,
options: integrationTypesNew, options: integrationTypesNew,
formVisible: false, formVisible: false,
integrationForm: {
name: '',
integrationTestPayload: {
json: null,
error: null,
},
active: false,
authKey: '',
url: '',
apiUrl: '',
},
}; };
}, },
computed: { computed: {
...@@ -125,9 +127,29 @@ export default { ...@@ -125,9 +127,29 @@ export default {
case this.$options.typeSet.prometheus: case this.$options.typeSet.prometheus:
return this.prometheus; return this.prometheus;
default: 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: { methods: {
onIntegrationTypeSelect() { onIntegrationTypeSelect() {
...@@ -142,18 +164,29 @@ export default { ...@@ -142,18 +164,29 @@ export default {
this.onSubmit(); this.onSubmit();
}, },
onSubmit() { onSubmit() {
const { name, apiUrl, active } = this.integrationForm; const { name, apiUrl } = this.integrationForm;
const variables = const variables =
this.selectedIntegration === this.$options.typeSet.http this.selectedIntegration === this.$options.typeSet.http
? { name, active } ? { name, active: this.active }
: { apiUrl, active }; : { apiUrl, active: this.active };
this.$emit('on-create-new-integration', { type: this.selectedIntegration, variables }); const integrationPayload = { type: this.selectedIntegration, variables };
if (this.currentIntegration) {
return this.$emit('update-integration', integrationPayload);
}
return this.$emit('create-new-integration', integrationPayload);
}, },
onReset() { onReset() {
// TODO: Reset form values this.integrationForm = this.defaultFormState;
this.selectedIntegration = integrationTypesNew[0].value;
this.onIntegrationTypeSelect();
}, },
onResetAuthKey() { onResetAuthKey() {
// TODO: Handle reset auth key via GraphQL this.$emit('reset-token', {
type: this.selectedIntegration,
variables: { id: this.currentIntegration.id },
});
}, },
validateJson() { validateJson() {
this.integrationForm.integrationTestPayload.error = null; this.integrationForm.integrationTestPayload.error = null;
...@@ -214,7 +247,7 @@ export default { ...@@ -214,7 +247,7 @@ export default {
/> />
<gl-toggle <gl-toggle
v-model="integrationForm.active" v-model="active"
:is-loading="loading" :is-loading="loading"
:label="__('Active')" :label="__('Active')"
class="gl-my-4 gl-font-weight-normal" class="gl-my-4 gl-font-weight-normal"
...@@ -242,13 +275,9 @@ export default { ...@@ -242,13 +275,9 @@ export default {
{{ s__('AlertSettings|Webhook URL') }} {{ s__('AlertSettings|Webhook URL') }}
</span> </span>
<gl-form-input-group id="url" readonly :value="selectedIntegrationType.url"> <gl-form-input-group id="url" readonly :value="integrationForm.url">
<template #append> <template #append>
<clipboard-button <clipboard-button :text="integrationForm.url" :title="__('Copy')" class="gl-m-0!" />
:text="selectedIntegrationType.url || ''"
:title="__('Copy')"
class="gl-m-0!"
/>
</template> </template>
</gl-form-input-group> </gl-form-input-group>
</div> </div>
...@@ -262,14 +291,10 @@ export default { ...@@ -262,14 +291,10 @@ export default {
id="authorization-key" id="authorization-key"
class="gl-mb-2" class="gl-mb-2"
readonly readonly
:value="selectedIntegrationType.authKey" :value="integrationForm.token"
> >
<template #append> <template #append>
<clipboard-button <clipboard-button :text="integrationForm.token" :title="__('Copy')" class="gl-m-0!" />
:text="selectedIntegrationType.authKey || ''"
:title="__('Copy')"
class="gl-m-0!"
/>
</template> </template>
</gl-form-input-group> </gl-form-input-group>
...@@ -281,9 +306,9 @@ export default { ...@@ -281,9 +306,9 @@ export default {
:title="$options.i18n.integrationFormSteps.step3.reset" :title="$options.i18n.integrationFormSteps.step3.reset"
:ok-title="$options.i18n.integrationFormSteps.step3.reset" :ok-title="$options.i18n.integrationFormSteps.step3.reset"
ok-variant="danger" ok-variant="danger"
@ok="() => {}" @ok="onResetAuthKey"
> >
{{ $options.i18n.integrationFormSteps.step3.reset }} {{ $options.i18n.integrationFormSteps.restKeyInfo.label }}
</gl-modal> </gl-modal>
</div> </div>
</gl-form-group> </gl-form-group>
......
...@@ -59,7 +59,7 @@ export default { ...@@ -59,7 +59,7 @@ export default {
selectedIntegration: integrationTypes[0].value, selectedIntegration: integrationTypes[0].value,
options: integrationTypes, options: integrationTypes,
active: false, active: false,
authKey: '', token: '',
targetUrl: '', targetUrl: '',
feedback: { feedback: {
variant: 'danger', variant: 'danger',
...@@ -98,7 +98,7 @@ export default { ...@@ -98,7 +98,7 @@ export default {
case 'HTTP': { case 'HTTP': {
return { return {
url: this.generic.url, url: this.generic.url,
authKey: this.generic.authKey, token: this.generic.token,
active: this.generic.active, active: this.generic.active,
resetKey: this.resetKey.bind(this), resetKey: this.resetKey.bind(this),
}; };
...@@ -106,7 +106,7 @@ export default { ...@@ -106,7 +106,7 @@ export default {
case 'PROMETHEUS': { case 'PROMETHEUS': {
return { return {
url: this.prometheus.url, url: this.prometheus.url,
authKey: this.prometheus.authKey, token: this.prometheus.token,
active: this.prometheus.active, active: this.prometheus.active,
resetKey: this.resetKey.bind(this, 'PROMETHEUS'), resetKey: this.resetKey.bind(this, 'PROMETHEUS'),
targetUrl: this.prometheus.prometheusApiUrl, targetUrl: this.prometheus.prometheusApiUrl,
...@@ -167,7 +167,7 @@ export default { ...@@ -167,7 +167,7 @@ export default {
this.setOpsgenieAsDefault(); this.setOpsgenieAsDefault();
} }
this.active = this.selectedIntegrationType.active; this.active = this.selectedIntegrationType.active;
this.authKey = this.selectedIntegrationType.authKey ?? ''; this.token = this.selectedIntegrationType.token ?? '';
}, },
methods: { methods: {
createUserErrorMessage(errors = {}) { createUserErrorMessage(errors = {}) {
...@@ -212,8 +212,8 @@ export default { ...@@ -212,8 +212,8 @@ export default {
return fn return fn
.then(({ data: { token } }) => { .then(({ data: { token } }) => {
this.authKey = token; this.token = token;
this.setFeedback({ feedbackMessage: this.$options.i18n.authKeyRest, variant: 'success' }); this.setFeedback({ feedbackMessage: this.$options.i18n.tokenRest, variant: 'success' });
}) })
.catch(() => { .catch(() => {
this.setFeedback({ feedbackMessage: this.$options.i18n.errorKeyMsg, variant: 'danger' }); this.setFeedback({ feedbackMessage: this.$options.i18n.errorKeyMsg, variant: 'danger' });
...@@ -313,7 +313,7 @@ export default { ...@@ -313,7 +313,7 @@ export default {
.updateTestAlert({ .updateTestAlert({
endpoint: this.selectedIntegrationType.url, endpoint: this.selectedIntegrationType.url,
data: this.testAlert.json, data: this.testAlert.json,
authKey: this.selectedIntegrationType.authKey, token: this.selectedIntegrationType.token,
}) })
.then(() => { .then(() => {
this.setFeedback({ this.setFeedback({
...@@ -439,21 +439,21 @@ export default { ...@@ -439,21 +439,21 @@ export default {
{{ prometheusInfo }} {{ prometheusInfo }}
</span> </span>
</gl-form-group> </gl-form-group>
<gl-form-group :label="$options.i18n.authKeyLabel" label-for="authorization-key"> <gl-form-group :label="$options.i18n.tokenLabel" label-for="authorization-key">
<gl-form-input-group id="authorization-key" class="gl-mb-2" readonly :value="authKey"> <gl-form-input-group id="authorization-key" class="gl-mb-2" readonly :value="token">
<template #append> <template #append>
<clipboard-button <clipboard-button
:text="authKey" :text="token"
:title="$options.i18n.copyToClipboard" :title="$options.i18n.copyToClipboard"
class="gl-m-0!" class="gl-m-0!"
/> />
</template> </template>
</gl-form-input-group> </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 $options.i18n.resetKey
}}</gl-button> }}</gl-button>
<gl-modal <gl-modal
modal-id="authKeyModal" modal-id="tokenModal"
:title="$options.i18n.resetKey" :title="$options.i18n.resetKey"
:ok-title="$options.i18n.resetKey" :ok-title="$options.i18n.resetKey"
ok-variant="danger" ok-variant="danger"
......
...@@ -7,6 +7,10 @@ import createFlash, { FLASH_TYPES } from '~/flash'; ...@@ -7,6 +7,10 @@ import createFlash, { FLASH_TYPES } from '~/flash';
import getIntegrationsQuery from '../graphql/queries/get_integrations.query.graphql'; import getIntegrationsQuery from '../graphql/queries/get_integrations.query.graphql';
import createHttpIntegrationMutation from '../graphql/mutations/create_http_integration.mutation.graphql'; import createHttpIntegrationMutation from '../graphql/mutations/create_http_integration.mutation.graphql';
import createPrometheusIntegrationMutation from '../graphql/mutations/create_prometheus_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 IntegrationsList from './alerts_integrations_list.vue';
import SettingsFormOld from './alerts_settings_form_old.vue'; import SettingsFormOld from './alerts_settings_form_old.vue';
import SettingsFormNew from './alerts_settings_form_new.vue'; import SettingsFormNew from './alerts_settings_form_new.vue';
...@@ -52,16 +56,16 @@ export default { ...@@ -52,16 +56,16 @@ export default {
list, list,
}; };
}, },
error() { error(err) {
this.errored = true; createFlash({ message: err });
}, },
}, },
}, },
data() { data() {
return { return {
errored: false,
isUpdating: false, isUpdating: false,
integrations: {}, integrations: {},
currentIntegration: null,
}; };
}, },
computed: { computed: {
...@@ -84,7 +88,7 @@ export default { ...@@ -84,7 +88,7 @@ export default {
}, },
}, },
methods: { methods: {
onCreateNewIntegration({ type, variables }) { createNewIntegration({ type, variables }) {
this.isUpdating = true; this.isUpdating = true;
this.$apollo this.$apollo
.mutate({ .mutate({
...@@ -109,7 +113,6 @@ export default { ...@@ -109,7 +113,6 @@ export default {
}); });
}) })
.catch(err => { .catch(err => {
this.errored = true;
createFlash({ message: err }); createFlash({ message: err });
}) })
.finally(() => { .finally(() => {
...@@ -151,6 +154,72 @@ export default { ...@@ -151,6 +154,72 @@ export default {
data, 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> </script>
...@@ -160,11 +229,16 @@ export default { ...@@ -160,11 +229,16 @@ export default {
<integrations-list <integrations-list
:integrations="glFeatures.httpIntegrationsList ? integrations.list : intergrationsOptionsOld" :integrations="glFeatures.httpIntegrationsList ? integrations.list : intergrationsOptionsOld"
:loading="loading" :loading="loading"
@edit-integration="editIntegration"
@delete-integration="deleteIntegration"
/> />
<settings-form-new <settings-form-new
v-if="glFeatures.httpIntegrationsList" v-if="glFeatures.httpIntegrationsList"
:loading="loading" :loading="isUpdating"
@on-create-new-integration="onCreateNewIntegration" :current-integration="currentIntegration"
@create-new-integration="createNewIntegration"
@update-integration="updateIntegration"
@reset-token="resetToken"
/> />
<settings-form-old v-else /> <settings-form-old v-else />
</div> </div>
......
...@@ -57,6 +57,15 @@ export const typeSet = { ...@@ -57,6 +57,15 @@ export const typeSet = {
prometheus: 'PROMETHEUS', prometheus: 'PROMETHEUS',
}; };
export const defaultFormState = {
name: '',
active: false,
token: '',
url: '',
apiUrl: '',
integrationTestPayload: { json: null, error: null },
};
export const JSON_VALIDATE_DELAY = 250; export const JSON_VALIDATE_DELAY = 250;
export const targetPrometheusUrlPlaceholder = 'http://prometheus.example.com/'; 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 => { ...@@ -50,7 +50,7 @@ export default el => {
prometheus: { prometheus: {
active: parseBoolean(prometheusActivated), active: parseBoolean(prometheusActivated),
url: prometheusUrl, url: prometheusUrl,
authKey: prometheusAuthorizationKey, token: prometheusAuthorizationKey,
prometheusFormPath, prometheusFormPath,
prometheusResetKeyPath, prometheusResetKeyPath,
prometheusApiUrl, prometheusApiUrl,
...@@ -60,7 +60,7 @@ export default el => { ...@@ -60,7 +60,7 @@ export default el => {
alertsUsageUrl, alertsUsageUrl,
active: parseBoolean(activatedStr), active: parseBoolean(activatedStr),
formPath, formPath,
authKey: authorizationKey, token: authorizationKey,
url, url,
}, },
opsgenie: { opsgenie: {
......
<script> <script>
import { GlButton, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import { VALID_DESIGN_FILE_MIMETYPE } from '../../constants'; import { VALID_DESIGN_FILE_MIMETYPE } from '../../constants';
export default { export default {
components: { components: {
GlButton, GlButton,
GlLoadingIcon,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
...@@ -38,12 +37,12 @@ export default { ...@@ -38,12 +37,12 @@ export default {
) )
" "
:disabled="isSaving" :disabled="isSaving"
:loading="isSaving"
variant="default" variant="default"
size="small" size="small"
@click="openFileUpload" @click="openFileUpload"
> >
{{ s__('DesignManagement|Upload designs') }} {{ s__('DesignManagement|Upload designs') }}
<gl-loading-icon v-if="isSaving" inline class="ml-1" />
</gl-button> </gl-button>
<input <input
......
...@@ -230,7 +230,13 @@ export default { ...@@ -230,7 +230,13 @@ export default {
:href="titleLink" :href="titleLink"
@click="handleFileNameClick" @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"> <span v-if="isFileRenamed">
<strong <strong
v-gl-tooltip v-gl-tooltip
......
...@@ -664,6 +664,7 @@ export const generateTreeList = files => { ...@@ -664,6 +664,7 @@ export const generateTreeList = files => {
addedLines: file.added_lines, addedLines: file.added_lines,
removedLines: file.removed_lines, removedLines: file.removed_lines,
parentPath: parent ? `${parent.path}/` : '/', parentPath: parent ? `${parent.path}/` : '/',
submodule: file.submodule,
}); });
} else { } else {
Object.assign(entry, { Object.assign(entry, {
......
...@@ -52,7 +52,7 @@ export default { ...@@ -52,7 +52,7 @@ export default {
<p class="gl-mb-0"> <p class="gl-mb-0">
{{ {{
s__( 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> </p>
......
...@@ -38,8 +38,11 @@ export default { ...@@ -38,8 +38,11 @@ export default {
isJira() { isJira() {
return this.propsSource.type === 'jira'; return this.propsSource.type === 'jira';
}, },
isInstanceLevel() { isInstanceOrGroupLevel() {
return this.propsSource.integrationLevel === integrationLevels.INSTANCE; return (
this.propsSource.integrationLevel === integrationLevels.INSTANCE ||
this.propsSource.integrationLevel === integrationLevels.GROUP
);
}, },
showJiraIssuesFields() { showJiraIssuesFields() {
return this.isJira && this.glFeatures.jiraIssuesIntegration; return this.isJira && this.glFeatures.jiraIssuesIntegration;
...@@ -91,7 +94,7 @@ export default { ...@@ -91,7 +94,7 @@ export default {
v-bind="propsSource.jiraIssuesProps" v-bind="propsSource.jiraIssuesProps"
/> />
<div v-if="isEditable" class="footer-block row-content-block"> <div v-if="isEditable" class="footer-block row-content-block">
<template v-if="isInstanceLevel"> <template v-if="isInstanceOrGroupLevel">
<gl-button <gl-button
v-gl-modal.confirmSaveIntegration v-gl-modal.confirmSaveIntegration
category="primary" category="primary"
......
...@@ -108,12 +108,7 @@ export default { ...@@ -108,12 +108,7 @@ export default {
:label="s__('Integrations|Comment detail:')" :label="s__('Integrations|Comment detail:')"
data-testid="comment-detail" data-testid="comment-detail"
> >
<input <input name="service[comment_detail]" type="hidden" :value="commentDetail" />
v-if="isInheriting"
name="service[comment_detail]"
type="hidden"
:value="commentDetail"
/>
<gl-form-radio <gl-form-radio
v-for="commentDetailOption in commentDetailOptions" v-for="commentDetailOption in commentDetailOptions"
:key="commentDetailOption.value" :key="commentDetailOption.value"
......
...@@ -6,13 +6,13 @@ import { ...@@ -6,13 +6,13 @@ import {
GlDatepicker, GlDatepicker,
GlLink, GlLink,
GlSprintf, GlSprintf,
GlSearchBoxByType,
GlButton, GlButton,
GlFormInput, GlFormInput,
} from '@gitlab/ui'; } from '@gitlab/ui';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import { s__, sprintf } from '~/locale'; import { s__, sprintf } from '~/locale';
import Api from '~/api'; import Api from '~/api';
import MembersTokenSelect from '~/invite_members/components/members_token_select.vue';
export default { export default {
name: 'InviteMembersModal', name: 'InviteMembersModal',
...@@ -23,9 +23,9 @@ export default { ...@@ -23,9 +23,9 @@ export default {
GlDropdown, GlDropdown,
GlDropdownItem, GlDropdownItem,
GlSprintf, GlSprintf,
GlSearchBoxByType,
GlButton, GlButton,
GlFormInput, GlFormInput,
MembersTokenSelect,
}, },
props: { props: {
groupId: { groupId: {
...@@ -129,44 +129,45 @@ export default { ...@@ -129,44 +129,45 @@ export default {
}, },
labels: { labels: {
modalTitle: s__('InviteMembersModal|Invite team members'), 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'), userPlaceholder: s__('InviteMembersModal|Search for members to invite'),
accessLevel: s__('InviteMembersModal|Choose a role permission'), accessLevel: s__('InviteMembersModal|Choose a role permission'),
accessExpireDate: s__('InviteMembersModal|Access expiration date (optional)'), accessExpireDate: s__('InviteMembersModal|Access expiration date (optional)'),
toastMessageSuccessful: s__('InviteMembersModal|Users were succesfully added'), toastMessageSuccessful: s__('InviteMembersModal|Members were successfully added'),
toastMessageUnsuccessful: s__('InviteMembersModal|User not invited. Feature coming soon!'), toastMessageUnsuccessful: s__('InviteMembersModal|Some of the members could not be added'),
readMoreText: s__(`InviteMembersModal|%{linkStart}Read more%{linkEnd} about role permissions`), readMoreText: s__(`InviteMembersModal|%{linkStart}Read more%{linkEnd} about role permissions`),
inviteButtonText: s__('InviteMembersModal|Invite'), inviteButtonText: s__('InviteMembersModal|Invite'),
cancelButtonText: s__('InviteMembersModal|Cancel'), cancelButtonText: s__('InviteMembersModal|Cancel'),
headerCloseLabel: s__('InviteMembersModal|Close invite team members'),
}, },
membersTokenSelectLabelId: 'invite-members-input',
}; };
</script> </script>
<template> <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 class="gl-ml-5 gl-mr-5">
<div>{{ introText }}</div> <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"> <div class="gl-mt-2">
<gl-search-box-by-type <members-token-select
v-model="newUsersToInvite" v-model="newUsersToInvite"
:label="$options.labels.newUsersToInvite"
:aria-labelledby="$options.membersTokenSelectLabelId"
:placeholder="$options.labels.userPlaceholder" :placeholder="$options.labels.userPlaceholder"
type="text"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
/> />
</div> </div>
<label class="gl-font-weight-bold gl-mt-5">{{ $options.labels.accessLevel }}</label> <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"> <div class="gl-mt-2 gl-w-half gl-xs-w-full">
<gl-dropdown <gl-dropdown class="gl-shadow-none gl-w-full" v-bind="$attrs" :text="selectedRoleName">
menu-class="dropdown-menu-selectable"
class="gl-shadow-none gl-w-full"
v-bind="$attrs"
:text="selectedRoleName"
>
<template v-for="(key, item) in accessLevels"> <template v-for="(key, item) in accessLevels">
<gl-dropdown-item <gl-dropdown-item
:key="key" :key="key"
...@@ -215,9 +216,13 @@ export default { ...@@ -215,9 +216,13 @@ export default {
{{ $options.labels.cancelButtonText }} {{ $options.labels.cancelButtonText }}
</gl-button> </gl-button>
<div class="gl-mr-3"></div> <div class="gl-mr-3"></div>
<gl-button ref="inviteButton" variant="success" @click="sendInvite">{{ <gl-button
$options.labels.inviteButtonText ref="inviteButton"
}}</gl-button> :disabled="!newUsersToInvite"
variant="success"
@click="sendInvite"
>{{ $options.labels.inviteButtonText }}</gl-button
>
</div> </div>
</template> </template>
</gl-modal> </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 { ...@@ -153,6 +153,7 @@ export default {
:folder="isTree" :folder="isTree"
:opened="file.opened" :opened="file.opened"
:size="16" :size="16"
:submodule="file.submodule"
/> />
<gl-truncate v-if="truncateMiddle" :text="file.name" position="middle" class="gl-pr-7" /> <gl-truncate v-if="truncateMiddle" :text="file.name" position="middle" class="gl-pr-7" />
<template v-else>{{ file.name }}</template> <template v-else>{{ file.name }}</template>
......
...@@ -115,14 +115,10 @@ code { ...@@ -115,14 +115,10 @@ code {
background-color: $gray-50; background-color: $gray-50;
border-radius: $border-radius-default; border-radius: $border-radius-default;
.code > & { .code > &,
background-color: inherit;
padding: unset;
}
.build-trace & { .build-trace & {
background-color: inherit; 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; $atlaskit-border-color: #dfe1e6;
......
...@@ -150,6 +150,14 @@ module PageLayoutHelper ...@@ -150,6 +150,14 @@ module PageLayoutHelper
css_class.join(' ') css_class.join(' ')
end end
def page_itemtype(itemtype = nil)
if itemtype
@page_itemtype = { itemscope: true, itemtype: itemtype }
else
@page_itemtype || {}
end
end
private private
def generic_canonical_url def generic_canonical_url
......
...@@ -91,18 +91,18 @@ module UsersHelper ...@@ -91,18 +91,18 @@ module UsersHelper
end end
end end
def work_information(user) def work_information(user, with_schema_markup: false)
return unless user return unless user
organization = user.organization organization = user.organization
job_title = user.job_title job_title = user.job_title
if organization.present? && job_title.present? 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? elsif job_title.present?
job_title render_job_title(job_title, with_schema_markup: with_schema_markup)
elsif organization.present? elsif organization.present?
organization render_organization(organization, with_schema_markup: with_schema_markup)
end end
end end
...@@ -151,6 +151,35 @@ module UsersHelper ...@@ -151,6 +151,35 @@ module UsersHelper
items items
end 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 end
UsersHelper.prepend_if_ee('EE::UsersHelper') UsersHelper.prepend_if_ee('EE::UsersHelper')
...@@ -1823,6 +1823,10 @@ class Project < ApplicationRecord ...@@ -1823,6 +1823,10 @@ class Project < ApplicationRecord
ensure_pages_metadatum.update!(deployed: false, artifacts_archive: nil, pages_deployment: nil) ensure_pages_metadatum.update!(deployed: false, artifacts_archive: nil, pages_deployment: nil)
end end
def update_pages_deployment!(deployment)
ensure_pages_metadatum.update!(pages_deployment: deployment)
end
def write_repository_config(gl_full_path: full_path) def write_repository_config(gl_full_path: full_path)
# We'd need to keep track of project full path otherwise directory tree # We'd need to keep track of project full path otherwise directory tree
# created with hashed storage enabled cannot be usefully imported using # created with hashed storage enabled cannot be usefully imported using
......
...@@ -138,7 +138,7 @@ module Projects ...@@ -138,7 +138,7 @@ module Projects
deployment = project.pages_deployments.create!(file: file, deployment = project.pages_deployments.create!(file: file,
file_count: entries_count, file_count: entries_count,
file_sha256: sha256) file_sha256: sha256)
project.pages_metadatum.update!(pages_deployment: deployment) project.update_pages_deployment!(deployment)
end end
DestroyPagesDeploymentsWorker.perform_in( DestroyPagesDeploymentsWorker.perform_in(
......
...@@ -21,6 +21,8 @@ ...@@ -21,6 +21,8 @@
.gl-mt-5 .gl-mt-5
%p Note: this integration only works with accounts on GitLab.com (SaaS). %p Note: this integration only works with accounts on GitLab.com (SaaS).
- else - else
.js-jira-connect-app
%form#add-subscription-form.subscription-form{ action: jira_connect_subscriptions_path } %form#add-subscription-form.subscription-form{ action: jira_connect_subscriptions_path }
.ak-field-group .ak-field-group
%label %label
...@@ -57,5 +59,8 @@ ...@@ -57,5 +59,8 @@
or enable cross-site cookies in your browser when adding a namespace. 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 %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') = page_specific_javascript_tag('jira_connect.js')
- add_page_specific_style 'page_bundles/jira_connect' - add_page_specific_style 'page_bundles/jira_connect'
...@@ -20,6 +20,6 @@ ...@@ -20,6 +20,6 @@
- unless @hide_breadcrumbs - unless @hide_breadcrumbs
= render "layouts/nav/breadcrumbs" = render "layouts/nav/breadcrumbs"
%div{ class: "#{(container_class unless @no_container)} #{@content_class}" } %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' = render "layouts/flash", extra_flash_class: 'limit-container-width'
= yield = yield
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
- page_title @user.blocked? ? s_('UserProfile|Blocked user') : @user.name - page_title @user.blocked? ? s_('UserProfile|Blocked user') : @user.name
- page_description @user.bio_html - page_description @user.bio_html
- header_title @user.name, user_path(@user) - header_title @user.name, user_path(@user)
- page_itemtype 'http://schema.org/Person'
- link_classes = "flex-grow-1 mx-1 " - link_classes = "flex-grow-1 mx-1 "
= content_for :meta_tags do = content_for :meta_tags do
...@@ -35,7 +36,7 @@ ...@@ -35,7 +36,7 @@
.profile-header{ class: [('with-no-profile-tabs' if profile_tabs.empty?)] } .profile-header{ class: [('with-no-profile-tabs' if profile_tabs.empty?)] }
.avatar-holder .avatar-holder
= link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do = 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? - if @user.blocked?
.user-info .user-info
...@@ -44,7 +45,7 @@ ...@@ -44,7 +45,7 @@
= render "users/profile_basic_info" = render "users/profile_basic_info"
- else - else
.user-info .user-info
.cover-title .cover-title{ itemprop: 'name' }
= @user.name = @user.name
- if @user.status - if @user.status
...@@ -54,15 +55,15 @@ ...@@ -54,15 +55,15 @@
= render "users/profile_basic_info" = render "users/profile_basic_info"
.cover-desc.cgray.mb-1.mb-sm-2 .cover-desc.cgray.mb-1.mb-sm-2
- unless @user.location.blank? - 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') = sprite_icon('location', css_class: 'vertical-align-sub fgray')
%span.vertical-align-middle %span.vertical-align-middle{ itemprop: 'addressLocality' }
= @user.location = @user.location
- unless work_information(@user).blank? - unless work_information(@user).blank?
.profile-link-holder.middle-dot-divider-sm.d-block.d-sm-inline .profile-link-holder.middle-dot-divider-sm.d-block.d-sm-inline
= sprite_icon('work', css_class: 'vertical-align-middle fgray') = sprite_icon('work', css_class: 'vertical-align-middle fgray')
%span.vertical-align-middle %span.vertical-align-middle
= work_information(@user) = work_information(@user, with_schema_markup: true)
.cover-desc.cgray.mb-1.mb-sm-2 .cover-desc.cgray.mb-1.mb-sm-2
- unless @user.skype.blank? - unless @user.skype.blank?
.profile-link-holder.middle-dot-divider .profile-link-holder.middle-dot-divider
...@@ -80,10 +81,10 @@ ...@@ -80,10 +81,10 @@
.profile-link-holder.middle-dot-divider-sm.d-block.d-sm-inline.mt-1.mt-sm-0 .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? - if Feature.enabled?(:security_auto_fix) && @user.bot?
= sprite_icon('question', css_class: 'gl-text-blue-600') = 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? - unless @user.public_email.blank?
.profile-link-holder.middle-dot-divider-sm.d-block.d-sm-inline.mt-1.mt-sm-0 .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? - if @user.bio.present?
.cover-desc.cgray .cover-desc.cgray
.profile-user-bio .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() { ...@@ -82,6 +82,7 @@ function generateEntries() {
// sentry: './sentry/index.js', Temporarily commented out to investigate performance: https://gitlab.com/gitlab-org/gitlab/-/issues/251179 // sentry: './sentry/index.js', Temporarily commented out to investigate performance: https://gitlab.com/gitlab-org/gitlab/-/issues/251179
performance_bar: './performance_bar/index.js', performance_bar: './performance_bar/index.js',
chrome_84_icon_fix: './lib/chrome_84_icon_fix.js', chrome_84_icon_fix: './lib/chrome_84_icon_fix.js',
jira_connect_app: './jira_connect/index.js',
}; };
return Object.assign(manualEntries, autoEntries); return Object.assign(manualEntries, autoEntries);
......
This diff is collapsed.
--- ---
stage: none stage: none
group: unassigned 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.' description: 'Learn how to install, configure, update, and maintain your GitLab instance.'
--- ---
......
...@@ -10818,7 +10818,7 @@ enum IssueType { ...@@ -10818,7 +10818,7 @@ enum IssueType {
""" """
Represents an iteration object Represents an iteration object
""" """
type Iteration implements TimeboxBurnupTimeSeriesInterface { type Iteration implements TimeboxReportInterface {
""" """
Daily scope and completed totals for burnup charts Daily scope and completed totals for burnup charts
""" """
...@@ -10854,6 +10854,11 @@ type Iteration implements TimeboxBurnupTimeSeriesInterface { ...@@ -10854,6 +10854,11 @@ type Iteration implements TimeboxBurnupTimeSeriesInterface {
""" """
iid: ID! 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 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 { ...@@ -12831,7 +12836,7 @@ type MetricsDashboardAnnotationEdge {
""" """
Represents a milestone Represents a milestone
""" """
type Milestone implements TimeboxBurnupTimeSeriesInterface { type Milestone implements TimeboxReportInterface {
""" """
Daily scope and completed totals for burnup charts Daily scope and completed totals for burnup charts
""" """
...@@ -12867,6 +12872,11 @@ type Milestone implements TimeboxBurnupTimeSeriesInterface { ...@@ -12867,6 +12872,11 @@ type Milestone implements TimeboxBurnupTimeSeriesInterface {
""" """
projectMilestone: Boolean! projectMilestone: Boolean!
"""
Historically accurate report about the timebox
"""
report: TimeboxReport
""" """
Timestamp of the milestone start date Timestamp of the milestone start date
""" """
...@@ -20155,13 +20165,28 @@ Time represented in ISO 8601 ...@@ -20155,13 +20165,28 @@ Time represented in ISO 8601
""" """
scalar Time scalar Time
interface TimeboxBurnupTimeSeriesInterface { """
Represents a historically accurate report about the timebox
"""
type TimeboxReport {
""" """
Daily scope and completed totals for burnup charts Daily scope and completed totals for burnup charts
""" """
burnupTimeSeries: [BurnupChartDailyTotals!] 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 A time-frame defined as a closed inclusive range of two dates
""" """
......
...@@ -29533,6 +29533,20 @@ ...@@ -29533,6 +29533,20 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "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", "name": "scopedPath",
"description": "Web path of the iteration, scoped to the query parent. Only valid for Project parents. Returns null in other contexts", "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 @@ ...@@ -29670,7 +29684,7 @@
"interfaces": [ "interfaces": [
{ {
"kind": "INTERFACE", "kind": "INTERFACE",
"name": "TimeboxBurnupTimeSeriesInterface", "name": "TimeboxReportInterface",
"ofType": null "ofType": null
} }
], ],
...@@ -35318,6 +35332,20 @@ ...@@ -35318,6 +35332,20 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "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", "name": "startDate",
"description": "Timestamp of the milestone start date", "description": "Timestamp of the milestone start date",
...@@ -35441,7 +35469,7 @@ ...@@ -35441,7 +35469,7 @@
"interfaces": [ "interfaces": [
{ {
"kind": "INTERFACE", "kind": "INTERFACE",
"name": "TimeboxBurnupTimeSeriesInterface", "name": "TimeboxReportInterface",
"ofType": null "ofType": null
} }
], ],
...@@ -58503,9 +58531,44 @@ ...@@ -58503,9 +58531,44 @@
"enumValues": null, "enumValues": null,
"possibleTypes": 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", "kind": "INTERFACE",
"name": "TimeboxBurnupTimeSeriesInterface", "name": "TimeboxReportInterface",
"description": null, "description": null,
"fields": [ "fields": [
{ {
...@@ -58529,6 +58592,20 @@ ...@@ -58529,6 +58592,20 @@
}, },
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "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, "inputFields": null,
...@@ -1652,6 +1652,7 @@ Represents an iteration object. ...@@ -1652,6 +1652,7 @@ Represents an iteration object.
| `dueDate` | Time | Timestamp of the iteration due date | | `dueDate` | Time | Timestamp of the iteration due date |
| `id` | ID! | ID of the iteration | | `id` | ID! | ID of the iteration |
| `iid` | ID! | Internal 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 | | `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 | | `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 | | `startDate` | Time | Timestamp of the iteration start date |
...@@ -1964,6 +1965,7 @@ Represents a milestone. ...@@ -1964,6 +1965,7 @@ Represents a milestone.
| `groupMilestone` | Boolean! | Indicates if milestone is at group level | | `groupMilestone` | Boolean! | Indicates if milestone is at group level |
| `id` | ID! | ID of the milestone | | `id` | ID! | ID of the milestone |
| `projectMilestone` | Boolean! | Indicates if milestone is at project level | | `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 | | `startDate` | Time | Timestamp of the milestone start date |
| `state` | MilestoneStateEnum! | State of the milestone | | `state` | MilestoneStateEnum! | State of the milestone |
| `stats` | MilestoneStats | Milestone statistics | | `stats` | MilestoneStats | Milestone statistics |
...@@ -2958,6 +2960,14 @@ Represents a requirement test report. ...@@ -2958,6 +2960,14 @@ Represents a requirement test report.
| `id` | ID! | ID of the test report | | `id` | ID! | ID of the test report |
| `state` | TestReportState! | State 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 ### Timelog
| Field | Type | Description | | Field | Type | Description |
......
...@@ -128,12 +128,6 @@ This helps you avoid having to add the `only:` rule to all of your jobs to make ...@@ -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 them always run. You can use this format to set up a Review App, helping to
save resources. 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 #### Excluding certain branches
Pipelines for merge requests require special treatment when Pipelines for merge requests require special treatment when
......
...@@ -138,13 +138,14 @@ Commit messages should follow the guidelines below, for reasons explained by Chr ...@@ -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 merge request should not contain more than 10 commit messages.
- The commit subject should contain at least 3 words. - The commit subject should contain at least 3 words.
CAUTION: **Caution:** **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). - 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)
TIP: **Tip:** if your merge request includes "Applied suggestion to X files" commits, so that Danger can ignore those.
Consider enabling [Squash and merge](../../user/project/merge_requests/squash_and_merge.md#squash-and-merge) if your merge - The prefixes in the form of `[prefix]` and `prefix:` are allowed (they can be all lowercase, as long
request includes "Applied suggestion to X files" commits, so that Danger can ignore those. 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 #### Why these standards matter
......
...@@ -83,3 +83,25 @@ inject scripts into the web app. ...@@ -83,3 +83,25 @@ inject scripts into the web app.
Inline styles should be avoided in almost all cases, they should only be used 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 when no alternatives can be found. This allows reusability of styles as well as
readability. 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: ...@@ -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`. - 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. - 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. - Consider using [`gl-sprintf`](../../ee/development/i18n/externalization.md#interpolation) to interpolate translated strings securely.
- Avoid `__()` with translations that contain user-controlled values. - Avoid `__()` with translations that contain user-controlled values.
- When working with `postMessage`, ensure the `origin` of the message is allowlisted. - 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 ...@@ -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. | | `: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. | | `: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. | `: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. | | `: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). | | `: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). | | `: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 ...@@ -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. | | `: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. | | `: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/). | | `: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 stage: Enablement
group: unassigned 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 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 type: concepts
--- ---
...@@ -57,21 +57,11 @@ one major version. For example, it is safe to: ...@@ -57,21 +57,11 @@ one major version. For example, it is safe to:
- `12.7.5` -> `12.10.5` - `12.7.5` -> `12.10.5`
- `11.3.4` -> `11.11.1` - `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: - Upgrade the *patch* version. For example:
- `12.0.4` -> `12.0.12` - `12.0.4` -> `12.0.12`
- `11.11.1` -> `11.11.8` - `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:** 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). 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 ...@@ -82,87 +72,9 @@ Instructions are available for downloading an Omnibus GitLab Linux package local
NOTE: **Note:** 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). 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 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:
| Target version | Your version | Recommended upgrade path | Note | Backward-incompatible changes and migrations are reserved for major versions. See the [upgrade guide](../update/README.md#upgrading-to-a-new-major-version).
| --------------------- | ------------ | ------------------------ | ---- |
| `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
```
## Patch releases ## Patch releases
...@@ -237,19 +149,6 @@ This decision is made on a case-by-case basis. ...@@ -237,19 +149,6 @@ This decision is made on a case-by-case basis.
## More information ## 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 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 [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/). [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 ...@@ -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 you will likely get errors. See the [troubleshooting section](#troubleshooting) for more
details. 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 ## Max import size
You can change the maximum file size for imports in GitLab. You can change the maximum file size for imports in GitLab.
......
...@@ -21,7 +21,7 @@ For an overview of application security with GitLab, see ...@@ -21,7 +21,7 @@ For an overview of application security with GitLab, see
## Quick start ## Quick start
Get started quickly with Dependency Scanning, License Scanning, Static Application Security 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 ```yaml
include: include:
...@@ -70,12 +70,26 @@ GitLab uses the following tools to scan and report known vulnerabilities found i ...@@ -70,12 +70,26 @@ GitLab uses the following tools to scan and report known vulnerabilities found i
| [Dependency List](dependency_list/index.md) **(ULTIMATE)** | View your project's dependencies and their known vulnerabilities. | | [Dependency List](dependency_list/index.md) **(ULTIMATE)** | View your project's dependencies and their known vulnerabilities. |
| [Dependency Scanning](dependency_scanning/index.md) **(ULTIMATE)** | Analyze your dependencies for known vulnerabilities. | | [Dependency Scanning](dependency_scanning/index.md) **(ULTIMATE)** | Analyze your dependencies for known vulnerabilities. |
| [Dynamic Application Security Testing (DAST)](dast/index.md) **(ULTIMATE)** | Analyze running web applications for known vulnerabilities. | | [Dynamic Application Security Testing (DAST)](dast/index.md) **(ULTIMATE)** | Analyze running web applications for known vulnerabilities. |
| [API fuzzing](api_fuzzing/index.md) **(ULTIMATE)** | Find unknown bugs and vulnerabilities in web APIs with fuzzing. | | [API fuzzing](api_fuzzing/index.md) **(ULTIMATE)** | Find unknown bugs and vulnerabilities in web APIs with fuzzing. |
| [Secret Detection](secret_detection/index.md) **(ULTIMATE)** | Analyze Git history for leaked secrets. | | [Secret Detection](secret_detection/index.md) **(ULTIMATE)** | Analyze Git history for leaked secrets. |
| [Security Dashboard](security_dashboard/index.md) **(ULTIMATE)** | View vulnerabilities in all your projects and groups. | | [Security Dashboard](security_dashboard/index.md) **(ULTIMATE)** | View vulnerabilities in all your projects and groups. |
| [Static Application Security Testing (SAST)](sast/index.md) | Analyze source code for known vulnerabilities. | | [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. | | [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 ## Security Scanning with Auto DevOps
When [Auto DevOps](../../topics/autodevops/) is enabled, all GitLab Security scanning tools will be configured using default settings. When [Auto DevOps](../../topics/autodevops/) is enabled, all GitLab Security scanning tools will be configured using default settings.
...@@ -144,21 +158,21 @@ To view details of DAST vulnerabilities: ...@@ -144,21 +158,21 @@ To view details of DAST vulnerabilities:
1. Click on the vulnerability's description. The following details are provided: 1. Click on the vulnerability's description. The following details are provided:
| Field | Description | | Field | Description |
|:-----------------|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| |:-----------------|:------------------------------------------------------------------ |
| Description | Description of the vulnerability. | | Description | Description of the vulnerability. |
| Project | Namespace and project in which the vulnerability was detected. | | Project | Namespace and project in which the vulnerability was detected. |
| Method | HTTP method used to detect the vulnerability. | | Method | HTTP method used to detect the vulnerability. |
| URL | URL at which the vulnerability was detected. | | URL | URL at which the vulnerability was detected. |
| Request Headers | Headers of the request. | | Request Headers | Headers of the request. |
| Response Status | Response status received from the application. | | Response Status | Response status received from the application. |
| Response Headers | Headers of the response received from the application. | | Response Headers | Headers of the response received from the application. |
| Evidence | Evidence of the data found that verified the vulnerability. Often a snippet of the request or response, this can be used to help verify that the finding is a vulnerability. | | Evidence | Evidence of the data found that verified the vulnerability. Often a snippet of the request or response, this can be used to help verify that the finding is a vulnerability. |
| Identifiers | Identifiers of the vulnerability. | | Identifiers | Identifiers of the vulnerability. |
| Severity | Severity of the vulnerability. | | Severity | Severity of the vulnerability. |
| Scanner Type | Type of vulnerability report. | | Scanner Type | Type of vulnerability report. |
| Links | Links to further details of the detected vulnerability. | | Links | Links to further details of the detected vulnerability. |
| Solution | Details of a recommended solution to the vulnerability (optional). | | Solution | Details of a recommended solution to the vulnerability (optional). |
#### Hide sensitive information in headers #### Hide sensitive information in headers
...@@ -238,14 +252,11 @@ Selecting the button creates a merge request with the solution. ...@@ -238,14 +252,11 @@ Selecting the button creates a merge request with the solution.
#### Manually applying the suggested patch #### 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 To manually apply the patch that GitLab generated for a vulnerability:
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)
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. Ensure your local project has the same commit checked out that was used to generate the patch.
1. Run `git apply remediation.patch`. 1. Run `git apply remediation.patch`.
......
...@@ -72,6 +72,17 @@ With GitLab Enterprise Edition, you can also: ...@@ -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. 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 ## Projects
In GitLab, you can create [projects](project/index.md) to host In GitLab, you can create [projects](project/index.md) to host
......
...@@ -117,6 +117,28 @@ the dropdown) **Approved-By** and select the user. ...@@ -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) ![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 ## Filters autocomplete
GitLab provides many filters across many pages (issues, merge requests, epics, GitLab provides many filters across many pages (issues, merge requests, epics,
......
...@@ -11,11 +11,16 @@ import { ...@@ -11,11 +11,16 @@ import {
GlInputGroupText, GlInputGroupText,
GlLoadingIcon, GlLoadingIcon,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { omit } from 'lodash';
import * as Sentry from '~/sentry/wrapper'; import * as Sentry from '~/sentry/wrapper';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import download from '~/lib/utils/downloader'; 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 { cleanLeadingSeparator, joinPaths, stripPathTail } from '~/lib/utils/url_utility';
import { fetchPolicies } from '~/lib/graphql'; import { fetchPolicies } from '~/lib/graphql';
import { import {
DAST_SITE_VALIDATION_HTTP_HEADER_KEY,
DAST_SITE_VALIDATION_METHOD_HTTP_HEADER,
DAST_SITE_VALIDATION_METHOD_TEXT_FILE, DAST_SITE_VALIDATION_METHOD_TEXT_FILE,
DAST_SITE_VALIDATION_METHODS, DAST_SITE_VALIDATION_METHODS,
DAST_SITE_VALIDATION_STATUS, DAST_SITE_VALIDATION_STATUS,
...@@ -27,6 +32,7 @@ import dastSiteValidationQuery from '../graphql/dast_site_validation.query.graph ...@@ -27,6 +32,7 @@ import dastSiteValidationQuery from '../graphql/dast_site_validation.query.graph
export default { export default {
name: 'DastSiteValidation', name: 'DastSiteValidation',
components: { components: {
ClipboardButton,
GlAlert, GlAlert,
GlButton, GlButton,
GlCard, GlCard,
...@@ -38,6 +44,7 @@ export default { ...@@ -38,6 +44,7 @@ export default {
GlInputGroupText, GlInputGroupText,
GlLoadingIcon, GlLoadingIcon,
}, },
mixins: [glFeatureFlagsMixin()],
apollo: { apollo: {
dastSiteValidation: { dastSiteValidation: {
query: dastSiteValidationQuery, query: dastSiteValidationQuery,
...@@ -103,6 +110,16 @@ export default { ...@@ -103,6 +110,16 @@ export default {
}; };
}, },
computed: { 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() { urlObject() {
try { try {
return new URL(this.targetUrl); return new URL(this.targetUrl);
...@@ -119,12 +136,18 @@ export default { ...@@ -119,12 +136,18 @@ export default {
isTextFileValidation() { isTextFileValidation() {
return this.validationMethod === DAST_SITE_VALIDATION_METHOD_TEXT_FILE; return this.validationMethod === DAST_SITE_VALIDATION_METHOD_TEXT_FILE;
}, },
isHttpHeaderValidation() {
return this.validationMethod === DAST_SITE_VALIDATION_METHOD_HTTP_HEADER;
},
textFileName() { textFileName() {
return `GitLab-DAST-Site-Validation-${this.token}.txt`; return `GitLab-DAST-Site-Validation-${this.token}.txt`;
}, },
locationStepLabel() { locationStepLabel() {
return DAST_SITE_VALIDATION_METHODS[this.validationMethod].i18n.locationStepLabel; return DAST_SITE_VALIDATION_METHODS[this.validationMethod].i18n.locationStepLabel;
}, },
httpHeader() {
return `${DAST_SITE_VALIDATION_HTTP_HEADER_KEY}: uuid-code-${this.token}`;
},
}, },
watch: { watch: {
targetUrl() { targetUrl() {
...@@ -132,13 +155,22 @@ export default { ...@@ -132,13 +155,22 @@ export default {
}, },
}, },
created() { created() {
this.unsubscribe = this.$watch(() => this.token, this.updateValidationPath, { this.unsubscribe = this.$watch(
immediate: true, () => [this.token, this.validationMethod],
}); this.updateValidationPath,
{
immediate: true,
},
);
}, },
methods: { methods: {
updateValidationPath() { 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() { onValidationPathInput() {
this.unsubscribe(); this.unsubscribe();
...@@ -189,7 +221,6 @@ export default { ...@@ -189,7 +221,6 @@ export default {
this.hasValidationError = true; this.hasValidationError = true;
}, },
}, },
validationMethodOptions: Object.values(DAST_SITE_VALIDATION_METHODS),
}; };
</script> </script>
...@@ -199,7 +230,7 @@ export default { ...@@ -199,7 +230,7 @@ export default {
{{ s__('DastProfiles|Site is not validated yet, please follow the steps.') }} {{ s__('DastProfiles|Site is not validated yet, please follow the steps.') }}
</gl-alert> </gl-alert>
<gl-form-group :label="s__('DastProfiles|Step 1 - Choose site validation method')"> <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>
<gl-form-group <gl-form-group
v-if="isTextFileValidation" v-if="isTextFileValidation"
...@@ -217,6 +248,16 @@ export default { ...@@ -217,6 +248,16 @@ export default {
{{ textFileName }} {{ textFileName }}
</gl-button> </gl-button>
</gl-form-group> </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-group :label="locationStepLabel" class="mw-460">
<gl-form-input-group> <gl-form-input-group>
<template #prepend> <template #prepend>
...@@ -255,7 +296,7 @@ export default { ...@@ -255,7 +296,7 @@ export default {
<gl-icon name="status_failed" /> <gl-icon name="status_failed" />
{{ {{
s__( 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> </template>
......
import { s__ } from '~/locale'; import { s__ } from '~/locale';
export const DAST_SITE_VALIDATION_METHOD_TEXT_FILE = 'TEXT_FILE'; 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 = { export const DAST_SITE_VALIDATION_METHODS = {
[DAST_SITE_VALIDATION_METHOD_TEXT_FILE]: { [DAST_SITE_VALIDATION_METHOD_TEXT_FILE]: {
value: DAST_SITE_VALIDATION_METHOD_TEXT_FILE, value: DAST_SITE_VALIDATION_METHOD_TEXT_FILE,
...@@ -9,6 +11,13 @@ export const DAST_SITE_VALIDATION_METHODS = { ...@@ -9,6 +11,13 @@ export const DAST_SITE_VALIDATION_METHODS = {
locationStepLabel: s__('DastProfiles|Step 3 - Confirm text file location and validate'), 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 = { export const DAST_SITE_VALIDATION_STATUS = {
...@@ -19,3 +28,4 @@ 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_POLL_INTERVAL = 1000;
export const DAST_SITE_VALIDATION_HTTP_HEADER_KEY = 'Gitlab-On-Demand-DAST';
...@@ -6,6 +6,7 @@ module Projects ...@@ -6,6 +6,7 @@ module Projects
before_action do before_action do
authorize_read_on_demand_scans! authorize_read_on_demand_scans!
push_frontend_feature_flag(:security_on_demand_scans_site_validation, @project) push_frontend_feature_flag(:security_on_demand_scans_site_validation, @project)
push_frontend_feature_flag(:security_on_demand_scans_http_header_validation, @project)
end end
feature_category :dynamic_application_security_testing feature_category :dynamic_application_security_testing
......
...@@ -6,7 +6,7 @@ module EE ...@@ -6,7 +6,7 @@ module EE
extend ActiveSupport::Concern extend ActiveSupport::Concern
prepended do prepended do
implements ::Types::TimeboxBurnupTimeSeriesInterface implements ::Types::TimeboxReportInterface
end end
end end
end end
......
...@@ -9,11 +9,11 @@ module Resolvers ...@@ -9,11 +9,11 @@ module Resolvers
def resolve(*args) def resolve(*args)
return [] unless timebox.burnup_charts_available? return [] unless timebox.burnup_charts_available?
response = TimeboxBurnupChartService.new(timebox).execute response = TimeboxReportService.new(timebox).execute
raise GraphQL::ExecutionError, response.message if response.error? raise GraphQL::ExecutionError, response.message if response.error?
response.payload response.payload[:burnup_time_series]
end end
end 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 ...@@ -9,7 +9,7 @@ module Types
authorize :read_iteration authorize :read_iteration
implements ::Types::TimeboxBurnupTimeSeriesInterface implements ::Types::TimeboxReportInterface
field :id, GraphQL::ID_TYPE, null: false, field :id, GraphQL::ID_TYPE, null: false,
description: 'ID of the iteration' description: 'ID of the iteration'
......
# frozen_string_literal: true # frozen_string_literal: true
module Types module Types
module TimeboxBurnupTimeSeriesInterface module TimeboxReportInterface
include BaseInterface 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, field :burnup_time_series, [::Types::BurnupChartDailyTotalsType], null: true,
resolver: ::Resolvers::TimeboxBurnupTimeSeriesResolver, resolver: ::Resolvers::TimeboxBurnupTimeSeriesResolver,
description: 'Daily scope and completed totals for burnup charts', 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 @@ ...@@ -7,7 +7,7 @@
# This is implemented by iterating over all relevant resource events ordered by time. We need to do this # 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. # 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 include Gitlab::Utils::StrongMemoize
EVENT_COUNT_LIMIT = 50_000 EVENT_COUNT_LIMIT = 50_000
...@@ -35,7 +35,9 @@ class TimeboxBurnupChartService ...@@ -35,7 +35,9 @@ class TimeboxBurnupChartService
end end
end end
ServiceResponse.success(payload: chart_data) ServiceResponse.success(payload: {
burnup_time_series: chart_data
})
end end
private private
......
...@@ -14,7 +14,7 @@ export const dastSiteValidation = (status = DAST_SITE_VALIDATION_STATUS.PENDING) ...@@ -14,7 +14,7 @@ export const dastSiteValidation = (status = DAST_SITE_VALIDATION_STATUS.PENDING)
export const dastSiteValidationCreate = (errors = []) => ({ export const dastSiteValidationCreate = (errors = []) => ({
data: { 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', () => { ...@@ -85,31 +85,29 @@ describe('IterationSelect', () => {
}); });
describe('when a user can edit', () => { 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 } }); createComponent({ props: { canEdit: true } });
expect(wrapper.find(GlDropdown).isVisible()).toBe(false); expect(wrapper.find(GlDropdown).isVisible()).toBe(false);
toggleDropdown(); toggleDropdown();
return wrapper.vm.$nextTick().then(() => { await wrapper.vm.$nextTick();
expect(wrapper.find(GlDropdown).isVisible()).toBe(true); expect(wrapper.find(GlDropdown).isVisible()).toBe(true);
});
}); });
it('focuses on the input', () => { it('focuses on the input', async () => {
createComponent({ props: { canEdit: true } }); createComponent({ props: { canEdit: true } });
const spy = jest.spyOn(wrapper.vm.$refs.search, 'focusInput'); const spy = jest.spyOn(wrapper.vm.$refs.search, 'focusInput');
toggleDropdown(); toggleDropdown();
return wrapper.vm.$nextTick().then(() => { await wrapper.vm.$nextTick();
expect(spy).toHaveBeenCalled(); 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(); const spy = jest.fn();
createComponent({ props: { canEdit: true } }); createComponent({ props: { canEdit: true } });
...@@ -117,9 +115,8 @@ describe('IterationSelect', () => { ...@@ -117,9 +115,8 @@ describe('IterationSelect', () => {
toggleDropdown(spy); toggleDropdown(spy);
return wrapper.vm.$nextTick().then(() => { await wrapper.vm.$nextTick();
expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledTimes(1);
});
}); });
describe('when user is editing', () => { describe('when user is editing', () => {
...@@ -214,10 +211,9 @@ describe('IterationSelect', () => { ...@@ -214,10 +211,9 @@ describe('IterationSelect', () => {
}); });
}); });
it('sets the value returned from the mutation to currentIteration', () => { it('sets the value returned from the mutation to currentIteration', async () => {
return wrapper.vm.$nextTick().then(() => { await wrapper.vm.$nextTick();
expect(wrapper.vm.currentIteration).toBe('123'); expect(wrapper.vm.currentIteration).toBe('123');
});
}); });
}); });
...@@ -247,10 +243,9 @@ describe('IterationSelect', () => { ...@@ -247,10 +243,9 @@ describe('IterationSelect', () => {
.vm.$emit('click'); .vm.$emit('click');
}); });
it('calls createFlash with $expectedMsg', () => { it('calls createFlash with $expectedMsg', async () => {
return wrapper.vm.$nextTick().then(() => { await wrapper.vm.$nextTick();
expect(createFlash).toHaveBeenCalledWith(expectedMsg); expect(createFlash).toHaveBeenCalledWith(expectedMsg);
});
}); });
}); });
}); });
...@@ -263,33 +258,31 @@ describe('IterationSelect', () => { ...@@ -263,33 +258,31 @@ describe('IterationSelect', () => {
createComponent({}); createComponent({});
}); });
it('sets the search term', () => { it('sets the search term', async () => {
wrapper.find(GlSearchBoxByType).vm.$emit('input', 'testing'); wrapper.find(GlSearchBoxByType).vm.$emit('input', 'testing');
return wrapper.vm.$nextTick().then(() => { await wrapper.vm.$nextTick();
expect(wrapper.vm.searchTerm).toBe('testing'); expect(wrapper.vm.searchTerm).toBe('testing');
});
}); });
}); });
describe('when the user off clicks', () => { describe('when the user off clicks', () => {
describe('when the dropdown is open', () => { describe('when the dropdown is open', () => {
beforeEach(() => { beforeEach(async () => {
createComponent({}); createComponent({});
toggleDropdown(); 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); expect(wrapper.find(GlDropdown).isVisible()).toBe(true);
toggleDropdown(); toggleDropdown();
return wrapper.vm.$nextTick().then(() => { await wrapper.vm.$nextTick();
expect(wrapper.find(GlDropdown).isVisible()).toBe(false); expect(wrapper.find(GlDropdown).isVisible()).toBe(false);
});
}); });
}); });
}); });
......
...@@ -29,71 +29,67 @@ describe('SidebarItemEpicsSelect', () => { ...@@ -29,71 +29,67 @@ describe('SidebarItemEpicsSelect', () => {
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null;
}); });
describe('methods', () => { describe('methods', () => {
describe('getInitialEpicLoading', () => { describe('getInitialEpicLoading', () => {
it('should return `false` when `initialEpic` prop is provided', () => { it('should return `false` when `initialEpic` prop is provided', async () => {
wrapper.setProps({ wrapper.setProps({
initialEpic: mockEpic1, initialEpic: mockEpic1,
}); });
return wrapper.vm.$nextTick(() => { await wrapper.vm.$nextTick();
expect(wrapper.vm.getInitialEpicLoading()).toBe(false); 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({ wrapper.setProps({
sidebarStore: { isFetching: { epic: true } }, sidebarStore: { isFetching: { epic: true } },
}); });
return wrapper.vm.$nextTick(() => { await wrapper.vm.$nextTick();
expect(wrapper.vm.getInitialEpicLoading()).toBe(true); 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({ wrapper.setProps({
initialEpic: null, initialEpic: null,
sidebarStore: { isFetching: null }, sidebarStore: { isFetching: null },
}); });
return wrapper.vm.$nextTick(() => { await wrapper.vm.$nextTick();
expect(wrapper.vm.getInitialEpicLoading()).toBe(false); expect(wrapper.vm.getInitialEpicLoading()).toBe(false);
});
}); });
}); });
describe('getEpic', () => { 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({ wrapper.setProps({
initialEpic: mockEpic1, initialEpic: mockEpic1,
}); });
return wrapper.vm.$nextTick(() => { await wrapper.vm.$nextTick();
expect(wrapper.vm.getEpic()).toBe(mockEpic1); expect(wrapper.vm.getEpic()).toBe(mockEpic1);
});
}); });
it('should return value of `sidebarStore.epic` as it is when it is available', () => { it('should return value of `sidebarStore.epic` as it is when it is available', () => {
expect(wrapper.vm.getEpic()).toBe(mockEpic1); 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({ wrapper.setProps({
initialEpic: null, initialEpic: null,
sidebarStore: { epic: null }, sidebarStore: { epic: null },
}); });
return wrapper.vm.$nextTick(() => { await wrapper.vm.$nextTick();
expect(wrapper.vm.getEpic()).toEqual( expect(wrapper.vm.getEpic()).toEqual(
expect.objectContaining({ expect.objectContaining({
id: 0, id: 0,
title: 'No Epic', title: 'No Epic',
}), }),
); );
});
}); });
}); });
}); });
......
import { GlDropdown, GlDropdownItem, GlLoadingIcon } from '@gitlab/ui'; import { GlDropdown, GlDropdownItem, GlLoadingIcon } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils'; import { mount, shallowMount } from '@vue/test-utils';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import Vue from 'vue';
import Status from 'ee/sidebar/components/status/status.vue'; import Status from 'ee/sidebar/components/status/status.vue';
import { healthStatus, healthStatusTextMap } from 'ee/sidebar/constants'; import { healthStatus, healthStatusTextMap } from 'ee/sidebar/constants';
...@@ -46,6 +45,7 @@ describe('Status', () => { ...@@ -46,6 +45,7 @@ describe('Status', () => {
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null;
}); });
it('shows the text "Status"', () => { it('shows the text "Status"', () => {
...@@ -106,7 +106,7 @@ describe('Status', () => { ...@@ -106,7 +106,7 @@ describe('Status', () => {
}); });
describe('remove status dropdown item', () => { describe('remove status dropdown item', () => {
it('is displayed when there is a status', () => { it('is displayed when there is a status', async () => {
const props = { const props = {
isEditable: true, isEditable: true,
status: healthStatus.AT_RISK, status: healthStatus.AT_RISK,
...@@ -116,9 +116,8 @@ describe('Status', () => { ...@@ -116,9 +116,8 @@ describe('Status', () => {
wrapper.vm.isDropdownShowing = true; wrapper.vm.isDropdownShowing = true;
wrapper.vm.$nextTick(() => { await wrapper.vm.$nextTick();
expect(getRemoveStatusItem(wrapper).exists()).toBe(true); expect(getRemoveStatusItem(wrapper).exists()).toBe(true);
});
}); });
it('emits an onDropdownClick event with argument null when clicked', () => { it('emits an onDropdownClick event with argument null when clicked', () => {
...@@ -201,12 +200,11 @@ describe('Status', () => { ...@@ -201,12 +200,11 @@ describe('Status', () => {
mountStatus(props); 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'); getEditButton(wrapper).trigger('click');
return Vue.nextTick().then(() => { await wrapper.vm.$nextTick();
expect(getDropdownClasses(wrapper)).toContain('show'); expect(getDropdownClasses(wrapper)).toContain('show');
});
}); });
}); });
...@@ -231,22 +229,20 @@ describe('Status', () => { ...@@ -231,22 +229,20 @@ describe('Status', () => {
).toContain(message); ).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'); getEditButton(wrapper).trigger('click');
return Vue.nextTick().then(() => { await wrapper.vm.$nextTick();
expect(getDropdownClasses(wrapper)).toContain('gl-display-none'); 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); const dropdownItem = wrapper.findAll(GlDropdownItem).at(1);
dropdownItem.vm.$emit('click'); dropdownItem.vm.$emit('click');
return wrapper.vm.$nextTick().then(() => { await wrapper.vm.$nextTick();
expect(getDropdownClasses(wrapper)).toContain('gl-display-none'); expect(getDropdownClasses(wrapper)).toContain('gl-display-none');
});
}); });
}); });
...@@ -285,15 +281,14 @@ describe('Status', () => { ...@@ -285,15 +281,14 @@ describe('Status', () => {
// Test that "onTrack", "needsAttention", and "atRisk" values are emitted when form is submitted // Test that "onTrack", "needsAttention", and "atRisk" values are emitted when form is submitted
it.each(getIterableArray(Object.values(healthStatus)))( it.each(getIterableArray(Object.values(healthStatus)))(
'emits onFormSubmit event with argument "%s" when user selects the option and submits form', 'emits onFormSubmit event with argument "%s" when user selects the option and submits form',
(status, index) => { async (status, index) => {
wrapper wrapper
.findAll(GlDropdownItem) .findAll(GlDropdownItem)
.at(index + 1) .at(index + 1)
.vm.$emit('click', { preventDefault: () => null }); .vm.$emit('click', { preventDefault: () => null });
return Vue.nextTick().then(() => { await wrapper.vm.$nextTick();
expect(wrapper.emitted().onDropdownClick[0]).toEqual([status]); expect(wrapper.emitted().onDropdownClick[0]).toEqual([status]);
});
}, },
); );
}); });
......
import Vue from 'vue'; import { shallowMount } from '@vue/test-utils';
import { escape } from 'lodash'; import { escape } from 'lodash';
import ancestorsTree from 'ee/sidebar/components/ancestors_tree/ancestors_tree.vue'; import { GlLoadingIcon } from '@gitlab/ui';
import mountComponent from 'helpers/vue_mount_component_helper'; import AncestorsTree from 'ee/sidebar/components/ancestors_tree/ancestors_tree.vue';
describe('AncestorsTreeContainer', () => { describe('AncestorsTreeContainer', () => {
let vm; let wrapper;
const ancestors = [ const ancestors = [
{ id: 1, url: '', title: 'A', state: 'open' }, { id: 1, url: '', title: 'A', state: 'open' },
{ id: 2, url: '', title: 'B', state: 'open' }, { id: 2, url: '', title: 'B', state: 'open' },
]; ];
beforeEach(() => { const defaultProps = {
const AncestorsTreeContainer = Vue.extend(ancestorsTree); ancestors,
vm = mountComponent(AncestorsTreeContainer, { ancestors, isFetching: false }); isFetching: false,
}); };
const createComponent = (props = {}) => {
wrapper = shallowMount(AncestorsTree, {
propsData: { ...defaultProps, ...props },
});
};
afterEach(() => { 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', () => { 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', () => { it('renders tooltip with the immediate parent', () => {
expect(vm.$el.querySelector('.collapse-truncated-title').innerText.trim()).toBe( createComponent();
ancestors.slice(-1)[0].title,
); expect(findTooltip().text()).toBe(ancestors.slice(-1)[0].title);
}); });
it('does not render timeline when fetching', () => { it('does not render timeline when fetching', () => {
vm.$props.isFetching = true; createComponent({
isFetching: true,
return vm.$nextTick().then(() => {
expect(vm.$el.querySelector('.vertical-timeline')).toBeNull();
expect(vm.$el.querySelector('.value')).toBeNull();
}); });
expect(containsTimeline()).toBe(false);
expect(containsValue()).toBe(false);
}); });
it('render `None` when ancestors is an empty array', () => { it('render `None` when ancestors is an empty array', () => {
vm.$props.ancestors = []; createComponent({
ancestors: [],
return vm.$nextTick().then(() => {
expect(vm.$el.querySelector('.vertical-timeline')).toBeNull();
expect(vm.$el.querySelector('.value')).not.toBeNull();
}); });
expect(containsTimeline()).toBe(false);
expect(containsValue()).not.toBe(false);
}); });
it('render loading icon when isFetching is true', () => { it('render loading icon when isFetching is true', () => {
vm.$props.isFetching = true; createComponent({
isFetching: true,
return vm.$nextTick().then(() => {
expect(vm.$el.querySelector('.fa-spinner')).toBeDefined();
}); });
expect(wrapper.contains(GlLoadingIcon)).toBe(true);
}); });
it('escapes html in the tooltip', () => { it('escapes html in the tooltip', () => {
const title = '<script>alert(1);</script>'; const title = '<script>alert(1);</script>';
const escapedTitle = escape(title); const escapedTitle = escape(title);
vm.$props.ancestors = [{ id: 1, url: '', title, state: 'open' }]; createComponent({
ancestors: [{ id: 1, url: '', title, state: 'open' }],
return vm.$nextTick().then(() => {
const tooltip = vm.$el.querySelector('.collapse-truncated-title');
expect(tooltip.innerText).toBe(escapedTitle);
}); });
expect(findTooltip().text()).toBe(escapedTitle);
}); });
}); });
import Vue from 'vue'; import { shallowMount } from '@vue/test-utils';
import sidebarWeight from 'ee/sidebar/components/weight/sidebar_weight.vue'; import SidebarWeight from 'ee/sidebar/components/weight/sidebar_weight.vue';
import SidebarMediator from 'ee/sidebar/sidebar_mediator'; import SidebarMediator from 'ee/sidebar/sidebar_mediator';
import SidebarStore from 'ee/sidebar/stores/sidebar_store'; import SidebarStore from 'ee/sidebar/stores/sidebar_store';
import mountComponent from 'helpers/vue_mount_component_helper';
import SidebarService from '~/sidebar/services/sidebar_service'; import SidebarService from '~/sidebar/services/sidebar_service';
import eventHub from '~/sidebar/event_hub'; import eventHub from '~/sidebar/event_hub';
import Mock from './ee_mock_data'; import Mock from './ee_mock_data';
describe('Sidebar Weight', () => { describe('Sidebar Weight', () => {
let vm;
let sidebarMediator; let sidebarMediator;
let SidebarWeight; let wrapper;
const createComponent = (props = {}) => {
wrapper = shallowMount(SidebarWeight, {
propsData: { ...props },
});
};
beforeEach(() => { beforeEach(() => {
SidebarWeight = Vue.extend(sidebarWeight);
// Set up the stores, services, etc // Set up the stores, services, etc
sidebarMediator = new SidebarMediator(Mock.mediator); sidebarMediator = new SidebarMediator(Mock.mediator);
}); });
afterEach(() => { afterEach(() => {
vm.$destroy(); if (wrapper) {
wrapper.destroy();
wrapper = null;
}
SidebarService.singleton = null; SidebarService.singleton = null;
SidebarStore.singleton = null; SidebarStore.singleton = null;
SidebarMediator.singleton = null; SidebarMediator.singleton = null;
...@@ -27,7 +33,8 @@ describe('Sidebar Weight', () => { ...@@ -27,7 +33,8 @@ describe('Sidebar Weight', () => {
it('calls the mediator updateWeight on event', () => { it('calls the mediator updateWeight on event', () => {
jest.spyOn(SidebarMediator.prototype, 'updateWeight').mockReturnValue(Promise.resolve()); jest.spyOn(SidebarMediator.prototype, 'updateWeight').mockReturnValue(Promise.resolve());
vm = mountComponent(SidebarWeight, {
createComponent({
mediator: sidebarMediator, mediator: sidebarMediator,
}); });
......
import Vue from 'vue'; import { shallowMount } from '@vue/test-utils';
import weight from 'ee/sidebar/components/weight/weight.vue';
import mountComponent from 'helpers/vue_mount_component_helper';
import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper'; import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper';
import Weight from 'ee/sidebar/components/weight/weight.vue';
import eventHub from '~/sidebar/event_hub'; import eventHub from '~/sidebar/event_hub';
import { ENTER_KEY_CODE } from '~/lib/utils/keycodes'; import { ENTER_KEY_CODE } from '~/lib/utils/keycodes';
const DEFAULT_PROPS = {
weightNoneValue: 'None',
};
describe('Weight', () => { describe('Weight', () => {
let vm; let wrapper;
let Weight;
beforeEach(() => { const defaultProps = {
Weight = Vue.extend(weight); weightNoneValue: 'None',
}); };
const createComponent = (props = {}) => {
wrapper = shallowMount(Weight, {
propsData: { ...defaultProps, ...props },
});
};
afterEach(() => { 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', () => { it('shows loading spinner when fetching', () => {
vm = mountComponent(Weight, { createComponent({
...DEFAULT_PROPS,
fetching: true, fetching: true,
}); });
expect(vm.$el.querySelector('.js-weight-collapsed-loading-icon')).not.toBeNull(); expect(containsCollapsedLoadingIcon()).toBe(true);
expect(vm.$el.querySelector('.js-weight-loading-icon')).not.toBeNull(); expect(containsLoadingIcon()).toBe(true);
}); });
it('shows loading spinner when loading', () => { it('shows loading spinner when loading', () => {
vm = mountComponent(Weight, { createComponent({
...DEFAULT_PROPS,
fetching: false, fetching: false,
loading: true, loading: true,
}); });
// We show the value in the collapsed view instead of the loading icon // We show the value in the collapsed view instead of the loading icon
expect(vm.$el.querySelector('.js-weight-collapsed-loading-icon')).toBeNull(); expect(containsCollapsedLoadingIcon()).toBe(false);
expect(vm.$el.querySelector('.js-weight-loading-icon')).not.toBeNull(); expect(containsLoadingIcon()).toBe(true);
}); });
it('shows weight value', () => { it('shows weight value', () => {
const WEIGHT = 3; const expectedWeight = 3;
vm = mountComponent(Weight, {
...DEFAULT_PROPS, createComponent({
fetching: false, fetching: false,
weight: WEIGHT, weight: expectedWeight,
}); });
expect(vm.$el.querySelector('.js-weight-collapsed-weight-label').textContent.trim()).toBe( expect(findCollapsedLabel().text()).toBe(`${expectedWeight}`);
`${WEIGHT}`, expect(findLabelValue().text()).toBe(`${expectedWeight}`);
);
expect(vm.$el.querySelector('.js-weight-weight-label-value').textContent.trim()).toBe(
`${WEIGHT}`,
);
}); });
it('shows weight no-value', () => { it('shows weight no-value', () => {
const WEIGHT = null; const expectedWeight = null;
vm = mountComponent(Weight, {
...DEFAULT_PROPS, createComponent({
fetching: false, fetching: false,
weight: WEIGHT, weight: expectedWeight,
}); });
expect(vm.$el.querySelector('.js-weight-collapsed-weight-label').textContent.trim()).toBe( expect(findCollapsedLabel().text()).toBe(defaultProps.weightNoneValue);
'None', expect(findLabelNoValue().text()).toBe(defaultProps.weightNoneValue);
);
expect(vm.$el.querySelector('.js-weight-weight-label .no-value').textContent.trim()).toBe(
'None',
);
}); });
it('adds `collapse-after-update` class when clicking the collapsed block', () => { it('adds `collapse-after-update` class when clicking the collapsed block', async () => {
vm = mountComponent(Weight, { createComponent();
...DEFAULT_PROPS,
});
vm.$el.querySelector('.js-weight-collapsed-block').click(); findCollapsedBlock().trigger('click');
return vm.$nextTick().then(() => { await wrapper.vm.$nextTick;
expect(vm.$el.classList.contains('collapse-after-update')).toBe(true);
}); expect(wrapper.classes()).toContain('collapse-after-update');
}); });
it('shows dropdown on "Edit" link click', () => { it('shows dropdown on "Edit" link click', async () => {
vm = mountComponent(Weight, { createComponent({
...DEFAULT_PROPS,
editable: true, 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(() => { await wrapper.vm.$nextTick;
expect(vm.shouldShowEditField).toBe(true);
}); expect(containsEditableField()).toBe(true);
}); });
it('emits event on input submission', () => { it('emits event on input submission', async () => {
const ID = 123; const mockId = 123;
const expectedWeightValue = '3'; const expectedWeightValue = '3';
jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
vm = mountComponent(Weight, {
...DEFAULT_PROPS, createComponent({
editable: true, editable: true,
id: ID, id: mockId,
}); });
vm.$el.querySelector('.js-weight-edit-link').click(); findEditLink().trigger('click');
return vm.$nextTick(() => { await wrapper.vm.$nextTick;
const event = new CustomEvent('keydown');
event.keyCode = ENTER_KEY_CODE;
vm.$refs.editableField.click(); const event = new CustomEvent('keydown');
vm.$refs.editableField.value = expectedWeightValue; event.keyCode = ENTER_KEY_CODE;
vm.$refs.editableField.dispatchEvent(event);
expect(vm.hasValidInput).toBe(true); const { editableField } = wrapper.vm.$refs;
expect(eventHub.$emit).toHaveBeenCalledWith('updateWeight', expectedWeightValue, ID); editableField.click();
}); editableField.value = expectedWeightValue;
editableField.dispatchEvent(event);
await wrapper.vm.$nextTick;
expect(containsInputError()).toBe(false);
expect(eventHub.$emit).toHaveBeenCalledWith('updateWeight', expectedWeightValue, mockId);
}); });
it('emits event on remove weight link click', () => { it('emits event on remove weight link click', async () => {
const ID = 123; const mockId = 234;
jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
vm = mountComponent(Weight, {
...DEFAULT_PROPS, createComponent({
editable: true, editable: true,
weight: 3, weight: 3,
id: ID, id: mockId,
}); });
vm.$el.querySelector('.js-weight-remove-link').click(); findRemoveLink().trigger('click');
return vm.$nextTick(() => { await wrapper.vm.$nextTick;
expect(vm.hasValidInput).toBe(true);
expect(eventHub.$emit).toHaveBeenCalledWith('updateWeight', '', ID); expect(containsInputError()).toBe(false);
}); expect(eventHub.$emit).toHaveBeenCalledWith('updateWeight', '', mockId);
}); });
it('triggers error on invalid negative integer value', () => { it('triggers error on invalid negative integer value', async () => {
vm = mountComponent(Weight, { createComponent({
...DEFAULT_PROPS,
editable: true, editable: true,
}); });
vm.$el.querySelector('.js-weight-edit-link').click(); findEditLink().trigger('click');
return vm.$nextTick(() => { await wrapper.vm.$nextTick;
const event = new CustomEvent('keydown');
event.keyCode = ENTER_KEY_CODE;
vm.$refs.editableField.click(); const event = new CustomEvent('keydown');
vm.$refs.editableField.value = -9001; event.keyCode = ENTER_KEY_CODE;
vm.$refs.editableField.dispatchEvent(event);
expect(vm.hasValidInput).toBe(false); const { editableField } = wrapper.vm.$refs;
}); editableField.click();
editableField.value = -9001;
editableField.dispatchEvent(event);
await wrapper.vm.$nextTick;
expect(containsInputError()).toBe(true);
}); });
describe('tracking', () => { describe('tracking', () => {
let trackingSpy; let trackingSpy;
beforeEach(() => { beforeEach(() => {
vm = mountComponent(Weight, { createComponent({
...DEFAULT_PROPS,
editable: true, editable: true,
}); });
trackingSpy = mockTracking('_category_', vm.$el, (obj, what) =>
trackingSpy = mockTracking('_category_', wrapper.element, (obj, what) =>
jest.spyOn(obj, what).mockImplementation(() => {}), jest.spyOn(obj, what).mockImplementation(() => {}),
); );
}); });
...@@ -184,12 +190,12 @@ describe('Weight', () => { ...@@ -184,12 +190,12 @@ describe('Weight', () => {
unmockTracking(); unmockTracking();
}); });
it('calls trackEvent when "Edit" is clicked', () => { it('calls trackEvent when "Edit" is clicked', async () => {
triggerEvent(vm.$el.querySelector('.js-weight-edit-link')); triggerEvent(findEditLink().element);
return vm.$nextTick().then(() => { await wrapper.vm.$nextTick;
expect(trackingSpy).toHaveBeenCalled();
}); expect(trackingSpy).toHaveBeenCalled();
}); });
}); });
}); });
...@@ -3,5 +3,11 @@ ...@@ -3,5 +3,11 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe GitlabSchema.types['Milestone'] do 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 end
...@@ -54,7 +54,7 @@ RSpec.describe Resolvers::TimeboxBurnupTimeSeriesResolver do ...@@ -54,7 +54,7 @@ RSpec.describe Resolvers::TimeboxBurnupTimeSeriesResolver do
context 'when the service returns an error' do context 'when the service returns an error' do
before do before do
stub_const('TimeboxBurnupChartService::EVENT_COUNT_LIMIT', 1) stub_const('TimeboxReportService::EVENT_COUNT_LIMIT', 1)
end end
it 'raises a GraphQL exception' do 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 ...@@ -10,7 +10,7 @@ RSpec.describe GitlabSchema.types['Iteration'] do
it 'has the expected fields' do it 'has the expected fields' do
expected_fields = %w[ expected_fields = %w[
id id title description state web_path web_url scoped_path scoped_url 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 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| ...@@ -31,7 +31,7 @@ RSpec.shared_examples 'timebox chart' do |timebox_type|
end end
it 'returns an error when the number of events exceeds the limit' do 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[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) 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| ...@@ -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) create(:resource_state_event, issue: issues[3], state: :closed, created_at: timebox_start_date - 6.days)
expect(response.success?).to eq(true) expect(response.success?).to eq(true)
expect(response.payload).to eq([ expect(response.payload[:burnup_time_series]).to eq([
{ {
date: timebox_start_date, date: timebox_start_date,
scope_count: 4, scope_count: 4,
...@@ -98,7 +98,7 @@ RSpec.shared_examples 'timebox chart' do |timebox_type| ...@@ -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) 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.success?).to eq(true)
expect(response.payload).to eq([ expect(response.payload[:burnup_time_series]).to eq([
{ {
date: timebox_start_date + 4.days, date: timebox_start_date + 4.days,
scope_count: 2, scope_count: 2,
...@@ -159,7 +159,7 @@ RSpec.shared_examples 'timebox chart' do |timebox_type| ...@@ -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) create(:resource_state_event, issue: issues[1], state: :closed, created_at: timebox_start_date + 9.days)
expect(response.success?).to eq(true) expect(response.success?).to eq(true)
expect(response.payload).to eq([ expect(response.payload[:burnup_time_series]).to eq([
{ {
date: timebox_start_date, date: timebox_start_date,
scope_count: 1, scope_count: 1,
...@@ -230,7 +230,7 @@ RSpec.shared_examples 'timebox chart' do |timebox_type| ...@@ -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) create(:resource_weight_event, issue: issues[0], weight: 10, created_at: timebox_start_date + 5.days)
expect(response.success?).to eq(true) expect(response.success?).to eq(true)
expect(response.payload).to eq([ expect(response.payload[:burnup_time_series]).to eq([
{ {
date: timebox_start_date, date: timebox_start_date,
scope_count: 1, scope_count: 1,
...@@ -271,7 +271,7 @@ RSpec.shared_examples 'timebox chart' do |timebox_type| ...@@ -271,7 +271,7 @@ RSpec.shared_examples 'timebox chart' do |timebox_type|
end end
end end
RSpec.describe TimeboxBurnupChartService do RSpec.describe TimeboxReportService do
let_it_be(:group) { create(:group) } let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) } let_it_be(:project) { create(:project, group: group) }
let_it_be(:timebox_start_date) { Date.today } let_it_be(:timebox_start_date) { Date.today }
......
...@@ -8319,6 +8319,9 @@ msgstr "" ...@@ -8319,6 +8319,9 @@ msgstr ""
msgid "DastProfiles|Authentication URL" msgid "DastProfiles|Authentication URL"
msgstr "" 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." msgid "DastProfiles|Could not create site validation token. Please refresh the page, or try again later."
msgstr "" msgstr ""
...@@ -8385,6 +8388,9 @@ msgstr "" ...@@ -8385,6 +8388,9 @@ msgstr ""
msgid "DastProfiles|Error Details" msgid "DastProfiles|Error Details"
msgstr "" msgstr ""
msgid "DastProfiles|Header validation"
msgstr ""
msgid "DastProfiles|Hide debug messages" msgid "DastProfiles|Hide debug messages"
msgstr "" msgstr ""
...@@ -8469,9 +8475,15 @@ msgstr "" ...@@ -8469,9 +8475,15 @@ msgstr ""
msgid "DastProfiles|Step 1 - Choose site validation method" msgid "DastProfiles|Step 1 - Choose site validation method"
msgstr "" msgstr ""
msgid "DastProfiles|Step 2 - Add following HTTP header to your site"
msgstr ""
msgid "DastProfiles|Step 2 - Add following text to the target site" msgid "DastProfiles|Step 2 - Add following text to the target site"
msgstr "" msgstr ""
msgid "DastProfiles|Step 3 - Confirm header location and validate"
msgstr ""
msgid "DastProfiles|Step 3 - Confirm text file location and validate" msgid "DastProfiles|Step 3 - Confirm text file location and validate"
msgstr "" msgstr ""
...@@ -8508,7 +8520,7 @@ msgstr "" ...@@ -8508,7 +8520,7 @@ msgstr ""
msgid "DastProfiles|Validating..." msgid "DastProfiles|Validating..."
msgstr "" 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 "" msgstr ""
msgid "DastProfiles|Validation failed. Please try again." msgid "DastProfiles|Validation failed. Please try again."
...@@ -14507,7 +14519,7 @@ msgstr "" ...@@ -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." msgid "Integrations|Issues created in Jira are shown here once you have created the issues in project setup in Jira."
msgstr "" 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 "" msgstr ""
msgid "Integrations|Return to GitLab for Jira" msgid "Integrations|Return to GitLab for Jira"
...@@ -14732,6 +14744,9 @@ msgstr "" ...@@ -14732,6 +14744,9 @@ msgstr ""
msgid "InviteMembersModal|Choose a role permission" msgid "InviteMembersModal|Choose a role permission"
msgstr "" msgstr ""
msgid "InviteMembersModal|Close invite team members"
msgstr ""
msgid "InviteMembersModal|GitLab member or Email address" msgid "InviteMembersModal|GitLab member or Email address"
msgstr "" msgstr ""
...@@ -14741,13 +14756,13 @@ msgstr "" ...@@ -14741,13 +14756,13 @@ msgstr ""
msgid "InviteMembersModal|Invite team members" msgid "InviteMembersModal|Invite team members"
msgstr "" msgstr ""
msgid "InviteMembersModal|Search for members to invite" msgid "InviteMembersModal|Members were successfully added"
msgstr "" msgstr ""
msgid "InviteMembersModal|User not invited. Feature coming soon!" msgid "InviteMembersModal|Search for members to invite"
msgstr "" msgstr ""
msgid "InviteMembersModal|Users were succesfully added" msgid "InviteMembersModal|Some of the members could not be added"
msgstr "" msgstr ""
msgid "InviteMembersModal|You're inviting members to the %{group_name} group" msgid "InviteMembersModal|You're inviting members to the %{group_name} group"
......
...@@ -2,11 +2,8 @@ ...@@ -2,11 +2,8 @@
module QA module QA
RSpec.describe 'Verify' do RSpec.describe 'Verify' do
describe 'Run pipeline', :requires_admin, :skip_live_env do describe 'Run pipeline', only: { subdomain: :staging } do
# [TODO]: Developer to remove :requires_admin and :skip_live_env once FF is removed in https://gitlab.com/gitlab-org/gitlab/-/issues/229632
context 'with web only rule' do context 'with web only rule' do
let(:feature_flag) { :new_pipeline_form }
let(:job_name) { 'test_job' } let(:job_name) { 'test_job' }
let(:project) do let(:project) do
Resource::Project.fabricate_via_api! do |project| Resource::Project.fabricate_via_api! do |project|
...@@ -20,33 +17,29 @@ module QA ...@@ -20,33 +17,29 @@ module QA
commit.commit_message = 'Add .gitlab-ci.yml' commit.commit_message = 'Add .gitlab-ci.yml'
commit.add_files( commit.add_files(
[ [
{ {
file_path: '.gitlab-ci.yml', file_path: '.gitlab-ci.yml',
content: <<~YAML content: <<~YAML
#{job_name}: #{job_name}:
tags: tags:
- #{project.name} - #{project.name}
script: echo 'OK' script: echo 'OK'
only: only:
- web - web
YAML
} YAML
}
] ]
) )
end end
end end
before do before do
Runtime::Feature.enable(feature_flag) # [TODO]: Developer to remove when feature flag is removed
Flow::Login.sign_in Flow::Login.sign_in
project.visit! project.visit!
Page::Project::Menu.perform(&:click_ci_cd_pipelines) Page::Project::Menu.perform(&:click_ci_cd_pipelines)
end 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 it 'can trigger pipeline', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/946' do
Page::Project::Pipeline::Index.perform do |index| Page::Project::Pipeline::Index.perform do |index|
expect(index).not_to have_pipeline # should not auto trigger pipeline expect(index).not_to have_pipeline # should not auto trigger pipeline
......
...@@ -5,11 +5,13 @@ require 'spec_helper' ...@@ -5,11 +5,13 @@ require 'spec_helper'
RSpec.describe 'User page' do RSpec.describe 'User page' do
include ExternalAuthorizationServiceHelpers 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 context 'with public profile' do
it 'shows all the tabs' do it 'shows all the tabs' do
visit(user_path(user)) subject
page.within '.nav-links' do page.within '.nav-links' do
expect(page).to have_link('Overview') expect(page).to have_link('Overview')
...@@ -22,14 +24,12 @@ RSpec.describe 'User page' do ...@@ -22,14 +24,12 @@ RSpec.describe 'User page' do
end end
it 'does not show private profile message' do 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") expect(page).not_to have_content("This user has a private profile")
end end
context 'work information' do context 'work information' do
subject { visit(user_path(user)) }
it 'shows job title and organization details' do it 'shows job title and organization details' do
user.update(organization: 'GitLab - work info test', job_title: 'Frontend Engineer') user.update(organization: 'GitLab - work info test', job_title: 'Frontend Engineer')
...@@ -57,24 +57,24 @@ RSpec.describe 'User page' do ...@@ -57,24 +57,24 @@ RSpec.describe 'User page' do
end end
context 'with private profile' do 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 it 'shows no tab' do
visit(user_path(user)) subject
expect(page).to have_css("div.profile-header") expect(page).to have_css("div.profile-header")
expect(page).not_to have_css("ul.nav-links") expect(page).not_to have_css("ul.nav-links")
end end
it 'shows private profile message' do it 'shows private profile message' do
visit(user_path(user)) subject
expect(page).to have_content("This user has a private profile") expect(page).to have_content("This user has a private profile")
end end
it 'shows own tabs' do it 'shows own tabs' do
sign_in(user) sign_in(user)
visit(user_path(user)) subject
page.within '.nav-links' do page.within '.nav-links' do
expect(page).to have_link('Overview') expect(page).to have_link('Overview')
...@@ -88,36 +88,36 @@ RSpec.describe 'User page' do ...@@ -88,36 +88,36 @@ RSpec.describe 'User page' do
end end
context 'with blocked profile' do context 'with blocked profile' do
let(:user) { create(:user, state: :blocked) } let_it_be(:user) { create(:user, state: :blocked) }
it 'shows no tab' do it 'shows no tab' do
visit(user_path(user)) subject
expect(page).to have_css("div.profile-header") expect(page).to have_css("div.profile-header")
expect(page).not_to have_css("ul.nav-links") expect(page).not_to have_css("ul.nav-links")
end end
it 'shows blocked message' do it 'shows blocked message' do
visit(user_path(user)) subject
expect(page).to have_content("This user is blocked") expect(page).to have_content("This user is blocked")
end end
it 'shows user name as blocked' do it 'shows user name as blocked' do
visit(user_path(user)) subject
expect(page).to have_css(".cover-title", text: 'Blocked user') expect(page).to have_css(".cover-title", text: 'Blocked user')
end end
it 'shows no additional fields' do 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-user-bio")
expect(page).not_to have_css(".profile-link-holder") expect(page).not_to have_css(".profile-link-holder")
end end
it 'shows username' do it 'shows username' do
visit(user_path(user)) subject
expect(page).to have_content("@#{user.username}") expect(page).to have_content("@#{user.username}")
end end
...@@ -126,7 +126,7 @@ RSpec.describe 'User page' do ...@@ -126,7 +126,7 @@ RSpec.describe 'User page' do
it 'shows the status if there was one' do it 'shows the status if there was one' do
create(:user_status, user: user, message: "Working hard!") create(:user_status, user: user, message: "Working hard!")
visit(user_path(user)) subject
expect(page).to have_content("Working hard!") expect(page).to have_content("Working hard!")
end end
...@@ -135,7 +135,7 @@ RSpec.describe 'User page' do ...@@ -135,7 +135,7 @@ RSpec.describe 'User page' do
it 'shows the sign in link' do it 'shows the sign in link' do
stub_application_setting(signup_enabled: false) stub_application_setting(signup_enabled: false)
visit(user_path(user)) subject
page.within '.navbar-nav' do page.within '.navbar-nav' do
expect(page).to have_link('Sign in') expect(page).to have_link('Sign in')
...@@ -147,7 +147,7 @@ RSpec.describe 'User page' do ...@@ -147,7 +147,7 @@ RSpec.describe 'User page' do
it 'shows the sign in and register link' do it 'shows the sign in and register link' do
stub_application_setting(signup_enabled: true) stub_application_setting(signup_enabled: true)
visit(user_path(user)) subject
page.within '.navbar-nav' do page.within '.navbar-nav' do
expect(page).to have_link('Sign in / Register') expect(page).to have_link('Sign in / Register')
...@@ -157,7 +157,7 @@ RSpec.describe 'User page' do ...@@ -157,7 +157,7 @@ RSpec.describe 'User page' do
context 'most recent activity' do context 'most recent activity' do
it 'shows the most recent activity' do it 'shows the most recent activity' do
visit(user_path(user)) subject
expect(page).to have_content('Most Recent Activity') expect(page).to have_content('Most Recent Activity')
end end
...@@ -168,7 +168,7 @@ RSpec.describe 'User page' do ...@@ -168,7 +168,7 @@ RSpec.describe 'User page' do
end end
it 'hides the most recent activity' do it 'hides the most recent activity' do
visit(user_path(user)) subject
expect(page).not_to have_content('Most Recent Activity') expect(page).not_to have_content('Most Recent Activity')
end end
...@@ -177,14 +177,14 @@ RSpec.describe 'User page' do ...@@ -177,14 +177,14 @@ RSpec.describe 'User page' do
context 'page description' do context 'page description' do
before do before do
visit(user_path(user)) subject
end end
it_behaves_like 'page meta description', 'Lorem ipsum dolor sit amet' it_behaves_like 'page meta description', 'Lorem ipsum dolor sit amet'
end end
context 'with a bot user' do 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 describe 'feature flag enabled' do
before do before do
...@@ -192,7 +192,7 @@ RSpec.describe 'User page' do ...@@ -192,7 +192,7 @@ RSpec.describe 'User page' do
end end
it 'only shows Overview and Activity tabs' do it 'only shows Overview and Activity tabs' do
visit(user_path(user)) subject
page.within '.nav-links' do page.within '.nav-links' do
expect(page).to have_link('Overview') expect(page).to have_link('Overview')
...@@ -211,7 +211,7 @@ RSpec.describe 'User page' do ...@@ -211,7 +211,7 @@ RSpec.describe 'User page' do
end end
it 'only shows Overview and Activity tabs' do it 'only shows Overview and Activity tabs' do
visit(user_path(user)) subject
page.within '.nav-links' do page.within '.nav-links' do
expect(page).to have_link('Overview') expect(page).to have_link('Overview')
...@@ -224,4 +224,24 @@ RSpec.describe 'User page' do ...@@ -224,4 +224,24 @@ RSpec.describe 'User page' do
end end
end 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 end
...@@ -24,10 +24,10 @@ exports[`AlertsSettingsFormOld with default values renders the initial template ...@@ -24,10 +24,10 @@ exports[`AlertsSettingsFormOld with default values renders the initial template
</span> </span>
</gl-form-group-stub> </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-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-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. 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-modal-stub>
</gl-form-group-stub> </gl-form-group-stub>
......
...@@ -70,16 +70,70 @@ describe('AlertsSettingsFormNew', () => { ...@@ -70,16 +70,70 @@ describe('AlertsSettingsFormNew', () => {
}); });
}); });
describe('when form is invalid', () => { describe('submitting integration form', () => {
// TODO, implement specs for when form is invalid it('allows for create-new-integration with the correct form values for HTTP', async () => {
}); createComponent({});
describe('when form is valid', () => { const options = findSelect().findAll('option');
beforeEach(() => { 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 } },
]);
});
it('allows for create-new-integration with the correct form values for PROMETHEUS', async () => {
createComponent({}); 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 on-create-new-integration with the correct form values for HTTP', async () => { it('allows for update-integration with the correct form values for HTTP', async () => {
createComponent({
props: {
currentIntegration: { id: '1' },
loading: false,
},
});
const options = findSelect().findAll('option'); const options = findSelect().findAll('option');
await options.at(1).setSelected(); await options.at(1).setSelected();
...@@ -97,13 +151,20 @@ describe('AlertsSettingsFormNew', () => { ...@@ -97,13 +151,20 @@ describe('AlertsSettingsFormNew', () => {
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
expect(wrapper.emitted('on-create-new-integration')).toBeTruthy(); expect(wrapper.emitted('update-integration')).toBeTruthy();
expect(wrapper.emitted('on-create-new-integration')[0]).toEqual([ expect(wrapper.emitted('update-integration')[0]).toEqual([
{ type: typeSet.http, variables: { name: 'Test integration', active: true } }, { 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'); const options = findSelect().findAll('option');
await options.at(2).setSelected(); await options.at(2).setSelected();
...@@ -124,8 +185,8 @@ describe('AlertsSettingsFormNew', () => { ...@@ -124,8 +185,8 @@ describe('AlertsSettingsFormNew', () => {
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
expect(wrapper.emitted('on-create-new-integration')).toBeTruthy(); expect(wrapper.emitted('update-integration')).toBeTruthy();
expect(wrapper.emitted('on-create-new-integration')[0]).toEqual([ expect(wrapper.emitted('update-integration')[0]).toEqual([
{ type: typeSet.prometheus, variables: { apiUrl: 'https://test.com', active: true } }, { type: typeSet.prometheus, variables: { apiUrl: 'https://test.com', active: true } },
]); ]);
}); });
......
...@@ -69,7 +69,7 @@ describe('AlertsSettingsFormOld', () => { ...@@ -69,7 +69,7 @@ describe('AlertsSettingsFormOld', () => {
createComponent( createComponent(
{}, {},
{ {
authKey: 'newToken', token: 'newToken',
}, },
); );
......
...@@ -6,14 +6,24 @@ import AlertsSettingsFormNew from '~/alerts_settings/components/alerts_settings_ ...@@ -6,14 +6,24 @@ import AlertsSettingsFormNew from '~/alerts_settings/components/alerts_settings_
import IntegrationsList from '~/alerts_settings/components/alerts_integrations_list.vue'; import IntegrationsList from '~/alerts_settings/components/alerts_integrations_list.vue';
import createHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql'; 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 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 createFlash from '~/flash';
import { defaultAlertSettingsConfig } from './util'; import { defaultAlertSettingsConfig } from './util';
import mockIntegrations from './mocks/integrations.json'; import mockIntegrations from './mocks/integrations.json';
import {
createHttpVariables,
updateHttpVariables,
createPrometheusVariables,
updatePrometheusVariables,
ID,
} from './mocks/apollo_mock';
jest.mock('~/flash'); jest.mock('~/flash');
const projectPath = '';
describe('AlertsSettingsWrapper', () => { describe('AlertsSettingsWrapper', () => {
let wrapper; let wrapper;
...@@ -80,7 +90,7 @@ describe('AlertsSettingsWrapper', () => { ...@@ -80,7 +90,7 @@ describe('AlertsSettingsWrapper', () => {
it('renders the IntegrationsList table using the API data', () => { it('renders the IntegrationsList table using the API data', () => {
createComponent({ createComponent({
data: { integrations: { list: mockIntegrations } }, data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
provide: { glFeatures: { httpIntegrationsList: true } }, provide: { glFeatures: { httpIntegrationsList: true } },
loading: false, loading: false,
}); });
...@@ -100,7 +110,7 @@ describe('AlertsSettingsWrapper', () => { ...@@ -100,7 +110,7 @@ describe('AlertsSettingsWrapper', () => {
it('calls `$apollo.mutate` with `createHttpIntegrationMutation`', () => { it('calls `$apollo.mutate` with `createHttpIntegrationMutation`', () => {
createComponent({ createComponent({
data: { integrations: { list: mockIntegrations } }, data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
provide: { glFeatures: { httpIntegrationsList: true } }, provide: { glFeatures: { httpIntegrationsList: true } },
loading: false, loading: false,
}); });
...@@ -108,26 +118,66 @@ describe('AlertsSettingsWrapper', () => { ...@@ -108,26 +118,66 @@ describe('AlertsSettingsWrapper', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({
data: { createHttpIntegrationMutation: { integration: { id: '1' } } }, data: { createHttpIntegrationMutation: { integration: { id: '1' } } },
}); });
wrapper.find(AlertsSettingsFormNew).vm.$emit('on-create-new-integration', { wrapper.find(AlertsSettingsFormNew).vm.$emit('create-new-integration', {
type: 'HTTP', type: typeSet.http,
variables: { name: 'Test 1', active: true }, variables: createHttpVariables,
}); });
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1); expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1);
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: createHttpIntegrationMutation, mutation: createHttpIntegrationMutation,
update: expect.anything(), 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: { variables: {
name: 'Test 1', id: ID,
active: true,
projectPath,
}, },
}); });
}); });
it('calls `$apollo.mutate` with `createPrometheusIntegrationMutation`', () => { it('calls `$apollo.mutate` with `createPrometheusIntegrationMutation`', () => {
createComponent({ createComponent({
data: { integrations: { list: mockIntegrations } }, data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
provide: { glFeatures: { httpIntegrationsList: true } }, provide: { glFeatures: { httpIntegrationsList: true } },
loading: false, loading: false,
}); });
...@@ -135,33 +185,107 @@ describe('AlertsSettingsWrapper', () => { ...@@ -135,33 +185,107 @@ describe('AlertsSettingsWrapper', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({
data: { createPrometheusIntegrationMutation: { integration: { id: '2' } } }, data: { createPrometheusIntegrationMutation: { integration: { id: '2' } } },
}); });
wrapper.find(AlertsSettingsFormNew).vm.$emit('on-create-new-integration', { wrapper.find(AlertsSettingsFormNew).vm.$emit('create-new-integration', {
type: 'PROMETHEUS', type: typeSet.prometheus,
variables: { apiUrl: 'https://test.com', active: true }, variables: createPrometheusVariables,
}); });
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1); expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1);
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: createPrometheusIntegrationMutation, mutation: createPrometheusIntegrationMutation,
update: expect.anything(), 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: { variables: {
apiUrl: 'https://test.com', id: ID,
active: true,
projectPath,
}, },
}); });
}); });
it('shows error alert when integration creation fails ', () => { it('shows error alert when integration creation fails ', async () => {
const errorMsg = 'Something went wrong'; const errorMsg = 'Something went wrong';
createComponent({ 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 } }, provide: { glFeatures: { httpIntegrationsList: true } },
loading: false, loading: false,
}); });
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(errorMsg); 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(() => { setImmediate(() => {
expect(createFlash).toHaveBeenCalledWith({ message: errorMsg }); 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,
};
...@@ -14,41 +14,7 @@ exports[`Design management upload button component renders inverted upload desig ...@@ -14,41 +14,7 @@ exports[`Design management upload button component renders inverted upload desig
> >
Upload designs 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> </gl-button-stub>
<input <input
...@@ -73,8 +39,7 @@ exports[`Design management upload button component renders upload design button ...@@ -73,8 +39,7 @@ exports[`Design management upload button component renders upload design button
> >
Upload designs Upload designs
<!---->
</gl-button-stub> </gl-button-stub>
<input <input
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui';
import UploadButton from '~/design_management/components/upload/button.vue'; import UploadButton from '~/design_management/components/upload/button.vue';
describe('Design management upload button component', () => { describe('Design management upload button component', () => {
let wrapper; let wrapper;
function createComponent(isSaving = false, isInverted = false) { function createComponent({ isSaving = false, isInverted = false } = {}) {
wrapper = shallowMount(UploadButton, { wrapper = shallowMount(UploadButton, {
propsData: { propsData: {
isSaving, isSaving,
...@@ -24,15 +25,19 @@ describe('Design management upload button component', () => { ...@@ -24,15 +25,19 @@ describe('Design management upload button component', () => {
}); });
it('renders inverted upload design button', () => { it('renders inverted upload design button', () => {
createComponent(false, true); createComponent({ isInverted: true });
expect(wrapper.element).toMatchSnapshot(); expect(wrapper.element).toMatchSnapshot();
}); });
it('renders loading icon', () => { describe('when `isSaving` prop is `true`', () => {
createComponent(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', () => { describe('onFileUploadChange', () => {
......
...@@ -6,6 +6,7 @@ import { mockTracking, triggerEvent } from 'helpers/tracking_helper'; ...@@ -6,6 +6,7 @@ import { mockTracking, triggerEvent } from 'helpers/tracking_helper';
import DiffFileHeader from '~/diffs/components/diff_file_header.vue'; import DiffFileHeader from '~/diffs/components/diff_file_header.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.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 diffDiscussionsMockData from '../mock_data/diff_discussions';
import { truncateSha } from '~/lib/utils/text_utility'; import { truncateSha } from '~/lib/utils/text_utility';
import { diffViewerModes } from '~/ide/constants'; import { diffViewerModes } from '~/ide/constants';
...@@ -207,6 +208,14 @@ describe('DiffFileHeader component', () => { ...@@ -207,6 +208,14 @@ describe('DiffFileHeader component', () => {
}); });
expect(findFileActions().exists()).toBe(false); expect(findFileActions().exists()).toBe(false);
}); });
it('renders submodule icon', () => {
createComponent({
diffFile: submoduleDiffFile,
});
expect(wrapper.find(FileIcon).props('submodule')).toBe(true);
});
}); });
describe('for any file', () => { describe('for any file', () => {
......
...@@ -34,7 +34,7 @@ describe('ConfirmationModal', () => { ...@@ -34,7 +34,7 @@ describe('ConfirmationModal', () => {
'Saving will update the default settings for all projects that are not using custom settings.', 'Saving will update the default settings for all projects that are not using custom settings.',
); );
expect(findGlModal().text()).toContain( 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 ...@@ -9,6 +9,7 @@ import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_field
import JiraIssuesFields from '~/integrations/edit/components/jira_issues_fields.vue'; import JiraIssuesFields from '~/integrations/edit/components/jira_issues_fields.vue';
import TriggerFields from '~/integrations/edit/components/trigger_fields.vue'; import TriggerFields from '~/integrations/edit/components/trigger_fields.vue';
import DynamicField from '~/integrations/edit/components/dynamic_field.vue'; import DynamicField from '~/integrations/edit/components/dynamic_field.vue';
import { integrationLevels } from '~/integrations/edit/constants';
describe('IntegrationForm', () => { describe('IntegrationForm', () => {
let wrapper; let wrapper;
...@@ -69,14 +70,24 @@ describe('IntegrationForm', () => { ...@@ -69,14 +70,24 @@ describe('IntegrationForm', () => {
describe('integrationLevel is instance', () => { describe('integrationLevel is instance', () => {
it('renders ConfirmationModal', () => { it('renders ConfirmationModal', () => {
createComponent({ createComponent({
integrationLevel: 'instance', integrationLevel: integrationLevels.INSTANCE,
}); });
expect(findConfirmationModal().exists()).toBe(true); 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', () => { it('does not render ConfirmationModal', () => {
createComponent({ createComponent({
integrationLevel: 'project', integrationLevel: 'project',
......
...@@ -9,7 +9,7 @@ const accessLevels = { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, O ...@@ -9,7 +9,7 @@ const accessLevels = { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, O
const defaultAccessLevel = '10'; const defaultAccessLevel = '10';
const helpLink = 'https://example.com'; const helpLink = 'https://example.com';
const createComponent = () => { const createComponent = (data = {}) => {
return shallowMount(InviteMembersModal, { return shallowMount(InviteMembersModal, {
propsData: { propsData: {
groupId, groupId,
...@@ -18,9 +18,14 @@ const createComponent = () => { ...@@ -18,9 +18,14 @@ const createComponent = () => {
defaultAccessLevel, defaultAccessLevel,
helpLink, helpLink,
}, },
data() {
return data;
},
stubs: { stubs: {
GlSprintf,
'gl-modal': '<div><slot name="modal-footer"></slot><slot></slot></div>', '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', () => { ...@@ -34,7 +39,7 @@ describe('InviteMembersModal', () => {
}); });
const findDropdown = () => wrapper.find(GlDropdown); const findDropdown = () => wrapper.find(GlDropdown);
const findDropdownItems = () => wrapper.findAll(GlDropdownItem); const findDropdownItems = () => findDropdown().findAll(GlDropdownItem);
const findDatepicker = () => wrapper.find(GlDatepicker); const findDatepicker = () => wrapper.find(GlDatepicker);
const findLink = () => wrapper.find(GlLink); const findLink = () => wrapper.find(GlLink);
const findCancelButton = () => wrapper.find({ ref: 'cancelButton' }); const findCancelButton = () => wrapper.find({ ref: 'cancelButton' });
...@@ -88,25 +93,69 @@ describe('InviteMembersModal', () => { ...@@ -88,25 +93,69 @@ describe('InviteMembersModal', () => {
format: 'json', format: 'json',
}; };
beforeEach(() => { describe('when the invite was sent successfully', () => {
wrapper = createComponent(); beforeEach(() => {
wrapper = createComponent();
wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'inviteGroupMember').mockResolvedValue({ data: postData });
jest.spyOn(Api, 'inviteGroupMember').mockResolvedValue({ data: postData }); wrapper.vm.submitForm(postData);
wrapper.vm.$toast = { show: jest.fn() }; });
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);
});
}); });
it('calls Api inviteGroupMember with the correct params', () => { describe('when sending the invite for a single member returned an api error', () => {
expect(Api.inviteGroupMember).toHaveBeenCalledWith(groupId, postData); const apiErrorMessage = 'Members already exists';
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(
apiErrorMessage,
wrapper.vm.toastOptions,
);
});
}); });
describe('when the invite was sent successfully', () => { describe('when sending the invite for multiple members returned any error', () => {
const toastMessageSuccessful = 'Users were succesfully added'; const genericErrorMessage = 'Some of the members could not be added';
it('displays the successful toastMessage', () => { 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( expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(
toastMessageSuccessful, genericErrorMessage,
wrapper.vm.toastOptions, 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'; ...@@ -3,6 +3,7 @@ import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import FileRow from '~/vue_shared/components/file_row.vue'; import FileRow from '~/vue_shared/components/file_row.vue';
import FileHeader from '~/vue_shared/components/file_row_header.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'; import { escapeFileUrl } from '~/lib/utils/url_utility';
describe('File row component', () => { describe('File row component', () => {
...@@ -151,4 +152,18 @@ describe('File row component', () => { ...@@ -151,4 +152,18 @@ describe('File row component', () => {
expect(wrapper.find('.file-row-name').classes()).toContain('font-weight-bold'); 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