Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-06-10 15:09:22 +00:00
parent 948023c9c9
commit 3714001371
100 changed files with 2030 additions and 443 deletions

View File

@ -1 +1 @@
70d6aa021ebfc05d9d727a7eb4c9ff4782db4c30
30b922784b9d0492ba525a35ec09782dd2bcace3

View File

@ -3,9 +3,9 @@
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import $ from 'jquery';
import { uniq } from 'lodash';
import { getEmojiScoreWithIntent } from '~/emoji/utils';
import { getCookie, setCookie, scrollToElement } from '~/lib/utils/common_utils';
import * as Emoji from '~/emoji';
import { dispose, fixTitle } from '~/tooltips';
import createFlash from './flash';
import axios from './lib/utils/axios_utils';
@ -559,13 +559,45 @@ export class AwardsHandler {
}
}
getEmojiScore(emojis, value) {
const elem = $(value).find('[data-name]').get(0);
const emoji = emojis.filter((x) => x.emoji.name === elem.dataset.name)[0];
elem.dataset.score = emoji.score;
return emoji.score;
}
sortEmojiElements(emojis, $elements) {
const scores = new WeakMap();
return $elements.sort((a, b) => {
let aScore = scores.get(a);
let bScore = scores.get(b);
if (!aScore) {
aScore = this.getEmojiScore(emojis, a);
scores.set(a, aScore);
}
if (!bScore) {
bScore = this.getEmojiScore(emojis, b);
scores.set(b, bScore);
}
return aScore - bScore;
});
}
findMatchingEmojiElements(query) {
const emojiMatches = this.emoji.searchEmoji(query).map((x) => x.emoji.name);
const matchingEmoji = this.emoji
.searchEmoji(query)
.map((x) => ({ ...x, score: getEmojiScoreWithIntent(x.emoji.name, x.score) }));
const matchingEmojiNames = matchingEmoji.map((x) => x.emoji.name);
const $emojiElements = $('.emoji-menu-list:not(.frequent-emojis) [data-name]');
const $matchingElements = $emojiElements.filter(
(i, elm) => emojiMatches.indexOf(elm.dataset.name) >= 0,
(i, elm) => matchingEmojiNames.indexOf(elm.dataset.name) >= 0,
);
return $matchingElements.closest('li').clone();
return this.sortEmojiElements(matchingEmoji, $matchingElements.closest('li').clone());
}
/* showMenuElement and hideMenuElement are performance optimizations. We use

View File

@ -19,3 +19,5 @@ export const CATEGORY_ROW_HEIGHT = 37;
export const CACHE_VERSION_KEY = 'gl-emoji-map-version';
export const CACHE_KEY = 'gl-emoji-map';
export const NEUTRAL_INTENT_MULTIPLIER = 1;

View File

@ -2,6 +2,7 @@ import { escape, minBy } from 'lodash';
import emojiRegexFactory from 'emoji-regex';
import emojiAliases from 'emojis/aliases.json';
import { setAttributes } from '~/lib/utils/dom_utils';
import { getEmojiScoreWithIntent } from '~/emoji/utils';
import AccessorUtilities from '../lib/utils/accessor';
import axios from '../lib/utils/axios_utils';
import { CACHE_KEY, CACHE_VERSION_KEY, CATEGORY_ICON_MAP, FREQUENTLY_USED_KEY } from './constants';
@ -144,6 +145,11 @@ function getNameMatch(emoji, query) {
return null;
}
// Sort emoji by emoji score falling back to a string comparison
export function sortEmoji(a, b) {
return a.score - b.score || a.fieldValue.localeCompare(b.fieldValue);
}
export function searchEmoji(query) {
const lowercaseQuery = query ? `${query}`.toLowerCase() : '';
@ -156,16 +162,14 @@ export function searchEmoji(query) {
getDescriptionMatch(emoji, lowercaseQuery),
getAliasMatch(emoji, matchingAliases),
getNameMatch(emoji, lowercaseQuery),
].filter(Boolean);
]
.filter(Boolean)
.map((x) => ({ ...x, score: getEmojiScoreWithIntent(x.emoji.name, x.score) }));
return minBy(matches, (x) => x.score);
})
.filter(Boolean);
}
export function sortEmoji(items) {
// Sort results by index of and string comparison
return [...items].sort((a, b) => a.score - b.score || a.fieldValue.localeCompare(b.fieldValue));
.filter(Boolean)
.sort(sortEmoji);
}
export const CATEGORY_NAMES = Object.keys(CATEGORY_ICON_MAP);

View File

@ -0,0 +1,8 @@
import emojiIntents from 'emojis/intents.json';
import { NEUTRAL_INTENT_MULTIPLIER } from '~/emoji/constants';
export function getEmojiScoreWithIntent(emojiName, baseScore) {
const intentMultiplier = emojiIntents[emojiName] || NEUTRAL_INTENT_MULTIPLIER;
return 2 ** baseScore * intentMultiplier;
}

View File

@ -897,7 +897,7 @@ GfmAutoComplete.Emoji = {
return Emoji.searchEmoji(query);
},
sorter(items) {
return Emoji.sortEmoji(items);
return items.sort(Emoji.sortEmoji);
},
};
// Team Members

View File

@ -1,5 +1,5 @@
<script>
import { GlTooltipDirective } from '@gitlab/ui';
import { GlTooltipDirective, GlFormCheckbox } from '@gitlab/ui';
import { createNamespacedHelpers } from 'vuex';
import { s__ } from '~/locale';
@ -8,19 +8,20 @@ const { mapActions: mapCommitActions, mapGetters: mapCommitGetters } = createNam
);
export default {
components: { GlFormCheckbox },
directives: {
GlTooltip: GlTooltipDirective,
},
i18n: {
newMrText: s__('IDE|Start a new merge request'),
tooltipText: s__(
'IDE|This option is disabled because you are not allowed to create merge requests in this project.',
),
},
computed: {
...mapCommitGetters(['shouldHideNewMrOption', 'shouldDisableNewMrOption', 'shouldCreateMR']),
tooltipText() {
if (this.shouldDisableNewMrOption) {
return s__(
'IDE|This option is disabled because you are not allowed to create merge requests in this project.',
);
}
return '';
return this.shouldDisableNewMrOption ? this.$options.i18n.tooltipText : null;
},
},
methods: {
@ -30,22 +31,23 @@ export default {
</script>
<template>
<fieldset v-if="!shouldHideNewMrOption">
<hr class="my-2" />
<label
v-gl-tooltip="tooltipText"
class="mb-0 js-ide-commit-new-mr"
:class="{ 'is-disabled': shouldDisableNewMrOption }"
<fieldset
v-if="!shouldHideNewMrOption"
v-gl-tooltip="tooltipText"
data-testid="new-merge-request-fieldset"
class="js-ide-commit-new-mr"
:class="{ 'is-disabled': shouldDisableNewMrOption }"
>
<hr class="gl-mt-3 gl-mb-4" />
<gl-form-checkbox
:disabled="shouldDisableNewMrOption"
:checked="shouldCreateMR"
@change="toggleShouldCreateMR"
>
<input
:disabled="shouldDisableNewMrOption"
:checked="shouldCreateMR"
type="checkbox"
@change="toggleShouldCreateMR"
/>
<span class="gl-ml-3 ide-option-label">
{{ __('Start a new merge request') }}
<span class="ide-option-label">
{{ $options.i18n.newMrText }}
</span>
</label>
</gl-form-checkbox>
</fieldset>
</template>

View File

@ -1,8 +1,20 @@
<script>
import { GlTooltipDirective } from '@gitlab/ui';
import {
GlTooltipDirective,
GlFormRadio,
GlFormRadioGroup,
GlFormGroup,
GlFormInput,
} from '@gitlab/ui';
import { mapActions, mapState, mapGetters } from 'vuex';
export default {
components: {
GlFormRadio,
GlFormRadioGroup,
GlFormGroup,
GlFormInput,
},
directives: {
GlTooltip: GlTooltipDirective,
},
@ -51,35 +63,42 @@ export default {
</script>
<template>
<fieldset>
<label
<fieldset class="gl-mb-2">
<gl-form-radio-group
v-gl-tooltip="tooltipTitle"
:checked="commitAction"
:class="{
'is-disabled': disabled,
}"
>
<input
<gl-form-radio
:value="value"
:checked="commitAction === value"
:disabled="disabled"
type="radio"
name="commit-action"
data-qa-selector="commit_type_radio"
@change="updateCommitAction($event.target.value)"
/>
<span class="gl-ml-3">
<span v-if="label" class="ide-option-label"> {{ label }} </span> <slot v-else></slot>
</span>
</label>
<div v-if="commitAction === value && showInput" class="ide-commit-new-branch">
<input
@change="updateCommitAction(value)"
>
<span v-if="label" class="ide-option-label">
{{ label }}
</span>
<slot v-else></slot>
</gl-form-radio>
</gl-form-radio-group>
<gl-form-group
v-if="commitAction === value && showInput"
:label="placeholderBranchName"
:label-sr-only="true"
class="gl-ml-6 gl-mb-0"
>
<gl-form-input
:placeholder="placeholderBranchName"
:value="newBranchName"
:disabled="disabled"
data-testid="ide-new-branch-name"
type="text"
class="form-control monospace"
@input="updateBranchName($event.target.value)"
class="gl-font-monospace"
@input="updateBranchName($event)"
/>
</div>
</gl-form-group>
</fieldset>
</template>

View File

@ -10,6 +10,7 @@ import RegistrationDropdown from '../components/registration/registration_dropdo
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
import RunnerBulkDelete from '../components/runner_bulk_delete.vue';
import RunnerList from '../components/runner_list.vue';
import RunnerListEmptyState from '../components/runner_list_empty_state.vue';
import RunnerName from '../components/runner_name.vue';
import RunnerStats from '../components/stat/runner_stats.vue';
import RunnerPagination from '../components/runner_pagination.vue';
@ -35,6 +36,7 @@ import {
fromUrlQueryToSearch,
fromSearchToUrl,
fromSearchToVariables,
isSearchFiltered,
} from '../runner_search_utils';
import { captureException } from '../sentry_utils';
@ -91,6 +93,7 @@ export default {
RunnerFilteredSearchBar,
RunnerBulkDelete,
RunnerList,
RunnerListEmptyState,
RunnerName,
RunnerStats,
RunnerPagination,
@ -98,7 +101,7 @@ export default {
RunnerActionsCell,
},
mixins: [glFeatureFlagMixin()],
inject: ['localMutations'],
inject: ['emptyStateSvgPath', 'emptyStateFilteredSvgPath', 'localMutations'],
props: {
registrationToken: {
type: String,
@ -190,6 +193,9 @@ export default {
// Rollout issue: https://gitlab.com/gitlab-org/gitlab/-/issues/353981
return this.glFeatures.adminRunnersBulkDelete;
},
isSearchFiltered() {
return isSearchFiltered(this.search);
},
},
watch: {
search: {
@ -298,9 +304,13 @@ export default {
:stale-runners-count="staleRunnersTotal"
/>
<div v-if="noRunnersFound" class="gl-text-center gl-p-5">
{{ __('No runners found') }}
</div>
<runner-list-empty-state
v-if="noRunnersFound"
:registration-token="registrationToken"
:is-search-filtered="isSearchFiltered"
:svg-path="emptyStateSvgPath"
:filtered-svg-path="emptyStateFilteredSvgPath"
/>
<template v-else>
<runner-bulk-delete v-if="isBulkDeleteEnabled" />
<runner-list

View File

@ -34,6 +34,8 @@ export const initAdminRunners = (selector = '#js-admin-runners') => {
registrationToken,
onlineContactTimeoutSecs,
staleTimeoutSecs,
emptyStateSvgPath,
emptyStateFilteredSvgPath,
} = el.dataset;
const { cacheConfig, typeDefs, localMutations } = createLocalState();
@ -50,6 +52,8 @@ export const initAdminRunners = (selector = '#js-admin-runners') => {
localMutations,
onlineContactTimeoutSecs,
staleTimeoutSecs,
emptyStateSvgPath,
emptyStateFilteredSvgPath,
},
render(h) {
return h(AdminRunnersApp, {

View File

@ -7,6 +7,8 @@ import RunnerPausedBadge from '../runner_paused_badge.vue';
export default {
components: {
RunnerStatusBadge,
RunnerUpgradeStatusBadge: () =>
import('ee_component/runner/components/runner_upgrade_status_badge.vue'),
RunnerPausedBadge,
},
directives: {
@ -33,6 +35,11 @@ export default {
size="sm"
class="gl-display-inline-block gl-max-w-full gl-text-truncate"
/>
<runner-upgrade-status-badge
:runner="runner"
size="sm"
class="gl-display-inline-block gl-max-w-full gl-text-truncate"
/>
<runner-paused-badge
v-if="paused"
size="sm"

View File

@ -12,7 +12,7 @@ import RunnerStatusCell from './cells/runner_status_cell.vue';
import RunnerTags from './runner_tags.vue';
const defaultFields = [
tableField({ key: 'status', label: s__('Runners|Status') }),
tableField({ key: 'status', label: s__('Runners|Status'), thClasses: ['gl-w-15p'] }),
tableField({ key: 'summary', label: s__('Runners|Runner'), thClasses: ['gl-lg-w-25p'] }),
tableField({ key: 'version', label: __('Version') }),
tableField({ key: 'jobCount', label: __('Jobs') }),

View File

@ -0,0 +1,68 @@
<script>
import { GlEmptyState, GlLink, GlSprintf, GlModalDirective } from '@gitlab/ui';
import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
export default {
components: {
GlEmptyState,
GlLink,
GlSprintf,
RunnerInstructionsModal,
},
directives: {
GlModal: GlModalDirective,
},
props: {
isSearchFiltered: {
type: Boolean,
required: false,
default: false,
},
svgPath: {
type: String,
required: false,
default: '',
},
filteredSvgPath: {
type: String,
required: false,
default: '',
},
registrationToken: {
type: String,
required: false,
default: null,
},
},
modalId: 'runners-empty-state-instructions-modal',
};
</script>
<template>
<gl-empty-state
v-if="isSearchFiltered"
:title="s__('Runners|No results found')"
:svg-path="filteredSvgPath"
:description="s__('Runners|Edit your search and try again')"
/>
<gl-empty-state v-else :title="s__('Runners|Get started with runners')" :svg-path="svgPath">
<template #description>
<gl-sprintf
:message="
s__(
'Runners|Runners are the agents that run your CI/CD jobs. Follow the %{linkStart}installation and registration instructions%{linkEnd} to set up a runner.',
)
"
>
<template #link="{ content }">
<gl-link v-gl-modal="$options.modalId">{{ content }}</gl-link>
</template>
</gl-sprintf>
<runner-instructions-modal
:modal-id="$options.modalId"
:registration-token="registrationToken"
/>
</template>
</gl-empty-state>
</template>

View File

@ -1,4 +1,4 @@
#import "~/runner/graphql/list/list_item.fragment.graphql"
#import "ee_else_ce/runner/graphql/list/list_item.fragment.graphql"
#import "~/graphql_shared/fragments/page_info.fragment.graphql"
query getRunners(

View File

@ -1,4 +1,4 @@
#import "~/runner/graphql/list/list_item.fragment.graphql"
#import "ee_else_ce/runner/graphql/list/list_item.fragment.graphql"
#import "~/graphql_shared/fragments/page_info.fragment.graphql"
query getGroupRunners(

View File

@ -1,20 +1,5 @@
#import "./list_item_shared.fragment.graphql"
fragment ListItem on CiRunner {
__typename
id
description
runnerType
shortSha
version
revision
ipAddress
active
locked
jobCount
tagList
contactedAt
status(legacyMode: null)
userPermissions {
updateRunner
deleteRunner
}
...ListItemShared
}

View File

@ -0,0 +1,20 @@
fragment ListItemShared on CiRunner {
__typename
id
description
runnerType
shortSha
version
revision
ipAddress
active
locked
jobCount
tagList
contactedAt
status(legacyMode: null)
userPermissions {
updateRunner
deleteRunner
}
}

View File

@ -0,0 +1,114 @@
<script>
import { GlBadge, GlTab, GlTooltipDirective } from '@gitlab/ui';
import { createAlert, VARIANT_SUCCESS } from '~/flash';
import { TYPE_CI_RUNNER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { redirectTo } from '~/lib/utils/url_utility';
import { formatJobCount } from '../utils';
import RunnerDeleteButton from '../components/runner_delete_button.vue';
import RunnerEditButton from '../components/runner_edit_button.vue';
import RunnerPauseButton from '../components/runner_pause_button.vue';
import RunnerHeader from '../components/runner_header.vue';
import RunnerDetails from '../components/runner_details.vue';
import RunnerJobs from '../components/runner_jobs.vue';
import { I18N_FETCH_ERROR } from '../constants';
import runnerQuery from '../graphql/show/runner.query.graphql';
import { captureException } from '../sentry_utils';
import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_local_storage';
export default {
name: 'GroupRunnerShowApp',
components: {
GlBadge,
GlTab,
RunnerDeleteButton,
RunnerEditButton,
RunnerPauseButton,
RunnerHeader,
RunnerDetails,
RunnerJobs,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
runnerId: {
type: String,
required: true,
},
runnersPath: {
type: String,
required: true,
},
},
data() {
return {
runner: null,
};
},
apollo: {
runner: {
query: runnerQuery,
variables() {
return {
id: convertToGraphQLId(TYPE_CI_RUNNER, this.runnerId),
};
},
error(error) {
createAlert({ message: I18N_FETCH_ERROR });
this.reportToSentry(error);
},
},
},
computed: {
canUpdate() {
return this.runner.userPermissions?.updateRunner;
},
canDelete() {
return this.runner.userPermissions?.deleteRunner;
},
jobCount() {
return formatJobCount(this.runner?.jobCount);
},
},
errorCaptured(error) {
this.reportToSentry(error);
},
methods: {
reportToSentry(error) {
captureException({ error, component: this.$options.name });
},
onDeleted({ message }) {
saveAlertToLocalStorage({ message, variant: VARIANT_SUCCESS });
redirectTo(this.runnersPath);
},
},
};
</script>
<template>
<div>
<runner-header v-if="runner" :runner="runner">
<template #actions>
<runner-edit-button v-if="canUpdate && runner.editAdminUrl" :href="runner.editAdminUrl" />
<runner-pause-button v-if="canUpdate" :runner="runner" />
<runner-delete-button v-if="canDelete" :runner="runner" @deleted="onDeleted" />
</template>
</runner-header>
<runner-details :runner="runner">
<template #jobs-tab>
<gl-tab>
<template #title>
{{ s__('Runners|Jobs') }}
<gl-badge v-if="jobCount" data-testid="job-count-badge" class="gl-ml-1" size="sm">
{{ jobCount }}
</gl-badge>
</template>
<runner-jobs v-if="runner" :runner="runner" />
</gl-tab>
</template>
</runner-details>
</div>
</template>

View File

@ -0,0 +1,36 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { showAlertFromLocalStorage } from '../local_storage_alert/show_alert_from_local_storage';
import GroupRunnerShowApp from './group_runner_show_app.vue';
Vue.use(VueApollo);
export const initAdminRunnerShow = (selector = '#js-group-runner-show') => {
showAlertFromLocalStorage();
const el = document.querySelector(selector);
if (!el) {
return null;
}
const { runnerId, runnersPath } = el.dataset;
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
return new Vue({
el,
apolloProvider,
render(h) {
return h(GroupRunnerShowApp, {
props: {
runnerId,
runnersPath,
},
});
},
});
};

View File

@ -8,6 +8,7 @@ import { fetchPolicies } from '~/lib/graphql';
import RegistrationDropdown from '../components/registration/registration_dropdown.vue';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
import RunnerList from '../components/runner_list.vue';
import RunnerListEmptyState from '../components/runner_list_empty_state.vue';
import RunnerName from '../components/runner_name.vue';
import RunnerStats from '../components/stat/runner_stats.vue';
import RunnerPagination from '../components/runner_pagination.vue';
@ -31,6 +32,7 @@ import {
fromUrlQueryToSearch,
fromSearchToUrl,
fromSearchToVariables,
isSearchFiltered,
} from '../runner_search_utils';
import { captureException } from '../sentry_utils';
@ -86,12 +88,14 @@ export default {
RegistrationDropdown,
RunnerFilteredSearchBar,
RunnerList,
RunnerListEmptyState,
RunnerName,
RunnerStats,
RunnerPagination,
RunnerTypeTabs,
RunnerActionsCell,
},
inject: ['emptyStateSvgPath', 'emptyStateFilteredSvgPath'],
props: {
registrationToken: {
type: String,
@ -196,6 +200,9 @@ export default {
filteredSearchNamespace() {
return `${GROUP_FILTERED_SEARCH_NAMESPACE}/${this.groupFullPath}`;
},
isSearchFiltered() {
return isSearchFiltered(this.search);
},
},
watch: {
search: {
@ -299,9 +306,13 @@ export default {
:stale-runners-count="staleRunnersTotal"
/>
<div v-if="noRunnersFound" class="gl-text-center gl-p-5">
{{ __('No runners found') }}
</div>
<runner-list-empty-state
v-if="noRunnersFound"
:registration-token="registrationToken"
:is-search-filtered="isSearchFiltered"
:svg-path="emptyStateSvgPath"
:filtered-svg-path="emptyStateFilteredSvgPath"
/>
<template v-else>
<runner-list :runners="runners.items" :loading="runnersLoading">
<template #runner-name="{ runner }">

View File

@ -22,6 +22,8 @@ export const initGroupRunners = (selector = '#js-group-runners') => {
groupRunnersLimitedCount,
onlineContactTimeoutSecs,
staleTimeoutSecs,
emptyStateSvgPath,
emptyStateFilteredSvgPath,
} = el.dataset;
const apolloProvider = new VueApollo({
@ -36,6 +38,8 @@ export const initGroupRunners = (selector = '#js-group-runners') => {
groupId,
onlineContactTimeoutSecs: parseInt(onlineContactTimeoutSecs, 10),
staleTimeoutSecs: parseInt(staleTimeoutSecs, 10),
emptyStateSvgPath,
emptyStateFilteredSvgPath,
},
render(h) {
return h(GroupRunnersApp, {

View File

@ -236,3 +236,17 @@ export const fromSearchToVariables = ({
...paginationVariables,
};
};
/**
* Decides whether or not a search object is the "default" or empty.
*
* A search is filtered if the user has entered filtering criteria.
*
* @param {Object} search
* @returns true if this search is filtered, false otherwise
*/
export const isSearchFiltered = ({ runnerType = null, filters = [], pagination = {} } = {}) => {
return Boolean(
runnerType !== null || filters?.length !== 0 || (pagination && pagination?.page !== 1),
);
};

