Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
e4fc62c0af
commit
c1ea2a9164
82 changed files with 2384 additions and 678 deletions
|
@ -5,8 +5,16 @@ import { deprecatedCreateFlash as createFlash } from '~/flash';
|
|||
import Poll from '~/lib/utils/poll';
|
||||
import { visitUrl } from '~/lib/utils/url_utility';
|
||||
import { __, s__, sprintf } from '~/locale';
|
||||
import { IssuableStatus, IssuableStatusText, IssuableType } from '../constants';
|
||||
import {
|
||||
IssuableStatus,
|
||||
IssuableStatusText,
|
||||
IssuableType,
|
||||
IssueTypePath,
|
||||
IncidentTypePath,
|
||||
IncidentType,
|
||||
} from '../constants';
|
||||
import eventHub from '../event_hub';
|
||||
import getIssueStateQuery from '../queries/get_issue_state.query.graphql';
|
||||
import Service from '../services/index';
|
||||
import Store from '../stores';
|
||||
import descriptionComponent from './description.vue';
|
||||
|
@ -195,8 +203,14 @@ export default {
|
|||
showForm: false,
|
||||
templatesRequested: false,
|
||||
isStickyHeaderShowing: false,
|
||||
issueState: {},
|
||||
};
|
||||
},
|
||||
apollo: {
|
||||
issueState: {
|
||||
query: getIssueStateQuery,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
issuableTemplates() {
|
||||
return this.store.formState.issuableTemplates;
|
||||
|
@ -288,7 +302,7 @@ export default {
|
|||
methods: {
|
||||
handleBeforeUnloadEvent(e) {
|
||||
const event = e;
|
||||
if (this.showForm && this.issueChanged) {
|
||||
if (this.showForm && this.issueChanged && !this.issueState.isDirty) {
|
||||
event.returnValue = __('Are you sure you want to lose your issue information?');
|
||||
}
|
||||
return undefined;
|
||||
|
@ -346,14 +360,32 @@ export default {
|
|||
},
|
||||
|
||||
updateIssuable() {
|
||||
const {
|
||||
store: { formState },
|
||||
issueState,
|
||||
} = this;
|
||||
const issuablePayload = issueState.isDirty
|
||||
? { ...formState, issue_type: issueState.issueType }
|
||||
: formState;
|
||||
this.clearFlash();
|
||||
return this.service
|
||||
.updateIssuable(this.store.formState)
|
||||
.updateIssuable(issuablePayload)
|
||||
.then((res) => res.data)
|
||||
.then((data) => {
|
||||
if (!window.location.pathname.includes(data.web_url)) {
|
||||
if (
|
||||
!window.location.pathname.includes(data.web_url) &&
|
||||
issueState.issueType !== IncidentType
|
||||
) {
|
||||
visitUrl(data.web_url);
|
||||
}
|
||||
|
||||
if (issueState.isDirty) {
|
||||
const URI =
|
||||
issueState.issueType === IncidentType
|
||||
? data.web_url.replace(IssueTypePath, IncidentTypePath)
|
||||
: data.web_url;
|
||||
visitUrl(URI);
|
||||
}
|
||||
})
|
||||
.then(this.updateStoreState)
|
||||
.then(() => {
|
||||
|
|
|
@ -1,17 +1,24 @@
|
|||
<script>
|
||||
import { GlButton } from '@gitlab/ui';
|
||||
import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui';
|
||||
import { uniqueId } from 'lodash';
|
||||
import { __, sprintf } from '~/locale';
|
||||
import eventHub from '../event_hub';
|
||||
import updateMixin from '../mixins/update';
|
||||
import getIssueStateQuery from '../queries/get_issue_state.query.graphql';
|
||||
|
||||
const issuableTypes = {
|
||||
issue: __('Issue'),
|
||||
epic: __('Epic'),
|
||||
incident: __('Incident'),
|
||||
};
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GlButton,
|
||||
GlModal,
|
||||
},
|
||||
directives: {
|
||||
GlModal: GlModalDirective,
|
||||
},
|
||||
mixins: [updateMixin],
|
||||
props: {
|
||||
|
@ -36,19 +43,56 @@ export default {
|
|||
data() {
|
||||
return {
|
||||
deleteLoading: false,
|
||||
skipApollo: false,
|
||||
issueState: {},
|
||||
modalId: uniqueId('delete-issuable-modal-'),
|
||||
};
|
||||
},
|
||||
apollo: {
|
||||
issueState: {
|
||||
query: getIssueStateQuery,
|
||||
skip() {
|
||||
return this.skipApollo;
|
||||
},
|
||||
result() {
|
||||
this.skipApollo = true;
|
||||
},
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
deleteIssuableButtonText() {
|
||||
return sprintf(__('Delete %{issuableType}'), {
|
||||
issuableType: this.typeToShow.toLowerCase(),
|
||||
});
|
||||
},
|
||||
deleteIssuableModalText() {
|
||||
return this.issuableType === 'epic'
|
||||
? __('Delete this epic and all descendants?')
|
||||
: sprintf(__('%{issuableType} will be removed! Are you sure?'), {
|
||||
issuableType: this.typeToShow,
|
||||
});
|
||||
},
|
||||
isSubmitEnabled() {
|
||||
return this.formState.title.trim() !== '';
|
||||
},
|
||||
modalActionProps() {
|
||||
return {
|
||||
primary: {
|
||||
text: this.deleteIssuableButtonText,
|
||||
attributes: [{ variant: 'danger' }, { loading: this.deleteLoading }],
|
||||
},
|
||||
cancel: {
|
||||
text: __('Cancel'),
|
||||
},
|
||||
};
|
||||
},
|
||||
shouldShowDeleteButton() {
|
||||
return this.canDestroy && this.showDeleteButton;
|
||||
},
|
||||
deleteIssuableButtonText() {
|
||||
return sprintf(__('Delete %{issuableType}'), {
|
||||
issuableType: issuableTypes[this.issuableType].toLowerCase(),
|
||||
});
|
||||
typeToShow() {
|
||||
const { issueState, issuableType } = this;
|
||||
const type = issueState.issueType ?? issuableType;
|
||||
return issuableTypes[type];
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
@ -56,49 +100,57 @@ export default {
|
|||
eventHub.$emit('close.form');
|
||||
},
|
||||
deleteIssuable() {
|
||||
const confirmMessage =
|
||||
this.issuableType === 'epic'
|
||||
? __('Delete this epic and all descendants?')
|
||||
: sprintf(__('%{issuableType} will be removed! Are you sure?'), {
|
||||
issuableType: issuableTypes[this.issuableType],
|
||||
});
|
||||
// eslint-disable-next-line no-alert
|
||||
if (window.confirm(confirmMessage)) {
|
||||
this.deleteLoading = true;
|
||||
|
||||
eventHub.$emit('delete.issuable', { destroy_confirm: true });
|
||||
}
|
||||
this.deleteLoading = true;
|
||||
eventHub.$emit('delete.issuable', { destroy_confirm: true });
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="gl-mt-3 gl-mb-3 clearfix">
|
||||
<gl-button
|
||||
:loading="formState.updateLoading"
|
||||
:disabled="formState.updateLoading || !isSubmitEnabled"
|
||||
category="primary"
|
||||
variant="confirm"
|
||||
class="float-left qa-save-button gl-mr-3"
|
||||
type="submit"
|
||||
@click.prevent="updateIssuable"
|
||||
>
|
||||
{{ __('Save changes') }}
|
||||
</gl-button>
|
||||
<gl-button @click="closeForm">
|
||||
{{ __('Cancel') }}
|
||||
</gl-button>
|
||||
<gl-button
|
||||
v-if="shouldShowDeleteButton"
|
||||
:loading="deleteLoading"
|
||||
:disabled="deleteLoading"
|
||||
category="secondary"
|
||||
variant="danger"
|
||||
class="float-right qa-delete-button"
|
||||
@click="deleteIssuable"
|
||||
>
|
||||
{{ deleteIssuableButtonText }}
|
||||
</gl-button>
|
||||
<div class="gl-mt-3 gl-mb-3 gl-display-flex gl-justify-content-space-between">
|
||||
<div>
|
||||
<gl-button
|
||||
:loading="formState.updateLoading"
|
||||
:disabled="formState.updateLoading || !isSubmitEnabled"
|
||||
category="primary"
|
||||
variant="confirm"
|
||||
class="qa-save-button gl-mr-3"
|
||||
data-testid="issuable-save-button"
|
||||
type="submit"
|
||||
@click.prevent="updateIssuable"
|
||||
>
|
||||
{{ __('Save changes') }}
|
||||
</gl-button>
|
||||
<gl-button data-testid="issuable-cancel-button" @click="closeForm">
|
||||
{{ __('Cancel') }}
|
||||
</gl-button>
|
||||
</div>
|
||||
<div v-if="shouldShowDeleteButton">
|
||||
<gl-button
|
||||
v-gl-modal="modalId"
|
||||
:loading="deleteLoading"
|
||||
:disabled="deleteLoading"
|
||||
category="secondary"
|
||||
variant="danger"
|
||||
class="qa-delete-button"
|
||||
data-testid="issuable-delete-button"
|
||||
>
|
||||
{{ deleteIssuableButtonText }}
|
||||
</gl-button>
|
||||
<gl-modal
|
||||
ref="removeModal"
|
||||
:modal-id="modalId"
|
||||
size="sm"
|
||||
:action-primary="modalActionProps.primary"
|
||||
:action-cancel="modalActionProps.cancel"
|
||||
@primary="deleteIssuable"
|
||||
>
|
||||
<template #modal-title>{{ deleteIssuableButtonText }}</template>
|
||||
<div>
|
||||
<p class="gl-mb-1">{{ deleteIssuableModalText }}</p>
|
||||
</div>
|
||||
</gl-modal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -54,14 +54,14 @@ export default {
|
|||
|
||||
<template>
|
||||
<!-- eslint-disable @gitlab/vue-no-data-toggle -->
|
||||
<div class="dropdown js-issuable-selector-wrap" data-issuable-type="issues">
|
||||
<div class="dropdown js-issuable-selector-wrap gl-mb-0" data-issuable-type="issues">
|
||||
<button
|
||||
ref="toggle"
|
||||
:data-namespace-path="projectNamespace"
|
||||
:data-project-path="projectPath"
|
||||
:data-project-id="projectId"
|
||||
:data-data="issuableTemplatesJson"
|
||||
class="dropdown-menu-toggle js-issuable-selector"
|
||||
class="dropdown-menu-toggle js-issuable-selector gl-button"
|
||||
type="button"
|
||||
data-field-name="issuable_template"
|
||||
data-selected="null"
|
||||
|
|
|
@ -20,7 +20,7 @@ export default {
|
|||
id="issuable-title"
|
||||
ref="input"
|
||||
v-model="formState.title"
|
||||
class="form-control qa-title-input"
|
||||
class="form-control qa-title-input gl-border-gray-200"
|
||||
dir="auto"
|
||||
type="text"
|
||||
:placeholder="__('Title')"
|
||||
|
|
79
app/assets/javascripts/issue_show/components/fields/type.vue
Normal file
79
app/assets/javascripts/issue_show/components/fields/type.vue
Normal file
|
@ -0,0 +1,79 @@
|
|||
<script>
|
||||
import { GlFormGroup, GlDropdown, GlDropdownItem } from '@gitlab/ui';
|
||||
import { capitalize } from 'lodash';
|
||||
import { __ } from '~/locale';
|
||||
import { IssuableTypes } from '../../constants';
|
||||
import getIssueStateQuery from '../../queries/get_issue_state.query.graphql';
|
||||
import updateIssueStateMutation from '../../queries/update_issue_state.mutation.graphql';
|
||||
|
||||
export const i18n = {
|
||||
label: __('Issue Type'),
|
||||
};
|
||||
|
||||
export default {
|
||||
i18n,
|
||||
IssuableTypes,
|
||||
components: {
|
||||
GlFormGroup,
|
||||
GlDropdown,
|
||||
GlDropdownItem,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
issueState: {},
|
||||
};
|
||||
},
|
||||
apollo: {
|
||||
issueState: {
|
||||
query: getIssueStateQuery,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
dropdownText() {
|
||||
const {
|
||||
issueState: { issueType },
|
||||
} = this;
|
||||
return capitalize(issueType);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
updateIssueType(issueType) {
|
||||
this.$apollo.mutate({
|
||||
mutation: updateIssueStateMutation,
|
||||
variables: {
|
||||
issueType,
|
||||
isDirty: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<gl-form-group
|
||||
:label="$options.i18n.label"
|
||||
label-class="sr-only"
|
||||
label-for="issuable-type"
|
||||
class="mb-2 mb-md-0"
|
||||
>
|
||||
<gl-dropdown
|
||||
id="issuable-type"
|
||||
:aria-labelledby="$options.i18n.label"
|
||||
:text="dropdownText"
|
||||
:header-text="$options.i18n.label"
|
||||
class="gl-w-full"
|
||||
toggle-class="dropdown-menu-toggle"
|
||||
>
|
||||
<gl-dropdown-item
|
||||
v-for="type in $options.IssuableTypes"
|
||||
:key="type.value"
|
||||
:is-checked="issueState.issueType === type.value"
|
||||
is-check-item
|
||||
@click="updateIssueType(type.value)"
|
||||
>
|
||||
{{ type.text }}
|
||||
</gl-dropdown-item>
|
||||
</gl-dropdown>
|
||||
</gl-form-group>
|
||||
</template>
|
|
@ -2,21 +2,24 @@
|
|||
import { GlAlert } from '@gitlab/ui';
|
||||
import $ from 'jquery';
|
||||
import Autosave from '~/autosave';
|
||||
import { IssuableType } from '~/issue_show/constants';
|
||||
import eventHub from '../event_hub';
|
||||
import editActions from './edit_actions.vue';
|
||||
import descriptionField from './fields/description.vue';
|
||||
import descriptionTemplate from './fields/description_template.vue';
|
||||
import titleField from './fields/title.vue';
|
||||
import lockedWarning from './locked_warning.vue';
|
||||
import EditActions from './edit_actions.vue';
|
||||
import DescriptionField from './fields/description.vue';
|
||||
import DescriptionTemplateField from './fields/description_template.vue';
|
||||
import IssuableTitleField from './fields/title.vue';
|
||||
import IssuableTypeField from './fields/type.vue';
|
||||
import LockedWarning from './locked_warning.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
lockedWarning,
|
||||
titleField,
|
||||
descriptionField,
|
||||
descriptionTemplate,
|
||||
editActions,
|
||||
DescriptionField,
|
||||
DescriptionTemplateField,
|
||||
EditActions,
|
||||
GlAlert,
|
||||
IssuableTitleField,
|
||||
IssuableTypeField,
|
||||
LockedWarning,
|
||||
},
|
||||
props: {
|
||||
canDestroy: {
|
||||
|
@ -89,6 +92,9 @@ export default {
|
|||
showLockedWarning() {
|
||||
return this.formState.lockedWarningVisible && !this.formState.updateLoading;
|
||||
},
|
||||
isIssueType() {
|
||||
return this.issuableType === IssuableType.Issue;
|
||||
},
|
||||
},
|
||||
created() {
|
||||
eventHub.$on('delete.issuable', this.resetAutosave);
|
||||
|
@ -162,7 +168,7 @@ export default {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<form>
|
||||
<form data-testid="issuable-form">
|
||||
<locked-warning v-if="showLockedWarning" />
|
||||
<gl-alert
|
||||
v-if="showOutdatedDescriptionWarning"
|
||||
|
@ -179,9 +185,17 @@ export default {
|
|||
)
|
||||
}}</gl-alert
|
||||
>
|
||||
<div class="row gl-mb-3">
|
||||
<div class="col-12">
|
||||
<issuable-title-field ref="title" :form-state="formState" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div v-if="hasIssuableTemplates" class="col-sm-4 col-lg-3">
|
||||
<description-template
|
||||
<div v-if="isIssueType" class="col-12 col-md-4 pr-md-0">
|
||||
<issuable-type-field ref="issue-type" />
|
||||
</div>
|
||||
<div v-if="hasIssuableTemplates" class="col-12 col-md-4 pl-md-2">
|
||||
<description-template-field
|
||||
:form-state="formState"
|
||||
:issuable-templates="issuableTemplates"
|
||||
:project-path="projectPath"
|
||||
|
@ -189,14 +203,6 @@ export default {
|
|||
:project-namespace="projectNamespace"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
:class="{
|
||||
'col-sm-8 col-lg-9': hasIssuableTemplates,
|
||||
'col-12': !hasIssuableTemplates,
|
||||
}"
|
||||
>
|
||||
<title-field ref="title" :form-state="formState" :issuable-templates="issuableTemplates" />
|
||||
</div>
|
||||
</div>
|
||||
<description-field
|
||||
ref="description"
|
||||
|
|
|
@ -25,3 +25,14 @@ export const IssueStateEvent = {
|
|||
|
||||
export const STATUS_PAGE_PUBLISHED = __('Published on status page');
|
||||
export const JOIN_ZOOM_MEETING = __('Join Zoom meeting');
|
||||
|
||||
export const IssuableTypes = [
|
||||
{ value: 'issue', text: __('Issue') },
|
||||
{ value: 'incident', text: __('Incident') },
|
||||
];
|
||||
|
||||
export const IssueTypePath = 'issues';
|
||||
export const IncidentTypePath = 'issues/incident';
|
||||
export const IncidentType = 'incident';
|
||||
|
||||
export const issueState = { issueType: undefined, isDirty: false };
|
||||
|
|
9
app/assets/javascripts/issue_show/graphql.js
Normal file
9
app/assets/javascripts/issue_show/graphql.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
import Vue from 'vue';
|
||||
import VueApollo from 'vue-apollo';
|
||||
import { defaultClient } from '~/sidebar/graphql';
|
||||
|
||||
Vue.use(VueApollo);
|
||||
|
||||
export default new VueApollo({
|
||||
defaultClient,
|
||||
});
|
|
@ -1,15 +1,23 @@
|
|||
import Vue from 'vue';
|
||||
import VueApollo from 'vue-apollo';
|
||||
import createDefaultClient from '~/lib/graphql';
|
||||
import { parseBoolean } from '~/lib/utils/common_utils';
|
||||
import issuableApp from './components/app.vue';
|
||||
import incidentTabs from './components/incidents/incident_tabs.vue';
|
||||
|
||||
Vue.use(VueApollo);
|
||||
import { issueState } from './constants';
|
||||
import apolloProvider from './graphql';
|
||||
import getIssueStateQuery from './queries/get_issue_state.query.graphql';
|
||||
|
||||
export default function initIssuableApp(issuableData = {}) {
|
||||
const apolloProvider = new VueApollo({
|
||||
defaultClient: createDefaultClient(),
|
||||
const el = document.getElementById('js-issuable-app');
|
||||
|
||||
if (!el) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
apolloProvider.clients.defaultClient.cache.writeQuery({
|
||||
query: getIssueStateQuery,
|
||||
data: {
|
||||
issueState: { ...issueState, issueType: el.dataset.issueType },
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
|
@ -25,7 +33,7 @@ export default function initIssuableApp(issuableData = {}) {
|
|||
const fullPath = `${projectNamespace}/${projectPath}`;
|
||||
|
||||
return new Vue({
|
||||
el: document.getElementById('js-issuable-app'),
|
||||
el,
|
||||
apolloProvider,
|
||||
components: {
|
||||
issuableApp,
|
||||
|
|
|
@ -1,14 +1,33 @@
|
|||
import Vue from 'vue';
|
||||
import VueApollo from 'vue-apollo';
|
||||
import { mapGetters } from 'vuex';
|
||||
import createDefaultClient from '~/lib/graphql';
|
||||
import { parseBoolean } from '~/lib/utils/common_utils';
|
||||
import IssuableApp from './components/app.vue';
|
||||
import HeaderActions from './components/header_actions.vue';
|
||||
import { issueState } from './constants';
|
||||
import apolloProvider from './graphql';
|
||||
import getIssueStateQuery from './queries/get_issue_state.query.graphql';
|
||||
|
||||
const bootstrapApollo = (state = {}) => {
|
||||
return apolloProvider.clients.defaultClient.cache.writeQuery({
|
||||
query: getIssueStateQuery,
|
||||
data: {
|
||||
issueState: state,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export function initIssuableApp(issuableData, store) {
|
||||
const el = document.getElementById('js-issuable-app');
|
||||
|
||||
if (!el) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
bootstrapApollo({ ...issueState, issueType: el.dataset.issueType });
|
||||
|
||||
return new Vue({
|
||||
el: document.getElementById('js-issuable-app'),
|
||||
el,
|
||||
apolloProvider,
|
||||
store,
|
||||
computed: {
|
||||
...mapGetters(['getNoteableData']),
|
||||
|
@ -33,11 +52,7 @@ export function initIssueHeaderActions(store) {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
Vue.use(VueApollo);
|
||||
|
||||
const apolloProvider = new VueApollo({
|
||||
defaultClient: createDefaultClient(),
|
||||
});
|
||||
bootstrapApollo({ ...issueState, issueType: el.dataset.issueType });
|
||||
|
||||
return new Vue({
|
||||
el,
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
query issueState {
|
||||
issueState @client
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
mutation updateIssueState($issueType: String, $isDirty: Boolean) {
|
||||
updateIssueState(issueType: $issueType, isDirty: $isDirty) @client
|
||||
}
|
|
@ -2,6 +2,7 @@ import AdminRunnersFilteredSearchTokenKeys from '~/filtered_search/admin_runners
|
|||
import { FILTERED_SEARCH } from '~/pages/constants';
|
||||
import initFilteredSearch from '~/pages/search/init_filtered_search';
|
||||
import { initInstallRunner } from '~/pages/shared/mount_runner_instructions';
|
||||
import { initRunnerList } from '~/runner/runner_list';
|
||||
|
||||
initFilteredSearch({
|
||||
page: FILTERED_SEARCH.ADMIN_RUNNERS,
|
||||
|
@ -10,3 +11,7 @@ initFilteredSearch({
|
|||
});
|
||||
|
||||
initInstallRunner();
|
||||
|
||||
if (gon.features?.runnerListViewVueUi) {
|
||||
initRunnerList();
|
||||
}
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
<script>
|
||||
import { GlLink } from '@gitlab/ui';
|
||||
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
|
||||
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GlLink,
|
||||
TooltipOnTruncate,
|
||||
},
|
||||
props: {
|
||||
runner: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
runnerNumericalId() {
|
||||
return getIdFromGraphQLId(this.runner.id);
|
||||
},
|
||||
runnerUrl() {
|
||||
// TODO implement using webUrl from the API
|
||||
return `${gon.gitlab_url || ''}/admin/runners/${this.runnerNumericalId}`;
|
||||
},
|
||||
description() {
|
||||
return this.runner.description;
|
||||
},
|
||||
shortSha() {
|
||||
return this.runner.shortSha;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<gl-link :href="runnerUrl"> #{{ runnerNumericalId }} ({{ shortSha }})</gl-link>
|
||||
<tooltip-on-truncate class="gl-display-block" :title="description" truncate-target="child">
|
||||
<div class="gl-text-truncate">
|
||||
{{ description }}
|
||||
</div>
|
||||
</tooltip-on-truncate>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,42 @@
|
|||
<script>
|
||||
import { GlBadge } from '@gitlab/ui';
|
||||
import RunnerTypeBadge from '../runner_type_badge.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GlBadge,
|
||||
RunnerTypeBadge,
|
||||
},
|
||||
props: {
|
||||
runner: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
runnerType() {
|
||||
return this.runner.runnerType;
|
||||
},
|
||||
locked() {
|
||||
return this.runner.locked;
|
||||
},
|
||||
paused() {
|
||||
return !this.runner.active;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<runner-type-badge :type="runnerType" size="sm" />
|
||||
|
||||
<gl-badge v-if="locked" variant="warning" size="sm">
|
||||
{{ __('locked') }}
|
||||
</gl-badge>
|
||||
|
||||
<gl-badge v-if="paused" variant="danger" size="sm">
|
||||
{{ __('paused') }}
|
||||
</gl-badge>
|
||||
</div>
|
||||
</template>
|
142
app/assets/javascripts/runner/components/runner_list.vue
Normal file
142
app/assets/javascripts/runner/components/runner_list.vue
Normal file
|
@ -0,0 +1,142 @@
|
|||
<script>
|
||||
import { GlTable, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui';
|
||||
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
|
||||
import { formatNumber, sprintf, __, s__ } from '~/locale';
|
||||
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
|
||||
import RunnerNameCell from './cells/runner_name_cell.vue';
|
||||
import RunnerTypeCell from './cells/runner_type_cell.vue';
|
||||
import RunnerTags from './runner_tags.vue';
|
||||
|
||||
const tableField = ({ key, label = '', width = 10 }) => {
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
thClass: [
|
||||
`gl-w-${width}p`,
|
||||
'gl-bg-transparent!',
|
||||
'gl-border-b-solid!',
|
||||
'gl-border-b-gray-100!',
|
||||
'gl-py-5!',
|
||||
'gl-px-0!',
|
||||
'gl-border-b-1!',
|
||||
],
|
||||
tdClass: ['gl-py-5!', 'gl-px-1!'],
|
||||
tdAttr: {
|
||||
'data-testid': `td-${key}`,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GlTable,
|
||||
GlSkeletonLoader,
|
||||
TimeAgo,
|
||||
RunnerNameCell,
|
||||
RunnerTags,
|
||||
RunnerTypeCell,
|
||||
},
|
||||
directives: {
|
||||
GlTooltip: GlTooltipDirective,
|
||||
},
|
||||
props: {
|
||||
loading: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
runners: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
activeRunnersCount: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
activeRunnersMessage() {
|
||||
return sprintf(__('Runners currently online: %{active_runners_count}'), {
|
||||
active_runners_count: formatNumber(this.activeRunnersCount),
|
||||
});
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
runnerTrAttr(runner) {
|
||||
if (runner) {
|
||||
return {
|
||||
'data-testid': `runner-row-${getIdFromGraphQLId(runner.id)}`,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
tableField({ key: 'type', label: __('Type/State') }),
|
||||
tableField({ key: 'name', label: s__('Runners|Runner'), width: 30 }),
|
||||
tableField({ key: 'version', label: __('Version') }),
|
||||
tableField({ key: 'ipAddress', label: __('IP Address') }),
|
||||
tableField({ key: 'projectCount', label: __('Projects'), width: 5 }),
|
||||
tableField({ key: 'jobCount', label: __('Jobs'), width: 5 }),
|
||||
tableField({ key: 'tagList', label: __('Tags') }),
|
||||
tableField({ key: 'contactedAt', label: __('Last contact') }),
|
||||
tableField({ key: 'actions', label: '' }),
|
||||
],
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<div class="gl-text-right" data-testid="active-runners-message">{{ activeRunnersMessage }}</div>
|
||||
<gl-table
|
||||
:busy="loading"
|
||||
:items="runners"
|
||||
:fields="$options.fields"
|
||||
:tbody-tr-attr="runnerTrAttr"
|
||||
stacked="md"
|
||||
fixed
|
||||
>
|
||||
<template #table-busy>
|
||||
<gl-skeleton-loader />
|
||||
</template>
|
||||
|
||||
<template #cell(type)="{ item }">
|
||||
<runner-type-cell :runner="item" />
|
||||
</template>
|
||||
|
||||
<template #cell(name)="{ item }">
|
||||
<runner-name-cell :runner="item" />
|
||||
</template>
|
||||
|
||||
<template #cell(version)="{ item: { version } }">
|
||||
{{ version }}
|
||||
</template>
|
||||
|
||||
<template #cell(ipAddress)="{ item: { ipAddress } }">
|
||||
{{ ipAddress }}
|
||||
</template>
|
||||
|
||||
<template #cell(projectCount)>
|
||||
<!-- TODO add projects count -->
|
||||
</template>
|
||||
|
||||
<template #cell(jobCount)>
|
||||
<!-- TODO add jobs count -->
|
||||
</template>
|
||||
|
||||
<template #cell(tagList)="{ item: { tagList } }">
|
||||
<runner-tags :tag-list="tagList" size="sm" />
|
||||
</template>
|
||||
|
||||
<template #cell(contactedAt)="{ item: { contactedAt } }">
|
||||
<time-ago v-if="contactedAt" :time="contactedAt" />
|
||||
<template v-else>{{ __('Never') }}</template>
|
||||
</template>
|
||||
|
||||
<template #cell(actions)>
|
||||
<!-- TODO add actions to update runners -->
|
||||
</template>
|
||||
</gl-table>
|
||||
|
||||
<!-- TODO implement pagination -->
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,76 @@
|
|||
<script>
|
||||
import { GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
|
||||
import { __ } from '~/locale';
|
||||
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
|
||||
import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GlLink,
|
||||
GlSprintf,
|
||||
ClipboardButton,
|
||||
RunnerInstructions,
|
||||
},
|
||||
directives: {
|
||||
GlTooltip: GlTooltipDirective,
|
||||
},
|
||||
inject: {
|
||||
runnerInstallHelpPage: {
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
registrationToken: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
typeName: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: __('shared'),
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
rootUrl() {
|
||||
return gon.gitlab_url || '';
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bs-callout">
|
||||
<h5 data-testid="runner-help-title">
|
||||
<gl-sprintf :message="__('Set up a %{type} runner manually')">
|
||||
<template #type>
|
||||
{{ typeName }}
|
||||
</template>
|
||||
</gl-sprintf>
|
||||
</h5>
|
||||
|
||||
<ol>
|
||||
<li>
|
||||
<gl-link :href="runnerInstallHelpPage" data-testid="runner-help-link" target="_blank">
|
||||
{{ __("Install GitLab Runner and ensure it's running.") }}
|
||||
</gl-link>
|
||||
</li>
|
||||
<li>
|
||||
{{ __('Register the runner with this URL:') }}
|
||||
<br />
|
||||
|
||||
<code data-testid="coordinator-url">{{ rootUrl }}</code>
|
||||
<clipboard-button :title="__('Copy URL')" :text="rootUrl" />
|
||||
</li>
|
||||
<li>
|
||||
{{ __('And this registration token:') }}
|
||||
<br />
|
||||
|
||||
<code data-testid="registration-token">{{ registrationToken }}</code>
|
||||
<clipboard-button :title="__('Copy token')" :text="registrationToken" />
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<!-- TODO Implement reset token functionality -->
|
||||
<runner-instructions />
|
||||
</div>
|
||||
</template>
|
33
app/assets/javascripts/runner/components/runner_tags.vue
Normal file
33
app/assets/javascripts/runner/components/runner_tags.vue
Normal file
|
@ -0,0 +1,33 @@
|
|||
<script>
|
||||
import { GlBadge } from '@gitlab/ui';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GlBadge,
|
||||
},
|
||||
props: {
|
||||
tagList: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => [],
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'md',
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'info',
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<gl-badge v-for="tag in tagList" :key="tag" :size="size" :variant="variant">
|
||||
{{ tag }}
|
||||
</gl-badge>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,60 @@
|
|||
<script>
|
||||
import { GlBadge } from '@gitlab/ui';
|
||||
import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants';
|
||||
import RunnerTypeBadge from './runner_type_badge.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GlBadge,
|
||||
RunnerTypeBadge,
|
||||
},
|
||||
runnerTypes: {
|
||||
INSTANCE_TYPE,
|
||||
GROUP_TYPE,
|
||||
PROJECT_TYPE,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bs-callout">
|
||||
<p>{{ __('Runners are processes that pick up and execute CI/CD jobs for GitLab.') }}</p>
|
||||
<p>
|
||||
{{
|
||||
__(
|
||||
'You can register runners as separate users, on separate servers, and on your local machine. Register as many runners as you want.',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<span> {{ __('Runners can be:') }}</span>
|
||||
<ul>
|
||||
<li>
|
||||
<runner-type-badge :type="$options.runnerTypes.INSTANCE_TYPE" size="sm" />
|
||||
- {{ __('Runs jobs from all unassigned projects.') }}
|
||||
</li>
|
||||
<li>
|
||||
<runner-type-badge :type="$options.runnerTypes.GROUP_TYPE" size="sm" />
|
||||
- {{ __('Runs jobs from all unassigned projects in its group.') }}
|
||||
</li>
|
||||
<li>
|
||||
<runner-type-badge :type="$options.runnerTypes.PROJECT_TYPE" size="sm" />
|
||||
- {{ __('Runs jobs from assigned projects.') }}
|
||||
</li>
|
||||
<li>
|
||||
<gl-badge variant="warning" size="sm">
|
||||
{{ __('locked') }}
|
||||
</gl-badge>
|
||||
- {{ __('Cannot be assigned to other projects.') }}
|
||||
</li>
|
||||
<li>
|
||||
<gl-badge variant="danger" size="sm">
|
||||
{{ __('paused') }}
|
||||
</gl-badge>
|
||||
- {{ __('Not available to run jobs.') }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,17 @@
|
|||
query getRunners {
|
||||
runners {
|
||||
nodes {
|
||||
id
|
||||
description
|
||||
runnerType
|
||||
shortSha
|
||||
version
|
||||
revision
|
||||
ipAddress
|
||||
active
|
||||
locked
|
||||
tagList
|
||||
contactedAt
|
||||
}
|
||||
}
|
||||
}
|
42
app/assets/javascripts/runner/runner_list/index.js
Normal file
42
app/assets/javascripts/runner/runner_list/index.js
Normal file
|
@ -0,0 +1,42 @@
|
|||
import Vue from 'vue';
|
||||
import VueApollo from 'vue-apollo';
|
||||
import createDefaultClient from '~/lib/graphql';
|
||||
import RunnerDetailsApp from './runner_list_app.vue';
|
||||
|
||||
Vue.use(VueApollo);
|
||||
|
||||
export const initRunnerList = (selector = '#js-runner-list') => {
|
||||
const el = document.querySelector(selector);
|
||||
|
||||
if (!el) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO `activeRunnersCount` should be implemented using a GraphQL API.
|
||||
const { activeRunnersCount, registrationToken, runnerInstallHelpPage } = el.dataset;
|
||||
|
||||
const apolloProvider = new VueApollo({
|
||||
defaultClient: createDefaultClient(
|
||||
{},
|
||||
{
|
||||
assumeImmutableResults: true,
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
return new Vue({
|
||||
el,
|
||||
apolloProvider,
|
||||
provide: {
|
||||
runnerInstallHelpPage,
|
||||
},
|
||||
render(h) {
|
||||
return h(RunnerDetailsApp, {
|
||||
props: {
|
||||
activeRunnersCount: parseInt(activeRunnersCount, 10),
|
||||
registrationToken,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
|
@ -0,0 +1,82 @@
|
|||
<script>
|
||||
import * as Sentry from '@sentry/browser';
|
||||
import RunnerList from '../components/runner_list.vue';
|
||||
import RunnerManualSetupHelp from '../components/runner_manual_setup_help.vue';
|
||||
import RunnerTypeHelp from '../components/runner_type_help.vue';
|
||||
import getRunnersQuery from '../graphql/get_runners.query.graphql';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
RunnerList,
|
||||
RunnerManualSetupHelp,
|
||||
RunnerTypeHelp,
|
||||
},
|
||||
props: {
|
||||
activeRunnersCount: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
registrationToken: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
runners: [],
|
||||
};
|
||||
},
|
||||
apollo: {
|
||||
runners: {
|
||||
query: getRunnersQuery,
|
||||
update({ runners }) {
|
||||
return runners?.nodes || [];
|
||||
},
|
||||
error(err) {
|
||||
this.captureException(err);
|
||||
},
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
runnersLoading() {
|
||||
return this.$apollo.queries.runners.loading;
|
||||
},
|
||||
noRunnersFound() {
|
||||
return !this.runnersLoading && !this.runners.length;
|
||||
},
|
||||
},
|
||||
errorCaptured(err) {
|
||||
this.captureException(err);
|
||||
},
|
||||
methods: {
|
||||
captureException(err) {
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setTag('component', 'runner_list_app');
|
||||
Sentry.captureException(err);
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<div class="row">
|
||||
<div class="col-sm-6">
|
||||
<runner-type-help />
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<runner-manual-setup-help :registration-token="registrationToken" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="noRunnersFound" class="gl-text-center gl-p-5">
|
||||
{{ __('No runners found') }}
|
||||
</div>
|
||||
<runner-list
|
||||
v-else
|
||||
:runners="runners"
|
||||
:loading="runnersLoading"
|
||||
:active-runners-count="activeRunnersCount"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
|
@ -1,5 +1,7 @@
|
|||
import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
|
||||
import produce from 'immer';
|
||||
import VueApollo from 'vue-apollo';
|
||||
import getIssueStateQuery from '~/issue_show/queries/get_issue_state.query.graphql';
|
||||
import createDefaultClient from '~/lib/graphql';
|
||||
import introspectionQueryResultData from './fragmentTypes.json';
|
||||
|
||||
|
@ -7,15 +9,24 @@ const fragmentMatcher = new IntrospectionFragmentMatcher({
|
|||
introspectionQueryResultData,
|
||||
});
|
||||
|
||||
export const defaultClient = createDefaultClient(
|
||||
{},
|
||||
{
|
||||
cacheConfig: {
|
||||
fragmentMatcher,
|
||||
const resolvers = {
|
||||
Mutation: {
|
||||
updateIssueState: (_, { issueType = undefined, isDirty = false }, { cache }) => {
|
||||
const sourceData = cache.readQuery({ query: getIssueStateQuery });
|
||||
const data = produce(sourceData, (draftData) => {
|
||||
draftData.issueState = { issueType, isDirty };
|
||||
});
|
||||
cache.writeQuery({ query: getIssueStateQuery, data });
|
||||
},
|
||||
assumeImmutableResults: true,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const defaultClient = createDefaultClient(resolvers, {
|
||||
cacheConfig: {
|
||||
fragmentMatcher,
|
||||
},
|
||||
assumeImmutableResults: true,
|
||||
});
|
||||
|
||||
export const apolloProvider = new VueApollo({
|
||||
defaultClient,
|
||||
|
|
|
@ -4,6 +4,9 @@ class Admin::RunnersController < Admin::ApplicationController
|
|||
include RunnerSetupScripts
|
||||
|
||||
before_action :runner, except: [:index, :tag_list, :runner_setup_scripts]
|
||||
before_action only: [:index] do
|
||||
push_frontend_feature_flag(:runner_list_view_vue_ui, current_user, default_enabled: :yaml)
|
||||
end
|
||||
|
||||
feature_category :continuous_integration
|
||||
|
||||
|
|
|
@ -160,8 +160,8 @@ module CommitsHelper
|
|||
commit.author,
|
||||
ref,
|
||||
{
|
||||
merge_request: merge_request,
|
||||
pipeline_status: commit.status_for(ref),
|
||||
merge_request: merge_request&.cache_key,
|
||||
pipeline_status: commit.status_for(ref)&.cache_key,
|
||||
xhr: request.xhr?,
|
||||
controller: controller.controller_path,
|
||||
path: @path # referred to in #link_to_browse_code
|
||||
|
|
|
@ -22,32 +22,26 @@ module Nav
|
|||
def build_anonymous_view_model(builder:)
|
||||
# These come from `app/views/layouts/nav/_explore.html.ham`
|
||||
if explore_nav_link?(:projects)
|
||||
builder.add_primary_menu_item(
|
||||
**projects_menu_item_attrs.merge(
|
||||
{
|
||||
active: active_nav_link?(path: %w[dashboard#show root#show projects#trending projects#starred projects#index]),
|
||||
href: explore_root_path
|
||||
})
|
||||
builder.add_primary_menu_item_with_shortcut(
|
||||
href: explore_root_path,
|
||||
active: active_nav_link?(path: %w[dashboard#show root#show projects#trending projects#starred projects#index]),
|
||||
**projects_menu_item_attrs
|
||||
)
|
||||
end
|
||||
|
||||
if explore_nav_link?(:groups)
|
||||
builder.add_primary_menu_item(
|
||||
**groups_menu_item_attrs.merge(
|
||||
{
|
||||
active: active_nav_link?(controller: [:groups, 'groups/milestones', 'groups/group_members']),
|
||||
href: explore_groups_path
|
||||
})
|
||||
builder.add_primary_menu_item_with_shortcut(
|
||||
href: explore_groups_path,
|
||||
active: active_nav_link?(controller: [:groups, 'groups/milestones', 'groups/group_members']),
|
||||
**groups_menu_item_attrs
|
||||
)
|
||||
end
|
||||
|
||||
if explore_nav_link?(:snippets)
|
||||
builder.add_primary_menu_item(
|
||||
**snippets_menu_item_attrs.merge(
|
||||
{
|
||||
active: active_nav_link?(controller: :snippets),
|
||||
href: explore_snippets_path
|
||||
})
|
||||
builder.add_primary_menu_item_with_shortcut(
|
||||
active: active_nav_link?(controller: :snippets),
|
||||
href: explore_snippets_path,
|
||||
**snippets_menu_item_attrs
|
||||
)
|
||||
end
|
||||
end
|
||||
|
@ -57,13 +51,13 @@ module Nav
|
|||
if dashboard_nav_link?(:projects)
|
||||
current_item = project ? current_project(project: project) : {}
|
||||
|
||||
builder.add_primary_menu_item(
|
||||
**projects_menu_item_attrs.merge({
|
||||
active: active_nav_link?(path: %w[root#index projects#trending projects#starred dashboard/projects#index]),
|
||||
css_class: 'qa-projects-dropdown',
|
||||
data: { track_label: "projects_dropdown", track_event: "click_dropdown", track_experiment: "new_repo" },
|
||||
view: PROJECTS_VIEW
|
||||
})
|
||||
builder.add_primary_menu_item_with_shortcut(
|
||||
active: active_nav_link?(path: %w[root#index projects#trending projects#starred dashboard/projects#index]),
|
||||
css_class: 'qa-projects-dropdown',
|
||||
data: { track_label: "projects_dropdown", track_event: "click_dropdown", track_experiment: "new_repo" },
|
||||
view: PROJECTS_VIEW,
|
||||
shortcut_href: dashboard_projects_path,
|
||||
**projects_menu_item_attrs
|
||||
)
|
||||
builder.add_view(PROJECTS_VIEW, container_view_props(namespace: 'projects', current_item: current_item, submenu: projects_submenu))
|
||||
end
|
||||
|
@ -71,46 +65,47 @@ module Nav
|
|||
if dashboard_nav_link?(:groups)
|
||||
current_item = group ? current_group(group: group) : {}
|
||||
|
||||
builder.add_primary_menu_item(
|
||||
**groups_menu_item_attrs.merge({
|
||||
active: active_nav_link?(path: %w[dashboard/groups explore/groups]),
|
||||
css_class: 'qa-groups-dropdown',
|
||||
data: { track_label: "groups_dropdown", track_event: "click_dropdown" },
|
||||
view: GROUPS_VIEW
|
||||
})
|
||||
builder.add_primary_menu_item_with_shortcut(
|
||||
active: active_nav_link?(path: %w[dashboard/groups explore/groups]),
|
||||
css_class: 'qa-groups-dropdown',
|
||||
data: { track_label: "groups_dropdown", track_event: "click_dropdown" },
|
||||
view: GROUPS_VIEW,
|
||||
shortcut_href: dashboard_groups_path,
|
||||
**groups_menu_item_attrs
|
||||
)
|
||||
builder.add_view(GROUPS_VIEW, container_view_props(namespace: 'groups', current_item: current_item, submenu: groups_submenu))
|
||||
end
|
||||
|
||||
if dashboard_nav_link?(:milestones)
|
||||
builder.add_primary_menu_item(
|
||||
builder.add_primary_menu_item_with_shortcut(
|
||||
id: 'milestones',
|
||||
title: 'Milestones',
|
||||
href: dashboard_milestones_path,
|
||||
active: active_nav_link?(controller: 'dashboard/milestones'),
|
||||
icon: 'clock',
|
||||
data: { qa_selector: 'milestones_link' },
|
||||
href: dashboard_milestones_path
|
||||
shortcut_class: 'dashboard-shortcuts-milestones'
|
||||
)
|
||||
end
|
||||
|
||||
if dashboard_nav_link?(:snippets)
|
||||
builder.add_primary_menu_item(
|
||||
**snippets_menu_item_attrs.merge({
|
||||
active: active_nav_link?(controller: 'dashboard/snippets'),
|
||||
data: { qa_selector: 'snippets_link' },
|
||||
href: dashboard_snippets_path
|
||||
})
|
||||
builder.add_primary_menu_item_with_shortcut(
|
||||
active: active_nav_link?(controller: 'dashboard/snippets'),
|
||||
data: { qa_selector: 'snippets_link' },
|
||||
href: dashboard_snippets_path,
|
||||
**snippets_menu_item_attrs
|
||||
)
|
||||
end
|
||||
|
||||
if dashboard_nav_link?(:activity)
|
||||
builder.add_primary_menu_item(
|
||||
builder.add_primary_menu_item_with_shortcut(
|
||||
id: 'activity',
|
||||
title: 'Activity',
|
||||
href: activity_dashboard_path,
|
||||
active: active_nav_link?(path: 'dashboard#activity'),
|
||||
icon: 'history',
|
||||
data: { qa_selector: 'activity_link' },
|
||||
href: activity_dashboard_path
|
||||
shortcut_class: 'dashboard-shortcuts-activity'
|
||||
)
|
||||
end
|
||||
|
||||
|
@ -165,7 +160,8 @@ module Nav
|
|||
{
|
||||
id: 'project',
|
||||
title: _('Projects'),
|
||||
icon: 'project'
|
||||
icon: 'project',
|
||||
shortcut_class: 'dashboard-shortcuts-projects'
|
||||
}
|
||||
end
|
||||
|
||||
|
@ -173,7 +169,8 @@ module Nav
|
|||
{
|
||||
id: 'groups',
|
||||
title: 'Groups',
|
||||
icon: 'group'
|
||||
icon: 'group',
|
||||
shortcut_class: 'dashboard-shortcuts-groups'
|
||||
}
|
||||
end
|
||||
|
||||
|
@ -181,7 +178,8 @@ module Nav
|
|||
{
|
||||
id: 'snippets',
|
||||
title: _('Snippets'),
|
||||
icon: 'snippet'
|
||||
icon: 'snippet',
|
||||
shortcut_class: 'dashboard-shortcuts-snippets'
|
||||
}
|
||||
end
|
||||
|
||||
|
|
|
@ -753,22 +753,14 @@ module Ci
|
|||
end
|
||||
|
||||
def any_runners_online?
|
||||
if Feature.enabled?(:runners_cached_states, project, default_enabled: :yaml)
|
||||
cache_for_online_runners do
|
||||
project.any_online_runners? { |runner| runner.match_build_if_online?(self) }
|
||||
end
|
||||
else
|
||||
project.any_active_runners? { |runner| runner.match_build_if_online?(self) }
|
||||
cache_for_online_runners do
|
||||
project.any_online_runners? { |runner| runner.match_build_if_online?(self) }
|
||||
end
|
||||
end
|
||||
|
||||
def any_runners_available?
|
||||
if Feature.enabled?(:runners_cached_states, project, default_enabled: :yaml)
|
||||
cache_for_available_runners do
|
||||
project.active_runners.exists?
|
||||
end
|
||||
else
|
||||
project.any_active_runners?
|
||||
cache_for_available_runners do
|
||||
project.active_runners.exists?
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -1751,11 +1751,6 @@ class Project < ApplicationRecord
|
|||
end
|
||||
end
|
||||
|
||||
# Deprecated: https://gitlab.com/gitlab-org/gitlab/-/issues/326989
|
||||
def any_active_runners?(&block)
|
||||
active_runners_with_tags.any?(&block)
|
||||
end
|
||||
|
||||
def any_online_runners?(&block)
|
||||
online_runners_with_tags.any?(&block)
|
||||
end
|
||||
|
|
|
@ -38,6 +38,7 @@ module Issues
|
|||
super
|
||||
|
||||
params.delete(:issue_type) unless issue_type_allowed?(issue)
|
||||
filter_incident_label(issue) if params[:issue_type]
|
||||
|
||||
moved_issue = params.delete(:moved_issue)
|
||||
|
||||
|
@ -82,6 +83,37 @@ module Issues
|
|||
def issue_type_allowed?(object)
|
||||
can?(current_user, :"create_#{params[:issue_type]}", object)
|
||||
end
|
||||
|
||||
# @param issue [Issue]
|
||||
def filter_incident_label(issue)
|
||||
return unless add_incident_label?(issue) || remove_incident_label?(issue)
|
||||
|
||||
label = ::IncidentManagement::CreateIncidentLabelService
|
||||
.new(project, current_user)
|
||||
.execute
|
||||
.payload[:label]
|
||||
|
||||
# These(add_label_ids, remove_label_ids) are being added ahead of time
|
||||
# to be consumed by #process_label_ids, this allows system notes
|
||||
# to be applied correctly alongside the label updates.
|
||||
if add_incident_label?(issue)
|
||||
params[:add_label_ids] ||= []
|
||||
params[:add_label_ids] << label.id
|
||||
else
|
||||
params[:remove_label_ids] ||= []
|
||||
params[:remove_label_ids] << label.id
|
||||
end
|
||||
end
|
||||
|
||||
# @param issue [Issue]
|
||||
def add_incident_label?(issue)
|
||||
issue.incident?
|
||||
end
|
||||
|
||||
# @param _issue [Issue, nil]
|
||||
def remove_incident_label?(_issue)
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -34,7 +34,6 @@ module Issues
|
|||
|
||||
# Add new items to Issues::AfterCreateService if they can be performed in Sidekiq
|
||||
def after_create(issue)
|
||||
add_incident_label(issue)
|
||||
user_agent_detail_service.create
|
||||
resolve_discussions_with_issue(issue)
|
||||
|
||||
|
@ -56,22 +55,6 @@ module Issues
|
|||
def user_agent_detail_service
|
||||
UserAgentDetailService.new(@issue, request)
|
||||
end
|
||||
|
||||
# Applies label "incident" (creates it if missing) to incident issues.
|
||||
# For use in "after" hooks only to ensure we are not appyling
|
||||
# labels prematurely.
|
||||
def add_incident_label(issue)
|
||||
return unless issue.incident?
|
||||
|
||||
label = ::IncidentManagement::CreateIncidentLabelService
|
||||
.new(project, current_user)
|
||||
.execute
|
||||
.payload[:label]
|
||||
|
||||
return if issue.label_ids.include?(label.id)
|
||||
|
||||
issue.labels << label
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -204,6 +204,16 @@ module Issues
|
|||
def create_confidentiality_note(issue)
|
||||
SystemNoteService.change_issue_confidentiality(issue, issue.project, current_user)
|
||||
end
|
||||
|
||||
override :add_incident_label?
|
||||
def add_incident_label?(issue)
|
||||
issue.issue_type != params[:issue_type] && !issue.incident?
|
||||
end
|
||||
|
||||
override :remove_incident_label?
|
||||
def remove_incident_label?(issue)
|
||||
issue.issue_type != params[:issue_type] && issue.incident?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
-# Note: This file should stay aligned with:
|
||||
-# `app/views/groups/runners/_runner.html.haml`
|
||||
|
||||
.gl-responsive-table-row{ id: dom_id(runner) }
|
||||
.gl-responsive-table-row{ data: { testid: "runner-row-#{runner.id}" } }
|
||||
.table-section.section-10.section-wrap
|
||||
.table-mobile-header{ role: 'rowheader' }= _('Type')
|
||||
.table-mobile-content
|
||||
|
|
|
@ -1,87 +1,97 @@
|
|||
- breadcrumb_title _('Runners')
|
||||
- page_title _('Runners')
|
||||
|
||||
.row
|
||||
.col-sm-6
|
||||
.bs-callout
|
||||
%p
|
||||
= _("Runners are processes that pick up and execute CI/CD jobs for GitLab.")
|
||||
%br
|
||||
= _('You can register runners as separate users, on separate servers, and on your local machine. Register as many runners as you want.')
|
||||
%br
|
||||
- if Feature.enabled?(:runner_list_view_vue_ui, current_user, default_enabled: :yaml)
|
||||
#js-runner-list{ data: { registration_token: Gitlab::CurrentSettings.runners_registration_token, runner_install_help_page: 'https://docs.gitlab.com/runner/install/', active_runners_count: @active_runners_count } }
|
||||
- else
|
||||
.row
|
||||
.col-sm-6
|
||||
.bs-callout
|
||||
%p
|
||||
= _("Runners are processes that pick up and execute CI/CD jobs for GitLab.")
|
||||
%br
|
||||
= _('You can register runners as separate users, on separate servers, and on your local machine. Register as many runners as you want.')
|
||||
%br
|
||||
|
||||
%div
|
||||
%span= _('Runners can be:')
|
||||
%ul
|
||||
%li
|
||||
%span.badge.badge-pill.gl-badge.sm.badge-success shared
|
||||
\-
|
||||
= _('Runs jobs from all unassigned projects.')
|
||||
%li
|
||||
%span.badge.badge-pill.gl-badge.sm.badge-success group
|
||||
\-
|
||||
= _('Runs jobs from all unassigned projects in its group.')
|
||||
%li
|
||||
%span.badge.badge-pill.gl-badge.sm.badge-info specific
|
||||
\-
|
||||
= _('Runs jobs from assigned projects.')
|
||||
%li
|
||||
%span.badge.badge-pill.gl-badge.sm.badge-warning locked
|
||||
\-
|
||||
= _('Cannot be assigned to other projects.')
|
||||
%li
|
||||
%span.badge.badge-pill.gl-badge.sm.badge-danger paused
|
||||
\-
|
||||
= _('Not available to run jobs.')
|
||||
%div
|
||||
%span= _('Runners can be:')
|
||||
%ul
|
||||
%li
|
||||
%span.badge.badge-pill.gl-badge.sm.badge-success shared
|
||||
\-
|
||||
= _('Runs jobs from all unassigned projects.')
|
||||
%li
|
||||
%span.badge.badge-pill.gl-badge.sm.badge-success group
|
||||
\-
|
||||
= _('Runs jobs from all unassigned projects in its group.')
|
||||
%li
|
||||
%span.badge.badge-pill.gl-badge.sm.badge-info specific
|
||||
\-
|
||||
= _('Runs jobs from assigned projects.')
|
||||
%li
|
||||
%span.badge.badge-pill.gl-badge.sm.badge-warning locked
|
||||
\-
|
||||
= _('Cannot be assigned to other projects.')
|
||||
%li
|
||||
%span.badge.badge-pill.gl-badge.sm.badge-danger paused
|
||||
\-
|
||||
= _('Not available to run jobs.')
|
||||
|
||||
.col-sm-6
|
||||
.bs-callout
|
||||
= render partial: 'ci/runner/how_to_setup_runner',
|
||||
locals: { registration_token: Gitlab::CurrentSettings.runners_registration_token,
|
||||
type: 'shared',
|
||||
reset_token_url: reset_registration_token_admin_application_settings_path,
|
||||
project_path: '',
|
||||
group_path: '' }
|
||||
.col-sm-6
|
||||
.bs-callout
|
||||
= render partial: 'ci/runner/how_to_setup_runner',
|
||||
locals: { registration_token: Gitlab::CurrentSettings.runners_registration_token,
|
||||
type: 'shared',
|
||||
reset_token_url: reset_registration_token_admin_application_settings_path,
|
||||
project_path: '',
|
||||
group_path: '' }
|
||||
|
||||
.row
|
||||
.col-sm-9
|
||||
= form_tag admin_runners_path, id: 'runners-search', method: :get, class: 'filter-form js-filter-form' do
|
||||
.filtered-search-wrapper.d-flex
|
||||
.filtered-search-box
|
||||
= dropdown_tag(_('Recent searches'),
|
||||
options: { wrapper_class: 'filtered-search-history-dropdown-wrapper',
|
||||
toggle_class: 'gl-button btn btn-default filtered-search-history-dropdown-toggle-button',
|
||||
dropdown_class: 'filtered-search-history-dropdown',
|
||||
content_class: 'filtered-search-history-dropdown-content' }) do
|
||||
.js-filtered-search-history-dropdown{ data: { full_path: admin_runners_path } }
|
||||
.filtered-search-box-input-container.droplab-dropdown
|
||||
.scroll-container
|
||||
%ul.tokens-container.list-unstyled
|
||||
%li.input-token
|
||||
%input.form-control.filtered-search{ search_filter_input_options('runners') }
|
||||
#js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
|
||||
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
|
||||
%li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } }
|
||||
= button_tag class: %w[gl-button btn btn-link] do
|
||||
-# Encapsulate static class name `{{icon}}` inside #{} to bypass
|
||||
-# haml lint's ClassAttributeWithStaticValue
|
||||
%svg
|
||||
%use{ 'xlink:href': "#{'{{icon}}'}" }
|
||||
%span.js-filter-hint
|
||||
{{formattedKey}}
|
||||
#js-dropdown-operator.filtered-search-input-dropdown-menu.dropdown-menu
|
||||
%ul.filter-dropdown{ data: { dropdown: true, dynamic: true } }
|
||||
%li.filter-dropdown-item{ data: { value: "{{ title }}" } }
|
||||
%button.gl-button.btn.btn-link{ type: 'button' }
|
||||
{{ title }}
|
||||
%span.btn-helptext
|
||||
{{ help }}
|
||||
#js-dropdown-admin-runner-status.filtered-search-input-dropdown-menu.dropdown-menu
|
||||
%ul{ data: { dropdown: true } }
|
||||
- Ci::Runner::AVAILABLE_STATUSES.each do |status|
|
||||
%li.filter-dropdown-item{ data: { value: status } }
|
||||
.row
|
||||
.col-sm-9
|
||||
= form_tag admin_runners_path, id: 'runners-search', method: :get, class: 'filter-form js-filter-form' do
|
||||
.filtered-search-wrapper.d-flex
|
||||
.filtered-search-box
|
||||
= dropdown_tag(_('Recent searches'),
|
||||
options: { wrapper_class: 'filtered-search-history-dropdown-wrapper',
|
||||
toggle_class: 'gl-button btn btn-default filtered-search-history-dropdown-toggle-button',
|
||||
dropdown_class: 'filtered-search-history-dropdown',
|
||||
content_class: 'filtered-search-history-dropdown-content' }) do
|
||||
.js-filtered-search-history-dropdown{ data: { full_path: admin_runners_path } }
|
||||
.filtered-search-box-input-container.droplab-dropdown
|
||||
.scroll-container
|
||||
%ul.tokens-container.list-unstyled
|
||||
%li.input-token
|
||||
%input.form-control.filtered-search{ search_filter_input_options('runners') }
|
||||
#js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
|
||||
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
|
||||
%li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } }
|
||||
= button_tag class: %w[gl-button btn btn-link] do
|
||||
= status.titleize
|
||||
-# Encapsulate static class name `{{icon}}` inside #{} to bypass
|
||||
-# haml lint's ClassAttributeWithStaticValue
|
||||
%svg
|
||||
%use{ 'xlink:href': "#{'{{icon}}'}" }
|
||||
%span.js-filter-hint
|
||||
{{formattedKey}}
|
||||
#js-dropdown-operator.filtered-search-input-dropdown-menu.dropdown-menu
|
||||
%ul.filter-dropdown{ data: { dropdown: true, dynamic: true } }
|
||||
%li.filter-dropdown-item{ data: { value: "{{ title }}" } }
|
||||
%button.gl-button.btn.btn-link{ type: 'button' }
|
||||
{{ title }}
|
||||
%span.btn-helptext
|
||||
{{ help }}
|
||||
#js-dropdown-admin-runner-status.filtered-search-input-dropdown-menu.dropdown-menu
|
||||
%ul{ data: { dropdown: true } }
|
||||
- Ci::Runner::AVAILABLE_STATUSES.each do |status|
|
||||
%li.filter-dropdown-item{ data: { value: status } }
|
||||
= button_tag class: %w[gl-button btn btn-link] do
|
||||
= status.titleize
|
||||
|
||||
#js-dropdown-admin-runner-type.filtered-search-input-dropdown-menu.dropdown-menu
|
||||
%ul{ data: { dropdown: true } }
|
||||
- Ci::Runner::AVAILABLE_TYPES.each do |runner_type|
|
||||
%li.filter-dropdown-item{ data: { value: runner_type } }
|
||||
= button_tag class: %w[gl-button btn btn-link] do
|
||||
= runner_type.titleize
|
||||
|
||||
#js-dropdown-admin-runner-type.filtered-search-input-dropdown-menu.dropdown-menu
|
||||
%ul{ data: { dropdown: true } }
|
||||
|
@ -90,49 +100,41 @@
|
|||
= button_tag class: %w[gl-button btn btn-link] do
|
||||
= runner_type.titleize
|
||||
|
||||
#js-dropdown-admin-runner-type.filtered-search-input-dropdown-menu.dropdown-menu
|
||||
%ul{ data: { dropdown: true } }
|
||||
- Ci::Runner::AVAILABLE_TYPES.each do |runner_type|
|
||||
%li.filter-dropdown-item{ data: { value: runner_type } }
|
||||
= button_tag class: %w[gl-button btn btn-link] do
|
||||
= runner_type.titleize
|
||||
#js-dropdown-runner-tag.filtered-search-input-dropdown-menu.dropdown-menu
|
||||
%ul{ data: { dropdown: true } }
|
||||
%li.filter-dropdown-item{ data: { value: 'none' } }
|
||||
%button.gl-button.btn.btn-link
|
||||
= _('No Tag')
|
||||
%li.divider.droplab-item-ignore
|
||||
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
|
||||
%li.filter-dropdown-item
|
||||
%button.gl-button.btn.btn-link.js-data-value
|
||||
%span.dropdown-light-content
|
||||
{{name}}
|
||||
|
||||
#js-dropdown-runner-tag.filtered-search-input-dropdown-menu.dropdown-menu
|
||||
%ul{ data: { dropdown: true } }
|
||||
%li.filter-dropdown-item{ data: { value: 'none' } }
|
||||
%button.gl-button.btn.btn-link
|
||||
= _('No Tag')
|
||||
%li.divider.droplab-item-ignore
|
||||
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
|
||||
%li.filter-dropdown-item
|
||||
%button.gl-button.btn.btn-link.js-data-value
|
||||
%span.dropdown-light-content
|
||||
{{name}}
|
||||
= button_tag class: %w[clear-search hidden] do
|
||||
= sprite_icon('close', size: 16, css_class: 'clear-search-icon')
|
||||
.filter-dropdown-container
|
||||
= render 'sort_dropdown'
|
||||
|
||||
= button_tag class: %w[clear-search hidden] do
|
||||
= sprite_icon('close', size: 16, css_class: 'clear-search-icon')
|
||||
.filter-dropdown-container
|
||||
= render 'sort_dropdown'
|
||||
.col-sm-3.text-right-lg
|
||||
= _('Runners currently online: %{active_runners_count}') % { active_runners_count: @active_runners_count }
|
||||
- if @runners.any?
|
||||
.content-list{ data: { testid: 'runners-table' } }
|
||||
.table-holder
|
||||
.gl-responsive-table-row.table-row-header{ role: 'row' }
|
||||
.table-section.section-10{ role: 'rowheader' }= _('Type/State')
|
||||
.table-section.section-30{ role: 'rowheader' }= s_('Runners|Runner')
|
||||
.table-section.section-10{ role: 'rowheader' }= _('Version')
|
||||
.table-section.section-10{ role: 'rowheader' }= _('IP Address')
|
||||
.table-section.section-5{ role: 'rowheader' }= _('Projects')
|
||||
.table-section.section-5{ role: 'rowheader' }= _('Jobs')
|
||||
.table-section.section-10{ role: 'rowheader' }= _('Tags')
|
||||
.table-section.section-10{ role: 'rowheader' }= _('Last contact')
|
||||
.table-section.section-10{ role: 'rowheader' }
|
||||
|
||||
.col-sm-3.text-right-lg
|
||||
= _('Runners currently online: %{active_runners_count}') % { active_runners_count: @active_runners_count }
|
||||
|
||||
- if @runners.any?
|
||||
.content-list{ data: { testid: 'runners-table' } }
|
||||
.table-holder
|
||||
.gl-responsive-table-row.table-row-header{ role: 'row' }
|
||||
.table-section.section-10{ role: 'rowheader' }= _('Type/State')
|
||||
.table-section.section-30{ role: 'rowheader' }= s_('Runners|Runner')
|
||||
.table-section.section-10{ role: 'rowheader' }= _('Version')
|
||||
.table-section.section-10{ role: 'rowheader' }= _('IP Address')
|
||||
.table-section.section-5{ role: 'rowheader' }= _('Projects')
|
||||
.table-section.section-5{ role: 'rowheader' }= _('Jobs')
|
||||
.table-section.section-10{ role: 'rowheader' }= _('Tags')
|
||||
.table-section.section-10{ role: 'rowheader' }= _('Last contact')
|
||||
.table-section.section-10{ role: 'rowheader' }
|
||||
|
||||
- @runners.each do |runner|
|
||||
= render 'admin/runners/runner', runner: runner
|
||||
= paginate @runners, theme: 'gitlab'
|
||||
- else
|
||||
.nothing-here-block= _('No runners found')
|
||||
- @runners.each do |runner|
|
||||
= render 'admin/runners/runner', runner: runner
|
||||
= paginate @runners, theme: 'gitlab'
|
||||
- else
|
||||
.nothing-here-block= _('No runners found')
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
%br
|
||||
= _("And this registration token:")
|
||||
%br
|
||||
%code#registration_token= registration_token
|
||||
%code#registration_token{ data: {testid: 'registration_token' } }= registration_token
|
||||
= clipboard_button(target: '#registration_token', title: _("Copy token"), class: "btn-transparent btn-clipboard")
|
||||
|
||||
.gl-mt-3.gl-mb-3
|
||||
|
|
|
@ -5,3 +5,8 @@
|
|||
= sprite_icon('dot-grid', css_class: "dropdown-icon")
|
||||
= view_model[:activeTitle]
|
||||
= sprite_icon('chevron-down')
|
||||
|
||||
.hidden
|
||||
- view_model[:shortcuts].each do |shortcut|
|
||||
= link_to shortcut[:href], class: shortcut[:css_class] do
|
||||
= shortcut[:title]
|
||||
|
|
|
@ -13,6 +13,7 @@ module ApplicationWorker
|
|||
include Gitlab::SidekiqVersioning::Worker
|
||||
|
||||
LOGGING_EXTRA_KEY = 'extra'
|
||||
DEFAULT_DELAY_INTERVAL = 1
|
||||
|
||||
included do
|
||||
set_queue
|
||||
|
@ -51,6 +52,16 @@ module ApplicationWorker
|
|||
subclass.after_set_class_attribute { subclass.set_queue }
|
||||
end
|
||||
|
||||
def perform_async(*args)
|
||||
# Worker execution for workers with data_consistency set to :delayed or :sticky
|
||||
# will be delayed to give replication enough time to complete
|
||||
if utilizes_load_balancing_capabilities? && data_consistency_delayed_execution_feature_flag_enabled?
|
||||
perform_in(delay_interval, *args)
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
def set_queue
|
||||
queue_name = ::Gitlab::SidekiqConfig::WorkerRouter.global.route(self)
|
||||
sidekiq_options queue: queue_name # rubocop:disable Cop/SidekiqOptionsQueue
|
||||
|
@ -111,5 +122,15 @@ module ApplicationWorker
|
|||
Sidekiq::Client.push_bulk('class' => self, 'args' => args_list, 'at' => schedule)
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def data_consistency_delayed_execution_feature_flag_enabled?
|
||||
Feature.enabled?(:data_consistency_delayed_execution, default_enabled: :yaml)
|
||||
end
|
||||
|
||||
def delay_interval
|
||||
DEFAULT_DELAY_INTERVAL.seconds
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -71,6 +71,20 @@ module WorkerAttributes
|
|||
class_attributes[:urgency] || :low
|
||||
end
|
||||
|
||||
# Allows configuring worker's data_consistency.
|
||||
#
|
||||
# Worker can utilize Sidekiq readonly database replicas capabilities by setting data_consistency attribute.
|
||||
# Workers with data_consistency set to :delayed or :sticky, calling #perform_async
|
||||
# will be delayed in order to give replication process enough time to complete.
|
||||
#
|
||||
# - *data_consistency* values:
|
||||
# - 'always' - The job is required to use the primary database (default).
|
||||
# - 'sticky' - The uses a replica as long as possible. It switches to primary either on write or long replication lag.
|
||||
# - 'delayed' - The job would switch to primary only on write. It would use replica always.
|
||||
# If there's a long replication lag the job will be delayed, and only if the replica is not up to date on the next retry,
|
||||
# it will switch to the primary.
|
||||
# - *feature_flag* - allows you to toggle a job's `data_consistency, which permits you to safely toggle load balancing capabilities for a specific job.
|
||||
# If disabled, job will default to `:always`, which means that the job will always use the primary.
|
||||
def data_consistency(data_consistency, feature_flag: nil)
|
||||
raise ArgumentError, "Invalid data consistency: #{data_consistency}" unless VALID_DATA_CONSISTENCIES.include?(data_consistency)
|
||||
raise ArgumentError, 'Data consistency is already set' if class_attributes[:data_consistency]
|
||||
|
@ -85,11 +99,16 @@ module WorkerAttributes
|
|||
# Since the deduplication should always take into account the latest binary replication pointer into account,
|
||||
# not the first one, the deduplication will not work with sticky or delayed.
|
||||
# Follow up issue to improve this: https://gitlab.com/gitlab-org/gitlab/-/issues/325291
|
||||
if idempotent? && get_data_consistency != :always
|
||||
if idempotent? && utilizes_load_balancing_capabilities?
|
||||
raise ArgumentError, "Class can't be marked as idempotent if data_consistency is not set to :always"
|
||||
end
|
||||
end
|
||||
|
||||
# If data_consistency is not set to :always, worker will try to utilize load balancing capabilities and use the replica
|
||||
def utilizes_load_balancing_capabilities?
|
||||
get_data_consistency != :always
|
||||
end
|
||||
|
||||
def get_data_consistency
|
||||
class_attributes[:data_consistency] || :always
|
||||
end
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Allow issue type change for incidents
|
||||
merge_request: 61363
|
||||
author:
|
||||
type: changed
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Use cache for CI::Build runners check
|
||||
merge_request: 61998
|
||||
author:
|
||||
type: performance
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Remove some deprecated global routes
|
||||
merge_request: 34295
|
||||
author:
|
||||
type: removed
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
name: data_consistency_delayed_execution
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/61501
|
||||
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/331110
|
||||
milestone: '14.0'
|
||||
type: development
|
||||
group: group::memory
|
||||
default_enabled: false
|
|
@ -1,8 +1,8 @@
|
|||
---
|
||||
name: runners_cached_states
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/57367
|
||||
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/326989
|
||||
milestone: '13.11'
|
||||
name: runner_list_view_vue_ui
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/61241
|
||||
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/330969
|
||||
milestone: '13.12'
|
||||
type: development
|
||||
group: group::source code
|
||||
group: group::runner
|
||||
default_enabled: false
|
|
@ -252,38 +252,6 @@ Rails.application.routes.draw do
|
|||
end
|
||||
end
|
||||
|
||||
# Deprecated routes.
|
||||
# Will be removed as part of https://gitlab.com/gitlab-org/gitlab/-/issues/210024
|
||||
scope as: :deprecated do
|
||||
# Autocomplete
|
||||
get '/autocomplete/users' => 'autocomplete#users'
|
||||
get '/autocomplete/users/:id' => 'autocomplete#user'
|
||||
get '/autocomplete/projects' => 'autocomplete#projects'
|
||||
get '/autocomplete/award_emojis' => 'autocomplete#award_emojis'
|
||||
get '/autocomplete/merge_request_target_branches' => 'autocomplete#merge_request_target_branches'
|
||||
|
||||
Gitlab.ee do
|
||||
get '/autocomplete/project_groups' => 'autocomplete#project_groups'
|
||||
get '/autocomplete/project_routes' => 'autocomplete#project_routes'
|
||||
get '/autocomplete/namespace_routes' => 'autocomplete#namespace_routes'
|
||||
end
|
||||
|
||||
resources :invites, only: [:show], constraints: { id: /[A-Za-z0-9_-]+/ } do
|
||||
member do
|
||||
post :accept
|
||||
match :decline, via: [:get, :post]
|
||||
end
|
||||
end
|
||||
|
||||
resources :sent_notifications, only: [], constraints: { id: /\h{32}/ } do
|
||||
member do
|
||||
get :unsubscribe
|
||||
end
|
||||
end
|
||||
|
||||
resources :abuse_reports, only: [:new, :create]
|
||||
end
|
||||
|
||||
resources :groups, only: [:index, :new, :create] do
|
||||
post :preview_markdown
|
||||
end
|
||||
|
|
|
@ -417,8 +417,27 @@ To configure the `s3` storage driver in Omnibus:
|
|||
}
|
||||
```
|
||||
|
||||
- `regionendpoint` is only required when configuring an S3 compatible service such as MinIO. It takes a URL such as `http://127.0.0.1:9000`.
|
||||
If using with an [AWS S3 VPC endpoint](https://docs.aws.amazon.com/vpc/latest/privatelink/vpc-endpoints-s3.html),
|
||||
then set `regionendpoint` to your VPC endpoint address and set `path_style` to false:
|
||||
|
||||
```ruby
|
||||
registry['storage'] = {
|
||||
's3' => {
|
||||
'accesskey' => 's3-access-key',
|
||||
'secretkey' => 's3-secret-key-for-access-key',
|
||||
'bucket' => 'your-s3-bucket',
|
||||
'region' => 'your-s3-region',
|
||||
'regionendpoint' => 'your-s3-vpc-endpoint',
|
||||
'path_style' => false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `regionendpoint` is only required when configuring an S3 compatible service such as MinIO, or
|
||||
when using an AWS S3 VPC Endpoint.
|
||||
- `your-s3-bucket` should be the name of a bucket that exists, and can't include subdirectories.
|
||||
- `path_style` should be set to true to use `host/bucket_name/object` style paths instead of
|
||||
`bucket_name.host/object`. [Set to false for AWS S3](https://aws.amazon.com/blogs/aws/amazon-s3-path-deprecation-plan-the-rest-of-the-story/).
|
||||
|
||||
1. Save the file and [reconfigure GitLab](../restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect.
|
||||
|
||||
|
|
BIN
doc/user/project/issues/img/issue_type_change_v13_12.png
Normal file
BIN
doc/user/project/issues/img/issue_type_change_v13_12.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 51 KiB |
|
@ -326,6 +326,17 @@ In order to change the default issue closing pattern, GitLab administrators must
|
|||
[`gitlab.rb` or `gitlab.yml` file](../../../administration/issue_closing_pattern.md)
|
||||
of your installation.
|
||||
|
||||
## Change the issue type
|
||||
|
||||
Users with [developer permission](../../permissions.md)
|
||||
can change an issue's type. To do this, edit the issue and select an issue type from the
|
||||
**Issue type** selector menu:
|
||||
|
||||
- [Issue](index.md)
|
||||
- [Incident](../../../operations/incident_management/index.md)
|
||||
|
||||
![Change the issue type](img/issue_type_change_v13_12.png)
|
||||
|
||||
## Deleting issues
|
||||
|
||||
Users with [project owner permission](../../permissions.md) can delete an issue by
|
||||
|
|
|
@ -54,13 +54,11 @@ Currently the following names are reserved as top level groups:
|
|||
- `500.html`
|
||||
- `502.html`
|
||||
- `503.html`
|
||||
- `abuse_reports`
|
||||
- `admin`
|
||||
- `api`
|
||||
- `apple-touch-icon-precomposed.png`
|
||||
- `apple-touch-icon.png`
|
||||
- `assets`
|
||||
- `autocomplete`
|
||||
- `dashboard`
|
||||
- `deploy.html`
|
||||
- `explore`
|
||||
|
@ -71,7 +69,6 @@ Currently the following names are reserved as top level groups:
|
|||
- `health_check`
|
||||
- `help`
|
||||
- `import`
|
||||
- `invites`
|
||||
- `jwt`
|
||||
- `login`
|
||||
- `oauth`
|
||||
|
@ -81,7 +78,6 @@ Currently the following names are reserved as top level groups:
|
|||
- `robots.txt`
|
||||
- `s`
|
||||
- `search`
|
||||
- `sent_notifications`
|
||||
- `sitemap`
|
||||
- `sitemap.xml`
|
||||
- `sitemap.xml.gz`
|
||||
|
|
|
@ -6,9 +6,34 @@ module Gitlab
|
|||
def initialize
|
||||
@menu_builder = ::Gitlab::Nav::TopNavMenuBuilder.new
|
||||
@views = {}
|
||||
@shortcuts = []
|
||||
end
|
||||
|
||||
delegate :add_primary_menu_item, :add_secondary_menu_item, to: :@menu_builder
|
||||
# Using delegate hides the stacktrace for some errors, so we choose to be explicit.
|
||||
# https://gitlab.com/gitlab-org/gitlab/-/merge_requests/62047#note_579031091
|
||||
def add_primary_menu_item(**args)
|
||||
@menu_builder.add_primary_menu_item(**args)
|
||||
end
|
||||
|
||||
def add_secondary_menu_item(**args)
|
||||
@menu_builder.add_secondary_menu_item(**args)
|
||||
end
|
||||
|
||||
def add_shortcut(**args)
|
||||
item = ::Gitlab::Nav::TopNavMenuItem.build(**args)
|
||||
|
||||
@shortcuts.push(item)
|
||||
end
|
||||
|
||||
def add_primary_menu_item_with_shortcut(shortcut_class:, shortcut_href: nil, **args)
|
||||
add_primary_menu_item(**args)
|
||||
add_shortcut(
|
||||
id: "#{args.fetch(:id)}-shortcut",
|
||||
title: args.fetch(:title),
|
||||
href: shortcut_href || args.fetch(:href),
|
||||
css_class: shortcut_class
|
||||
)
|
||||
end
|
||||
|
||||
def add_view(name, props)
|
||||
@views[name] = props
|
||||
|
@ -19,6 +44,7 @@ module Gitlab
|
|||
|
||||
menu.merge({
|
||||
views: @views,
|
||||
shortcuts: @shortcuts,
|
||||
activeTitle: _('Menu')
|
||||
})
|
||||
end
|
||||
|
|
|
@ -21,13 +21,11 @@ module Gitlab
|
|||
500.html
|
||||
502.html
|
||||
503.html
|
||||
abuse_reports
|
||||
admin
|
||||
api
|
||||
apple-touch-icon-precomposed.png
|
||||
apple-touch-icon.png
|
||||
assets
|
||||
autocomplete
|
||||
dashboard
|
||||
deploy.html
|
||||
explore
|
||||
|
@ -38,7 +36,6 @@ module Gitlab
|
|||
health_check
|
||||
help
|
||||
import
|
||||
invites
|
||||
jwt
|
||||
login
|
||||
oauth
|
||||
|
@ -48,7 +45,6 @@ module Gitlab
|
|||
robots.txt
|
||||
s
|
||||
search
|
||||
sent_notifications
|
||||
sitemap
|
||||
sitemap.xml
|
||||
sitemap.xml.gz
|
||||
|
|
|
@ -18230,6 +18230,9 @@ msgstr ""
|
|||
msgid "Issue Boards"
|
||||
msgstr ""
|
||||
|
||||
msgid "Issue Type"
|
||||
msgstr ""
|
||||
|
||||
msgid "Issue already promoted to epic."
|
||||
msgstr ""
|
||||
|
||||
|
@ -32145,9 +32148,6 @@ msgstr ""
|
|||
msgid "The \"%{group_path}\" group allows you to sign in with your Single Sign-On Account"
|
||||
msgstr ""
|
||||
|
||||
msgid "The \"Require approval from CODEOWNERS\" setting was moved to %{banner_link_start}Protected Branches%{banner_link_end}"
|
||||
msgstr ""
|
||||
|
||||
msgid "The %{featureName} feature is part of your GitLab Ultimate trial."
|
||||
msgstr ""
|
||||
|
||||
|
|
|
@ -12,6 +12,10 @@ RSpec.describe Admin::RunnersController do
|
|||
describe '#index' do
|
||||
render_views
|
||||
|
||||
before do
|
||||
stub_feature_flags(runner_list_view_vue_ui: false)
|
||||
end
|
||||
|
||||
it 'lists all runners' do
|
||||
get :index
|
||||
|
||||
|
|
|
@ -17,6 +17,10 @@ RSpec.describe "Admin Runners" do
|
|||
describe "Runners page" do
|
||||
let(:pipeline) { create(:ci_pipeline) }
|
||||
|
||||
before do
|
||||
stub_feature_flags(runner_list_view_vue_ui: false)
|
||||
end
|
||||
|
||||
context "when there are runners" do
|
||||
it 'has all necessary texts' do
|
||||
runner = create(:ci_runner, contacted_at: Time.now)
|
||||
|
@ -240,7 +244,7 @@ RSpec.describe "Admin Runners" do
|
|||
it 'shows the label and does not show the project count' do
|
||||
visit admin_runners_path
|
||||
|
||||
within "#runner_#{runner.id}" do
|
||||
within "[data-testid='runner-row-#{runner.id}']" do
|
||||
expect(page).to have_selector '.badge', text: 'group'
|
||||
expect(page).to have_text 'n/a'
|
||||
end
|
||||
|
@ -253,7 +257,7 @@ RSpec.describe "Admin Runners" do
|
|||
|
||||
visit admin_runners_path
|
||||
|
||||
within "#runner_#{runner.id}" do
|
||||
within "[data-testid='runner-row-#{runner.id}']" do
|
||||
expect(page).to have_selector '.badge', text: 'shared'
|
||||
expect(page).to have_text 'n/a'
|
||||
end
|
||||
|
@ -267,12 +271,36 @@ RSpec.describe "Admin Runners" do
|
|||
|
||||
visit admin_runners_path
|
||||
|
||||
within "#runner_#{runner.id}" do
|
||||
within "[data-testid='runner-row-#{runner.id}']" do
|
||||
expect(page).to have_selector '.badge', text: 'specific'
|
||||
expect(page).to have_text '1'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'runners registration token' do
|
||||
let!(:token) { Gitlab::CurrentSettings.runners_registration_token }
|
||||
|
||||
before do
|
||||
visit admin_runners_path
|
||||
end
|
||||
|
||||
it 'has a registration token' do
|
||||
expect(page.find('[data-testid="registration_token"]')).to have_content(token)
|
||||
end
|
||||
|
||||
describe 'reset registration token' do
|
||||
let(:page_token) { find('[data-testid="registration_token"]').text }
|
||||
|
||||
before do
|
||||
click_button 'Reset registration token'
|
||||
end
|
||||
|
||||
it 'changes registration token' do
|
||||
expect(page_token).not_to eq token
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "Runner show page" do
|
||||
|
@ -381,28 +409,4 @@ RSpec.describe "Admin Runners" do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'runners registration token' do
|
||||
let!(:token) { Gitlab::CurrentSettings.runners_registration_token }
|
||||
|
||||
before do
|
||||
visit admin_runners_path
|
||||
end
|
||||
|
||||
it 'has a registration token' do
|
||||
expect(page.find('#registration_token')).to have_content(token)
|
||||
end
|
||||
|
||||
describe 'reload registration token' do
|
||||
let(:page_token) { find('#registration_token').text }
|
||||
|
||||
before do
|
||||
click_button 'Reset registration token'
|
||||
end
|
||||
|
||||
it 'changes registration token' do
|
||||
expect(page_token).not_to eq token
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -15,8 +15,6 @@ RSpec.describe 'Dashboard shortcuts', :js do
|
|||
end
|
||||
|
||||
it 'navigate to tabs' do
|
||||
pending_on_combined_menu_flag
|
||||
|
||||
find('body').send_keys([:shift, 'I'])
|
||||
|
||||
check_page_title('Issues')
|
||||
|
@ -49,8 +47,6 @@ RSpec.describe 'Dashboard shortcuts', :js do
|
|||
end
|
||||
|
||||
it 'navigate to tabs' do
|
||||
pending_on_combined_menu_flag
|
||||
|
||||
find('body').send_keys([:shift, 'G'])
|
||||
|
||||
find('.nothing-here-block')
|
||||
|
@ -74,8 +70,6 @@ RSpec.describe 'Dashboard shortcuts', :js do
|
|||
end
|
||||
|
||||
context 'with combined_menu: feature flag on' do
|
||||
let(:needs_rewrite_for_combined_menu_flag_on) { true }
|
||||
|
||||
before do
|
||||
stub_feature_flags(combined_menu: true)
|
||||
end
|
||||
|
@ -84,16 +78,10 @@ RSpec.describe 'Dashboard shortcuts', :js do
|
|||
end
|
||||
|
||||
context 'with combined_menu feature flag off' do
|
||||
let(:needs_rewrite_for_combined_menu_flag_on) { false }
|
||||
|
||||
before do
|
||||
stub_feature_flags(combined_menu: false)
|
||||
end
|
||||
|
||||
it_behaves_like 'combined_menu: feature flag examples'
|
||||
end
|
||||
|
||||
def pending_on_combined_menu_flag
|
||||
pending 'https://gitlab.com/gitlab-org/gitlab/-/merge_requests/56587' if needs_rewrite_for_combined_menu_flag_on
|
||||
end
|
||||
end
|
||||
|
|
|
@ -49,4 +49,42 @@ RSpec.describe 'Incident details', :js do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an incident `issue_type` is edited by a signed in user' do
|
||||
it 'routes the user to the incident details page when the `issue_type` is set to incident' do
|
||||
wait_for_requests
|
||||
project_path = "/#{project.full_path}"
|
||||
click_button 'Edit title and description'
|
||||
wait_for_requests
|
||||
|
||||
page.within('[data-testid="issuable-form"]') do
|
||||
click_button 'Incident'
|
||||
click_button 'Issue'
|
||||
click_button 'Save changes'
|
||||
|
||||
wait_for_requests
|
||||
|
||||
expect(page).to have_current_path("#{project_path}/-/issues/#{incident.iid}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when incident details are edited by a signed in user' do
|
||||
it 'routes the user to the incident details page when the `issue_type` is set to incident' do
|
||||
wait_for_requests
|
||||
project_path = "/#{project.full_path}"
|
||||
click_button 'Edit title and description'
|
||||
wait_for_requests
|
||||
|
||||
page.within('[data-testid="issuable-form"]') do
|
||||
click_button 'Incident'
|
||||
click_button 'Issue'
|
||||
click_button 'Save changes'
|
||||
|
||||
wait_for_requests
|
||||
|
||||
expect(page).to have_current_path("#{project_path}/-/issues/#{incident.iid}")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -6,6 +6,7 @@ RSpec.describe 'Issue Detail', :js do
|
|||
let(:user) { create(:user) }
|
||||
let(:project) { create(:project, :public) }
|
||||
let(:issue) { create(:issue, project: project, author: user) }
|
||||
let(:incident) { create(:incident, project: project, author: user) }
|
||||
|
||||
context 'when user displays the issue' do
|
||||
before do
|
||||
|
@ -21,10 +22,8 @@ RSpec.describe 'Issue Detail', :js do
|
|||
end
|
||||
|
||||
context 'when user displays the issue as an incident' do
|
||||
let(:issue) { create(:incident, project: project, author: user) }
|
||||
|
||||
before do
|
||||
visit project_issue_path(project, issue)
|
||||
visit project_issue_path(project, incident)
|
||||
wait_for_requests
|
||||
end
|
||||
|
||||
|
@ -58,9 +57,9 @@ RSpec.describe 'Issue Detail', :js do
|
|||
visit project_issue_path(project, issue)
|
||||
wait_for_requests
|
||||
|
||||
page.find('.js-issuable-edit').click
|
||||
click_button 'Edit title and description'
|
||||
fill_in 'issuable-title', with: 'issue title'
|
||||
click_button 'Save'
|
||||
click_button 'Save changes'
|
||||
wait_for_requests
|
||||
|
||||
Users::DestroyService.new(user).execute(user)
|
||||
|
@ -74,4 +73,58 @@ RSpec.describe 'Issue Detail', :js do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'user updates `issue_type` via the issue type dropdown' do
|
||||
context 'when an issue `issue_type` is edited by a signed in user' do
|
||||
before do
|
||||
sign_in(user)
|
||||
|
||||
visit project_issue_path(project, issue)
|
||||
wait_for_requests
|
||||
end
|
||||
|
||||
it 'routes the user to the incident details page when the `issue_type` is set to incident' do
|
||||
open_issue_edit_form
|
||||
|
||||
page.within('[data-testid="issuable-form"]') do
|
||||
update_type_select('Issue', 'Incident')
|
||||
|
||||
expect(page).to have_current_path(project_issues_incident_path(project, issue))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an incident `issue_type` is edited by a signed in user' do
|
||||
before do
|
||||
sign_in(user)
|
||||
|
||||
visit project_issue_path(project, incident)
|
||||
wait_for_requests
|
||||
end
|
||||
|
||||
it 'routes the user to the issue details page when the `issue_type` is set to issue' do
|
||||
open_issue_edit_form
|
||||
|
||||
page.within('[data-testid="issuable-form"]') do
|
||||
update_type_select('Incident', 'Issue')
|
||||
|
||||
expect(page).to have_current_path(project_issue_path(project, incident))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def update_type_select(from, to)
|
||||
click_button from
|
||||
click_button to
|
||||
click_button 'Save changes'
|
||||
|
||||
wait_for_requests
|
||||
end
|
||||
|
||||
def open_issue_edit_form
|
||||
wait_for_requests
|
||||
click_button 'Edit title and description'
|
||||
wait_for_requests
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { GlIntersectionObserver } from '@gitlab/ui';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { nextTick } from 'vue';
|
||||
import { useMockIntersectionObserver } from 'helpers/mock_dom_observer';
|
||||
import '~/behaviors/markdown/render_gfm';
|
||||
import IssuableApp from '~/issue_show/components/app.vue';
|
||||
|
@ -17,7 +18,7 @@ import {
|
|||
publishedIncidentUrl,
|
||||
secondRequest,
|
||||
zoomMeetingUrl,
|
||||
} from '../mock_data';
|
||||
} from '../mock_data/mock_data';
|
||||
|
||||
function formatText(text) {
|
||||
return text.trim().replace(/\s\s+/g, ' ');
|
||||
|
@ -36,12 +37,11 @@ describe('Issuable output', () => {
|
|||
let wrapper;
|
||||
|
||||
const findStickyHeader = () => wrapper.find('[data-testid="issue-sticky-header"]');
|
||||
|
||||
const findLockedBadge = () => wrapper.find('[data-testid="locked"]');
|
||||
|
||||
const findConfidentialBadge = () => wrapper.find('[data-testid="confidential"]');
|
||||
const findAlert = () => wrapper.find('.alert');
|
||||
|
||||
const mountComponent = (props = {}, options = {}) => {
|
||||
const mountComponent = (props = {}, options = {}, data = {}) => {
|
||||
wrapper = mount(IssuableApp, {
|
||||
propsData: { ...appProps, ...props },
|
||||
provide: {
|
||||
|
@ -53,6 +53,11 @@ describe('Issuable output', () => {
|
|||
HighlightBar: true,
|
||||
IncidentTabs: true,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
...data,
|
||||
};
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
@ -91,10 +96,8 @@ describe('Issuable output', () => {
|
|||
afterEach(() => {
|
||||
mock.restore();
|
||||
realtimeRequestCount = 0;
|
||||
|
||||
wrapper.vm.poll.stop();
|
||||
wrapper.destroy();
|
||||
wrapper = null;
|
||||
});
|
||||
|
||||
it('should render a title/description/edited and update title/description/edited on update', () => {
|
||||
|
@ -115,7 +118,7 @@ describe('Issuable output', () => {
|
|||
expect(formatText(editedText.text())).toMatch(/Edited[\s\S]+?by Some User/);
|
||||
expect(editedText.find('.author-link').attributes('href')).toMatch(/\/some_user$/);
|
||||
expect(editedText.find('time').text()).toBeTruthy();
|
||||
expect(wrapper.vm.state.lock_version).toEqual(1);
|
||||
expect(wrapper.vm.state.lock_version).toBe(initialRequest.lock_version);
|
||||
})
|
||||
.then(() => {
|
||||
wrapper.vm.poll.makeRequest();
|
||||
|
@ -133,7 +136,9 @@ describe('Issuable output', () => {
|
|||
|
||||
expect(editedText.find('.author-link').attributes('href')).toMatch(/\/other_user$/);
|
||||
expect(editedText.find('time').text()).toBeTruthy();
|
||||
expect(wrapper.vm.state.lock_version).toEqual(2);
|
||||
// As the lock_version value does not differ from the server,
|
||||
// we should not see an alert
|
||||
expect(findAlert().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -172,7 +177,7 @@ describe('Issuable output', () => {
|
|||
${'zoomMeetingUrl'} | ${zoomMeetingUrl}
|
||||
${'publishedIncidentUrl'} | ${publishedIncidentUrl}
|
||||
`('sets the $prop correctly on underlying pinned links', ({ prop, value }) => {
|
||||
expect(wrapper.vm[prop]).toEqual(value);
|
||||
expect(wrapper.vm[prop]).toBe(value);
|
||||
expect(wrapper.find(`[data-testid="${prop}"]`).attributes('href')).toBe(value);
|
||||
});
|
||||
});
|
||||
|
@ -374,9 +379,9 @@ describe('Issuable output', () => {
|
|||
});
|
||||
})
|
||||
.then(() => {
|
||||
expect(wrapper.vm.formState.lockedWarningVisible).toEqual(true);
|
||||
expect(wrapper.vm.formState.lock_version).toEqual(1);
|
||||
expect(wrapper.find('.alert').exists()).toBe(true);
|
||||
expect(wrapper.vm.formState.lockedWarningVisible).toBe(true);
|
||||
expect(wrapper.vm.formState.lock_version).toBe(1);
|
||||
expect(findAlert().exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -530,7 +535,7 @@ describe('Issuable output', () => {
|
|||
`('$title', async ({ state }) => {
|
||||
wrapper.setProps({ issuableStatus: state });
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(findStickyHeader().text()).toContain(IssuableStatusText[state]);
|
||||
});
|
||||
|
@ -542,7 +547,7 @@ describe('Issuable output', () => {
|
|||
`('$title', async ({ isConfidential }) => {
|
||||
wrapper.setProps({ isConfidential });
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(findConfidentialBadge().exists()).toBe(isConfidential);
|
||||
});
|
||||
|
@ -554,7 +559,7 @@ describe('Issuable output', () => {
|
|||
`('$title', async ({ isLocked }) => {
|
||||
wrapper.setProps({ isLocked });
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(findLockedBadge().exists()).toBe(isLocked);
|
||||
});
|
||||
|
@ -562,9 +567,9 @@ describe('Issuable output', () => {
|
|||
});
|
||||
|
||||
describe('Composable description component', () => {
|
||||
const findIncidentTabs = () => wrapper.find(IncidentTabs);
|
||||
const findDescriptionComponent = () => wrapper.find(DescriptionComponent);
|
||||
const findPinnedLinks = () => wrapper.find(PinnedLinks);
|
||||
const findIncidentTabs = () => wrapper.findComponent(IncidentTabs);
|
||||
const findDescriptionComponent = () => wrapper.findComponent(DescriptionComponent);
|
||||
const findPinnedLinks = () => wrapper.findComponent(PinnedLinks);
|
||||
const borderClass = 'gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-mb-6';
|
||||
|
||||
describe('when using description component', () => {
|
||||
|
|
|
@ -5,7 +5,7 @@ import { TEST_HOST } from 'helpers/test_constants';
|
|||
import mountComponent from 'helpers/vue_mount_component_helper';
|
||||
import Description from '~/issue_show/components/description.vue';
|
||||
import TaskList from '~/task_list';
|
||||
import { descriptionProps as props } from '../mock_data';
|
||||
import { descriptionProps as props } from '../mock_data/mock_data';
|
||||
|
||||
jest.mock('~/task_list');
|
||||
|
||||
|
|
|
@ -1,113 +1,163 @@
|
|||
import Vue from 'vue';
|
||||
import editActions from '~/issue_show/components/edit_actions.vue';
|
||||
import { GlButton, GlModal } from '@gitlab/ui';
|
||||
import { createLocalVue } from '@vue/test-utils';
|
||||
import VueApollo from 'vue-apollo';
|
||||
import createMockApollo from 'helpers/mock_apollo_helper';
|
||||
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import IssuableEditActions from '~/issue_show/components/edit_actions.vue';
|
||||
import eventHub from '~/issue_show/event_hub';
|
||||
import Store from '~/issue_show/stores';
|
||||
|
||||
describe('Edit Actions components', () => {
|
||||
let vm;
|
||||
import {
|
||||
getIssueStateQueryResponse,
|
||||
updateIssueStateQueryResponse,
|
||||
} from '../mock_data/apollo_mock';
|
||||
|
||||
beforeEach((done) => {
|
||||
const Component = Vue.extend(editActions);
|
||||
const store = new Store({
|
||||
titleHtml: '',
|
||||
descriptionHtml: '',
|
||||
issuableRef: '',
|
||||
});
|
||||
store.formState.title = 'test';
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(VueApollo);
|
||||
|
||||
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
|
||||
describe('Edit Actions component', () => {
|
||||
let wrapper;
|
||||
let fakeApollo;
|
||||
let mockIssueStateData;
|
||||
|
||||
vm = new Component({
|
||||
propsData: {
|
||||
canDestroy: true,
|
||||
formState: store.formState,
|
||||
issuableType: 'issue',
|
||||
const mockResolvers = {
|
||||
Query: {
|
||||
issueState() {
|
||||
return {
|
||||
__typename: 'IssueState',
|
||||
rawData: mockIssueStateData(),
|
||||
};
|
||||
},
|
||||
}).$mount();
|
||||
},
|
||||
};
|
||||
|
||||
Vue.nextTick(done);
|
||||
const modalId = 'delete-issuable-modal-1';
|
||||
|
||||
const createComponent = ({ props, data } = {}) => {
|
||||
fakeApollo = createMockApollo([], mockResolvers);
|
||||
|
||||
wrapper = shallowMountExtended(IssuableEditActions, {
|
||||
apolloProvider: fakeApollo,
|
||||
propsData: {
|
||||
formState: {
|
||||
title: 'GitLab Issue',
|
||||
},
|
||||
canDestroy: true,
|
||||
issuableType: 'issue',
|
||||
...props,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
issueState: {},
|
||||
modalId,
|
||||
...data,
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
async function deleteIssuable(localWrapper) {
|
||||
localWrapper.findComponent(GlModal).vm.$emit('primary');
|
||||
}
|
||||
|
||||
const findModal = () => wrapper.findComponent(GlModal);
|
||||
const findEditButtons = () => wrapper.findAllComponents(GlButton);
|
||||
const findDeleteButton = () => wrapper.findByTestId('issuable-delete-button');
|
||||
const findSaveButton = () => wrapper.findByTestId('issuable-save-button');
|
||||
const findCancelButton = () => wrapper.findByTestId('issuable-cancel-button');
|
||||
|
||||
beforeEach(() => {
|
||||
mockIssueStateData = jest.fn();
|
||||
createComponent();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
it('renders all buttons as enabled', () => {
|
||||
expect(vm.$el.querySelectorAll('.disabled').length).toBe(0);
|
||||
|
||||
expect(vm.$el.querySelectorAll('[disabled]').length).toBe(0);
|
||||
});
|
||||
|
||||
it('does not render delete button if canUpdate is false', (done) => {
|
||||
vm.canDestroy = false;
|
||||
|
||||
Vue.nextTick(() => {
|
||||
expect(vm.$el.querySelector('.btn-danger')).toBeNull();
|
||||
|
||||
done();
|
||||
const buttons = findEditButtons().wrappers;
|
||||
buttons.forEach((button) => {
|
||||
expect(button.attributes('disabled')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
it('disables submit button when title is blank', (done) => {
|
||||
vm.formState.title = '';
|
||||
|
||||
Vue.nextTick(() => {
|
||||
expect(vm.$el.querySelector('.btn-confirm').getAttribute('disabled')).toBe('disabled');
|
||||
|
||||
done();
|
||||
});
|
||||
it('does not render the delete button if canDestroy is false', () => {
|
||||
createComponent({ props: { canDestroy: false } });
|
||||
expect(findDeleteButton().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('should not show delete button if showDeleteButton is false', (done) => {
|
||||
vm.showDeleteButton = false;
|
||||
it('disables save button when title is blank', () => {
|
||||
createComponent({ props: { formState: { title: '', issue_type: '' } } });
|
||||
|
||||
Vue.nextTick(() => {
|
||||
expect(vm.$el.querySelector('.btn-danger')).toBeNull();
|
||||
done();
|
||||
});
|
||||
expect(findSaveButton().attributes('disabled')).toBe('true');
|
||||
});
|
||||
|
||||
it('does not render the delete button if showDeleteButton is false', () => {
|
||||
createComponent({ props: { showDeleteButton: false } });
|
||||
|
||||
expect(findDeleteButton().exists()).toBe(false);
|
||||
});
|
||||
|
||||
describe('updateIssuable', () => {
|
||||
it('sends update.issauble event when clicking save button', () => {
|
||||
vm.$el.querySelector('.btn-confirm').click();
|
||||
|
||||
expect(eventHub.$emit).toHaveBeenCalledWith('update.issuable');
|
||||
beforeEach(() => {
|
||||
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('disabled button after clicking save button', (done) => {
|
||||
vm.$el.querySelector('.btn-confirm').click();
|
||||
it('sends update.issauble event when clicking save button', () => {
|
||||
findSaveButton().vm.$emit('click', { preventDefault: jest.fn() });
|
||||
|
||||
Vue.nextTick(() => {
|
||||
expect(vm.$el.querySelector('.btn-confirm').getAttribute('disabled')).toBe('disabled');
|
||||
|
||||
done();
|
||||
});
|
||||
expect(eventHub.$emit).toHaveBeenCalledWith('update.issuable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('closeForm', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('emits close.form when clicking cancel', () => {
|
||||
vm.$el.querySelector('.btn-default').click();
|
||||
findCancelButton().vm.$emit('click');
|
||||
|
||||
expect(eventHub.$emit).toHaveBeenCalledWith('close.form');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteIssuable', () => {
|
||||
it('sends delete.issuable event when clicking save button', () => {
|
||||
jest.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
vm.$el.querySelector('.btn-danger').click();
|
||||
describe('renders create modal with the correct information', () => {
|
||||
it('renders correct modal id', () => {
|
||||
expect(findModal().attributes('modalid')).toBe(modalId);
|
||||
});
|
||||
});
|
||||
|
||||
expect(eventHub.$emit).toHaveBeenCalledWith('delete.issuable', { destroy_confirm: true });
|
||||
describe('deleteIssuable', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('does no actions when confirm is false', (done) => {
|
||||
jest.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
vm.$el.querySelector('.btn-danger').click();
|
||||
it('does not send the `delete.issuable` event when clicking delete button', () => {
|
||||
findDeleteButton().vm.$emit('click');
|
||||
expect(eventHub.$emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
Vue.nextTick(() => {
|
||||
expect(eventHub.$emit).not.toHaveBeenCalledWith('delete.issuable');
|
||||
it('sends the `delete.issuable` event when clicking the delete confirm button', async () => {
|
||||
expect(eventHub.$emit).toHaveBeenCalledTimes(0);
|
||||
await deleteIssuable(wrapper);
|
||||
expect(eventHub.$emit).toHaveBeenCalledWith('delete.issuable', { destroy_confirm: true });
|
||||
expect(eventHub.$emit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
expect(vm.$el.querySelector('.btn-danger .fa')).toBeNull();
|
||||
describe('with Apollo cache mock', () => {
|
||||
it('renders the right delete button text per apollo cache type', async () => {
|
||||
mockIssueStateData.mockResolvedValue(getIssueStateQueryResponse);
|
||||
await waitForPromises();
|
||||
expect(findDeleteButton().text()).toBe('Delete issue');
|
||||
});
|
||||
|
||||
done();
|
||||
});
|
||||
it('should not change the delete button text per apollo cache mutation', async () => {
|
||||
mockIssueStateData.mockResolvedValue(updateIssueStateQueryResponse);
|
||||
await waitForPromises();
|
||||
expect(findDeleteButton().text()).toBe('Delete issue');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
84
spec/frontend/issue_show/components/fields/type_spec.js
Normal file
84
spec/frontend/issue_show/components/fields/type_spec.js
Normal file
|
@ -0,0 +1,84 @@
|
|||
import { GlFormGroup, GlDropdown, GlDropdownItem } from '@gitlab/ui';
|
||||
import { shallowMount, createLocalVue } from '@vue/test-utils';
|
||||
import VueApollo from 'vue-apollo';
|
||||
import createMockApollo from 'helpers/mock_apollo_helper';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import IssueTypeField, { i18n } from '~/issue_show/components/fields/type.vue';
|
||||
import { IssuableTypes } from '~/issue_show/constants';
|
||||
import {
|
||||
getIssueStateQueryResponse,
|
||||
updateIssueStateQueryResponse,
|
||||
} from '../../mock_data/apollo_mock';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(VueApollo);
|
||||
|
||||
describe('Issue type field component', () => {
|
||||
let wrapper;
|
||||
let fakeApollo;
|
||||
let mockIssueStateData;
|
||||
|
||||
const mockResolvers = {
|
||||
Query: {
|
||||
issueState() {
|
||||
return {
|
||||
__typename: 'IssueState',
|
||||
rawData: mockIssueStateData(),
|
||||
};
|
||||
},
|
||||
},
|
||||
Mutation: {
|
||||
updateIssueState: jest.fn().mockResolvedValue(updateIssueStateQueryResponse),
|
||||
},
|
||||
};
|
||||
|
||||
const findTypeFromGroup = () => wrapper.findComponent(GlFormGroup);
|
||||
const findTypeFromDropDown = () => wrapper.findComponent(GlDropdown);
|
||||
const findTypeFromDropDownItems = () => wrapper.findAllComponents(GlDropdownItem);
|
||||
|
||||
const createComponent = ({ data } = {}) => {
|
||||
fakeApollo = createMockApollo([], mockResolvers);
|
||||
|
||||
wrapper = shallowMount(IssueTypeField, {
|
||||
localVue,
|
||||
apolloProvider: fakeApollo,
|
||||
data() {
|
||||
return {
|
||||
issueState: {},
|
||||
...data,
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockIssueStateData = jest.fn();
|
||||
createComponent();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
it('renders a form group with the correct label', () => {
|
||||
expect(findTypeFromGroup().attributes('label')).toBe(i18n.label);
|
||||
});
|
||||
|
||||
it('renders a form select with the `issue_type` value', () => {
|
||||
expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.issue);
|
||||
});
|
||||
|
||||
describe('with Apollo cache mock', () => {
|
||||
it('renders the selected issueType', async () => {
|
||||
mockIssueStateData.mockResolvedValue(getIssueStateQueryResponse);
|
||||
await waitForPromises();
|
||||
expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.issue);
|
||||
});
|
||||
|
||||
it('updates the `issue_type` in the apollo cache when the value is changed', async () => {
|
||||
findTypeFromDropDownItems().at(1).vm.$emit('click', IssuableTypes.incident);
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(findTypeFromDropDown().attributes('value')).toBe(IssuableTypes.incident);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -2,6 +2,7 @@ import { GlAlert } from '@gitlab/ui';
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
import Autosave from '~/autosave';
|
||||
import DescriptionTemplate from '~/issue_show/components/fields/description_template.vue';
|
||||
import IssueTypeField from '~/issue_show/components/fields/type.vue';
|
||||
import formComponent from '~/issue_show/components/form.vue';
|
||||
import LockedWarning from '~/issue_show/components/locked_warning.vue';
|
||||
import eventHub from '~/issue_show/event_hub';
|
||||
|
@ -39,6 +40,7 @@ describe('Inline edit form component', () => {
|
|||
};
|
||||
|
||||
const findDescriptionTemplate = () => wrapper.findComponent(DescriptionTemplate);
|
||||
const findIssuableTypeField = () => wrapper.findComponent(IssueTypeField);
|
||||
const findLockedWarning = () => wrapper.findComponent(LockedWarning);
|
||||
const findAlert = () => wrapper.findComponent(GlAlert);
|
||||
|
||||
|
@ -68,6 +70,21 @@ describe('Inline edit form component', () => {
|
|||
expect(findDescriptionTemplate().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it.each`
|
||||
issuableType | value
|
||||
${'issue'} | ${true}
|
||||
${'epic'} | ${false}
|
||||
`(
|
||||
'when `issue_type` is set to "$issuableType" rendering the type select will be "$value"',
|
||||
({ issuableType, value }) => {
|
||||
createComponent({
|
||||
issuableType,
|
||||
});
|
||||
|
||||
expect(findIssuableTypeField().exists()).toBe(value);
|
||||
},
|
||||
);
|
||||
|
||||
it('hides locked warning by default', () => {
|
||||
createComponent();
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ import IncidentTabs from '~/issue_show/components/incidents/incident_tabs.vue';
|
|||
import INVALID_URL from '~/lib/utils/invalid_url';
|
||||
import Tracking from '~/tracking';
|
||||
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
|
||||
import { descriptionProps } from '../../mock_data';
|
||||
import { descriptionProps } from '../../mock_data/mock_data';
|
||||
|
||||
const mockAlert = {
|
||||
__typename: 'AlertManagementAlert',
|
||||
|
|
|
@ -5,7 +5,7 @@ import { initIssuableApp } from '~/issue_show/issue';
|
|||
import * as parseData from '~/issue_show/utils/parse_data';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
import createStore from '~/notes/stores';
|
||||
import { appProps } from './mock_data';
|
||||
import { appProps } from './mock_data/mock_data';
|
||||
|
||||
const mock = new MockAdapter(axios);
|
||||
mock.onGet().reply(200);
|
||||
|
|
9
spec/frontend/issue_show/mock_data/apollo_mock.js
Normal file
9
spec/frontend/issue_show/mock_data/apollo_mock.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
export const getIssueStateQueryResponse = {
|
||||
issueType: 'issue',
|
||||
isDirty: false,
|
||||
};
|
||||
|
||||
export const updateIssueStateQueryResponse = {
|
||||
issueType: 'incident',
|
||||
isDirty: true,
|
||||
};
|
|
@ -48,6 +48,7 @@ export const appProps = {
|
|||
initialDescriptionHtml: 'test',
|
||||
initialDescriptionText: 'test',
|
||||
lockVersion: 1,
|
||||
issueType: 'issue',
|
||||
markdownPreviewPath: '/',
|
||||
markdownDocsPath: '/',
|
||||
projectNamespace: '/',
|
|
@ -0,0 +1,42 @@
|
|||
import { GlLink } from '@gitlab/ui';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import RunnerNameCell from '~/runner/components/cells/runner_name_cell.vue';
|
||||
|
||||
const mockId = '1';
|
||||
const mockShortSha = '2P6oDVDm';
|
||||
const mockDescription = 'runner-1';
|
||||
|
||||
describe('RunnerTypeCell', () => {
|
||||
let wrapper;
|
||||
|
||||
const findLink = () => wrapper.findComponent(GlLink);
|
||||
|
||||
const createComponent = () => {
|
||||
wrapper = mount(RunnerNameCell, {
|
||||
propsData: {
|
||||
runner: {
|
||||
id: `gid://gitlab/Ci::Runner/${mockId}`,
|
||||
shortSha: mockShortSha,
|
||||
description: mockDescription,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
createComponent();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
it('Displays the runner link with id and short token', () => {
|
||||
expect(findLink().text()).toBe(`#${mockId} (${mockShortSha})`);
|
||||
expect(findLink().attributes('href')).toBe(`/admin/runners/${mockId}`);
|
||||
});
|
||||
|
||||
it('Displays the runner description', () => {
|
||||
expect(wrapper.text()).toContain(mockDescription);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,48 @@
|
|||
import { GlBadge } from '@gitlab/ui';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import RunnerTypeCell from '~/runner/components/cells/runner_type_cell.vue';
|
||||
import { INSTANCE_TYPE } from '~/runner/constants';
|
||||
|
||||
describe('RunnerTypeCell', () => {
|
||||
let wrapper;
|
||||
|
||||
const findBadges = () => wrapper.findAllComponents(GlBadge);
|
||||
|
||||
const createComponent = ({ runner = {} } = {}) => {
|
||||
wrapper = mount(RunnerTypeCell, {
|
||||
propsData: {
|
||||
runner: {
|
||||
runnerType: INSTANCE_TYPE,
|
||||
active: true,
|
||||
locked: false,
|
||||
...runner,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
it('Displays the runner type', () => {
|
||||
createComponent();
|
||||
|
||||
expect(findBadges()).toHaveLength(1);
|
||||
expect(findBadges().at(0).text()).toBe('shared');
|
||||
});
|
||||
|
||||
it('Displays locked and paused states', () => {
|
||||
createComponent({
|
||||
runner: {
|
||||
active: false,
|
||||
locked: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(findBadges()).toHaveLength(3);
|
||||
expect(findBadges().at(0).text()).toBe('shared');
|
||||
expect(findBadges().at(1).text()).toBe('locked');
|
||||
expect(findBadges().at(2).text()).toBe('paused');
|
||||
});
|
||||
});
|
115
spec/frontend/runner/components/runner_list_spec.js
Normal file
115
spec/frontend/runner/components/runner_list_spec.js
Normal file
|
@ -0,0 +1,115 @@
|
|||
import { GlLink, GlSkeletonLoader } from '@gitlab/ui';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
|
||||
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
|
||||
import RunnerList from '~/runner/components/runner_list.vue';
|
||||
import { runnersData } from '../mock_data';
|
||||
|
||||
const mockRunners = runnersData.data.runners.nodes;
|
||||
const mockActiveRunnersCount = mockRunners.length;
|
||||
|
||||
describe('RunnerList', () => {
|
||||
let wrapper;
|
||||
|
||||
const findActiveRunnersMessage = () => wrapper.findByTestId('active-runners-message');
|
||||
const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
|
||||
const findHeaders = () => wrapper.findAll('th');
|
||||
const findRows = () => wrapper.findAll('[data-testid^="runner-row-"]');
|
||||
const findCell = ({ row = 0, fieldKey }) =>
|
||||
findRows().at(row).find(`[data-testid="td-${fieldKey}"]`);
|
||||
|
||||
const createComponent = ({ props = {} } = {}) => {
|
||||
wrapper = extendedWrapper(
|
||||
mount(RunnerList, {
|
||||
propsData: {
|
||||
runners: mockRunners,
|
||||
activeRunnersCount: mockActiveRunnersCount,
|
||||
...props,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
createComponent();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
it('Displays active runner count', () => {
|
||||
expect(findActiveRunnersMessage().text()).toBe(
|
||||
`Runners currently online: ${mockActiveRunnersCount}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('Displays a large active runner count', () => {
|
||||
createComponent({ props: { activeRunnersCount: 2000 } });
|
||||
|
||||
expect(findActiveRunnersMessage().text()).toBe('Runners currently online: 2,000');
|
||||
});
|
||||
|
||||
it('Displays headers', () => {
|
||||
const headerLabels = findHeaders().wrappers.map((w) => w.text());
|
||||
|
||||
expect(headerLabels).toEqual([
|
||||
'Type/State',
|
||||
'Runner',
|
||||
'Version',
|
||||
'IP Address',
|
||||
'Projects',
|
||||
'Jobs',
|
||||
'Tags',
|
||||
'Last contact',
|
||||
'', // actions has no label
|
||||
]);
|
||||
});
|
||||
|
||||
it('Displays a list of runners', () => {
|
||||
expect(findRows()).toHaveLength(2);
|
||||
|
||||
expect(findSkeletonLoader().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('Displays details of a runner', () => {
|
||||
const { id, description, version, ipAddress, shortSha } = mockRunners[0];
|
||||
|
||||
// Badges
|
||||
expect(findCell({ fieldKey: 'type' }).text()).toMatchInterpolatedText('shared locked');
|
||||
|
||||
// Runner identifier
|
||||
expect(findCell({ fieldKey: 'name' }).text()).toContain(
|
||||
`#${getIdFromGraphQLId(id)} (${shortSha})`,
|
||||
);
|
||||
expect(findCell({ fieldKey: 'name' }).text()).toContain(description);
|
||||
|
||||
// Other fields: some cells are empty in the first iteration
|
||||
// See https://gitlab.com/gitlab-org/gitlab/-/issues/329658#pending-features
|
||||
expect(findCell({ fieldKey: 'version' }).text()).toBe(version);
|
||||
expect(findCell({ fieldKey: 'ipAddress' }).text()).toBe(ipAddress);
|
||||
expect(findCell({ fieldKey: 'projectCount' }).text()).toBe('');
|
||||
expect(findCell({ fieldKey: 'jobCount' }).text()).toBe('');
|
||||
expect(findCell({ fieldKey: 'tagList' }).text()).toBe('');
|
||||
expect(findCell({ fieldKey: 'contactedAt' }).text()).toEqual(expect.any(String));
|
||||
expect(findCell({ fieldKey: 'actions' }).text()).toBe('');
|
||||
});
|
||||
|
||||
it('Links to the runner page', () => {
|
||||
const { id } = mockRunners[0];
|
||||
|
||||
expect(findCell({ fieldKey: 'name' }).find(GlLink).attributes('href')).toBe(
|
||||
`/admin/runners/${getIdFromGraphQLId(id)}`,
|
||||
);
|
||||
});
|
||||
|
||||
describe('When data is loading', () => {
|
||||
beforeEach(() => {
|
||||
createComponent({ props: { loading: true } });
|
||||
});
|
||||
|
||||
it('shows an skeleton loader', () => {
|
||||
expect(findSkeletonLoader().exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,84 @@
|
|||
import { GlSprintf } from '@gitlab/ui';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { TEST_HOST } from 'helpers/test_constants';
|
||||
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
|
||||
import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue';
|
||||
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
|
||||
import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue';
|
||||
|
||||
const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN';
|
||||
const mockRunnerInstallHelpPage = 'https://docs.gitlab.com/runner/install/';
|
||||
|
||||
describe('RunnerManualSetupHelp', () => {
|
||||
let wrapper;
|
||||
let originalGon;
|
||||
|
||||
const findRunnerInstructions = () => wrapper.findComponent(RunnerInstructions);
|
||||
const findClipboardButtons = () => wrapper.findAllComponents(ClipboardButton);
|
||||
const findRunnerHelpTitle = () => wrapper.findByTestId('runner-help-title');
|
||||
const findCoordinatorUrl = () => wrapper.findByTestId('coordinator-url');
|
||||
const findRegistrationToken = () => wrapper.findByTestId('registration-token');
|
||||
const findRunnerHelpLink = () => wrapper.findByTestId('runner-help-link');
|
||||
|
||||
const createComponent = ({ props = {} } = {}) => {
|
||||
wrapper = extendedWrapper(
|
||||
shallowMount(RunnerManualSetupHelp, {
|
||||
provide: {
|
||||
runnerInstallHelpPage: mockRunnerInstallHelpPage,
|
||||
},
|
||||
propsData: {
|
||||
registrationToken: mockRegistrationToken,
|
||||
...props,
|
||||
},
|
||||
stubs: {
|
||||
GlSprintf,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
originalGon = global.gon;
|
||||
global.gon = { gitlab_url: TEST_HOST };
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
global.gon = originalGon;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
createComponent();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
it('Title contains the default runner type', () => {
|
||||
expect(findRunnerHelpTitle().text()).toMatchInterpolatedText('Set up a shared runner manually');
|
||||
});
|
||||
|
||||
it('Title contains the group runner type', () => {
|
||||
createComponent({ props: { typeName: 'group' } });
|
||||
|
||||
expect(findRunnerHelpTitle().text()).toMatchInterpolatedText('Set up a group runner manually');
|
||||
});
|
||||
|
||||
it('Runner Install Page link', () => {
|
||||
expect(findRunnerHelpLink().attributes('href')).toBe(mockRunnerInstallHelpPage);
|
||||
});
|
||||
|
||||
it('Displays the coordinator URL token', () => {
|
||||
expect(findCoordinatorUrl().text()).toBe(TEST_HOST);
|
||||
expect(findClipboardButtons().at(0).props('text')).toBe(TEST_HOST);
|
||||
});
|
||||
|
||||
it('Displays the registration token', () => {
|
||||
expect(findRegistrationToken().text()).toBe(mockRegistrationToken);
|
||||
expect(findClipboardButtons().at(1).props('text')).toBe(mockRegistrationToken);
|
||||
});
|
||||
|
||||
it('Displays the runner instructions', () => {
|
||||
expect(findRunnerInstructions().exists()).toBe(true);
|
||||
});
|
||||
});
|
64
spec/frontend/runner/components/runner_tags_spec.js
Normal file
64
spec/frontend/runner/components/runner_tags_spec.js
Normal file
|
@ -0,0 +1,64 @@
|
|||
import { GlBadge } from '@gitlab/ui';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import RunnerTags from '~/runner/components/runner_tags.vue';
|
||||
|
||||
describe('RunnerTags', () => {
|
||||
let wrapper;
|
||||
|
||||
const findBadge = () => wrapper.findComponent(GlBadge);
|
||||
const findBadgesAt = (i = 0) => wrapper.findAllComponents(GlBadge).at(i);
|
||||
|
||||
const createComponent = ({ props = {} } = {}) => {
|
||||
wrapper = shallowMount(RunnerTags, {
|
||||
propsData: {
|
||||
tagList: ['tag1', 'tag2'],
|
||||
...props,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
createComponent();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
it('Displays tags text', () => {
|
||||
expect(wrapper.text()).toMatchInterpolatedText('tag1 tag2');
|
||||
|
||||
expect(findBadgesAt(0).text()).toBe('tag1');
|
||||
expect(findBadgesAt(1).text()).toBe('tag2');
|
||||
});
|
||||
|
||||
it('Displays tags with correct style', () => {
|
||||
expect(findBadge().props('size')).toBe('md');
|
||||
expect(findBadge().props('variant')).toBe('info');
|
||||
});
|
||||
|
||||
it('Displays tags with small size', () => {
|
||||
createComponent({
|
||||
props: { size: 'sm' },
|
||||
});
|
||||
|
||||
expect(findBadge().props('size')).toBe('sm');
|
||||
});
|
||||
|
||||
it('Displays tags with a variant', () => {
|
||||
createComponent({
|
||||
props: { variant: 'warning' },
|
||||
});
|
||||
|
||||
expect(findBadge().props('variant')).toBe('warning');
|
||||
});
|
||||
|
||||
it('Is empty when there are no tags', () => {
|
||||
createComponent({
|
||||
props: { tagList: null },
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toBe('');
|
||||
expect(findBadge().exists()).toBe(false);
|
||||
});
|
||||
});
|
32
spec/frontend/runner/components/runner_type_help_spec.js
Normal file
32
spec/frontend/runner/components/runner_type_help_spec.js
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { GlBadge } from '@gitlab/ui';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import RunnerTypeHelp from '~/runner/components/runner_type_help.vue';
|
||||
|
||||
describe('RunnerTypeHelp', () => {
|
||||
let wrapper;
|
||||
|
||||
const findBadges = () => wrapper.findAllComponents(GlBadge);
|
||||
|
||||
const createComponent = () => {
|
||||
wrapper = mount(RunnerTypeHelp);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
createComponent();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
it('Displays each of the runner types', () => {
|
||||
expect(findBadges().at(0).text()).toBe('shared');
|
||||
expect(findBadges().at(1).text()).toBe('group');
|
||||
expect(findBadges().at(2).text()).toBe('specific');
|
||||
});
|
||||
|
||||
it('Displays runner states', () => {
|
||||
expect(findBadges().at(3).text()).toBe('locked');
|
||||
expect(findBadges().at(4).text()).toBe('paused');
|
||||
});
|
||||
});
|
37
spec/frontend/runner/mock_data.js
Normal file
37
spec/frontend/runner/mock_data.js
Normal file
|
@ -0,0 +1,37 @@
|
|||
export const runnersData = {
|
||||
data: {
|
||||
runners: {
|
||||
nodes: [
|
||||
{
|
||||
id: 'gid://gitlab/Ci::Runner/1',
|
||||
description: 'runner-1',
|
||||
runnerType: 'INSTANCE_TYPE',
|
||||
shortSha: '2P6oDVDm',
|
||||
version: '13.12.0',
|
||||
revision: '11223344',
|
||||
ipAddress: '127.0.0.1',
|
||||
active: true,
|
||||
locked: true,
|
||||
tagList: [],
|
||||
contactedAt: '2021-05-14T11:44:03Z',
|
||||
__typename: 'CiRunner',
|
||||
},
|
||||
{
|
||||
id: 'gid://gitlab/Ci::Runner/2',
|
||||
description: 'runner-2',
|
||||
runnerType: 'GROUP_TYPE',
|
||||
shortSha: 'dpSCAC31',
|
||||
version: '13.12.0',
|
||||
revision: '11223344',
|
||||
ipAddress: '127.0.0.1',
|
||||
active: true,
|
||||
locked: true,
|
||||
tagList: [],
|
||||
contactedAt: '2021-05-14T11:44:02Z',
|
||||
__typename: 'CiRunner',
|
||||
},
|
||||
],
|
||||
__typename: 'CiRunnerConnection',
|
||||
},
|
||||
},
|
||||
};
|
106
spec/frontend/runner/runner_list/runner_list_app_spec.js
Normal file
106
spec/frontend/runner/runner_list/runner_list_app_spec.js
Normal file
|
@ -0,0 +1,106 @@
|
|||
import * as Sentry from '@sentry/browser';
|
||||
import { createLocalVue, shallowMount } from '@vue/test-utils';
|
||||
import VueApollo from 'vue-apollo';
|
||||
import createMockApollo from 'helpers/mock_apollo_helper';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
|
||||
import RunnerList from '~/runner/components/runner_list.vue';
|
||||
import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue';
|
||||
import RunnerTypeHelp from '~/runner/components/runner_type_help.vue';
|
||||
|
||||
import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql';
|
||||
import RunnerListApp from '~/runner/runner_list/runner_list_app.vue';
|
||||
|
||||
import { runnersData } from '../mock_data';
|
||||
|
||||
const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN';
|
||||
const mockActiveRunnersCount = 2;
|
||||
const mocKRunners = runnersData.data.runners.nodes;
|
||||
|
||||
jest.mock('@sentry/browser');
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(VueApollo);
|
||||
|
||||
describe('RunnerListApp', () => {
|
||||
let wrapper;
|
||||
let mockRunnersQuery;
|
||||
|
||||
const findRunnerTypeHelp = () => wrapper.findComponent(RunnerTypeHelp);
|
||||
const findRunnerManualSetupHelp = () => wrapper.findComponent(RunnerManualSetupHelp);
|
||||
const findRunnerList = () => wrapper.findComponent(RunnerList);
|
||||
|
||||
const createComponentWithApollo = ({ props = {}, mountFn = shallowMount } = {}) => {
|
||||
const handlers = [[getRunnersQuery, mockRunnersQuery]];
|
||||
|
||||
wrapper = mountFn(RunnerListApp, {
|
||||
localVue,
|
||||
apolloProvider: createMockApollo(handlers),
|
||||
propsData: {
|
||||
activeRunnersCount: mockActiveRunnersCount,
|
||||
registrationToken: mockRegistrationToken,
|
||||
...props,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
Sentry.withScope.mockImplementation((fn) => {
|
||||
const scope = { setTag: jest.fn() };
|
||||
fn(scope);
|
||||
});
|
||||
|
||||
mockRunnersQuery = jest.fn().mockResolvedValue(runnersData);
|
||||
createComponentWithApollo();
|
||||
await waitForPromises();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockRunnersQuery.mockReset();
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
it('shows the runners list', () => {
|
||||
expect(mocKRunners).toMatchObject(findRunnerList().props('runners'));
|
||||
});
|
||||
|
||||
it('shows the runner type help', () => {
|
||||
expect(findRunnerTypeHelp().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('shows the runner setup instructions', () => {
|
||||
expect(findRunnerManualSetupHelp().exists()).toBe(true);
|
||||
expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken);
|
||||
});
|
||||
|
||||
describe('when no runners are found', () => {
|
||||
beforeEach(async () => {
|
||||
mockRunnersQuery = jest.fn().mockResolvedValue({ data: { runners: { nodes: [] } } });
|
||||
createComponentWithApollo();
|
||||
await waitForPromises();
|
||||
});
|
||||
|
||||
it('shows a message for no results', async () => {
|
||||
expect(wrapper.text()).toContain('No runners found');
|
||||
});
|
||||
});
|
||||
|
||||
it('when runners have not loaded, shows a loading state', () => {
|
||||
createComponentWithApollo();
|
||||
expect(findRunnerList().props('loading')).toBe(true);
|
||||
});
|
||||
|
||||
describe('when runners query fails', () => {
|
||||
beforeEach(async () => {
|
||||
mockRunnersQuery = jest.fn().mockRejectedValue(new Error());
|
||||
createComponentWithApollo();
|
||||
|
||||
await waitForPromises();
|
||||
});
|
||||
|
||||
it('error is reported to sentry', async () => {
|
||||
expect(Sentry.withScope).toHaveBeenCalled();
|
||||
expect(Sentry.captureException).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -298,7 +298,7 @@ RSpec.describe CommitsHelper do
|
|||
let(:pipeline) { create(:ci_pipeline, :running) }
|
||||
let(:user) { create(:user) }
|
||||
let(:ref) { "master" }
|
||||
let(:merge_request) { nil }
|
||||
let(:merge_request) { create(:merge_request) }
|
||||
let(:request) { double(xhr?: true) }
|
||||
let(:current_path) { "test" }
|
||||
|
||||
|
@ -315,8 +315,8 @@ RSpec.describe CommitsHelper do
|
|||
it do
|
||||
is_expected.to include(
|
||||
{
|
||||
merge_request: merge_request,
|
||||
pipeline_status: commit_status,
|
||||
merge_request: merge_request.cache_key,
|
||||
pipeline_status: pipeline.cache_key,
|
||||
xhr: true,
|
||||
controller: "commits",
|
||||
path: current_path
|
||||
|
|
|
@ -48,30 +48,51 @@ RSpec.describe Nav::TopNavHelper do
|
|||
|
||||
context 'when current_user is nil (anonymous)' do
|
||||
it 'has expected :primary' do
|
||||
expected_projects_item = ::Gitlab::Nav::TopNavMenuItem.build(
|
||||
href: '/explore',
|
||||
icon: 'project',
|
||||
id: 'project',
|
||||
title: 'Projects'
|
||||
)
|
||||
expected_groups_item = ::Gitlab::Nav::TopNavMenuItem.build(
|
||||
href: '/explore/groups',
|
||||
icon: 'group',
|
||||
id: 'groups',
|
||||
title: 'Groups'
|
||||
)
|
||||
expected_snippets_item = ::Gitlab::Nav::TopNavMenuItem.build(
|
||||
href: '/explore/snippets',
|
||||
icon: 'snippet',
|
||||
id: 'snippets',
|
||||
title: 'Snippets'
|
||||
)
|
||||
expect(subject[:primary])
|
||||
.to eq([
|
||||
expected_projects_item,
|
||||
expected_groups_item,
|
||||
expected_snippets_item
|
||||
])
|
||||
expected_primary = [
|
||||
::Gitlab::Nav::TopNavMenuItem.build(
|
||||
href: '/explore',
|
||||
icon: 'project',
|
||||
id: 'project',
|
||||
title: 'Projects'
|
||||
),
|
||||
::Gitlab::Nav::TopNavMenuItem.build(
|
||||
href: '/explore/groups',
|
||||
icon: 'group',
|
||||
id: 'groups',
|
||||
title: 'Groups'
|
||||
),
|
||||
::Gitlab::Nav::TopNavMenuItem.build(
|
||||
href: '/explore/snippets',
|
||||
icon: 'snippet',
|
||||
id: 'snippets',
|
||||
title: 'Snippets'
|
||||
)
|
||||
]
|
||||
expect(subject[:primary]).to eq(expected_primary)
|
||||
end
|
||||
|
||||
it 'has expected :shortcuts' do
|
||||
expected_shortcuts = [
|
||||
::Gitlab::Nav::TopNavMenuItem.build(
|
||||
href: '/explore',
|
||||
id: 'project-shortcut',
|
||||
title: 'Projects',
|
||||
css_class: 'dashboard-shortcuts-projects'
|
||||
),
|
||||
::Gitlab::Nav::TopNavMenuItem.build(
|
||||
href: '/explore/groups',
|
||||
id: 'groups-shortcut',
|
||||
title: 'Groups',
|
||||
css_class: 'dashboard-shortcuts-groups'
|
||||
),
|
||||
::Gitlab::Nav::TopNavMenuItem.build(
|
||||
href: '/explore/snippets',
|
||||
id: 'snippets-shortcut',
|
||||
title: 'Snippets',
|
||||
css_class: 'dashboard-shortcuts-snippets'
|
||||
)
|
||||
]
|
||||
expect(subject[:shortcuts]).to eq(expected_shortcuts)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -82,6 +103,7 @@ RSpec.describe Nav::TopNavHelper do
|
|||
expect(subject).to eq({ activeTitle: active_title,
|
||||
primary: [],
|
||||
secondary: [],
|
||||
shortcuts: [],
|
||||
views: {} })
|
||||
end
|
||||
|
||||
|
@ -105,6 +127,16 @@ RSpec.describe Nav::TopNavHelper do
|
|||
expect(subject[:primary]).to eq([expected_primary])
|
||||
end
|
||||
|
||||
it 'has expected :shortcuts' do
|
||||
expected_shortcuts = ::Gitlab::Nav::TopNavMenuItem.build(
|
||||
id: 'project-shortcut',
|
||||
title: 'Projects',
|
||||
href: '/dashboard/projects',
|
||||
css_class: 'dashboard-shortcuts-projects'
|
||||
)
|
||||
expect(subject[:shortcuts]).to eq([expected_shortcuts])
|
||||
end
|
||||
|
||||
context 'projects' do
|
||||
it 'has expected :currentUserName' do
|
||||
expect(projects_view[:currentUserName]).to eq(current_user.username)
|
||||
|
@ -191,6 +223,16 @@ RSpec.describe Nav::TopNavHelper do
|
|||
expect(subject[:primary]).to eq([expected_primary])
|
||||
end
|
||||
|
||||
it 'has expected :shortcuts' do
|
||||
expected_shortcuts = ::Gitlab::Nav::TopNavMenuItem.build(
|
||||
id: 'groups-shortcut',
|
||||
title: 'Groups',
|
||||
href: '/dashboard/groups',
|
||||
css_class: 'dashboard-shortcuts-groups'
|
||||
)
|
||||
expect(subject[:shortcuts]).to eq([expected_shortcuts])
|
||||
end
|
||||
|
||||
context 'groups' do
|
||||
it 'has expected :currentUserName' do
|
||||
expect(groups_view[:currentUserName]).to eq(current_user.username)
|
||||
|
@ -268,6 +310,16 @@ RSpec.describe Nav::TopNavHelper do
|
|||
)
|
||||
expect(subject[:primary]).to eq([expected_primary])
|
||||
end
|
||||
|
||||
it 'has expected :shortcuts' do
|
||||
expected_shortcuts = ::Gitlab::Nav::TopNavMenuItem.build(
|
||||
id: 'milestones-shortcut',
|
||||
title: 'Milestones',
|
||||
href: '/dashboard/milestones',
|
||||
css_class: 'dashboard-shortcuts-milestones'
|
||||
)
|
||||
expect(subject[:shortcuts]).to eq([expected_shortcuts])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with snippets' do
|
||||
|
@ -285,6 +337,16 @@ RSpec.describe Nav::TopNavHelper do
|
|||
)
|
||||
expect(subject[:primary]).to eq([expected_primary])
|
||||
end
|
||||
|
||||
it 'has expected :shortcuts' do
|
||||
expected_shortcuts = ::Gitlab::Nav::TopNavMenuItem.build(
|
||||
id: 'snippets-shortcut',
|
||||
title: 'Snippets',
|
||||
href: '/dashboard/snippets',
|
||||
css_class: 'dashboard-shortcuts-snippets'
|
||||
)
|
||||
expect(subject[:shortcuts]).to eq([expected_shortcuts])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with activity' do
|
||||
|
@ -302,6 +364,16 @@ RSpec.describe Nav::TopNavHelper do
|
|||
)
|
||||
expect(subject[:primary]).to eq([expected_primary])
|
||||
end
|
||||
|
||||
it 'has expected :shortcuts' do
|
||||
expected_shortcuts = ::Gitlab::Nav::TopNavMenuItem.build(
|
||||
id: 'activity-shortcut',
|
||||
title: 'Activity',
|
||||
href: '/dashboard/activity',
|
||||
css_class: 'dashboard-shortcuts-activity'
|
||||
)
|
||||
expect(subject[:shortcuts]).to eq([expected_shortcuts])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when sherlock is enabled' do
|
||||
|
|
|
@ -183,7 +183,7 @@ RSpec.describe Gitlab::PathRegex do
|
|||
|
||||
# We ban new items in this list, see https://gitlab.com/gitlab-org/gitlab/-/issues/215362
|
||||
it 'does not allow expansion' do
|
||||
expect(described_class::TOP_LEVEL_ROUTES.size).to eq(44)
|
||||
expect(described_class::TOP_LEVEL_ROUTES.size).to eq(40)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -662,28 +662,10 @@ RSpec.describe Ci::Build do
|
|||
end
|
||||
end
|
||||
|
||||
context 'with runners_cached_states feature flag enabled' do
|
||||
before do
|
||||
stub_feature_flags(runners_cached_states: true)
|
||||
end
|
||||
it 'caches the result in Redis' do
|
||||
expect(Rails.cache).to receive(:fetch).with(['has-online-runners', build.id], expires_in: 1.minute)
|
||||
|
||||
it 'caches the result in Redis' do
|
||||
expect(Rails.cache).to receive(:fetch).with(['has-online-runners', build.id], expires_in: 1.minute)
|
||||
|
||||
build.any_runners_online?
|
||||
end
|
||||
end
|
||||
|
||||
context 'with runners_cached_states feature flag disabled' do
|
||||
before do
|
||||
stub_feature_flags(runners_cached_states: false)
|
||||
end
|
||||
|
||||
it 'does not cache' do
|
||||
expect(Rails.cache).not_to receive(:fetch).with(['has-online-runners', build.id], expires_in: 1.minute)
|
||||
|
||||
build.any_runners_online?
|
||||
end
|
||||
build.any_runners_online?
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -700,28 +682,10 @@ RSpec.describe Ci::Build do
|
|||
it { is_expected.to be_truthy }
|
||||
end
|
||||
|
||||
context 'with runners_cached_states feature flag enabled' do
|
||||
before do
|
||||
stub_feature_flags(runners_cached_states: true)
|
||||
end
|
||||
it 'caches the result in Redis' do
|
||||
expect(Rails.cache).to receive(:fetch).with(['has-available-runners', build.project.id], expires_in: 1.minute)
|
||||
|
||||
it 'caches the result in Redis' do
|
||||
expect(Rails.cache).to receive(:fetch).with(['has-available-runners', build.project.id], expires_in: 1.minute)
|
||||
|
||||
build.any_runners_available?
|
||||
end
|
||||
end
|
||||
|
||||
context 'with runners_cached_states feature flag disabled' do
|
||||
before do
|
||||
stub_feature_flags(runners_cached_states: false)
|
||||
end
|
||||
|
||||
it 'does not cache' do
|
||||
expect(Rails.cache).not_to receive(:fetch).with(['has-available-runners', build.project.id], expires_in: 1.minute)
|
||||
|
||||
build.any_runners_available?
|
||||
end
|
||||
build.any_runners_available?
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -1632,112 +1632,6 @@ RSpec.describe Project, factory_default: :keep do
|
|||
end
|
||||
end
|
||||
|
||||
describe '#any_active_runners?' do
|
||||
subject { project.any_active_runners? }
|
||||
|
||||
context 'shared runners' do
|
||||
let(:project) { create(:project, shared_runners_enabled: shared_runners_enabled) }
|
||||
let(:specific_runner) { create(:ci_runner, :project, projects: [project]) }
|
||||
let(:shared_runner) { create(:ci_runner, :instance) }
|
||||
|
||||
context 'for shared runners disabled' do
|
||||
let(:shared_runners_enabled) { false }
|
||||
|
||||
it 'has no runners available' do
|
||||
is_expected.to be_falsey
|
||||
end
|
||||
|
||||
it 'has a specific runner' do
|
||||
specific_runner
|
||||
|
||||
is_expected.to be_truthy
|
||||
end
|
||||
|
||||
it 'has a shared runner, but they are prohibited to use' do
|
||||
shared_runner
|
||||
|
||||
is_expected.to be_falsey
|
||||
end
|
||||
|
||||
it 'checks the presence of specific runner' do
|
||||
specific_runner
|
||||
|
||||
expect(project.any_active_runners? { |runner| runner == specific_runner }).to be_truthy
|
||||
end
|
||||
|
||||
it 'returns false if match cannot be found' do
|
||||
specific_runner
|
||||
|
||||
expect(project.any_active_runners? { false }).to be_falsey
|
||||
end
|
||||
end
|
||||
|
||||
context 'for shared runners enabled' do
|
||||
let(:shared_runners_enabled) { true }
|
||||
|
||||
it 'has a shared runner' do
|
||||
shared_runner
|
||||
|
||||
is_expected.to be_truthy
|
||||
end
|
||||
|
||||
it 'checks the presence of shared runner' do
|
||||
shared_runner
|
||||
|
||||
expect(project.any_active_runners? { |runner| runner == shared_runner }).to be_truthy
|
||||
end
|
||||
|
||||
it 'returns false if match cannot be found' do
|
||||
shared_runner
|
||||
|
||||
expect(project.any_active_runners? { false }).to be_falsey
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'group runners' do
|
||||
let(:project) { create(:project, group_runners_enabled: group_runners_enabled) }
|
||||
let(:group) { create(:group, projects: [project]) }
|
||||
let(:group_runner) { create(:ci_runner, :group, groups: [group]) }
|
||||
|
||||
context 'for group runners disabled' do
|
||||
let(:group_runners_enabled) { false }
|
||||
|
||||
it 'has no runners available' do
|
||||
is_expected.to be_falsey
|
||||
end
|
||||
|
||||
it 'has a group runner, but they are prohibited to use' do
|
||||
group_runner
|
||||
|
||||
is_expected.to be_falsey
|
||||
end
|
||||
end
|
||||
|
||||
context 'for group runners enabled' do
|
||||
let(:group_runners_enabled) { true }
|
||||
|
||||
it 'has a group runner' do
|
||||
group_runner
|
||||
|
||||
is_expected.to be_truthy
|
||||
end
|
||||
|
||||
it 'checks the presence of group runner' do
|
||||
group_runner
|
||||
|
||||
expect(project.any_active_runners? { |runner| runner == group_runner }).to be_truthy
|
||||
end
|
||||
|
||||
it 'returns false if match cannot be found' do
|
||||
group_runner
|
||||
|
||||
expect(project.any_active_runners? { false }).to be_falsey
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#any_online_runners?' do
|
||||
subject { project.any_online_runners? }
|
||||
|
||||
|
|
|
@ -335,10 +335,6 @@ RSpec.describe InvitesController, 'routing' do
|
|||
it 'to #show' do
|
||||
expect(get("/-/invites/#{member.invite_token}")).to route_to('invites#show', id: member.invite_token)
|
||||
end
|
||||
|
||||
it 'to legacy route' do
|
||||
expect(get("/invites/#{member.invite_token}")).to route_to('invites#show', id: member.invite_token)
|
||||
end
|
||||
end
|
||||
|
||||
RSpec.describe AbuseReportsController, 'routing' do
|
||||
|
@ -347,10 +343,6 @@ RSpec.describe AbuseReportsController, 'routing' do
|
|||
it 'to #new' do
|
||||
expect(get("/-/abuse_reports/new?user_id=#{user.id}")).to route_to('abuse_reports#new', user_id: user.id.to_s)
|
||||
end
|
||||
|
||||
it 'to legacy route' do
|
||||
expect(get("/abuse_reports/new?user_id=#{user.id}")).to route_to('abuse_reports#new', user_id: user.id.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
RSpec.describe SentNotificationsController, 'routing' do
|
||||
|
@ -376,12 +368,6 @@ RSpec.describe AutocompleteController, 'routing' do
|
|||
it 'to #merge_request_target_branches' do
|
||||
expect(get("/-/autocomplete/merge_request_target_branches")).to route_to('autocomplete#merge_request_target_branches')
|
||||
end
|
||||
|
||||
it 'to legacy route' do
|
||||
expect(get("/autocomplete/users")).to route_to('autocomplete#users')
|
||||
expect(get("/autocomplete/projects")).to route_to('autocomplete#projects')
|
||||
expect(get("/autocomplete/award_emojis")).to route_to('autocomplete#award_emojis')
|
||||
end
|
||||
end
|
||||
|
||||
RSpec.describe Snippets::BlobsController, "routing" do
|
||||
|
|
|
@ -78,8 +78,8 @@ RSpec.describe Issues::CreateService do
|
|||
opts.merge!(title: '')
|
||||
end
|
||||
|
||||
it 'does not create an incident label prematurely' do
|
||||
expect { subject }.not_to change(Label, :count)
|
||||
it 'does not apply an incident label prematurely' do
|
||||
expect { subject }.to not_change(LabelLink, :count).and not_change(Issue, :count)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -158,6 +158,90 @@ RSpec.describe Issues::UpdateService, :mailer do
|
|||
end
|
||||
end
|
||||
|
||||
context 'changing issue_type' do
|
||||
let!(:label_1) { create(:label, project: project, title: 'incident') }
|
||||
let!(:label_2) { create(:label, project: project, title: 'missed-sla') }
|
||||
|
||||
before do
|
||||
stub_licensed_features(quality_management: true)
|
||||
end
|
||||
|
||||
context 'from issue to incident' do
|
||||
it 'adds a `incident` label if one does not exist' do
|
||||
expect { update_issue(issue_type: 'incident') }.to change(issue.labels, :count).by(1)
|
||||
expect(issue.labels.pluck(:title)).to eq(['incident'])
|
||||
end
|
||||
|
||||
context 'for an issue with multiple labels' do
|
||||
let(:issue) { create(:incident, project: project, labels: [label_1]) }
|
||||
|
||||
before do
|
||||
update_issue(issue_type: 'incident')
|
||||
end
|
||||
|
||||
it 'does not add an `incident` label if one already exist' do
|
||||
expect(issue.labels).to eq([label_1])
|
||||
end
|
||||
end
|
||||
|
||||
context 'filtering the incident label' do
|
||||
let(:params) { { add_label_ids: [] } }
|
||||
|
||||
before do
|
||||
update_issue(issue_type: 'incident')
|
||||
end
|
||||
|
||||
it 'creates and add a incident label id to add_label_ids' do
|
||||
expect(issue.label_ids).to contain_exactly(label_1.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'from incident to issue' do
|
||||
let(:issue) { create(:incident, project: project) }
|
||||
|
||||
context 'for an incident with multiple labels' do
|
||||
let(:issue) { create(:incident, project: project, labels: [label_1, label_2]) }
|
||||
|
||||
before do
|
||||
update_issue(issue_type: 'issue')
|
||||
end
|
||||
|
||||
it 'removes an `incident` label if one exists on the incident' do
|
||||
expect(issue.labels).to eq([label_2])
|
||||
end
|
||||
end
|
||||
|
||||
context 'filtering the incident label' do
|
||||
let(:issue) { create(:incident, project: project, labels: [label_1, label_2]) }
|
||||
let(:params) { { label_ids: [label_1.id, label_2.id], remove_label_ids: [] } }
|
||||
|
||||
before do
|
||||
update_issue(issue_type: 'issue')
|
||||
end
|
||||
|
||||
it 'adds an incident label id to remove_label_ids for it to be removed' do
|
||||
expect(issue.label_ids).to contain_exactly(label_2.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'from issue to restricted issue types' do
|
||||
context 'without sufficient permissions' do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
before do
|
||||
project.add_guest(user)
|
||||
end
|
||||
|
||||
it 'does nothing to the labels' do
|
||||
expect { update_issue(issue_type: 'issue') }.not_to change(issue.labels, :count)
|
||||
expect(issue.reload.labels).to eq([])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'updates open issue counter for assignees when issue is reassigned' do
|
||||
update_issue(assignee_ids: [user2.id])
|
||||
|
||||
|
|
|
@ -29,8 +29,10 @@ RSpec.describe BuildHooksWorker do
|
|||
stub_feature_flags(delayed_perform_for_build_hooks_worker: false)
|
||||
end
|
||||
|
||||
it 'does not call perform_in' do
|
||||
expect(described_class).not_to receive(:perform_in)
|
||||
it 'delays scheduling a job by calling perform_in with default delay' do
|
||||
expect(described_class).to receive(:perform_in).with(ApplicationWorker::DEFAULT_DELAY_INTERVAL.second, 123)
|
||||
|
||||
described_class.perform_async(123)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -176,6 +176,58 @@ RSpec.describe ApplicationWorker do
|
|||
end
|
||||
end
|
||||
|
||||
describe '.perform_async' do
|
||||
shared_examples_for 'worker utilizes load balancing capabilities' do |data_consistency|
|
||||
before do
|
||||
worker.data_consistency(data_consistency)
|
||||
end
|
||||
|
||||
context 'when data_consistency_delayed_execution feature flag is disabled' do
|
||||
before do
|
||||
stub_feature_flags(data_consistency_delayed_execution: false)
|
||||
end
|
||||
|
||||
it 'data_consistency_delayed_execution_feature_flag_enabled? should return false' do
|
||||
expect(worker).to receive(:data_consistency_delayed_execution_feature_flag_enabled?).and_return(false)
|
||||
|
||||
worker.perform_async
|
||||
end
|
||||
|
||||
it 'does not call perform_in' do
|
||||
expect(worker).not_to receive(:perform_in)
|
||||
|
||||
worker.perform_async
|
||||
end
|
||||
end
|
||||
|
||||
it 'call perform_in' do
|
||||
expect(worker).to receive(:perform_in).with(described_class::DEFAULT_DELAY_INTERVAL.seconds, 123)
|
||||
|
||||
worker.perform_async(123)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when workers data consistency is :sticky' do
|
||||
it_behaves_like 'worker utilizes load balancing capabilities', :sticky
|
||||
end
|
||||
|
||||
context 'when workers data consistency is :delayed' do
|
||||
it_behaves_like 'worker utilizes load balancing capabilities', :delayed
|
||||
end
|
||||
|
||||
context 'when workers data consistency is :always' do
|
||||
before do
|
||||
worker.data_consistency(:always)
|
||||
end
|
||||
|
||||
it 'does not call perform_in' do
|
||||
expect(worker).not_to receive(:perform_in)
|
||||
|
||||
worker.perform_async
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.bulk_perform_async' do
|
||||
it 'enqueues jobs in bulk' do
|
||||
Sidekiq::Testing.fake! do
|
||||
|
|
Loading…
Reference in a new issue