Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-10-17 18:09:13 +00:00
parent 3884d9d716
commit 5150ecc452
118 changed files with 1563 additions and 816 deletions

View File

@ -12,3 +12,4 @@ Metrics/AbcSize:
- 'lib/gitlab/analytics/cycle_analytics/request_params.rb'
- 'lib/gitlab/sidekiq_middleware/server_metrics.rb'
- 'qa/qa/resource/repository/push.rb'
- 'ee/db/seeds/awesome_co/**/*.rb'

View File

@ -103,6 +103,7 @@
"workflow": {
"type": "object",
"properties": {
"name": { "$ref": "#/definitions/workflowName" },
"rules": {
"type": "array",
"items": {
@ -714,6 +715,12 @@
]
}
},
"workflowName": {
"type": "string",
"markdownDescription": "Defines the pipeline name. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#workflowname).",
"minLength": 1,
"maxLength": 255
},
"globalVariables": {
"markdownDescription": "Defines default variables for all jobs. Job level property overrides global variables. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#variables).",
"type": "object",

View File

@ -14,6 +14,7 @@ import { visitUrl } from '~/lib/utils/url_utility';
import { truncate } from '~/lib/utils/text_utility';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { s__, sprintf } from '~/locale';
import Tracking from '~/tracking';
import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue';
import {
FIRST_DROPDOWN_INDEX,
@ -163,8 +164,17 @@ export default {
...mapActions(['setSearch', 'fetchAutocompleteOptions', 'clearAutocomplete']),
openDropdown() {
this.showDropdown = true;
this.isFocused = true;
this.$emit('expandSearchBar', true);
// check isFocused state to avoid firing duplicate events
if (!this.isFocused) {
this.isFocused = true;
this.$emit('expandSearchBar', true);
Tracking.event(undefined, 'focus_input', {
label: 'global_search',
property: 'top_navigation',
});
}
},
closeDropdown() {
this.showDropdown = false;
@ -178,6 +188,11 @@ export default {
this.showDropdown = false;
this.isFocused = false;
this.$emit('collapseSearchBar');
Tracking.event(undefined, 'blur_input', {
label: 'global_search',
property: 'top_navigation',
});
}, 200);
},
submitSearch() {

View File

@ -1,4 +1,4 @@
import { GlDropdown, GlDropdownItem, GlListbox } from '@gitlab/ui';
import { GlListbox } from '@gitlab/ui';
import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
@ -31,59 +31,25 @@ export function initListbox(el, { onChange } = {}) {
},
},
render(h) {
if (gon.features?.glListboxForSortDropdowns) {
return h(GlListbox, {
props: {
items,
right,
selected: this.selected,
toggleText: this.text,
},
class: className,
on: {
select: (selectedValue) => {
this.selected = selectedValue;
const selectedItem = items.find(({ value }) => value === selectedValue);
if (typeof onChange === 'function') {
onChange(selectedItem);
}
},
},
});
}
return h(
GlDropdown,
{
props: {
text: this.text,
right,
},
class: className,
return h(GlListbox, {
props: {
items,
right,
selected: this.selected,
toggleText: this.text,
},
items.map((item) =>
h(
GlDropdownItem,
{
props: {
isCheckItem: true,
isChecked: this.selected === item.value,
},
on: {
click: () => {
this.selected = item.value;
class: className,
on: {
select: (selectedValue) => {
this.selected = selectedValue;
const selectedItem = items.find(({ value }) => value === selectedValue);
if (typeof onChange === 'function') {
onChange(item);
}
},
},
},
item.text,
),
),
);
if (typeof onChange === 'function') {
onChange(selectedItem);
}
},
},
});
},
});
}

View File

@ -1,5 +1,6 @@
<script>
import { GlNav, GlIcon, GlNavItemDropdown, GlDropdownForm, GlTooltipDirective } from '@gitlab/ui';
import Tracking from '~/tracking';
import TopNavDropdownMenu from './top_nav_dropdown_menu.vue';
export default {
@ -19,6 +20,14 @@ export default {
required: true,
},
},
methods: {
trackToggleEvent() {
Tracking.event(undefined, 'click_nav', {
label: 'hamburger_menu',
property: 'top_navigation',
});
},
},
};
</script>
@ -32,6 +41,7 @@ export default {
toggle-class="top-nav-toggle js-top-nav-dropdown-toggle gl-px-3!"
no-flip
no-caret
@toggle="trackToggleEvent"
>
<template #button-content>
<gl-icon name="hamburger" />

View File

@ -1,9 +1,10 @@
<script>
import pdfjsLib from 'pdfjs-dist/build/pdf';
import workerSrc from 'pdfjs-dist/build/pdf.worker.min';
import { getDocument, GlobalWorkerOptions } from 'pdfjs-dist/legacy/build/pdf';
import Page from './page/index.vue';
GlobalWorkerOptions.workerSrc = '/assets/webpack/pdfjs/pdf.worker.min.js';
export default {
components: { Page },
props: {
@ -30,18 +31,16 @@ export default {
},
watch: { pdf: 'load' },
mounted() {
pdfjsLib.GlobalWorkerOptions.workerSrc = workerSrc;
if (this.hasPDF) this.load();
},
methods: {
load() {
this.pages = [];
return pdfjsLib
.getDocument({
url: this.document,
cMapUrl: '/assets/webpack/cmaps/',
cMapPacked: true,
})
return getDocument({
url: this.document,
cMapUrl: '/assets/webpack/pdfjs/cmaps/',
cMapPacked: true,
})
.promise.then(this.renderPages)
.then((pages) => {
this.pages = pages;

View File

@ -17,8 +17,6 @@ import allRunnersCountQuery from 'ee_else_ce/runner/graphql/list/all_runners_cou
import RegistrationDropdown from '../components/registration/registration_dropdown.vue';
import RunnerStackedLayoutBanner from '../components/runner_stacked_layout_banner.vue';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
import RunnerBulkDelete from '../components/runner_bulk_delete.vue';
import RunnerBulkDeleteCheckbox from '../components/runner_bulk_delete_checkbox.vue';
import RunnerList from '../components/runner_list.vue';
import RunnerListEmptyState from '../components/runner_list_empty_state.vue';
import RunnerName from '../components/runner_name.vue';
@ -45,8 +43,6 @@ export default {
RegistrationDropdown,
RunnerStackedLayoutBanner,
RunnerFilteredSearchBar,
RunnerBulkDelete,
RunnerBulkDeleteCheckbox,
RunnerList,
RunnerListEmptyState,
RunnerName,
@ -56,7 +52,7 @@ export default {
RunnerActionsCell,
},
mixins: [glFeatureFlagMixin()],
inject: ['emptyStateSvgPath', 'emptyStateFilteredSvgPath', 'localMutations'],
inject: ['emptyStateSvgPath', 'emptyStateFilteredSvgPath'],
props: {
registrationToken: {
type: String,
@ -155,12 +151,6 @@ export default {
reportToSentry(error) {
captureException({ error, component: this.$options.name });
},
onChecked({ runner, isChecked }) {
this.localMutations.setRunnerChecked({
runner,
isChecked,
});
},
onPaginationInput(value) {
this.search.pagination = value;
},
@ -211,16 +201,12 @@ export default {
:filtered-svg-path="emptyStateFilteredSvgPath"
/>
<template v-else>
<runner-bulk-delete :runners="runners.items" @deleted="onDeleted" />
<runner-list
:runners="runners.items"
:loading="runnersLoading"
:checkable="true"
@checked="onChecked"
@deleted="onDeleted"
>
<template #head-checkbox>
<runner-bulk-delete-checkbox :runners="runners.items" />
</template>
<template #runner-name="{ runner }">
<gl-link :href="runner.adminUrl">
<runner-name :runner="runner" />

View File

@ -26,14 +26,17 @@ export default {
},
},
computed: {
deletableRunners() {
return this.runners.filter((runner) => runner.userPermissions?.deleteRunner);
},
disabled() {
return !this.runners.length;
return !this.deletableRunners.length;
},
checked() {
return Boolean(this.runners.length) && this.runners.every(this.isChecked);
return Boolean(this.deletableRunners.length) && this.deletableRunners.every(this.isChecked);
},
indeterminate() {
return !this.checked && this.runners.some(this.isChecked);
return !this.checked && this.deletableRunners.some(this.isChecked);
},
label() {
return this.checked ? s__('Runners|Unselect all') : s__('Runners|Select all');
@ -45,7 +48,7 @@ export default {
},
onChange($event) {
this.localMutations.setRunnersChecked({
runners: this.runners,
runners: this.deletableRunners,
isChecked: $event,
});
},

View File

@ -4,7 +4,6 @@ import { helpPagePath } from '~/helpers/help_page_helper';
import { s__ } from '~/locale';
import HelpPopover from '~/vue_shared/components/help_popover.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { timeIntervalInWords } from '~/lib/utils/datetime_utility';
import { ACCESS_LEVEL_REF_PROTECTED, GROUP_TYPE, PROJECT_TYPE } from '../constants';
import RunnerDetail from './runner_detail.vue';
@ -29,7 +28,6 @@ export default {
RunnerTags,
TimeAgo,
},
mixins: [glFeatureFlagMixin()],
props: {
runner: {
type: Object,
@ -117,10 +115,7 @@ export default {
</template>
</runner-detail>
<runner-detail :label="s__('Runners|Maximum job timeout')" :value="maximumTimeout" />
<runner-detail
v-if="glFeatures.enforceRunnerTokenExpiresAt"
:empty-value="s__('Runners|Never expires')"
>
<runner-detail :empty-value="s__('Runners|Never expires')">
<template #label>
{{ s__('Runners|Token expiry') }}
<help-popover :options="tokenExpirationHelpPopoverOptions">

View File

@ -5,6 +5,8 @@ import { s__ } from '~/locale';
import HelpPopover from '~/vue_shared/components/help_popover.vue';
import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.graphql';
import { formatJobCount, tableField } from '../utils';
import RunnerBulkDelete from './runner_bulk_delete.vue';
import RunnerBulkDeleteCheckbox from './runner_bulk_delete_checkbox.vue';
import RunnerStackedSummaryCell from './cells/runner_stacked_summary_cell.vue';
import RunnerStatusPopover from './runner_status_popover.vue';
import RunnerStatusCell from './cells/runner_status_cell.vue';
@ -23,6 +25,8 @@ export default {
GlTableLite,
GlSkeletonLoader,
HelpPopover,
RunnerBulkDelete,
RunnerBulkDeleteCheckbox,
RunnerStatusPopover,
RunnerStackedSummaryCell,
RunnerStatusCell,
@ -39,6 +43,7 @@ export default {
},
},
},
inject: ['localMutations'],
props: {
checkable: {
type: Boolean,
@ -55,7 +60,7 @@ export default {
required: true,
},
},
emits: ['checked'],
emits: ['deleted'],
data() {
return { checkedRunnerIds: [] };
},
@ -84,6 +89,12 @@ export default {
},
},
methods: {
canDelete(runner) {
return runner.userPermissions?.deleteRunner;
},
onDeleted(event) {
this.$emit('deleted', event);
},
formatJobCount(jobCount) {
return formatJobCount(jobCount);
},
@ -96,7 +107,7 @@ export default {
return {};
},
onCheckboxChange(runner, isChecked) {
this.$emit('checked', {
this.localMutations.setRunnerChecked({
runner,
isChecked,
});
@ -109,6 +120,7 @@ export default {
</script>
<template>
<div>
<runner-bulk-delete v-if="checkable" :runners="runners" @deleted="onDeleted" />
<gl-table-lite
:aria-busy="loading"
:class="tableClass"
@ -121,11 +133,15 @@ export default {
fixed
>
<template #head(checkbox)>
<slot name="head-checkbox"></slot>
<runner-bulk-delete-checkbox :runners="runners" />
</template>
<template #cell(checkbox)="{ item }">
<gl-form-checkbox :checked="isChecked(item)" @change="onCheckboxChange(item, $event)" />
<gl-form-checkbox
v-if="canDelete(item)"
:checked="isChecked(item)"
@change="onCheckboxChange(item, $event)"
/>
</template>
<template #head(status)="{ label }">

View File

@ -48,16 +48,18 @@ export const createLocalState = () => {
const localMutations = {
setRunnerChecked({ runner, isChecked }) {
checkedRunnerIdsVar({
...checkedRunnerIdsVar(),
[runner.id]: isChecked,
});
const { id, userPermissions } = runner;
if (userPermissions?.deleteRunner) {
checkedRunnerIdsVar({
...checkedRunnerIdsVar(),
[id]: isChecked,
});
}
},
setRunnersChecked({ runners, isChecked }) {
const newVal = runners.reduce(
(acc, { id }) => ({ ...acc, [id]: isChecked }),
checkedRunnerIdsVar(),
);
const newVal = runners
.filter(({ userPermissions }) => userPermissions?.deleteRunner)
.reduce((acc, { id }) => ({ ...acc, [id]: isChecked }), checkedRunnerIdsVar());
checkedRunnerIdsVar(newVal);
},
clearChecked() {

View File

@ -12,6 +12,7 @@ import {
} from 'ee_else_ce/runner/runner_search_utils';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import groupRunnersQuery from 'ee_else_ce/runner/graphql/list/group_runners.query.graphql';
import groupRunnersCountQuery from 'ee_else_ce/runner/graphql/list/group_runners_count.query.graphql';
import RegistrationDropdown from '../components/registration/registration_dropdown.vue';
import RunnerStackedLayoutBanner from '../components/runner_stacked_layout_banner.vue';
@ -173,13 +174,17 @@ export default {
editUrl(runner) {
return this.runners.urlsById[runner.id]?.edit;
},
refetchCounts() {
this.$apollo.getClient().refetchQueries({ include: [groupRunnersCountQuery] });
},
onToggledPaused() {
// When a runner becomes Paused, the tab count can
// become stale, refetch outdated counts.
this.$refs['runner-type-tabs'].refetch();
this.refetchCounts();
},
onDeleted({ message }) {
this.$root.$toast?.show(message);
this.refetchCounts();
},
reportToSentry(error) {
captureException({ error, component: this.$options.name });
@ -245,7 +250,7 @@ export default {
:filtered-svg-path="emptyStateFilteredSvgPath"
/>
<template v-else>
<runner-list :runners="runners.items" :loading="runnersLoading">
<runner-list :runners="runners.items" :loading="runnersLoading" @deleted="onDeleted">
<template #runner-name="{ runner }">
<gl-link :href="webUrl(runner)">
<runner-name :runner="runner" />

View File

@ -2,6 +2,7 @@ import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { createLocalState } from '../graphql/list/local_state';
import GroupRunnersApp from './group_runners_app.vue';
Vue.use(GlToast);
@ -26,8 +27,10 @@ export const initGroupRunners = (selector = '#js-group-runners') => {
emptyStateFilteredSvgPath,
} = el.dataset;
const { cacheConfig, typeDefs, localMutations } = createLocalState();
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
defaultClient: createDefaultClient({}, { cacheConfig, typeDefs }),
});
return new Vue({
@ -35,6 +38,7 @@ export const initGroupRunners = (selector = '#js-group-runners') => {
apolloProvider,
provide: {
runnerInstallHelpPage,
localMutations,
groupId,
onlineContactTimeoutSecs: parseInt(onlineContactTimeoutSecs, 10),
staleTimeoutSecs: parseInt(staleTimeoutSecs, 10),

View File

@ -23,6 +23,7 @@ import {
WIDGET_TYPE_WEIGHT,
WIDGET_TYPE_HIERARCHY,
WORK_ITEM_VIEWED_STORAGE_KEY,
WIDGET_TYPE_ITERATION,
} from '../constants';
import workItemQuery from '../graphql/work_item.query.graphql';
@ -65,6 +66,7 @@ export default {
WorkItemInformation,
LocalStorageSync,
WorkItemTypeIcon,
WorkItemIteration: () => import('ee_component/work_items/components/work_item_iteration.vue'),
},
mixins: [glFeatureFlagMixin()],
props: {
@ -134,7 +136,7 @@ export default {
};
},
skip() {
return !this.workItemDueDate;
return !this.isWidgetPresent(WIDGET_TYPE_START_AND_DUE_DATE);
},
},
{
@ -145,7 +147,7 @@ export default {
};
},
skip() {
return !this.workItemAssignees;
return !this.isWidgetPresent(WIDGET_TYPE_ASSIGNEES);
},
},
],
@ -170,28 +172,8 @@ export default {
workItemsMvc2Enabled() {
return this.glFeatures.workItemsMvc2;
},
hasDescriptionWidget() {
return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_DESCRIPTION);
},
workItemAssignees() {
return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_ASSIGNEES);
},
workItemLabels() {
return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_LABELS);
},
workItemDueDate() {
return this.workItem?.widgets?.find(
(widget) => widget.type === WIDGET_TYPE_START_AND_DUE_DATE,
);
},
workItemWeight() {
return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_WEIGHT);
},
workItemHierarchy() {
return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY);
},
parentWorkItem() {
return this.workItemHierarchy?.parent;
return this.isWidgetPresent(WIDGET_TYPE_HIERARCHY)?.parent;
},
parentWorkItemConfidentiality() {
return this.parentWorkItem?.confidential;
@ -205,6 +187,27 @@ export default {
noAccessSvgPath() {
return `data:image/svg+xml;utf8,${encodeURIComponent(noAccessSvg)}`;
},
hasDescriptionWidget() {
return this.isWidgetPresent(WIDGET_TYPE_DESCRIPTION);
},
workItemAssignees() {
return this.isWidgetPresent(WIDGET_TYPE_ASSIGNEES);
},
workItemLabels() {
return this.isWidgetPresent(WIDGET_TYPE_LABELS);
},
workItemDueDate() {
return this.isWidgetPresent(WIDGET_TYPE_START_AND_DUE_DATE);
},
workItemWeight() {
return this.isWidgetPresent(WIDGET_TYPE_WEIGHT);
},
workItemHierarchy() {
return this.isWidgetPresent(WIDGET_TYPE_HIERARCHY);
},
workItemIteration() {
return this.isWidgetPresent(WIDGET_TYPE_ITERATION);
},
},
beforeDestroy() {
/** make sure that if the user has not even dismissed the alert ,
@ -212,6 +215,9 @@ export default {
this.dismissBanner();
},
methods: {
isWidgetPresent(type) {
return this.workItem?.widgets?.find((widget) => widget.type === type);
},
dismissBanner() {
this.showInfoBanner = false;
},
@ -416,6 +422,17 @@ export default {
:work-item-type="workItemType"
@error="updateError = $event"
/>
<template v-if="workItemsMvc2Enabled">
<work-item-iteration
v-if="workItemIteration"
class="gl-mb-5"
:iteration="workItemIteration.iteration"
:can-update="canUpdate"
:work-item-id="workItem.id"
:work-item-type="workItemType"
@error="updateError = $event"
/>
</template>
<work-item-description
v-if="hasDescriptionWidget"
:work-item-id="workItem.id"

View File

@ -16,7 +16,13 @@ export default function initWorkItemLinks() {
return;
}
const { projectPath, wiHasIssueWeightsFeature, iid } = workItemLinksRoot.dataset;
const {
projectPath,
wiHasIssueWeightsFeature,
iid,
wiHasIterationsFeature,
projectNamespace,
} = workItemLinksRoot.dataset;
// eslint-disable-next-line no-new
new Vue({
@ -31,6 +37,8 @@ export default function initWorkItemLinks() {
iid,
fullPath: projectPath,
hasIssueWeightsFeature: wiHasIssueWeightsFeature,
hasIterationsFeature: wiHasIterationsFeature,
projectNamespace,
},
render: (createElement) =>
createElement('work-item-links', {

View File

@ -5,7 +5,7 @@ import { s__ } from '~/locale';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { TYPE_WORK_ITEM } from '~/graphql_shared/constants';
import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql';
import getIssueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql';
import { isMetaKey } from '~/lib/utils/common_utils';
import { setUrlParams, updateHistory } from '~/lib/utils/url_utility';
@ -59,7 +59,7 @@ export default {
},
},
parentIssue: {
query: issueConfidentialQuery,
query: getIssueDetailsQuery,
variables() {
return {
fullPath: this.projectPath,
@ -86,6 +86,9 @@ export default {
confidential() {
return this.parentIssue?.confidential || this.workItem?.confidential || false;
},
issuableIteration() {
return this.parentIssue?.iteration;
},
children() {
return (
this.workItem?.widgets.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY)?.children
@ -305,6 +308,7 @@ export default {
:issuable-gid="issuableGid"
:children-ids="childrenIds"
:parent-confidential="confidential"
:parent-iteration="issuableIteration"
@cancel="hideAddForm"
@addWorkItemChild="addChild"
/>

View File

@ -16,7 +16,7 @@ export default {
GlFormGroup,
GlFormInput,
},
inject: ['projectPath'],
inject: ['projectPath', 'hasIterationsFeature'],
props: {
issuableGid: {
type: String,
@ -33,6 +33,11 @@ export default {
required: false,
default: false,
},
parentIteration: {
type: Object,
required: false,
default: () => {},
},
},
apollo: {
workItemTypes: {
@ -77,6 +82,9 @@ export default {
taskWorkItemType() {
return this.workItemTypes.find((type) => type.name === TASK_TYPE_NAME)?.id;
},
parentIterationId() {
return this.parentIteration?.id;
},
},
methods: {
getIdFromGraphQLId,
@ -133,6 +141,13 @@ export default {
} else {
this.unsetError();
this.$emit('addWorkItemChild', data.workItemCreate.workItem);
/**
* call update mutation only when there is an iteration associated with the issue
*/
// TODO: setting the iteration should be moved to the creation mutation once the backend is done
if (this.parentIterationId && this.hasIterationsFeature) {
this.addIterationToWorkItem(data.workItemCreate.workItem.id);
}
}
})
.catch(() => {
@ -143,6 +158,19 @@ export default {
this.childToCreateTitle = null;
});
},
async addIterationToWorkItem(workItemId) {
await this.$apollo.mutate({
mutation: updateWorkItemMutation,
variables: {
input: {
id: workItemId,
iterationWidget: {
iterationId: this.parentIterationId,
},
},
},
});
},
},
i18n: {
inputLabel: __('Title'),

View File

@ -17,6 +17,8 @@ export const WIDGET_TYPE_LABELS = 'LABELS';
export const WIDGET_TYPE_START_AND_DUE_DATE = 'START_AND_DUE_DATE';
export const WIDGET_TYPE_WEIGHT = 'WEIGHT';
export const WIDGET_TYPE_HIERARCHY = 'HIERARCHY';
export const WIDGET_TYPE_ITERATION = 'ITERATION';
export const WORK_ITEM_VIEWED_STORAGE_KEY = 'gl-show-work-item-banner';
export const WORK_ITEM_TYPE_ENUM_INCIDENT = 'INCIDENT';
@ -54,6 +56,10 @@ export const I18N_WORK_ITEM_ARE_YOU_SURE_DELETE = s__(
);
export const I18N_WORK_ITEM_DELETED = s__('WorkItem|%{workItemType} deleted');
export const I18N_WORK_ITEM_FETCH_ITERATIONS_ERROR = s__(
'WorkItem|Something went wrong when fetching iterations. Please try again.',
);
export const sprintfWorkItem = (msg, workItemTypeArg) => {
const workItemType = workItemTypeArg || s__('WorkItem|Work item');
return capitalizeFirstCharacter(

View File

@ -0,0 +1,9 @@
query issuableDetails($fullPath: ID!, $iid: String) {
workspace: project(fullPath: $fullPath) {
id
issuable: issue(iid: $iid) {
id
confidential
}
}
}

View File

@ -6,7 +6,13 @@ import { createRouter } from './router';
export const initWorkItemsRoot = () => {
const el = document.querySelector('#js-work-items');
const { fullPath, hasIssueWeightsFeature, issuesListPath } = el.dataset;
const {
fullPath,
hasIssueWeightsFeature,
issuesListPath,
projectNamespace,
hasIterationsFeature,
} = el.dataset;
return new Vue({
el,
@ -17,6 +23,8 @@ export const initWorkItemsRoot = () => {
fullPath,
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
issuesListPath,
projectNamespace,
hasIterationsFeature: parseBoolean(hasIterationsFeature),
},
render(createElement) {
return createElement(App);

View File

@ -63,3 +63,23 @@
display: none;
}
}
.work-item-iteration {
.gl-dropdown-toggle {
background: none !important;
&:hover,
&:focus {
box-shadow: inset 0 0 0 $gl-border-size-1 var(--gray-darkest, $gray-darkest) !important;
}
&.is-not-focused:not(:hover, :focus) {
box-shadow: none;
.gl-button-icon {
display: none;
}
}
}
}

View File

@ -5,10 +5,6 @@ class Admin::RunnersController < Admin::ApplicationController
before_action :runner, except: [:index, :tag_list, :runner_setup_scripts]
before_action only: [:show] do
push_frontend_feature_flag(:enforce_runner_token_expires_at)
end
feature_category :runner
urgency :low

View File

@ -5,10 +5,6 @@ class Groups::RunnersController < Groups::ApplicationController
before_action :authorize_update_runner!, only: [:edit, :update, :destroy, :pause, :resume]
before_action :runner, only: [:edit, :update, :destroy, :pause, :resume, :show]
before_action only: [:show] do
push_frontend_feature_flag(:enforce_runner_token_expires_at)
end
feature_category :runner
urgency :low

View File

@ -617,6 +617,15 @@ module Ci
# auto_canceled_by_pipeline_id - store the pipeline_id of the pipeline that triggered cancellation
# execute_async - if true cancel the children asyncronously
def cancel_running(retries: 1, cascade_to_children: true, auto_canceled_by_pipeline_id: nil, execute_async: true)
Gitlab::AppJsonLogger.info(
event: 'pipeline_cancel_running',
pipeline_id: id,
auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id,
cascade_to_children: cascade_to_children,
execute_async: execute_async,
**Gitlab::ApplicationContext.current
)
update(auto_canceled_by_id: auto_canceled_by_pipeline_id) if auto_canceled_by_pipeline_id
cancel_jobs(cancelable_statuses, retries: retries, auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id)

View File

@ -14,7 +14,7 @@ module Ci
include Presentable
include EachBatch
add_authentication_token_field :token, encrypted: :optional, expires_at: :compute_token_expiration, expiration_enforced?: :token_expiration_enforced?
add_authentication_token_field :token, encrypted: :optional, expires_at: :compute_token_expiration
enum access_level: {
not_protected: 0,
@ -480,10 +480,6 @@ module Ci
end
end
def self.token_expiration_enforced?
Feature.enabled?(:enforce_runner_token_expires_at)
end
private
scope :with_upgrade_status, ->(upgrade_status) do

View File

@ -7,7 +7,7 @@ class WebHook < ApplicationRecord
MAX_FAILURES = 100
FAILURE_THRESHOLD = 3 # three strikes
INITIAL_BACKOFF = 10.minutes
INITIAL_BACKOFF = 1.minute
MAX_BACKOFF = 1.day
BACKOFF_GROWTH_FACTOR = 2.0

View File

@ -17,14 +17,14 @@
.form-group
= f.label :receive_max_input_size, _('Maximum push size (MB)'), class: 'label-light'
= f.number_field :receive_max_input_size, class: 'form-control gl-form-input qa-receive-max-input-size-field', title: _('Maximum size limit for a single commit.'), data: { toggle: 'tooltip', container: 'body' }
= f.number_field :receive_max_input_size, class: 'form-control gl-form-input', title: _('Maximum size limit for a single commit.'), data: { toggle: 'tooltip', container: 'body', qa_selector: 'receive_max_input_size_field' }
.form-group
= f.label :max_export_size, _('Maximum export size (MB)'), class: 'label-light'
= f.number_field :max_export_size, class: 'form-control gl-form-input', title: _('Maximum size of export files.'), data: { toggle: 'tooltip', container: 'body' }
%span.form-text.text-muted= _('Set to 0 for no size limit.')
.form-group
= f.label :max_import_size, _('Maximum import size (MB)'), class: 'label-light'
= f.number_field :max_import_size, class: 'form-control gl-form-input qa-receive-max-import-size-field', title: _('Maximum size of import files.'), data: { toggle: 'tooltip', container: 'body' }
= f.number_field :max_import_size, class: 'form-control gl-form-input', title: _('Maximum size of import files.'), data: { toggle: 'tooltip', container: 'body' }
%span.form-text.text-muted= _('Only effective when remote storage is enabled. Set to 0 for no size limit.')
.form-group
= f.label :session_expire_delay, _('Session duration (minutes)'), class: 'label-light'
@ -69,4 +69,4 @@
= render 'admin/application_settings/invitation_flow_enforcement', form: f
= render 'admin/application_settings/user_restrictions', form: f
= render_if_exists 'admin/application_settings/availability_on_namespace_setting', form: f
= f.submit _('Save changes'), class: 'qa-save-changes-button', pajamas_button: true
= f.submit _('Save changes'), pajamas_button: true, data: { qa_selector: 'save_changes_button' }

View File

@ -53,8 +53,7 @@
= link_to sprite_icon('question-o'), help_page_path('ci/pipelines/settings', anchor: 'specify-a-custom-cicd-configuration-file'), target: '_blank', rel: 'noopener noreferrer'
.form-group
= f.gitlab_ui_checkbox_component :suggest_pipeline_enabled, s_('AdminSettings|Enable pipeline suggestion banner'), help_text: s_('AdminSettings|Display a banner on merge requests in projects with no pipelines to initiate steps to add a .gitlab-ci.yml file.')
- if Feature.enabled?(:enforce_runner_token_expires_at)
#js-runner-token-expiration-intervals{ data: runner_token_expiration_interval_attributes }
#js-runner-token-expiration-intervals{ data: runner_token_expiration_interval_attributes }
= f.submit _('Save changes'), pajamas_button: true

View File

@ -10,4 +10,4 @@
= f.label :performance_bar_allowed_group_path, _('Allow access to members of the following group'), class: 'label-bold'
= f.text_field :performance_bar_allowed_group_path, class: 'form-control gl-form-input', placeholder: 'my-org/my-group', value: @application_setting.performance_bar_allowed_group&.full_path
= f.submit _('Save changes'), class: 'qa-save-changes-button', pajamas_button: true
= f.submit _('Save changes'), pajamas_button: true, data: { qa_selector: 'save_changes_button' }

View File

@ -15,12 +15,12 @@
%input.js-ci-variable-input-destroy{ type: "hidden", name: destroy_input_name }
%select.js-ci-variable-input-variable-type.ci-variable-body-item.form-control.select-control.custom-select.table-section.section-15{ name: variable_type_input_name }
= options_for_select(ci_variable_type_options, variable_type)
%input.js-ci-variable-input-key.ci-variable-body-item.qa-ci-variable-input-key.form-control.table-section.section-15{ type: "text",
%input.js-ci-variable-input-key.ci-variable-body-item.form-control.table-section.section-15{ type: "text",
name: key_input_name,
value: key,
placeholder: s_('CiVariables|Input variable key') }
.ci-variable-body-item.gl-show-field-errors.table-section.section-15.border-top-0.p-0
%textarea.js-ci-variable-input-value.js-secret-value.qa-ci-variable-input-value.form-control{ rows: 1,
%textarea.js-ci-variable-input-value.js-secret-value.form-control{ rows: 1,
name: value_input_name,
placeholder: s_('CiVariables|Input variable value') }
= value

View File

@ -18,14 +18,14 @@
%input.js-ci-variable-input-destroy{ type: "hidden", name: destroy_input_name }
%select.js-ci-variable-input-variable-type.ci-variable-body-item.form-control.select-control.custom-select.table-section.section-15{ name: variable_type_input_name }
= options_for_select(ci_variable_type_options, variable_type)
%input.js-ci-variable-input-key.ci-variable-body-item.qa-ci-variable-input-key.form-control.gl-form-input.table-section.section-15{ type: "text",
%input.js-ci-variable-input-key.ci-variable-body-item.form-control.gl-form-input.table-section.section-15{ type: "text",
name: key_input_name,
value: key,
placeholder: s_('CiVariables|Input variable key') }
.ci-variable-body-item.gl-show-field-errors.table-section.section-15.border-top-0.p-0
.form-control.js-secret-value-placeholder.qa-ci-variable-input-value.overflow-hidden{ class: ('hide' unless id) }
.form-control.js-secret-value-placeholder.overflow-hidden{ class: ('hide' unless id) }
= '*' * 17
%textarea.js-ci-variable-input-value.js-secret-value.qa-ci-variable-input-value.form-control.gl-form-input{ class: ('hide' if id),
%textarea.js-ci-variable-input-value.js-secret-value.form-control.gl-form-input{ class: ('hide' if id),
rows: 1,
name: value_input_name,
placeholder: s_('CiVariables|Input variable value') }

View File

@ -1,6 +1,6 @@
- add_page_specific_style 'page_bundles/prometheus'
%section.settings.no-animate.expanded.cluster-health-graphs.qa-cluster-health-section#cluster-health
%section.settings.no-animate.expanded.cluster-health-graphs#cluster-health
- if @cluster&.integration_prometheus_available?
#prometheus-graphs{ data: @cluster.health_data(clusterable) }

View File

@ -36,7 +36,7 @@
= platform_kubernetes_field.form_group :authorization_type,
{ help: '%{help_text} %{help_link}'.html_safe % { help_text: rbac_help_text, help_link: rbac_help_link } } do
= platform_kubernetes_field.check_box :authorization_type,
{ class: 'qa-rbac-checkbox', label: s_('ClusterIntegration|RBAC-enabled cluster'),
{ data: { qa_selector: 'rbac_checkbox'}, label: s_('ClusterIntegration|RBAC-enabled cluster'),
label_class: 'label-bold', inline: true }, 'rbac', 'abac'
.form-group

View File

@ -132,7 +132,7 @@
- if header_link?(:user_dropdown)
%li.nav-item.header-user.js-nav-user-dropdown.dropdown{ data: { track_label: "profile_dropdown", track_action: "click_dropdown", track_value: "", qa_selector: 'user_menu', testid: 'user-menu' }, class: ('mr-0' if has_impersonation_link) }
= link_to current_user, class: user_dropdown_class, data: { toggle: "dropdown" } do
= render Pajamas::AvatarComponent.new(current_user, size: 24, class: 'header-user-avatar qa-user-avatar')
= render Pajamas::AvatarComponent.new(current_user, size: 24, class: 'header-user-avatar', avatar_options: { data: { qa_selector: 'user_avatar_content' } })
= render_if_exists 'layouts/header/user_notification_dot', project: project, namespace: group
= sprite_icon('chevron-down', css_class: 'caret-down')
.dropdown-menu.dropdown-menu-right

View File

@ -1,4 +1,4 @@
%aside.nav-sidebar.qa-admin-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), 'aria-label': _('Admin navigation') }
%aside.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), 'aria-label': _('Admin navigation'), data: { qa_selector: 'admin_sidebar_content' } }
.nav-sidebar-inner-scroll
.context-header
= link_to admin_root_path, title: _('Admin Overview'), class: 'has-tooltip', data: { container: 'body', placement: 'right' } do
@ -6,7 +6,7 @@
= sprite_icon('admin', size: 18)
%span.sidebar-context-title
= _('Admin Area')
%ul.sidebar-top-level-items{ data: { qa_selector: 'admin_sidebar_overview_submenu_content' } }
%ul.sidebar-top-level-items{ data: { qa_selector: 'admin_overview_submenu_content' } }
= nav_link(controller: %w[dashboard admin admin/projects users groups admin/topics jobs runners gitaly_servers cohorts], html_options: {class: 'home'}) do
= link_to admin_root_path, class: 'has-sub-items' do
.nav-icon-container
@ -28,15 +28,15 @@
%span
= _('Projects')
= nav_link(controller: %w[users cohorts]) do
= link_to admin_users_path, title: _('Users'), data: { qa_selector: 'users_overview_link' } do
= link_to admin_users_path, title: _('Users'), data: { qa_selector: 'admin_overview_users_link' } do
%span
= _('Users')
= nav_link(controller: :groups) do
= link_to admin_groups_path, title: _('Groups'), data: { qa_selector: 'groups_overview_link' } do
= link_to admin_groups_path, title: _('Groups'), data: { qa_selector: 'admin_overview_groups_link' } do
%span
= _('Groups')
= nav_link(controller: [:admin, 'admin/topics']) do
= link_to admin_topics_path, title: _('Topics'), data: { qa_selector: 'topics_overview_link' } do
= link_to admin_topics_path, title: _('Topics') do
%span
= _('Topics')
= nav_link path: 'jobs#index' do
@ -75,13 +75,13 @@
= _('Usage Trends')
= nav_link(controller: admin_monitoring_nav_links) do
= link_to admin_system_info_path, data: { qa_selector: 'admin_monitoring_link' }, class: 'has-sub-items' do
= link_to admin_system_info_path, data: { qa_selector: 'admin_monitoring_menu_link' }, class: 'has-sub-items' do
.nav-icon-container
= sprite_icon('monitor')
%span.nav-item-name
= _('Monitoring')
%ul.sidebar-sub-level-items{ data: { qa_selector: 'admin_sidebar_monitoring_submenu_content' } }
%ul.sidebar-sub-level-items{ data: { qa_selector: 'admin_monitoring_submenu_content' } }
= nav_link(controller: admin_monitoring_nav_links, html_options: { class: "fly-out-top-item" } ) do
= link_to admin_system_info_path do
%strong.fly-out-top-item-name
@ -222,10 +222,10 @@
= link_to general_admin_application_settings_path, class: 'has-sub-items' do
.nav-icon-container
= sprite_icon('settings')
%span.nav-item-name.qa-admin-settings-item
%span.nav-item-name{ data: { qa_selector: 'admin_settings_menu_link' } }
= _('Settings')
%ul.sidebar-sub-level-items{ data: { qa_selector: 'admin_sidebar_settings_submenu_content' } }
%ul.sidebar-sub-level-items{ data: { qa_selector: 'admin_settings_submenu_content' } }
-# This active_nav_link check is also used in `app/views/layouts/admin.html.haml`
= nav_link(controller: [:application_settings, :integrations, :appearances], html_options: { class: "fly-out-top-item" } ) do
= link_to general_admin_application_settings_path do
@ -233,24 +233,24 @@
= _('Settings')
%li.divider.fly-out-top-item
= nav_link(path: 'application_settings#general') do
= link_to general_admin_application_settings_path, title: _('General'), class: 'qa-admin-settings-general-item' do
= link_to general_admin_application_settings_path, title: _('General'), data: { qa_selector: 'admin_settings_general_link' } do
%span
= _('General')
= render_if_exists 'layouts/nav/sidebar/advanced_search', class: 'qa-admin-settings-advanced-search'
= render_if_exists 'layouts/nav/sidebar/advanced_search', data: { qa_selector: 'admin_settings_advanced_search_link' }
- if instance_level_integrations?
= nav_link(path: ['application_settings#integrations', 'integrations#edit']) do
= link_to integrations_admin_application_settings_path, title: _('Integrations'), data: { qa_selector: 'integration_settings_link' } do
= link_to integrations_admin_application_settings_path, title: _('Integrations'), data: { qa_selector: 'admin_settings_integrations_link' } do
%span
= _('Integrations')
= nav_link(path: 'application_settings#repository') do
= link_to repository_admin_application_settings_path, title: _('Repository'), class: 'qa-admin-settings-repository-item' do
= link_to repository_admin_application_settings_path, title: _('Repository'), data: { qa_selector: 'admin_settings_repository_link' } do
%span
= _('Repository')
- if Gitlab.ee? && License.feature_available?(:custom_file_templates)
= nav_link(path: 'application_settings#templates') do
= link_to templates_admin_application_settings_path, title: _('Templates'), class: 'qa-admin-settings-template-item' do
= link_to templates_admin_application_settings_path, title: _('Templates'), data: { qa_selector: 'admin_settings_templates_link' } do
%span
= _('Templates')
= nav_link(path: 'application_settings#ci_cd') do
@ -262,7 +262,7 @@
%span
= _('Reporting')
= nav_link(path: 'application_settings#metrics_and_profiling') do
= link_to metrics_and_profiling_admin_application_settings_path, title: _('Metrics and profiling'), class: 'qa-admin-settings-metrics-and-profiling-item' do
= link_to metrics_and_profiling_admin_application_settings_path, title: _('Metrics and profiling'), data: { qa_selector: 'admin_settings_metrics_and_profiling_link' } do
%span
= _('Metrics and profiling')
= nav_link(path: ['application_settings#service_usage_data']) do
@ -270,7 +270,7 @@
%span
= _('Service usage data')
= nav_link(path: 'application_settings#network') do
= link_to network_admin_application_settings_path, title: _('Network'), data: { qa_selector: 'admin_settings_network_item' } do
= link_to network_admin_application_settings_path, title: _('Network'), data: { qa_selector: 'admin_settings_network_link' } do
%span
= _('Network')
= nav_link(controller: :appearances ) do

View File

@ -25,7 +25,7 @@
%ul.nav.navbar-nav
%li.header-user.dropdown
= link_to current_user, class: user_dropdown_class, data: { toggle: "dropdown" } do
= render Pajamas::AvatarComponent.new(current_user, size: 24, class: 'gl-mr-3', avatar_options: { data: { qa_selector: 'user_avatar' } })
= render Pajamas::AvatarComponent.new(current_user, size: 24, class: 'gl-mr-3', avatar_options: { data: { qa_selector: 'user_avatar_content' } })
= sprite_icon('chevron-down')
.dropdown-menu.dropdown-menu-right
= render 'layouts/header/current_user_dropdown'

View File

@ -2,14 +2,14 @@
.template-selector-dropdowns-wrap
.template-type-selector.js-template-type-selector-wrap.hidden
- toggle_text = should_suggest_gitlab_ci_yml? ? '.gitlab-ci.yml' : 'Select a template type'
= dropdown_tag(_(toggle_text), options: { toggle_class: 'js-template-type-selector qa-template-type-dropdown', dropdown_class: 'dropdown-menu-selectable' })
= dropdown_tag(_(toggle_text), options: { toggle_class: 'js-template-type-selector', dropdown_class: 'dropdown-menu-selectable', data: { qa_selector: 'template_type_dropdown' } })
.license-selector.js-license-selector-wrap.js-template-selector-wrap.hidden
= dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-license-selector qa-license-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: licenses_for_select(@project), project: @project.name, fullname: @project.namespace.human_name } } )
= dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-license-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: licenses_for_select(@project), project: @project.name, fullname: @project.namespace.human_name, qa_selector: 'license_dropdown' } } )
.gitignore-selector.js-gitignore-selector-wrap.js-template-selector-wrap.hidden
= dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-gitignore-selector qa-gitignore-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitignore_names(@project) } } )
= dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-gitignore-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitignore_names(@project), qa_selector: 'gitignore_dropdown' } } )
.metrics-dashboard-selector.js-metrics-dashboard-selector-wrap.js-template-selector-wrap.hidden
= dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-metrics-dashboard-selector qa-metrics-dashboard-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: metrics_dashboard_ymls(@project) } } )
= dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-metrics-dashboard-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: metrics_dashboard_ymls(@project), qa_selector: 'metrics_dashboard_dropdown' } } )
#gitlab-ci-yml-selector.gitlab-ci-yml-selector.js-gitlab-ci-yml-selector-wrap.js-template-selector-wrap.hidden
= dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-gitlab-ci-yml-selector qa-gitlab-ci-yml-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitlab_ci_ymls(@project), selected: params[:template] } } )
= dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-gitlab-ci-yml-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitlab_ci_ymls(@project), selected: params[:template], qa_selector: 'gitlab_ci_yml_dropdown' } } )
.dockerfile-selector.js-dockerfile-selector-wrap.js-template-selector-wrap.hidden
= dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-dockerfile-selector qa-dockerfile-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: dockerfile_names(@project) } } )
= dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-dockerfile-selector', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: dockerfile_names(@project), qa_selector: 'dockerfile_dropdown' } } )