View File

@ -302,9 +302,11 @@ export default {
<span v-else class="gl-display-flex gl-align-items-center gl-line-height-normal">
{{ __('None') }} -
<gl-button
class="gl-ml-2"
class="gl-ml-2 gl-reset-color!"
href="#"
category="tertiary"
variant="link"
size="small"
data-testid="unassigned-users"
@click="updateAlertAssignees(currentUser)"
>

View File

@ -247,8 +247,8 @@
z-index: 600;
width: $contextual-sidebar-width;
top: $header-height;
@include gl-bg-gray-10;
border-right: 1px solid $gray-50;
background-color: $contextual-sidebar-bg-color;
border-right: 1px solid $contextual-sidebar-border-color;
transform: translate3d(0, 0, 0);
&.sidebar-collapsed-desktop {
@ -411,7 +411,7 @@
.toggle-sidebar-button,
.close-nav-button {
@include side-panel-toggle;
@include gl-bg-gray-10;
background-color: $contextual-sidebar-bg-color;
position: fixed;
bottom: 0;
width: #{$contextual-sidebar-width - 1px};

View File

@ -357,6 +357,8 @@ $border-gray-normal-dashed: darken($gray-normal, $darken-border-dashed-factor);
/*
* UI elements
*/
$contextual-sidebar-bg-color: #f5f5f5;
$contextual-sidebar-border-color: #e9e9e9;
$border-color: $gray-100;
$shadow-color: $t-gray-a-08;
$well-expand-item: #e8f2f7 !default;

View File

@ -563,24 +563,11 @@ $ide-commit-header-height: 48px;
}
.ide-commit-options {
label {
font-weight: normal;
&.is-disabled {
.ide-option-label {
text-decoration: line-through;
}
.is-disabled {
.ide-option-label {
text-decoration: line-through;
}
}
.form-text.text-muted {
margin-top: 0;
line-height: 0;
}
}
.ide-commit-new-branch {
margin-left: 25px;
}
.ide-sidebar-link {

View File

@ -1034,8 +1034,8 @@ input {
z-index: 600;
width: 256px;
top: var(--header-height, 48px);
background-color: #1f1f1f;
border-right: 1px solid #303030;
background-color: #f5f5f5;
border-right: 1px solid #e9e9e9;
transform: translate3d(0, 0, 0);
}
.nav-sidebar.sidebar-collapsed-desktop {
@ -1402,7 +1402,7 @@ input {
color: #999;
display: flex;
align-items: center;
background-color: #1f1f1f;
background-color: #f5f5f5;
position: fixed;
bottom: 0;
width: 255px;
@ -1792,6 +1792,7 @@ body.gl-dark {
.toggle-sidebar-button,
.close-nav-button {
background-color: #262626;
border-right: 1px solid #303030;
}
.nav-sidebar li a {
color: var(--gray-600);

View File

@ -1019,8 +1019,8 @@ input {
z-index: 600;
width: 256px;
top: var(--header-height, 48px);
background-color: #fafafa;
border-right: 1px solid #f0f0f0;
background-color: #f5f5f5;
border-right: 1px solid #e9e9e9;
transform: translate3d(0, 0, 0);
}
.nav-sidebar.sidebar-collapsed-desktop {
@ -1387,7 +1387,7 @@ input {
color: #666;
display: flex;
align-items: center;
background-color: #fafafa;
background-color: #f5f5f5;
position: fixed;
bottom: 0;
width: 255px;

View File

@ -45,6 +45,7 @@
.toggle-sidebar-button,
.close-nav-button {
background-color: darken($gray-50, 4%);
border-right: 1px solid $gray-50;
}
.nav-sidebar {

View File

@ -41,3 +41,5 @@ class Groups::RunnersController < Groups::ApplicationController
params.require(:runner).permit(Ci::Runner::FORM_EDITABLE)
end
end
Groups::RunnersController.prepend_mod

View File

@ -6,6 +6,8 @@
# current_user - user performing the action. Must have the correct permission level for the group.
# params:
# group: Group, required
# search: String, optional
# state: CustomerRelations::ContactStateEnum, optional
module Crm
class ContactsFinder
include Gitlab::Allowable
@ -21,7 +23,10 @@ module Crm
def execute
return CustomerRelations::Contact.none unless root_group
root_group.contacts
contacts = root_group.contacts
contacts = by_state(contacts)
contacts = by_search(contacts)
contacts.sort_by_name
end
private
@ -35,5 +40,25 @@ module Crm
group
end
end
def by_search(contacts)
return contacts unless search?
contacts.search(params[:search])
end
def by_state(contacts)
return contacts unless state?
contacts.search_by_state(params[:state])
end
def search?
params[:search].present?
end
def state?
params[:state].present?
end
end
end

View File

@ -0,0 +1,64 @@
# frozen_string_literal: true
# Finder for retrieving organizations scoped to a group
#
# Arguments:
# current_user - user performing the action. Must have the correct permission level for the group.
# params:
# group: Group, required
# search: String, optional
# state: CustomerRelations::OrganizationStateEnum, optional
module Crm
class OrganizationsFinder
include Gitlab::Allowable
include Gitlab::Utils::StrongMemoize
attr_reader :params, :current_user
def initialize(current_user, params = {})
@current_user = current_user
@params = params
end
def execute
return CustomerRelations::Organization.none unless root_group
organizations = root_group.organizations
organizations = by_search(organizations)
organizations = by_state(organizations)
organizations.sort_by_name
end
private
def root_group
strong_memoize(:root_group) do
group = params[:group]&.root_ancestor
next unless can?(@current_user, :read_crm_organization, group)
group
end
end
def by_search(organizations)
return organizations unless search?
organizations.search(params[:search])
end
def by_state(organizations)
return organizations unless state?
organizations.search_by_state(params[:state])
end
def search?
params[:search].present?
end
def state?
params[:state].present?
end
end
end

View File

@ -18,11 +18,9 @@ module ResolvesGroups
def preloads
{
contacts: [:contacts],
container_repositories_count: [:container_repositories],
custom_emoji: [:custom_emoji],
full_path: [:route],
organizations: [:organizations],
path: [:route],
dependency_proxy_blob_count: [:dependency_proxy_blobs],
dependency_proxy_blobs: [:dependency_proxy_blobs],

View File

@ -0,0 +1,29 @@
# frozen_string_literal: true
module Resolvers
module Crm
class ContactsResolver < BaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
authorize :read_crm_contact
type Types::CustomerRelations::ContactType, null: true
argument :search, GraphQL::Types::String,
required: false,
description: 'Search term to find contacts with.'
argument :state, Types::CustomerRelations::ContactStateEnum,
required: false,
description: 'State of the contacts to search for.'
def resolve(**args)
::Crm::ContactsFinder.new(current_user, { group: group }.merge(args)).execute
end
def group
object.respond_to?(:sync) ? object.sync : object
end
end
end
end

View File

@ -0,0 +1,29 @@
# frozen_string_literal: true
module Resolvers
module Crm
class OrganizationsResolver < BaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
authorize :read_crm_organization
type Types::CustomerRelations::OrganizationType, null: true
argument :search, GraphQL::Types::String,
required: false,
description: 'Search term used to find organizations with.'
argument :state, Types::CustomerRelations::OrganizationStateEnum,
required: false,
description: 'State of the organization to search for.'
def resolve(**args)
::Crm::OrganizationsFinder.new(current_user, { group: group }.merge(args)).execute
end
def group
object.respond_to?(:sync) ? object.sync : object
end
end
end
end

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
module Types
module CustomerRelations
class ContactStateEnum < BaseEnum
graphql_name 'CustomerRelationsContactState'
value 'active',
description: "Active contact.",
value: :active
value 'inactive',
description: "Inactive contact.",
value: :inactive
end
end
end

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
module Types
module CustomerRelations
class OrganizationStateEnum < BaseEnum
graphql_name 'CustomerRelationsOrganizationState'
value 'active',
description: "Active organization.",
value: :active
value 'inactive',
description: "Inactive organization.",
value: :inactive
end
end
end

View File

@ -201,11 +201,13 @@ module Types
field :organizations, Types::CustomerRelations::OrganizationType.connection_type,
null: true,
description: "Find organizations of this group."
description: "Find organizations of this group.",
resolver: Resolvers::Crm::OrganizationsResolver
field :contacts, Types::CustomerRelations::ContactType.connection_type,
null: true,
description: "Find contacts of this group."
description: "Find contacts of this group.",
resolver: Resolvers::Crm::ContactsResolver
field :work_item_types, Types::WorkItems::TypeType.connection_type,
resolver: Resolvers::WorkItems::TypesResolver,

View File

@ -65,7 +65,9 @@ module Ci
runner_install_help_page: 'https://docs.gitlab.com/runner/install/',
registration_token: Gitlab::CurrentSettings.runners_registration_token,
online_contact_timeout_secs: ::Ci::Runner::ONLINE_CONTACT_TIMEOUT.to_i,
stale_timeout_secs: ::Ci::Runner::STALE_TIMEOUT.to_i
stale_timeout_secs: ::Ci::Runner::STALE_TIMEOUT.to_i,
empty_state_svg_path: image_path('illustrations/pipelines_empty.svg'),
empty_state_filtered_svg_path: image_path('illustrations/magnifying-glass.svg')
}
end
@ -87,7 +89,9 @@ module Ci
group_full_path: group.full_path,
runner_install_help_page: 'https://docs.gitlab.com/runner/install/',
online_contact_timeout_secs: ::Ci::Runner::ONLINE_CONTACT_TIMEOUT.to_i,
stale_timeout_secs: ::Ci::Runner::STALE_TIMEOUT.to_i
stale_timeout_secs: ::Ci::Runner::STALE_TIMEOUT.to_i,
empty_state_svg_path: image_path('illustrations/pipelines_empty.svg'),
empty_state_filtered_svg_path: image_path('illustrations/magnifying-glass.svg')
}
end

View File

@ -808,6 +808,7 @@ module Ci
def execute_hooks
return unless project
return if user&.blocked?
project.execute_hooks(build_data.dup, :job_hooks) if project.has_active_hooks?(:job_hooks)
project.execute_integrations(build_data.dup, :job_hooks) if project.has_active_integrations?(:job_hooks)

View File

@ -124,10 +124,10 @@ module Ci
# We will start using this column once we complete https://gitlab.com/gitlab-org/gitlab/-/issues/285597
ignore_column :original_filename, remove_with: '14.7', remove_after: '2022-11-22'
mount_file_store_uploader JobArtifactUploader
mount_file_store_uploader JobArtifactUploader, skip_store_file: true
skip_callback :save, :after, :store_file!, if: :store_after_commit?
after_commit :store_file_after_commit!, on: [:create, :update], if: :store_after_commit?
after_save :store_file_in_transaction!, unless: :store_after_commit?
after_commit :store_file_after_transaction!, on: [:create, :update], if: :store_after_commit?
validates :file_format, presence: true, unless: :trace?, on: :create
validate :validate_file_format!, unless: :trace?, on: :create
@ -362,11 +362,24 @@ module Ci
private
def store_file_after_commit!
return unless previous_changes.key?(:file)
def store_file_in_transaction!
store_file_now! if saved_change_to_file?
store_file!
update_file_store
file_stored_in_transaction_hooks
end
def store_file_after_transaction!
store_file_now! if previous_changes.key?(:file)
file_stored_after_transaction_hooks
end
# method overriden in EE
def file_stored_after_transaction_hooks
end
# method overriden in EE
def file_stored_in_transaction_hooks
end
def set_size

View File

@ -240,7 +240,9 @@ module Ci
next if transition.loopback?
pipeline.run_after_commit do
PipelineHooksWorker.perform_async(pipeline.id)
unless pipeline.user&.blocked?
PipelineHooksWorker.perform_async(pipeline.id)
end
if pipeline.project.jira_subscription_exists?
# Passing the seq-id ensures this is idempotent
@ -297,7 +299,12 @@ module Ci
ref_status = pipeline.ci_ref&.update_status_by!(pipeline)
pipeline.run_after_commit do
PipelineNotificationWorker.perform_async(pipeline.id, ref_status: ref_status)
# We don't send notifications for a pipeline dropped due to the
# user been blocked.
unless pipeline.user&.blocked?
PipelineNotificationWorker
.perform_async(pipeline.id, ref_status: ref_status)
end
end
end

View File

@ -4,9 +4,16 @@ module FileStoreMounter
extend ActiveSupport::Concern
class_methods do
def mount_file_store_uploader(uploader)
# When `skip_store_file: true` is used, the model MUST explicitly call `store_file_now!`
def mount_file_store_uploader(uploader, skip_store_file: false)
mount_uploader(:file, uploader)
if skip_store_file
skip_callback :save, :after, :store_file!
return
end
# This hook is a no-op when the file is uploaded after_commit
after_save :update_file_store, if: :saved_change_to_file?
end
@ -16,4 +23,9 @@ module FileStoreMounter
# The file.object_store is set during `uploader.store!` and `uploader.migrate!`
update_column(:file_store, file.object_store)
end
def store_file_now!
store_file!
update_file_store
end
end

View File

@ -1,6 +1,8 @@
# frozen_string_literal: true
class CustomerRelations::Contact < ApplicationRecord
include Gitlab::SQL::Pattern
include Sortable
include StripAttribute
self.table_name = "customer_relations_contacts"
@ -39,6 +41,25 @@ class CustomerRelations::Contact < ApplicationRecord
']'
end
# Searches for contacts with a matching first name, last name, email or description.
#
# This method uses ILIKE on PostgreSQL
#
# query - The search query as a String
#
# Returns an ActiveRecord::Relation.
def self.search(query)
fuzzy_search(query, [:first_name, :last_name, :email, :description], use_minimum_char_limit: false)
end
def self.search_by_state(state)
where(state: state)
end
def self.sort_by_name
order("last_name ASC, first_name ASC")
end
def self.find_ids_by_emails(group, emails)
raise ArgumentError, "Cannot lookup more than #{MAX_PLUCK} emails" if emails.length > MAX_PLUCK

View File

@ -1,6 +1,8 @@
# frozen_string_literal: true
class CustomerRelations::Organization < ApplicationRecord
include Gitlab::SQL::Pattern
include Sortable
include StripAttribute
self.table_name = "customer_relations_organizations"
@ -21,6 +23,25 @@ class CustomerRelations::Organization < ApplicationRecord
validates :description, length: { maximum: 1024 }
validate :validate_root_group
# Searches for organizations with a matching name or description.
#
# This method uses ILIKE on PostgreSQL
#
# query - The search query as a String
#
# Returns an ActiveRecord::Relation.
def self.search(query)
fuzzy_search(query, [:name, :description], use_minimum_char_limit: false)
end
def self.search_by_state(state)
where(state: state)
end
def self.sort_by_name
order(name: :asc)
end
def self.find_by_name(group_id, name)
where(group: group_id)
.where('LOWER(name) = LOWER(?)', name)

View File

@ -3,6 +3,7 @@
module Projects
class UpdatePagesService < BaseService
InvalidStateError = Class.new(StandardError)
WrongUploadedDeploymentSizeError = Class.new(StandardError)
BLOCK_SIZE = 32.kilobytes
PUBLIC_DIR = 'public'
@ -39,6 +40,9 @@ module Projects
end
rescue InvalidStateError => e
error(e.message)
rescue WrongUploadedDeploymentSizeError => e
error("Uploading artifacts to pages storage failed")
raise e
rescue StandardError => e
error(e.message)
raise e
@ -80,6 +84,10 @@ module Projects
ci_build_id: build.id
)
if deployment.size != file.size || deployment.file.size != file.size
raise(WrongUploadedDeploymentSizeError)
end
validate_outdated_sha!
project.update_pages_deployment!(deployment)

View File

@ -2,7 +2,7 @@
%section.settings.js-service-desk-setting-wrapper.no-animate#js-service-desk{ class: ('expanded' if expanded), data: { qa_selector: 'service_desk_settings_content' } }
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Service Desk')
%button.btn.gl-button.btn-default.js-settings-toggle
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded ? _('Collapse') : _('Expand')
- link_start = "<a href='#{help_page_path('user/project/service_desk')}' target='_blank' rel='noopener noreferrer'>".html_safe
%p= _('Enable and disable Service Desk. Some additional configuration might be required. %{link_start}Learn more%{link_end}.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }

View File

@ -8,5 +8,4 @@
- title = s_('TagsPage|Only a project maintainer or owner can delete a protected tag')
- disabled = true
%button{ type: "button", class: "js-delete-tag-button gl-button btn btn-default btn-icon has-tooltip gl-ml-3\! #{disabled ? 'disabled' : ''}", title: title, disabled: disabled, data: { path: project_tag_path(@project, tag.name), tag_name: tag.name, is_protected: protected_tag?(project, tag).to_s } }
= sprite_icon('remove', css_class: 'gl-icon')
= render Pajamas::ButtonComponent.new(variant: :default, icon: 'remove', button_options: { class: "js-delete-tag-button gl-ml-3\!", 'aria-label': s_('TagsPage|Delete tag'), title: title, disabled: disabled, data: { toggle: 'tooltip', container: 'body', path: project_tag_path(@project, tag.name), tag_name: tag.name, is_protected: protected_tag?(project, tag).to_s } })

View File

@ -13,6 +13,7 @@ class PipelineHooksWorker # rubocop:disable Scalability/IdempotentWorker
def perform(pipeline_id)
pipeline = Ci::Pipeline.find_by_id(pipeline_id)
return unless pipeline
return if pipeline.user&.blocked?
Ci::Pipelines::HookService.new(pipeline).execute
end

View File

@ -23,6 +23,7 @@ class PipelineNotificationWorker # rubocop:disable Scalability/IdempotentWorker
pipeline = Ci::Pipeline.find_by_id(pipeline_id)
return unless pipeline
return if pipeline.user&.blocked?
NotificationService.new.pipeline_finished(pipeline, ref_status: ref_status, recipients: recipients)
end

View File

@ -0,0 +1,8 @@
---
name: ci_enforce_throttle_pipelines_creation_override
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/89518
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/362513
milestone: '15.1'
type: development
group: group::pipeline execution
default_enabled: false

View File

@ -0,0 +1,31 @@
# frozen_string_literal: true
class ScheduleSetLegacyOpenSourceLicenseAvailableForNonPublicProjects < Gitlab::Database::Migration[2.0]
MIGRATION = 'SetLegacyOpenSourceLicenseAvailableForNonPublicProjects'
INTERVAL = 2.minutes
BATCH_SIZE = 5_000
SUB_BATCH_SIZE = 200
disable_ddl_transaction!
restrict_gitlab_migration gitlab_schema: :gitlab_main
def up
return unless Gitlab.com?
queue_batched_background_migration(
MIGRATION,
:projects,
:id,
job_interval: INTERVAL,
batch_size: BATCH_SIZE,
sub_batch_size: SUB_BATCH_SIZE
)
end
def down
return unless Gitlab.com?
delete_batched_background_migration(MIGRATION, :projects, :id, [])
end
end

View File

@ -0,0 +1 @@
5055a0f5fd7125d353654be2425c881afa42a3b09eb0ab34dd0929b3440aa643

View File

@ -11519,7 +11519,6 @@ four standard [pagination arguments](#connection-pagination-arguments):
| <a id="groupallowstalerunnerpruning"></a>`allowStaleRunnerPruning` | [`Boolean!`](#boolean) | Indicates whether to regularly prune stale group runners. Defaults to false. |
| <a id="groupautodevopsenabled"></a>`autoDevopsEnabled` | [`Boolean`](#boolean) | Indicates whether Auto DevOps is enabled for all projects within this group. |
| <a id="groupavatarurl"></a>`avatarUrl` | [`String`](#string) | Avatar URL of the group. |
| <a id="groupcontacts"></a>`contacts` | [`CustomerRelationsContactConnection`](#customerrelationscontactconnection) | Find contacts of this group. (see [Connections](#connections)) |
| <a id="groupcontainerrepositoriescount"></a>`containerRepositoriesCount` | [`Int!`](#int) | Number of container repositories in the group. |
| <a id="groupcontainslockedprojects"></a>`containsLockedProjects` | [`Boolean!`](#boolean) | Includes at least one project where the repository size exceeds the limit. |
| <a id="groupcrossprojectpipelineavailable"></a>`crossProjectPipelineAvailable` | [`Boolean!`](#boolean) | Indicates if the cross_project_pipeline feature is available for the namespace. |
@ -11546,7 +11545,6 @@ four standard [pagination arguments](#connection-pagination-arguments):
| <a id="grouplfsenabled"></a>`lfsEnabled` | [`Boolean`](#boolean) | Indicates if Large File Storage (LFS) is enabled for namespace. |
| <a id="groupmentionsdisabled"></a>`mentionsDisabled` | [`Boolean`](#boolean) | Indicates if a group is disabled from getting mentioned. |
| <a id="groupname"></a>`name` | [`String!`](#string) | Name of the namespace. |
| <a id="grouporganizations"></a>`organizations` | [`CustomerRelationsOrganizationConnection`](#customerrelationsorganizationconnection) | Find organizations of this group. (see [Connections](#connections)) |
| <a id="grouppackagesettings"></a>`packageSettings` | [`PackageSettings`](#packagesettings) | Package settings for the namespace. |
| <a id="groupparent"></a>`parent` | [`Group`](#group) | Parent group. |
| <a id="grouppath"></a>`path` | [`String!`](#string) | Path of the namespace. |
@ -11644,6 +11642,23 @@ four standard [pagination arguments](#connection-pagination-arguments):
| ---- | ---- | ----------- |
| <a id="groupcomplianceframeworksid"></a>`id` | [`ComplianceManagementFrameworkID`](#compliancemanagementframeworkid) | Global ID of a specific compliance framework to return. |
##### `Group.contacts`
Find contacts of this group.
Returns [`CustomerRelationsContactConnection`](#customerrelationscontactconnection).
This field returns a [connection](#connections). It accepts the
four standard [pagination arguments](#connection-pagination-arguments):
`before: String`, `after: String`, `first: Int`, `last: Int`.
###### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="groupcontactssearch"></a>`search` | [`String`](#string) | Search term to find contacts with. |
| <a id="groupcontactsstate"></a>`state` | [`CustomerRelationsContactState`](#customerrelationscontactstate) | State of the contacts to search for. |
##### `Group.containerRepositories`
Container repositories of the group.
@ -11983,6 +11998,23 @@ four standard [pagination arguments](#connection-pagination-arguments):
| <a id="groupmilestonestimeframe"></a>`timeframe` | [`Timeframe`](#timeframe) | List items overlapping the given timeframe. |
| <a id="groupmilestonestitle"></a>`title` | [`String`](#string) | Title of the milestone. |
##### `Group.organizations`
Find organizations of this group.
Returns [`CustomerRelationsOrganizationConnection`](#customerrelationsorganizationconnection).
This field returns a [connection](#connections). It accepts the
four standard [pagination arguments](#connection-pagination-arguments):
`before: String`, `after: String`, `first: Int`, `last: Int`.
###### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="grouporganizationssearch"></a>`search` | [`String`](#string) | Search term used to find organizations with. |
| <a id="grouporganizationsstate"></a>`state` | [`CustomerRelationsOrganizationState`](#customerrelationsorganizationstate) | State of the organization to search for. |
##### `Group.packages`
Packages of the group.
@ -18492,6 +18524,20 @@ Values for sorting tags.
| <a id="containerrepositorytagsortname_asc"></a>`NAME_ASC` | Ordered by name in ascending order. |
| <a id="containerrepositorytagsortname_desc"></a>`NAME_DESC` | Ordered by name in descending order. |
### `CustomerRelationsContactState`
| Value | Description |
| ----- | ----------- |
| <a id="customerrelationscontactstateactive"></a>`active` | Active contact. |
| <a id="customerrelationscontactstateinactive"></a>`inactive` | Inactive contact. |
### `CustomerRelationsOrganizationState`
| Value | Description |
| ----- | ----------- |
| <a id="customerrelationsorganizationstateactive"></a>`active` | Active organization. |
| <a id="customerrelationsorganizationstateinactive"></a>`inactive` | Inactive organization. |
### `DastProfileCadenceUnit`
Unit for the duration of Dast Profile Cadence.

View File

@ -230,7 +230,7 @@ include:
file: '/templates/.gitlab-ci-template.yml'
- project: 'my-group/my-project'
ref: v1.0.0
ref: v1.0.0 # Git Tag
file: '/templates/.gitlab-ci-template.yml'
- project: 'my-group/my-project'

View File

@ -450,5 +450,26 @@ test-vars-2:
- printenv
```
You can't reuse a section that already includes a `!reference` tag. Only one level
of nesting is supported.
### Nest `!reference` tags in `script`, `before_script`, and `after_script`
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74792) in GitLab 14.8.
You can nest `!reference` tags up to 10 levels deep in `script`, `before_script`, and `after_script` sections. Use nested tags to define reusable sections when building more complex scripts. For example:
```yaml
.snippets:
one:
- echo "ONE!"
two:
- !reference [.snippets, one]
- echo "TWO!"
three:
- !reference [.snippets, two]
- echo "THREE!"
nested-references:
script:
- !reference [.snippets, three]
```
In this example, the `nested-references` job runs all three `echo` commands.

View File

@ -24,6 +24,10 @@ when your platform does not support it.
1. Ensure new sprite sheets generated for 1x and 2x
- `app/assets/images/emoji.png`
- `app/assets/images/emoji@2x.png`
1. Update `fixtures/emojis/intents.json` with any new emoji that we would like to highlight as having positive or negative intent.
- Positive intent should be set to `0.5`.
- Neutral intent can be set to `1`. This is applied to all emoji automatically so there is no need to set this explicitly.
- Negative intent should be set to `1.5`.
1. Ensure you see new individual images copied into `app/assets/images/emoji/`
1. Ensure you can see the new emojis and their aliases in the GitLab Flavored Markdown (GLFM) Autocomplete
1. Ensure you can see the new emojis and their aliases in the award emoji menu

View File

@ -52,7 +52,10 @@ this inconsistency.
Some places in the code refer to both the GitLab and GitHub specifications
simultaneous in the same areas of logic. In these situations,
_GitHub_ Flavored Markdown may be referred to with variable or constant names like
`ghfm_` to avoid confusion.
`ghfm_` to avoid confusion. For example, we use the `ghfm` acronym for the
[`ghfm_spec_v_0.29.txt` GitHub Flavored Markdown specification file](#github-flavored-markdown-specification)
which is committed to the `gitlab` repo and used as input to the
[`update_specification.rb` script](#update-specificationrb-script).
The original CommonMark specification is referred to as _CommonMark_ (no acronym).
@ -434,7 +437,7 @@ subgraph script:
A --> B{Backend Markdown API}
end
subgraph input:<br/>input specification files
C[gfm_spec_v_0.29.txt] --> A
C[ghfm_spec_v_0.29.txt] --> A
D[glfm_intro.txt] --> A
E[glfm_canonical_examples.txt] --> A
end
@ -572,12 +575,16 @@ updated, as in the case of all GFM files.
##### GitHub Flavored Markdown specification
[`glfm_specification/input/github_flavored_markdown/gfm_spec_v_0.29.txt`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/glfm_specification/input/github_flavored_markdown/gfm_spec_v_0.29.txt)
[`glfm_specification/input/github_flavored_markdown/ghfm_spec_v_0.29.txt`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/glfm_specification/input/github_flavored_markdown/ghfm_spec_v_0.29.txt)
is the official latest [GFM `spec.txt`](https://github.com/github/cmark-gfm/blob/master/test/spec.txt).
- It is automatically downloaded and updated by `update-specification.rb` script.
- When it is downloaded, the version number is added to the filename.
NOTE:
This file uses the `ghfm` acronym instead of `gfm`, as
explained in the [Acronyms section](#acronyms-glfm-ghfm-gfm-commonmark).
##### `glfm_intro.txt`
[`glfm_specification/input/gitlab_flavored_markdown/glfm_intro.txt`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/glfm_specification/input/gitlab_flavored_markdown/glfm_intro.txt)
@ -900,12 +907,12 @@ Any exceptions or failures which occur when generating HTML are replaced with an
```yaml
06_04_inlines_emphasis_and_strong_emphasis_1:
canonical: |
<p><em>foo bar</em></p>
static: |
<p data-sourcepos="1:1-1:9" dir="auto"><strong>foo bar</strong></p>
wysiwyg: |
<p><strong>foo bar</strong></p>
canonical: |
<p><em>foo bar</em></p>
static: |
<p data-sourcepos="1:1-1:9" dir="auto"><strong>foo bar</strong></p>
wysiwyg: |
<p><strong>foo bar</strong></p>
```
NOTE:

View File

@ -205,7 +205,7 @@ To assign an alert:
![Alert Details View Assignees](img/alert_details_assignees_v13_1.png)
1. If the right sidebar is not expanded, select
**Expand sidebar** (**{angle-double-right}**) to expand it.
**Expand sidebar** (**{chevron-double-lg-right}**) to expand it.
1. On the right sidebar, locate the **Assignee**, and then select **Edit**.
From the list, select each user you want to assign to the alert.

View File

@ -21,3 +21,6 @@ To enable pipeline status emails:
**Notify only broken pipelines**.
1. Select the branches to send notifications for.
1. Select **Save changes**.
In [GitLab 15.1](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/89546)
and later, pipeline notifications triggered by blocked users are not delivered.

View File

@ -1050,6 +1050,9 @@ Pipeline events are triggered when the status of a pipeline changes.
In [GitLab 13.9](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/53159)
and later, the pipeline webhook returns only the latest jobs.
In [GitLab 15.1](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/89546)
and later, pipeline webhooks triggered by blocked users are not processed.
Request header:
```plaintext
@ -1310,6 +1313,9 @@ Job events are triggered when the status of a job changes.
The `commit.id` in the payload is the ID of the pipeline, not the ID of the commit.
In [GitLab 15.1](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/89546)
and later, job events triggered by blocked users are not processed.
Request header:
```plaintext

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

View File

@ -87,8 +87,6 @@ method selected, you can accept it **only if a fast-forward merge is possible**.
## Rebasing in (semi-)linear merge methods
> Rebasing without running a CI/CD pipeline [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/118825) in GitLab 14.7.
In these merge methods, you can merge only when your source branch is up-to-date with the target branch:
- Merge commit with semi-linear history.
@ -96,11 +94,7 @@ In these merge methods, you can merge only when your source branch is up-to-date
If a fast-forward merge is not possible but a conflict-free rebase is possible,
GitLab offers you the [`/rebase` quick action](../../../../topics/git/git_rebase.md#rebase-from-the-gitlab-ui),
and the ability to **Rebase** from the user interface:
![Fast forward merge request](../img/ff_merge_rebase_v14_9.png)
In [GitLab 14.7](https://gitlab.com/gitlab-org/gitlab/-/issues/118825) and later, you can also rebase without running a CI/CD pipeline.
and the ability to select **Rebase** from the user interface.
If the target branch is ahead of the source branch and a conflict-free rebase is
not possible, you must rebase the source branch locally before you can do a fast-forward merge.
@ -110,6 +104,23 @@ not possible, you must rebase the source branch locally before you can do a fast
Rebasing may be required before squashing, even though squashing can itself be
considered equivalent to rebasing.
### Rebase without CI/CD pipeline
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/118825) in GitLab 14.7 [with a flag](../../../../administration/feature_flags.md) named `rebase_without_ci_ui`. Disabled by default.
FLAG:
On GitLab.com and 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 `rebase_without_ci_ui`.
The feature is not ready for production use.
To rebase a merge request's branch without triggering a CI/CD pipeline, select
**Rebase without pipeline** from the merge request reports section.
This option is available when fast-forward merge is not possible but a conflict-free
rebase is possible.
Rebasing without a CI/CD pipeline saves resources in projects with a semi-linear
workflow that requires frequent rebases.
## Related topics
- [Commits history](../commits.md)

View File

@ -0,0 +1,16 @@
{
"thumbsdown": 1.5,
"thumbsdown_tone1": 1.5,
"thumbsdown_tone2": 1.5,
"thumbsdown_tone3": 1.5,
"thumbsdown_tone4": 1.5,
"thumbsdown_tone5": 1.5,
"thumbsup": 0.5,
"thumbsup_tone1": 0.5,
"thumbsup_tone2": 0.5,
"thumbsup_tone3": 0.5,
"thumbsup_tone4": 0.5,
"thumbsup_tone5": 0.5,
"slight_frown": 1.5,
"slight_smile": 0.5
}

View File

@ -0,0 +1,24 @@
# frozen_string_literal: true
module Gitlab
module BackgroundMigration
# Set `project_settings.legacy_open_source_license_available` to false for non-public projects
class SetLegacyOpenSourceLicenseAvailableForNonPublicProjects < ::Gitlab::BackgroundMigration::BatchedMigrationJob
PUBLIC = 20
# Migration only version of `project_settings` table
class ProjectSetting < ApplicationRecord
self.table_name = 'project_settings'
end
def perform
each_sub_batch(
operation_name: :set_legacy_open_source_license_available,
batching_scope: ->(relation) { relation.where.not(visibility_level: PUBLIC) }
) do |sub_batch|
ProjectSetting.where(project_id: sub_batch).update_all(legacy_open_source_license_available: false)
end
end
end
end
end

View File

@ -7,6 +7,7 @@ module Gitlab
module Limit
class RateLimit < Chain::Base
include Chain::Helpers
include ::Gitlab::Utils::StrongMemoize
def perform!
# We exclude child-pipelines from the rate limit because they represent
@ -41,7 +42,9 @@ module Gitlab
commit_sha: command.sha,
current_user_id: current_user.id,
subscription_plan: project.actual_plan_name,
message: 'Activated pipeline creation rate limit'
message: 'Activated pipeline creation rate limit',
throttled: enforce_throttle?,
throttle_override: throttle_override?
)
end
@ -50,9 +53,16 @@ module Gitlab
end
def enforce_throttle?
::Feature.enabled?(
:ci_enforce_throttle_pipelines_creation,
project)
strong_memoize(:enforce_throttle) do
::Feature.enabled?(:ci_enforce_throttle_pipelines_creation, project) &&
!throttle_override?
end
end
def throttle_override?
strong_memoize(:throttle_override) do
::Feature.enabled?(:ci_enforce_throttle_pipelines_creation_override, project)
end
end
end
end

View File

@ -17232,7 +17232,7 @@ msgstr ""
msgid "GitLab is open source software to collaborate on code."
msgstr ""
msgid "GitLab is undergoing maintenance and is operating in read-only mode."
msgid "GitLab is undergoing maintenance"
msgstr ""
msgid "GitLab logo"
@ -19088,6 +19088,9 @@ msgstr ""
msgid "IDE|Review"
msgstr ""
msgid "IDE|Start a new merge request"
msgstr ""
msgid "IDE|Successful commit"
msgstr ""
@ -25758,9 +25761,6 @@ msgstr ""
msgid "No runner executable"
msgstr ""
msgid "No runners found"
msgstr ""
msgid "No schedules"
msgstr ""
@ -32765,6 +32765,9 @@ msgstr[1] ""
msgid "Runners|A capacity of 1 enables warm HA through Auto Scaling group re-spawn. A capacity of 2 enables hot HA because the service is available even when a node is lost. A capacity of 3 or more enables hot HA and manual scaling of runner fleet."
msgstr ""
msgid "Runners|A new version is available"
msgstr ""
msgid "Runners|A periodic background task deletes runners that haven't contacted GitLab in more than %{elapsedTime}. %{linkStart}Can I view how many runners were deleted?%{linkEnd}"
msgstr ""
@ -32866,6 +32869,9 @@ msgstr ""
msgid "Runners|Download latest binary"
msgstr ""
msgid "Runners|Edit your search and try again"
msgstr ""
msgid "Runners|Enable stale runner cleanup"
msgstr ""
@ -32878,6 +32884,9 @@ msgstr ""
msgid "Runners|Executor"
msgstr ""
msgid "Runners|Get started with runners"
msgstr ""
msgid "Runners|Group"
msgstr ""
@ -32926,6 +32935,9 @@ msgstr ""
msgid "Runners|New registration token generated!"
msgstr ""
msgid "Runners|No results found"
msgstr ""
msgid "Runners|No spot. Default choice for Windows Shell executor."
msgstr ""
@ -33069,6 +33081,9 @@ msgstr ""
msgid "Runners|Runners"
msgstr ""
msgid "Runners|Runners are the agents that run your CI/CD jobs. Follow the %{linkStart}installation and registration instructions%{linkEnd} to set up a runner."
msgstr ""
msgid "Runners|Runs untagged jobs"
msgstr ""
@ -33134,6 +33149,9 @@ msgstr ""
msgid "Runners|This runner is available to all projects and subgroups in a group."
msgstr ""
msgid "Runners|This runner is outdated, an upgrade is recommended"
msgstr ""
msgid "Runners|To install Runner in Kubernetes follow the instructions described in the GitLab documentation."
msgstr ""
@ -33203,6 +33221,12 @@ msgstr ""
msgid "Runners|stale"
msgstr ""
msgid "Runners|upgrade available"
msgstr ""
msgid "Runners|upgrade recommended"
msgstr ""
msgid "Running"
msgstr ""
@ -36268,9 +36292,6 @@ msgstr ""
msgid "Start a new discussion…"
msgstr ""
msgid "Start a new merge request"
msgstr ""
msgid "Start a new merge request with these changes"
msgstr ""
@ -38748,9 +38769,6 @@ msgstr ""
msgid "This GitLab instance is licensed at the %{insufficient_license} tier. Geo is only available for users who have at least a Premium license."
msgstr ""
msgid "This GitLab instance is undergoing maintenance and is operating in read-only mode."
msgstr ""
msgid "This PDF is too large to display. Please download to view."
msgstr ""

View File

@ -56,7 +56,7 @@
"@babel/preset-env": "^7.10.1",
"@gitlab/at.js": "1.5.7",
"@gitlab/favicon-overlay": "2.0.0",
"@gitlab/svgs": "2.17.0",
"@gitlab/svgs": "2.18.0",
"@gitlab/ui": "40.7.1",
"@gitlab/visual-review-tools": "1.7.3",
"@rails/actioncable": "6.1.4-7",

View File

@ -115,13 +115,17 @@ RSpec.describe "Admin Runners" do
expect(page).not_to have_content("runner-bar")
end
it 'shows no runner when description does not match' do
input_filtered_search_keys('runner-baz')
context 'when description does not match' do
before do
input_filtered_search_keys('runner-baz')
end
expect(page).to have_link('All 0')
expect(page).to have_link('Instance 0')
it_behaves_like 'shows no runners found'
expect(page).to have_text 'No runners found'
it 'shows no runner' do
expect(page).to have_link('All 0')
expect(page).to have_link('Instance 0')
end
end
end
@ -190,14 +194,6 @@ RSpec.describe "Admin Runners" do
expect(page).not_to have_content 'runner-never-contacted'
end
it 'shows no runner when status does not match' do
input_filtered_search_filter_is_only('Status', 'Stale')
expect(page).to have_link('All 0')
expect(page).to have_text 'No runners found'
end
it 'shows correct runner when status is selected and search term is entered' do
input_filtered_search_filter_is_only('Status', 'Online')
input_filtered_search_keys('runner-1')
@ -225,6 +221,18 @@ RSpec.describe "Admin Runners" do
expect(page).to have_selector '.badge', text: 'never contacted'
end
end
context 'when status does not match' do
before do
input_filtered_search_filter_is_only('Status', 'Stale')
end
it_behaves_like 'shows no runners found'
it 'shows no runner' do
expect(page).to have_link('All 0')
end
end
end
describe 'filter by type' do
@ -273,21 +281,6 @@ RSpec.describe "Admin Runners" do
end
end
it 'shows no runner when type does not match' do
visit admin_runners_path
page.within('[data-testid="runner-type-tabs"]') do
click_on 'Instance'
expect(page).to have_link('Instance', class: 'active')
end
expect(page).not_to have_content 'runner-project'
expect(page).not_to have_content 'runner-group'
expect(page).to have_text 'No runners found'
end
it 'shows correct runner when type is selected and search term is entered' do
create(:ci_runner, :project, description: 'runner-2-project', projects: [project])
@ -327,6 +320,24 @@ RSpec.describe "Admin Runners" do
expect(page).not_to have_content 'runner-group'
expect(page).not_to have_content 'runner-paused-project'
end
context 'when type does not match' do
before do
visit admin_runners_path
page.within('[data-testid="runner-type-tabs"]') do
click_on 'Instance'
expect(page).to have_link('Instance', class: 'active')
end
end
it_behaves_like 'shows no runners found'
it 'shows no runner' do
expect(page).not_to have_content 'runner-project'
expect(page).not_to have_content 'runner-group'
end
end
end
describe 'filter by tag' do
@ -358,15 +369,6 @@ RSpec.describe "Admin Runners" do
expect(page).not_to have_content 'runner-red'
end
it 'shows no runner when tag does not match' do
visit admin_runners_path
input_filtered_search_filter_is_only('Tags', 'green')
expect(page).not_to have_content 'runner-blue'
expect(page).to have_text 'No runners found'
end
it 'shows correct runner when tag is selected and search term is entered' do
create(:ci_runner, :instance, description: 'runner-2-blue', tag_list: ['blue'])
@ -384,6 +386,19 @@ RSpec.describe "Admin Runners" do
expect(page).not_to have_content 'runner-blue'
expect(page).not_to have_content 'runner-red'
end
context 'when tag does not match' do
before do
visit admin_runners_path
input_filtered_search_filter_is_only('Tags', 'green')
end
it_behaves_like 'shows no runners found'
it 'shows no runner' do
expect(page).not_to have_content 'runner-blue'
end
end
end
it 'sorts by last contact date' do
@ -419,7 +434,7 @@ RSpec.describe "Admin Runners" do
visit admin_runners_path
end
it_behaves_like "shows no runners"
it_behaves_like 'shows no runners registered'
it 'shows tabs with total counts equal to 0' do
expect(page).to have_link('All 0')

View File

@ -33,7 +33,7 @@ RSpec.describe "Group Runners" do
visit group_runners_path(group)
end
it_behaves_like "shows no runners"
it_behaves_like 'shows no runners registered'
it 'shows tabs with total counts equal to 0' do
expect(page).to have_link('All 0')
@ -70,6 +70,18 @@ RSpec.describe "Group Runners" do
expect(find_link('Edit')[:href]).to end_with(edit_group_runner_path(group, group_runner))
end
end
context 'when description does not match' do
before do
input_filtered_search_keys('runner-baz')
end
it_behaves_like 'shows no runners found'
it 'shows no runner' do
expect(page).not_to have_content 'runner-foo'
end
end
end
context "with an online project runner" do

View File

@ -66,5 +66,83 @@ RSpec.describe Crm::ContactsFinder do
expect(subject).to be_empty
end
end
context 'with search informations' do
let_it_be(:search_test_group) { create(:group, :crm_enabled) }
let_it_be(:search_test_a) do
create(
:contact,
group: search_test_group,
first_name: "ABC",
last_name: "DEF",
email: "ghi@test.com",
description: "LMNO",
state: "inactive"
)
end
let_it_be(:search_test_b) do
create(
:contact,
group: search_test_group,
first_name: "PQR",
last_name: "STU",
email: "vwx@test.com",
description: "YZ",
state: "active"
)
end
before do
search_test_group.add_developer(user)
end
context 'when search term is empty' do
it 'returns all group contacts alphabetically ordered' do
finder = described_class.new(user, group: search_test_group, search: "")
expect(finder.execute).to eq([search_test_a, search_test_b])
end
end
context 'when search term is not empty' do
it 'searches for first name ignoring casing' do
finder = described_class.new(user, group: search_test_group, search: "aBc")
expect(finder.execute).to match_array([search_test_a])
end
it 'searches for last name ignoring casing' do
finder = described_class.new(user, group: search_test_group, search: "StU")
expect(finder.execute).to match_array([search_test_b])
end
it 'searches for email' do
finder = described_class.new(user, group: search_test_group, search: "ghi")
expect(finder.execute).to match_array([search_test_a])
end
it 'searches for description ignoring casing' do
finder = described_class.new(user, group: search_test_group, search: "Yz")
expect(finder.execute).to match_array([search_test_b])
end
it 'fuzzy searches for email and last name' do
finder = described_class.new(user, group: search_test_group, search: "s")
expect(finder.execute).to match_array([search_test_a, search_test_b])
end
end
context 'when searching for contacts state' do
it 'returns only inactive contacts' do
finder = described_class.new(user, group: search_test_group, state: :inactive)
expect(finder.execute).to match_array([search_test_a])
end
it 'returns only active contacts' do
finder = described_class.new(user, group: search_test_group, state: :active)
expect(finder.execute).to match_array([search_test_b])
end
end
end
end
end

View File

@ -0,0 +1,134 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Crm::OrganizationsFinder do
let_it_be(:user) { create(:user) }
describe '#execute' do
subject { described_class.new(user, group: group).execute }
context 'when customer relations feature is enabled for the group' do
let_it_be(:root_group) { create(:group, :crm_enabled) }
let_it_be(:group) { create(:group, parent: root_group) }
let_it_be(:organization_1) { create(:organization, group: root_group) }
let_it_be(:organization_2) { create(:organization, group: root_group) }
context 'when user does not have permissions to see organizations in the group' do
it 'returns an empty array' do
expect(subject).to be_empty
end
end
context 'when user is member of the root group' do
before do
root_group.add_developer(user)
end
context 'when feature flag is disabled' do
before do
stub_feature_flags(customer_relations: false)
end
it 'returns an empty array' do
expect(subject).to be_empty
end
end
context 'when feature flag is enabled' do
it 'returns all group organizations' do
expect(subject).to match_array([organization_1, organization_2])
end
end
end
context 'when user is member of the sub group' do
before do
group.add_developer(user)
end
it 'returns an empty array' do
expect(subject).to be_empty
end
end
end
context 'when customer relations feature is disabled for the group' do
let_it_be(:group) { create(:group) }
let_it_be(:organization) { create(:organization, group: group) }
before do
group.add_developer(user)
end
it 'returns an empty array' do
expect(subject).to be_empty
end
end
context 'with search informations' do
let_it_be(:search_test_group) { create(:group, :crm_enabled) }
let_it_be(:search_test_a) do
create(
:organization,
group: search_test_group,
name: "DEF",
description: "ghi_st",
state: "inactive"
)
end
let_it_be(:search_test_b) do
create(
:organization,
group: search_test_group,
name: "ABC_st",
description: "JKL",
state: "active"
)
end
before do
search_test_group.add_developer(user)
end
context 'when search term is empty' do
it 'returns all group organizations alphabetically ordered' do
finder = described_class.new(user, group: search_test_group, search: "")
expect(finder.execute).to eq([search_test_b, search_test_a])
end
end
context 'when search term is not empty' do
it 'searches for name' do
finder = described_class.new(user, group: search_test_group, search: "aBc")
expect(finder.execute).to match_array([search_test_b])
end
it 'searches for description' do
finder = described_class.new(user, group: search_test_group, search: "ghI")
expect(finder.execute).to match_array([search_test_a])
end
it 'searches for name and description' do
finder = described_class.new(user, group: search_test_group, search: "_st")
expect(finder.execute).to eq([search_test_b, search_test_a])
end
end
context 'when searching for organizations state' do
it 'returns only inactive organizations' do
finder = described_class.new(user, group: search_test_group, state: :inactive)
expect(finder.execute).to match_array([search_test_a])
end
it 'returns only active organizations' do
finder = described_class.new(user, group: search_test_group, state: :active)
expect(finder.execute).to match_array([search_test_b])
end
end
end
end
end

View File

@ -58,6 +58,16 @@ export const validEmoji = {
unicodeVersion: '6.0',
description: 'because it contains multiple zero width joiners',
},
thumbsup: {
moji: '👍',
unicodeVersion: '6.0',
description: 'thumbs up sign',
},
thumbsdown: {
moji: '👎',
description: 'thumbs down sign',
unicodeVersion: '6.0',
},
};
export const invalidEmoji = {

View File

@ -57,6 +57,18 @@ describe('AwardsHandler', () => {
d: 'white question mark ornament',
u: '6.0',
},
thumbsup: {
c: 'people',
e: '👍',
d: 'thumbs up sign',
u: '6.0',
},
thumbsdown: {
c: 'people',
e: '👎',
d: 'thumbs down sign',
u: '6.0',
},
};
const openAndWaitForEmojiMenu = (sel = '.js-add-award') => {
@ -296,6 +308,23 @@ describe('AwardsHandler', () => {
awardsHandler.searchEmojis('👼');
expect($('[data-name=angel]').is(':visible')).toBe(true);
});
it('should show positive intent emoji first', async () => {
await openAndWaitForEmojiMenu();
awardsHandler.searchEmojis('thumb');
const $menu = $('.emoji-menu');
const $thumbsUpItem = $menu.find('[data-name=thumbsup]');
const $thumbsDownItem = $menu.find('[data-name=thumbsdown]');
expect($thumbsUpItem.is(':visible')).toBe(true);
expect($thumbsDownItem.is(':visible')).toBe(true);
expect($thumbsUpItem.parents('.emoji-menu-list-item').index()).toBeLessThan(
$thumbsDownItem.parents('.emoji-menu-list-item').index(),
);
});
});
describe('emoji menu', () => {

View File

@ -24,6 +24,7 @@ import isEmojiUnicodeSupported, {
isHorceRacingSkinToneComboEmoji,
isPersonZwjEmoji,
} from '~/emoji/support/is_emoji_unicode_supported';
import { NEUTRAL_INTENT_MULTIPLIER } from '~/emoji/constants';
const emptySupportMap = {
personZwj: false,
@ -436,14 +437,28 @@ describe('emoji', () => {
it.each([undefined, null, ''])("should return all emoji when the input is '%s'", (input) => {
const search = searchEmoji(input);
const expected = Object.keys(validEmoji).map((name) => {
return {
emoji: mockEmojiData[name],
field: 'd',
fieldValue: mockEmojiData[name].d,
score: 0,
};
});
const expected = Object.keys(validEmoji)
.map((name) => {
let score = NEUTRAL_INTENT_MULTIPLIER;
// Positive intent value retrieved from ~/emoji/intents.json
if (name === 'thumbsup') {
score = 0.5;
}
// Negative intent value retrieved from ~/emoji/intents.json
if (name === 'thumbsdown') {
score = 1.5;
}
return {
emoji: mockEmojiData[name],
field: 'd',
fieldValue: mockEmojiData[name].d,
score,
};
})
.sort(sortEmoji);
expect(search).toEqual(expected);
});
@ -457,7 +472,7 @@ describe('emoji', () => {
name: 'atom',
field: 'e',
fieldValue: 'atom',
score: 0,
score: NEUTRAL_INTENT_MULTIPLIER,
},
],
],
@ -469,7 +484,7 @@ describe('emoji', () => {
name: 'atom',
field: 'alias',
fieldValue: 'atom_symbol',
score: 4,
score: 16,
},
],
],
@ -481,7 +496,7 @@ describe('emoji', () => {
name: 'atom',
field: 'alias',
fieldValue: 'atom_symbol',
score: 0,
score: NEUTRAL_INTENT_MULTIPLIER,
},
],
],
@ -509,7 +524,7 @@ describe('emoji', () => {
{
name: 'atom',
field: 'd',
score: 0,
score: NEUTRAL_INTENT_MULTIPLIER,
},
],
],
@ -521,7 +536,7 @@ describe('emoji', () => {
{
name: 'atom',
field: 'd',
score: 0,
score: NEUTRAL_INTENT_MULTIPLIER,
},
],
],
@ -533,7 +548,7 @@ describe('emoji', () => {
{
name: 'grey_question',
field: 'name',
score: 5,
score: 32,
},
],
],
@ -544,7 +559,7 @@ describe('emoji', () => {
{
name: 'grey_question',
field: 'd',
score: 24,
score: 16777216,
},
],
],
@ -552,15 +567,15 @@ describe('emoji', () => {
'searching with query "heart"',
'heart',
[
{
name: 'black_heart',
field: 'd',
score: 6,
},
{
name: 'heart',
field: 'name',
score: 0,
score: NEUTRAL_INTENT_MULTIPLIER,
},
{
name: 'black_heart',
field: 'd',
score: 64,
},
],
],
@ -568,15 +583,15 @@ describe('emoji', () => {
'searching with query "HEART"',
'HEART',
[
{
name: 'black_heart',
field: 'd',
score: 6,
},
{
name: 'heart',
field: 'name',
score: 0,
score: NEUTRAL_INTENT_MULTIPLIER,
},
{
name: 'black_heart',
field: 'd',
score: 64,
},
],
],
@ -584,15 +599,31 @@ describe('emoji', () => {
'searching with query "star"',
'star',
[
{
name: 'custard',
field: 'd',
score: 2,
},
{
name: 'star',
field: 'name',
score: 0,
score: NEUTRAL_INTENT_MULTIPLIER,
},
{
name: 'custard',
field: 'd',
score: 4,
},
],
],
[
'searching for emoji with intentions assigned',
'thumbs',
[
{
name: 'thumbsup',
field: 'd',
score: 0.5,
},
{
name: 'thumbsdown',
field: 'd',
score: 1.5,
},
],
],
@ -619,10 +650,10 @@ describe('emoji', () => {
[
{ score: 10, fieldValue: '', emoji: { name: 'a' } },
{ score: 5, fieldValue: '', emoji: { name: 'b' } },
{ score: 0, fieldValue: '', emoji: { name: 'c' } },
{ score: 1, fieldValue: '', emoji: { name: 'c' } },
],
[
{ score: 0, fieldValue: '', emoji: { name: 'c' } },
{ score: 1, fieldValue: '', emoji: { name: 'c' } },
{ score: 5, fieldValue: '', emoji: { name: 'b' } },
{ score: 10, fieldValue: '', emoji: { name: 'a' } },
],
@ -630,25 +661,25 @@ describe('emoji', () => {
[
'should correctly sort by fieldValue',
[
{ score: 0, fieldValue: 'y', emoji: { name: 'b' } },
{ score: 0, fieldValue: 'x', emoji: { name: 'a' } },
{ score: 0, fieldValue: 'z', emoji: { name: 'c' } },
{ score: 1, fieldValue: 'y', emoji: { name: 'b' } },
{ score: 1, fieldValue: 'x', emoji: { name: 'a' } },
{ score: 1, fieldValue: 'z', emoji: { name: 'c' } },
],
[
{ score: 0, fieldValue: 'x', emoji: { name: 'a' } },
{ score: 0, fieldValue: 'y', emoji: { name: 'b' } },
{ score: 0, fieldValue: 'z', emoji: { name: 'c' } },
{ score: 1, fieldValue: 'x', emoji: { name: 'a' } },
{ score: 1, fieldValue: 'y', emoji: { name: 'b' } },
{ score: 1, fieldValue: 'z', emoji: { name: 'c' } },
],
],
[
'should correctly sort by score and then by fieldValue (in order)',
[
{ score: 5, fieldValue: 'y', emoji: { name: 'c' } },
{ score: 0, fieldValue: 'z', emoji: { name: 'a' } },
{ score: 1, fieldValue: 'z', emoji: { name: 'a' } },
{ score: 5, fieldValue: 'x', emoji: { name: 'b' } },
],
[
{ score: 0, fieldValue: 'z', emoji: { name: 'a' } },
{ score: 1, fieldValue: 'z', emoji: { name: 'a' } },
{ score: 5, fieldValue: 'x', emoji: { name: 'b' } },
{ score: 5, fieldValue: 'y', emoji: { name: 'c' } },
],
@ -656,7 +687,7 @@ describe('emoji', () => {
];
it.each(testCases)('%s', (_, scoredItems, expected) => {
expect(sortEmoji(scoredItems)).toEqual(expected);
expect(scoredItems.sort(sortEmoji)).toEqual(expected);
});
});
});

View File

@ -0,0 +1,15 @@
import { getEmojiScoreWithIntent } from '~/emoji/utils';
describe('Utils', () => {
describe('getEmojiScoreWithIntent', () => {
it.each`
emojiName | baseScore | finalScore
${'thumbsup'} | ${1} | ${1}
${'thumbsdown'} | ${1} | ${3}
${'neutralemoji'} | ${1} | ${2}
${'zerobaseemoji'} | ${0} | ${1}
`('returns the correct score for $emojiName', ({ emojiName, baseScore, finalScore }) => {
expect(getEmojiScoreWithIntent(emojiName, baseScore)).toBe(finalScore);
});
});
});

View File

@ -26,6 +26,12 @@ RSpec.describe 'Runner (JavaScript fixtures)' do
remove_repository(project)
end
before do
allow(Gitlab::Ci::RunnerUpgradeCheck.instance)
.to receive(:check_runner_upgrade_status)
.and_return(:not_available)
end
describe do
before do
sign_in(admin)

View File

@ -1,193 +1,97 @@
import Vue, { nextTick } from 'vue';
import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
import { projectData, branches } from 'jest/ide/mock_data';
import Vue from 'vue';
import Vuex from 'vuex';
import { GlFormCheckbox } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import NewMergeRequestOption from '~/ide/components/commit_sidebar/new_merge_request_option.vue';
import { PERMISSION_CREATE_MR } from '~/ide/constants';
import { createStore } from '~/ide/stores';
import {
COMMIT_TO_CURRENT_BRANCH,
COMMIT_TO_NEW_BRANCH,
} from '~/ide/stores/modules/commit/constants';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
describe('create new MR checkbox', () => {
Vue.use(Vuex);
describe('NewMergeRequestOption component', () => {
let store;
let vm;
let wrapper;
const setMR = () => {
vm.$store.state.currentMergeRequestId = '1';
vm.$store.state.projects[store.state.currentProjectId].mergeRequests[
store.state.currentMergeRequestId
] = { foo: 'bar' };
};
const findCheckbox = () => wrapper.findComponent(GlFormCheckbox);
const findFieldset = () => wrapper.findByTestId('new-merge-request-fieldset');
const findTooltip = () => getBinding(findFieldset().element, 'gl-tooltip');
const setPermissions = (permissions) => {
store.state.projects[store.state.currentProjectId].userPermissions = permissions;
};
const createComponent = ({ currentBranchId = 'main', createNewBranch = false } = {}) => {
const Component = Vue.extend(NewMergeRequestOption);
vm = createComponentWithStore(Component, store);
vm.$store.state.commit.commitAction = createNewBranch
? COMMIT_TO_NEW_BRANCH
: COMMIT_TO_CURRENT_BRANCH;
vm.$store.state.currentBranchId = currentBranchId;
store.state.projects.abcproject.branches[currentBranchId] = branches.find(
(branch) => branch.name === currentBranchId,
);
return vm.$mount();
};
const findInput = () => vm.$el.querySelector('input[type="checkbox"]');
const findLabel = () => vm.$el.querySelector('.js-ide-commit-new-mr');
beforeEach(() => {
const createComponent = ({
shouldHideNewMrOption = false,
shouldDisableNewMrOption = false,
shouldCreateMR = false,
} = {}) => {
store = createStore();
store.state.currentProjectId = 'abcproject';
const proj = JSON.parse(JSON.stringify(projectData));
proj.userPermissions[PERMISSION_CREATE_MR] = true;
Vue.set(store.state.projects, 'abcproject', proj);
});
wrapper = shallowMountExtended(NewMergeRequestOption, {
store: {
...store,
getters: {
'commit/shouldHideNewMrOption': shouldHideNewMrOption,
'commit/shouldDisableNewMrOption': shouldDisableNewMrOption,
'commit/shouldCreateMR': shouldCreateMR,
},
},
directives: {
GlTooltip: createMockDirective(),
},
});
};
afterEach(() => {
vm.$destroy();
wrapper.destroy();
});
describe('for default branch', () => {
describe('is rendered when pushing to a new branch', () => {
beforeEach(() => {
createComponent({
currentBranchId: 'main',
createNewBranch: true,
});
});
it('has NO new MR', () => {
expect(vm.$el.textContent).not.toBe('');
});
it('has new MR', async () => {
setMR();
await nextTick();
expect(vm.$el.textContent).not.toBe('');
});
describe('when the `shouldHideNewMrOption` getter returns false', () => {
beforeEach(() => {
createComponent();
jest.spyOn(store, 'dispatch').mockImplementation();
});
describe('is NOT rendered when pushing to the same branch', () => {
it('renders an enabled new MR checkbox', () => {
expect(findCheckbox().attributes('disabled')).toBeUndefined();
});
it("doesn't add `is-disabled` class to the fieldset", () => {
expect(findFieldset().classes()).not.toContain('is-disabled');
});
it('dispatches toggleShouldCreateMR when clicking checkbox', () => {
findCheckbox().vm.$emit('change');
expect(store.dispatch).toHaveBeenCalledWith('commit/toggleShouldCreateMR', undefined);
});
describe('when user cannot create an MR', () => {
beforeEach(() => {
createComponent({
currentBranchId: 'main',
createNewBranch: false,
shouldDisableNewMrOption: true,
});
});
it('has NO new MR', () => {
expect(vm.$el.textContent).toBe('');
it('disables the new MR checkbox', () => {
expect(findCheckbox().attributes('disabled')).toBe('true');
});
it('has new MR', async () => {
setMR();
it('adds `is-disabled` class to the fieldset', () => {
expect(findFieldset().classes()).toContain('is-disabled');
});
await nextTick();
expect(vm.$el.textContent).toBe('');
it('shows a tooltip', () => {
expect(findTooltip().value).toBe(wrapper.vm.$options.i18n.tooltipText);
});
});
});
describe('for protected branch', () => {
describe('when user does not have the write access', () => {
beforeEach(() => {
createComponent({
currentBranchId: 'protected/no-access',
});
});
it('is rendered if MR does not exists', () => {
expect(vm.$el.textContent).not.toBe('');
});
it('is rendered if MR exists', async () => {
setMR();
await nextTick();
expect(vm.$el.textContent).not.toBe('');
});
});
describe('when user has the write access', () => {
beforeEach(() => {
createComponent({
currentBranchId: 'protected/access',
});
});
it('is rendered if MR does not exist', () => {
expect(vm.$el.textContent).not.toBe('');
});
it('is hidden if MR exists', async () => {
setMR();
await nextTick();
expect(vm.$el.textContent).toBe('');
});
});
});
describe('for regular branch', () => {
describe('when the `shouldHideNewMrOption` getter returns true', () => {
beforeEach(() => {
createComponent({
currentBranchId: 'regular',
shouldHideNewMrOption: true,
});
});
it('is rendered if no MR exists', () => {
expect(vm.$el.textContent).not.toBe('');
it("doesn't render the new MR checkbox", () => {
expect(findCheckbox().exists()).toBe(false);
});
it('is hidden if MR exists', async () => {
setMR();
await nextTick();
expect(vm.$el.textContent).toBe('');
});
it('shows enablded checkbox', () => {
expect(findLabel().classList.contains('is-disabled')).toBe(false);
expect(findInput().disabled).toBe(false);
});
});
describe('when user cannot create MR', () => {
beforeEach(() => {
setPermissions({ [PERMISSION_CREATE_MR]: false });
createComponent({ currentBranchId: 'regular' });
});
it('disabled checkbox', () => {
expect(findLabel().classList.contains('is-disabled')).toBe(true);
expect(findInput().disabled).toBe(true);
});
});
it('dispatches toggleShouldCreateMR when clicking checkbox', () => {
createComponent({
currentBranchId: 'regular',
});
const el = vm.$el.querySelector('input[type="checkbox"]');
jest.spyOn(vm.$store, 'dispatch').mockImplementation(() => {});
el.dispatchEvent(new Event('change'));
expect(vm.$store.dispatch.mock.calls).toEqual(
expect.arrayContaining([['commit/toggleShouldCreateMR', expect.any(Object)]]),
);
});
});

View File

@ -18,6 +18,7 @@ import AdminRunnersApp from '~/runner/admin_runners/admin_runners_app.vue';
import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue';
import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.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';
import RunnerActionsCell from '~/runner/components/cells/runner_actions_cell.vue';
import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue';
@ -50,6 +51,8 @@ import {
runnersDataPaginated,
onlineContactTimeoutSecs,
staleTimeoutSecs,
emptyStateSvgPath,
emptyStateFilteredSvgPath,
} from '../mock_data';
const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN';
@ -78,6 +81,7 @@ describe('AdminRunnersApp', () => {
const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown);
const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs);
const findRunnerList = () => wrapper.findComponent(RunnerList);
const findRunnerListEmptyState = () => wrapper.findComponent(RunnerListEmptyState);
const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination));
const findRunnerPaginationNext = () => findRunnerPagination().findByLabelText('Go to next page');
const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar);
@ -106,6 +110,8 @@ describe('AdminRunnersApp', () => {
localMutations,
onlineContactTimeoutSecs,
staleTimeoutSecs,
emptyStateSvgPath,
emptyStateFilteredSvgPath,
...provide,
},
...options,
@ -457,12 +463,28 @@ describe('AdminRunnersApp', () => {
runners: { nodes: [] },
},
});
createComponent();
await waitForPromises();
});
it('shows a message for no results', async () => {
expect(wrapper.text()).toContain('No runners found');
it('shows an empty state', () => {
expect(findRunnerListEmptyState().props('isSearchFiltered')).toBe(false);
});
describe('when a filter is selected by the user', () => {
beforeEach(async () => {
findRunnerFilteredSearchBar().vm.$emit('input', {
runnerType: null,
filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }],
sort: CREATED_ASC,
});
await waitForPromises();
});
it('shows an empty state for a filtered search', () => {
expect(findRunnerListEmptyState().props('isSearchFiltered')).toBe(true);
});
});
});

View File

@ -1,12 +1,15 @@
import { GlBadge } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import RunnerStatusCell from '~/runner/components/cells/runner_status_cell.vue';
import RunnerStatusBadge from '~/runner/components/runner_status_badge.vue';
import RunnerPausedBadge from '~/runner/components/runner_paused_badge.vue';
import { INSTANCE_TYPE, STATUS_ONLINE, STATUS_OFFLINE } from '~/runner/constants';
describe('RunnerTypeCell', () => {
describe('RunnerStatusCell', () => {
let wrapper;
const findBadgeAt = (i) => wrapper.findAllComponents(GlBadge).at(i);
const findStatusBadge = () => wrapper.findComponent(RunnerStatusBadge);
const findPausedBadge = () => wrapper.findComponent(RunnerPausedBadge);
const createComponent = ({ runner = {} } = {}) => {
wrapper = mount(RunnerStatusCell, {
@ -29,7 +32,7 @@ describe('RunnerTypeCell', () => {
createComponent();
expect(wrapper.text()).toMatchInterpolatedText('online');
expect(findBadgeAt(0).text()).toBe('online');
expect(findStatusBadge().text()).toBe('online');
});
it('Displays offline status', () => {
@ -40,7 +43,7 @@ describe('RunnerTypeCell', () => {
});
expect(wrapper.text()).toMatchInterpolatedText('offline');
expect(findBadgeAt(0).text()).toBe('offline');
expect(findStatusBadge().text()).toBe('offline');
});
it('Displays paused status', () => {
@ -52,9 +55,7 @@ describe('RunnerTypeCell', () => {
});
expect(wrapper.text()).toMatchInterpolatedText('online paused');
expect(findBadgeAt(0).text()).toBe('online');
expect(findBadgeAt(1).text()).toBe('paused');
expect(findPausedBadge().text()).toBe('paused');
});
it('Is empty when data is missing', () => {

View File

@ -0,0 +1,76 @@
import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
import RunnerListEmptyState from '~/runner/components/runner_list_empty_state.vue';
const mockSvgPath = 'mock-svg-path.svg';
const mockFilteredSvgPath = 'mock-filtered-svg-path.svg';
describe('RunnerListEmptyState', () => {
let wrapper;
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
const findLink = () => wrapper.findComponent(GlLink);
const findRunnerInstructionsModal = () => wrapper.findComponent(RunnerInstructionsModal);
const createComponent = ({ props, mountFn = shallowMountExtended } = {}) => {
wrapper = mountFn(RunnerListEmptyState, {
propsData: {
svgPath: mockSvgPath,
filteredSvgPath: mockFilteredSvgPath,
...props,
},
directives: {
GlModal: createMockDirective(),
},
stubs: {
GlEmptyState,
GlSprintf,
GlLink,
},
});
};
describe('when search is not filtered', () => {
beforeEach(() => {
createComponent();
});
it('renders an illustration', () => {
expect(findEmptyState().props('svgPath')).toBe(mockSvgPath);
});
it('displays "no results" text', () => {
const title = s__('Runners|Get started with runners');
const desc = s__(
'Runners|Runners are the agents that run your CI/CD jobs. Follow the %{linkStart}installation and registration instructions%{linkEnd} to set up a runner.',
);
expect(findEmptyState().text()).toMatchInterpolatedText(`${title} ${desc}`);
});
it('opens a runner registration instructions modal with a link', () => {
const { value } = getBinding(findLink().element, 'gl-modal');
expect(findRunnerInstructionsModal().props('modalId')).toEqual(value);
});
});
describe('when search is filtered', () => {
beforeEach(() => {
createComponent({ props: { isSearchFiltered: true } });
});
it('renders a "filtered search" illustration', () => {
expect(findEmptyState().props('svgPath')).toBe(mockFilteredSvgPath);
});
it('displays "no filtered results" text', () => {
expect(findEmptyState().text()).toContain(s__('Runners|No results found'));
expect(findEmptyState().text()).toContain(s__('Runners|Edit your search and try again'));
});
});
});

View File

@ -16,6 +16,7 @@ import { updateHistory } from '~/lib/utils/url_utility';
import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue';
import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.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';
import RunnerActionsCell from '~/runner/components/cells/runner_actions_cell.vue';
import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue';
@ -48,6 +49,8 @@ import {
groupRunnersCountData,
onlineContactTimeoutSecs,
staleTimeoutSecs,
emptyStateSvgPath,
emptyStateFilteredSvgPath,
} from '../mock_data';
Vue.use(VueApollo);
@ -75,6 +78,7 @@ describe('GroupRunnersApp', () => {
const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown);
const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs);
const findRunnerList = () => wrapper.findComponent(RunnerList);
const findRunnerListEmptyState = () => wrapper.findComponent(RunnerListEmptyState);
const findRunnerRow = (id) => extendedWrapper(wrapper.findByTestId(`runner-row-${id}`));
const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination));
const findRunnerPaginationNext = () => findRunnerPagination().findByLabelText('Go to next page');
@ -103,6 +107,8 @@ describe('GroupRunnersApp', () => {
provide: {
onlineContactTimeoutSecs,
staleTimeoutSecs,
emptyStateSvgPath,
emptyStateFilteredSvgPath,
},
});
};
@ -388,8 +394,8 @@ describe('GroupRunnersApp', () => {
await waitForPromises();
});
it('shows a message for no results', async () => {
expect(wrapper.text()).toContain('No runners found');
it('shows an empty state', async () => {
expect(findRunnerListEmptyState().exists()).toBe(true);
});
});

View File

@ -21,6 +21,9 @@ import groupRunnersCountData from 'test_fixtures/graphql/runner/list/group_runne
export const onlineContactTimeoutSecs = 2 * 60 * 60;
export const staleTimeoutSecs = 7889238; // Ruby's `3.months`
export const emptyStateSvgPath = 'emptyStateSvgPath.svg';
export const emptyStateFilteredSvgPath = 'emptyStateFilteredSvgPath.svg';
export {
runnersData,
runnersDataPaginated,

View File

@ -5,6 +5,7 @@ import {
fromUrlQueryToSearch,
fromSearchToUrl,
fromSearchToVariables,
isSearchFiltered,
} from '~/runner/runner_search_utils';
describe('search_params.js', () => {
@ -14,6 +15,7 @@ describe('search_params.js', () => {
urlQuery: '',
search: { runnerType: null, filters: [], pagination: { page: 1 }, sort: 'CREATED_DESC' },
graphqlVariables: { sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE },
isDefault: true,
},
{
name: 'a single status',
@ -268,7 +270,7 @@ describe('search_params.js', () => {
describe('fromSearchToUrl', () => {
examples.forEach(({ name, urlQuery, search }) => {
it(`Converts ${name} to a url`, () => {
expect(fromSearchToUrl(search)).toEqual(`http://test.host/${urlQuery}`);
expect(fromSearchToUrl(search)).toBe(`http://test.host/${urlQuery}`);
});
});
@ -280,7 +282,7 @@ describe('search_params.js', () => {
const search = { filters: [], sort: 'CREATED_DESC' };
const expectedUrl = `http://test.host/`;
expect(fromSearchToUrl(search, initalUrl)).toEqual(expectedUrl);
expect(fromSearchToUrl(search, initalUrl)).toBe(expectedUrl);
});
it('When unrelated search parameter is present, it does not get removed', () => {
@ -288,7 +290,7 @@ describe('search_params.js', () => {
const search = { filters: [], sort: 'CREATED_DESC' };
const expectedUrl = `http://test.host/?unrelated=UNRELATED`;
expect(fromSearchToUrl(search, initialUrl)).toEqual(expectedUrl);
expect(fromSearchToUrl(search, initialUrl)).toBe(expectedUrl);
});
});
@ -331,4 +333,16 @@ describe('search_params.js', () => {
});
});
});
describe('isSearchFiltered', () => {
examples.forEach(({ name, search, isDefault }) => {
it(`Given ${name}, evaluates to ${isDefault ? 'not ' : ''}filtered`, () => {
expect(isSearchFiltered(search)).toBe(!isDefault);
});
});
it('given a missing pagination, evaluates as not filtered', () => {
expect(isSearchFiltered({ pagination: null })).toBe(false);
});
});
});

View File

@ -207,10 +207,10 @@ export const commit = async ({ newBranch = false, newMR = false, newBranchName =
if (!newBranch) {
const option = await screen.findByLabelText(/Commit to .+ branch/);
option.click();
await option.click();
} else {
const option = await screen.findByLabelText('Create a new branch');
option.click();
await option.click();
const branchNameInput = await screen.findByTestId('ide-new-branch-name');
fireEvent.input(branchNameInput, { target: { value: newBranchName } });

View File

@ -27,11 +27,9 @@ RSpec.describe ResolvesGroups do
let_it_be(:lookahead_fields) do
<<~FIELDS
contacts { nodes { id } }
containerRepositoriesCount
customEmoji { nodes { id } }
fullPath
organizations { nodes { id } }
path
dependencyProxyBlobCount
dependencyProxyBlobs { nodes { fileName } }

View File

@ -0,0 +1,86 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::Crm::ContactsResolver do
include GraphqlHelpers
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group, :crm_enabled) }
let_it_be(:contact_a) do
create(
:contact,
group: group,
first_name: "ABC",
last_name: "DEF",
email: "ghi@test.com",
description: "LMNO",
state: "inactive"
)
end
let_it_be(:contact_b) do
create(
:contact,
group: group,
first_name: "PQR",
last_name: "STU",
email: "vwx@test.com",
description: "YZ",
state: "active"
)
end
describe '#resolve' do
context 'with unauthorized user' do
it 'does not rise an error and returns no contacts' do
expect { resolve_contacts(group) }.not_to raise_error
expect(resolve_contacts(group)).to be_empty
end
end
context 'with authorized user' do
it 'does not rise an error and returns all contacts' do
group.add_reporter(user)
expect { resolve_contacts(group) }.not_to raise_error
expect(resolve_contacts(group)).to eq([contact_a, contact_b])
end
end
context 'without parent' do
it 'returns no contacts' do
expect(resolve_contacts(nil)).to be_empty
end
end
context 'with a group parent' do
before do
group.add_developer(user)
end
context 'when no filter is provided' do
it 'returns all the contacts' do
expect(resolve_contacts(group)).to match_array([contact_a, contact_b])
end
end
context 'when search term is provided' do
it 'returns the correct contacts' do
expect(resolve_contacts(group, { search: "x@test.com" })).to match_array([contact_b])
end
end
context 'when state is provided' do
it 'returns the correct contacts' do
expect(resolve_contacts(group, { state: :inactive })).to match_array([contact_a])
end
end
end
end
def resolve_contacts(parent, args = {}, context = { current_user: user })
resolve(described_class, obj: parent, args: args, ctx: context)
end
end

View File

@ -0,0 +1,80 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::Crm::OrganizationsResolver do
include GraphqlHelpers
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group, :crm_enabled) }
let_it_be(:organization_a) do
create(
:organization,
group: group,
name: "ABC",
state: "inactive"
)
end
let_it_be(:organization_b) do
create(
:organization,
group: group,
name: "DEF",
state: "active"
)
end
describe '#resolve' do
context 'with unauthorized user' do
it 'does not rise an error and returns no organizations' do
expect { resolve_organizations(group) }.not_to raise_error
expect(resolve_organizations(group)).to be_empty
end
end
context 'with authorized user' do
it 'does not rise an error and returns all organizations' do
group.add_reporter(user)
expect { resolve_organizations(group) }.not_to raise_error
expect(resolve_organizations(group)).to eq([organization_a, organization_b])
end
end
context 'without parent' do
it 'returns no organizations' do
expect(resolve_organizations(nil)).to be_empty
end
end
context 'with a group parent' do
before do
group.add_developer(user)
end
context 'when no filter is provided' do
it 'returns all the organizations' do
expect(resolve_organizations(group)).to match_array([organization_a, organization_b])
end
end
context 'when search term is provided' do
it 'returns the correct organizations' do
expect(resolve_organizations(group, { search: "def" })).to match_array([organization_b])
end
end
context 'when state is provided' do
it 'returns the correct organizations' do
expect(resolve_organizations(group, { state: :inactive })).to match_array([organization_a])
end
end
end
end
def resolve_organizations(parent, args = {}, context = { current_user: user })
resolve(described_class, obj: parent, args: args, ctx: context)
end
end

View File

@ -84,12 +84,14 @@ RSpec.describe Ci::RunnersHelper do
end
it 'returns the data in format' do
expect(helper.admin_runners_data_attributes).to eq({
expect(helper.admin_runners_data_attributes).to include(
runner_install_help_page: 'https://docs.gitlab.com/runner/install/',
registration_token: Gitlab::CurrentSettings.runners_registration_token,
online_contact_timeout_secs: 7200,
stale_timeout_secs: 7889238
})
stale_timeout_secs: 7889238,
empty_state_svg_path: start_with('/assets/illustrations/pipelines_empty'),
empty_state_filtered_svg_path: start_with('/assets/illustrations/magnifying-glass')
)
end
end
@ -130,14 +132,16 @@ RSpec.describe Ci::RunnersHelper do
let(:group) { create(:group) }
it 'returns group data to render a runner list' do
expect(helper.group_runners_data_attributes(group)).to eq({
expect(helper.group_runners_data_attributes(group)).to include(
registration_token: group.runners_token,
group_id: group.id,
group_full_path: group.full_path,
runner_install_help_page: 'https://docs.gitlab.com/runner/install/',
online_contact_timeout_secs: 7200,
stale_timeout_secs: 7889238
})
stale_timeout_secs: 7889238,
empty_state_svg_path: start_with('/assets/illustrations/pipelines_empty'),
empty_state_filtered_svg_path: start_with('/assets/illustrations/magnifying-glass')
)
end
end

View File

@ -0,0 +1,54 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::BackgroundMigration::SetLegacyOpenSourceLicenseAvailableForNonPublicProjects,
:migration,
schema: 20220520040416 do
let(:namespaces_table) { table(:namespaces) }
let(:projects_table) { table(:projects) }
let(:project_settings_table) { table(:project_settings) }
subject(:perform_migration) do
described_class.new(start_id: 1,
end_id: 30,
batch_table: :projects,
batch_column: :id,
sub_batch_size: 2,
pause_ms: 0,
connection: ActiveRecord::Base.connection)
.perform
end
let(:queries) { ActiveRecord::QueryRecorder.new { perform_migration } }
before do
namespaces_table.create!(id: 1, name: 'namespace', path: 'namespace-path-1')
namespaces_table.create!(id: 2, name: 'namespace', path: 'namespace-path-2', type: 'Project')
namespaces_table.create!(id: 3, name: 'namespace', path: 'namespace-path-3', type: 'Project')
namespaces_table.create!(id: 4, name: 'namespace', path: 'namespace-path-4', type: 'Project')
projects_table
.create!(id: 11, name: 'proj-1', path: 'path-1', namespace_id: 1, project_namespace_id: 2, visibility_level: 0)
projects_table
.create!(id: 12, name: 'proj-2', path: 'path-2', namespace_id: 1, project_namespace_id: 3, visibility_level: 10)
projects_table
.create!(id: 13, name: 'proj-3', path: 'path-3', namespace_id: 1, project_namespace_id: 4, visibility_level: 20)
project_settings_table.create!(project_id: 11, legacy_open_source_license_available: true)
project_settings_table.create!(project_id: 12, legacy_open_source_license_available: true)
project_settings_table.create!(project_id: 13, legacy_open_source_license_available: true)
end
it 'sets `legacy_open_source_license_available` attribute to false for non-public projects', :aggregate_failures do
expect(queries.count).to eq(3)
expect(migrated_attribute(11)).to be_falsey
expect(migrated_attribute(12)).to be_falsey
expect(migrated_attribute(13)).to be_truthy
end
def migrated_attribute(project_id)
project_settings_table.find(project_id).legacy_open_source_license_available
end
end

View File

@ -31,6 +31,7 @@ RSpec.describe ::Gitlab::Ci::Pipeline::Chain::Limit::RateLimit, :freeze_time, :c
context 'when the limit is exceeded' do
before do
stub_application_setting(pipeline_limit_per_project_user_sha: 1)
stub_feature_flags(ci_enforce_throttle_pipelines_creation_override: false)
end
it 'does not persist the pipeline' do
@ -52,7 +53,9 @@ RSpec.describe ::Gitlab::Ci::Pipeline::Chain::Limit::RateLimit, :freeze_time, :c
class: described_class.name,
project_id: project.id,
subscription_plan: project.actual_plan_name,
commit_sha: command.sha
commit_sha: command.sha,
throttled: true,
throttle_override: false
)
)
@ -121,7 +124,42 @@ RSpec.describe ::Gitlab::Ci::Pipeline::Chain::Limit::RateLimit, :freeze_time, :c
class: described_class.name,
project_id: project.id,
subscription_plan: project.actual_plan_name,
commit_sha: command.sha
commit_sha: command.sha,
throttled: false,
throttle_override: false
)
)
perform
end
end
context 'when ci_enforce_throttle_pipelines_creation_override is enabled' do
before do
stub_feature_flags(ci_enforce_throttle_pipelines_creation_override: true)
end
it 'does not break the chain' do
perform
expect(step.break?).to be_falsey
end
it 'does not invalidate the pipeline' do
perform
expect(pipeline.errors).to be_empty
end
it 'creates a log entry' do
expect(Gitlab::AppJsonLogger).to receive(:info).with(
a_hash_including(
class: described_class.name,
project_id: project.id,
subscription_plan: project.actual_plan_name,
commit_sha: command.sha,
throttled: false,
throttle_override: true
)
)

View File

@ -0,0 +1,63 @@
# frozen_string_literal: true
require 'spec_helper'
require_migration!
RSpec.describe ScheduleSetLegacyOpenSourceLicenseAvailableForNonPublicProjects do
context 'on gitlab.com' do
let(:migration) { described_class::MIGRATION }
before do
allow(Gitlab).to receive(:com?).and_return(true)
end
describe '#up' do
it 'schedules background jobs for each batch of projects' do
migrate!
expect(migration).to(
have_scheduled_batched_migration(
table_name: :projects,
column_name: :id,
interval: described_class::INTERVAL,
batch_size: described_class::BATCH_SIZE,
sub_batch_size: described_class::SUB_BATCH_SIZE
)
)
end
end
describe '#down' do
it 'deletes all batched migration records' do
migrate!
schema_migrate_down!
expect(migration).not_to have_scheduled_batched_migration
end
end
end
context 'on self-managed instance' do
let(:migration) { described_class.new }
before do
allow(Gitlab).to receive(:com?).and_return(false)
end
describe '#up' do
it 'does not schedule background job' do
expect(migration).not_to receive(:queue_batched_background_migration)
migration.up
end
end
describe '#down' do
it 'does not delete background job' do
expect(migration).not_to receive(:delete_batched_background_migration)
migration.down
end
end
end
end

View File

@ -5071,6 +5071,18 @@ RSpec.describe Ci::Build do
build.execute_hooks
end
context 'with blocked users' do
before do
allow(build).to receive(:user) { FactoryBot.build(:user, :blocked) }
end
it 'does not call project.execute_hooks' do
expect(build.project).not_to receive(:execute_hooks)
build.execute_hooks
end
end
end
context 'without project hooks' do

View File

@ -3056,7 +3056,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe 'hooks trigerring' do
let_it_be(:pipeline) { create(:ci_empty_pipeline, :created) }
let_it_be_with_reload(:pipeline) { create(:ci_empty_pipeline, :created) }
%i[
enqueue
@ -3076,7 +3076,19 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
it 'schedules a new PipelineHooksWorker job' do
expect(PipelineHooksWorker).to receive(:perform_async).with(pipeline.id)
pipeline.reload.public_send(pipeline_action)
pipeline.public_send(pipeline_action)
end
context 'with blocked users' do
before do
allow(pipeline).to receive(:user) { build(:user, :blocked) }
end
it 'does not schedule a new PipelineHooksWorker job' do
expect(PipelineHooksWorker).not_to receive(:perform_async)
pipeline.public_send(pipeline_action)
end
end
end
end
@ -3636,6 +3648,18 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
pipeline.succeed!
end
end
context 'when the user is blocked' do
before do
pipeline.user.block!
end
it 'does not enqueue PipelineNotificationWorker' do
expect(PipelineNotificationWorker).not_to receive(:perform_async)
pipeline.succeed
end
end
end
context 'with failed pipeline' do
@ -3656,6 +3680,18 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
pipeline.drop
end
context 'when the user is blocked' do
before do
pipeline.user.block!
end
it 'does not enqueue PipelineNotificationWorker' do
expect(PipelineNotificationWorker).not_to receive(:perform_async)
pipeline.drop
end
end
end
context 'with skipped pipeline' do

View File

@ -11,6 +11,7 @@ RSpec.describe Ci::CreatePipelineService, :freeze_time, :clean_gitlab_redis_rate
before do
stub_ci_pipeline_yaml_file(gitlab_ci_yaml)
stub_application_setting(pipeline_limit_per_project_user_sha: 1)
stub_feature_flags(ci_enforce_throttle_pipelines_creation_override: false)
end
context 'when user is under the limit' do

View File

@ -205,6 +205,25 @@ RSpec.describe Projects::UpdatePagesService do
include_examples 'fails with outdated reference message'
end
end
context 'when uploaded deployment size is wrong' do
it 'raises an error' do
allow_next_instance_of(PagesDeployment) do |deployment|
allow(deployment)
.to receive(:size)
.and_return(file.size + 1)
end
expect do
expect(execute).not_to eq(:success)
expect(GenericCommitStatus.last.description).to eq("Error: The uploaded artifact size does not match the expected value.")
project.pages_metadatum.reload
expect(project.pages_metadatum).not_to be_deployed
expect(project.pages_metadatum.pages_deployment).to be_ni
end.to raise_error(Projects::UpdatePagesService::WrongUploadedDeploymentSizeError)
end
end
end
end

View File

@ -62,7 +62,7 @@ RSpec.shared_examples 'shows and resets runner registration token' do
end
end
RSpec.shared_examples 'shows no runners' do
RSpec.shared_examples 'shows no runners registered' do
it 'shows counts with 0' do
expect(page).to have_text "Online runners 0"
expect(page).to have_text "Offline runners 0"
@ -70,13 +70,19 @@ RSpec.shared_examples 'shows no runners' do
end
it 'shows "no runners" message' do
expect(page).to have_text 'No runners found'
expect(page).to have_text s_('Runners|Get started with runners')
end
end
RSpec.shared_examples 'shows no runners found' do
it 'shows "no runners" message' do
expect(page).to have_text s_('Runners|No results found')
end
end
RSpec.shared_examples 'shows runner in list' do
it 'does not show empty state' do
expect(page).not_to have_content 'No runners found'
expect(page).not_to have_content s_('Runners|Get started with runners')
end
it 'shows runner row' do

View File

@ -25,6 +25,16 @@ RSpec.describe PipelineHooksWorker do
.not_to raise_error
end
end
context 'when the user is blocked' do
let(:pipeline) { create(:ci_pipeline, user: create(:user, :blocked)) }
it 'returns early without executing' do
expect(Ci::Pipelines::HookService).not_to receive(:new)
described_class.new.perform(pipeline.id)
end
end
end
it_behaves_like 'worker with data consistency',

View File

@ -21,6 +21,20 @@ RSpec.describe PipelineNotificationWorker, :mailer do
subject.perform(non_existing_record_id)
end
context 'when the user is blocked' do
before do
expect_next_found_instance_of(Ci::Pipeline) do |pipeline|
allow(pipeline).to receive(:user) { build(:user, :blocked) }
end
end
it 'does nothing' do
expect(NotificationService).not_to receive(:new)
subject.perform(pipeline.id)
end
end
it_behaves_like 'worker with data consistency',
described_class,
data_consistency: :delayed

View File

@ -963,10 +963,10 @@
stylelint-declaration-strict-value "1.8.0"
stylelint-scss "4.1.0"
"@gitlab/svgs@2.17.0":
version "2.17.0"
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-2.17.0.tgz#56d0d11744859b3e1da80dedab2396a95cd01a02"
integrity sha512-+cmn4ptdOFjSC8ByqD41kj1xSQ9/YFYLq/Es+jy5t12HmUtvYL8YRfNTlvApReSJ8SM7scwleVy4S19M15Siqw==
"@gitlab/svgs@2.18.0":
version "2.18.0"
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-2.18.0.tgz#aafff929bc5365f7cad736b6d061895b3f9aa381"
integrity sha512-Okbm4dAAf/aiaRojUT57yfqY/TVka/zAXN4T+hOx/Yho6wUT2eAJ8CcFpctPdt3kUNM4bHU2CZYoGqklbtXkmg==
"@gitlab/ui@40.7.1":
version "40.7.1"