View File

@ -2,17 +2,17 @@
- dropdown_class = local_assigns.fetch(:dropdown_class, '')
.git-clone-holder.js-git-clone-holder
%a#clone-dropdown.gl-button.btn.btn-confirm.clone-dropdown-btn.qa-clone-dropdown{ href: '#', data: { toggle: 'dropdown' } }
%a#clone-dropdown.gl-button.btn.btn-confirm.clone-dropdown-btn{ href: '#', data: { toggle: 'dropdown', qa_selector: 'clone_dropdown' } }
%span.gl-mr-2.js-clone-dropdown-label
= _('Clone')
= sprite_icon("chevron-down", css_class: "icon")
%ul.dropdown-menu.dropdown-menu-large.dropdown-menu-selectable.clone-options-dropdown.qa-clone-options{ class: dropdown_class }
%ul.dropdown-menu.dropdown-menu-large.dropdown-menu-selectable.clone-options-dropdown{ class: dropdown_class, data: { qa_selector: 'clone_dropdown_content' } }
- if ssh_enabled?
%li{ class: 'gl-px-4!' }
%label.label-bold
= _('Clone with SSH')
.input-group.btn-group
= text_field_tag :ssh_project_clone, project.ssh_url_to_repo, class: "js-select-on-focus form-control qa-ssh-clone-url", readonly: true, aria: { label: _('Repository clone URL') }
= text_field_tag :ssh_project_clone, project.ssh_url_to_repo, class: "js-select-on-focus form-control", readonly: true, aria: { label: _('Repository clone URL') }, data: { qa_selector: 'ssh_clone_url_content' }
.input-group-append
= clipboard_button(target: '#ssh_project_clone', title: _("Copy URL"), class: "input-group-text gl-button btn btn-icon btn-default")
= render_if_exists 'projects/buttons/geo'
@ -21,7 +21,7 @@
%label.label-bold
= _('Clone with %{http_label}') % { http_label: gitlab_config.protocol.upcase }
.input-group.btn-group
= text_field_tag :http_project_clone, project.http_url_to_repo, class: "js-select-on-focus form-control qa-http-clone-url", readonly: true, aria: { label: _('Repository clone URL') }
= text_field_tag :http_project_clone, project.http_url_to_repo, class: "js-select-on-focus form-control", readonly: true, aria: { label: _('Repository clone URL') }, data: { qa_selector: 'http_clone_url_content' }
.input-group-append
= clipboard_button(target: '#http_project_clone', title: _("Copy URL"), class: "input-group-text gl-button btn btn-icon btn-default")
= render_if_exists 'projects/buttons/geo'

View File

@ -1,2 +1,2 @@
- if Feature.enabled?(:work_items_hierarchy, @project)
.js-work-item-links-root{ data: { issuable_id: @issue.id, iid: @issue.iid, project_path: @project.full_path, wi: work_items_index_data(@project) } }
.js-work-item-links-root{ data: { issuable_id: @issue.id, iid: @issue.iid, project_namespace: @project.namespace.path, project_path: @project.full_path, wi: work_items_index_data(@project) } }

View File

@ -9,14 +9,14 @@
%span.js-clone-dropdown-label
= default_clone_protocol.upcase
= sprite_icon('chevron-down', css_class: 'gl-icon')
%ul.dropdown-menu.dropdown-menu-selectable.clone-options-dropdown
%ul.dropdown-menu.dropdown-menu-selectable{ data: { qa_selector: 'clone_dropdown_content' } }
%li
= ssh_clone_button(container)
%li
= http_clone_button(container)
= render_if_exists 'shared/kerberos_clone_button', container: container
= text_field_tag :clone_url, default_url_to_repo(container), class: "js-select-on-focus btn gl-button", readonly: true, aria: { label: _('Repository clone URL') }
= text_field_tag :clone_url, default_url_to_repo(container), class: "js-select-on-focus btn gl-button", readonly: true, aria: { label: _('Repository clone URL') }, data: { qa_selector: 'clone_url_content' }
.input-group-append
= clipboard_button(target: '#clone_url', title: _("Copy URL"), class: "input-group-text gl-button btn-default btn-clipboard")

View File

@ -4,16 +4,12 @@
- link_end = '</a>'.html_safe
- if hook.rate_limited?
- support_path = 'https://support.gitlab.com/hc/en-us/requests/new'
- placeholders = { strong_start: strong_start,
strong_end: strong_end,
limit: hook.rate_limit,
support_link_start: link_start % { url: support_path },
support_link_end: link_end }
= render Pajamas::AlertComponent.new(title: s_('Webhooks|Webhook was automatically disabled'),
- placeholders = { limit: number_with_delimiter(hook.rate_limit),
root_namespace: hook.parent.root_namespace.path }
= render Pajamas::AlertComponent.new(title: s_('Webhooks|Webhook rate limit has been reached'),
variant: :danger) do |c|
= c.body do
= s_('Webhooks|The webhook was triggered more than %{limit} times per minute and is now disabled. To re-enable this webhook, fix the problems shown in %{strong_start}Recent events%{strong_end}, then re-test your settings. %{support_link_start}Contact Support%{support_link_end} if you need help re-enabling your webhook.').html_safe % placeholders
= s_("Webhooks|Webhooks for %{root_namespace} are now disabled because they've been triggered more than %{limit} times per minute. They'll be automatically re-enabled in the next minute.").html_safe % placeholders
- elsif hook.permanently_disabled?
= render Pajamas::AlertComponent.new(title: s_('Webhooks|Webhook failed to connect'),
variant: :danger) do |c|

View File

@ -8,15 +8,15 @@ opt_parser = OptionParser.new do |opt|
Profile a URL on this GitLab instance.
Usage:
#{__FILE__} url --output=<profile-html> --sql=<sql-log> [--user=<user>] [--post=<post-data>]
#{__FILE__} url --output=<profile-dump> --sql=<sql-log> [--user=<user>] [--post=<post-data>]
Example:
#{__FILE__} /dashboard/issues --output=dashboard-profile.html --sql=dashboard.log --user=root
#{__FILE__} /dashboard/issues --output=dashboard-profile.dump --sql=dashboard.log --user=root
DOCSTRING
opt.separator ''
opt.separator 'Options:'
opt.on('-o', '--output=/tmp/profile.html', 'profile output filename') do |output|
opt.on('-o', '--output=/tmp/profile.dump', 'profile output filename') do |output|
options[:profile_output] = output
end
@ -45,13 +45,9 @@ end
require File.expand_path('../config/environment', File.dirname(__FILE__))
result = Gitlab::Profiler.profile(options[:url],
logger: Logger.new(options[:sql_output]),
post_data: options[:post_data],
user: UserFinder.new(options[:username]).find_by_username,
private_token: ENV['PRIVATE_TOKEN'])
printer = RubyProf::CallStackPrinter.new(result)
file = File.open(options[:profile_output], 'w')
printer.print(file)
file.close
Gitlab::Profiler.profile(options[:url],
logger: Logger.new(options[:sql_output]),
post_data: options[:post_data],
user: UserFinder.new(options[:username]).find_by_username,
private_token: ENV['PRIVATE_TOKEN'],
profiler_options: { out: options[:profile_output] })

39
bin/rubocop-profile Executable file
View File

@ -0,0 +1,39 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
# Profile bundled RuboCop version.
#
# See https://github.com/rubocop/rubocop/blob/master/bin/rubocop-profile
if ARGV.include?('-h') || ARGV.include?('--help')
puts "Usage: same as main `rubocop` command but gathers profiling info"
puts "Additional option: `--memory` to print memory usage"
exit(0)
end
with_mem = ARGV.delete('--memory')
ARGV.unshift '--cache', 'false' unless ARGV.include?('--cache')
require 'stackprof'
if with_mem
require 'memory_profiler'
MemoryProfiler.start
end
StackProf.start
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
begin
require 'rubocop'
exit RuboCop::CLI.new.run
ensure
delta = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
puts "Finished in #{delta.round(1)} seconds"
StackProf.stop
if with_mem
puts "Building memory report..."
report = MemoryProfiler.stop
end
Dir.mkdir('tmp') unless File.exist?('tmp')
StackProf.results('tmp/stackprof.dump')
report&.pretty_print(scale_bytes: true)
puts "StackProf written to `tmp/stackprof.dump`."
end

View File

@ -1,8 +0,0 @@
---
name: enforce_runner_token_expires_at
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/78557
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/352008
milestone: '14.8'
type: development
group: group::runner
default_enabled: false

View File

@ -5,4 +5,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/373792
milestone: '15.4'
type: development
group: group::optimize
default_enabled: false
default_enabled: true

View File

@ -1,8 +1,8 @@
---
name: gl_listbox_for_sort_dropdowns
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/98363
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/364715
name: pipeline_name
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/97502
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/376095
milestone: '15.5'
type: development
group: group::foundations
group: group::delivery
default_enabled: false

View File

@ -646,7 +646,11 @@ module.exports = {
patterns: [
{
from: path.join(ROOT_PATH, 'node_modules/pdfjs-dist/cmaps/'),
to: path.join(WEBPACK_OUTPUT_PATH, 'cmaps/'),
to: path.join(WEBPACK_OUTPUT_PATH, 'pdfjs/cmaps/'),
},
{
from: path.join(ROOT_PATH, 'node_modules/pdfjs-dist/legacy/build/pdf.worker.min.js'),
to: path.join(WEBPACK_OUTPUT_PATH, 'pdfjs/'),
},
{
from: path.join(ROOT_PATH, 'node_modules', SOURCEGRAPH_PACKAGE, '/'),

View File

@ -26,8 +26,6 @@ module.exports = {
entry: {
vendor: [
'jquery/dist/jquery.slim.js',
'pdfjs-dist/build/pdf',
'pdfjs-dist/build/pdf.worker.min',
'core-js',
'echarts',
'lodash',

View File

@ -0,0 +1,20 @@
# frozen_string_literal: true
class AddFreeUserCapOverLimitNotifiedAtToNamespaceDetails < Gitlab::Database::Migration[2.0]
disable_ddl_transaction!
TABLE_NAME = 'namespace_details'
COLUMN_NAME = 'free_user_cap_over_limit_notified_at'
def up
with_lock_retries do
add_column(TABLE_NAME, COLUMN_NAME, :datetime_with_timezone)
end
end
def down
with_lock_retries do
remove_column TABLE_NAME, COLUMN_NAME
end
end
end

View File

@ -0,0 +1 @@
2652f733d5998b4dacc89a7c43af45e6d411235efcdc120be02bbf04eb1c55d6

View File

@ -17866,7 +17866,8 @@ CREATE TABLE namespace_details (
cached_markdown_version integer,
description text,
description_html text,
free_user_cap_over_limt_notified_at timestamp with time zone
free_user_cap_over_limt_notified_at timestamp with time zone,
free_user_cap_over_limit_notified_at timestamp with time zone
);
CREATE TABLE namespace_limits (

View File

@ -915,12 +915,8 @@ To determine which runners need to be upgraded:
## Authentication token security
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/30942) in GitLab 15.3 [with a flag](../../administration/feature_flags.md) named `enforce_runner_token_expires_at`. Disabled by default.
FLAG:
On self-managed GitLab, by default this feature is not available. To make it available, ask an administrator to
[enable the feature flag](../../administration/feature_flags.md) named `enforce_runner_token_expires_at`.
On GitLab.com, this feature is not available.
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/30942) in GitLab 15.3 [with a flag](../../administration/feature_flags.md) named `enforce_runner_token_expires_at`. Disabled by default.
> - [Generally available](https://gitlab.com/gitlab-org/gitlab/-/issues/377902) in GitLab 15.5. Feature flag `enforce_runner_token_expires_at` removed.
Each runner has an [authentication token](../../api/runners.md#registration-and-authentication-tokens)
to connect with the GitLab instance.

View File

@ -398,6 +398,30 @@ Use [`workflow`](workflow.md) to control pipeline behavior.
- [`workflow: rules` examples](workflow.md#workflow-rules-examples)
- [Switch between branch pipelines and merge request pipelines](workflow.md#switch-between-branch-pipelines-and-merge-request-pipelines)
#### `workflow:name`
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/372538) in GitLab 15.5 [with a flag](../../administration/feature_flags.md) named `pipeline_name`. Disabled by default.
FLAG:
On self-managed GitLab, by default this feature is not available. To make it available,
ask an administrator to [enable the feature flag](../../administration/feature_flags.md) named `pipeline_name`.
The feature is not ready for production use.
You can use `name` in `workflow:` to define a name for pipelines.
All pipelines are assigned the defined name. Any leading or trailing spaces in the name are removed.
**Possible inputs**:
- A string.
**Example of `workflow:name`**:
```yaml
workflow:
name: 'Pipeline name'
```
#### `workflow:rules`
The `rules` keyword in `workflow` is similar to [`rules` defined in jobs](#rules),

View File

@ -20,6 +20,9 @@ The first argument to the profiler is either a full URL
(including the instance hostname) or an absolute path, including the
leading slash.
By default the report dump will be stored in a temporary file, which can be
interacted with using the [stackprof API](#reading-a-gitlabprofiler-report).
When using the script, command-line documentation is available by passing no
arguments.
@ -31,10 +34,11 @@ For example:
```ruby
Gitlab::Profiler.profile('/my-user')
# Returns a RubyProf::Profile for the regular operation of this request
# Returns the location of the temp file where the report dump is stored
class UsersController; def show; sleep 100; end; end
Gitlab::Profiler.profile('/my-user')
# Returns a RubyProf::Profile where 100 seconds is spent in UsersController#show
# Returns the location of the temp file where the report dump is stored
# where 100 seconds is spent in UsersController#show
```
For routes that require authorization you must provide a user to
@ -52,57 +56,14 @@ documented with the method source.
Gitlab::Profiler.profile('/gitlab-org/gitlab-test', user: User.first, logger: Logger.new($stdout))
```
There is also a RubyProf printer available:
`Gitlab::Profiler::TotalTimeFlatPrinter`. This acts like
`RubyProf::FlatPrinter`, but its `min_percent` option works on the method's
total time, not its self time. (This is because we often spend most of our time
in library code, but this comes from calls in our application.) It also offers a
`max_percent` option to help filter out outer calls that aren't useful (like
`ActionDispatch::Integration::Session#process`).
There is a convenience method for using this,
`Gitlab::Profiler.print_by_total_time`:
```ruby
result = Gitlab::Profiler.profile('/my-user')
Gitlab::Profiler.print_by_total_time(result, max_percent: 60, min_percent: 2)
# Measure Mode: wall_time
# Thread ID: 70005223698240
# Fiber ID: 70004894952580
# Total: 1.768912
# Sort by: total_time
#
# %self total self wait child calls name
# 0.00 1.017 0.000 0.000 1.017 14 *ActionView::Helpers::RenderingHelper#render
# 0.00 1.017 0.000 0.000 1.017 14 *ActionView::Renderer#render_partial
# 0.00 1.017 0.000 0.000 1.017 14 *ActionView::PartialRenderer#render
# 0.00 1.007 0.000 0.000 1.007 14 *ActionView::PartialRenderer#render_partial
# 0.00 0.930 0.000 0.000 0.930 14 Hamlit::TemplateHandler#call
# 0.00 0.928 0.000 0.000 0.928 14 Temple::Engine#call
# 0.02 0.865 0.000 0.000 0.864 638 *Enumerable#inject
```
To print the profile in HTML format, use the following example:
```ruby
result = Gitlab::Profiler.profile('/my-user')
printer = RubyProf::CallStackPrinter.new(result)
printer.print(File.open('/tmp/profile.html', 'w'))
```
### Stackprof support
By default, `Gitlab::Profiler.profile` uses a tracing profiler called [`ruby-prof`](https://ruby-prof.github.io/). However, sampling profilers
[run faster and use less memory](https://jvns.ca/blog/2017/12/17/how-do-ruby---python-profilers-work-/), so they might be preferred.
You can switch to [Stackprof](https://github.com/tmm1/stackprof) (a sampling profiler) to generate a profile by passing `sampling_mode: true`.
Pass in a `profiler_options` hash to configure the output file (`out`) of the sampling data. For example:
```ruby
Gitlab::Profiler.profile('/gitlab-org/gitlab-test', user: User.first, sampling_mode: true, profiler_options: { out: 'tmp/profile.dump' })
Gitlab::Profiler.profile('/gitlab-org/gitlab-test', user: User.first, profiler_options: { out: 'tmp/profile.dump' })
```
## Reading a GitLab::Profiler report
You can get a summary of where time was spent by running Stackprof against the sampling data. For example:
```shell

143
doc/topics/awesome_co.md Normal file
View File

@ -0,0 +1,143 @@
---
stage: Ecosystem
group: Foundations
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
description: AwesomeCo test data harness created by the Test Data Working Group https://about.gitlab.com/company/team/structure/working-groups/demo-test-data/
comments: false
---
# AwesomeCo
AwesomeCo is a test data seeding harness, that can seed test data into a user or group namespace.
AwesomeCo uses FactoryBot in the backend which makes maintenance extremely easy. When a Model is changed,
FactoryBot will already be reflected to account for the change.
## Docker Setup
See [AwesomeCo Docker Demo](https://gitlab.com/-/snippets/2390362)
## GDK Setup
```shell
$ gdk start db
ok: run: services/postgresql: (pid n) 0s, normally down
ok: run: services/redis: (pid n) 74s, normally down
$ bundle install
Bundle complete!
$ bundle exec rake db:migrate
main: migrated
ci: migrated
```
### Run
The `gitlab:seed:awesome_co` Rake task takes two arguments. `:name` and `:namespace_id`.
```shell
$ bundle exec rake "gitlab:seed:awesome_co[awesome_co,1]"
Seeding AwesomeCo for Administrator
```
#### `:name`
Where `:name` is the name of the AwesomeCo. (This will reflect .rb files located in db/seeds/awesome_co/*.rb)
#### `:namespace_id`
Where `:namespace_id` is the ID of the User or Group Namespace
## List of Awesome Companies
Each company (i.e. test data template) is represented as a Ruby file (.rb) in `db/seeds/awesome_co`.
### AwesomeCo (db/seeds/awesome_co/awesome_co.rb)
```shell
$ bundle exec rake "gitlab:seed:awesome_co[awesome_co,:namespace_id]"
Seeding AwesomeCo for :namespace_id
```
AwesomeCo is an automated seeding of [this demo repository](https://gitlab.com/tech-marketing/demos/gitlab-agile-demo/awesome-co).
## Develop
AwesomeCo seeding uses FactoryBot definitions from `spec/factories` which ...
1. Saves time on development
1. Are easy-to-read
1. Are easy to maintain
1. Do not rely on an API that may change in the future
1. Are always up-to-date
1. Execute on the lowest-level (`ActiveRecord`) possible to create data as quickly as possible
> _from the [FactoryBot README](https://github.com/thoughtbot/factory_bot#readme_) : factory_bot is a fixtures replacement with a straightforward definition syntax, support for multiple build
> strategies (saved instances, unsaved instances, attribute hashes, and stubbed objects), and support for multiple factories for the same class, including factory
> inheritance
Factories reside in `spec/factories/*` and are fixtures for Rails models found in `app/models/*`. For example, For a model named `app/models/issue.rb`, the factory will
be named `spec/factories/issues.rb`. For a model named `app/models/project.rb`, the factory will be named `app/models/projects.rb`.
### Taxonomy of a Factory
Factories consist of three main parts - the **Name** of the factory, the **Traits** and the **Attributes**.
Given: `create(:iteration, :with_title, :current, title: 'My Iteration')`
|||
|:-|:-|
| **:iteration** | This is the **Name** of the factory. The file name will be the plural form of this **Name** and reside under either `spec/factories/iterations.rb` or `ee/spec/factories/iterations.rb`. |
| **:with_title** | This is a **Trait** of the factory. [See how it's defined](https://gitlab.com/gitlab-org/gitlab/-/blob/9c2a1f98483921dd006d70fdaed316e21fc5652f/ee/spec/factories/iterations.rb#L21-23). |
| **:current** | This is a **Trait** of the factory. [See how it's defined](https://gitlab.com/gitlab-org/gitlab/-/blob/9c2a1f98483921dd006d70fdaed316e21fc5652f/ee/spec/factories/iterations.rb#L29-31). |
| **title: 'My Iteration'** | This is an **Attribute** of the factory that will be passed to the Model for creation. |
### Examples
In these examples, you will see an instance variable `@owner`. This is the `root` user (`User.first`).
#### Create a Group
```ruby
my_group = create(:group, name: 'My Group', path: 'my-group-path')
```
#### Create a Project
```ruby
# create a Project belonging to a Group
my_project = create(:project, :public, name: 'My Project', namespace: my_group, creator: @owner)
```
#### Create an Issue
```ruby
# create an Issue belonging to a Project
my_issue = create(:issue, title: 'My Issue', project: my_project, weight: 2)
```
#### Create an Iteration
```ruby
# create an Iteration under a Group
my_iteration = create(:iteration, :with_title, :current, title: 'My Iteration', group: my_group)
```
### Frequently encountered issues
#### ActiveRecord::RecordInvalid: Validation failed: Email has already been taken, Username has already been taken
This is because, by default, our factories are written to backfill any data that is missing. For instance, when a project
is created, the project must have somebody that created it. If the owner is not specified, the factory attempts to create it.
**How to fix**
Check the respective Factory to find out what key is required. Usually `:author` or `:owner`.
```ruby
# This throws ActiveRecord::RecordInvalid
create(:project, name: 'Throws Error', namespace: create(:group, name: 'Some Group'))
# Specify the user where @owner is a [User] record
create(:project, name: 'No longer throws error', owner: @owner, namespace: create(:group, name: 'Some Group'))
create(:epic, group: create(:group), author: @owner)
```

View File

@ -88,11 +88,11 @@ Prometheus as long as you meet the requirements above.
To enable the Prometheus integration for your cluster:
1. Go to the cluster's page:
- For a [project-level cluster](../project/clusters/index.md), navigate to your project's
- For a [project-level cluster](../project/clusters/index.md), go to your project's
**Infrastructure > Kubernetes clusters**.
- For a [group-level cluster](../group/clusters/index.md), navigate to your group's
- For a [group-level cluster](../group/clusters/index.md), go to your group's
**Kubernetes** page.
- For an [instance-level cluster](../instance/clusters/index.md), navigate to your instance's
- For an [instance-level cluster](../instance/clusters/index.md), go to your instance's
**Kubernetes** page.
1. Select the **Integrations** tab.
1. Check the **Enable Prometheus integration** checkbox.

View File

@ -186,7 +186,7 @@ When you use the `/add_contacts` or `/remove_contacts` quick actions, follow the
The root group is the topmost group in the group hierarchy.
When you move an issue, project, or group **within the same group hierarchy**,
When you move an issue, project, or group **in the same group hierarchy**,
issues retain their contacts.
When you move an issue or project and the **root group changes**,

View File

@ -35,6 +35,22 @@ graph TD
Also, read more about possible [planning hierarchies](../planning_hierarchy/index.md).
### Child issues from different group hierarchies
<!-- When feature flag is removed, integrate this info as a sentence in
https://docs.gitlab.com/ee/user/group/epics/manage_epics.html#add-an-existing-issue-to-an-epic -->
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/371081) in GitLab 15.5 [with a flag](../../../administration/feature_flags.md) named `epic_issues_from_different_hierarchies`. Disabled by default.
> - [Enabled on GitLab.com](https://gitlab.com/gitlab-org/gitlab/-/issues/373304) in GitLab 15.5.
FLAG:
On self-managed GitLab, by default this feature is unavailable. To make it available, ask an administrator to [enable the feature flag](../../../administration/feature_flags.md) named `epic_issues_from_different_hierarchies`.
On GitLab.com, this feature is available.
You can add issues from a different group hierarchy to an epic.
To do it, paste the issue URL when
[adding an existing issue](manage_epics.md#add-an-existing-issue-to-an-epic).
## Roadmap in epics **(ULTIMATE)**
If your epic contains one or more [child epics](manage_epics.md#multi-level-child-epics) that

View File

@ -577,6 +577,7 @@ Or:
> - Filtering by type was [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/322755) in GitLab 13.10 [with a flag](../../../administration/feature_flags.md) named `vue_issues_list`. Disabled by default.
> - Filtering by type was [enabled on self-managed](https://gitlab.com/gitlab-org/gitlab/-/issues/322755) in GitLab 14.10.
> - Filtering by type is generally available in GitLab 15.1. [Feature flag `vue_issues_list`](https://gitlab.com/gitlab-org/gitlab/-/issues/359966) removed.
> - Filtering by health status [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/218711) in GitLab 15.5.
To filter the list of issues:

View File

@ -390,6 +390,11 @@ line of your Apache configuration to ensure your page slugs render correctly.
WARNING:
This operation deletes all data in the wiki.
WARNING:
Any command that changes data directly could be damaging if not run correctly, or under the
right conditions. We highly recommend running them in a test environment with a backup of the
instance ready to be restored, just in case.
To clear all data from a project wiki and recreate it in a blank state:
1. [Start a Rails console session](../../../administration/operations/rails_console.md#starting-a-rails-console-session).

View File

@ -185,3 +185,21 @@ To set issue weight of a task:
The task window opens.
1. Next to **Weight**, enter a whole, positive number.
1. Select the close icon (**{close}**).
## Add a task to an iteration **(PREMIUM)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/362550) in GitLab 15.5.
You can add a task to an [iteration](group/iterations/index.md).
You can see the iteration title and period only when you view a task.
Prerequisites:
- You must have at least the Reporter role for the project.
To add a task to an iteration:
1. In the issue description, in the **Tasks** section, select the title of the task you want to edit.
The task window opens.
1. Next to **Iteration**, select **Add to iteration**.
1. From the dropdown list, select the iteration to be associated with the task.

View File

@ -218,6 +218,7 @@ module API
[
current_user&.cache_key,
mr.merge_status,
mr.labels.map(&:cache_key),
mr.merge_request_assignees.map(&:cache_key),
mr.merge_request_reviewers.map(&:cache_key)
].join(":")

View File

@ -63,7 +63,7 @@ module API
@results = search_service(additional_params).search_objects(preload_method)
end
set_global_search_log_information
set_global_search_log_information(additional_params)
Gitlab::Metrics::GlobalSearchSlis.record_apdex(
elapsed: @search_duration_s,
@ -105,7 +105,7 @@ module API
# EE, without having to modify this file directly.
end
def search_type
def search_type(additional_params = {})
'basic'
end
@ -113,10 +113,10 @@ module API
params[:scope]
end
def set_global_search_log_information
def set_global_search_log_information(additional_params)
Gitlab::Instrumentation::GlobalSearchApi.set_information(
type: search_type,
level: search_service.level,
type: search_type(additional_params),
level: search_service(additional_params).level,
scope: search_scope,
search_duration_s: @search_duration_s
)

View File

@ -85,6 +85,10 @@ module Gitlab
root.workflow_entry.rules_value
end
def workflow_name
root.workflow_entry.name
end
def normalized_jobs
@normalized_jobs ||= Ci::Config::Normalizer.new(jobs).normalize_jobs
end

View File

@ -6,12 +6,17 @@ module Gitlab
module Entry
class Workflow < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Configurable
include ::Gitlab::Config::Entry::Validatable
include ::Gitlab::Config::Entry::Attributable
ALLOWED_KEYS = %i[rules].freeze
ALLOWED_KEYS = %i[rules name].freeze
attributes :name
validations do
validates :config, type: Hash
validates :config, allowed_keys: ALLOWED_KEYS
validates :name, allow_nil: true, length: { minimum: 1, maximum: 255 }
end
entry :rules, Entry::Rules,

View File

@ -25,6 +25,8 @@ module Gitlab
return error('Failed to build the pipeline!')
end
set_pipeline_name
raise Populate::PopulateError if pipeline.persisted?
end
@ -34,6 +36,15 @@ module Gitlab
private
def set_pipeline_name
return if Feature.disabled?(:pipeline_name, pipeline.project) ||
@command.yaml_processor_result.workflow_name.blank?
name = @command.yaml_processor_result.workflow_name
pipeline.build_pipeline_metadata(project: pipeline.project, title: name)
end
def stage_names
# We filter out `.pre/.post` stages, as they alone are not considered
# a complete pipeline:

View File

@ -36,6 +36,10 @@ module Gitlab
@workflow_rules ||= @ci_config.workflow_rules
end
def workflow_name
@workflow_name ||= @ci_config.workflow_name&.strip
end
def root_variables
@root_variables ||= transform_to_array(@ci_config.variables)
end

View File

@ -56,7 +56,6 @@ module Gitlab
push_frontend_feature_flag(:security_auto_fix)
push_frontend_feature_flag(:new_header_search)
push_frontend_feature_flag(:source_editor_toolbar)
push_frontend_feature_flag(:gl_listbox_for_sort_dropdowns)
push_frontend_feature_flag(:integration_slack_app_notifications)
end

View File

@ -43,12 +43,9 @@ module Gitlab
# - private_token: instead of providing a user instance, the token can be
# given as a string. Takes precedence over the user option.
#
# - sampling_mode: When true, uses a sampling profiler (StackProf) instead of a tracing profiler (RubyProf).
#
# - profiler_options: A keyword Hash of arguments passed to the profiler. Defaults by profiler type:
# RubyProf - {}
# StackProf - { mode: :wall, out: <some temporary file>, interval: 1000, raw: true }
def self.profile(url, logger: nil, post_data: nil, user: nil, private_token: nil, sampling_mode: false, profiler_options: {})
# - profiler_options: A keyword Hash of arguments passed to the profiler. Defaults:
# { mode: :wall, out: <some temporary file>, interval: 1000, raw: true }
def self.profile(url, logger: nil, post_data: nil, user: nil, private_token: nil, profiler_options: {})
app = ActionDispatch::Integration::Session.new(Rails.application)
verb = :get
headers = {}
@ -80,7 +77,7 @@ module Gitlab
with_custom_logger(logger) do
with_user(user) do
with_profiler(sampling_mode, profiler_options) do
with_profiler(profiler_options) do
app.public_send(verb, url, params: post_data, headers: headers) # rubocop:disable GitlabSecurity/PublicSend
end
end
@ -174,21 +171,11 @@ module Gitlab
end
# rubocop: enable CodeReuse/ActiveRecord
def self.print_by_total_time(result, options = {})
default_options = { sort_method: :total_time, filter_by: :total_time }
RubyProf::FlatPrinter.new(result).print($stdout, default_options.merge(options))
end
def self.with_profiler(sampling_mode, profiler_options)
if sampling_mode
require 'stackprof'
args = { mode: :wall, interval: 1000, raw: true }.merge!(profiler_options)
args[:out] ||= ::Tempfile.new(["profile-#{Time.now.to_i}-", ".dump"]).path
::StackProf.run(**args) { yield }
else
RubyProf.profile(**profiler_options) { yield }
end
def self.with_profiler(profiler_options)
require 'stackprof'
args = { mode: :wall, interval: 1000, raw: true }.merge!(profiler_options)
args[:out] ||= ::Tempfile.new(["profile-#{Time.now.to_i}-", ".dump"]).path
::StackProf.run(**args) { yield }
end
end
end

View File

@ -45295,9 +45295,6 @@ msgstr ""
msgid "Webhooks|The webhook failed to connect, and is disabled. To re-enable it, check %{strong_start}Recent events%{strong_end} for error details, then test your settings below."
msgstr ""
msgid "Webhooks|The webhook was triggered more than %{limit} times per minute and is now disabled. To re-enable this webhook, fix the problems shown in %{strong_start}Recent events%{strong_end}, then re-test your settings. %{support_link_start}Contact Support%{support_link_end} if you need help re-enabling your webhook."
msgstr ""
msgid "Webhooks|Trigger"
msgstr ""
@ -45322,7 +45319,10 @@ msgstr ""
msgid "Webhooks|Webhook fails to connect"
msgstr ""
msgid "Webhooks|Webhook was automatically disabled"
msgid "Webhooks|Webhook rate limit has been reached"
msgstr ""
msgid "Webhooks|Webhooks for %{root_namespace} are now disabled because they've been triggered more than %{limit} times per minute. They'll be automatically re-enabled in the next minute."
msgstr ""
msgid "Webhooks|Wiki page events"
@ -45731,6 +45731,9 @@ msgstr ""
msgid "WorkItem|Add task"
msgstr ""
msgid "WorkItem|Add to iteration"
msgstr ""
msgid "WorkItem|Are you sure you want to cancel editing?"
msgstr ""
@ -45781,9 +45784,18 @@ msgstr ""
msgid "WorkItem|Issue"
msgstr ""
msgid "WorkItem|Iteration"
msgstr ""
msgid "WorkItem|Learn about tasks."
msgstr ""
msgid "WorkItem|No iteration"
msgstr ""
msgid "WorkItem|No matching results"
msgstr ""
msgid "WorkItem|No tasks are currently assigned. Use tasks to break down this issue into smaller parts."
msgstr ""
@ -45814,6 +45826,9 @@ msgstr ""
msgid "WorkItem|Something went wrong when deleting the task. Please try again."
msgstr ""
msgid "WorkItem|Something went wrong when fetching iterations. Please try again."
msgstr ""
msgid "WorkItem|Something went wrong when fetching labels. Please try again."
msgstr ""

View File

@ -151,7 +151,7 @@
"mousetrap": "1.6.5",
"papaparse": "^5.3.1",
"patch-package": "^6.4.7",
"pdfjs-dist": "^2.0.943",
"pdfjs-dist": "^2.16.105",
"pikaday": "^1.8.0",
"popper.js": "^1.16.1",
"portal-vue": "^2.1.7",

View File

@ -0,0 +1,8 @@
install:
image: maven:3.6-jdk-11
script:
- 'mvn install -U -s settings.xml'
only:
- "<%= imported_project.default_branch %>"
tags:
- "runner-for-<%= imported_project.name %>"

View File

@ -0,0 +1,23 @@
<settings>
<servers>
<server>
<id>central-proxy</id>
<configuration>
<httpHeaders>
<property>
<name>Private-Token</name>
<value><%= personal_access_token %></value>
</property>
</httpHeaders>
</configuration>
</server>
</servers>
<mirrors>
<mirror>
<id>central-proxy</id>
<name>GitLab proxy of central repo</name>
<url><%= gitlab_address_with_port %>/api/v4/projects/<%= imported_project.id %>/packages/maven</url>
<mirrorOf>central</mirrorOf>
</mirror>
</mirrors>
</settings>

View File

@ -22,10 +22,10 @@ module QA
end
def open_mobile_menu
if has_no_element?(:user_avatar)
if has_no_element?(:user_avatar_content)
Support::Retrier.retry_until do
click_element(:mobile_navbar_button)
has_element?(:user_avatar)
has_element?(:user_avatar_content)
end
end
end

View File

@ -5,78 +5,79 @@ module QA
module Admin
class Menu < Page::Base
view 'app/views/layouts/nav/sidebar/_admin.html.haml' do
element :admin_sidebar
element :admin_sidebar_settings_submenu_content
element :admin_settings_item
element :admin_settings_repository_item
element :admin_settings_general_item
element :admin_settings_metrics_and_profiling_item
element :admin_sidebar_content
element :admin_monitoring_menu_link
element :admin_monitoring_submenu_content
element :admin_overview_submenu_content
element :admin_overview_users_link
element :admin_overview_groups_link
element :admin_settings_menu_link
element :admin_settings_submenu_content
element :admin_settings_general_link
element :admin_settings_integrations_link
element :admin_settings_metrics_and_profiling_link
element :admin_settings_network_link
element :admin_settings_preferences_link
element :admin_monitoring_link
element :admin_sidebar_monitoring_submenu_content
element :admin_sidebar_overview_submenu_content
element :users_overview_link
element :groups_overview_link
element :integration_settings_link
element :admin_settings_repository_link
end
def go_to_preferences_settings
hover_element(:admin_settings_item) do
within_submenu(:admin_sidebar_settings_submenu_content) do
hover_element(:admin_settings_menu_link) do
within_submenu(:admin_settings_submenu_content) do
click_element :admin_settings_preferences_link
end
end
end
def go_to_repository_settings
hover_element(:admin_settings_item) do
within_submenu(:admin_sidebar_settings_submenu_content) do
click_element :admin_settings_repository_item
hover_element(:admin_settings_menu_link) do
within_submenu(:admin_settings_submenu_content) do
click_element :admin_settings_repository_link
end
end
end
def go_to_integration_settings
hover_element(:admin_settings_item) do
within_submenu(:admin_sidebar_settings_submenu_content) do
click_element :integration_settings_link
hover_element(:admin_settings_menu_link) do
within_submenu(:admin_settings_submenu_content) do
click_element :admin_settings_integrations_link
end
end
end
def go_to_general_settings
hover_element(:admin_settings_item) do
within_submenu(:admin_sidebar_settings_submenu_content) do
click_element :admin_settings_general_item
hover_element(:admin_settings_menu_link) do
within_submenu(:admin_settings_submenu_content) do
click_element :admin_settings_general_link
end
end
end
def go_to_metrics_and_profiling_settings
hover_element(:admin_settings_item) do
within_submenu(:admin_sidebar_settings_submenu_content) do
click_element :admin_settings_metrics_and_profiling_item
hover_element(:admin_settings_menu_link) do
within_submenu(:admin_settings_submenu_content) do
click_element :admin_settings_metrics_and_profiling_link
end
end
end
def go_to_network_settings
hover_element(:admin_settings_item) do
within_submenu(:admin_sidebar_settings_submenu_content) do
click_element :admin_settings_network_item
hover_element(:admin_settings_menu_link) do
within_submenu(:admin_settings_submenu_content) do
click_element :admin_settings_network_link
end
end
end
def go_to_users_overview
within_submenu(:admin_sidebar_overview_submenu_content) do
click_element :users_overview_link
within_submenu(:admin_overview_submenu_content) do
click_element :admin_overview_users_link
end
end
def go_to_groups_overview
within_submenu(:admin_sidebar_overview_submenu_content) do
click_element :groups_overview_link
within_submenu(:admin_overview_submenu_content) do
click_element :admin_overview_groups_link
end
end
@ -92,7 +93,7 @@ module QA
end
def within_sidebar
within_element(:admin_sidebar) do
within_element(:admin_sidebar_content) do
yield
end
end

View File

@ -11,18 +11,18 @@ module QA
base.view 'app/views/projects/buttons/_clone.html.haml' do
element :clone_dropdown
element :clone_options
element :ssh_clone_url
element :http_clone_url
element :clone_dropdown_content
element :ssh_clone_url_content
element :http_clone_url_content
end
end
def repository_clone_http_location
repository_clone_location(:http_clone_url)
repository_clone_location(:http_clone_url_content)
end
def repository_clone_ssh_location
repository_clone_location(:ssh_clone_url)
repository_clone_location(:ssh_clone_url_content)
end
private
@ -31,7 +31,7 @@ module QA
wait_until(reload: false) do
click_element :clone_dropdown
within_element :clone_options do
within_element :clone_dropdown_content do
Git::Location.new(find_element(kind).value)
end
end

View File

@ -11,8 +11,8 @@ module QA
base.view 'app/views/shared/_clone_panel.html.haml' do
element :clone_dropdown
element :clone_options_dropdown, '.clone-options-dropdown' # rubocop:disable QA/ElementWithPattern
element :clone_url, 'text_field_tag :clone_url' # rubocop:disable QA/ElementWithPattern
element :clone_dropdown_content
element :clone_url_content
end
end
@ -28,7 +28,7 @@ module QA
end
def repository_location
Git::Location.new(find('#clone_url').value)
Git::Location.new(find_element(:clone_url_content).text)
end
private
@ -37,7 +37,7 @@ module QA
wait_until(reload: false) do
click_element :clone_dropdown
page.within('.clone-options-dropdown') do
within_element(:clone_dropdown_content) do
click_link(kind)
end

View File

@ -14,7 +14,7 @@ module QA
view 'app/views/layouts/header/_default.html.haml' do
element :navbar, required: true
element :canary_badge_link
element :user_avatar, required: !QA::Runtime::Env.mobile_layout?
element :user_avatar_content, required: !QA::Runtime::Env.mobile_layout?
element :user_menu, required: !QA::Runtime::Env.mobile_layout?
element :stop_impersonation_link
element :issues_shortcut_button, required: !QA::Runtime::Env.mobile_layout?
@ -184,11 +184,11 @@ module QA
end
def has_personal_area?(wait: Capybara.default_max_wait_time)
has_element?(:user_avatar, wait: wait)
has_element?(:user_avatar_content, wait: wait)
end
def has_no_personal_area?(wait: Capybara.default_max_wait_time)
has_no_element?(:user_avatar, wait: wait)
has_no_element?(:user_avatar_content, wait: wait)
end
def has_admin_area_link?(wait: Capybara.default_max_wait_time)
@ -227,7 +227,7 @@ module QA
def within_user_menu(&block)
within_top_menu do
click_element :user_avatar unless has_element?(:user_profile_link, wait: 1)
click_element :user_avatar_content unless has_element?(:user_profile_link, wait: 1)
within_element(:user_menu, &block)
end

View File

@ -5,7 +5,7 @@ module QA
module Main
class Terms < Page::Base
view 'app/views/layouts/terms.html.haml' do
element :user_avatar, required: true
element :user_avatar_content, required: true
end
view 'app/assets/javascripts/terms/components/app.vue' do

View File

@ -1,7 +1,11 @@
# frozen_string_literal: true
module QA
RSpec.describe 'Package', :orchestrated, :packages, :object_storage, :reliable do
RSpec.describe 'Package', :orchestrated, :packages, :object_storage, :reliable,
feature_flag: {
name: 'maven_central_request_forwarding',
scope: :global
} do
describe 'Maven project level endpoint' do
include Runtime::Fixtures
@ -143,5 +147,103 @@ module QA
end
end
end
describe 'Maven request forwarding' do
include Runtime::Fixtures
let(:group_id) { 'com.gitlab.qa' }
let(:artifact_id) { "maven-#{SecureRandom.hex(8)}" }
let(:package_name) { "#{group_id}/#{artifact_id}".tr('.', '/') }
let(:package_version) { '1.3.7' }
let(:package_type) { 'maven' }
let(:personal_access_token) { Runtime::Env.personal_access_token }
let(:group) { Resource::Group.fabricate_via_api! }
let(:gitlab_address_with_port) do
uri = URI.parse(Runtime::Scenario.gitlab_address)
"#{uri.scheme}://#{uri.host}:#{uri.port}"
end
let(:package) do
Resource::Package.init do |package|
package.name = package_name
package.project = imported_project
end
end
let(:runner) do
Resource::Runner.fabricate! do |runner|
runner.name = "qa-runner-#{Time.now.to_i}"
runner.tags = ["runner-for-#{imported_project.name}"]
runner.executor = :docker
runner.token = group.reload!.runners_token
end
end
let(:imported_project) do
Resource::ProjectImportedFromURL.fabricate_via_browser_ui! do |project|
project.name = "#{package_type}_imported_project"
project.group = group
project.gitlab_repository_path = 'https://gitlab.com/gitlab-org/quality/imported-projects/maven.git'
end
end
before do
Runtime::Feature.enable(:maven_central_request_forwarding)
Flow::Login.sign_in_unless_signed_in
imported_project
runner
end
after do
Runtime::Feature.disable(:maven_central_request_forwarding)
runner.remove_via_api!
package.remove_via_api!
imported_project.remove_via_api!
end
it(
'uses GitLab as a mirror of the central proxy',
:skip_live_env,
testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/375767'
) do
Support::Retrier.retry_on_exception(max_attempts: 3, sleep_interval: 2) do
Resource::Repository::Commit.fabricate_via_api! do |commit|
gitlab_ci_yaml = ERB.new(read_fixture('package_managers/maven/project/request_forwarding',
'gitlab_ci.yaml.erb'
)
)
.result(binding)
settings_xml = ERB.new(read_fixture('package_managers/maven/project/request_forwarding',
'settings.xml.erb'
)
)
.result(binding)
commit.project = imported_project
commit.commit_message = 'Add files'
commit.add_files(
[
{ file_path: '.gitlab-ci.yml', content: gitlab_ci_yaml },
{ file_path: 'settings.xml', content: settings_xml }
])
end
end
imported_project.visit!
Flow::Pipeline.visit_latest_pipeline
Page::Project::Pipeline::Show.perform do |pipeline|
pipeline.click_job('install')
end
Page::Project::Job::Show.perform do |job|
expect(job).to be_successful(timeout: 800)
end
end
end
end
end

26
rubocop/ext/path_util.rb Normal file
View File

@ -0,0 +1,26 @@
# frozen_string_literal: true
module RuboCop
module PathUtil
def match_path?(pattern, path)
case pattern
when String
matched = if /[*{}]/.match?(pattern)
File.fnmatch?(pattern, path, File::FNM_PATHNAME | File::FNM_EXTGLOB)
else
pattern == path
end
matched || hidden_file_in_not_hidden_dir?(pattern, path)
when Regexp
begin
pattern.match?(path)
rescue ArgumentError => e
return false if e.message.start_with?('invalid byte sequence')
raise e
end
end
end
end
end

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
module RuboCop
module Ext
module VariableForce
def scanned_node?(node)
scanned_nodes.include?(node)
end
def scanned_nodes
@scanned_nodes ||= Set.new.compare_by_identity
end
end
end
end
RuboCop::Cop::VariableForce.prepend RuboCop::Ext::VariableForce

View File

@ -1,6 +1,11 @@
# rubocop:disable Naming/FileName
# frozen_string_literal: true
# Performance improvements to be upstreamed soon:
# See https://gitlab.com/gitlab-org/gitlab/-/issues/377469
require_relative 'ext/path_util'
require_relative 'ext/variable_force'
# Auto-require all cops under `rubocop/cop/**/*.rb`
Dir[File.join(__dir__, 'cop', '**', '*.rb')].sort.each(&method(:require))

View File

@ -9,8 +9,6 @@ RSpec.describe 'Dashboard > User filters projects' do
let(:project2) { create(:project, name: 'Treasure', namespace: user2.namespace, created_at: 1.second.ago, updated_at: 1.second.ago) }
before do
stub_feature_flags(gl_listbox_for_sort_dropdowns: false)
project.add_maintainer(user)
sign_in(user)
@ -147,7 +145,14 @@ RSpec.describe 'Dashboard > User filters projects' do
end
it 'filters any project' do
# Selecting the same option in the `GlListbox` does not emit `select` event
# and that is why URL update won't be triggered. Given that `Any` is a default option
# we need to explicitly switch from some other option (e.g. `Internal`) to `Any`
# to trigger the page update
select_dropdown_option '#filtered-search-visibility-dropdown > .dropdown', 'Internal', '.dropdown-item'
select_dropdown_option '#filtered-search-visibility-dropdown > .dropdown', 'Any', '.dropdown-item'
list = page.all('.projects-list .project-name').map(&:text)
expect(list).to contain_exactly("Internal project", "Private project", "Treasure", "Victorialand")

View File

@ -9,8 +9,6 @@ RSpec.describe 'Sort labels', :js do
let!(:label2) { create(:group_label, title: 'Bar', description: 'Fusce consequat', group: group) }
before do
stub_feature_flags(gl_listbox_for_sort_dropdowns: false)
group.add_maintainer(user)
sign_in(user)
@ -30,7 +28,7 @@ RSpec.describe 'Sort labels', :js do
it 'sorts by date' do
click_button 'Name'
sort_options = find('ul.dropdown-menu').all('li').collect(&:text)
sort_options = find('ul[role="listbox"]').all('li').collect(&:text)
expect(sort_options[0]).to eq('Name')
expect(sort_options[1]).to eq('Name, descending')
@ -39,7 +37,7 @@ RSpec.describe 'Sort labels', :js do
expect(sort_options[4]).to eq('Updated date')
expect(sort_options[5]).to eq('Oldest updated')
click_button 'Name, descending'
find('li', text: 'Name, descending').click
# assert default sorting
within '.other-labels' do

View File

@ -14,7 +14,6 @@ RSpec.describe 'Milestones sorting', :js do
let(:user) { create(:group_member, :maintainer, user: create(:user), group: group ).user }
before do
stub_feature_flags(gl_listbox_for_sort_dropdowns: false)
sign_in(user)
end
@ -30,9 +29,9 @@ RSpec.describe 'Milestones sorting', :js do
within '[data-testid=milestone_sort_by_dropdown]' do
click_button 'Due soon'
expect(find('.gl-new-dropdown-contents').all('.gl-new-dropdown-item-text-wrapper p').map(&:text)).to eq(['Due soon', 'Due later', 'Start soon', 'Start later', 'Name, ascending', 'Name, descending'])
expect(find('ul[role="listbox"]').all('li').map(&:text)).to eq(['Due soon', 'Due later', 'Start soon', 'Start later', 'Name, ascending', 'Name, descending'])
click_button 'Due later'
find('li', text: 'Due later').click
expect(page).to have_button('Due later')
end

View File

@ -9,8 +9,6 @@ RSpec.describe 'Sort labels', :js do
let!(:label2) { create(:label, title: 'Bar', description: 'Fusce consequat', project: project) }
before do
stub_feature_flags(gl_listbox_for_sort_dropdowns: false)
project.add_maintainer(user)
sign_in(user)
@ -30,7 +28,7 @@ RSpec.describe 'Sort labels', :js do
it 'sorts by date' do
click_button 'Name'
sort_options = find('ul.dropdown-menu').all('li').collect(&:text)
sort_options = find('ul[role="listbox"]').all('li').collect(&:text)
expect(sort_options[0]).to eq('Name')
expect(sort_options[1]).to eq('Name, descending')
@ -39,7 +37,7 @@ RSpec.describe 'Sort labels', :js do
expect(sort_options[4]).to eq('Updated date')
expect(sort_options[5]).to eq('Oldest updated')
click_button 'Name, descending'
find('li', text: 'Name, descending').click
# assert default sorting
within '.other-labels' do

View File

@ -21,7 +21,6 @@ RSpec.describe 'Milestones sorting', :js do
end
before do
stub_feature_flags(gl_listbox_for_sort_dropdowns: false)
create(:milestone, start_date: 7.days.from_now, due_date: 10.days.from_now, title: "a", project: project)
create(:milestone, start_date: 6.days.from_now, due_date: 11.days.from_now, title: "c", project: project)
create(:milestone, start_date: 5.days.from_now, due_date: 12.days.from_now, title: "b", project: project)
@ -43,10 +42,10 @@ RSpec.describe 'Milestones sorting', :js do
milestones_for_sort_by.each do |sort_by, expected_milestones|
within '[data-testid=milestone_sort_by_dropdown]' do
click_button selected_sort_order
milestones = find('.gl-new-dropdown-contents').all('.gl-new-dropdown-item-text-wrapper p').map(&:text)
milestones = find('ul[role="listbox"]').all('li').map(&:text)
expect(milestones).to eq(ordered_milestones)
click_button sort_by
find('li', text: sort_by).click
expect(page).to have_button(sort_by)
end

View File

@ -42,7 +42,6 @@ RSpec.describe 'User sorts projects and order persists' do
context "from explore projects", :js do
before do
stub_feature_flags(gl_listbox_for_sort_dropdowns: false)
sign_in(user)
visit(explore_projects_path)
find('#sort-projects-dropdown').click
@ -54,7 +53,6 @@ RSpec.describe 'User sorts projects and order persists' do
context 'from dashboard projects', :js do
before do
stub_feature_flags(gl_listbox_for_sort_dropdowns: false)
sign_in(user)
visit(dashboard_projects_path)
find('#sort-projects-dropdown').click

View File

@ -3,10 +3,6 @@
require "spec_helper"
RSpec.describe 'Project wikis', :js do
before do
stub_feature_flags(gl_listbox_for_sort_dropdowns: false)
end
let_it_be(:user) { create(:user) }
let(:wiki) { create(:project_wiki, user: user, project: project) }

View File

@ -15,7 +15,9 @@ rules:changes as array of strings:
# valid workflow:rules:exists
# valid rules:changes:path
# valid workflow:name
workflow:
name: 'Pipeline name'
rules:
- changes:
paths:

View File

@ -2,6 +2,7 @@ import { GlSearchBoxByType, GlToken, GlIcon } from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { mockTracking } from 'helpers/tracking_helper';
import { s__, sprintf } from '~/locale';
import HeaderSearchApp from '~/header_search/components/app.vue';
import HeaderSearchAutocompleteItems from '~/header_search/components/header_search_autocomplete_items.vue';
@ -360,22 +361,43 @@ describe('HeaderSearchApp', () => {
describe('Header Search Input', () => {
describe('when dropdown is closed', () => {
it('onFocus opens dropdown', async () => {
let trackingSpy;
beforeEach(() => {
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
});
it('onFocus opens dropdown and triggers snowplow event', async () => {
expect(findHeaderSearchDropdown().exists()).toBe(false);
findHeaderSearchInput().vm.$emit('focus');
await nextTick();
expect(findHeaderSearchDropdown().exists()).toBe(true);
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'focus_input', {
label: 'global_search',
property: 'top_navigation',
});
});
it('onClick opens dropdown', async () => {
it('onClick opens dropdown and triggers snowplow event', async () => {
expect(findHeaderSearchDropdown().exists()).toBe(false);
findHeaderSearchInput().vm.$emit('click');
await nextTick();
expect(findHeaderSearchDropdown().exists()).toBe(true);
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'focus_input', {
label: 'global_search',
property: 'top_navigation',
});
});
it('onClick followed by onFocus only triggers a single snowplow event', async () => {
findHeaderSearchInput().vm.$emit('click');
findHeaderSearchInput().vm.$emit('focus');
expect(trackingSpy).toHaveBeenCalledTimes(1);
});
});

View File

@ -1,6 +1,6 @@
import { nextTick } from 'vue';
import { getAllByRole, getByRole, getByTestId } from '@testing-library/dom';
import { GlDropdown, GlListbox } from '@gitlab/ui';
import { getAllByRole, getByTestId } from '@testing-library/dom';
import { GlListbox } from '@gitlab/ui';
import { createWrapper } from '@vue/test-utils';
import { initListbox, parseAttributes } from '~/listbox';
import { getFixture, setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
@ -39,141 +39,65 @@ describe('initListbox', () => {
});
describe('given a valid element', () => {
describe('when `glListboxForSortDropdowns` FF is enabled', () => {
let onChangeSpy;
let onChangeSpy;
const listbox = () => createWrapper(instance).findComponent(GlListbox);
const findToggleButton = () => getByTestId(document.body, 'base-dropdown-toggle');
const findSelectedItems = () => getAllByRole(document.body, 'option', { selected: true });
const listbox = () => createWrapper(instance).findComponent(GlListbox);
const findToggleButton = () => getByTestId(document.body, 'base-dropdown-toggle');
const findSelectedItems = () => getAllByRole(document.body, 'option', { selected: true });
beforeEach(async () => {
setHTMLFixture(fixture);
onChangeSpy = jest.fn();
setup(document.querySelector('.js-redirect-listbox'), { onChange: onChangeSpy });
await nextTick();
});
afterEach(() => {
resetHTMLFixture();
});
it('returns an instance', () => {
expect(instance).not.toBe(null);
});
it('renders button with selected item text', () => {
expect(findToggleButton().textContent.trim()).toBe('Bar');
});
it('has the correct item selected', () => {
const selectedItems = findSelectedItems();
expect(selectedItems).toHaveLength(1);
expect(selectedItems[0].textContent.trim()).toBe('Bar');
});
it('applies additional classes from the original element', () => {
expect(instance.$el.classList).toContain('test-class-1', 'test-class-2');
});
describe.each(parsedAttributes.items)('selecting an item', (item) => {
beforeEach(async () => {
window.gon.features = { glListboxForSortDropdowns: true };
setHTMLFixture(fixture);
onChangeSpy = jest.fn();
setup(document.querySelector('.js-redirect-listbox'), { onChange: onChangeSpy });
listbox().vm.$emit('select', item.value);
await nextTick();
});
afterEach(() => {
resetHTMLFixture();
it('calls the onChange callback with the item', () => {
expect(onChangeSpy).toHaveBeenCalledWith(item);
});
it('returns an instance', () => {
expect(instance).not.toBe(null);
it('updates the toggle button text', () => {
expect(findToggleButton().textContent.trim()).toBe(item.text);
});
it('renders button with selected item text', () => {
expect(findToggleButton().textContent.trim()).toBe('Bar');
});
it('has the correct item selected', () => {
it('marks the item as selected', () => {
const selectedItems = findSelectedItems();
expect(selectedItems).toHaveLength(1);
expect(selectedItems[0].textContent.trim()).toBe('Bar');
});
it('applies additional classes from the original element', () => {
expect(instance.$el.classList).toContain('test-class-1', 'test-class-2');
});
describe.each(parsedAttributes.items)('selecting an item', (item) => {
beforeEach(async () => {
listbox().vm.$emit('select', item.value);
await nextTick();
});
it('calls the onChange callback with the item', () => {
expect(onChangeSpy).toHaveBeenCalledWith(item);
});
it('updates the toggle button text', () => {
expect(findToggleButton().textContent.trim()).toBe(item.text);
});
it('marks the item as selected', () => {
const selectedItems = findSelectedItems();
expect(selectedItems).toHaveLength(1);
expect(selectedItems[0].textContent.trim()).toBe(item.text);
});
});
it('passes the "right" prop through to the underlying component', () => {
expect(listbox().props('right')).toBe(parsedAttributes.right);
expect(selectedItems[0].textContent.trim()).toBe(item.text);
});
});
describe('when `glListboxForSortDropdowns` FF is disabled', () => {
let onChangeSpy;
const ITEM_ROLE = 'menuitem';
const dropdown = () => createWrapper(instance).findComponent(GlDropdown);
const findToggleButton = () => document.body.querySelector('.gl-dropdown-toggle');
const findItem = (text) => getByRole(document.body, ITEM_ROLE, { name: text });
const findItems = () => getAllByRole(document.body, ITEM_ROLE);
const findSelectedItems = () =>
findItems().filter(
(item) =>
!item
.querySelector('.gl-new-dropdown-item-check-icon')
.classList.contains('gl-visibility-hidden'),
);
beforeEach(async () => {
window.gon.features = { glListboxForSortDropdowns: false };
setHTMLFixture(fixture);
onChangeSpy = jest.fn();
setup(document.querySelector('.js-redirect-listbox'), { onChange: onChangeSpy });
await nextTick();
});
afterEach(() => {
resetHTMLFixture();
});
it('returns an instance', () => {
expect(instance).not.toBe(null);
});
it('renders button with selected item text', () => {
expect(findToggleButton().textContent.trim()).toBe('Bar');
});
it('has the correct item selected', () => {
const selectedItems = findSelectedItems();
expect(selectedItems).toHaveLength(1);
expect(selectedItems[0].textContent.trim()).toBe('Bar');
});
it('applies additional classes from the original element', () => {
expect(instance.$el.classList).toContain('test-class-1', 'test-class-2');
});
describe.each(parsedAttributes.items)('selecting an item', (item) => {
beforeEach(async () => {
findItem(item.text).click();
await nextTick();
});
it('calls the onChange callback with the item', () => {
expect(onChangeSpy).toHaveBeenCalledWith(item);
});
it('updates the toggle button text', () => {
expect(findToggleButton().textContent.trim()).toBe(item.text);
});
it('marks the item as selected', () => {
const selectedItems = findSelectedItems();
expect(selectedItems).toHaveLength(1);
expect(selectedItems[0].textContent.trim()).toBe(item.text);
});
});
it('passes the "right" prop through to the underlying component', () => {
expect(dropdown().props('right')).toBe(parsedAttributes.right);
});
it('passes the "right" prop through to the underlying component', () => {
expect(listbox().props('right')).toBe(parsedAttributes.right);
});
});
});

View File

@ -1,5 +1,6 @@
import { GlNavItemDropdown } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { mount, shallowMount } from '@vue/test-utils';
import { mockTracking } from 'helpers/tracking_helper';
import TopNavApp from '~/nav/components/top_nav_app.vue';
import TopNavDropdownMenu from '~/nav/components/top_nav_dropdown_menu.vue';
import { TEST_NAV_DATA } from '../mock_data';
@ -8,6 +9,14 @@ describe('~/nav/components/top_nav_app.vue', () => {
let wrapper;
const createComponent = () => {
wrapper = mount(TopNavApp, {
propsData: {
navData: TEST_NAV_DATA,
},
});
};
const createComponentShallow = () => {
wrapper = shallowMount(TopNavApp, {
propsData: {
navData: TEST_NAV_DATA,
@ -16,6 +25,7 @@ describe('~/nav/components/top_nav_app.vue', () => {
};
const findNavItemDropdown = () => wrapper.findComponent(GlNavItemDropdown);
const findNavItemDropdowToggle = () => findNavItemDropdown().find('.js-top-nav-dropdown-toggle');
const findMenu = () => wrapper.findComponent(TopNavDropdownMenu);
afterEach(() => {
@ -24,7 +34,7 @@ describe('~/nav/components/top_nav_app.vue', () => {
describe('default', () => {
beforeEach(() => {
createComponent();
createComponentShallow();
});
it('renders nav item dropdown', () => {
@ -45,4 +55,18 @@ describe('~/nav/components/top_nav_app.vue', () => {
});
});
});
describe('tracking', () => {
it('emits a tracking event when the toggle is clicked', () => {
const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
createComponent();
findNavItemDropdowToggle().trigger('click');
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_nav', {
label: 'hamburger_menu',
property: 'top_navigation',
});
});
});
});

View File

@ -3,7 +3,7 @@ import { mount } from '@vue/test-utils';
import PageComponent from '~/pdf/page/index.vue';
jest.mock('pdfjs-dist/webpack', () => {
return { default: jest.requireActual('pdfjs-dist/build/pdf') };
return { default: jest.requireActual('pdfjs-dist/legacy/build/pdf') };
});
describe('Page component', () => {

View File

@ -104,6 +104,10 @@ describe('AdminRunnerShowApp', () => {
Platform darwin
Configuration Runs untagged jobs
Maximum job timeout None
Token expiry
Runner authentication token expiration
Runner authentication tokens will expire based on a set interval.
They will automatically rotate once expired. Learn more Never expires
Tags None`.replace(/\s+/g, ' ');
expect(wrapper.text().replace(/\s+/g, ' ')).toContain(expected);

View File

@ -20,8 +20,6 @@ import AdminRunnersApp from '~/runner/admin_runners/admin_runners_app.vue';
import RunnerStackedLayoutBanner from '~/runner/components/runner_stacked_layout_banner.vue';
import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue';
import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue';
import RunnerBulkDelete from '~/runner/components/runner_bulk_delete.vue';
import RunnerBulkDeleteCheckbox from '~/runner/components/runner_bulk_delete_checkbox.vue';
import RunnerList from '~/runner/components/runner_list.vue';
import RunnerListEmptyState from '~/runner/components/runner_list_empty_state.vue';
import RunnerStats from '~/runner/components/stat/runner_stats.vue';
@ -84,8 +82,6 @@ const COUNT_QUERIES = 7; // 4 tabs + 3 status queries
describe('AdminRunnersApp', () => {
let wrapper;
let cacheConfig;
let localMutations;
let showToast;
const findRunnerStackedLayoutBanner = () => wrapper.findComponent(RunnerStackedLayoutBanner);
@ -93,8 +89,6 @@ describe('AdminRunnersApp', () => {
const findRunnerActionsCell = () => wrapper.findComponent(RunnerActionsCell);
const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown);
const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs);
const findRunnerBulkDelete = () => wrapper.findComponent(RunnerBulkDelete);
const findRunnerBulkDeleteCheckbox = () => wrapper.findComponent(RunnerBulkDeleteCheckbox);
const findRunnerList = () => wrapper.findComponent(RunnerList);
const findRunnerListEmptyState = () => wrapper.findComponent(RunnerListEmptyState);
const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination));
@ -107,7 +101,7 @@ describe('AdminRunnersApp', () => {
provide,
...options
} = {}) => {
({ cacheConfig, localMutations } = createLocalState());
const { cacheConfig, localMutations } = createLocalState();
const handlers = [
[allRunnersQuery, mockRunnersHandler],
@ -373,38 +367,9 @@ describe('AdminRunnersApp', () => {
await createComponent({ mountFn: mountExtended });
});
it('runner bulk delete is available', () => {
expect(findRunnerBulkDelete().props('runners')).toEqual(mockRunners);
});
it('runner bulk delete checkbox is available', () => {
expect(findRunnerBulkDeleteCheckbox().props('runners')).toEqual(mockRunners);
});
it('runner list is checkable', () => {
expect(findRunnerList().props('checkable')).toBe(true);
});
it('responds to checked items by updating the local cache', () => {
const setRunnerCheckedMock = jest
.spyOn(localMutations, 'setRunnerChecked')
.mockImplementation(() => {});
const runner = mockRunners[0];
expect(setRunnerCheckedMock).toHaveBeenCalledTimes(0);
findRunnerList().vm.$emit('checked', {
runner,
isChecked: true,
});
expect(setRunnerCheckedMock).toHaveBeenCalledTimes(1);
expect(setRunnerCheckedMock).toHaveBeenCalledWith({
runner,
isChecked: true,
});
});
});
describe('When runners are deleted', () => {
@ -415,7 +380,7 @@ describe('AdminRunnersApp', () => {
it('count data is refetched', async () => {
expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES);
findRunnerBulkDelete().vm.$emit('deleted', { message: 'Runners deleted' });
findRunnerList().vm.$emit('deleted', { message: 'Runners deleted' });
expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES * 2);
});
@ -423,7 +388,7 @@ describe('AdminRunnersApp', () => {
it('toast is shown', async () => {
expect(showToast).toHaveBeenCalledTimes(0);
findRunnerBulkDelete().vm.$emit('deleted', { message: 'Runners deleted' });
findRunnerList().vm.$emit('deleted', { message: 'Runners deleted' });
expect(showToast).toHaveBeenCalledTimes(1);
expect(showToast).toHaveBeenCalledWith('Runners deleted');

View File

@ -5,11 +5,21 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import RunnerBulkDeleteCheckbox from '~/runner/components/runner_bulk_delete_checkbox.vue';
import createMockApollo from 'helpers/mock_apollo_helper';
import { createLocalState } from '~/runner/graphql/list/local_state';
import { allRunnersData } from '../mock_data';
Vue.use(VueApollo);
jest.mock('~/flash');
const makeRunner = (id, deleteRunner = true) => ({
id,
userPermissions: { deleteRunner },
});
// Multi-select checkbox possible states:
const stateToAttrs = {
unchecked: { disabled: undefined, checked: undefined, indeterminate: undefined },
checked: { disabled: undefined, checked: 'true', indeterminate: undefined },
indeterminate: { disabled: undefined, checked: undefined, indeterminate: 'true' },
disabled: { disabled: 'true', checked: undefined, indeterminate: undefined },
};
describe('RunnerBulkDeleteCheckbox', () => {
let wrapper;
@ -18,12 +28,14 @@ describe('RunnerBulkDeleteCheckbox', () => {
const findCheckbox = () => wrapper.findComponent(GlFormCheckbox);
const mockRunners = allRunnersData.data.runners.nodes;
const mockIds = allRunnersData.data.runners.nodes.map(({ id }) => id);
const mockId = mockIds[0];
const mockIdAnotherPage = 'RUNNER_IN_ANOTHER_PAGE_ID';
const expectCheckboxToBe = (state) => {
const expected = stateToAttrs[state];
expect(findCheckbox().attributes('disabled')).toBe(expected.disabled);
expect(findCheckbox().attributes('checked')).toBe(expected.checked);
expect(findCheckbox().attributes('indeterminate')).toBe(expected.indeterminate);
};
const createComponent = ({ props = {} } = {}) => {
const createComponent = ({ runners = [] } = {}) => {
const { cacheConfig, localMutations } = mockState;
const apolloProvider = createMockApollo(undefined, undefined, cacheConfig);
@ -33,8 +45,7 @@ describe('RunnerBulkDeleteCheckbox', () => {
localMutations,
},
propsData: {
runners: mockRunners,
...props,
runners,
},
});
};
@ -49,31 +60,61 @@ describe('RunnerBulkDeleteCheckbox', () => {
jest.spyOn(mockState.localMutations, 'setRunnersChecked');
});
describe.each`
case | is | checkedRunnerIds | disabled | checked | indeterminate
${'no runners'} | ${'unchecked'} | ${[]} | ${undefined} | ${undefined} | ${undefined}
${'no runners in this page'} | ${'unchecked'} | ${[mockIdAnotherPage]} | ${undefined} | ${undefined} | ${undefined}
${'all runners'} | ${'checked'} | ${mockIds} | ${undefined} | ${'true'} | ${undefined}
${'some runners'} | ${'indeterminate'} | ${[mockId]} | ${undefined} | ${undefined} | ${'true'}
${'all plus other runners'} | ${'checked'} | ${[...mockIds, mockIdAnotherPage]} | ${undefined} | ${'true'} | ${undefined}
`('When $case are checked', ({ is, checkedRunnerIds, disabled, checked, indeterminate }) => {
beforeEach(async () => {
describe('when all runners can be deleted', () => {
const mockIds = ['1', '2', '3'];
const mockIdAnotherPage = '4';
const mockRunners = mockIds.map((id) => makeRunner(id));
it.each`
case | checkedRunnerIds | state
${'no runners'} | ${[]} | ${'unchecked'}
${'no runners in this page'} | ${[mockIdAnotherPage]} | ${'unchecked'}
${'all runners'} | ${mockIds} | ${'checked'}
${'some runners'} | ${[mockIds[0]]} | ${'indeterminate'}
${'all plus other runners'} | ${[...mockIds, mockIdAnotherPage]} | ${'checked'}
`('if $case are checked, checkbox is $state', ({ checkedRunnerIds, state }) => {
mockCheckedRunnerIds = checkedRunnerIds;
createComponent();
createComponent({ runners: mockRunners });
expectCheckboxToBe(state);
});
});
describe('when some runners cannot be deleted', () => {
it('all allowed runners are selected, checkbox is checked', () => {
mockCheckedRunnerIds = ['a', 'b', 'c'];
createComponent({
runners: [makeRunner('a'), makeRunner('b'), makeRunner('c', false)],
});
expectCheckboxToBe('checked');
});
it(`is ${is}`, () => {
expect(findCheckbox().attributes('disabled')).toBe(disabled);
expect(findCheckbox().attributes('checked')).toBe(checked);
expect(findCheckbox().attributes('indeterminate')).toBe(indeterminate);
it('some allowed runners are selected, checkbox is indeterminate', () => {
mockCheckedRunnerIds = ['a', 'b'];
createComponent({
runners: [makeRunner('a'), makeRunner('b'), makeRunner('c')],
});
expectCheckboxToBe('indeterminate');
});
it('no allowed runners are selected, checkbox is disabled', () => {
mockCheckedRunnerIds = ['a', 'b'];
createComponent({
runners: [makeRunner('a', false), makeRunner('b', false)],
});
expectCheckboxToBe('disabled');
});
});
describe('When user selects', () => {
const mockRunners = [makeRunner('1'), makeRunner('2')];
beforeEach(() => {
mockCheckedRunnerIds = mockIds;
createComponent();
mockCheckedRunnerIds = ['1', '2'];
createComponent({ runners: mockRunners });
});
it.each([[true], [false]])('sets checked to %s', (checked) => {
@ -89,13 +130,11 @@ describe('RunnerBulkDeleteCheckbox', () => {
describe('When runners are loading', () => {
beforeEach(() => {
createComponent({ props: { runners: [] } });
createComponent();
});
it(`is disabled`, () => {
expect(findCheckbox().attributes('disabled')).toBe('true');
expect(findCheckbox().attributes('checked')).toBe(undefined);
expect(findCheckbox().attributes('indeterminate')).toBe(undefined);
it('is disabled', () => {
expectCheckboxToBe('disabled');
});
});
});

View File

@ -25,12 +25,7 @@ describe('RunnerDetails', () => {
const findDetailGroups = () => wrapper.findComponent(RunnerGroups);
const createComponent = ({
props = {},
stubs,
mountFn = shallowMountExtended,
enforceRunnerTokenExpiresAt = false,
} = {}) => {
const createComponent = ({ props = {}, stubs, mountFn = shallowMountExtended } = {}) => {
wrapper = mountFn(RunnerDetails, {
propsData: {
...props,
@ -39,9 +34,6 @@ describe('RunnerDetails', () => {
RunnerDetail,
...stubs,
},
provide: {
glFeatures: { enforceRunnerTokenExpiresAt },
},
});
};
@ -82,7 +74,6 @@ describe('RunnerDetails', () => {
...runner,
},
},
enforceRunnerTokenExpiresAt: true,
stubs: {
GlIntersperse,
GlSprintf,
@ -135,22 +126,5 @@ describe('RunnerDetails', () => {
expect(findDetailGroups().props('runner')).toEqual(mockGroupRunner);
});
});
describe('Token expiration field', () => {
it.each`
case | flag | shown
${'is shown when feature flag is enabled'} | ${true} | ${true}
${'is not shown when feature flag is disabled'} | ${false} | ${false}
`('$case', ({ flag, shown }) => {
createComponent({
props: {
runner: mockGroupRunner,
},
enforceRunnerTokenExpiresAt: flag,
});
expect(findDd('Token expiry', wrapper).exists()).toBe(shown);
});
});
});
});

View File

@ -5,9 +5,15 @@ import {
shallowMountExtended,
mountExtended,
} from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import { s__ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { createLocalState } from '~/runner/graphql/list/local_state';
import RunnerList from '~/runner/components/runner_list.vue';
import RunnerBulkDelete from '~/runner/components/runner_bulk_delete.vue';
import RunnerBulkDeleteCheckbox from '~/runner/components/runner_bulk_delete_checkbox.vue';
import { I18N_PROJECT_TYPE, I18N_STATUS_NEVER_CONTACTED } from '~/runner/constants';
import { allRunnersData, onlineContactTimeoutSecs, staleTimeoutSecs } from '../mock_data';
@ -16,6 +22,8 @@ const mockActiveRunnersCount = mockRunners.length;
describe('RunnerList', () => {
let wrapper;
let cacheConfig;
let localMutations;
const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
const findTable = () => wrapper.findComponent(GlTableLite);
@ -23,18 +31,24 @@ describe('RunnerList', () => {
const findRows = () => wrapper.findAll('[data-testid^="runner-row-"]');
const findCell = ({ row = 0, fieldKey }) =>
extendedWrapper(findRows().at(row).find(`[data-testid="td-${fieldKey}"]`));
const findRunnerBulkDelete = () => wrapper.findComponent(RunnerBulkDelete);
const findRunnerBulkDeleteCheckbox = () => wrapper.findComponent(RunnerBulkDeleteCheckbox);
const createComponent = (
{ props = {}, provide = {}, ...options } = {},
mountFn = shallowMountExtended,
) => {
({ cacheConfig, localMutations } = createLocalState());
wrapper = mountFn(RunnerList, {
apolloProvider: createMockApollo([], {}, cacheConfig),
propsData: {
runners: mockRunners,
activeRunnersCount: mockActiveRunnersCount,
...props,
},
provide: {
localMutations,
onlineContactTimeoutSecs,
staleTimeoutSecs,
...provide,
@ -126,21 +140,40 @@ describe('RunnerList', () => {
);
});
it('runner bulk delete is available', () => {
expect(findRunnerBulkDelete().props('runners')).toEqual(mockRunners);
});
it('runner bulk delete checkbox is available', () => {
expect(findRunnerBulkDeleteCheckbox().props('runners')).toEqual(mockRunners);
});
it('Displays a checkbox field', () => {
expect(findCell({ fieldKey: 'checkbox' }).find('input').exists()).toBe(true);
});
it('Emits a checked event', async () => {
const checkbox = findCell({ fieldKey: 'checkbox' }).find('input');
it('Sets a runner as checked', async () => {
const runner = mockRunners[0];
const setRunnerCheckedMock = jest
.spyOn(localMutations, 'setRunnerChecked')
.mockImplementation(() => {});
const checkbox = findCell({ fieldKey: 'checkbox' }).find('input');
await checkbox.setChecked();
expect(wrapper.emitted('checked')).toHaveLength(1);
expect(wrapper.emitted('checked')[0][0]).toEqual({
expect(setRunnerCheckedMock).toHaveBeenCalledTimes(1);
expect(setRunnerCheckedMock).toHaveBeenCalledWith({
runner,
isChecked: true,
runner: mockRunners[0],
});
});
it('Emits a deleted event', async () => {
const event = { message: 'Deleted!' };
findRunnerBulkDelete().vm.$emit('deleted', event);
expect(wrapper.emitted('deleted')).toEqual([[event]]);
});
});
describe('Scoped cell slots', () => {

Some files were not shown because too many files have changed in this diff Show More